From 312a33882f210c609b01e88040db41640aad09e2 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Thu, 1 Jan 2026 20:57:20 -0800 Subject: [PATCH 01/17] Reform --- src/attribute.rs | 31 ++++++++++++++++++++++++++++--- src/parser.rs | 14 +++++++++----- tests/options.rs | 15 +++++++++++++++ 3 files changed, 52 insertions(+), 8 deletions(-) diff --git a/src/attribute.rs b/src/attribute.rs index 567f84ae24..425e75efc0 100644 --- a/src/attribute.rs +++ b/src/attribute.rs @@ -91,7 +91,7 @@ impl<'src> Attribute<'src> { pub(crate) fn new( name: Name<'src>, arguments: Vec>, - mut keyword_arguments: BTreeMap<&'src str, (Name<'src>, StringLiteral<'src>)>, + mut keyword_arguments: BTreeMap<&'src str, (Name<'src>, Option>)>, ) -> CompileResult<'src, Self> { let discriminant = name .lexeme() @@ -122,6 +122,7 @@ impl<'src> Attribute<'src> { let long = keyword_arguments .remove("long") .map(|(_name, literal)| { + let literal = literal.unwrap_or_else(|| name.clone()); Self::check_option_name(&name, &literal)?; Ok(literal) }) @@ -130,6 +131,11 @@ impl<'src> Attribute<'src> { let short = keyword_arguments .remove("short") .map(|(_name, literal)| { + let literal = literal.ok_or_else(|| { + _name.error(CompileErrorKind::Internal { + message: "short option missing value".into(), + }) + })?; Self::check_option_name(&name, &literal)?; if literal.cooked.chars().count() != 1 { @@ -146,12 +152,24 @@ impl<'src> Attribute<'src> { let pattern = keyword_arguments .remove("pattern") - .map(|(_name, literal)| Pattern::new(&literal)) + .map(|(keyword, literal)| { + let literal = literal.ok_or_else(|| { + keyword.error(CompileErrorKind::Internal { + message: "pattern missing value".into(), + }) + })?; + Pattern::new(&literal) + }) .transpose()?; let value = keyword_arguments .remove("value") .map(|(name, literal)| { + let literal = literal.ok_or_else(|| { + name.error(CompileErrorKind::Internal { + message: "value missing for arg attribute".into(), + }) + })?; if long.is_none() && short.is_none() { return Err(name.error(CompileErrorKind::ArgAttributeValueRequiresOption)); } @@ -161,7 +179,14 @@ impl<'src> Attribute<'src> { let help = keyword_arguments .remove("help") - .map(|(_name, literal)| literal); + .map(|(keyword, literal)| { + literal.ok_or_else(|| { + keyword.error(CompileErrorKind::Internal { + message: "help missing value".into(), + }) + }) + }) + .transpose()?; Self::Arg { help, diff --git a/src/parser.rs b/src/parser.rs index a30a80b165..67147834f8 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -1385,13 +1385,17 @@ impl<'run, 'src> Parser<'run, 'src> { } else if self.accepted(ParenL)? { loop { if self.next_is(Identifier) && !self.next_is_shell_expanded_string() { - let name = self.parse_name()?; + let keyword = self.parse_name()?; - self.expect(Equals)?; + if self.accepted(Equals)? { + let value = self.parse_string_literal()?; - let value = self.parse_string_literal()?; - - keyword_arguments.insert(name.lexeme(), (name, value)); + keyword_arguments.insert(keyword.lexeme(), (keyword, Some(value))); + } else if name.lexeme() == "arg" && keyword.lexeme() == "long" { + keyword_arguments.insert(keyword.lexeme(), (keyword, None)); + } else { + return Err(self.unexpected_token()?); + } } else { let literal = self.parse_string_literal()?; diff --git a/tests/options.rs b/tests/options.rs index 826bfb3b2c..924fafee5a 100644 --- a/tests/options.rs +++ b/tests/options.rs @@ -84,6 +84,21 @@ fn parameters_may_be_passed_with_long_options() { .run(); } +#[test] +fn long_option_defaults_to_argument_name() { + Test::new() + .justfile( + " + [arg('bar', long)] + @foo bar: + echo bar={{bar}} + ", + ) + .args(["foo", "--bar", "baz"]) + .stdout("bar=baz\n") + .run(); +} + #[test] fn parameters_may_be_passed_with_short_options() { Test::new() From 88246e3f04651a100272ad7c4e9ff83f48e04783 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Thu, 1 Jan 2026 21:01:06 -0800 Subject: [PATCH 02/17] Adjust --- src/attribute.rs | 38 +++++++++++++++++++++----------------- src/compile_error.rs | 6 ++++++ src/compile_error_kind.rs | 4 ++++ src/parser.rs | 14 ++++++-------- 4 files changed, 37 insertions(+), 25 deletions(-) diff --git a/src/attribute.rs b/src/attribute.rs index 425e75efc0..3290365c27 100644 --- a/src/attribute.rs +++ b/src/attribute.rs @@ -117,31 +117,32 @@ impl<'src> Attribute<'src> { let attribute = match discriminant { AttributeDiscriminant::Arg => { - let name = arguments.into_iter().next().unwrap(); + let arg_name = arguments.into_iter().next().unwrap(); let long = keyword_arguments .remove("long") .map(|(_name, literal)| { - let literal = literal.unwrap_or_else(|| name.clone()); - Self::check_option_name(&name, &literal)?; + let literal = literal.unwrap_or_else(|| arg_name.clone()); + Self::check_option_name(&arg_name, &literal)?; Ok(literal) }) .transpose()?; let short = keyword_arguments .remove("short") - .map(|(_name, literal)| { + .map(|(keyword, literal)| { let literal = literal.ok_or_else(|| { - _name.error(CompileErrorKind::Internal { - message: "short option missing value".into(), + keyword.error(CompileErrorKind::AttributeKeywordMissingValue { + attribute: name.lexeme(), + keyword: keyword.lexeme(), }) })?; - Self::check_option_name(&name, &literal)?; + Self::check_option_name(&arg_name, &literal)?; if literal.cooked.chars().count() != 1 { return Err(literal.token.error( CompileErrorKind::ShortOptionWithMultipleCharacters { - parameter: name.cooked.clone(), + parameter: arg_name.cooked.clone(), }, )); } @@ -154,8 +155,9 @@ impl<'src> Attribute<'src> { .remove("pattern") .map(|(keyword, literal)| { let literal = literal.ok_or_else(|| { - keyword.error(CompileErrorKind::Internal { - message: "pattern missing value".into(), + keyword.error(CompileErrorKind::AttributeKeywordMissingValue { + attribute: name.lexeme(), + keyword: keyword.lexeme(), }) })?; Pattern::new(&literal) @@ -164,14 +166,15 @@ impl<'src> Attribute<'src> { let value = keyword_arguments .remove("value") - .map(|(name, literal)| { + .map(|(keyword, literal)| { let literal = literal.ok_or_else(|| { - name.error(CompileErrorKind::Internal { - message: "value missing for arg attribute".into(), + keyword.error(CompileErrorKind::AttributeKeywordMissingValue { + attribute: name.lexeme(), + keyword: keyword.lexeme(), }) })?; if long.is_none() && short.is_none() { - return Err(name.error(CompileErrorKind::ArgAttributeValueRequiresOption)); + return Err(keyword.error(CompileErrorKind::ArgAttributeValueRequiresOption)); } Ok(literal) }) @@ -181,8 +184,9 @@ impl<'src> Attribute<'src> { .remove("help") .map(|(keyword, literal)| { literal.ok_or_else(|| { - keyword.error(CompileErrorKind::Internal { - message: "help missing value".into(), + keyword.error(CompileErrorKind::AttributeKeywordMissingValue { + attribute: name.lexeme(), + keyword: keyword.lexeme(), }) }) }) @@ -191,7 +195,7 @@ impl<'src> Attribute<'src> { Self::Arg { help, long, - name, + name: arg_name, pattern, short, value, diff --git a/src/compile_error.rs b/src/compile_error.rs index 7600d63abd..3f8662f134 100644 --- a/src/compile_error.rs +++ b/src/compile_error.rs @@ -337,6 +337,12 @@ impl Display for CompileError<'_> { UnknownAliasTarget { alias, target } => { write!(f, "Alias `{alias}` has an unknown target `{target}`") } + AttributeKeywordMissingValue { attribute, keyword } => { + write!( + f, + "Keyword `{keyword}` for `{attribute}` attribute requires a value" + ) + } UnknownAttributeKeyword { attribute, keyword } => { write!(f, "Unknown keyword `{keyword}` for `{attribute}` attribute") } diff --git a/src/compile_error_kind.rs b/src/compile_error_kind.rs index 4f85e9f344..8659c158ca 100644 --- a/src/compile_error_kind.rs +++ b/src/compile_error_kind.rs @@ -12,6 +12,10 @@ pub(crate) enum CompileErrorKind<'src> { min: usize, max: usize, }, + AttributeKeywordMissingValue { + attribute: &'src str, + keyword: &'src str, + }, AttributePositionalFollowsKeyword, BacktickShebang, CircularRecipeDependency { diff --git a/src/parser.rs b/src/parser.rs index 67147834f8..5cfcb78c10 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -1387,15 +1387,13 @@ impl<'run, 'src> Parser<'run, 'src> { if self.next_is(Identifier) && !self.next_is_shell_expanded_string() { let keyword = self.parse_name()?; - if self.accepted(Equals)? { - let value = self.parse_string_literal()?; - - keyword_arguments.insert(keyword.lexeme(), (keyword, Some(value))); - } else if name.lexeme() == "arg" && keyword.lexeme() == "long" { - keyword_arguments.insert(keyword.lexeme(), (keyword, None)); + let value = if self.accepted(Equals)? { + Some(self.parse_string_literal()?) } else { - return Err(self.unexpected_token()?); - } + None + }; + + keyword_arguments.insert(keyword.lexeme(), (keyword, value)); } else { let literal = self.parse_string_literal()?; From 035a3e33af7a0d94cdb1853ec0f21d27f57774e5 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Thu, 1 Jan 2026 21:10:57 -0800 Subject: [PATCH 03/17] Reform --- src/attribute.rs | 68 +++++++++++++++++++----------------------------- 1 file changed, 27 insertions(+), 41 deletions(-) diff --git a/src/attribute.rs b/src/attribute.rs index 3290365c27..8542753f84 100644 --- a/src/attribute.rs +++ b/src/attribute.rs @@ -88,6 +88,25 @@ impl<'src> Attribute<'src> { Ok(()) } + fn remove_required( + keyword_arguments: &mut BTreeMap<&'src str, (Name<'src>, Option>)>, + attribute: &Name<'src>, + keyword: &'src str, + ) -> CompileResult<'src, Option>> { + let Some((keyword_name, literal)) = keyword_arguments.remove(keyword) else { + return Ok(None); + }; + + let literal = literal.ok_or_else(|| { + keyword_name.error(CompileErrorKind::AttributeKeywordMissingValue { + attribute: attribute.lexeme(), + keyword: keyword_name.lexeme(), + }) + })?; + + Ok(Some(literal)) + } + pub(crate) fn new( name: Name<'src>, arguments: Vec>, @@ -128,15 +147,8 @@ impl<'src> Attribute<'src> { }) .transpose()?; - let short = keyword_arguments - .remove("short") - .map(|(keyword, literal)| { - let literal = literal.ok_or_else(|| { - keyword.error(CompileErrorKind::AttributeKeywordMissingValue { - attribute: name.lexeme(), - keyword: keyword.lexeme(), - }) - })?; + let short = Self::remove_required(&mut keyword_arguments, &name, "short")? + .map(|literal| { Self::check_option_name(&arg_name, &literal)?; if literal.cooked.chars().count() != 1 { @@ -151,46 +163,20 @@ impl<'src> Attribute<'src> { }) .transpose()?; - let pattern = keyword_arguments - .remove("pattern") - .map(|(keyword, literal)| { - let literal = literal.ok_or_else(|| { - keyword.error(CompileErrorKind::AttributeKeywordMissingValue { - attribute: name.lexeme(), - keyword: keyword.lexeme(), - }) - })?; - Pattern::new(&literal) - }) + let pattern = Self::remove_required(&mut keyword_arguments, &name, "pattern")? + .map(|literal| Pattern::new(&literal)) .transpose()?; - let value = keyword_arguments - .remove("value") - .map(|(keyword, literal)| { - let literal = literal.ok_or_else(|| { - keyword.error(CompileErrorKind::AttributeKeywordMissingValue { - attribute: name.lexeme(), - keyword: keyword.lexeme(), - }) - })?; + let value = Self::remove_required(&mut keyword_arguments, &name, "value")? + .map(|literal| { if long.is_none() && short.is_none() { - return Err(keyword.error(CompileErrorKind::ArgAttributeValueRequiresOption)); + return Err(name.error(CompileErrorKind::ArgAttributeValueRequiresOption)); } Ok(literal) }) .transpose()?; - let help = keyword_arguments - .remove("help") - .map(|(keyword, literal)| { - literal.ok_or_else(|| { - keyword.error(CompileErrorKind::AttributeKeywordMissingValue { - attribute: name.lexeme(), - keyword: keyword.lexeme(), - }) - }) - }) - .transpose()?; + let help = Self::remove_required(&mut keyword_arguments, &name, "help")?; Self::Arg { help, From 2f8a9885862b9c5f2ef3e9252b1ce9f83bc802a4 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Thu, 1 Jan 2026 21:13:13 -0800 Subject: [PATCH 04/17] Amend --- src/attribute.rs | 8 ++++---- src/compile_error.rs | 4 ++-- src/compile_error_kind.rs | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/attribute.rs b/src/attribute.rs index 8542753f84..48416b222f 100644 --- a/src/attribute.rs +++ b/src/attribute.rs @@ -91,16 +91,16 @@ impl<'src> Attribute<'src> { fn remove_required( keyword_arguments: &mut BTreeMap<&'src str, (Name<'src>, Option>)>, attribute: &Name<'src>, - keyword: &'src str, + key: &'src str, ) -> CompileResult<'src, Option>> { - let Some((keyword_name, literal)) = keyword_arguments.remove(keyword) else { + let Some((key_name, literal)) = keyword_arguments.remove(key) else { return Ok(None); }; let literal = literal.ok_or_else(|| { - keyword_name.error(CompileErrorKind::AttributeKeywordMissingValue { + key_name.error(CompileErrorKind::AttributeKeyMissingValue { attribute: attribute.lexeme(), - keyword: keyword_name.lexeme(), + key: key_name.lexeme(), }) })?; diff --git a/src/compile_error.rs b/src/compile_error.rs index 3f8662f134..244b3ebd51 100644 --- a/src/compile_error.rs +++ b/src/compile_error.rs @@ -337,10 +337,10 @@ impl Display for CompileError<'_> { UnknownAliasTarget { alias, target } => { write!(f, "Alias `{alias}` has an unknown target `{target}`") } - AttributeKeywordMissingValue { attribute, keyword } => { + AttributeKeyMissingValue { attribute, key } => { write!( f, - "Keyword `{keyword}` for `{attribute}` attribute requires a value" + "Key `{key}` for `{attribute}` attribute requires a value" ) } UnknownAttributeKeyword { attribute, keyword } => { diff --git a/src/compile_error_kind.rs b/src/compile_error_kind.rs index 8659c158ca..be8729e792 100644 --- a/src/compile_error_kind.rs +++ b/src/compile_error_kind.rs @@ -12,9 +12,9 @@ pub(crate) enum CompileErrorKind<'src> { min: usize, max: usize, }, - AttributeKeywordMissingValue { + AttributeKeyMissingValue { attribute: &'src str, - keyword: &'src str, + key: &'src str, }, AttributePositionalFollowsKeyword, BacktickShebang, From 67a731d2b8a468b941c62ee75c44aebeb06c1a60 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Thu, 1 Jan 2026 21:17:07 -0800 Subject: [PATCH 05/17] Enhance --- src/attribute.rs | 15 ++++++++------- tests/options.rs | 23 +++++++++++++++++++++++ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/attribute.rs b/src/attribute.rs index 48416b222f..8a9c2fe237 100644 --- a/src/attribute.rs +++ b/src/attribute.rs @@ -92,7 +92,7 @@ impl<'src> Attribute<'src> { keyword_arguments: &mut BTreeMap<&'src str, (Name<'src>, Option>)>, attribute: &Name<'src>, key: &'src str, - ) -> CompileResult<'src, Option>> { + ) -> CompileResult<'src, Option<(Name<'src>, StringLiteral<'src>)>> { let Some((key_name, literal)) = keyword_arguments.remove(key) else { return Ok(None); }; @@ -104,7 +104,7 @@ impl<'src> Attribute<'src> { }) })?; - Ok(Some(literal)) + Ok(Some((key_name, literal))) } pub(crate) fn new( @@ -148,7 +148,7 @@ impl<'src> Attribute<'src> { .transpose()?; let short = Self::remove_required(&mut keyword_arguments, &name, "short")? - .map(|literal| { + .map(|(_key_name, literal)| { Self::check_option_name(&arg_name, &literal)?; if literal.cooked.chars().count() != 1 { @@ -164,19 +164,20 @@ impl<'src> Attribute<'src> { .transpose()?; let pattern = Self::remove_required(&mut keyword_arguments, &name, "pattern")? - .map(|literal| Pattern::new(&literal)) + .map(|(_key_name, literal)| Pattern::new(&literal)) .transpose()?; let value = Self::remove_required(&mut keyword_arguments, &name, "value")? - .map(|literal| { + .map(|(key_name, literal)| { if long.is_none() && short.is_none() { - return Err(name.error(CompileErrorKind::ArgAttributeValueRequiresOption)); + return Err(key_name.error(CompileErrorKind::ArgAttributeValueRequiresOption)); } Ok(literal) }) .transpose()?; - let help = Self::remove_required(&mut keyword_arguments, &name, "help")?; + let help = Self::remove_required(&mut keyword_arguments, &name, "help")? + .map(|(_key_name, literal)| literal); Self::Arg { help, diff --git a/tests/options.rs b/tests/options.rs index 924fafee5a..9f95893820 100644 --- a/tests/options.rs +++ b/tests/options.rs @@ -187,6 +187,29 @@ fn duplicate_long_option_attributes_are_forbidden() { .run(); } +#[test] +fn duplicate_long_option_with_defaulted_long_attribute_is_forbidden() { + Test::new() + .justfile( + " + [arg('bar', long)] + [arg('baz', long='bar')] + foo bar baz: + ", + ) + .stderr( + " + error: Recipe `foo` defines option `--bar` multiple times + ——▶ justfile:2:18 + │ + 2 │ [arg('baz', long='bar')] + │ ^^^^^ + ", + ) + .status(EXIT_FAILURE) + .run(); +} + #[test] fn duplicate_short_option_attributes_are_forbidden() { Test::new() From 07ea0ca260ddb41bbf511aa05663bbfe054153d7 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Thu, 1 Jan 2026 21:30:49 -0800 Subject: [PATCH 06/17] Revise --- tests/options.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/options.rs b/tests/options.rs index 9f95893820..0a5cbd038c 100644 --- a/tests/options.rs +++ b/tests/options.rs @@ -210,6 +210,32 @@ fn duplicate_long_option_with_defaulted_long_attribute_is_forbidden() { .run(); } +#[test] +fn duplicate_long_option_with_explicit_long_then_defaulted_long_is_forbidden() { + Test::new() + .justfile( + " + [arg( + 'aaa', + long='bar' + )] + [arg( 'bar', long)] + foo aaa bar: + ", + ) + .stderr( + " + error: Recipe `foo` defines option `--bar` multiple times + ——▶ justfile:5:12 + │ + 5 │ [arg( 'bar', long)] + │ ^^^^^ + ", + ) + .status(EXIT_FAILURE) + .run(); +} + #[test] fn duplicate_short_option_attributes_are_forbidden() { Test::new() From f2f44f661a88f3c3fd8b0a716d0f5af564426c37 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Thu, 1 Jan 2026 21:35:03 -0800 Subject: [PATCH 07/17] Adjust --- src/attribute.rs | 22 +++++++++------------- src/compile_error_kind.rs | 4 ++-- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/src/attribute.rs b/src/attribute.rs index 8a9c2fe237..8e0e128b95 100644 --- a/src/attribute.rs +++ b/src/attribute.rs @@ -90,21 +90,17 @@ impl<'src> Attribute<'src> { fn remove_required( keyword_arguments: &mut BTreeMap<&'src str, (Name<'src>, Option>)>, - attribute: &Name<'src>, + attribute: Name<'src>, key: &'src str, ) -> CompileResult<'src, Option<(Name<'src>, StringLiteral<'src>)>> { - let Some((key_name, literal)) = keyword_arguments.remove(key) else { + let Some((key, literal)) = keyword_arguments.remove(key) else { return Ok(None); }; - let literal = literal.ok_or_else(|| { - key_name.error(CompileErrorKind::AttributeKeyMissingValue { - attribute: attribute.lexeme(), - key: key_name.lexeme(), - }) - })?; + let literal = literal + .ok_or_else(|| key.error(CompileErrorKind::AttributeKeyMissingValue { attribute, key }))?; - Ok(Some((key_name, literal))) + Ok(Some((key, literal))) } pub(crate) fn new( @@ -147,7 +143,7 @@ impl<'src> Attribute<'src> { }) .transpose()?; - let short = Self::remove_required(&mut keyword_arguments, &name, "short")? + let short = Self::remove_required(&mut keyword_arguments, name, "short")? .map(|(_key_name, literal)| { Self::check_option_name(&arg_name, &literal)?; @@ -163,11 +159,11 @@ impl<'src> Attribute<'src> { }) .transpose()?; - let pattern = Self::remove_required(&mut keyword_arguments, &name, "pattern")? + let pattern = Self::remove_required(&mut keyword_arguments, name, "pattern")? .map(|(_key_name, literal)| Pattern::new(&literal)) .transpose()?; - let value = Self::remove_required(&mut keyword_arguments, &name, "value")? + let value = Self::remove_required(&mut keyword_arguments, name, "value")? .map(|(key_name, literal)| { if long.is_none() && short.is_none() { return Err(key_name.error(CompileErrorKind::ArgAttributeValueRequiresOption)); @@ -176,7 +172,7 @@ impl<'src> Attribute<'src> { }) .transpose()?; - let help = Self::remove_required(&mut keyword_arguments, &name, "help")? + let help = Self::remove_required(&mut keyword_arguments, name, "help")? .map(|(_key_name, literal)| literal); Self::Arg { diff --git a/src/compile_error_kind.rs b/src/compile_error_kind.rs index be8729e792..0388951050 100644 --- a/src/compile_error_kind.rs +++ b/src/compile_error_kind.rs @@ -13,8 +13,8 @@ pub(crate) enum CompileErrorKind<'src> { max: usize, }, AttributeKeyMissingValue { - attribute: &'src str, - key: &'src str, + attribute: Name<'src>, + key: Name<'src>, }, AttributePositionalFollowsKeyword, BacktickShebang, From a36a78a89fd6e8b6a136f3b31cdbc3597c93454d Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Thu, 1 Jan 2026 21:51:06 -0800 Subject: [PATCH 08/17] Delete duplicate test --- tests/options.rs | 27 ++------------------------- 1 file changed, 2 insertions(+), 25 deletions(-) diff --git a/tests/options.rs b/tests/options.rs index 0a5cbd038c..cba6efbfd6 100644 --- a/tests/options.rs +++ b/tests/options.rs @@ -85,7 +85,7 @@ fn parameters_may_be_passed_with_long_options() { } #[test] -fn long_option_defaults_to_argument_name() { +fn long_option_defaults_to_parameter_name() { Test::new() .justfile( " @@ -188,30 +188,7 @@ fn duplicate_long_option_attributes_are_forbidden() { } #[test] -fn duplicate_long_option_with_defaulted_long_attribute_is_forbidden() { - Test::new() - .justfile( - " - [arg('bar', long)] - [arg('baz', long='bar')] - foo bar baz: - ", - ) - .stderr( - " - error: Recipe `foo` defines option `--bar` multiple times - ——▶ justfile:2:18 - │ - 2 │ [arg('baz', long='bar')] - │ ^^^^^ - ", - ) - .status(EXIT_FAILURE) - .run(); -} - -#[test] -fn duplicate_long_option_with_explicit_long_then_defaulted_long_is_forbidden() { +fn defaulted_duplicate_long_option() { Test::new() .justfile( " From 2f5ccce17ddde932b977db20d68092f0e7176fc3 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Thu, 1 Jan 2026 22:07:27 -0800 Subject: [PATCH 09/17] Enhance --- src/attribute.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/attribute.rs b/src/attribute.rs index 8e0e128b95..588eed0db5 100644 --- a/src/attribute.rs +++ b/src/attribute.rs @@ -132,25 +132,25 @@ impl<'src> Attribute<'src> { let attribute = match discriminant { AttributeDiscriminant::Arg => { - let arg_name = arguments.into_iter().next().unwrap(); + let arg = arguments.into_iter().next().unwrap(); let long = keyword_arguments .remove("long") .map(|(_name, literal)| { - let literal = literal.unwrap_or_else(|| arg_name.clone()); - Self::check_option_name(&arg_name, &literal)?; + let literal = literal.unwrap_or_else(|| arg.clone()); + Self::check_option_name(&arg, &literal)?; Ok(literal) }) .transpose()?; let short = Self::remove_required(&mut keyword_arguments, name, "short")? .map(|(_key_name, literal)| { - Self::check_option_name(&arg_name, &literal)?; + Self::check_option_name(&arg, &literal)?; if literal.cooked.chars().count() != 1 { return Err(literal.token.error( CompileErrorKind::ShortOptionWithMultipleCharacters { - parameter: arg_name.cooked.clone(), + parameter: arg.cooked.clone(), }, )); } @@ -178,7 +178,7 @@ impl<'src> Attribute<'src> { Self::Arg { help, long, - name: arg_name, + name: arg, pattern, short, value, From dc74b8c3e6aefc924de53bb5086f9692a1fede6f Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Thu, 1 Jan 2026 22:22:21 -0800 Subject: [PATCH 10/17] Tweak --- src/attribute.rs | 22 ++++++++++++++++------ src/parser.rs | 13 +++++++++---- tests/options.rs | 4 ++-- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/attribute.rs b/src/attribute.rs index 588eed0db5..2e4a7ba1c8 100644 --- a/src/attribute.rs +++ b/src/attribute.rs @@ -1,5 +1,7 @@ use super::*; +// will need to do this for short too + #[allow(clippy::large_enum_variant)] #[derive( EnumDiscriminants, PartialEq, Debug, Clone, Serialize, Ord, PartialOrd, Eq, IntoStaticStr, @@ -13,6 +15,8 @@ pub(crate) enum Attribute<'src> { Arg { help: Option>, long: Option>, + #[serde(skip)] + long_err: Option>, name: StringLiteral<'src>, pattern: Option>, short: Option>, @@ -134,14 +138,18 @@ impl<'src> Attribute<'src> { AttributeDiscriminant::Arg => { let arg = arguments.into_iter().next().unwrap(); - let long = keyword_arguments + let (long, long_err) = keyword_arguments .remove("long") - .map(|(_name, literal)| { - let literal = literal.unwrap_or_else(|| arg.clone()); - Self::check_option_name(&arg, &literal)?; - Ok(literal) + .map(|(name, literal)| { + if let Some(literal) = literal { + Self::check_option_name(&arg, &literal)?; + Ok((Some(literal), None)) + } else { + Ok((Some(arg.clone()), Some(*name))) + } }) - .transpose()?; + .transpose()? + .unwrap_or((None, None)); let short = Self::remove_required(&mut keyword_arguments, name, "short")? .map(|(_key_name, literal)| { @@ -178,6 +186,7 @@ impl<'src> Attribute<'src> { Self::Arg { help, long, + long_err, name: arg, pattern, short, @@ -250,6 +259,7 @@ impl Display for Attribute<'_> { Self::Arg { help, long, + long_err: _, name, pattern, short, diff --git a/src/parser.rs b/src/parser.rs index 5cfcb78c10..fc705618e1 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -1022,6 +1022,7 @@ impl<'run, 'src> Parser<'run, 'src> { let Attribute::Arg { help, long, + long_err, name: arg, pattern, short, @@ -1034,10 +1035,14 @@ impl<'run, 'src> Parser<'run, 'src> { if let Some(option) = long { if !longs.insert(&option.cooked) { - return Err(option.token.error(CompileErrorKind::DuplicateOption { - option: Switch::Long(option.cooked.clone()), - recipe: name.lexeme(), - })); + return Err( + long_err + .unwrap_or(option.token) + .error(CompileErrorKind::DuplicateOption { + option: Switch::Long(option.cooked.clone()), + recipe: name.lexeme(), + }), + ); } } diff --git a/tests/options.rs b/tests/options.rs index cba6efbfd6..d7f688bb98 100644 --- a/tests/options.rs +++ b/tests/options.rs @@ -203,10 +203,10 @@ fn defaulted_duplicate_long_option() { .stderr( " error: Recipe `foo` defines option `--bar` multiple times - ——▶ justfile:5:12 + ——▶ justfile:5:19 │ 5 │ [arg( 'bar', long)] - │ ^^^^^ + │ ^^^^ ", ) .status(EXIT_FAILURE) From 28d9a8ffab5af05af4ead84e667ca3d1e38431bd Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Thu, 1 Jan 2026 22:26:17 -0800 Subject: [PATCH 11/17] Adapt --- README.md | 8 ++++++++ src/attribute.rs | 2 -- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 30ec8b1cc0..6f74552026 100644 --- a/README.md +++ b/README.md @@ -2842,6 +2842,14 @@ $ just foo --bar=hello bar=hello ``` +The value of `long` can be omitted, in which case the option defaults to the +name of the parameter: + +```just +[arg("bar", long)] +foo bar: +``` + The `[arg(ARG, short=OPTION)]`master attribute can be used to make a parameter a short option. diff --git a/src/attribute.rs b/src/attribute.rs index 2e4a7ba1c8..1b2dce8e33 100644 --- a/src/attribute.rs +++ b/src/attribute.rs @@ -1,7 +1,5 @@ use super::*; -// will need to do this for short too - #[allow(clippy::large_enum_variant)] #[derive( EnumDiscriminants, PartialEq, Debug, Clone, Serialize, Ord, PartialOrd, Eq, IntoStaticStr, From 774b754da5cf92afbf1271397af5a26c28b60d3b Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Thu, 1 Jan 2026 22:30:33 -0800 Subject: [PATCH 12/17] Modify --- src/attribute.rs | 8 ++++---- src/parser.rs | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/attribute.rs b/src/attribute.rs index 1b2dce8e33..5e183046ad 100644 --- a/src/attribute.rs +++ b/src/attribute.rs @@ -14,7 +14,7 @@ pub(crate) enum Attribute<'src> { help: Option>, long: Option>, #[serde(skip)] - long_err: Option>, + long_key: Option>, name: StringLiteral<'src>, pattern: Option>, short: Option>, @@ -136,7 +136,7 @@ impl<'src> Attribute<'src> { AttributeDiscriminant::Arg => { let arg = arguments.into_iter().next().unwrap(); - let (long, long_err) = keyword_arguments + let (long, long_key) = keyword_arguments .remove("long") .map(|(name, literal)| { if let Some(literal) = literal { @@ -184,7 +184,7 @@ impl<'src> Attribute<'src> { Self::Arg { help, long, - long_err, + long_key, name: arg, pattern, short, @@ -257,7 +257,7 @@ impl Display for Attribute<'_> { Self::Arg { help, long, - long_err: _, + long_key: _, name, pattern, short, diff --git a/src/parser.rs b/src/parser.rs index fc705618e1..6a80149dea 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -1022,7 +1022,7 @@ impl<'run, 'src> Parser<'run, 'src> { let Attribute::Arg { help, long, - long_err, + long_key, name: arg, pattern, short, @@ -1036,7 +1036,7 @@ impl<'run, 'src> Parser<'run, 'src> { if let Some(option) = long { if !longs.insert(&option.cooked) { return Err( - long_err + long_key .unwrap_or(option.token) .error(CompileErrorKind::DuplicateOption { option: Switch::Long(option.cooked.clone()), From 79fb463402c8b3b92bf9aada4793d3d3136c0d3b Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Thu, 1 Jan 2026 22:31:23 -0800 Subject: [PATCH 13/17] Adapt --- src/attribute.rs | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/attribute.rs b/src/attribute.rs index 5e183046ad..7a6d79e94b 100644 --- a/src/attribute.rs +++ b/src/attribute.rs @@ -90,21 +90,6 @@ impl<'src> Attribute<'src> { Ok(()) } - fn remove_required( - keyword_arguments: &mut BTreeMap<&'src str, (Name<'src>, Option>)>, - attribute: Name<'src>, - key: &'src str, - ) -> CompileResult<'src, Option<(Name<'src>, StringLiteral<'src>)>> { - let Some((key, literal)) = keyword_arguments.remove(key) else { - return Ok(None); - }; - - let literal = literal - .ok_or_else(|| key.error(CompileErrorKind::AttributeKeyMissingValue { attribute, key }))?; - - Ok(Some((key, literal))) - } - pub(crate) fn new( name: Name<'src>, arguments: Vec>, @@ -233,6 +218,21 @@ impl<'src> Attribute<'src> { Ok(attribute) } + fn remove_required( + keyword_arguments: &mut BTreeMap<&'src str, (Name<'src>, Option>)>, + attribute: Name<'src>, + key: &'src str, + ) -> CompileResult<'src, Option<(Name<'src>, StringLiteral<'src>)>> { + let Some((key, literal)) = keyword_arguments.remove(key) else { + return Ok(None); + }; + + let literal = literal + .ok_or_else(|| key.error(CompileErrorKind::AttributeKeyMissingValue { attribute, key }))?; + + Ok(Some((key, literal))) + } + pub(crate) fn discriminant(&self) -> AttributeDiscriminant { self.into() } From 2e30c1af06cbb9b3874ea57b8f24ba716453e958 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Thu, 1 Jan 2026 22:32:07 -0800 Subject: [PATCH 14/17] Reform --- src/attribute.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/attribute.rs b/src/attribute.rs index 7a6d79e94b..441f078ecb 100644 --- a/src/attribute.rs +++ b/src/attribute.rs @@ -135,7 +135,7 @@ impl<'src> Attribute<'src> { .unwrap_or((None, None)); let short = Self::remove_required(&mut keyword_arguments, name, "short")? - .map(|(_key_name, literal)| { + .map(|(_key, literal)| { Self::check_option_name(&arg, &literal)?; if literal.cooked.chars().count() != 1 { @@ -151,20 +151,20 @@ impl<'src> Attribute<'src> { .transpose()?; let pattern = Self::remove_required(&mut keyword_arguments, name, "pattern")? - .map(|(_key_name, literal)| Pattern::new(&literal)) + .map(|(_key, literal)| Pattern::new(&literal)) .transpose()?; let value = Self::remove_required(&mut keyword_arguments, name, "value")? - .map(|(key_name, literal)| { + .map(|(key, literal)| { if long.is_none() && short.is_none() { - return Err(key_name.error(CompileErrorKind::ArgAttributeValueRequiresOption)); + return Err(key.error(CompileErrorKind::ArgAttributeValueRequiresOption)); } Ok(literal) }) .transpose()?; let help = Self::remove_required(&mut keyword_arguments, name, "help")? - .map(|(_key_name, literal)| literal); + .map(|(_key, literal)| literal); Self::Arg { help, From 52b2848f3a445c9d6812347707047921e3c0f580 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Thu, 1 Jan 2026 22:33:18 -0800 Subject: [PATCH 15/17] Reform --- src/compile_error.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/compile_error.rs b/src/compile_error.rs index 244b3ebd51..27b1b023d1 100644 --- a/src/compile_error.rs +++ b/src/compile_error.rs @@ -340,7 +340,7 @@ impl Display for CompileError<'_> { AttributeKeyMissingValue { attribute, key } => { write!( f, - "Key `{key}` for `{attribute}` attribute requires a value" + "Key `{key}` for `{attribute}` attribute requires value", ) } UnknownAttributeKeyword { attribute, keyword } => { From 52b215c9b0affe82922e89d742efabed49f86b1f Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Thu, 1 Jan 2026 22:38:06 -0800 Subject: [PATCH 16/17] Test value missing error message --- src/attribute.rs | 15 +++++---- src/compile_error.rs | 4 +-- src/compile_error_kind.rs | 1 - tests/arg_attribute.rs | 66 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 11 deletions(-) diff --git a/src/attribute.rs b/src/attribute.rs index 441f078ecb..1cf9a71b78 100644 --- a/src/attribute.rs +++ b/src/attribute.rs @@ -134,7 +134,7 @@ impl<'src> Attribute<'src> { .transpose()? .unwrap_or((None, None)); - let short = Self::remove_required(&mut keyword_arguments, name, "short")? + let short = Self::remove_required(&mut keyword_arguments, "short")? .map(|(_key, literal)| { Self::check_option_name(&arg, &literal)?; @@ -150,11 +150,11 @@ impl<'src> Attribute<'src> { }) .transpose()?; - let pattern = Self::remove_required(&mut keyword_arguments, name, "pattern")? + let pattern = Self::remove_required(&mut keyword_arguments, "pattern")? .map(|(_key, literal)| Pattern::new(&literal)) .transpose()?; - let value = Self::remove_required(&mut keyword_arguments, name, "value")? + let value = Self::remove_required(&mut keyword_arguments, "value")? .map(|(key, literal)| { if long.is_none() && short.is_none() { return Err(key.error(CompileErrorKind::ArgAttributeValueRequiresOption)); @@ -163,8 +163,8 @@ impl<'src> Attribute<'src> { }) .transpose()?; - let help = Self::remove_required(&mut keyword_arguments, name, "help")? - .map(|(_key, literal)| literal); + let help = + Self::remove_required(&mut keyword_arguments, "help")?.map(|(_key, literal)| literal); Self::Arg { help, @@ -220,15 +220,14 @@ impl<'src> Attribute<'src> { fn remove_required( keyword_arguments: &mut BTreeMap<&'src str, (Name<'src>, Option>)>, - attribute: Name<'src>, key: &'src str, ) -> CompileResult<'src, Option<(Name<'src>, StringLiteral<'src>)>> { let Some((key, literal)) = keyword_arguments.remove(key) else { return Ok(None); }; - let literal = literal - .ok_or_else(|| key.error(CompileErrorKind::AttributeKeyMissingValue { attribute, key }))?; + let literal = + literal.ok_or_else(|| key.error(CompileErrorKind::AttributeKeyMissingValue { key }))?; Ok(Some((key, literal))) } diff --git a/src/compile_error.rs b/src/compile_error.rs index 27b1b023d1..6064099bab 100644 --- a/src/compile_error.rs +++ b/src/compile_error.rs @@ -337,10 +337,10 @@ impl Display for CompileError<'_> { UnknownAliasTarget { alias, target } => { write!(f, "Alias `{alias}` has an unknown target `{target}`") } - AttributeKeyMissingValue { attribute, key } => { + AttributeKeyMissingValue { key } => { write!( f, - "Key `{key}` for `{attribute}` attribute requires value", + "Attribute key `{key}` requires value", ) } UnknownAttributeKeyword { attribute, keyword } => { diff --git a/src/compile_error_kind.rs b/src/compile_error_kind.rs index 0388951050..c052ccd3e2 100644 --- a/src/compile_error_kind.rs +++ b/src/compile_error_kind.rs @@ -13,7 +13,6 @@ pub(crate) enum CompileErrorKind<'src> { max: usize, }, AttributeKeyMissingValue { - attribute: Name<'src>, key: Name<'src>, }, AttributePositionalFollowsKeyword, diff --git a/tests/arg_attribute.rs b/tests/arg_attribute.rs index 176410c5fa..e8a95b8108 100644 --- a/tests/arg_attribute.rs +++ b/tests/arg_attribute.rs @@ -354,3 +354,69 @@ fn pattern_mismatch_variadic() { .status(EXIT_FAILURE) .run(); } + +#[test] +fn pattern_requires_value() { + Test::new() + .justfile( + " + [arg('bar', pattern)] + foo bar: + ", + ) + .stderr( + " + error: Attribute key `pattern` requires value + ——▶ justfile:1:13 + │ + 1 │ [arg('bar', pattern)] + │ ^^^^^^^ + ", + ) + .status(EXIT_FAILURE) + .run(); +} + +#[test] +fn short_requires_value() { + Test::new() + .justfile( + " + [arg('bar', short)] + foo bar: + ", + ) + .stderr( + " + error: Attribute key `short` requires value + ——▶ justfile:1:13 + │ + 1 │ [arg('bar', short)] + │ ^^^^^ + ", + ) + .status(EXIT_FAILURE) + .run(); +} + +#[test] +fn value_requires_value() { + Test::new() + .justfile( + " + [arg('bar', long, value)] + foo bar: + ", + ) + .stderr( + " + error: Attribute key `value` requires value + ——▶ justfile:1:19 + │ + 1 │ [arg('bar', long, value)] + │ ^^^^^ + ", + ) + .status(EXIT_FAILURE) + .run(); +} From dbfb22515dc2c9fe68d78b74dae6968c4e46d0c4 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Thu, 1 Jan 2026 22:40:39 -0800 Subject: [PATCH 17/17] Adjust --- src/parser.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/parser.rs b/src/parser.rs index 6a80149dea..b903a5b2fe 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -1390,15 +1390,14 @@ impl<'run, 'src> Parser<'run, 'src> { } else if self.accepted(ParenL)? { loop { if self.next_is(Identifier) && !self.next_is_shell_expanded_string() { - let keyword = self.parse_name()?; + let key = self.parse_name()?; - let value = if self.accepted(Equals)? { - Some(self.parse_string_literal()?) - } else { - None - }; + let value = self + .accepted(Equals)? + .then(|| self.parse_string_literal()) + .transpose()?; - keyword_arguments.insert(keyword.lexeme(), (keyword, value)); + keyword_arguments.insert(key.lexeme(), (key, value)); } else { let literal = self.parse_string_literal()?;