From 5cff1182378ca22a1803c2a50f54448b46a9a240 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:27:36 +0000 Subject: [PATCH 1/7] docs: audit 6.x release documentation --- PLAN.md | 12 ++++++------ docs/rust/completions.md | 2 +- docs/spec/integrations/clap.md | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/PLAN.md b/PLAN.md index 82e9c792b..4ad83f704 100644 --- a/PLAN.md +++ b/PLAN.md @@ -766,12 +766,12 @@ feature list is not an exhaustive audit. compatibility baseline, and the parser-behavior semver policy. State which builder and `ArgMatches` APIs are architectural non-goals instead of leaving their absence implicit. -- [ ] **A release documentation audit.** Generate the limitations page from, or - check it against, the compatibility matrix; verify dependency snippets against - workspace versions; and remove stale claims after features land. Today the - Rust limitations page still says non-UTF-8 `OsString` values cannot be accepted, - and the clap integration still recommends `clap_usage = "2"` while this - workspace is on version 5. +- [x] **A release documentation audit.** The limitations page is checked against + the versioned compatibility matrix and dependency snippets consistently name + the 6.x epoch. Stale claims about non-UTF-8 values and prefix inference have + been removed; the clap integration now recommends the matching + `clap_usage = "6"`. Concrete workspace and generated-artifact versions remain + release-plz's responsibility. - [ ] **Completion ecosystem coverage.** Decide whether general clap parity includes every shell in `clap_complete` and every `ValueHint`. At minimum, either add Elvish beside bash, fish, PowerShell and zsh or document it as a launch non-goal; diff --git a/docs/rust/completions.md b/docs/rust/completions.md index 50c6a6ae9..11429f730 100644 --- a/docs/rust/completions.md +++ b/docs/rust/completions.md @@ -10,7 +10,7 @@ Completion support is opt-in: add `completion` to the root attribute and enable ```toml [dependencies] -usage = { package = "usage-rs", version = "5", features = ["completions"] } +usage = { package = "usage-rs", version = "6", features = ["completions"] } ``` ```rust diff --git a/docs/spec/integrations/clap.md b/docs/spec/integrations/clap.md index 217a7c0c9..5fb8fd82f 100644 --- a/docs/spec/integrations/clap.md +++ b/docs/spec/integrations/clap.md @@ -6,7 +6,7 @@ ```toml [dependencies] -clap_usage = "5" +clap_usage = "6" ``` ## Quick Start From 4bdc1258c627354781a06d73014cf5d53d1e320b Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:05:44 +0000 Subject: [PATCH 2/7] fix(complete): reset command paths after restart --- argv/src/complete.rs | 41 ++++++++++++++++++++++++++++++------ argv/src/script.rs | 5 ++++- cli/src/cli/complete_word.rs | 4 +++- cli/tests/complete_word.rs | 22 +++++++++++++++++++ 4 files changed, 64 insertions(+), 8 deletions(-) diff --git a/argv/src/complete.rs b/argv/src/complete.rs index 07922ff34..459c5e125 100644 --- a/argv/src/complete.rs +++ b/argv/src/complete.rs @@ -625,9 +625,9 @@ fn files_for(name: &str) -> Option { } } -fn declared_files(type_: &str, position: &Position<'_>) -> Option { +fn declared_files(type_: &str, next_arg_values: u32) -> Option { if type_.eq_ignore_ascii_case("command_args") { - return Some(if position.next_arg_values == 0 { + return Some(if next_arg_values == 0 { Files::Commands } else { Files::Any @@ -649,7 +649,8 @@ fn declared_files_at_cursor( return None; } let meta = metadata_chain_on_route(spec, position).and_then(|chain| chain.last().copied()); - let at_cursor = if restarted(meta, split) { + let after_restart = restarted(meta, split); + let at_cursor = if after_restart { meta.and_then(|m| m.args.first()).map(|m| m.arg) } else { position @@ -675,7 +676,16 @@ fn declared_files_at_cursor( (None, None) }; complete_type - .and_then(|type_| declared_files(type_, position)) + .and_then(|type_| { + declared_files( + type_, + if after_restart { + 0 + } else { + position.next_arg_values + }, + ) + }) .or_else(|| name.and_then(files_for)) } @@ -700,7 +710,8 @@ pub fn complete<'a>(spec: &'a Spec<'a>, split: &Split) -> Completions<'a> { // that the two halves cannot disagree. Past a restart token it is the command's *first* // argument, whatever the words before the token filled, and everything below follows from // that: whether paths belong, whether the set is declared, whether a separator is owed. - let at_cursor = if restarted(meta, split) { + let after_restart = restarted(meta, split); + let at_cursor = if after_restart { meta.and_then(|m| m.args.first()).map(|m| m.arg) } else { position.next_arg @@ -730,7 +741,16 @@ pub fn complete<'a>(spec: &'a Spec<'a>, split: &Split) -> Completions<'a> { (None, false, None) }; let asked_for = complete_type - .and_then(|type_| declared_files(type_, &position)) + .and_then(|type_| { + declared_files( + type_, + if after_restart { + 0 + } else { + position.next_arg_values + }, + ) + }) .or_else(|| named.and_then(files_for)); // An argument that requires a separator is not fillable yet, so nothing else belongs here — @@ -1925,10 +1945,12 @@ mod tests { static META_EXEC: CommandMeta = CommandMeta { cmd: &EXEC, about: Some("Run something"), + restart_token: Some(":::"), args: &[ArgMeta { arg: &FORWARDED, help: Some("What to run"), choices: &["one", "two"], + complete_type: Some("command_args"), ..ArgMeta::EMPTY }], ..CommandMeta::EMPTY @@ -2893,6 +2915,13 @@ mod tests { assert_eq!(mistyped.files, None, "a mistyped choice is still a choice"); } + #[test] + fn a_restart_makes_command_args_expect_a_command_again() { + assert_eq!(answer("mise exec ").files, Some(Files::Commands)); + assert_eq!(answer("mise exec one ").files, Some(Files::Any)); + assert_eq!(answer("mise exec one ::: ").files, Some(Files::Commands)); + } + #[test] fn each_shell_is_written_the_way_it_reads() { let answer = complete(&SPEC, &at_end("mise pl")); diff --git a/argv/src/script.rs b/argv/src/script.rs index c7fd58472..6d1f52827 100644 --- a/argv/src/script.rs +++ b/argv/src/script.rs @@ -203,7 +203,7 @@ _{bin}() {{ case "$__usage_files" in any) _files && __usage_ret=0 ;; dirs) _files -/ && __usage_ret=0 ;; - executables) _files -g '*(*)' && __usage_ret=0 ;; + executables) _files -g '*(/,*)' && __usage_ret=0 ;; commands) _command_names && __usage_ret=0 ;; esac return $__usage_ret @@ -503,6 +503,9 @@ mod tests { ); assert!(powershell.contains("} elseif ($files) {"), "{powershell}"); assert!(!powershell.contains("} else if ($files) {"), "{powershell}"); + + let zsh = script("mise", Shell::Zsh); + assert!(zsh.contains("_files -g '*(/,*)'"), "{zsh}"); } #[test] diff --git a/cli/src/cli/complete_word.rs b/cli/src/cli/complete_word.rs index fcf6cf138..4e5d9838e 100644 --- a/cli/src/cli/complete_word.rs +++ b/cli/src/cli/complete_word.rs @@ -173,6 +173,7 @@ impl CompleteWord { tera: &ctx, spec, parsed: &parsed, + after_restart_token, }; let mut has_explicit_choices = false; // Not `available_flags`: inside a mounted command, the mounting CLI's flags stay @@ -382,7 +383,7 @@ impl CompleteWord { .keys() .any(|bound| bound.as_ref() == next.as_ref()) }); - if !command_was_bound { + if cx.after_restart_token || !command_was_bound { return (self.complete_commands(ctoken), true); } } @@ -757,6 +758,7 @@ struct Ctx<'a> { tera: &'a tera::Context, spec: &'a Spec, parsed: &'a ParseOutput, + after_restart_token: bool, } /// A description reduced to one line. diff --git a/cli/tests/complete_word.rs b/cli/tests/complete_word.rs index 2db7dafb1..5480a2d07 100644 --- a/cli/tests/complete_word.rs +++ b/cli/tests/complete_word.rs @@ -694,6 +694,28 @@ complete "command" type="command_args" .stdout(contains("Cargo.toml")); } +#[test] +fn complete_word_command_args_restarts_with_executables() { + let usage = cargo::cargo_bin!("usage"); + let executable = usage.file_name().unwrap().to_string_lossy().into_owned(); + let spec = r#" +name "mycli" +bin "mycli" +cmd "run" restart_token=":::" { + arg "..." double_dash="automatic" +} +complete "command" type="command_args" +"#; + Command::new(usage) + .args([ + "cw", "--shell", "fish", "--spec", spec, "--", "mycli", "run", "usage", ":::", "", + ]) + .env("PATH", usage.parent().unwrap()) + .assert() + .success() + .stdout(contains(executable)); +} + #[test] fn complete_word_subcommands_without_shell() { let mut cmd = cmd("basic.usage.kdl", None); From 7b828380ea17c39e46c1c21b87d44f6cb982e6ae Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:17:00 +0000 Subject: [PATCH 3/7] fix(clap): build commands before conversion --- clap_usage/src/generate.rs | 1 + clap_usage/tests/fidelity_report.rs | 24 ++++++++++++++----- .../tests/snapshots/simple__simple.snap | 3 ++- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/clap_usage/src/generate.rs b/clap_usage/src/generate.rs index c5f2cdd8b..e2fdd6b63 100644 --- a/clap_usage/src/generate.rs +++ b/clap_usage/src/generate.rs @@ -30,6 +30,7 @@ pub fn spec_with_report>( cmd: &mut Command, bin_name: S, ) -> (usage::Spec, FidelityReport) { + cmd.build(); let report = report(cmd); (spec(cmd, bin_name), report) } diff --git a/clap_usage/tests/fidelity_report.rs b/clap_usage/tests/fidelity_report.rs index 2a555b360..82b40f2ba 100644 --- a/clap_usage/tests/fidelity_report.rs +++ b/clap_usage/tests/fidelity_report.rs @@ -64,12 +64,13 @@ fn reports_nested_paths_and_leaves_supported_commands_clean() { ), ); let (_, report) = spec_with_report(&mut nested, "ex"); - assert_eq!(report.losses()[0].command, ["ex", "run"]); - assert_eq!(report.losses()[0].argument.as_deref(), Some("number")); - assert_eq!( - report.losses()[0].feature, - FidelityFeature::AllowNegativeNumbers - ); + let loss = report + .losses() + .iter() + .find(|loss| loss.argument.as_deref() == Some("number")) + .expect("nested argument loss"); + assert_eq!(loss.command, ["ex", "run"]); + assert_eq!(loss.feature, FidelityFeature::AllowNegativeNumbers); } #[test] @@ -87,6 +88,17 @@ fn reports_delimited_arity_that_the_bridge_cannot_count() { .any(|loss| loss.feature == FidelityFeature::ValueArity)); } +#[test] +fn builds_action_derived_arity_before_reporting() { + let mut command = + Command::new("ex").arg(Arg::new("values").long("values").action(ArgAction::Append)); + let (_, report) = spec_with_report(&mut command, "ex"); + assert!(report + .losses() + .iter() + .any(|loss| loss.feature == FidelityFeature::ValueArity)); +} + #[test] fn reports_positional_conflicts_declared_from_either_endpoint() { for command in [ diff --git a/clap_usage/tests/snapshots/simple__simple.snap b/clap_usage/tests/snapshots/simple__simple.snap index 8a4f375b5..41dccae8b 100644 --- a/clap_usage/tests/snapshots/simple__simple.snap +++ b/clap_usage/tests/snapshots/simple__simple.snap @@ -10,4 +10,5 @@ usage "Usage: example [OPTIONS]" flag --file help="some input file" { arg } -flag --usage +flag --usage default="false" +flag "-h --help" help="Print help" From 2e526418aff4b012785c5c56b0101de9e09aa8f7 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:17:00 +0000 Subject: [PATCH 4/7] fix(complete): include directories for executable paths --- argv/src/script.rs | 4 ++-- go/argv/script.go | 2 +- go/argv/script_test.go | 3 +++ 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/argv/src/script.rs b/argv/src/script.rs index 6d1f52827..0879cb17d 100644 --- a/argv/src/script.rs +++ b/argv/src/script.rs @@ -203,7 +203,7 @@ _{bin}() {{ case "$__usage_files" in any) _files && __usage_ret=0 ;; dirs) _files -/ && __usage_ret=0 ;; - executables) _files -g '*(/,*)' && __usage_ret=0 ;; + executables) _files -g '*(-/,*)' && __usage_ret=0 ;; commands) _command_names && __usage_ret=0 ;; esac return $__usage_ret @@ -505,7 +505,7 @@ mod tests { assert!(!powershell.contains("} else if ($files) {"), "{powershell}"); let zsh = script("mise", Shell::Zsh); - assert!(zsh.contains("_files -g '*(/,*)'"), "{zsh}"); + assert!(zsh.contains("_files -g '*(-/,*)'"), "{zsh}"); } #[test] diff --git a/go/argv/script.go b/go/argv/script.go index 2fb8d4327..ccf417ff9 100644 --- a/go/argv/script.go +++ b/go/argv/script.go @@ -230,7 +230,7 @@ _{bin}() { case "$__usage_files" in any) _files && __usage_ret=0 ;; dirs) _files -/ && __usage_ret=0 ;; - executables) _files -g '*(*)' && __usage_ret=0 ;; + executables) _files -g '*(-/,*)' && __usage_ret=0 ;; commands) _command_names && __usage_ret=0 ;; esac return $__usage_ret diff --git a/go/argv/script_test.go b/go/argv/script_test.go index e426b3cd8..d9195f080 100644 --- a/go/argv/script_test.go +++ b/go/argv/script_test.go @@ -73,6 +73,9 @@ func TestTheScriptsWatchForTheMarkerTheRendererWrites(t *testing.T) { if !strings.Contains(Script("mise", Fish), `test -d "$value"; or test -x "$value"`) { t.Error("fish filters executable-path candidates") } + if !strings.Contains(Script("mise", Zsh), `_files -g '*(-/,*)'`) { + t.Error("zsh keeps directories beside executable-path candidates") + } if !strings.Contains(Script("mise", PowerShell), "-CommandType Application, ExternalScript") { t.Error("powershell filters executable-path candidates") } From 0b176e28608c3b7e652b23b7a0c2930bef7a6c3e Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:17:00 +0000 Subject: [PATCH 5/7] fix(parse): preserve inferred negation bindings --- lib/src/parse.rs | 46 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/lib/src/parse.rs b/lib/src/parse.rs index 00519ee85..18ffe54cb 100644 --- a/lib/src/parse.rs +++ b/lib/src/parse.rs @@ -785,13 +785,14 @@ fn parse_partial_with_env( // - Global flags affect all commands and should be passed to mount points let mut prefix_flags: Vec<(Arc, Vec)> = vec![]; // Which flag each word skipped here belongs to, aligned with the leading words left in - // `input`: `Some(flag)` for a flag word, `None` for its value (or anything unresolved). + // `input`: `Some((flag, negated))` for a flag word, `None` for its value (or anything + // unresolved). // // The words stay in `input` for Phase 2 to re-parse — that is how they reach `out.flags` // and `as_env()` — but by then the recognized flags have changed, because each descent // drops the parent's non-global flags and a mounted command may declare the same name as // a global seen here. Recording the owner keeps a word bound to the flag it was read as. - let mut prefix_bindings: VecDeque>> = VecDeque::new(); + let mut prefix_bindings: VecDeque, bool)>> = VecDeque::new(); let mut idx = 0; // Track whether we've already applied the default_subcommand to prevent // multiple switches (e.g., if default is "run" and there's a task named "run") @@ -896,16 +897,18 @@ fn parse_partial_with_env( let is_bundle = word.starts_with("--") || short_bundle_is_known(&out.available_flags, &word); let infer_long_args = out.cmds.iter().any(|cmd| cmd.infer_long_args); - if let Some(f) = (if word.starts_with("--") { + if let Some((f, negated)) = (if word.starts_with("--") { resolve_long_flag( &out.available_flags, &word, infer_long_args, spec.disable_help != Some(true), ) - .map(|(flag, _)| flag) } else { - out.available_flags.get(flag_key).cloned() + out.available_flags + .get(flag_key) + .cloned() + .map(|flag| (flag, false)) }) .filter(|_| is_bundle) { @@ -916,7 +919,7 @@ fn parse_partial_with_env( // // Only globals are forwarded to mounts: a non-global flag belongs to the // command that declared it, not to what is mounted below it. - prefix_bindings.push_back(Some(Arc::clone(&f))); + prefix_bindings.push_back(Some((Arc::clone(&f), negated))); let mut forwarded = f.global.then(|| vec![word.clone()]); idx += 1; @@ -1154,8 +1157,9 @@ fn parse_partial_with_env( out.cmds.iter().any(|cmd| cmd.infer_long_args), spec.disable_help != Some(true), ); + let bound_flag = binding.as_ref().map(|(flag, _)| flag); let resolved_flag = resolved.as_ref().map(|(flag, _)| flag); - if let Some(f) = binding.as_ref().or(resolved_flag) { + if let Some(f) = bound_flag.or(resolved_flag) { parsed_flag_spellings .entry(Arc::as_ptr(f) as usize) .or_default() @@ -1209,10 +1213,16 @@ fn parse_partial_with_env( .unwrap(); arr.push(true); } else { - let negated = resolved + let negated = binding .as_ref() - .filter(|(resolved, _)| Arc::ptr_eq(resolved, f)) + .filter(|(bound, _)| Arc::ptr_eq(bound, f)) .map(|(_, negated)| *negated) + .or_else(|| { + resolved + .as_ref() + .filter(|(resolved, _)| Arc::ptr_eq(resolved, f)) + .map(|(_, negated)| *negated) + }) .unwrap_or_else(|| f.negate.as_deref() == Some(word)); // Exact bindings can compare the typed form. Inferred bindings carry // which form matched, because a prefix is deliberately not equal to the @@ -1260,6 +1270,7 @@ fn parse_partial_with_env( let short = w.chars().nth(1).unwrap(); if let Some(f) = binding .as_ref() + .map(|(flag, _)| flag) .or_else(|| out.available_flags.get(&format!("-{short}"))) { parsed_flag_spellings @@ -2388,7 +2399,7 @@ fn bind_pending_flag_value( flag_awaiting_value: &mut Vec>, word: &mut String, input: &mut VecDeque, - prefix_bindings: &mut VecDeque>>, + prefix_bindings: &mut VecDeque, bool)>>, custom_env: Option<&HashMap>, ) -> miette::Result { // Held before the drain pops it, along with what the flag is already carrying: a @@ -2452,7 +2463,7 @@ fn collect_variadic_flag_values( flag: &Arc, carried: usize, input: &mut VecDeque, - prefix_bindings: &mut VecDeque>>, + prefix_bindings: &mut VecDeque, bool)>>, custom_env: Option<&HashMap>, ) -> miette::Result { let max = flag @@ -4066,6 +4077,19 @@ flag "--file " required_unless="--stdin" ); } + #[test] + fn an_inferred_negation_keeps_its_prefix_binding_across_redeclaration() { + let spec: Spec = "name \"ex\"\nbin \"ex\"\ninfer_long_args #true\nflag \"--clean\" negate=\"--no-clean\" global=#true\ncmd \"run\" {\n flag \"--clean\" negate=\"--no-clean\" global=#true\n}\n" + .parse() + .unwrap(); + + let parsed = parse(&spec, &input(&["ex", "--no-cl", "run"])) + .expect("the prefix should remain bound to the ancestor declaration"); + assert!(parsed.flags.iter().any(|(flag, value)| { + flag.name == "clean" && matches!(value, ParseValue::Bool(false)) + })); + } + #[test] fn a_colliding_alias_does_not_disown_the_child_from_the_rest() { // The child re-declares the inherited `--clean` as exclusive and gives it a `-c` that From bc6f31a33014a225bb392715445f402cb0bf6419 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:17:00 +0000 Subject: [PATCH 6/7] docs: clarify completion and bridge behavior --- docs/rust/args-and-flags.md | 2 +- docs/rust/spec.md | 19 ++++++++++--------- docs/spec/integrations/clap.md | 8 +++++--- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/docs/rust/args-and-flags.md b/docs/rust/args-and-flags.md index f6e113529..b2591a1d5 100644 --- a/docs/rust/args-and-flags.md +++ b/docs/rust/args-and-flags.md @@ -84,7 +84,7 @@ jobs: Option, | `overrides(…)` | Later occurrence silently overrides the named flag | | `required_if(…)` / `required_unless(…)` | Conditional required-ness | | `complete = my_fn` | Custom completion function ([Completions](/rust/completions)) | -| `value_hint = ValueHint::FilePath` | Ask the shell for paths or commands (see below) | +| `value_hint = ValueHint::FilePath` | Ask the shell for path completion (see below) | | `value_name = "…"` | The placeholder shown in help (`--file `) | | `help = "…"` / `long_help = "…"` | Help text (doc comments are usually nicer) | | `help_heading = "…"` | Group the entry under a heading in help output | diff --git a/docs/rust/spec.md b/docs/rust/spec.md index 94047b782..a82e1b429 100644 --- a/docs/rust/spec.md +++ b/docs/rust/spec.md @@ -97,15 +97,16 @@ with the literal written to portable artifacts: struct Cli; ``` -The name and bin expressions return `&'static str`. They are evaluated only when the process -renders help, version output, diagnostics, or a completion script. Successful argument parsing -still reads the static tables directly and does not allocate or build a command graph. `to_kdl()` -keeps `mycli` and `6.0.0`, so generated artifacts are deterministic and do not depend on the -embedding process. - -`Cli::runtime_app()` returns the borrowed view with the computed identity applied. For a caller -that already has different identity values, `Cli::app().name(...).bin(...)` provides the same -split explicitly. +The name and bin expressions return `&'static str`; a computed version implements `ToString`. +They are evaluated only when the process renders help, version output, diagnostics, or a +completion script. Successful argument parsing still reads the static tables directly and does +not allocate or build a command graph. `to_kdl()` keeps `mycli` and `6.0.0`, so generated +artifacts are deterministic and do not depend on the embedding process. `--version` formats the +computed version, while `version_spec` remains the static value exported to KDL. + +`Cli::runtime_app()` returns the borrowed view with the computed name and bin applied; it does +not currently apply the computed version. For a caller that already has different identity +values, `Cli::app().name(...).bin(...).version(...)` provides the split explicitly. ## What the parser does with the spec diff --git a/docs/spec/integrations/clap.md b/docs/spec/integrations/clap.md index 5fb8fd82f..b94c82814 100644 --- a/docs/spec/integrations/clap.md +++ b/docs/spec/integrations/clap.md @@ -36,9 +36,11 @@ println!("{spec}"); ``` The report includes the command path, clap argument ID, feature, and source detail -for each detectable loss. clap settings that have setters but no public getter -cannot be detected; the [compatibility matrix](/rust/clap-compatibility) lists -those as **usage-only**. +for each detectable loss. `is_lossless()` therefore means lossless for behavior +visible through clap's public getters, not for every setter clap exposes. Before +treating the generated spec as fully compatible, audit the declaration against the +[compatibility matrix](/rust/clap-compatibility), especially its **usage-only** and +**lossy** bridge rows. ## Integration Pattern From 2ae65de75fd11eb4c66065662141f446efe4df7b Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:26:38 +0000 Subject: [PATCH 7/7] fix(clap): exclude generated builtin entries --- clap_usage/src/generate.rs | 43 +++++++++++++++++-- clap_usage/tests/fidelity_report.rs | 16 ++++++- .../tests/snapshots/simple__simple.snap | 1 - 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/clap_usage/src/generate.rs b/clap_usage/src/generate.rs index e2fdd6b63..d75d75bea 100644 --- a/clap_usage/src/generate.rs +++ b/clap_usage/src/generate.rs @@ -20,19 +20,54 @@ use crate::report::{report, FidelityReport}; /// println!("{spec}"); /// ``` pub fn spec>(cmd: &mut Command, bin_name: S) -> usage::Spec { - let mut spec: usage::Spec = cmd.clone().into(); + let built = build_without_implicit_entries(cmd); + spec_from_built(&built, bin_name) +} + +fn spec_from_built>(cmd: &Command, bin_name: S) -> usage::Spec { + let mut spec: usage::Spec = cmd.into(); spec.bin = bin_name.into(); spec } +/// Materialize action-derived metadata without turning clap's generated help and version +/// entries into declarations in the portable spec. +fn build_without_implicit_entries(cmd: &Command) -> Command { + let mut built = cmd + .clone() + .disable_help_flag(true) + .disable_help_subcommand(true) + .disable_version_flag(true); + built.build(); + restore_declared_builtin_settings(&mut built, cmd); + built +} + +fn restore_declared_builtin_settings(built: &mut Command, declared: &Command) { + let current = std::mem::take(built); + *built = current + .disable_help_flag(declared.is_disable_help_flag_set()) + .disable_help_subcommand(declared.is_disable_help_subcommand_set()) + .disable_version_flag(declared.is_disable_version_flag_set()); + + for built_subcommand in built.get_subcommands_mut() { + if let Some(declared_subcommand) = declared + .get_subcommands() + .find(|subcommand| subcommand.get_name() == built_subcommand.get_name()) + { + restore_declared_builtin_settings(built_subcommand, declared_subcommand); + } + } +} + /// Build a spec and report every publicly detectable clap behavior it loses. pub fn spec_with_report>( cmd: &mut Command, bin_name: S, ) -> (usage::Spec, FidelityReport) { - cmd.build(); - let report = report(cmd); - (spec(cmd, bin_name), report) + let built = build_without_implicit_entries(cmd); + let report = report(&built); + (spec_from_built(&built, bin_name), report) } /// Write the usage spec for a clap command, with the `@generated` header. diff --git a/clap_usage/tests/fidelity_report.rs b/clap_usage/tests/fidelity_report.rs index 82b40f2ba..89581af35 100644 --- a/clap_usage/tests/fidelity_report.rs +++ b/clap_usage/tests/fidelity_report.rs @@ -63,7 +63,9 @@ fn reports_nested_paths_and_leaves_supported_commands_clean() { .allow_negative_numbers(true), ), ); - let (_, report) = spec_with_report(&mut nested, "ex"); + let (spec, report) = spec_with_report(&mut nested, "ex"); + assert!(!spec.cmd.subcommands.contains_key("help")); + assert_eq!(report.losses().len(), 1, "{report:#?}"); let loss = report .losses() .iter() @@ -99,6 +101,18 @@ fn builds_action_derived_arity_before_reporting() { .any(|loss| loss.feature == FidelityFeature::ValueArity)); } +#[test] +fn building_metadata_does_not_mutate_the_callers_command() { + let mut command = Command::new("ex").subcommand(Command::new("run")); + let argument_count = command.get_arguments().count(); + let subcommand_count = command.get_subcommands().count(); + let (spec, _) = spec_with_report(&mut command, "ex"); + + assert_eq!(command.get_arguments().count(), argument_count); + assert_eq!(command.get_subcommands().count(), subcommand_count); + assert!(!spec.cmd.subcommands.contains_key("help")); +} + #[test] fn reports_positional_conflicts_declared_from_either_endpoint() { for command in [ diff --git a/clap_usage/tests/snapshots/simple__simple.snap b/clap_usage/tests/snapshots/simple__simple.snap index 41dccae8b..8a99e25a0 100644 --- a/clap_usage/tests/snapshots/simple__simple.snap +++ b/clap_usage/tests/snapshots/simple__simple.snap @@ -11,4 +11,3 @@ flag --file help="some input file" { arg } flag --usage default="false" -flag "-h --help" help="Print help"