Reform and improve string literals (#793)

- Combine and simplify string and backtick lexing.
- Allow newlines in strings and backticks.
- Add triple-delimited indented strings and backticks. Common indented literal non-blank line leading whitespace is stripped.
- If a literal newline is escaped, it will be suppressed.
- Backticks starting with `#!` are reserved for a future upgrade.
This commit is contained in:
Casey Rodarmor 2021-04-05 21:28:37 -07:00 committed by GitHub
parent da97f8d7dd
commit fec979c2c6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 827 additions and 356 deletions

View File

@ -9,17 +9,20 @@ tokens
------ ------
``` ```
BACKTICK = `[^`]*` BACKTICK = `[^`]*`
COMMENT = #([^!].*)?$ INDENTED_BACKTICK = ```[^(```)]*```
DEDENT = emitted when indentation decreases COMMENT = #([^!].*)?$
EOF = emitted at the end of the file DEDENT = emitted when indentation decreases
INDENT = emitted when indentation increases EOF = emitted at the end of the file
LINE = emitted before a recipe line INDENT = emitted when indentation increases
NAME = [a-zA-Z_][a-zA-Z0-9_-]* LINE = emitted before a recipe line
NEWLINE = \n|\r\n NAME = [a-zA-Z_][a-zA-Z0-9_-]*
RAW_STRING = '[^']*' NEWLINE = \n|\r\n
STRING = "[^"]*" # also processes \n \r \t \" \\ escapes RAW_STRING = '[^']*'
TEXT = recipe text, only matches in a recipe body 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 grammar syntax
@ -69,14 +72,16 @@ condition : expression '==' expression
| expression '!=' expression | expression '!=' expression
value : NAME '(' sequence? ')' value : NAME '(' sequence? ')'
| STRING
| RAW_STRING
| BACKTICK | BACKTICK
| INDENTED_BACKTICK
| NAME | NAME
| string
| '(' expression ')' | '(' expression ')'
string : STRING string : STRING
| INDENTED_STRING
| RAW_STRING | RAW_STRING
| INDENTED_RAW_STRING
sequence : expression ',' sequence sequence : expression ',' sequence
| expression ','? | expression ','?

View File

@ -545,6 +545,8 @@ string-with-newline := "\n"
string-with-carriage-return := "\r" string-with-carriage-return := "\r"
string-with-double-quote := "\"" string-with-double-quote := "\""
string-with-slash := "\\" string-with-slash := "\\"
string-with-no-newline := "\
"
``` ```
```sh ```sh
@ -553,6 +555,7 @@ $ just --evaluate
string-with-double-quote := """ string-with-double-quote := """
string-with-newline := " string-with-newline := "
" "
string-with-no-newline := ""
string-with-slash := "\" string-with-slash := "\"
string-with-tab := " " string-with-tab := " "
``` ```
@ -580,6 +583,25 @@ $ just --evaluate
escapes := "\t\n\r\"\\" 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 === Ignoring Errors
Normally, if a command returns a nonzero exit status, execution will stop. To Normally, if a command returns a nonzero exit status, execution will stop. To
@ -716,6 +738,20 @@ serve:
./serve {{localhost}} 8080 ./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 <<Strings>> section for details on unindenting.
Backticks may not start with `#!`. This syntax is reserved for a future upgrade.
=== Conditional Expressions === Conditional Expressions
`if`/`else` expressions evaluate different branches depending on if two expressions evaluate to the same value: `if`/`else` expressions evaluate different branches depending on if two expressions evaluate to the same value:

View File

@ -1,6 +1,5 @@
// stdlib // stdlib
pub(crate) use std::{ pub(crate) use std::{
borrow::Cow,
cmp, cmp,
collections::{BTreeMap, BTreeSet}, collections::{BTreeMap, BTreeSet},
env, env,
@ -31,7 +30,9 @@ pub(crate) use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
pub(crate) use crate::{config_error, setting}; pub(crate) use crate::{config_error, setting};
// functions // 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 // traits
pub(crate) use crate::{ pub(crate) use crate::{

View File

@ -26,6 +26,9 @@ impl Display for CompilationError<'_> {
recipe_line.ordinal(), recipe_line.ordinal(),
)?; )?;
}, },
BacktickShebang => {
writeln!(f, "Backticks may not start with `#!`")?;
},
CircularRecipeDependency { recipe, ref circle } => CircularRecipeDependency { recipe, ref circle } =>
if circle.len() == 2 { if circle.len() == 2 {
writeln!(f, "Recipe `{}` depends on itself", recipe)?; writeln!(f, "Recipe `{}` depends on itself", recipe)?;
@ -242,10 +245,10 @@ impl Display for CompilationError<'_> {
UnterminatedInterpolation => { UnterminatedInterpolation => {
writeln!(f, "Unterminated interpolation")?; writeln!(f, "Unterminated interpolation")?;
}, },
UnterminatedString(StringKind::Cooked) | UnterminatedString(StringKind::Raw) => { UnterminatedString => {
writeln!(f, "Unterminated string")?; writeln!(f, "Unterminated string")?;
}, },
UnterminatedString(StringKind::Backtick) => { UnterminatedBacktick => {
writeln!(f, "Unterminated backtick")?; writeln!(f, "Unterminated backtick")?;
}, },
Internal { ref message } => { Internal { ref message } => {

View File

@ -6,6 +6,7 @@ pub(crate) enum CompilationErrorKind<'src> {
alias: &'src str, alias: &'src str,
recipe_line: usize, recipe_line: usize,
}, },
BacktickShebang,
CircularRecipeDependency { CircularRecipeDependency {
recipe: &'src str, recipe: &'src str,
circle: Vec<&'src str>, circle: Vec<&'src str>,
@ -107,5 +108,6 @@ pub(crate) enum CompilationErrorKind<'src> {
open_line: usize, open_line: usize,
}, },
UnterminatedInterpolation, UnterminatedInterpolation,
UnterminatedString(StringKind), UnterminatedString,
UnterminatedBacktick,
} }

