From 7a8d285ce3aaf35b973fa5c30635c19aa3702b64 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Thu, 20 Aug 2026 06:14:30 +0000 Subject: [PATCH 1/4] feat(parse): support optional flag values --- PLAN.md | 4 + argv/src/lib.rs | 116 ++++++++++++++++++++--- argv/src/spec.rs | 3 + conformance/src/tables.rs | 1 + conformance/tests/optional_flag_value.rs | 35 +++++++ derive/src/codegen.rs | 38 ++++++++ derive/src/model.rs | 44 ++++++++- docs/rust/args-and-flags.md | 5 + docs/rust/clap-compatibility.md | 52 +++++----- docs/rust/index.md | 4 +- docs/spec/reference/flag.md | 11 +++ lib/src/parse.rs | 93 ++++++++++++++++-- lib/src/spec/arg.rs | 9 +- lib/src/spec/builder.rs | 6 ++ lib/src/spec/flag.rs | 65 ++++++++++++- usage-rs/tests/facade.rs | 47 +++++++++ 16 files changed, 473 insertions(+), 60 deletions(-) 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..9d7a5d001 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 `FromStr`. | +| `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/lib/src/parse.rs b/lib/src/parse.rs index 60cc5650f..6f47b1fd9 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() @@ -2834,7 +2834,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 +2852,29 @@ 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 collecting = flag.var || flag.arg.as_ref().is_some_and(|arg| arg.var); + if collecting { + // 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 +7648,66 @@ 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_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(); From 925565dfccfa5b286006d5a9fe117e036fa3114c Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:32:58 +0000 Subject: [PATCH 2/4] fix(parse): carry optional values into go tables --- go/argv/argv.go | 4 ++++ go/argv/parser.go | 29 ++++++++++++++++++----------- go/argv/parser_test.go | 33 +++++++++++++++++++++++++++++++++ go/internal/spec/spec.go | 2 ++ go/internal/spec/spec_test.go | 12 ++++++++++++ lib/src/go/mod.rs | 12 ++++++++++++ 6 files changed, 81 insertions(+), 11 deletions(-) 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( From 543cea968b36fc64222ae41fe46a4a6a8ec06abb Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:06:25 +0000 Subject: [PATCH 3/4] fix(parse): count repeatable bare optional flags --- lib/src/parse.rs | 99 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 92 insertions(+), 7 deletions(-) diff --git a/lib/src/parse.rs b/lib/src/parse.rs index 6f47b1fd9..b8bdf8708 100644 --- a/lib/src/parse.rs +++ b/lib/src/parse.rs @@ -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 { @@ -2859,8 +2875,19 @@ fn try_bind_default_missing( // 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 collecting = flag.var || flag.arg.as_ref().is_some_and(|arg| arg.var); - if collecting { + 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 @@ -7708,6 +7735,64 @@ flag "--verbose" 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#" From eefc14fbe49c7b4aa84269107f43a185ae48b52d Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:06:25 +0000 Subject: [PATCH 4/4] fix(parse): count repeatable bare optional flags --- docs/rust/clap-compatibility.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rust/clap-compatibility.md b/docs/rust/clap-compatibility.md index 9d7a5d001..12f3a4861 100644 --- a/docs/rust/clap-compatibility.md +++ b/docs/rust/clap-compatibility.md @@ -57,7 +57,7 @@ the Rust declaration, not only from generated KDL, wherever the bridge column sa | 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 `FromStr`. | +| `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. |