From 0db4589efe55c51b2e9fe1f9026c07cfeff2c127 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Fri, 17 Sep 2021 02:45:56 +0300 Subject: [PATCH] Implement regular expression match conditionals (#970) --- Cargo.toml | 64 +++++++++++++++++------------------ README.adoc | 16 +++++++++ src/common.rs | 28 ++++++++-------- src/conditional_operator.rs | 22 +++++++++++++ src/error.rs | 6 ++++ src/evaluator.rs | 14 +++++--- src/expression.rs | 10 ++---- src/lexer.rs | 58 +++++++++++++++----------------- src/lib.rs | 1 + src/node.rs | 8 ++--- src/parser.rs | 13 +++++--- src/summary.rs | 39 +++++++++++++++------- src/token_kind.rs | 2 ++ tests/conditional.rs | 2 +- tests/lib.rs | 1 + tests/regexes.rs | 66 +++++++++++++++++++++++++++++++++++++ 16 files changed, 239 insertions(+), 111 deletions(-) create mode 100644 src/conditional_operator.rs create mode 100644 tests/regexes.rs diff --git a/Cargo.toml b/Cargo.toml index a4f4f75..7bd116c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,37 +1,38 @@ [package] -name = "just" -version = "0.10.1" +name = "just" +version = "0.10.1" description = "🤖 Just a command runner" -authors = ["Casey Rodarmor "] -license = "CC0-1.0" -homepage = "https://github.com/casey/just" -repository = "https://github.com/casey/just" -readme = "crates-io-readme.md" -edition = "2018" -autotests = false -categories = ["command-line-utilities", "development-tools"] -keywords = ["command-line", "task", "runner", "development", "utility"] +authors = ["Casey Rodarmor "] +license = "CC0-1.0" +homepage = "https://github.com/casey/just" +repository = "https://github.com/casey/just" +readme = "crates-io-readme.md" +edition = "2018" +autotests = false +categories = ["command-line-utilities", "development-tools"] +keywords = ["command-line", "task", "runner", "development", "utility"] [workspace] members = [".", "bin/ref-type"] [dependencies] -ansi_term = "0.12.0" -atty = "0.2.0" -camino = "1.0.4" -derivative = "2.0.0" -dotenv = "0.15.0" +ansi_term = "0.12.0" +atty = "0.2.0" +camino = "1.0.4" +derivative = "2.0.0" +dotenv = "0.15.0" edit-distance = "2.0.0" -env_logger = "0.9.0" -lazy_static = "1.0.0" -lexiclean = "0.0.1" -libc = "0.2.0" -log = "0.4.4" -snafu = "0.6.0" -strum_macros = "0.21.1" -target = "2.0.0" -tempfile = "3.0.0" -typed-arena = "2.0.1" +env_logger = "0.9.0" +lazy_static = "1.0.0" +lexiclean = "0.0.1" +libc = "0.2.0" +log = "0.4.4" +regex = "1.5.4" +snafu = "0.6.0" +strum_macros = "0.21.1" +target = "2.0.0" +tempfile = "3.0.0" +typed-arena = "2.0.1" unicode-width = "0.1.0" [dependencies.clap] @@ -47,13 +48,12 @@ version = "0.21.0" features = ["derive"] [dev-dependencies] -cradle = "0.0.22" -executable-path = "1.0.0" +cradle = "0.0.22" +executable-path = "1.0.0" pretty_assertions = "0.7.0" -regex = "1.5.4" -temptree = "0.2.0" -which = "4.0.0" -yaml-rust = "0.4.5" +temptree = "0.2.0" +which = "4.0.0" +yaml-rust = "0.4.5" [features] # No features are active by default. diff --git a/README.adoc b/README.adoc index 2649759..eb6760c 100644 --- a/README.adoc +++ b/README.adoc @@ -884,6 +884,22 @@ $ just bar xyz ``` +And match against regular expressions: + +```make +foo := if "hello" =~ 'hel+o' { "match" } else { "mismatch" } + +bar: + @echo {{foo}} +``` + +```sh +$ just bar +match +``` + +Regular expressions are provided by the https://github.com/rust-lang/regex[regex crate], whose syntax is documented on https://docs.rs/regex/1.5.4/regex/#syntax[docs.rs]. Since regular expressions commonly use backslash escape sequences, consider using single-quoted string literals, which will pass slashes to the regex parser unmolested. + 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. diff --git a/src/common.rs b/src/common.rs index 12e558d..cbfa0b2 100644 --- a/src/common.rs +++ b/src/common.rs @@ -25,6 +25,7 @@ pub(crate) use edit_distance::edit_distance; pub(crate) use lexiclean::Lexiclean; pub(crate) use libc::EXIT_FAILURE; pub(crate) use log::{info, warn}; +pub(crate) use regex::Regex; pub(crate) use snafu::{ResultExt, Snafu}; pub(crate) use strum::{Display, EnumString, IntoStaticStr}; pub(crate) use typed_arena::Arena; @@ -46,19 +47,20 @@ pub(crate) use crate::{ pub(crate) use crate::{ alias::Alias, analyzer::Analyzer, assignment::Assignment, assignment_resolver::AssignmentResolver, ast::Ast, binding::Binding, color::Color, - compile_error::CompileError, compile_error_kind::CompileErrorKind, config::Config, - config_error::ConfigError, count::Count, delimiter::Delimiter, dependency::Dependency, - enclosure::Enclosure, error::Error, evaluator::Evaluator, expression::Expression, - fragment::Fragment, function::Function, function_context::FunctionContext, - interrupt_guard::InterruptGuard, interrupt_handler::InterruptHandler, item::Item, - justfile::Justfile, keyword::Keyword, lexer::Lexer, line::Line, list::List, loader::Loader, - 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, scope::Scope, search::Search, - search_config::SearchConfig, search_error::SearchError, set::Set, setting::Setting, - settings::Settings, shebang::Shebang, show_whitespace::ShowWhitespace, string_kind::StringKind, - string_literal::StringLiteral, subcommand::Subcommand, suggestion::Suggestion, table::Table, - thunk::Thunk, token::Token, token_kind::TokenKind, unresolved_dependency::UnresolvedDependency, + compile_error::CompileError, compile_error_kind::CompileErrorKind, + conditional_operator::ConditionalOperator, config::Config, config_error::ConfigError, + count::Count, delimiter::Delimiter, dependency::Dependency, enclosure::Enclosure, error::Error, + evaluator::Evaluator, expression::Expression, fragment::Fragment, function::Function, + function_context::FunctionContext, interrupt_guard::InterruptGuard, + interrupt_handler::InterruptHandler, item::Item, justfile::Justfile, keyword::Keyword, + lexer::Lexer, line::Line, list::List, loader::Loader, 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, scope::Scope, search::Search, search_config::SearchConfig, + search_error::SearchError, set::Set, setting::Setting, settings::Settings, shebang::Shebang, + show_whitespace::ShowWhitespace, string_kind::StringKind, 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, verbosity::Verbosity, warning::Warning, }; diff --git a/src/conditional_operator.rs b/src/conditional_operator.rs new file mode 100644 index 0000000..802be9f --- /dev/null +++ b/src/conditional_operator.rs @@ -0,0 +1,22 @@ +use crate::common::*; + +/// A conditional expression operator. +#[derive(PartialEq, Debug, Copy, Clone)] +pub(crate) enum ConditionalOperator { + /// `==` + Equality, + /// `!=` + Inequality, + /// `=~` + RegexMatch, +} + +impl Display for ConditionalOperator { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + match self { + Self::Equality => write!(f, "=="), + Self::Inequality => write!(f, "!="), + Self::RegexMatch => write!(f, "=~"), + } + } +} diff --git a/src/error.rs b/src/error.rs index ba3ffe8..f7da998 100644 --- a/src/error.rs +++ b/src/error.rs @@ -95,6 +95,9 @@ pub(crate) enum Error<'src> { }, NoChoosableRecipes, NoRecipes, + RegexCompile { + source: regex::Error, + }, Search { search_error: SearchError, }, @@ -507,6 +510,9 @@ impl<'src> ColorDisplay for Error<'src> { NoRecipes => { write!(f, "Justfile contains no recipes.")?; } + RegexCompile { source } => { + write!(f, "{}", source)?; + } Search { search_error } => Display::fmt(search_error, f)?, Shebang { recipe, diff --git a/src/evaluator.rs b/src/evaluator.rs index a5148cb..0ab52a5 100644 --- a/src/evaluator.rs +++ b/src/evaluator.rs @@ -139,11 +139,17 @@ impl<'src, 'run> Evaluator<'src, 'run> { rhs, then, otherwise, - inverted, + operator, } => { - let lhs = self.evaluate_expression(lhs)?; - let rhs = self.evaluate_expression(rhs)?; - let condition = if *inverted { lhs != rhs } else { lhs == rhs }; + let lhs_value = self.evaluate_expression(lhs)?; + let rhs_value = self.evaluate_expression(rhs)?; + let condition = match operator { + ConditionalOperator::Equality => lhs_value == rhs_value, + ConditionalOperator::Inequality => lhs_value != rhs_value, + ConditionalOperator::RegexMatch => Regex::new(&rhs_value) + .map_err(|source| Error::RegexCompile { source })? + .is_match(&lhs_value), + }; if condition { self.evaluate_expression(then) } else { diff --git a/src/expression.rs b/src/expression.rs index 71b450a..407ea36 100644 --- a/src/expression.rs +++ b/src/expression.rs @@ -26,7 +26,7 @@ pub(crate) enum Expression<'src> { rhs: Box>, then: Box>, otherwise: Box>, - inverted: bool, + operator: ConditionalOperator, }, /// `(contents)` Group { contents: Box> }, @@ -52,15 +52,11 @@ impl<'src> Display for Expression<'src> { rhs, then, otherwise, - inverted, + operator, } => write!( f, "if {} {} {} {{ {} }} else {{ {} }}", - lhs, - if *inverted { "!=" } else { "==" }, - rhs, - then, - otherwise + lhs, operator, rhs, then, otherwise ), Expression::StringLiteral { string_literal } => write!(f, "{}", string_literal), Expression::Variable { name } => write!(f, "{}", name.lexeme()), diff --git a/src/lexer.rs b/src/lexer.rs index 8890429..232ab94 100644 --- a/src/lexer.rs +++ b/src/lexer.rs @@ -475,25 +475,25 @@ impl<'src> Lexer<'src> { /// Lex token beginning with `start` outside of a recipe body fn lex_normal(&mut self, start: char) -> CompileResult<'src, ()> { match start { - '&' => self.lex_digraph('&', '&', AmpersandAmpersand), + ' ' | '\t' => self.lex_whitespace(), '!' => self.lex_digraph('!', '=', BangEquals), - '*' => self.lex_single(Asterisk), + '#' => self.lex_comment(), '$' => self.lex_single(Dollar), - '@' => self.lex_single(At), - '[' => self.lex_delimiter(BracketL), - ']' => self.lex_delimiter(BracketR), - '=' => self.lex_choice('=', EqualsEquals, Equals), - ',' => self.lex_single(Comma), - ':' => self.lex_colon(), + '&' => self.lex_digraph('&', '&', AmpersandAmpersand), '(' => self.lex_delimiter(ParenL), ')' => self.lex_delimiter(ParenR), + '*' => self.lex_single(Asterisk), + '+' => self.lex_single(Plus), + ',' => self.lex_single(Comma), + ':' => self.lex_colon(), + '=' => self.lex_choices('=', &[('=', EqualsEquals), ('~', EqualsTilde)], Equals), + '@' => self.lex_single(At), + '[' => self.lex_delimiter(BracketL), + '\n' | '\r' => self.lex_eol(), + ']' => self.lex_delimiter(BracketR), + '`' | '"' | '\'' => self.lex_string(), '{' => self.lex_delimiter(BraceL), '}' => self.lex_delimiter(BraceR), - '+' => self.lex_single(Plus), - '#' => self.lex_comment(), - ' ' | '\t' => self.lex_whitespace(), - '`' | '"' | '\'' => self.lex_string(), - '\n' | '\r' => self.lex_eol(), _ if Self::is_identifier_start(start) => self.lex_identifier(), _ => { self.advance()?; @@ -610,20 +610,23 @@ impl<'src> Lexer<'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( + fn lex_choices( &mut self, - second: char, - then: TokenKind, + first: char, + choices: &[(char, TokenKind)], otherwise: TokenKind, ) -> CompileResult<'src, ()> { - self.advance()?; + self.presume(first)?; - if self.accepted(second)? { - self.token(then); - } else { - self.token(otherwise); + for (second, then) in choices { + if self.accepted(*second)? { + self.token(*then); + return Ok(()); + } } + self.token(otherwise); + Ok(()) } @@ -930,6 +933,7 @@ mod tests { Eol => "\n", Equals => "=", EqualsEquals => "==", + EqualsTilde => "=~", Indent => " ", InterpolationEnd => "}}", InterpolationStart => "{{", @@ -2054,7 +2058,7 @@ mod tests { error! { name: tokenize_unknown, - input: "~", + input: "%", offset: 0, line: 0, column: 0, @@ -2113,16 +2117,6 @@ mod tests { kind: UnpairedCarriageReturn, } - error! { - name: unknown_start_of_token_tilde, - input: "~", - offset: 0, - line: 0, - column: 0, - width: 1, - kind: UnknownStartOfToken, - } - error! { name: invalid_name_start_dash, input: "-foo", diff --git a/src/lib.rs b/src/lib.rs index e0c656c..166a91b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,6 +43,7 @@ mod compile_error; mod compile_error_kind; mod compiler; mod completions; +mod conditional_operator; mod config; mod config_error; mod count; diff --git a/src/node.rs b/src/node.rs index 7988184..727f39d 100644 --- a/src/node.rs +++ b/src/node.rs @@ -58,15 +58,11 @@ impl<'src> Node<'src> for Expression<'src> { rhs, then, otherwise, - inverted, + operator, } => { 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(operator.to_string()); tree.push_mut(rhs.tree()); tree.push_mut(then.tree()); tree.push_mut(otherwise.tree()); diff --git a/src/parser.rs b/src/parser.rs index 68242ba..90533d6 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -419,11 +419,14 @@ impl<'tokens, 'src> Parser<'tokens, 'src> { fn parse_conditional(&mut self) -> CompileResult<'src, Expression<'src>> { let lhs = self.parse_expression()?; - let inverted = self.accepted(BangEquals)?; - - if !inverted { + let operator = if self.accepted(BangEquals)? { + ConditionalOperator::Inequality + } else if self.accepted(EqualsTilde)? { + ConditionalOperator::RegexMatch + } else { self.expect(EqualsEquals)?; - } + ConditionalOperator::Equality + }; let rhs = self.parse_expression()?; @@ -449,7 +452,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> { rhs: Box::new(rhs), then: Box::new(then), otherwise: Box::new(otherwise), - inverted, + operator, }) } diff --git a/src/summary.rs b/src/summary.rs index 66be847..985f56e 100644 --- a/src/summary.rs +++ b/src/summary.rs @@ -18,9 +18,9 @@ use crate::compiler::Compiler; mod full { pub(crate) use crate::{ - assignment::Assignment, dependency::Dependency, expression::Expression, fragment::Fragment, - justfile::Justfile, line::Line, parameter::Parameter, parameter_kind::ParameterKind, - recipe::Recipe, thunk::Thunk, + assignment::Assignment, conditional_operator::ConditionalOperator, dependency::Dependency, + expression::Expression, fragment::Fragment, justfile::Justfile, line::Line, + parameter::Parameter, parameter_kind::ParameterKind, recipe::Recipe, thunk::Thunk, }; } @@ -198,7 +198,7 @@ pub enum Expression { rhs: Box, then: Box, otherwise: Box, - inverted: bool, + operator: ConditionalOperator, }, String { text: String, @@ -245,16 +245,16 @@ impl Expression { }, Conditional { lhs, - rhs, - inverted, - then, + operator, otherwise, + rhs, + then, } => Expression::Conditional { lhs: Box::new(Expression::new(lhs)), + operator: ConditionalOperator::new(*operator), + otherwise: Box::new(Expression::new(otherwise)), rhs: Box::new(Expression::new(rhs)), then: Box::new(Expression::new(then)), - otherwise: Box::new(Expression::new(otherwise)), - inverted: *inverted, }, StringLiteral { string_literal } => Expression::String { text: string_literal.cooked.clone(), @@ -267,6 +267,23 @@ impl Expression { } } +#[derive(Eq, PartialEq, Hash, Ord, PartialOrd, Debug, Clone)] +pub enum ConditionalOperator { + Equality, + Inequality, + RegexMatch, +} + +impl ConditionalOperator { + fn new(operator: full::ConditionalOperator) -> Self { + match operator { + full::ConditionalOperator::Equality => Self::Equality, + full::ConditionalOperator::Inequality => Self::Inequality, + full::ConditionalOperator::RegexMatch => Self::RegexMatch, + } + } +} + #[derive(Eq, PartialEq, Hash, Ord, PartialOrd, Debug, Clone)] pub struct Dependency { pub recipe: String, @@ -274,8 +291,8 @@ pub struct Dependency { } impl Dependency { - fn new(dependency: &full::Dependency) -> Dependency { - Dependency { + fn new(dependency: &full::Dependency) -> Self { + Self { recipe: dependency.recipe.name().to_owned(), arguments: dependency.arguments.iter().map(Expression::new).collect(), } diff --git a/src/token_kind.rs b/src/token_kind.rs index 8dd45e9..bd75273 100644 --- a/src/token_kind.rs +++ b/src/token_kind.rs @@ -21,6 +21,7 @@ pub(crate) enum TokenKind { Eol, Equals, EqualsEquals, + EqualsTilde, Identifier, Indent, InterpolationEnd, @@ -69,6 +70,7 @@ impl Display for TokenKind { Plus => "'+'", StringToken => "string", Text => "command text", + EqualsTilde => "'=~'", Unspecified => "unspecified", Whitespace => "whitespace", } diff --git a/tests/conditional.rs b/tests/conditional.rs index dc346e7..a9bec9f 100644 --- a/tests/conditional.rs +++ b/tests/conditional.rs @@ -132,7 +132,7 @@ test! { ", stdout: "", stderr: " - error: Expected '!=', '==', or '+', but found identifier + error: Expected '!=', '==', '=~', or '+', but found identifier | 1 | a := if '' a '' { '' } else { b } | ^ diff --git a/tests/lib.rs b/tests/lib.rs index 650785e..f35d878 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -25,6 +25,7 @@ mod misc; mod positional_arguments; mod quiet; mod readme; +mod regexes; mod search; mod shebang; mod shell; diff --git a/tests/regexes.rs b/tests/regexes.rs new file mode 100644 index 0000000..c39045e --- /dev/null +++ b/tests/regexes.rs @@ -0,0 +1,66 @@ +use crate::common::*; + +#[test] +fn match_succeeds_evaluates_to_first_branch() { + Test::new() + .justfile( + " + foo := if 'abbbc' =~ 'ab+c' { + 'yes' + } else { + 'no' + } + + default: + echo {{ foo }} + ", + ) + .stderr("echo yes\n") + .stdout("yes\n") + .run(); +} + +#[test] +fn match_fails_evaluates_to_second_branch() { + Test::new() + .justfile( + " + foo := if 'abbbc' =~ 'ab{4}c' { + 'yes' + } else { + 'no' + } + + default: + echo {{ foo }} + ", + ) + .stderr("echo no\n") + .stdout("no\n") + .run(); +} + +#[test] +fn bad_regex_fails_at_runtime() { + Test::new() + .justfile( + " + default: + echo before + echo {{ if '' =~ '(' { 'a' } else { 'b' } }} + echo after + ", + ) + .stderr( + " + echo before + error: regex parse error: + ( + ^ + error: unclosed group + ", + ) + .stdout("before\n") + .status(EXIT_FAILURE) + .run(); +}