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;