From 4923382b0c9e2b8243295883757070d57e948c04 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:36:01 +0000 Subject: [PATCH 1/2] fix(derive): three more descriptions a spec keeps and the derive lost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found while starting on `--help`, which reads the long forms the short one does not. A variant's short description was hiding the struct's long one. `meta_overrides` chose both from the variant when the variant had *either*, so the shape every generated CLI has — the enum says what a command is for in a line, the struct's own comment carries the detail — lost the detail entirely. Each falls back on its own now. A doc comment's lines were trimmed one by one, which flattened every indented block in a CLI's help. An indented block is how a spec shows a command to type, and mise's help is full of them. Only the single space `///` conventionally adds is stripped now, and trailing space. A program could not describe itself twice over. A comment's long form always contains its short one, because the short form *is* the comment's first paragraph — where a spec keeps `about` and `about_long` independent, and mise's are entirely different sentences. `#[usage(about = …, long_about = …)]` declares them, and the generator uses it when a comment cannot say both. The `--help` layout these were found by is written but not yet at parity — 123 of 211 pages still differ, each remaining cause another metadata path where the shadow's description is not the spec's. It is held back rather than shipped wrong, and the plan says so. Co-Authored-By: Claude Opus 5 --- PLAN.md | 9 ++- benches/shadows/mise-clap/src/lib.rs | 3 - benches/shadows/mise/src/lib.rs | 10 +-- conformance/tests/metadata.rs | 95 ++++++++++++++++++++++++++++ derive/src/codegen.rs | 20 +++--- derive/src/model.rs | 29 ++++++++- xtask/src/shadow.rs | 24 ++++++- 7 files changed, 171 insertions(+), 19 deletions(-) diff --git a/PLAN.md b/PLAN.md index 80a7838a8..0a2389c8f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -272,7 +272,14 @@ tasks --usage"`, so task names are meant to come from running that. usage-argv d Holding it to parity found six more things a spec could say that the derive could not — value names, required collections, `var` on a count, the spec's own `about`, `hide` on a command, and help text whose line breaks matter — plus a bug in usage-lib, which printed - everything marked `hide`. + everything marked `hide`. Then three more, found while starting on `--help`: a variant's short + description was hiding the struct's long one, which is the shape every generated CLI has; + a doc comment's lines were trimmed one by one, flattening every indented example in help; + and a program could not describe itself twice over, since a comment's long form always + contains its short one where a spec keeps the two independent. `--help`'s own layout is + written and not yet at parity — 123 of 211 pages differ, each remaining cause a metadata + path where the shadow's description is not the spec's — so it is held back rather than + shipped wrong. - [ ] **Completions, self-contained** — ` completion ` emits the script; a hidden ` complete-word` serves requests from the binary's own embedded spec. Same dispatch shape usage-cli uses today, without requiring diff --git a/benches/shadows/mise-clap/src/lib.rs b/benches/shadows/mise-clap/src/lib.rs index ecb0d5a31..3a57e218f 100644 --- a/benches/shadows/mise-clap/src/lib.rs +++ b/benches/shadows/mise-clap/src/lib.rs @@ -5476,9 +5476,6 @@ pub struct WhichArgs { pub bin_name: Option, } -/// Dev tools, env vars, and tasks in one CLI -/// -/// mise prepares your development environment before each command runs. https://github.com/jdx/mise #[derive(Parser)] #[command(name = "mise")] pub struct Cli { diff --git a/benches/shadows/mise/src/lib.rs b/benches/shadows/mise/src/lib.rs index 4ec505582..21c43ad17 100644 --- a/benches/shadows/mise/src/lib.rs +++ b/benches/shadows/mise/src/lib.rs @@ -5759,11 +5759,13 @@ pub struct WhichArgs { pub bin_name: ::std::option::Option<::std::string::String>, } -/// Dev tools, env vars, and tasks in one CLI -/// -/// mise prepares your development environment before each command runs. https://github.com/jdx/mise #[derive(Cli)] -#[usage(bin = "mise", default_subcommand = "run")] +#[usage( + bin = "mise", + about = "Dev tools, env vars, and tasks in one CLI", + long_about = "mise prepares your development environment before each command runs. https://github.com/jdx/mise", + default_subcommand = "run" +)] pub struct Cli { /// Continue running tasks even if one fails #[usage(long = "continue-on-error", short = 'c', hide)] diff --git a/conformance/tests/metadata.rs b/conformance/tests/metadata.rs index 734657ae9..575855168 100644 --- a/conformance/tests/metadata.rs +++ b/conformance/tests/metadata.rs @@ -253,3 +253,98 @@ fn a_command_can_be_hidden() { }; assert!(shims.shims); } + +/// A command whose short description is on the enum and whose long one is on the struct. +/// +/// The shape every generated CLI has, and mise's own: the variant says what the command is for +/// in a line, and the struct's comment carries the detail. +#[derive(Args)] +/// Initializes mise in the current shell session +/// +/// This should go into your shell's rc file. +/// Otherwise, it will only take effect in the current session. +/// +/// echo 'eval "$(mise activate zsh)"' >> ~/.zshrc +struct ActivateArgs { + /// Use shims instead of modifying PATH + #[usage(long)] + shims: bool, +} + +#[derive(Subcommands)] +enum SplitCommands { + /// Initializes mise in the current shell session + Activate(Box), +} + +#[derive(Cli)] +#[usage( + bin = "split", + about = "Dev tools, env vars, and tasks in one CLI", + long_about = "split prepares your development environment before each command runs." +)] +struct Split { + #[usage(subcommand)] + command: Option, +} + +#[test] +fn a_variants_short_description_does_not_hide_the_structs_long_one() { + // Each falls back on its own. A variant that gave a short description was suppressing the + // struct's long one, so the long form went missing from help for every command written the + // way generated CLIs are written. + let spec: LibSpec = Split::to_kdl().parse().expect("valid spec"); + let activate = spec.cmd.subcommands.get("activate").expect("activate"); + assert_eq!( + activate.help.as_deref(), + Some("Initializes mise in the current shell session"), + "the variant's line" + ); + let long = activate.help_long.as_deref().expect("the struct's detail"); + assert!(long.contains("rc file"), "{long}"); +} + +#[test] +fn an_indented_example_in_help_keeps_its_indentation() { + // A doc comment's lines were trimmed one by one, which flattened every indented block in a + // CLI's help — and an indented block is how a spec shows a command to type. mise's help is + // full of them. + let spec: LibSpec = Split::to_kdl().parse().expect("valid spec"); + let activate = spec.cmd.subcommands.get("activate").expect("activate"); + let long = activate.help_long.as_deref().expect("long help"); + assert!( + long.contains("\n echo 'eval"), + "the example should still be indented:\n{long}" + ); +} + +#[test] +fn a_program_can_describe_itself_twice_over() { + // A comment's long form always contains its short one, because the short form *is* its + // first paragraph. A spec keeps the two independent, and mise's differ entirely — so there + // is no comment that says both. + let spec: LibSpec = Split::to_kdl().parse().expect("valid spec"); + assert_eq!( + spec.about.as_deref(), + Some("Dev tools, env vars, and tasks in one CLI") + ); + assert_eq!( + spec.about_long.as_deref(), + Some("split prepares your development environment before each command runs.") + ); +} + +#[test] +fn the_split_description_cli_still_parses() { + // Reading what the fixture declares, which is also how these structs avoid being dead + // code: a test CLI nobody parses is a warning, and CI denies warnings. + use std::ffi::OsStr; + + let argv = [OsStr::new("activate"), OsStr::new("--shims")]; + let Some(SplitCommands::Activate(activate)) = + Split::parse_from(&argv).expect("should parse").command + else { + panic!("expected activate") + }; + assert!(activate.shims); +} diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index 12ffd70ac..20d0e7e75 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -1615,15 +1615,17 @@ pub fn emit_subcommands(subs: &Subcommands) -> TokenStream { // A doc comment on the variant wins over the struct's, since that is where a // reader of the enum expects to describe the command. Absent one, the // struct's own description carries through. - let (about, long_about) = match &v.help { - Some(_) => ( - option_str(v.help.as_deref()), - option_str(v.long_help.as_deref()), - ), - None => ( - quote!(<#ty as ::usage_argv::spec::CommandArgs>::META.about), - quote!(<#ty as ::usage_argv::spec::CommandArgs>::META.long_about), - ), + // Each falls back on its own. A variant that gives a short description was suppressing + // the struct's long one, which is exactly how a generated CLI is shaped: the enum says + // what the command is for in a line, and the struct's own comment carries the rest. The + // long form went missing from help for every command written that way. + let about = match v.help.as_deref() { + Some(help) => option_str(Some(help)), + None => quote!(<#ty as ::usage_argv::spec::CommandArgs>::META.about), + }; + let long_about = match v.long_help.as_deref() { + Some(long) => option_str(Some(long)), + None => quote!(<#ty as ::usage_argv::spec::CommandArgs>::META.long_about), }; // Which of the table's aliases are hidden. The visible ones are not listed // anywhere: `cmd.aliases` minus these is what help and completions show. diff --git a/derive/src/model.rs b/derive/src/model.rs index 99ef2ba8d..a98357618 100644 --- a/derive/src/model.rs +++ b/derive/src/model.rs @@ -31,6 +31,10 @@ pub struct Cli { /// /// Only the root has one, and it is what mise sets by hand on the emitted spec today. pub default_subcommand: Option, + /// Declared descriptions, for the case a doc comment cannot express: a long form that does + /// not contain the short one. + pub about_attr: Option, + pub long_about_attr: Option, /// The word that starts another invocation of the same command: mise's `:::`. pub restart_token: Option, /// A command to run for subcommands discovered at completion time. @@ -212,6 +216,8 @@ impl Cli { long_about, unknown_flags: None, default_subcommand: None, + about_attr: None, + long_about_attr: None, restart_token: None, mount: None, fields: Vec::new(), @@ -224,6 +230,14 @@ impl Cli { "name" => cli.name = string_value(&meta)?, "bin" => cli.bin = Some(string_value(&meta)?), "version" => cli.version = Some(string_value(&meta)?), + // A doc comment's long form always contains its short one — the short form + // *is* the comment's first paragraph. A spec keeps `about` and `about_long` + // independent, and mise's differ entirely: "Dev tools, env vars, and tasks + // in one CLI" against "mise prepares your development environment before + // each command runs." There is no comment that says both, so they can be + // declared. + "about" => cli.about_attr = Some(string_value(&meta)?), + "long_about" => cli.long_about_attr = Some(string_value(&meta)?), // A Rust CLI usually owns every flag it accepts, which is the // case the stricter reading is for — but it is still opt-in, // since a wrapper forwarding options wants the default. @@ -261,6 +275,14 @@ impl Cli { } } + // Declared descriptions win over the comment, which is the point of declaring them. + if let Some(about) = cli.about_attr.take() { + cli.about = Some(about); + } + if let Some(long) = cli.long_about_attr.take() { + cli.long_about = Some(long); + } + for field in &named.named { cli.fields.push(Field::from_field(field)?); } @@ -1562,7 +1584,12 @@ fn doc_comment(attrs: &[Attribute]) -> syn::Result<(Option, Option !l.trim_start().starts_with(a.trim()), + _ => false, + }; + let declared_about: Vec = if root_declares_about { + [ + run.about.map(|a| format!("about = {:?}", a.trim())), + run.about_long + .map(|l| format!("long_about = {:?}", l.trim_end())), + ] + .into_iter() + .flatten() + .collect() + } else { + Vec::new() + }; + + let (about, about_long) = if is_root && !root_declares_about { ( cmd.help.as_deref().or(run.about), cmd.help_long.as_deref().or(run.about_long), @@ -295,6 +316,7 @@ fn emit_command(out: &mut String, cmd: &SpecCommand, ty: &Type, is_root: bool, r let mut usage_opts: Vec = Vec::new(); if is_root { usage_opts.push(format!("bin = {bin:?}")); + usage_opts.extend(declared_about.iter().cloned()); } for (present, declaration, what) in [ ( From 3c6fc19c6a782f248947ef8f6223ddbc93cddff2 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:49:06 +0000 Subject: [PATCH 2/2] fix(derive): keep a short-only flag's value name, and the clap shadow's about MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from Cursor Bugbot, both regressions from this stack. A flag is named after the form it answers to, and for a short-only flag that form is one character — right for the flag's name and useless as the name of its *value*, since help and the KDL both fall back to `flag.name` for the placeholder. `-j ` where the field is called `jobs`. The descriptive name is kept as the value name before the flag's own is overwritten. And when a program's two descriptions are independent, the generator skipped the root's doc comment and wrote them into the *usage* attribute list only — which the clap dialect never emits, so the clap shadow stopped describing the program at all. clap takes an independent `about` and `long_about`, and a fixture for comparing two frameworks cannot have one of them missing the CLI's own description. Both mutation-checked. `-h` parity is unchanged at 211 of 211, which is what says the value-name fix did not move the rendering. Two older findings on #854 were already answered by the `demanded`/`flag_demanded` helpers: a defaulted argument or flag is one the parser fills, so the usage line brackets it as optional. Co-Authored-By: Claude Opus 5 --- benches/shadows/mise-clap/src/lib.rs | 6 +++++- xtask/src/shadow.rs | 8 +++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/benches/shadows/mise-clap/src/lib.rs b/benches/shadows/mise-clap/src/lib.rs index 3a57e218f..24661bfb0 100644 --- a/benches/shadows/mise-clap/src/lib.rs +++ b/benches/shadows/mise-clap/src/lib.rs @@ -5477,7 +5477,11 @@ pub struct WhichArgs { } #[derive(Parser)] -#[command(name = "mise")] +#[command( + name = "mise", + about = "Dev tools, env vars, and tasks in one CLI", + long_about = "mise prepares your development environment before each command runs. https://github.com/jdx/mise" +)] pub struct Cli { /// Continue running tasks even if one fails #[arg(long = "continue-on-error", short = 'c', hide = true)] diff --git a/xtask/src/shadow.rs b/xtask/src/shadow.rs index 0f9b88271..58834f751 100644 --- a/xtask/src/shadow.rs +++ b/xtask/src/shadow.rs @@ -353,7 +353,13 @@ fn emit_command(out: &mut String, cmd: &SpecCommand, ty: &Type, is_root: bool, r } (true, Dialect::Clap) => { out.push_str("#[derive(Parser)]\n"); - out.push_str(&format!("#[command(name = {bin:?})]\n")); + // The descriptions go here too when a comment cannot carry them. clap takes an + // independent `about` and `long_about`, and skipping the comment without writing + // them left the clap shadow not describing the program at all — a fixture for + // comparing two frameworks cannot have one of them missing the CLI's own about. + let mut opts = vec![format!("name = {bin:?}")]; + opts.extend(declared_about.iter().cloned()); + out.push_str(&format!("#[command({})]\n", opts.join(", "))); } (false, Dialect::Usage) => { out.push_str("#[derive(Args)]\n");