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:
parent
da97f8d7dd
commit
fec979c2c6
@ -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 ','?
|
||||
|
36
README.adoc
36
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 <<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:
|
||||
|
@ -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::{
|
||||
|
@ -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 } => {
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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))
|
||||
|
@ -10,7 +10,7 @@ use crate::common::*;
|
||||
pub(crate) enum Expression<'src> {
|
||||
/// `contents`
|
||||
Backtick {
|
||||
contents: &'src str,
|
||||
contents: String,
|
||||
token: Token<'src>,
|
||||
},
|
||||
/// `name(arguments)`
|
||||
|
147
src/lexer.rs
147
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,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 `-`
|
||||
\
|
||||
|
@ -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;
|
||||
|
181
src/parser.rs
181
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,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,
|
||||
},
|
||||
}
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -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
134
src/unindent.rs
Normal 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"), "");
|
||||
}
|
||||
}
|
@ -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::*;
|
||||
|
@ -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;
|
||||
|
119
tests/misc.rs
119
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,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'
|
||||
|
213
tests/string.rs
213
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
|
||||
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,
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user