diff --git a/README.md b/README.md
index df0d7afb..006c3bcd 100644
--- a/README.md
+++ b/README.md
@@ -2730,6 +2730,32 @@ foo $bar:
echo $bar
```
+Parameters may be constrained to match regular expression patterns using the
+`[arg("name", pattern="pattern")]` attributemaster:
+
+```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:
diff --git a/src/arg_attribute.rs b/src/arg_attribute.rs
new file mode 100644
index 00000000..1114a209
--- /dev/null
+++ b/src/arg_attribute.rs
@@ -0,0 +1,6 @@
+use super::*;
+
+pub(crate) struct ArgAttribute<'src> {
+ pub(crate) name: Token<'src>,
+ pub(crate) pattern: Option,
+}
diff --git a/src/argument_parser.rs b/src/argument_parser.rs
index 091ffd89..d43c6f91 100644
--- a/src/argument_parser.rs
+++ b/src/argument_parser.rs
@@ -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 })
diff --git a/src/attribute.rs b/src/attribute.rs
index e37f1f51..2099f650 100644
--- a/src/attribute.rs
+++ b/src/attribute.rs
@@ -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>),
Default,
Doc(Option>),
@@ -34,7 +40,6 @@ pub(crate) enum Attribute<'src> {
impl AttributeDiscriminant {
fn argument_range(self) -> RangeInclusive {
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>,
+ 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, Vec) = 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)
diff --git a/src/compile_error.rs b/src/compile_error.rs
index d37c5b1e..0c0487b7 100644
--- a/src/compile_error.rs
+++ b/src/compile_error.rs
@@ -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}`")
diff --git a/src/compile_error_kind.rs b/src/compile_error_kind.rs
index 640c15bb..eb8a2f79 100644
--- a/src/compile_error_kind.rs
+++ b/src/compile_error_kind.rs
@@ -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,
},
+ 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>,
diff --git a/src/error.rs b/src/error.rs
index 6e1106ad..a4037f7d 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -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> 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(())
}
}
diff --git a/src/evaluator.rs b/src/evaluator.rs
index 60eb0f0b..683a9fc6 100644
--- a/src/evaluator.rs
+++ b/src/evaluator.rs
@@ -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)> {
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,
diff --git a/src/justfile.rs b/src/justfile.rs
index 9c84274d..b70e921d 100644
--- a/src/justfile.rs
+++ b/src/justfile.rs
@@ -344,10 +344,11 @@ impl<'src> Justfile<'src> {
};
let (outer, positional) = Evaluator::evaluate_parameters(
+ arguments,
&context,
is_dependency,
- arguments,
&recipe.parameters,
+ recipe,
scope,
)?;
diff --git a/src/lib.rs b/src/lib.rs
index a73b7bbf..389b5617 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -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;
diff --git a/src/parameter.rs b/src/parameter.rs
index df4375cc..e1de240d 100644
--- a/src/parameter.rs
+++ b/src/parameter.rs
@@ -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,
}
-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<'_> {
diff --git a/src/parser.rs b/src/parser.rs
index b98a8150..2c4204d1 100644
--- a/src/parser.rs
+++ b/src/parser.rs
@@ -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::>();
+
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,
+ 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())))
}
}
}
diff --git a/src/pattern.rs b/src/pattern.rs
new file mode 100644
index 00000000..292da94a
--- /dev/null
+++ b/src/pattern.rs
@@ -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> {
+ Ok(Self(
+ format!("^{}$", literal.cooked)
+ .parse::()
+ .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 {
+ Some(self.cmp(other))
+ }
+}
+
+impl Serialize for Pattern {
+ fn serialize(&self, serializer: S) -> Result
+ where
+ S: Serializer,
+ {
+ serializer.serialize_str(self.0.as_str())
+ }
+}
diff --git a/tests/arg_attribute.rs b/tests/arg_attribute.rs
new file mode 100644
index 00000000..8d7be70b
--- /dev/null
+++ b/tests/arg_attribute.rs
@@ -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();
+}
diff --git a/tests/json.rs b/tests/json.rs
index 8565d9e5..48b58aef 100644
--- a/tests/json.rs
+++ b/tests/json.rs
@@ -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)]
diff --git a/tests/lib.rs b/tests/lib.rs
index f2d4bfeb..363c8730 100644
--- a/tests/lib.rs
+++ b/tests/lib.rs
@@ -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;