diff --git a/src/parser.rs b/src/parser.rs index 77c6147..2843fd8 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -554,9 +554,27 @@ impl<'run, 'src> Parser<'run, 'src> { }) } + // Check if the next tokens are a shell-expanded string, i.e., `x"foo"`. + // + // This function skips initial whitespace tokens, but thereafter is + // whitespace-sensitive, so `x"foo"` is a shell-expanded string, whereas `x + // "foo"` is not. + fn next_is_shell_expanded_string(&self) -> bool { + let mut tokens = self + .tokens + .iter() + .skip(self.next_token) + .skip_while(|token| token.kind == Whitespace); + + tokens + .next() + .is_some_and(|token| token.kind == Identifier && token.lexeme() == "x") + && tokens.next().is_some_and(|token| token.kind == StringToken) + } + /// Parse a value, e.g. `(bar)` fn parse_value(&mut self) -> CompileResult<'src, Expression<'src>> { - if self.next_is(StringToken) || self.next_are(&[Identifier, StringToken]) { + if self.next_is(StringToken) || self.next_is_shell_expanded_string() { Ok(Expression::StringLiteral { string_literal: self.parse_string_literal()?, }) @@ -610,7 +628,12 @@ impl<'run, 'src> Parser<'run, 'src> { fn parse_string_literal_token( &mut self, ) -> CompileResult<'src, (Token<'src>, StringLiteral<'src>)> { - let expand = self.accepted_keyword(Keyword::X)?; + let expand = if self.next_is(Identifier) { + self.expect_keyword(Keyword::X)?; + true + } else { + false + }; let token = self.expect(StringToken)?; @@ -2362,6 +2385,7 @@ mod tests { width: 1, kind: UnexpectedToken { expected: vec![ + Identifier, StringToken, ], found: BracketR, @@ -2404,6 +2428,7 @@ mod tests { kind: UnexpectedToken { expected: vec![ BracketR, + Identifier, StringToken, ], found: Eof, diff --git a/tests/shell_expansion.rs b/tests/shell_expansion.rs index f91fd00..c50ce5b 100644 --- a/tests/shell_expansion.rs +++ b/tests/shell_expansion.rs @@ -14,6 +14,27 @@ fn strings_are_shell_expanded() { .run(); } +#[test] +fn shell_expanded_strings_must_not_have_whitespace() { + Test::new() + .justfile( + " + x := x '$JUST_TEST_VARIABLE' + ", + ) + .status(1) + .stderr( + " + error: Expected comment, end of file, end of line, '(', '+', or '/', but found string + ——▶ justfile:1:8 + │ + 1 │ x := x '$JUST_TEST_VARIABLE' + │ ^^^^^^^^^^^^^^^^^^^^^ + ", + ) + .run(); +} + #[test] fn shell_expanded_error_messages_highlight_string_token() { Test::new() @@ -97,3 +118,42 @@ fn shell_expanded_strings_can_be_used_in_mod_paths() { .test_round_trip(false) .run(); } + +#[test] +fn shell_expanded_strings_do_not_conflict_with_dependencies() { + Test::new() + .justfile( + " + foo a b: + @echo {{a}}{{b}} + bar a b: (foo a 'c') + ", + ) + .args(["bar", "A", "B"]) + .stdout("Ac\n") + .run(); + + Test::new() + .justfile( + " + foo a b: + @echo {{a}}{{b}} + bar a b: (foo a'c') + ", + ) + .args(["bar", "A", "B"]) + .stdout("Ac\n") + .run(); + + Test::new() + .justfile( + " + foo a b: + @echo {{a}}{{b}} + bar x b: (foo x 'c') + ", + ) + .args(["bar", "A", "B"]) + .stdout("Ac\n") + .run(); +}