From 19f7ad09a7d69b4bee758aef9d6628d9dd7c0f7a Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Mon, 26 Oct 2020 18:16:42 -0700 Subject: [PATCH] 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 `==`. --- Cargo.lock | 37 ++++++ Cargo.toml | 4 + GRAMMAR.md | 6 +- README.adoc | 48 ++++++++ justfile | 2 +- src/assignment_resolver.rs | 12 ++ src/common.rs | 16 +-- src/compilation_error.rs | 8 ++ src/compilation_error_kind.rs | 7 ++ src/evaluator.rs | 16 +++ src/expression.rs | 23 ++++ src/keyword.rs | 31 ++++- src/lexer.rs | 218 ++++++++++++++++++++++++++-------- src/node.rs | 27 ++++- src/parser.rs | 163 ++++++++++++++++++++----- src/summary.rs | 20 ++++ src/testing.rs | 13 ++ src/token_kind.rs | 8 ++ src/tree.rs | 12 ++ src/variables.rs | 13 ++ tests/conditional.rs | 158 ++++++++++++++++++++++++ tests/error_messages.rs | 25 ++++ tests/lib.rs | 2 + 23 files changed, 769 insertions(+), 100 deletions(-) create mode 100644 tests/conditional.rs create mode 100644 tests/error_messages.rs diff --git a/Cargo.lock b/Cargo.lock index cf506807..fefeb1db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 1b67d794..1d727907 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/GRAMMAR.md b/GRAMMAR.md index 73ecdb0d..1d844f35 100644 --- a/GRAMMAR.md +++ b/GRAMMAR.md @@ -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 diff --git a/README.adoc b/README.adoc index fe987292..558bf9da 100644 --- a/README.adoc +++ b/README.adoc @@ -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. diff --git a/justfile b/justfile index 119378f8..90d26369 100755 --- a/justfile +++ b/justfile @@ -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 diff --git a/src/assignment_resolver.rs b/src/assignment_resolver.rs index f4798434..9e84c751 100644 --- a/src/assignment_resolver.rs +++ b/src/assignment_resolver.rs @@ -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), } diff --git a/src/common.rs b/src/common.rs index bb27b430..059be63f 100644 --- a/src/common.rs +++ b/src/common.rs @@ -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, diff --git a/src/compilation_error.rs b/src/compilation_error.rs index 305025cb..c6e72288 100644 --- a/src/compilation_error.rs +++ b/src/compilation_error.rs @@ -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:")?; }, diff --git a/src/compilation_error_kind.rs b/src/compilation_error_kind.rs index 32832ff8..807fc70c 100644 --- a/src/compilation_error_kind.rs +++ b/src/compilation_error_kind.rs @@ -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, }, diff --git a/src/evaluator.rs b/src/evaluator.rs index 0f773dc8..b56a6ad4 100644 --- a/src/evaluator.rs +++ b/src/evaluator.rs @@ -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), } } diff --git a/src/expression.rs b/src/expression.rs index a4595c3e..1616d349 100644 --- a/src/expression.rs +++ b/src/expression.rs @@ -20,6 +20,14 @@ pub(crate) enum Expression<'src> { lhs: Box>, rhs: Box>, }, + /// `if lhs == rhs { then } else { otherwise }` + Conditional { + lhs: Box>, + rhs: Box>, + then: Box>, + otherwise: Box>, + inverted: bool, + }, /// `(contents)` Group { contents: Box> }, /// `"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), diff --git a/src/keyword.rs b/src/keyword.rs index 8bf8371b..4d75305f 100644 --- a/src/keyword.rs +++ b/src/keyword.rs @@ -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 { + 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 + } +} diff --git a/src/lexer.rs b/src/lexer.rs index c5189f3c..9b763d95 100644 --- a/src/lexer.rs +++ b/src/lexer.rs @@ -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,13 +469,11 @@ 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 { - self.advance()?; - Err(self.error(UnknownStartOfToken)) - }, + _ 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 | ! + | ^" + ), + ); + } } diff --git a/src/node.rs b/src/node.rs index 4a65dff6..55476a57 100644 --- a/src/node.rs +++ b/src/node.rs @@ -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()); diff --git a/src/parser.rs b/src/parser.rs index 02d95519..69dcbfc8 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -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>> { 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,37 +675,36 @@ 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 => { - self.expect(BracketL)?; + if name.lexeme() == Keyword::Shell.lexeme() { + self.expect(BracketL)?; - let command = self.parse_string_literal()?; + let command = self.parse_string_literal()?; - let mut arguments = Vec::new(); + let mut arguments = Vec::new(); - if self.accepted(Comma)? { - while !self.next_is(BracketR) { - arguments.push(self.parse_string_literal()?); + if self.accepted(Comma)? { + while !self.next_is(BracketR) { + arguments.push(self.parse_string_literal()?); - if !self.accepted(Comma)? { - break; - } + if !self.accepted(Comma)? { + break; } } + } - self.expect(BracketR)?; + self.expect(BracketR)?; - Ok(Set { - value: Setting::Shell(setting::Shell { command, arguments }), - name, - }) - }, - _ => Err(name.error(CompilationErrorKind::UnknownSetting { + Ok(Set { + value: Setting::Shell(setting::Shell { command, arguments }), + name, + }) + } 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, }, } diff --git a/src/summary.rs b/src/summary.rs index dd8e9986..71d524ae 100644 --- a/src/summary.rs +++ b/src/summary.rs @@ -193,6 +193,13 @@ pub enum Expression { lhs: Box, rhs: Box, }, + Conditional { + lhs: Box, + rhs: Box, + then: Box, + otherwise: Box, + 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(), }, diff --git a/src/testing.rs b/src/testing.rs index 28129b45..455ff8bd 100644 --- a/src/testing.rs +++ b/src/testing.rs @@ -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)?) + ), + } + } +} diff --git a/src/token_kind.rs b/src/token_kind.rs index 30dd34df..ce225aee 100644 --- a/src/token_kind.rs +++ b/src/token_kind.rs @@ -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 => "'}}'", diff --git a/src/tree.rs b/src/tree.rs index b71c6edb..0d929601 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -41,6 +41,18 @@ macro_rules! tree { } => { $crate::tree::Tree::atom("*") }; + + { + == + } => { + $crate::tree::Tree::atom("==") + }; + + { + != + } => { + $crate::tree::Tree::atom("!=") + }; } /// A `Tree` is either… diff --git a/src/variables.rs b/src/variables.rs index ff836824..586d928c 100644 --- a/src/variables.rs +++ b/src/variables.rs @@ -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); diff --git a/tests/conditional.rs b/tests/conditional.rs new file mode 100644 index 00000000..7e02c632 --- /dev/null +++ b/tests/conditional.rs @@ -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(), +} diff --git a/tests/error_messages.rs b/tests/error_messages.rs new file mode 100644 index 00000000..e4b2e1d9 --- /dev/null +++ b/tests/error_messages.rs @@ -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, +} diff --git a/tests/lib.rs b/tests/lib.rs index 17095d26..a89f5c69 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -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;