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

@ -10,6 +10,7 @@ tokens
```
BACKTICK = `[^`]*`
INDENTED_BACKTICK = ```[^(```)]*```
COMMENT = #([^!].*)?$
DEDENT = emitted when indentation decreases
EOF = emitted at the end of the file
@ -18,7 +19,9 @@ 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
```
@ -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 ','?

View File

@ -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 <<Strings>> 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:

View File

@ -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::{

View File

@ -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 } => {

View File

@ -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,
}

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

View File

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

View File

@ -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,10 +811,6 @@ 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,
@ -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,16 +1118,16 @@ 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`",
),
}
@ -1092,12 +1135,14 @@ mod tests {
name: eol_linefeed,
text: "\n",
tokens: (Eol),
unindent: false,
}
test! {
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 `-`
\

View File

@ -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;

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.
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,28 +486,31 @@ 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 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 raw.chars() {
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(
@ -522,15 +525,12 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
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,
},
}

View File

@ -53,6 +53,8 @@ impl PlatformInterface for Platform {
command: &str,
argument: Option<&str>,
) -> Result<Command, OutputError> {
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.

View File

@ -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> {
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)]
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()
)
}
}

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 {
(
@ -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,

View File

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

View File

@ -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

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);
}
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::*;

View File

@ -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;

View File

@ -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,7 +1260,7 @@ test! {
test! {
name: shebang_line_numbers,
justfile: r#"
quiet:
quiet:
#!/usr/bin/env cat
a
@ -1259,18 +1271,18 @@ quiet:
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:
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`:
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'

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! {
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
stderr: "
error: Unterminated string
|
2 | a b= ':
1 | a b= ':
| ^
",
",
status: EXIT_FAILURE,
}
test! {
name: unterminated_string,
justfile: r#"
a b= ":
"#,
a b= ":
"#,
args: ("a"),
stdout: "",
stderr: r#"error: Unterminated string
stderr: r#"
error: Unterminated string
|
2 | a b= ":
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,
}