Add conditional expressions (#714)

Add conditional expressions of the form:

   foo := if lhs == rhs { then } else { otherwise }

`lhs`, `rhs`, `then`, and `otherwise` are all arbitrary expressions, and
can recursively include other conditionals. Conditionals short-circuit,
so the branch not taken isn't evaluated.

It is also possible to test for inequality with `==`.
This commit is contained in:
Casey Rodarmor 2020-10-26 18:16:42 -07:00 committed by GitHub
parent 3643a0dff0
commit 19f7ad09a7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 769 additions and 100 deletions

37
Cargo.lock generated
View File

@ -156,6 +156,15 @@ dependencies = [
"wasi",
]
[[package]]
name = "heck"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20564e78d53d2bb135c343b3f47714a56af2061f1c928fdb541dc7b9fdd94205"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "hermit-abi"
version = "0.1.17"
@ -192,6 +201,7 @@ dependencies = [
"log",
"pretty_assertions",
"snafu",
"strum",
"target",
"tempfile",
"test-utilities",
@ -390,6 +400,27 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a"
[[package]]
name = "strum"
version = "0.19.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b89a286a7e3b5720b9a477b23253bc50debac207c8d21505f8e70b36792f11b5"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.19.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e61bb0be289045cb80bfce000512e32d09f8337e54c186725da381377ad1f8d5"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "syn"
version = "1.0.44"
@ -475,6 +506,12 @@ dependencies = [
"lazy_static",
]
[[package]]
name = "unicode-segmentation"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e83e153d1053cbb5a118eeff7fd5be06ed99153f00dbcd8ae310c5fb2b22edc0"
[[package]]
name = "unicode-width"
version = "0.1.8"

View File

@ -30,6 +30,10 @@ unicode-width = "0.1.0"
version = "3.1.1"
features = ["termination"]
[dependencies.strum]
version = "0.19.0"
features = ["derive"]
[dev-dependencies]
executable-path = "1.0.0"
pretty_assertions = "0.6.0"

View File

@ -57,9 +57,13 @@ export : 'export' assignment
setting : 'set' 'shell' ':=' '[' string (',' string)* ','? ']'
expression : value '+' expression
expression : 'if' condition '{' expression '}' else '{' expression '}'
| value '+' expression
| value
condition : expression '==' expression
| expression '!=' expression
value : NAME '(' sequence? ')'
| STRING
| RAW_STRING

View File

@ -545,6 +545,54 @@ serve:
./serve {{localhost}} 8080
```
=== Conditional Expressions
`if`/`else` expressions evaluate different branches depending on if two expressions evaluate to the same value:
```make
foo := if "2" == "2" { "Good!" } else { "1984" }
bar:
@echo "{{foo}}"
```
```sh
$ just bar
Good!
```
It is also possible to test for inequality:
```make
foo := if "hello" != "goodbye" { "xyz" } else { "abc" }
bar:
@echo {{foo}}
```
```sh
$ just bar
abc
```
Conditional expressions short-circuit, which means they only evaluate one of
their branches. This can be used to make sure that backtick expressions don't
run when they shouldn't.
```make
foo := if env_var("RELEASE") == "true" { `get-something-from-release-database` } else { "dummy-value" }
```
Conditionals can be used inside of recipes:
```make
bar foo:
echo {{ if foo == "bar" { "hello" } else { "goodbye" } }}
```
Note the space after the final `}`! Without the space, the interpolation will
be prematurely closed.
=== Setting Variables from the Command Line
Variables can be overridden from the command line.

View File

@ -121,7 +121,7 @@ sloc:
@lint:
echo Checking for FIXME/TODO...
! grep --color -En 'FIXME|TODO' src/*.rs
! grep --color -Ein 'fixme|todo|xxx|#\[ignore\]' src/*.rs
echo Checking for long lines...
! grep --color -En '.{101}' src/*.rs

View File

@ -87,6 +87,18 @@ impl<'src: 'run, 'run> AssignmentResolver<'src, 'run> {
self.resolve_expression(lhs)?;
self.resolve_expression(rhs)
},
Expression::Conditional {
lhs,
rhs,
then,
otherwise,
..
} => {
self.resolve_expression(lhs)?;
self.resolve_expression(rhs)?;
self.resolve_expression(then)?;
self.resolve_expression(otherwise)
},
Expression::StringLiteral { .. } | Expression::Backtick { .. } => Ok(()),
Expression::Group { contents } => self.resolve_expression(contents),
}

View File

@ -24,10 +24,11 @@ pub(crate) use edit_distance::edit_distance;
pub(crate) use libc::EXIT_FAILURE;
pub(crate) use log::{info, warn};
pub(crate) use snafu::{ResultExt, Snafu};
pub(crate) use strum::{Display, EnumString, IntoStaticStr};
pub(crate) use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
// modules
pub(crate) use crate::{config_error, keyword, setting};
pub(crate) use crate::{config_error, setting};
// functions
pub(crate) use crate::{default::default, empty::empty, load_dotenv::load_dotenv, output::output};
@ -47,12 +48,13 @@ pub(crate) use crate::{
dependency::Dependency, enclosure::Enclosure, evaluator::Evaluator, expression::Expression,
fragment::Fragment, function::Function, function_context::FunctionContext,
interrupt_guard::InterruptGuard, interrupt_handler::InterruptHandler, item::Item,
justfile::Justfile, lexer::Lexer, line::Line, list::List, load_error::LoadError, module::Module,
name::Name, output_error::OutputError, parameter::Parameter, parameter_kind::ParameterKind,
parser::Parser, platform::Platform, position::Position, positional::Positional, recipe::Recipe,
recipe_context::RecipeContext, recipe_resolver::RecipeResolver, runtime_error::RuntimeError,
scope::Scope, search::Search, search_config::SearchConfig, search_error::SearchError, set::Set,
setting::Setting, settings::Settings, shebang::Shebang, show_whitespace::ShowWhitespace,
justfile::Justfile, keyword::Keyword, lexer::Lexer, line::Line, list::List,
load_error::LoadError, module::Module, name::Name, output_error::OutputError,
parameter::Parameter, parameter_kind::ParameterKind, parser::Parser, platform::Platform,
position::Position, positional::Positional, recipe::Recipe, recipe_context::RecipeContext,
recipe_resolver::RecipeResolver, runtime_error::RuntimeError, scope::Scope, search::Search,
search_config::SearchConfig, search_error::SearchError, set::Set, setting::Setting,
settings::Settings, shebang::Shebang, show_whitespace::ShowWhitespace,
string_literal::StringLiteral, subcommand::Subcommand, suggestion::Suggestion, table::Table,
thunk::Thunk, token::Token, token_kind::TokenKind, unresolved_dependency::UnresolvedDependency,
unresolved_recipe::UnresolvedRecipe, use_color::UseColor, variables::Variables,

View File

@ -128,6 +128,11 @@ impl Display for CompilationError<'_> {
writeln!(f, "at most {} {}", max, Count("argument", max))?;
}
},
ExpectedKeyword { expected, found } => writeln!(
f,
"Expected keyword `{}` but found identifier `{}`",
expected, found
)?,
ParameterShadowsVariable { parameter } => {
writeln!(
f,
@ -198,6 +203,9 @@ impl Display for CompilationError<'_> {
UnknownSetting { setting } => {
writeln!(f, "Unknown setting `{}`", setting)?;
},
UnexpectedCharacter { expected } => {
writeln!(f, "Expected character `{}`", expected)?;
},
UnknownStartOfToken => {
writeln!(f, "Unknown start of token:")?;
},

View File

@ -39,6 +39,10 @@ pub(crate) enum CompilationErrorKind<'src> {
setting: &'src str,
first: usize,
},
ExpectedKeyword {
expected: Keyword,
found: &'src str,
},
ExtraLeadingWhitespace,
FunctionArgumentCountMismatch {
function: &'src str,
@ -86,6 +90,9 @@ pub(crate) enum CompilationErrorKind<'src> {
function: &'src str,
},
UnknownStartOfToken,
UnexpectedCharacter {
expected: char,
},
UnknownSetting {
setting: &'src str,
},

View File

@ -116,6 +116,22 @@ impl<'src, 'run> Evaluator<'src, 'run> {
},
Expression::Concatination { lhs, rhs } =>
Ok(self.evaluate_expression(lhs)? + &self.evaluate_expression(rhs)?),
Expression::Conditional {
lhs,
rhs,
then,
otherwise,
inverted,
} => {
let lhs = self.evaluate_expression(lhs)?;
let rhs = self.evaluate_expression(rhs)?;
let condition = if *inverted { lhs != rhs } else { lhs == rhs };
if condition {
self.evaluate_expression(then)
} else {
self.evaluate_expression(otherwise)
}
},
Expression::Group { contents } => self.evaluate_expression(contents),
}
}

View File

@ -20,6 +20,14 @@ pub(crate) enum Expression<'src> {
lhs: Box<Expression<'src>>,
rhs: Box<Expression<'src>>,
},
/// `if lhs == rhs { then } else { otherwise }`
Conditional {
lhs: Box<Expression<'src>>,
rhs: Box<Expression<'src>>,
then: Box<Expression<'src>>,
otherwise: Box<Expression<'src>>,
inverted: bool,
},
/// `(contents)`
Group { contents: Box<Expression<'src>> },
/// `"string_literal"` or `'string_literal'`
@ -39,6 +47,21 @@ impl<'src> Display for Expression<'src> {
match self {
Expression::Backtick { contents, .. } => write!(f, "`{}`", contents),
Expression::Concatination { lhs, rhs } => write!(f, "{} + {}", lhs, rhs),
Expression::Conditional {
lhs,
rhs,
then,
otherwise,
inverted,
} => write!(
f,
"if {} {} {} {{ {} }} else {{ {} }} ",
lhs,
if *inverted { "!=" } else { "==" },
rhs,
then,
otherwise
),
Expression::StringLiteral { string_literal } => write!(f, "{}", string_literal),
Expression::Variable { name } => write!(f, "{}", name.lexeme()),
Expression::Call { thunk } => write!(f, "{}", thunk),

View File

@ -1,5 +1,28 @@
pub(crate) const ALIAS: &str = "alias";
pub(crate) const EXPORT: &str = "export";
pub(crate) const SET: &str = "set";
use crate::common::*;
pub(crate) const SHELL: &str = "shell";
#[derive(Debug, Eq, PartialEq, IntoStaticStr, Display, Copy, Clone, EnumString)]
#[strum(serialize_all = "kebab_case")]
pub(crate) enum Keyword {
Alias,
Else,
Export,
If,
Set,
Shell,
}
impl Keyword {
pub(crate) fn from_lexeme(lexeme: &str) -> Option<Keyword> {
lexeme.parse().ok()
}
pub(crate) fn lexeme(self) -> &'static str {
self.into()
}
}
impl<'a> PartialEq<&'a str> for Keyword {
fn eq(&self, other: &&'a str) -> bool {
self.lexeme() == *other
}
}

View File

@ -98,6 +98,25 @@ impl<'src> Lexer<'src> {
self.token_end.offset - self.token_start.offset
}
fn accepted(&mut self, c: char) -> CompilationResult<'src, bool> {
if self.next_is(c) {
self.advance()?;
Ok(true)
} else {
Ok(false)
}
}
fn presume(&mut self, c: char) -> CompilationResult<'src, ()> {
if !self.next_is(c) {
return Err(self.internal_error(format!("Lexer presumed character `{}`", c)));
}
self.advance()?;
Ok(())
}
/// Is next character c?
fn next_is(&self, c: char) -> bool {
self.next == Some(c)
@ -430,17 +449,18 @@ impl<'src> Lexer<'src> {
/// Lex token beginning with `start` outside of a recipe body
fn lex_normal(&mut self, start: char) -> CompilationResult<'src, ()> {
match start {
'!' => self.lex_bang(),
'*' => self.lex_single(Asterisk),
'@' => self.lex_single(At),
'[' => self.lex_single(BracketL),
']' => self.lex_single(BracketR),
'=' => self.lex_single(Equals),
'=' => self.lex_choice('=', EqualsEquals, Equals),
',' => self.lex_single(Comma),
':' => self.lex_colon(),
'(' => self.lex_single(ParenL),
')' => self.lex_single(ParenR),
'{' => self.lex_brace_l(),
'}' => self.lex_brace_r(),
'{' => self.lex_single(BraceL),
'}' => self.lex_single(BraceR),
'+' => self.lex_single(Plus),
'\n' => self.lex_single(Eol),
'\r' => self.lex_cr_lf(),
@ -449,10 +469,8 @@ impl<'src> Lexer<'src> {
' ' | '\t' => self.lex_whitespace(),
'\'' => self.lex_raw_string(),
'"' => self.lex_cooked_string(),
_ =>
if Self::is_identifier_start(start) {
self.lex_identifier()
} else {
_ if Self::is_identifier_start(start) => self.lex_identifier(),
_ => {
self.advance()?;
Err(self.error(UnknownStartOfToken))
},
@ -465,7 +483,6 @@ impl<'src> Lexer<'src> {
interpolation_start: Token<'src>,
start: char,
) -> CompilationResult<'src, ()> {
// Check for end of interpolation
if self.rest_starts_with("}}") {
// end current interpolation
self.interpolation_start = None;
@ -537,14 +554,14 @@ impl<'src> Lexer<'src> {
self.recipe_body = false;
}
/// Lex a single character token
/// Lex a single-character token
fn lex_single(&mut self, kind: TokenKind) -> CompilationResult<'src, ()> {
self.advance()?;
self.token(kind);
Ok(())
}
/// Lex a double character token
/// Lex a double-character token
fn lex_double(&mut self, kind: TokenKind) -> CompilationResult<'src, ()> {
self.advance()?;
self.advance()?;
@ -552,12 +569,48 @@ impl<'src> Lexer<'src> {
Ok(())
}
/// Lex a token starting with ':'
fn lex_colon(&mut self) -> CompilationResult<'src, ()> {
/// Lex a double-character token of kind `then` if the second character of
/// that token would be `second`, otherwise lex a single-character token of
/// kind `otherwise`
fn lex_choice(
&mut self,
second: char,
then: TokenKind,
otherwise: TokenKind,
) -> CompilationResult<'src, ()> {
self.advance()?;
if self.next_is('=') {
if self.accepted(second)? {
self.token(then);
} else {
self.token(otherwise);
}
Ok(())
}
/// Lex a token starting with '!'
fn lex_bang(&mut self) -> CompilationResult<'src, ()> {
self.presume('!')?;
if self.accepted('=')? {
self.token(BangEquals);
Ok(())
} else {
// Emit an unspecified token to consume the current character,
self.token(Unspecified);
// …and advance past another character,
self.advance()?;
// …so that the error we produce highlights the unexpected character.
Err(self.error(UnexpectedCharacter { expected: '=' }))
}
}
/// Lex a token starting with ':'
fn lex_colon(&mut self) -> CompilationResult<'src, ()> {
self.presume(':')?;
if self.accepted('=')? {
self.token(ColonEquals);
} else {
self.token(Colon);
@ -567,43 +620,21 @@ impl<'src> Lexer<'src> {
Ok(())
}
/// Lex a token starting with '{'
fn lex_brace_l(&mut self) -> CompilationResult<'src, ()> {
if !self.rest_starts_with("{{") {
self.advance()?;
return Err(self.error(UnknownStartOfToken));
}
self.lex_double(InterpolationStart)
}
/// Lex a token starting with '}'
fn lex_brace_r(&mut self) -> CompilationResult<'src, ()> {
if !self.rest_starts_with("}}") {
self.advance()?;
return Err(self.error(UnknownStartOfToken));
}
self.lex_double(InterpolationEnd)
}
/// Lex a carriage return and line feed
fn lex_cr_lf(&mut self) -> CompilationResult<'src, ()> {
if !self.rest_starts_with("\r\n") {
// advance over \r
self.advance()?;
self.presume('\r')?;
if !self.accepted('\n')? {
return Err(self.error(UnpairedCarriageReturn));
}
self.lex_double(Eol)
self.token(Eol);
Ok(())
}
/// Lex name: [a-zA-Z_][a-zA-Z0-9_]*
fn lex_identifier(&mut self) -> CompilationResult<'src, ()> {
// advance over initial character
self.advance()?;
while let Some(c) = self.next {
@ -621,8 +652,7 @@ impl<'src> Lexer<'src> {
/// Lex comment: #[^\r\n]
fn lex_comment(&mut self) -> CompilationResult<'src, ()> {
// advance over #
self.advance()?;
self.presume('#')?;
while !self.at_eol_or_eof() {
self.advance()?;
@ -665,8 +695,7 @@ impl<'src> Lexer<'src> {
/// Lex raw string: '[^']*'
fn lex_raw_string(&mut self) -> CompilationResult<'src, ()> {
// advance over opening '
self.advance()?;
self.presume('\'')?;
loop {
match self.next {
@ -678,8 +707,7 @@ impl<'src> Lexer<'src> {
self.advance()?;
}
// advance over closing '
self.advance()?;
self.presume('\'')?;
self.token(StringRaw);
@ -688,8 +716,7 @@ impl<'src> Lexer<'src> {
/// Lex cooked string: "[^"\n\r]*" (also processes escape sequences)
fn lex_cooked_string(&mut self) -> CompilationResult<'src, ()> {
// advance over opening "
self.advance()?;
self.presume('"')?;
let mut escape = false;
@ -803,6 +830,9 @@ mod tests {
// Fixed lexemes
Asterisk => "*",
At => "@",
BangEquals => "!=",
BraceL => "{",
BraceR => "}",
BracketL => "[",
BracketR => "]",
Colon => ":",
@ -810,6 +840,7 @@ mod tests {
Comma => ",",
Eol => "\n",
Equals => "=",
EqualsEquals => "==",
Indent => " ",
InterpolationEnd => "}}",
InterpolationStart => "{{",
@ -901,6 +932,48 @@ mod tests {
tokens: (StringCooked:"\"hello\""),
}
test! {
name: equals,
text: "=",
tokens: (Equals),
}
test! {
name: equals_equals,
text: "==",
tokens: (EqualsEquals),
}
test! {
name: bang_equals,
text: "!=",
tokens: (BangEquals),
}
test! {
name: brace_l,
text: "{",
tokens: (BraceL),
}
test! {
name: brace_r,
text: "}",
tokens: (BraceR),
}
test! {
name: brace_lll,
text: "{{{",
tokens: (BraceL, BraceL, BraceL),
}
test! {
name: brace_rrr,
text: "}}}",
tokens: (BraceR, BraceR, BraceR),
}
test! {
name: export_concatination,
text: "export foo = 'foo' + 'bar'",
@ -1965,4 +2038,47 @@ mod tests {
width: 2,
kind: UnterminatedInterpolation,
}
error! {
name: unexpected_character_after_bang,
input: "!{",
offset: 1,
line: 0,
column: 1,
width: 1,
kind: UnexpectedCharacter { expected: '=' },
}
#[test]
fn presume_error() {
assert_matches!(
Lexer::new("!").presume('-').unwrap_err(),
CompilationError {
token: Token {
offset: 0,
line: 0,
column: 0,
length: 0,
src: "!",
kind: Unspecified,
},
kind: Internal {
message,
},
} if message == "Lexer presumed character `-`"
);
assert_eq!(
Lexer::new("!").presume('-').unwrap_err().to_string(),
testing::unindent(
"
Internal error, this may indicate a bug in just: Lexer presumed character `-`
\
consider filing an issue: https://github.com/casey/just/issues/new
|
1 | !
| ^"
),
);
}
}

View File

@ -28,7 +28,7 @@ impl<'src> Node<'src> for Item<'src> {
impl<'src> Node<'src> for Alias<'src, Name<'src>> {
fn tree(&self) -> Tree<'src> {
Tree::atom(keyword::ALIAS)
Tree::atom(Keyword::Alias.lexeme())
.push(self.name.lexeme())
.push(self.target.lexeme())
}
@ -37,7 +37,9 @@ impl<'src> Node<'src> for Alias<'src, Name<'src>> {
impl<'src> Node<'src> for Assignment<'src> {
fn tree(&self) -> Tree<'src> {
if self.export {
Tree::atom("assignment").push("#").push(keyword::EXPORT)
Tree::atom("assignment")
.push("#")
.push(Keyword::Export.lexeme())
} else {
Tree::atom("assignment")
}
@ -50,6 +52,25 @@ impl<'src> Node<'src> for Expression<'src> {
fn tree(&self) -> Tree<'src> {
match self {
Expression::Concatination { lhs, rhs } => Tree::atom("+").push(lhs.tree()).push(rhs.tree()),
Expression::Conditional {
lhs,
rhs,
then,
otherwise,
inverted,
} => {
let mut tree = Tree::atom(Keyword::If.lexeme());
tree.push_mut(lhs.tree());
if *inverted {
tree.push_mut("!=")
} else {
tree.push_mut("==")
}
tree.push_mut(rhs.tree());
tree.push_mut(then.tree());
tree.push_mut(otherwise.tree());
tree
},
Expression::Call { thunk } => {
let mut tree = Tree::atom("call");
@ -164,7 +185,7 @@ impl<'src> Node<'src> for Fragment<'src> {
impl<'src> Node<'src> for Set<'src> {
fn tree(&self) -> Tree<'src> {
let mut set = Tree::atom(keyword::SET);
let mut set = Tree::atom(Keyword::Set.lexeme());
set.push_mut(self.name.lexeme());

View File

@ -172,9 +172,20 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
self.expect(Eol).map(|_| ())
}
fn expect_keyword(&mut self, expected: Keyword) -> CompilationResult<'src, ()> {
let identifier = self.expect(Identifier)?;
let found = identifier.lexeme();
if expected == found {
Ok(())
} else {
Err(identifier.error(CompilationErrorKind::ExpectedKeyword { expected, found }))
}
}
/// Return an internal error if the next token is not of kind `Identifier`
/// with lexeme `lexeme`.
fn presume_name(&mut self, lexeme: &str) -> CompilationResult<'src, ()> {
fn presume_keyword(&mut self, keyword: Keyword) -> CompilationResult<'src, ()> {
let next = self.advance()?;
if next.kind != Identifier {
@ -182,10 +193,10 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
"Presumed next token would have kind {}, but found {}",
Identifier, next.kind
))?)
} else if next.lexeme() != lexeme {
} else if keyword != next.lexeme() {
Err(self.internal_error(format!(
"Presumed next token would have lexeme \"{}\", but found \"{}\"",
lexeme,
keyword,
next.lexeme(),
))?)
} else {
@ -253,6 +264,17 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
}
}
fn accepted_keyword(&mut self, keyword: Keyword) -> CompilationResult<'src, bool> {
let next = self.next()?;
if next.kind == Identifier && next.lexeme() == keyword.lexeme() {
self.advance()?;
Ok(true)
} else {
Ok(false)
}
}
/// Accept a dependency
fn accept_dependency(&mut self) -> CompilationResult<'src, Option<UnresolvedDependency<'src>>> {
if let Some(recipe) = self.accept_name()? {
@ -297,8 +319,8 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
} else if self.accepted(Eof)? {
break;
} else if self.next_is(Identifier) {
match next.lexeme() {
keyword::ALIAS =>
match Keyword::from_lexeme(next.lexeme()) {
Some(Keyword::Alias) =>
if self.next_are(&[Identifier, Identifier, Equals]) {
warnings.push(Warning::DeprecatedEquals {
equals: self.get(2)?,
@ -309,20 +331,20 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
} else {
items.push(Item::Recipe(self.parse_recipe(doc, false)?));
},
keyword::EXPORT =>
Some(Keyword::Export) =>
if self.next_are(&[Identifier, Identifier, Equals]) {
warnings.push(Warning::DeprecatedEquals {
equals: self.get(2)?,
});
self.presume_name(keyword::EXPORT)?;
self.presume_keyword(Keyword::Export)?;
items.push(Item::Assignment(self.parse_assignment(true)?));
} else if self.next_are(&[Identifier, Identifier, ColonEquals]) {
self.presume_name(keyword::EXPORT)?;
self.presume_keyword(Keyword::Export)?;
items.push(Item::Assignment(self.parse_assignment(true)?));
} else {
items.push(Item::Recipe(self.parse_recipe(doc, false)?));
},
keyword::SET =>
Some(Keyword::Set) =>
if self.next_are(&[Identifier, Identifier, ColonEquals]) {
items.push(Item::Set(self.parse_set()?));
} else {
@ -363,7 +385,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
/// Parse an alias, e.g `alias name := target`
fn parse_alias(&mut self) -> CompilationResult<'src, Alias<'src, Name<'src>>> {
self.presume_name(keyword::ALIAS)?;
self.presume_keyword(Keyword::Alias)?;
let name = self.parse_name()?;
self.presume_any(&[Equals, ColonEquals])?;
let target = self.parse_name()?;
@ -386,6 +408,40 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
/// Parse an expression, e.g. `1 + 2`
fn parse_expression(&mut self) -> CompilationResult<'src, Expression<'src>> {
if self.accepted_keyword(Keyword::If)? {
let lhs = self.parse_expression()?;
let inverted = self.accepted(BangEquals)?;
if !inverted {
self.expect(EqualsEquals)?;
}
let rhs = self.parse_expression()?;
self.expect(BraceL)?;
let then = self.parse_expression()?;
self.expect(BraceR)?;
self.expect_keyword(Keyword::Else)?;
self.expect(BraceL)?;
let otherwise = self.parse_expression()?;
self.expect(BraceR)?;
return Ok(Expression::Conditional {
lhs: Box::new(lhs),
rhs: Box::new(rhs),
then: Box::new(then),
otherwise: Box::new(otherwise),
inverted,
});
}
let value = self.parse_value()?;
if self.accepted(Plus)? {
@ -619,11 +675,10 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
/// Parse a setting
fn parse_set(&mut self) -> CompilationResult<'src, Set<'src>> {
self.presume_name(keyword::SET)?;
self.presume_keyword(Keyword::Set)?;
let name = Name::from_identifier(self.presume(Identifier)?);
self.presume(ColonEquals)?;
match name.lexeme() {
keyword::SHELL => {
if name.lexeme() == Keyword::Shell.lexeme() {
self.expect(BracketL)?;
let command = self.parse_string_literal()?;
@ -646,10 +701,10 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
value: Setting::Shell(setting::Shell { command, arguments }),
name,
})
},
_ => Err(name.error(CompilationErrorKind::UnknownSetting {
} else {
Err(name.error(CompilationErrorKind::UnknownSetting {
setting: name.lexeme(),
})),
}))
}
}
}
@ -1509,6 +1564,48 @@ mod tests {
tree: (justfile (set shell "bash" "-cu" "-l")),
}
test! {
name: conditional,
text: "a := if b == c { d } else { e }",
tree: (justfile (assignment a (if b == c d e))),
}
test! {
name: conditional_inverted,
text: "a := if b != c { d } else { e }",
tree: (justfile (assignment a (if b != c d e))),
}
test! {
name: conditional_concatinations,
text: "a := if b0 + b1 == c0 + c1 { d0 + d1 } else { e0 + e1 }",
tree: (justfile (assignment a (if (+ b0 b1) == (+ c0 c1) (+ d0 d1) (+ e0 e1)))),
}
test! {
name: conditional_nested_lhs,
text: "a := if if b == c { d } else { e } == c { d } else { e }",
tree: (justfile (assignment a (if (if b == c d e) == c d e))),
}
test! {
name: conditional_nested_rhs,
text: "a := if c == if b == c { d } else { e } { d } else { e }",
tree: (justfile (assignment a (if c == (if b == c d e) d e))),
}
test! {
name: conditional_nested_then,
text: "a := if b == c { if b == c { d } else { e } } else { e }",
tree: (justfile (assignment a (if b == c (if b == c d e) e))),
}
test! {
name: conditional_nested_otherwise,
text: "a := if b == c { d } else { if b == c { d } else { e } }",
tree: (justfile (assignment a (if b == c d (if b == c d e)))),
}
error! {
name: alias_syntax_multiple_rhs,
input: "alias foo = bar baz",
@ -1576,15 +1673,15 @@ mod tests {
}
error! {
name: interpolation_outside_of_recipe,
name: unexpected_brace,
input: "{{",
offset: 0,
line: 0,
column: 0,
width: 2,
width: 1,
kind: UnexpectedToken {
expected: vec![At, Comment, Eof, Eol, Identifier],
found: InterpolationStart,
found: BraceL,
},
}

View File

@ -193,6 +193,13 @@ pub enum Expression {
lhs: Box<Expression>,
rhs: Box<Expression>,
},
Conditional {
lhs: Box<Expression>,
rhs: Box<Expression>,
then: Box<Expression>,
otherwise: Box<Expression>,
inverted: bool,
},
String {
text: String,
},
@ -228,6 +235,19 @@ impl Expression {
lhs: Box::new(Expression::new(lhs)),
rhs: Box::new(Expression::new(rhs)),
},
Conditional {
lhs,
rhs,
inverted,
then,
otherwise,
} => Expression::Conditional {
lhs: Box::new(Expression::new(lhs)),
rhs: Box::new(Expression::new(rhs)),
then: Box::new(Expression::new(lhs)),
otherwise: Box::new(Expression::new(rhs)),
inverted: *inverted,
},
StringLiteral { string_literal } => Expression::String {
text: string_literal.cooked.to_string(),
},

View File

@ -113,3 +113,16 @@ macro_rules! run_error {
}
};
}
macro_rules! assert_matches {
($expression:expr, $( $pattern:pat )|+ $( if $guard:expr )?) => {
match $expression {
$( $pattern )|+ $( if $guard )? => {}
left => panic!(
"assertion failed: (left ~= right)\n left: `{:?}`\n right: `{}`",
left,
stringify!($($pattern)|+ $(if $guard)?)
),
}
}
}

View File

@ -5,6 +5,9 @@ pub(crate) enum TokenKind {
Asterisk,
At,
Backtick,
BangEquals,
BraceL,
BraceR,
BracketL,
BracketR,
Colon,
@ -15,6 +18,7 @@ pub(crate) enum TokenKind {
Eof,
Eol,
Equals,
EqualsEquals,
Identifier,
Indent,
InterpolationEnd,
@ -36,6 +40,9 @@ impl Display for TokenKind {
Asterisk => "'*'",
At => "'@'",
Backtick => "backtick",
BangEquals => "'!='",
BraceL => "'{'",
BraceR => "'}'",
BracketL => "'['",
BracketR => "']'",
Colon => "':'",
@ -46,6 +53,7 @@ impl Display for TokenKind {
Eof => "end of file",
Eol => "end of line",
Equals => "'='",
EqualsEquals => "'=='",
Identifier => "identifier",
Indent => "indent",
InterpolationEnd => "'}}'",

View File

@ -41,6 +41,18 @@ macro_rules! tree {
} => {
$crate::tree::Tree::atom("*")
};
{
==
} => {
$crate::tree::Tree::atom("==")
};
{
!=
} => {
$crate::tree::Tree::atom("!=")
};
}
/// A `Tree` is either…

View File

@ -19,6 +19,19 @@ impl<'expression, 'src> Iterator for Variables<'expression, 'src> {
| Some(Expression::StringLiteral { .. })
| Some(Expression::Backtick { .. })
| Some(Expression::Call { .. }) => None,
Some(Expression::Conditional {
lhs,
rhs,
then,
otherwise,
..
}) => {
self.stack.push(lhs);
self.stack.push(rhs);
self.stack.push(then);
self.stack.push(otherwise);
self.next()
},
Some(Expression::Variable { name, .. }) => Some(name.token()),
Some(Expression::Concatination { lhs, rhs }) => {
self.stack.push(lhs);

158
tests/conditional.rs Normal file
View File

@ -0,0 +1,158 @@
use crate::common::*;
test! {
name: then_branch_unevaluated,
justfile: "
foo:
echo {{ if 'a' == 'b' { `exit 1` } else { 'otherwise' } }}
",
stdout: "otherwise\n",
stderr: "echo otherwise\n",
}
test! {
name: otherwise_branch_unevaluated,
justfile: "
foo:
echo {{ if 'a' == 'a' { 'then' } else { `exit 1` } }}
",
stdout: "then\n",
stderr: "echo then\n",
}
test! {
name: otherwise_branch_unevaluated_inverted,
justfile: "
foo:
echo {{ if 'a' != 'b' { 'then' } else { `exit 1` } }}
",
stdout: "then\n",
stderr: "echo then\n",
}
test! {
name: then_branch_unevaluated_inverted,
justfile: "
foo:
echo {{ if 'a' != 'a' { `exit 1` } else { 'otherwise' } }}
",
stdout: "otherwise\n",
stderr: "echo otherwise\n",
}
test! {
name: complex_expressions,
justfile: "
foo:
echo {{ if 'a' + 'b' == `echo ab` { 'c' + 'd' } else { 'e' + 'f' } }}
",
stdout: "cd\n",
stderr: "echo cd\n",
}
test! {
name: undefined_lhs,
justfile: "
a := if b == '' { '' } else { '' }
foo:
echo {{ a }}
",
stdout: "",
stderr: "
error: Variable `b` not defined
|
1 | a := if b == '' { '' } else { '' }
| ^
",
status: EXIT_FAILURE,
}
test! {
name: undefined_rhs,
justfile: "
a := if '' == b { '' } else { '' }
foo:
echo {{ a }}
",
stdout: "",
stderr: "
error: Variable `b` not defined
|
1 | a := if '' == b { '' } else { '' }
| ^
",
status: EXIT_FAILURE,
}
test! {
name: undefined_then,
justfile: "
a := if '' == '' { b } else { '' }
foo:
echo {{ a }}
",
stdout: "",
stderr: "
error: Variable `b` not defined
|
1 | a := if '' == '' { b } else { '' }
| ^
",
status: EXIT_FAILURE,
}
test! {
name: undefined_otherwise,
justfile: "
a := if '' == '' { '' } else { b }
foo:
echo {{ a }}
",
stdout: "",
stderr: "
error: Variable `b` not defined
|
1 | a := if '' == '' { '' } else { b }
| ^
",
status: EXIT_FAILURE,
}
test! {
name: unexpected_op,
justfile: "
a := if '' a '' { '' } else { b }
foo:
echo {{ a }}
",
stdout: "",
stderr: "
error: Expected '!=', '==', or '+', but found identifier
|
1 | a := if '' a '' { '' } else { b }
| ^
",
status: EXIT_FAILURE,
}
test! {
name: dump,
justfile: "
a := if '' == '' { '' } else { '' }
foo:
echo {{ a }}
",
args: ("--dump"),
stdout: format!("
a := if '' == '' {{ '' }} else {{ '' }}{}
foo:
echo {{{{a}}}}
", " ").as_str(),
}

25
tests/error_messages.rs Normal file
View File

@ -0,0 +1,25 @@
use crate::common::*;
test! {
name: expected_keyword,
justfile: "foo := if '' == '' { '' } arlo { '' }",
stderr: "
error: Expected keyword `else` but found identifier `arlo`
|
1 | foo := if '' == '' { '' } arlo { '' }
| ^^^^
",
status: EXIT_FAILURE,
}
test! {
name: unexpected_character,
justfile: "!~",
stderr: "
error: Expected character `=`
|
1 | !~
| ^
",
status: EXIT_FAILURE,
}

View File

@ -5,8 +5,10 @@ mod common;
mod choose;
mod completions;
mod conditional;
mod dotenv;
mod edit;
mod error_messages;
mod examples;
mod init;
mod interrupts;