From ea349d8ed0260543a45bf5e4a9fab724652be290 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sat, 6 Dec 2025 15:39:30 -0800 Subject: [PATCH] Allow newlines in interpolations and `}` to abut interpolation `}}` (#2992) --- README.md | 3 - src/lexer.rs | 57 ++++++++++++------ src/parser.rs | 21 +------ tests/delimiters.rs | 14 +---- tests/interpolation.rs | 131 +++++++++++++++++++++++++++++++++++++++++ tests/lib.rs | 1 + 6 files changed, 175 insertions(+), 52 deletions(-) create mode 100644 tests/interpolation.rs diff --git a/README.md b/README.md index be11177b..bb42237b 100644 --- a/README.md +++ b/README.md @@ -2418,9 +2418,6 @@ bar foo: echo {{ if foo == "bar" { "hello" } else { "goodbye" } }} ``` -Note the space after the final `}`! Without the space, the interpolation will -be prematurely closed. - Multiple conditionals can be chained: ```just diff --git a/src/lexer.rs b/src/lexer.rs index 73ce2a9c..a35c0772 100644 --- a/src/lexer.rs +++ b/src/lexer.rs @@ -351,6 +351,18 @@ impl<'src> Lexer<'src> { let whitespace = &self.rest()[..nonblank_index]; + if self.open_delimiters_or_interpolation() { + if !whitespace.is_empty() { + while self.next_is_whitespace() { + self.advance()?; + } + + self.token(Whitespace); + } + + return Ok(()); + } + let body_whitespace = &whitespace[..whitespace .char_indices() .take(self.indentation().chars().count()) @@ -454,15 +466,11 @@ impl<'src> Lexer<'src> { self.advance()?; } - if self.open_delimiters() { - self.token(Whitespace); - } else { - let indentation = self.lexeme(); - self.indentation.push(indentation); - self.token(Indent); - if self.recipe_body_pending { - self.recipe_body = true; - } + let indentation = self.lexeme(); + self.indentation.push(indentation); + self.token(Indent); + if self.recipe_body_pending { + self.recipe_body = true; } Ok(()) @@ -530,18 +538,17 @@ impl<'src> Lexer<'src> { interpolation_start: Token<'src>, start: char, ) -> CompileResult<'src> { - if self.rest_starts_with("}}") { + if self.rest_starts_with("}}") && self.open_delimiters.is_empty() { // end current interpolation if self.interpolation_stack.pop().is_none() { - self.advance()?; - self.advance()?; + self.presume_str("}}")?; return Err(self.internal_error( "Lexer::lex_interpolation found `}}` but was called with empty interpolation stack.", )); } // Emit interpolation end token self.lex_double(InterpolationEnd) - } else if self.at_eol_or_eof() { + } else if self.at_eof() && self.open_delimiters.is_empty() { // Return unterminated interpolation error that highlights the opening // {{ Err(Self::unterminated_interpolation_error(interpolation_start)) @@ -712,8 +719,8 @@ impl<'src> Lexer<'src> { } /// Return true if there are any unclosed delimiters - fn open_delimiters(&self) -> bool { - !self.open_delimiters.is_empty() + fn open_delimiters_or_interpolation(&self) -> bool { + !self.open_delimiters.is_empty() || !self.interpolation_stack.is_empty() } /// Lex a two-character digraph @@ -794,9 +801,8 @@ impl<'src> Lexer<'src> { self.presume('\n')?; } - // Emit an eol if there are no open delimiters, otherwise emit a whitespace - // token. - if self.open_delimiters() { + // Emit eol if there are no open delimiters, otherwise emit whitespace. + if self.open_delimiters_or_interpolation() { self.token(Whitespace); } else { self.token(Eol); @@ -1085,6 +1091,7 @@ mod tests { }; } + #[track_caller] fn error( src: &str, offset: usize, @@ -2426,6 +2433,20 @@ mod tests { }, } + error! { + name: unclosed_parenthesis_in_interpolation, + input: "a:\n echo {{foo(}}", + offset: 15, + line: 1, + column: 12, + width: 0, + kind: MismatchedClosingDelimiter { + close: Delimiter::Brace, + open: Delimiter::Paren, + open_line: 1, + }, + } + #[test] fn presume_error() { let compile_error = Lexer::new("justfile".as_ref(), "!") diff --git a/src/parser.rs b/src/parser.rs index 1811bc95..0cd4c513 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -1411,6 +1411,7 @@ mod tests { }; } + #[track_caller] fn error( src: &str, offset: usize, @@ -2720,26 +2721,6 @@ mod tests { }, } - error! { - name: unclosed_parenthesis_in_interpolation, - input: "a:\n echo {{foo(}}", - offset: 15, - line: 1, - column: 12, - width: 2, - kind: UnexpectedToken{ - expected: vec![ - Backtick, - Identifier, - ParenL, - ParenR, - Slash, - StringToken, - ], - found: InterpolationEnd, - }, - } - error! { name: plus_following_parameter, input: "a b c+:", diff --git a/tests/delimiters.rs b/tests/delimiters.rs index dc3f6815..5adb53c8 100644 --- a/tests/delimiters.rs +++ b/tests/delimiters.rs @@ -113,7 +113,7 @@ fn dependency_continuation() { } #[test] -fn no_interpolation_continuation() { +fn interpolation_continuation() { Test::new() .justfile( " @@ -122,15 +122,7 @@ fn no_interpolation_continuation() { 'a' + 'b')}} ", ) - .stderr( - " - error: Unterminated interpolation - ——▶ justfile:2:8 - │ - 2 │ echo {{ ( - │ ^^ - ", - ) - .status(EXIT_FAILURE) + .stderr("echo ab\n") + .stdout("ab\n") .run(); } diff --git a/tests/interpolation.rs b/tests/interpolation.rs new file mode 100644 index 00000000..fc633e66 --- /dev/null +++ b/tests/interpolation.rs @@ -0,0 +1,131 @@ +use super::*; + +#[test] +fn closing_curly_brace_can_abut_interpolation_close() { + Test::new() + .justfile( + " + foo: + echo {{if 'a' == 'b' { 'c' } else { 'd' }}} + ", + ) + .stderr("echo d\n") + .stdout("d\n") + .run(); +} + +#[test] +fn eol_with_continuation_in_interpolation() { + Test::new() + .justfile( + " + foo: + echo {{( + 'a' + )}} + ", + ) + .stderr("echo a\n") + .stdout("a\n") + .run(); +} + +#[test] +fn eol_without_continuation_in_interpolation() { + Test::new() + .justfile( + " + foo: + echo {{ + 'a' + }} + ", + ) + .stderr("echo a\n") + .stdout("a\n") + .run(); +} + +#[test] +fn comment_in_interopolation() { + Test::new() + .justfile( + " + foo: + echo {{ # hello + 'a' + }} + ", + ) + .stderr( + " + error: Expected backtick, identifier, '(', '/', or string, but found comment + ——▶ justfile:2:11 + │ + 2 │ echo {{ # hello + │ ^^^^^^^ + ", + ) + .status(EXIT_FAILURE) + .run(); +} + +#[test] +fn indent_and_dedent_are_ignored_in_interpolation() { + Test::new() + .justfile( + " + foo: + echo {{ + 'a' + + 'b' + + 'c' + }} + echo foo + ", + ) + .stderr("echo abc\necho foo\n") + .stdout("abc\nfoo\n") + .run(); +} + +#[test] +fn shebang_line_numbers_are_correct_with_multi_line_interpolations() { + Test::new() + .justfile( + " + foo: + #!/usr/bin/env cat + echo {{ + 'a' + + 'b' + + 'c' + }} + echo foo + ", + ) + .stdout(if cfg!(windows) { + " + + + echo abc + + + + + echo foo + " + } else { + " + #!/usr/bin/env cat + + echo abc + + + + + echo foo + " + }) + .run(); +} diff --git a/tests/lib.rs b/tests/lib.rs index 34ae74ee..f2d4bfeb 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -83,6 +83,7 @@ mod groups; mod ignore_comments; mod imports; mod init; +mod interpolation; mod invocation_directory; mod json; mod line_prefixes;