Implement regular expression match conditionals (#970)
This commit is contained in:
parent
09af9bb5e5
commit
0db4589efe
@ -27,6 +27,7 @@ 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"
|
||||
@ -50,7 +51,6 @@ features = ["derive"]
|
||||
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"
|
||||
|
16
README.adoc
16
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.
|
||||
|
@ -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,
|
||||
};
|
||||
|
22
src/conditional_operator.rs
Normal file
22
src/conditional_operator.rs
Normal 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, "=~"),
|
||||
}
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
@ -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 {
|
||||
|
@ -26,7 +26,7 @@ pub(crate) enum Expression<'src> {
|
||||
rhs: Box<Expression<'src>>,
|
||||
then: Box<Expression<'src>>,
|
||||
otherwise: Box<Expression<'src>>,
|
||||
inverted: bool,
|
||||
operator: ConditionalOperator,
|
||||
},
|
||||
/// `(contents)`
|
||||
Group { contents: Box<Expression<'src>> },
|
||||
@ -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()),
|
||||
|
58
src/lexer.rs
58
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,19 +610,22 @@ 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",
|
||||
|
@ -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;
|
||||
|
@ -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());
|
||||
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -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<Expression>,
|
||||
then: Box<Expression>,
|
||||
otherwise: Box<Expression>,
|
||||
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(),
|
||||
}
|
||||
|
@ -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",
|
||||
}
|
||||
|
@ -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 }
|
||||
| ^
|
||||
|
@ -25,6 +25,7 @@ mod misc;
|
||||
mod positional_arguments;
|
||||
mod quiet;
|
||||
mod readme;
|
||||
mod regexes;
|
||||
mod search;
|
||||
mod shebang;
|
||||
mod shell;
|
||||
|
66
tests/regexes.rs
Normal file
66
tests/regexes.rs
Normal 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();
|
||||
}
|
Loading…
Reference in New Issue
Block a user