From 0e778401f8f982f4763e9387b005d8a80cbecb27 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:31:32 +0000 Subject: [PATCH 1/6] fix(cli): simplify repeatable flags in help --- argv/src/help.rs | 11 +++++++---- conformance/tests/flag_column.rs | 21 +++++++++++++++++++++ lib/src/docs/models.rs | 6 +++++- lib/src/spec/flag.rs | 10 +++++++++- 4 files changed, 42 insertions(+), 6 deletions(-) diff --git a/argv/src/help.rs b/argv/src/help.rs index 94cf25041..aa1f6f5b4 100644 --- a/argv/src/help.rs +++ b/argv/src/help.rs @@ -795,7 +795,7 @@ fn usage_section(out: &mut String, spec: &Spec<'_>, path: &[&str], meta: &Comman /// How one flag appears in the usage line: `-f --force`, plus its value if it takes one. fn flag_usage(meta: &FlagMeta<'_>) -> String { - flag_usage_masked(meta, &Shown::all(meta)) + flag_usage_masked(meta, &Shown::all(meta), true) } /// The spellings of one flag that a page should offer. @@ -878,7 +878,7 @@ impl<'a> Shown<'a> { /// A descendant may take one of an ancestor's two spellings — its own `-v` beside the root's /// `-v, --verbose` — and the parser still accepts the other, so the page has to offer the other /// and not the one that now means something else. -fn flag_usage_masked(meta: &FlagMeta<'_>, show: &Shown) -> String { +fn flag_usage_masked(meta: &FlagMeta<'_>, show: &Shown, show_repeatable: bool) -> String { let flag = meta.flag; let mut out = String::new(); @@ -921,7 +921,7 @@ fn flag_usage_masked(meta: &FlagMeta<'_>, show: &Shown) -> String { // A repeatable flag, which is the spec's `var=#true` — not one occurrence taking several // values, which is the value's own business below. - if meta.repeatable { + if show_repeatable && meta.repeatable { out.push('…'); } if flag.takes_value { @@ -1863,7 +1863,10 @@ pub(crate) fn flag_spelling(meta: &FlagMeta<'_>) -> String { } fn display_usage_masked(meta: &FlagMeta<'_>, show: &Shown) -> String { - let usage = flag_usage_masked(meta, show); + // Repetition is useful in the formal usage grammar, but an ellipsis immediately after a + // flag spelling looks like terminal truncation in the aligned help table. clap and most + // other CLIs list the ordinary spelling here and leave repeatability to the description. + let usage = flag_usage_masked(meta, show, false); match meta.flag.negate.filter(|_| show.negate) { // A flag whose only spelling is its negation has nothing before it: the name prefix // would repeat the spelling, so `flag_usage_masked` writes nothing and the negation diff --git a/conformance/tests/flag_column.rs b/conformance/tests/flag_column.rs index 036f4ec8a..43da8b769 100644 --- a/conformance/tests/flag_column.rs +++ b/conformance/tests/flag_column.rs @@ -37,6 +37,27 @@ struct Ex { /// Its description is long enough to need wrapping at any sane width #[usage(long)] describe: bool, + /// A repeatable value + #[usage(long)] + tag: Vec, + /// Repeatable verbosity + #[usage(long, count)] + verbose: u8, +} + +#[test] +fn repeatable_flags_do_not_look_truncated_in_the_help_table() { + for long in [false, true] { + let compiled = page(long); + assert!(compiled.contains("--tag "), "long={long}: {compiled}"); + assert!(compiled.contains("--verbose"), "long={long}: {compiled}"); + assert!(!compiled.contains("--tag…"), "long={long}: {compiled}"); + assert!(!compiled.contains("--verbose…"), "long={long}: {compiled}"); + + let spec: usage::Spec = Ex::to_kdl().parse().expect("generated spec"); + let reference = usage::docs::cli::render_help(&spec, &spec.cmd, long); + assert_eq!(reference, compiled); + } } fn page(long: bool) -> String { diff --git a/lib/src/docs/models.rs b/lib/src/docs/models.rs index 47126ee9d..0ad8c6725 100644 --- a/lib/src/docs/models.rs +++ b/lib/src/docs/models.rs @@ -990,7 +990,11 @@ const SHORT_COL: usize = 4; /// The twin of `column_usage` in `usage-argv`'s `help` module; the two must agree, and the gate /// over mise's spec is what says they do. fn column_usage(flag: &crate::SpecFlag) -> String { - let usage = flag.usage.trim(); + // `var` marks repeatable occurrences in the formal usage grammar. In an interactive help + // row its ellipsis looks like the terminal truncated the flag name, so keep the ordinary + // spelling here. A variadic value's trailing ellipsis is separate and remains visible. + let column_usage = flag.usage_without_repeatable(); + let usage = column_usage.trim(); let rest = match flag.negate.as_deref().map(str::trim) { // `SpecFlag::usage` already writes the negation for a flag that has no other // spelling — clap's `SetFalse`, tak's `--no-credit` — and appending it again rendered diff --git a/lib/src/spec/flag.rs b/lib/src/spec/flag.rs index c4e61324e..deb54e7a1 100644 --- a/lib/src/spec/flag.rs +++ b/lib/src/spec/flag.rs @@ -867,6 +867,14 @@ impl SpecFlag { } pub fn usage(&self) -> String { + self.usage_with_repeatable(true) + } + + pub(crate) fn usage_without_repeatable(&self) -> String { + self.usage_with_repeatable(false) + } + + fn usage_with_repeatable(&self, show_repeatable: bool) -> String { let mut parts = vec![]; let name = get_name_from_short_and_long(&self.short, &self.long).unwrap_or_default(); // A flag whose only spelling is its negation — clap's `SetFalse`, tak's @@ -890,7 +898,7 @@ impl SpecFlag { parts.push(format!("--{long}")); } let mut out = parts.join(" "); - if self.var { + if show_repeatable && self.var { out = format!("{out}…"); } if let Some(arg) = &self.arg { From 7b21b59823057a2f4a84d3387e7f19c7efd0b272 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:38:59 +0000 Subject: [PATCH 2/6] fix(cli): omit repeatability markers from output --- argv/src/help.rs | 18 +-- conformance/tests/flag_column.rs | 32 +++-- examples/docs/MISE_INLINE.md | 126 +++++++++--------- examples/docs/MISE_MULTI.md | 126 +++++++++--------- lib/src/docs/models.rs | 9 +- ...thon__tests__python_client_edge_cases.snap | 2 +- ...__tests__python_double_dash_automatic.snap | 2 +- ...on__tests__python_full_feature_client.snap | 2 +- ...pt__types__tests__full_feature_client.snap | 2 +- ...__tests__typescript_client_edge_cases.snap | 2 +- ...sts__typescript_double_dash_automatic.snap | 2 +- lib/src/spec/flag.rs | 25 +--- 12 files changed, 169 insertions(+), 179 deletions(-) diff --git a/argv/src/help.rs b/argv/src/help.rs index aa1f6f5b4..ad640742c 100644 --- a/argv/src/help.rs +++ b/argv/src/help.rs @@ -795,7 +795,7 @@ fn usage_section(out: &mut String, spec: &Spec<'_>, path: &[&str], meta: &Comman /// How one flag appears in the usage line: `-f --force`, plus its value if it takes one. fn flag_usage(meta: &FlagMeta<'_>) -> String { - flag_usage_masked(meta, &Shown::all(meta), true) + flag_usage_masked(meta, &Shown::all(meta)) } /// The spellings of one flag that a page should offer. @@ -878,7 +878,7 @@ impl<'a> Shown<'a> { /// A descendant may take one of an ancestor's two spellings — its own `-v` beside the root's /// `-v, --verbose` — and the parser still accepts the other, so the page has to offer the other /// and not the one that now means something else. -fn flag_usage_masked(meta: &FlagMeta<'_>, show: &Shown, show_repeatable: bool) -> String { +fn flag_usage_masked(meta: &FlagMeta<'_>, show: &Shown) -> String { let flag = meta.flag; let mut out = String::new(); @@ -887,8 +887,8 @@ fn flag_usage_masked(meta: &FlagMeta<'_>, show: &Shown, show_repeatable: bool) - // spec does not. // // Judged on the forms this page is *showing*. mise's root has a global `-E --env`; a - // descendant that claims `--env` leaves `-E` inherited, and `-E… ` alone gives a - // reader nothing to connect it to the `--env` they saw elsewhere. `env: -E… ` does. + // descendant that claims `--env` leaves `-E` inherited, and `-E ` alone gives a + // reader nothing to connect it to the `--env` they saw elsewhere. `env: -E ` does. let long = show.long; let short = show.short.as_ref(); let implied = long.or_else(|| short.map(|_| "")); @@ -919,11 +919,6 @@ fn flag_usage_masked(meta: &FlagMeta<'_>, show: &Shown, show_repeatable: bool) - let _ = write!(out, "--{long}"); } - // A repeatable flag, which is the spec's `var=#true` — not one occurrence taking several - // values, which is the value's own business below. - if show_repeatable && meta.repeatable { - out.push('…'); - } if flag.takes_value { // Angled where the value must be given, squared where it need not — the same brackets // an argument uses, and for the same reason. pitchfork's `--bump` is the fleet's case. @@ -1863,10 +1858,7 @@ pub(crate) fn flag_spelling(meta: &FlagMeta<'_>) -> String { } fn display_usage_masked(meta: &FlagMeta<'_>, show: &Shown) -> String { - // Repetition is useful in the formal usage grammar, but an ellipsis immediately after a - // flag spelling looks like terminal truncation in the aligned help table. clap and most - // other CLIs list the ordinary spelling here and leave repeatability to the description. - let usage = flag_usage_masked(meta, show, false); + let usage = flag_usage_masked(meta, show); match meta.flag.negate.filter(|_| show.negate) { // A flag whose only spelling is its negation has nothing before it: the name prefix // would repeat the spelling, so `flag_usage_masked` writes nothing and the negation diff --git a/conformance/tests/flag_column.rs b/conformance/tests/flag_column.rs index 43da8b769..af507373e 100644 --- a/conformance/tests/flag_column.rs +++ b/conformance/tests/flag_column.rs @@ -15,6 +15,7 @@ //! there is a long form to line up with. `-j ` has none, so it does not pad; that is //! clap's behaviour and not an oversight in it. +use usage::docs::markdown::MarkdownRenderer; use usage_argv::help; use usage_derive::Cli; @@ -37,26 +38,41 @@ struct Ex { /// Its description is long enough to need wrapping at any sane width #[usage(long)] describe: bool, +} + +#[derive(Cli)] +#[usage(bin = "repeat")] +#[allow(dead_code)] +struct Repeatable { /// A repeatable value - #[usage(long)] - tag: Vec, + #[usage(short = 'A', long, value_name = "NAME")] + allow: Vec, /// Repeatable verbosity #[usage(long, count)] verbose: u8, } #[test] -fn repeatable_flags_do_not_look_truncated_in_the_help_table() { +fn repeatable_flags_have_ordinary_spellings_in_every_rendered_format() { for long in [false, true] { - let compiled = page(long); - assert!(compiled.contains("--tag "), "long={long}: {compiled}"); + let compiled = + help::render(Repeatable::spec(), Repeatable::spec().root.cmd, long).expect("a page"); + assert!( + compiled.contains("-A, --allow "), + "long={long}: {compiled}" + ); assert!(compiled.contains("--verbose"), "long={long}: {compiled}"); - assert!(!compiled.contains("--tag…"), "long={long}: {compiled}"); - assert!(!compiled.contains("--verbose…"), "long={long}: {compiled}"); + assert!(!compiled.contains('…'), "long={long}: {compiled}"); - let spec: usage::Spec = Ex::to_kdl().parse().expect("generated spec"); + let spec: usage::Spec = Repeatable::to_kdl().parse().expect("generated spec"); let reference = usage::docs::cli::render_help(&spec, &spec.cmd, long); assert_eq!(reference, compiled); + + let markdown = MarkdownRenderer::new(spec.clone()) + .render_cmd(&spec.cmd) + .expect("markdown page"); + assert!(markdown.contains("-A --allow "), "{markdown}"); + assert!(!markdown.contains('…'), "{markdown}"); } } diff --git a/examples/docs/MISE_INLINE.md b/examples/docs/MISE_INLINE.md index 21c890749..3a40ed797 100644 --- a/examples/docs/MISE_INLINE.md +++ b/examples/docs/MISE_INLINE.md @@ -14,12 +14,12 @@ mise prepares your development environment before each command runs. https://git ## Global Flags - **`-C --cd `** — Change directory before running command -- **`-E --env… `** — Set the environment for loading `mise..toml` +- **`-E --env `** — Set the environment for loading `mise..toml` - **`-j --jobs `** — How many jobs to run in parallel; values below 1 are treated as 1 [default: 8] **Environment Variable:** `MISE_JOBS` - **`-q --quiet`** — Suppress non-error messages -- **`-v --verbose…`** — Show extra output (use -vv for even more) +- **`-v --verbose`** — Show extra output (use -vv for even more) - **`-y --yes`** — Answer yes to all confirmation prompts - **`--raw`** — Read/write directly to stdin/stdout/stderr instead of by line - **`--locked`** — Require lockfile URLs to be present during installation @@ -289,13 +289,13 @@ Use `--skip ` to skip named parts, or `--only ` to run just named pa - **`-n --dry-run`** — Print what would happen without installing anything - **`-y --yes`** — Skip confirmation prompts - **`--force-dotfiles`** — Overwrite existing files that conflict with whole-file dotfile entries -- **`--only… `** — Run only one or more bootstrap parts +- **`--only `** — Run only one or more bootstrap parts Can be passed multiple times or as a comma-separated list. Cannot be used with `--skip`. **Choices:** `plugins`, `packages`, `accounts`, `files`, `services`, `firewall`, `compose`, `repos`, `dotfiles`, `mise-shell-activate`, `macos-defaults`, `macos-launchd-agents`, `linux-systemd-units`, `user`, `tools`, `task`, `final-hook` - **`--prompt-secrets`** — Prompt securely for missing bootstrap secret inputs -- **`--skip… `** — Skip one or more bootstrap parts +- **`--skip `** — Skip one or more bootstrap parts Can be passed multiple times or as a comma-separated list. @@ -1032,29 +1032,29 @@ Bootstrap one or more machines over OpenSSH - **`--connect-timeout `** — SSH connection timeout in seconds **Default:** `10` -- **`--copy-link… `** — Dereference one source-relative symbolic link; repeat for multiple links +- **`--copy-link `** — Dereference one source-relative symbolic link; repeat for multiple links - **`--copy-links`** — Dereference every symbolic link in the source archive -- **`--exclude… `** — Additional archive pattern to exclude; repeat for multiple patterns +- **`--exclude `** — Additional archive pattern to exclude; repeat for multiple patterns - **`--fail-fast`** — Stop after the first failed target - **`--force-dotfiles`** — Allow remote dotfile conflicts to be replaced -- **`--host… <[USER@]HOST>`** — Ad-hoc SSH destination (`[user@]host`); repeat for multiple hosts +- **`--host <[USER@]HOST>`** — Ad-hoc SSH destination (`[user@]host`); repeat for multiple hosts - **`-i --identity-file `** — SSH identity file override - **`-n --dry-run`** — Print the remote bootstrap changes without applying them - **`--keep-staging`** — Keep the remote staging directory for debugging - **`--mise-bin `** — Local mise binary to upload (escape hatch for custom architectures) -- **`--only… `** — Run only one or more remote bootstrap parts +- **`--only `** — Run only one or more remote bootstrap parts **Choices:** `plugins`, `packages`, `accounts`, `files`, `services`, `firewall`, `compose`, `repos`, `dotfiles`, `mise-shell-activate`, `macos-defaults`, `macos-launchd-agents`, `linux-systemd-units`, `user`, `tools`, `task`, `final-hook` - **`--port `** — SSH port override - **`--prompt-secrets`** — Prompt securely for missing secret inputs on the remote host -- **`--remote-env… `** — Config environments to load on the remote host; repeat or delimit with commas (for example, ci,dotfiles) +- **`--remote-env `** — Config environments to load on the remote host; repeat or delimit with commas (for example, ci,dotfiles) - **`--remote-mise `** — Existing mise executable name or path; relative paths use the staged project -- **`--skip… `** — Skip one or more remote bootstrap parts +- **`--skip `** — Skip one or more remote bootstrap parts **Choices:** `plugins`, `packages`, `accounts`, `files`, `services`, `firewall`, `compose`, `repos`, `dotfiles`, `mise-shell-activate`, `macos-defaults`, `macos-launchd-agents`, `linux-systemd-units`, `user`, `tools`, `task`, `final-hook` - **`--source `** — Local directory archived and sent to each target -- **`--ssh-option…