diff --git a/PLAN.md b/PLAN.md index b0a6a75ce..12441ad1a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -567,9 +567,10 @@ Groups are the opposite case: `Command::get_groups`, `ArgGroup::get_args` and 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. -- [ ] **Explicit `display_order`.** Declaration order covers the common case, - but cannot preserve an adopter that deliberately presents fields or - subcommands in a different order. +- [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** diff --git a/argv/src/help.rs b/argv/src/help.rs index eb0be6da6..2681c66a1 100644 --- a/argv/src/help.rs +++ b/argv/src/help.rs @@ -133,7 +133,8 @@ fn help_structure( !arg.hide_short_help } }; - let args: Vec<_> = meta.args.iter().filter(visible_arg).collect(); + let mut args: Vec<_> = meta.args.iter().filter(visible_arg).collect(); + order_args(&mut args, meta.args); if args.iter().any(|arg| arg.help_heading.is_none()) { headings.push("Arguments".to_string()); } @@ -151,7 +152,8 @@ fn help_structure( !flag.hide_short_help } }; - let own: Vec<_> = own.into_iter().filter(visible_flag).collect(); + let mut own: Vec<_> = own.into_iter().filter(visible_flag).collect(); + order_flags(&mut own, meta.flags); let inherited: Vec<_> = inherited .into_iter() .filter(|(flag, _)| { @@ -186,7 +188,7 @@ fn help_structure( fn flat_help_headings(path: &[&str], meta: &CommandMeta<'_>, headings: &mut Vec) { let mut visible: Vec<_> = meta.subcommands.iter().filter(|sub| !sub.hide).collect(); - visible.sort_by_key(|sub| sub.cmd.name); + order_commands(&mut visible); for sub in visible { let mut sub_path = path.to_vec(); sub_path.push(sub.cmd.name); @@ -666,11 +668,12 @@ pub fn short_help(spec: &Spec<'_>, path: &[&str], chain: &[&CommandMeta<'_>]) -> // after the name it belonged to, so nothing in `-h` lined up with anything — and `-h` is // the form most people type. One column per section over its visible entries, which is // the rule the long page already follows. - let args: Vec<&ArgMeta<'_>> = meta + let mut args: Vec<&ArgMeta<'_>> = meta .args .iter() .filter(|a| !a.hide && !a.hide_short_help) .collect(); + order_args(&mut args, meta.args); let arg_col = args .iter() .map(|a| arg_usage(a).chars().count()) @@ -801,7 +804,8 @@ pub fn short_help(spec: &Spec<'_>, path: &[&str], chain: &[&CommandMeta<'_>]) -> /// The list of subcommands, and the `help` command every CLI with subcommands has. fn commands_section(out: &mut String, path: &[&str], meta: &CommandMeta<'_>) { - let visible: Vec<&&CommandMeta<'_>> = meta.subcommands.iter().filter(|c| !c.hide).collect(); + let mut visible: Vec<&&CommandMeta<'_>> = meta.subcommands.iter().filter(|c| !c.hide).collect(); + order_commands(&mut visible); // Nothing visible, no section — `mise direnv` and `mise dotfiles` have subcommands and // every one of them is hidden. The usage *line* still says ``, because // usage-lib computes it before filtering and stores it; matching the reference means @@ -823,7 +827,12 @@ fn commands_section(out: &mut String, path: &[&str], meta: &CommandMeta<'_>) { (usage_line(&sub_path, sub), *sub) }) .collect(); - lines.sort_by(|a, b| a.0.cmp(&b.0)); + lines.sort_by(|a, b| { + a.1.display_order + .unwrap_or(999) + .cmp(&b.1.display_order.unwrap_or(999)) + .then_with(|| a.0.cmp(&b.0)) + }); for (usage, sub) in &lines { let _ = write!(out, " {usage}"); @@ -866,7 +875,7 @@ fn commands_section(out: &mut String, path: &[&str], meta: &CommandMeta<'_>) { fn flat_commands_short(out: &mut String, path: &[&str], meta: &CommandMeta<'_>) { let mut visible: Vec<_> = meta.subcommands.iter().filter(|sub| !sub.hide).collect(); - visible.sort_by_key(|sub| sub.cmd.name); + order_commands(&mut visible); for sub in visible { let mut sub_path = path.to_vec(); sub_path.push(sub.cmd.name); @@ -875,16 +884,18 @@ fn flat_commands_short(out: &mut String, path: &[&str], meta: &CommandMeta<'_>) let _ = writeln!(out, "{}", about.trim_end()); } - let args: Vec<_> = sub + let mut args: Vec<_> = sub .args .iter() .filter(|arg| !arg.hide && !arg.hide_short_help) .collect(); - let flags: Vec<&FlagMeta<'_>> = sub + order_args(&mut args, sub.args); + let mut flags: Vec<&FlagMeta<'_>> = sub .flags .iter() .filter(|flag| !flag.flag.global && !flag.hide && !flag.hide_short_help) .collect(); + order_flags(&mut flags, sub.flags); let arg_col = args .iter() .map(|arg| arg_usage(arg).chars().count()) @@ -983,6 +994,37 @@ fn groups_section<'m, T: 'm>( } } +fn order_args<'a>(items: &mut Vec<&'a ArgMeta<'a>>, declared: &'a [ArgMeta<'a>]) { + items.sort_by_key(|item| { + item.display_order.unwrap_or_else(|| { + declared + .iter() + .position(|candidate| core::ptr::eq(candidate, *item)) + .unwrap_or(usize::MAX) + }) + }); +} + +fn order_flags<'a>(items: &mut Vec<&'a FlagMeta<'a>>, declared: &'a [FlagMeta<'a>]) { + items.sort_by_key(|item| { + item.display_order.unwrap_or_else(|| { + declared + .iter() + .position(|candidate| core::ptr::eq(candidate, *item)) + .unwrap_or(usize::MAX) + }) + }); +} + +fn order_commands(items: &mut Vec<&&CommandMeta<'_>>) { + items.sort_by(|a, b| { + a.display_order + .unwrap_or(999) + .cmp(&b.display_order.unwrap_or(999)) + .then_with(|| a.cmd.name.cmp(b.cmd.name)) + }); +} + /// The bracketed notes after an entry's help: choices, environment, default. fn annotations(out: &mut String, choices: &[&str], env: Option<&str>, default: &[&str]) { if !choices.is_empty() { @@ -1190,11 +1232,12 @@ pub fn long_help(spec: &Spec<'_>, path: &[&str], chain: &[&CommandMeta<'_>]) -> // One column width per section, over its visible entries — the same two the reference // computes, and separately, so a long flag does not push the arguments out. - let args: Vec<&ArgMeta<'_>> = meta + let mut args: Vec<&ArgMeta<'_>> = meta .args .iter() .filter(|a| !a.hide && !a.hide_long_help) .collect(); + order_args(&mut args, meta.args); let arg_col = args .iter() .map(|a| arg_usage(a).chars().count()) @@ -1430,7 +1473,8 @@ fn long_annotations(out: &mut String, choices: &[&str], env: Option<&str>, defau /// The commands list, with each command's help beneath its usage. fn long_commands_section(out: &mut String, path: &[&str], meta: &CommandMeta<'_>) { - let visible: Vec<&&CommandMeta<'_>> = meta.subcommands.iter().filter(|c| !c.hide).collect(); + let mut visible: Vec<&&CommandMeta<'_>> = meta.subcommands.iter().filter(|c| !c.hide).collect(); + order_commands(&mut visible); if visible.is_empty() { return; } @@ -1445,7 +1489,12 @@ fn long_commands_section(out: &mut String, path: &[&str], meta: &CommandMeta<'_> (usage_line(&sub_path, sub), *sub) }) .collect(); - lines.sort_by(|a, b| a.0.cmp(&b.0)); + lines.sort_by(|a, b| { + a.1.display_order + .unwrap_or(999) + .cmp(&b.1.display_order.unwrap_or(999)) + .then_with(|| a.0.cmp(&b.0)) + }); for (usage, sub) in &lines { let _ = write!(out, " {usage}"); @@ -1479,7 +1528,7 @@ fn long_commands_section(out: &mut String, path: &[&str], meta: &CommandMeta<'_> fn flat_commands_long(out: &mut String, path: &[&str], meta: &CommandMeta<'_>, width: usize) { let mut visible: Vec<_> = meta.subcommands.iter().filter(|sub| !sub.hide).collect(); - visible.sort_by_key(|sub| sub.cmd.name); + order_commands(&mut visible); for sub in visible { let mut sub_path = path.to_vec(); sub_path.push(sub.cmd.name); @@ -1492,16 +1541,18 @@ fn flat_commands_long(out: &mut String, path: &[&str], meta: &CommandMeta<'_>, w let _ = writeln!(out, "{}", about.trim_end()); } - let args: Vec<_> = sub + let mut args: Vec<_> = sub .args .iter() .filter(|arg| !arg.hide && !arg.hide_long_help) .collect(); - let flags: Vec<&FlagMeta<'_>> = sub + order_args(&mut args, sub.args); + let mut flags: Vec<&FlagMeta<'_>> = sub .flags .iter() .filter(|flag| !flag.flag.global && !flag.hide && !flag.hide_long_help) .collect(); + order_flags(&mut flags, sub.flags); let arg_col = args .iter() .map(|arg| arg_usage(arg).chars().count()) @@ -1807,7 +1858,7 @@ fn own_and_global<'a>( keep.push((f as *const _, show)); } } - let inherited: Vec<(&FlagMeta<'_>, String)> = ancestors + let mut inherited: Vec<(&FlagMeta<'_>, String)> = ancestors .iter() .flat_map(|meta| meta.flags.iter()) .filter_map(|f| { @@ -1816,6 +1867,18 @@ fn own_and_global<'a>( .map(|(_, show)| (f, column_usage_masked(f, show))) }) .collect(); + let inherited_positions: Vec<*const FlagMeta<'_>> = inherited + .iter() + .map(|(flag, _)| *flag as *const _) + .collect(); + inherited.sort_by_key(|(flag, _)| { + flag.display_order.unwrap_or_else(|| { + inherited_positions + .iter() + .position(|candidate| core::ptr::eq(*candidate, *flag as *const _)) + .unwrap_or(usize::MAX) + }) + }); // Last in the command's own section, which is where clap has them: they carry no // `help_heading`, so a CLI that groups its flags gets them at the end of the ungrouped @@ -1825,6 +1888,7 @@ fn own_and_global<'a>( // negations, and a `--help` the page offers while something else binds it is exactly the // lie this whole model exists to prevent. let mut own = own; + order_flags(&mut own, here.flags); // Forms *and* negations: `long_flag` asks `find_negation` before it offers `--version`, // so a declared negation beats a supplied flag even though it loses to a long. let claimed: Vec = taken diff --git a/argv/src/spec.rs b/argv/src/spec.rs index 952f17fd4..a0effecfa 100644 --- a/argv/src/spec.rs +++ b/argv/src/spec.rs @@ -711,6 +711,8 @@ pub struct CommandMeta<'a> { pub hidden_aliases: &'a [&'a str], /// Whether the command is hidden from help and completions. pub hide: bool, + /// Explicit placement within the parent's command section. + pub display_order: Option, /// What running this does to the world, for a caller deciding whether to /// confirm first. clap cannot express this, which is why mise keeps a /// 330-entry table to bolt it on afterwards. @@ -778,6 +780,7 @@ impl CommandMeta<'_> { long_about: None, hidden_aliases: &[], hide: false, + display_order: None, effect: None, mount: None, restart_token: None, @@ -805,6 +808,8 @@ impl CommandMeta<'_> { #[derive(Debug, Clone, Copy)] pub struct FlagMeta<'a> { pub flag: &'a Flag<'a>, + /// Explicit placement within its help section. + pub display_order: Option, /// Short forms accepted by the parser but omitted from help and completion. pub hidden_shorts: &'a [u8], /// Long forms accepted by the parser but omitted from help and completion. @@ -913,6 +918,7 @@ impl FlagMeta<'_> { complete: None, complete_type: None, flag: &Flag::BOOL, + display_order: None, hidden_shorts: &[], hidden_longs: &[], help: None, @@ -989,6 +995,8 @@ pub struct DefaultIf<'a> { #[derive(Debug, Clone, Copy)] pub struct ArgMeta<'a> { pub arg: &'a Arg<'a>, + /// Explicit placement within its help section. + pub display_order: Option, /// Ordered placeholders for a fixed-arity positional. pub value_names: &'a [&'a str], pub help: Option<&'a str>, @@ -1044,6 +1052,7 @@ impl ArgMeta<'_> { complete: None, complete_type: None, arg: &Arg::REQUIRED, + display_order: None, value_names: &[], help: None, long_help: None, @@ -1486,6 +1495,9 @@ fn write_command<'a>( if meta.subcommand_required && !meta.cmd.subcommands.is_empty() { out.push_str(" subcommand_required=#true"); } + if let Some(order) = meta.display_order { + write!(out, " display_order={order}")?; + } if let Some(heading) = meta.subcommand_help_heading { write!(out, " subcommand_help_heading={}", quoted(heading))?; } @@ -1689,6 +1701,9 @@ fn write_flag(out: &mut String, meta: &FlagMeta<'_>, depth: usize) -> core::fmt: if let Some(heading) = meta.help_heading { write!(out, " help_heading={}", quoted(heading))?; } + if let Some(order) = meta.display_order { + write!(out, " display_order={order}")?; + } if let Some(effect) = meta.effect { write!(out, " effect={}", quoted(effect.as_str()))?; } @@ -1998,6 +2013,9 @@ fn write_arg(out: &mut String, meta: &ArgMeta<'_>, depth: usize) -> core::fmt::R if let Some(heading) = meta.help_heading { write!(out, " help_heading={}", quoted(heading))?; } + if let Some(order) = meta.display_order { + write!(out, " display_order={order}")?; + } if let Some(env) = meta.env { write!(out, " env={}", quoted(env))?; } diff --git a/clap_usage/tests/fidelity_report.rs b/clap_usage/tests/fidelity_report.rs index 6f9894d3a..5324bd946 100644 --- a/clap_usage/tests/fidelity_report.rs +++ b/clap_usage/tests/fidelity_report.rs @@ -26,6 +26,22 @@ fn disabled_long_version_flag_is_reported() { .any(|loss| loss.feature == FidelityFeature::DisableVersionFlag)); } +#[test] +fn display_order_is_lossless() { + let mut command = Command::new("ex") + .arg(Arg::new("second").long("second").display_order(20)) + .arg(Arg::new("first").long("first").display_order(10)) + .subcommand(Command::new("second").display_order(20)) + .subcommand(Command::new("first").display_order(10)); + let (spec, report) = spec_with_report(&mut command, "ex"); + + assert_eq!(spec.cmd.flags[0].display_order, Some(20)); + assert_eq!(spec.cmd.flags[1].display_order, Some(10)); + assert_eq!(spec.cmd.subcommands[0].display_order, Some(20)); + assert_eq!(spec.cmd.subcommands[1].display_order, Some(10)); + assert!(report.is_lossless(), "{report:#?}"); +} + #[test] fn reports_detectable_losses_with_locations() { let mut command = Command::new("ex") diff --git a/conformance/src/tables.rs b/conformance/src/tables.rs index f360b4ee2..49de5dbb4 100644 --- a/conformance/src/tables.rs +++ b/conformance/src/tables.rs @@ -143,6 +143,7 @@ pub fn build( .into_boxed_slice(), ), hide: cmd.hide, + display_order: cmd.display_order, effect: cmd.effect.map(effect), // A command carries at most one mount in the tables; a spec may list several, and the // first is the one the tables can hold. @@ -420,6 +421,7 @@ fn flag_meta( required_unless: strs(&f.required_unless), required_unless_all: strs(&f.required_unless_all), help_heading: opt(&f.help_heading), + display_order: f.display_order, effect: f.effect.map(effect), complete_type: complete_type(completers, &f.name, arg.map(|a| a.name.as_str())), complete: NO_COMPLETER, @@ -448,6 +450,7 @@ fn arg_meta( validate_error: a.validate_error.as_deref().map(leak), required: a.required, hide: a.hide, + display_order: a.display_order, hide_default_value: a.hide_default_value, hide_env: a.hide_env, hide_env_values: a.hide_env_values, diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index 80ea07b9f..1cf232e2b 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -1302,6 +1302,7 @@ fn flag_meta(cli: &Cli, i: usize, field: &Field, owner: &syn::Ident) -> TokenStr let long_help = option_str(field.long_help.as_deref()); let env = option_str(field.env.as_deref()); let help_heading = option_str(field.help_heading.as_deref()); + let display_order = option_usize(field.display_order); let value_name = option_str(field.value_name.as_deref()); let value_names = &field.value_names; let complete_type = option_str(field.complete_type.as_deref()); @@ -1417,6 +1418,7 @@ fn flag_meta(cli: &Cli, i: usize, field: &Field, owner: &syn::Ident) -> TokenStr complete: #completer, complete_type: #complete_type, flag: &#table, + display_order: #display_order, help: #help, long_help: #long_help, env: #env, @@ -1472,6 +1474,7 @@ fn arg_meta(cli: &Cli, i: usize, field: &Field, owner: &syn::Ident) -> TokenStre let long_help = option_str(field.long_help.as_deref()); let env = option_str(field.env.as_deref()); let help_heading = option_str(field.help_heading.as_deref()); + let display_order = option_usize(field.display_order); let complete_type = option_str(field.complete_type.as_deref()); let value_names = &field.value_names; let defaults = &field.default; @@ -1538,6 +1541,7 @@ fn arg_meta(cli: &Cli, i: usize, field: &Field, owner: &syn::Ident) -> TokenStre complete: #completer, complete_type: #complete_type, arg: &#table, + display_order: #display_order, value_names: &[#(#value_names),*], help: #help, long_help: #long_help, @@ -4193,6 +4197,7 @@ pub fn emit_subcommands(subs: &Subcommands) -> TokenStream { // A hidden command still answers to its name; it is simply not offered. Declared on // the variant, which is where the command itself is declared. let hide = v.hide; + let display_order = option_usize(v.display_order); quote! { const #hidden_groups: &[&[&str]] = &[ <#ty as usage_argv::spec::CommandArgs>::META.hidden_aliases, @@ -4210,6 +4215,7 @@ pub fn emit_subcommands(subs: &Subcommands) -> TokenStream { after_help: #after_help, after_long_help: #after_long_help, hide: #hide, + display_order: #display_order, hidden_aliases: &#hidden_name, ..*<#ty as usage_argv::spec::CommandArgs>::META }; diff --git a/derive/src/lib.rs b/derive/src/lib.rs index 9c76ab1fe..64ecd4e97 100644 --- a/derive/src/lib.rs +++ b/derive/src/lib.rs @@ -236,6 +236,7 @@ //! | `env` | infer the environment variable from the field, using the command's `rename_all_env` policy | //! | `default = "x"` | the value when the command line does not supply one; a `Vec` may be given several, and starts out holding all of them | //! | `help_heading = "x"` | the section to list this under in help output | +//! | `display_order = n` | explicit help order; positional parsing still follows declaration order | //! | `verbatim_doc_comment` | preserve line breaks and whitespace in the doc comment instead of flowing its first paragraph | //! | `hide` | keep it out of help and completions | //! | `effect = "write"` | what supplying this flag does to the world: `read`, `write` or `destructive`. Also goes on an `Args`, where it says what *running* the command does | @@ -312,6 +313,7 @@ //! list. They may be written on the `Args` struct that owns the command or on its //! `Subcommands` variant; when both say some, the lists are joined. The parser matches both; //! the difference is only whether help and completions mention them. +//! `display_order = n` on the variant controls where the command is presented in help. //! //! # Settings and the flags that set them //! diff --git a/derive/src/model.rs b/derive/src/model.rs index 12884e9eb..8acff41e0 100644 --- a/derive/src/model.rs +++ b/derive/src/model.rs @@ -213,6 +213,8 @@ pub struct Field { /// the explicit portable spelling emitted in static metadata. pub default_value_t: Option, pub help_heading: Option, + /// Explicit placement within its help section. + pub display_order: Option, /// What supplying this flag does to the world, when it says. /// /// A flag can only *raise* what its command does — `--dry-run` does not make a writing @@ -1533,6 +1535,7 @@ impl Field { default: Vec::new(), default_value_t: None, help_heading: None, + display_order: None, value_name: None, value_names: Vec::new(), required_collection: false, @@ -1659,6 +1662,7 @@ impl Field { default: Vec::new(), default_value_t: None, help_heading: None, + display_order: None, value_name: None, value_names: Vec::new(), required_collection: false, @@ -1779,6 +1783,7 @@ impl Field { default: Vec::new(), default_value_t: None, help_heading: None, + display_order: None, value_name: None, value_names: Vec::new(), required_collection: false, @@ -1870,6 +1875,7 @@ impl Field { let mut default: Vec = Vec::new(); let mut default_value_t = None; let mut help_heading = None; + let mut display_order = None; let mut effect = None; let mut value_name = None; let mut value_names: Vec = Vec::new(); @@ -2103,6 +2109,7 @@ impl Field { }); } "help_heading" => help_heading = Some(string_value(&meta)?), + "display_order" => display_order = Some(int_value(&meta)?), "effect" => effect = Some(effect_value(&meta)?), "value_name" => value_name = Some(string_value(&meta)?), "value_names" => value_names = selectors(&meta)?, @@ -2163,7 +2170,7 @@ impl Field { `value_terminator`, `require_equals`, \ `default_missing`, `default_if`, \ `required_if`, \ - `required_unless`, `help_heading`, `value_name`, `value_names`, `num_args`, \ + `required_unless`, `help_heading`, `display_order`, `value_name`, `value_names`, `num_args`, \ `verbatim_doc_comment`, \ `visible_alias`, `visible_aliases`, `required`, \ `double_dash`, and `skip`" @@ -2991,6 +2998,7 @@ impl Field { default, default_value_t, help_heading, + display_order, effect, value_name, value_names, @@ -3851,6 +3859,8 @@ fn unit_struct_ident(enum_ident: &syn::Ident, variant: &syn::Ident) -> syn::Iden /// One variant: a command name and the struct holding its flags and arguments. pub struct Variant { pub ident: syn::Ident, + /// Explicit placement within the parent's command section. + pub display_order: Option, /// Whether the command is kept out of help and completions. /// /// A spec says `hide=#true` on a `cmd`; mise hides eight commands that way, `asdf` and @@ -4018,6 +4028,7 @@ impl Variant { let mut hidden_aliases: Vec = Vec::new(); let mut effect = None; let mut hide = false; + let mut display_order = None; let mut external = false; let mut help_attr: Option = None; let mut long_help_attr: Option = None; @@ -4035,6 +4046,7 @@ impl Variant { "alias" => aliases.extend(selectors(&meta)?), "alias_hidden" => hidden_aliases.extend(selectors(&meta)?), "hide" => hide = flag_value(&meta)?, + "display_order" => display_order = Some(int_value(&meta)?), "external_subcommand" => external = flag_value(&meta)?, "effect" => { // Checked where it is written, and checked again by the struct's own @@ -4057,7 +4069,7 @@ impl Variant { path, format!( "unknown option `{other}` on a variant; a subcommand \ - variant takes `name`, `alias`, `alias_hidden`, \ + variant takes `name`, `alias`, `alias_hidden`, `display_order`, \ `external_subcommand`, `help`, `long_help`, `before_help`, \ `before_long_help`, `after_help`, `after_long_help`, and `verbatim_doc_comment` here, \ and its description comes from the doc comment" @@ -4157,6 +4169,7 @@ impl Variant { hide: false, name, effect: None, + display_order: None, unit: false, inline_fields: None, ty: held, @@ -4218,6 +4231,7 @@ impl Variant { Ok(Variant { ident: variant.ident.clone(), + display_order, hide, name, effect, diff --git a/docs/rust/args-and-flags.md b/docs/rust/args-and-flags.md index 50fc3ac32..fbf88f22b 100644 --- a/docs/rust/args-and-flags.md +++ b/docs/rust/args-and-flags.md @@ -92,6 +92,7 @@ jobs: Option, | `value_names = ["A", "B"]` | Distinct placeholders for a fixed multi-value field | | `help = "…"` / `long_help = "…"` | Help text (doc comments are usually nicer) | | `help_heading = "…"` | Group the entry under a heading in help output | +| `display_order = n` | Explicit help order; positional parsing still follows declaration order | | `hide` | Omit from help, docs, and completions | | `required` | Explicit required-ness (for `Vec` fields) | | `value_optional` | Mark the value optional in help (help-only; the parser still wants one) | diff --git a/docs/rust/clap-compatibility.md b/docs/rust/clap-compatibility.md index efca2d15d..a687f624d 100644 --- a/docs/rust/clap-compatibility.md +++ b/docs/rust/clap-compatibility.md @@ -115,6 +115,7 @@ the Rust declaration, not only from generated KDL, wherever the bridge column sa | `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. | diff --git a/docs/rust/help.md b/docs/rust/help.md index f76c7c498..721e20d81 100644 --- a/docs/rust/help.md +++ b/docs/rust/help.md @@ -57,6 +57,8 @@ convenience entry point for CLIs that want immediate print-and-exit behavior. - `before_help`, `after_help`, `before_long_help`, `after_long_help` add text around the page — `after_long_help` is the conventional home for an Examples section. - `help_heading` on a field groups it under a heading. +- `display_order = n` on a field or subcommand controls its position within a help section + without changing positional parsing order. - `next_line_help` on a command puts every argument, flag, and subcommand description beneath its usage instead of in an aligned column beside it. - `flatten_help` replaces the command list with a synopsis and argument summary for every diff --git a/docs/spec/reference/arg.md b/docs/spec/reference/arg.md index 7d2ee2f6d..51dcb933d 100644 --- a/docs/spec/reference/arg.md +++ b/docs/spec/reference/arg.md @@ -11,6 +11,7 @@ arg "" // positional arg, completed as a direc arg "[file]" // optional positional arg arg "" default="file.txt" // default value for arg arg "" env="MY_FILE" // arg can be backed by an env var +arg "" display_order=10 // explicit order in help; parse order is unchanged arg "" parse="mycli parse-file {}" // parse arg value with external command arg "" validate="int(value) >= 1 && int(value) <= 65535" validate_error="must be a valid port" diff --git a/docs/spec/reference/cmd.md b/docs/spec/reference/cmd.md index bc141bf84..010f5ad2f 100644 --- a/docs/spec/reference/cmd.md +++ b/docs/spec/reference/cmd.md @@ -12,6 +12,7 @@ cmd "config" subcommand_required=#true // subcommand is not optional cmd "config" subcommand_help_heading="Actions" subcommand_value_name="ACTION" cmd "config" next_line_help=#true // put descriptions below each entry cmd "config" flatten_help=#true // expand visible subcommands into this page +cmd "config" display_order=10 // present before commands with a greater order // these are shown under -h cmd "config" before_help="shown before the command" diff --git a/docs/spec/reference/flag.md b/docs/spec/reference/flag.md index 960ae7838..174d2d997 100644 --- a/docs/spec/reference/flag.md +++ b/docs/spec/reference/flag.md @@ -16,6 +16,7 @@ flag "--user" { // another way to define the same flag flag "--user" { alias "-u" hide=#true } // hide alias from docs and completions flag "-f --force" global=#true // global can be set on any subcommand +flag "--force" display_order=10 // explicit order within its help section flag "--file " default="file.txt" // default value for flag flag "-v --verbose" count=#true // instead of true/false $usage_verbose is # of times // flag was used (e.g. -vvv = 3) diff --git a/go/argv/help.go b/go/argv/help.go index 6b130ee4d..5a43ab695 100644 --- a/go/argv/help.go +++ b/go/argv/help.go @@ -63,6 +63,9 @@ type Help struct { Long string // Heading groups an entry into a section of the page. Presentational only. Heading string + // DisplayOrderSet distinguishes an explicit zero from declaration order. + DisplayOrder uint32 + DisplayOrderSet bool // The rest a page prints and the usage line does not. diff --git a/go/argv/page.go b/go/argv/page.go index 8d5623647..0b0bbf7e5 100644 --- a/go/argv/page.go +++ b/go/argv/page.go @@ -259,8 +259,13 @@ func commandsSection(out *strings.Builder, path []string, cmd *Command, help Hel } out.WriteString("\n" + heading + ":\n") - // Sorted by the rendered usage rather than by name, as usage-lib sorts them. - sortLines(lines, func(i int) string { return lines[i].usage }) + sort.SliceStable(lines, func(i, j int) bool { + left, right := helpOrder(help, lines[i].sub.Key, 999), helpOrder(help, lines[j].sub.Key, 999) + if left != right { + return left < right + } + return lines[i].usage < lines[j].usage + }) for _, l := range lines { out.WriteString(" " + l.usage) @@ -292,7 +297,7 @@ func commandsSection(out *strings.Builder, path []string, cmd *Command, help Hel func flatCommandsShort(out *strings.Builder, path []string, cmd *Command, help HelpTable, nextLine bool) { visible := append([]*Command{}, cmd.Subcommands...) - sort.Slice(visible, func(i, j int) bool { return visible[i].Name < visible[j].Name }) + orderCommands(visible, help) for _, sub := range visible { h := help.Lookup(sub.Key) if h != nil && h.Hide { @@ -319,6 +324,7 @@ func flatCommandsShort(out *strings.Builder, path []string, cmd *Command, help H flags = append(flags, f) flagCol = max(flagCol, width(columnUsage(f, allShown(f), help))) } + orderFlags(flags, help) for _, a := range args { ah := help.Lookup(a.Key) usage := argUsage(a, ah) @@ -457,6 +463,34 @@ func headingOf(help HelpTable, key uint64) string { return "" } +func helpOrder(help HelpTable, key uint64, fallback int) uint32 { + if h := help.Lookup(key); h != nil && h.DisplayOrderSet { + return h.DisplayOrder + } + return uint32(fallback) +} + +func orderCommands(commands []*Command, help HelpTable) { + sort.SliceStable(commands, func(i, j int) bool { + left, right := helpOrder(help, commands[i].Key, 999), helpOrder(help, commands[j].Key, 999) + if left != right { + return left < right + } + return commands[i].Name < commands[j].Name + }) +} + +func orderFlags(flags []*Flag, help HelpTable) { + positions := map[uint64]int{} + for i, flag := range flags { + positions[flag.Key] = i + } + sort.SliceStable(flags, func(i, j int) bool { + return helpOrder(help, flags[i].Key, positions[flags[i].Key]) < + helpOrder(help, flags[j].Key, positions[flags[j].Key]) + }) +} + func visibleArgs(cmd *Command, help HelpTable, long bool) []*Arg { out := make([]*Arg, 0, len(cmd.Args)) for _, a := range cmd.Args { @@ -467,6 +501,14 @@ func visibleArgs(cmd *Command, help HelpTable, long bool) []*Arg { } out = append(out, a) } + positions := map[uint64]int{} + for i, arg := range out { + positions[arg.Key] = i + } + sort.SliceStable(out, func(i, j int) bool { + return helpOrder(help, out[i].Key, positions[out[i].Key]) < + helpOrder(help, out[j].Key, positions[out[j].Key]) + }) return out } diff --git a/go/argv/page_long.go b/go/argv/page_long.go index 100ede313..3c481727f 100644 --- a/go/argv/page_long.go +++ b/go/argv/page_long.go @@ -182,7 +182,13 @@ func longCommandsSection(out *strings.Builder, path []string, cmd *Command, help heading = h.SubcommandHelpHeading } out.WriteString("\n" + heading + ":\n") - sortLines(lines, func(i int) string { return lines[i].usage }) + sort.SliceStable(lines, func(i, j int) bool { + left, right := helpOrder(help, lines[i].sub.Key, 999), helpOrder(help, lines[j].sub.Key, 999) + if left != right { + return left < right + } + return lines[i].usage < lines[j].usage + }) for _, l := range lines { out.WriteString(" " + l.usage) @@ -208,7 +214,7 @@ func longCommandsSection(out *strings.Builder, path []string, cmd *Command, help func flatCommandsLong(out *strings.Builder, path []string, cmd *Command, help HelpTable, nextLine bool) { visible := append([]*Command{}, cmd.Subcommands...) - sort.Slice(visible, func(i, j int) bool { return visible[i].Name < visible[j].Name }) + orderCommands(visible, help) for _, sub := range visible { h := help.Lookup(sub.Key) if h != nil && h.Hide { @@ -236,6 +242,7 @@ func flatCommandsLong(out *strings.Builder, path []string, cmd *Command, help He flags = append(flags, f) flagCol = max(flagCol, width(columnUsage(f, allShown(f), help))) } + orderFlags(flags, help) for _, a := range args { ah := help.Lookup(a.Key) entry(out, argUsage(a, ah), firstOf(metaField(ah, func(x *Help) string { return x.Long }), diff --git a/go/argv/page_test.go b/go/argv/page_test.go index f2d40d953..85c455001 100644 --- a/go/argv/page_test.go +++ b/go/argv/page_test.go @@ -139,6 +139,40 @@ func TestSubcommandPresentation(t *testing.T) { } } +func TestExplicitDisplayOrder(t *testing.T) { + second := &Flag{Key: 2, Name: "second", Longs: []string{"second"}} + first := &Flag{Key: 3, Name: "first", Longs: []string{"first"}} + secondCmd := &Command{Name: "second", Key: 4} + firstCmd := &Command{Name: "first", Key: 5} + root := &Command{ + Name: "ex", + Key: 1, + Flags: []*Flag{second, first}, + Subcommands: []*Command{secondCmd, firstCmd}, + } + help := HelpTable{ + {Key: 1}, + {Key: 2, Short: "shown second", DisplayOrder: 20, DisplayOrderSet: true}, + {Key: 3, Short: "shown first", DisplayOrder: 10, DisplayOrderSet: true}, + {Key: 4, Short: "shown second", DisplayOrder: 20, DisplayOrderSet: true}, + {Key: 5, Short: "shown first", DisplayOrder: 10, DisplayOrderSet: true}, + } + + for _, page := range []string{ + ShortHelp(HelpSpec{Bin: "ex"}, []string{"ex"}, []*Command{root}, help), + LongHelp(HelpSpec{Bin: "ex"}, []string{"ex"}, []*Command{root}, help), + } { + commands := strings.SplitN(page, "\nCommands:\n", 2)[1] + if strings.Index(commands, "first") > strings.Index(commands, "second") { + t.Fatalf("commands ignored display order:\n%s", page) + } + flags := strings.SplitN(page, "\nFlags:\n", 2)[1] + if strings.Index(flags, "--first") > strings.Index(flags, "--second") { + t.Fatalf("flags ignored display order:\n%s", page) + } + } +} + func TestNextLineHelp(t *testing.T) { arg := &Arg{Name: "input", Key: 2, Required: true} flag := &Flag{Name: "verbose", Key: 3, Longs: []string{"verbose"}} diff --git a/go/argv/scope.go b/go/argv/scope.go index c69040ed0..51c32205b 100644 --- a/go/argv/scope.go +++ b/go/argv/scope.go @@ -1,6 +1,9 @@ package argv -import "strings" +import ( + "sort" + "strings" +) // Which flags a page offers, and under which spellings. // @@ -222,6 +225,18 @@ func ownAndGlobal(chain []*Command, help HelpTable) (own, inherited []shownFlag) } } } + orderShown := func(flags []shownFlag) { + positions := map[uint64]int{} + for i, flag := range flags { + positions[flag.key] = i + } + sort.SliceStable(flags, func(i, j int) bool { + return helpOrder(help, flags[i].key, positions[flags[i].key]) < + helpOrder(help, flags[j].key, positions[flags[j].key]) + }) + } + orderShown(own) + orderShown(inherited) // Last in the command's own section, which is where clap has them: they carry // no heading, so a CLI that groups its flags gets them at the end of the diff --git a/go/internal/spec/spec.go b/go/internal/spec/spec.go index cdcc55ef5..77002f389 100644 --- a/go/internal/spec/spec.go +++ b/go/internal/spec/spec.go @@ -87,6 +87,7 @@ type Cmd struct { Hide bool `json:"hide"` Help string `json:"help"` HelpLong string `json:"help_long"` + DisplayOrder *uint32 `json:"display_order"` Usage string `json:"usage"` BeforeHelp string `json:"before_help"` AfterHelp string `json:"after_help"` @@ -641,7 +642,7 @@ func (b *builder) command(c *Cmd, inherited argv.UnknownFlags) *argv.Command { for _, e := range c.Examples { examples = append(examples, argv.Example{Header: e.Header, Code: e.Code, Help: e.Help}) } - b.recordHelp(out.Key, argv.Help{ + commandHelp := argv.Help{ Hide: c.Hide, Short: first(c.Help, c.HelpLong), // No fallback to the short text, because the emitter does not write one for @@ -664,7 +665,12 @@ func (b *builder) command(c *Cmd, inherited argv.UnknownFlags) *argv.Command { FlattenHelp: c.FlattenHelp, SubcommandRequired: c.SubcommandRequired, Examples: examples, - }) + } + if c.DisplayOrder != nil { + commandHelp.DisplayOrder = *c.DisplayOrder + commandHelp.DisplayOrderSet = true + } + b.recordHelp(out.Key, commandHelp) if n := len(c.Aliases) + len(c.HiddenAliases); n > 0 { out.Aliases = make([]string, 0, n) diff --git a/go/internal/spec/spec_test.go b/go/internal/spec/spec_test.go index 01cbf3b73..73694144b 100644 --- a/go/internal/spec/spec_test.go +++ b/go/internal/spec/spec_test.go @@ -541,6 +541,36 @@ func TestSubcommandsKeepTheOrderTheyWereDeclaredIn(t *testing.T) { } } +func TestCommandDisplayOrderSurvivesLoweredJSON(t *testing.T) { + var s Spec + const lowered = `{"name":"ex","bin":"ex","cmd":{"name":"ex","subcommands":{ + "late":{"name":"late","display_order":20},"early":{"name":"early","display_order":0}}}}` + if err := json.Unmarshal([]byte(lowered), &s); err != nil { + t.Fatalf("lowered spec should decode: %v", err) + } + + root, _, help := s.BuildAll() + for _, tc := range []struct { + name string + order uint32 + }{{"late", 20}, {"early", 0}} { + var sub *argv.Command + for _, candidate := range root.Subcommands { + if candidate.Name == tc.name { + sub = candidate + break + } + } + if sub == nil { + t.Fatalf("missing command %q", tc.name) + } + meta := help.Lookup(sub.Key) + if meta == nil || !meta.DisplayOrderSet || meta.DisplayOrder != tc.order { + t.Fatalf("%s display order was lost: %#v", tc.name, meta) + } + } +} + // Decoding into a spec that already holds one replaces its commands. // // A decoder does not get to assume a fresh value: appending to the receiver would diff --git a/lib/src/docs/cli/mod.rs b/lib/src/docs/cli/mod.rs index 7098f01e8..0a428d629 100644 --- a/lib/src/docs/cli/mod.rs +++ b/lib/src/docs/cli/mod.rs @@ -952,6 +952,36 @@ cmd "run" help="Run it\n" assert!(page.contains("\nActions:\n"), "{page}"); } + #[test] + fn test_render_help_honors_explicit_display_order() { + let spec = crate::spec! { r#" +bin "testcli" +flag "--unset" help="Unordered" +flag "--later" help="Later" display_order=20 +flag "--first" help="First" display_order=10 +cmd "zulu" help="Unordered" +cmd "later" help="Later" display_order=20 +cmd "first" help="First" display_order=10 +cmd "alpha" help="Unordered" + "# } + .unwrap(); + + let page = render_help(&spec, &spec.cmd, false); + let commands = page.split_once("\nCommands:\n").unwrap().1; + assert!( + commands.find("first").unwrap() < commands.find("later").unwrap() + && commands.find("later").unwrap() < commands.find("alpha").unwrap() + && commands.find("alpha").unwrap() < commands.find("zulu").unwrap(), + "{page}" + ); + let flags = page.split_once("\nFlags:\n").unwrap().1; + assert!( + flags.find("--first").unwrap() < flags.find("--later").unwrap() + && flags.find("--later").unwrap() < flags.find("--unset").unwrap(), + "{page}" + ); + } + #[test] fn test_render_help_with_next_line_layout() { 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 2f39574b5..a7cd284b9 100644 --- a/lib/src/docs/cli/templates/spec_template_long.tera +++ b/lib/src/docs/cli/templates/spec_template_long.tera @@ -34,7 +34,7 @@ Usage: {{ (spec.bin ~ " " ~ cmd.usage) | trim }} {%- if cmd.subcommands and not cmd.flatten_help %} {{ cmd.subcommand_help_heading | default(value="Commands") }}: -{%- for cmd in cmd.subcommands | values | sort(attribute="usage") %} +{%- for cmd in cmd.help_subcommands %} {{ cmd.usage | trim }} {%- if cmd.deprecated %} [deprecated: {{ cmd.deprecated }}]{%- endif %} {%- if cmd.aliases %} [aliases: {{ cmd.aliases | join(sep=", ") }}]{% endif %} diff --git a/lib/src/docs/cli/templates/spec_template_short.tera b/lib/src/docs/cli/templates/spec_template_short.tera index 3c65396de..14d0db199 100644 --- a/lib/src/docs/cli/templates/spec_template_short.tera +++ b/lib/src/docs/cli/templates/spec_template_short.tera @@ -27,7 +27,7 @@ Usage: {{ (spec.bin ~ " " ~ cmd.usage) | trim }} {{ cmd.subcommand_help_heading | default(value="Commands") }}: {%- set next_line_help = cmd.next_line_help %} -{%- for cmd in cmd.subcommands | values | sort(attribute="usage") %} +{%- for cmd in cmd.help_subcommands %} {{ cmd.usage | trim }} {%- if cmd.deprecated %} [deprecated: {{ cmd.deprecated }}]{%- endif %} {%- if cmd.aliases %} [aliases: {{ cmd.aliases | join(sep=", ") }}]{% endif %} diff --git a/lib/src/docs/models.rs b/lib/src/docs/models.rs index 04c9be455..351a9ca2b 100644 --- a/lib/src/docs/models.rs +++ b/lib/src/docs/models.rs @@ -35,6 +35,8 @@ pub struct SpecCommand { pub full_cmd: Vec, pub usage: String, pub subcommands: IndexMap, + /// Immediate children in help presentation order, without duplicating their trees. + pub help_subcommands: Vec, pub args: Vec, pub flags: Vec, /// `flags`, partitioned by `help_heading`. Same flags, same order. @@ -45,6 +47,7 @@ pub struct SpecCommand { pub deprecated: Option, pub effect: Option, pub hide: bool, + pub display_order: Option, pub subcommand_required: bool, pub subcommand_help_heading: Option, pub next_line_help: bool, @@ -73,6 +76,31 @@ pub struct SpecCommand { pub rendered: bool, } +/// The fields an immediate child contributes to its parent's command list. +/// +/// Keeping this separate from [`SpecCommand`] avoids cloning every descendant tree for every +/// ancestor merely to put one level of command summaries in presentation order. +#[derive(Debug, Serialize, Clone)] +pub struct HelpCommand { + pub usage: String, + pub deprecated: Option, + pub aliases: Vec, + pub help: Option, + pub help_long: Option, +} + +impl From<&SpecCommand> for HelpCommand { + fn from(cmd: &SpecCommand) -> Self { + Self { + usage: cmd.usage.clone(), + deprecated: cmd.deprecated.clone(), + aliases: cmd.aliases.clone(), + help: cmd.help.clone(), + help_long: cmd.help_long.clone(), + } + } +} + #[derive(Debug, Default, Clone, Serialize)] pub struct SpecFlag { pub name: String, @@ -104,6 +132,7 @@ pub struct SpecFlag { pub negate: Option, pub env: Option, pub help_heading: Option, + pub display_order: Option, pub rendered: bool, #[serde(skip_serializing_if = "Option::is_none")] pub help_rendered: Option, @@ -382,6 +411,7 @@ pub struct SpecArg { pub validate_error: Option, pub env: Option, pub help_heading: Option, + pub display_order: Option, pub rendered: bool, #[serde(skip_serializing_if = "Option::is_none")] pub help_rendered: Option, @@ -434,10 +464,10 @@ impl From<&crate::SpecCommand> for SpecCommand { .subcommands .values() .filter(|sub| !sub.hide) - .map(crate::SpecCommand::usage) + .map(|sub| (sub.display_order.unwrap_or(999), sub.usage())) .collect(); children.sort(); - lines.extend(children); + lines.extend(children.into_iter().map(|(_, usage)| usage)); lines } else { Vec::new() @@ -445,10 +475,11 @@ impl From<&crate::SpecCommand> for SpecCommand { // Calculate layout for args let args_usage_col_width = max_usage_width(cmd.args.iter().map(|a| a.usage.as_str())); - let args: Vec = cmd + let mut args: Vec<(usize, SpecArg)> = cmd .args .iter() - .map(|arg| { + .enumerate() + .map(|(index, arg)| { let mut spec_arg = SpecArg::from(arg); // Get help text (prefer help_long over help) @@ -465,12 +496,21 @@ impl From<&crate::SpecCommand> for SpecCommand { } spec_arg.usage_col_width = args_usage_col_width; - spec_arg + (index, spec_arg) }) .collect(); + args.sort_by_key(|(_, arg)| arg.display_order.unwrap_or(999)); + let args: Vec = args.into_iter().map(|(_, arg)| arg).collect(); // Calculate layout for flags - let flags: Vec = cmd.flags.iter().map(SpecFlag::from).collect(); + let mut flags: Vec<(usize, SpecFlag)> = cmd + .flags + .iter() + .enumerate() + .map(|(index, flag)| (index, SpecFlag::from(flag))) + .collect(); + flags.sort_by_key(|(_, flag)| flag.display_order.unwrap_or(999)); + let flags: Vec = flags.into_iter().map(|(_, flag)| flag).collect(); let flags_usage_col_width = max_usage_width(flags.iter().map(|f| f.display_usage.as_str())); let flags: Vec = flags .into_iter() @@ -502,6 +542,7 @@ impl From<&crate::SpecCommand> for SpecCommand { deprecated, effect, hide, + display_order, subcommand_required, subcommand_help_heading, subcommand_value_name: _, @@ -547,15 +588,38 @@ impl From<&crate::SpecCommand> for SpecCommand { groups: _, } = cmd; - let subcommands: IndexMap<_, _> = subcommands + let rendered_subcommands: IndexMap = subcommands + .iter() + .map(|(key, command)| (key.clone(), SpecCommand::from(command))) + .collect(); + let mut help_order: Vec<_> = subcommands .iter() - .map(|(k, v)| (k.clone(), SpecCommand::from(v))) + .map(|(name, command)| (command.display_order.unwrap_or(999), command.usage(), name)) + .collect(); + help_order.sort_by(|a, b| { + a.0.cmp(&b.0) + .then_with(|| a.1.cmp(&b.1)) + .then_with(|| a.2.cmp(b.2)) + }); + let help_subcommands: Vec = help_order + .iter() + .map(|(_, _, key)| { + HelpCommand::from( + rendered_subcommands + .get(*key) + .expect("rendered subcommand retains its key"), + ) + }) .collect(); let mut flattened_subcommands = Vec::new(); if *flatten_help { - let mut visible: Vec<_> = subcommands.values().filter(|sub| !sub.hide).collect(); - visible.sort_by(|a, b| a.name.cmp(&b.name)); - for sub in visible { + for (_, _, key) in help_order { + let sub = rendered_subcommands + .get(key) + .expect("rendered subcommand retains its key"); + if sub.hide { + continue; + } let mut section = sub.clone(); section.flattened_next_line_help = *next_line_help; flattened_subcommands.push(section); @@ -566,7 +630,8 @@ impl From<&crate::SpecCommand> for SpecCommand { Self { full_cmd: full_cmd.clone(), usage: usage.clone(), - subcommands, + subcommands: rendered_subcommands, + help_subcommands, flag_groups: group_by_heading(&flags, |f| f.help_heading.as_deref()), arg_groups: group_by_heading(&args, |a| a.help_heading.as_deref()), args, @@ -574,6 +639,7 @@ impl From<&crate::SpecCommand> for SpecCommand { deprecated: deprecated.clone(), effect: *effect, hide: *hide, + display_order: *display_order, subcommand_required: *subcommand_required, subcommand_help_heading: subcommand_help_heading.clone(), next_line_help: *next_line_help, @@ -747,6 +813,7 @@ impl From<&crate::SpecFlag> for SpecFlag { negate: flag.negate.clone(), env: flag.env.clone(), help_heading: flag.help_heading.clone(), + display_order: flag.display_order, rendered: false, help_rendered: None, help_is_multiline: false, @@ -793,6 +860,7 @@ impl From<&crate::SpecArg> for SpecArg { validate_error: arg.validate_error.clone(), env: arg.env.clone(), help_heading: arg.help_heading.clone(), + display_order: arg.display_order, rendered: false, help_rendered: None, help_is_multiline: false, diff --git a/lib/src/go/mod.rs b/lib/src/go/mod.rs index 1b9c3bd38..ab7da804c 100644 --- a/lib/src/go/mod.rs +++ b/lib/src/go/mod.rs @@ -996,6 +996,10 @@ fn command_help(e: &Emitted) -> String { if e.cmd.hide { fields.push("Hide: true".to_string()); } + if let Some(order) = e.cmd.display_order { + fields.push(format!("DisplayOrder: {order}")); + fields.push("DisplayOrderSet: true".to_string()); + } if let Some(help) = e.cmd.help.as_deref().or(e.cmd.help_long.as_deref()) { fields.push(format!("Short: {}", go_string(help))); } @@ -1075,6 +1079,10 @@ fn flag_help(flag: &SpecFlag, named: &Named) -> String { if flag.hide { fields.push("Hide: true".to_string()); } + if let Some(order) = flag.display_order { + fields.push(format!("DisplayOrder: {order}")); + fields.push("DisplayOrderSet: true".to_string()); + } for (name, hidden) in [ ("HideDefaultValue", flag.hide_default_value), ("HideEnv", flag.hide_env), @@ -1149,6 +1157,10 @@ fn flag_help(flag: &SpecFlag, named: &Named) -> String { fn arg_help(arg: &SpecArg, named: &Named) -> String { let mut fields = vec![format!("Key: {}", named.key)]; + if let Some(order) = arg.display_order { + fields.push(format!("DisplayOrder: {order}")); + fields.push("DisplayOrderSet: true".to_string()); + } if arg.hide { fields.push("Hide: true".to_string()); } diff --git a/lib/src/spec/arg.rs b/lib/src/spec/arg.rs index 0f6cbaa03..648c3b5a3 100644 --- a/lib/src/spec/arg.rs +++ b/lib/src/spec/arg.rs @@ -168,6 +168,9 @@ pub struct SpecArg { /// like the flag field of the same name. #[serde(skip_serializing_if = "Option::is_none")] pub help_heading: Option, + /// Explicit placement within its help section. + #[serde(skip_serializing_if = "Option::is_none")] + pub display_order: Option, } impl SpecArg { @@ -242,6 +245,7 @@ impl SpecArg { "validate" => arg.validate = v.ensure_string().map(Some)?, "validate_error" => arg.validate_error = v.ensure_string().map(Some)?, "help_heading" => arg.help_heading = v.ensure_string().map(Some)?, + "display_order" => arg.display_order = v.ensure_usize().map(Some)?, k => bail_parse!(ctx, v.entry.span(), "unsupported arg key {k}"), } } @@ -271,6 +275,9 @@ impl SpecArg { "help_heading" => { arg.help_heading = child.arg(0)?.ensure_string().map(Some)?; } + "display_order" => { + arg.display_order = child.arg(0)?.ensure_usize().map(Some)?; + } "default" => { // Support both single value and multiple values // default "bar" -> vec!["bar"] @@ -561,6 +568,9 @@ impl From<&SpecArg> for KdlNode { if let Some(help_heading) = &arg.help_heading { node.push(string_entry(Some("help_heading"), help_heading)); } + if let Some(order) = arg.display_order { + node.push(KdlEntry::new_prop("display_order", order as i128)); + } if let Some(effect) = &arg.effect { node.push(string_entry(Some("effect"), effect.as_str())); } @@ -970,6 +980,7 @@ impl From<&clap::Arg> for SpecArg { effect: None, env: None, help_heading: arg.get_help_heading().map(|s| s.to_string()), + display_order: Some(arg.get_display_order()), }; arg.choices = choices; diff --git a/lib/src/spec/cmd.rs b/lib/src/spec/cmd.rs index f1fa5419c..0a80b77c5 100644 --- a/lib/src/spec/cmd.rs +++ b/lib/src/spec/cmd.rs @@ -72,6 +72,9 @@ pub struct SpecCommand { pub unknown_flags: Option, /// Whether to hide this command from help output pub hide: bool, + /// Explicit placement within its parent's command section. + #[serde(skip_serializing_if = "Option::is_none")] + pub display_order: Option, /// True when this command came from a [`SpecMount`], i.e. it describes another /// program's CLI that was merged in at parse time. /// @@ -205,6 +208,7 @@ impl Default for SpecCommand { effect: None, unknown_flags: None, hide: false, + display_order: None, mounted: false, flags_from_mount: false, subcommand_required: false, @@ -348,6 +352,7 @@ impl SpecCommand { } "allow_missing_positional" => cmd.allow_missing_positional = v.ensure_bool()?, "hide" => cmd.hide = v.ensure_bool()?, + "display_order" => cmd.display_order = Some(v.ensure_usize()?), "unknown_flags" => { let raw = v.ensure_string()?; match raw.parse() { @@ -638,6 +643,7 @@ impl SpecCommand { hidden_aliases, examples, hide, + display_order, subcommand_required, subcommand_help_heading, subcommand_value_name, @@ -724,6 +730,9 @@ impl SpecCommand { self.examples = examples; } self.hide = hide; + if display_order.is_some() { + self.display_order = display_order; + } self.subcommand_required = subcommand_required; if subcommand_help_heading.is_some() { self.subcommand_help_heading = subcommand_help_heading; @@ -848,6 +857,7 @@ impl From<&SpecCommand> for KdlNode { let SpecCommand { name, hide, + display_order, subcommand_required, subcommand_help_heading, subcommand_value_name, @@ -897,6 +907,10 @@ impl From<&SpecCommand> for KdlNode { if *hide { node.entries_mut().push(KdlEntry::new_prop("hide", true)); } + if let Some(order) = display_order { + node.entries_mut() + .push(KdlEntry::new_prop("display_order", *order as i128)); + } if *subcommand_required { node.entries_mut() .push(KdlEntry::new_prop("subcommand_required", true)); @@ -1172,6 +1186,28 @@ impl From<&clap::Command> for SpecCommand { spec.flags.push(flag) } } + // clap assigns an implicit monotonically increasing order to arguments. Emitting + // that number for every ordinary declaration makes generated specs noisy without + // changing presentation, since usage already retains declaration order. Keep the + // values only when they actually reorder a section. + if spec + .args + .windows(2) + .all(|pair| pair[0].display_order <= pair[1].display_order) + { + for arg in &mut spec.args { + arg.display_order = None; + } + } + if spec + .flags + .windows(2) + .all(|pair| pair[0].display_order <= pair[1].display_order) + { + for flag in &mut spec.flags { + flag.display_order = None; + } + } // Groups, which clap does expose — `get_groups`, and `get_args` on each. A group // names its members by clap's internal id, so each is resolved back to the flag it // points at and written as a selector, the way conflicts are just above. @@ -1233,8 +1269,21 @@ impl From<&clap::Command> for SpecCommand { for subcmd in cmd.get_subcommands() { let mut scmd: SpecCommand = subcmd.into(); scmd.name = subcmd.get_name().to_string(); + scmd.display_order = Some(subcmd.get_display_order()); spec.subcommands.insert(scmd.name.clone(), scmd); } + // 999 is clap's ordinary subcommand order. Leaving every command at that value lets + // usage's existing alphabetical tie-breaker produce the same page without serializing + // redundant metadata. + if spec + .subcommands + .iter() + .all(|(_, subcommand)| subcommand.display_order == Some(999)) + { + for (_, subcommand) in &mut spec.subcommands { + subcommand.display_order = None; + } + } spec } } diff --git a/lib/src/spec/flag.rs b/lib/src/spec/flag.rs index 6170844b0..8797a60e5 100644 --- a/lib/src/spec/flag.rs +++ b/lib/src/spec/flag.rs @@ -246,6 +246,9 @@ pub struct SpecFlag { /// it. #[serde(skip_serializing_if = "Option::is_none")] pub help_heading: Option, + /// Explicit placement within its help section. + #[serde(skip_serializing_if = "Option::is_none")] + pub display_order: Option, } impl SpecFlag { @@ -325,6 +328,7 @@ impl SpecFlag { } "env" => flag.env = v.ensure_string().map(Some)?, "help_heading" => flag.help_heading = v.ensure_string().map(Some)?, + "display_order" => flag.display_order = v.ensure_usize().map(Some)?, k => bail_parse!(ctx, v.entry.span(), "unsupported flag key {k}"), } } @@ -452,6 +456,9 @@ impl SpecFlag { "help_heading" => { flag.help_heading = child.arg(0)?.ensure_string().map(Some)?; } + "display_order" => { + flag.display_order = child.arg(0)?.ensure_usize().map(Some)?; + } "alias" => { let hide = child .get("hide") @@ -904,6 +911,9 @@ impl From<&SpecFlag> for KdlNode { if let Some(help_heading) = &flag.help_heading { node.push(string_entry(Some("help_heading"), help_heading)); } + if let Some(order) = flag.display_order { + node.push(KdlEntry::new_prop("display_order", order as i128)); + } if let Some(effect) = &flag.effect { node.push(string_entry(Some("effect"), effect.as_str())); } @@ -1160,6 +1170,7 @@ impl From<&clap::Arg> for SpecFlag { effect: None, env: None, help_heading: c.get_help_heading().map(|s| s.to_string()), + display_order: Some(c.get_display_order()), }; if c.is_allow_hyphen_values_set() { if let Some(arg) = &mut flag.arg { diff --git a/usage-rs/tests/facade.rs b/usage-rs/tests/facade.rs index ff8e80707..7b9c6f5dd 100644 --- a/usage-rs/tests/facade.rs +++ b/usage-rs/tests/facade.rs @@ -198,6 +198,31 @@ struct PresentedSubcommands { command: Option, } +#[derive(Cli)] +#[command(bin = "ordered")] +#[allow(dead_code)] +struct OrderedHelp { + /// Shown second. + #[arg(long, global, display_order = 20)] + second: bool, + /// Shown first. + #[arg(long, global, display_order = 10)] + first: bool, + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommands)] +#[allow(dead_code)] +enum OrderedCommand { + /// Shown second. + #[command(display_order = 20)] + Second, + /// Shown first. + #[command(display_order = 10)] + First, +} + #[derive(Subcommands, serde::Deserialize)] enum InlineCommand { /// Run a named benchmark. @@ -1238,6 +1263,38 @@ fn typed_subcommand_presentation_reaches_help_and_the_spec() { assert!(page.contains("Actions:"), "{page}"); } +#[test] +fn explicit_display_order_reaches_help_and_the_portable_spec() { + let spec = OrderedHelp::spec(); + let page = usage::argv::help::short_help(spec, &["ordered"], &[spec.root]); + let flags = page.split_once("\nFlags:\n").unwrap().1; + assert!( + flags.find("--first").unwrap() < flags.find("--second").unwrap(), + "{page}" + ); + assert!( + page.find("first Shown first.").unwrap() < page.find("second Shown second.").unwrap(), + "{page}" + ); + let child = &spec.root.subcommands[0]; + let child_page = + usage::argv::help::short_help(spec, &["ordered", child.cmd.name], &[spec.root, child]); + let globals = child_page.split_once("\nGlobal flags:\n").unwrap().1; + assert!( + globals.find("--first").unwrap() < globals.find("--second").unwrap(), + "{child_page}" + ); + + let kdl = OrderedHelp::to_kdl(); + assert!(kdl.contains("display_order=10"), "{kdl}"); + assert!(kdl.contains("display_order=20"), "{kdl}"); + let portable: usage_parser::Spec = kdl.parse().unwrap(); + assert_eq!(portable.cmd.flags[0].display_order, Some(20)); + assert_eq!(portable.cmd.flags[1].display_order, Some(10)); + assert_eq!(portable.cmd.subcommands[0].display_order, Some(20)); + assert_eq!(portable.cmd.subcommands[1].display_order, Some(10)); +} + #[test] fn typed_help_width_reaches_help_and_the_portable_spec() { let spec = SizedHelp::spec();