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

@ -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 <casey@rodarmor.com>"]
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 <casey@rodarmor.com>"]
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.

View file

@ -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.

View file

@ -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,
};

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,
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,

View file

@ -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 {

View file

@ -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()),

View file

@ -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",

View file

@ -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;

View file

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

View file

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

View file

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

View file

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

View file

@ -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 }
| ^

View file

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