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 567f84ae24..1cf9a71b78 100644
--- a/src/attribute.rs
+++ b/src/attribute.rs
@@ -13,6 +13,8 @@ pub(crate) enum Attribute<'src> {
Arg {
help: Option>,
long: Option>,
+ #[serde(skip)]
+ long_key: Option>,
name: StringLiteral<'src>,
pattern: Option>,
short: Option>,
@@ -91,7 +93,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()
@@ -117,25 +119,29 @@ impl<'src> Attribute<'src> {
let attribute = match discriminant {
AttributeDiscriminant::Arg => {
- let name = arguments.into_iter().next().unwrap();
+ let arg = arguments.into_iter().next().unwrap();
- let long = keyword_arguments
+ let (long, long_key) = keyword_arguments
.remove("long")
- .map(|(_name, literal)| {
- Self::check_option_name(&name, &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 = keyword_arguments
- .remove("short")
- .map(|(_name, literal)| {
- Self::check_option_name(&name, &literal)?;
+ let short = Self::remove_required(&mut keyword_arguments, "short")?
+ .map(|(_key, literal)| {
+ Self::check_option_name(&arg, &literal)?;
if literal.cooked.chars().count() != 1 {
return Err(literal.token.error(
CompileErrorKind::ShortOptionWithMultipleCharacters {
- parameter: name.cooked.clone(),
+ parameter: arg.cooked.clone(),
},
));
}
@@ -144,29 +150,27 @@ impl<'src> Attribute<'src> {
})
.transpose()?;
- let pattern = keyword_arguments
- .remove("pattern")
- .map(|(_name, literal)| Pattern::new(&literal))
+ let pattern = Self::remove_required(&mut keyword_arguments, "pattern")?
+ .map(|(_key, literal)| Pattern::new(&literal))
.transpose()?;
- let value = keyword_arguments
- .remove("value")
- .map(|(name, literal)| {
+ let value = Self::remove_required(&mut keyword_arguments, "value")?
+ .map(|(key, literal)| {
if long.is_none() && short.is_none() {
- return Err(name.error(CompileErrorKind::ArgAttributeValueRequiresOption));
+ return Err(key.error(CompileErrorKind::ArgAttributeValueRequiresOption));
}
Ok(literal)
})
.transpose()?;
- let help = keyword_arguments
- .remove("help")
- .map(|(_name, literal)| literal);
+ let help =
+ Self::remove_required(&mut keyword_arguments, "help")?.map(|(_key, literal)| literal);
Self::Arg {
help,
long,
- name,
+ long_key,
+ name: arg,
pattern,
short,
value,
@@ -214,6 +218,20 @@ impl<'src> Attribute<'src> {
Ok(attribute)
}
+ fn remove_required(
+ keyword_arguments: &mut BTreeMap<&'src str, (Name<'src>, Option>)>,
+ 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 { key }))?;
+
+ Ok(Some((key, literal)))
+ }
+
pub(crate) fn discriminant(&self) -> AttributeDiscriminant {
self.into()
}
@@ -238,6 +256,7 @@ impl Display for Attribute<'_> {
Self::Arg {
help,
long,
+ long_key: _,
name,
pattern,
short,
diff --git a/src/compile_error.rs b/src/compile_error.rs
index 7600d63abd..6064099bab 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}`")
}
+ AttributeKeyMissingValue { key } => {
+ write!(
+ f,
+ "Attribute key `{key}` requires 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..c052ccd3e2 100644
--- a/src/compile_error_kind.rs
+++ b/src/compile_error_kind.rs
@@ -12,6 +12,9 @@ pub(crate) enum CompileErrorKind<'src> {
min: usize,
max: usize,
},
+ AttributeKeyMissingValue {
+ key: Name<'src>,
+ },
AttributePositionalFollowsKeyword,
BacktickShebang,
CircularRecipeDependency {
diff --git a/src/parser.rs b/src/parser.rs
index a30a80b165..b903a5b2fe 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_key,
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_key
+ .unwrap_or(option.token)
+ .error(CompileErrorKind::DuplicateOption {
+ option: Switch::Long(option.cooked.clone()),
+ recipe: name.lexeme(),
+ }),
+ );
}
}
@@ -1385,13 +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 name = self.parse_name()?;
-
- self.expect(Equals)?;
+ let key = self.parse_name()?;
- let value = self.parse_string_literal()?;
+ let value = self
+ .accepted(Equals)?
+ .then(|| self.parse_string_literal())
+ .transpose()?;
- keyword_arguments.insert(name.lexeme(), (name, value));
+ keyword_arguments.insert(key.lexeme(), (key, value));
} else {
let literal = self.parse_string_literal()?;
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();
+}
diff --git a/tests/options.rs b/tests/options.rs
index 826bfb3b2c..d7f688bb98 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_parameter_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()
@@ -172,6 +187,32 @@ fn duplicate_long_option_attributes_are_forbidden() {
.run();
}
+#[test]
+fn defaulted_duplicate_long_option() {
+ Test::new()
+ .justfile(
+ "
+ [arg(
+ 'aaa',
+ long='bar'
+ )]
+ [arg( 'bar', long)]
+ foo aaa bar:
+ ",
+ )
+ .stderr(
+ "
+ error: Recipe `foo` defines option `--bar` multiple times
+ ——▶ justfile:5:19
+ │
+ 5 │ [arg( 'bar', long)]
+ │ ^^^^
+ ",
+ )
+ .status(EXIT_FAILURE)
+ .run();
+}
+
#[test]
fn duplicate_short_option_attributes_are_forbidden() {
Test::new()