Allow requiring arguments to match patterns

This commit is contained in:
Casey Rodarmor 2025-12-09 16:16:47 -08:00
parent db93841ea5
commit 64e7a15882
16 changed files with 620 additions and 28 deletions

View file

@ -2730,6 +2730,32 @@ foo $bar:
echo $bar
```
Parameters may be constrained to match regular expression patterns using the
`[arg("name", pattern="pattern")]` attribute<sup>master</sup>:
```just
[arg('n', pattern='\d+')]
double n:
echo $(({{n}} * 2))
```
A leading `^` and trailing `$` are added to the pattern, so it must match the
entire argument value.
You may constrain the pattern to a number of alternatives using the `|`
operator:
```just
[arg('flag', pattern='--help|--version')]
info flag:
just {{flag}}
```
Regular expressions are provided by the
[Rust `regex` crate](https://docs.rs/regex/latest/regex/). See the
[syntax documentation](https://docs.rs/regex/latest/regex/#syntax) for usage
examples.
### Dependencies
Dependencies run before recipes that depend on them:

6
src/arg_attribute.rs Normal file
View file

@ -0,0 +1,6 @@
use super::*;
pub(crate) struct ArgAttribute<'src> {
pub(crate) name: Token<'src>,
pub(crate) pattern: Option<Pattern>,
}

View file

@ -91,6 +91,10 @@ impl<'src: 'run, 'run> ArgumentParser<'src, 'run> {
let arguments = rest[..argument_count].to_vec();
for (argument, parameter) in arguments.iter().zip(&recipe.parameters) {
parameter.is_pattern_match(recipe, argument)?;
}
self.next += argument_count;
Ok(ArgumentGroup { arguments, path })

View file

@ -9,6 +9,12 @@ use super::*;
#[strum_discriminants(derive(EnumString, Ord, PartialOrd))]
#[strum_discriminants(strum(serialize_all = "kebab-case"))]
pub(crate) enum Attribute<'src> {
Arg {
name: StringLiteral<'src>,
#[serde(skip)]
name_token: Token<'src>,
pattern: Option<(StringLiteral<'src>, Pattern)>,
},
Confirm(Option<StringLiteral<'src>>),
Default,
Doc(Option<StringLiteral<'src>>),
@ -34,7 +40,6 @@ pub(crate) enum Attribute<'src> {
impl AttributeDiscriminant {
fn argument_range(self) -> RangeInclusive<usize> {
match self {
Self::Confirm | Self::Doc => 0..=1,
Self::Default
| Self::ExitMessage
| Self::Linux
@ -48,9 +53,10 @@ impl AttributeDiscriminant {
| Self::Private
| Self::Unix
| Self::Windows => 0..=0,
Self::Extension | Self::Group | Self::WorkingDirectory => 1..=1,
Self::Metadata => 1..=usize::MAX,
Self::Confirm | Self::Doc => 0..=1,
Self::Script => 0..=usize::MAX,
Self::Arg | Self::Extension | Self::Group | Self::WorkingDirectory => 1..=1,
Self::Metadata => 1..=usize::MAX,
}
}
}
@ -58,7 +64,8 @@ impl AttributeDiscriminant {
impl<'src> Attribute<'src> {
pub(crate) fn new(
name: Name<'src>,
arguments: Vec<StringLiteral<'src>>,
arguments: Vec<(Token<'src>, StringLiteral<'src>)>,
mut keyword_arguments: BTreeMap<&'src str, (Name<'src>, Token<'src>, StringLiteral<'src>)>,
) -> CompileResult<'src, Self> {
let discriminant = name
.lexeme()
@ -83,7 +90,24 @@ impl<'src> Attribute<'src> {
);
}
Ok(match discriminant {
let (tokens, arguments): (Vec<Token>, Vec<StringLiteral>) = arguments.into_iter().unzip();
let attribute = match discriminant {
AttributeDiscriminant::Arg => {
let pattern = keyword_arguments
.remove("pattern")
.map(|(_name, token, literal)| {
let pattern = Pattern::new(token, &literal)?;
Ok((literal, pattern))
})
.transpose()?;
Self::Arg {
name: arguments.into_iter().next().unwrap(),
name_token: tokens.into_iter().next().unwrap(),
pattern,
}
}
AttributeDiscriminant::Confirm => Self::Confirm(arguments.into_iter().next()),
AttributeDiscriminant::Default => Self::Default,
AttributeDiscriminant::Doc => Self::Doc(arguments.into_iter().next()),
@ -112,7 +136,18 @@ impl<'src> Attribute<'src> {
AttributeDiscriminant::WorkingDirectory => {
Self::WorkingDirectory(arguments.into_iter().next().unwrap())
}
})
};
if let Some((_name, (keyword_name, _token, _literal))) = keyword_arguments.into_iter().next() {
return Err(
keyword_name.error(CompileErrorKind::UnknownAttributeKeyword {
attribute: name.lexeme(),
keyword: keyword_name.lexeme(),
}),
);
}
Ok(attribute)
}
pub(crate) fn discriminant(&self) -> AttributeDiscriminant {
@ -124,7 +159,10 @@ impl<'src> Attribute<'src> {
}
pub(crate) fn repeatable(&self) -> bool {
matches!(self, Attribute::Group(_) | Attribute::Metadata(_))
matches!(
self,
Attribute::Arg { .. } | Attribute::Group(_) | Attribute::Metadata(_),
)
}
}
@ -133,6 +171,15 @@ impl Display for Attribute<'_> {
write!(f, "{}", self.name())?;
match self {
Self::Arg { name, pattern, .. } => {
write!(f, "({name}")?;
if let Some((literal, _pattern)) = pattern {
write!(f, ", pattern={literal}")?;
}
write!(f, ")")?;
}
Self::Confirm(None)
| Self::Default
| Self::Doc(None)

View file

@ -17,6 +17,13 @@ impl<'src> CompileError<'src> {
kind: kind.into(),
}
}
pub(crate) fn source(&self) -> Option<&dyn std::error::Error> {
match &*self.kind {
CompileErrorKind::ArgumentPatternRegex { source } => Some(source),
_ => None,
}
}
}
fn capitalize(s: &str) -> String {
@ -32,6 +39,9 @@ impl Display for CompileError<'_> {
use CompileErrorKind::*;
match &*self.kind {
ArgumentPatternRegex { .. } => {
write!(f, "Failed to parse argument pattern")
}
AttributeArgumentCountMismatch {
attribute,
found,
@ -97,6 +107,12 @@ impl Display for CompileError<'_> {
write!(f, "at most {max} {}", Count("argument", *max))
}
}
DuplicateArgAttribute { arg, first } => write!(
f,
"Recipe attribute for argument `{arg}` first used on line {} is duplicated on line {}",
first.ordinal(),
self.token.line.ordinal(),
),
DuplicateAttribute { attribute, first } => write!(
f,
"Recipe attribute `{attribute}` first used on line {} is duplicated on line {}",
@ -216,6 +232,9 @@ impl Display for CompileError<'_> {
write!(f, "Parameter `{parameter}` follows variadic parameter")
}
ParsingRecursionDepthExceeded => write!(f, "Parsing recursion depth exceeded"),
PositionalAttributeArgumentFollowsKeywordAttributeArgument => {
write!(f, "Positional attribute arguments cannot follow keyword attribute arguments")
},
Redefinition {
first,
first_type,
@ -246,6 +265,9 @@ impl Display for CompileError<'_> {
f,
"Non-default parameter `{parameter}` follows default parameter"
),
UndefinedArgAttribute { argument } => {
write!(f, "Argument attribute for unknown argument `{argument}`")
}
UndefinedVariable { variable } => write!(f, "Variable `{variable}` not defined"),
UnexpectedCharacter { expected } => {
write!(f, "Expected character {}", List::or_ticked(expected))
@ -285,6 +307,9 @@ impl Display for CompileError<'_> {
UnknownAliasTarget { alias, target } => {
write!(f, "Alias `{alias}` has an unknown target `{target}`")
}
UnknownAttributeKeyword { attribute, keyword, } => {
write!(f, "Unknown keyword `{keyword}` for `{attribute}` attribute")
}
UnknownAttribute { attribute } => write!(f, "Unknown attribute `{attribute}`"),
UnknownDependency { recipe, unknown } => {
write!(f, "Recipe `{recipe}` has unknown dependency `{unknown}`")

View file

@ -2,6 +2,9 @@ use super::*;
#[derive(Debug, PartialEq)]
pub(crate) enum CompileErrorKind<'src> {
ArgumentPatternRegex {
source: regex::Error,
},
AttributeArgumentCountMismatch {
attribute: &'src str,
found: usize,
@ -23,6 +26,10 @@ pub(crate) enum CompileErrorKind<'src> {
min: usize,
max: usize,
},
DuplicateArgAttribute {
arg: String,
first: usize,
},
DuplicateAttribute {
attribute: &'src str,
first: usize,
@ -94,6 +101,7 @@ pub(crate) enum CompileErrorKind<'src> {
parameter: &'src str,
},
ParsingRecursionDepthExceeded,
PositionalAttributeArgumentFollowsKeywordAttributeArgument,
Redefinition {
first: usize,
first_type: &'static str,
@ -106,6 +114,9 @@ pub(crate) enum CompileErrorKind<'src> {
ShellExpansion {
err: shellexpand::LookupError<env::VarError>,
},
UndefinedArgAttribute {
argument: String,
},
UndefinedVariable {
variable: &'src str,
},
@ -143,6 +154,10 @@ pub(crate) enum CompileErrorKind<'src> {
UnknownAttribute {
attribute: &'src str,
},
UnknownAttributeKeyword {
attribute: &'src str,
keyword: &'src str,
},
UnknownDependency {
recipe: &'src str,
unknown: Namepath<'src>,

View file

@ -13,6 +13,12 @@ pub(crate) enum Error<'src> {
min: usize,
max: usize,
},
ArgumentPatternMismatch {
argument: String,
parameter: &'src str,
pattern: Pattern,
recipe: &'src str,
},
Assert {
message: String,
},
@ -260,6 +266,13 @@ impl<'src> Error<'src> {
}
)
}
fn source(&self) -> Option<&dyn std::error::Error> {
match self {
Self::Compile { compile_error } => compile_error.source(),
_ => None,
}
}
}
impl<'src> From<CompileError<'src>> for Error<'src> {
@ -312,6 +325,17 @@ impl ColorDisplay for Error<'_> {
write!(f, "Recipe `{recipe}` got {found} {count} but takes at most {max}")?;
}
}
ArgumentPatternMismatch {
argument,
parameter,
pattern,
recipe,
} => {
write!(
f,
"Argument `{argument}` passed to recipe `{recipe}` parameter `{parameter}` does not match pattern '{pattern}'",
)?;
}
Assert { message }=> {
write!(f, "Assert failed: {message}")?;
}
@ -546,6 +570,11 @@ impl ColorDisplay for Error<'_> {
write!(f, "{}", token.color_display(color.error()))?;
}
if let Some(source) = self.source() {
writeln!(f)?;
write!(f, "caused by: {source}")?;
}
Ok(())
}
}

View file

@ -336,10 +336,11 @@ impl<'src, 'run> Evaluator<'src, 'run> {
}
pub(crate) fn evaluate_parameters(
arguments: &[String],
context: &ExecutionContext<'src, 'run>,
is_dependency: bool,
arguments: &[String],
parameters: &[Parameter<'src>],
recipe: &Recipe<'src>,
scope: &'run Scope<'src, 'run>,
) -> RunResult<'src, (Scope<'src, 'run>, Vec<String>)> {
let mut evaluator = Self::new(context, is_dependency, scope);
@ -373,6 +374,9 @@ impl<'src, 'run> Evaluator<'src, 'run> {
rest = &rest[1..];
value
};
parameter.is_pattern_match(recipe, &value)?;
evaluator.scope.bind(Binding {
constant: false,
export: parameter.export,

View file

@ -344,10 +344,11 @@ impl<'src> Justfile<'src> {
};
let (outer, positional) = Evaluator::evaluate_parameters(
arguments,
&context,
is_dependency,
arguments,
&recipe.parameters,
recipe,
scope,
)?;

View file

@ -9,6 +9,7 @@ pub(crate) use {
alias::Alias,
alias_style::AliasStyle,
analyzer::Analyzer,
arg_attribute::ArgAttribute,
argument_parser::ArgumentParser,
assignment::Assignment,
assignment_resolver::AssignmentResolver,
@ -60,6 +61,7 @@ pub(crate) use {
parameter::Parameter,
parameter_kind::ParameterKind,
parser::Parser,
pattern::Pattern,
platform::Platform,
platform_interface::PlatformInterface,
position::Position,
@ -115,7 +117,7 @@ pub(crate) use {
snafu::{ResultExt, Snafu},
std::{
borrow::Cow,
cmp,
cmp::{self, Ordering},
collections::{BTreeMap, BTreeSet, HashMap, HashSet},
env,
ffi::OsString,
@ -186,6 +188,7 @@ pub mod request;
mod alias;
mod alias_style;
mod analyzer;
mod arg_attribute;
mod argument_parser;
mod assignment;
mod assignment_resolver;
@ -238,6 +241,7 @@ mod output_error;
mod parameter;
mod parameter_kind;
mod parser;
mod pattern;
mod platform;
mod platform_interface;
mod position;

View file

@ -11,12 +11,35 @@ pub(crate) struct Parameter<'src> {
pub(crate) kind: ParameterKind,
/// The parameter name
pub(crate) name: Name<'src>,
/// The parameter pattern
pub(crate) pattern: Option<Pattern>,
}
impl Parameter<'_> {
impl<'src> Parameter<'src> {
pub(crate) fn is_required(&self) -> bool {
self.default.is_none() && self.kind != ParameterKind::Star
}
pub(crate) fn is_pattern_match(
&self,
recipe: &Recipe<'src>,
value: &str,
) -> Result<(), Error<'src>> {
let Some(pattern) = &self.pattern else {
return Ok(());
};
if pattern.is_match(value) {
return Ok(());
}
Err(Error::ArgumentPatternMismatch {
argument: value.into(),
parameter: self.name.lexeme(),
pattern: pattern.clone(),
recipe: recipe.name(),
})
}
}
impl ColorDisplay for Parameter<'_> {

View file

@ -1026,8 +1026,31 @@ impl<'run, 'src> Parser<'run, 'src> {
let mut positional = Vec::new();
let mut arg_attributes = attributes
.iter()
.filter_map(|attribute| {
if let Attribute::Arg {
name,
name_token,
pattern,
..
} = attribute
{
Some((
name.cooked.clone(),
ArgAttribute {
name: *name_token,
pattern: pattern.as_ref().map(|(_literal, pattern)| pattern.clone()),
},
))
} else {
None
}
})
.collect::<BTreeMap<String, ArgAttribute>>();
while self.next_is(Identifier) || self.next_is(Dollar) {
positional.push(self.parse_parameter(ParameterKind::Singular)?);
positional.push(self.parse_parameter(&mut arg_attributes, ParameterKind::Singular)?);
}
let kind = if self.accepted(Plus)? {
@ -1039,7 +1062,7 @@ impl<'run, 'src> Parser<'run, 'src> {
};
let variadic = if kind.is_variadic() {
let variadic = self.parse_parameter(kind)?;
let variadic = self.parse_parameter(&mut arg_attributes, kind)?;
self.forbid(Identifier, |token| {
token.error(CompileErrorKind::ParameterFollowsVariadicParameter {
@ -1054,6 +1077,10 @@ impl<'run, 'src> Parser<'run, 'src> {
self.expect(Colon)?;
if let Some((argument, ArgAttribute { name, .. })) = arg_attributes.into_iter().next() {
return Err(name.error(CompileErrorKind::UndefinedArgAttribute { argument }));
}
let mut dependencies = Vec::new();
while let Some(dependency) = self.accept_dependency()? {
@ -1132,7 +1159,11 @@ impl<'run, 'src> Parser<'run, 'src> {
}
/// Parse a recipe parameter
fn parse_parameter(&mut self, kind: ParameterKind) -> CompileResult<'src, Parameter<'src>> {
fn parse_parameter(
&mut self,
arg_attributes: &mut BTreeMap<String, ArgAttribute>,
kind: ParameterKind,
) -> CompileResult<'src, Parameter<'src>> {
let export = self.accepted(Dollar)?;
let name = self.parse_name()?;
@ -1148,6 +1179,9 @@ impl<'run, 'src> Parser<'run, 'src> {
export,
kind,
name,
pattern: arg_attributes
.remove(name.lexeme())
.and_then(|attribute| attribute.pattern),
})
}
@ -1295,7 +1329,8 @@ impl<'run, 'src> Parser<'run, 'src> {
/// Item attributes, i.e., `[macos]` or `[confirm: "warning!"]`
fn parse_attributes(&mut self) -> CompileResult<'src, Option<(Token<'src>, AttributeSet<'src>)>> {
let mut attributes = BTreeMap::new();
let mut arg_attributes = BTreeMap::new();
let mut attributes = Vec::new();
let mut discriminants = BTreeMap::new();
let mut token = None;
@ -1307,29 +1342,47 @@ impl<'run, 'src> Parser<'run, 'src> {
let name = self.parse_name()?;
let mut arguments = Vec::new();
let mut keyword_arguments = BTreeMap::new();
if self.accepted(Colon)? {
arguments.push(self.parse_string_literal()?);
arguments.push(self.parse_string_literal_token()?);
} else if self.accepted(ParenL)? {
loop {
arguments.push(self.parse_string_literal()?);
if self.next_is(Identifier) {
let name = self.parse_name()?;
if !self.accepted(Comma)? {
self.expect(Equals)?;
let (token, value) = self.parse_string_literal_token()?;
keyword_arguments.insert(name.lexeme(), (name, token, value));
} else {
let (token, literal) = self.parse_string_literal_token()?;
if !keyword_arguments.is_empty() {
return Err(token.error(
CompileErrorKind::PositionalAttributeArgumentFollowsKeywordAttributeArgument,
));
}
arguments.push((token, literal));
}
if !self.accepted(Comma)? || self.next_is(ParenR) {
break;
}
}
self.expect(ParenR)?;
}
let attribute = Attribute::new(name, arguments)?;
let attribute = Attribute::new(name, arguments, keyword_arguments)?;
let first = attributes.get(&attribute).or_else(|| {
if attribute.repeatable() {
None
} else {
discriminants.get(&attribute.discriminant())
}
});
let first = if attribute.repeatable() {
None
} else {
discriminants.get(&attribute.discriminant())
};
if let Some(&first) = first {
return Err(name.error(CompileErrorKind::DuplicateAttribute {
@ -1338,9 +1391,20 @@ impl<'run, 'src> Parser<'run, 'src> {
}));
}
if let Attribute::Arg { name: arg, .. } = &attribute {
if let Some(&first) = arg_attributes.get(&arg.cooked) {
return Err(name.error(CompileErrorKind::DuplicateArgAttribute {
arg: arg.cooked.clone(),
first,
}));
}
arg_attributes.insert(arg.cooked.clone(), name.line);
}
discriminants.insert(attribute.discriminant(), name.line);
attributes.insert(attribute, name.line);
attributes.push(attribute);
if !self.accepted(Comma)? {
break;
@ -1353,7 +1417,7 @@ impl<'run, 'src> Parser<'run, 'src> {
if attributes.is_empty() {
Ok(None)
} else {
Ok(Some((token.unwrap(), attributes.into_keys().collect())))
Ok(Some((token.unwrap(), attributes.into_iter().collect())))
}
}
}

56
src/pattern.rs Normal file
View file

@ -0,0 +1,56 @@
use super::*;
#[derive(Debug, Clone)]
pub(crate) struct Pattern(pub(crate) Regex);
impl Pattern {
pub(crate) fn is_match(&self, haystack: &str) -> bool {
self.0.is_match(haystack)
}
pub(crate) fn new<'src>(
token: Token<'src>,
literal: &StringLiteral,
) -> Result<Self, CompileError<'src>> {
Ok(Self(
format!("^{}$", literal.cooked)
.parse::<Regex>()
.map_err(|source| token.error(CompileErrorKind::ArgumentPatternRegex { source }))?,
))
}
}
impl Display for Pattern {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
write!(f, "{}", &self.0.as_str()[1..self.0.as_str().len() - 1])
}
}
impl Eq for Pattern {}
impl Ord for Pattern {
fn cmp(&self, other: &pattern::Pattern) -> Ordering {
self.0.as_str().cmp(other.0.as_str())
}
}
impl PartialEq for Pattern {
fn eq(&self, other: &pattern::Pattern) -> bool {
self.0.as_str() == other.0.as_str()
}
}
impl PartialOrd for Pattern {
fn partial_cmp(&self, other: &pattern::Pattern) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Serialize for Pattern {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.0.as_str())
}
}

286
tests/arg_attribute.rs Normal file
View file

@ -0,0 +1,286 @@
use super::*;
#[test]
fn pattern_match() {
Test::new()
.justfile(
"
[arg('bar', pattern='BAR')]
foo bar:
",
)
.args(["foo", "BAR"])
.run();
}
#[test]
fn pattern_mismatch() {
Test::new()
.justfile(
"
[arg('bar', pattern='BAR')]
foo bar:
",
)
.args(["foo", "bar"])
.stderr(
"
error: Argument `bar` passed to recipe `foo` parameter `bar` does not match pattern 'BAR'
",
)
.status(EXIT_FAILURE)
.run();
}
#[test]
fn pattern_must_match_entire_string() {
Test::new()
.justfile(
"
[arg('bar', pattern='bar')]
foo bar:
",
)
.args(["foo", "xbarx"])
.stderr(
"
error: Argument `xbarx` passed to recipe `foo` parameter `bar` does not match pattern 'bar'
",
)
.status(EXIT_FAILURE)
.run();
}
#[test]
fn pattern_invalid_regex_error() {
Test::new()
.justfile(
"
[arg('bar', pattern='{')]
foo bar:
",
)
.stderr(
"
error: Failed to parse argument pattern
justfile:1:21
1 [arg('bar', pattern='{')]
^^^
caused by: regex parse error:
^{$
^
error: repetition quantifier expects a valid decimal
",
)
.status(EXIT_FAILURE)
.run();
}
#[test]
fn dump() {
Test::new()
.justfile(
"
[arg('bar', pattern='BAR')]
foo bar:
",
)
.arg("--dump")
.stdout(
"
[arg('bar', pattern='BAR')]
foo bar:
",
)
.run();
}
#[test]
fn duplicate_attribute_error() {
Test::new()
.justfile(
"
[arg('bar', pattern='BAR')]
[arg('bar', pattern='BAR')]
foo bar:
",
)
.args(["foo", "BAR"])
.stderr(
"
error: Recipe attribute for argument `bar` first used on line 1 is duplicated on line 2
justfile:2:2
2 [arg('bar', pattern='BAR')]
^^^
",
)
.status(EXIT_FAILURE)
.run();
}
#[test]
fn extra_keyword_error() {
Test::new()
.justfile(
"
[arg('bar', pattern='BAR', foo='foo')]
foo bar:
",
)
.args(["foo", "BAR"])
.stderr(
"
error: Unknown keyword `foo` for `arg` attribute
justfile:1:28
1 [arg('bar', pattern='BAR', foo='foo')]
^^^
",
)
.status(EXIT_FAILURE)
.run();
}
#[test]
fn unknown_argument_error() {
Test::new()
.justfile(
"
[arg('bar', pattern='BAR')]
foo:
",
)
.arg("foo")
.stderr(
"
error: Argument attribute for unknown argument `bar`
justfile:1:6
1 [arg('bar', pattern='BAR')]
^^^^^
",
)
.status(EXIT_FAILURE)
.run();
}
#[test]
fn split_across_multiple_lines() {
Test::new()
.justfile(
"
[arg(
'bar',
pattern='BAR'
)]
foo bar:
",
)
.args(["foo", "BAR"])
.run();
}
#[test]
fn optional_trailing_comma() {
Test::new()
.justfile(
"
[arg(
'bar',
pattern='BAR',
)]
foo bar:
",
)
.args(["foo", "BAR"])
.run();
}
#[test]
fn positional_arguments_cannot_follow_keyword_arguments() {
Test::new()
.justfile(
"
[arg(pattern='BAR', 'bar')]
foo bar:
",
)
.args(["foo", "BAR"])
.stderr(
"
error: Positional attribute arguments cannot follow keyword attribute arguments
justfile:1:21
1 [arg(pattern='BAR', 'bar')]
^^^^^
",
)
.status(EXIT_FAILURE)
.run();
}
#[test]
fn pattern_mismatches_are_caught_before_running_dependencies() {
Test::new()
.justfile(
"
baz:
exit 1
[arg('bar', pattern='BAR')]
foo bar: baz
",
)
.args(["foo", "bar"])
.stderr(
"
error: Argument `bar` passed to recipe `foo` parameter `bar` does not match pattern 'BAR'
",
)
.status(EXIT_FAILURE)
.run();
}
#[test]
fn pattern_mismatches_are_caught_before_running_invocation() {
Test::new()
.justfile(
"
baz:
exit 1
[arg('bar', pattern='BAR')]
foo bar: baz
",
)
.args(["baz", "foo", "bar"])
.stderr(
"
error: Argument `bar` passed to recipe `foo` parameter `bar` does not match pattern 'BAR'
",
)
.status(EXIT_FAILURE)
.run();
}
#[test]
fn pattern_mismatches_are_caught_in_evaluated_arguments() {
Test::new()
.justfile(
"
bar: (foo 'ba' + 'r')
[arg('bar', pattern='BAR')]
foo bar:
",
)
.stderr(
"
error: Argument `bar` passed to recipe `foo` parameter `bar` does not match pattern 'BAR'
",
)
.status(EXIT_FAILURE)
.run();
}

View file

@ -54,6 +54,7 @@ struct Parameter<'a> {
export: bool,
kind: &'a str,
name: &'a str,
pattern: Option<&'a str>,
}
#[derive(Debug, Default, Deserialize, PartialEq, Serialize)]

View file

@ -45,6 +45,7 @@ mod alias_style;
mod allow_duplicate_recipes;
mod allow_duplicate_variables;
mod allow_missing;
mod arg_attribute;
mod assert_stdout;
mod assert_success;
mod assertions;