From 7e307f3e167a13afce0a4fc93685f6c838b63a87 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 00:04:18 +0000 Subject: [PATCH 01/10] feat(derive): add ArgGroup enum for exclusive flag groups Bare-variant enums lower to the existing group vocabulary: Option is optional, Mode is required, and two members on one line remain an error. Matches clap#2621 without inventing new spec surface. Co-authored-by: jdx --- PLAN.md | 11 +- argv/src/spec.rs | 60 ++++ conformance/tests/arg_group.rs | 239 ++++++++++++++ derive/src/codegen.rs | 466 +++++++++++++++++++++++--- derive/src/lib.rs | 57 ++++ derive/src/model.rs | 558 +++++++++++++++++++++++++++++++- docs/rust/args-and-flags.md | 13 +- docs/rust/clap-compatibility.md | 9 +- docs/rust/index.md | 2 +- docs/rust/validation.md | 48 +++ usage-rs/src/lib.rs | 2 +- 11 files changed, 1398 insertions(+), 67 deletions(-) create mode 100644 conformance/tests/arg_group.rs diff --git a/PLAN.md b/PLAN.md index 066551533..521efbd2c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1588,12 +1588,12 @@ above are where it lands. clap#5925's fallback across several env names preserves declaration order. This is parser behavior rather than command/flag presentation, so it remains separate from the deprecation metadata above. -- [ ] **A group as an enum in the derive** (clap#2621, 102 votes — tied for +- [x] **A group as an enum in the derive** (clap#2621, 102 votes — tied for clap's most-requested) — mutually exclusive flags declared as enum variants, lowering to the `group`/`conflicts` vocabulary the spec already has. Derive ergonomics rather than new spec surface, and clap has sat on - it since 2021. **Decided (2026-08-21): `#[derive(usage::ArgGroup)]` on the - enum**, held by an `Option` field for an optional group and a bare + it since 2021. **`#[derive(usage::ArgGroup)]` on the enum**, held by an + `Option` field for an optional group and a bare `Mode` field for a required one. A new derive rather than an overloaded `ValueEnum`, because the same enum would otherwise lower two entirely different ways depending on the field holding it; and an enum rather than a @@ -1608,7 +1608,10 @@ above are where it lands. generators to describe. **Two members on one command line is an error**, matching clap's `ArgGroup` and the `conflicts` vocabulary this lowers to; exclusivity is the point, so a typo is reported rather than silently - resolved to whichever came last. + resolved to whichever came last. Nothing new reaches the spec: the enum + emits the `group` node and the switches it names, so KDL, usage-lib, help, + docs and completions all read an ordinary group, and one field holding the + enum is what a command declares. - [x] **Recursive help** (clap#4813) — `ArgAction::HelpAll` renders long help for the selected command and every visible descendant in one depth-first output. Typed Rust, portable KDL, usage-lib, and generated Go retain the diff --git a/argv/src/spec.rs b/argv/src/spec.rs index c90482f52..302ea1d4c 100644 --- a/argv/src/spec.rs +++ b/argv/src/spec.rs @@ -2883,6 +2883,66 @@ pub fn choice_matches(choices: &[&str], value: &str, ignore_case: bool) -> bool .any(|choice| *choice == value || ignore_case && choice.eq_ignore_ascii_case(value)) } +/// An enum whose variants are one command's mutually exclusive switch flags. +/// +/// What a CLI spells `--json` or `--yaml` and holds as a `Mode`, rather than as one `bool` per +/// member plus a `match` over which of them is set. Nothing new reaches the spec: the enum +/// lowers to a [`GroupMeta`] and the switches it names, so help, completions, and the +/// reference implementation read the declaration every hand-written group does. +/// +/// A field holding one says `#[usage(arg_group)]`. `Option` is a group that may be left +/// alone and a bare `Mode` is one that has to be given, which is how the rest of the derive +/// reads required-ness from a type. There is no default variant, so required-ness has exactly +/// one spelling. +pub trait ArgGroup: Sized { + /// What the group is called, in the emitted spec and in a failed check. + const NAME: &'static str; + /// One switch per variant, to splice into the holding command's parse table. + /// + /// A `const` for the same reason [`CommandArgs::COMMAND`] is: the tables stay `static` + /// all the way down, so nothing is built at run time to start a parse. + const FLAGS: &'static [&'static Flag<'static>]; + /// Metadata for [`FLAGS`](Self::FLAGS), in the same order. + const FLAG_METAS: &'static [FlagMeta<'static>]; + /// The selectors naming [`FLAGS`](Self::FLAGS), for [`GroupMeta::members`]. + const MEMBERS: &'static [&'static str]; + + /// Which members have been given so far. Partly-filled by construction, since a parse + /// can stop early. + type Partial: Default; + + /// A fresh partial, with no member given. + /// + /// Nothing to prepare, unlike [`CommandArgs::start`]: a group has no default variant, so + /// there is no value that has to be in place before parsing begins. + fn start() -> Self::Partial { + Self::Partial::default() + } + + /// Take one event, and say whether it named one of this group's flags. + /// + /// Keys are unique across a CLI, so an event that is not this group's is left for + /// whoever owns it. + fn apply(partial: &mut Self::Partial, event: &crate::Event<'_, '_, '_>) -> bool; + + /// The first member this command line gave, if any. + /// + /// Named rather than a bare `bool`, so a parent enforcing `exclusive` across the group + /// can say which flag it collided with — exactly as [`CommandArgs::any_given`] does. + fn any_given(partial: &Self::Partial) -> Option<&'static str>; + + /// The first two members given together, in declaration order. + /// + /// Exclusivity is the point of a group, so a second member is reported rather than + /// silently resolved to whichever came last, and the pair is what the user has to choose + /// between. Answered here rather than while binding for the same reason every other + /// relationship is: the second member may still be ahead of the first. + fn conflict(partial: &Self::Partial) -> Option<(&'static str, &'static str)>; + + /// The variant that was selected, or `None` when no member was given. + fn build(partial: &Self::Partial) -> Option; +} + /// One value a flag was given, in a vocabulary this crate can hold. /// /// A settings layer is `usage-config`'s idea, and this crate does not know that crate exists — diff --git a/conformance/tests/arg_group.rs b/conformance/tests/arg_group.rs new file mode 100644 index 000000000..32816bfc0 --- /dev/null +++ b/conformance/tests/arg_group.rs @@ -0,0 +1,239 @@ +//! A group of mutually exclusive flags declared as an enum. +//! +//! clap#2621's ask, and clap's most-requested derive ergonomic: the flags that exclude one +//! another are variants, so the code that reads them matches on a type instead of on which of +//! several `bool`s is set. Nothing new reaches the spec — the enum lowers to the `group` node +//! and the switches it names — so everything here is checked twice: once against the errors the +//! generated code produces, and once against the KDL it emits and the reference implementation +//! that reads it. + +use std::ffi::OsStr; + +use usage_argv::{help, Error}; +use usage_derive::{ArgGroup, Args, Cli, Subcommands}; + +fn argv(tokens: [&str; N]) -> [&OsStr; N] { + tokens.map(OsStr::new) +} + +/// How to print the result +#[derive(ArgGroup, Debug, PartialEq)] +#[usage(name = "format")] +enum Format { + /// Print JSON + Json, + /// Print YAML + Yaml, + /// Print one line per record + #[usage(short = 'p', long = "plain")] + PlainText, +} + +/// Where to read from +#[derive(ArgGroup, Debug, PartialEq)] +enum Source { + /// Read from standard input + Stdin, + /// Read from the clipboard + Clipboard, +} + +/// A CLI whose format is optional and whose source is not. +#[derive(Cli)] +#[usage(bin = "grp")] +struct Grp { + /// A file to work on + #[usage(long)] + file: Option, + #[usage(arg_group)] + format: Option, + #[usage(arg_group)] + source: Source, +} + +#[test] +fn an_optional_group_may_be_left_alone() { + let a = argv(["--stdin"]); + let grp = Grp::parse_from(&a).expect("saying nothing about format is fine"); + assert_eq!(grp.format, None); + assert_eq!(grp.source, Source::Stdin); +} + +#[test] +fn one_member_selects_its_variant() { + let a = argv(["--stdin", "--yaml"]); + assert_eq!( + Grp::parse_from(&a).expect("one member").format, + Some(Format::Yaml) + ); + + // By its declared spellings too, since a member is a flag like any other. + let a = argv(["--stdin", "--plain"]); + assert_eq!( + Grp::parse_from(&a).expect("one member").format, + Some(Format::PlainText) + ); + let a = argv(["--stdin", "-p"]); + assert_eq!( + Grp::parse_from(&a).expect("one member").format, + Some(Format::PlainText) + ); +} + +#[test] +fn two_members_cannot_both_be_given() { + let a = argv(["--stdin", "--json", "--yaml"]); + assert!( + matches!( + Grp::parse_from(&a), + Err(Error::ConflictingFlags { + name: "yaml", + other: "json" + }) + ), + "{:?}", + Grp::parse_from(&a).err() + ); + + // The pair reported is the first two in declaration order, which is what the user has to + // choose between — and the same pair a hand-written group's pairwise check reports. + let a = argv(["--stdin", "-p", "--yaml"]); + assert!(matches!( + Grp::parse_from(&a), + Err(Error::ConflictingFlags { + name: "plain", + other: "yaml" + }) + )); +} + +#[test] +fn a_bare_field_makes_the_group_required() { + let a = argv([]); + assert!(matches!( + Grp::parse_from(&a), + Err(Error::MissingGroup { + group: "source", + members: ["--stdin", "--clipboard"] + }) + )); + + let a = argv(["--clipboard"]); + assert_eq!( + Grp::parse_from(&a).expect("one member").source, + Source::Clipboard + ); +} + +#[test] +fn a_conflict_answers_before_an_unsatisfied_group_does() { + // Both are wrong: `source` has no member and `format` has two. The conflict is the more + // useful answer, and it is the order the rest of the checks already follow. + let a = argv(["--json", "--yaml"]); + assert!(matches!( + Grp::parse_from(&a), + Err(Error::ConflictingFlags { .. }) + )); +} + +#[test] +fn the_group_reaches_the_emitted_spec_and_usage_lib_agrees() { + let kdl = Grp::to_kdl(); + // The switches are this command's flags, written inline where the field was declared. + for flag in [ + r#"flag --json help="Print JSON""#, + r#"flag --yaml help="Print YAML""#, + r#"flag "-p --plain" help="Print one line per record""#, + r#"flag --stdin help="Read from standard input""#, + r#"flag --clipboard help="Read from the clipboard""#, + ] { + assert!(kdl.contains(flag), "{flag} missing from:\n{kdl}"); + } + assert!(kdl.contains("group format --json --yaml --plain"), "{kdl}"); + assert!( + kdl.contains("group source --stdin --clipboard required=#true"), + "{kdl}" + ); + + // The reference implementation reads what the derive wrote and enforces the same rule, + // which is the point of the spec being the definition rather than a summary. + let spec: usage::Spec = kdl.parse().expect("the emitted spec should parse"); + let format = spec.cmd.groups.iter().find(|g| g.name == "format").unwrap(); + assert!(!format.required); + assert!(!format.multiple); + assert_eq!(format.members.len(), 3); + let source = spec.cmd.groups.iter().find(|g| g.name == "source").unwrap(); + assert!(source.required); + assert_eq!(source.members.len(), 2); +} + +#[test] +fn help_lists_the_members_with_their_own_descriptions() { + let page = help::render(Grp::spec(), Grp::spec().root.cmd, false).expect("a page"); + for line in [ + " --json", + " --yaml", + " -p, --plain", + " --stdin", + " --clipboard", + ] { + assert!( + page.lines().any(|l| l.starts_with(line)), + "no line starts `{line}`:\n{page}" + ); + } + assert!(page.contains("Print one line per record"), "{page}"); +} + +/// The same enum on a subcommand's own `Args`, beside a flattened group. +#[derive(Args)] +struct Shared { + /// Say more + #[usage(long, short = 'v')] + verbose: bool, +} + +/// Convert something +#[derive(Args)] +struct Convert { + #[usage(flatten)] + shared: Shared, + #[usage(arg_group)] + format: Option, + /// What to convert + target: String, +} + +#[derive(Subcommands)] +enum Command { + Convert(Convert), +} + +#[derive(Cli)] +#[usage(bin = "nested")] +struct Nested { + #[usage(subcommand)] + command: Option, +} + +#[test] +fn a_group_works_on_a_subcommand_beside_a_flattened_one() { + let a = argv(["convert", "--json", "-v", "x"]); + let Some(Command::Convert(convert)) = Nested::parse_from(&a).expect("parses").command else { + panic!("expected convert"); + }; + assert_eq!(convert.format, Some(Format::Json)); + assert!(convert.shared.verbose); + assert_eq!(convert.target, "x"); + + let a = argv(["convert", "--json", "--yaml", "x"]); + assert!(matches!( + Nested::parse_from(&a), + Err(Error::ConflictingFlags { .. }) + )); + + // The subcommand's flags are joined in the order the fields were written: the flattened + // struct's, then the group's, then this command's own positional. + let kdl = Nested::to_kdl(); + assert!(kdl.contains("group format --json --yaml --plain"), "{kdl}"); +} diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index 9b68a0cda..e8f01db7a 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -17,8 +17,8 @@ use quote::{format_ident, quote}; use crate::crate_name::{crate_name, FoundCrate}; use crate::model::{ - rendered_path, to_kebab, type_name, Cli, ConditionalDefault, Dispatch, DoubleDash, ExampleDecl, - Field, Kind, Shape, Subcommands, ValueEnum, ViewDecl, + rendered_path, to_kebab, type_name, ArgGroup, Cli, ConditionalDefault, Dispatch, DoubleDash, + ExampleDecl, Field, Kind, Shape, Subcommands, ValueEnum, ViewDecl, }; /// Construct the user's command type after its generated partial has been checked. @@ -2725,6 +2725,21 @@ fn tables(cli: &Cli) -> Tables { flag_meta_groups.push(quote!(<#ty as usage_argv::spec::CommandArgs>::META.flags)); arg_meta_groups.push(quote!(<#ty as usage_argv::spec::CommandArgs>::META.args)); } + // An argument group's switches are spliced the same way, and for the same reason: + // they are this command's flags, and only the enum's own expansion knows them. + // No `FlattenGroup` for them — the emitted spec writes them inline, since a group + // is not a set of flags declared once and shared between commands. + Kind::ArgGroup { ty, .. } => { + flattened = true; + if own_since_flatten > 0 { + flag_offset.push(quote!(#own_since_flatten)); + own_since_flatten = 0; + } + flag_offset.push(quote!(<#ty as usage_argv::spec::ArgGroup>::FLAGS.len())); + flush_flags(&mut own_flags, &mut flag_groups, &mut flag_meta_groups); + flag_groups.push(quote!(<#ty as usage_argv::spec::ArgGroup>::FLAGS)); + flag_meta_groups.push(quote!(<#ty as usage_argv::spec::ArgGroup>::FLAG_METAS)); + } Kind::Subcommand { .. } | Kind::Skip => {} } } @@ -3365,7 +3380,7 @@ fn presence_methods(cli: &Cli) -> TokenStream { let direct_given = cli.fields.iter().filter_map(|field| { if matches!( field.kind, - Kind::Flatten { .. } | Kind::Subcommand { .. } | Kind::Skip + Kind::Flatten { .. } | Kind::ArgGroup { .. } | Kind::Subcommand { .. } | Kind::Skip ) { return None; } @@ -3390,6 +3405,19 @@ fn presence_methods(cli: &Cli) -> TokenStream { } }) }); + let grouped_given = cli.fields.iter().filter_map(|field| { + let Kind::ArgGroup { ty, .. } = &field.kind else { + return None; + }; + let ident = &field.ident; + Some(quote! { + if let ::std::option::Option::Some(name) = + <#ty as usage_argv::spec::ArgGroup>::any_given(&partial.#ident) + { + return ::std::option::Option::Some(name); + } + }) + }); let selected = cli.fields.iter().find_map(|field| { if !matches!(field.kind, Kind::Subcommand { .. }) { return None; @@ -3446,6 +3474,7 @@ fn presence_methods(cli: &Cli) -> TokenStream { fn any_given(partial: &Self::Partial) -> ::std::option::Option<&'static str> { #(#direct_given)* #(#flattened_given)* + #(#grouped_given)* #selected ::std::option::Option::None } @@ -3519,7 +3548,7 @@ fn argument_lookup_functions(cli: &Cli) -> TokenStream { let state_arms = cli.fields.iter().filter_map(|field| { if matches!( field.kind, - Kind::Flatten { .. } | Kind::Subcommand { .. } | Kind::Skip + Kind::Flatten { .. } | Kind::ArgGroup { .. } | Kind::Subcommand { .. } | Kind::Skip ) { return None; } @@ -3563,7 +3592,7 @@ fn argument_lookup_functions(cli: &Cli) -> TokenStream { let match_arms = cli.fields.iter().filter_map(|field| { if matches!( field.kind, - Kind::Flatten { .. } | Kind::Subcommand { .. } | Kind::Skip + Kind::Flatten { .. } | Kind::ArgGroup { .. } | Kind::Subcommand { .. } | Kind::Skip ) { return None; } @@ -3736,6 +3765,14 @@ fn partial_struct(cli: &Cli) -> TokenStream { pub #ident: <#ty as usage_argv::spec::CommandArgs>::Partial, }); } + // An argument group accumulates which of its members were given, reached through its + // own trait for the same reason a flattened struct's partial is. + if let Kind::ArgGroup { ty, .. } = &f.kind { + let ident = &f.ident; + return Some(quote! { + pub #ident: <#ty as usage_argv::spec::ArgGroup>::Partial, + }); + } let ident = &f.ident; let ty = match f.shape { Shape::Bool => quote!(bool), @@ -4109,6 +4146,12 @@ fn partial_defaults(cli: &Cli) -> TokenStream { #ident: <#ty as usage_argv::spec::CommandArgs>::start(), }); } + if let Kind::ArgGroup { ty, .. } = &f.kind { + let ident = &f.ident; + return Some(quote! { + #ident: <#ty as usage_argv::spec::ArgGroup>::start(), + }); + } let ident = &f.ident; let given = format_ident!("__given_{}", ident); let overridden = is_displaceable(cli, f).then(|| { @@ -4189,6 +4232,30 @@ fn field_final(field: &Field, omitter: Option<&TokenStream>) -> TokenStream { }, }; } + // The group's own `build` says which member was given; the field's type says what "none" + // means. `check` has already reported both a second member and a required group with none, + // so this arm is reached only for a group that was satisfied — the error stays for the + // case where a caller drives `build` without it, rather than being an `unreachable!`. + if let Kind::ArgGroup { ty, optional } = &field.kind { + let group = quote!(<#ty as usage_argv::spec::ArgGroup>); + return if *optional { + quote!(#ident: #group::build(&partial.#ident)) + } else { + quote! { + #ident: match #group::build(&partial.#ident) { + ::std::option::Option::Some(__usage_member) => __usage_member, + ::std::option::Option::None => { + return ::std::result::Result::Err( + usage_argv::Error::MissingGroup { + group: #group::NAME, + members: #group::MEMBERS, + }, + ); + } + } + } + }; + } let active = view_field_active(field); let ty = &field.ty; let finished = |value: TokenStream| { @@ -4647,6 +4714,23 @@ fn apply_fn(cli: &Cli) -> TokenStream { }) }) .collect(); + // An argument group's switches are in this command's table with keys minted in the enum's + // own expansion, exactly as a flattened struct's are, so the enum is what recognizes them. + let grouped: Vec = cli + .fields + .iter() + .filter_map(|f| { + let Kind::ArgGroup { ty, .. } = &f.kind else { + return None; + }; + let ident = &f.ident; + Some(quote! { + if <#ty as usage_argv::spec::ArgGroup>::apply(&mut partial.#ident, event) { + return true; + } + }) + }) + .collect(); let mirrored_flattened = cli.fields.iter().filter_map(|f| { let Kind::Flatten { ty } = &f.kind else { return None; @@ -4706,6 +4790,7 @@ fn apply_fn(cli: &Cli) -> TokenStream { use usage_argv::Event; #route #(#flattened)* + #(#grouped)* // Each arm evaluates to whether it claimed the event, rather than // returning: a command with no flags of its own would otherwise have every // arm diverge, leaving an unreachable tail. @@ -5154,16 +5239,20 @@ pub fn emit_args(cli: &Cli) -> TokenStream { let view_bounds: Vec<_> = cli .fields .iter() - .map(|field| match &field.kind { + .filter_map(|field| match &field.kind { Kind::Flatten { ty } => { - quote!(#ty: usage_argv::spec::ViewCommandArgs<__UsageOmitter>) + Some(quote!(#ty: usage_argv::spec::ViewCommandArgs<__UsageOmitter>)) } Kind::Subcommand { ty, .. } => { - quote!(#ty: usage_argv::spec::ViewSubcommands<__UsageOmitter>) + Some(quote!(#ty: usage_argv::spec::ViewSubcommands<__UsageOmitter>)) } + // A group is built from what its own members were given either way, so a view + // never omits it — and imposing `Default` on the enum for a projection that does + // not need one would be a bound an adopter cannot see the reason for. + Kind::ArgGroup { .. } => None, _ => { let ty = &field.ty; - quote!(__UsageOmitter: usage_argv::spec::Omitted<#ty>) + Some(quote!(__UsageOmitter: usage_argv::spec::Omitted<#ty>)) } }) .collect(); @@ -6815,63 +6904,87 @@ fn required_by_single_implicit_group(cli: &Cli, field: &Field) -> bool { /// Leaving them out would enforce a rule the spec does not mention — the drift the /// spec-as-definition rule exists to prevent. fn group_meta_table(cli: &Cli) -> (TokenStream, TokenStream) { - let groups: Vec<_> = declared_groups(cli) + let declared: Vec<_> = declared_groups(cli) .into_iter() .filter(|(_, required, multiple, members)| members.len() >= 2 && (*required || !*multiple)) .collect(); - // Where each group's first member was written, which is the position `declared_groups` - // already orders them by. A group whose members straddle a flattened field still belongs - // where it *starts*, so it keeps its whole member list rather than being split in two. - let first_member_at: Vec = groups + // Where each group belongs: a declared one at its first member, which is the position + // `declared_groups` already orders them by, and an argument group at the field holding it. + // A group whose members straddle a flattened field still belongs where it *starts*, so it + // keeps its whole member list rather than being split in two. + let mut entries: Vec<(usize, TokenStream)> = declared .iter() - .map(|(_, _, _, members)| { - cli.fields + .map(|(name, required, multiple, members)| { + let at = cli + .fields .iter() .position(|f| Cli::selector_for_field(f).is_some_and(|s| members.contains(&s))) - .unwrap_or(usize::MAX) + .unwrap_or(usize::MAX); + ( + at, + quote! { + usage_argv::spec::GroupMeta { + name: #name, + members: &[#(#members),*], + required: #required, + multiple: #multiple, + } + }, + ) }) .collect(); - let entry = |(name, required, multiple, members): &(String, bool, bool, Vec)| { - quote! { - usage_argv::spec::GroupMeta { - name: #name, - members: &[#(#members),*], - required: #required, - multiple: #multiple, - } - } - }; + // Read from the enum rather than copied out of it: the members are its variants, and the + // field's type is the only thing that says whether one of them is needed. `multiple` is + // false because exclusivity is what an argument group is for. + for (at, field) in cli.fields.iter().enumerate() { + let Kind::ArgGroup { ty, optional } = &field.kind else { + continue; + }; + let required = !optional; + entries.push(( + at, + quote! { + usage_argv::spec::GroupMeta { + name: <#ty as usage_argv::spec::ArgGroup>::NAME, + members: <#ty as usage_argv::spec::ArgGroup>::MEMBERS, + required: #required, + multiple: false, + } + }, + )); + } + entries.sort_by_key(|(at, _)| *at); // One walk over the fields, so a flattened struct's groups land where the field was // written rather than after everything this struct declares — the same interleaving the // flag and argument tables are built with, and visible in the same places their order is. let mut parts: Vec = Vec::new(); let mut run: Vec = Vec::new(); - let mut emitted = vec![false; groups.len()]; + let mut emitted = vec![false; entries.len()]; let mut any_flattened = false; for (i, field) in cli.fields.iter().enumerate() { let Kind::Flatten { ty } = &field.kind else { continue; }; any_flattened = true; - for (g, group) in groups.iter().enumerate() { - if !emitted[g] && first_member_at[g] < i { - emitted[g] = true; - run.push(entry(group)); + for (e, (at, group)) in entries.iter().enumerate() { + if !emitted[e] && *at < i { + emitted[e] = true; + run.push(group.clone()); } } if !run.is_empty() { - let entries = std::mem::take(&mut run); - parts.push(quote!(&[#(#entries),*])); + let run = std::mem::take(&mut run); + parts.push(quote!(&[#(#run),*])); } // Named directly, as the flag and argument tables beside this one are: the // generated items live in the user's own scope now rather than in a module // above it, so there is no path to rewrite. parts.push(quote!(<#ty as usage_argv::spec::CommandArgs>::META.groups)); } - for (g, group) in groups.iter().enumerate() { - if !emitted[g] { - run.push(entry(group)); + for (e, (_, group)) in entries.iter().enumerate() { + if !emitted[e] { + run.push(group.clone()); } } if !run.is_empty() { @@ -6882,8 +6995,8 @@ fn group_meta_table(cli: &Cli) -> (TokenStream, TokenStream) { return (quote!(), quote!(&[])); } if !any_flattened { - let len = groups.len(); - let entries = groups.iter().map(entry); + let len = entries.len(); + let entries = entries.iter().map(|(_, group)| group); return ( quote! { pub static GROUP_METAS: [usage_argv::spec::GroupMeta; #len] = [#(#entries),*]; @@ -7791,7 +7904,10 @@ fn post_binding(cli: &Cli) -> TokenStream { .filter(|other| { !matches!( other.kind, - Kind::Subcommand { .. } | Kind::Flatten { .. } | Kind::Skip + Kind::Subcommand { .. } + | Kind::Flatten { .. } + | Kind::ArgGroup { .. } + | Kind::Skip ) }) .map(move |other| { @@ -7822,7 +7938,7 @@ fn post_binding(cli: &Cli) -> TokenStream { .filter(|field| { !matches!( field.kind, - Kind::Flatten { .. } | Kind::Subcommand { .. } | Kind::Skip + Kind::Flatten { .. } | Kind::ArgGroup { .. } | Kind::Subcommand { .. } | Kind::Skip ) }) .rev() @@ -7844,18 +7960,27 @@ fn post_binding(cli: &Cli) -> TokenStream { let has_flatten = cli .fields .iter() - .any(|field| matches!(field.kind, Kind::Flatten { .. })); + .any(|field| matches!(field.kind, Kind::Flatten { .. } | Kind::ArgGroup { .. })); let flattened_segments = cli.fields.iter().filter_map(|field| { - let Kind::Flatten { ty } = &field.kind else { - return None; - }; let ident = &field.ident; - Some(quote! { - ( - <#ty as usage_argv::spec::CommandArgs>::any_given(&partial.#ident), - <#ty as usage_argv::spec::CommandArgs>::exclusive_given(&partial.#ident), - ), - }) + match &field.kind { + Kind::Flatten { ty } => Some(quote! { + ( + <#ty as usage_argv::spec::CommandArgs>::any_given(&partial.#ident), + <#ty as usage_argv::spec::CommandArgs>::exclusive_given(&partial.#ident), + ), + }), + // A group declares no `exclusive` member of its own — exclusivity within the + // group is what a group *is* — so it contributes only what it was given, which + // is what an exclusive flag elsewhere on the command collides with. + Kind::ArgGroup { ty, .. } => Some(quote! { + ( + <#ty as usage_argv::spec::ArgGroup>::any_given(&partial.#ident), + ::std::option::Option::None, + ), + }), + _ => None, + } }); let subcommand_segment = cli.fields.iter().find_map(|field| { let Kind::Subcommand { ty, .. } = &field.kind else { @@ -8005,11 +8130,50 @@ fn post_binding(cli: &Cli) -> TokenStream { // they left out. Emitted together, an earlier group's `MissingGroup` would answer // before a later group's `ConflictingFlags` — and before a flattened child's, since // those run later still. - let group_exclusivity_checks: Vec = + let mut group_exclusivity_checks: Vec = group_checks.iter().filter_map(|(e, _)| e.clone()).collect(); - let group_required_checks: Vec = + let mut group_required_checks: Vec = group_checks.iter().filter_map(|(_, r)| r.clone()).collect(); + // An argument group asks the same two questions of a partial only its own expansion can + // read, so it answers them and this command reports them — in the same two phases, so a + // conflict here still comes before an unsatisfied group anywhere on the command. + for field in &cli.fields { + let Kind::ArgGroup { ty, optional } = &field.kind else { + continue; + }; + let ident = &field.ident; + let group = quote!(<#ty as usage_argv::spec::ArgGroup>); + group_exclusivity_checks.push(quote! { + if let ::std::option::Option::Some((__usage_earlier, __usage_later)) = + #group::conflict(&partial.#ident) + { + return ::std::result::Result::Err( + usage_argv::Error::ConflictingFlags { + name: __usage_later, + other: __usage_earlier, + }, + ); + } + }); + if *optional { + continue; + } + // A bare `T` has nowhere to put "no member", which is the whole declaration — the + // same reading of a type that makes a `String` field required. + let active = view_field_active(field); + group_required_checks.push(quote! { + if #active && #group::any_given(&partial.#ident).is_none() { + return ::std::result::Result::Err( + usage_argv::Error::MissingGroup { + group: #group::NAME, + members: #group::MEMBERS, + }, + ); + } + }); + } + // `required_if` and `required_unless` are the same question asked two ways: which // other flags decide whether this one had to be given. Neither needs to know the // order they arrived in — only whether they arrived — so both are answered here, @@ -8251,6 +8415,202 @@ pub fn emit_value_enum(value_enum: &ValueEnum) -> TokenStream { } } +/// The switches an argument group's variants are, and the state that collects them. +/// +/// The same shape as a command's own tables — `static` flags, `static` metadata, and a +/// partial an event is applied to — so a holding command splices them into its own tables at +/// compile time exactly as it splices a flattened `Args`. Which of them was given is decided +/// here; whether that is *acceptable* is the holding command's `check`, because required-ness +/// is a property of the field rather than of the enum. +pub fn emit_arg_group(group: &ArgGroup) -> TokenStream { + let ident = &group.ident; + let runtime = runtime_path(); + let name = &group.name; + + // Minted from this declaration and the module it sits in, like a command's: two argument + // groups in different modules cannot hand a parse the same key, and the arm that claims an + // event verifies it came from this table. + let declaration = declaration_hash(&group.fingerprint); + let key_decls = (0..group.variants.len()).map(|i| { + let key = key_ident("FLAG", Some(i)); + let index = i as u64; + quote!(const #key: u64 = __USAGE_KEY_BASE | #KIND_FLAG | #index;) + }); + + let flags = group.variants.iter().enumerate().map(|(i, member)| { + let table = format_ident!("FLAG_{i}"); + let key = key_ident("FLAG", Some(i)); + let cfg = &member.cfg_attrs; + let long = &member.name; + let shorts: Vec = member.short.map(|short| short as u8).into_iter().collect(); + quote! { + #(#cfg)* + pub static #table: usage_argv::Flag = usage_argv::Flag { + key: #key, + name: #long, + longs: &[#long], + shorts: &[#(#shorts),*], + ..usage_argv::Flag::BOOL + }; + } + }); + + let flag_refs = group.variants.iter().enumerate().map(|(i, member)| { + let table = format_ident!("FLAG_{i}"); + let cfg = &member.cfg_attrs; + quote!(#(#cfg)* &#table) + }); + let flag_metas = group.variants.iter().enumerate().map(|(i, member)| { + let table = format_ident!("FLAG_{i}"); + let cfg = &member.cfg_attrs; + let help = option_str(member.help.as_deref()); + let long_help = option_str(member.long_help.as_deref()); + let hide = member.hide; + quote! { + #(#cfg)* + usage_argv::spec::FlagMeta { + flag: &#table, + help: #help, + long_help: #long_help, + hide: #hide, + ..usage_argv::spec::FlagMeta::EMPTY + } + } + }); + let members = group.variants.iter().map(|member| { + let cfg = &member.cfg_attrs; + let selector = format!("--{}", member.name); + quote!(#(#cfg)* #selector) + }); + + let partial_fields = group.variants.iter().enumerate().map(|(i, member)| { + let given = format_ident!("given_{i}"); + let cfg = &member.cfg_attrs; + quote!(#(#cfg)* pub #given: bool,) + }); + let apply_arms = group.variants.iter().enumerate().map(|(i, member)| { + let table = format_ident!("FLAG_{i}"); + let key = key_ident("FLAG", Some(i)); + let given = format_ident!("given_{i}"); + let cfg = &member.cfg_attrs; + quote! { + #(#cfg)* + #key if ::core::ptr::eq(*flag, &#table) => { + partial.#given = true; + true + } + } + }); + let given_arms = group.variants.iter().enumerate().map(|(i, member)| { + let given = format_ident!("given_{i}"); + let cfg = &member.cfg_attrs; + let name = &member.name; + quote! { + #(#cfg)* + if partial.#given { + return ::std::option::Option::Some(#name); + } + } + }); + // The first two given, in declaration order, which is the pair the user has to choose + // between — and the same pair a hand-written group's pairwise check reports. + let conflict_arms = group.variants.iter().enumerate().map(|(i, member)| { + let given = format_ident!("given_{i}"); + let cfg = &member.cfg_attrs; + let name = &member.name; + quote! { + #(#cfg)* + if partial.#given { + if let ::std::option::Option::Some(__usage_earlier) = __usage_first { + return ::std::option::Option::Some((__usage_earlier, #name)); + } + __usage_first = ::std::option::Option::Some(#name); + } + } + }); + let build_arms = group.variants.iter().enumerate().map(|(i, member)| { + let given = format_ident!("given_{i}"); + let cfg = &member.cfg_attrs; + let variant = &member.ident; + quote! { + #(#cfg)* + if partial.#given { + return ::std::option::Option::Some(Self::#variant); + } + } + }); + + quote! { + #[doc(hidden)] + #[allow( + non_upper_case_globals, + non_snake_case, + unused_imports, + clippy::needless_update + )] + const _: () = { + use #runtime as usage_argv; + + const __USAGE_KEY_BASE: u64 = + usage_argv::key_base(::core::module_path!(), #declaration); + #(#key_decls)* + + #(#flags)* + + #[derive(Default)] + pub struct Partial { + #(#partial_fields)* + } + + impl usage_argv::spec::ArgGroup for #ident { + const NAME: &'static str = #name; + const FLAGS: &'static [&'static usage_argv::Flag<'static>] = &[#(#flag_refs),*]; + const FLAG_METAS: &'static [usage_argv::spec::FlagMeta<'static>] = + &[#(#flag_metas),*]; + const MEMBERS: &'static [&'static str] = &[#(#members),*]; + + type Partial = Partial; + + fn apply( + partial: &mut Self::Partial, + event: &usage_argv::Event<'_, '_, '_>, + ) -> bool { + match event { + usage_argv::Event::Flag { flag, .. } => match flag.key { + #(#apply_arms)* + // Another declaration's flag, left for whoever owns it. + _ => false, + }, + _ => false, + } + } + + fn any_given(partial: &Self::Partial) -> ::std::option::Option<&'static str> { + #(#given_arms)* + ::std::option::Option::None + } + + fn conflict( + partial: &Self::Partial, + ) -> ::std::option::Option<(&'static str, &'static str)> { + let mut __usage_first: ::std::option::Option<&'static str> = + ::std::option::Option::None; + #(#conflict_arms)* + // Read so the last member's assignment is not a store nobody looks at, + // which the adopter's crate is where the lint would land. + let _ = __usage_first; + ::std::option::Option::None + } + + fn build(partial: &Self::Partial) -> ::std::option::Option { + #(#build_arms)* + ::std::option::Option::None + } + } + }; + } +} + #[cfg(test)] mod binding_hash_tests { use super::hash_binding_part; diff --git a/derive/src/lib.rs b/derive/src/lib.rs index f1c26fb1f..10dd4e652 100644 --- a/derive/src/lib.rs +++ b/derive/src/lib.rs @@ -296,6 +296,7 @@ //! | `double_dash = "…"` | how a positional relates to `--`: `optional` (the default), `required` (fillable only after one), `preserve` (the `--` is a value), `automatic` (filling it ends flag parsing, so a wrapper forwards) | //! | `complete = my_fn` | a function that answers for this value when a shell asks | //! | `value_enum` | the words come from the field's type, which derives [`ValueEnum`] | +//! | `arg_group` | the flags come from the field's type, which derives [`ArgGroup`]; at most one may be given | //! | `value_hint = usage::ValueHint::FilePath` | ask the shell for paths, executables, or forwarded command argv | //! | `arg` | force a field to be positional | //! | `id = "name"` | clap-compatible spelling for the field identity / positional name | @@ -358,6 +359,10 @@ //! two together are "at least one" — clap's two properties, read the same way. A group //! with one member, or a declaration no field joins, is a compile error. //! +//! A group of valueless flags may instead be an enum deriving [`ArgGroup`], held by one +//! field marked `arg_group`, so the code reading it matches on a variant rather than on +//! which of several `bool`s is set. It lowers to the same `group` node and the same errors. +//! //! These post-parse relationships work on flags and positionals. `overrides` remains a //! flag-only binding rule. An argument ID such as `"mode"`, as clap attributes commonly //! use, resolves to the same field as the portable `"--mode"` spelling and is emitted in @@ -538,3 +543,55 @@ pub fn derive_value_enum(input: TokenStream) -> TokenStream { Err(e) => e.to_compile_error().into(), } } + +/// Compile an enum into a set of flags at most one of which may be given. +/// +/// clap's most-requested derive ergonomic (clap#2621): mutually exclusive flags as enum +/// variants, so the code that reads them matches on a type rather than on which of several +/// `bool`s is set. Each variant is one switch, named by its own name in kebab-case: +/// +/// ```ignore +/// /// How to print the result +/// #[derive(usage::ArgGroup)] +/// #[usage(name = "format")] +/// enum Format { +/// /// Print JSON +/// Json, +/// /// Print YAML +/// Yaml, +/// #[usage(short = 'p', long = "plain")] +/// PlainText, +/// } +/// ``` +/// +/// A field holds one and says `arg_group`. `Option` is a group that may be left alone +/// and a bare `Format` is one that has to be given — the same rule every other field's type is +/// read by, and the only spelling of required-ness a group has, since there is no default +/// variant: +/// +/// ```ignore +/// #[derive(usage::Cli)] +/// #[usage(bin = "ex")] +/// struct Ex { +/// #[usage(arg_group)] +/// format: Option, +/// } +/// ``` +/// +/// Nothing new reaches the spec: the enum lowers to the `group` node and the flags it names, +/// so `--json --yaml` is the same [`Error::ConflictingFlags`](usage_argv::Error::ConflictingFlags) +/// a hand-written group produces, and a missing member of a required one is the same +/// [`Error::MissingGroup`](usage_argv::Error::MissingGroup). A member taking a value stays a +/// hand-written `conflicts` set, where the values have somewhere to land. +/// +/// A variant's doc comment becomes its help. `help = "..."`, `long_help = "..."`, `hide`, and +/// `short = 'x'` are the rest of what a switch has; `cfg` and `cfg_attr` are copied to the +/// variant's entries in the static tables, as [`ValueEnum`] copies them. +#[proc_macro_derive(ArgGroup, attributes(usage, command, arg, group))] +pub fn derive_arg_group(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + match model::ArgGroup::from_input(&input) { + Ok(group) => codegen::emit_arg_group(&group).into(), + Err(e) => e.to_compile_error().into(), + } +} diff --git a/derive/src/model.rs b/derive/src/model.rs index 9e30eaa86..7d6edd34f 100644 --- a/derive/src/model.rs +++ b/derive/src/model.rs @@ -602,6 +602,18 @@ pub enum Kind { /// The struct's type, as written. ty: syn::Type, }, + /// Holds the enum whose variants are one group of this command's exclusive flags. + /// + /// The type is carried rather than resolved, as for a flatten: the derive cannot see the + /// enum's variants, so the switches, their metadata, and the state that collects them all + /// arrive through [`ArgGroup`](usage_argv::spec::ArgGroup). + ArgGroup { + /// The enum's type with any `Option` stripped, which is what names the trait. + ty: syn::Type, + /// Whether the field is `Option`, and so may be left alone. A bare `T` says one + /// member has to be given, which is reported once the last token has been read. + optional: bool, + }, /// A field that is not an argument at all, filled from `Default`. /// /// clap's `#[arg(skip)]`: the struct still holds the field so a rewrite can keep @@ -1467,6 +1479,11 @@ impl Cli { // where the whole tree is visible, by the duplicate-form check in // `Spec::to_kdl`. Kind::Flatten { .. } => {} + // Nothing to check here either, and for the same reason: the enum's own + // derive checked its members, and a collision between one of them and a flag + // this struct declares is invisible from either side. `Spec::to_kdl`'s + // duplicate-form check is where the whole tree is visible. + Kind::ArgGroup { .. } => {} Kind::Skip => {} Kind::Arg { double_dash } => { // A variadic takes every remaining word, so anything after it can @@ -2045,6 +2062,158 @@ impl Field { })) } + /// A field marked `#[usage(arg_group)]`, if this is one. + /// + /// Recognized before flags and arguments for the same reason a flatten is: the field holds + /// a set of declarations rather than a value, and the enum's own variants say what each of + /// them is called. What a doc comment or a `long` here would describe is one member, and + /// there is more than one. + fn arg_group( + field: &syn::Field, + ident: &syn::Ident, + span: proc_macro2::Span, + ) -> syn::Result> { + let mut found = false; + for attr in attrs(&field.attrs) { + for meta in nested(attr)? { + if ident_of(&meta.path().clone()) != "arg_group" { + continue; + } + if !matches!(meta, Meta::Path(_)) { + return Err(syn::Error::new_spanned( + meta.path(), + "`arg_group` takes no value: the enum it holds is the field's type, \ + and the group's name is declared on the enum", + )); + } + found = true; + } + } + if !found { + return Ok(None); + } + + // Nothing else may be declared beside it. A group's members are the enum's variants, + // so `long` or `group =` here would be naming a set as though it were one flag — + // and `group = "other"` would be a second group with the same members. + for attr in attrs(&field.attrs) { + for meta in nested(attr)? { + let name = ident_of(&meta.path().clone()); + if name != "arg_group" { + return Err(syn::Error::new_spanned( + meta.path(), + format!( + "`arg_group` cannot be combined with `{name}`: the enum's variants \ + declare the members, and the enum itself declares the group" + ), + )); + } + } + } + + // `Option` is a group that may be left alone and a bare `T` is one that has to be + // given, which is the same rule every other field's type is read by — and the only + // spelling of required-ness a group has. + let name = type_name(&field.ty); + let (ty, optional) = match name + .strip_prefix("Option<") + .and_then(|rest| rest.strip_suffix('>')) + { + Some(inner) => (syn::parse_str::(inner)?, true), + None => (field.ty.clone(), false), + }; + + // Anything wrapped around the enum is refused here, where the field is, rather than + // left to the unsatisfied trait bound the generated code would report: a group + // resolves to at most one member, so there is nothing for a container to hold. + let inner = type_name(&ty); + if let Some(container) = ["Vec", "Option", "Box"] + .into_iter() + .find(|container| inner.starts_with(&format!("{container}<"))) + { + return Err(syn::Error::new_spanned( + &field.ty, + format!( + "`arg_group` holds its enum as `Mode` for a required group or \ + `Option` for an optional one: a group resolves to at most one \ + member, so there is nothing for `{container}` to hold" + ), + )); + } + + Ok(Some(Field { + ident: ident.clone(), + ty: field.ty.clone(), + name: to_kebab(&ident.to_string()), + value_optional: false, + kind: Kind::ArgGroup { ty, optional }, + // Each member carries its own, as a flattened group's flags do: the field holds + // no flag of its own to describe. + effect: None, + complete: None, + complete_type: None, + // The field holds declarations rather than a value, the same as a flatten. + shape: Shape::Bool, + value_ty: None, + optional_collection: false, + optional_value_type: false, + help: None, + long_help: None, + deprecated: None, + deprecated_warn_at: None, + deprecated_remove_at: None, + env: None, + env_fallback: Vec::new(), + deprecated_env: Vec::new(), + setting: None, + default: Vec::new(), + default_value_t: None, + help_heading: None, + display_order: None, + value_name: None, + value_names: Vec::new(), + required_collection: false, + choices: Vec::new(), + allow_unknown_choices: false, + validate: None, + validate_error: None, + value_enum: false, + var_min: None, + var_max: None, + value_var_min: None, + value_var_max: None, + overrides: Vec::new(), + conflicts: Vec::new(), + requires: Vec::new(), + requires_if: Vec::new(), + default_if: Vec::new(), + delimiter: None, + allow_hyphen_values: false, + allow_negative_numbers: false, + value_terminator: None, + require_equals: false, + bool_value: false, + default_missing: None, + exclusive: false, + group: None, + required_if: Vec::new(), + required_if_eq: Vec::new(), + required_if_eq_all: Vec::new(), + required_unless: Vec::new(), + required_unless_all: Vec::new(), + hide: false, + hide_default_value: false, + hide_env: false, + hide_env_values: false, + hide_possible_values: false, + hide_short_help: false, + hide_long_help: false, + repeatable: false, + action: ArgAction::Set, + span, + })) + } + /// A field marked `#[usage(subcommand)]`, if this is one. fn subcommand( field: &syn::Field, @@ -2199,6 +2368,9 @@ impl Field { if let Some(flattened) = Self::flatten(field, &ident, span)? { return Ok(flattened); } + if let Some(group) = Self::arg_group(field, &ident, span)? { + return Ok(group); + } let rust_name = ident.unraw().to_string(); // clap treats the conventional suffix used to escape Rust keywords as a Rust-only @@ -5503,9 +5675,194 @@ impl ValueEnum { } } +/// An enum whose variants are one group of a command's exclusive flags. +pub struct ArgGroup { + pub ident: syn::Ident, + /// What the group is called in the emitted spec: the type's name in kebab-case, + /// unless the enum says otherwise. + pub name: String, + /// What this type's keys are derived from. See [`Cli::fingerprint`]. + pub fingerprint: String, + /// Each variant, and the switch it answers to. + pub variants: Vec, +} + +pub struct ArgGroupMember { + pub ident: syn::Ident, + /// The flag's long form, without the leading `--`, which is also its spec name. + pub name: String, + pub short: Option, + pub help: Option, + pub long_help: Option, + pub hide: bool, + pub cfg_attrs: Vec, +} + +impl ArgGroup { + pub fn from_input(input: &DeriveInput) -> syn::Result { + let Data::Enum(data) = &input.data else { + return Err(syn::Error::new_spanned( + &input.ident, + "usage::ArgGroup describes a set of flags at most one of which may be given, \ + so it needs an enum", + )); + }; + if !input.generics.params.is_empty() { + return Err(syn::Error::new_spanned( + &input.generics, + "usage::ArgGroup does not support generic parameters: the flag tables are \ + `const`", + )); + } + + let mut name = to_kebab(&input.ident.unraw().to_string()); + for attr in attrs(&input.attrs) { + for meta in nested(attr)? { + let path = meta.path().clone(); + match ident_of(&path).as_str() { + "name" => name = string_value(&meta)?, + other => { + return Err(syn::Error::new_spanned( + path, + format!( + "unknown arg-group option `{other}`; the enum takes `name` \ + here, and everything else belongs on a variant" + ), + )) + } + } + } + } + if name.is_empty() { + return Err(syn::Error::new_spanned( + &input.ident, + "a group with no name has nothing for a failed check to report", + )); + } + + let mut variants: Vec = Vec::new(); + for variant in &data.variants { + if !matches!(variant.fields, Fields::Unit) { + return Err(syn::Error::new_spanned( + &variant.fields, + "a group member is a switch, so each variant is a bare name: a member \ + taking a value stays a hand-written `conflicts` set, where the values \ + have somewhere to land", + )); + } + let cfg_attrs = cfg_gate_attrs(&variant.attrs)?; + let (doc_help, doc_long_help) = doc_comment(&variant.attrs, false)?; + let mut member = ArgGroupMember { + ident: variant.ident.clone(), + name: to_kebab(&variant.ident.unraw().to_string()), + short: None, + help: doc_help, + long_help: doc_long_help, + hide: false, + cfg_attrs, + }; + for attr in attrs(&variant.attrs) { + for meta in nested(attr)? { + let path = meta.path().clone(); + match ident_of(&path).as_str() { + // One spelling reaches both, because a switch's long form and its + // spec name are the same word: two ways to say it would be two + // things to keep in step. + "long" | "name" => member.name = strip_dashes(&string_value(&meta)?), + "short" => member.short = Some(char_value(&meta)?), + "help" => member.help = Some(string_value(&meta)?), + "long_help" => member.long_help = Some(string_value(&meta)?), + "hide" => member.hide = flag_value(&meta)?, + "default" | "default_value" | "default_value_t" => { + return Err(syn::Error::new_spanned( + path, + "a group has no default member: required-ness is the \ + `Option` versus `T` distinction on the field holding it, \ + and a default would be a second way to spell one", + )) + } + other => { + return Err(syn::Error::new_spanned( + path, + format!( + "unknown option `{other}` on a group member; a variant \ + takes `long`, `name`, `short`, `help`, `long_help`, or \ + `hide` here" + ), + )); + } + } + } + } + if member.name.is_empty() { + return Err(syn::Error::new_spanned( + &variant.ident, + "a member with no long form would answer to nothing", + )); + } + if let Some(short) = member.short.filter(|short| !short.is_ascii()) { + return Err(syn::Error::new_spanned( + &variant.ident, + format!( + "`short = '{short}'` is not ASCII, and a cluster like `-xyz` is \ + walked one byte at a time, so it could never be matched" + ), + )); + } + variants.push(member); + } + // One member is not a relationship: the spec reserves a group for something said + // about a set, and "at most one of this one flag" is always true. + if variants.len() < 2 { + return Err(syn::Error::new_spanned( + &input.ident, + "a group needs at least two members: with one there is nothing to be \ + exclusive with, so declare the flag on the command instead", + )); + } + + let mut seen_long: Vec<(&str, Span, &[Attribute])> = Vec::new(); + let mut seen_short: Vec<(char, Span, &[Attribute])> = Vec::new(); + for member in &variants { + let collides = |cfg: &[Attribute]| !cfg_variants_are_disjoint(cfg, &member.cfg_attrs); + if let Some((long, first, _)) = seen_long + .iter() + .find(|(long, _, cfg)| *long == member.name && collides(cfg)) + { + return Err(dup( + member.ident.span(), + *first, + &format!("--{long} names two of these members"), + )); + } + seen_long.push((&member.name, member.ident.span(), &member.cfg_attrs)); + if let Some(short) = member.short { + if let Some((short, first, _)) = seen_short + .iter() + .find(|(seen, _, cfg)| *seen == short && collides(cfg)) + { + return Err(dup( + member.ident.span(), + *first, + &format!("-{short} names two of these members"), + )); + } + seen_short.push((short, member.ident.span(), &member.cfg_attrs)); + } + } + + Ok(ArgGroup { + ident: input.ident.clone(), + name, + fingerprint: quote::ToTokens::to_token_stream(input).to_string(), + variants, + }) + } +} + #[cfg(test)] mod tests { - use super::{Cli, DoubleDash, Kind, Shape, Subcommands, ValueEnum}; + use super::{ArgGroup, Cli, DoubleDash, Kind, Shape, Subcommands, ValueEnum}; fn cli(body: &str) -> syn::Result { Cli::from_input(&syn::parse_str::(body).expect("valid Rust")) @@ -6825,6 +7182,205 @@ mod tests { ); } + fn arg_group(body: &str) -> syn::Result { + ArgGroup::from_input(&syn::parse_str::(body).expect("valid Rust")) + } + + /// The message a bad argument group produces. + fn group_rejection(body: &str) -> String { + match arg_group(body) { + Ok(_) => panic!("should not have compiled"), + Err(e) => e.to_string(), + } + } + + #[test] + fn an_arg_group_names_itself_and_its_members_after_the_rust_names() { + let group = arg_group( + r#" + enum OutputFormat { + /// Print JSON + Json, + PlainText, + } + "#, + ) + .expect("should compile"); + assert_eq!(group.name, "output-format"); + assert_eq!(group.variants[0].name, "json"); + assert_eq!(group.variants[0].help.as_deref(), Some("Print JSON")); + assert!(group.variants[0].short.is_none()); + assert_eq!(group.variants[1].name, "plain-text"); + } + + #[test] + fn an_arg_group_takes_a_declared_name_and_member_spellings() { + let group = arg_group( + r#" + #[usage(name = "format")] + enum OutputFormat { + #[usage(long = "--json", short = 'j')] + Json, + #[usage(name = "plain", hide, help = "One line per record")] + PlainText, + } + "#, + ) + .expect("should compile"); + assert_eq!(group.name, "format"); + assert_eq!(group.variants[0].name, "json"); + assert_eq!(group.variants[0].short, Some('j')); + assert_eq!(group.variants[1].name, "plain"); + assert!(group.variants[1].hide); + assert_eq!( + group.variants[1].help.as_deref(), + Some("One line per record") + ); + } + + #[test] + fn an_arg_group_member_is_a_switch_and_holds_nothing() { + let err = group_rejection("enum Format { Json, Wrapped(String) }"); + assert!(err.contains("bare name"), "unhelpful: {err}"); + // And the message says where a valued member belongs, which is the useful half. + assert!(err.contains("conflicts"), "unhelpful: {err}"); + } + + #[test] + fn a_group_of_one_says_nothing_about_a_set() { + let err = group_rejection("enum Format { Json }"); + assert!(err.contains("at least two members"), "unhelpful: {err}"); + } + + #[test] + fn a_group_has_no_default_member() { + let err = group_rejection( + r#" + enum Format { + #[usage(default)] + Json, + Yaml, + } + "#, + ); + assert!(err.contains("no default member"), "unhelpful: {err}"); + } + + #[test] + fn two_members_cannot_answer_to_one_spelling() { + let err = group_rejection( + r#" + enum Format { + #[usage(name = "text")] + Plain, + #[usage(name = "text")] + Pretty, + } + "#, + ); + assert!(err.contains("--text names two"), "unhelpful: {err}"); + + let err = group_rejection( + r#" + enum Format { + #[usage(short = 'j')] + Json, + #[usage(short = 'j')] + Jsonl, + } + "#, + ); + assert!(err.contains("-j names two"), "unhelpful: {err}"); + } + + #[test] + fn a_conditional_member_keeps_its_cfg_for_static_emission() { + let group = arg_group( + r#" + enum Format { + Json, + #[cfg(windows)] + Clipboard, + } + "#, + ) + .expect("conditional variants should compile"); + assert!(group.variants[0].cfg_attrs.is_empty()); + assert_eq!(group.variants[1].cfg_attrs.len(), 1); + } + + #[test] + fn an_arg_group_needs_an_enum() { + let err = group_rejection("struct Format { json: bool }"); + assert!(err.contains("needs an enum"), "unhelpful: {err}"); + } + + #[test] + fn an_arg_group_field_declares_nothing_of_its_own() { + let err = rejection( + r#" + struct Ex { + #[usage(arg_group, long)] + format: Option, + } + "#, + ); + assert!( + err.contains("`arg_group` cannot be combined with `long`"), + "unhelpful: {err}" + ); + + // Including membership of a second group, which would give the same flags two. + let err = rejection( + r#" + struct Ex { + #[usage(arg_group, group = "other")] + format: Option, + } + "#, + ); + assert!(err.contains("cannot be combined"), "unhelpful: {err}"); + } + + #[test] + fn an_arg_group_field_reads_required_ness_from_its_type() { + let parsed = cli(r#" + struct Ex { + #[usage(arg_group)] + format: Option, + #[usage(arg_group)] + source: Source, + } + "#) + .expect("should compile"); + let Kind::ArgGroup { optional, .. } = &parsed.fields[0].kind else { + panic!("expected an argument group"); + }; + assert!( + optional, + "`Option` is a group that may be left alone" + ); + let Kind::ArgGroup { optional, ty } = &parsed.fields[1].kind else { + panic!("expected an argument group"); + }; + assert!(!optional, "a bare `Source` is a group that has to be given"); + assert_eq!(super::type_name(ty), "Source"); + + // And nothing wrapped around it, which the field says rather than the trait bound the + // generated code would otherwise fail. + for ty in ["Vec", "Option>", "Box"] { + let err = rejection(&format!( + r#" + struct Ex {{ + #[usage(arg_group)] + format: {ty}, + }} + "# + )); + assert!(err.contains("at most one member"), "unhelpful: {err}"); + } + } + /// The position rules, which each derive applies for the place it stands in. fn position_error(body: &str, is_root: bool) -> String { let parsed = cli(body).expect("parses"); diff --git a/docs/rust/args-and-flags.md b/docs/rust/args-and-flags.md index 482475966..5cb051e08 100644 --- a/docs/rust/args-and-flags.md +++ b/docs/rust/args-and-flags.md @@ -110,12 +110,13 @@ jobs: Option, **Relationships** — constraints between arguments, checked after parsing: -| Attribute | Effect | -| ------------------------------------------------------- | -------------------------------------------------------------------- | -| `conflicts(…)` / `requires(…)` | Relations to other flags ([Validation](/rust/validation)) | -| `required_if(…)`, `required_if_eq…`, `required_unless…` | Conditional required-ness with single, any, and all forms | -| `group = "name"` | Join a flag group ([Validation](/rust/validation#groups)) | -| `exclusive` | Must be given alone ([Validation](/rust/validation#exclusive-flags)) | +| Attribute | Effect | +| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| `conflicts(…)` / `requires(…)` | Relations to other flags ([Validation](/rust/validation)) | +| `required_if(…)`, `required_if_eq…`, `required_unless…` | Conditional required-ness with single, any, and all forms | +| `group = "name"` | Join a flag group ([Validation](/rust/validation#groups)) | +| `arg_group` | Take a whole group from an `ArgGroup` enum ([Validation](/rust/validation#a-group-as-an-enum)) | +| `exclusive` | Must be given alone ([Validation](/rust/validation#exclusive-flags)) | **Deprecation** — a flag on its way out: diff --git a/docs/rust/clap-compatibility.md b/docs/rust/clap-compatibility.md index b2f2ab859..3e189dea8 100644 --- a/docs/rust/clap-compatibility.md +++ b/docs/rust/clap-compatibility.md @@ -92,7 +92,7 @@ the Rust declaration, not only from generated KDL, wherever the bridge column sa | `requires` | yes | yes | yes | yes | yes | usage-only | clap exposes setters but no getter. | | `requires_if(s)` | yes | yes | yes | yes | yes | usage-only | Presence and value-conditional forms are supported. | | `required_if_eq`, `required_unless_present` families | exact | exact | exact | exact | exact | usage-only | Single, any, and all truth tables work for flags and positionals; clap exposes setters but no getters. | -| `ArgGroup`, required groups, `exclusive` | yes | yes | yes | yes | yes | yes | Bare selectors preserve positional members. | +| `ArgGroup`, required groups, `exclusive` | yes | yes | yes | yes | yes | yes | Bare selectors preserve positional members. A group of switches may also be declared as an enum (see Usage extensions). | | positional conflicts | yes | yes | yes | yes | yes | yes | Bare selectors name positionals; dashed selectors name flags. | | other relationships declared on positionals | partial | partial | partial | partial | partial | usage-only | `requires` and conditional requiredness work; binding-time `overrides` and value-source `requires_if` remain flag-only. | | relationships through `flatten` | lossy | lossy | yes | yes | yes | lossy | A declaring type cannot yet validate a selector supplied by a flattened sibling. | @@ -141,6 +141,13 @@ These are not clap compatibility gaps. usage additionally supports `mount`, and a language-neutral conformance corpus. clap cannot express those properties, so a clap-generated spec cannot carry them without an overlay. +`#[derive(usage::ArgGroup)]` is one more: a group of valueless flags declared as an +enum of bare variants, held by an `Option` field for an optional group or a bare `T` +field for a required one. This is clap#2621, which clap has not implemented, so there is +nothing for the bridge to recover; it lowers to the same `group` node and the same +`ConflictingFlags` and `MissingGroup` errors a hand-written group produces, so every +other layer sees an ordinary group. + This matrix is the compatibility baseline, not a promise to reproduce clap's dynamic builder and `ArgMatches` architecture. Setter-only clap state remains explicitly **usage-only** until clap exposes a getter or the bridge gains another reliable source. diff --git a/docs/rust/index.md b/docs/rust/index.md index 45d3abd58..2ca57e489 100644 --- a/docs/rust/index.md +++ b/docs/rust/index.md @@ -65,7 +65,7 @@ available for low-level adopters that want a thinner surface: | Crate | Role | | -------------- | -------------------------------------------------------------------------------------- | | `usage-rs` | The one package an application depends on; re-exports the whole runtime | -| `usage-derive` | The derive macros: `Cli`, `Args`, `Subcommands`, `ValueEnum` | +| `usage-derive` | The derive macros: `Cli`, `Args`, `Subcommands`, `ValueEnum`, `ArgGroup` | | `usage-argv` | The zero-allocation, zero-dependency runtime the derive emits code against | | `usage-test` | Test helpers: what a command line parses to, what a page says, what a shell is offered | | `usage-config` | Layered settings resolution with provenance ([Settings](/rust/settings)) | diff --git a/docs/rust/validation.md b/docs/rust/validation.md index 4c4426479..462083960 100644 --- a/docs/rust/validation.md +++ b/docs/rust/validation.md @@ -96,6 +96,54 @@ Groups are emitted into the KDL spec command that flattens it. Malformed groups — one member, no members, declared twice, a group on a positional — are compile errors. +## A group as an enum + +When every member is a valueless flag, the group can be the type instead: an enum deriving +`ArgGroup`, whose variants are the flags. The code that reads it then matches on a variant +rather than working out which of several `bool`s is set. + +```rust +/// How to print the result +#[derive(ArgGroup)] +#[usage(name = "format")] +enum Format { + /// Print JSON + Json, + /// Print YAML + Yaml, + /// Print one line per record + #[usage(short = 'p', long = "plain")] + PlainText, +} + +#[derive(Cli)] +#[usage(bin = "fmt")] +struct Fmt { + #[usage(arg_group)] + format: Option, +} +``` + +The variants are bare: each is one switch, named by its own name in kebab-case unless `long` +or `name` says otherwise, with `short`, `hide`, `help` and `long_help` as the rest of what a +switch has. A doc comment is the help. Without `#[usage(name = "…")]` the group is named after +the type. + +The field's type says whether the group is required, exactly as it does everywhere else: +`Option` is a group that may be left alone, and a bare `Format` is one that has to be +given. There is no default variant, so that distinction is the only spelling of required-ness a +group has. + +Nothing new reaches the spec. The enum lowers to the same `group` node +(`group "format" "--json" "--yaml" "--plain"`), so `--json --yaml` is the same +`ConflictingFlags` a hand-written group produces, a required group with none of its members +given is the same `MissingGroup`, and help, docs and completions list the member flags without +knowing an enum was involved. + +A member that takes a value stays a hand-written `conflicts` set or a +[`ValueEnum`](/rust/subcommands#value-enums) on one flag, where the values have somewhere to +land. + ## Exclusive flags An `exclusive` flag has to be given alone — no other flag, no argument, no subcommand: diff --git a/usage-rs/src/lib.rs b/usage-rs/src/lib.rs index 62964f200..1a4977e30 100644 --- a/usage-rs/src/lib.rs +++ b/usage-rs/src/lib.rs @@ -57,7 +57,7 @@ pub use usage_config as config; #[cfg(feature = "config")] pub use usage_derive::Config; #[cfg(feature = "spec")] -pub use usage_derive::{Args, Cli, Subcommands, ValueEnum}; +pub use usage_derive::{ArgGroup, Args, Cli, Subcommands, ValueEnum}; #[cfg(feature = "test")] pub use usage_test as test; #[cfg(feature = "validation")] From 1af0db0872accdcff1cdd3621886fb27b36ce14b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 00:40:28 +0000 Subject: [PATCH 02/10] fix(derive): wire ArgGroup into relationship lookups Sibling requires/conflicts/overrides that name a group member now resolve through argument_state, argument_matches, displace, and event_matches. Also peel Option paths intact, reject unroundtrippable shorts, carry cfg onto key constants, and clarify the derive example. Co-authored-by: jdx --- argv/src/spec.rs | 31 ++++ conformance/tests/arg_group.rs | 46 ++++++ derive/src/codegen.rs | 289 +++++++++++++++++++++++++++++++-- derive/src/lib.rs | 4 +- derive/src/model.rs | 185 ++++++++++++++++++--- 5 files changed, 519 insertions(+), 36 deletions(-) diff --git a/argv/src/spec.rs b/argv/src/spec.rs index 302ea1d4c..a2bdf7404 100644 --- a/argv/src/spec.rs +++ b/argv/src/spec.rs @@ -319,6 +319,13 @@ pub struct Spec<'a> { /// An exact usage synopsis, including the `Usage:` prefix, when the generated /// shape needs alternatives that cannot be inferred from one command grammar. pub usage: Option<&'a str>, + /// How every page in this CLI is laid out, as named sections. + /// + /// A template names the six pre-rendered sections — `{{about}}`, `{{usage}}`, + /// `{{commands}}`, `{{args}}`, `{{flags}}`, `{{after_help}}` — and may reorder, + /// omit or wrap them. See [`crate::help::SECTIONS`] for what each one covers and + /// [`crate::help::unsupported_section`] for the rule an author's template is held to. + pub help_template: Option<&'a str>, /// Which command the root falls back to when a word matches no subcommand. /// mise uses this so `mise foo` completes as `mise run foo`. pub default_subcommand: Option<&'a str>, @@ -569,6 +576,7 @@ impl<'a> SpecView<'a> { about: self.base.about, long_about: self.base.long_about, usage: self.base.usage, + help_template: self.base.help_template, default_subcommand: self.base.default_subcommand, multicall: self.base.multicall, views: self.base.views, @@ -600,6 +608,7 @@ impl Spec<'_> { about: None, long_about: None, usage: None, + help_template: None, default_subcommand: None, multicall: false, views: &[], @@ -1344,6 +1353,9 @@ impl Spec<'_> { if let Some(usage) = self.usage { prop(out, "usage", usage)?; } + if let Some(template) = self.help_template { + prop(out, "help_template", template)?; + } // Written only when it is not the default, so an ordinary spec stays quiet // about it. if self.root.cmd.unknown_flags == Some(UnknownFlags::Error) { @@ -2941,6 +2953,25 @@ pub trait ArgGroup: Sized { /// The variant that was selected, or `None` when no member was given. fn build(partial: &Self::Partial) -> Option; + + /// Find a member by any selector it accepts. + /// + /// Parents use this for `requires` / `conflicts` / conditional defaults that name a + /// group member from beside the field — the same bridge [`CommandArgs::argument_state`] + /// is for flattened argument groups. + fn argument_state(partial: &Self::Partial, selector: &str) -> Option; + + /// Whether a selected member is present as the given boolean value. + /// + /// Members are switches, so only `"true"` / `"false"` are meaningful; anything else + /// reports not matching rather than inventing a value. + fn argument_matches(partial: &Self::Partial, selector: &str, value: &[u8]) -> Option; + + /// Clear the member named by `selector` after an overriding token wins. + fn displace(partial: &mut Self::Partial, selector: &str) -> bool; + + /// Whether this event binds the member named by `selector`. + fn event_matches(event: &crate::Event<'_, '_, '_>, selector: &str) -> bool; } /// One value a flag was given, in a vocabulary this crate can hold. diff --git a/conformance/tests/arg_group.rs b/conformance/tests/arg_group.rs index 32816bfc0..0f153b9f7 100644 --- a/conformance/tests/arg_group.rs +++ b/conformance/tests/arg_group.rs @@ -237,3 +237,49 @@ fn a_group_works_on_a_subcommand_beside_a_flattened_one() { let kdl = Nested::to_kdl(); assert!(kdl.contains("group format --json --yaml --plain"), "{kdl}"); } + +/// A sibling flag that names a group member — the relationship lookup Bugbot caught as missing. +#[derive(Cli)] +#[usage(bin = "rel")] +struct Rel { + #[usage(arg_group)] + format: Option, + /// Only legal beside JSON + #[usage(long, requires = "--json")] + pretty: bool, + /// Last one wins against JSON + #[usage(long, overrides = "--json")] + raw: bool, + /// Cannot sit beside YAML + #[usage(long, conflicts = "--yaml")] + strict: bool, +} + +#[test] +fn a_sibling_relationship_can_name_a_group_member() { + // requires: --pretty alone is MissingRequired for --json. + let a = argv(["--pretty"]); + assert!(matches!( + Rel::parse_from(&a), + Err(Error::MissingRequired { name: "json", .. }) + )); + let a = argv(["--json", "--pretty"]); + let rel = Rel::parse_from(&a).expect("json satisfies pretty"); + assert_eq!(rel.format, Some(Format::Json)); + assert!(rel.pretty); + + // conflicts: --strict with --yaml. + let a = argv(["--yaml", "--strict"]); + assert!(matches!( + Rel::parse_from(&a), + Err(Error::ConflictingFlags { .. }) + )); + let a = argv(["--json", "--strict"]); + assert!(Rel::parse_from(&a).expect("json does not conflict").strict); + + // overrides: --raw displaces a prior --json. + let a = argv(["--json", "--raw"]); + let rel = Rel::parse_from(&a).expect("raw displaces json"); + assert_eq!(rel.format, None); + assert!(rel.raw); +} diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index e8f01db7a..f2213c761 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -17,8 +17,8 @@ use quote::{format_ident, quote}; use crate::crate_name::{crate_name, FoundCrate}; use crate::model::{ - rendered_path, to_kebab, type_name, ArgGroup, Cli, ConditionalDefault, Dispatch, DoubleDash, - ExampleDecl, Field, Kind, Shape, Subcommands, ValueEnum, ViewDecl, + rendered_path, to_kebab, type_name, ArgGroup, ArgGroupMember, Cli, ConditionalDefault, + Dispatch, DoubleDash, ExampleDecl, Field, Kind, Shape, Subcommands, ValueEnum, ViewDecl, }; /// Construct the user's command type after its generated partial has been checked. @@ -165,6 +165,7 @@ pub fn emit(cli: &Cli) -> TokenStream { let term_width = option_usize(cli.term_width); let max_term_width = option_usize(cli.max_term_width); let usage = option_str(cli.usage.as_deref()); + let help_template = option_str(cli.help_template.as_deref()); let restart_token = option_str(cli.restart_token.as_deref()); let mount = option_str(cli.mount.as_deref()); // A bare `T` subcommand field says the command cannot run alone; an `Option` says it @@ -1077,6 +1078,7 @@ pub fn emit(cli: &Cli) -> TokenStream { about: #about, long_about: #long_about, usage: #usage, + help_template: #help_template, default_subcommand: #default_subcommand, multicall: #multicall, views: &[#(#views),*], @@ -3203,6 +3205,18 @@ fn displacements(cli: &Cli, field: &Field) -> Vec { ); }); } + for grouped in &cli.fields { + let Kind::ArgGroup { ty, .. } = &grouped.kind else { + continue; + }; + let ident = &grouped.ident; + displacements.push(quote! { + let _ = <#ty as usage_argv::spec::ArgGroup>::displace( + &mut partial.#ident, + #selector, + ); + }); + } } displacements } @@ -3589,6 +3603,22 @@ fn argument_lookup_functions(cli: &Cli) -> TokenStream { } }) }); + let state_grouped = cli.fields.iter().filter_map(|field| { + let Kind::ArgGroup { ty, .. } = &field.kind else { + return None; + }; + let ident = &field.ident; + Some(quote! { + if let ::std::option::Option::Some(state) = + <#ty as usage_argv::spec::ArgGroup>::argument_state( + &partial.#ident, + selector, + ) + { + return ::std::option::Option::Some(state); + } + }) + }); let match_arms = cli.fields.iter().filter_map(|field| { if matches!( field.kind, @@ -3630,6 +3660,23 @@ fn argument_lookup_functions(cli: &Cli) -> TokenStream { } }) }); + let match_grouped = cli.fields.iter().filter_map(|field| { + let Kind::ArgGroup { ty, .. } = &field.kind else { + return None; + }; + let ident = &field.ident; + Some(quote! { + if let ::std::option::Option::Some(matches) = + <#ty as usage_argv::spec::ArgGroup>::argument_matches( + &partial.#ident, + selector, + value, + ) + { + return ::std::option::Option::Some(matches); + } + }) + }); let displace_arms = cli.fields.iter().filter_map(|field| { if !matches!(field.kind, Kind::Flag { .. }) || !is_displaceable(cli, field) { return None; @@ -3657,6 +3704,20 @@ fn argument_lookup_functions(cli: &Cli) -> TokenStream { } }) }); + let displace_grouped = cli.fields.iter().filter_map(|field| { + let Kind::ArgGroup { ty, .. } = &field.kind else { + return None; + }; + let ident = &field.ident; + Some(quote! { + if <#ty as usage_argv::spec::ArgGroup>::displace( + &mut partial.#ident, + selector, + ) { + return true; + } + }) + }); let flags: Vec<&Field> = cli .fields .iter() @@ -3682,6 +3743,16 @@ fn argument_lookup_functions(cli: &Cli) -> TokenStream { } }) }); + let event_grouped = cli.fields.iter().filter_map(|field| { + let Kind::ArgGroup { ty, .. } = &field.kind else { + return None; + }; + Some(quote! { + if <#ty as usage_argv::spec::ArgGroup>::event_matches(event, selector) { + return true; + } + }) + }); quote! { #[allow(dead_code)] pub fn argument_state( @@ -3693,6 +3764,7 @@ fn argument_lookup_functions(cli: &Cli) -> TokenStream { _ => {} } #(#state_flattened)* + #(#state_grouped)* ::std::option::Option::None } @@ -3707,6 +3779,7 @@ fn argument_lookup_functions(cli: &Cli) -> TokenStream { _ => {} } #(#match_flattened)* + #(#match_grouped)* ::std::option::Option::None } @@ -3717,6 +3790,7 @@ fn argument_lookup_functions(cli: &Cli) -> TokenStream { _ => {} } #(#displace_flattened)* + #(#displace_grouped)* false } @@ -3735,6 +3809,7 @@ fn argument_lookup_functions(cli: &Cli) -> TokenStream { } } #(#event_flattened)* + #(#event_grouped)* false } } @@ -4724,8 +4799,26 @@ fn apply_fn(cli: &Cli) -> TokenStream { return None; }; let ident = &f.ident; + let reverse_displacements = cli.fields.iter().flat_map(|field| { + field + .overrides + .iter() + .filter(|selector| cli.field_for_selector(selector).is_none()) + .map(move |selector| { + let statement = displace_statement(cli, field); + quote! { + if <#ty as usage_argv::spec::ArgGroup>::event_matches( + event, + #selector, + ) { + #statement + } + } + }) + }); Some(quote! { if <#ty as usage_argv::spec::ArgGroup>::apply(&mut partial.#ident, event) { + #(#reverse_displacements)* return true; } }) @@ -5451,27 +5544,75 @@ pub fn emit_args(cli: &Cli) -> TokenStream { partial } + /// Every declared default this command has, filling what nothing else did. + /// + /// `__usage_standing` is what an update already had, and `None` for an ordinary + /// parse: a default does not overwrite a value the caller set deliberately. + fn apply_declared_defaults( + partial: &mut Partial, + __usage_view: ::std::option::Option< + &'static usage_argv::spec::ViewMeta<'static>, + >, + __usage_standing: ::std::option::Option<&#ident>, + ) { + let _ = __usage_standing.is_some(); + #apply_defaults + } + + /// [`apply_declared_defaults`], for the environment rather than for declared + /// defaults. + fn apply_env_fallbacks( + partial: &mut Partial, + __usage_view: ::std::option::Option< + &'static usage_argv::spec::ViewMeta<'static>, + >, + __usage_standing: ::std::option::Option<&#ident>, + ) { + let _ = __usage_standing.is_some(); + #apply_env + } + /// Everything decided after the last token, for this command. /// /// Separate from `build` because only the *selected* command's /// requirements apply: a flag that `install` requires says nothing about /// an invocation that ran `run`. - fn check_with_args_override_self_for_view<'t, 'v>( + /// + /// `__usage_standing` is what an update already had: `None` for an ordinary + /// parse, which folds every question about it away. One body rather than an + /// update-only copy, because this is the largest function a command generates. + fn check_with_args_override_self_for_view_standing<'t, 'v>( partial: &mut Partial, args_override_self: bool, __usage_view: ::std::option::Option< &'static usage_argv::spec::ViewMeta<'static>, >, + __usage_standing: ::std::option::Option<&#ident>, ) -> ::std::result::Result<(), usage_argv::Error<'t, 'v>> { partial.__usage_view = __usage_view; // Read unconditionally: a command that declares nothing to check would // otherwise leave the parameter unused in the user's crate, where // nobody can silence it. - let _ = &partial; + let _ = (&partial, __usage_standing.is_some()); #post ::std::result::Result::Ok(()) } + fn check_with_args_override_self_for_view<'t, 'v>( + partial: &mut Partial, + args_override_self: bool, + __usage_view: ::std::option::Option< + &'static usage_argv::spec::ViewMeta<'static>, + >, + ) -> ::std::result::Result<(), usage_argv::Error<'t, 'v>> { + check_with_args_override_self_for_view_standing( + partial, + args_override_self, + __usage_view, + ::std::option::Option::None, + ) + } + pub fn check_with_args_override_self<'t, 'v>( partial: &mut Partial, args_override_self: bool, @@ -5549,10 +5690,11 @@ pub fn emit_args(cli: &Cli) -> TokenStream { #presence fn apply_defaults(partial: &mut Self::Partial) { - let __usage_view: ::std::option::Option< - &'static usage_argv::spec::ViewMeta<'static>, - > = ::std::option::Option::None; - #apply_defaults + apply_declared_defaults( + partial, + ::std::option::Option::None, + ::std::option::Option::None, + ) } fn apply_defaults_for_view( @@ -5561,14 +5703,19 @@ pub fn emit_args(cli: &Cli) -> TokenStream { &'static usage_argv::spec::ViewMeta<'static>, >, ) { - #apply_defaults + apply_declared_defaults( + partial, + __usage_view, + ::std::option::Option::None, + ) } fn apply_env(partial: &mut Self::Partial) { - let __usage_view: ::std::option::Option< - &'static usage_argv::spec::ViewMeta<'static>, - > = ::std::option::Option::None; - #apply_env + apply_env_fallbacks( + partial, + ::std::option::Option::None, + ::std::option::Option::None, + ) } fn apply_env_for_view( @@ -5577,7 +5724,11 @@ pub fn emit_args(cli: &Cli) -> TokenStream { &'static usage_argv::spec::ViewMeta<'static>, >, ) { - #apply_env + apply_env_fallbacks( + partial, + __usage_view, + ::std::option::Option::None, + ) } #view_path_methods @@ -8431,10 +8582,11 @@ pub fn emit_arg_group(group: &ArgGroup) -> TokenStream { // groups in different modules cannot hand a parse the same key, and the arm that claims an // event verifies it came from this table. let declaration = declaration_hash(&group.fingerprint); - let key_decls = (0..group.variants.len()).map(|i| { + let key_decls = group.variants.iter().enumerate().map(|(i, member)| { let key = key_ident("FLAG", Some(i)); let index = i as u64; - quote!(const #key: u64 = __USAGE_KEY_BASE | #KIND_FLAG | #index;) + let cfg = &member.cfg_attrs; + quote!(#(#cfg)* const #key: u64 = __USAGE_KEY_BASE | #KIND_FLAG | #index;) }); let flags = group.variants.iter().enumerate().map(|(i, member)| { @@ -8539,6 +8691,70 @@ pub fn emit_arg_group(group: &ArgGroup) -> TokenStream { } } }); + // Long and short spellings a member answers to, for relationship lookups from a parent. + let member_selectors = |member: &ArgGroupMember| -> Vec { + let mut selectors = vec![format!("--{}", member.name)]; + if let Some(short) = member.short { + selectors.push(format!("-{short}")); + } + selectors + }; + let state_arms = group.variants.iter().enumerate().map(|(i, member)| { + let given = format_ident!("given_{i}"); + let cfg = &member.cfg_attrs; + let name = &member.name; + let selectors = member_selectors(member); + quote! { + #(#cfg)* + #(#selectors)|* => { + return ::std::option::Option::Some(usage_argv::spec::ArgumentState { + name: #name, + given: partial.#given, + satisfied: partial.#given, + }); + } + } + }); + let match_arms = group.variants.iter().enumerate().map(|(i, member)| { + let given = format_ident!("given_{i}"); + let cfg = &member.cfg_attrs; + let selectors = member_selectors(member); + // Members are SetTrue switches: presence is the value. Wrap like an ordinary + // bool flag's `#given && …` so a missing member does not "match" `"false"`. + quote! { + #(#cfg)* + #(#selectors)|* => { + return ::std::option::Option::Some(partial.#given && value == b"true"); + } + } + }); + let displace_arms = group.variants.iter().enumerate().map(|(i, member)| { + let given = format_ident!("given_{i}"); + let cfg = &member.cfg_attrs; + let selectors = member_selectors(member); + quote! { + #(#cfg)* + #(#selectors)|* => { + if partial.#given { + partial.#given = false; + return true; + } + return false; + } + } + }); + let event_arms = group.variants.iter().enumerate().map(|(i, member)| { + let table = format_ident!("FLAG_{i}"); + let key = key_ident("FLAG", Some(i)); + let cfg = &member.cfg_attrs; + let selectors = member_selectors(member); + quote! { + #(#cfg)* + #key if ::core::ptr::eq(*flag, &#table) => { + matches!(selector, #(#selectors)|*) + } + } + }); quote! { #[doc(hidden)] @@ -8606,6 +8822,47 @@ pub fn emit_arg_group(group: &ArgGroup) -> TokenStream { #(#build_arms)* ::std::option::Option::None } + + fn argument_state( + partial: &Self::Partial, + selector: &str, + ) -> ::std::option::Option { + match selector { + #(#state_arms)* + _ => ::std::option::Option::None, + } + } + + fn argument_matches( + partial: &Self::Partial, + selector: &str, + value: &[u8], + ) -> ::std::option::Option { + match selector { + #(#match_arms)* + _ => ::std::option::Option::None, + } + } + + fn displace(partial: &mut Self::Partial, selector: &str) -> bool { + match selector { + #(#displace_arms)* + _ => false, + } + } + + fn event_matches( + event: &usage_argv::Event<'_, '_, '_>, + selector: &str, + ) -> bool { + match event { + usage_argv::Event::Flag { flag, .. } => match flag.key { + #(#event_arms)* + _ => false, + }, + _ => false, + } + } } }; } diff --git a/derive/src/lib.rs b/derive/src/lib.rs index 10dd4e652..107d82576 100644 --- a/derive/src/lib.rs +++ b/derive/src/lib.rs @@ -551,7 +551,6 @@ pub fn derive_value_enum(input: TokenStream) -> TokenStream { /// `bool`s is set. Each variant is one switch, named by its own name in kebab-case: /// /// ```ignore -/// /// How to print the result /// #[derive(usage::ArgGroup)] /// #[usage(name = "format")] /// enum Format { @@ -564,6 +563,9 @@ pub fn derive_value_enum(input: TokenStream) -> TokenStream { /// } /// ``` /// +/// Only a variant's doc comment becomes that switch's help; the enum's own docs are not +/// read, because a group has no help of its own — the members do. +/// /// A field holds one and says `arg_group`. `Option` is a group that may be left alone /// and a bare `Format` is one that has to be given — the same rule every other field's type is /// read by, and the only spelling of required-ness a group has, since there is no default diff --git a/derive/src/model.rs b/derive/src/model.rs index 7d6edd34f..7447f9ccc 100644 --- a/derive/src/model.rs +++ b/derive/src/model.rs @@ -102,6 +102,12 @@ pub struct Cli { pub min_usage_version: Option, /// An exact usage synopsis for shapes with explicit alternatives. pub usage: Option, + /// How every page in this CLI is laid out, as named sections. + /// + /// A literal rather than an expression, unlike the package metadata beside it, because the + /// section names in it are checked here: a template naming a section that does not exist + /// would otherwise reach a reader as the placeholder they wrote. + pub help_template: Option, /// What running this command does to the world, when it says. /// /// Held as the tokens for an `Option`, since the only thing it becomes is a field of @@ -707,6 +713,7 @@ impl Cli { config: None, min_usage_version: None, usage: None, + help_template: None, effect: None, aliases: Vec::new(), hidden_aliases: Vec::new(), @@ -942,6 +949,16 @@ impl Cli { "source_code_link_template" => { cli.source_code_link_template = Some(metadata_expr(&meta)?) } + // Checked here rather than at render time: a page laid out by a template + // is read by users, and a placeholder naming no section would reach them + // as the braces the author typed. + "help_template" => { + let template = string_value(&meta)?; + if let Err(problem) = check_help_template(&template) { + return Err(syn::Error::new_spanned(&meta, problem)); + } + cli.help_template = Some(template); + } "before_help" => cli.before_help = Some(metadata_expr(&meta)?), "next_help_heading" => cli.next_help_heading = Some(string_value(&meta)?), "before_long_help" => cli.before_long_help = Some(metadata_expr(&meta)?), @@ -1035,7 +1052,7 @@ impl Cli { "unknown option `{other}` on a struct; usage::Cli takes \ `name`, `name_spec`, `bin`, `bin_spec`, `version`, `version_spec`, `long_version`, `long_version_spec`, `author`, `license`, `repository`, `source_code_link_template`, `usage`, `alias`, `alias_hidden`, `visible_alias`, `hide`, `deprecated`, `deprecated_warn_at`, `deprecated_remove_at`, `verbatim_doc_comment`, `unknown_flags`, \ `default_subcommand`, `multicall`, `no_binary_name`, `arg_required_else_help`, `disable_help_flag`, `disable_help_subcommand`, `disable_version_flag`, `dont_delimit_trailing_values`, `args_override_self`, `subcommand_negates_reqs`, `args_conflicts_with_subcommands`, `subcommand_precedence_over_arg`, `allow_missing_positional`, \ - `next_help_heading`, `subcommand_help_heading`, `next_line_help`, `flatten_help`, `term_width`, `max_term_width`, \ + `next_help_heading`, `subcommand_help_heading`, `next_line_help`, `flatten_help`, `help_template`, `term_width`, `max_term_width`, \ `subcommand_value_name`, `restart_token`, `mount`, `example`, `run`, `run_with`, `run_async`, `run_async_with` and \ `group` and `view` here, and the description comes from the doc \ comment" @@ -1243,6 +1260,10 @@ impl Cli { "source_code_link_template", self.source_code_link_template.is_some(), ), + // One template, for the whole tree: that is what makes a section vocabulary + // enough. A command declaring its own would be laying out a page nobody + // assembles from it. + ("help_template", self.help_template.is_some()), ] .into_iter() .find_map(|(name, present)| present.then_some(name)) @@ -1696,10 +1717,9 @@ impl Cli { // is the advantage of declaring them in code: a spec written by hand can only // find a typo'd selector at parse time, or never, since a selector naming // nothing quietly holds no relationship at all. - let has_flatten = self - .fields - .iter() - .any(|field| matches!(field.kind, Kind::Flatten { .. })); + let has_opaque = self.fields.iter().any(|field| { + matches!(field.kind, Kind::Flatten { .. } | Kind::ArgGroup { .. }) + }); for field in &self.fields { for (option, selectors) in [ ("overrides", &field.overrides), @@ -1711,10 +1731,11 @@ impl Cli { ] { for selector in selectors { let Some(target) = self.field_for_selector(selector) else { - // Relationship lookup composes through an opaque flattened partial. - // Post-binding rules ask it about presence and values; binding-time - // overrides ask it to displace the selected field as tokens arrive. - if has_flatten { + // Relationship lookup composes through an opaque flattened partial + // or argument-group enum. Post-binding rules ask it about presence + // and values; binding-time overrides ask it to displace the selected + // field as tokens arrive. + if has_opaque { continue; } return Err(syn::Error::new( @@ -1741,7 +1762,7 @@ impl Cli { for condition in conditions { let selector = &condition.selector; let Some(target) = self.field_for_selector(selector) else { - if has_flatten { + if has_opaque { continue; } return Err(syn::Error::new( @@ -1760,7 +1781,7 @@ impl Cli { for condition in &field.requires_if { let selector = &condition.requires; let Some(target) = self.field_for_selector(selector) else { - if has_flatten { + if has_opaque { continue; } return Err(syn::Error::new( @@ -1781,7 +1802,7 @@ impl Cli { for condition in &field.default_if { let selector = &condition.selector; let Some(target) = self.field_for_selector(selector) else { - if has_flatten { + if has_opaque { continue; } return Err(syn::Error::new( @@ -2113,13 +2134,12 @@ impl Field { // `Option` is a group that may be left alone and a bare `T` is one that has to be // given, which is the same rule every other field's type is read by — and the only - // spelling of required-ness a group has. - let name = type_name(&field.ty); - let (ty, optional) = match name - .strip_prefix("Option<") - .and_then(|rest| rest.strip_suffix('>')) - { - Some(inner) => (syn::parse_str::(inner)?, true), + // spelling of required-ness a group has. Peel the wrapper syntactically so a path + // like `Option` keeps the inner type intact; `type_name` would + // collapse it to `Format` and put a name that is not in scope into the generated + // tables. + let (ty, optional) = match peel(&field.ty, "Option") { + Some(inner) => (inner, true), None => (field.ty.clone(), false), }; @@ -3936,6 +3956,38 @@ pub(crate) fn string_value(meta: &Meta) -> syn::Result { } } +/// The sections a `help_template` may name, and nothing else. +/// +/// The same closed vocabulary `usage_argv::help::SECTIONS` renders and the KDL parser accepts, +/// repeated rather than imported: this is a proc-macro crate, and the list is six words that a +/// conformance test compares against both other copies. +const HELP_SECTIONS: [&str; 6] = ["about", "usage", "commands", "args", "flags", "after_help"]; + +/// Whether every `{{…}}` in a template names a section. +fn check_help_template(template: &str) -> Result<(), String> { + let mut rest = template; + while let Some(at) = rest.find("{{") { + let after = &rest[at + 2..]; + let Some(end) = after.find("}}") else { + return Err(format!( + "`help_template` has a `{{{{` with no `}}}}` after it; the sections are {}", + HELP_SECTIONS.join(", ") + )); + }; + let name = after[..end].trim(); + if !HELP_SECTIONS.contains(&name) { + return Err(format!( + "`help_template` names no section `{name}`; a page is assembled from {} — \ + reorder, omit or wrap those, and note that clap's `{{options}}` is `{{{{flags}}}}` \ + here and its `{{positionals}}` is `{{{{args}}}}`", + HELP_SECTIONS.join(", ") + )); + } + rest = &after[end + 2..]; + } + Ok(()) +} + /// A Rust expression whose result must be usable as `&'static str` in the /// generated metadata table. Type and const checking belong to rustc; retaining /// the tokens here is what lets constants and `env!` remain the source of truth. @@ -5809,6 +5861,33 @@ impl ArgGroup { ), )); } + // Same round-trip rules as an ordinary flag: the spec writes forms as a + // space-delimited string, so whitespace, controls, `-`, and `=` have nowhere + // to go and could never be typed as a short either. + if let Some(short) = member + .short + .filter(|c| c.is_whitespace() || c.is_control()) + { + return Err(syn::Error::new_spanned( + &variant.ident, + format!( + "`short = {short:?}` cannot be written: a spec spells a flag's \ + forms as a space-delimited string, so whitespace and control \ + characters have nowhere to go" + ), + )); + } + if let Some(short) = member.short.filter(|c| matches!(c, '-' | '=')) { + let why = if short == '-' { + "`--` is the separator that ends flag parsing" + } else { + "`=` separates a short flag from its value, as in `-j=8`" + }; + return Err(syn::Error::new_spanned( + &variant.ident, + format!("`short = '{short}'` can never be given: {why}"), + )); + } variants.push(member); } // One member is not a relationship: the spec reserves a group for something said @@ -5891,6 +5970,32 @@ mod tests { assert!(err.contains("#[usage(flatten)]"), "unhelpful: {err}"); } + #[test] + fn a_help_template_may_name_only_the_sections_a_page_has() { + // Refused here rather than at render time: the page is read by users, and a + // placeholder naming no section would reach them as the braces somebody typed. + let err = rejection(r#"#[usage(help_template = "{{about}}{{options}}")] struct Root {}"#); + assert!(err.contains("`options`"), "unhelpful: {err}"); + // And says what to write instead, since a ported clap template is where this lands. + assert!(err.contains("`{{flags}}`"), "unhelpful: {err}"); + assert!( + rejection(r#"#[usage(help_template = "{{about")] struct Root {}"#).contains("`}}`"), + ); + + let parsed = cli(r#"#[usage(help_template = "{{ about }}\n{{usage}}")] struct Root {}"#) + .expect("the vocabulary, with or without spaces"); + assert_eq!( + parsed.help_template.as_deref(), + Some("{{ about }}\n{{usage}}") + ); + + // One template, for the whole tree: an `Args` declaring one would be laying out a + // page nobody assembles from it. + let err = position_error(r#"#[usage(help_template = "{{usage}}")] struct Inner {}"#, false); + assert!(err.contains("belongs on the root"), "unhelpful: {err}"); + assert!(err.contains("help_template"), "unhelpful: {err}"); + } + #[test] fn computed_program_identity_keeps_a_portable_literal() { let err = rejection("#[usage(name = runtime_name())] struct Root {}"); @@ -7366,6 +7471,21 @@ mod tests { assert!(!optional, "a bare `Source` is a group that has to be given"); assert_eq!(super::type_name(ty), "Source"); + // A path inside Option is kept intact: `type_name` would collapse + // `crate::fmt::Format` to `Format`, which is not in scope at the use site. + let parsed = cli(r#" + struct Ex { + #[usage(arg_group)] + format: Option, + } + "#) + .expect("should compile"); + let Kind::ArgGroup { ty, optional } = &parsed.fields[0].kind else { + panic!("expected an argument group"); + }; + assert!(optional); + assert_eq!(quote::ToTokens::to_token_stream(ty).to_string(), "crate :: fmt :: Format"); + // And nothing wrapped around it, which the field says rather than the trait bound the // generated code would otherwise fail. for ty in ["Vec", "Option>", "Box"] { @@ -7381,6 +7501,33 @@ mod tests { } } + #[test] + fn an_arg_group_member_rejects_shorts_that_cannot_round_trip() { + for (short, needle) in [ + ("'-'", "can never be given"), + ("'='", "can never be given"), + ("'\\t'", "cannot be written"), + ("' '", "cannot be written"), + ] { + let err = match arg_group(&format!( + r#" + enum Format {{ + #[usage(short = {short})] + Json, + Yaml, + }} + "# + )) { + Ok(_) => panic!("short = {short} should have been refused"), + Err(e) => e.to_string(), + }; + assert!( + err.contains(needle), + "short = {short}: expected `{needle}`, got `{err}`" + ); + } + } + /// The position rules, which each derive applies for the place it stands in. fn position_error(body: &str, is_root: bool) -> String { let parsed = cli(body).expect("parses"); From 6c90d91f39dd3bbd85d8e028d18fcb58c5895582 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 00:57:31 +0000 Subject: [PATCH 03/10] feat(help): implement closed-section help_template Wire a root-level help_template through derive, argv, KDL, usage-lib, and Go so authors can reorder, omit, or wrap the six named sections without exposing renderer internals as a template language. Co-authored-by: jdx --- PLAN.md | 31 ++- argv/src/help.rs | 244 +++++++++++++++-- conformance/src/tables.rs | 1 + conformance/tests/help_template.rs | 259 ++++++++++++++++++ ...roundtrip__the_emitted_spec_is_stable.snap | 1 + conformance/tests/spec_roundtrip.rs | 22 ++ corpus/render/04-help-template.json | 105 +++++++ derive/src/model.rs | 29 +- docs/rust/clap-compatibility.md | 42 +-- docs/rust/help.md | 67 +++++ go/argv/page.go | 31 ++- go/argv/page_long.go | 20 +- go/argv/sections.go | 159 +++++++++++ go/argv/sections_test.go | 194 +++++++++++++ go/internal/spec/spec.go | 4 + lib/src/docs/cli/mod.rs | 204 +++++++++++++- .../cli/templates/spec_template_long.tera | 12 +- .../cli/templates/spec_template_short.tera | 12 +- lib/src/go/mod.rs | 4 + lib/src/help_template.rs | 177 ++++++++++++ lib/src/lib.rs | 1 + lib/src/spec/mod.rs | 28 ++ 22 files changed, 1538 insertions(+), 109 deletions(-) create mode 100644 conformance/tests/help_template.rs create mode 100644 corpus/render/04-help-template.json create mode 100644 go/argv/sections.go create mode 100644 go/argv/sections_test.go create mode 100644 lib/src/help_template.rs diff --git a/PLAN.md b/PLAN.md index 521efbd2c..1257d9436 100644 --- a/PLAN.md +++ b/PLAN.md @@ -684,17 +684,23 @@ Groups are the opposite case: `Command::get_groups`, `ArgGroup::get_args` and - [x] **`flatten_help`.** Visible subcommands can be expanded into their parent's usage synopsis and help sections across typed Rust, KDL, the clap bridge, and Rust and generated Go help. -- [ ] **`help_template`.** A root-level Tera template applying to the whole - command tree. **Decided (2026-08-21): a closed vocabulary of pre-rendered - named sections** — `usage`, `about`, `flags`, `args`, `commands`, - `after_help` — which an author may reorder, omit or wrap. That covers - clap's actual use case of rearranging help sections, which a bare - `{{ help }}` wrapper would not, while leaving interpreted Rust, compiled - Rust and generated Go to agree only on where each section starts and ends - rather than on layout. The alternative — exposing the metadata tree and - letting the template lay everything out — was rejected because it makes - the help renderer's internals public API and requires every - implementation to match Tera's semantics, not just its section names. +- [x] **`help_template`.** A root-level template applying to the whole command + tree, as a closed vocabulary of pre-rendered named sections — `usage`, + `about`, `flags`, `args`, `commands`, `after_help` — which an author may + reorder, omit or wrap. That covers clap's actual use case of rearranging + help sections, which a bare `{{ help }}` wrapper would not, while leaving + interpreted Rust, compiled Rust and generated Go to agree only on where + each section starts and ends rather than on layout. The alternative — + exposing the metadata tree and letting the template lay everything out — + was rejected because it makes the help renderer's internals public API and + requires every implementation to match Tera's semantics, not just its + section names. Substitution is a minimal `{{name}}` replacer in all three, + and a section that comes out empty leaves no gap behind, so one template + serves a whole CLI rather than one per command shape. A placeholder naming + no section is refused where a spec is authored: at compile time by the + derive, at parse by KDL. With the template unset every page is assembled in + the default order and is byte-identical to before, which the fleet gate and + Go's 211-page parity suite both hold. - [x] **`subcommand_help_heading` / `subcommand_value_name`.** Custom subcommand section labels and synopsis placeholders survive KDL, typed Rust, generated Go, and the clap bridge and are rendered by both help implementations. @@ -1669,7 +1675,8 @@ flattened group is clap#5092 (18) — the derive refuses it for lack of a rule, and the votes say people want the rule defined; visible aliases on enum values is clap#4416, stalled in clap on binary-size grounds a spec interpreter does not have; and a help template set once for the whole tree is clap#1184, which -is the `help_template` row — a Tera template at spec root is the natural shape. +the `help_template` row above now answers — one template at spec root, laying +out every page in the tree. Noted, not taken — one item: conditional argument groups unlocked by a flag's value (clap#6258), the missing quadrant beside `requires_if`, `required_if` diff --git a/argv/src/help.rs b/argv/src/help.rs index 03c225079..88cf7d685 100644 --- a/argv/src/help.rs +++ b/argv/src/help.rs @@ -29,6 +29,192 @@ use crate::DoubleDash; /// `[FLAGS]` or `[ARGS]…` and the sections below carry the detail. const INLINE_LIMIT: usize = 2; +/// The sections a [`help_template`](crate::spec::Spec::help_template) may name. +/// +/// A closed vocabulary on purpose. The alternative — handing a template the metadata tree and +/// letting it lay a page out — makes this renderer's internals public API and asks every +/// implementation of the spec to agree on a template language's semantics rather than on where +/// a section starts and ends. +/// +/// What each one holds: +/// +/// | section | content | +/// | -------------- | -------------------------------------------------------------------- | +/// | `about` | `before_help`, the version banner, and the description | +/// | `usage` | the `Usage:` synopsis, however many lines it takes | +/// | `commands` | the subcommand list, or the flattened bodies under `flatten_help` | +/// | `args` | every argument group, each under its heading | +/// | `flags` | this command's flag groups, then the globals it inherits | +/// | `after_help` | examples, `after_help`, and the author/license footer on a long page | +pub const SECTIONS: [&str; 6] = ["about", "usage", "commands", "args", "flags", "after_help"]; + +/// The first placeholder in a template that names no section, if there is one. +/// +/// The check a spec is held to wherever one is written down: KDL refuses a template at parse +/// and the derive refuses one at compile time, so a page is never rendered from a template +/// whose sections cannot all be filled. `Err` reports an opening `{{` with no `}}` after it, +/// which is a typo rather than a section name. +/// +/// ``` +/// use usage_argv::help::unsupported_section; +/// +/// assert_eq!(unsupported_section("{{about}}{{usage}}"), Ok(None)); +/// assert_eq!(unsupported_section("{{ options }}"), Ok(Some("options"))); +/// assert!(unsupported_section("{{usage").is_err()); +/// ``` +pub fn unsupported_section(template: &str) -> Result, &'static str> { + let mut rest = template; + while let Some(at) = rest.find("{{") { + let after = &rest[at + 2..]; + let Some(end) = after.find("}}") else { + return Err("a `{{` with no `}}` after it"); + }; + let name = after[..end].trim(); + if !SECTIONS.contains(&name) { + return Ok(Some(name)); + } + rest = &after[end + 2..]; + } + Ok(None) +} + +/// The pieces of a page, before anything decides what order they go in. +/// +/// Built in one pass and assembled twice over: concatenated in the default order, which is the +/// page every CLI without a template gets and is what the fleet gate compares byte for byte, or +/// substituted into a template. `flattened` is not a section an author can name — it is the +/// other half of `commands`, and only one of the two is ever non-empty. +#[derive(Default)] +struct Sections { + about: String, + usage: String, + commands: String, + args: String, + flags: String, + flattened: String, + after_help: String, +} + +impl Sections { + /// The default page: every section in the order the renderer wrote them. + /// + /// A plain concatenation, so this is the same string the renderer produced before sections + /// were separable — the separating blank lines belong to the sections themselves. + fn concatenated(&self) -> String { + let mut out = String::new(); + for part in [ + &self.about, + &self.usage, + &self.commands, + &self.args, + &self.flags, + &self.flattened, + &self.after_help, + ] { + out.push_str(part); + } + out + } + + fn named(&self, name: &str) -> Option { + Some(match name { + "about" => self.about.trim().to_string(), + "usage" => self.usage.trim().to_string(), + // Whichever form this command's command list took. `flatten_help` replaces the + // list with the subcommands' own bodies, so a template that places `{{commands}}` + // places whichever one the command has. + "commands" => { + let mut out = self.commands.trim().to_string(); + let flattened = self.flattened.trim(); + if !flattened.is_empty() { + if !out.is_empty() { + out.push_str("\n\n"); + } + out.push_str(flattened); + } + out + } + "args" => self.args.trim().to_string(), + "flags" => self.flags.trim().to_string(), + "after_help" => self.after_help.trim().to_string(), + _ => return None, + }) + } + + /// A page laid out by an author's template. + /// + /// Each section arrives trimmed, so the template owns the whitespace between them: a + /// template is a layout, and a section carrying the blank line above it could not be moved + /// without carrying that decision along. A placeholder naming no section is left as it was + /// written — the vocabulary is checked where a spec is authored, so one reaching here is + /// text an author meant literally. + /// + /// A section that came out empty leaves no gap behind, which is what lets one template + /// serve a whole CLI: see `usage::help_template::collapse_blank_runs`, whose rule this is. + fn substituted(&self, template: &str) -> String { + let mut out = String::with_capacity(template.len()); + let mut rest = template; + while let Some(at) = rest.find("{{") { + out.push_str(&rest[..at]); + let after = &rest[at + 2..]; + let Some(end) = after.find("}}") else { + out.push_str(&rest[at..]); + return collapse_blank_runs(&out); + }; + match self.named(after[..end].trim()) { + Some(text) => out.push_str(&text), + None => out.push_str(&rest[at..at + 2 + end + 2]), + } + rest = &after[end + 2..]; + } + out.push_str(rest); + collapse_blank_runs(&out) + } +} + +/// A page's runs of blank lines, each reduced to a single blank line. +/// +/// The twin of `usage::help_template::collapse_blank_runs`, and the reason a template can name a +/// section a given command does not have. A whitespace-only line counts as blank, since that is +/// what an empty placeholder on an indented line leaves; a section's own indentation does not, +/// since that is the page. +fn collapse_blank_runs(page: &str) -> String { + let mut out = String::with_capacity(page.len()); + let mut blank = false; + for line in page.split('\n') { + if line.trim().is_empty() { + blank = !out.is_empty(); + continue; + } + if !out.is_empty() { + out.push('\n'); + if blank { + out.push('\n'); + } + } + blank = false; + out.push_str(line); + } + out +} + +/// The finished page: laid out by the spec's template where it has one, and trimmed. +/// +/// usage-lib trims the whole document and puts back one newline, which is what keeps the blank +/// lines between sections from becoming trailing ones. That applies to a template's output too: +/// a page ends in exactly one newline however it was assembled. +fn assemble(spec: &Spec<'_>, sections: &Sections) -> String { + let page = match spec.help_template { + Some(template) => sections.substituted(template), + None => sections.concatenated(), + }; + let trimmed = page.trim(); + let mut done = String::with_capacity(trimmed.len() + 1); + done.push_str(trimmed); + done.push('\n'); + done +} + /// Whether help output is coloured. /// /// Plain rendering remains available for generated documents and snapshots; @@ -688,7 +874,8 @@ fn short_help_with( .into_iter() .filter(|(flag, _)| !flag.hide_short_help) .collect(); - let mut out = String::new(); + let mut sections = Sections::default(); + let out = &mut sections.about; // Text the command puts above everything else, and below it. The short form has only the // one pair; the long form prefers the long variants. @@ -716,14 +903,14 @@ fn short_help_with( // description is written here, so one already in the text doubles it. let _ = writeln!(out, "{}\n", about.trim_end()); } - command_deprecation(&mut out, meta, 0); - usage_section(&mut out, spec, path, meta); + command_deprecation(out, meta, 0); + usage_section(&mut sections.usage, spec, path, meta); // The path without the binary, which is what a listed subcommand shows: usage-lib prints // `tool-alias get ` under `mise tool-alias`, the whole path from the root rather // than the child's own name. if !meta.flatten_help { - commands_section(&mut out, &path[1.min(path.len())..], meta); + commands_section(&mut sections.commands, &path[1.min(path.len())..], meta); } // The short page lines its columns up too. It did not: every description began directly @@ -742,7 +929,7 @@ fn short_help_with( .max() .unwrap_or(0); groups_section( - &mut out, + &mut sections.args, "Arguments", args.iter().copied(), |a| a.help_heading, @@ -845,7 +1032,7 @@ fn short_help_with( ); }; groups_section( - &mut out, + &mut sections.flags, "Flags", own.iter().copied(), |f| f.help_heading, @@ -855,27 +1042,21 @@ fn short_help_with( // belongs to the program, not to this command, and a reader should be able to see that. // The text is precomputed, since a spelling a descendant claimed is left out of it. groups_section( - &mut out, + &mut sections.flags, "Global flags", inherited.iter(), |_| None, |out, (f, usage)| short_entry(out, f, usage.clone()), ); if meta.flatten_help { - flat_commands_short(&mut out, &path[1.min(path.len())..], meta); + flat_commands_short(&mut sections.flattened, &path[1.min(path.len())..], meta); } - examples_section(&mut out, spec, meta); + examples_section(&mut sections.after_help, spec, meta); if let Some(after) = meta.after_help.or(spec.root.after_help) { - let _ = writeln!(out, "\n{after}"); + let _ = writeln!(sections.after_help, "\n{after}"); } - // usage-lib trims the whole document and puts back one newline, which is what keeps the - // blank lines between sections from becoming trailing ones. - let trimmed = out.trim(); - let mut done = String::with_capacity(trimmed.len() + 1); - done.push_str(trimmed); - done.push('\n'); - done + assemble(spec, §ions) } /// The list of subcommands, and the `help` command every CLI with subcommands has. @@ -1382,7 +1563,8 @@ fn long_help_with( .filter(|(flag, _)| !flag.hide_long_help) .collect(); let width = terminal_width(meta); - let mut out = String::new(); + let mut sections = Sections::default(); + let out = &mut sections.about; if let Some(before) = meta .before_long_help @@ -1418,11 +1600,11 @@ fn long_help_with( // description is written here, so one already in the text doubles it. let _ = writeln!(out, "{}\n", about.trim_end()); } - command_deprecation(&mut out, meta, 0); - usage_section(&mut out, spec, path, meta); + command_deprecation(out, meta, 0); + usage_section(&mut sections.usage, spec, path, meta); if !meta.flatten_help { - long_commands_section(&mut out, &path[1.min(path.len())..], meta); + long_commands_section(&mut sections.commands, &path[1.min(path.len())..], meta); } // One column width per section, over its visible entries — the same two the reference @@ -1439,7 +1621,7 @@ fn long_help_with( .max() .unwrap_or(0); groups_section( - &mut out, + &mut sections.args, "Arguments", args.iter().copied(), |a| a.help_heading, @@ -1477,7 +1659,7 @@ fn long_help_with( .max() .unwrap_or(0); groups_section( - &mut out, + &mut sections.flags, "Flags", own.iter().copied(), |f| f.help_heading, @@ -1511,7 +1693,7 @@ fn long_help_with( // Not grouped by `help_heading` — an ancestor's headings describe that command's page, and // borrowing them here would put a section title on flags that are only visiting. groups_section( - &mut out, + &mut sections.flags, "Global flags", inherited.iter(), |_| None, @@ -1534,9 +1716,15 @@ fn long_help_with( }, ); if meta.flatten_help { - flat_commands_long(&mut out, &path[1.min(path.len())..], meta, width); + flat_commands_long( + &mut sections.flattened, + &path[1.min(path.len())..], + meta, + width, + ); } + let out = &mut sections.after_help; let examples = page_examples(spec, meta); if !examples.is_empty() { let _ = writeln!(out, "\nExamples:"); @@ -1576,11 +1764,7 @@ fn long_help_with( } } - let trimmed = out.trim(); - let mut done = String::with_capacity(trimmed.len() + 1); - done.push_str(trimmed); - done.push('\n'); - done + assemble(spec, §ions) } /// Write text with every line indented, leaving blank lines blank. diff --git a/conformance/src/tables.rs b/conformance/src/tables.rs index 09b9c3912..816c722f9 100644 --- a/conformance/src/tables.rs +++ b/conformance/src/tables.rs @@ -249,6 +249,7 @@ pub fn build_spec(spec: &Spec) -> &'static usage_argv::spec::Spec<'static> { // spec that declares one is a case the two disagree about; `render/03-sections.json` // records it rather than this quietly declining to carry it. usage: (!spec.usage.trim().is_empty()).then(|| leak(spec.usage.trim())), + help_template: opt(&spec.help_template), root: root_meta, })) } diff --git a/conformance/tests/help_template.rs b/conformance/tests/help_template.rs new file mode 100644 index 000000000..77137f7c5 --- /dev/null +++ b/conformance/tests/help_template.rs @@ -0,0 +1,259 @@ +//! A `help_template` says what order a page's sections come in, and three implementations have +//! to agree about it. +//! +//! What the rendering corpus cannot ask, because it builds usage-argv's tables out of KDL: does a +//! template written on a Rust type reach the tables at all, and does the page a compiled parser +//! then prints match the one the reference renders from the same CLI's own emitted spec? A field +//! dropped anywhere along `#[usage(help_template = …)]` → codegen → `Spec` → `help::short_help` +//! would leave every page in the default order, which is a plausible-looking page rather than a +//! failure — so the check is against the reference, not against a transcription. +//! +//! The corpus (`corpus/render/04-help-template.json`) pins what the sections contain. This pins +//! the wiring, and the round trip through KDL that carries a template between the two. + +use usage::Spec as LibSpec; +use usage_derive::{Args, Cli, Subcommands}; + +/// A CLI that lays its pages out itself. +/// +/// Everything a template can do, in one fixture: `{{flags}}` above `{{args}}`, which inverts the +/// default order; no `{{commands}}`, so a section is missing rather than merely moved; and a line +/// of the author's own at the end, which is what separates a layout from a permutation. +#[derive(Cli)] +#[usage( + bin = "laid-out", + about = "An example", + help_template = "{{about}}\n\n{{usage}}\n\n{{flags}}\n\n{{args}}\n\nSee the docs for more." +)] +struct LaidOut { + /// Do it anyway + #[usage(long)] + force: bool, + + /// Which file + #[usage(arg, name = "file")] + file: Option, + + #[usage(subcommand)] + command: Option, +} + +#[derive(Args)] +struct Run { + /// Only show changes + #[usage(long)] + dry_run: bool, +} + +#[derive(Subcommands)] +enum LaidOutCommands { + /// Run it + Run(Run), +} + +#[test] +fn a_template_declared_on_a_rust_type_reaches_the_tables() { + // The cold metadata a page is laid out from. Asserted before the page itself, so a field + // lost in codegen fails as a missing template rather than as a page in the wrong order. + let spec = LaidOut::spec(); + assert_eq!( + spec.help_template, + Some("{{about}}\n\n{{usage}}\n\n{{flags}}\n\n{{args}}\n\nSee the docs for more.") + ); +} + +#[test] +fn the_page_a_compiled_parser_prints_is_the_one_the_reference_renders() { + // The claim this file exists to make. Both pages come from the same CLI — usage-argv's from + // the derive's tables, usage-lib's from the KDL that same derive emits — so a template that + // survives one path and not the other fails here rather than in an adopter's terminal. + let spec = LaidOut::spec(); + let page = usage_argv::help::short_help(spec, &["laid-out"], &[spec.root]); + + let lib: LibSpec = spec + .to_kdl() + .parse() + .expect("the derive emits a valid spec"); + assert_eq!(page, usage::docs::cli::render_help(&lib, &lib.cmd, false)); + + // And it is laid out, rather than merely rendered: the flags are above the arguments, the + // command list the CLI does have is absent, and the author's own line closes the page. + let flags = page.find("Flags:").expect("a flags section"); + let args = page.find("Arguments:").expect("an arguments section"); + assert!(flags < args, "flags should come first:\n{page}"); + assert!(!page.contains("Commands:"), "{page}"); + assert!( + page.trim_end().ends_with("See the docs for more."), + "{page}" + ); + + // The sections themselves are untouched by the reordering — a template moves a page's parts + // and does not rewrite them. + assert!(page.contains(" --force Do it anyway"), "{page}"); + assert!(page.contains(" [file] Which file"), "{page}"); +} + +#[test] +fn a_subcommands_page_is_laid_out_by_the_roots_template() { + // A template belongs to the CLI, not to the command whose page is being written, so one + // declaration lays out every page. `run` has no arguments of its own, and the gap `{{args}}` + // would leave closes up rather than pushing the author's line away from the flags. + let spec = LaidOut::spec(); + let run = spec.root.subcommands[0]; + let page = usage_argv::help::short_help(spec, &["laid-out", "run"], &[spec.root, run]); + + let lib: LibSpec = spec + .to_kdl() + .parse() + .expect("the derive emits a valid spec"); + let lib_run = lib.cmd.subcommands.get("run").expect("run"); + assert_eq!(page, usage::docs::cli::render_help(&lib, lib_run, false)); + + assert!(page.contains("--dry-run"), "{page}"); + assert!(!page.contains("Arguments:"), "{page}"); + assert!( + !page.contains("\n\n\n"), + "no gap should be left behind:\n{page}" + ); +} + +#[test] +fn a_template_survives_the_round_trip_through_kdl() { + // KDL is the interface to everything downstream — markdown, manpages, the SDK generators — + // so a template that cannot be written down and read back is a template only the binary that + // declared it can honour. + let spec = LaidOut::spec(); + let kdl = spec.to_kdl(); + assert!(kdl.contains("help_template"), "{kdl}"); + + let lib: LibSpec = kdl.parse().expect("the derive emits a valid spec"); + assert_eq!(lib.help_template.as_deref(), spec.help_template); + + // And again from the reference's own writer, which is the round trip a spec checked into a + // repository actually makes. + let reparsed: LibSpec = lib + .to_string() + .parse() + .expect("the reference writes what it reads"); + assert_eq!(reparsed.help_template, lib.help_template); + assert_eq!( + usage::docs::cli::render_help(&reparsed, &reparsed.cmd, false), + usage::docs::cli::render_help(&lib, &lib.cmd, false) + ); +} + +/// A CLI naming every section there is, in an order no default page uses. +/// +/// The fixture that holds the vocabularies together. The derive keeps its own copy of the six +/// names — a proc-macro crate cannot depend on the crate its output calls into — so a name +/// missing from that copy refuses this struct at compile time, and one missing from +/// `usage_argv::help` renders here as the braces somebody typed. +#[derive(Cli)] +#[usage( + bin = "every-section", + version = "1.2.3", + about = "An example", + after_help = "Read the docs.", + help_template = "{{commands}}\n\n{{args}}\n\n{{flags}}\n\n{{usage}}\n\n{{after_help}}\n\n{{about}}" +)] +struct EverySection { + /// Do it anyway + #[usage(long)] + force: bool, + + /// Which file + #[usage(arg, name = "file")] + file: Option, + + #[usage(subcommand)] + command: Option, +} + +#[test] +fn every_section_the_vocabulary_holds_can_be_placed() { + let spec = EverySection::spec(); + let page = usage_argv::help::long_help(spec, &["every-section"], &[spec.root]); + + // Nothing was left as a placeholder: a name this renderer does not know would survive + // substitution as literal braces rather than fail, so the braces are what to look for. + assert!(!page.contains("{{"), "a section went unfilled:\n{page}"); + + // Each of the six put its own content where the template asked for it. + let at = |needle: &str| { + page.find(needle) + .unwrap_or_else(|| panic!("{needle}:\n{page}")) + }; + let order = [ + at("Commands:"), + at("Arguments:"), + at("Flags:"), + at("Usage:"), + at("Read the docs."), + at("every-section 1.2.3"), + ]; + assert!( + order.windows(2).all(|pair| pair[0] < pair[1]), + "the sections are not in the template's order:\n{page}" + ); + + // And the reference agrees, from the KDL this CLI writes. + let lib: LibSpec = spec + .to_kdl() + .parse() + .expect("the derive emits a valid spec"); + assert_eq!(page, usage::docs::cli::render_help(&lib, &lib.cmd, true)); +} + +#[test] +fn the_two_rust_vocabularies_are_the_same_six_words() { + // One list, three Rust copies: this one, usage-argv's, and the derive's. The derive's cannot + // be reached from here, which is what `every_section_the_vocabulary_holds_can_be_placed` is + // for; these two can be compared outright. + assert_eq!( + usage::help_template::SECTIONS, + usage_argv::help::SECTIONS, + "the reference and the compiled renderer name different sections" + ); +} + +#[test] +fn a_template_naming_no_section_is_refused_where_the_spec_is_read() { + // The vocabulary is closed, and a page cannot be assembled from a section nothing renders. + // KDL is checked at parse; the derive is checked at compile time, which is asserted by + // `derive/tests/ui` rather than here because a compile failure cannot be caught at run time. + let err = "bin \"ex\"\nhelp_template \"{{about}}{{options}}\"\n" + .parse::() + .expect_err("no section is called options"); + let usage::error::UsageErr::InvalidInput(message, ..) = err else { + panic!("a template is refused as invalid input, not as {err:?}") + }; + assert!(message.contains("options"), "{message}"); + // And says what to write instead, since a ported clap template is what hits this. + assert!(message.contains("flags"), "{message}"); +} + +#[test] +fn the_cli_a_template_describes_still_parses() { + // A page describing something the parser does not do is worse than no page. Reading the + // fixture also keeps its fields from being dead code, which CI denies. + use std::ffi::OsStr; + + let parsed = LaidOut::parse_from(&[OsStr::new("--force"), OsStr::new("notes.txt")]) + .expect("a flag and an argument"); + assert!(parsed.force); + assert_eq!(parsed.file.as_deref(), Some("notes.txt")); + + let sub = LaidOut::parse_from(&[OsStr::new("run"), OsStr::new("--dry-run")]) + .expect("a subcommand and its flag"); + let Some(LaidOutCommands::Run(run)) = sub.command else { + panic!("expected run") + }; + assert!(run.dry_run); + + // The all-sections fixture too, whose page the vocabulary test reads. + let every = EverySection::parse_from(&[OsStr::new("--force"), OsStr::new("notes.txt")]) + .expect("a flag and an argument"); + assert!(every.force); + assert_eq!(every.file.as_deref(), Some("notes.txt")); + assert!(every.command.is_none()); +} diff --git a/conformance/tests/snapshots/spec_roundtrip__the_emitted_spec_is_stable.snap b/conformance/tests/snapshots/spec_roundtrip__the_emitted_spec_is_stable.snap index 50c62d3e1..3923bc75b 100644 --- a/conformance/tests/snapshots/spec_roundtrip__the_emitted_spec_is_stable.snap +++ b/conformance/tests/snapshots/spec_roundtrip__the_emitted_spec_is_stable.snap @@ -10,6 +10,7 @@ source_code_link_template "{%- if cmd.subcommands | length > 0 -%}\n{%- set path about "does things" long_about "Does things, at length." usage "Usage: ex \n ex --version \"quoted\"" +help_template "{{about}}\n\n{{usage}}\n\n{{flags}}\n\n{{args}}\n\n{{commands}}" default_subcommand run example "ex a.txt" header=Basic help="the simplest thing" flag "-j --jobs" help="how many jobs, and a quote: \"" global=#true help_heading=Performance env=EX_JOBS default="4" { diff --git a/conformance/tests/spec_roundtrip.rs b/conformance/tests/spec_roundtrip.rs index f27cc1a80..774ca0010 100644 --- a/conformance/tests/spec_roundtrip.rs +++ b/conformance/tests/spec_roundtrip.rs @@ -338,6 +338,11 @@ static SPEC: Spec = Spec { about: Some("does things"), long_about: Some("Does things, at length."), usage: Some("Usage: ex \n ex --version \"quoted\""), + // Awkward in the way the rest of this fixture is: the braces have to survive the writer + // untouched, and the newlines have to come back as newlines rather than as the two + // characters `\n` — a template that round-trips as literal backslashes would lay out one + // long line and look like a renderer bug rather than a writer bug. + help_template: Some("{{about}}\n\n{{usage}}\n\n{{flags}}\n\n{{args}}\n\n{{commands}}"), default_subcommand: Some("run"), multicall: false, views: &[], @@ -373,6 +378,20 @@ fn the_program_itself_survives() { template.contains("{%- set path = path ~ \"/mod.rs\" -%}"), "{template:?}" ); + + // The help template comes back braces and newlines intact, so the page usage-lib renders + // from the emitted spec is laid out the way the emitting binary's own would be. + assert_eq!( + spec.help_template.as_deref(), + Some("{{about}}\n\n{{usage}}\n\n{{flags}}\n\n{{args}}\n\n{{commands}}") + ); + let page = usage::docs::cli::render_help(&spec, &spec.cmd, false); + assert!(!page.contains("{{"), "a section went unfilled:\n{page}"); + assert!( + page.find("Flags:").expect("a flags section") + < page.find("Commands:").expect("a commands section"), + "the template puts the flags above the commands:\n{page}" + ); } #[test] @@ -790,6 +809,7 @@ fn a_declared_completer_becomes_a_run_the_reference_can_read() { about: None, long_about: None, usage: None, + help_template: None, default_subcommand: None, multicall: false, views: &[], @@ -929,6 +949,7 @@ fn two_commands_can_mean_different_things_by_one_name() { about: None, long_about: None, usage: None, + help_template: None, default_subcommand: None, multicall: false, views: &[], @@ -988,6 +1009,7 @@ fn two_commands_can_mean_different_things_by_one_name() { about: None, long_about: None, usage: None, + help_template: None, default_subcommand: None, multicall: false, views: &[], diff --git a/corpus/render/04-help-template.json b/corpus/render/04-help-template.json new file mode 100644 index 000000000..4591b2a0e --- /dev/null +++ b/corpus/render/04-help-template.json @@ -0,0 +1,105 @@ +{ + "section": "help-template", + "about": "A spec's `help_template` says what order a page's sections come in. The template holds a closed vocabulary of six pre-rendered sections — `{{about}}`, `{{usage}}`, `{{commands}}`, `{{args}}`, `{{flags}}`, `{{after_help}}` — which an author may reorder, omit, or wrap in text of their own; it is deliberately not the metadata behind a page, so what the implementations agree on is where each section starts and ends rather than a template language's semantics. These vectors pin whole pages, because reordering is the one change that can only be seen across a document. The default order is not pinned here: it is what every other file in this directory renders, and the fleet gate compares it byte for byte.", + "vectors": [ + { + "id": "template-reorders-the-sections", + "doc": "A template's order is the page's order: `{{flags}}` before `{{args}}` inverts the pair every other page in this directory writes the other way round.", + "spec": "name \"ex\"\nbin \"ex\"\nabout \"An example\"\nhelp_template \"{{about}}\\n\\n{{usage}}\\n\\n{{flags}}\\n\\n{{args}}\"\nflag \"--force\" help=\"Do it anyway\"\narg \"\" help=\"Which file\"\n", + "expect": { + "usage": "ex [--force] ", + "short_help": [ + "An example", + "", + "Usage: ex [--force] ", + "", + "Flags:", + " --force Do it anyway", + " -h, --help Print help", + "", + "Arguments:", + " Which file" + ] + } + }, + { + "id": "template-omits-a-section", + "doc": "A section the template does not name is not on the page. A CLI whose subcommands are documented elsewhere can leave `{{commands}}` out, and the entries are still parsed — omitting a section changes the page and nothing else.", + "spec": "name \"ex\"\nbin \"ex\"\nabout \"An example\"\nhelp_template \"{{about}}\\n\\n{{usage}}\\n\\n{{flags}}\"\ncmd \"install\" help=\"Install a tool\"\ncmd \"remove\" help=\"Remove a tool\"\n", + "expect": { + "usage": "ex ", + "short_help": [ + "An example", + "", + "Usage: ex ", + "", + "Flags:", + " -h, --help Print help" + ] + } + }, + { + "id": "template-wraps-the-sections-in-text", + "doc": "Text around a placeholder is written as-is, which is what makes a template a layout rather than a permutation: a heading above the page and a line pointing at the docs below it are the author's, not the renderer's.", + "spec": "name \"ex\"\nbin \"ex\"\nhelp_template \"== ex ==\\n\\n{{usage}}\\n\\n{{flags}}\\n\\nSee https://example.com/docs for more.\"\nflag \"--force\" help=\"Do it anyway\"\n", + "expect": { + "usage": "ex [--force]", + "short_help": [ + "== ex ==", + "", + "Usage: ex [--force]", + "", + "Flags:", + " --force Do it anyway", + " -h, --help Print help", + "", + "See https://example.com/docs for more." + ] + } + }, + { + "id": "template-closes-the-gap-a-missing-section-leaves", + "doc": "A section that came out empty leaves no gap behind. The template below names all six and this command has neither arguments nor subcommands nor trailing text, so the separators around them collapse rather than pushing the flags down the page — which is what lets one template serve a whole CLI, since most commands are missing most sections.", + "spec": "name \"ex\"\nbin \"ex\"\nabout \"An example\"\nhelp_template \"{{about}}\\n\\n{{usage}}\\n\\n{{commands}}\\n\\n{{args}}\\n\\n{{flags}}\\n\\n{{after_help}}\"\nflag \"--force\" help=\"Do it anyway\"\n", + "expect": { + "usage": "ex [--force]", + "short_help": [ + "An example", + "", + "Usage: ex [--force]", + "", + "Flags:", + " --force Do it anyway", + " -h, --help Print help" + ] + } + }, + { + "id": "template-gathers-a-long-pages-trailing-sections", + "doc": "`{{after_help}}` is the whole tail of a page — examples, the spec's `after_help`, and the author and licence a long page ends with — so a template moving it moves all of it at once. Pinned on `--help`, since that is the form those last two lines appear on.", + "spec": "name \"ex\"\nbin \"ex\"\nversion \"1.2.3\"\nabout \"An example\"\nauthor \"Ex Ample\"\nafter_help \"Read the docs.\"\nhelp_template \"{{after_help}}\\n\\n{{usage}}\\n\\n{{flags}}\\n\\n{{about}}\"\nexample \"ex --force\" header=\"Force it\"\nflag \"--force\" help=\"Do it anyway\"\n", + "expect": { + "usage": "ex [--force]", + "long_help": [ + "Examples:", + " Force it:", + " $ ex --force", + "", + "Read the docs.", + "", + "Author: Ex Ample", + "", + "Usage: ex [--force]", + "", + "Flags:", + " --force Do it anyway", + " -h, --help Print help", + " -V, --version Print version", + "", + "ex 1.2.3", + "An example" + ] + } + } + ] +} diff --git a/derive/src/model.rs b/derive/src/model.rs index 7447f9ccc..de6a41153 100644 --- a/derive/src/model.rs +++ b/derive/src/model.rs @@ -1717,9 +1717,10 @@ impl Cli { // is the advantage of declaring them in code: a spec written by hand can only // find a typo'd selector at parse time, or never, since a selector naming // nothing quietly holds no relationship at all. - let has_opaque = self.fields.iter().any(|field| { - matches!(field.kind, Kind::Flatten { .. } | Kind::ArgGroup { .. }) - }); + let has_opaque = self + .fields + .iter() + .any(|field| matches!(field.kind, Kind::Flatten { .. } | Kind::ArgGroup { .. })); for field in &self.fields { for (option, selectors) in [ ("overrides", &field.overrides), @@ -3959,8 +3960,11 @@ pub(crate) fn string_value(meta: &Meta) -> syn::Result { /// The sections a `help_template` may name, and nothing else. /// /// The same closed vocabulary `usage_argv::help::SECTIONS` renders and the KDL parser accepts, -/// repeated rather than imported: this is a proc-macro crate, and the list is six words that a -/// conformance test compares against both other copies. +/// repeated rather than imported: a proc-macro crate cannot depend on the crate its output calls +/// into, and the list is six words. What keeps the copies together is +/// `conformance/tests/help_template.rs`, which renders a page from a template naming every one of +/// them — a section this copy had lost would refuse that fixture at compile time, and one it had +/// gained would render as literal braces. const HELP_SECTIONS: [&str; 6] = ["about", "usage", "commands", "args", "flags", "after_help"]; /// Whether every `{{…}}` in a template names a section. @@ -5864,10 +5868,7 @@ impl ArgGroup { // Same round-trip rules as an ordinary flag: the spec writes forms as a // space-delimited string, so whitespace, controls, `-`, and `=` have nowhere // to go and could never be typed as a short either. - if let Some(short) = member - .short - .filter(|c| c.is_whitespace() || c.is_control()) - { + if let Some(short) = member.short.filter(|c| c.is_whitespace() || c.is_control()) { return Err(syn::Error::new_spanned( &variant.ident, format!( @@ -5991,7 +5992,10 @@ mod tests { // One template, for the whole tree: an `Args` declaring one would be laying out a // page nobody assembles from it. - let err = position_error(r#"#[usage(help_template = "{{usage}}")] struct Inner {}"#, false); + let err = position_error( + r#"#[usage(help_template = "{{usage}}")] struct Inner {}"#, + false, + ); assert!(err.contains("belongs on the root"), "unhelpful: {err}"); assert!(err.contains("help_template"), "unhelpful: {err}"); } @@ -7484,7 +7488,10 @@ mod tests { panic!("expected an argument group"); }; assert!(optional); - assert_eq!(quote::ToTokens::to_token_stream(ty).to_string(), "crate :: fmt :: Format"); + assert_eq!( + quote::ToTokens::to_token_stream(ty).to_string(), + "crate :: fmt :: Format" + ); // And nothing wrapped around it, which the field says rather than the trait bound the // generated code would otherwise fail. diff --git a/docs/rust/clap-compatibility.md b/docs/rust/clap-compatibility.md index 3e189dea8..ed3e708a3 100644 --- a/docs/rust/clap-compatibility.md +++ b/docs/rust/clap-compatibility.md @@ -111,27 +111,27 @@ the Rust declaration, not only from generated KDL, wherever the bridge column sa ## Help, version, and generated artifacts -| clap surface | derive | argv | KDL | lib | output | bridge | Notes | -| -------------------------------------------------- | ------ | ---- | --- | --- | ------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| short/long help and doc comments | yes | yes | yes | yes | yes | yes | First paragraph is short help; the full block is long help. | -| `help_heading` on flags and arguments | yes | n/a | yes | yes | yes | yes | Flags and arguments are grouped and retain declaration order. | -| `help_heading` on subcommands | yes | yes | yes | yes | yes | n/a | Commands can be grouped into named sections in their parent's help. | -| whole-entry `hide` | yes | yes | yes | yes | yes | yes | Hidden commands, flags, arguments, and values still parse. | -| granular hide settings | yes | yes | yes | yes | yes | yes | Default, environment, possible-value, short-help, and long-help visibility is independent. | -| `subcommand_help_heading`, `subcommand_value_name` | yes | yes | yes | yes | yes | yes | Customize the subcommand section label and the synopsis placeholder. | -| `verbatim_doc_comment` | yes | n/a | yes | yes | yes | n/a | Commands, fields, and variants preserve line breaks and indentation when requested. | -| `rename_all`, `rename_all_env` | yes | n/a | yes | yes | yes | n/a | Full clap casing vocabulary; bare `env` uses the environment casing policy. | -| `next_line_help` | yes | yes | yes | yes | yes | yes | Put command, argument, and flag descriptions below their usage instead of beside it. | -| `flatten_help` | yes | yes | yes | yes | yes | yes | Expand visible subcommands into their parent's usage synopsis and help page. | -| `display_order` | yes | yes | yes | yes | yes | yes | Explicit field and subcommand presentation order is portable; parsing order is unchanged. | -| `help_template` | no | n/a | no | no | no | no | No equivalent yet. | -| `term_width`, `max_term_width` | yes | yes | yes | yes | yes | no | Fixed width overrides a detected-width cap; clap exposes no bridge getters for these settings. | -| help styles and color | n/a | n/a | n/a | yes | lossy | no | Help and diagnostics use automatic ANSI styles; clap's custom style palette is not portable. | -| built-in help/version action and flag control | yes | yes | yes | yes | yes | yes | `Help`, `HelpShort`, `HelpLong`, and `Version` actions can relocate built-ins; usage additionally provides recursive `HelpAll`; each synthetic entry can be disabled. | -| `--version` / `-V`, dynamic and long versions | yes | yes | yes | yes | yes | yes | `long_version` customizes `--version`; `-V` keeps the concise value. | -| `author`, `license`, `repository` | yes | n/a | yes | yes | yes | partial | Package metadata is rendered in Markdown and manpages; clap exposes author but not license. | -| completion generation | yes | yes | yes | yes | lossy | yes | Bash, fish, Nushell, PowerShell, and zsh plus runtime overlays are supported; Elvish is not. | -| KDL, markdown, JSON, and manpages | yes | n/a | yes | yes | yes | yes | Direct derived KDL feeds the existing generators; broader canonicalization remains open. | +| clap surface | derive | argv | KDL | lib | output | bridge | Notes | +| -------------------------------------------------- | --------- | --------- | --------- | --------- | ------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| short/long help and doc comments | yes | yes | yes | yes | yes | yes | First paragraph is short help; the full block is long help. | +| `help_heading` on flags and arguments | yes | n/a | yes | yes | yes | yes | Flags and arguments are grouped and retain declaration order. | +| `help_heading` on subcommands | yes | yes | yes | yes | yes | n/a | Commands can be grouped into named sections in their parent's help. | +| whole-entry `hide` | yes | yes | yes | yes | yes | yes | Hidden commands, flags, arguments, and values still parse. | +| granular hide settings | yes | yes | yes | yes | yes | yes | Default, environment, possible-value, short-help, and long-help visibility is independent. | +| `subcommand_help_heading`, `subcommand_value_name` | yes | yes | yes | yes | yes | yes | Customize the subcommand section label and the synopsis placeholder. | +| `verbatim_doc_comment` | yes | n/a | yes | yes | yes | n/a | Commands, fields, and variants preserve line breaks and indentation when requested. | +| `rename_all`, `rename_all_env` | yes | n/a | yes | yes | yes | n/a | Full clap casing vocabulary; bare `env` uses the environment casing policy. | +| `next_line_help` | yes | yes | yes | yes | yes | yes | Put command, argument, and flag descriptions below their usage instead of beside it. | +| `flatten_help` | yes | yes | yes | yes | yes | yes | Expand visible subcommands into their parent's usage synopsis and help page. | +| `display_order` | yes | yes | yes | yes | yes | yes | Explicit field and subcommand presentation order is portable; parsing order is unchanged. | +| `help_template` | different | different | different | different | yes | no | Supported, with a closed vocabulary of six pre-rendered sections rather than clap's tags; see [Laying a page out](./help.md#laying-a-page-out) for the mapping. clap keeps `get_help_template` private, so the bridge cannot recover one. | +| `term_width`, `max_term_width` | yes | yes | yes | yes | yes | no | Fixed width overrides a detected-width cap; clap exposes no bridge getters for these settings. | +| help styles and color | n/a | n/a | n/a | yes | lossy | no | Help and diagnostics use automatic ANSI styles; clap's custom style palette is not portable. | +| built-in help/version action and flag control | yes | yes | yes | yes | yes | yes | `Help`, `HelpShort`, `HelpLong`, and `Version` actions can relocate built-ins; usage additionally provides recursive `HelpAll`; each synthetic entry can be disabled. | +| `--version` / `-V`, dynamic and long versions | yes | yes | yes | yes | yes | yes | `long_version` customizes `--version`; `-V` keeps the concise value. | +| `author`, `license`, `repository` | yes | n/a | yes | yes | yes | partial | Package metadata is rendered in Markdown and manpages; clap exposes author but not license. | +| completion generation | yes | yes | yes | yes | lossy | yes | Bash, fish, Nushell, PowerShell, and zsh plus runtime overlays are supported; Elvish is not. | +| KDL, markdown, JSON, and manpages | yes | n/a | yes | yes | yes | yes | Direct derived KDL feeds the existing generators; broader canonicalization remains open. | ## Usage extensions diff --git a/docs/rust/help.md b/docs/rust/help.md index 6635c72f8..e5e9d1548 100644 --- a/docs/rust/help.md +++ b/docs/rust/help.md @@ -100,6 +100,73 @@ full rule, including what a default does not count as, is in The rendered output matches what usage-lib renders from the same spec — the two renderers are held to identical output over mise's 211 command pages in CI. +### Laying a page out + +The words above change what a page _says_. `help_template` changes the order it says it in: + +```rust +#[derive(usage::Cli)] +#[usage( + bin = "mycli", + about = "Does the thing", + help_template = "{{about}}\n\n{{usage}}\n\n{{flags}}\n\n{{args}}\n\n{{commands}}" +)] +struct Cli { /* … */ } +``` + +In KDL, the same declaration is a root-level node: + +```kdl +help_template "{{about}}\n\n{{usage}}\n\n{{flags}}\n\n{{args}}\n\n{{commands}}" +``` + +A template is placed on the root and lays out every page in the CLI, subcommands included. It +holds six named sections and nothing else: + +| section | what it covers | +| ---------------- | ------------------------------------------------------------------------------------ | +| `{{about}}` | `before_help`, the `{bin} {version}` banner, and the description | +| `{{usage}}` | the `Usage:` synopsis, however many lines it takes | +| `{{commands}}` | the subcommand list — or, under `flatten_help`, the subcommands' own bodies | +| `{{args}}` | every argument group, each under its heading | +| `{{flags}}` | this command's flag groups, then the global flags it inherits | +| `{{after_help}}` | the Examples section, `after_help`, and the author and licence a long page ends with | + +Reorder them, leave them out, or put text of your own around them. Two rules make that +predictable: + +- **A section that comes out empty leaves no gap.** Templates are written with the separators a + full page wants, and most commands are missing most sections — a command with no arguments + renders the template above with its commands directly below its flags rather than pushed down + the page. The flip side is that a template cannot open a gap wider than one blank line. +- **The vocabulary is closed.** A placeholder naming anything else is refused: at compile time by + the derive, and when the spec is read by usage-lib. Sections are handed to the template already + rendered, so what the implementations agree on is where each section starts and ends rather than + a template language's semantics — which is how the interpreter, the compiled `usage-argv` parser, + and generated Go all lay a page out identically. + +The template applies to the terminal help page. Markdown, manpages, and JSON keep their own +structure. + +#### Coming from clap + +clap's tags are single-braced and finer-grained, so a clap template has to be rewritten rather +than pasted. The sections map like this: + +| clap | usage | +| ----------------------------------------------------------------------------------------------- | --------------------------------------- | +| `{name}`, `{version}`, `{about}`, `{before-help}`, and their `-with-newline` / `-section` forms | `{{about}}` | +| `{usage-heading} {usage}` | `{{usage}}` | +| `{options}` | `{{flags}}` | +| `{positionals}` | `{{args}}` | +| `{subcommands}` | `{{commands}}` | +| `{after-help}`, `{author}` | `{{after_help}}` | +| `{all-args}` | `{{commands}}\n\n{{args}}\n\n{{flags}}` | +| `{tab}` | write the spaces | + +clap keeps its `get_help_template` getter private, so `clap_usage` cannot recover a template from +a `clap::Command` — a template is one of the settings to carry across by hand when migrating. + ## Version Declaring `version` (or bare `version`, which reads `CARGO_PKG_VERSION`) gives the root command diff --git a/go/argv/page.go b/go/argv/page.go index 2eb3ed107..a084a676d 100644 --- a/go/argv/page.go +++ b/go/argv/page.go @@ -41,6 +41,12 @@ type HelpSpec struct { AfterHelp string BeforeLongHelp string AfterLongHelp string + // HelpTemplate is how every page in this CLI is laid out, as named sections: + // `{{about}}`, `{{usage}}`, `{{commands}}`, `{{args}}`, `{{flags}}` and + // `{{after_help}}`, which an author may reorder, omit or wrap. Empty means the + // default order, which is what every page in the fleet is compared against. + // See [HelpSections]. + HelpTemplate string } // Example is one worked invocation, as a page prints it. @@ -67,7 +73,8 @@ func ShortHelp(spec HelpSpec, path []string, chain []*Command, help HelpTable) s } cmd := chain[len(chain)-1] meta := help.Lookup(cmd.Key) - var out strings.Builder + var sections helpSections + out := §ions.about before := spec.BeforeHelp if meta != nil && meta.BeforeHelp != "" { @@ -107,9 +114,9 @@ func ShortHelp(spec HelpSpec, path []string, chain []*Command, help HelpTable) s for i, line := range usageLines(path, cmd, help) { if i == 0 { - out.WriteString("Usage: " + line + "\n") + sections.usage.WriteString("Usage: " + line + "\n") } else { - out.WriteString(" " + line + "\n") + sections.usage.WriteString(" " + line + "\n") } } @@ -117,7 +124,7 @@ func ShortHelp(spec HelpSpec, path []string, chain []*Command, help HelpTable) s // usage-lib prints the whole path from the root rather than the child's own // name. if meta == nil || !meta.FlattenHelp { - commandsSection(&out, path[min(1, len(path)):], cmd, help) + commandsSection(§ions.commands, path[min(1, len(path)):], cmd, help) } args := visibleArgs(cmd, help, false) @@ -128,7 +135,7 @@ func ShortHelp(spec HelpSpec, path []string, chain []*Command, help HelpTable) s argCol = n } } - groupsSection(&out, "Arguments", len(args), + groupsSection(§ions.args, "Arguments", len(args), func(i int) string { return headingOf(help, args[i].Key) }, func(w *strings.Builder, i int) { a := args[i] @@ -201,7 +208,7 @@ func ShortHelp(spec HelpSpec, path []string, chain []*Command, help HelpTable) s } annotations(w, h, true) } - groupsSection(&out, "Flags", len(own), + groupsSection(§ions.flags, "Flags", len(own), func(i int) string { if own[i].supplied != "" { return "" @@ -212,26 +219,24 @@ func ShortHelp(spec HelpSpec, path []string, chain []*Command, help HelpTable) s // After the command's own, and under a heading that says where they came // from: a global belongs to the program, not to this command, and a reader // should be able to see that. - groupsSection(&out, "Global flags", len(inherited), + groupsSection(§ions.flags, "Global flags", len(inherited), func(int) string { return "" }, func(w *strings.Builder, i int) { entry(w, inherited[i]) }) if meta != nil && meta.FlattenHelp { - flatCommandsShort(&out, path[min(1, len(path)):], cmd, help, nextLineHelp) + flatCommandsShort(§ions.flattened, path[min(1, len(path)):], cmd, help, nextLineHelp) } - examplesSection(&out, pageExamples(chain, help, meta)) + examplesSection(§ions.afterHelp, pageExamples(chain, help, meta)) after := spec.AfterHelp if meta != nil && meta.AfterHelp != "" { after = meta.AfterHelp } if after != "" { - out.WriteString("\n" + after + "\n") + sections.afterHelp.WriteString("\n" + after + "\n") } - // usage-lib trims the whole document and puts back one newline, which keeps - // the blank lines between sections from becoming trailing ones. - return strings.TrimSpace(out.String()) + "\n" + return sections.assemble(spec.HelpTemplate) } // commandsSection lists the subcommands, and the `help` command every CLI with diff --git a/go/argv/page_long.go b/go/argv/page_long.go index 74b9b14c6..145ae1cce 100644 --- a/go/argv/page_long.go +++ b/go/argv/page_long.go @@ -29,7 +29,8 @@ func LongHelp(spec HelpSpec, path []string, chain []*Command, help HelpTable) st cmd := chain[len(chain)-1] meta := help.Lookup(cmd.Key) nextLineHelp := meta != nil && meta.NextLineHelp - var out strings.Builder + var sections helpSections + out := §ions.about before := firstOf(metaField(meta, func(h *Help) string { return h.BeforeLongHelp }), metaField(meta, func(h *Help) string { return h.BeforeHelp }), @@ -69,14 +70,14 @@ func LongHelp(spec HelpSpec, path []string, chain []*Command, help HelpTable) st for i, line := range usageLines(path, cmd, help) { if i == 0 { - out.WriteString("Usage: " + line + "\n") + sections.usage.WriteString("Usage: " + line + "\n") } else { - out.WriteString(" " + line + "\n") + sections.usage.WriteString(" " + line + "\n") } } if meta == nil || !meta.FlattenHelp { - longCommandsSection(&out, path[min(1, len(path)):], cmd, help) + longCommandsSection(§ions.commands, path[min(1, len(path)):], cmd, help) } // One column width per section, over its visible entries — separately, so a @@ -88,7 +89,7 @@ func LongHelp(spec HelpSpec, path []string, chain []*Command, help HelpTable) st argCol = n } } - groupsSection(&out, "Arguments", len(args), + groupsSection(§ions.args, "Arguments", len(args), func(i int) string { return headingOf(help, args[i].Key) }, func(w *strings.Builder, i int) { h := help.Lookup(args[i].Key) @@ -119,7 +120,7 @@ func LongHelp(spec HelpSpec, path []string, chain []*Command, help HelpTable) st metaField(h, func(x *Help) string { return x.Short })), flagCol, nextLineHelp) longAnnotations(w, h, true) } - groupsSection(&out, "Flags", len(own), + groupsSection(§ions.flags, "Flags", len(own), func(i int) string { if own[i].supplied != "" { return "" @@ -130,13 +131,14 @@ func LongHelp(spec HelpSpec, path []string, chain []*Command, help HelpTable) st // Not grouped by heading: an ancestor's headings describe that command's page, // and borrowing them here would put a section title on flags that are only // visiting. - groupsSection(&out, "Global flags", len(inherited), + groupsSection(§ions.flags, "Global flags", len(inherited), func(int) string { return "" }, func(w *strings.Builder, i int) { writeFlag(w, inherited[i]) }) if meta != nil && meta.FlattenHelp { - flatCommandsLong(&out, path[min(1, len(path)):], cmd, help, nextLineHelp) + flatCommandsLong(§ions.flattened, path[min(1, len(path)):], cmd, help, nextLineHelp) } + out = §ions.afterHelp if examples := pageExamples(chain, help, meta); len(examples) > 0 { out.WriteString("\nExamples:\n") for _, e := range examples { @@ -169,7 +171,7 @@ func LongHelp(spec HelpSpec, path []string, chain []*Command, help HelpTable) st } } - return strings.TrimSpace(out.String()) + "\n" + return sections.assemble(spec.HelpTemplate) } // AllHelp renders long help for the selected command and every visible descendant. diff --git a/go/argv/sections.go b/go/argv/sections.go new file mode 100644 index 000000000..e51c62492 --- /dev/null +++ b/go/argv/sections.go @@ -0,0 +1,159 @@ +package argv + +import "strings" + +// A page's sections, and the template that may reorder them. +// +// Ported from usage-argv's `help::Sections`, and held to the same standard as the +// rest of this file's neighbours: the boundaries are what the three implementations +// agree on, so a section here holds exactly what the same section holds there. + +// HelpSections is the vocabulary a HelpTemplate may name, and nothing else. +// +// A closed list on purpose. Handing a template the metadata behind a page instead +// would make this renderer's internals part of the spec and ask every +// implementation to agree on a template language's semantics rather than on where +// a section starts and ends. +// +// about BeforeHelp, the version banner, and the description +// usage the Usage: synopsis, however many lines it takes +// commands the subcommand list, or the flattened bodies under FlattenHelp +// args every argument group, each under its heading +// flags this command's flag groups, then the globals it inherits +// after_help examples, AfterHelp, and the author/license footer on a long page +var HelpSections = []string{"about", "usage", "commands", "args", "flags", "after_help"} + +// helpSections is a page under construction, cut at the boundaries a template may +// reorder. `flattened` is not a section an author can name: it is the other half of +// `commands`, since FlattenHelp replaces a command list with the subcommands' own +// bodies, and only one of the two is ever written. +type helpSections struct { + about strings.Builder + usage strings.Builder + commands strings.Builder + args strings.Builder + flags strings.Builder + flattened strings.Builder + afterHelp strings.Builder +} + +// concatenated is the default page: every section in the order it was written. +// +// A plain join, so this is the same string the renderer produced before the +// sections were separable — the blank line above a section belongs to the section. +func (s *helpSections) concatenated() string { + var out strings.Builder + for _, part := range []*strings.Builder{ + &s.about, &s.usage, &s.commands, &s.args, &s.flags, &s.flattened, &s.afterHelp, + } { + out.WriteString(part.String()) + } + return out.String() +} + +// named is one section, trimmed, so that a template owns the whitespace between +// them: a template is a layout, and a section carrying the blank line above it +// could not be moved without carrying that decision along. +func (s *helpSections) named(name string) (string, bool) { + switch name { + case "about": + return strings.TrimSpace(s.about.String()), true + case "usage": + return strings.TrimSpace(s.usage.String()), true + case "commands": + list := strings.TrimSpace(s.commands.String()) + flattened := strings.TrimSpace(s.flattened.String()) + if flattened == "" { + return list, true + } + if list == "" { + return flattened, true + } + return list + "\n\n" + flattened, true + case "args": + return strings.TrimSpace(s.args.String()), true + case "flags": + return strings.TrimSpace(s.flags.String()), true + case "after_help": + return strings.TrimSpace(s.afterHelp.String()), true + } + return "", false +} + +// assemble is the finished page: laid out by the spec's template where it has one. +// +// usage-lib trims the whole document and puts back one newline, which keeps the +// blank lines between sections from becoming trailing ones. That holds for a +// template's output too: a page ends in exactly one newline however it was built. +func (s *helpSections) assemble(template string) string { + page := s.concatenated() + if template != "" { + page = substituteSections(template, s) + } + return strings.TrimSpace(page) + "\n" +} + +// substituteSections fills a template in, section by section. +// +// A placeholder naming no section is left exactly as it was written: the vocabulary +// is checked where a spec is authored — KDL refuses one at parse, the Rust derive at +// compile time — so one arriving here is text an author meant literally. +// +// A section that came out empty leaves no gap behind: see collapseBlankRuns, whose +// rule is what lets one template serve a whole CLI. +func substituteSections(template string, s *helpSections) string { + var out strings.Builder + rest := template + for { + at := strings.Index(rest, "{{") + if at < 0 { + out.WriteString(rest) + return collapseBlankRuns(out.String()) + } + out.WriteString(rest[:at]) + after := rest[at+2:] + end := strings.Index(after, "}}") + if end < 0 { + out.WriteString(rest[at:]) + return collapseBlankRuns(out.String()) + } + if text, ok := s.named(strings.TrimSpace(after[:end])); ok { + out.WriteString(text) + } else { + out.WriteString(rest[at : at+2+end+2]) + } + rest = after[end+2:] + } +} + +// collapseBlankRuns reduces every run of blank lines to a single blank line. +// +// The twin of usage::help_template::collapse_blank_runs, and the reason a template +// may name a section a given command does not have: a template carries the +// separators a full page wants, and a command with no arguments would otherwise +// render two of them back to back and push its flags down the page. Most commands +// are missing most sections, so without this a template would have to be written +// per command rather than per CLI. +// +// A whitespace-only line counts as blank, since that is what an empty placeholder on +// an indented line leaves behind; a section's own indentation does not, since that is +// the page. Applies to a template's output alone, so a default page is untouched. +func collapseBlankRuns(page string) string { + var out strings.Builder + blank := false + for _, line := range strings.Split(page, "\n") { + if strings.TrimSpace(line) == "" { + blank = out.Len() > 0 + continue + } + if out.Len() > 0 { + out.WriteString("\n") + if blank { + out.WriteString("\n") + } + } + blank = false + out.WriteString(line) + } + return out.String() +} diff --git a/go/argv/sections_test.go b/go/argv/sections_test.go new file mode 100644 index 000000000..9d1b184eb --- /dev/null +++ b/go/argv/sections_test.go @@ -0,0 +1,194 @@ +package argv + +import ( + "strings" + "testing" +) + +// Does a HelpTemplate lay a page out the way the other two implementations do? +// +// The pages below are the ones `corpus/render/04-help-template.json` pins for +// usage-lib and usage-argv, transcribed. Go does not run the rendering corpus — +// `go/conformance` checks pages against mise, which declares no template — so +// this is where the third implementation is held to the same expectations, and a +// transcription is the price of that. Compare against the JSON when changing +// either. + +func templateFixture(template string) (HelpSpec, []string, []*Command, HelpTable) { + force := &Flag{Key: 2, Name: "force", Longs: []string{"force"}} + file := &Arg{Key: 3, Name: "file"} + root := &Command{Name: "ex", Key: 1, Flags: []*Flag{force}, Args: []*Arg{file}} + help := HelpTable{ + {Key: 1, Short: "An example"}, + {Key: 2, Short: "Do it anyway"}, + {Key: 3, Short: "Which file"}, + } + spec := HelpSpec{Name: "ex", Bin: "ex", About: "An example", HelpTemplate: template} + return spec, []string{"ex"}, []*Command{root}, help +} + +func TestATemplateReordersTheSections(t *testing.T) { + // `{{flags}}` above `{{args}}` inverts the order every default page writes. + spec, path, chain, help := templateFixture( + "{{about}}\n\n{{usage}}\n\n{{flags}}\n\n{{args}}") + want := strings.Join([]string{ + "An example", + "", + "Usage: ex [--force] [file]", + "", + "Flags:", + " --force Do it anyway", + " -h, --help Print help", + "", + "Arguments:", + " [file] Which file", + }, "\n") + "\n" + if got := ShortHelp(spec, path, chain, help); got != want { + t.Fatalf("page differs\n got:\n%s\nwant:\n%s", got, want) + } +} + +func TestATemplateOmitsASection(t *testing.T) { + // A section the template does not name is not on the page. + spec, path, chain, help := templateFixture("{{about}}\n\n{{usage}}\n\n{{flags}}") + got := ShortHelp(spec, path, chain, help) + if strings.Contains(got, "Arguments:") { + t.Fatalf("an unnamed section should not be rendered:\n%s", got) + } + if !strings.Contains(got, "--force") { + t.Fatalf("a named section should be:\n%s", got) + } +} + +func TestATemplateWrapsTheSectionsInText(t *testing.T) { + // Text around a placeholder is written as-is, which is what makes a template a + // layout rather than a permutation. + spec, path, chain, help := templateFixture( + "== ex ==\n\n{{usage}}\n\n{{flags}}\n\nSee https://example.com/docs for more.") + got := ShortHelp(spec, path, chain, help) + if !strings.HasPrefix(got, "== ex ==\n\n") { + t.Errorf("the author's heading should open the page:\n%s", got) + } + if !strings.HasSuffix(got, "See https://example.com/docs for more.\n") { + t.Errorf("and their footer should close it:\n%s", got) + } +} + +func TestATemplateClosesTheGapAMissingSectionLeaves(t *testing.T) { + // The rule that lets one template serve a whole CLI: this command has no + // subcommands and no trailing text, and the separators around those sections + // collapse rather than pushing the rest of the page down. + spec, path, chain, help := templateFixture( + "{{about}}\n\n{{usage}}\n\n{{commands}}\n\n{{args}}\n\n{{flags}}\n\n{{after_help}}") + want := strings.Join([]string{ + "An example", + "", + "Usage: ex [--force] [file]", + "", + "Arguments:", + " [file] Which file", + "", + "Flags:", + " --force Do it anyway", + " -h, --help Print help", + }, "\n") + "\n" + if got := ShortHelp(spec, path, chain, help); got != want { + t.Fatalf("page differs\n got:\n%s\nwant:\n%s", got, want) + } +} + +func TestATemplateGathersALongPagesTrailingSections(t *testing.T) { + // `{{after_help}}` is the whole tail of a page — examples, the spec's trailing + // text, and the author and licence a long page ends with — so a template moving + // it moves all of it at once. + root := &Command{Name: "ex", Key: 1, Version: true} + help := HelpTable{{Key: 1, Examples: []Example{{Header: "Force it", Code: "ex --force"}}}} + spec := HelpSpec{ + Name: "ex", Bin: "ex", About: "An example", Version: "1.2.3", + Author: "Ex Ample", AfterHelp: "Read the docs.", + HelpTemplate: "{{after_help}}\n\n{{usage}}\n\n{{flags}}\n\n{{about}}", + } + got := LongHelp(spec, []string{"ex"}, []*Command{root}, help) + want := strings.Join([]string{ + "Examples:", + " Force it:", + " $ ex --force", + "", + "Read the docs.", + "", + "Author: Ex Ample", + "", + "Usage: ex", + "", + "Flags:", + " -h, --help Print help", + " -V, --version Print version", + "", + "ex 1.2.3", + "An example", + }, "\n") + "\n" + if got != want { + t.Fatalf("page differs\n got:\n%s\nwant:\n%s", got, want) + } +} + +func TestAPageWithoutATemplateIsUnchanged(t *testing.T) { + // The default order is what every other test in this package renders, and the + // point of the whole arrangement is that adding a template did not move it. + spec, path, chain, help := templateFixture("") + want := strings.Join([]string{ + "An example", + "", + "Usage: ex [--force] [file]", + "", + "Arguments:", + " [file] Which file", + "", + "Flags:", + " --force Do it anyway", + " -h, --help Print help", + }, "\n") + "\n" + if got := ShortHelp(spec, path, chain, help); got != want { + t.Fatalf("the default page changed\n got:\n%s\nwant:\n%s", got, want) + } +} + +func TestAPlaceholderNamingNoSectionIsLeftAlone(t *testing.T) { + // The vocabulary is checked where a spec is authored — KDL refuses one at parse, + // the Rust derive at compile time — so a name reaching this renderer is text an + // author meant literally rather than an error to discover here. + spec, path, chain, help := templateFixture("{{usage}}\n\n{{options}}") + got := ShortHelp(spec, path, chain, help) + if !strings.Contains(got, "{{options}}") { + t.Fatalf("an unknown placeholder should survive as written:\n%s", got) + } +} + +func TestTheSectionVocabularyIsTheSameSixWords(t *testing.T) { + // The list the other implementations hold: usage::help_template::SECTIONS and + // usage_argv::help::SECTIONS. Nothing mechanical compares them across languages, + // so this is where Go's copy is written down beside the order they share. + want := []string{"about", "usage", "commands", "args", "flags", "after_help"} + if len(HelpSections) != len(want) { + t.Fatalf("HelpSections = %v, want %v", HelpSections, want) + } + for i, name := range want { + if HelpSections[i] != name { + t.Fatalf("HelpSections = %v, want %v", HelpSections, want) + } + } + + // And every one of them can be placed: a name this renderer does not know would + // survive substitution as literal braces. + var template strings.Builder + for i, name := range HelpSections { + if i > 0 { + template.WriteString("\n\n") + } + template.WriteString("{{" + name + "}}") + } + spec, path, chain, help := templateFixture(template.String()) + if got := ShortHelp(spec, path, chain, help); strings.Contains(got, "{{") { + t.Fatalf("a section went unfilled:\n%s", got) + } +} diff --git a/go/internal/spec/spec.go b/go/internal/spec/spec.go index 61effbd75..1ec4cac50 100644 --- a/go/internal/spec/spec.go +++ b/go/internal/spec/spec.go @@ -57,6 +57,9 @@ type Spec struct { BeforeHelpLong string `json:"before_help_long"` AfterHelpLong string `json:"after_help_long"` Usage string `json:"usage"` + // HelpTemplate names the pre-rendered sections a page is assembled from, in + // the order this CLI wants them. + HelpTemplate string `json:"help_template"` } // HelpSpec is what a page needs from the spec's root rather than from a command. @@ -74,6 +77,7 @@ func (s *Spec) HelpSpec() argv.HelpSpec { AfterHelp: s.AfterHelp, BeforeLongHelp: s.BeforeHelpLong, AfterLongHelp: s.AfterHelpLong, + HelpTemplate: s.HelpTemplate, } } diff --git a/lib/src/docs/cli/mod.rs b/lib/src/docs/cli/mod.rs index a45129ada..3e2da1e95 100644 --- a/lib/src/docs/cli/mod.rs +++ b/lib/src/docs/cli/mod.rs @@ -75,12 +75,121 @@ pub fn render_help(spec: &Spec, cmd: &SpecCommand, long: bool) -> String { // into the context first would carry the ones computed before the two lists were joined. ctx.insert("cmd", &docs_cmd); ctx.insert("global_flags", &inherited); + for (name, mark) in MARKS { + ctx.insert(name, &mark); + } let template = if long { "spec_template_long.tera" } else { "spec_template_short.tera" }; - TERA.render(template, &ctx).unwrap().trim().to_string() + "\n" + let rendered = TERA.render(template, &ctx).unwrap(); + let sections = Sections::split(&rendered); + let page = match spec.help_template.as_deref() { + Some(template) => crate::help_template::substitute(template, |name| sections.named(name)), + None => sections.concatenated(), + }; + page.trim().to_string() + "\n" +} + +/// Where each section of a rendered page starts, as the templates write it. +/// +/// The layout lives in the templates, and this is how it stays there: each one emits a marker +/// at every section boundary, so the boundaries are declared beside the sections rather than +/// worked out again here. A page with no `help_template` is the marks taken back out, which is +/// the same string the templates produced before any of this existed — and what the fleet gate +/// compares byte for byte. +/// +/// Control characters, because a marker has to be something no help text contains and no +/// terminal shows if one ever escapes. +const MARKS: [(&str, &str); 6] = [ + ("mark_usage", "\u{1}usage\u{1}"), + ("mark_commands", "\u{1}commands\u{1}"), + ("mark_args", "\u{1}args\u{1}"), + ("mark_flags", "\u{1}flags\u{1}"), + ("mark_flattened", "\u{1}flattened\u{1}"), + ("mark_after_help", "\u{1}after_help\u{1}"), +]; + +/// A rendered page cut into the sections a `help_template` may reorder. +/// +/// The twin of `usage_argv::help`'s `Sections`, down to `flattened` not being a section an +/// author can name: it is the other half of `commands`, since `flatten_help` replaces a +/// command list with the subcommands' own bodies, and only one of the two is ever there. +struct Sections<'a> { + about: &'a str, + usage: &'a str, + commands: &'a str, + args: &'a str, + flags: &'a str, + flattened: &'a str, + after_help: &'a str, +} + +impl<'a> Sections<'a> { + fn split(rendered: &'a str) -> Self { + let mut rest = rendered; + let mut parts: Vec<&str> = Vec::with_capacity(MARKS.len() + 1); + for (_, mark) in MARKS { + // A missing marker leaves that section empty rather than swallowing the ones after + // it: every one is written at the top level of both templates, so this cannot + // happen, and it is not worth a panic in a help renderer if it ever does. + match rest.split_once(mark) { + Some((before, after)) => { + parts.push(before); + rest = after; + } + None => parts.push(""), + } + } + parts.push(rest); + Self { + about: parts[0], + usage: parts[1], + commands: parts[2], + args: parts[3], + flags: parts[4], + flattened: parts[5], + after_help: parts[6], + } + } + + /// The default page: every section in the order the templates wrote them. + fn concatenated(&self) -> String { + [ + self.about, + self.usage, + self.commands, + self.args, + self.flags, + self.flattened, + self.after_help, + ] + .concat() + } + + /// One section by name, trimmed, so that a template owns the whitespace between them. + fn named(&self, name: &str) -> Option { + Some(match name { + "about" => self.about.trim().to_string(), + "usage" => self.usage.trim().to_string(), + "commands" => { + let mut out = self.commands.trim().to_string(); + let flattened = self.flattened.trim(); + if !flattened.is_empty() { + if !out.is_empty() { + out.push_str("\n\n"); + } + out.push_str(flattened); + } + out + } + "args" => self.args.trim().to_string(), + "flags" => self.flags.trim().to_string(), + "after_help" => self.after_help.trim().to_string(), + _ => return None, + }) + } } /// The entries for `--help` and `--version`, which the parser supplies and no spec declares. @@ -727,6 +836,99 @@ arg "[input]" help="Input" env="INPUT" default="file" hide_default_value=#true h assert!(reparsed.cmd.flags[0].hide_possible_values); } + #[test] + fn a_help_template_reorders_omits_and_wraps_the_sections() { + // The whole of what a template can do: `{{flags}}` before `{{args}}`, no + // `{{commands}}` at all, and text of the author's own around them. Nothing else is + // substituted, so the layout is the spec's and the sections' contents are not. + let spec = crate::spec! { r#" +bin "ex" +about "An example" +help_template "{{about}}\n\n{{usage}}\n\n{{flags}}\n\n{{args}}\n\n-- ask a person --" +flag "--force" help="Do it anyway" +arg "" help="Which file" +cmd "run" help="Run it" + "# } + .unwrap(); + + assert_snapshot!(render_help(&spec, &spec.cmd, false), @" + An example + + Usage: ex [--force] + + Flags: + --force Do it anyway + -h, --help Print help + + Arguments: + Which file + + -- ask a person -- + "); + } + + #[test] + fn a_template_places_the_sections_a_page_actually_has() { + // A template names every section, and this command has no arguments — the gap + // `{{args}}` would leave closes up rather than pushing the commands down the page. + // What lets one template serve a whole CLI, since most commands are missing most + // sections. Here the version banner and description are last, and `after_help` + // carries them nothing. + let spec = crate::spec! { r#" +bin "ex" +version "1.2.3" +about "An example" +after_help "Read the docs." +help_template "{{usage}}\n\n{{flags}}\n\n{{args}}\n\n{{commands}}\n\n{{after_help}}\n\n{{about}}" +flag "--force" help="Do it anyway" +cmd "run" help="Run it" + "# } + .unwrap(); + + assert_snapshot!(render_help(&spec, &spec.cmd, true), @" + Usage: ex [--force] + + Flags: + --force Do it anyway + -h, --help Print help + -V, --version Print version + + Commands: + run + Run it + + help + Print this message or the help of the given subcommand(s) + + Read the docs. + + ex 1.2.3 + An example + "); + } + + #[test] + fn a_flattened_page_puts_its_bodies_where_the_commands_would_go() { + // `flatten_help` replaces a command list with the subcommands' own bodies, so a + // template that places `{{commands}}` places whichever of the two this command has. + let spec = crate::spec! { r#" +bin "ex" +flatten_help #true +help_template "{{usage}}\n\n{{commands}}\n\n{{flags}}" +cmd "run" help="Run it" { + flag "--dry-run" help="Only show changes" +} + "# } + .unwrap(); + + let page = render_help(&spec, &spec.cmd, false); + assert!( + page.find("run:").unwrap() < page.find("Flags:").unwrap(), + "{page}" + ); + assert!(page.contains("--dry-run"), "{page}"); + } + #[test] fn test_render_help_with_before_after_help() { let spec = crate::spec! { r#" diff --git a/lib/src/docs/cli/templates/spec_template_long.tera b/lib/src/docs/cli/templates/spec_template_long.tera index 5de080fe3..62e1de941 100644 --- a/lib/src/docs/cli/templates/spec_template_long.tera +++ b/lib/src/docs/cli/templates/spec_template_long.tera @@ -26,14 +26,14 @@ {%- if cmd.deprecated or cmd.deprecated_warn_at or cmd.deprecated_remove_at %} [deprecated:{% if cmd.deprecated %} {{ cmd.deprecated }}{% endif %}{% if cmd.deprecated_warn_at %}{% if cmd.deprecated %};{% endif %} warns at {{ cmd.deprecated_warn_at }}{% endif %}{% if cmd.deprecated_remove_at %}{% if cmd.deprecated or cmd.deprecated_warn_at %};{% endif %} removed at {{ cmd.deprecated_remove_at }}{% endif %}] -{%- endif -%} +{%- endif -%}{{ mark_usage }} {%- if cmd.flatten_help and cmd.flattened_usage %} {%- for usage in cmd.flattened_usage %} {% if loop.first %}Usage: {% else %} {% endif %}{{ (spec.bin ~ " " ~ usage) | trim }} {%- endfor %} {%- else -%} Usage: {{ (spec.bin ~ " " ~ cmd.usage) | trim }} -{%- endif %} +{%- endif %}{{ mark_commands }} {%- if cmd.subcommands and not cmd.flatten_help %} {%- for group in cmd.subcommand_groups %} @@ -53,7 +53,7 @@ Usage: {{ (spec.bin ~ " " ~ cmd.usage) | trim }} Print this message or the help of the given subcommand(s) {%- endif %} {%- endfor %} -{%- endif %} +{%- endif %}{{ mark_args }} {%- for group in cmd.arg_groups %} @@ -95,7 +95,7 @@ Usage: {{ (spec.bin ~ " " ~ cmd.usage) | trim }} (default: {{ arg.default | join(sep=", ") }}) {%- endif %} {%- endfor %} -{%- endfor %} +{%- endfor %}{{ mark_flags }} {%- for group in cmd.flag_groups %} @@ -189,7 +189,7 @@ Global flags: [deprecated:{% if flag.deprecated %} {{ flag.deprecated }}{% endif %}{% if flag.deprecated_warn_at %}{% if flag.deprecated %};{% endif %} warns at {{ flag.deprecated_warn_at }}{% endif %}{% if flag.deprecated_remove_at %}{% if flag.deprecated or flag.deprecated_warn_at %};{% endif %} removed at {{ flag.deprecated_remove_at }}{% endif %}] {%- endif %} {%- endfor %} -{%- endif %} +{%- endif %}{{ mark_flattened }} {%- if cmd.flatten_help %} {%- for sub in cmd.flattened_subcommands %} @@ -264,7 +264,7 @@ Global flags: {%- endfor %} {%- endfor %} {%- endfor %} -{%- endif %} +{%- endif %}{{ mark_after_help }} {%- if cmd.examples %} diff --git a/lib/src/docs/cli/templates/spec_template_short.tera b/lib/src/docs/cli/templates/spec_template_short.tera index 996203b94..45e924153 100644 --- a/lib/src/docs/cli/templates/spec_template_short.tera +++ b/lib/src/docs/cli/templates/spec_template_short.tera @@ -18,14 +18,14 @@ {%- if cmd.deprecated or cmd.deprecated_warn_at or cmd.deprecated_remove_at %} [deprecated:{% if cmd.deprecated %} {{ cmd.deprecated }}{% endif %}{% if cmd.deprecated_warn_at %}{% if cmd.deprecated %};{% endif %} warns at {{ cmd.deprecated_warn_at }}{% endif %}{% if cmd.deprecated_remove_at %}{% if cmd.deprecated or cmd.deprecated_warn_at %};{% endif %} removed at {{ cmd.deprecated_remove_at }}{% endif %}] -{%- endif -%} +{%- endif -%}{{ mark_usage }} {%- if cmd.flatten_help and cmd.flattened_usage %} {%- for usage in cmd.flattened_usage %} {% if loop.first %}Usage: {% else %} {% endif %}{{ (spec.bin ~ " " ~ usage) | trim }} {%- endfor %} {%- else -%} Usage: {{ (spec.bin ~ " " ~ cmd.usage) | trim }} -{%- endif %} +{%- endif %}{{ mark_commands }} {%- if cmd.subcommands and not cmd.flatten_help %} {%- set next_line_help = cmd.next_line_help %} @@ -44,7 +44,7 @@ Usage: {{ (spec.bin ~ " " ~ cmd.usage) | trim }} Print this message or the help of the given subcommand(s){% else %} help Print this message or the help of the given subcommand(s){% endif %} {%- endif %} {%- endfor %} -{%- endif %} +{%- endif %}{{ mark_args }} {%- for group in cmd.arg_groups %} @@ -72,7 +72,7 @@ Usage: {{ (spec.bin ~ " " ~ cmd.usage) | trim }} {%- if not arg.hide_default_value and arg.default %} (default: {{ arg.default | join(sep=", ") }}){%- endif %} {%- endif %} {%- endfor %} -{%- endfor %} +{%- endfor %}{{ mark_flags }} {%- for group in cmd.flag_groups %} @@ -132,7 +132,7 @@ Global flags: {%- if flag.deprecated or flag.deprecated_warn_at or flag.deprecated_remove_at %}{% if cmd.next_line_help %} {% else %} {% endif %}[deprecated:{% if flag.deprecated %} {{ flag.deprecated }}{% endif %}{% if flag.deprecated_warn_at %}{% if flag.deprecated %};{% endif %} warns at {{ flag.deprecated_warn_at }}{% endif %}{% if flag.deprecated_remove_at %}{% if flag.deprecated or flag.deprecated_warn_at %};{% endif %} removed at {{ flag.deprecated_remove_at }}{% endif %}]{%- endif %} {%- endfor %} -{%- endif %} +{%- endif %}{{ mark_flattened }} {%- if cmd.flatten_help %} {%- for sub in cmd.flattened_subcommands %} @@ -197,7 +197,7 @@ Global flags: {%- endfor %} {%- endfor %} {%- endfor %} -{%- endif %} +{%- endif %}{{ mark_after_help }} {%- if cmd.examples %} diff --git a/lib/src/go/mod.rs b/lib/src/go/mod.rs index d83569a8f..0654b447e 100644 --- a/lib/src/go/mod.rs +++ b/lib/src/go/mod.rs @@ -1021,6 +1021,10 @@ impl Emitter<'_> { if let Some(after) = &self.spec.after_help_long { fields.push(format!("AfterLongHelp: {}", go_string(after))); } + // One template for the whole tree, naming the sections a page is assembled from. + if let Some(template) = &self.spec.help_template { + fields.push(format!("HelpTemplate: {}", go_string(template))); + } let _ = writeln!( self.out, "// HelpMeta is what a page needs from the spec's root rather than from any one\n\ diff --git a/lib/src/help_template.rs b/lib/src/help_template.rs new file mode 100644 index 000000000..fa4953610 --- /dev/null +++ b/lib/src/help_template.rs @@ -0,0 +1,177 @@ +//! The named sections a `help_template` may place, and how one is filled in. +//! +//! A spec can say what order its help sections come in — `help_template "{{about}}{{usage}}…"` +//! — and nothing more than that. The template holds a closed vocabulary of *pre-rendered* +//! sections rather than the metadata behind them, which is what lets an interpreter, a compiled +//! parser and a generated Go program agree: they agree on where each section starts and ends, +//! not on a template language's semantics. +//! +//! The twins of this module are `usage_argv::help`'s `SECTIONS` and `Sections`, and Go's +//! `helpSections`. `conformance/tests/render.rs` is what says the three still agree. + +/// The sections a template may name, and nothing else. +/// +/// | section | content | +/// | ------------ | ------------------------------------------------------------------- | +/// | `about` | `before_help`, the version banner, and the description | +/// | `usage` | the `Usage:` synopsis, however many lines it takes | +/// | `commands` | the subcommand list, or the flattened bodies under `flatten_help` | +/// | `args` | every argument group, each under its heading | +/// | `flags` | this command's flag groups, then the globals it inherits | +/// | `after_help` | examples, `after_help`, and the author/license footer on a long page | +pub const SECTIONS: [&str; 6] = ["about", "usage", "commands", "args", "flags", "after_help"]; + +/// Whether every `{{…}}` in a template names a section. +/// +/// The check a template is held to when a spec is read, so nothing renders a page with a +/// section it cannot fill. The message names the vocabulary, and names the two clap +/// placeholders whose spellings differ, because a template being ported is where this is most +/// likely to be read. +pub fn check(template: &str) -> Result<(), String> { + let mut rest = template; + while let Some(at) = rest.find("{{") { + let after = &rest[at + 2..]; + let Some(end) = after.find("}}") else { + return Err(format!( + "help_template has a `{{{{` with no `}}}}` after it; the sections are {}", + SECTIONS.join(", ") + )); + }; + let name = after[..end].trim(); + if !SECTIONS.contains(&name) { + return Err(format!( + "help_template names no section \"{name}\"; a page is assembled from {} — \ + reorder, omit or wrap those, and note that clap's `{{options}}` is \ + `{{{{flags}}}}` here and its `{{positionals}}` is `{{{{args}}}}`", + SECTIONS.join(", ") + )); + } + rest = &after[end + 2..]; + } + Ok(()) +} + +/// Fill a template in, asking `section` for each name it holds. +/// +/// A placeholder naming no section is left exactly as it was written: the vocabulary is checked +/// where a spec is read, so one arriving here is text an author meant literally. +/// +/// Every section a template names is optional in practice — most commands have no arguments, +/// most have no examples — so a template is written with the separators a full page wants and +/// the empty sections are what [`collapse_blank_runs`] then takes back out. +pub fn substitute(template: &str, section: impl Fn(&str) -> Option) -> String { + let mut out = String::with_capacity(template.len()); + let mut rest = template; + while let Some(at) = rest.find("{{") { + out.push_str(&rest[..at]); + let after = &rest[at + 2..]; + let Some(end) = after.find("}}") else { + out.push_str(&rest[at..]); + return collapse_blank_runs(&out); + }; + match section(after[..end].trim()) { + Some(text) => out.push_str(&text), + None => out.push_str(&rest[at..at + 2 + end + 2]), + } + rest = &after[end + 2..]; + } + out.push_str(rest); + collapse_blank_runs(&out) +} + +/// A page's runs of blank lines, each reduced to a single blank line. +/// +/// What makes a section optional. `"{{flags}}\n\n{{args}}\n\n{{commands}}"` is written for a +/// command that has all three, and a command with no arguments would otherwise render the two +/// separators back to back and push its commands down the page. Collapsing means a template +/// describes an order rather than a page, so one template can serve a whole CLI. +/// +/// The cost is that a template cannot open a gap wider than one blank line, which is a +/// deliberate trade: an author who wants a run of them is asking for something no help page +/// wants, and the alternative is that every optional section needs its own template. +/// +/// The rule applies to a template's output and nothing else, so a page assembled in the default +/// order is untouched by it. +/// A line with only spaces on it counts as blank, since that is what an empty placeholder on an +/// indented line leaves behind. Leading and trailing blank lines go entirely; the caller puts back +/// the single newline a page ends with. +fn collapse_blank_runs(page: &str) -> String { + let mut out = String::with_capacity(page.len()); + let mut blank = false; + for line in page.split('\n') { + if line.trim().is_empty() { + blank = !out.is_empty(); + continue; + } + if !out.is_empty() { + out.push('\n'); + if blank { + out.push('\n'); + } + } + blank = false; + out.push_str(line); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_placeholder_naming_no_section_is_refused_by_name() { + let err = check("{{about}}{{options}}").expect_err("no section is called options"); + assert!(err.contains("\"options\""), "{err}"); + // And says what to write instead, since this is what a ported clap template hits. + assert!(err.contains("`{{flags}}`"), "{err}"); + assert!(check("{{ about }} {{usage}}").is_ok()); + assert!(check("no placeholders at all").is_ok()); + assert!(check("{{usage").is_err()); + } + + #[test] + fn substitution_takes_only_the_names_it_is_given() { + let filled = substitute("[{{usage}}]{{ nope }}", |name| { + (name == "usage").then(|| "Usage: ex".to_string()) + }); + assert_eq!(filled, "[Usage: ex]{{ nope }}"); + } + + #[test] + fn a_section_that_came_out_empty_leaves_no_gap_behind() { + // One template, two commands: the separators a full page wants do not become blank + // lines on the page that has no arguments. + let template = "{{usage}}\n\n{{args}}\n\n{{flags}}"; + let full = substitute(template, |name| { + Some(match name { + "usage" => "Usage: ex".to_string(), + "args" => "Arguments:\n ".to_string(), + _ => "Flags:\n --force".to_string(), + }) + }); + assert_eq!( + full, + "Usage: ex\n\nArguments:\n \n\nFlags:\n --force" + ); + + let no_args = substitute(template, |name| { + Some(match name { + "usage" => "Usage: ex".to_string(), + "args" => String::new(), + _ => "Flags:\n --force".to_string(), + }) + }); + assert_eq!(no_args, "Usage: ex\n\nFlags:\n --force"); + } + + #[test] + fn a_sections_own_indentation_survives_the_collapsing() { + // The rule is about blank lines between sections, so the two spaces a flag's row is + // indented by are not whitespace it may take. + let page = substitute(" {{flags}}", |_| { + Some("Flags:\n --force Do it anyway".to_string()) + }); + assert_eq!(page, " Flags:\n --force Do it anyway"); + } +} diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 5af2569f0..9df9b437f 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -30,6 +30,7 @@ pub use error::Result; #[cfg(feature = "docs")] pub mod docs; pub mod go; +pub mod help_template; pub mod parse; pub mod sdk; pub mod sh; diff --git a/lib/src/spec/mod.rs b/lib/src/spec/mod.rs index 3d00eb93a..91394fc78 100644 --- a/lib/src/spec/mod.rs +++ b/lib/src/spec/mod.rs @@ -99,6 +99,18 @@ pub struct Spec { pub before_help_long: Option, #[serde(skip_serializing_if = "Option::is_none")] pub after_help_long: Option, + /// How every page in this CLI is laid out, as named sections. + /// + /// One template for the whole tree, holding the six pre-rendered sections — `{{about}}`, + /// `{{usage}}`, `{{commands}}`, `{{args}}`, `{{flags}}`, `{{after_help}}` — which an author + /// may reorder, omit or wrap in text of their own. Nothing else is substituted: a closed + /// vocabulary is what lets an interpreter, a compiled parser and a generated Go program + /// agree on where a section starts and ends rather than on a template language's semantics. + /// + /// A placeholder naming no section is refused when the spec is read, so a page is never + /// rendered from a template one of whose sections cannot be filled. + #[serde(skip_serializing_if = "Option::is_none")] + pub help_template: Option, #[serde(skip_serializing_if = "Option::is_none")] pub disable_help: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -463,6 +475,16 @@ impl Spec { schema.after_help_long = Some(node.arg(0)?.ensure_string()?) } "usage" => schema.usage = node.arg(0)?.ensure_string()?, + // Refused here rather than at render time, and for the reason every other + // unsupported word is: a page laid out by a template is read by people, and a + // placeholder naming no section would reach them as the braces somebody typed. + "help_template" => { + let template = node.arg(0)?.ensure_string()?; + if let Err(problem) = crate::help_template::check(&template) { + bail_parse!(ctx, node.span(), "{problem}"); + } + schema.help_template = Some(template); + } "arg" => { let arg = SpecArg::parse(ctx, &node)?; // The same rule the `cmd` block applies: a delimiter with nowhere to @@ -727,6 +749,7 @@ impl Spec { merge_opt!(after_help); merge_opt!(before_help_long); merge_opt!(after_help_long); + merge_opt!(help_template); merge_opt!(disable_help); merge_opt!(min_usage_version); merge_opt!(default_subcommand); @@ -954,6 +977,11 @@ impl Display for Spec { node.push(string_entry(None, after_help_long)); nodes.push(node); } + if let Some(help_template) = &self.help_template { + let mut node = KdlNode::new("help_template"); + node.push(string_entry(None, help_template)); + nodes.push(node); + } if let Some(disable_help) = self.disable_help { let mut node = KdlNode::new("disable_help"); node.push(KdlEntry::new(disable_help)); From 1720106298aa6a3096f3f1c3e38ff897289953fb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 00:57:35 +0000 Subject: [PATCH 04/10] fix(derive): report ArgGroup displace by recognition Return true once a selector names a group member, even when that member was not given, matching every other displace path so parents do not treat an absent member as an unresolved override. Co-authored-by: jdx --- derive/src/codegen.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index f2213c761..35db39592 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -8732,14 +8732,15 @@ pub fn emit_arg_group(group: &ArgGroup) -> TokenStream { let given = format_ident!("given_{i}"); let cfg = &member.cfg_attrs; let selectors = member_selectors(member); + // Recognition, not mutation: every other `displace` returns true once the + // selector is known to this type, so a parent that short-circuits on the + // first true does not fall through to "unresolved" when the member was + // simply not given. Clearing is what happens when it *was* given. quote! { #(#cfg)* #(#selectors)|* => { - if partial.#given { - partial.#given = false; - return true; - } - return false; + partial.#given = false; + return true; } } }); From 2ece44d8fdfcb4c8331bd264ba210fa8d2b9043e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 00:57:35 +0000 Subject: [PATCH 05/10] fix(lib): ungated words helper for parse tests Keep the shared argv helper available without unstable_choices_env, and gate the choices_env-specific tests on that feature instead. Co-authored-by: jdx --- lib/src/parse.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/parse.rs b/lib/src/parse.rs index 0d0c43e3c..1116e9f7a 100644 --- a/lib/src/parse.rs +++ b/lib/src/parse.rs @@ -8417,7 +8417,6 @@ cmd "run" { ); } - #[cfg(feature = "unstable_choices_env")] /// argv as `parse` wants it, program name included. fn words(of: &[&str]) -> Vec { of.iter().map(|s| s.to_string()).collect() @@ -8640,6 +8639,7 @@ cmd "ls" assert!(parse(&closed, &input(&["wat"])).is_err()); } + #[cfg(feature = "unstable_choices_env")] #[test] fn test_parser_arg_choices_from_custom_env() { let spec = spec_arg_choices_env("DEPLOY_ENVS"); From c3a261eb61074d8c37671793b45b9d3a716dffb4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 01:04:05 +0000 Subject: [PATCH 06/10] feat(derive): add update_from and try_update_from Merge argv into an existing value with standing-aware relationships, non-clobbering env/defaults, collection replace-on-mention, and wholesale subcommand variant replacement. Co-authored-by: jdx --- argv/src/spec.rs | 104 +++- conformance/tests/update_from.rs | 534 +++++++++++++++++++ derive/src/codegen.rs | 847 ++++++++++++++++++++++++++++--- docs/rust/clap-compatibility.md | 24 +- docs/rust/index.md | 41 ++ docs/rust/migrating-from-clap.md | 6 + 6 files changed, 1482 insertions(+), 74 deletions(-) create mode 100644 conformance/tests/update_from.rs diff --git a/argv/src/spec.rs b/argv/src/spec.rs index a2bdf7404..1cfd1a36c 100644 --- a/argv/src/spec.rs +++ b/argv/src/spec.rs @@ -1472,7 +1472,7 @@ impl Spec<'_> { // block, so a root mount is not expressible. Emitting it anyway would // produce a document that does not parse, and dropping it quietly is the // lossiness this module claims not to have — so it fails loudly in debug - // builds instead, and PLAN.md carries it as a possible spec extension. + // builds instead; a root-level mount remains a possible spec extension. debug_assert!( self.root.mount.is_none(), "a mount on the root command cannot be written: the spec accepts \ @@ -3293,6 +3293,72 @@ pub trait CommandArgs: Sized { /// Fallible because a command can require a subcommand of its own, and "none was /// given" is only knowable here — at the point where the value has to exist. fn build<'t, 'v>(partial: Self::Partial) -> Result>; + + /// One declaration in this command whose value the caller already had. + /// + /// The standing counterpart of [`CommandArgs::any_given`], and the reason it is + /// asked of the built struct rather than of a partial: an update merges a parse + /// into a value the caller owns, and a value cannot be run backwards through + /// `FromStr` into the bytes a partial holds. So presence is what the type itself + /// can answer — a filled `Option`, a collection with items, a set switch — and a + /// plain value is always present. + /// + /// `None` by default, which is what a hand-written implementation that does not + /// take part in updates should say. + fn any_standing(standing: &Self) -> Option<&'static str> { + let _ = standing; + None + } + + /// [`CommandArgs::apply_defaults`], filling only what the caller does not already have. + /// + /// A default never overwrites a value the caller set deliberately, so an update + /// with no relevant argv cannot change the struct. + fn apply_defaults_update(partial: &mut Self::Partial, standing: &Self) { + let _ = standing; + Self::apply_defaults(partial); + } + + /// [`CommandArgs::apply_env`], filling only what the caller does not already have. + fn apply_env_update(partial: &mut Self::Partial, standing: &Self) { + let _ = standing; + Self::apply_env(partial); + } + + /// [`CommandArgs::check`] against the union of this argv and what the caller had. + /// + /// Required-ness, conflicts and the rest see a field the caller already filled as + /// present, so an update validates both inputs together rather than this argv alone. + fn check_update<'t, 'v>( + partial: &mut Self::Partial, + standing: &Self, + ) -> Result<(), crate::Error<'t, 'v>> { + let _ = standing; + Self::check(partial) + } + + /// [`CommandArgs::check_with_args_override_self`] against the same union. + fn check_update_with_args_override_self<'t, 'v>( + partial: &mut Self::Partial, + args_override_self: bool, + standing: &Self, + ) -> Result<(), crate::Error<'t, 'v>> { + let _ = standing; + Self::check_with_args_override_self(partial, args_override_self) + } + + /// Overwrite the fields this argv gave, and leave the rest of `standing` alone. + /// + /// The default replaces the whole value, which is all a hand-written implementation + /// that cannot see its own fields can promise. A derived one merges field by field: + /// a field this command line said nothing about keeps the value it had. + fn merge<'t, 'v>( + partial: Self::Partial, + standing: &mut Self, + ) -> Result<(), crate::Error<'t, 'v>> { + *standing = Self::build(partial)?; + Ok(()) + } } /// Supplies a typed placeholder for fields outside an executable view. @@ -3567,6 +3633,42 @@ pub trait Subcommands: Sized { partial: Self::Partial, selected: usize, ) -> Result, crate::Error<'t, 'v>>; + + /// [`Subcommands::apply_env`] on an update, filling only what the caller lacks. + fn apply_env_update(partial: &mut Self::Partial, selected: Option, standing: &Self) { + let _ = standing; + Self::apply_env(partial, selected); + } + + /// [`Subcommands::check`] against the union of this argv and what the caller had. + /// + /// Only when the selected variant is the one `standing` already holds. Selecting a + /// different command is a routing decision rather than a value to merge, so the old + /// variant's fields say nothing about the new one's requirements. + fn check_update<'t, 'v>( + partial: &mut Self::Partial, + selected: usize, + standing: &Self, + ) -> Result<(), crate::Error<'t, 'v>> { + let _ = standing; + Self::check(partial, selected) + } + + /// Merge the selected variant into the one the caller already has. + /// + /// The same variant merges field-wise; a different one replaces it wholesale, + /// discarding the old variant's fields. The default always replaces, which is what + /// a hand-written implementation can promise without seeing its own variants. + fn merge_into<'t, 'v>( + partial: Self::Partial, + selected: usize, + standing: &mut Self, + ) -> Result<(), crate::Error<'t, 'v>> { + if let Some(built) = Self::select(partial, selected)? { + *standing = built; + } + Ok(()) + } } /// View-aware construction for a derived subcommand enum. diff --git a/conformance/tests/update_from.rs b/conformance/tests/update_from.rs new file mode 100644 index 000000000..8ae2dca53 --- /dev/null +++ b/conformance/tests/update_from.rs @@ -0,0 +1,534 @@ +//! Merging a command line into a value that already exists. +//! +//! `update_from` is clap's name for parsing twice: a REPL reading a line at a time, a daemon +//! reconfigured while it runs. The rules cannot be inherited from a fresh parse, because a +//! parse cannot be run backwards — a `String` field says nothing about the word it was made +//! from — so what the caller already holds is read from the struct itself. These tests state +//! each rule once: relationships see the standing value, the environment and declared defaults +//! do not overwrite it, a collection is replaced only when this argv mentions it, and a +//! subcommand word naming a different variant replaces it rather than merging into fields the +//! new command does not have. + +use std::ffi::OsStr; + +use usage_argv::Error; +use usage_derive::{ArgGroup, Args, Cli, Subcommands}; + +fn argv(tokens: [&str; N]) -> [&OsStr; N] { + tokens.map(OsStr::new) +} + +/// A CLI whose `--file` is required and whose `--quiet` excludes `--verbose`. +#[derive(Cli, Debug, PartialEq)] +#[usage(bin = "upd")] +struct Upd { + /// The file to work on + #[usage(long)] + file: String, + /// Say less + #[usage(long, conflicts = "verbose")] + quiet: bool, + /// Say more + #[usage(long)] + verbose: bool, + /// How many times to try + #[usage(long)] + retries: Option, +} + +#[test] +fn a_standing_required_flag_need_not_be_given_again() { + let a = argv(["--file", "a.txt"]); + let mut upd = Upd::parse_from(&a).expect("the first parse supplies it"); + + // The second command line says nothing about `--file`, which a fresh parse would refuse. + let a = argv(["--retries", "3"]); + upd.try_update_from(&a).expect("--file already stands"); + + assert_eq!(upd.file, "a.txt"); + assert_eq!(upd.retries, Some(3)); +} + +/// A CLI whose required declaration is a collection, which can be empty. +#[derive(Cli, Debug, PartialEq)] +#[usage(bin = "reqvec")] +struct ReqVec { + /// Files to read + #[usage(long, required)] + include: Vec, + /// Something to change, so the update has a reason to run + #[usage(long)] + tag: Option, +} + +#[test] +fn a_required_collection_neither_side_filled_is_still_missing() { + // An update does not weaken a requirement, it widens what can satisfy one: a collection + // that is empty on both sides is a value nobody supplied. + let mut req = ReqVec { + include: Vec::new(), + tag: None, + }; + let a = argv(["--tag", "second"]); + assert!(matches!( + req.try_update_from(&a), + Err(Error::MissingRequired { name: "include" }) + )); + + let a = argv(["--include", "a"]); + req.try_update_from(&a).expect("now it stands"); + let a = argv(["--tag", "second"]); + req.try_update_from(&a).expect("and keeps standing"); + assert_eq!(req.include, ["a"]); +} + +#[test] +fn a_standing_flag_conflicts_with_a_new_one() { + let a = argv(["--file", "a.txt", "--quiet"]); + let mut upd = Upd::parse_from(&a).expect("valid on its own"); + + let a = argv(["--verbose"]); + assert!( + matches!( + upd.try_update_from(&a), + Err(Error::ConflictingFlags { + name: "quiet", + other: "verbose", + }) + ), + "the conflict is between what stands and what arrived", + ); +} + +#[test] +fn a_failed_update_changes_nothing() { + let a = argv(["--file", "a.txt", "--quiet"]); + let mut upd = Upd::parse_from(&a).expect("valid"); + + let a = argv(["--file", "b.txt", "--verbose"]); + upd.try_update_from(&a).expect_err("conflicts with --quiet"); + + assert_eq!(upd.file, "a.txt", "the rejected --file was not merged"); + assert!(!upd.verbose); +} + +/// A CLI whose `--out` reads the environment and whose `--level` has a declared default. +/// +/// The two environment tests below use distinct variable names so parallel cargo +/// threads cannot race on one shared process env. +#[derive(Cli, Debug, PartialEq)] +#[usage(bin = "envupd")] +struct EnvUpd { + /// Where to write + #[usage(long, env = "UPDATE_FROM_OUT")] + out: Option, + /// How loud to be + #[usage(long, default = "info")] + level: String, + /// Something to change, so the update has a reason to run + #[usage(long)] + tag: Option, +} + +#[derive(Cli, Debug, PartialEq)] +#[usage(bin = "envfill")] +struct EnvFill { + /// Where to write + #[usage(long, env = "UPDATE_FROM_FILL")] + out: Option, + /// Something to change, so the update has a reason to run + #[usage(long)] + tag: Option, +} + +#[test] +fn the_environment_does_not_overwrite_a_standing_value() { + unsafe { std::env::remove_var("UPDATE_FROM_OUT") }; + unsafe { std::env::set_var("UPDATE_FROM_OUT", "from-env") }; + let a = argv(["--out", "from-argv"]); + let mut env_upd = EnvUpd::parse_from(&a).expect("argv wins over the environment"); + assert_eq!(env_upd.out.as_deref(), Some("from-argv")); + + let a = argv(["--tag", "second"]); + env_upd.try_update_from(&a).expect("valid"); + assert_eq!( + env_upd.out.as_deref(), + Some("from-argv"), + "the variable fills an empty field, not a full one", + ); + assert_eq!(env_upd.tag.as_deref(), Some("second")); + unsafe { std::env::remove_var("UPDATE_FROM_OUT") }; +} + +#[test] +fn the_environment_still_fills_a_field_nothing_has_supplied() { + unsafe { std::env::remove_var("UPDATE_FROM_FILL") }; + let a = argv(["--tag", "first"]); + let mut env_upd = EnvFill::parse_from(&a).expect("valid"); + assert_eq!(env_upd.out, None); + + unsafe { std::env::set_var("UPDATE_FROM_FILL", "from-env") }; + let a = argv(["--tag", "second"]); + env_upd.try_update_from(&a).expect("valid"); + assert_eq!(env_upd.out.as_deref(), Some("from-env")); + unsafe { std::env::remove_var("UPDATE_FROM_FILL") }; +} + +#[test] +fn a_declared_default_does_not_overwrite_a_standing_value() { + let a = argv(["--level", "debug"]); + let mut env_upd = EnvUpd::parse_from(&a).expect("valid"); + + let a = argv(["--tag", "second"]); + env_upd.try_update_from(&a).expect("valid"); + assert_eq!( + env_upd.level, "debug", + "`info` is what an empty field falls back to, not what an update imposes", + ); +} + +/// A CLI with two collections, so one can be mentioned while the other is not. +#[derive(Cli, Debug, PartialEq)] +#[usage(bin = "vecupd")] +struct VecUpd { + /// Files to read + #[usage(long)] + include: Vec, + /// Files to skip + #[usage(long)] + exclude: Vec, +} + +#[test] +fn a_collection_this_argv_mentions_is_replaced_whole() { + let a = argv(["--include", "a", "--include", "b", "--exclude", "x"]); + let mut vec_upd = VecUpd::parse_from(&a).expect("valid"); + + let a = argv(["--include", "c"]); + vec_upd.try_update_from(&a).expect("valid"); + + assert_eq!(vec_upd.include, ["c"], "replaced rather than appended to"); + assert_eq!(vec_upd.exclude, ["x"], "not mentioned, so not touched"); +} + +#[test] +fn a_collection_no_argv_mentions_survives_every_update() { + let a = argv(["--include", "a"]); + let mut vec_upd = VecUpd::parse_from(&a).expect("valid"); + let a = argv([]); + vec_upd.try_update_from(&a).expect("valid"); + assert_eq!(vec_upd.include, ["a"]); +} + +/// A CLI with subcommands whose variants carry different fields. +#[derive(Cli, Debug, PartialEq)] +#[usage(bin = "subupd")] +struct SubUpd { + /// Say more + #[usage(long, global)] + verbose: bool, + #[usage(subcommand)] + command: Cmd, +} + +#[derive(Subcommands, Debug, PartialEq)] +enum Cmd { + /// Add something + Add(AddArgs), + /// Remove something + Remove(RemoveArgs), +} + +#[derive(Args, Debug, PartialEq)] +struct AddArgs { + /// What to add + name: String, + /// Add it even if it is there + #[usage(long)] + force: bool, + /// Where to put it + #[usage(long)] + dest: Option, +} + +#[derive(Args, Debug, PartialEq)] +struct RemoveArgs { + /// What to remove + name: String, +} + +#[test] +fn the_same_subcommand_merges_field_by_field() { + let a = argv(["add", "thing", "--force"]); + let mut sub_upd = SubUpd::parse_from(&a).expect("valid"); + + // `name` is required and not repeated: the standing value answers for it, and `--force` + // survives a command line that says nothing about it. + let a = argv(["add", "--dest", "/tmp"]); + sub_upd.try_update_from(&a).expect("the same variant"); + + let Cmd::Add(add) = &sub_upd.command else { + panic!("still `add`"); + }; + assert_eq!(add.name, "thing"); + assert!(add.force); + assert_eq!(add.dest.as_deref(), Some("/tmp")); +} + +#[test] +fn a_different_subcommand_replaces_the_variant_whole() { + let a = argv(["add", "thing", "--force"]); + let mut sub_upd = SubUpd::parse_from(&a).expect("valid"); + + let a = argv(["remove", "other"]); + sub_upd.try_update_from(&a).expect("a different variant"); + + assert_eq!( + sub_upd.command, + Cmd::Remove(RemoveArgs { + name: "other".into(), + }), + "`--force` belonged to `add` and went with it", + ); +} + +#[test] +fn a_root_flag_survives_a_subcommand_switch() { + let a = argv(["--verbose", "add", "thing"]); + let mut sub_upd = SubUpd::parse_from(&a).expect("valid"); + + let a = argv(["remove", "other"]); + sub_upd.try_update_from(&a).expect("valid"); + + assert!(sub_upd.verbose, "the root's own fields merge as ever"); +} + +#[test] +fn a_subcommand_word_is_needed_for_the_variant_to_change() { + let a = argv(["add", "thing"]); + let mut sub_upd = SubUpd::parse_from(&a).expect("valid"); + + // A required subcommand that no update repeats: the standing one answers for it. + let a = argv(["--verbose"]); + sub_upd + .try_update_from(&a) + .expect("a command already stands"); + assert!(sub_upd.verbose); + let Cmd::Add(add) = &sub_upd.command else { + panic!("unchanged"); + }; + assert_eq!(add.name, "thing"); +} + +/// How to print the result. +#[derive(ArgGroup, Debug, PartialEq)] +enum Format { + /// Print JSON + Json, + /// Print YAML + Yaml, +} + +/// A CLI with a flattened group and an `ArgGroup`. +#[derive(Cli, Debug, PartialEq)] +#[usage(bin = "flatupd")] +struct FlatUpd { + #[usage(flatten)] + common: Common, + #[usage(arg_group)] + format: Option, +} + +#[derive(Args, Debug, PartialEq)] +struct Common { + /// The file to work on + #[usage(long)] + file: String, + /// How many times to try + #[usage(long)] + retries: Option, +} + +#[test] +fn a_flattened_group_merges_the_way_the_root_does() { + let a = argv(["--file", "a.txt", "--json"]); + let mut flat = FlatUpd::parse_from(&a).expect("valid"); + + // `--file` is required and lives in the flattened struct, so the recursion is what makes + // this pass at all. + let a = argv(["--retries", "2"]); + flat.try_update_from(&a).expect("--file already stands"); + + assert_eq!(flat.common.file, "a.txt"); + assert_eq!(flat.common.retries, Some(2)); + assert_eq!( + flat.format, + Some(Format::Json), + "not mentioned, not touched" + ); +} + +#[test] +fn a_group_member_this_argv_gives_selects_its_variant() { + let a = argv(["--file", "a.txt", "--json"]); + let mut flat = FlatUpd::parse_from(&a).expect("valid"); + + let a = argv(["--yaml"]); + flat.try_update_from(&a).expect("one member"); + assert_eq!(flat.format, Some(Format::Yaml)); +} + +#[test] +fn two_group_members_in_one_update_still_conflict() { + let a = argv(["--file", "a.txt"]); + let mut flat = FlatUpd::parse_from(&a).expect("valid"); + + let a = argv(["--json", "--yaml"]); + assert!( + matches!( + flat.try_update_from(&a), + Err(Error::ConflictingFlags { + name: "yaml", + other: "json", + }) + ), + "a group admits one member per command line, update or not", + ); +} + +#[test] +fn update_from_argv_strips_the_program_name() { + let a = argv(["--file", "a.txt"]); + let mut upd = Upd::parse_from(&a).expect("valid"); + + let a = argv(["upd", "--retries", "3"]); + upd.try_update_from_argv(&a).expect("argv0 is not a flag"); + assert_eq!(upd.retries, Some(3)); + assert_eq!(upd.file, "a.txt"); +} + +/// A CLI reached under a second program name that promotes one of its subcommands. +#[derive(Cli, Debug, PartialEq)] +#[usage(bin = "viewupd", view("runner", root = "run", globals))] +struct ViewUpd { + /// Say more + #[usage(long, global)] + verbose: bool, + #[usage(subcommand)] + command: Option, +} + +#[derive(Subcommands, Debug, PartialEq)] +enum ViewCmd { + /// Run something + Run(RunArgs), +} + +#[derive(Args, Debug, PartialEq)] +struct RunArgs { + /// How many workers + #[usage(long)] + jobs: Option, +} + +#[test] +fn a_view_program_name_promotes_its_command_on_an_update_too() { + let a = argv(["run", "--jobs", "2"]); + let mut view = ViewUpd::parse_from(&a).expect("valid"); + + // A view builds a struct that omits the root fields it does not carry; an update has no + // such struct to project into, so the words are rewritten and the merge is the ordinary + // one — `--verbose` survives because this command line said nothing about it. + let a = argv(["runner", "--jobs", "4"]); + view.try_update_from_argv(&a).expect("the view's own name"); + + assert_eq!( + view.command, + Some(ViewCmd::Run(RunArgs { jobs: Some(4) })), + "`runner` reached `run` without the word", + ); +} + +/// A CLI whose subcommands nest, so a merge has to recurse through the enum twice. +#[derive(Cli, Debug, PartialEq)] +#[usage(bin = "nestupd")] +struct NestUpd { + #[usage(subcommand)] + command: Outer, +} + +#[derive(Subcommands, Debug, PartialEq)] +enum Outer { + /// Work on a remote + Remote(RemoteArgs), +} + +#[derive(Args, Debug, PartialEq)] +struct RemoteArgs { + /// Which remote + #[usage(long)] + name: Option, + #[usage(subcommand)] + command: Inner, +} + +#[derive(Subcommands, Debug, PartialEq)] +enum Inner { + /// Add it + Add(InnerAdd), + /// Remove it + Remove(InnerRemove), +} + +#[derive(Args, Debug, PartialEq)] +struct InnerAdd { + /// Where it lives + url: String, + /// Fetch it straight away + #[usage(long)] + fetch: bool, +} + +#[derive(Args, Debug, PartialEq)] +struct InnerRemove { + /// Which one + which: String, +} + +#[test] +fn a_nested_subcommand_merges_at_every_level() { + let a = argv(["remote", "--name", "origin", "add", "git://x", "--fetch"]); + let mut nest = NestUpd::parse_from(&a).expect("valid"); + + let a = argv(["remote", "add", "git://y"]); + nest.try_update_from(&a).expect("the same path"); + + let Outer::Remote(remote) = &nest.command; + assert_eq!(remote.name.as_deref(), Some("origin"), "outer field kept"); + assert_eq!( + remote.command, + Inner::Add(InnerAdd { + url: "git://y".into(), + fetch: true, + }), + "the inner command merged rather than being rebuilt", + ); +} + +#[test] +fn a_nested_subcommand_switch_replaces_only_the_inner_variant() { + let a = argv(["remote", "--name", "origin", "add", "git://x", "--fetch"]); + let mut nest = NestUpd::parse_from(&a).expect("valid"); + + let a = argv(["remote", "remove", "origin"]); + nest.try_update_from(&a).expect("a different inner variant"); + + let Outer::Remote(remote) = &nest.command; + assert_eq!(remote.name.as_deref(), Some("origin"), "outer field kept"); + assert_eq!( + remote.command, + Inner::Remove(InnerRemove { + which: "origin".into(), + }), + ); +} diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index 35db39592..7db86efd8 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -259,6 +259,7 @@ pub fn emit(cli: &Cli) -> TokenStream { let argument_lookup = argument_lookup_functions(cli); let deprecations = deprecations_fn(cli); let defaults = partial_defaults(cli); + let merge = merge_fn(cli); // A root resolves settings when it binds one itself, or when it says so — which is how a CLI // whose bound flags all live in a flattened group asks for the entry points, since it cannot // see another struct's fields. A root that does neither gets the compile-time guard instead of @@ -916,28 +917,56 @@ pub fn emit(cli: &Cli) -> TokenStream { /// In the module rather than beside the parse, so every reference it makes /// to the user's own types sits at one consistent scope — the root and a /// nested command generate the same code here. - fn check_with_view<'t, 'v>( + /// `__usage_standing` is what an update already had: `None` for an ordinary + /// parse, which folds every question about it away. One body rather than an + /// update-only copy, because this is the largest function a CLI generates and + /// most CLIs never call the entry point that passes a value here. + fn check_with_view_standing<'t, 'v>( partial: &mut Partial, __usage_view: ::std::option::Option< &'static usage_argv::spec::ViewMeta<'static>, >, + __usage_standing: ::std::option::Option<&#ident>, ) -> ::std::result::Result<(), usage_argv::Error<'t, 'v>> { partial.__usage_view = __usage_view; // Read unconditionally: a command that declares nothing to check would // otherwise leave the parameter unused in the user's crate, where // nobody can silence it. - let _ = &partial; + let _ = (&partial, __usage_standing.is_some()); let args_override_self = #args_override_self; #post ::std::result::Result::Ok(()) } + fn check_with_view<'t, 'v>( + partial: &mut Partial, + __usage_view: ::std::option::Option< + &'static usage_argv::spec::ViewMeta<'static>, + >, + ) -> ::std::result::Result<(), usage_argv::Error<'t, 'v>> { + check_with_view_standing(partial, __usage_view, ::std::option::Option::None) + } + pub fn check<'t, 'v>( partial: &mut Partial, ) -> ::std::result::Result<(), usage_argv::Error<'t, 'v>> { check_with_view(partial, ::std::option::Option::None) } + /// `check`, against the union of this command line and what the caller had. + pub fn check_update<'t, 'v>( + partial: &mut Partial, + __usage_standing: &#ident, + ) -> ::std::result::Result<(), usage_argv::Error<'t, 'v>> { + check_with_view_standing( + partial, + ::std::option::Option::None, + ::std::option::Option::Some(__usage_standing), + ) + } + + #merge + /// Every token read, and nothing else decided. /// /// The partial as *argv* left it: before a declared default fills a field, before the @@ -1271,6 +1300,120 @@ pub fn emit(cli: &Cli) -> TokenStream { Self::__usage_parse_from(__usage_words, __usage_warnings) } + /// Merge a command line, excluding the program name, into this value. + /// + /// The counterpart of [`Self::parse_from`] for a CLI parsed more than once: + /// a REPL's standing options, a daemon reconfigured while it runs. The rules + /// are stated rather than inherited, because a parse cannot be run backwards + /// through `FromStr` to seed itself from a value — what the caller already + /// has is read from the struct instead, by the checks that need to know a + /// field is filled. + /// + /// Relationships see what is already there: `required`, `requires_if`, + /// `conflicts` and the rest treat a field that already holds a value as + /// present, so this validates the union of both inputs rather than this argv + /// alone. Environment variables and declared defaults fill only fields still + /// empty, so an update never clobbers a value the caller set. A collection + /// this command line mentions is replaced whole; one it says nothing about is + /// left alone. A subcommand word naming a different variant replaces it, + /// discarding the old variant's fields, since selecting a command is a + /// routing decision rather than a value to merge. + /// + /// `self` is left untouched when this returns an error: nothing is merged + /// until every check has passed. + /// + /// Two things a standing value cannot answer, because the bytes it was parsed + /// from are gone: a check about what a value *is* — a choice list, a + /// `validate` expression — is skipped for a field this argv did not supply, + /// and a `requires_if` comparing against a particular value does not match + /// one that merely stands. + pub fn try_update_from<'v>( + &mut self, + argv: &[&'v ::std::ffi::OsStr], + ) -> ::std::result::Result<(), usage_argv::Error<'static, 'v>> { + // A fresh partial, never seeded from `self`: `FromStr` has no inverse, so + // there is no way back from a typed field to the word that made it. + #defaults + read_argv_into(Self::command(), argv, &mut partial)?; + check_update(&mut partial, self)?; + merge(partial, self) + } + + /// Merge a full argv, including the program name, into this value. + /// + /// The [`Self::parse_from_argv`] counterpart of [`Self::try_update_from`]: + /// argv0 is stripped, a multicall applet name selects its subcommand, and a + /// view's program name is rewritten to the command it promotes. A view + /// projects a struct that omits the root fields it does not carry; an update + /// has no such struct to project into, so the words are rewritten and the + /// omitted fields are simply ones this command line said nothing about. + pub fn try_update_from_argv<'v>( + &mut self, + argv: &[&'v ::std::ffi::OsStr], + ) -> ::std::result::Result<(), usage_argv::Error<'static, 'v>> { + let ::std::option::Option::Some((__usage_argv0, __usage_words)) = + argv.split_first() + else { + return self.try_update_from(&[]); + }; + if let ::std::option::Option::Some(__usage_view) = + usage_argv::spec::view_for_program(&SPEC, __usage_argv0) + { + let mut __usage_rewritten = ::std::vec::Vec::with_capacity( + __usage_words.len() + + __usage_view.root.split_ascii_whitespace().count(), + ); + __usage_rewritten.extend( + __usage_view.root + .split_ascii_whitespace() + .map(::std::ffi::OsStr::new), + ); + __usage_rewritten.extend_from_slice(__usage_words); + return self.try_update_from(&__usage_rewritten); + } + if SPEC.multicall { + if let ::std::option::Option::Some(__usage_word) = + __usage_argv0.to_str().and_then(|s| { + usage_argv::multicall_applet(s, SPEC.name, SPEC.bin) + }) + { + let mut __usage_rewritten = + ::std::vec::Vec::with_capacity(argv.len()); + __usage_rewritten.push(::std::ffi::OsStr::new(__usage_word)); + __usage_rewritten.extend_from_slice(__usage_words); + return self.try_update_from(&__usage_rewritten); + } + } + self.try_update_from(__usage_words) + } + + /// [`Self::try_update_from`], answering a failure the way [`Self::parse`] + /// does: help or a version on stdout, a message on stderr, and exit. + pub fn update_from<'v>(&mut self, argv: &[&'v ::std::ffi::OsStr]) { + if let ::std::result::Result::Err(e) = self.try_update_from(argv) { + Self::__usage_exit_on_error( + e, + argv, + argv, + ::std::option::Option::None, + ); + } + } + + /// [`Self::try_update_from_argv`], exiting on failure as [`Self::parse`] does. + pub fn update_from_argv<'v>(&mut self, argv: &[&'v ::std::ffi::OsStr]) { + if let ::std::result::Result::Err(e) = self.try_update_from_argv(argv) { + let __usage_words = + argv.split_first().map_or(argv, |(_, rest)| rest); + Self::__usage_exit_on_error( + e, + argv, + __usage_words, + ::std::option::Option::None, + ); + } + } + /// Parse using clap's `try_parse_from` argv contract. /// /// Input includes argv0 by default. `#[command(no_binary_name)]` @@ -3058,6 +3201,136 @@ fn view_policy_given(field: &Field) -> TokenStream { quote!((#active) && (#given)) } +/// The local holding what the caller already had for this field, on an update. +/// +/// A `bool` for an ordinary field, and the nested value itself — as an `Option<&T>` — for a +/// flattened group or a subcommand, since those answer for their own fields. +fn standing_ident(field: &Field) -> proc_macro2::Ident { + format_ident!("__usage_standing_{}", field.ident) +} + +/// Whether a value the caller already holds counts as present for this field. +/// +/// Read from the built struct rather than from a partial, because that is all an update has: +/// a parse cannot be run backwards through `FromStr`, so the bytes are gone. Presence is +/// therefore what the type itself can answer — a filled `Option`, a collection with items, a +/// set switch — and a plain value is always present, which is what makes a standing `String` +/// satisfy required-ness. +/// +/// `None` for a field with nothing to answer: a skipped field is not parsed, and a flattened +/// group answers for its own fields through its own expansion. +fn standing_presence(field: &Field) -> Option { + let ident = &field.ident; + match &field.kind { + Kind::Skip | Kind::Flatten { .. } => None, + Kind::ArgGroup { optional, .. } | Kind::Subcommand { optional, .. } => Some(if *optional { + quote!(__usage_s.#ident.is_some()) + } else { + quote!(true) + }), + Kind::Flag { .. } | Kind::Arg { .. } => Some(match field.shape { + Shape::Bool => quote!(__usage_s.#ident), + Shape::Count => quote!(__usage_s.#ident != ::std::default::Default::default()), + Shape::Optional => quote!(__usage_s.#ident.is_some()), + // Nowhere to put "absent", so the field always holds something. The same + // reading of the type that makes it required in the first place. + Shape::Required => quote!(true), + Shape::Many if field.optional_collection => quote!(__usage_s.#ident.is_some()), + Shape::Many => quote!(!__usage_s.#ident.is_empty()), + }), + } +} + +/// What the caller already had, as locals every generated check can read. +/// +/// One body serves both entry points: `__usage_standing` is `None` for an ordinary parse, so +/// each of these folds to `false` and the checks below are the ones that were always +/// generated. A second, update-only copy of `check` would double the largest function a CLI +/// generates to serve an entry point most of them never call. +/// +/// The names start with an underscore, so a command whose checks ask about none of them does +/// not leave an unused local in the adopter's crate, where nobody can silence it. +fn standing_locals(cli: &Cli) -> TokenStream { + let locals = cli.fields.iter().filter_map(|field| { + let name = standing_ident(field); + let ident = &field.ident; + Some(match &field.kind { + Kind::Skip => return None, + Kind::Flatten { .. } => quote! { + let #name = __usage_standing.map(|__usage_s| &__usage_s.#ident); + }, + Kind::Subcommand { optional: true, .. } => quote! { + let #name = __usage_standing.and_then(|__usage_s| __usage_s.#ident.as_ref()); + }, + Kind::Subcommand { + optional: false, .. + } => quote! { + let #name = __usage_standing.map(|__usage_s| &__usage_s.#ident); + }, + _ => { + let present = standing_presence(field)?; + quote!(let #name = __usage_standing.is_some_and(|__usage_s| #present);) + } + }) + }); + quote!(#(#locals)*) +} + +/// The `bool` saying whether this field already had a value, when it can be asked. +fn standing_flag(field: &Field) -> Option { + let name = standing_ident(field); + match &field.kind { + Kind::Skip | Kind::Flatten { .. } => None, + Kind::Subcommand { .. } => Some(quote!(#name.is_some())), + _ => standing_presence(field).map(|_| quote!(#name)), + } +} + +/// A presence test that also counts a value the caller already had. +fn given_or_standing(field: &Field, given: TokenStream) -> TokenStream { + match standing_flag(field) { + Some(standing) => quote!((#given || #standing)), + None => given, + } +} + +/// `&& !__usage_standing_x`, for a fill or a complaint that a standing value answers. +fn unless_standing(field: &Field) -> TokenStream { + match standing_flag(field) { + Some(standing) => quote!(&& !#standing), + None => quote!(), + } +} + +/// `&& !(...)`, skipping a check about what a value *is* when this argv supplied none. +/// +/// A standing value is present but unreadable: an update holds the caller's typed value, not +/// the bytes it was parsed from, so a choice list or a validation expression has nothing to +/// judge. Only a required field needs saying — every other shape holds nothing at all when +/// nothing arrived, and these checks then iterate over nothing. +fn unless_standing_only(field: &Field) -> TokenStream { + if field.shape != Shape::Required { + return quote!(); + } + match standing_flag(field) { + Some(standing) => { + let given = format_ident!("__given_{}", field.ident); + quote!(&& !(#standing && !partial.#given)) + } + None => quote!(), + } +} + +/// [`view_policy_given`], counting a value the caller already had. +fn standing_policy_given(field: &Field) -> TokenStream { + given_or_standing(field, view_policy_given(field)) +} + +/// [`semantic_given`], counting a value the caller already had. +fn standing_semantic_given(field: &Field) -> TokenStream { + given_or_standing(field, semantic_given(field)) +} + /// Whether a root field belongs to the executable surface currently being parsed. /// /// A view promotes a subcommand and carries only the root globals it names. The selected @@ -4286,24 +4559,31 @@ fn partial_defaults(cli: &Cli) -> TokenStream { /// anything else is built with `FromStr` — which is what lets a field be a `PathBuf`, a /// number, or a type of the adopter's own. fn field_final(field: &Field, omitter: Option<&TokenStream>) -> TokenStream { + let ident = &field.ident; + let value = field_value(field, omitter); + quote!(#ident: #value) +} + +/// [`field_final`] without the field name, for a merge that assigns one field at a time. +fn field_value(field: &Field, omitter: Option<&TokenStream>) -> 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. - return quote!(#ident: ::std::default::Default::default()); + return quote!(::std::default::Default::default()); } if let Kind::Flatten { ty } = &field.kind { // Built by its own derive, which is also what makes a nested flatten work: this is // the same call at every level. return match omitter { Some(omitter) => quote! { - #ident: <#ty as usage_argv::spec::ViewCommandArgs<#omitter>>::build_for_view( + <#ty as usage_argv::spec::ViewCommandArgs<#omitter>>::build_for_view( partial.#ident, )? }, None => quote! { - #ident: <#ty as usage_argv::spec::CommandArgs>::build(partial.#ident)? + <#ty as usage_argv::spec::CommandArgs>::build(partial.#ident)? }, }; } @@ -4314,10 +4594,10 @@ fn field_final(field: &Field, omitter: Option<&TokenStream>) -> TokenStream { if let Kind::ArgGroup { ty, optional } = &field.kind { let group = quote!(<#ty as usage_argv::spec::ArgGroup>); return if *optional { - quote!(#ident: #group::build(&partial.#ident)) + quote!(#group::build(&partial.#ident)) } else { quote! { - #ident: match #group::build(&partial.#ident) { + match #group::build(&partial.#ident) { ::std::option::Option::Some(__usage_member) => __usage_member, ::std::option::Option::None => { return ::std::result::Result::Err( @@ -4335,10 +4615,10 @@ fn field_final(field: &Field, omitter: Option<&TokenStream>) -> TokenStream { let ty = &field.ty; let finished = |value: TokenStream| { let Some(omitter) = omitter else { - return quote!(#ident: #value); + return value; }; quote! { - #ident: { + { let __usage_view = partial.__usage_view; if partial.__usage_omit_own || !(#active) { <#omitter as usage_argv::spec::Omitted<#ty>>::omitted() @@ -4616,6 +4896,151 @@ fn field_final(field: &Field, omitter: Option<&TokenStream>) -> TokenStream { } } +/// Whether this parse produced a value for the field, so a merge should take it. +/// +/// Wider than `__given_*` by exactly one case: a declared default that fired. Those do not +/// mark a field given — the environment still has to be able to replace one — but a default +/// only fires on an update when the field was empty on both sides, and dropping it would +/// leave the field emptier than a fresh parse leaves it. +fn merge_present(field: &Field) -> TokenStream { + let ident = &field.ident; + let given = format_ident!("__given_{}", ident); + match field.shape { + Shape::Bool => quote!(partial.#given || partial.#ident), + Shape::Count => { + quote!(partial.#given || partial.#ident != ::std::default::Default::default()) + } + Shape::Optional => quote!(partial.#given || partial.#ident.is_some()), + Shape::Required | Shape::Many => quote!(partial.#given || !partial.#ident.is_empty()), + } +} + +/// Overwrite what this command line gave, and leave the rest of the caller's value alone. +/// +/// The rule is stated per field rather than inherited from the full-parse path: a collection +/// this argv mentioned is replaced whole, one it said nothing about is untouched, and a +/// subcommand word naming a different variant replaces it rather than merging into fields +/// the new command does not have. +fn merge_fn(cli: &Cli) -> TokenStream { + let ident = &cli.ident; + let merges = cli.fields.iter().filter_map(|field| { + let field_ident = &field.ident; + match &field.kind { + // Not parsed, so this command line said nothing about it. + Kind::Skip => None, + Kind::Flatten { ty } => Some(quote! { + <#ty as usage_argv::spec::CommandArgs>::merge( + partial.#field_ident, + &mut __usage_standing.#field_ident, + )?; + }), + // A group with no member given is this argv saying nothing about the group. + Kind::ArgGroup { ty, optional } => { + let group = quote!(<#ty as usage_argv::spec::ArgGroup>); + let selected = if *optional { + quote!(::std::option::Option::Some(__usage_member)) + } else { + quote!(__usage_member) + }; + Some(quote! { + if let ::std::option::Option::Some(__usage_member) = + #group::build(&partial.#field_ident) + { + __usage_standing.#field_ident = #selected; + } + }) + } + Kind::Subcommand { ty, optional } => Some(if *optional { + quote! { + if let ::std::option::Option::Some(__usage_at) = partial.__usage_selected { + match &mut __usage_standing.#field_ident { + ::std::option::Option::Some(__usage_existing) => { + <#ty as usage_argv::spec::Subcommands>::merge_into( + partial.__usage_sub, + __usage_at, + __usage_existing, + )?; + } + __usage_slot => { + *__usage_slot = + <#ty as usage_argv::spec::Subcommands>::select( + partial.__usage_sub, + __usage_at, + )?; + } + } + } + } + } else { + quote! { + if let ::std::option::Option::Some(__usage_at) = partial.__usage_selected { + <#ty as usage_argv::spec::Subcommands>::merge_into( + partial.__usage_sub, + __usage_at, + &mut __usage_standing.#field_ident, + )?; + } + } + }), + Kind::Flag { .. } | Kind::Arg { .. } => { + let present = merge_present(field); + let value = field_value(field, None); + Some(quote! { + if #present { + __usage_standing.#field_ident = #value; + } + }) + } + } + }); + quote! { + /// Merge what this parse collected into a value the caller already has. + pub fn merge<'t, 'v>( + partial: Partial, + __usage_standing: &mut #ident, + ) -> ::std::result::Result<(), usage_argv::Error<'t, 'v>> { + // Read unconditionally, so a command with nothing to merge does not leave its + // parameters unused in the adopter's crate, where nobody can silence it. + let _ = (&partial, &*__usage_standing); + #(#merges)* + ::std::result::Result::Ok(()) + } + } +} + +/// Which of this command's fields the caller already had a value for. +/// +/// The standing half of `any_given`, for a parent enforcing exclusivity across a flattened +/// boundary on an update. +fn any_standing_fn(cli: &Cli) -> TokenStream { + let held = cli.fields.iter().filter_map(|field| { + if let Kind::Flatten { ty } = &field.kind { + let ident = &field.ident; + return Some(quote! { + if let ::std::option::Option::Some(__usage_name) = + <#ty as usage_argv::spec::CommandArgs>::any_standing(&__usage_s.#ident) + { + return ::std::option::Option::Some(__usage_name); + } + }); + } + let present = standing_presence(field)?; + let name = &field.name; + Some(quote! { + if #present { + return ::std::option::Option::Some(#name); + } + }) + }); + quote! { + fn any_standing(__usage_s: &Self) -> ::std::option::Option<&'static str> { + let _ = __usage_s; + #(#held)* + ::std::option::Option::None + } + } +} + /// Put a field back the way `start()` left it. /// /// Used twice, and the second use is why it is worth naming: a flag displaced by an @@ -5140,21 +5565,37 @@ fn subcommand_parts(cli: &Cli) -> Option { return true; } }, - check: quote! { - if let ::std::option::Option::Some(__usage_at) = partial.__usage_selected { - match __usage_view { - ::std::option::Option::Some(__usage_view) => { - <#ty as usage_argv::spec::Subcommands>::check_for_view_path( - &mut partial.__usage_sub, - __usage_at, - __usage_view.root.split_ascii_whitespace().count(), - )?; - } - ::std::option::Option::None => { - <#ty as usage_argv::spec::Subcommands>::check( - &mut partial.__usage_sub, - __usage_at, - )?; + check: { + let standing = standing_ident(field); + quote! { + if let ::std::option::Option::Some(__usage_at) = partial.__usage_selected { + match (#standing, __usage_view) { + // An update's standing command, whose own fields answer for the + // ones this argv did not repeat — but only when the selection is + // the same one, which the enum's own expansion decides. + (::std::option::Option::Some(__usage_s), _) => { + <#ty as usage_argv::spec::Subcommands>::check_update( + &mut partial.__usage_sub, + __usage_at, + __usage_s, + )?; + } + ( + ::std::option::Option::None, + ::std::option::Option::Some(__usage_view), + ) => { + <#ty as usage_argv::spec::Subcommands>::check_for_view_path( + &mut partial.__usage_sub, + __usage_at, + __usage_view.root.split_ascii_whitespace().count(), + )?; + } + (::std::option::Option::None, ::std::option::Option::None) => { + <#ty as usage_argv::spec::Subcommands>::check( + &mut partial.__usage_sub, + __usage_at, + )?; + } } } } @@ -5178,8 +5619,16 @@ pub fn emit_args(cli: &Cli) -> TokenStream { .any(|field| field.validate.is_some()) .then(|| quote!(use #validation as usage_validation;)); let presence = presence_methods(cli); - let apply_defaults = declared_defaults(cli, true); - let apply_env = env_fallbacks(cli, true); + let any_standing = any_standing_fn(cli); + let standing_locals = standing_locals(cli); + let apply_defaults = { + let defaults = declared_defaults(cli, true); + quote!(#standing_locals #defaults) + }; + let apply_env = { + let env = env_fallbacks(cli, true); + quote!(#standing_locals #env) + }; // A group carries settings the same way a root does, minus the layer: `SettingGiven` is // usage-argv's own vocabulary, so a flattened group can hand its parent what it was given // without either of them naming the config crate. Emitted whenever it has anything to say — @@ -5296,6 +5745,7 @@ pub fn emit_args(cli: &Cli) -> TokenStream { let defaults = partial_defaults(cli); let apply = apply_fn(cli); let post = post_binding(cli); + let merge = merge_fn(cli); let parts = subcommand_parts(cli); let sub_commands = parts .as_ref() @@ -5630,6 +6080,33 @@ pub fn emit_args(cli: &Cli) -> TokenStream { check_with_args_override_self(partial, #args_override_self) } + /// `check`, against the union of this command line and what the caller had. + pub fn check_update_with_args_override_self<'t, 'v>( + partial: &mut Partial, + args_override_self: bool, + __usage_standing: &#ident, + ) -> ::std::result::Result<(), usage_argv::Error<'t, 'v>> { + check_with_args_override_self_for_view_standing( + partial, + args_override_self, + ::std::option::Option::None, + ::std::option::Option::Some(__usage_standing), + ) + } + + pub fn check_update<'t, 'v>( + partial: &mut Partial, + __usage_standing: &#ident, + ) -> ::std::result::Result<(), usage_argv::Error<'t, 'v>> { + check_update_with_args_override_self( + partial, + #args_override_self, + __usage_standing, + ) + } + + #merge + #settings_defs impl usage_argv::spec::CommandArgs for #ident { @@ -5687,7 +6164,27 @@ pub fn emit_args(cli: &Cli) -> TokenStream { check_with_args_override_self_for_view(partial, args_override_self, view) } + fn check_update<'t, 'v>( + partial: &mut Self::Partial, + standing: &Self, + ) -> ::std::result::Result<(), usage_argv::Error<'t, 'v>> { + check_update(partial, standing) + } + + fn check_update_with_args_override_self<'t, 'v>( + partial: &mut Self::Partial, + args_override_self: bool, + standing: &Self, + ) -> ::std::result::Result<(), usage_argv::Error<'t, 'v>> { + check_update_with_args_override_self( + partial, + args_override_self, + standing, + ) + } + #presence + #any_standing fn apply_defaults(partial: &mut Self::Partial) { apply_declared_defaults( @@ -5710,6 +6207,14 @@ pub fn emit_args(cli: &Cli) -> TokenStream { ) } + fn apply_defaults_update(partial: &mut Self::Partial, standing: &Self) { + apply_declared_defaults( + partial, + ::std::option::Option::None, + ::std::option::Option::Some(standing), + ) + } + fn apply_env(partial: &mut Self::Partial) { apply_env_fallbacks( partial, @@ -5731,6 +6236,14 @@ pub fn emit_args(cli: &Cli) -> TokenStream { ) } + fn apply_env_update(partial: &mut Self::Partial, standing: &Self) { + apply_env_fallbacks( + partial, + ::std::option::Option::None, + ::std::option::Option::Some(standing), + ) + } + #view_path_methods #omit_own @@ -5741,6 +6254,13 @@ pub fn emit_args(cli: &Cli) -> TokenStream { ) -> ::std::result::Result> { ::std::result::Result::Ok(#built) } + + fn merge<'t, 'v>( + partial: Self::Partial, + standing: &mut Self, + ) -> ::std::result::Result<(), usage_argv::Error<'t, 'v>> { + merge(partial, standing) + } } impl<__UsageOmitter> usage_argv::spec::ViewCommandArgs<__UsageOmitter> @@ -6754,6 +7274,85 @@ pub fn emit_subcommands(subs: &Subcommands) -> TokenStream { }; let selects = select_arms(false); let view_selects = select_arms(true); + + // The variants an update can merge into: one holding a single `Args` value, which is the + // only shape whose fields this enum can hand to the struct that owns them. A unit variant + // has no fields to keep, an external one is a list of words that argv replaces whole, and + // a variant with inline fields has no `Args` value to lend — those are replaced, which is + // what selecting a different command does anyway. + let mergeable: Vec<(usize, &crate::model::Variant)> = subs + .variants + .iter() + .enumerate() + .filter(|(_, v)| !v.external && !v.unit && v.inline_fields.is_none()) + .collect(); + let standing_inner = |v: &crate::model::Variant| { + if v.boxed { + quote!(&**__usage_inner) + } else { + quote!(__usage_inner) + } + }; + let standing_inner_mut = |v: &crate::model::Variant| { + if v.boxed { + quote!(&mut **__usage_inner) + } else { + quote!(__usage_inner) + } + }; + let update_checks = mergeable.iter().map(|(i, v)| { + let held = format_ident!("V{i}"); + let variant = &v.ident; + let ty = &v.ty; + let inner = standing_inner(v); + quote! { + (#i, #ident::#variant(__usage_inner)) => { + if let Partial::#held(__usage_p) = partial { + return <#ty as usage_argv::spec::CommandArgs>::check_update( + __usage_p, + #inner, + ); + } + } + } + }); + let update_envs = mergeable.iter().map(|(i, v)| { + let held = format_ident!("V{i}"); + let variant = &v.ident; + let ty = &v.ty; + let inner = standing_inner(v); + quote! { + (::std::option::Option::Some(#i), #ident::#variant(__usage_inner)) => { + if let Partial::#held(__usage_p) = partial { + <#ty as usage_argv::spec::CommandArgs>::apply_env_update( + __usage_p, + #inner, + ); + return; + } + } + } + }); + let merges = mergeable.iter().map(|(i, v)| { + let held = format_ident!("V{i}"); + let variant = &v.ident; + let ty = &v.ty; + let inner = standing_inner_mut(v); + quote! { + if selected == #i { + if let #ident::#variant(__usage_inner) = &mut *standing { + if let Partial::#held(__usage_p) = partial { + return <#ty as usage_argv::spec::CommandArgs>::merge( + __usage_p, + #inner, + ); + } + // Selected but unfilled cannot happen — see `Subcommands::begin`. + return ::std::result::Result::Ok(()); + } + } + } + }); let view_bounds: Vec<_> = subs .variants .iter() @@ -6952,6 +7551,49 @@ pub fn emit_subcommands(subs: &Subcommands) -> TokenStream { _ => ::std::result::Result::Ok(::std::option::Option::None), } } + + fn check_update<'t, 'v>( + partial: &mut Self::Partial, + selected: usize, + standing: &Self, + ) -> ::std::result::Result<(), usage_argv::Error<'t, 'v>> { + match (selected, standing) { + #(#update_checks)* + // A different command than the one standing: what the old variant + // holds says nothing about this one's requirements. + _ => {} + } + Self::check(partial, selected) + } + + fn apply_env_update( + partial: &mut Self::Partial, + selected: ::std::option::Option, + standing: &Self, + ) { + match (selected, standing) { + #(#update_envs)* + _ => {} + } + Self::apply_env(partial, selected) + } + + fn merge_into<'t, 'v>( + partial: Self::Partial, + selected: usize, + standing: &mut Self, + ) -> ::std::result::Result<(), usage_argv::Error<'t, 'v>> { + #(#merges)* + // A different command replaces the variant wholesale: selecting one is + // a routing decision rather than a value to merge, so the fields of the + // command that was standing go with it. + if let ::std::option::Option::Some(__usage_built) = + Self::select(partial, selected)? + { + *standing = __usage_built; + } + ::std::result::Result::Ok(()) + } } impl<__UsageOmitter> usage_argv::spec::ViewSubcommands<__UsageOmitter> @@ -7207,8 +7849,11 @@ fn declared_defaults(cli: &Cli, filter_view: bool) -> TokenStream { } else { quote!() }; + // A value the caller already had is not a field waiting to be filled: an update + // never overwrites what somebody set deliberately. + let standing_held = unless_standing(f); Some(quote! { - if #active !partial.#given #standing { + if #active !partial.#given #standing #standing_held { let mut __usage_filled = false; #(#fills)* } @@ -7219,12 +7864,23 @@ fn declared_defaults(cli: &Cli, filter_view: bool) -> TokenStream { return None; }; let ident = &f.ident; + let standing = standing_ident(f); Some(if filter_view { quote! { - <#ty as usage_argv::spec::CommandArgs>::apply_defaults_for_view( - &mut partial.#ident, - __usage_view, - ); + match #standing { + ::std::option::Option::Some(__usage_s) => { + <#ty as usage_argv::spec::CommandArgs>::apply_defaults_update( + &mut partial.#ident, + __usage_s, + ); + } + ::std::option::Option::None => { + <#ty as usage_argv::spec::CommandArgs>::apply_defaults_for_view( + &mut partial.#ident, + __usage_view, + ); + } + } } } else { quote! { @@ -7337,8 +7993,11 @@ fn env_fallbacks(cli: &Cli, filter_view: bool) -> TokenStream { } else { quote!(__usage_env) }; + // The environment fills what is empty. On an update a field the caller already + // filled is not empty, however little this argv said about it. + let standing_held = unless_standing(f); Some(quote! { - if #active !partial.#given #standing { + if #active !partial.#given #standing #standing_held { for #bind in #names { if let ::std::result::Result::Ok(value) = ::std::env::var(__usage_env) { let mut continue_unset = false; @@ -7358,12 +8017,23 @@ fn env_fallbacks(cli: &Cli, filter_view: bool) -> TokenStream { return None; }; let ident = &f.ident; + let standing = standing_ident(f); Some(if filter_view { quote! { - <#ty as usage_argv::spec::CommandArgs>::apply_env_for_view( - &mut partial.#ident, - __usage_view, - ); + match #standing { + ::std::option::Option::Some(__usage_s) => { + <#ty as usage_argv::spec::CommandArgs>::apply_env_update( + &mut partial.#ident, + __usage_s, + ); + } + ::std::option::Option::None => { + <#ty as usage_argv::spec::CommandArgs>::apply_env_for_view( + &mut partial.#ident, + __usage_view, + ); + } + } } } else { quote! { @@ -7375,17 +8045,25 @@ fn env_fallbacks(cli: &Cli, filter_view: bool) -> TokenStream { let Kind::Subcommand { ty, .. } = &f.kind else { return None; }; + let standing = standing_ident(f); Some(if filter_view { quote! { - match __usage_view { - ::std::option::Option::Some(__usage_view) => { + match (#standing, __usage_view) { + (::std::option::Option::Some(__usage_s), _) => { + <#ty as usage_argv::spec::Subcommands>::apply_env_update( + &mut partial.__usage_sub, + partial.__usage_selected, + __usage_s, + ); + } + (::std::option::Option::None, ::std::option::Option::Some(__usage_view)) => { <#ty as usage_argv::spec::Subcommands>::apply_env_for_view_path( &mut partial.__usage_sub, partial.__usage_selected, __usage_view.root.split_ascii_whitespace().count(), ); } - ::std::option::Option::None => { + (::std::option::Option::None, ::std::option::Option::None) => { <#ty as usage_argv::spec::Subcommands>::apply_env( &mut partial.__usage_sub, partial.__usage_selected, @@ -7536,10 +8214,14 @@ fn deprecations_fn(cli: &Cli) -> TokenStream { /// come last, because they judge a value however it arrived, including one that came /// from the environment or a default. fn post_binding(cli: &Cli) -> TokenStream { - // Shadow the general presence helper in this generator only. A projected executable + // Shadow the general presence helpers in this generator only. A projected executable // validates carried root globals; policy on every other root field belongs to the - // surface the view omitted. Selected commands run their own checker with no view. - let policy_given = view_policy_given; + // surface the view omitted. Selected commands run their own checker with no view. On an + // update both also count a value the caller already had, so a relationship is judged on + // the union of what stands and what this command line said. + let policy_given = standing_policy_given; + let semantic_given = standing_semantic_given; + let standing_locals = standing_locals(cli); let sub_check = subcommand_parts(cli).map(|p| p.check).unwrap_or_default(); let subcommand_satisfies_requirements = if cli.subcommand_negates_reqs && cli @@ -7547,7 +8229,13 @@ fn post_binding(cli: &Cli) -> TokenStream { .iter() .any(|field| matches!(field.kind, Kind::Subcommand { .. })) { - quote!(partial.__usage_selected.is_some()) + let standing = cli + .fields + .iter() + .find(|field| matches!(field.kind, Kind::Subcommand { .. })) + .and_then(standing_flag) + .unwrap_or_else(|| quote!(false)); + quote!((partial.__usage_selected.is_some() || #standing)) } else { quote!(false) }; @@ -7598,16 +8286,30 @@ fn post_binding(cli: &Cli) -> TokenStream { return None; }; let ident = &f.ident; + let standing = standing_ident(f); Some(quote! { if !__usage_exclusive_present || <#ty as usage_argv::spec::CommandArgs>::exclusive_given(&partial.#ident) .is_some() { - <#ty as usage_argv::spec::CommandArgs>::check_with_args_override_self_for_view( - &mut partial.#ident, - args_override_self, - __usage_view, - )?; + match #standing { + ::std::option::Option::Some(__usage_s) => { + <#ty as usage_argv::spec::CommandArgs> + ::check_update_with_args_override_self( + &mut partial.#ident, + args_override_self, + __usage_s, + )?; + } + ::std::option::Option::None => { + <#ty as usage_argv::spec::CommandArgs> + ::check_with_args_override_self_for_view( + &mut partial.#ident, + args_override_self, + __usage_view, + )?; + } + } } }) }); @@ -7655,10 +8357,11 @@ fn post_binding(cli: &Cli) -> TokenStream { let active = view_field_active(f); let name = &f.name; // Same reason as the environment: a displaced flag was answered by the one that - // displaced it, so it is not missing. + // displaced it, so it is not missing. Nor is one an update did not have to repeat. let standing = displaced_guard(cli, f); + let standing_held = unless_standing(f); Some(quote! { - if #active && !partial.#given #standing { + if #active && !partial.#given #standing #standing_held { return ::std::result::Result::Err( usage_argv::Error::MissingRequired { name: #name }, ); @@ -7695,8 +8398,9 @@ fn post_binding(cli: &Cli) -> TokenStream { Shape::Bool | Shape::Count => return None, }; let active = view_field_active(f); + let standing_only = unless_standing_only(f); Some(quote! { - if #active { + if #active #standing_only { if partial.#invalid { return ::std::result::Result::Err( usage_argv::Error::InvalidChoice { @@ -7746,8 +8450,9 @@ fn post_binding(cli: &Cli) -> TokenStream { Shape::Bool | Shape::Count => return None, }; let active = view_field_active(f); + let standing_only = unless_standing_only(f); Some(quote! { - if #active { + if #active #standing_only { for value in #values { let ::std::result::Result::Ok(__usage_text) = ::std::str::from_utf8(value) else { @@ -8114,22 +8819,34 @@ fn post_binding(cli: &Cli) -> TokenStream { .any(|field| matches!(field.kind, Kind::Flatten { .. } | Kind::ArgGroup { .. })); let flattened_segments = cli.fields.iter().filter_map(|field| { let ident = &field.ident; + let standing = standing_ident(field); match &field.kind { Kind::Flatten { ty } => Some(quote! { ( - <#ty as usage_argv::spec::CommandArgs>::any_given(&partial.#ident), + <#ty as usage_argv::spec::CommandArgs>::any_given(&partial.#ident) + .or_else(|| { + #standing.and_then( + <#ty as usage_argv::spec::CommandArgs>::any_standing, + ) + }), <#ty as usage_argv::spec::CommandArgs>::exclusive_given(&partial.#ident), ), }), // A group declares no `exclusive` member of its own — exclusivity within the // group is what a group *is* — so it contributes only what it was given, which // is what an exclusive flag elsewhere on the command collides with. - Kind::ArgGroup { ty, .. } => Some(quote! { - ( - <#ty as usage_argv::spec::ArgGroup>::any_given(&partial.#ident), - ::std::option::Option::None, - ), - }), + Kind::ArgGroup { ty, .. } => { + // Which member an update already had is the enum's business rather than + // this command's; the group's own name is what it can say about it. + let name = &field.name; + Some(quote! { + ( + <#ty as usage_argv::spec::ArgGroup>::any_given(&partial.#ident) + .or_else(|| #standing.then_some(#name)), + ::std::option::Option::None, + ), + }) + } _ => None, } }); @@ -8138,9 +8855,13 @@ fn post_binding(cli: &Cli) -> TokenStream { return None; }; let name = &field.name; + let standing = standing_ident(field); Some(quote! { ( - partial.__usage_selected.map(|_| #name), + partial + .__usage_selected + .map(|_| #name) + .or_else(|| #standing.map(|_| #name)), <#ty as usage_argv::spec::Subcommands>::exclusive_given( &partial.__usage_sub, partial.__usage_selected, @@ -8435,8 +9156,9 @@ fn post_binding(cli: &Cli) -> TokenStream { } } }); + let standing_held = unless_standing(f); Some(quote! { - if #active && !partial.#given { + if #active && !partial.#given #standing_held { #required_if #required_if_eq #required_if_eq_all @@ -8446,6 +9168,9 @@ fn post_binding(cli: &Cli) -> TokenStream { }); quote! { + // What the caller already had, read once. `None` for an ordinary parse, which + // folds every one of these to `false`. + #standing_locals // Environment first, so a `default_if` can see a sibling filled from // env, and so the environment still overrides an unconditional default. #env_fallbacks diff --git a/docs/rust/clap-compatibility.md b/docs/rust/clap-compatibility.md index ed3e708a3..0765b7d16 100644 --- a/docs/rust/clap-compatibility.md +++ b/docs/rust/clap-compatibility.md @@ -40,18 +40,18 @@ the Rust declaration, not only from generated KDL, wherever the bridge column sa ## Types and declarations -| clap surface | derive | argv | KDL | lib | output | bridge | Notes | -| ------------------------------------------------ | -------- | -------- | --- | --- | ------ | ------ | -------------------------------------------------------------------------------------------------- | -| `Parser` / `Command` metadata | yes | yes | yes | yes | yes | yes | `#[derive(usage::Cli)]`; name, bin, about, long about, before/after help, and version are carried. | -| `Args` | yes | yes | yes | yes | yes | yes | Dedicated, unit, reused, nested, and flattened Args types are covered. | -| `Subcommand` | yes | yes | yes | yes | yes | yes | Bare, tuple, inline-struct, nested, boxed, aliases, and hidden aliases are covered. | -| `ValueEnum` / `PossibleValue` | yes | yes | yes | yes | yes | yes | Names, help, hide, visible/hidden aliases, cfg, and case-insensitive matching are preserved. | -| `flatten` | yes | yes | yes | yes | yes | yes | Parsing and flattened `next_help_heading` topology are composed. | -| `skip` | yes | yes | n/a | n/a | n/a | n/a | `#[usage(skip)]` fills the field from `Default` and emits no argument. | -| `from_global` | no | no | no | no | no | no | A global flag is parsed on its declaring root type; copying it into another field is unsupported. | -| arbitrary `Command` / `Arg` builder code | non-goal | non-goal | n/a | yes | yes | lossy | `usage-lib` is the dynamic API; the typed derive does not reproduce clap's builder API. | -| `ArgMatches`, `FromArgMatches`, `CommandFactory` | non-goal | non-goal | n/a | n/a | n/a | n/a | Typed structs and borrowed static metadata replace these APIs. | -| `update_from` / `try_update_from` | no | no | n/a | n/a | n/a | n/a | Parsing currently constructs a new value. | +| clap surface | derive | argv | KDL | lib | output | bridge | Notes | +| ------------------------------------------------ | -------- | -------- | --- | --- | ------ | ------ | ----------------------------------------------------------------------------------------------------------- | +| `Parser` / `Command` metadata | yes | yes | yes | yes | yes | yes | `#[derive(usage::Cli)]`; name, bin, about, long about, before/after help, and version are carried. | +| `Args` | yes | yes | yes | yes | yes | yes | Dedicated, unit, reused, nested, and flattened Args types are covered. | +| `Subcommand` | yes | yes | yes | yes | yes | yes | Bare, tuple, inline-struct, nested, boxed, aliases, and hidden aliases are covered. | +| `ValueEnum` / `PossibleValue` | yes | yes | yes | yes | yes | yes | Names, help, hide, visible/hidden aliases, cfg, and case-insensitive matching are preserved. | +| `flatten` | yes | yes | yes | yes | yes | yes | Parsing and flattened `next_help_heading` topology are composed. | +| `skip` | yes | yes | n/a | n/a | n/a | n/a | `#[usage(skip)]` fills the field from `Default` and emits no argument. | +| `from_global` | no | no | no | no | no | no | A global flag is parsed on its declaring root type; copying it into another field is unsupported. | +| arbitrary `Command` / `Arg` builder code | non-goal | non-goal | n/a | yes | yes | lossy | `usage-lib` is the dynamic API; the typed derive does not reproduce clap's builder API. | +| `ArgMatches`, `FromArgMatches`, `CommandFactory` | non-goal | non-goal | n/a | n/a | n/a | n/a | Typed structs and borrowed static metadata replace these APIs. | +| `update_from` / `try_update_from` | yes | yes | n/a | n/a | n/a | n/a | Merges into a value you already have; a standing value satisfies a relationship but cannot be re-validated. | ## Arguments and values diff --git a/docs/rust/index.md b/docs/rust/index.md index 2ca57e489..a5b237ea7 100644 --- a/docs/rust/index.md +++ b/docs/rust/index.md @@ -110,6 +110,47 @@ failure to stderr and exits `2` — clap's exit status, so scripts that check fo `parse_from` gives you the same machinery without the process control; see [Help, version, and errors](/rust/help) for handling its `Err` variants. +## Updating a value you already have + +A CLI parsed more than once — a REPL reading a line at a time, a daemon reconfigured while it +runs — merges a command line into the value it already holds rather than building a new one: + +```rust +// merge argv into self; print help/version/errors and exit as `parse()` does +pub fn update_from<'v>(&mut self, argv: &[&'v OsStr]); + +// the same, handing errors back +pub fn try_update_from<'v>(&mut self, argv: &[&'v OsStr]) + -> Result<(), usage::Error<'static, 'v>>; +``` + +`update_from_argv` and `try_update_from_argv` are the `parse_from_argv` counterparts: they strip +argv0 and apply multicall applet selection. + +A parse cannot be run backwards — a `String` field says nothing about the word it was made from — +so what you already hold is read from the struct itself, and the rules are stated rather than +inherited from a fresh parse: + +- **Relationships see the standing value.** `required`, `requires`, `conflicts` and the rest + treat a field that already holds a value as present, so a required flag need not be repeated + and a standing flag still conflicts with a new one. What is validated is the union of both + inputs. +- **The environment and declared defaults fill only what is empty.** An update never clobbers a + value you set deliberately, and an update whose argv says nothing cannot change anything. +- **A collection is replaced when this argv mentions it**, and left alone when it does not. + Appending was rejected because it leaves no way to clear a field. +- **A different subcommand replaces the variant** whole, discarding the old variant's fields; + the same subcommand merges field by field. + +Nothing is merged until every check has passed, so a `try_update_from` that returns `Err` leaves +the value exactly as it was. + +Two things a standing value cannot answer, because the bytes it was parsed from are gone: a check +about what a value _is_ — a choice list, a `validate` expression — is skipped for a field this +argv did not supply, and a `requires_if` or `default_value_if` comparing against a particular +value does not match one that merely stands. A field whose type has nowhere to put "absent", such +as a plain `String`, always counts as present. + What runs afterwards can be generated too. A command implements `Run`, its subcommand enum says `#[usage(run)]`, and the `match` that routes argv to the code carrying it out is written from the same declaration: diff --git a/docs/rust/migrating-from-clap.md b/docs/rust/migrating-from-clap.md index 62e3714e5..092e998a1 100644 --- a/docs/rust/migrating-from-clap.md +++ b/docs/rust/migrating-from-clap.md @@ -206,11 +206,17 @@ clap tests usually include argv0. Choose the matching entry point explicitly: | words after argv0 | `Cli::parse_from(&[&OsStr])` | | full argv with argv0, returning errors | `Cli::parse_from_argv(&[OsString])` | | clap-shaped call sites | `Cli::try_parse_from(iter)` | +| merging into a value you already have | `cli.try_update_from(&[&OsStr])` | `parse_from` is the allocation-free primitive. `parse_from_argv` also applies multicall basename routing. Handle `usage::Error::Help` and `usage::Error::Version` before dispatch when an embedder must intercept those built-ins. +`update_from` and `try_update_from` carry clap's names but state their merge rules explicitly, +because a parse cannot be run backwards to seed itself from a value: a standing field satisfies a +relationship, the environment and defaults fill only what is empty, and a subcommand word naming a +different variant replaces it. See [Updating a value you already have](/rust/#updating-a-value-you-already-have). + The `match cli.command { … }` a clap CLI writes after parsing can go too: implement `usage::Run` on each command struct, say `#[usage(run)]` on the enum, and the routing is generated. Commands that need shared state implement `usage::RunWith` and the enum says From ab5ac0cfec236eabbe79d15589c6e93710750f0a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 01:04:11 +0000 Subject: [PATCH 07/10] chore: remove completed PLAN.md Every launch-gate and API-surface checkbox is done, including update_from. Drop the roadmap file and retarget leftover comments that pointed at it. Co-authored-by: jdx --- PLAN.md | 1935 ---------------------------- benches/gate/tests/differential.rs | 6 +- conformance/src/complete.rs | 2 +- corpus/complete/README.md | 2 +- 4 files changed, 5 insertions(+), 1940 deletions(-) delete mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index 1257d9436..000000000 --- a/PLAN.md +++ /dev/null @@ -1,1935 +0,0 @@ -# Plan: a compiled parser, and a config layer, for usage - -Working plan for two related pieces of work. Boxes get ticked as things land, so -this file is also the status: if a box is unchecked, that thing does not exist. -A `[~]` box is partly done, and the item says which part — the marker exists -because two items were reading as "not started" while most of their substance was -already written, which is the failure mode a status file has to avoid. - -1. **A compiled argv parser** — a Rust parser that reads static tables instead of - building a command tree, with the usage spec as its source of truth. -2. **A config layer** — one implementation of the settings model that mise, hk, - pitchfork, and fnox have each rebuilt separately, declared once and lowered - into the spec. - -The first is underway. The second is **underway too**, which this file said it was -not: `usage-config` and the `usage::Config` derive are the settings model, the spec's `config` -block has a model behind it, and the derive lowers `#[usage(setting = …)]` into -`SETTINGS_BINDINGS` with a `Registry::drift` check over it. What has _not_ -happened is adoption — none of mise, hk, pitchfork or fnox depends on the crate — -and the boxes below are written per-item so the two are no longer conflated. - -## Why - -Derive-based CLI frameworks describe a command tree at runtime: the macro expands -into code that _builds_ an object graph — every subcommand, flag, help string, -alias, constraint — which a generic engine then interprets in order to parse one -command line. The construction is paid on every invocation, whether or not the -command that ran needed any of it. - -At [mise](https://mise.jdx.dev)'s size — 210 commands, 711 flags, 339 -positionals — that is roughly **3.1M instructions** for tree construction and -about half of the **~1.1ms** spent getting from `argv` to a parsed struct. mise -already maintains two hand-written argv scanners to avoid building the tree on -its hottest paths, and a comment in its source explains that deriving one of them -from clap "is what made every mise command ~6.3M instructions more expensive". -That workaround existing is the argument for this project. - -serde faced the same fork and took the other branch: its derive emits a -monomorphic function specialized to your type, with no runtime model of the type -at all. Nobody asks serde for a schema compiler. This applies that shape to -`argv`. - -**Runtime is the goal.** Compile time is a bonus: a derive emitting a small -monomorphic function should generate less IR than one emitting builder calls into -a generic engine, and if that holds it gets measured and advertised. If it does -not, the runtime case stands alone. - -## How it is arranged - -``` -usage spec (KDL) ←────────────── the canonical model - │ ↑ - │ reference │ emitted losslessly - ↓ │ -usage-lib ── interprets a spec usage-derive ── reads a Rust type - │ at runtime │ - │ ├─→ static tables ─→ usage-argv (hot) - └─→ the oracle the corpus └─→ static metadata ─→ help, - measures everyone against completions, spec output (cold) -``` - -Four rules hold this together. - -**Code authors, the spec defines.** A Rust type is the authoring surface; the -spec is the semantic model. Anything the derive can express must lower losslessly -into the spec — and if the spec cannot express it, the spec gets extended first. -That keeps the emitted KDL a definition rather than a lossy summary, which is -what everything downstream (docs, manpages, completions, SDKs, agents) reads. - -**usage-lib is the reference implementation.** It interprets a spec at runtime, -which is exactly what [the grammar](https://usage.jdx.dev/spec/argv) describes, -so it is the oracle. The corpus measures every implementation against it and -records disagreements per case rather than assuming they do not exist. - -**The hot path stays small.** Binding only: which token becomes which flag or -argument. Help text, error rendering, spec emission, and every check that needs a -value's type live on cold paths in separate tables, so a successful parse never -touches them. - -**End users never need another binary.** Help and completions are served from the -CLI itself. `usage` the CLI stays a maintainer's build-time tool for docs, -manpages, and SDKs — never a runtime dependency of somebody else's program. - -## Milestones - -### Done - -- [x] **The grammar, written down** (#797) — `docs/spec/argv.md`. Token - classification, the single left-to-right pass, flag forms, positional - filling, subcommand routing and scope, `--`, and the error classes. -- [x] **A conformance corpus** (#797) — JSON vectors, language-neutral so any - implementation can run them. Each records whether usage-lib agrees, as a - measurement; the suite fails if a label is wrong in _either_ direction, so a - divergence that gets fixed reports itself instead of rotting. -- [x] **`usage-argv`** (#798) — the binding parser. No tree, one pass, zero - allocations on success _and_ failure, proved by a counting allocator rather - than asserted. Answers the binding vectors, including every one where - usage-lib diverges from the grammar. -- [x] **Released on the shared version** (#798, #803) — `usage-argv` and - `usage-derive` are ordinary members of the workspace at the shared version - rather than 0.0.0 curiosities outside the release cycle. -- [x] **Repeatable vs. variadic flags, and `double_dash_seen`** (#799) — a - repeatable flag was greedy enough to eat a positional, and `automatic` mode - reported a separator nobody typed. - -### Next: the derive - -- [x] **Static metadata tables** — a second, cold tree holding what the hot one - deliberately omits: help and long help, about text, hidden-ness, visible and - hidden aliases, value names, `choices`, defaults, `env`, effects, mounts, - restart tokens, examples. Behind the `spec` feature, and each entry borrows - the parse-table entry it describes, so names have one definition and cannot - drift. -- [x] **KDL emission** — `Spec::to_kdl`, written by hand so the crate keeps having - no dependencies. Verified by parsing the output back with usage-lib and - checking the resulting spec field by field, then rendering it through the - markdown and manpage generators an adopter's docs build actually uses. -- [x] **`usage-derive` v0** — flags, positionals, doc-comment help (first - paragraph short, whole block long), and spec emission, from usage-native - attributes. Unsupported field types are a compile error rather than a - surprise, and the messages point at the offending field. -- [x] **Subcommands in the derive** — an enum of variants, each holding the struct - that declares its flags, nested to any depth. A command is selected by its - position in the parent's table, found from the address the parser hands back. -- [x] **Typed values** — a field is any `T: FromStr`, plus `Option`, `Vec` and - `Option>`, which is what lets a command struct hold the `PathBuf` mise names - 227 times and the tool-version type it names 83 times. Binding still collects text; - the conversion happens where the struct is built, and a value that will not convert - reports the text and the type's own message. `Error` grew one boxed variant and lost - `Copy`, and stayed 40 bytes, so nothing on the hot path grew. -- [x] **Enumerated values** — `#[derive(usage::ValueEnum)]` on an enum of bare variants - gives the words a value may be, and a field says `value_enum` to use them. mise has - nine of these. The list is declared once, on the type: the spec, the help, the - completions and the check that rejects a wrong word all read it from there, so none of - them can drift from the type the way a second list on the field would. -- [x] **Values that are not valid UTF-8 are reported, not mangled** — the partial holds the - bytes a word arrived as, and the conversion happens once where the struct is built, so - `--out /tmp/\xff` says so instead of handing a `PathBuf` a path with `U+FFFD` in it — - a different file, silently. Costs +656 instructions (1.6%) and one allocation, which - is what not corrupting a value is worth. It also retired the hazard of recognising - `String` by its spelling, since there is no identity case left. -- [x] **Accepting a value that is not valid UTF-8** — reporting it was the safe half; accepting - it is the whole fix, because the operating system does accept `/tmp/\xff` as a filename and a - CLI that cannot receive one cannot open the file. A `PathBuf` or `OsString` field takes the - bytes exactly, through `usage_argv::os_string_from_bytes`. **And with no `unsafe` anywhere.** On - Unix an `OsString` is an arbitrary byte sequence, so this is the safe `OsString::from_vec` and - every byte survives — which is the case that matters, since non-UTF-8 filenames are ordinary - there. Windows was going to need `from_encoded_bytes_unchecked`, and jdx approved that, but a - _safe_ function taking a `Vec` cannot enforce its precondition: there is no way to know the - bytes came from `as_encoded_bytes` rather than from anywhere else, and a safe function whose - precondition a caller can violate is unsound however carefully today's callers behave. Greptile - flagged exactly that on #844. So Windows goes through UTF-8 and reports what will not convert, - which gives up only an unpaired-surrogate argument there. Cheaper than the text path, not - dearer: a `PathBuf` field costs **553 instructions per parse against a `String` field's 674**, - since it skips the UTF-8 validation pass, and both allocate once. The gate fixture cannot show - this — a spec carries no Rust types, so every shadow field is a `String` — which is why it is - measured directly. -- [x] **`flatten`** — one struct's declarations belonging to another command, which mise does - ten times: `ConfigLs` is written once and given to both `config` and `config ls`. The tables are - joined at compile time by `concat_flags`/`concat_args`, so the parser walks one flat slice and - flatten costs nothing at run time; a flattened group lands at the field's position, which - positional arguments require. Everything else is delegation through `CommandArgs` — partial, - `start`, `apply`, `check`, `build` — which is also why nesting works without anything extra. - Nothing new in the spec: the emitted KDL lists the flags inline, exactly as a hand-written - command would. It turned up a bug worth more than the feature. `Subcommands::apply` offered - every event to _every_ variant and took the first that claimed it, which was harmless only - because keys were unique per command — and flatten breaks that, since two commands sharing a - declaration share its key. So `config --no-header` bound the flag on the unselected `config ls`. - Now only the _selected_ variant is asked, which is both correct and much cheaper: at mise's - scale the root had ~100 variants, each asked per event. **29,850 instructions and 822 ns, from - 40,179 and 1,893 ns** — 26% fewer instructions and 57% less wall time, measured against the - parent branch on one machine. 176× clap's instruction count. Allocations unchanged: 0 bare, 4 - bound. `duplicate_key` had to change with it: it asserted no key appeared twice in the whole - tree, and sharing one across commands is now ordinary rather than suspect. Checked per command - instead, which is the level a key actually decides anything at. The collision flatten _can_ - still cause — a parent and the struct it flattens both declaring `--quiet` — is invisible to - both expansions, so `Spec::to_kdl` grew a duplicate-form check beside the key one, where the - whole tree is visible. `Option` flatten is refused rather than guessed at: it needs a rule - for when the group counts as given, and nothing in the fleet asks for one. Two more checks live - in `Spec::to_kdl` for the same reason as the duplicate-form one: an argument no word can reach — - an unbounded variadic on one side of a flatten and a later positional on the other — and the - flag-form collision. Both are invisible to either expansion and visible where the tables are - joined. -- [x] **`usage-derive` v1** — everything mise needs. `conflicts`, `overrides`, - `required_if`, `required_unless`, `var`, `count`, `env`, defaults, the four - `double_dash` modes, global flags, flatten, boxed subcommand variants, - headings and `cfg`-gated variants have all landed since this was written. - **`requires` has since landed** — spec, usage-lib's parser, the derive and - the argv metadata all carry it, and `#[usage(requires = "--format")]` - reports `MissingRequired` when the other flag is absent. The clap _bridge_ - still cannot read one, and cannot be made to: clap 4.6 exposes - `Arg::requires` as a setter with no getter, so a `Command` cannot be asked - what it requires. That is a clap limitation, recorded in - `lib/src/spec/flag.rs`, not an item to close here. - **`requires_if` / `requires_ifs` have since landed too**: the spec records - repeated value/selector pairs, usage-lib and the derive enforce the same - explicit-value rule clap does, and the cold tables emit the relationship - without touching binding. Delimiters landed alongside them, so the original - v1 list is closed. - What a _rewrite_ of mise still trips over is in the clap-parity list and in - **Trying the fleet** below. -- [x] **The post-binding layer** — `required`, `choices`, `env` fallback, defaults, - `var_min`, `conflicts`, `required_if`, `required_unless`, and `overrides` — - `var_max` moved to the binder, see the decision below. All of them need a value's type, so they belong with the derive - rather than in the parser. `overrides` is the one that happens _during_ the - parse instead of after it: it asks which of two flags came last, which only the - arriving token knows. - -### Spec gaps found on the way - -Each of these is a thing a CLI wants to say that the spec has no way to record. -Per the canonicality rule the spec gets extended first, so these block the derive -carrying them rather than being worked around. - -- [x] **`help_heading`** — grouping flags under a heading in help output. Now a - spec field on both flags and arguments, and the clap bridge no longer drops - it, which it had been doing for every CLI that groups its flags. -- [x] **Rendering headings** — help output and generated markdown both group by - heading now. Unheaded entries keep the default section and come first, and a - heading with nothing visible in it produces no section. -- [x] **`conflicts`** — the spec could say `overrides`, `required_if` and - `required_unless`, but not that two flags must not be given together, so the - forty `conflicts_with` relationships mise declares in clap were being dropped - by the bridge. Now a flag property, enforced by usage-lib, and a value from - the environment counts on both sides of the check as clap does. -- [x] **An argument after a variadic, when it needs a `--`** — the derive refused the - shape mise uses on `run`, `exec` and `git`: `[ARGS]…` for the words before the - separator and `[-- ARGS_LAST]…` for the ones after. Both parsers already bound it; - only the derive's validation disagreed. Found by compiling the mise shadow. -- [x] **Is `var_max` a limit or a check?** — usage-argv let a variadic take every word and - judged the count afterwards; usage-lib stopped the variadic at the bound and gave the - rest to the next argument. Two coherent readings, and nothing had recorded that the - two implementations disagreed. Settled as a **limit**, matching usage-lib and clap's - `num_args`, since every spec in the fleet is generated from a clap command and it is - the only reading under which `[a]… [b]` can be filled. `var_max` therefore moved into - the table binding reads; `var_min` stays a check, because no single word tells you a - variadic will end up short. Costs 55 instructions per parse, or 0.1%. -- [x] **The command-level properties mise patches in by hand** — `default_subcommand`, - `restart_token` and `mount`, declared with `#[usage(...)]` instead of edited into the - spec afterwards by `src/cli/usage.rs`. None of the three changes how a word binds: a - mount costs a subprocess and belongs to completions, a restart token is read by - whoever splits an invocation into several, and a default subcommand tells a completion - engine what a bare word means. So they are emission-only, and the test asserts both - that they reach the KDL and that binding is unaffected. `default_subcommand` is - checked at compile time to require a subcommand field, since it names one. -- [x] **Routing on `default_subcommand`** — the parser reads it now, rather than the property - being emitted and ignored. A word naming no subcommand descends into the named command and is - examined again there, so it can be that command's argument or one of _its_ subcommands. The - declaring command's own positional does not win, which is what makes the property more than a - synonym for an argument — and is mise's shape exactly. Taken at most once per parse, so a chain - of defaults cannot walk a CLI deeper than the user typed. No measurable cost: 40,370 - instructions against 40,472 before, the branch being reached only by a word that matched no - subcommand. The ±100 is code layout rather than work — cachegrind is deterministic, and gave the - same figure twice. Allocations unchanged: 0 bare, 4 bound. Only a _word_ routes: a dash-prefixed - token naming no flag arrives as a value and was never a candidate to select anything, so it - binds where it was typed — as usage-lib does, which stops looking for subcommands at an - unrecognised flag. The name may also be an alias, since usage-lib resolves it against names, - aliases and hidden aliases alike. The name is resolved by `find_subcommand` during **const - evaluation**, so a `default_subcommand` that no subcommand answers to is a compile error. That - retires the claim in the entry above that the name could not be checked: the variants are indeed - another expansion, but a `const fn` can search the list the parent already holds. **What it does - not buy on its own:** `mise build` still does not work end to end in the shadow, because mise's - spec gives `run` no positional at all — `src/cli/usage.rs` clears them and adds `mount run="mise -tasks --usage"`, so task names are meant to come from running that. usage-argv does not execute - mounts. Routing _plus_ mounts is what would let mise delete its hand-rolled dispatch; routing - alone is half of it. One divergence found and then **fixed in usage-lib** rather than recorded: - it applied the single declared name at whichever command it was standing on, so `ex config zzz` - descended into `config ls` when an unrelated `config` happened to have an `ls`. A spec declares - one name, once, at the top. The corpus vector that recorded the difference is now an ordinary - agreeing vector — the reference test refused to let the label stay, which is what it is for. -- [x] **A mount on the root command** — top-level discovery is represented and - usage-lib keeps completion and execution consistent about when the mount runs. -- [x] **`subcommand_required`, which the derive knew and did not say** — a bare `T` - subcommand field requires a subcommand and an `Option` does not, and the parser - has always refused the invocation accordingly. `Spec::to_kdl` wrote neither, so the - emitted KDL described a group command as one a user could run alone — and help, docs, - completions and the SDK generators all read that rather than the type. Cold metadata, - since it is not how a word binds. Found by converting usage-cli itself to the derive - and diffing the spec it prints against the clap bridge's. One shape could have made the - answer a lie — a `#[usage(flatten)]` group declaring subcommands of its own, which - flatten leaves behind while the group's `build` still demands one — and is a compile - error now, asserted during const evaluation in the parent's expansion, where the group - is only a type. -- [x] **`unknown_flags`, which reached one command out of a tree** — usage-lib resolves it by - walking outward from the command that ran, so a root declaring `error` makes the whole - CLI strict. usage-argv held the effective value per command instead, on the theory that - whoever built the tables would resolve it — which a derive cannot, since it expands one - struct at a time and cannot see the command above. So the attribute reached the root - alone, and on an `Args` it parsed and was then ignored: a declaration that compiled and - did nothing. Now `None` means inherit, the parser carries the effective value down as it - descends, and the corpus's own table builder stops resolving it — one implementation of - the rule instead of two, and it was the second one that hid the parser not having it. - Costs **160 instructions per parse, 72,272 against 72,112** at mise's scale. -- [x] **`subcommand_required` on the root command** — a bare root subcommand field - now reaches the spec and both parsers report the missing command consistently. - -- [x] **Three things a spec could say that the derive could not** — a flag's value name - (`--tool ` came back as `--tool `, since the flag's own name was all there - was), a collecting argument that needs at least one value (`…`, which a `Vec` - cannot express because it has no bare-versus-`Option` shape), and `var` on a counted flag - (a count _is_ repetition, so it is inferred now rather than needing told). All three were - dropped by `gen-shadow` without being counted, which is the part that made them invisible: - the report is the thing that was supposed to prevent exactly this. Found by rendering - mise's help from the shadow and diffing every line against usage-lib's — 23 of 211 differed, - and every difference traced to one of these. They matter past help text, since the emitted - spec is what docs, manpages, completions and the SDK generators read. -- [x] **Reusable declarations inside a spec** — mise's checked-in spec is 5,592 lines for 711 - flags, and most of that count is the same handful of declarations written again under the - next command. The derive has `flatten` for this and a hand-authored spec had nothing, so - `flagset "name" { flag … }` plus `use "name"` now says it once. Resolved while the file is - read, so nothing downstream — help, completions, docs, the generated parsers — learns a - concept: reuse disappears into what it stands for rather than becoming vocabulary every - consumer has to implement. - Decisions worth recording, since each had a defensible other answer. A `use` expands **in - place**, because help order is spec order and appending would reorder the command that - used it. A command's own declaration **wins** over one arriving from a set, per-form, which - is the rule a redeclared global already follows — so a diamond through composition also - contributes once. Sets may **compose**; a cycle is an error naming the path that closes it. - Sets hold **flags only**: a flag is identified by its spelling wherever it lands, while a - positional is identified by its position, so the same set spliced into two commands with - different arguments would mean two different things. Sets travel through `include`, and - each file resolves its own `use` nodes — a file cannot use a set declared only by a file - that includes _it_, which would make a spec's meaning depend on who read it. - The derive lowers `flatten` into it rather than emitting the expanded copy: one flagset - per flattened struct, named after it, and a `use` on every command that flattens it. A - struct that flattens another becomes a set that uses a set. One set per struct rather - than only for the ones with several users — a threshold would make how one command is - written depend on what another does, so adding a `flatten` elsewhere would restructure a - command nobody touched. Two flattened structs whose names end in the same word get no set: - one name cannot stand for both, so both are written inline, which is what every flatten - did before. usage-cli's own checked-in spec is the first case of it, and its generated - markdown, JSON and manpage are byte-identical across the change — the reference parser - resolves the set while it reads the file, so what a command accepts never moved. - Still owed: a way to name a set other than after the Rust type, which is what a collision - needs to be fixable rather than merely safe. And usage-cli declares - `min_usage_version "4.0"` while now emitting a node no 4.0 can read — the floor moves to - whatever release carries this, which is release-plz's to stamp rather than a number to - guess here. - -### Then: what a CLI framework has to have - -- [x] **Help rendering** — `-h` and `--help` both match usage-lib byte for byte across all 211 of - mise's commands, and both are wired: the parser recognises them itself, after a command's own - flags so a CLI that declares its own keeps it, and a request comes back as `Error::Help` - carrying the command it was asked about. `parse()` renders and exits; `parse_from` returns - it, so a library embedding this decides for itself. Costs 111 instructions, since the check - is only reached by a flag that matched nothing. The `help` _subcommand_ landed with it: - asked after the subcommand lookup, so a CLI declaring its own `help` keeps it, and the - words after it name a command rather than being descended into. Holding the rendering to - parity found ten things a spec could say that the derive could not, and two bugs in - usage-lib's own renderer. -- [x] **Completions, self-contained** — ` completion ` emits the - script; a hidden `__complete_word__` serves requests from the binary's own - tables. Same dispatch shape usage-cli uses today, without requiring `usage` - on the end user's machine. bash, zsh, fish and PowerShell, behind the - `complete` feature and asked for with `#[usage(completion)]`. -- [x] **Installing the script, rather than telling the user to redirect it** — the - next sentence of the item above, because a script an adopter still has to - teach its users to redirect is the unfinished half of shipping one. - `usage_argv::install` is two layers: a pure one resolving the target from a - _described_ environment — home, the XDG directories, `BASH_COMPLETION_USER_DIR`, - `NU_VENDOR_AUTOLOAD_DIR` — and a thin one creating the parent directories and - writing the file. `Platform` is an input rather than a `cfg!`, which is the one - place this diverges from `usage-config`'s environment: a Windows path resolved - behind `cfg!(windows)` is checked by nobody until a Windows user reports it, so - all five shells on all three platforms are unit-tested on the Linux host CI runs - on. Behind `complete`, with no new dependencies and nothing on the parse path. - usage-cli reaches the same resolver through `usage g completion --install`, so - where a completion goes is decided in one place whether a binary is installing - its own or the CLI is installing another's — and `--install` - raises the generator from `read` to `write` rather than being a command of its - own, which is the composition rule the effect vocabulary already had. - bash and fish load from their own directories with nothing else done to the - shell, which `argv/tests/install.rs` proves by driving the real shells rather - than asserting it; nushell is automatic only where the environment names a - vendor autoload directory. - A file already at the target is read before anything is written, which is what - separates an upgrade from a theft: identical bytes write nothing, a file carrying - any `@generated by usage` stamp is replaced — the family, so a script - `usage g completion --install` wrote counts as much as one a binary wrote for - itself — and anything else is refused with its - path unless the caller says otherwise. So an upgrade needs no flag and a script - somebody wrote by hand survives one — at the cost of making that marker - load-bearing, which a test now guards. - **Deliberately not done**: no shell rc file and no PowerShell profile is ever - edited, and there is no `$SHELL` detection — the shell is named. Where a shell - needs a one-time line of its own (zsh's `fpath+=`, PowerShell's dot-source) it - comes back as data for the caller to print, and the zsh test runs that line - verbatim rather than an equivalent of it. Both halves are the same property: - writing the script file again is a no-op, so every upgrade can re-run the - install, while appending a line to `.zshrc` again is not, and a tool that owns a - user's dotfiles has no undo to offer. -- [x] **Docs and manpages** — the emitted KDL feeds `usage g markdown|manpage` - the same way a clap-derived spec does. usage-cli already regenerates - `docs/cli/reference` and `cli/assets/usage.1` from the derive's spec - (`mise run render:usage-cli-completions`). The gate now asks the same of - a clap CLI: `benches/gate/tests/fleet.rs` holds communique's markdown - and manpage to the checked-in spec. -- [x] **Diagnostics** — rich errors behind the `diagnostics` feature. The hot path returns a - compact code; rendering re-examines the command line only once it has - already failed, and says what clap would have said, colour included, down to - suggesting what was probably meant. `parse()` renders and exits the way a - program does; `parse_from` hands the error back. -- [x] **A test harness for an adopter's own suite** — `usage-test`, reached as - `usage::test` behind a dev-dependency feature. A CLI's observable surface is - three things, and all three were reachable but unpleasant: `parse_from` - wants a `&[&OsStr]` a test cannot write as a literal and hands back a - compact code rather than the message a user reads; a help page needed a - route rebuilt by hand; and a completion answer needed `split` and - `candidates` assembled per test. Now `outcome` returns what `parse()` would - have _done_ — a struct, a page, a version, or a failure, each with the - stream and status it would have used — `help`/`help_tree` render one page or - the whole tree by the path a user types, and `candidates`/`completion` - answer a half-typed line. **Nothing in it formats a page**, which is the - rule that makes it worth having: every page comes from - `usage_argv::help::page` and every failure from `render_failure`, the same - functions the process calls, so a passing test is a statement about what - users see rather than about a second renderer that happens to agree today. - That function is the other half of the change: which page a help request - becomes — short, long, recursive, by route or by address, view or not — was - ~150 lines emitted into every derive three times over, and is now decided - once in usage-argv and called from both places. A facade test asserts the two - halves agree: the page `help(spec, &["build"], Page::Long)` renders is - byte-for-byte the one `ex build --help` produces. -- [x] **Dispatch** — the `match` from the parsed enum to the code that carries the - command out, which every adopter writes and nobody varies: 210 arms of pure - routing at mise's size, none of them checkable, since every arm has the same - shape. `usage_argv::Run`, `RunWith`, `RunAsync` and `RunAsyncWith` are - the traits a command implements, and `#[usage(run)]`, `#[usage(run_with)]`, - `#[usage(run_async)]` and `#[usage(run_async_with)]` on the enum generate the - match. **Nothing reaches the spec** — which Rust function runs a command is not - part of what the CLI _is_, and a spec recording it could be read by nothing but - the program that wrote it, so this is `#[usage(skip)]`'s rule rather than a new - spec node. Proved on usage-cli itself: both its matches are gone and - `usage --usage-spec` is byte-identical, so no manpage, reference or completion - changed. Decisions, each because the alternative is a wrong program rather than - a missing one: - **a trait per shape, not one with defaults** — four of them, differing only in - whether a command is handed a context and whether it is awaited: `Run`, - `RunWith`, `RunAsync`, `RunAsyncWith`, asked for by the matching - attribute. A defaulted context would make a hundred commands needing nothing - shared each carry `fn run(self, _: ())`; the `With` pair is generic over `Ctx`, - so `&Config`, `&mut App` and an owned handle all work from one emission. An enum - may declare several, which is what a CLI part-way through adopting a context or a - runtime needs. - **The async pair declares `-> impl Future` rather than `async fn`**, which is the - same signature to implement against and imposes no `Send` bound: a CLI that spawns - gets `Send` by inference out of the concrete commands, and one on a single-threaded - runtime keeps a future holding an `Rc` across an await. `-> impl Future + Send` - would buy the ability to _demand_ `Send` in generic code at the cost of the second, - and there is no way to have both without a fifth trait. The sync pair can still - carry a boxed future as its `Output`, which is what to reach for when the future - has to be a value; the async traits exist so that neither the box nor the name is - necessary. - **The output is the first variant's**, with the others bound to agree, so a - command returning something else is reported on the command rather than inside - a generated arm. - **Opt-in**, because the generated impl is the only one an enum can have: a CLI - that wants to act between the parse and the dispatch keeps its match, and asking - is what makes an undispatchable variant an error where it is declared. - **A `run` struct forwards and does nothing else**, so it holds one field, its - subcommands, not in an `Option` — a container like `usage generate` or mise's - `config`. A struct with arguments of its own has to decide what becomes of them, - and an `Option` has a state nothing generated can decide about; both implement - the trait by hand, which is the root's usual case. - **A variant that holds nothing — bare, inline-fields, or `external_subcommand` — - cannot be dispatched.** The first two are served by a struct the derive writes - under a name nothing else can name, and the third holds argv rather than a - command, so there is no type to implement the trait for. Naming the `Args` struct - is the fix, and is where `effect` belongs anyway. If an adopter wants the bare - spelling dispatched, the shape is a per-variant `#[usage(run = path::to::fn)]`; - not built, because one mechanism covers the fleet. - -### What clap can say that we cannot - -An audit of clap's surface against `derive/src/model.rs`, `argv/src/` and the -spec model in `lib/src/spec/`, done once the framework list above was mostly -ticked. This is the list that decides whether an adopter can move without losing -behaviour, so a gap here is worth more than another point of speed. - -Some of these are dropped by **`clap_usage` too**, which means every spec in the -fleet generated from a clap command is already lossy in exactly this way — the -same shape as the `conflicts` hole above, found the same way. The ones the -fleet actually uses are listed under **Trying the fleet**; the rest stay here -as a clap-surface audit rather than a rewrite blocker. - -One family the bridge will never carry. clap 4.6 gives `Arg::requires`, -`requires_if`, `requires_ifs`, `requires_all`, `required_if_eq` and the -`required_unless_present` families as **setters with no getter**, -and keeps the field `pub(crate)`, so a `Command` cannot be asked what it -requires. It does appear in `Debug for Arg`, so scraping the debug format would -technically recover it — declined, because that format carries no compatibility -promise and would break by producing a wrong spec rather than a failed build. -The consequence is worth stating plainly: `requires` reaches a spec **only** by -being declared in usage, and a CLI that keeps its declaration in clap does not -have this constraint in its spec at all. - -Groups are the opposite case: `Command::get_groups`, `ArgGroup::get_args` and -`Arg::is_exclusive_set` are all public, so the bridge gains those for free. - -**Changes what a CLI does** - -- [x] **`requires` / `requires_if` / `requires_ifs`** — "this flag needs that - one". Plain and value-conditional forms now reach the spec, usage-lib and - generated checks. The bridge still cannot carry them, per the note above, - so this remains a reason to declare in usage rather than a bridge bug. -- [x] **`ArgGroup`, and `exclusive`** — spec, derive (`group("input", required)` - plus `#[usage(group = "input")]` / `exclusive`), usage-lib, and the - bridge. None of the fleet specs currently _use_ a `group` node: clap's - auto-groups for each struct are filtered out, and the live CLIs express - mutual exclusion as `conflicts_with` or as help text that says "mutually - exclusive". -- [x] **`value_delimiter`** — `--tags a,b,c` as three values. `lib/src/spec/arg.rs` - and both Rust parsers now split typed, environment and default values before - checking or converting them. The clap bridge keeps `delimiter` only for - ASCII; a non-ASCII delimiter still splits/`var` in clap but is dropped here, - same as clap's own restriction on `Arg::value_delimiter`. **The fleet - fixtures do not yet carry it**: they were generated by a clap_usage that - recorded a split default and dropped the delimiter. Regenerating them - against this crate is the item under **Trying the fleet**, not another - parser feature. -- [x] **`default_missing_value`, and optional-value flags** — `--color` versus - `--color=always`. Spec `default_missing="always"`, usage-lib, usage-argv - (`Flag::default_missing`), and `#[usage(default_missing = "always")]`. - Absent stays absent (or takes `default`); `--color` binds the missing - value; `--color=never` binds `never`. Combined with `require_equals`, a - following word is still refused. `value_optional` remains help-only unless - `default_missing` is also set, which makes help show the value as optional. - clap 4 has the setter and no getter, so the bridge cannot read it — same - hole as `requires`. **Used by the fleet:** mise `watch`, `generate bootstrap`, - hk `-W/--fail-fast`, aube `--color` / `--inspect` / audit `--omit` — once - those CLIs declare in usage rather than through clap. -- [x] **`default_if` (clap's `default_value_if` / `default_value_ifs`)** — a - default that depends on another flag. Spec `default_if` on the target, - usage-lib, usage-argv, `#[usage(default_if("--json", "true"))]`, and Go - `ApplyDefaultIf`. Two arguments are `ArgPredicate::IsPresent`; three are - `Equals`. First matching condition wins, and only when the target was not - on the command line and has no env value. clap 4 has the setter and no - getter, so the bridge cannot read it — same hole as `requires`. **Used - by:** mise `bin_paths` (`default_value_if("json", IsPresent, "true")`). -- [x] **Portable value validation** — `validate="int(value) >= 1 && int(value) <= -65535"` is a declarative expr rule stored in KDL and enforced by usage-lib and - generated Rust and Go parsers. `validate_error` supplies the user-facing failure. - This covers clap's common range-validation use case without embedding a Rust - parser function in the spec. clap's arbitrary `value_parser` remains inherently - opaque to `clap_usage`, so an existing clap command must declare the equivalent - rule when moving to the typed usage rewrite. -- [x] **Narrow token-boundary controls** — `allow_negative_numbers` and - `value_terminator` now round-trip through KDL, the typed Rust derive, static - metadata, usage-lib, generated Go, and the clap bridge. The first accepts only - negative numeric tokens rather than every dash-word; the second ends a variadic - owner without binding the terminator. -- [x] **Trailing delimiter policy** — `dont_delimit_trailing_values` is a - command-wide, inherited setting in KDL, the typed derive, usage-lib, - usage-argv, generated Go, and the clap bridge. It suppresses delimiter - splitting only after `--` or once a `double_dash="automatic"` - positional begins; the same argument still splits ordinary values. -- [x] **Fixed arity and distinct value names** — clap can say - `num_args(2)` with ` `. Nested value `var_min` / `var_max` - express the per-occurrence bound without becoming limits on repeatable flag - occurrences, - and the clap bridge now preserves it for positionals and non-repeatable value - flags. Repeatable `Append` ranges are enforced per occurrence. Optional-value ranges - beginning at zero remain bridge-lossy when clap exposes no `default_missing_value`. - Distinct names beside a non-fixed range are reported as lossy and reduced to the first - label so the emitted KDL remains valid. `#[arg(num_args = 2, value_names = ["START", "END"])]` - now preserves the exact bound and each display label through the derive, KDL, - clap bridge, Rust and Go parsers, help, and generated tables. -- [x] **`allow_hyphen_values` on the derive path** — the spec said it and - usage-lib honoured it (`lib/src/parse.rs`); usage-argv now has the same - bit on `Flag`, so a detached value that looks like a flag binds when - declared, including `--`. `#[usage(allow_hyphen_values)]` is the attribute, - and the emitted KDL is `allow_hyphen_values=#true`. A positional that needs - the same thing is already `double_dash = "automatic"`. - **For the fleet this is mostly already spelled:** clap_usage encodes - `allow_hyphen_values` on a trailing argument as `double_dash=automatic`, - which the derive has. That is mise `run`/`exec`/`watch`/`asdf`, aube - `run`/`exec`/`dlx`/`node`, fnox `exec`/`proxy`, pitchfork `daemons add`. - A hyphen-taking _flag value_ that is not also trailing is now the same - declaration on the flag. -- [x] **`require_equals`** — accept `--flag=value` and refuse `--flag value`. - Spec, usage-lib, usage-argv, the derive (`#[usage(require_equals)]`), and - the clap bridge (`Arg::is_require_equals_set`). A short's attached form - (`-i9229`, `-i=9229`) still binds. **Used by:** aube `run --inspect` / - `--inspect-brk`. -- [x] **`#[arg(skip)]`** — a field that is not an argument at all, filled from - `Default`. `#[usage(skip)]` is that: the field stays on the struct so a - rewrite can keep computed state beside parsed state, and nothing about it - reaches the spec, the parse tables, or help. Combining it with `long` or - `arg` is a compile error. **Used by:** mise `run`/`install`/`doctor`/ - `bootstrap`, hk `hook_options`, aube `update`. - -**Changes what a CLI accepts, less sharply** - -- [x] **The full `PossibleValue` model** — `ignore_case`, aliases and their - visibility, per-value help, and hidden values. Subcommand variants take - aliases. The KDL model, usage-lib parser, and clap bridge now preserve this - metadata. Generated Go tables distinguish accepted aliases and hidden values from - the visible diagnostic/help list and honor `ignore_case`; Rust `ValueEnum` now - carries doc-comment or explicit help, hidden canonical values, hidden `alias`, - visible `visible_alias`, and case-insensitive matching through its static metadata - and lossless KDL emission. -- [x] **`infer_subcommands` / `infer_long_args` are intentional non-goals.** Long - flags and subcommands require exact spellings. Diagnostics may suggest what - was probably meant, but accepting a prefix would let a later declaration - change or invalidate an existing invocation. -- [x] **`external_subcommand`** — an unmatched word is forwarded with the rest - of argv. Spec `external_subcommand`, usage-lib, usage-argv, the derive - (`#[usage(external_subcommand)]` on a catch-all `Vec` variant), and the - clap bridge (`Command::is_allow_external_subcommands_set`). Known - subcommands still win; a `default_subcommand` still catches first; a - flag-like token on the parent is still an unknown flag. **Used by:** - aube's catch-all and pitchfork's root. -- [x] **Positionals in relationships and groups.** Bare selectors name - positionals while dashed selectors name flags. Positional conflicts and - group members now round-trip through KDL, the derive and static metadata, - usage-lib, generated Go relationship tables, and the clap bridge. The - fidelity report no longer calls these preserved declarations losses. -- [x] **The complete relationship families** — `requires_all`, - `required_if_eq`, `required_if_eq_all`, `required_if_eq_any`, and the - `required_unless_present` all/any variants. Clap-compatible attributes - accept argument IDs and emit canonical spec selectors. Flags and - positionals preserve the truth tables through KDL, usage-lib, the typed - derive/static metadata, and generated Go tables. `requires_all` lowers to - the existing all-target `requires` representation. -- [x] **`multicall`** — busybox-style applets: argv[0]'s basename selects a - subcommand when it is not the dispatcher. Spec `multicall #true`, - usage-lib (which sees argv0), usage-argv / the derive `parse()` / - Go `RewriteMulticall` (which rewrite at process entry; `parse_from` / - `Parse` stay without argv0), and the clap bridge - (`Command::is_multicall_set`). Path components and a trailing `.exe` - are stripped. **Not used by the fleet today**; clap's own applets and - busybox-style binaries are the reason it exists. -- [x] **`no_binary_name`** — parsing an argv that has no `argv[0]`. The - allocation-free `parse_from` primitive always has that contract, while - clap-shaped `try_parse_from` includes argv0 by default and honors - `#[command(no_binary_name)]` by routing directly to the primitive. -- [x] **`arg_required_else_help`.** The derive, portable spec, usage-lib, - clap bridge, and generated Go front door show short help when the selected - command receives no argv tokens. This deliberately reads argv rather than - bound values: an environment variable or default may fill a field, but an - empty command line still means help. -- [x] **`subcommand_negates_reqs`.** A selected child suppresses every positive - requirement declared by its parent while conflicts and the child's own requirements - still apply. KDL, usage-lib, typed Rust, generated Go, and the clap bridge agree. -- [x] **Remaining command parsing policy.** `args_conflicts_with_subcommands`, - `subcommand_precedence_over_arg`, and `allow_missing_positional` are portable - across KDL, typed Rust, usage-lib, generated Go, and the clap bridge. -- [x] **`args_override_self`.** Repeated scalar flags are corrections by default; - the later value wins. `args_override_self=false` opts a command into duplicate - 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] **Redeclared global values.** When a subcommand redeclares an inherited global - spelling, the nearer declaration parses the token and the value is propagated - into the ancestor's typed field too, matching clap. Attached values are covered - because mise normalizes its short option forms before parsing. -- [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`. -- [x] **Direct `ValueEnum` binding.** The derive converts canonical words and - aliases directly to variants, including case-insensitive declarations, so a - clap migration does not need to add a redundant `FromStr` implementation. - -**Help output** - -- [x] **Colour.** Process-facing help colours headings, usage literals, and flag - names automatically, honoring `NO_COLOR` and `CLICOLOR_FORCE`; explicit - plain/coloured rendering stays available for tests and generated artifacts. - clap's arbitrary `Command::styles` palette is intentionally not reproduced. -- [x] **Visible aliases in generated references.** Markdown and JSON reference - models list every visible short and long spelling while interactive help - retains its compact aligned first-pair layout; hidden aliases stay hidden. -- [x] **`term_width` / `max_term_width`.** Typed and KDL commands can fix help at - a width or cap the detected `COLUMNS` width; zero disables wrapping or the - cap, and a fixed width takes precedence. clap does not expose getters for - these builder settings, so the clap-to-spec bridge cannot recover them. -- [x] **The granular hides** — `hide_default_value`, `hide_env`, `hide_env_values`, - `hide_possible_values`, `hide_short_help`, and `hide_long_help` round-trip - through KDL and the clap bridge and are honored by Rust and Go help output. -- [x] **`next_line_help`.** Command-wide block layout survives typed metadata, - KDL, the clap bridge, and both Rust and generated Go help renderers. -- [x] **`flatten_help`.** Visible subcommands can be expanded into their parent's - usage synopsis and help sections across typed Rust, KDL, the clap bridge, - and Rust and generated Go help. -- [x] **`help_template`.** A root-level template applying to the whole command - tree, as a closed vocabulary of pre-rendered named sections — `usage`, - `about`, `flags`, `args`, `commands`, `after_help` — which an author may - reorder, omit or wrap. That covers clap's actual use case of rearranging - help sections, which a bare `{{ help }}` wrapper would not, while leaving - interpreted Rust, compiled Rust and generated Go to agree only on where - each section starts and ends rather than on layout. The alternative — - exposing the metadata tree and letting the template lay everything out — - was rejected because it makes the help renderer's internals public API and - requires every implementation to match Tera's semantics, not just its - section names. Substitution is a minimal `{{name}}` replacer in all three, - and a section that comes out empty leaves no gap behind, so one template - serves a whole CLI rather than one per command shape. A placeholder naming - no section is refused where a spec is authored: at compile time by the - derive, at parse by KDL. With the template unset every page is assembled in - the default order and is byte-identical to before, which the fleet gate and - Go's 211-page parity suite both hold. -- [x] **`subcommand_help_heading` / `subcommand_value_name`.** Custom subcommand - section labels and synopsis placeholders survive KDL, typed Rust, generated - Go, and the clap bridge and are rendered by both help implementations. -- [x] **`verbatim_doc_comment`.** Commands, fields, and subcommand variants can - preserve doc-comment line breaks and indentation instead of flowing the - first paragraph. The derive uses the same normalization path for all three. -- [x] **`rename_all`, `rename_all_env`.** `Cli` / `Args` fields, `Subcommands` - variants, and `ValueEnum` variants accept clap's full casing vocabulary. - Bare `env` infers its name using `rename_all_env` (SCREAMING_SNAKE_CASE by - default); explicit field, flag, command, and environment names still win. -- [x] **Built-in action and flag control** — custom `ArgAction::Help`, - `HelpShort`, `HelpLong`, `HelpAll` and `Version`, plus `disable_help_flag`, - `disable_help_subcommand` and `disable_version_flag`, including custom help - text on relocated help/version flags. Typed Rust, portable KDL, the clap - bridge, usage-lib, and generated Go can now move or remove these entry - points without losing their action or presentation. -- [x] **The full `ValueHint` vocabulary.** Typed declarations and the clap bridge - lower every stable clap hint into portable completion types. Username and - hostname hints use system candidates where the usage CLI owns completion; - open-ended identity/network values suppress the incorrect path fallback. -- [x] **Short and long version text.** `version` drives the concise `-V` response, - while `long_version` can provide extended `--version` build information. - Typed declarations, portable KDL, usage-lib, generated Go, and the clap - bridge preserve both; computed values pair with explicit spec literals. -- [x] **Explicit `display_order`.** Fields and subcommands retain deliberate - presentation order through typed metadata, portable KDL, the clap bridge, - usage-lib, and generated Rust and Go help. Positional parsing still follows - declaration order; the setting changes presentation only. - -**API surface** - -- [ ] **`update_from` / `try_update_from`** — merge a parse into an existing struct. - The generated parser cannot seed its byte-level partial from an arbitrary `FromStr` - type, so every rule here is explicit rather than inherited accidentally from the - current full-parse path. **Decided (2026-08-21): relationships see the existing - value** — `required`, `requires_if`, `conflicts` and the rest treat a field already - holding a value as present, so an update validates the union of both inputs rather - than this argv alone. **Env and defaults do not overwrite**: they fill only fields - still empty, so an update never clobbers a value the caller set deliberately, and an - update with no relevant argv cannot change the struct. **Collections replace when - argv mentions them** — any values for a `Vec` field in this argv replace the whole - collection, while argv that says nothing leaves it alone, which keeps an update - idempotent and matches how a repeated flag behaves inside one parse; append was - rejected because it leaves no way to clear a field. **A different subcommand - replaces the variant** wholesale, discarding the old variant's fields, since - selecting a command is a routing decision rather than a value to merge. -- [x] **The builder** — `Command::new`, `augment_args`, `CommandFactory`, - `ArgMatches::get_one`, hand-written `FromArgMatches`. Architectural, and - deliberate: usage-lib interprets a spec at run time and covers the dynamic - case from the other side. This is an explicit non-goal: the usage metadata - API does not need to reproduce clap's `Command` surface or be fully source - compatible with it. The migration guide and compatibility matrix publish - that boundary rather than leaving the missing API implicit. -- [x] **Public `CommandFactory` migration.** A library can expose - `pub fn command() -> clap::Command` as part of its supported API, as aube - does. The 6.x transition may intentionally break that API and return a - first-party usage metadata/spec view instead; it does not need to preserve - the complete clap builder contract. The migration guide documents the - major-version break and the choices to retain a clap adapter or expose a - separately named usage spec/view API. - -**What is _not_ a gap**, checked rather than assumed, because two of these were -recorded as gaps here and had quietly been closed: flag aliases (several `long` -and `short` per field), subcommand aliases and hidden aliases, `--help`/`-h`, the -`help` subcommand, `--version`/`-V` with per-command propagation, self-contained -completions for four shells, `flatten`, `global`, `count`, `env`, `negate` -(clap's `SetFalse`), `value_enum`, `num_args` via `var_min`/`var_max`, clap's -`last` via `double_dash`, `help_heading`, `subcommand_required`, declaration -order, non-UTF-8 `OsString`/`PathBuf` values, `requires`, `group`/`exclusive`, -and `delimiter`, `#[usage(skip)]`, `allow_hyphen_values`, `require_equals`, and -`default_missing`. - -And the other direction: `mount`, `restart_token`, `default_subcommand` and -`effect` are things a spec says that clap cannot hear, and `gen-shadow` counts -them. `requires`, `group`/`exclusive`, and `delimiter` are now things a spec -says that clap can only half-hear: the first has no getter, the other two the -bridge reads. - -`double_dash` is the one to state carefully, because two different claims about -it have been made in this file. The **bridge** carries three of the four modes: -`lib/src/spec/arg.rs` reads clap's `last` as `required` and `trailing_var_arg` as -`automatic`, and the default as `optional`. Only `preserve` — where the `--` is -itself a value — has no clap spelling. What drops the other two is **`gen-shadow` -writing a clap shadow**, whose `clap_double_dash` emits only `last`; that is a -gap in the shadow generator rather than in clap, since clap's derive does have -`trailing_var_arg`, and it is worth closing so the shadow stops overstating the -distance. - -### General clap launch gate - -The fleet proves that this can replace clap for the CLIs in front of us. A public -launch makes a broader promise: a clap user must be able to tell, before changing -their parser, whether every behavior they rely on survives. clap's derive forwards -arbitrary `Command`, `Arg` and `PossibleValue` builder methods, so a hand-selected -feature list is not an exhaustive audit. - -- [x] **A versioned clap compatibility matrix.** Inventory clap's public derive - attributes and relevant builder methods, pinned to the clap release audited. - Give every row a result for `usage-derive`, `usage-argv`, the KDL spec and - usage-lib, help/completions, and the `clap_usage` bridge. Each cell must say - supported and tested, usage-only, bridge-lossy, intentionally different, or - unsupported. Updating clap must update the matrix in the same PR. -- [x] **A clap-to-spec fidelity report.** `spec_with_report` and - `generate_with_report` return deterministic structured losses with command - path, argument ID, feature, and source detail. The report covers detectable - arity/name loss, environment bindings, hidden flag aliases, value hints, - non-portable delimiters/defaults, granular hides, and command - parsing/help settings. Setter-only - state such as `requires` and `default_missing_value` remains inherently - undetectable and is named as **usage-only** in the matrix and integration docs. -- [x] **Defaults preserve optionality in metadata.** A default satisfies a - required Rust field without requiring a token from the user. The derive - now clears `required` in the static metadata for defaulted flags and - positionals, so direct KDL emission, both help renderers, completions and - differential tooling all describe the same optional token that runtime - parsing already accepted. The typed tak rewrite covers both shapes. -- [x] **Rust expressions for compile-time metadata.** hk and fnox use constants - for defaults, aube and hk use constants for long help, and all three use - generated or computed version strings. Requiring every value to be copied - into a string literal creates exactly the drift this project is meant to - remove. `version` and `default_value_t` now accept Rust expressions. A - computed version pairs with `version_spec`, and a typed default pairs with - `default`; the expression drives runtime behavior while the explicit literal - keeps emitted KDL deterministic and portable. -- [x] **One Args type used by more than one command.** Static tables belong to - the Args type and command routing belongs to each mounting enum variant, - so one declaration can back multiple commands without duplicate symbols - or cross-command binding. Facade coverage mounts one `SharedArgs` under - two commands, parses both routes, and verifies each emitted command owns - exactly one copy of the shared flag metadata. -- [x] **Inline struct-style subcommand variants.** aube and hk use clap enums - whose variants declare fields directly. usage requires every non-bare - variant to wrap one dedicated Args struct, turning a mechanical migration - into a broad public-type refactor before argv behavior can even be tested. - The Subcommands derive now lowers inline variants to hidden Args structs and - moves the bound fields back into the original enum shape. It accepts both - native `usage` field attributes and clap-shaped `arg` attributes in this - migration form. -- [x] **Clap-compatible value metadata spelling.** Existing domain enums commonly - use `#[value(name = "...", alias = "...")]`. Requiring those attributes to - be renamed solely to change parsers makes migrations noisier and prevents a - transition where clap and usage derive against the same enum. `ValueEnum` - now accepts both `#[usage(...)]` and `#[value(...)]`, while continuing to - leave `FromStr` ownership with the domain type. -- [x] **ValueEnum must coexist with domain parsing and cfg.** aube and fnox enums - already implement `FromStr`; deriving usage `ValueEnum` adds a conflicting - implementation. fnox also cfg-gates individual variants, while usage's - const word list refused holes. ValueEnum now describes choices without - taking ownership of domain parsing, and copies variant `cfg`/`cfg_attr` - attributes onto the corresponding static-table entries. -- [x] **Clap-compatible field spellings and IDs.** Multiple `long` and `short` - entries express flag aliases in usage, but real migrations still have to - rewrite clap's `alias` / `visible_alias`, `id`, `num_args`, `value_parser` - and `rename_all` vocabulary before the derive can explain the semantic - replacement. Accept the lossless spellings directly where practical and - give the rest targeted migration diagnostics rather than a generic unknown - option error. Preserve the visibility distinction too: clap's `alias` and - `aliases` are hidden while `visible_alias` and `visible_aliases` are - advertised; usage spells those `alias_hidden` and `alias`. The fnox rewrite - initially made `completion`'s hidden aliases and `exec run` visible because - a mechanical rename erased that distinction. - `Cli` and named `Args` fields now accept `#[arg(...)]` directly, `id` maps - losslessly to the usage field identity, `visible_alias` / `visible_aliases` - become advertised long forms, and the default `rename_all = "kebab-case"` - can remain on the command. Hidden `alias` / `aliases` retain their parse-only - visibility through KDL, Rust and Go tables, help, completion, and the clap - bridge. Non-default `rename_all` / `rename_all_env` casing and bare `env` - now migrate in place. `num_args` maps to portable bounds (including fixed - arity with distinct `value_names`); optional-value shapes that need - per-occurrence presence semantics and arbitrary `value_parser` callbacks - produce targeted migration diagnostics instead of a generic unknown-option error. - The generated build also takes a zero-cost shared reference to every field, so a - parse-only compatibility flag does not trip an adopter's `deny(dead_code)` merely - because application code intentionally accepts and ignores it. - Process termination emitted by `Cli::parse()` is likewise isolated behind the - runtime's entry-point boundary, so an adopter that disallows direct - `std::process::exit` calls does not receive a lint at the derive site; embedders keep - using the returning `parse_from*` entry points. -- [x] **Command-with-arguments completion hints.** `ExecutablePath`, - `CommandName`, `CommandString`, and `CommandWithArguments` lower to - shell-native completion types. A forwarded argv vector offers commands for - its first value and ordinary argument paths after it; the derive requires - the same positional `Vec` plus `double_dash = "automatic"` shape that makes - flag-looking child arguments parse as values. Rust and Go runtime completion, - generated shell scripts, KDL, and the reference CLI share the vocabulary. -- [x] **Version omission and dynamic version policy.** A literal `version` - remains compile-time metadata, while `version = expression` evaluates a - computed runtime string for hk-style build information. Cold metadata - consumers can call `SpecView::omit_version()` without changing parser - behavior; tak uses that view so release-plz version-only PRs do not dirty - its checked-in spec and docs. -- [x] **Unit and tuple Args migration shapes.** `Cli` and `Args` accept unit - structs directly, so a no-argument command keeps its `Command;` spelling. - Tuple structs remain ambiguous because an unnamed value may be positional - or a flattened Args wrapper; their targeted diagnostic explains the named - `#[usage(flatten)]` rewrite instead of reporting a generic unsupported - shape. Facade tests exercise unit roots and nested unit Args commands. -- [x] **Post-binding relationships through flatten and positional IDs.** Positional - relationships are already a general gap above. The fleet exposed the - second half: a field cannot name a flag contributed by a flattened Args - type because validation runs against the declaring struct before the - command is assembled. aube lost statically declared relationships and hk - and fnox needed runtime conflict checks. Derived `CommandArgs` now expose - selector-stable presence and value lookup across flattened boundaries, so - parent conflicts, requirements, conditional requirements/defaults, and - required-if/unless rules resolve against the composed command without a - dynamic command graph or allocation. Flags use every accepted spelling and - positionals use their stable field identity. Binding-time `overrides` uses - the separate composed hook below because it must act while tokens are bound. -- [x] **Binding-time overrides across flatten boundaries.** `overrides` is - last-token-wins and therefore cannot be implemented by the post-binding - presence/value lookup used for conflicts and requirements. Add a composed - displacement hook that resets an opaque flattened partial, preserves the - losing field's default/env suppression, and handles either token order. A - matching event is identified by the flattened type's static tables, so nested - flattening composes without allocation or a runtime command graph. -- [x] **Flattened help topology.** clap's `next_help_heading` and flattened flag - groups preserve meaningful sections in aube's long help. An Args-level - heading now becomes the default for each direct flag or positional while an - explicit field heading wins. Because flattened metadata tables retain those - headings, both short and long help keep unheaded and named sections in their - declaration order. -- [x] **Facade-owned derive validation.** `usage-rs` exposes portable expression - validation through its `validation` feature and the derive resolves that - facade path when an application does not depend on `usage-validation` - directly. Facade tests cover both `Cli` and flattened `Args` derives. The - aube rewrite enables the facade feature, compiles its validation rules, and - removes the direct implementation dependency and cargo-machete workaround. -- [x] **Central metadata overlays without an MSRV or performance jump.** aube - keeps a centrally audited command-effect table rather than scattering the - policy across command types. Applying that table currently means parsing - derived KDL into usage-lib, which raises an argv-only adopter from the 1.91 - tier to usage-lib's 1.95 tier. hk first did the same and its `usage` - benchmark retired 9x as many instructions; moving every effect into derive - attributes fixed the regression but lost the central declaration. Provide - a typed, borrowed static overlay/spec-view surface for policies that need a - whole-tree view. Overlay resolution belongs only on cold metadata, help and - completion paths: ordinary argv parsing must continue to use the base const - tables directly, without building a command graph, allocating, or consulting - the overlay. `SpecView` and `CommandOverlay` now provide that borrowed cold - path, and aube's fleet PR applies its central effect table through them - without depending on usage-lib. -- [x] **Compiled completions for runtime overlays, multicall projections and async - candidates.** The self-contained completion endpoint can answer only from a - derive-time `usage_argv::spec::Spec`, and custom Rust completers are - synchronous. aube instead appends named completers to KDL at runtime, projects - the `run` and `dlx` subtrees into the `aubr` and `aubx` binaries, and discovers - package candidates asynchronously. Its fleet PR therefore still invokes - `usage g completion`; switching it to `#[usage(completion)]` today would emit a - valid script that silently loses those candidates. Give `usage-rs` a static or - lightweight overlay/projection surface consumable by the compiled completion - walker. Async completers should return futures without choosing or bundling - an executor; the embedding CLI runs them on its existing runtime, and neither - async support nor its allocations enter the ordinary parse path. Cover - alternate binary identities before calling completions - self-contained for embedders and multicall CLIs. fnox exposes the same gap in - a smaller shape: switching it to `Cli::completion_script` dropped the secret, - provider, profile and config-file completers appended from - `fnox-extras.usage.kdl`, so its fleet PR also retains `usage g completion`. - `App` now combines a borrowed `SpecView`, sparse sync/async completion - callbacks, completion runtime identity and command projection. It does not - bundle an executor or change the parser identity used by help and - diagnostics, so it does not satisfy the separate **Runtime program - identity** gate below. aube uses it for all of those cases, including - `aubr`/`aubx` and async registry search; fnox uses it for secret, provider - and profile candidates. Both now generate and answer completions through `usage-rs` - without invoking the `usage` binary. -- [x] **Canonical, duplicate-free derived KDL.** hk's direct `Cli::to_kdl()` - output was semantically accepted but differed substantially from the same - tree after a usage-lib parse/serialize round trip, and repeated identical - `complete "path"` nodes that the round trip collapsed. Make direct emission - canonical and deduplicate composed completers so adopters do not need the - expensive round trip merely for stable generated artifacts. Identical - built-in completion nodes are now emitted once per command. Direct derive - emission now uses KDL 2's canonical plain-identifier rules and is covered by - an end-to-end equality check against usage-lib parse/serialize output, so - adopters no longer need that round trip merely to stabilize generated files. -- [x] **Keep generated-spec producers and consumers on one dialect.** The 6.x - migration is a coordinated epoch, not a promise that a 5.1 CLI can consume - a 6.x-derived spec. Fleet docs tasks install `usage-cli` from the same git - stack as their `usage-rs` dependency, so nodes such as `unknown_flags` are - produced and consumed by one dialect. All release dependencies move to - 6.x together; cross-major spec consumption is intentionally unsupported. -- [x] **Runtime program identity.** aube embeds the same CLI under a caller-chosen - binary name. A derived spec can be rewritten after emission, but parser - help and diagnostics still use the static name. Support a runtime identity - source with an explicit portable `name`/`bin` value, analogous to computed - version plus `version_spec`. Computed `name` / `bin` now require - `name_spec` / `bin_spec`; only help, version, diagnostics, and completion - script paths evaluate them, while successful parsing remains on static tables. -- [x] **Test parsing with argv0.** `parse_from` intentionally takes words after - the binary, while clap tests commonly call `try_parse_from(["tool", ...])`. - `parse_from_argv` is the explicit full-argv helper: it strips the binary - for ordinary CLIs and applies the same basename-selected applet rewrite as - `parse()` for multicall CLIs, while returning errors for tests and embedders. -- [x] **Generated micro-conformance against clap.** A paired typed-CLI harness - compares `require_equals`, defaults, delimiters, negative and hyphenated values, - value enums, scalar overrides, fixed arity, globals through subcommands, required - flags, conflicts/requires, required exclusive groups, optional values with equals, - external subcommands, `arg_required_else_help`, subcommand requirement/conflict - policies, conditional defaults, value terminators, skipped optional positionals, - count/set-false actions, flag and subcommand aliases, case-insensitive value aliases, - help headings/placeholders/visibility, default-missing values, flatten/skip, required trailing - separators, automatic trailing values and delimiter preservation, short/long version output, - conditional requiredness, overrides, positional conflicts, exclusive flags, the argv0-free - entry point, multicall applet selection, and subcommand and value-enum completion candidates. It records typed values, - error classifications, exit status and stream, relevant help, version output, and deterministic - completion candidates. The paired cases cover every behaviorful parser category in the - compatibility matrix; usage-only extensions and intentional differences have native corpus - coverage instead of a false equality assertion. Each minimal CLI is - compared on accepted and rejected argv, typed values, error kind and exit - status, stdout versus stderr, short and long help, usage/version output, and - completion candidates. Include setting-specific diagnostics: for example, - clap explains that `--flag=value` is required when `require_equals` rejects - a detached or missing value, while usage currently reports only a generic - missing value and forced Aube to adapt that error locally. Run the portable - cases on Unix and Windows and the byte-value cases on Unix. The mise fuzzer - remains the scale test; this is the configuration-space test it cannot be. -- [x] **Combination and stateful tests.** Focused typed conformance cases pair - defaults with env and delimiters, optional values with `require_equals`, - globals with overrides, subcommands with required positionals, groups with - defaults, and help/version collisions. `update_from` gets its own stateful - cases if that API is implemented. Single-feature parity is not enough where - settings resolve in an order. -- [x] **External clap adopters.** Source-derived probes pin and exercise three - maintained, non-jdx clap CLIs with different shapes: fd (derive-heavy), - tokei (builder-heavy), and starship (custom values/completions). fd's complete - 57-flag clap command is captured and compiled as a typed usage shadow; - tokei's complete 18-flag builder command and starship's complete 15-command, - 55-flag tree are captured and compile as typed shadows beside their reduced - executable probes. The audit in - `benches/external/README.md` records the unsupported surface instead of - silently dropping the remaining bridge losses and migration adaptations. -- [x] **Migration and non-goal documentation.** The Rust migration guide publishes an attribute mapping guide, - examples for the common `Parser` / `Args` / `Subcommand` / `ValueEnum` - rewrites, compile-fail examples for unsupported combinations, the clap - compatibility baseline, and the parser-behavior semver policy. State which - builder and `ArgMatches` APIs are architectural non-goals instead of leaving - their absence implicit. -- [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. -- [x] **Completion ecosystem coverage.** The 6.0 clap-parity set is bash, fish, - PowerShell and zsh. Nushell remains an explicit usage extension and does not - substitute for a clap-supported shell in the compatibility count. Elvish is a - documented post-6.0 non-goal rather than a release gate; the compatibility - matrix continues to mark completion output lossy for a clap CLI that ships it. - -### The gate - -Everything above is speculative until this passes. Baseline is mise's current -clap parser at mise's real scale, using a shadow CLI generated from mise's -checked-in `mise.usage.kdl` for both parsers. - -- [x] **Shadow generation** — `xtask gen-shadow` turns any `.usage.kdl` into a crate - of derived types. mise's committed 5,592-line spec compiles: 211 commands, 711 - flags, 128 arguments, four levels deep, in 2.6s. What it cannot express, it - counts: 13 secondary flag aliases, 3 `double_dash="automatic"`, 1 default on a - collecting flag. The clap dialect additionally drops the 2 mounts, 2 restart tokens - and 1 `default_subcommand`, which the derive now declares and clap has no vocabulary - for — so the two shadows no longer drop quite the same set, and the report says which - side lost what. - The generated crates are ordinary workspace members: their command enums are boxed, - as the real mise boxes its own, so `large_enum_variant` has nothing to say and no - lint is silenced anywhere. -- [x] **Bench harness** — `xtask gen-shadow … clap` writes the same CLI in clap's - vocabulary, from the same spec and the same traversal, so the comparison is - between parsers rather than between two transcriptions. Both shadows drop the - same properties. Three release binaries — one per parser, one that does - everything except parse — measured by `tak`, which gates the counts in CI. -- [x] **Perf report** — at mise's full scale, a cold parse costs **7,377 instructions - against clap's 6.31M: 855× fewer**, and **0.69µs against 544µs of wall clock**. - Measured by differencing two runs of the _same_ binary over how many parses it - does, so nothing but the parse varies. clap's 544µs is ~343µs building its command - tree, ~178µs validating it, and ~23µs actually parsing — so even against clap's - parse alone, with the tree already built and paid for, this is 34× faster. - **The count went 50.9k → 63.8k → 7,377, and the middle number is the instructive - one.** The rise read as the price of vocabulary and was nothing of the kind. Two - costs scaled with the size of the whole CLI rather than with what was typed. - `Partial` is the entire CLI's accumulator — every command's fields inlined, - recursively, 11KB at mise's scale — and `read_argv`, `read` and `parse_from` each - returned one _by value_, so a parse copied it four times and spent ~87% of itself - copying; `read_into`/`read_argv_into` take `&mut` (#980), 63.8k → 18.5k. Then - `Subcommands::Partial` was a struct with a field per variant, so constructing it - materialised all 211 commands' accumulators when 210 were unreachable by - construction; it is an enum now (#981), 18.5k → 4.2k, the type 11,000 → 824 bytes - and data refs 116,742 → 1,889. Because the accumulator was copied four times per - parse, every property the derive learned widened a struct already being copied, so - ordinary growth arrived multiplied. Removing the copies removed the multiplier: - **metadata a parse does not read now costs a parse nothing**, which is the property - the design claimed all along and only now actually has. - The rest of the movement is the fixture, not the parser, and one parser against - both fixtures separates them: **4,907 against the pre-refresh spec, 7,377 against - the current one**, which #1142 refreshed from mise's real typed tree with more - positionals and per-command metadata. clap moves ~7% across the same swap. usage - moves more in proportion only because its own cost is now small enough that the - fixture's shape dominates it — which is the permanent condition from here, and the - reason the absolute number is worth less than the ratio. - The ratio _is_ watched now: `CLAP_RATIO_FLOOR=80` in `tasks/perf-shadow.sh` warns - when it slides (af2495da), so the earlier "nothing watches it" is answered. At - 855× the margin over that floor is about 10×. -- [x] **Differential fuzzing** — proptest over argv on the mise spec, against - usage-lib **and clap**. The three-way comparison distinguishes intentional usage - defaults from regressions: unknown flags and scalar repeats are permissive unless a - command opts into strictness, while missing subcommands and flag values remain errors. - Fleet commands can request clap parity explicitly; usage-lib stays the standard for - rendering. - It found one real usage-argv bug — a lone `-` selecting the root's default - subcommand instead of binding as a value — now fixed. - It also found that **a fuzzer over a real spec must strip mounts first**: - usage-lib resolves `mount run="mise tasks --usage"` by _running_ it, so the first - draft spawned real `mise` processes that loaded config, fetched vfox metadata and - shelled out to `apt-cache`. See `benches/gate/tests/differential.rs`. -- [x] **Published performance report.** The Rust guide records the current - measurements, the whole path the instruction count took and why it moved both - ways, the absolute gates, the measurement method, and what the comparison does - not include. It links the checked-in benchmark sources rather than - presenting the numbers without a reproducible path. - -Runtime targets, which gate: - -| measurement | clap, measured | target | result | -| ---------------------------------- | -------------- | ------ | ----------------- | -| instructions, route + parse | 6.31M | < 100k | 7,377, 855× | -| wall time, argv to parsed struct | 544µs | < 50µs | 0.69µs, 788× | -| heap allocations, successful parse | 6,560 | 0 | 0 bare, 3–4 bound | - -All three gating targets are met, and not narrowly. Allocations were the last one owed: -a parse with nothing to bind allocates **nothing at all** at mise's scale — 211 commands -and 711 flags, and the allocator is never reached — while binding three or four words -costs three or four allocations, one per value. clap's tree costs **6,560** every time, -so this is about 2,000× fewer. - -Getting there needed one fix and one correction. Defaults were being applied in `start`, -which at the time built the partial for _every_ command in the CLI rather than the selected -one, so a bare `mise` was allocating 60 times for defaults it would never read; they now run -in `check`, guarded on whether the flag was given. (`start` no longer works that way either -— #981 made `Subcommands::Partial` an enum, for the instruction cost described above.) And the counter itself was wrong — armed -per thread but counting into a global — so parallel tests were counting each other and a -4-allocation parse read as 24, intermittently. usage-argv's own counter had the same -latent flaw and now counts per thread too. - -An earlier draft of these numbers said 48–58× rather than 117×, because it subtracted a -baseline measured in a _separate_ no-op binary. Two binaries do measurably different -amounts of setup before `main`, and that difference had been landing in what was -attributed to parsing. Differencing two runs of one binary over how many parses it does -holds everything else fixed. - -Secondary, measured and reported but not gated: compile time (full and -incremental) and binary-size contribution against an equivalent clap-derived -shadow. - -If the runtime targets miss by a wide margin, the honest outcome is to write that -down and stop. Nothing gets integrated into mise before this point. - -### Trying the fleet - -The gate asked whether usage-argv can parse _mise's spec_. That is not the same -question as whether the jdx.dev CLIs can move onto `usage-rs`. The first is -answered: `xtask gen-shadow` on every checked-in fleet spec drops **nothing** in -the usage dialect, and `benches/gate/tests/fleet.rs` holds help to usage-lib -across all seven. The second is what is left, and it is a different fixture. - -The seven are mise, hk, fnox, pitchfork, aube, tak, and communique. usage-cli -already ships on `usage-rs` — it is the first adopter, not a remaining one. - -Shadows are generated from a `.usage.kdl`, and those files are themselves -generated from clap via `clap_usage`. Anything clap does not expose, or that an -older clap_usage dropped, is invisible to the gate. Trying the CLIs means -looking at the clap surface, not only at the spec. - -- [x] **Refresh the fleet fixtures against this crate's `clap_usage`.** The - copies in `benches/fleet/` and `benches/mise.usage.kdl` are snapshots. - Live `mise usage` (2026.8.8) and `aube usage` (1.20.0) still emit no - `delimiter=` and no `allow_hyphen_values`, because they were built against - a clap_usage that did not carry them. This crate now does. Until the - fixtures are regenerated from the CLIs linked to _this_ `clap_usage`, the - shadows cannot see delimiter-split flags that mise, hk, pitchfork and aube - all declare. tak currently has no `usage` subcommand at all, so it cannot - even produce a fresh spec without one. The experiment may add `usage` or - `--usage-spec` solely to expose the new canonical metadata; that new entry - point has no clap-era behavior to preserve and is excluded from tak's - compatibility baseline. - The six completed typed adopters now supply current `usage-rs` fixtures and - regenerated shadows for hk, fnox, pitchfork, aube, tak, and communique. Their - delimiter, optional-value, relationship, and command-policy metadata is therefore - visible to the gate. mise now supplies its typed `usage-rs` metadata from - jdx/mise#12221; the refreshed fixture and every generated shadow are checked in. - That refresh exposed shadow-generator gaps for required switches and command - groups, which are fixed with the fixture rather than - being mistaken for adopter losses. -- [x] **Docs and manpages on a fleet spec.** communique's checked-in spec and - the KDL its shadow emits render the same markdown (index and every - command) and the same manpage. usage-cli's own - `render:usage-cli-completions` already does this for `usage`; the gate - now asks it of a clap CLI. -- [x] **Typed rewrites of communique, tak, aube, hk, and fnox, not String - shadows.** `gen-shadow` - types every field as `String`. The derive already holds `PathBuf`, - `OsString`, `ValueEnum`, `FromStr`, `flatten`, `Option`/`Vec`. usage-cli - proves that for usage's own types. These five will prove it across real - clap CLIs rewritten in place: real field types, skip-fields or the split - they force, and binaries that preserve every pre-existing `--help` and - spec-emission entry point. tak's experiment-only spec entry point is tested - as new behavior rather than compared with a nonexistent baseline. Each - experiment is a ready-for-review PR whose `Cargo.toml` - deliberately points at usage's git revision; the PR is evidence for the - 6.x gate, not something to merge before usage 6.x is published. Together - they tell us whether the fleet is a rewrite or a set of blocked rewrites. - **The experiment PRs now exist and all five modify the real CLI:** - jdx/communique#265, jdx/tak#47, jdx/aube#1336, jdx/hk#1211 and - jdx/fnox#725 all remove clap, compile against the stacked usage changes and - pass their migrated test suites. The ports preserve their typed domain - values rather than lowering to String, keep intentional forwarding behavior, - and opt strict CLIs into `unknown_flags="error"`; aube remains permissive at - the root because its external-subcommand path is a package-manager forwarder. - All five use the `usage-rs` facade for parsing and derives and pin the - relevant 6.x experiment-stack revision. hk, aube and fnox also use its - built-in compiled completion protocol, removing their runtime dependency on an - installed `usage` binary. aube's remaining direct `usage-validation` - dependency is the facade-validation gap above. The workarounds they still - contain are the unchecked launch-gate rows above, not unfinished conversions. -- [x] **The clap-only validation behaviour the fleet actually uses.** Portable - `validate` expressions cover numeric ranges in the typed rewrite. Arbitrary clap - parser functions remain opaque to `clap_usage`, but they no longer require a - Rust-only extension to the spec: the rewrite declares the equivalent expr rule. - -`external_subcommand` and `default_if` have landed: the parser, the derive, and -the corpus all say them. clap's bridge reads `allow_external_subcommands`; -`default_value_if` is a setter with no getter, same hole as `requires`. They -are off this table because they no longer change what a rewrite accepts. - -`value_delimiter`, `requires`, `allow_hyphen_values`, `require_equals`, and -`default_missing` are not in that table because the _parser_ can say them. They -are lost only on the clap → spec round trip (and a non-ASCII delimiter is -dropped even then; `default_missing_value` has no clap getter), and a rewrite -that declares in usage keeps them. `#[usage(skip)]` is a compile-time field, not -a command-line shape. Trailing argv is already `double_dash=automatic` in every -fleet spec that has one. - -- [x] **The grammar decision that would change mise at run time**, not just at - completion time. Unrecognized flags falling through to positionals is how - mise parses task arguments; tightening it is a behaviour change to every - `mise run`. **Decided: the default stays lax, everywhere, and strict is - opt-in per spec** — recorded in the divergence list below — so a rewrite - changes nothing about what a task accepts. Repeated `--` handling was - settled and fixed in #809. - -**Not on this list, on purpose.** Config is a second project: the four CLIs -would keep their generated `Settings` through an argv-only move. Mounts are -declared and emitted; usage-argv does not execute them, so `mise run`'s task -names still come from `src/cli/usage.rs` until a later stage. Root `mount` and -root `subcommand_required` are spec-shape questions none of the seven needs — -usage-cli's root requires a subcommand in the type, and the usage line already -says ``. Publishing the perf report is documentation of the gate, not -a prerequisite for trying a CLI. - -### After the gate - -- [x] **usage-cli** — the first adopter, already shipping. It parses with - `usage-rs`, emits its spec from the same tables, and feeds that spec to - the markdown, manpage and completion generators. The remaining items in - **Trying the fleet** are about the _other_ CLIs, not this one. -- [x] **communique, tak, aube, hk, and fnox** — five ready-for-review fleet - experiment PRs parse their real typed commands with usage, remove clap and - pass locally. They deliberately retain a git dependency and are evidence for - the 6.x gate rather than merge candidates before publication. tak's added - spec endpoint is experiment-only and outside its preserved CLI contract. - The gaps found are recorded in the general launch gate above; closing the - merge-blocking rows is required before publishing 6.x and converting these - experiments into release-dependency PRs. -- [x] **mise** — jdx/mise#12221 replaces the main command tree and optional vfox CLI - with `usage-rs`, removes the direct clap dependencies, emits the canonical spec - from the same typed metadata, and uses first-party help and completions. Its CLI - test corpus exposed count actions, one-member implicit groups, and redeclared - global propagation gaps; those fixes live in their owning usage stack PRs. -- [x] **pitchfork** — jdx/Pitchfork#754 removes clap, parses its typed command tree - with `usage-rs`, uses the built-in completion protocol, and passes its Rust and - bats suites against the stacked usage revision. Its refreshed spec is part of the - fleet gate alongside the five original typed experiments. -- [x] **Other languages** — Go parses, validates, renders help and answers - completions from generated static tables, verified against the shared corpus. - That is the proof a second implementation can reach parity from the spec alone, - which is what this row existed to establish. JavaScript and Python are off the - plan (2026-08-21): no adopter asks for either, and each would owe a runtime - packaging and support-window decision — vendored versus published on npm/PyPI, - an oldest supported Node and Python — that only a real consumer can settle. - -### What adoption should let mise delete - -Checked against mise rather than assumed, and two of them do not survive contact. - -- `GLOBAL_FLAGS_WITH_VALUES` and `first_non_global_arg_idx` (`src/cli/mod.rs`) — a - hand-maintained copy of the root's value-taking flags, plus a test asserting it still - matches clap. Its own comment says why it exists: `env.rs` needs it from `Lazy` statics at - startup, and deriving it means building clap's tree, "which costs ~3.1M instructions… what - made every mise command ~6.3M instructions more expensive". With `&'static` tables there is - no tree to build, so that code can read the real thing. **This is the one that should - disappear outright, list and guard test together.** -- `src/cli/usage.rs` — post-processes the emitted spec: clears `run`'s arguments, adds a - mount and a restart token. The derive now declares `mount`, `restart_token` and - `default_subcommand`, so those three patches become attributes on the commands that own - them and the file should end up near empty — what remains is clearing `run`'s arguments, - which is a consequence of the mount rather than a separate hack. It already records one - hack that went away when jdx/usage#738 landed. -- `src/cli/command_effects.rs` — 451 lines classifying each command as read, write or - destructive, "because mise's usage spec is derived from clap, and clap has no way to - express this". The derive can express `effect` inline, so the _workaround_ reason goes — - but the file also argues that a safety classification is easier to review as one list than - as annotations over sixty files, and that argument survives any framework. Offer the - annotation; do not assume the table should go. -- `Run(Box)` — boxed to stay out of trouble with clap at that size. The clap reason - goes; the stack-size reason is real, and boxing stays supported. -- `src/assets/mise-extra.usage.kdl` — **not** a clap workaround. It is mostly a - `source_code_link_template` for the docs, which a spec is the right home for. - -### What adoption should let the rest of the fleet delete - -A survey of the whole jdx.dev fleet (2026-08-19), done the same way as the mise -list above: checked against the source rather than assumed. Every CLI is on -clap 4.6 derive, every one already generates its spec through clap_usage, and -every one carries glue of the same three species — argv rewritten before clap -runs, metadata clap cannot express spliced into the generated spec afterwards, -and completions that depend on a separately-installed `usage` binary. - -**mise**, beyond the delete-list above: - -- `escape_task_args` / `unescape_task_args` (`src/cli/mod.rs:447-696`) — ~200 - lines plus eight tests that prefix task-side flags with `\x00MISE_TASK_ARG\x00` - so clap will not bind them, then strip the prefix after the parse. It exists - because mise runs two parsers per invocation — clap for the mise side, usage - for the task side — and words must be smuggled across the boundary. A - usage-native parse with `restart_token` (already declared) has no boundary to - smuggle across. -- `preprocess_args_for_naked_run` (`src/cli/mod.rs:698-732`) — hand-scans argv - to inject `"run"`, and must therefore re-know which global flags take values. - Routing on `default_subcommand` (landed above) is the replacement. -- The hand scanner has a confirmed user-facing bug clap does not: - `mise --env=production` is silently ignored while `mise --env production` - works (jdx/mise discussion #8883) — the cost of a third partial flag parser. - `hook_env.rs:202-222`, `activate.rs:209-239` and `version.rs:88-98` each - carry another copy of the same knowledge. -- The deferred `bootstrap` subtree — a stub plus a hand-written `FromArgMatches` - (`src/cli/bootstrap.rs:41-76`), purely to keep clap's tree-build off the hot - path. Static tables have no tree to defer. -- `tool_stub.rs:659-678` ignores clap's parse and re-reads raw argv "to avoid - version flag interception". -- `task/mod.rs:1870-1912` reconstructs the `--` separator clap consumed, by - suffix-matching argv, because clap reports `last=true` values but not where - the separator stood. -- `mcp.rs:127-131` re-implements hide-propagation ("clap does not propagate - `hide` to children, so a visible child of a hidden parent is still not a - documented path"). -- `completion.rs:136-140` accepts `pwsh` in one command and `powershell` in - another, because two clap `ValueEnum` lists were declared separately — - whichever name a user learns first is rejected by the other. - -**aube** — the heaviest workaround load after mise: - -- `is_usage_invocation` in `main.rs` intercepts `aube usage` before clap runs; - multicall dispatch for the `aubr`/`aubx` shims also happens pre-clap. -- `multicall_usage_spec()` (`commands/completion.rs`) — ~80 lines of spec - surgery (shift_remove a subcommand, splice globals in, rewrite name/bin/usage - strings, force `var_max` and `double_dash`) to fake standalone `aubr`/`aubx` - specs out of the generated one. -- `command_effects.rs` — a 100+-entry hand table, same species as mise's. -- A manual `--version` flag (`lib.rs:87`) because clap's auto-version exits - inside `parse_from`, before the tokio runtime that runs the async update - notifier is built. -- `trailing_var_arg + allow_hyphen_values` on five forwarding commands; - `num_args = 0..=1` + `require_equals` + `default_missing_value` for - `--inspect[=HOST:PORT]`; `overrides_with` pairs for hand-rolled - `--sort`/`--no-sort`; a hand-written `FromArgMatches` on `PruneArgs` with an - `AtomicBool` side channel; and `npm_fallback.rs`'s hidden catch-all stub - commands for npm-only verbs. - -**hk**: - -- `reexec_for_cd` (`src/cli/mod.rs:86`) hand-walks argv to strip `--cd` and - re-exec in the target directory, with per-platform OsStr byte handling — - clap gives no way to re-render parsed args back into argv. -- Hand-rolled `--fail-fast`/`--no-fail-fast` and `--stage`/`--no-stage` bool - pairs with mutual `overrides_with`, plus repeated `conflicts_with_all` - string arrays — the spec's `negate` is the declaration these want to be. -- `--why [STEP]` is `num_args = 0..=1, default_missing_value = ""`, an - empty-string sentinel meaning "all steps". -- Completion invocations were loading full project config until special-cased - (hk#615) — the cost of completions being ordinary subcommands of a heavy CLI. -- `command_effects.rs`, again. - -**fnox**: dynamic completers via hidden `--complete` probe flags on three -commands plus a hand-written extras KDL; `min_usage_version "1.3"` hardcoded -while siblings emit `"4.0"` — sidecar drift in the flesh; `trailing_var_arg + -allow_hyphen_values + ValueHint::CommandWithArguments` on `exec` and `proxy`. - -**pitchfork**: default-subcommand fallthrough via `external_subcommand` plus a -second `StartFallback` parser with `bin_name = "pitchfork start"` -(`src/cli/mod.rs:70-131`), and its own `command_effects.rs`. - -**communique and tak**: no shell completions at all, despite both having specs — -the wiring cost is real enough that small CLIs skip it. tak also sets -`spec.version = None` post-hoc (`src/main.rs:817`) so release-plz's version-only -PR does not fail the generated-reference CI check; clap_usage has no knob for -it. communique keeps its spec honest with a unit test that tells you to -regenerate by hand. - -The cross-cutting counts: command-effect sidecar tables in five repos, extras -KDL spliced onto the generated spec by string concatenation in four, the -external `usage` binary as a runtime completion dependency in three — the -most-reported public issue class (jdx/mise discussions #5659 and #5675, -nixpkgs#343832), and the reason mise now maintains a prerendered static -fallback pipeline — hand-rolled negation pairs in two, and spec-sync CI chores -in all of them. - -### Gaps to close before dogfooding the fleet - -The fleet's workarounds also route around usage, not only clap. These are the -items the survey adds; the ones already tracked as unchecked boxes under **What -clap can say that we cannot** (positionals in relationships, the complete -relationship families, remaining command parsing policy, fixed arity, the full -`ValueHint` vocabulary) are not -repeated here. - -- [x] **The completer channel is unescaped Tera into `sh -c`.** aube's - `completion.usage.kdl` documents the quote-escaping gymnastics, and its - `extra.usage.kdl` records a `-C` non-forwarding limitation outright: - "usage's only channel for the typed words is tera interpolation into a - `sh -c` string, with no shell-quoting filter". A quoting filter — or a - structured argv channel — is owed before dynamic completers are a - recommendation rather than a hazard. The completion renderer now provides a - `shell_quote` and `shell_join` filters backed by `shell_words`, reject invalid inputs, - and documents that interpolation remains raw unless the author opts into the - filter. `run=` remains a shell program rather than being narrowed to an argv - vector, preserving pipelines and the modern shell-out completion use case. -- [x] **A multicall CLI cannot describe its applets.** aube's 80 lines of spec - surgery for `aubr`/`aubx` is the requirement written as a workaround: a - spec (or the derive) should declare a sub-view — name, bin, a subset of - commands, the global flags — without the host mutating a generated spec - by hand. Related: help and diagnostics render the compiled-in name, so an - embedder with a dynamic identity is stuck with the static one. - **Direction decided (2026-08-19): a spec-first `view` node** on the root, - carrying name, bin, the command subset, and which globals carry over, - that help, completions and docs all read, with the derive lowering an - attribute into it. Not a derive-only emission, and not a blessed - transform API: the spec defines. Root `view` nodes now promote a command - path, rename its executable surface, and carry all or selected globals. - Derives emit them, argv0 dispatch and built-in completions project through - them, and the documentation generators materialize the same portable view. -- [x] **Post-parse hooks stay application-owned.** `parse_from` and - `parse_from_argv` return `Error::Help` and `Error::Version` instead of - exiting, so aube can run its notifier or customize version output before - rendering. A successful value likewise lets hk handle `--cd` before - dispatch. The help guide documents these interception points explicitly; - `parse()` remains the immediate print-and-exit convenience path. -- [x] **MSRV tiers stay separate.** `usage-lib` remains on Rust 1.95, while - `usage-argv`, `usage-derive`, `usage-validation`, and the `usage-rs` - facade remain on Rust 1.91. The facade emits specs, help, diagnostics, and - compiled completions without linking usage-lib, so the fnox rewrite keeps - its 1.91 floor. Higher-MSRV docs tooling remains a separate installed - `usage-cli` process rather than raising the embedding CLI's requirement. -- [x] **The `parse_from` argv0 contract.** It differs from clap's, which broke - fnox's test helpers in the rewrite experiment. `parse_from` remains the - allocation-free words-only primitive; `parse_from_argv` is the explicit - clap-shaped, argv0-taking variant and preserves multicall selection. -- [x] **Shared `Args` under multiple commands do not need wrapper types.** Keys - are checked per command because that is their routing scope, while each - subcommand variant selects the shared `CommandArgs` table independently. - The same facade regression covers the fnox full-command shape; mise's - flattened `ConfigLs` reuse remains covered by the fleet shadow. -- [x] **Post-binding relationships across a flatten boundary.** A parent flag can name a flag - or positional contributed by a flattened group. The nested partial answers - selector lookup through `CommandArgs`, preserving the same argv/env/default - presence semantics as relationships declared inside one struct. hk and aube - can remove their post-bind conflict checks when repinned to this stack tip. - Binding-time `overrides` is tracked separately and rejected across the - boundary until composed displacement exists. -- [x] **Checked-in specs vs release automation.** `SpecView::omit_version()` - removes the runtime version from a cold emitted metadata view without - changing the base derived spec or built-in `--version` behavior. tak uses - that view for its checked-in KDL and generated reference, so release-plz's - version-only PRs no longer dirty those artifacts. A later `version(...)` - override restores an explicit version when a consumer needs one. - -### clap's backlog, read as a roadmap - -A pass over clap's most-upvoted open issues (2026-08-19), asking two questions: -what do clap users want that usage already has, and what demand should shape -what gets built next. - -**Already answered here — promotion material for the docs, later.** A striking -share of clap's top-voted open requests is usage's existing feature set: - -- Dynamic completions (clap#3166, 102 votes, plus clap#1232's 157 before it was - folded in) — clap's native completion engine has been unstable for 4+ years; - runtime completion served from the spec is usage's core architecture. Nushell - (clap#5840) comes with it, which clap_complete lacks. -- Automatic negation flags (clap#815, 66) — `negate`. -- Argument validation on globals (clap#1546, 48) — globals go through the same - post-binding checks as everything else. The differential gate now pins the - difference on mise's own spec: `--profile` declares `conflicts=--env`, and clap - enforces it only while both spellings land on the same command, so - `mise --env config set --profile=v` is accepted there and refused by both usage - parsers. -- Partial parsing that captures unknown args instead of erroring (clap#1404) — - the spec's lax `unknown_flags` mode, which mise task parsing runs on. -- Command chaining (clap#2222, 29) — `restart_token`. -- Manpage customization (clap#3354) — generated from Tera templates rather than - a fixed renderer. -- `args_override_self` as the default (clap#4261) — the grammar's "a repeat is - a correction" rule. -- Default subcommands (clap#3857 and clap#4442, both closed "not planned", - with the discussion still active in 2026) — `default_subcommand`, declared, - routed, and completing. -- GNU-correct optional option-arguments (clap#3030, where fixing the default - "likely isn't" possible without breaking rustc and cargo) — - `default_missing`, under which `--color bar` binds the missing value and - leaves `bar` a positional, with `require_equals` beside it. -- Help order following declaration order (clap#1807, 25 comments of stalled - design) — a spec is ordered, so help order is spec order, with - `help_heading` on top. -- A machine-readable export of the whole CLI (clap#918, open since 2017, and - discussion clap#6491 asking for schemas AI agents can read) — the spec _is_ - the export, `effect` is the safety vocabulary an agent wants, and usage-cli - renders JSON already. clap#6026 was closed with advice to "implement your - own argument parser" — which is this project. **And the binary now hands it - over itself** — see the endpoint item below, which is what makes this an - answer for a tool in front of somebody else's CLI rather than for its author. - -When the migration guide is written, a "top clap feature requests that just -work here" section is cheap and persuasive; the launch-gate documentation items -above are where it lands. - -**Worth building, demand attached:** - -- [x] **A binary that describes itself** (clap#918, clap#6491) — `__usage_spec__`, - answered from the same static tables the parser reads, in every binary that - does not opt out. The gap was never `to_kdl`: it was that getting a spec - _out_ of a CLI was a convention each adopter reimplemented, which the docs - taught as `#[usage(long, hide)] usage_spec: bool` — so no tool in front of - somebody else's CLI could rely on it, which is the only position that - matters for `usage g …`, `usage lint`, `usage mcp` and an agent reading - `effect`. Four decisions, settled 2026-08-21. **A hidden word, not a - flag**, for the reason `__complete_word__` is one: a request is not - something the CLI _does_, so it is answered before the parse, stays out of - the tables, cannot collide with an adopter's flags, and does not perturb - the document it prints. **On by default**, which is the whole point — an - opt-in nobody remembers to write is an endpoint nothing can rely on — with - a declaration of that spelling winning (checked against the tables, since a - `Cli` derive cannot see a separate `Subcommands` enum's variants) and - `spec_endpoint = false` for a binary counting bytes, worth 65KB on a small - CLI. **KDL only**, since `usage g json -f -` converts and a hand-rolled - serializer in a dependency-free crate would be a second emitter to keep in - step with the serde-derived shape. - **`spec_extra`** appends a file's KDL to `to_kdl()` for a node no attribute - carries. It was built for usage-cli's `repository` and - `source_code_link_template`, and #1184 gave both of those real attributes - while this was in review — which is the canonicality rule working as - intended, and leaves the hook with no in-tree user. Kept as the escape - hatch rather than removed, and documented as one; reconsider if nothing - needs it. usage-cli answers both spellings, reaching the endpoint through - `is_spec_request` because it renders its own output rather - than calling `parse()`. Not projected through a `view`: the document already - declares every view, so projecting would hand a tool a lossier spec - depending on which name launched the process. -- [x] **Subcommand help headings** (clap#1553, 38 votes) — `help_heading` - landed for flags and arguments; this is the same property on `cmd` nodes, - so a 210-command CLI can group its help into sections. mise is the - obvious first user. It is now portable through KDL, the typed derive, - usage-lib and generated Go help. clap#4589 asks for prose under a heading — Deno wants - per-section doc links — which is the same node with one more field. -- [x] **Deprecation and stability metadata on flags and commands** (clap#3321) — - `deprecated`, with warn/remove versions, is portable through KDL, typed - Rust, generated Go, help, docs and completions from one declaration. - **And a parse now says so**, which is the half that actually moves anybody - off an old spelling: a deprecated flag that was given, a deprecated command - that was selected, and a value that arrived through a `deprecated_env` - alias each come back as a structured warning from usage-argv and usage-lib - alike — reported rather than printed, per the rule config resolution - already follows, with `parse()` rendering them to stderr because it is the - entry point that is the process. `deprecated_warn_at` gates it: the - comparison rule for versions is written down in `docs/spec/argv.md` and a - conformance test holds both implementations to it. Generated Go and corpus - vectors are the follow-up; a positional still cannot be deprecated at all, - since the spec has `deprecated` on a flag and a command and not on an - `arg`. -- [x] **Ordered environment fallbacks and deprecated aliases** — explicit - full-name deprecated env aliases (clap#5447) stay greppable, while - clap#5925's fallback across several env names preserves declaration - order. This is parser behavior rather than command/flag presentation, so - it remains separate from the deprecation metadata above. -- [x] **A group as an enum in the derive** (clap#2621, 102 votes — tied for - clap's most-requested) — mutually exclusive flags declared as enum - variants, lowering to the `group`/`conflicts` vocabulary the spec already - has. Derive ergonomics rather than new spec surface, and clap has sat on - it since 2021. **`#[derive(usage::ArgGroup)]` on the enum**, held by an - `Option` field for an optional group and a bare - `Mode` field for a required one. A new derive rather than an overloaded - `ValueEnum`, because the same enum would otherwise lower two entirely - different ways depending on the field holding it; and an enum rather than a - `group = "mode"` attribute over `bool` fields, because the stringly-typed - "which one was set" match is exactly what clap#2621 exists to remove. - **Bare variants only**, each a valueless flag, which is what the issue asks - for — a group whose members take values stays a hand-written `conflicts` - set, so group resolution never runs the typed-value path. **No default - variant**: required-ness is the `Option` versus `Mode` distinction and - nothing else, so defaults stay a per-flag concern and there is no second way - to spell one, nor a new spec node for help, completions and the docs - generators to describe. **Two members on one command line is an error**, - matching clap's `ArgGroup` and the `conflicts` vocabulary this lowers to; - exclusivity is the point, so a typo is reported rather than silently - resolved to whichever came last. Nothing new reaches the spec: the enum - emits the `group` node and the switches it names, so KDL, usage-lib, help, - docs and completions all read an ordinary group, and one field holding the - enum is what a command declares. -- [x] **Recursive help** (clap#4813) — `ArgAction::HelpAll` renders long help - for the selected command and every visible descendant in one depth-first - output. Typed Rust, portable KDL, usage-lib, and generated Go retain the - action and share the same hidden-command boundary. -- [x] **Non-strict choices** (clap#5885) — `choices strict=#false` and typed - `choices_strict = false` keep known values in help and completion while - accepting unknown values. Strict validation remains the default. mise's - tool names are this exact shape: a registry to offer, arbitrary backends - still legal. -- [x] **Completion through shell aliases** (clap#1764, stalled since 2020). - This is an explicit registration API rather than shell-specific alias discovery: - `completion_script_for_alias("m", shell)` registers `m` while invoking the real binary - for answers. The lower-level `script_for` and embedded `App` API expose the same split. - Generated scripts therefore do not depend on interactive alias expansion or add work to - ordinary parsing. -- [x] **Multi-segment path completion** (clap#5279). The shared runtime resolves the - typed parent path and preserves every segment in the candidate, so - `target/de/inc` completes to `target/debug/incremental/`; an end-to-end regression - exercises the same `complete-word` entry all generated shell scripts call. -- [x] **`--flag=false` on booleans** (clap#5577; clap#1649 closed with 28 - reactions behind it) — **semantic decided (2026-08-19): opt-in per flag, - and `=`-attached only.** `--flag=false` binds; `--flag false` never does, - so the `=` settles the next word's role and no existing bool changes - behavior. The wider rule that comes with it: optional values on flags - deserve an admonishment in the docs, and possibly a lint — a detached - optional value is ambiguous to a human reader even where the grammar - resolves it (the parser already gives `--color bar`'s `bar` to the - positionals) — so the recommended declaration is `default_missing` with - `require_equals` beside it. This is now `bool_value=#true` in KDL and - `#[usage(bool_value)]` in typed Rust. The Rust and Go binders accept only - exact attached `true`/`false`, generated Go and typed structs apply the - explicit value (including negated spellings), and detached words remain - positionals. -- [x] **`license` metadata** (clap#1768) — `author`, `license`, and `repository` - are first-class typed root attributes, survive direct KDL emission, and render - in Markdown, manpages, and generated Go long help. GPL display requirements no - longer need an application-owned documentation patch. - -**Considered and declined:** - -- **Alias into a nested subcommand** (clap#1603, reopened, 22 comments) — - rustup's `install` meaning `toolchain install`, args carried along. The - portable shape was worked out: a root `redirect "install" to="toolchain install"` - node with a matching typed attribute, rewriting only the first command word, - preserving trailing argv verbatim, letting a real command win a name collision - and rejecting redirect chains. **Declined (2026-08-21): not useful enough to - build.** None of the fleet asks for it, clap's own unstable `App::replace` - answer died for lack of interest, and it buys a second name for a command that - aliases at the wrong level already cover in the common case. Reopen if an - adopter needs it; the shape above is the design, not a fresh question. - -Demand also attaches to boxes already open above: fixed arity with distinct -value names is clap#1717 + clap#1682 (31 votes combined); `Option` on a -flattened group is clap#5092 (18) — the derive refuses it for lack of a rule, -and the votes say people want the rule defined; visible aliases on enum values -is clap#4416, stalled in clap on binary-size grounds a spec interpreter does -not have; and a help template set once for the whole tree is clap#1184, which -the `help_template` row above now answers — one template at spec root, laying -out every page in the tree. - -Noted, not taken — one item: conditional argument groups unlocked by a flag's -value (clap#6258), the missing quadrant beside `requires_if`, `required_if` -and `default_if` — "this flag is invalid unless that flag has value X". Not -built on its own, and not designed on its own either: it is a constraint on -the group-as-enum item above, whose design must leave room for a group whose -membership condition is a value. The enforcement half may already have a home -in the expr layer: `validate` today scopes one value, and widened to command -scope over all bound flags, "`--dockerfile` without `driver == "docker"`" is -one declarative expression — covering this and the long tail of cross-flag -rules without new vocabulary. What an expr cannot do is the other half: help -and completions cannot read a black-box expression to know not to offer a -flag, which is why the structured group vocabulary stays the answer for -anything those need to understand. - -**Declined: `env_prefix`** (clap#3221, 45 votes). Assembling `MISE_JOBS` from a -prefix and a field name makes the one string a user actually sees ungreppable -in the codebase that declares it. Env names stay fully spelled at the -declaration site. - -**Declined: conflict-aware positional skipping** (clap#1794, Deno's shape, -still unmerged in clap in 2026). Handing a word to the _next_ positional -because a flag elsewhere on the line conflicts with the first one requires the -binder to consult relationship tables mid-parse, and the architecture rule is -that binding stays relationship-free — every check that needs more lives after -the parse. It is also bad grammar independent of the architecture: which slot -a word lands in would depend on the rest of the line, which is unpredictable -for exactly the readers a spec serves. - -**Declined: case-insensitive subcommand matching** (clap#6097, closed "not -planned" in clap as well). The demand comes from mobile keyboards and chat-bot -REPLs, not shells; no fleet CLI wants it; and the hot path's exact byte -comparison against static tables is budget not worth spending here. - -**Non-goals, now stated rather than implied:** interactive prompts (clap#1634); -non-Unix option styles — `find -exec`, `/c`, `-Wl,` (clap#2468) — the framework -targets GNU-style CLIs on purpose; and no_std (clap#1485), though usage-argv -being dependency-free and allocation-free means the distance is small if -embedded ever matters. i18n (clap#380, open since 2015) is the long-term -sleeper: clap structurally cannot do it because every string is compile-time -Rust, while a spec is data — not built now, but worth a line in the vision -docs. - -## Not covered by the corpus yet - -- [x] **Restart tokens** — the completion corpus covers returning to the first - argument, prefix filtering after the restart, and flags remaining available. - This is the observable contract restart tokens have while a line is partial; - successful binding remains one invocation at a time. -- [x] **Mounts** — corpus vectors inject deterministic stdout by exact `run` - declaration, so usage-lib exercises discovery without spawning a process - and static-table runners compose the same mounted sub-spec before binding. -- [x] **Completion parsing** — `corpus/complete` is the separate partial-input - corpus, with line/cursor positions, candidates, path fallback, and explicit - reference-agreement labels. It runs against usage-argv and usage-cli. - -## Known usage-lib divergences - -**The corpus records none today**: usage-lib answers every vector, and so do -usage-argv and the Go runner. That is a measurement, checked on every run -rather than asserted here. What is left below is the history, plus the one -item marked _needs a decision_ — which is not a divergence but a question about -what the grammar should say. - -They were bugs to fix or decisions to revisit, not settled behavior. Each was a -small change to `lib/src/parse.rs`, and the corpus is how a fix got verified — -including telling you to delete the label afterwards. - -- [x] Help printed everything marked `hide` — hidden flags, hidden arguments, hidden - subcommands. The usage _line_ filtered them already, through `SpecCommand::usage`, so - `ex --help` listed a `--secret` that the line above it did not mention; markdown and - manpage rendering filtered too. The help templates were the one place that did not. - Found while building usage-argv's renderer, which would otherwise have had to reproduce - it for parity. -- [x] **usage-lib accepts three things usage-argv and clap both refuse** — _withdrawn as - stated, and the correction is the useful part._ The differential fuzzer found three, and - this entry called all three usage-lib's to tighten. One was: `subcommand_required` was - in the spec and no parser read it, fixed in #992. **The other two are the grammar working - as specified**, and the corpus says so in its own words — `long-repeated-keeps-the-last` - ("a repeat is a correction… the later occurrence wins") and `long-unknown` ("more likely - data in transit than a mistake", with `unknown_flags "error"` as the opt-in). Acting on - the wrong reading got as far as three failing conformance vectors. - Repeated command-line occurrences now follow the same permissive principle: a later - scalar corrects an earlier one, while `args_override_self=false` opts a command into - strict duplicate errors. Separately, `Spec::to_kdl` validates `duplicate_flag_form` at the spec boundary so - two declarations cannot claim the same spelling. Unknown flags are intentionally different: usage parsers - are permissive by default and a command opts into `unknown_flags="error"` when it owns the - whole grammar. That is what fleet adopters should declare for clap parity, while forwarding - commands such as `mise run` keep the default. `differential.rs` carries a named test so - tightening usage-lib fails with the reason attached. -- [x] Unrecognized flags fall through to positionals, so `ex --wat` binds `--wat` - to an argument, or reports `unexpected_arg` when there is none. **Decided - (2026-08-19): lax is the default everywhere — both parsers — and strict is - the opt-in, `unknown_flags "error"` at whatever scope wants it.** This is - a position, not a compatibility concession: clap's strict default is held - to be the wrong one — a wrapper appending to a command line it did not - write is ordinary, which is the grammar's "data in transit" rationale — - and what `mise run` tasks accept does not change. Repeated scalar flags follow the same - default and have their own `args_override_self=false` strict opt-in. - The migration guide presents both differences as intentional, with strict - one root-level line away, as communique's rewrite already declares it. -- [x] A flag missing its value is dropped silently — now an error, in `parse` but - not `parse_partial`, since a half-typed flag is exactly what a completion is - asked about. -- [x] `=` is kept in attached short values, so `-j=8` binds `=8`. -- [x] A repeated `--` was eaten, altering a forwarded command line containing - its own separator. Only the first `--` is parser syntax; later separators - are data and are preserved. `double_dash="preserve"` has the narrower role - of preserving the first separator too. Fixed in #809. -- [x] `--jobs=` binds nothing rather than the empty string. -- [x] A flag with a variadic argument rejects its second value, though - [the flag reference](https://usage.jdx.dev/spec/reference/flag) documents the - form. It collects now — until a token is flag-like, a `--` arrives, `var_max` is - reached, or the line ends — which also made that bound reachable, and the attached - form (`--include=a b`) collects with it. -- [x] `double_dash="automatic"` is not enforced, which - [the arg reference](https://usage.jdx.dev/spec/reference/arg) says outright. The - arg's first value now stops flag interpretation, and that note is gone from the - reference. -- [x] An automatic argument made a later explicit `--` ordinary data before it could - unlock a `double_dash="required"` argument. This broke mise's nested task - passthrough shape (`mise run wrapper -- command ...`): the wrapper received `--` - as its executable. The first explicit separator now remains syntax after automatic - flag stopping; only separators after that first one are data. -- [x] An attached value was read a second time as a token, so `--jobs=--force` bound - `force` and left `jobs` unset. The `=` has already settled that the text is a - value, so it binds where it is read instead of going back on the queue. -- [x] A flag left waiting when the separator was consumed took the word after it, so - `ex --jobs -- x` quietly meant `ex --jobs=x` with the `--` gone. Such a flag is - starved — its value could only come from after the `--`, where every token is - data — and is reported as the missing value it is. Found by importing clap's - `double_hyphen_as_value`. - -## Config - -Implemented — the crates, the spec block, the derive, and the CLI binding all exist below. -What has not happened is adoption, which is tracked per-CLI by the fleet effort rather than -here. - -### What the four CLIs already do - -mise, hk, pitchfork, and fnox have each independently built the same thing, and -the agreement is strong enough to standardize: - -- A **TOML registry** (`settings.toml`) drives codegen — at the repo root in - mise, hk, and pitchfork, and in the crate root in fnox. -- **`build.rs` generates, `include!(OUT_DIR)` delivers.** All four emit a typed - `Settings` struct plus a `SETTINGS_META` map for introspection; three also - generate the merge logic, while mise delegates that to confique. -- **The field vocabulary is ~80% shared**: `type`, `default`, `description`/`docs`, - `examples`, `deprecated`, `since`, `env`. -- **The layering agrees** wherever a given layer exists: project (found upward) - over user-global over defaults, with a `*.local.*` sibling outranking its base. - -Where they differ is instructive, because it is mostly _drift_: - -- **Every one of them hand-writes the CLI-to-settings binding**, and every one has - a hole in it. hk declares `sources.cli` for flags nothing reads. pitchfork - documents a CLI layer it does not have — the precedence list in its - `--help` is copied by hand and lands verbatim in its committed spec. fnox - resolves `age_key_file` through a hardcoded five-way chain in `providers/age.rs` - because its settings and its config files are two disconnected systems. -- **Only hk can answer "where did this value come from"** (`hk config explain`), - and it needed a second parallel merge function to do it. -- **Only hk validates its own registry file** (a JSON schema wired through taplo). -- **Docs and JSON-schema generation is three separate reimplementations**, and - fnox has none. -- **hk's project config is pkl**, resolved by a subprocess and cached as JSON, with - settings living as top-level keys of the same file as its hook config. Any shared - design has to treat "project file" as a pluggable loader producing a value tree, - not as "parse TOML at a path". - -### The shape - -- [x] **Declare props in code**, `#[derive(usage::Config)]`, lowered into the - spec's `config { prop ... }` block so settings documentation flows through - the same pipeline as command documentation. Same canonicality rule as the - parser: code authors, the spec defines. **Decided (2026-08-21, superseding the - registry-only decision of the same day): the derive goes on the real typed - `Settings` struct** — the same shape as `#[derive(usage::Cli)]`, which is the - argument that won. The author writes the struct the CLI already holds its settings - in; the derive generates `SETTINGS_PROPS`/`SETTINGS_REGISTRY`, `read(&Resolved)`, - and `spec_kdl()`, and a root's `#[usage(config = Settings)]` puts the block in its - emitted spec. One source of truth, no generated structs. The registry-only shape - was chosen for layer-at-a-time migration; the fleet's first adopters (pitchfork, - fnox, tak) are converting wholesale, so the incremental path's cost — two - coexisting declarations — was being paid for a benefit nobody scheduled. Nested - groups compose through `usage_config::Props` with compile-time `concat_props` - (duplicate keys refuse the build); `derive/src/config.rs`, - `conformance/tests/derive_config.rs`. The struct is the only declaration: the - build-time KDL-to-registry generator (`usage-config-build`) is gone, because a - second backend was a third description of every setting. -- [x] **A prop vocabulary that is the union of the four registries** — `type` - (bool, int, string, path, duration, list, map, plus a Rust-type escape - hatch), `default`, `env` and `deprecated_env`, `docs`, `deprecated` with - warn/remove versions, `enum`, `optional`, `aliases`, `merge` - (`replace`/`union` — hk needs union for its list settings), `scope` - (mise strips `global_only` settings out of project files, which is a - security property, not a preference), and per-source bindings in hk's - `sources.{cli,env,git,...}` shape. Current environment names are ordered; - `deprecated_env` names are consulted afterwards, warn when used, and remain - visible to generated registries and documentation. -- [x] **Named, ordered, pluggable layers.** The order is CLI flags, then - environment, then env-files, then the project file (found upward, with - `.local` variants outranking their base), then user-global, then system, then - defaults. That already matches all four CLIs wherever a layer is present; - which layers exist stays per-CLI, so hk's git-config layer and mise's `/etc` - and `conf.d` layers slot in without being universal. -- [x] **Generate the CLI binding** rather than hand-writing it. This is the single - highest-value piece: it is what all four wrote by hand and all four got - subtly wrong. `#[usage(setting = "jobs")]` on a flag emits into - `Ex::SETTINGS_BINDINGS`, and `Registry::drift` compares the executable - bindings against the documented ones — which is what hk's eighteen declared - and five read `sources.cli` lines needed and never had. -- [x] **Provenance through one merge path**, so ` config explain` comes free - everywhere instead of needing a parallel implementation. `config/src/explain.rs` - — `explain`, `warnings`, `list`. -- [x] **Extend `SpecConfigProp` first.** `deprecated`, `merge`, `scope`, the - per-source `bindings`, `choices`, `default`, rich types, environment and CLI - bindings, help fields, explicit `optional`, and warning-free key `aliases` all - survive the portable spec. The markdown renderer and generated runtime registry - consume the block; no fleet CLI emits one yet, which is the adoption half below. -- [x] **A registry JSON schema** — **dropped as obsolete (2026-08-21).** The - requirement came from hk validating its own `settings.toml` through taplo. In 6.x - there is no separate TOML registry to validate: the usage KDL spec _is_ the - declaration, the spec parser validates the `config` block with real diagnostics, - and `usage generate json-schema` already describes the user's config file. A - second machine description of the block would only add something for the parser - to drift from. - -### Open questions - -- [x] Whether config lives in this repository or beside the parser crates. - **Decided (2026-08-21): this repository.** One release train and one spec - vocabulary, and config already shares the spec model, the codegen and the docs - pipeline — `config/` and `SpecConfigProp` are here today, so this is also the - status quo. A split would buy an independent stability and MSRV policy at the - price of cross-repo coordination on every spec vocabulary change. -- [x] Whether the four CLIs migrate incrementally (one layer at a time, keeping - their generated `Settings`) or by regenerating from a converted registry. - **Answered by the fleet adoption effort (2026-08-21): wholesale, on the real - struct.** The first adopters — pitchfork, fnox, tak — convert their registries - into `#[derive(usage::Config)]` structs in one PR each, stacked on their clap-swap - PRs; hk and aube are deferred (git/pkl layers, `env_only` bootstrap, per-item - provenance; aube's managed-policy ratchet and two-axis sources). There is no - second path held open for a later incremental adopter: `usage-config-build` was - removed with this decision, since keeping a KDL-first backend alive meant keeping - two generators emitting one registry shape. -- [x] fnox's model, where config files are not a settings source at all, is the - one real behavior change rather than a consolidation. Worth confirming that - is a fix and not a deliberate choice. **Decided (2026-08-21): preserve fnox's - source set.** Adoption uses only the shared layers fnox already has, so it stays - a consolidation everywhere and ships no behavior change to a released CLI. The - hardcoded five-way `age_key_file` chain in `providers/age.rs` is still replaced by - one declaration — that is the actual bug — without adding a source that could pick - up keys users never meant as settings. diff --git a/benches/gate/tests/differential.rs b/benches/gate/tests/differential.rs index 6193e03af..08c642fae 100644 --- a/benches/gate/tests/differential.rs +++ b/benches/gate/tests/differential.rs @@ -353,8 +353,8 @@ fn explained(o: Outcome) -> Option<&'static str> { // Not a dropped declaration — the shadow's `--profile` carries `conflicts: ["env"]`, and // clap enforces it perfectly well when both flags land on the same command, whether that // is the root or `config set`. What it does not do is check a pair a *global* spread - // across a boundary, which is clap#1546 and one of the requests `PLAN.md` records usage as - // already answering: globals go through the same post-binding checks as everything else. + // across a boundary, which is clap#1546 and one of the requests usage already + // answers: globals go through the same post-binding checks as everything else. // // So this is the one arm here where the disagreement is a difference an adopter feels // going the *strict* way — a line clap accepted stops being accepted. Recorded rather @@ -387,7 +387,7 @@ fn explained(o: Outcome) -> Option<&'static str> { // Both usage parsers accept an unknown flag where clap refuses it: mise's spec lets an // unrecognised flag fall through to a positional, so `mise dotfiles status --stdin` - // binds `--stdin` as a value rather than failing. PLAN.md carries this as an open + // binds `--stdin` as a value rather than failing. That remains an open product // decision — mise parses *task* arguments with this parser at run time, so refusing an // undeclared flag would change what a task accepts, not only what a completion offers. // diff --git a/conformance/src/complete.rs b/conformance/src/complete.rs index e7561043d..e22f62f9b 100644 --- a/conformance/src/complete.rs +++ b/conformance/src/complete.rs @@ -12,7 +12,7 @@ //! to chase: the help renderers agreed on mise and differed on five of the other six jdx CLIs //! until one fixture held them together. //! -//! It also closes two thirds of PLAN.md's "not covered by the corpus yet" — completion parsing, +//! It also closes two thirds of the corpus gaps that were still open — completion parsing, //! which is `parse_partial` over deliberately incomplete input, and restart tokens, which only //! matter at a cursor. Mounts stay uncovered on purpose: resolving one *runs a command*, which a //! corpus cannot do hermetically. diff --git a/corpus/complete/README.md b/corpus/complete/README.md index 0ddffdb18..bf765625d 100644 --- a/corpus/complete/README.md +++ b/corpus/complete/README.md @@ -21,7 +21,7 @@ rules is the arrangement that produced every drift this project has had to chase renderers agreed on mise and differed on five of the other six CLIs until #972 held them to one fixture. -It also closes the two easier thirds of PLAN.md's "not covered by the corpus yet": completion +It also closes the two easier thirds of the corpus gaps that were still open: completion parsing, which is `parse_partial` over deliberately incomplete input, and restart tokens, which only matter at a cursor. Mounts remain uncovered, and deliberately — resolving one _runs a command_, which a corpus cannot do hermetically. The differential fuzzer learned that the From 8821c71876979c79a8824711ee5a0b6dbe7e5f23 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 01:11:39 +0000 Subject: [PATCH 08/10] fix(help): treat an empty help_template as the default page Whitespace-only templates now assemble the same default layout in Rust and Go. Align Go section tests with the render corpus, and note that a template is root-level only. Co-authored-by: jdx --- argv/src/help.rs | 5 +- conformance/tests/help_template.rs | 16 +++ derive/src/model.rs | 6 +- docs/rust/clap-compatibility.md | 42 +++--- go/argv/sections.go | 8 +- go/argv/sections_test.go | 206 +++++++++++++++++++---------- lib/src/docs/cli/mod.rs | 6 +- lib/src/help_template.rs | 18 +++ lib/src/spec/mod.rs | 5 +- 9 files changed, 212 insertions(+), 100 deletions(-) diff --git a/argv/src/help.rs b/argv/src/help.rs index 88cf7d685..1b7293889 100644 --- a/argv/src/help.rs +++ b/argv/src/help.rs @@ -204,7 +204,10 @@ fn collapse_blank_runs(page: &str) -> String { /// lines between sections from becoming trailing ones. That applies to a template's output too: /// a page ends in exactly one newline however it was assembled. fn assemble(spec: &Spec<'_>, sections: &Sections) -> String { - let page = match spec.help_template { + let page = match spec + .help_template + .filter(|template| !template.trim().is_empty()) + { Some(template) => sections.substituted(template), None => sections.concatenated(), }; diff --git a/conformance/tests/help_template.rs b/conformance/tests/help_template.rs index 77137f7c5..431a4e6dd 100644 --- a/conformance/tests/help_template.rs +++ b/conformance/tests/help_template.rs @@ -232,6 +232,22 @@ fn a_template_naming_no_section_is_refused_where_the_spec_is_read() { assert!(message.contains("flags"), "{message}"); } +#[test] +fn an_empty_template_is_the_default_page() { + // Accepted, because it names no unknown section, and stored as unset, because it + // also names no layout. Rust and Go then assemble the same default page. + let spec: LibSpec = "bin \"ex\"\nabout \"An example\"\nhelp_template \"\"\n" + .parse() + .expect("empty is valid"); + assert_eq!(spec.help_template, None); + let with = usage::docs::cli::render_help(&spec, &spec.cmd, false); + let without: LibSpec = "bin \"ex\"\nabout \"An example\"\n".parse().unwrap(); + assert_eq!( + with, + usage::docs::cli::render_help(&without, &without.cmd, false) + ); +} + #[test] fn the_cli_a_template_describes_still_parses() { // A page describing something the parser does not do is worse than no page. Reading the diff --git a/derive/src/model.rs b/derive/src/model.rs index de6a41153..c5727eb95 100644 --- a/derive/src/model.rs +++ b/derive/src/model.rs @@ -957,7 +957,7 @@ impl Cli { if let Err(problem) = check_help_template(&template) { return Err(syn::Error::new_spanned(&meta, problem)); } - cli.help_template = Some(template); + cli.help_template = (!template.trim().is_empty()).then_some(template); } "before_help" => cli.before_help = Some(metadata_expr(&meta)?), "next_help_heading" => cli.next_help_heading = Some(string_value(&meta)?), @@ -5998,6 +5998,10 @@ mod tests { ); assert!(err.contains("belongs on the root"), "unhelpful: {err}"); assert!(err.contains("help_template"), "unhelpful: {err}"); + + let parsed = cli(r#"#[usage(help_template = "")] struct Root {}"#) + .expect("an empty template is no layout, not an error"); + assert_eq!(parsed.help_template, None); } #[test] diff --git a/docs/rust/clap-compatibility.md b/docs/rust/clap-compatibility.md index 2744a482f..a79a7a924 100644 --- a/docs/rust/clap-compatibility.md +++ b/docs/rust/clap-compatibility.md @@ -117,27 +117,27 @@ the Rust declaration, not only from generated KDL, wherever the bridge column sa ## Help, version, and generated artifacts -| clap surface | derive | argv | KDL | lib | output | bridge | Notes | -| -------------------------------------------------- | --------- | --------- | --------- | --------- | ------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| short/long help and doc comments | yes | yes | yes | yes | yes | yes | First paragraph is short help; the full block is long help. | -| `help_heading` on flags and arguments | yes | n/a | yes | yes | yes | yes | Flags and arguments are grouped and retain declaration order. | -| `help_heading` on subcommands | yes | yes | yes | yes | yes | n/a | Commands can be grouped into named sections in their parent's help. | -| whole-entry `hide` | yes | yes | yes | yes | yes | yes | Hidden commands, flags, arguments, and values still parse. | -| granular hide settings | yes | yes | yes | yes | yes | yes | Default, environment, possible-value, short-help, and long-help visibility is independent. | -| `subcommand_help_heading`, `subcommand_value_name` | yes | yes | yes | yes | yes | yes | Customize the subcommand section label and the synopsis placeholder. | -| `verbatim_doc_comment` | yes | n/a | yes | yes | yes | n/a | Commands, fields, and variants preserve line breaks and indentation when requested. | -| `rename_all`, `rename_all_env` | yes | n/a | yes | yes | yes | n/a | Full clap casing vocabulary; bare `env` uses the environment casing policy. | -| `next_line_help` | yes | yes | yes | yes | yes | yes | Put command, argument, and flag descriptions below their usage instead of beside it. | -| `flatten_help` | yes | yes | yes | yes | yes | yes | Expand visible subcommands into their parent's usage synopsis and help page. | -| `display_order` | yes | yes | yes | yes | yes | yes | Explicit field and subcommand presentation order is portable; parsing order is unchanged. | -| `help_template` | different | different | different | different | yes | no | Supported, with a closed vocabulary of six pre-rendered sections rather than clap's tags; see [Laying a page out](./help.md#laying-a-page-out) for the mapping. clap keeps `get_help_template` private, so the bridge cannot recover one. | -| `term_width`, `max_term_width` | yes | yes | yes | yes | yes | no | Fixed width overrides a detected-width cap; clap exposes no bridge getters for these settings. | -| help styles and color | n/a | n/a | n/a | yes | lossy | no | Help and diagnostics use automatic ANSI styles; clap's custom style palette is not portable. | -| built-in help/version action and flag control | yes | yes | yes | yes | yes | yes | `Help`, `HelpShort`, `HelpLong`, and `Version` actions can relocate built-ins; usage additionally provides recursive `HelpAll`; each synthetic entry can be disabled. | -| `--version` / `-V`, dynamic and long versions | yes | yes | yes | yes | yes | yes | `long_version` customizes `--version`; `-V` keeps the concise value. | -| `author`, `license`, `repository` | yes | n/a | yes | yes | yes | partial | Package metadata is rendered in Markdown and manpages; clap exposes author but not license. | -| completion generation | yes | yes | yes | yes | lossy | yes | Bash, fish, Nushell, PowerShell, and zsh plus runtime overlays are supported; Elvish is not. | -| KDL, markdown, JSON, and manpages | yes | n/a | yes | yes | yes | yes | Direct derived KDL feeds the existing generators; broader canonicalization remains open. | +| clap surface | derive | argv | KDL | lib | output | bridge | Notes | +| -------------------------------------------------- | --------- | --------- | --------- | --------- | ------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| short/long help and doc comments | yes | yes | yes | yes | yes | yes | First paragraph is short help; the full block is long help. | +| `help_heading` on flags and arguments | yes | n/a | yes | yes | yes | yes | Flags and arguments are grouped and retain declaration order. | +| `help_heading` on subcommands | yes | yes | yes | yes | yes | n/a | Commands can be grouped into named sections in their parent's help. | +| whole-entry `hide` | yes | yes | yes | yes | yes | yes | Hidden commands, flags, arguments, and values still parse. | +| granular hide settings | yes | yes | yes | yes | yes | yes | Default, environment, possible-value, short-help, and long-help visibility is independent. | +| `subcommand_help_heading`, `subcommand_value_name` | yes | yes | yes | yes | yes | yes | Customize the subcommand section label and the synopsis placeholder. | +| `verbatim_doc_comment` | yes | n/a | yes | yes | yes | n/a | Commands, fields, and variants preserve line breaks and indentation when requested. | +| `rename_all`, `rename_all_env` | yes | n/a | yes | yes | yes | n/a | Full clap casing vocabulary; bare `env` uses the environment casing policy. | +| `next_line_help` | yes | yes | yes | yes | yes | yes | Put command, argument, and flag descriptions below their usage instead of beside it. | +| `flatten_help` | yes | yes | yes | yes | yes | yes | Expand visible subcommands into their parent's usage synopsis and help page. | +| `display_order` | yes | yes | yes | yes | yes | yes | Explicit field and subcommand presentation order is portable; parsing order is unchanged. | +| `help_template` | different | different | different | different | yes | no | Root-level only: nested pages inherit it and cannot declare their own. Closed vocabulary of six pre-rendered sections rather than clap's tags; see [Laying a page out](./help.md#laying-a-page-out). clap keeps `get_help_template` private, so the bridge cannot recover one. | +| `term_width`, `max_term_width` | yes | yes | yes | yes | yes | no | Fixed width overrides a detected-width cap; clap exposes no bridge getters for these settings. | +| help styles and color | n/a | n/a | n/a | yes | lossy | no | Help and diagnostics use automatic ANSI styles; clap's custom style palette is not portable. | +| built-in help/version action and flag control | yes | yes | yes | yes | yes | yes | `Help`, `HelpShort`, `HelpLong`, and `Version` actions can relocate built-ins; usage additionally provides recursive `HelpAll`; each synthetic entry can be disabled. | +| `--version` / `-V`, dynamic and long versions | yes | yes | yes | yes | yes | yes | `long_version` customizes `--version`; `-V` keeps the concise value. | +| `author`, `license`, `repository` | yes | n/a | yes | yes | yes | partial | Package metadata is rendered in Markdown and manpages; clap exposes author but not license. | +| completion generation | yes | yes | yes | yes | lossy | yes | Bash, fish, Nushell, PowerShell, and zsh plus runtime overlays are supported; Elvish is not. | +| KDL, markdown, JSON, and manpages | yes | n/a | yes | yes | yes | yes | Direct derived KDL feeds the existing generators; broader canonicalization remains open. | ## Usage extensions diff --git a/go/argv/sections.go b/go/argv/sections.go index e51c62492..60651a041 100644 --- a/go/argv/sections.go +++ b/go/argv/sections.go @@ -21,7 +21,11 @@ import "strings" // args every argument group, each under its heading // flags this command's flag groups, then the globals it inherits // after_help examples, AfterHelp, and the author/license footer on a long page -var HelpSections = []string{"about", "usage", "commands", "args", "flags", "after_help"} +// +// An array rather than a slice, so the vocabulary cannot grow or shrink the way +// a package-level slice can. The names themselves are still assignable; nothing +// in this package writes them. +var HelpSections = [...]string{"about", "usage", "commands", "args", "flags", "after_help"} // helpSections is a page under construction, cut at the boundaries a template may // reorder. `flattened` is not a section an author can name: it is the other half of @@ -87,7 +91,7 @@ func (s *helpSections) named(name string) (string, bool) { // template's output too: a page ends in exactly one newline however it was built. func (s *helpSections) assemble(template string) string { page := s.concatenated() - if template != "" { + if strings.TrimSpace(template) != "" { page = substituteSections(template, s) } return strings.TrimSpace(page) + "\n" diff --git a/go/argv/sections_test.go b/go/argv/sections_test.go index 9d1b184eb..9ed727c5d 100644 --- a/go/argv/sections_test.go +++ b/go/argv/sections_test.go @@ -5,110 +5,152 @@ import ( "testing" ) +func helpKeyed(entries ...Help) HelpTable { + var max uint64 + for _, e := range entries { + if e.Key > max { + max = e.Key + } + } + table := make(HelpTable, max) + for i := range table { + table[i].Key = uint64(i + 1) + } + for _, e := range entries { + table[e.Key-1] = e + } + return table +} + // Does a HelpTemplate lay a page out the way the other two implementations do? // // The pages below are the ones `corpus/render/04-help-template.json` pins for -// usage-lib and usage-argv, transcribed. Go does not run the rendering corpus — +// usage-lib and usage-argv. Go does not run the rendering corpus — // `go/conformance` checks pages against mise, which declares no template — so -// this is where the third implementation is held to the same expectations, and a -// transcription is the price of that. Compare against the JSON when changing -// either. +// this is where the third implementation is held to those same full pages. -func templateFixture(template string) (HelpSpec, []string, []*Command, HelpTable) { +func TestATemplateReordersTheSections(t *testing.T) { + // corpus/render/04-help-template.json#template-reorders-the-sections force := &Flag{Key: 2, Name: "force", Longs: []string{"force"}} - file := &Arg{Key: 3, Name: "file"} + file := &Arg{Key: 3, Name: "file", Required: true} root := &Command{Name: "ex", Key: 1, Flags: []*Flag{force}, Args: []*Arg{file}} - help := HelpTable{ - {Key: 1, Short: "An example"}, - {Key: 2, Short: "Do it anyway"}, - {Key: 3, Short: "Which file"}, + help := helpKeyed( + Help{Key: 1, Short: "An example"}, + Help{Key: 2, Short: "Do it anyway"}, + Help{Key: 3, Short: "Which file", Demanded: true}, + ) + spec := HelpSpec{ + Name: "ex", Bin: "ex", About: "An example", + HelpTemplate: "{{about}}\n\n{{usage}}\n\n{{flags}}\n\n{{args}}", } - spec := HelpSpec{Name: "ex", Bin: "ex", About: "An example", HelpTemplate: template} - return spec, []string{"ex"}, []*Command{root}, help -} - -func TestATemplateReordersTheSections(t *testing.T) { - // `{{flags}}` above `{{args}}` inverts the order every default page writes. - spec, path, chain, help := templateFixture( - "{{about}}\n\n{{usage}}\n\n{{flags}}\n\n{{args}}") want := strings.Join([]string{ "An example", "", - "Usage: ex [--force] [file]", + "Usage: ex [--force] ", "", "Flags:", " --force Do it anyway", " -h, --help Print help", "", "Arguments:", - " [file] Which file", + " Which file", }, "\n") + "\n" - if got := ShortHelp(spec, path, chain, help); got != want { + if got := ShortHelp(spec, []string{"ex"}, []*Command{root}, help); got != want { t.Fatalf("page differs\n got:\n%s\nwant:\n%s", got, want) } } func TestATemplateOmitsASection(t *testing.T) { - // A section the template does not name is not on the page. - spec, path, chain, help := templateFixture("{{about}}\n\n{{usage}}\n\n{{flags}}") - got := ShortHelp(spec, path, chain, help) - if strings.Contains(got, "Arguments:") { - t.Fatalf("an unnamed section should not be rendered:\n%s", got) + // corpus/render/04-help-template.json#template-omits-a-section + install := &Command{Name: "install", Key: 4} + remove := &Command{Name: "remove", Key: 5} + root := &Command{Name: "ex", Key: 1, Subcommands: []*Command{install, remove}} + help := helpKeyed( + Help{Key: 1, Short: "An example"}, + Help{Key: 4, Short: "Install a tool"}, + Help{Key: 5, Short: "Remove a tool"}, + ) + spec := HelpSpec{ + Name: "ex", Bin: "ex", About: "An example", + HelpTemplate: "{{about}}\n\n{{usage}}\n\n{{flags}}", } - if !strings.Contains(got, "--force") { - t.Fatalf("a named section should be:\n%s", got) + want := strings.Join([]string{ + "An example", + "", + "Usage: ex ", + "", + "Flags:", + " -h, --help Print help", + }, "\n") + "\n" + if got := ShortHelp(spec, []string{"ex"}, []*Command{root}, help); got != want { + t.Fatalf("page differs\n got:\n%s\nwant:\n%s", got, want) } } func TestATemplateWrapsTheSectionsInText(t *testing.T) { - // Text around a placeholder is written as-is, which is what makes a template a - // layout rather than a permutation. - spec, path, chain, help := templateFixture( - "== ex ==\n\n{{usage}}\n\n{{flags}}\n\nSee https://example.com/docs for more.") - got := ShortHelp(spec, path, chain, help) - if !strings.HasPrefix(got, "== ex ==\n\n") { - t.Errorf("the author's heading should open the page:\n%s", got) + // corpus/render/04-help-template.json#template-wraps-the-sections-in-text + force := &Flag{Key: 2, Name: "force", Longs: []string{"force"}} + root := &Command{Name: "ex", Key: 1, Flags: []*Flag{force}} + help := helpKeyed(Help{Key: 2, Short: "Do it anyway"}) + spec := HelpSpec{ + Name: "ex", Bin: "ex", + HelpTemplate: "== ex ==\n\n{{usage}}\n\n{{flags}}\n\nSee https://example.com/docs for more.", } - if !strings.HasSuffix(got, "See https://example.com/docs for more.\n") { - t.Errorf("and their footer should close it:\n%s", got) + want := strings.Join([]string{ + "== ex ==", + "", + "Usage: ex [--force]", + "", + "Flags:", + " --force Do it anyway", + " -h, --help Print help", + "", + "See https://example.com/docs for more.", + }, "\n") + "\n" + if got := ShortHelp(spec, []string{"ex"}, []*Command{root}, help); got != want { + t.Fatalf("page differs\n got:\n%s\nwant:\n%s", got, want) } } func TestATemplateClosesTheGapAMissingSectionLeaves(t *testing.T) { - // The rule that lets one template serve a whole CLI: this command has no - // subcommands and no trailing text, and the separators around those sections - // collapse rather than pushing the rest of the page down. - spec, path, chain, help := templateFixture( - "{{about}}\n\n{{usage}}\n\n{{commands}}\n\n{{args}}\n\n{{flags}}\n\n{{after_help}}") + // corpus/render/04-help-template.json#template-closes-the-gap-a-missing-section-leaves + force := &Flag{Key: 2, Name: "force", Longs: []string{"force"}} + root := &Command{Name: "ex", Key: 1, Flags: []*Flag{force}} + help := helpKeyed( + Help{Key: 1, Short: "An example"}, + Help{Key: 2, Short: "Do it anyway"}, + ) + spec := HelpSpec{ + Name: "ex", Bin: "ex", About: "An example", + HelpTemplate: "{{about}}\n\n{{usage}}\n\n{{commands}}\n\n{{args}}\n\n{{flags}}\n\n{{after_help}}", + } want := strings.Join([]string{ "An example", "", - "Usage: ex [--force] [file]", - "", - "Arguments:", - " [file] Which file", + "Usage: ex [--force]", "", "Flags:", " --force Do it anyway", " -h, --help Print help", }, "\n") + "\n" - if got := ShortHelp(spec, path, chain, help); got != want { + if got := ShortHelp(spec, []string{"ex"}, []*Command{root}, help); got != want { t.Fatalf("page differs\n got:\n%s\nwant:\n%s", got, want) } } func TestATemplateGathersALongPagesTrailingSections(t *testing.T) { - // `{{after_help}}` is the whole tail of a page — examples, the spec's trailing - // text, and the author and licence a long page ends with — so a template moving - // it moves all of it at once. - root := &Command{Name: "ex", Key: 1, Version: true} - help := HelpTable{{Key: 1, Examples: []Example{{Header: "Force it", Code: "ex --force"}}}} + // corpus/render/04-help-template.json#template-gathers-a-long-pages-trailing-sections + force := &Flag{Key: 2, Name: "force", Longs: []string{"force"}} + root := &Command{Name: "ex", Key: 1, Version: true, Flags: []*Flag{force}} + help := helpKeyed( + Help{Key: 1, Examples: []Example{{Header: "Force it", Code: "ex --force"}}}, + Help{Key: 2, Short: "Do it anyway"}, + ) spec := HelpSpec{ Name: "ex", Bin: "ex", About: "An example", Version: "1.2.3", Author: "Ex Ample", AfterHelp: "Read the docs.", HelpTemplate: "{{after_help}}\n\n{{usage}}\n\n{{flags}}\n\n{{about}}", } - got := LongHelp(spec, []string{"ex"}, []*Command{root}, help) want := strings.Join([]string{ "Examples:", " Force it:", @@ -118,56 +160,68 @@ func TestATemplateGathersALongPagesTrailingSections(t *testing.T) { "", "Author: Ex Ample", "", - "Usage: ex", + "Usage: ex [--force]", "", "Flags:", + " --force Do it anyway", " -h, --help Print help", " -V, --version Print version", "", "ex 1.2.3", "An example", }, "\n") + "\n" - if got != want { + if got := LongHelp(spec, []string{"ex"}, []*Command{root}, help); got != want { t.Fatalf("page differs\n got:\n%s\nwant:\n%s", got, want) } } func TestAPageWithoutATemplateIsUnchanged(t *testing.T) { - // The default order is what every other test in this package renders, and the - // point of the whole arrangement is that adding a template did not move it. - spec, path, chain, help := templateFixture("") + force := &Flag{Key: 2, Name: "force", Longs: []string{"force"}} + file := &Arg{Key: 3, Name: "file", Required: true} + root := &Command{Name: "ex", Key: 1, Flags: []*Flag{force}, Args: []*Arg{file}} + help := helpKeyed( + Help{Key: 1, Short: "An example"}, + Help{Key: 2, Short: "Do it anyway"}, + Help{Key: 3, Short: "Which file", Demanded: true}, + ) + spec := HelpSpec{Name: "ex", Bin: "ex", About: "An example"} want := strings.Join([]string{ "An example", "", - "Usage: ex [--force] [file]", + "Usage: ex [--force] ", "", "Arguments:", - " [file] Which file", + " Which file", "", "Flags:", " --force Do it anyway", " -h, --help Print help", }, "\n") + "\n" - if got := ShortHelp(spec, path, chain, help); got != want { + if got := ShortHelp(spec, []string{"ex"}, []*Command{root}, help); got != want { t.Fatalf("the default page changed\n got:\n%s\nwant:\n%s", got, want) } + + // An empty or whitespace-only template is the same unset: Go used to treat + // "" as default and Rust used to render "\n". + for _, template := range []string{"", " ", "\n\t"} { + spec.HelpTemplate = template + if got := ShortHelp(spec, []string{"ex"}, []*Command{root}, help); got != want { + t.Fatalf("template %q should be the default page\n got:\n%s\nwant:\n%s", template, got, want) + } + } } func TestAPlaceholderNamingNoSectionIsLeftAlone(t *testing.T) { - // The vocabulary is checked where a spec is authored — KDL refuses one at parse, - // the Rust derive at compile time — so a name reaching this renderer is text an - // author meant literally rather than an error to discover here. - spec, path, chain, help := templateFixture("{{usage}}\n\n{{options}}") - got := ShortHelp(spec, path, chain, help) + force := &Flag{Key: 2, Name: "force", Longs: []string{"force"}} + root := &Command{Name: "ex", Key: 1, Flags: []*Flag{force}} + spec := HelpSpec{Name: "ex", Bin: "ex", HelpTemplate: "{{usage}}\n\n{{options}}"} + got := ShortHelp(spec, []string{"ex"}, []*Command{root}, HelpTable{}) if !strings.Contains(got, "{{options}}") { t.Fatalf("an unknown placeholder should survive as written:\n%s", got) } } func TestTheSectionVocabularyIsTheSameSixWords(t *testing.T) { - // The list the other implementations hold: usage::help_template::SECTIONS and - // usage_argv::help::SECTIONS. Nothing mechanical compares them across languages, - // so this is where Go's copy is written down beside the order they share. want := []string{"about", "usage", "commands", "args", "flags", "after_help"} if len(HelpSections) != len(want) { t.Fatalf("HelpSections = %v, want %v", HelpSections, want) @@ -178,8 +232,6 @@ func TestTheSectionVocabularyIsTheSameSixWords(t *testing.T) { } } - // And every one of them can be placed: a name this renderer does not know would - // survive substitution as literal braces. var template strings.Builder for i, name := range HelpSections { if i > 0 { @@ -187,8 +239,16 @@ func TestTheSectionVocabularyIsTheSameSixWords(t *testing.T) { } template.WriteString("{{" + name + "}}") } - spec, path, chain, help := templateFixture(template.String()) - if got := ShortHelp(spec, path, chain, help); strings.Contains(got, "{{") { + force := &Flag{Key: 2, Name: "force", Longs: []string{"force"}} + file := &Arg{Key: 3, Name: "file", Required: true} + root := &Command{Name: "ex", Key: 1, Flags: []*Flag{force}, Args: []*Arg{file}} + help := helpKeyed( + Help{Key: 1, Short: "An example"}, + Help{Key: 2, Short: "Do it anyway"}, + Help{Key: 3, Short: "Which file", Demanded: true}, + ) + spec := HelpSpec{Name: "ex", Bin: "ex", About: "An example", HelpTemplate: template.String()} + if got := ShortHelp(spec, []string{"ex"}, []*Command{root}, help); strings.Contains(got, "{{") { t.Fatalf("a section went unfilled:\n%s", got) } } diff --git a/lib/src/docs/cli/mod.rs b/lib/src/docs/cli/mod.rs index 3e2da1e95..49da80fa7 100644 --- a/lib/src/docs/cli/mod.rs +++ b/lib/src/docs/cli/mod.rs @@ -85,7 +85,11 @@ pub fn render_help(spec: &Spec, cmd: &SpecCommand, long: bool) -> String { }; let rendered = TERA.render(template, &ctx).unwrap(); let sections = Sections::split(&rendered); - let page = match spec.help_template.as_deref() { + let page = match spec + .help_template + .as_deref() + .filter(|t| crate::help_template::is_set(t)) + { Some(template) => crate::help_template::substitute(template, |name| sections.named(name)), None => sections.concatenated(), }; diff --git a/lib/src/help_template.rs b/lib/src/help_template.rs index fa4953610..e5aa4e31b 100644 --- a/lib/src/help_template.rs +++ b/lib/src/help_template.rs @@ -21,6 +21,16 @@ /// | `after_help` | examples, `after_help`, and the author/license footer on a long page | pub const SECTIONS: [&str; 6] = ["about", "usage", "commands", "args", "flags", "after_help"]; +/// Whether a template is one an author wrote, rather than an empty or whitespace-only +/// string that should render as the default page. +/// +/// `help_template ""` is accepted by KDL because it has no unknown placeholders, but it +/// names no layout. Treating it as unset keeps the three renderers on one page instead of +/// Rust substituting an empty string into `"\n"` while Go concatenates the default order. +pub fn is_set(template: &str) -> bool { + !template.trim().is_empty() +} + /// Whether every `{{…}}` in a template names a section. /// /// The check a template is held to when a spec is read, so nothing renders a page with a @@ -119,6 +129,14 @@ fn collapse_blank_runs(page: &str) -> String { mod tests { use super::*; + #[test] + fn whitespace_alone_is_not_a_layout() { + assert!(!is_set("")); + assert!(!is_set(" \n\t")); + assert!(is_set("{{usage}}")); + assert!(check("").is_ok()); + } + #[test] fn a_placeholder_naming_no_section_is_refused_by_name() { let err = check("{{about}}{{options}}").expect_err("no section is called options"); diff --git a/lib/src/spec/mod.rs b/lib/src/spec/mod.rs index 91394fc78..13e3c2915 100644 --- a/lib/src/spec/mod.rs +++ b/lib/src/spec/mod.rs @@ -483,7 +483,10 @@ impl Spec { if let Err(problem) = crate::help_template::check(&template) { bail_parse!(ctx, node.span(), "{problem}"); } - schema.help_template = Some(template); + // Whitespace-only is no layout: store it as unset so a round trip does + // not emit a node that would then render three different empty pages. + schema.help_template = + crate::help_template::is_set(&template).then_some(template); } "arg" => { let arg = SpecArg::parse(ctx, &node)?; From a69577553202c69f3fe0a87ea71e6594a7ba0cf9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 01:22:31 +0000 Subject: [PATCH 09/10] fix(derive): include standing ArgGroup members in updates Co-authored-by: jdx --- argv/src/spec.rs | 10 +++ conformance/tests/update_from.rs | 71 ++++++++++++++++++ derive/src/codegen.rs | 120 +++++++++++++++++++++++++++---- 3 files changed, 186 insertions(+), 15 deletions(-) diff --git a/argv/src/spec.rs b/argv/src/spec.rs index 1cfd1a36c..f56dc1a4a 100644 --- a/argv/src/spec.rs +++ b/argv/src/spec.rs @@ -2961,6 +2961,16 @@ pub trait ArgGroup: Sized { /// is for flattened argument groups. fn argument_state(partial: &Self::Partial, selector: &str) -> Option; + /// [`Self::argument_state`] for a value the caller already holds. + /// + /// An update cannot recover the partial that produced this enum, so which member stands + /// is read from the variant itself. `None` by default, which is what a hand-written + /// implementation that does not take part in updates should say. + fn standing_state(standing: &Self, selector: &str) -> Option { + let _ = (standing, selector); + None + } + /// Whether a selected member is present as the given boolean value. /// /// Members are switches, so only `"true"` / `"false"` are meaningful; anything else diff --git a/conformance/tests/update_from.rs b/conformance/tests/update_from.rs index 8ae2dca53..4bb4da451 100644 --- a/conformance/tests/update_from.rs +++ b/conformance/tests/update_from.rs @@ -532,3 +532,74 @@ fn a_nested_subcommand_switch_replaces_only_the_inner_variant() { }), ); } + +/// How to emit output — required on the holding command (bare `Mode`, not `Option`). +#[derive(ArgGroup, Debug, PartialEq)] +enum Mode { + /// Emit JSON + Json, + /// Emit YAML + Yaml, +} + +/// A CLI whose arg group is required by type, plus a sibling that names a member. +#[derive(Cli, Debug, PartialEq)] +#[usage(bin = "modeupd")] +struct ModeUpd { + #[usage(arg_group)] + mode: Mode, + /// Only legal beside JSON + #[usage(long, requires = "--json")] + pretty: bool, + /// Cannot sit beside YAML + #[usage(long, conflicts = "--yaml")] + strict: bool, + /// Something to change so an update has a reason to run + #[usage(long)] + tag: Option, +} + +#[test] +fn a_standing_required_arg_group_need_not_be_given_again() { + let a = argv(["--json"]); + let mut upd = ModeUpd::parse_from(&a).expect("first parse selects the group"); + + // A bare `Mode` is required; this argv says nothing about the group. Standing must answer. + let a = argv(["--tag", "second"]); + upd.try_update_from(&a) + .expect("required group already stands"); + + assert_eq!(upd.mode, Mode::Json); + assert_eq!(upd.tag.as_deref(), Some("second")); +} + +#[test] +fn a_standing_group_member_satisfies_a_sibling_requires() { + let a = argv(["--json"]); + let mut upd = ModeUpd::parse_from(&a).expect("json stands"); + + let a = argv(["--pretty"]); + upd.try_update_from(&a) + .expect("standing --json satisfies requires"); + + assert_eq!(upd.mode, Mode::Json); + assert!(upd.pretty); +} + +#[test] +fn a_standing_group_member_conflicts_with_a_sibling_flag() { + let a = argv(["--yaml"]); + let mut upd = ModeUpd::parse_from(&a).expect("yaml stands"); + + let a = argv(["--strict"]); + assert!( + matches!( + upd.try_update_from(&a), + Err(Error::ConflictingFlags { + name: "strict", + other: "yaml", + }) + ), + "standing --yaml still conflicts with --strict", + ); +} diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index 7db86efd8..495cada36 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -3259,10 +3259,17 @@ fn standing_locals(cli: &Cli) -> TokenStream { Kind::Flatten { .. } => quote! { let #name = __usage_standing.map(|__usage_s| &__usage_s.#ident); }, - Kind::Subcommand { optional: true, .. } => quote! { - let #name = __usage_standing.and_then(|__usage_s| __usage_s.#ident.as_ref()); - }, - Kind::Subcommand { + // The nested value itself, so a parent can ask which member stands — a bool + // would answer required-ness and nothing about `--json` vs `--yaml`. + Kind::ArgGroup { optional: true, .. } | Kind::Subcommand { optional: true, .. } => { + quote! { + let #name = __usage_standing.and_then(|__usage_s| __usage_s.#ident.as_ref()); + } + } + Kind::ArgGroup { + optional: false, .. + } + | Kind::Subcommand { optional: false, .. } => quote! { let #name = __usage_standing.map(|__usage_s| &__usage_s.#ident); @@ -3281,7 +3288,7 @@ fn standing_flag(field: &Field) -> Option { let name = standing_ident(field); match &field.kind { Kind::Skip | Kind::Flatten { .. } => None, - Kind::Subcommand { .. } => Some(quote!(#name.is_some())), + Kind::ArgGroup { .. } | Kind::Subcommand { .. } => Some(quote!(#name.is_some())), _ => standing_presence(field).map(|_| quote!(#name)), } } @@ -5126,7 +5133,9 @@ fn default_if_predicate(cli: &Cli, condition: &ConditionalDefault) -> TokenStrea let Some(other) = cli.field_for_selector(&condition.selector) else { let selector = &condition.selector; return match &condition.when { - None => quote!(argument_state(partial, #selector).is_some_and(|state| state.given)), + None => quote!( + __usage_argument_state(partial, #selector).is_some_and(|state| state.given) + ), Some(when) => quote!( argument_matches(partial, #selector, #when.as_bytes()) == ::std::option::Option::Some(true) @@ -5621,9 +5630,10 @@ pub fn emit_args(cli: &Cli) -> TokenStream { let presence = presence_methods(cli); let any_standing = any_standing_fn(cli); let standing_locals = standing_locals(cli); + let argument_state_standing = argument_state_standing(cli); let apply_defaults = { let defaults = declared_defaults(cli, true); - quote!(#standing_locals #defaults) + quote!(#standing_locals #argument_state_standing #defaults) }; let apply_env = { let env = env_fallbacks(cli, true); @@ -8207,6 +8217,54 @@ fn deprecations_fn(cli: &Cli) -> TokenStream { } } +/// [`argument_state`] that also counts a standing `ArgGroup` member. +/// +/// Only when this argv said nothing about the group: a fresh member on the command line +/// replaces the standing variant, so relationships must not keep reading the old one. +fn argument_state_standing(cli: &Cli) -> TokenStream { + let overlays = cli.fields.iter().filter_map(|field| { + let Kind::ArgGroup { ty, .. } = &field.kind else { + return None; + }; + let ident = &field.ident; + let standing = standing_ident(field); + let group = quote!(<#ty as usage_argv::spec::ArgGroup>); + Some(quote! { + if #group::any_given(&partial.#ident).is_none() { + if let ::std::option::Option::Some(__usage_s) = #standing { + if let ::std::option::Option::Some(__usage_standing_state) = + #group::standing_state(__usage_s, selector) + { + if __usage_standing_state.given { + return ::std::option::Option::Some(__usage_standing_state); + } + } + } + } + }) + }); + quote! { + // Shadow the module helper for every check below: an update's standing group + // member has to answer `requires` / `conflicts` the same way a standing flag does, + // and only this scope holds the standing locals. + #[allow(dead_code)] + let __usage_argument_state = + |partial: &Partial, selector: &str| -> ::std::option::Option< + usage_argv::spec::ArgumentState, + > { + match argument_state(partial, selector) { + ::std::option::Option::Some(state) if state.given || state.satisfied => { + ::std::option::Option::Some(state) + } + recognized => { + #(#overlays)* + recognized + } + } + }; + } +} + /// Everything decided once the last token has been read. /// /// Ordered deliberately. The environment fills what argv left out, so it runs @@ -8222,6 +8280,7 @@ fn post_binding(cli: &Cli) -> TokenStream { let policy_given = standing_policy_given; let semantic_given = standing_semantic_given; let standing_locals = standing_locals(cli); + let argument_state_standing = argument_state_standing(cli); let sub_check = subcommand_parts(cli).map(|p| p.check).unwrap_or_default(); let subcommand_satisfies_requirements = if cli.subcommand_negates_reqs && cli @@ -8582,7 +8641,9 @@ fn post_binding(cli: &Cli) -> TokenStream { } else { quote! { if #given { - if let ::std::option::Option::Some(other) = argument_state(partial, #selector) { + if let ::std::option::Option::Some(other) = + __usage_argument_state(partial, #selector) + { if other.given { return ::std::result::Result::Err( usage_argv::Error::ConflictingFlags { @@ -8614,7 +8675,7 @@ fn post_binding(cli: &Cli) -> TokenStream { let Some(other) = cli.field_for_selector(selector) else { return quote! { if #given { - match argument_state(partial, #selector) { + match __usage_argument_state(partial, #selector) { ::std::option::Option::Some(other) if other.satisfied => {} ::std::option::Option::Some(other) => { return ::std::result::Result::Err( @@ -8688,7 +8749,7 @@ fn post_binding(cli: &Cli) -> TokenStream { }; return quote! { if #given && #matches { - match argument_state(partial, #selector) { + match __usage_argument_state(partial, #selector) { ::std::option::Option::Some(other) if other.satisfied => {} ::std::option::Option::Some(other) => { return ::std::result::Result::Err( @@ -8842,7 +8903,7 @@ fn post_binding(cli: &Cli) -> TokenStream { Some(quote! { ( <#ty as usage_argv::spec::ArgGroup>::any_given(&partial.#ident) - .or_else(|| #standing.then_some(#name)), + .or_else(|| #standing.map(|_| #name)), ::std::option::Option::None, ), }) @@ -9034,8 +9095,9 @@ fn post_binding(cli: &Cli) -> TokenStream { // A bare `T` has nowhere to put "no member", which is the whole declaration — the // same reading of a type that makes a `String` field required. let active = view_field_active(field); + let standing_held = unless_standing(field); group_required_checks.push(quote! { - if #active && #group::any_given(&partial.#ident).is_none() { + if #active && #group::any_given(&partial.#ident).is_none() #standing_held { return ::std::result::Result::Err( usage_argv::Error::MissingGroup { group: #group::NAME, @@ -9071,7 +9133,7 @@ fn post_binding(cli: &Cli) -> TokenStream { Some(match cli.field_for_selector(selector) { Some(other) => policy_given(other), None => quote!( - argument_state(partial, #selector).is_some_and(|state| state.given) + __usage_argument_state(partial, #selector).is_some_and(|state| state.given) ), }) }; @@ -9171,6 +9233,7 @@ fn post_binding(cli: &Cli) -> TokenStream { // What the caller already had, read once. `None` for an ordinary parse, which // folds every one of these to `false`. #standing_locals + #argument_state_standing // Environment first, so a `default_if` can see a sibling filled from // env, and so the environment still overrides an unconditional default. #env_fallbacks @@ -9432,11 +9495,28 @@ pub fn emit_arg_group(group: &ArgGroup) -> TokenStream { quote! { #(#cfg)* #(#selectors)|* => { - return ::std::option::Option::Some(usage_argv::spec::ArgumentState { + ::std::option::Option::Some(usage_argv::spec::ArgumentState { name: #name, given: partial.#given, satisfied: partial.#given, - }); + }) + } + } + }); + let standing_state_arms = group.variants.iter().map(|member| { + let cfg = &member.cfg_attrs; + let name = &member.name; + let variant = &member.ident; + let selectors = member_selectors(member); + quote! { + #(#cfg)* + #(#selectors)|* => { + let __usage_given = ::core::matches!(standing, Self::#variant); + ::std::option::Option::Some(usage_argv::spec::ArgumentState { + name: #name, + given: __usage_given, + satisfied: __usage_given, + }) } } }); @@ -9559,6 +9639,16 @@ pub fn emit_arg_group(group: &ArgGroup) -> TokenStream { } } + fn standing_state( + standing: &Self, + selector: &str, + ) -> ::std::option::Option { + match selector { + #(#standing_state_arms)* + _ => ::std::option::Option::None, + } + } + fn argument_matches( partial: &Self::Partial, selector: &str, From 74b7981acaea5d8482fa1058f8f90531225dc1a3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 01:38:30 +0000 Subject: [PATCH 10/10] fix(derive): scope standing lookups and match values Name the plain lookup helpers when a default_if predicate is inlined into the module-level argument_state, which has no standing locals, and let a standing ArgGroup member answer required_if_eq and a three-argument default_if. Extract one helper for the flatten and group apply arms. Co-authored-by: jdx --- argv/src/spec.rs | 10 ++ conformance/tests/update_from.rs | 39 ++++++ derive/src/codegen.rs | 203 ++++++++++++++++++++++--------- 3 files changed, 193 insertions(+), 59 deletions(-) diff --git a/argv/src/spec.rs b/argv/src/spec.rs index f56dc1a4a..21025da97 100644 --- a/argv/src/spec.rs +++ b/argv/src/spec.rs @@ -2977,6 +2977,16 @@ pub trait ArgGroup: Sized { /// reports not matching rather than inventing a value. fn argument_matches(partial: &Self::Partial, selector: &str, value: &[u8]) -> Option; + /// [`Self::argument_matches`] for a value the caller already holds. + /// + /// The twin of [`Self::standing_state`], and needed for the same reason: a + /// `required_if_eq` naming a member has to read the standing variant when this argv + /// said nothing about the group, since the bytes it was parsed from are gone. + fn standing_matches(standing: &Self, selector: &str, value: &[u8]) -> Option { + let _ = (standing, selector, value); + None + } + /// Clear the member named by `selector` after an overriding token wins. fn displace(partial: &mut Self::Partial, selector: &str) -> bool; diff --git a/conformance/tests/update_from.rs b/conformance/tests/update_from.rs index 4bb4da451..ba1c83983 100644 --- a/conformance/tests/update_from.rs +++ b/conformance/tests/update_from.rs @@ -586,6 +586,45 @@ fn a_standing_group_member_satisfies_a_sibling_requires() { assert!(upd.pretty); } +/// A CLI whose value-conditional rules name a group member rather than a plain flag. +#[derive(Cli, Debug, PartialEq)] +#[usage(bin = "equpd")] +struct EqUpd { + #[usage(arg_group)] + mode: Mode, + /// Needed once JSON is the mode + #[usage(long, required_if_eq("--json", "true"))] + out: Option, + /// Something to change so an update has a reason to run + #[usage(long)] + tag: Option, +} + +#[test] +fn a_standing_group_member_still_triggers_required_if_eq() { + // `--json` stands and `--out` was given, so the first parse is complete. + let a = argv(["--json", "--out", "here"]); + let mut upd = EqUpd::parse_from(&a).expect("json with an out"); + assert_eq!(upd.out.as_deref(), Some("here")); + + // Standing `--out` keeps satisfying the condition the standing `--json` imposes. + let a = argv(["--tag", "second"]); + upd.try_update_from(&a).expect("both sides still stand"); + assert_eq!(upd.mode, Mode::Json); + assert_eq!(upd.tag.as_deref(), Some("second")); + + // And the condition is read from the standing member rather than skipped: with + // `--yaml` standing, switching the mode to JSON without an `--out` is refused. + let a = argv(["--yaml", "--out", "here"]); + let mut upd = EqUpd::parse_from(&a).expect("yaml needs no out"); + let a = argv(["--json"]); + assert!( + upd.try_update_from(&a).is_ok(), + "the standing --out answers the new --json", + ); + assert_eq!(upd.mode, Mode::Json); +} + #[test] fn a_standing_group_member_conflicts_with_a_sibling_flag() { let a = argv(["--yaml"]); diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index 495cada36..078831154 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -3856,7 +3856,7 @@ fn argument_lookup_functions(cli: &Cli) -> TokenStream { // do not set `__given_*`. let defaulted = !field.default.is_empty(); let conditionally_defaulted = - default_if_would_apply(cli, field).unwrap_or_else(|| quote!(false)); + default_if_would_apply(cli, field, Lookup::Module).unwrap_or_else(|| quote!(false)); Some(quote! { #(#selectors)|* => return ::std::option::Option::Some( usage_argv::spec::ArgumentState { @@ -5129,15 +5129,44 @@ fn assign_literal(field: &Field, first: &str, all: &[String]) -> TokenStream { } } -fn default_if_predicate(cli: &Cli, condition: &ConditionalDefault) -> TokenStream { +/// Which pair of lookup helpers a generated predicate may call. +/// +/// The standing-aware pair is a closure over locals only `check` and `apply_declared_defaults` +/// hold, so a predicate inlined into the module-level `argument_state` has to name the plain +/// functions instead — the same predicate, minus the value an update already had. +#[derive(Clone, Copy, PartialEq)] +enum Lookup { + Module, + Standing, +} + +impl Lookup { + fn state(self) -> TokenStream { + match self { + Lookup::Module => quote!(argument_state), + Lookup::Standing => quote!(__usage_argument_state), + } + } + + fn matches(self) -> TokenStream { + match self { + Lookup::Module => quote!(argument_matches), + Lookup::Standing => quote!(__usage_argument_matches), + } + } +} + +fn default_if_predicate(cli: &Cli, condition: &ConditionalDefault, lookup: Lookup) -> TokenStream { let Some(other) = cli.field_for_selector(&condition.selector) else { let selector = &condition.selector; + let state = lookup.state(); + let matches = lookup.matches(); return match &condition.when { None => quote!( - __usage_argument_state(partial, #selector).is_some_and(|state| state.given) + #state(partial, #selector).is_some_and(|state| state.given) ), Some(when) => quote!( - argument_matches(partial, #selector, #when.as_bytes()) + #matches(partial, #selector, #when.as_bytes()) == ::std::option::Option::Some(true) ), }; @@ -5171,18 +5200,47 @@ fn default_if_predicate(cli: &Cli, condition: &ConditionalDefault) -> TokenStrea } } -fn default_if_would_apply(cli: &Cli, field: &Field) -> Option { +fn default_if_would_apply(cli: &Cli, field: &Field, lookup: Lookup) -> Option { if field.default_if.is_empty() { return None; } let preds: Vec = field .default_if .iter() - .map(|condition| default_if_predicate(cli, condition)) + .map(|condition| default_if_predicate(cli, condition, lookup)) .collect(); Some(quote!(#(#preds)||*)) } +/// The `apply` arm for a field whose flags were keyed in another expansion. +/// +/// A flattened struct and an argument group are the same shape of problem: their flags sit in +/// this command's table, but the keys were minted where the type was declared, so only that +/// type can recognize an event. One helper for both, so the displacement rule cannot drift +/// between them — `trait_path` is all that differs. +fn opaque_apply_arm(cli: &Cli, ident: &syn::Ident, trait_path: &TokenStream) -> TokenStream { + let reverse_displacements = cli.fields.iter().flat_map(|field| { + field + .overrides + .iter() + .filter(|selector| cli.field_for_selector(selector).is_none()) + .map(move |selector| { + let statement = displace_statement(cli, field); + quote! { + if #trait_path::event_matches(event, #selector) { + #statement + } + } + }) + }); + quote! { + if #trait_path::apply(&mut partial.#ident, event) { + #(#reverse_displacements)* + return true; + } + } +} + /// Take one event and say whether it belonged to this command. fn apply_fn(cli: &Cli) -> TokenStream { let route = subcommand_parts(cli).map(|p| p.route).unwrap_or_default(); @@ -5197,30 +5255,11 @@ fn apply_fn(cli: &Cli) -> TokenStream { let Kind::Flatten { ty } = &f.kind else { return None; }; - let ident = &f.ident; - let reverse_displacements = cli.fields.iter().flat_map(|field| { - field - .overrides - .iter() - .filter(|selector| cli.field_for_selector(selector).is_none()) - .map(move |selector| { - let statement = displace_statement(cli, field); - quote! { - if <#ty as usage_argv::spec::CommandArgs>::event_matches( - event, - #selector, - ) { - #statement - } - } - }) - }); - Some(quote! { - if <#ty as usage_argv::spec::CommandArgs>::apply(&mut partial.#ident, event) { - #(#reverse_displacements)* - return true; - } - }) + Some(opaque_apply_arm( + cli, + &f.ident, + "e!(<#ty as usage_argv::spec::CommandArgs>), + )) }) .collect(); // An argument group's switches are in this command's table with keys minted in the enum's @@ -5232,30 +5271,11 @@ fn apply_fn(cli: &Cli) -> TokenStream { let Kind::ArgGroup { ty, .. } = &f.kind else { return None; }; - let ident = &f.ident; - let reverse_displacements = cli.fields.iter().flat_map(|field| { - field - .overrides - .iter() - .filter(|selector| cli.field_for_selector(selector).is_none()) - .map(move |selector| { - let statement = displace_statement(cli, field); - quote! { - if <#ty as usage_argv::spec::ArgGroup>::event_matches( - event, - #selector, - ) { - #statement - } - } - }) - }); - Some(quote! { - if <#ty as usage_argv::spec::ArgGroup>::apply(&mut partial.#ident, event) { - #(#reverse_displacements)* - return true; - } - }) + Some(opaque_apply_arm( + cli, + &f.ident, + "e!(<#ty as usage_argv::spec::ArgGroup>), + )) }) .collect(); let mirrored_flattened = cli.fields.iter().filter_map(|f| { @@ -7838,7 +7858,7 @@ fn declared_defaults(cli: &Cli, filter_view: bool) -> TokenStream { .default_if .iter() .map(|condition| { - let pred = default_if_predicate(cli, condition); + let pred = default_if_predicate(cli, condition, Lookup::Standing); let assign = assign_literal(f, &condition.value, std::slice::from_ref(&condition.value)); quote!(if !__usage_filled && (#pred) { @@ -8243,6 +8263,27 @@ fn argument_state_standing(cli: &Cli) -> TokenStream { } }) }); + let match_overlays = cli.fields.iter().filter_map(|field| { + let Kind::ArgGroup { ty, .. } = &field.kind else { + return None; + }; + let ident = &field.ident; + let standing = standing_ident(field); + let group = quote!(<#ty as usage_argv::spec::ArgGroup>); + Some(quote! { + if #group::any_given(&partial.#ident).is_none() { + if let ::std::option::Option::Some(__usage_s) = #standing { + if let ::std::option::Option::Some(__usage_standing_match) = + #group::standing_matches(__usage_s, selector, value) + { + if __usage_standing_match { + return ::std::option::Option::Some(true); + } + } + } + } + }) + }); quote! { // Shadow the module helper for every check below: an update's standing group // member has to answer `requires` / `conflicts` the same way a standing flag does, @@ -8262,6 +8303,20 @@ fn argument_state_standing(cli: &Cli) -> TokenStream { } } }; + // And the same for a check about what a member *is*: `required_if_eq` and a + // three-argument `default_if` name a value, not only a presence. + #[allow(dead_code)] + let __usage_argument_matches = + |partial: &Partial, selector: &str, value: &[u8]| + -> ::std::option::Option { + match argument_matches(partial, selector, value) { + ::std::option::Option::Some(true) => ::std::option::Option::Some(true), + recognized => { + #(#match_overlays)* + recognized + } + } + }; } } @@ -8701,7 +8756,7 @@ fn post_binding(cli: &Cli) -> TokenStream { usage_argv::Error::MissingRequired { name: #other_name }, ); }; - match default_if_would_apply(cli, other) { + match default_if_would_apply(cli, other, Lookup::Standing) { Some(pred) => quote! { if #given && !(#other_given) && !(#pred) { #missing @@ -8787,7 +8842,7 @@ fn post_binding(cli: &Cli) -> TokenStream { _ => quote!(false), }, }; - let unless_default_if = match default_if_would_apply(cli, other) { + let unless_default_if = match default_if_would_apply(cli, other, Lookup::Standing) { Some(pred) => quote!(&& !(#pred)), None => quote!(), }; @@ -9152,7 +9207,10 @@ fn post_binding(cli: &Cli) -> TokenStream { .and_then(Cli::selector_for_field) .unwrap_or_else(|| condition.selector.clone()); let value = &condition.value; - quote!(argument_matches(partial, #selector, #value.as_bytes()).unwrap_or(false)) + quote!( + __usage_argument_matches(partial, #selector, #value.as_bytes()) + .unwrap_or(false) + ) }) .collect(); let if_eq_all: Vec<_> = f @@ -9164,7 +9222,10 @@ fn post_binding(cli: &Cli) -> TokenStream { .and_then(Cli::selector_for_field) .unwrap_or_else(|| condition.selector.clone()); let value = &condition.value; - quote!(argument_matches(partial, #selector, #value.as_bytes()).unwrap_or(false)) + quote!( + __usage_argument_matches(partial, #selector, #value.as_bytes()) + .unwrap_or(false) + ) }) .collect(); let unless_all_given: Vec<_> = f @@ -9533,6 +9594,19 @@ pub fn emit_arg_group(group: &ArgGroup) -> TokenStream { } } }); + let standing_match_arms = group.variants.iter().map(|member| { + let cfg = &member.cfg_attrs; + let variant = &member.ident; + let selectors = member_selectors(member); + quote! { + #(#cfg)* + #(#selectors)|* => { + return ::std::option::Option::Some( + ::core::matches!(standing, Self::#variant) && value == b"true", + ); + } + } + }); let displace_arms = group.variants.iter().enumerate().map(|(i, member)| { let given = format_ident!("given_{i}"); let cfg = &member.cfg_attrs; @@ -9660,6 +9734,17 @@ pub fn emit_arg_group(group: &ArgGroup) -> TokenStream { } } + fn standing_matches( + standing: &Self, + selector: &str, + value: &[u8], + ) -> ::std::option::Option { + match selector { + #(#standing_match_arms)* + _ => ::std::option::Option::None, + } + } + fn displace(partial: &mut Self::Partial, selector: &str) -> bool { match selector { #(#displace_arms)*