diff --git a/GRAMMAR.md b/GRAMMAR.md index c2c409a..f0d6fbf 100644 --- a/GRAMMAR.md +++ b/GRAMMAR.md @@ -9,17 +9,20 @@ tokens ------ ``` -BACKTICK = `[^`]*` -COMMENT = #([^!].*)?$ -DEDENT = emitted when indentation decreases -EOF = emitted at the end of the file -INDENT = emitted when indentation increases -LINE = emitted before a recipe line -NAME = [a-zA-Z_][a-zA-Z0-9_-]* -NEWLINE = \n|\r\n -RAW_STRING = '[^']*' -STRING = "[^"]*" # also processes \n \r \t \" \\ escapes -TEXT = recipe text, only matches in a recipe body +BACKTICK = `[^`]*` +INDENTED_BACKTICK = ```[^(```)]*``` +COMMENT = #([^!].*)?$ +DEDENT = emitted when indentation decreases +EOF = emitted at the end of the file +INDENT = emitted when indentation increases +LINE = emitted before a recipe line +NAME = [a-zA-Z_][a-zA-Z0-9_-]* +NEWLINE = \n|\r\n +RAW_STRING = '[^']*' +INDENTED_RAW_STRING = '''[^(''')]*''' +STRING = "[^"]*" # also processes \n \r \t \" \\ escapes +INDENTED_STRING = """[^("""]*""" # also processes \n \r \t \" \\ escapes +TEXT = recipe text, only matches in a recipe body ``` grammar syntax @@ -69,14 +72,16 @@ condition : expression '==' expression | expression '!=' expression value : NAME '(' sequence? ')' - | STRING - | RAW_STRING | BACKTICK + | INDENTED_BACKTICK | NAME + | string | '(' expression ')' string : STRING + | INDENTED_STRING | RAW_STRING + | INDENTED_RAW_STRING sequence : expression ',' sequence | expression ','? diff --git a/README.adoc b/README.adoc index 46d9c20..d364cdf 100644 --- a/README.adoc +++ b/README.adoc @@ -545,6 +545,8 @@ string-with-newline := "\n" string-with-carriage-return := "\r" string-with-double-quote := "\"" string-with-slash := "\\" +string-with-no-newline := "\ +" ``` ```sh @@ -553,6 +555,7 @@ $ just --evaluate string-with-double-quote := """ string-with-newline := " " +string-with-no-newline := "" string-with-slash := "\" string-with-tab := " " ``` @@ -580,6 +583,25 @@ $ just --evaluate escapes := "\t\n\r\"\\" ``` +Indented versions of both single- and double-quoted strings, delimited by triple single- or triple double-quotes, are supported. Indented string lines are stripped of leading whitespace common to all non-blank lines: + +```make +# this string will evaluate to `foo\nbar\n` +x := ''' + foo + bar +''' + +# this string will evaluate to `abc\n wuv\nbar\n` +y := """ + abc + wuv + xyz +""" +``` + +Similar to unindented strings, indented double-quoted strings process escape sequences, and indented single-quoted strings ignore escape sequences. Escape sequence processing takes place after unindentation. The unindention algorithm does not take escape-sequence produced whitespace or newlines into account. + === Ignoring Errors Normally, if a command returns a nonzero exit status, execution will stop. To @@ -716,6 +738,20 @@ serve: ./serve {{localhost}} 8080 ``` +Indented backticks, delimited by three backticks, are de-indented in the same manner as indented strings: + +```make +# This backtick evaluates the command `echo foo\necho bar\n`, which produces the value `foo\nbar\n`. +stuff := ``` + echo foo + echo bar + ``` +``` + +See the <> section for details on unindenting. + +Backticks may not start with `#!`. This syntax is reserved for a future upgrade. + === Conditional Expressions `if`/`else` expressions evaluate different branches depending on if two expressions evaluate to the same value: diff --git a/src/common.rs b/src/common.rs index 6b2288b..5dd5ec0 100644 --- a/src/common.rs +++ b/src/common.rs @@ -1,6 +1,5 @@ // stdlib pub(crate) use std::{ - borrow::Cow, cmp, collections::{BTreeMap, BTreeSet}, env, @@ -31,7 +30,9 @@ pub(crate) use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; pub(crate) use crate::{config_error, setting}; // functions -pub(crate) use crate::{default::default, empty::empty, load_dotenv::load_dotenv, output::output}; +pub(crate) use crate::{ + default::default, empty::empty, load_dotenv::load_dotenv, output::output, unindent::unindent, +}; // traits pub(crate) use crate::{ diff --git a/src/compilation_error.rs b/src/compilation_error.rs index 83d1129..62ac653 100644 --- a/src/compilation_error.rs +++ b/src/compilation_error.rs @@ -26,6 +26,9 @@ impl Display for CompilationError<'_> { recipe_line.ordinal(), )?; }, + BacktickShebang => { + writeln!(f, "Backticks may not start with `#!`")?; + }, CircularRecipeDependency { recipe, ref circle } => if circle.len() == 2 { writeln!(f, "Recipe `{}` depends on itself", recipe)?; @@ -242,10 +245,10 @@ impl Display for CompilationError<'_> { UnterminatedInterpolation => { writeln!(f, "Unterminated interpolation")?; }, - UnterminatedString(StringKind::Cooked) | UnterminatedString(StringKind::Raw) => { + UnterminatedString => { writeln!(f, "Unterminated string")?; }, - UnterminatedString(StringKind::Backtick) => { + UnterminatedBacktick => { writeln!(f, "Unterminated backtick")?; }, Internal { ref message } => { diff --git a/src/compilation_error_kind.rs b/src/compilation_error_kind.rs index 4ac8374..86fc08b 100644 --- a/src/compilation_error_kind.rs +++ b/src/compilation_error_kind.rs @@ -6,6 +6,7 @@ pub(crate) enum CompilationErrorKind<'src> { alias: &'src str, recipe_line: usize, }, + BacktickShebang, CircularRecipeDependency { recipe: &'src str, circle: Vec<&'src str>, @@ -107,5 +108,6 @@ pub(crate) enum CompilationErrorKind<'src> { open_line: usize, }, UnterminatedInterpolation, - UnterminatedString(StringKind), + UnterminatedString, + UnterminatedBacktick, } diff --git a/src/evaluator.rs b/src/evaluator.rs index c3cd7ab..99a8b3c 100644 --- a/src/evaluator.rs +++ b/src/evaluator.rs @@ -107,7 +107,7 @@ impl<'src, 'run> Evaluator<'src, 'run> { }), } }, - Expression::StringLiteral { string_literal } => Ok(string_literal.cooked.to_string()), + Expression::StringLiteral { string_literal } => Ok(string_literal.cooked.clone()), Expression::Backtick { contents, token } => if self.config.dry_run { Ok(format!("`{}`", contents)) diff --git a/src/expression.rs b/src/expression.rs index 1616d34..fe8ff00 100644 --- a/src/expression.rs +++ b/src/expression.rs @@ -10,7 +10,7 @@ use crate::common::*; pub(crate) enum Expression<'src> { /// `contents` Backtick { - contents: &'src str, + contents: String, token: Token<'src>, }, /// `name(arguments)` diff --git a/src/lexer.rs b/src/lexer.rs index 916c94d..e87166c 100644 --- a/src/lexer.rs +++ b/src/lexer.rs @@ -129,6 +129,14 @@ impl<'src> Lexer<'src> { Ok(()) } + fn presume_str(&mut self, s: &str) -> CompilationResult<'src, ()> { + for c in s.chars() { + self.presume(c)?; + } + + Ok(()) + } + /// Is next character c? fn next_is(&self, c: char) -> bool { self.next == Some(c) @@ -210,8 +218,14 @@ impl<'src> Lexer<'src> { // The width of the error site to highlight depends on the kind of error: let length = match kind { - // highlight ', ", or ` - UnterminatedString(_) => 1, + UnterminatedString | UnterminatedBacktick => { + let kind = match StringKind::from_token_start(self.lexeme()) { + Some(kind) => kind, + None => + return self.internal_error("Lexer::error: expected string or backtick token start"), + }; + kind.delimiter().len() + }, // highlight the full token _ => self.lexeme().len(), }; @@ -476,9 +490,7 @@ impl<'src> Lexer<'src> { '+' => self.lex_single(Plus), '#' => self.lex_comment(), ' ' => self.lex_whitespace(), - '`' => self.lex_string(StringKind::Backtick), - '"' => self.lex_string(StringKind::Cooked), - '\'' => self.lex_string(StringKind::Raw), + '`' | '"' | '\'' => self.lex_string(), '\n' => self.lex_eol(), '\r' => self.lex_eol(), '\t' => self.lex_whitespace(), @@ -760,23 +772,33 @@ impl<'src> Lexer<'src> { /// Backtick: `[^`]*` /// Cooked string: "[^"]*" # also processes escape sequences /// Raw string: '[^']*' - fn lex_string(&mut self, kind: StringKind) -> CompilationResult<'src, ()> { - self.presume(kind.delimiter())?; + fn lex_string(&mut self) -> CompilationResult<'src, ()> { + let kind = if let Some(kind) = StringKind::from_token_start(self.rest()) { + kind + } else { + self.advance()?; + return Err(self.internal_error("Lexer::lex_string: invalid string start")); + }; + + self.presume_str(kind.delimiter())?; let mut escape = false; loop { - match self.next { - Some(c) if c == kind.delimiter() && !escape => break, - Some('\\') if kind.processes_escape_sequences() && !escape => escape = true, - Some(_) => escape = false, - None => return Err(self.error(kind.unterminated_error_kind())), + if self.next == None { + return Err(self.error(kind.unterminated_error_kind())); + } else if kind.processes_escape_sequences() && self.next_is('\\') && !escape { + escape = true; + } else if self.rest_starts_with(kind.delimiter()) && !escape { + break; + } else { + escape = false; } self.advance()?; } - self.presume(kind.delimiter())?; + self.presume_str(kind.delimiter())?; self.token(kind.token_kind()); Ok(()) @@ -789,15 +811,11 @@ mod tests { use pretty_assertions::assert_eq; - const STRING_BACKTICK: TokenKind = StringToken(StringKind::Backtick); - const STRING_RAW: TokenKind = StringToken(StringKind::Raw); - const STRING_COOKED: TokenKind = StringToken(StringKind::Cooked); - macro_rules! test { { - name: $name:ident, - text: $text:expr, - tokens: ($($kind:ident $(: $lexeme:literal)?),* $(,)?)$(,)? + name: $name:ident, + text: $text:expr, + tokens: ($($kind:ident $(: $lexeme:literal)?),* $(,)?)$(,)? } => { #[test] fn $name() { @@ -805,7 +823,22 @@ mod tests { let lexemes: &[&str] = &[$(lexeme!($kind $(, $lexeme)?),)* ""]; - test($text, kinds, lexemes); + test($text, true, kinds, lexemes); + } + }; + { + name: $name:ident, + text: $text:expr, + tokens: ($($kind:ident $(: $lexeme:literal)?),* $(,)?)$(,)? + unindent: $unindent:expr, + } => { + #[test] + fn $name() { + let kinds: &[TokenKind] = &[$($kind,)* Eof]; + + let lexemes: &[&str] = &[$(lexeme!($kind $(, $lexeme)?),)* ""]; + + test($text, $unindent, kinds, lexemes); } } } @@ -823,8 +856,12 @@ mod tests { } } - fn test(text: &str, want_kinds: &[TokenKind], want_lexemes: &[&str]) { - let text = testing::unindent(text); + fn test(text: &str, unindent_text: bool, want_kinds: &[TokenKind], want_lexemes: &[&str]) { + let text = if unindent_text { + unindent(text) + } else { + text.to_owned() + }; let have = Lexer::lex(&text).unwrap(); @@ -901,7 +938,7 @@ mod tests { Dedent | Eof => "", // Variable lexemes - Text | StringToken(_) | Identifier | Comment | Unspecified => + Text | StringToken | Backtick | Identifier | Comment | Unspecified => panic!("Token {:?} has no default lexeme", kind), } } @@ -965,37 +1002,43 @@ mod tests { test! { name: backtick, text: "`echo`", - tokens: (STRING_BACKTICK:"`echo`"), + tokens: (Backtick:"`echo`"), } test! { name: backtick_multi_line, text: "`echo\necho`", - tokens: (STRING_BACKTICK:"`echo\necho`"), + tokens: (Backtick:"`echo\necho`"), } test! { name: raw_string, text: "'hello'", - tokens: (STRING_RAW:"'hello'"), + tokens: (StringToken:"'hello'"), } test! { name: raw_string_multi_line, text: "'hello\ngoodbye'", - tokens: (STRING_RAW:"'hello\ngoodbye'"), + tokens: (StringToken:"'hello\ngoodbye'"), } test! { name: cooked_string, text: "\"hello\"", - tokens: (STRING_COOKED:"\"hello\""), + tokens: (StringToken:"\"hello\""), } test! { name: cooked_string_multi_line, text: "\"hello\ngoodbye\"", - tokens: (STRING_COOKED:"\"hello\ngoodbye\""), + tokens: (StringToken:"\"hello\ngoodbye\""), + } + + test! { + name: cooked_multiline_string, + text: "\"\"\"hello\ngoodbye\"\"\"", + tokens: (StringToken:"\"\"\"hello\ngoodbye\"\"\""), } test! { @@ -1056,11 +1099,11 @@ mod tests { Whitespace, Equals, Whitespace, - STRING_RAW:"'foo'", + StringToken:"'foo'", Whitespace, Plus, Whitespace, - STRING_RAW:"'bar'", + StringToken:"'bar'", ) } @@ -1075,29 +1118,31 @@ mod tests { Equals, Whitespace, ParenL, - STRING_RAW:"'foo'", + StringToken:"'foo'", Whitespace, Plus, Whitespace, - STRING_RAW:"'bar'", + StringToken:"'bar'", ParenR, Whitespace, Plus, Whitespace, - STRING_BACKTICK:"`baz`", + Backtick:"`baz`", ), } test! { - name: eol_linefeed, - text: "\n", - tokens: (Eol), + name: eol_linefeed, + text: "\n", + tokens: (Eol), + unindent: false, } test! { - name: eol_carriage_return_linefeed, - text: "\r\n", - tokens: (Eol:"\r\n"), + name: eol_carriage_return_linefeed, + text: "\r\n", + tokens: (Eol:"\r\n"), + unindent: false, } test! { @@ -1142,6 +1187,7 @@ mod tests { Eol, Dedent, ), + unindent: false, } test! { @@ -1324,6 +1370,7 @@ mod tests { Eol, Dedent, ), + unindent: false, } test! { @@ -1411,11 +1458,11 @@ mod tests { Indent:" ", Text:"echo ", InterpolationStart, - STRING_BACKTICK:"`echo hello`", + Backtick:"`echo hello`", Whitespace, Plus, Whitespace, - STRING_BACKTICK:"`echo goodbye`", + Backtick:"`echo goodbye`", InterpolationEnd, Dedent, ), @@ -1431,7 +1478,7 @@ mod tests { Indent:" ", Text:"echo ", InterpolationStart, - STRING_RAW:"'\n'", + StringToken:"'\n'", InterpolationEnd, Dedent, ), @@ -1503,19 +1550,19 @@ mod tests { Whitespace, Equals, Whitespace, - STRING_COOKED:"\"'a'\"", + StringToken:"\"'a'\"", Whitespace, Plus, Whitespace, - STRING_RAW:"'\"b\"'", + StringToken:"'\"b\"'", Whitespace, Plus, Whitespace, - STRING_COOKED:"\"'c'\"", + StringToken:"\"'c'\"", Whitespace, Plus, Whitespace, - STRING_RAW:"'\"d\"'", + StringToken:"'\"d\"'", Comment:"#echo hello", ) } @@ -1583,7 +1630,7 @@ mod tests { Whitespace, Plus, Whitespace, - STRING_COOKED:"\"z\"", + StringToken:"\"z\"", Whitespace, Plus, Whitespace, @@ -1707,7 +1754,7 @@ mod tests { Eol, Identifier:"A", Equals, - STRING_RAW:"'1'", + StringToken:"'1'", Eol, Identifier:"echo", Colon, @@ -1732,11 +1779,11 @@ mod tests { Indent:" ", Text:"echo ", InterpolationStart, - STRING_BACKTICK:"`echo hello`", + Backtick:"`echo hello`", Whitespace, Plus, Whitespace, - STRING_BACKTICK:"`echo goodbye`", + Backtick:"`echo goodbye`", InterpolationEnd, Dedent ), @@ -1765,11 +1812,11 @@ mod tests { Whitespace, Equals, Whitespace, - STRING_BACKTICK:"`echo hello`", + Backtick:"`echo hello`", Whitespace, Plus, Whitespace, - STRING_BACKTICK:"`echo goodbye`", + Backtick:"`echo goodbye`", ), } @@ -2011,7 +2058,7 @@ mod tests { line: 0, column: 4, width: 1, - kind: UnterminatedString(StringKind::Cooked), + kind: UnterminatedString, } error! { @@ -2021,7 +2068,7 @@ mod tests { line: 0, column: 4, width: 1, - kind: UnterminatedString(StringKind::Raw), + kind: UnterminatedString, } error! { @@ -2042,7 +2089,7 @@ mod tests { line: 0, column: 0, width: 1, - kind: UnterminatedString(StringKind::Backtick), + kind: UnterminatedBacktick, } error! { @@ -2102,7 +2149,7 @@ mod tests { line: 0, column: 4, width: 1, - kind: UnterminatedString(StringKind::Cooked), + kind: UnterminatedString, } error! { @@ -2200,7 +2247,7 @@ mod tests { assert_eq!( Lexer::new("!").presume('-').unwrap_err().to_string(), - testing::unindent( + unindent( " Internal error, this may indicate a bug in just: Lexer presumed character `-` \ diff --git a/src/lib.rs b/src/lib.rs index 9fccd16..bec5d56 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -125,6 +125,7 @@ mod table; mod thunk; mod token; mod token_kind; +mod unindent; mod unresolved_dependency; mod unresolved_recipe; mod use_color; @@ -134,5 +135,9 @@ mod warning; pub use crate::run::run; +// Used in integration tests. +#[doc(hidden)] +pub use unindent::unindent; + #[cfg(feature = "summary")] pub mod summary; diff --git a/src/parser.rs b/src/parser.rs index 1d25259..69b5d29 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -100,8 +100,8 @@ impl<'tokens, 'src> Parser<'tokens, 'src> { /// /// The first token in `kinds` will be added to the expected token set. fn next_are(&mut self, kinds: &[TokenKind]) -> bool { - if let Some(kind) = kinds.first() { - self.expected.insert(*kind); + if let Some(&kind) = kinds.first() { + self.expected.insert(kind); } let mut rest = self.rest(); @@ -150,17 +150,6 @@ impl<'tokens, 'src> Parser<'tokens, 'src> { } } - /// Return an error if the next token is not one of kinds `kinds`. - fn expect_any(&mut self, expected: &[TokenKind]) -> CompilationResult<'src, Token<'src>> { - for expected in expected.iter().cloned() { - if let Some(token) = self.accept(expected)? { - return Ok(token); - } - } - - Err(self.unexpected_token()?) - } - /// Return an unexpected token error if the next token is not an EOL fn expect_eol(&mut self) -> CompilationResult<'src, ()> { self.accept(Comment)?; @@ -453,15 +442,26 @@ impl<'tokens, 'src> Parser<'tokens, 'src> { /// Parse a value, e.g. `(bar)` fn parse_value(&mut self) -> CompilationResult<'src, Expression<'src>> { - if self.next_is(StringToken(StringKind::Cooked)) || self.next_is(StringToken(StringKind::Raw)) { + if self.next_is(StringToken) { Ok(Expression::StringLiteral { string_literal: self.parse_string_literal()?, }) - } else if self.next_is(StringToken(StringKind::Backtick)) { + } else if self.next_is(Backtick) { let next = self.next()?; - - let contents = &next.lexeme()[1..next.lexeme().len() - 1]; + let kind = StringKind::from_string_or_backtick(next)?; + let contents = + &next.lexeme()[kind.delimiter_len()..next.lexeme().len() - kind.delimiter_len()]; let token = self.advance()?; + let contents = if kind.indented() { + unindent(contents) + } else { + contents.to_owned() + }; + + if contents.starts_with("#!") { + return Err(next.error(CompilationErrorKind::BacktickShebang)); + } + Ok(Expression::Backtick { contents, token }) } else if self.next_is(Identifier) { let name = self.parse_name()?; @@ -486,51 +486,51 @@ impl<'tokens, 'src> Parser<'tokens, 'src> { /// Parse a string literal, e.g. `"FOO"` fn parse_string_literal(&mut self) -> CompilationResult<'src, StringLiteral<'src>> { - let token = self.expect_any(&[ - StringToken(StringKind::Raw), - StringToken(StringKind::Cooked), - ])?; + let token = self.expect(StringToken)?; - let raw = &token.lexeme()[1..token.lexeme().len() - 1]; + let kind = StringKind::from_string_or_backtick(token)?; - match token.kind { - StringToken(StringKind::Raw) => Ok(StringLiteral { - raw, - cooked: Cow::Borrowed(raw), - }), - StringToken(StringKind::Cooked) => { - let mut cooked = String::new(); - let mut escape = false; - for c in raw.chars() { - if escape { - match c { - 'n' => cooked.push('\n'), - 'r' => cooked.push('\r'), - 't' => cooked.push('\t'), - '\\' => cooked.push('\\'), - '"' => cooked.push('"'), - other => { - return Err( - token.error(CompilationErrorKind::InvalidEscapeSequence { character: other }), - ); - }, - } - escape = false; - } else if c == '\\' { - escape = true; - } else { - cooked.push(c); + let delimiter_len = kind.delimiter_len(); + + let raw = &token.lexeme()[delimiter_len..token.lexeme().len() - delimiter_len]; + + let unindented = if kind.indented() { + unindent(raw) + } else { + raw.to_owned() + }; + + let cooked = if kind.processes_escape_sequences() { + let mut cooked = String::new(); + let mut escape = false; + for c in unindented.chars() { + if escape { + match c { + 'n' => cooked.push('\n'), + 'r' => cooked.push('\r'), + 't' => cooked.push('\t'), + '\\' => cooked.push('\\'), + '\n' => {}, + '"' => cooked.push('"'), + other => { + return Err( + token.error(CompilationErrorKind::InvalidEscapeSequence { character: other }), + ); + }, } + escape = false; + } else if c == '\\' { + escape = true; + } else { + cooked.push(c); } - Ok(StringLiteral { - raw, - cooked: Cow::Owned(cooked), - }) - }, - _ => Err(token.error(CompilationErrorKind::Internal { - message: "`Parser::parse_string_literal` called on non-string token".to_owned(), - })), - } + } + cooked + } else { + unindented + }; + + Ok(StringLiteral { cooked, raw, kind }) } /// Parse a name from an identifier token @@ -757,7 +757,6 @@ mod tests { use super::*; use pretty_assertions::assert_eq; - use testing::unindent; use CompilationErrorKind::*; macro_rules! test { @@ -1186,6 +1185,15 @@ mod tests { tree: (justfile (assignment x "foo\nbar")), } + test! { + name: string_escape_suppress_newline, + text: r#" + x := "foo\ + bar" + "#, + tree: (justfile (assignment x "foobar")), + } + test! { name: string_escape_carriage_return, text: r#"x := "foo\rbar""#, @@ -1204,6 +1212,72 @@ mod tests { tree: (justfile (assignment x "foo\"bar")), } + test! { + name: indented_string_raw_with_dedent, + text: " + x := ''' + foo\\t + bar\\n + ''' + ", + tree: (justfile (assignment x "foo\\t\nbar\\n\n")), + } + + test! { + name: indented_string_raw_no_dedent, + text: " + x := ''' + foo\\t + bar\\n + ''' + ", + tree: (justfile (assignment x "foo\\t\n bar\\n\n")), + } + + test! { + name: indented_string_cooked, + text: r#" + x := """ + \tfoo\t + \tbar\n + """ + "#, + tree: (justfile (assignment x "\tfoo\t\n\tbar\n\n")), + } + + test! { + name: indented_string_cooked_no_dedent, + text: r#" + x := """ + \tfoo\t + \tbar\n + """ + "#, + tree: (justfile (assignment x "\tfoo\t\n \tbar\n\n")), + } + + test! { + name: indented_backtick, + text: r#" + x := ``` + \tfoo\t + \tbar\n + ``` + "#, + tree: (justfile (assignment x (backtick "\\tfoo\\t\n\\tbar\\n\n"))), + } + + test! { + name: indented_backtick_no_dedent, + text: r#" + x := ``` + \tfoo\t + \tbar\n + ``` + "#, + tree: (justfile (assignment x (backtick "\\tfoo\\t\n \\tbar\\n\n"))), + } + test! { name: recipe_variadic_with_default_after_default, text: r#" @@ -1724,11 +1798,10 @@ mod tests { width: 1, kind: UnexpectedToken { expected: vec![ + Backtick, Identifier, ParenL, - StringToken(StringKind::Backtick), - StringToken(StringKind::Cooked), - StringToken(StringKind::Raw) + StringToken, ], found: Eol }, @@ -1743,11 +1816,10 @@ mod tests { width: 0, kind: UnexpectedToken { expected: vec![ + Backtick, Identifier, ParenL, - StringToken(StringKind::Backtick), - StringToken(StringKind::Cooked), - StringToken(StringKind::Raw) + StringToken, ], found: Eof, }, @@ -1785,12 +1857,11 @@ mod tests { width: 0, kind: UnexpectedToken{ expected: vec![ + Backtick, Identifier, ParenL, ParenR, - StringToken(StringKind::Backtick), - StringToken(StringKind::Cooked), - StringToken(StringKind::Raw) + StringToken, ], found: Eof, }, @@ -1805,12 +1876,11 @@ mod tests { width: 2, kind: UnexpectedToken{ expected: vec![ + Backtick, Identifier, ParenL, ParenR, - StringToken(StringKind::Backtick), - StringToken(StringKind::Cooked), - StringToken(StringKind::Raw) + StringToken, ], found: InterpolationEnd, }, @@ -1887,7 +1957,9 @@ mod tests { column: 14, width: 1, kind: UnexpectedToken { - expected: vec![StringToken(StringKind::Cooked), StringToken(StringKind::Raw)], + expected: vec![ + StringToken, + ], found: BracketR, }, } @@ -1926,7 +1998,10 @@ mod tests { column: 21, width: 0, kind: UnexpectedToken { - expected: vec![BracketR, StringToken(StringKind::Cooked), StringToken(StringKind::Raw)], + expected: vec![ + BracketR, + StringToken, + ], found: Eof, }, } diff --git a/src/platform.rs b/src/platform.rs index cbaf67e..b08d90a 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -53,6 +53,8 @@ impl PlatformInterface for Platform { command: &str, argument: Option<&str>, ) -> Result { + use std::borrow::Cow; + // If the path contains forward slashes… let command = if command.contains('/') { // …translate path to the interpreter from unix style to windows style. diff --git a/src/string_kind.rs b/src/string_kind.rs index d484720..45e5988 100644 --- a/src/string_kind.rs +++ b/src/string_kind.rs @@ -1,33 +1,94 @@ use crate::common::*; #[derive(Debug, PartialEq, Clone, Copy, Ord, PartialOrd, Eq)] -pub(crate) enum StringKind { +pub(crate) struct StringKind { + indented: bool, + delimiter: StringDelimiter, +} + +#[derive(Debug, PartialEq, Clone, Copy, Ord, PartialOrd, Eq)] +enum StringDelimiter { Backtick, - Cooked, - Raw, + QuoteDouble, + QuoteSingle, } impl StringKind { - pub(crate) fn delimiter(self) -> char { - match self { - Self::Backtick => '`', - Self::Cooked => '"', - Self::Raw => '\'', + // Indented values must come before un-indented values, or else + // `Self::from_token_start` will incorrectly return indented = false + // for indented strings. + const ALL: &'static [Self] = &[ + Self::new(StringDelimiter::Backtick, true), + Self::new(StringDelimiter::Backtick, false), + Self::new(StringDelimiter::QuoteDouble, true), + Self::new(StringDelimiter::QuoteDouble, false), + Self::new(StringDelimiter::QuoteSingle, true), + Self::new(StringDelimiter::QuoteSingle, false), + ]; + + const fn new(delimiter: StringDelimiter, indented: bool) -> Self { + Self { + delimiter, + indented, } } + pub(crate) fn delimiter(self) -> &'static str { + match (self.delimiter, self.indented) { + (StringDelimiter::Backtick, false) => "`", + (StringDelimiter::Backtick, true) => "```", + (StringDelimiter::QuoteDouble, false) => "\"", + (StringDelimiter::QuoteDouble, true) => "\"\"\"", + (StringDelimiter::QuoteSingle, false) => "'", + (StringDelimiter::QuoteSingle, true) => "'''", + } + } + + pub(crate) fn delimiter_len(self) -> usize { + self.delimiter().len() + } + pub(crate) fn token_kind(self) -> TokenKind { - TokenKind::StringToken(self) + match self.delimiter { + StringDelimiter::QuoteDouble | StringDelimiter::QuoteSingle => TokenKind::StringToken, + StringDelimiter::Backtick => TokenKind::Backtick, + } } pub(crate) fn unterminated_error_kind(self) -> CompilationErrorKind<'static> { - CompilationErrorKind::UnterminatedString(self) + match self.delimiter { + StringDelimiter::QuoteDouble | StringDelimiter::QuoteSingle => + CompilationErrorKind::UnterminatedString, + StringDelimiter::Backtick => CompilationErrorKind::UnterminatedBacktick, + } } pub(crate) fn processes_escape_sequences(self) -> bool { - match self { - Self::Backtick | Self::Raw => false, - Self::Cooked => true, + match self.delimiter { + StringDelimiter::QuoteDouble => true, + StringDelimiter::Backtick | StringDelimiter::QuoteSingle => false, } } + + pub(crate) fn indented(self) -> bool { + self.indented + } + + pub(crate) fn from_string_or_backtick(token: Token) -> CompilationResult { + Self::from_token_start(token.lexeme()).ok_or_else(|| { + token.error(CompilationErrorKind::Internal { + message: "StringKind::from_token: Expected String or Backtick".to_owned(), + }) + }) + } + + pub(crate) fn from_token_start(token_start: &str) -> Option { + for &kind in Self::ALL { + if token_start.starts_with(kind.delimiter()) { + return Some(kind); + } + } + + None + } } diff --git a/src/string_literal.rs b/src/string_literal.rs index 7429dd7..7034366 100644 --- a/src/string_literal.rs +++ b/src/string_literal.rs @@ -2,15 +2,19 @@ use crate::common::*; #[derive(PartialEq, Debug)] pub(crate) struct StringLiteral<'src> { + pub(crate) kind: StringKind, pub(crate) raw: &'src str, - pub(crate) cooked: Cow<'src, str>, + pub(crate) cooked: String, } impl Display for StringLiteral<'_> { fn fmt(&self, f: &mut Formatter) -> fmt::Result { - match self.cooked { - Cow::Borrowed(raw) => write!(f, "'{}'", raw), - Cow::Owned(_) => write!(f, "\"{}\"", self.raw), - } + write!( + f, + "{}{}{}", + self.kind.delimiter(), + self.raw, + self.kind.delimiter() + ) } } diff --git a/src/testing.rs b/src/testing.rs index 455ff8b..2af210a 100644 --- a/src/testing.rs +++ b/src/testing.rs @@ -30,7 +30,7 @@ pub(crate) fn search(config: &Config) -> Search { } } -pub(crate) use test_utilities::{tempdir, unindent}; +pub(crate) use test_utilities::tempdir; macro_rules! analysis_error { ( @@ -94,7 +94,7 @@ macro_rules! run_error { let search = $crate::testing::search(&config); if let Subcommand::Run{ overrides, arguments } = &config.subcommand { - match $crate::compiler::Compiler::compile(&$crate::testing::unindent($src)) + match $crate::compiler::Compiler::compile(&$crate::unindent::unindent($src)) .expect("Expected successful compilation") .run( &config, diff --git a/src/token_kind.rs b/src/token_kind.rs index 3be221b..a437762 100644 --- a/src/token_kind.rs +++ b/src/token_kind.rs @@ -4,6 +4,7 @@ use crate::common::*; pub(crate) enum TokenKind { Asterisk, At, + Backtick, BangEquals, BraceL, BraceR, @@ -26,7 +27,7 @@ pub(crate) enum TokenKind { ParenL, ParenR, Plus, - StringToken(StringKind), + StringToken, Text, Unspecified, Whitespace, @@ -38,6 +39,7 @@ impl Display for TokenKind { write!(f, "{}", match *self { Asterisk => "'*'", At => "'@'", + Backtick => "backtick", BangEquals => "'!='", BraceL => "'{'", BraceR => "'}'", @@ -60,12 +62,10 @@ impl Display for TokenKind { ParenL => "'('", ParenR => "')'", Plus => "'+'", - StringToken(StringKind::Backtick) => "backtick", - StringToken(StringKind::Cooked) => "cooked string", - StringToken(StringKind::Raw) => "raw string", + StringToken => "string", Text => "command text", - Whitespace => "whitespace", Unspecified => "unspecified", + Whitespace => "whitespace", }) } } diff --git a/src/tree.rs b/src/tree.rs index 1e86cb2..fd13822 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -1,6 +1,6 @@ use crate::common::*; -use std::mem; +use std::{borrow::Cow, mem}; /// Construct a `Tree` from a symbolic expression literal. This macro, and the /// Tree type, are only used in the Parser unit tests, providing a concise diff --git a/src/unindent.rs b/src/unindent.rs new file mode 100644 index 0000000..e1c0f87 --- /dev/null +++ b/src/unindent.rs @@ -0,0 +1,134 @@ +#[must_use] +pub fn unindent(text: &str) -> String { + // find line start and end indices + let mut lines = Vec::new(); + let mut start = 0; + for (i, c) in text.char_indices() { + if c == '\n' || i == text.len() - c.len_utf8() { + let end = i + 1; + lines.push(&text[start..end]); + start = end; + } + } + + let common_indentation = lines + .iter() + .filter(|line| !blank(line)) + .cloned() + .map(indentation) + .fold( + None, + |common_indentation, line_indentation| match common_indentation { + Some(common_indentation) => Some(common(common_indentation, line_indentation)), + None => Some(line_indentation), + }, + ) + .unwrap_or(""); + + let mut replacements = Vec::with_capacity(lines.len()); + + for (i, line) in lines.iter().enumerate() { + let blank = blank(line); + let first = i == 0; + let last = i == lines.len() - 1; + + let replacement = match (blank, first, last) { + (true, false, false) => "\n", + (true, _, _) => "", + (false, _, _) => &line[common_indentation.len()..], + }; + + replacements.push(replacement); + } + + replacements.into_iter().collect() +} + +fn indentation(line: &str) -> &str { + let i = line + .char_indices() + .take_while(|(_, c)| matches!(c, ' ' | '\t')) + .map(|(i, _)| i + 1) + .last() + .unwrap_or(0); + + &line[..i] +} + +fn blank(line: &str) -> bool { + line.chars().all(|c| matches!(c, ' ' | '\t' | '\r' | '\n')) +} + +fn common<'s>(a: &'s str, b: &'s str) -> &'s str { + let i = a + .char_indices() + .zip(b.chars()) + .take_while(|((_, ac), bc)| ac == bc) + .map(|((i, c), _)| i + c.len_utf8()) + .last() + .unwrap_or(0); + + &a[0..i] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unindents() { + assert_eq!(unindent("foo"), "foo"); + assert_eq!(unindent("foo\nbar\nbaz\n"), "foo\nbar\nbaz\n"); + assert_eq!(unindent(""), ""); + assert_eq!(unindent(" foo\n bar"), "foo\nbar"); + assert_eq!(unindent(" foo\n bar\n\n"), "foo\nbar\n"); + + assert_eq!( + unindent( + " + hello + bar + " + ), + "hello\nbar\n" + ); + + assert_eq!(unindent("hello\n bar\n foo"), "hello\n bar\n foo"); + + assert_eq!( + unindent( + " + + hello + bar + + " + ), + "\nhello\nbar\n\n" + ); + } + + #[test] + fn indentations() { + assert_eq!(indentation(""), ""); + assert_eq!(indentation("foo"), ""); + assert_eq!(indentation(" foo"), " "); + assert_eq!(indentation("\t\tfoo"), "\t\t"); + assert_eq!(indentation("\t \t foo"), "\t \t "); + } + + #[test] + fn blanks() { + assert!(blank(" \n")); + assert!(!blank(" foo\n")); + assert!(blank("\t\t\n")); + } + + #[test] + fn commons() { + assert_eq!(common("foo", "foobar"), "foo"); + assert_eq!(common("foo", "bar"), ""); + assert_eq!(common("", ""), ""); + assert_eq!(common("", "bar"), ""); + } +} diff --git a/test-utilities/src/lib.rs b/test-utilities/src/lib.rs index 60e6153..88f2a0e 100644 --- a/test-utilities/src/lib.rs +++ b/test-utilities/src/lib.rs @@ -20,67 +20,6 @@ pub fn assert_stdout(output: &Output, stdout: &str) { assert_eq!(String::from_utf8_lossy(&output.stdout), stdout); } -pub fn unindent(text: &str) -> String { - // find line start and end indices - let mut lines = Vec::new(); - let mut start = 0; - for (i, c) in text.char_indices() { - if c == '\n' { - let end = i + 1; - lines.push((start, end)); - start = end; - } - } - - // if the text isn't newline-terminated, add the final line - if text.chars().last() != Some('\n') { - lines.push((start, text.len())); - } - - // find the longest common indentation - let mut common_indentation = None; - for (start, end) in lines.iter().cloned() { - let line = &text[start..end]; - - // skip blank lines - if blank(line) { - continue; - } - - // calculate new common indentation - common_indentation = match common_indentation { - Some(common_indentation) => Some(common(common_indentation, indentation(line))), - None => Some(indentation(line)), - }; - } - - // if common indentation is present, process the text - if let Some(common_indentation) = common_indentation { - if common_indentation != "" { - let mut output = String::new(); - - for (i, (start, end)) in lines.iter().cloned().enumerate() { - let line = &text[start..end]; - - if blank(line) { - // skip intial and final blank line - if i != 0 && i != lines.len() - 1 { - output.push('\n'); - } - } else { - // otherwise push the line without the common indentation - output.push_str(&line[common_indentation.len()..]); - } - } - - return output; - } - } - - // otherwise just return the input string - text.to_owned() -} - pub enum Entry { File { contents: &'static str, @@ -180,42 +119,6 @@ macro_rules! tmptree { } } -fn indentation(line: &str) -> &str { - for (i, c) in line.char_indices() { - if c != ' ' && c != '\t' { - return &line[0..i]; - } - } - - line -} - -fn blank(line: &str) -> bool { - for (i, c) in line.char_indices() { - if c == ' ' || c == '\t' { - continue; - } - - if c == '\n' && i == line.len() - 1 { - continue; - } - - return false; - } - - true -} - -fn common<'s>(a: &'s str, b: &'s str) -> &'s str { - for ((i, ac), bc) in a.char_indices().zip(b.chars()) { - if ac != bc { - return &a[0..i]; - } - } - - a -} - #[cfg(test)] mod tests { use super::*; diff --git a/tests/common.rs b/tests/common.rs index 4f8048a..4602111 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -9,6 +9,7 @@ pub(crate) use std::{ }; pub(crate) use executable_path::executable_path; +pub(crate) use just::unindent; pub(crate) use libc::{EXIT_FAILURE, EXIT_SUCCESS}; -pub(crate) use test_utilities::{assert_stdout, tempdir, tmptree, unindent}; +pub(crate) use test_utilities::{assert_stdout, tempdir, tmptree}; pub(crate) use which::which; diff --git a/tests/misc.rs b/tests/misc.rs index 55c52ba..65e80b6 100644 --- a/tests/misc.rs +++ b/tests/misc.rs @@ -278,7 +278,7 @@ hello: recipe: @exit 100", args: ("recipe"), - stderr: "error: Recipe `recipe` failed on line 6 with exit code 100\n", + stderr: "error: Recipe `recipe` failed on line 5 with exit code 100\n", status: 100, } @@ -347,12 +347,12 @@ test! { test! { name: backtick_code_interpolation_tab, justfile: " -backtick-fail: -\techo {{`exit 200`}} -", + backtick-fail: + \techo {{`exit 200`}} + ", stderr: " error: Backtick failed with exit code 200 | - 3 | echo {{`exit 200`}} + 2 | echo {{`exit 200`}} | ^^^^^^^^^^ ", status: 200, @@ -361,12 +361,12 @@ backtick-fail: test! { name: backtick_code_interpolation_tabs, justfile: " -backtick-fail: -\techo {{\t`exit 200`}} -", + backtick-fail: + \techo {{\t`exit 200`}} + ", stderr: "error: Backtick failed with exit code 200 | -3 | echo {{ `exit 200`}} +2 | echo {{ `exit 200`}} | ^^^^^^^^^^ ", status: 200, @@ -375,13 +375,13 @@ backtick-fail: test! { name: backtick_code_interpolation_inner_tab, justfile: " -backtick-fail: -\techo {{\t`exit\t\t200`}} -", + backtick-fail: + \techo {{\t`exit\t\t200`}} + ", stderr: " error: Backtick failed with exit code 200 | - 3 | echo {{ `exit 200`}} + 2 | echo {{ `exit 200`}} | ^^^^^^^^^^^^^^^^^ ", status: 200, @@ -390,13 +390,13 @@ backtick-fail: test! { name: backtick_code_interpolation_leading_emoji, justfile: " -backtick-fail: -\techo 😬{{`exit 200`}} -", + backtick-fail: + \techo 😬{{`exit 200`}} + ", stderr: " error: Backtick failed with exit code 200 | - 3 | echo 😬{{`exit 200`}} + 2 | echo 😬{{`exit 200`}} | ^^^^^^^^^^ ", status: 200, @@ -405,13 +405,13 @@ backtick-fail: test! { name: backtick_code_interpolation_unicode_hell, justfile: " -backtick-fail: -\techo \t\t\t😬鎌鼬{{\t\t`exit 200 # \t\t\tabc`}}\t\t\t😬鎌鼬 -", + backtick-fail: + \techo \t\t\t😬鎌鼬{{\t\t`exit 200 # \t\t\tabc`}}\t\t\t😬鎌鼬 + ", stderr: " error: Backtick failed with exit code 200 | - 3 | echo 😬鎌鼬{{ `exit 200 # abc`}} 😬鎌鼬 + 2 | echo 😬鎌鼬{{ `exit 200 # abc`}} 😬鎌鼬 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ", status: 200, @@ -419,7 +419,18 @@ backtick-fail: test! { name: backtick_code_long, - justfile: "\n\n\n\n\n\nb := a\na := `echo hello`\nbar:\n echo '{{`exit 200`}}'", + justfile: " + + + + + + + b := a + a := `echo hello` + bar: + echo '{{`exit 200`}}' + ", stderr: " error: Backtick failed with exit code 200 | @@ -580,6 +591,7 @@ test! { + ??? "#, stdout: "", @@ -727,7 +739,7 @@ recipe: args: ("--color=always"), stdout: "", stderr: "\u{1b}[1;31merror\u{1b}[0m: \u{1b}[1m\ -Recipe `recipe` failed on line 3 with exit code 100\u{1b}[0m\n", +Recipe `recipe` failed on line 2 with exit code 100\u{1b}[0m\n", status: 100, } @@ -1217,7 +1229,7 @@ infallable: "#, stderr: r#"exit 101 exit 202 -error: Recipe `infallable` failed on line 4 with exit code 202 +error: Recipe `infallable` failed on line 3 with exit code 202 "#, status: 202, } @@ -1248,29 +1260,29 @@ test! { test! { name: shebang_line_numbers, justfile: r#" -quiet: - #!/usr/bin/env cat + quiet: + #!/usr/bin/env cat - a + a - b + b - c + c -"#, - stdout: "#!/usr/bin/env cat + "#, + stdout: " + #!/usr/bin/env cat + a -a - -b + b -c -", + c + ", } test! { @@ -1404,7 +1416,7 @@ test! { args: ("foo"), stdout: "", stderr: "error: Expected comment, end of file, end of line, \ - identifier, or '(', but found raw string + identifier, or '(', but found string | 1 | foo: 'bar' | ^^^^^ @@ -1417,7 +1429,7 @@ test! { justfile: "foo 'bar'", args: ("foo"), stdout: "", - stderr: "error: Expected '*', ':', '$', identifier, or '+', but found raw string + stderr: "error: Expected '*', ':', '$', identifier, or '+', but found string | 1 | foo 'bar' | ^^^^^ @@ -1575,7 +1587,7 @@ foo *a +b: stdout: "", stderr: "error: Expected \':\' or \'=\', but found \'+\' | -2 | foo *a +b: +1 | foo *a +b: | ^ ", status: EXIT_FAILURE, @@ -1590,7 +1602,7 @@ foo +a *b: stdout: "", stderr: "error: Expected \':\' or \'=\', but found \'*\' | -2 | foo +a *b: +1 | foo +a *b: | ^ ", status: EXIT_FAILURE, @@ -1623,7 +1635,7 @@ a: x y stdout: "", stderr: "error: Recipe `a` has unknown dependency `y` | -4 | a: x y +3 | a: x y | ^ ", status: EXIT_FAILURE, @@ -1775,7 +1787,7 @@ X := "\'" stdout: "", stderr: r#"error: `\'` is not a valid escape sequence | -2 | X := "\'" +1 | X := "\'" | ^^^^ "#, status: EXIT_FAILURE, @@ -1784,12 +1796,12 @@ X := "\'" test! { name: unknown_variable_in_default, justfile: " -foo x=bar: -", + foo x=bar: + ", stdout: "", stderr: r#"error: Variable `bar` not defined | -2 | foo x=bar: +1 | foo x=bar: | ^^^ "#, status: EXIT_FAILURE, @@ -1803,7 +1815,7 @@ foo x=bar(): stdout: "", stderr: r#"error: Call to unknown function `bar` | -2 | foo x=bar(): +1 | foo x=bar(): | ^^^ "#, status: EXIT_FAILURE, @@ -1863,13 +1875,13 @@ foo a=arch() o=os() f=os_family(): test! { name: unterminated_interpolation_eol, justfile: " -foo: - echo {{ -", + foo: + echo {{ + ", stderr: r#" error: Unterminated interpolation | - 3 | echo {{ + 2 | echo {{ | ^^ "#, status: EXIT_FAILURE, @@ -1878,12 +1890,13 @@ foo: test! { name: unterminated_interpolation_eof, justfile: " -foo: - echo {{", + foo: + echo {{ + ", stderr: r#" error: Unterminated interpolation | - 3 | echo {{ + 2 | echo {{ | ^^ "#, status: EXIT_FAILURE, @@ -1897,7 +1910,7 @@ assembly_source_files = %(wildcard src/arch/$(arch)/*.s) stderr: r#" error: Unknown start of token: | - 2 | assembly_source_files = %(wildcard src/arch/$(arch)/*.s) + 1 | assembly_source_files = %(wildcard src/arch/$(arch)/*.s) | ^ "#, status: EXIT_FAILURE, @@ -1930,19 +1943,17 @@ default stdin = `cat`: test! { name: backtick_default_cat_justfile, justfile: " -default stdin = `cat justfile`: - echo '{{stdin}}' -", + default stdin = `cat justfile`: + echo '{{stdin}}' + ", stdout: " - default stdin = `cat justfile`: echo {{stdin}} set dotenv-load := true ", stderr: " - echo ' - default stdin = `cat justfile`: + echo 'default stdin = `cat justfile`: echo '{{stdin}}' set dotenv-load := true' diff --git a/tests/string.rs b/tests/string.rs index 0b68475..d1ad12b 100644 --- a/tests/string.rs +++ b/tests/string.rs @@ -65,6 +65,22 @@ whatever' ", } +test! { + name: cooked_string_suppress_newline, + justfile: r#" + a := """ + foo\ + bar + """ + + @default: + printf %s '{{a}}' + "#, + stdout: " + foobar + ", +} + test! { name: invalid_escape_sequence, justfile: r#"x := "\q" @@ -93,7 +109,7 @@ a: stdout: "", stderr: "error: Variable `foo` not defined | -7 | echo '{{foo}}' +6 | echo '{{foo}}' | ^^^ ", status: EXIT_FAILURE, @@ -113,7 +129,7 @@ a: stdout: "", stderr: "error: Variable `bar` not defined | -4 | whatever' + bar +3 | whatever' + bar | ^^^ ", status: EXIT_FAILURE, @@ -150,7 +166,7 @@ a: stdout: "", stderr: "error: Variable `b` not defined | -6 | echo {{b}} +5 | echo {{b}} | ^ ", status: EXIT_FAILURE, @@ -159,43 +175,208 @@ a: test! { name: unterminated_raw_string, justfile: " -a b= ': -", + a b= ': + ", args: ("a"), stdout: "", - stderr: "error: Unterminated string - | -2 | a b= ': - | ^ -", + stderr: " + error: Unterminated string + | + 1 | a b= ': + | ^ + ", status: EXIT_FAILURE, } test! { name: unterminated_string, justfile: r#" -a b= ": -"#, + a b= ": + "#, args: ("a"), stdout: "", - stderr: r#"error: Unterminated string - | -2 | a b= ": - | ^ -"#, + stderr: r#" + error: Unterminated string + | + 1 | a b= ": + | ^ + "#, status: EXIT_FAILURE, } test! { name: unterminated_backtick, justfile: " -foo a=\t`echo blaaaaaah: - echo {{a}}", + foo a=\t`echo blaaaaaah: + echo {{a}} + ", stderr: r#" error: Unterminated backtick | - 2 | foo a= `echo blaaaaaah: + 1 | foo a= `echo blaaaaaah: | ^ "#, status: EXIT_FAILURE, } + +test! { + name: unterminated_indented_raw_string, + justfile: " + a b= ''': + ", + args: ("a"), + stdout: "", + stderr: " + error: Unterminated string + | + 1 | a b= ''': + | ^^^ + ", + status: EXIT_FAILURE, +} + +test! { + name: unterminated_indented_string, + justfile: r#" + a b= """: + "#, + args: ("a"), + stdout: "", + stderr: r#" + error: Unterminated string + | + 1 | a b= """: + | ^^^ + "#, + status: EXIT_FAILURE, +} + +test! { + name: unterminated_indented_backtick, + justfile: " + foo a=\t```echo blaaaaaah: + echo {{a}} + ", + stderr: r#" + error: Unterminated backtick + | + 1 | foo a= ```echo blaaaaaah: + | ^^^ + "#, + status: EXIT_FAILURE, +} + +test! { + name: indented_raw_string_contents_indentation_removed, + justfile: " + a := ''' + foo + bar + ''' + + @default: + printf '{{a}}' + ", + stdout: " + foo + bar + ", +} + +test! { + name: indented_cooked_string_contents_indentation_removed, + justfile: r#" + a := """ + foo + bar + """ + + @default: + printf '{{a}}' + "#, + stdout: " + foo + bar + ", +} + +test! { + name: indented_backtick_string_contents_indentation_removed, + justfile: r#" + a := ``` + printf ' + foo + bar + ' + ``` + + @default: + printf '{{a}}' + "#, + stdout: "\n\nfoo\nbar", +} + +test! { + name: indented_raw_string_escapes, + justfile: r#" + a := ''' + foo\n + bar + ''' + + @default: + printf %s '{{a}}' + "#, + stdout: r#" + foo\n + bar + "#, +} + +test! { + name: indented_cooked_string_escapes, + justfile: r#" + a := """ + foo\n + bar + """ + + @default: + printf %s '{{a}}' + "#, + stdout: " + foo + + bar + ", +} + +test! { + name: indented_backtick_string_escapes, + justfile: r#" + a := ``` + printf %s ' + foo\n + bar + ' + ``` + + @default: + printf %s '{{a}}' + "#, + stdout: "\n\nfoo\\n\nbar", +} + +test! { + name: shebang_backtick, + justfile: " + x := `#!/usr/bin/env sh` + ", + stderr: " + error: Backticks may not start with `#!` + | + 1 | x := `#!/usr/bin/env sh` + | ^^^^^^^^^^^^^^^^^^^ + ", + status: EXIT_FAILURE, +}