diff --git a/PLAN.md b/PLAN.md
index 251958e50..e84764d1f 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -514,6 +514,10 @@ Groups are the opposite case: `Command::get_groups`, `ArgGroup::get_args` and
errors. KDL, usage-lib, the typed derive/static metadata, generated Go, and the
clap bridge carry the policy. Repeatable, variadic, and count flags retain their
collecting behavior.
+- [x] **Optional flag values.** `Option>` distinguishes an absent flag,
+ a bare flag, and an explicit value. The derive infers zero-or-one value arity,
+ usage-argv binds all three states, and emitted KDL/help use an optional
+ placeholder without requiring a synthetic `default_missing`.
**Help output**
diff --git a/argv/src/lib.rs b/argv/src/lib.rs
index f620ef4c3..40b41754a 100644
--- a/argv/src/lib.rs
+++ b/argv/src/lib.rs
@@ -352,6 +352,12 @@ pub struct Flag<'a> {
/// attached form (`-i9229`, `-i=9229`) still binds: only the following word
/// is refused.
pub require_equals: bool,
+ /// Whether this value-taking flag may be present without a value.
+ ///
+ /// A missing value emits the flag event with `value: None`; bindings such as
+ /// `Option >` can therefore distinguish an absent flag from a bare
+ /// flag and from a flag with an explicit value.
+ pub value_optional: bool,
/// Value used when the flag is present but no value is given.
///
/// clap's `default_missing_value` and the spec's `default_missing`. `--color`
@@ -379,6 +385,7 @@ impl Flag<'_> {
allow_negative_numbers: false,
value_terminator: ::core::option::Option::None,
require_equals: false,
+ value_optional: false,
default_missing: ::core::option::Option::None,
global: false,
};
@@ -1413,15 +1420,17 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> {
if let Some(flag) = self.find_long(name) {
let value = if flag.takes_value {
- Some(match attached {
- Some(v) => v,
+ match attached {
+ Some(v) => Some(v),
None => self.take_detached_value(flag)?,
- })
+ }
} else {
None
};
if flag.variadic {
- self.start_collecting(flag, value.unwrap_or(b""))?;
+ if let Some(value) = value {
+ self.start_collecting(flag, value)?;
+ }
}
return Ok(Event::Flag {
flag,
@@ -1511,16 +1520,18 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> {
let value = if rest.is_empty() {
self.take_detached_value(flag)?
} else if rest[0] == b'=' {
- &rest[1..]
+ Some(&rest[1..])
} else {
- rest
+ Some(rest)
};
if flag.variadic {
- self.start_collecting(flag, value)?;
+ if let Some(value) = value {
+ self.start_collecting(flag, value)?;
+ }
}
Ok(Event::Flag {
flag,
- value: Some(value),
+ value,
negated: false,
})
}
@@ -1531,7 +1542,10 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> {
/// `--jobs --force` is far more likely a forgotten value than a deliberate
/// one, and the attached form is available for the deliberate case. Declared,
/// the next token is taken whatever it looks like, including `--`.
- fn take_detached_value(&mut self, flag: &'t Flag<'t>) -> Result<&'v [u8], Error<'t, 'v>> {
+ fn take_detached_value(
+ &mut self,
+ flag: &'t Flag<'t>,
+ ) -> Result , Error<'t, 'v>> {
if flag.require_equals {
return self.missing_or_default(flag);
}
@@ -1542,15 +1556,16 @@ impl<'t: 'v, 'a, 'v> Parser<'t, 'a, 'v> {
|| (flag.allow_negative_numbers && is_negative_number(bytes(next))) =>
{
self.pos += 1;
- Ok(bytes(next))
+ Ok(Some(bytes(next)))
}
_ => self.missing_or_default(flag),
}
}
- fn missing_or_default(&self, flag: &'t Flag<'t>) -> Result<&'v [u8], Error<'t, 'v>> {
+ fn missing_or_default(&self, flag: &'t Flag<'t>) -> Result , Error<'t, 'v>> {
match flag.default_missing {
- Some(value) => Ok(value),
+ Some(value) => Ok(Some(value)),
+ None if flag.value_optional => Ok(None),
None => Err(Error::MissingFlagValue { flag }),
}
}
@@ -3037,6 +3052,83 @@ mod tests {
);
}
+ #[test]
+ fn optional_flag_value_distinguishes_bare_and_explicit_forms() {
+ static BUMP: Flag = Flag {
+ key: 11,
+ name: "bump",
+ longs: &["bump"],
+ takes_value: true,
+ value_optional: true,
+ ..Flag::BOOL
+ };
+ static OPTIONAL: Command = Command {
+ name: "ex",
+ flags: &[&BUMP],
+ ..Command::EMPTY
+ };
+
+ assert_eq!(parse(&OPTIONAL, &argv([])).unwrap(), vec![]);
+ assert_eq!(
+ parse(&OPTIONAL, &argv(["--bump"])).unwrap(),
+ vec![Event::Flag {
+ flag: &BUMP,
+ value: None,
+ negated: false,
+ }]
+ );
+ assert_eq!(
+ parse(&OPTIONAL, &argv(["--bump=5"])).unwrap(),
+ vec![Event::Flag {
+ flag: &BUMP,
+ value: Some(b"5"),
+ negated: false,
+ }]
+ );
+
+ static INCLUDE: Flag = Flag {
+ key: 12,
+ name: "include",
+ longs: &["include"],
+ takes_value: true,
+ variadic: true,
+ value_optional: true,
+ ..Flag::BOOL
+ };
+ static VERBOSE: Flag = Flag {
+ key: 13,
+ name: "verbose",
+ longs: &["verbose"],
+ ..Flag::BOOL
+ };
+ static VARIADIC: Command = Command {
+ name: "ex",
+ flags: &[&INCLUDE, &VERBOSE],
+ args: &[&REST],
+ ..Command::EMPTY
+ };
+ assert_eq!(
+ parse(&VARIADIC, &argv(["--include", "--verbose", "file"])).unwrap(),
+ vec![
+ Event::Flag {
+ flag: &INCLUDE,
+ value: None,
+ negated: false,
+ },
+ Event::Flag {
+ flag: &VERBOSE,
+ value: None,
+ negated: false,
+ },
+ Event::Arg {
+ arg: &REST,
+ value: b"file",
+ delimit: true,
+ },
+ ]
+ );
+ }
+
#[test]
fn default_missing_with_require_equals_leaves_the_following_word() {
static INSPECT: Flag = Flag {
diff --git a/argv/src/spec.rs b/argv/src/spec.rs
index 4dba674c6..24fdeafe2 100644
--- a/argv/src/spec.rs
+++ b/argv/src/spec.rs
@@ -1641,6 +1641,9 @@ fn write_flag(out: &mut String, meta: &FlagMeta<'_>, depth: usize) -> core::fmt:
if meta.flag.require_equals {
out.push_str(" require_equals=#true");
}
+ if meta.flag.value_optional {
+ out.push_str(" value_optional=#true");
+ }
if let Some(missing) = meta.flag.default_missing {
write!(
out,
diff --git a/conformance/src/tables.rs b/conformance/src/tables.rs
index fa0caf7e0..dd0ec8066 100644
--- a/conformance/src/tables.rs
+++ b/conformance/src/tables.rs
@@ -290,6 +290,7 @@ fn build_flag(f: &SpecFlag) -> &'static Flag<'static> {
.and_then(|arg| arg.value_terminator.as_deref())
.map(|value| leak(value).as_bytes()),
require_equals: f.require_equals,
+ value_optional: f.value_optional,
default_missing: f.default_missing.as_deref().map(|s| leak(s).as_bytes()),
global: f.global,
}))
diff --git a/conformance/tests/optional_flag_value.rs b/conformance/tests/optional_flag_value.rs
index e252c3a0c..35397055e 100644
--- a/conformance/tests/optional_flag_value.rs
+++ b/conformance/tests/optional_flag_value.rs
@@ -64,9 +64,44 @@ fn the_reference_renders_it_the_same_way() {
fn the_emitted_spec_says_both_halves() {
let kdl = Ex::to_kdl();
assert!(kdl.contains(r#"arg "[BUMP]" required=#false"#), "{kdl}");
+ assert!(!kdl.contains("value_optional=#true"), "{kdl}");
assert!(kdl.contains("arg "), "{kdl}");
}
+#[test]
+fn portable_tables_keep_help_optionality_separate_from_binding() {
+ let presentation: LibSpec = Ex::to_kdl().parse().unwrap();
+ let presentation = usage_conformance::tables::build_spec(&presentation);
+ let bare = [OsStr::new("--bump")];
+ let mut parser = usage_argv::Parser::new(presentation.root.cmd, &bare);
+ let presentation_error = loop {
+ match parser.next_event() {
+ Some(Ok(_)) => {}
+ Some(Err(_)) => break true,
+ None => break false,
+ }
+ };
+ assert!(presentation_error);
+
+ let executable: LibSpec = r#"
+name "ex"
+flag "--bump [BUMP]" value_optional=#true
+"#
+ .parse()
+ .unwrap();
+ let executable = usage_conformance::tables::build_spec(&executable);
+ let mut parser = usage_argv::Parser::new(executable.root.cmd, &bare);
+ let mut events = Vec::new();
+ while let Some(event) = parser.next_event() {
+ events.push(event.unwrap());
+ }
+ assert_eq!(events.len(), 1);
+ assert!(matches!(
+ events[0],
+ usage_argv::Event::Flag { value: None, .. }
+ ));
+}
+
#[test]
fn it_still_takes_its_value() {
// Nothing about binding changed: the value is read where it is given.
diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs
index 28f08a041..f65ffda92 100644
--- a/derive/src/codegen.rs
+++ b/derive/src/codegen.rs
@@ -1070,6 +1070,11 @@ fn flag_table(i: usize, field: &Field) -> TokenStream {
None => quote!(::core::option::Option::None),
};
let require_equals = field.require_equals;
+ // `value_optional` can be a presentation-only declaration for clap/spec
+ // compatibility. Only a nested Option can represent a genuinely bare value
+ // in the typed result; `default_missing` turns the bare form into a value
+ // before binding.
+ let value_optional = field.optional_value_type || field.default_missing.is_some();
let default_missing = match field.default_missing.as_deref() {
Some(value) => quote!(::core::option::Option::Some(#value.as_bytes())),
None => quote!(::core::option::Option::None),
@@ -1089,6 +1094,7 @@ fn flag_table(i: usize, field: &Field) -> TokenStream {
allow_negative_numbers: #allow_negative_numbers,
value_terminator: #value_terminator,
require_equals: #require_equals,
+ value_optional: #value_optional,
default_missing: #default_missing,
global: #global,
};
@@ -1819,6 +1825,9 @@ fn flag_arm(cli: &Cli, i: usize, field: &Field) -> TokenStream {
// Saturating, because a `u8` field given 256 occurrences would otherwise
// panic in debug and wrap to zero in release.
Shape::Count => quote!(partial.#ident = partial.#ident.saturating_add(1);),
+ Shape::Optional if field.optional_value_type => quote! {
+ partial.#ident = value.map(__usage_text);
+ },
Shape::Optional => quote! {
partial.#ident = ::std::option::Option::Some(__usage_value_text(value));
},
@@ -2810,6 +2819,7 @@ fn partial_defaults(cli: &Cli) -> TokenStream {
/// number, or a type of the adopter's own.
fn field_final(field: &Field) -> TokenStream {
let ident = &field.ident;
+ let given = format_ident!("__given_{}", ident);
let name = &field.name;
if matches!(field.kind, Kind::Skip) {
// clap's skip: not parsed, filled from Default when the struct is built.
@@ -2902,6 +2912,20 @@ fn field_final(field: &Field) -> TokenStream {
// A `match` rather than `.map`, and a loop rather than `.collect`, for the same
// reason the text path below uses them: the conversion can fail, and a `return`
// inside a closure would leave the error in the closure's own return type.
+ Shape::Optional if field.optional_value_type => {
+ let value = converted(quote!(__usage_value));
+ quote! {
+ #ident: match partial.#ident {
+ ::std::option::Option::Some(__usage_value) => {
+ ::std::option::Option::Some(::std::option::Option::Some(#value))
+ }
+ ::std::option::Option::None if partial.#given => {
+ ::std::option::Option::Some(::std::option::Option::None)
+ }
+ ::std::option::Option::None => ::std::option::Option::None,
+ }
+ }
+ }
Shape::Optional => {
let value = converted(quote!(__usage_value));
quote! {
@@ -2991,6 +3015,20 @@ fn field_final(field: &Field) -> TokenStream {
let one = converted(quote!(partial.#ident));
quote!(#ident: #one)
}
+ Shape::Optional if field.optional_value_type => {
+ let one = converted(quote!(__usage_value));
+ quote! {
+ #ident: match partial.#ident {
+ ::std::option::Option::Some(__usage_value) => {
+ ::std::option::Option::Some(::std::option::Option::Some(#one))
+ }
+ ::std::option::Option::None if partial.#given => {
+ ::std::option::Option::Some(::std::option::Option::None)
+ }
+ ::std::option::Option::None => ::std::option::Option::None,
+ }
+ }
+ }
Shape::Optional => {
let one = converted(quote!(__usage_value));
quote! {
diff --git a/derive/src/model.rs b/derive/src/model.rs
index f94189200..998391681 100644
--- a/derive/src/model.rs
+++ b/derive/src/model.rs
@@ -172,6 +172,8 @@ pub struct Field {
pub value_ty: Option,
/// Written as `Option>`, so "never given" and "given nothing" differ.
pub optional_collection: bool,
+ /// Written as `Option>`, so absent, bare, and valued flags differ.
+ pub optional_value_type: bool,
/// Whether the words come from the type, via [`ValueEnum`].
///
/// The alternative is `choices("a", "b")` written on the field, which is the same list
@@ -210,10 +212,7 @@ pub struct Field {
pub required_collection: bool,
/// Whether the flag's value may be left off: `[BUMP]` rather than ``.
///
- /// Help and the emitted spec only. usage-lib's parser refuses a bare `--bump` exactly as it
- /// refuses a bare `--port`, so this binds nothing differently — which is why it is stated
- /// here and not inferred from the type, where `Option` already means the *flag* is
- /// optional and says nothing about its value.
+ /// `Option>` infers this; `default_missing` also makes the value optional.
pub value_optional: bool,
/// The placeholder for a flag's value in help and in the emitted spec: `n` in
/// `--jobs `.
@@ -1434,6 +1433,7 @@ impl Field {
shape: Shape::Bool,
value_ty: None,
optional_collection: false,
+ optional_value_type: false,
help: None,
long_help: None,
env: None,
@@ -1559,6 +1559,7 @@ impl Field {
shape: Shape::Bool,
value_ty: None,
optional_collection: false,
+ optional_value_type: false,
help: None,
long_help: None,
env: None,
@@ -1678,6 +1679,7 @@ impl Field {
shape: Shape::Bool,
value_ty: None,
optional_collection: false,
+ optional_value_type: false,
help: None,
long_help: None,
env: None,
@@ -2178,6 +2180,7 @@ impl Field {
shape,
ty: value_ty,
optional_collection,
+ optional_value_type,
} = ValueKind::from_type(&field.ty, count, span)?;
let is_flag = !longs.is_empty() || !shorts.is_empty();
let (mut value_var_min, mut value_var_max) = (None, None);
@@ -2190,6 +2193,17 @@ impl Field {
var_max = max;
}
}
+ if optional_value_type {
+ if !is_flag {
+ return Err(syn::Error::new(
+ span,
+ "`Option>` represents a flag that may omit its value, so it needs `long` or `short`",
+ ));
+ }
+ value_optional = true;
+ value_var_min.get_or_insert(0);
+ value_var_max.get_or_insert(1);
+ }
// The spec records a default and the generated code applies it; anything it
// cannot apply would be documented and then ignored.
//
@@ -2323,7 +2337,12 @@ impl Field {
"`var_min` and `var_max` count values, so the field has to be a `Vec`",
));
}
- if num_args.is_some() && is_flag && value_var_min == Some(0) && default_missing.is_none() {
+ if num_args.is_some()
+ && is_flag
+ && value_var_min == Some(0)
+ && default_missing.is_none()
+ && !optional_value_type
+ {
return Err(syn::Error::new(
span,
"a flag whose `num_args` begins at zero distinguishes an absent flag from a \
@@ -2872,6 +2891,7 @@ impl Field {
shape,
value_ty,
optional_collection,
+ optional_value_type,
help,
long_help,
env,
@@ -2943,6 +2963,8 @@ pub struct ValueKind {
/// distinction mise's root draws three times. Values collect the same way; only the
/// field it is finally put into differs.
pub optional_collection: bool,
+ /// Whether one flag occurrence may have no value (`Option >`).
+ pub optional_value_type: bool,
}
impl ValueKind {
@@ -2951,6 +2973,7 @@ impl ValueKind {
shape,
ty: None,
optional_collection: false,
+ optional_value_type: false,
};
let name = type_name(ty);
if count {
@@ -2975,6 +2998,15 @@ impl ValueKind {
shape: Shape::Many,
ty: Some(inner),
optional_collection: true,
+ optional_value_type: false,
+ });
+ }
+ if let Some(inner) = peel(ty, "Option").as_ref().and_then(|o| peel(o, "Option")) {
+ return Ok(ValueKind {
+ shape: Shape::Optional,
+ ty: Some(inner),
+ optional_collection: false,
+ optional_value_type: true,
});
}
for (wrapper, shape) in [("Option", Shape::Optional), ("Vec", Shape::Many)] {
@@ -2983,6 +3015,7 @@ impl ValueKind {
shape,
ty: Some(inner),
optional_collection: false,
+ optional_value_type: false,
});
}
}
@@ -2993,6 +3026,7 @@ impl ValueKind {
shape: Shape::Required,
ty: Some(ty.clone()),
optional_collection: false,
+ optional_value_type: false,
})
}
}
diff --git a/docs/rust/args-and-flags.md b/docs/rust/args-and-flags.md
index 38908380c..d1dd5eceb 100644
--- a/docs/rust/args-and-flags.md
+++ b/docs/rust/args-and-flags.md
@@ -147,6 +147,11 @@ The flag has to take a value. Help shows the value as optional. Emitted KDL:
`flag "--color " default_missing="always"`. Combined with `require_equals`,
a following word is still refused.
+An `Option>` field preserves all three optional-value states: an absent
+flag is `None`, a bare `--bump` is `Some(None)`, and `--bump=5` is
+`Some(Some(5))`. It infers a zero-or-one value range and renders `[BUMP]` in help
+and the portable spec.
+
`#[usage(default_if("--json", "true"))]` is clap's `default_value_if` with
`ArgPredicate::IsPresent`. Three arguments (`default_if("--output", "json", "pretty")`)
are `Equals`. First match wins. The target's own argv and env suppress it. An
diff --git a/docs/rust/clap-compatibility.md b/docs/rust/clap-compatibility.md
index bbd3d3e7b..12f3a4861 100644
--- a/docs/rust/clap-compatibility.md
+++ b/docs/rust/clap-compatibility.md
@@ -51,32 +51,32 @@ the Rust declaration, not only from generated KDL, wherever the bridge column sa
## Arguments and values
-| clap surface | derive | argv | KDL | lib | output | bridge | Notes |
-| ---------------------------------------------------------- | ---------- | ---- | ----- | --- | ------ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `long`, `short`, visible aliases | yes | yes | yes | yes | yes | yes | Multiple forms are accepted and advertised by parsing, help, completion, and generated tables. |
-| hidden flag `alias` / `aliases` | yes | yes | yes | yes | yes | yes | Hidden aliases bind and round-trip through KDL and generated Rust/Go tables without appearing in help or completion. |
-| explicit `id` | yes | yes | yes | yes | yes | yes | `#[arg(id = "…")]` supplies the stable field identity used by relationships and generated specs. |
-| positional arguments | yes | yes | yes | yes | yes | yes | Required, optional, and variadic positionals are supported. |
-| `Option`, `Vec`, `Option>` | yes | yes | yes | yes | yes | n/a | Values use `FromStr`; Unix `PathBuf` and `OsString` preserve non-UTF-8 bytes. |
-| `ArgAction::Set`, `SetTrue`, `SetFalse`, `Append`, `Count` | yes | yes | yes | yes | yes | lossy | Common typed shapes are covered; arbitrary action/type combinations are not. |
-| `default_value` | yes | yes | yes | yes | yes | yes | Defaults apply after argv and environment values and clear token-required metadata. |
-| `default_missing_value` | yes | yes | yes | yes | yes | usage-only | `#[usage(default_missing = "…")]`; clap has no getter. |
-| `default_value_if(s)` | yes | yes | yes | yes | yes | usage-only | Presence and equality predicates are portable; clap has no getter. |
-| `env` | yes | yes | yes | yes | yes | lossy | Environment fallback works; the current bridge can lose the binding. |
-| `value_delimiter` | yes | yes | yes | yes | yes | yes | ASCII delimiters round-trip and are applied before arity checks. |
-| `num_args` ranges | yes | yes | yes | yes | yes | lossy | Nested value `var_min` / `var_max` preserve per-occurrence ranges separately from flag occurrence bounds; zero-minimum flag ranges can be bridge-lossy. |
-| fixed `num_args` with distinct `value_names` | yes | yes | yes | yes | yes | yes | `#[arg(num_args = 2, value_names = ["START", "END"])]` preserves the exact bound and both placeholders. |
-| `allow_hyphen_values` | yes | yes | yes | yes | yes | yes | Supported on value-taking flags; forwarded positionals use `double_dash = "automatic"`. |
-| `allow_negative_numbers` | yes | yes | yes | yes | yes | yes | Accepts negative numeric tokens without accepting arbitrary dash-prefixed values. |
-| `require_equals` | yes | yes | yes | yes | yes | yes | Detached values are refused. |
-| `value_terminator` | yes | yes | yes | yes | yes | yes | Ends a variadic value owner without binding the terminator token. |
-| `trailing_var_arg` / `last` | yes | yes | yes | yes | yes | lossy | `double_dash` carries automatic/required/optional; clap shadow generation still drops automatic mode. |
-| `dont_delimit_trailing_values` | yes | yes | yes | yes | yes | yes | Preserves delimiters after `--` and on automatic trailing positionals while ordinary values still split. |
-| possible-values parser | yes | yes | yes | yes | yes | yes | Use `ValueEnum` or `choices`. |
-| arbitrary `value_parser` callbacks | usage-only | yes | lossy | yes | yes | no | `FromStr` handles typed conversion and portable `validate` expressions handle declarative rules; Rust callbacks cannot enter KDL. |
-| `ValueHint::{FilePath,DirPath}` | yes | yes | yes | yes | yes | lossy | Shell-native path completion is supported directly; `clap_usage` does not yet lower hints into completion nodes. |
-| executable and command value hints | yes | yes | yes | yes | yes | lossy | Direct usage declarations work; the clap bridge currently reports and drops these hints. |
-| identity and network `ValueHint`s | no | no | no | no | no | lossy | Username, hostname, URL, email, and related hints are not yet represented. |
+| clap surface | derive | argv | KDL | lib | output | bridge | Notes |
+| ------------------------------------------------------------ | ---------- | ---- | ----- | --- | ------ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `long`, `short`, visible aliases | yes | yes | yes | yes | yes | yes | Multiple forms are accepted and advertised by parsing, help, completion, and generated tables. |
+| hidden flag `alias` / `aliases` | yes | yes | yes | yes | yes | yes | Hidden aliases bind and round-trip through KDL and generated Rust/Go tables without appearing in help or completion. |
+| explicit `id` | yes | yes | yes | yes | yes | yes | `#[arg(id = "…")]` supplies the stable field identity used by relationships and generated specs. |
+| positional arguments | yes | yes | yes | yes | yes | yes | Required, optional, and variadic positionals are supported. |
+| `Option`, `Vec`, `Option>`, `Option>` | yes | yes | yes | yes | yes | n/a | Nested `Option` preserves absent, bare, and explicitly valued flags; values use the declared `FromStr` or `ValueEnum` conversion. |
+| `ArgAction::Set`, `SetTrue`, `SetFalse`, `Append`, `Count` | yes | yes | yes | yes | yes | lossy | Common typed shapes are covered; arbitrary action/type combinations are not. |
+| `default_value` | yes | yes | yes | yes | yes | yes | Defaults apply after argv and environment values and clear token-required metadata. |
+| `default_missing_value` | yes | yes | yes | yes | yes | usage-only | `#[usage(default_missing = "…")]`; clap has no getter. |
+| `default_value_if(s)` | yes | yes | yes | yes | yes | usage-only | Presence and equality predicates are portable; clap has no getter. |
+| `env` | yes | yes | yes | yes | yes | lossy | Environment fallback works; the current bridge can lose the binding. |
+| `value_delimiter` | yes | yes | yes | yes | yes | yes | ASCII delimiters round-trip and are applied before arity checks. |
+| `num_args` ranges | yes | yes | yes | yes | yes | lossy | Nested value `var_min` / `var_max` preserve per-occurrence ranges separately from flag occurrence bounds; zero-minimum flag ranges can be bridge-lossy. |
+| fixed `num_args` with distinct `value_names` | yes | yes | yes | yes | yes | yes | `#[arg(num_args = 2, value_names = ["START", "END"])]` preserves the exact bound and both placeholders. |
+| `allow_hyphen_values` | yes | yes | yes | yes | yes | yes | Supported on value-taking flags; forwarded positionals use `double_dash = "automatic"`. |
+| `allow_negative_numbers` | yes | yes | yes | yes | yes | yes | Accepts negative numeric tokens without accepting arbitrary dash-prefixed values. |
+| `require_equals` | yes | yes | yes | yes | yes | yes | Detached values are refused. |
+| `value_terminator` | yes | yes | yes | yes | yes | yes | Ends a variadic value owner without binding the terminator token. |
+| `trailing_var_arg` / `last` | yes | yes | yes | yes | yes | lossy | `double_dash` carries automatic/required/optional; clap shadow generation still drops automatic mode. |
+| `dont_delimit_trailing_values` | yes | yes | yes | yes | yes | yes | Preserves delimiters after `--` and on automatic trailing positionals while ordinary values still split. |
+| possible-values parser | yes | yes | yes | yes | yes | yes | Use `ValueEnum` or `choices`. |
+| arbitrary `value_parser` callbacks | usage-only | yes | lossy | yes | yes | no | `FromStr` handles typed conversion and portable `validate` expressions handle declarative rules; Rust callbacks cannot enter KDL. |
+| `ValueHint::{FilePath,DirPath}` | yes | yes | yes | yes | yes | lossy | Shell-native path completion is supported directly; `clap_usage` does not yet lower hints into completion nodes. |
+| executable and command value hints | yes | yes | yes | yes | yes | lossy | Direct usage declarations work; the clap bridge currently reports and drops these hints. |
+| identity and network `ValueHint`s | no | no | no | no | no | lossy | Username, hostname, URL, email, and related hints are not yet represented. |
## Relationships and command routing
diff --git a/docs/rust/index.md b/docs/rust/index.md
index 665759107..e5d499642 100644
--- a/docs/rust/index.md
+++ b/docs/rust/index.md
@@ -149,8 +149,8 @@ equivalent yet:
- `example` nodes exist in the spec format but cannot be declared from the derive — put an
Examples section in `after_long_help` instead (mise does this).
-- `value_optional` alone affects help. Pair it with `default_missing` to define what a bare flag
- binds; arbitrary zero-or-one value ranges from clap are not inferred.
+- A declared `value_optional` needs either `default_missing` or an
+ `Option >` field to define what a bare flag binds.
- Rust `value_parser` functions are not portable metadata. Values use `FromStr`; use
`validate` for a portable expression rule and `validate_error` for its diagnostic.
- Long flags and subcommands require exact spellings. Diagnostics can suggest a close match, but
diff --git a/docs/spec/reference/flag.md b/docs/spec/reference/flag.md
index 9b0f350c0..960ae7838 100644
--- a/docs/spec/reference/flag.md
+++ b/docs/spec/reference/flag.md
@@ -59,6 +59,7 @@ flag "--jobs " allow_negative_numbers=#true // --jobs -1 binds "-1"
flag "--item - " var=#true value_terminator=";" // ; ends this occurrence
flag "--inspect
" require_equals=#true // --inspect=9229 yes, --inspect 9229 no
flag "--color " default_missing="always" // --color is always; --color=never is never
+flag "--bump [LEVEL]" value_optional=#true // absent, bare, and valued are distinct
flag "--bin-names" {
default_if "--json" "true" // --json implies --bin-names
}
@@ -249,6 +250,16 @@ the detached form stays refused.
A flag that takes no value cannot declare it.
+## `value_optional`
+
+A value-taking flag may be present without a value. This is executable parser
+policy: `flag "--bump [LEVEL]" value_optional=#true` distinguishes an absent
+flag, a bare `--bump`, and `--bump=major`. The nested argument's square brackets
+remain presentational on their own, so a spec can render `[LEVEL]` without
+silently changing what argv accepts.
+
+A flag that takes no value cannot declare it.
+
## `default_missing`
The value used when the flag is given with none: `--color` binds `always` if the
diff --git a/go/argv/argv.go b/go/argv/argv.go
index 41ef8d298..621338726 100644
--- a/go/argv/argv.go
+++ b/go/argv/argv.go
@@ -128,6 +128,10 @@ type Flag struct {
Negate string
// TakesValue is whether the flag takes a value.
TakesValue bool
+ // ValueOptional is whether an occurrence may omit that value. A bare flag
+ // still emits an event, with HasValue false, instead of producing a
+ // missing-value error.
+ ValueOptional bool
// Variadic is whether one occurrence of this flag keeps taking values, until a
// flag-like token or the end of the command line.
//
diff --git a/go/argv/parser.go b/go/argv/parser.go
index 63c303d3f..c41be075c 100644
--- a/go/argv/parser.go
+++ b/go/argv/parser.go
@@ -343,14 +343,15 @@ func (p *Parser) longFlag(token string) bool {
// `token`, not `--`+name: with no attached value the token is the
// spelling, and slicing it costs nothing on a path that must not
// allocate.
- v, ok := p.takeDetachedValue(flag, token, 0)
+ v, present, ok := p.takeDetachedValue(flag, token, 0)
if !ok {
return false
}
value = v
+ hasValue = present
}
}
- if flag.Variadic {
+ if flag.Variadic && hasValue {
p.startCollecting(flag, value)
}
return p.emit(Event{Kind: KindFlag, Flag: flag, Value: value, HasValue: hasValue})
@@ -424,22 +425,24 @@ func (p *Parser) shortFlag() bool {
// one separating =.
p.bundle = ""
var value string
+ hasValue := true
switch {
case rest == "":
- v, ok := p.takeDetachedValue(flag, "", b)
+ v, present, ok := p.takeDetachedValue(flag, "", b)
if !ok {
return false
}
value = v
+ hasValue = present
case rest[0] == '=':
value = rest[1:]
default:
value = rest
}
- if flag.Variadic {
+ if flag.Variadic && hasValue {
p.startCollecting(flag, value)
}
- return p.emit(Event{Kind: KindFlag, Flag: flag, Value: value, HasValue: true})
+ return p.emit(Event{Kind: KindFlag, Flag: flag, Value: value, HasValue: hasValue})
}
// takeDetachedValue takes the following token as a flag's value.
@@ -449,14 +452,14 @@ func (p *Parser) shortFlag() bool {
// form is available for the deliberate case. Declared, the next token is taken
// whatever it looks like, including `--`. RequireEquals refuses the following
// word either way. The negative-number exception means `--offset -1` still works.
-func (p *Parser) takeDetachedValue(flag *Flag, long string, short byte) (string, bool) {
+func (p *Parser) takeDetachedValue(flag *Flag, long string, short byte) (string, bool, bool) {
if flag.RequireEquals {
return p.missingOrDefault(flag, long, short)
}
if p.pos < len(p.argv) && (flag.AllowHyphenValues || !isFlagLike(p.argv[p.pos]) || (flag.AllowNegativeNumbers && isNegativeNumber(p.argv[p.pos]))) {
v := p.argv[p.pos]
p.pos++
- return v, true
+ return v, true, true
}
return p.missingOrDefault(flag, long, short)
}
@@ -465,10 +468,14 @@ func (p *Parser) takeDetachedValue(flag *Flag, long string, short byte) (string,
//
// DefaultMissing binds without consuming the next token, so `--color --verbose`
// still sets verbose and `--inspect 80` with RequireEquals leaves `80` for a
-// positional. Empty DefaultMissing is unset, and is then the missing-value error.
-func (p *Parser) missingOrDefault(flag *Flag, long string, short byte) (string, bool) {
+// positional. Empty DefaultMissing is unset; ValueOptional then emits a bare
+// occurrence, while a required value produces the missing-value error.
+func (p *Parser) missingOrDefault(flag *Flag, long string, short byte) (string, bool, bool) {
if flag.DefaultMissing != "" {
- return flag.DefaultMissing, true
+ return flag.DefaultMissing, true, true
+ }
+ if flag.ValueOptional {
+ return "", false, true
}
// The form the user actually wrote, carried so the advice can use it. A flag
// answers to several spellings and the first is not always the one in front of
@@ -484,7 +491,7 @@ func (p *Parser) missingOrDefault(flag *Flag, long string, short byte) (string,
typed = "-" + string(short)
}
p.fail(Error{Code: CodeMissingFlagValue, Flag: flag, Token: typed})
- return "", false
+ return "", false, false
}
func (p *Parser) word(token string) bool {
diff --git a/go/argv/parser_test.go b/go/argv/parser_test.go
index 6618bceed..560235bad 100644
--- a/go/argv/parser_test.go
+++ b/go/argv/parser_test.go
@@ -360,6 +360,39 @@ func TestDefaultMissing(t *testing.T) {
}
}
+func TestOptionalFlagValue(t *testing.T) {
+ color := &Flag{Key: 9, Name: "color", Longs: []string{"color"}, Shorts: []byte{'c'},
+ TakesValue: true, ValueOptional: true}
+ verbose := &Flag{Key: 10, Name: "verbose", Longs: []string{"verbose"}, Shorts: []byte{'v'}}
+ rest := &Arg{Key: 11, Name: "rest"}
+ cmd := &Command{Name: "ex", Flags: []*Flag{color, verbose}, Args: []*Arg{rest}}
+
+ for _, tc := range []struct {
+ name string
+ argv []string
+ want string
+ }{
+ {"bare long", []string{"--color"}, "flag:color"},
+ {"explicit long", []string{"--color=never"}, "flag:color=never"},
+ {"detached long", []string{"--color", "never"}, "flag:color=never"},
+ {"later flag", []string{"--color", "--verbose"}, "flag:color flag:verbose"},
+ {"bare short", []string{"-c"}, "flag:color"},
+ {"attached short", []string{"-cnever"}, "flag:color=never"},
+ {"explicit empty", []string{"--color="}, "flag:color="},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := collect(cmd, tc.argv...); got != tc.want {
+ t.Fatalf("got %q, want %q", got, tc.want)
+ }
+ })
+ }
+
+ color.RequireEquals = true
+ if got := collect(cmd, "--color", "rest"); got != "flag:color arg:rest=rest" {
+ t.Fatalf("require equals: got %q", got)
+ }
+}
+
// Bind only: choices live in Check. A missing default that is not on the list
// still binds, and Check is what refuses it — the same path as `--color=wat`.
func TestDefaultMissingGoesThroughChoices(t *testing.T) {
diff --git a/go/internal/spec/spec.go b/go/internal/spec/spec.go
index b9ad79ea4..56018f257 100644
--- a/go/internal/spec/spec.go
+++ b/go/internal/spec/spec.go
@@ -236,6 +236,7 @@ type Flag struct {
RequiresIf []RequiresIf `json:"requires_if"`
DefaultIf []DefaultIf `json:"default_if"`
RequireEquals bool `json:"require_equals"`
+ ValueOptional bool `json:"value_optional"`
// Empty means unset: usage-lib stores Option, and a missing default of "" is
// not carried across the lowering. The corpus never uses one.
DefaultMissing string `json:"default_missing"`
@@ -863,6 +864,7 @@ func (b *builder) flag(f *Flag, strictDuplicates bool) *argv.Flag {
AllowHyphenValues: f.Arg != nil && strings.EqualFold(f.Arg.DoubleDash, "automatic"),
AllowNegativeNumbers: f.Arg != nil && f.Arg.AllowNegativeNumbers,
RequireEquals: f.RequireEquals,
+ ValueOptional: f.ValueOptional,
DefaultMissing: f.DefaultMissing,
Global: f.Global,
}
diff --git a/go/internal/spec/spec_test.go b/go/internal/spec/spec_test.go
index bc395495a..339df1ab9 100644
--- a/go/internal/spec/spec_test.go
+++ b/go/internal/spec/spec_test.go
@@ -629,6 +629,18 @@ func TestDefaultMissingCarries(t *testing.T) {
}
}
+func TestValueOptionalCarries(t *testing.T) {
+ root, _ := build(&Spec{
+ Name: "ex", Bin: "ex",
+ Cmd: Cmd{Name: "ex", Flags: []Flag{
+ {Name: "color", Long: []string{"color"}, ValueOptional: true, Arg: &Arg{Name: "WHEN"}},
+ }},
+ })
+ if !root.Flags[0].ValueOptional {
+ t.Error("value_optional should carry")
+ }
+}
+
func TestDefaultIfResolves(t *testing.T) {
root, meta := build(&Spec{
Name: "ex", Bin: "ex",
diff --git a/lib/src/go/mod.rs b/lib/src/go/mod.rs
index e596e091f..9b4a0f276 100644
--- a/lib/src/go/mod.rs
+++ b/lib/src/go/mod.rs
@@ -1292,6 +1292,9 @@ fn flag_literal(flag: &SpecFlag, named: &Named) -> String {
if flag.arg.is_some() {
fields.push("TakesValue: true".to_string());
}
+ if flag.value_optional {
+ fields.push("ValueOptional: true".to_string());
+ }
// Only a variadic *argument* is greedy. The spec's flag-level `var` means the
// flag may be repeated and takes one value each time, which needs nothing from
// the parser: it reports every occurrence separately either way. Conflating the
@@ -1955,6 +1958,15 @@ cmd "run" arg_required_else_help=#true {
assert!(out.contains("Name: \"required\", Required: true"), "{out}");
}
+ #[test]
+ fn optional_flag_values_reach_generated_go() {
+ let out = go("name \"ex\"\nbin \"ex\"\nflag \"--color [WHEN]\" value_optional=#true\n");
+ assert!(
+ out.contains("TakesValue: true, ValueOptional: true"),
+ "{out}"
+ );
+ }
+
#[test]
fn granular_help_hides_reach_generated_go() {
let out = go(
diff --git a/lib/src/parse.rs b/lib/src/parse.rs
index 60cc5650f..b8bdf8708 100644
--- a/lib/src/parse.rs
+++ b/lib/src/parse.rs
@@ -1037,8 +1037,8 @@ fn parse_partial_with_env(
continue;
}
- // A flag with `default_missing` that cannot take this token as a detached
- // value binds that string and leaves the token for whatever comes next:
+ // A flag whose value may be omitted that cannot take this token as a detached
+ // value finishes bare and leaves the token for whatever comes next:
// `--color --verbose` colours with the missing value and still sets verbose,
// and `--inspect 9229` with `require_equals` binds the missing value rather
// than treating 9229 as the port.
@@ -1046,7 +1046,7 @@ fn parse_partial_with_env(
&& !attached_continuation
&& !out.flag_awaiting_value.is_empty()
&& out.flag_awaiting_value.last().is_some_and(|flag| {
- flag.default_missing.is_some()
+ (flag.default_missing.is_some() || flag.value_optional)
&& (flag.require_equals
|| (is_flag_like(&w)
&& !flag.allow_hyphen_values()
@@ -1980,13 +1980,29 @@ fn parse_partial_with_env(
// Validate var_min/var_max constraints for variadic flags. These are bounds on
// repeated occurrences of the flag itself. Bounds on its nested argument are enforced
// by binding once per occurrence, where the per-occurrence count is still available.
- for (flag, value) in &out.flags {
+ for flag in unique_flags(out.available_flags.values()) {
if flag.var {
- let count = match value {
- ParseValue::MultiString(values) => values.len(),
- ParseValue::MultiBool(values) => values.len(),
- _ => continue,
+ let bound = match out.flags.get(flag) {
+ Some(ParseValue::MultiString(values)) => values.len(),
+ Some(ParseValue::MultiBool(values)) => values.len(),
+ Some(_) => 1,
+ None => 0,
};
+ // A partial parse deliberately leaves the final value-optional flag pending so
+ // completion can still answer for it. It is nevertheless a real occurrence for
+ // the repeated flag's bounds; the full parser closes it just after this phase.
+ let pending = out
+ .flag_awaiting_value
+ .iter()
+ .filter(|pending| {
+ Arc::ptr_eq(pending, flag)
+ && (pending.value_optional || pending.default_missing.is_some())
+ })
+ .count();
+ let count = bound + pending;
+ if count == 0 {
+ continue;
+ }
if let Some(min) = flag.var_min {
if count < min {
out.errors.push(UsageErr::VarFlagTooFew {
@@ -2834,7 +2850,7 @@ fn value_count(value: &ParseValue) -> usize {
}
}
-/// Bind [`SpecFlag::default_missing`] to a flag that was given with no value.
+/// Finish a value-optional flag that was given with no value.
///
/// Returns whether anything was bound. Completions keep the flag waiting — a
/// half-typed `--color ` is a question about the value — so this is asked only
@@ -2852,8 +2868,40 @@ fn try_bind_default_missing(
let Some(flag) = flag_awaiting_value.last() else {
return Ok(false);
};
- let Some(value) = flag.default_missing.clone() else {
- return Ok(false);
+ let value = match flag.default_missing.clone() {
+ Some(value) => value,
+ None if flag.value_optional => {
+ let flag = flag_awaiting_value.pop().unwrap();
+ // Presence in the map distinguishes this from an absent flag; an
+ // empty collection distinguishes it from an explicitly empty
+ // `--flag=` string without inventing a sentinel value.
+ let variadic_value = flag.arg.as_ref().is_some_and(|arg| arg.var);
+ if flag.var {
+ // A repeated bare occurrence is still an occurrence. The string collection
+ // uses an empty value for it, just as the concrete `default_missing` path
+ // pushes one value per occurrence; otherwise bounds and consumers silently
+ // lose every bare repeat after the first.
+ flags
+ .entry(flag)
+ .or_insert_with(|| ParseValue::MultiString(Vec::new()))
+ .try_as_multi_string_mut()
+ .unwrap()
+ .push(String::new());
+ } else if variadic_value {
+ // A variadic occurrence stays pending after each value. Reaching the next
+ // flag (or EOF) closes that same occurrence; it must not erase what it took.
+ flags
+ .entry(flag)
+ .or_insert_with(|| ParseValue::MultiString(Vec::new()));
+ } else {
+ // A scalar pending here is a new bare occurrence. The normal permissive
+ // repeat policy makes the later occurrence a correction, including a
+ // correction from an explicit value back to the bare tri-state.
+ flags.insert(flag, ParseValue::MultiString(Vec::new()));
+ }
+ return Ok(true);
+ }
+ None => return Ok(false),
};
if let Some(arg) = flag.arg.as_ref() {
validate_choice_value(
@@ -7627,6 +7675,124 @@ flag "-v --verbose"
assert!(parsed.flags.keys().any(|f| f.name == "verbose"));
}
+ #[test]
+ fn test_optional_flag_value_preserves_bare_and_explicit_empty_forms() {
+ let spec = r#"
+flag "--bump [LEVEL]" value_optional=#true
+flag "--verbose"
+arg "[FILE]"
+"#
+ .parse::()
+ .unwrap();
+
+ let absent = parse(&spec, &input(&["test"])).unwrap();
+ assert!(!absent.flags.keys().any(|flag| flag.name == "bump"));
+
+ let bare = parse(&spec, &input(&["test", "--bump", "--verbose", "file.txt"])).unwrap();
+ let bump = bare
+ .flags
+ .iter()
+ .find(|(flag, _)| flag.name == "bump")
+ .map(|(_, value)| value)
+ .unwrap();
+ assert!(matches!(bump, ParseValue::MultiString(values) if values.is_empty()));
+ assert!(bare.flags.keys().any(|flag| flag.name == "verbose"));
+ assert_eq!(arg_value(&bare, "FILE"), "file.txt");
+
+ let explicit = parse(&spec, &input(&["test", "--bump=", "file.txt"])).unwrap();
+ assert_eq!(flag_string_value(&explicit, "bump"), "");
+
+ let corrected = parse(
+ &spec,
+ &input(&["test", "--bump=2", "--bump", "--verbose", "file.txt"]),
+ )
+ .unwrap();
+ let bump = corrected
+ .flags
+ .iter()
+ .find(|(flag, _)| flag.name == "bump")
+ .map(|(_, value)| value)
+ .unwrap();
+ assert!(matches!(bump, ParseValue::MultiString(values) if values.is_empty()));
+
+ let collecting = r#"
+flag "--tag [TAG]..." value_optional=#true
+flag "--verbose"
+"#
+ .parse::()
+ .unwrap();
+ let valued = parse(
+ &collecting,
+ &input(&["test", "--tag", "one", "two", "--verbose"]),
+ )
+ .unwrap();
+ let tag = valued
+ .flags
+ .iter()
+ .find(|(flag, _)| flag.name == "tag")
+ .map(|(_, value)| value)
+ .unwrap();
+ assert!(matches!(tag, ParseValue::MultiString(values) if values == &["one", "two"]));
+ }
+
+ #[test]
+ fn test_repeatable_bare_optional_values_count_each_occurrence() {
+ let spec = r#"
+flag "--tag [TAG]" var=#true var_min=2 var_max=2 value_optional=#true
+"#
+ .parse::()
+ .unwrap();
+
+ let parsed = parse(&spec, &input(&["test", "--tag", "--tag"])).unwrap();
+ let tag = parsed
+ .flags
+ .iter()
+ .find(|(flag, _)| flag.name == "tag")
+ .map(|(_, value)| value)
+ .unwrap();
+ assert!(matches!(tag, ParseValue::MultiString(values) if values == &["", ""]));
+
+ assert!(parse(&spec, &input(&["test", "--tag"])).is_err());
+ assert!(parse(&spec, &input(&["test", "--tag", "--tag", "--tag"])).is_err());
+ }
+
+ #[test]
+ fn test_repeatable_variadic_optional_values_do_not_gain_bare_occurrences() {
+ let spec = r#"
+flag "--tag [TAG]..." var=#true value_optional=#true
+flag "--verbose"
+"#
+ .parse::()
+ .unwrap();
+
+ for argv in [
+ &["test", "--tag", "one", "two"][..],
+ &["test", "--tag", "one", "two", "--verbose"][..],
+ &["test", "--tag", "one", "--tag", "two"][..],
+ ] {
+ let parsed = parse(&spec, &input(argv)).unwrap();
+ let tag = parsed
+ .flags
+ .iter()
+ .find(|(flag, _)| flag.name == "tag")
+ .map(|(_, value)| value)
+ .unwrap();
+ assert!(
+ matches!(tag, ParseValue::MultiString(values) if values == &["one", "two"]),
+ "argv={argv:?}: {tag:?}"
+ );
+ }
+
+ let bare = parse(&spec, &input(&["test", "--tag", "--verbose"])).unwrap();
+ let tag = bare
+ .flags
+ .iter()
+ .find(|(flag, _)| flag.name == "tag")
+ .map(|(_, value)| value)
+ .unwrap();
+ assert!(matches!(tag, ParseValue::MultiString(values) if values == &[""]));
+ }
+
#[test]
fn test_default_missing_with_require_equals_refuses_the_following_word() {
let spec = r#"
diff --git a/lib/src/spec/arg.rs b/lib/src/spec/arg.rs
index 8f3591c90..d461d9274 100644
--- a/lib/src/spec/arg.rs
+++ b/lib/src/spec/arg.rs
@@ -781,11 +781,10 @@ pub(crate) fn default_values(arg: &clap::Arg) -> Vec {
/// Carry clap's value-count range into the spec where the two parsers mean the same thing.
///
-/// A positional may accept zero values: its ordinary optionality already gives the binder a
-/// path that consumes nothing. A flag with `num_args(0..)` is different — a bare occurrence is
-/// itself valid — and needs optional-value binding, which the clap bridge cannot recover
-/// losslessly without `default_missing_value` (a setter with no getter). Leave that range absent
-/// rather than writing a bound that claims the bare flag is supported.
+/// A positional may accept zero values through its ordinary optionality. A flag
+/// with `num_args(0..)` additionally permits a bare occurrence; callers pass
+/// `zero_values_supported` only when they also carry that executable policy on
+/// the containing flag.
#[cfg(feature = "clap")]
pub(crate) fn value_bounds(source: &clap::Arg, target: &mut SpecArg, zero_values_supported: bool) {
// clap verifies num_args against raw command-line tokens and only splits each token on the
diff --git a/lib/src/spec/builder.rs b/lib/src/spec/builder.rs
index 2442fc35b..cff83edd7 100644
--- a/lib/src/spec/builder.rs
+++ b/lib/src/spec/builder.rs
@@ -262,6 +262,12 @@ impl SpecFlagBuilder {
self
}
+ /// Allow this flag to be present without a value.
+ pub fn value_optional(mut self, optional: bool) -> Self {
+ self.inner.value_optional = optional;
+ self
+ }
+
/// Value used when the flag is present but no value is given.
pub fn default_missing(mut self, value: impl Into) -> Self {
self.inner.default_missing = Some(value.into());
diff --git a/lib/src/spec/flag.rs b/lib/src/spec/flag.rs
index 1acf26876..6170844b0 100644
--- a/lib/src/spec/flag.rs
+++ b/lib/src/spec/flag.rs
@@ -214,6 +214,12 @@ pub struct SpecFlag {
/// is the fleet case.
#[serde(skip_serializing_if = "is_false")]
pub require_equals: bool,
+ /// Whether a value-taking flag may be present without a value.
+ ///
+ /// This is executable parser policy, distinct from the nested argument's
+ /// `required` bit, which controls whether help renders `` or `[VALUE]`.
+ #[serde(skip_serializing_if = "is_false")]
+ pub value_optional: bool,
/// Value used when the flag is present but no value is given.
///
/// clap's `default_missing_value`: `--color` binds this string, `--color=never`
@@ -300,6 +306,7 @@ impl SpecFlag {
"requires" => flag.requires = vec![v.ensure_string()?],
"exclusive" => flag.exclusive = v.ensure_bool()?,
"require_equals" => flag.require_equals = v.ensure_bool()?,
+ "value_optional" => flag.value_optional = v.ensure_bool()?,
"default_missing" => flag.default_missing = Some(v.ensure_string()?),
// Written on the flag and kept on its argument, as `allow_hyphen_values`
// is: the value is what gets split, and `flag "--tags "` is where a
@@ -501,6 +508,7 @@ impl SpecFlag {
}
"exclusive" => flag.exclusive = child.arg(0)?.ensure_bool()?,
"require_equals" => flag.require_equals = child.arg(0)?.ensure_bool()?,
+ "value_optional" => flag.value_optional = child.arg(0)?.ensure_bool()?,
"default_missing" => {
flag.default_missing = Some(child.arg(0)?.ensure_string()?);
}
@@ -593,6 +601,13 @@ impl SpecFlag {
"flag must have value to require equals"
);
}
+ if flag.value_optional && flag.arg.is_none() {
+ bail_parse!(
+ ctx,
+ node.node.name().span(),
+ "flag must have a value to make that value optional"
+ );
+ }
if flag.default_missing.is_some() && flag.arg.is_none() {
bail_parse!(
ctx,
@@ -877,6 +892,9 @@ impl From<&SpecFlag> for KdlNode {
if flag.require_equals {
node.push(KdlEntry::new_prop("require_equals", true));
}
+ if flag.value_optional {
+ node.push(KdlEntry::new_prop("value_optional", true));
+ }
if let Some(missing) = &flag.default_missing {
node.push(string_entry(Some("default_missing"), missing));
}
@@ -1077,7 +1095,7 @@ impl From<&clap::Arg> for SpecFlag {
// These bounds live on the nested value argument and are enforced per occurrence.
// That preserves both a single `Set` and each repetition of `Append`.
- crate::spec::arg::value_bounds(c, &mut arg, false);
+ crate::spec::arg::value_bounds(c, &mut arg, true);
Some(arg)
} else {
@@ -1109,6 +1127,9 @@ impl From<&clap::Arg> for SpecFlag {
// This one clap does expose, unlike `requires` just above.
exclusive: c.is_exclusive_set(),
require_equals: c.is_require_equals_set(),
+ value_optional: arg.is_some()
+ && c.get_num_args()
+ .is_some_and(|n| n.min_values() == 0 && n.max_values() > 0),
// clap 4 has `Arg::default_missing_value` as a setter with no getter.
default_missing: None,
help,
@@ -1468,6 +1489,41 @@ mod tests {
assert!(inspect.require_equals);
}
+ #[test]
+ fn optional_flag_value_policy_round_trips_separately_from_help() {
+ let spec: Spec = "flag \"--bump [LEVEL]\" value_optional=#true\n"
+ .parse()
+ .unwrap();
+ let bump = &spec.cmd.flags[0];
+ assert!(bump.value_optional);
+ assert!(!bump.arg.as_ref().unwrap().required);
+
+ let rendered = spec.to_string();
+ assert!(rendered.contains("value_optional=#true"), "{rendered}");
+ let reparsed: Spec = rendered.parse().unwrap();
+ assert!(reparsed.cmd.flags[0].value_optional);
+
+ let presentation_only: Spec = "flag \"--bump [LEVEL]\"\n".parse().unwrap();
+ assert!(!presentation_only.cmd.flags[0].value_optional);
+
+ let command = clap::Command::new("ex").arg(
+ clap::Arg::new("bump")
+ .long("bump")
+ .action(clap::ArgAction::Set)
+ .num_args(0..=1),
+ );
+ let bridged = Spec::from(&command);
+ assert!(bridged.cmd.flags[0].value_optional);
+
+ let zero_arity = clap::Command::new("ex").arg(
+ clap::Arg::new("plain")
+ .long("plain")
+ .action(clap::ArgAction::Set)
+ .num_args(0),
+ );
+ assert!(!Spec::from(&zero_arity).cmd.flags[0].value_optional);
+ }
+
#[test]
fn default_missing_round_trips_and_cannot_come_across_from_clap() {
let spec: Spec = "flag \"--color \" default_missing=\"always\"\n"
@@ -1637,7 +1693,7 @@ mod tests {
}
#[test]
- fn an_optional_flag_value_is_not_misreported_as_a_supported_bound() {
+ fn an_optional_flag_value_carries_its_policy_and_bound() {
let cmd = clap::Command::new("ex").arg(
clap::Arg::new("values")
.long("values")
@@ -1647,8 +1703,9 @@ mod tests {
let spec = Spec::from(&cmd);
let values = spec.cmd.flags[0].arg.as_ref().unwrap();
- assert_eq!(values.var_min, None);
- assert_eq!(values.var_max, None);
+ assert!(spec.cmd.flags[0].value_optional);
+ assert_eq!(values.var_min, Some(0));
+ assert_eq!(values.var_max, Some(3));
assert_eq!(spec.cmd.flags[0].var_min, None);
assert_eq!(spec.cmd.flags[0].var_max, None);
}
diff --git a/usage-rs/tests/facade.rs b/usage-rs/tests/facade.rs
index b97fac439..7db315a37 100644
--- a/usage-rs/tests/facade.rs
+++ b/usage-rs/tests/facade.rs
@@ -93,6 +93,20 @@ struct HiddenHelp {
mode: String,
}
+#[derive(Debug, Cli)]
+#[usage(bin = "optional-value")]
+struct OptionalValue {
+ #[usage(long)]
+ bump: Option>,
+}
+
+#[derive(Debug, Cli)]
+#[usage(bin = "help-optional-value")]
+struct HelpOptionalValue {
+ #[usage(long, value_optional)]
+ bump: Option,
+}
+
#[derive(Cli)]
#[command(
bin = "presented",
@@ -1074,6 +1088,39 @@ fn typed_granular_help_hides_reach_the_portable_spec() {
assert_eq!(HiddenHelp::parse_from(&[]).unwrap().mode, "fast");
}
+#[test]
+fn nested_option_distinguishes_absent_bare_and_valued_flags() {
+ assert_eq!(OptionalValue::parse_from(&[]).unwrap().bump, None);
+ assert_eq!(
+ OptionalValue::parse_from(&[OsStr::new("--bump")])
+ .unwrap()
+ .bump,
+ Some(None)
+ );
+ assert_eq!(
+ OptionalValue::parse_from(&[OsStr::new("--bump=5")])
+ .unwrap()
+ .bump,
+ Some(Some(5))
+ );
+ let kdl = OptionalValue::to_kdl();
+ assert!(kdl.contains("flag --bump"), "{kdl}");
+ assert!(kdl.contains("[BUMP]"), "{kdl}");
+}
+
+#[test]
+fn help_only_optional_values_still_require_a_typed_value() {
+ assert!(HelpOptionalValue::parse_from(&[OsStr::new("--bump")]).is_err());
+ assert_eq!(
+ HelpOptionalValue::parse_from(&[OsStr::new("--bump=5")])
+ .unwrap()
+ .bump,
+ Some(5)
+ );
+ let kdl = HelpOptionalValue::to_kdl();
+ assert!(kdl.contains("[BUMP]"), "{kdl}");
+}
+
#[test]
fn typed_subcommand_presentation_reaches_help_and_the_spec() {
let kdl = PresentedSubcommands::to_kdl();