View File

@ -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 } => Expression::Backtick { contents, token } =>
if self.config.dry_run { if self.config.dry_run {
Ok(format!("`{}`", contents)) Ok(format!("`{}`", contents))

View File

@ -10,7 +10,7 @@ use crate::common::*;
pub(crate) enum Expression<'src> { pub(crate) enum Expression<'src> {
/// `contents` /// `contents`
Backtick { Backtick {
contents: &'src str, contents: String,
token: Token<'src>, token: Token<'src>,
}, },
/// `name(arguments)` /// `name(arguments)`

View File

@ -129,6 +129,14 @@ impl<'src> Lexer<'src> {
Ok(()) Ok(())
} }
fn presume_str(&mut self, s: &str) -> CompilationResult<'src, ()> {
for c in s.chars() {
self.presume(c)?;
}
Ok(())
}
/// Is next character c? /// Is next character c?
fn next_is(&self, c: char) -> bool { fn next_is(&self, c: char) -> bool {
self.next == Some(c) 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: // The width of the error site to highlight depends on the kind of error:
let length = match kind { let length = match kind {
// highlight ', ", or ` UnterminatedString | UnterminatedBacktick => {
UnterminatedString(_) => 1, 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 // highlight the full token
_ => self.lexeme().len(), _ => self.lexeme().len(),
}; };
@ -476,9 +490,7 @@ impl<'src> Lexer<'src> {
'+' => self.lex_single(Plus), '+' => self.lex_single(Plus),
'#' => self.lex_comment(), '#' => self.lex_comment(),
' ' => self.lex_whitespace(), ' ' => self.lex_whitespace(),
'`' => self.lex_string(StringKind::Backtick), '`' | '"' | '\'' => self.lex_string(),
'"' => self.lex_string(StringKind::Cooked),
'\'' => self.lex_string(StringKind::Raw),
'\n' => self.lex_eol(), '\n' => self.lex_eol(),
'\r' => self.lex_eol(), '\r' => self.lex_eol(),
'\t' => self.lex_whitespace(), '\t' => self.lex_whitespace(),
@ -760,23 +772,33 @@ impl<'src> Lexer<'src> {
/// Backtick: `[^`]*` /// Backtick: `[^`]*`
/// Cooked string: "[^"]*" # also processes escape sequences /// Cooked string: "[^"]*" # also processes escape sequences
/// Raw string: '[^']*' /// Raw string: '[^']*'
fn lex_string(&mut self, kind: StringKind) -> CompilationResult<'src, ()> { fn lex_string(&mut self) -> CompilationResult<'src, ()> {
self.presume(kind.delimiter())?; 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; let mut escape = false;
loop { loop {
match self.next { if self.next == None {
Some(c) if c == kind.delimiter() && !escape => break, return Err(self.error(kind.unterminated_error_kind()));
Some('\\') if kind.processes_escape_sequences() && !escape => escape = true, } else if kind.processes_escape_sequences() && self.next_is('\\') && !escape {
Some(_) => escape = false, escape = true;
None => return Err(self.error(kind.unterminated_error_kind())), } else if self.rest_starts_with(kind.delimiter()) && !escape {
break;
} else {
escape = false;
} }
self.advance()?; self.advance()?;
} }
self.presume(kind.delimiter())?; self.presume_str(kind.delimiter())?;
self.token(kind.token_kind()); self.token(kind.token_kind());
Ok(()) Ok(())
@ -789,15 +811,11 @@ mod tests {
use pretty_assertions::assert_eq; 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 { macro_rules! test {
{ {
name: $name:ident, name: $name:ident,
text: $text:expr, text: $text:expr,
tokens: ($($kind:ident $(: $lexeme:literal)?),* $(,)?)$(,)? tokens: ($($kind:ident $(: $lexeme:literal)?),* $(,)?)$(,)?
} => { } => {
#[test] #[test]
fn $name() { fn $name() {
@ -805,7 +823,22 @@ mod tests {
let lexemes: &[&str] = &[$(lexeme!($kind $(, $lexeme)?),)* ""]; 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]) { fn test(text: &str, unindent_text: bool, want_kinds: &[TokenKind], want_lexemes: &[&str]) {
let text = testing::unindent(text); let text = if unindent_text {
unindent(text)
} else {
text.to_owned()
};
let have = Lexer::lex(&text).unwrap(); let have = Lexer::lex(&text).unwrap();
@ -901,7 +938,7 @@ mod tests {
Dedent | Eof => "", Dedent | Eof => "",
// Variable lexemes // Variable lexemes
Text | StringToken(_) | Identifier | Comment | Unspecified => Text | StringToken | Backtick | Identifier | Comment | Unspecified =>
panic!("Token {:?} has no default lexeme", kind), panic!("Token {:?} has no default lexeme", kind),
} }
} }
@ -965,37 +1002,43 @@ mod tests {
test! { test! {
name: backtick, name: backtick,
text: "`echo`", text: "`echo`",
tokens: (STRING_BACKTICK:"`echo`"), tokens: (Backtick:"`echo`"),
} }
test! { test! {
name: backtick_multi_line, name: backtick_multi_line,
text: "`echo\necho`", text: "`echo\necho`",
tokens: (STRING_BACKTICK:"`echo\necho`"), tokens: (Backtick:"`echo\necho`"),
} }
test! { test! {
name: raw_string, name: raw_string,
text: "'hello'", text: "'hello'",
tokens: (STRING_RAW:"'hello'"), tokens: (StringToken:"'hello'"),
} }
test! { test! {
name: raw_string_multi_line, name: raw_string_multi_line,
text: "'hello\ngoodbye'", text: "'hello\ngoodbye'",
tokens: (STRING_RAW:"'hello\ngoodbye'"), tokens: (StringToken:"'hello\ngoodbye'"),
} }
test! { test! {
name: cooked_string, name: cooked_string,
text: "\"hello\"", text: "\"hello\"",
tokens: (STRING_COOKED:"\"hello\""), tokens: (StringToken:"\"hello\""),
} }
test! { test! {
name: cooked_string_multi_line, name: cooked_string_multi_line,
text: "\"hello\ngoodbye\"", 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! { test! {
@ -1056,11 +1099,11 @@ mod tests {
Whitespace, Whitespace,
Equals, Equals,
Whitespace, Whitespace,
STRING_RAW:"'foo'", StringToken:"'foo'",
Whitespace, Whitespace,
Plus, Plus,
Whitespace, Whitespace,
STRING_RAW:"'bar'", StringToken:"'bar'",
) )
} }
@ -1075,29 +1118,31 @@ mod tests {
Equals, Equals,
Whitespace, Whitespace,
ParenL, ParenL,
STRING_RAW:"'foo'", StringToken:"'foo'",
Whitespace, Whitespace,
Plus, Plus,
Whitespace, Whitespace,
STRING_RAW:"'bar'", StringToken:"'bar'",
ParenR, ParenR,
Whitespace, Whitespace,
Plus, Plus,
Whitespace, Whitespace,
STRING_BACKTICK:"`baz`", Backtick:"`baz`",
), ),
} }
test! { test! {
name: eol_linefeed, name: eol_linefeed,
text: "\n", text: "\n",
tokens: (Eol), tokens: (Eol),
unindent: false,
} }
test! { test! {
name: eol_carriage_return_linefeed, name: eol_carriage_return_linefeed,
text: "\r\n", text: "\r\n",
tokens: (Eol:"\r\n"), tokens: (Eol:"\r\n"),
unindent: false,
} }
test! { test! {
@ -1142,6 +1187,7 @@ mod tests {
Eol, Eol,
Dedent, Dedent,
), ),
unindent: false,
} }
test! { test! {
@ -1324,6 +1370,7 @@ mod tests {
Eol, Eol,
Dedent, Dedent,
), ),
unindent: false,
} }
test! { test! {
@ -1411,11 +1458,11 @@ mod tests {
Indent:" ", Indent:" ",
Text:"echo ", Text:"echo ",
InterpolationStart, InterpolationStart,
STRING_BACKTICK:"`echo hello`", Backtick:"`echo hello`",
Whitespace, Whitespace,
Plus, Plus,
Whitespace, Whitespace,
STRING_BACKTICK:"`echo goodbye`", Backtick:"`echo goodbye`",
InterpolationEnd, InterpolationEnd,
Dedent, Dedent,
), ),
@ -1431,7 +1478,7 @@ mod tests {
Indent:" ", Indent:" ",
Text:"echo ", Text:"echo ",
InterpolationStart, InterpolationStart,
STRING_RAW:"'\n'", StringToken:"'\n'",
InterpolationEnd, InterpolationEnd,
Dedent, Dedent,
), ),
@ -1503,19 +1550,19 @@ mod tests {
Whitespace, Whitespace,
Equals, Equals,
Whitespace, Whitespace,
STRING_COOKED:"\"'a'\"", StringToken:"\"'a'\"",
Whitespace, Whitespace,
Plus, Plus,
Whitespace, Whitespace,
STRING_RAW:"'\"b\"'", StringToken:"'\"b\"'",
Whitespace, Whitespace,
Plus, Plus,
Whitespace, Whitespace,
STRING_COOKED:"\"'c'\"", StringToken:"\"'c'\"",
Whitespace, Whitespace,
Plus, Plus,
Whitespace, Whitespace,
STRING_RAW:"'\"d\"'", StringToken:"'\"d\"'",
Comment:"#echo hello", Comment:"#echo hello",
) )
} }
@ -1583,7 +1630,7 @@ mod tests {
Whitespace, Whitespace,
Plus, Plus,
Whitespace, Whitespace,
STRING_COOKED:"\"z\"", StringToken:"\"z\"",
Whitespace, Whitespace,
Plus, Plus,
Whitespace, Whitespace,
@ -1707,7 +1754,7 @@ mod tests {
Eol, Eol,
Identifier:"A", Identifier:"A",
Equals, Equals,
STRING_RAW:"'1'", StringToken:"'1'",
Eol, Eol,
Identifier:"echo", Identifier:"echo",
Colon, Colon,
@ -1732,11 +1779,11 @@ mod tests {
Indent:" ", Indent:" ",
Text:"echo ", Text:"echo ",
InterpolationStart, InterpolationStart,
STRING_BACKTICK:"`echo hello`", Backtick:"`echo hello`",
Whitespace, Whitespace,
Plus, Plus,
Whitespace, Whitespace,
STRING_BACKTICK:"`echo goodbye`", Backtick:"`echo goodbye`",
InterpolationEnd, InterpolationEnd,
Dedent Dedent
), ),
@ -1765,11 +1812,11 @@ mod tests {
Whitespace, Whitespace,
Equals, Equals,
Whitespace, Whitespace,
STRING_BACKTICK:"`echo hello`", Backtick:"`echo hello`",
Whitespace, Whitespace,
Plus, Plus,
Whitespace, Whitespace,
STRING_BACKTICK:"`echo goodbye`", Backtick:"`echo goodbye`",
), ),
} }
@ -2011,7 +2058,7 @@ mod tests {
line: 0, line: 0,
column: 4, column: 4,
width: 1, width: 1,
kind: UnterminatedString(StringKind::Cooked), kind: UnterminatedString,
} }
error! { error! {
@ -2021,7 +2068,7 @@ mod tests {
line: 0, line: 0,
column: 4, column: 4,
width: 1, width: 1,
kind: UnterminatedString(StringKind::Raw), kind: UnterminatedString,
} }
error! { error! {
@ -2042,7 +2089,7 @@ mod tests {
line: 0, line: 0,
column: 0, column: 0,
width: 1, width: 1,
kind: UnterminatedString(StringKind::Backtick), kind: UnterminatedBacktick,
} }
error! { error! {
@ -2102,7 +2149,7 @@ mod tests {
line: 0, line: 0,
column: 4, column: 4,
width: 1, width: 1,
kind: UnterminatedString(StringKind::Cooked), kind: UnterminatedString,
} }
error! { error! {
@ -2200,7 +2247,7 @@ mod tests {
assert_eq!( assert_eq!(
Lexer::new("!").presume('-').unwrap_err().to_string(), Lexer::new("!").presume('-').unwrap_err().to_string(),
testing::unindent( unindent(
" "
Internal error, this may indicate a bug in just: Lexer presumed character `-` Internal error, this may indicate a bug in just: Lexer presumed character `-`
\ \

View File

@ -125,6 +125,7 @@ mod table;
mod thunk; mod thunk;
mod token; mod token;
mod token_kind; mod token_kind;
mod unindent;
mod unresolved_dependency; mod unresolved_dependency;
mod unresolved_recipe; mod unresolved_recipe;
mod use_color; mod use_color;
@ -134,5 +135,9 @@ mod warning;
pub use crate::run::run; pub use crate::run::run;
// Used in integration tests.
#[doc(hidden)]
pub use unindent::unindent;
#[cfg(feature = "summary")] #[cfg(feature = "summary")]
pub mod summary; pub mod summary;

View File

@ -100,8 +100,8 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
/// ///
/// The first token in `kinds` will be added to the expected token set. /// The first token in `kinds` will be added to the expected token set.
fn next_are(&mut self, kinds: &[TokenKind]) -> bool { fn next_are(&mut self, kinds: &[TokenKind]) -> bool {
if let Some(kind) = kinds.first() { if let Some(&kind) = kinds.first() {
self.expected.insert(*kind); self.expected.insert(kind);
} }
let mut rest = self.rest(); 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 /// Return an unexpected token error if the next token is not an EOL
fn expect_eol(&mut self) -> CompilationResult<'src, ()> { fn expect_eol(&mut self) -> CompilationResult<'src, ()> {
self.accept(Comment)?; self.accept(Comment)?;
@ -453,15 +442,26 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
/// Parse a value, e.g. `(bar)` /// Parse a value, e.g. `(bar)`
fn parse_value(&mut self) -> CompilationResult<'src, Expression<'src>> { 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 { Ok(Expression::StringLiteral {
string_literal: self.parse_string_literal()?, 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 next = self.next()?;
let kind = StringKind::from_string_or_backtick(next)?;
let contents = &next.lexeme()[1..next.lexeme().len() - 1]; let contents =
&next.lexeme()[kind.delimiter_len()..next.lexeme().len() - kind.delimiter_len()];
let token = self.advance()?; 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 }) Ok(Expression::Backtick { contents, token })
} else if self.next_is(Identifier) { } else if self.next_is(Identifier) {
let name = self.parse_name()?; let name = self.parse_name()?;
@ -486,51 +486,51 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
/// Parse a string literal, e.g. `"FOO"` /// Parse a string literal, e.g. `"FOO"`
fn parse_string_literal(&mut self) -> CompilationResult<'src, StringLiteral<'src>> { fn parse_string_literal(&mut self) -> CompilationResult<'src, StringLiteral<'src>> {
let token = self.expect_any(&[ let token = self.expect(StringToken)?;
StringToken(StringKind::Raw),
StringToken(StringKind::Cooked),
])?;
let raw = &token.lexeme()[1..token.lexeme().len() - 1]; let kind = StringKind::from_string_or_backtick(token)?;
match token.kind { let delimiter_len = kind.delimiter_len();
StringToken(StringKind::Raw) => Ok(StringLiteral {
raw, let raw = &token.lexeme()[delimiter_len..token.lexeme().len() - delimiter_len];
cooked: Cow::Borrowed(raw),
}), let unindented = if kind.indented() {
StringToken(StringKind::Cooked) => { unindent(raw)
let mut cooked = String::new(); } else {
let mut escape = false; raw.to_owned()
for c in raw.chars() { };
if escape {
match c { let cooked = if kind.processes_escape_sequences() {
'n' => cooked.push('\n'), let mut cooked = String::new();
'r' => cooked.push('\r'), let mut escape = false;
't' => cooked.push('\t'), for c in unindented.chars() {
'\\' => cooked.push('\\'), if escape {
'"' => cooked.push('"'), match c {
other => { 'n' => cooked.push('\n'),
return Err( 'r' => cooked.push('\r'),
token.error(CompilationErrorKind::InvalidEscapeSequence { character: other }), 't' => cooked.push('\t'),
); '\\' => cooked.push('\\'),
}, '\n' => {},
} '"' => cooked.push('"'),
escape = false; other => {
} else if c == '\\' { return Err(
escape = true; token.error(CompilationErrorKind::InvalidEscapeSequence { character: other }),
} else { );
cooked.push(c); },
} }
escape = false;
} else if c == '\\' {
escape = true;
} else {
cooked.push(c);
} }
Ok(StringLiteral { }
raw, cooked
cooked: Cow::Owned(cooked), } else {
}) unindented
}, };
_ => Err(token.error(CompilationErrorKind::Internal {
message: "`Parser::parse_string_literal` called on non-string token".to_owned(), Ok(StringLiteral { cooked, raw, kind })
})),
}
} }
/// Parse a name from an identifier token /// Parse a name from an identifier token
@ -757,7 +757,6 @@ mod tests {
use super::*; use super::*;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use testing::unindent;
use CompilationErrorKind::*; use CompilationErrorKind::*;
macro_rules! test { macro_rules! test {
@ -1186,6 +1185,15 @@ mod tests {
tree: (justfile (assignment x "foo\nbar")), tree: (justfile (assignment x "foo\nbar")),
} }
test! {
name: string_escape_suppress_newline,
text: r#"
x := "foo\
bar"
"#,
tree: (justfile (assignment x "foobar")),
}
test! { test! {
name: string_escape_carriage_return, name: string_escape_carriage_return,
text: r#"x := "foo\rbar""#, text: r#"x := "foo\rbar""#,
@ -1204,6 +1212,72 @@ mod tests {
tree: (justfile (assignment x "foo\"bar")), 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! { test! {
name: recipe_variadic_with_default_after_default, name: recipe_variadic_with_default_after_default,
text: r#" text: r#"
@ -1724,11 +1798,10 @@ mod tests {
width: 1, width: 1,
kind: UnexpectedToken { kind: UnexpectedToken {
expected: vec![ expected: vec![
Backtick,
Identifier, Identifier,
ParenL, ParenL,
StringToken(StringKind::Backtick), StringToken,
StringToken(StringKind::Cooked),
StringToken(StringKind::Raw)
], ],
found: Eol found: Eol
}, },
@ -1743,11 +1816,10 @@ mod tests {
width: 0, width: 0,
kind: UnexpectedToken { kind: UnexpectedToken {
expected: vec![ expected: vec![
Backtick,
Identifier, Identifier,
ParenL, ParenL,
StringToken(StringKind::Backtick), StringToken,
StringToken(StringKind::Cooked),
StringToken(StringKind::Raw)
], ],
found: Eof, found: Eof,
}, },
@ -1785,12 +1857,11 @@ mod tests {
width: 0, width: 0,
kind: UnexpectedToken{ kind: UnexpectedToken{
expected: vec![ expected: vec![
Backtick,
Identifier, Identifier,
ParenL, ParenL,
ParenR, ParenR,
StringToken(StringKind::Backtick), StringToken,
StringToken(StringKind::Cooked),
StringToken(StringKind::Raw)
], ],
found: Eof, found: Eof,
}, },
@ -1805,12 +1876,11 @@ mod tests {
width: 2, width: 2,
kind: UnexpectedToken{ kind: UnexpectedToken{
expected: vec![ expected: vec![
Backtick,
Identifier, Identifier,
ParenL, ParenL,
ParenR, ParenR,
StringToken(StringKind::Backtick), StringToken,
StringToken(StringKind::Cooked),
StringToken(StringKind::Raw)
], ],
found: InterpolationEnd, found: InterpolationEnd,
}, },
@ -1887,7 +1957,9 @@ mod tests {
column: 14, column: 14,
width: 1, width: 1,
kind: UnexpectedToken { kind: UnexpectedToken {
expected: vec![StringToken(StringKind::Cooked), StringToken(StringKind::Raw)], expected: vec![
StringToken,
],
found: BracketR, found: BracketR,
}, },
} }
@ -1926,7 +1998,10 @@ mod tests {
column: 21, column: 21,
width: 0, width: 0,
kind: UnexpectedToken { kind: UnexpectedToken {
expected: vec![BracketR, StringToken(StringKind::Cooked), StringToken(StringKind::Raw)], expected: vec![
BracketR,
StringToken,
],
found: Eof, found: Eof,
}, },
} }

View File

@ -53,6 +53,8 @@ impl PlatformInterface for Platform {
command: &str, command: &str,
argument: Option<&str>, argument: Option<&str>,
) -> Result<Command, OutputError> { ) -> Result<Command, OutputError> {
use std::borrow::Cow;
// If the path contains forward slashes… // If the path contains forward slashes…
let command = if command.contains('/') { let command = if command.contains('/') {
// …translate path to the interpreter from unix style to windows style. // …translate path to the interpreter from unix style to windows style.

View File

@ -1,33 +1,94 @@
use crate::common::*; use crate::common::*;
#[derive(Debug, PartialEq, Clone, Copy, Ord, PartialOrd, Eq)] #[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, Backtick,
Cooked, QuoteDouble,
Raw, QuoteSingle,
} }
impl StringKind { impl StringKind {
pub(crate) fn delimiter(self) -> char { // Indented values must come before un-indented values, or else
match self { // `Self::from_token_start` will incorrectly return indented = false
Self::Backtick => '`', // for indented strings.
Self::Cooked => '"', const ALL: &'static [Self] = &[
Self::Raw => '\'', 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 { 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> { 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 { pub(crate) fn processes_escape_sequences(self) -> bool {
match self { match self.delimiter {
Self::Backtick | Self::Raw => false, StringDelimiter::QuoteDouble => true,
Self::Cooked => 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> {
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<Self> {
for &kind in Self::ALL {
if token_start.starts_with(kind.delimiter()) {
return Some(kind);
}
}
None
}
} }

View File

@ -2,15 +2,19 @@ use crate::common::*;
#[derive(PartialEq, Debug)] #[derive(PartialEq, Debug)]
pub(crate) struct StringLiteral<'src> { pub(crate) struct StringLiteral<'src> {
pub(crate) kind: StringKind,
pub(crate) raw: &'src str, pub(crate) raw: &'src str,
pub(crate) cooked: Cow<'src, str>, pub(crate) cooked: String,
} }
impl Display for StringLiteral<'_> { impl Display for StringLiteral<'_> {
fn fmt(&self, f: &mut Formatter) -> fmt::Result { fn fmt(&self, f: &mut Formatter) -> fmt::Result {
match self.cooked { write!(
Cow::Borrowed(raw) => write!(f, "'{}'", raw), f,
Cow::Owned(_) => write!(f, "\"{}\"", self.raw), "{}{}{}",
} self.kind.delimiter(),
self.raw,
self.kind.delimiter()
)
} }
} }

View File

@ -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 { macro_rules! analysis_error {
( (
@ -94,7 +94,7 @@ macro_rules! run_error {
let search = $crate::testing::search(&config); let search = $crate::testing::search(&config);
if let Subcommand::Run{ overrides, arguments } = &config.subcommand { 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") .expect("Expected successful compilation")
.run( .run(
&config, &config,

View File

@ -4,6 +4,7 @@ use crate::common::*;
pub(crate) enum TokenKind { pub(crate) enum TokenKind {
Asterisk, Asterisk,
At, At,
Backtick,
BangEquals, BangEquals,
BraceL, BraceL,
BraceR, BraceR,
@ -26,7 +27,7 @@ pub(crate) enum TokenKind {
ParenL, ParenL,
ParenR, ParenR,
Plus, Plus,
StringToken(StringKind), StringToken,
Text, Text,
Unspecified, Unspecified,
Whitespace, Whitespace,
@ -38,6 +39,7 @@ impl Display for TokenKind {
write!(f, "{}", match *self { write!(f, "{}", match *self {
Asterisk => "'*'", Asterisk => "'*'",
At => "'@'", At => "'@'",
Backtick => "backtick",
BangEquals => "'!='", BangEquals => "'!='",
BraceL => "'{'", BraceL => "'{'",
BraceR => "'}'", BraceR => "'}'",
@ -60,12 +62,10 @@ impl Display for TokenKind {
ParenL => "'('", ParenL => "'('",
ParenR => "')'", ParenR => "')'",
Plus => "'+'", Plus => "'+'",
StringToken(StringKind::Backtick) => "backtick", StringToken => "string",
StringToken(StringKind::Cooked) => "cooked string",
StringToken(StringKind::Raw) => "raw string",
Text => "command text", Text => "command text",
Whitespace => "whitespace",
Unspecified => "unspecified", Unspecified => "unspecified",
Whitespace => "whitespace",
}) })
} }
} }

View File

@ -1,6 +1,6 @@
use crate::common::*; use crate::common::*;
use std::mem; use std::{borrow::Cow, mem};
/// Construct a `Tree` from a symbolic expression literal. This macro, and the /// 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 /// Tree type, are only used in the Parser unit tests, providing a concise

134
src/unindent.rs Normal file
View File

@ -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"), "");
}
}

View File

@ -20,67 +20,6 @@ pub fn assert_stdout(output: &Output, stdout: &str) {
assert_eq!(String::from_utf8_lossy(&output.stdout), stdout); 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 { pub enum Entry {
File { File {
contents: &'static str, 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@ -9,6 +9,7 @@ pub(crate) use std::{
}; };
pub(crate) use executable_path::executable_path; pub(crate) use executable_path::executable_path;
pub(crate) use just::unindent;
pub(crate) use libc::{EXIT_FAILURE, EXIT_SUCCESS}; 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; pub(crate) use which::which;

View File

@ -278,7 +278,7 @@ hello:
recipe: recipe:
@exit 100", @exit 100",
args: ("recipe"), 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, status: 100,
} }
@ -347,12 +347,12 @@ test! {
test! { test! {
name: backtick_code_interpolation_tab, name: backtick_code_interpolation_tab,
justfile: " justfile: "
backtick-fail: backtick-fail:
\techo {{`exit 200`}} \techo {{`exit 200`}}
", ",
stderr: " error: Backtick failed with exit code 200 stderr: " error: Backtick failed with exit code 200
| |
3 | echo {{`exit 200`}} 2 | echo {{`exit 200`}}
| ^^^^^^^^^^ | ^^^^^^^^^^
", ",
status: 200, status: 200,
@ -361,12 +361,12 @@ backtick-fail:
test! { test! {
name: backtick_code_interpolation_tabs, name: backtick_code_interpolation_tabs,
justfile: " justfile: "
backtick-fail: backtick-fail:
\techo {{\t`exit 200`}} \techo {{\t`exit 200`}}
", ",
stderr: "error: Backtick failed with exit code 200 stderr: "error: Backtick failed with exit code 200
| |
3 | echo {{ `exit 200`}} 2 | echo {{ `exit 200`}}
| ^^^^^^^^^^ | ^^^^^^^^^^
", ",
status: 200, status: 200,
@ -375,13 +375,13 @@ backtick-fail:
test! { test! {
name: backtick_code_interpolation_inner_tab, name: backtick_code_interpolation_inner_tab,
justfile: " justfile: "
backtick-fail: backtick-fail:
\techo {{\t`exit\t\t200`}} \techo {{\t`exit\t\t200`}}
", ",
stderr: " stderr: "
error: Backtick failed with exit code 200 error: Backtick failed with exit code 200
| |
3 | echo {{ `exit 200`}} 2 | echo {{ `exit 200`}}
| ^^^^^^^^^^^^^^^^^ | ^^^^^^^^^^^^^^^^^
", ",
status: 200, status: 200,
@ -390,13 +390,13 @@ backtick-fail:
test! { test! {
name: backtick_code_interpolation_leading_emoji, name: backtick_code_interpolation_leading_emoji,
justfile: " justfile: "
backtick-fail: backtick-fail:
\techo 😬{{`exit 200`}} \techo 😬{{`exit 200`}}
", ",
stderr: " stderr: "
error: Backtick failed with exit code 200 error: Backtick failed with exit code 200
| |
3 | echo 😬{{`exit 200`}} 2 | echo 😬{{`exit 200`}}
| ^^^^^^^^^^ | ^^^^^^^^^^
", ",
status: 200, status: 200,
@ -405,13 +405,13 @@ backtick-fail:
test! { test! {
name: backtick_code_interpolation_unicode_hell, name: backtick_code_interpolation_unicode_hell,
justfile: " justfile: "
backtick-fail: backtick-fail:
\techo \t\t\t😬{{\t\t`exit 200 # \t\t\tabc`}}\t\t\t😬 \techo \t\t\t😬{{\t\t`exit 200 # \t\t\tabc`}}\t\t\t😬
", ",
stderr: " stderr: "
error: Backtick failed with exit code 200 error: Backtick failed with exit code 200
| |
3 | echo 😬{{ `exit 200 # abc`}} 😬 2 | echo 😬{{ `exit 200 # abc`}} 😬
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
", ",
status: 200, status: 200,
@ -419,7 +419,18 @@ backtick-fail:
test! { test! {
name: backtick_code_long, 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: " stderr: "
error: Backtick failed with exit code 200 error: Backtick failed with exit code 200
| |
@ -580,6 +591,7 @@ test! {
??? ???
"#, "#,
stdout: "", stdout: "",
@ -727,7 +739,7 @@ recipe:
args: ("--color=always"), args: ("--color=always"),
stdout: "", stdout: "",
stderr: "\u{1b}[1;31merror\u{1b}[0m: \u{1b}[1m\ 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, status: 100,
} }
@ -1217,7 +1229,7 @@ infallable:
"#, "#,
stderr: r#"exit 101 stderr: r#"exit 101
exit 202 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, status: 202,
} }
@ -1248,29 +1260,29 @@ test! {
test! { test! {
name: shebang_line_numbers, name: shebang_line_numbers,
justfile: r#" justfile: r#"
quiet: quiet:
#!/usr/bin/env cat #!/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! { test! {
@ -1404,7 +1416,7 @@ test! {
args: ("foo"), args: ("foo"),
stdout: "", stdout: "",
stderr: "error: Expected comment, end of file, end of line, \ stderr: "error: Expected comment, end of file, end of line, \
identifier, or '(', but found raw string identifier, or '(', but found string
| |
1 | foo: 'bar' 1 | foo: 'bar'
| ^^^^^ | ^^^^^
@ -1417,7 +1429,7 @@ test! {
justfile: "foo 'bar'", justfile: "foo 'bar'",
args: ("foo"), args: ("foo"),
stdout: "", stdout: "",
stderr: "error: Expected '*', ':', '$', identifier, or '+', but found raw string stderr: "error: Expected '*', ':', '$', identifier, or '+', but found string
| |
1 | foo 'bar' 1 | foo 'bar'
| ^^^^^ | ^^^^^
@ -1575,7 +1587,7 @@ foo *a +b:
stdout: "", stdout: "",
stderr: "error: Expected \':\' or \'=\', but found \'+\' stderr: "error: Expected \':\' or \'=\', but found \'+\'
| |
2 | foo *a +b: 1 | foo *a +b:
| ^ | ^
", ",
status: EXIT_FAILURE, status: EXIT_FAILURE,
@ -1590,7 +1602,7 @@ foo +a *b:
stdout: "", stdout: "",
stderr: "error: Expected \':\' or \'=\', but found \'*\' stderr: "error: Expected \':\' or \'=\', but found \'*\'
| |
2 | foo +a *b: 1 | foo +a *b:
| ^ | ^
", ",
status: EXIT_FAILURE, status: EXIT_FAILURE,
@ -1623,7 +1635,7 @@ a: x y
stdout: "", stdout: "",
stderr: "error: Recipe `a` has unknown dependency `y` stderr: "error: Recipe `a` has unknown dependency `y`
| |
4 | a: x y 3 | a: x y
| ^ | ^
", ",
status: EXIT_FAILURE, status: EXIT_FAILURE,
@ -1775,7 +1787,7 @@ X := "\'"
stdout: "", stdout: "",
stderr: r#"error: `\'` is not a valid escape sequence stderr: r#"error: `\'` is not a valid escape sequence
| |
2 | X := "\'" 1 | X := "\'"
| ^^^^ | ^^^^
"#, "#,
status: EXIT_FAILURE, status: EXIT_FAILURE,
@ -1784,12 +1796,12 @@ X := "\'"
test! { test! {
name: unknown_variable_in_default, name: unknown_variable_in_default,
justfile: " justfile: "
foo x=bar: foo x=bar:
", ",
stdout: "", stdout: "",
stderr: r#"error: Variable `bar` not defined stderr: r#"error: Variable `bar` not defined
| |
2 | foo x=bar: 1 | foo x=bar:
| ^^^ | ^^^
"#, "#,
status: EXIT_FAILURE, status: EXIT_FAILURE,
@ -1803,7 +1815,7 @@ foo x=bar():
stdout: "", stdout: "",
stderr: r#"error: Call to unknown function `bar` stderr: r#"error: Call to unknown function `bar`
| |
2 | foo x=bar(): 1 | foo x=bar():
| ^^^ | ^^^
"#, "#,
status: EXIT_FAILURE, status: EXIT_FAILURE,
@ -1863,13 +1875,13 @@ foo a=arch() o=os() f=os_family():
test! { test! {
name: unterminated_interpolation_eol, name: unterminated_interpolation_eol,
justfile: " justfile: "
foo: foo:
echo {{ echo {{
", ",
stderr: r#" stderr: r#"
error: Unterminated interpolation error: Unterminated interpolation
| |
3 | echo {{ 2 | echo {{
| ^^ | ^^
"#, "#,
status: EXIT_FAILURE, status: EXIT_FAILURE,
@ -1878,12 +1890,13 @@ foo:
test! { test! {
name: unterminated_interpolation_eof, name: unterminated_interpolation_eof,
justfile: " justfile: "
foo: foo:
echo {{", echo {{
",
stderr: r#" stderr: r#"
error: Unterminated interpolation error: Unterminated interpolation
| |
3 | echo {{ 2 | echo {{
| ^^ | ^^
"#, "#,
status: EXIT_FAILURE, status: EXIT_FAILURE,
@ -1897,7 +1910,7 @@ assembly_source_files = %(wildcard src/arch/$(arch)/*.s)
stderr: r#" stderr: r#"
error: Unknown start of token: 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, status: EXIT_FAILURE,
@ -1930,19 +1943,17 @@ default stdin = `cat`:
test! { test! {
name: backtick_default_cat_justfile, name: backtick_default_cat_justfile,
justfile: " justfile: "
default stdin = `cat justfile`: default stdin = `cat justfile`:
echo '{{stdin}}' echo '{{stdin}}'
", ",
stdout: " stdout: "
default stdin = `cat justfile`: default stdin = `cat justfile`:
echo {{stdin}} echo {{stdin}}
set dotenv-load := true set dotenv-load := true
", ",
stderr: " stderr: "
echo ' echo 'default stdin = `cat justfile`:
default stdin = `cat justfile`:
echo '{{stdin}}' echo '{{stdin}}'
set dotenv-load := true' set dotenv-load := true'

View File

@ -65,6 +65,22 @@ whatever'
", ",
} }
test! {
name: cooked_string_suppress_newline,
justfile: r#"
a := """
foo\
bar
"""
@default:
printf %s '{{a}}'
"#,
stdout: "
foobar
",
}
test! { test! {
name: invalid_escape_sequence, name: invalid_escape_sequence,
justfile: r#"x := "\q" justfile: r#"x := "\q"
@ -93,7 +109,7 @@ a:
stdout: "", stdout: "",
stderr: "error: Variable `foo` not defined stderr: "error: Variable `foo` not defined
| |
7 | echo '{{foo}}' 6 | echo '{{foo}}'
| ^^^ | ^^^
", ",
status: EXIT_FAILURE, status: EXIT_FAILURE,
@ -113,7 +129,7 @@ a:
stdout: "", stdout: "",
stderr: "error: Variable `bar` not defined stderr: "error: Variable `bar` not defined
| |
4 | whatever' + bar 3 | whatever' + bar
| ^^^ | ^^^
", ",
status: EXIT_FAILURE, status: EXIT_FAILURE,
@ -150,7 +166,7 @@ a:
stdout: "", stdout: "",
stderr: "error: Variable `b` not defined stderr: "error: Variable `b` not defined
| |
6 | echo {{b}} 5 | echo {{b}}
| ^ | ^
", ",
status: EXIT_FAILURE, status: EXIT_FAILURE,
@ -159,43 +175,208 @@ a:
test! { test! {
name: unterminated_raw_string, name: unterminated_raw_string,
justfile: " justfile: "
a b= ': a b= ':
", ",
args: ("a"), args: ("a"),
stdout: "", stdout: "",
stderr: "error: Unterminated string stderr: "
| error: Unterminated string
2 | a b= ': |
| ^ 1 | a b= ':
", | ^
",
status: EXIT_FAILURE, status: EXIT_FAILURE,
} }
test! { test! {
name: unterminated_string, name: unterminated_string,
justfile: r#" justfile: r#"
a b= ": a b= ":
"#, "#,
args: ("a"), args: ("a"),
stdout: "", stdout: "",
stderr: r#"error: Unterminated string stderr: r#"
| error: Unterminated string
2 | a b= ": |
| ^ 1 | a b= ":
"#, | ^
"#,
status: EXIT_FAILURE, status: EXIT_FAILURE,
} }
test! { test! {
name: unterminated_backtick, name: unterminated_backtick,
justfile: " justfile: "
foo a=\t`echo blaaaaaah: foo a=\t`echo blaaaaaah:
echo {{a}}", echo {{a}}
",
stderr: r#" stderr: r#"
error: Unterminated backtick error: Unterminated backtick
| |
2 | foo a= `echo blaaaaaah: 1 | foo a= `echo blaaaaaah:
| ^ | ^
"#, "#,
status: EXIT_FAILURE, 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,
}