Implement regular expression match conditionals (#970)

This commit is contained in:
Casey Rodarmor 2021-09-17 02:45:56 +03:00 committed by GitHub
parent 09af9bb5e5
commit 0db4589efe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 239 additions and 111 deletions

View File

@ -27,6 +27,7 @@ lazy_static = "1.0.0"
lexiclean = "0.0.1" lexiclean = "0.0.1"
libc = "0.2.0" libc = "0.2.0"
log = "0.4.4" log = "0.4.4"
regex = "1.5.4"
snafu = "0.6.0" snafu = "0.6.0"
strum_macros = "0.21.1" strum_macros = "0.21.1"
target = "2.0.0" target = "2.0.0"
@ -50,7 +51,6 @@ features = ["derive"]
cradle = "0.0.22" cradle = "0.0.22"
executable-path = "1.0.0" executable-path = "1.0.0"
pretty_assertions = "0.7.0" pretty_assertions = "0.7.0"
regex = "1.5.4"
temptree = "0.2.0" temptree = "0.2.0"
which = "4.0.0" which = "4.0.0"
yaml-rust = "0.4.5" yaml-rust = "0.4.5"

View File

@ -884,6 +884,22 @@ $ just bar
xyz 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 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 their branches. This can be used to make sure that backtick expressions don't
run when they shouldn't. run when they shouldn't.

View File

@ -25,6 +25,7 @@ pub(crate) use edit_distance::edit_distance;
pub(crate) use lexiclean::Lexiclean; pub(crate) use lexiclean::Lexiclean;
pub(crate) use libc::EXIT_FAILURE; pub(crate) use libc::EXIT_FAILURE;
pub(crate) use log::{info, warn}; pub(crate) use log::{info, warn};
pub(crate) use regex::Regex;
pub(crate) use snafu::{ResultExt, Snafu}; pub(crate) use snafu::{ResultExt, Snafu};
pub(crate) use strum::{Display, EnumString, IntoStaticStr}; pub(crate) use strum::{Display, EnumString, IntoStaticStr};
pub(crate) use typed_arena::Arena; pub(crate) use typed_arena::Arena;
@ -46,19 +47,20 @@ pub(crate) use crate::{
pub(crate) use crate::{ pub(crate) use crate::{
alias::Alias, analyzer::Analyzer, assignment::Assignment, alias::Alias, analyzer::Analyzer, assignment::Assignment,
assignment_resolver::AssignmentResolver, ast::Ast, binding::Binding, color::Color, assignment_resolver::AssignmentResolver, ast::Ast, binding::Binding, color::Color,
compile_error::CompileError, compile_error_kind::CompileErrorKind, config::Config, compile_error::CompileError, compile_error_kind::CompileErrorKind,
config_error::ConfigError, count::Count, delimiter::Delimiter, dependency::Dependency, conditional_operator::ConditionalOperator, config::Config, config_error::ConfigError,
enclosure::Enclosure, error::Error, evaluator::Evaluator, expression::Expression, count::Count, delimiter::Delimiter, dependency::Dependency, enclosure::Enclosure, error::Error,
fragment::Fragment, function::Function, function_context::FunctionContext, evaluator::Evaluator, expression::Expression, fragment::Fragment, function::Function,
interrupt_guard::InterruptGuard, interrupt_handler::InterruptHandler, item::Item, function_context::FunctionContext, interrupt_guard::InterruptGuard,
justfile::Justfile, keyword::Keyword, lexer::Lexer, line::Line, list::List, loader::Loader, interrupt_handler::InterruptHandler, item::Item, justfile::Justfile, keyword::Keyword,
name::Name, output_error::OutputError, parameter::Parameter, parameter_kind::ParameterKind, lexer::Lexer, line::Line, list::List, loader::Loader, name::Name, output_error::OutputError,
parser::Parser, platform::Platform, position::Position, positional::Positional, recipe::Recipe, parameter::Parameter, parameter_kind::ParameterKind, parser::Parser, platform::Platform,
recipe_context::RecipeContext, recipe_resolver::RecipeResolver, scope::Scope, search::Search, position::Position, positional::Positional, recipe::Recipe, recipe_context::RecipeContext,
search_config::SearchConfig, search_error::SearchError, set::Set, setting::Setting, recipe_resolver::RecipeResolver, scope::Scope, search::Search, search_config::SearchConfig,
settings::Settings, shebang::Shebang, show_whitespace::ShowWhitespace, string_kind::StringKind, search_error::SearchError, set::Set, setting::Setting, settings::Settings, shebang::Shebang,
string_literal::StringLiteral, subcommand::Subcommand, suggestion::Suggestion, table::Table, show_whitespace::ShowWhitespace, string_kind::StringKind, string_literal::StringLiteral,
thunk::Thunk, token::Token, token_kind::TokenKind, unresolved_dependency::UnresolvedDependency, subcommand::Subcommand, suggestion::Suggestion, table::Table, thunk::Thunk, token::Token,
token_kind::TokenKind, unresolved_dependency::UnresolvedDependency,
unresolved_recipe::UnresolvedRecipe, use_color::UseColor, variables::Variables, unresolved_recipe::UnresolvedRecipe, use_color::UseColor, variables::Variables,
verbosity::Verbosity, warning::Warning, verbosity::Verbosity, warning::Warning,
}; };

View File

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

View File

@ -95,6 +95,9 @@ pub(crate) enum Error<'src> {
}, },
NoChoosableRecipes, NoChoosableRecipes,
NoRecipes, NoRecipes,
RegexCompile {
source: regex::Error,
},
Search { Search {
search_error: SearchError, search_error: SearchError,
}, },
@ -507,6 +510,9 @@ impl<'src> ColorDisplay for Error<'src> {
NoRecipes => { NoRecipes => {
write!(f, "Justfile contains no recipes.")?; write!(f, "Justfile contains no recipes.")?;
} }
RegexCompile { source } => {
write!(f, "{}", source)?;
}
Search { search_error } => Display::fmt(search_error, f)?, Search { search_error } => Display::fmt(search_error, f)?,
Shebang { Shebang {
recipe, recipe,

View File

@ -139,11 +139,17 @@ impl<'src, 'run> Evaluator<'src, 'run> {
rhs, rhs,
then, then,
otherwise, otherwise,
inverted, operator,
} => { } => {
let lhs = self.evaluate_expression(lhs)?; let lhs_value = self.evaluate_expression(lhs)?;
let rhs = self.evaluate_expression(rhs)?; let rhs_value = self.evaluate_expression(rhs)?;
let condition = if *inverted { lhs != rhs } else { lhs == 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 { if condition {
self.evaluate_expression(then) self.evaluate_expression(then)
} else { } else {

View File

@ -26,7 +26,7 @@ pub(crate) enum Expression<'src> {
rhs: Box<Expression<'src>>, rhs: Box<Expression<'src>>,
then: Box<Expression<'src>>, then: Box<Expression<'src>>,
otherwise: Box<Expression<'src>>, otherwise: Box<Expression<'src>>,
inverted: bool, operator: ConditionalOperator,
}, },
/// `(contents)` /// `(contents)`
Group { contents: Box<Expression<'src>> }, Group { contents: Box<Expression<'src>> },
@ -52,15 +52,11 @@ impl<'src> Display for Expression<'src> {
rhs, rhs,
then, then,
otherwise, otherwise,
inverted, operator,
} => write!( } => write!(
f, f,
"if {} {} {} {{ {} }} else {{ {} }}", "if {} {} {} {{ {} }} else {{ {} }}",
lhs, lhs, operator, rhs, then, otherwise
if *inverted { "!=" } else { "==" },
rhs,
then,
otherwise
), ),
Expression::StringLiteral { string_literal } => write!(f, "{}", string_literal), Expression::StringLiteral { string_literal } => write!(f, "{}", string_literal),
Expression::Variable { name } => write!(f, "{}", name.lexeme()), Expression::Variable { name } => write!(f, "{}", name.lexeme()),

View File

@ -475,25 +475,25 @@ impl<'src> Lexer<'src> {
/// Lex token beginning with `start` outside of a recipe body /// Lex token beginning with `start` outside of a recipe body
fn lex_normal(&mut self, start: char) -> CompileResult<'src, ()> { fn lex_normal(&mut self, start: char) -> CompileResult<'src, ()> {
match start { match start {
'&' => self.lex_digraph('&', '&', AmpersandAmpersand), ' ' | '\t' => self.lex_whitespace(),
'!' => self.lex_digraph('!', '=', BangEquals), '!' => self.lex_digraph('!', '=', BangEquals),
'*' => self.lex_single(Asterisk), '#' => self.lex_comment(),
'$' => self.lex_single(Dollar), '$' => self.lex_single(Dollar),
'@' => self.lex_single(At), '&' => self.lex_digraph('&', '&', AmpersandAmpersand),
'[' => self.lex_delimiter(BracketL),
']' => self.lex_delimiter(BracketR),
'=' => self.lex_choice('=', EqualsEquals, Equals),
',' => self.lex_single(Comma),
':' => self.lex_colon(),
'(' => self.lex_delimiter(ParenL), '(' => self.lex_delimiter(ParenL),
')' => self.lex_delimiter(ParenR), ')' => 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(BraceL),
'}' => self.lex_delimiter(BraceR), '}' => 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(), _ if Self::is_identifier_start(start) => self.lex_identifier(),
_ => { _ => {
self.advance()?; self.advance()?;
@ -610,19 +610,22 @@ impl<'src> Lexer<'src> {
/// Lex a double-character token of kind `then` if the second character of /// 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 /// that token would be `second`, otherwise lex a single-character token of
/// kind `otherwise` /// kind `otherwise`
fn lex_choice( fn lex_choices(
&mut self, &mut self,
second: char, first: char,
then: TokenKind, choices: &[(char, TokenKind)],
otherwise: TokenKind, otherwise: TokenKind,
) -> CompileResult<'src, ()> { ) -> CompileResult<'src, ()> {
self.advance()?; self.presume(first)?;
if self.accepted(second)? { for (second, then) in choices {
self.token(then); if self.accepted(*second)? {
} else { self.token(*then);
self.token(otherwise); return Ok(());
} }
}
self.token(otherwise);
Ok(()) Ok(())
} }
@ -930,6 +933,7 @@ mod tests {
Eol => "\n", Eol => "\n",
Equals => "=", Equals => "=",
EqualsEquals => "==", EqualsEquals => "==",
EqualsTilde => "=~",
Indent => " ", Indent => " ",
InterpolationEnd => "}}", InterpolationEnd => "}}",
InterpolationStart => "{{", InterpolationStart => "{{",
@ -2054,7 +2058,7 @@ mod tests {
error! { error! {
name: tokenize_unknown, name: tokenize_unknown,
input: "~", input: "%",
offset: 0, offset: 0,
line: 0, line: 0,
column: 0, column: 0,
@ -2113,16 +2117,6 @@ mod tests {
kind: UnpairedCarriageReturn, kind: UnpairedCarriageReturn,
} }
error! {
name: unknown_start_of_token_tilde,
input: "~",
offset: 0,
line: 0,
column: 0,
width: 1,
kind: UnknownStartOfToken,
}
error! { error! {
name: invalid_name_start_dash, name: invalid_name_start_dash,
input: "-foo", input: "-foo",

View File

@ -43,6 +43,7 @@ mod compile_error;
mod compile_error_kind; mod compile_error_kind;
mod compiler; mod compiler;
mod completions; mod completions;
mod conditional_operator;
mod config; mod config;
mod config_error; mod config_error;
mod count; mod count;

View File

@ -58,15 +58,11 @@ impl<'src> Node<'src> for Expression<'src> {
rhs, rhs,
then, then,
otherwise, otherwise,
inverted, operator,
} => { } => {
let mut tree = Tree::atom(Keyword::If.lexeme()); let mut tree = Tree::atom(Keyword::If.lexeme());
tree.push_mut(lhs.tree()); tree.push_mut(lhs.tree());
if *inverted { tree.push_mut(operator.to_string());
tree.push_mut("!=");
} else {
tree.push_mut("==");
}
tree.push_mut(rhs.tree()); tree.push_mut(rhs.tree());
tree.push_mut(then.tree()); tree.push_mut(then.tree());
tree.push_mut(otherwise.tree()); tree.push_mut(otherwise.tree());

View File

@ -419,11 +419,14 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
fn parse_conditional(&mut self) -> CompileResult<'src, Expression<'src>> { fn parse_conditional(&mut self) -> CompileResult<'src, Expression<'src>> {
let lhs = self.parse_expression()?; let lhs = self.parse_expression()?;
let inverted = self.accepted(BangEquals)?; let operator = if self.accepted(BangEquals)? {
ConditionalOperator::Inequality
if !inverted { } else if self.accepted(EqualsTilde)? {
ConditionalOperator::RegexMatch
} else {
self.expect(EqualsEquals)?; self.expect(EqualsEquals)?;
} ConditionalOperator::Equality
};
let rhs = self.parse_expression()?; let rhs = self.parse_expression()?;
@ -449,7 +452,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
rhs: Box::new(rhs), rhs: Box::new(rhs),
then: Box::new(then), then: Box::new(then),
otherwise: Box::new(otherwise), otherwise: Box::new(otherwise),
inverted, operator,
}) })
} }

View File

@ -18,9 +18,9 @@ use crate::compiler::Compiler;
mod full { mod full {
pub(crate) use crate::{ pub(crate) use crate::{
assignment::Assignment, dependency::Dependency, expression::Expression, fragment::Fragment, assignment::Assignment, conditional_operator::ConditionalOperator, dependency::Dependency,
justfile::Justfile, line::Line, parameter::Parameter, parameter_kind::ParameterKind, expression::Expression, fragment::Fragment, justfile::Justfile, line::Line,
recipe::Recipe, thunk::Thunk, parameter::Parameter, parameter_kind::ParameterKind, recipe::Recipe, thunk::Thunk,
}; };
} }
@ -198,7 +198,7 @@ pub enum Expression {
rhs: Box<Expression>, rhs: Box<Expression>,
then: Box<Expression>, then: Box<Expression>,
otherwise: Box<Expression>, otherwise: Box<Expression>,
inverted: bool, operator: ConditionalOperator,
}, },
String { String {
text: String, text: String,
@ -245,16 +245,16 @@ impl Expression {
}, },
Conditional { Conditional {
lhs, lhs,
rhs, operator,
inverted,
then,
otherwise, otherwise,
rhs,
then,
} => Expression::Conditional { } => Expression::Conditional {
lhs: Box::new(Expression::new(lhs)), lhs: Box::new(Expression::new(lhs)),
operator: ConditionalOperator::new(*operator),
otherwise: Box::new(Expression::new(otherwise)),
rhs: Box::new(Expression::new(rhs)), rhs: Box::new(Expression::new(rhs)),
then: Box::new(Expression::new(then)), then: Box::new(Expression::new(then)),
otherwise: Box::new(Expression::new(otherwise)),
inverted: *inverted,
}, },
StringLiteral { string_literal } => Expression::String { StringLiteral { string_literal } => Expression::String {
text: string_literal.cooked.clone(), 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)] #[derive(Eq, PartialEq, Hash, Ord, PartialOrd, Debug, Clone)]
pub struct Dependency { pub struct Dependency {
pub recipe: String, pub recipe: String,
@ -274,8 +291,8 @@ pub struct Dependency {
} }
impl Dependency { impl Dependency {
fn new(dependency: &full::Dependency) -> Dependency { fn new(dependency: &full::Dependency) -> Self {
Dependency { Self {
recipe: dependency.recipe.name().to_owned(), recipe: dependency.recipe.name().to_owned(),
arguments: dependency.arguments.iter().map(Expression::new).collect(), arguments: dependency.arguments.iter().map(Expression::new).collect(),
} }

View File

@ -21,6 +21,7 @@ pub(crate) enum TokenKind {
Eol, Eol,
Equals, Equals,
EqualsEquals, EqualsEquals,
EqualsTilde,
Identifier, Identifier,
Indent, Indent,
InterpolationEnd, InterpolationEnd,
@ -69,6 +70,7 @@ impl Display for TokenKind {
Plus => "'+'", Plus => "'+'",
StringToken => "string", StringToken => "string",
Text => "command text", Text => "command text",
EqualsTilde => "'=~'",
Unspecified => "unspecified", Unspecified => "unspecified",
Whitespace => "whitespace", Whitespace => "whitespace",
} }

View File

@ -132,7 +132,7 @@ test! {
", ",
stdout: "", stdout: "",
stderr: " stderr: "
error: Expected '!=', '==', or '+', but found identifier error: Expected '!=', '==', '=~', or '+', but found identifier
| |
1 | a := if '' a '' { '' } else { b } 1 | a := if '' a '' { '' } else { b }
| ^ | ^

View File

@ -25,6 +25,7 @@ mod misc;
mod positional_arguments; mod positional_arguments;
mod quiet; mod quiet;
mod readme; mod readme;
mod regexes;
mod search; mod search;
mod shebang; mod shebang;
mod shell; mod shell;

66
tests/regexes.rs Normal file
View File

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