Skip to content

Commit

Permalink
Implement regular expression match conditionals (#970)
Browse files Browse the repository at this point in the history
  • Loading branch information
casey authored Sep 16, 2021
1 parent 09af9bb commit 0db4589
Show file tree
Hide file tree
Showing 16 changed files with 237 additions and 109 deletions.
64 changes: 32 additions & 32 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -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]
Expand All @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 15 additions & 13 deletions src/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
};
Expand Down
22 changes: 22 additions & 0 deletions src/conditional_operator.rs
Original file line number Diff line number Diff line change
@@ -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, "=~"),
}
}
}
6 changes: 6 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ pub(crate) enum Error<'src> {
},
NoChoosableRecipes,
NoRecipes,
RegexCompile {
source: regex::Error,
},
Search {
search_error: SearchError,
},
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 10 additions & 4 deletions src/evaluator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
10 changes: 3 additions & 7 deletions src/expression.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>> },
Expand All @@ -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()),
Expand Down
56 changes: 25 additions & 31 deletions src/lexer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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_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_choice('=', EqualsEquals, Equals),
',' => self.lex_single(Comma),
':' => self.lex_colon(),
'(' => self.lex_delimiter(ParenL),
')' => self.lex_delimiter(ParenR),
'`' | '"' | '\'' => 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()?;
Expand Down Expand Up @@ -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(())
}

Expand Down Expand Up @@ -930,6 +933,7 @@ mod tests {
Eol => "\n",
Equals => "=",
EqualsEquals => "==",
EqualsTilde => "=~",
Indent => " ",
InterpolationEnd => "}}",
InterpolationStart => "{{",
Expand Down Expand Up @@ -2054,7 +2058,7 @@ mod tests {

error! {
name: tokenize_unknown,
input: "~",
input: "%",
offset: 0,
line: 0,
column: 0,
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 2 additions & 6 deletions src/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
13 changes: 8 additions & 5 deletions src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()?;

Expand All @@ -449,7 +452,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
rhs: Box::new(rhs),
then: Box::new(then),
otherwise: Box::new(otherwise),
inverted,
operator,
})
}

Expand Down
Loading

0 comments on commit 0db4589

Please sign in to comment.