From c83fa016eddd8104b2cdf738d5056278b78c5535 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:32:42 +0000 Subject: [PATCH] feat(derive): inherit clap command metadata --- derive/src/codegen.rs | 4 +++- derive/src/lib.rs | 2 ++ derive/src/model.rs | 36 ++++++++++++++++++++++++++----- lib/src/docs/markdown/renderer.rs | 15 +++++++++++++ usage-rs/tests/facade.rs | 35 ++++++++++++++++++++++++++++++ 5 files changed, 86 insertions(+), 6 deletions(-) diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index 73da2efb6..30ccb20da 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -3846,6 +3846,7 @@ pub fn emit_args(cli: &Cli) -> TokenStream { .effect .clone() .unwrap_or_else(|| quote!(::core::option::Option::None)); + let hide = cli.hide; quote! { #[doc(hidden)] #[allow( @@ -3903,6 +3904,7 @@ pub fn emit_args(cli: &Cli) -> TokenStream { deprecated_warn_at: #deprecated_warn_at, deprecated_remove_at: #deprecated_remove_at, hidden_aliases: &[#(#hidden_aliases),*], + hide: #hide, restart_token: #restart_token, subcommand_required: #subcommand_required, subcommand_help_heading: #subcommand_help_heading, @@ -4378,7 +4380,7 @@ pub fn emit_subcommands(subs: &Subcommands) -> TokenStream { before_long_help: #before_long_help, after_help: #after_help, after_long_help: #after_long_help, - hide: #hide, + hide: #hide || <#ty as usage_argv::spec::CommandArgs>::META.hide, help_heading: #help_heading, display_order: #display_order, hidden_aliases: &#hidden_name, diff --git a/derive/src/lib.rs b/derive/src/lib.rs index 721234c38..56ac8e117 100644 --- a/derive/src/lib.rs +++ b/derive/src/lib.rs @@ -201,6 +201,8 @@ //! //! On the struct itself: `bin`, `version`, `long_version`, `author`, `license`, `repository`, `about`, //! `long_about`, `before_help`, `after_help`, +//! clap-compatible `visible_alias(es)`, hidden `alias(es)`, and `hide` may stay on an +//! `Args` struct and are inherited by every subcommand variant that mounts it — //! `verbatim_doc_comment` — preserve doc-comment line breaks and whitespace — //! `default_subcommand`, `multicall` — argv[0]'s basename selects a subcommand — //! `arg_required_else_help` — a selected command with no argv of its own shows short help — diff --git a/derive/src/model.rs b/derive/src/model.rs index 466b7f014..a65bb51c3 100644 --- a/derive/src/model.rs +++ b/derive/src/model.rs @@ -82,6 +82,8 @@ pub struct Cli { /// `Subcommands` variant may still add aliases for the particular route mounting it. pub aliases: Vec, pub hidden_aliases: Vec, + /// Whether a command using this argument struct is omitted from help and completions. + pub hide: bool, /// Default casing for inferred flag and positional names. rename_all: Option, /// Default casing for environment names inferred by bare `env`. @@ -630,6 +632,7 @@ impl Cli { effect: None, aliases: Vec::new(), hidden_aliases: Vec::new(), + hide: false, rename_all: None, rename_all_env: CasingStyle::ScreamingSnake, attr_span: input @@ -683,6 +686,7 @@ impl Cli { }; for attr in attrs(&input.attrs) { + let clap_attr = attr.path().is_ident("command"); for meta in nested(attr)? { let path = meta.path().clone(); match ident_of(&path).as_str() { @@ -739,8 +743,15 @@ impl Cli { "settings" => cli.settings = flag_value(&meta)?, "verbatim_doc_comment" => verbatim_doc_comment = flag_value(&meta)?, "effect" => cli.effect = Some(effect_value(&meta)?), - "alias" => cli.aliases.extend(selectors(&meta)?), + "alias" | "aliases" if clap_attr => { + cli.hidden_aliases.extend(selectors(&meta)?); + } + "alias" | "aliases" => cli.aliases.extend(selectors(&meta)?), + "visible_alias" | "visible_aliases" => { + cli.aliases.extend(selectors(&meta)?); + } "alias_hidden" => cli.hidden_aliases.extend(selectors(&meta)?), + "hide" => cli.hide = flag_value(&meta)?, "min_usage_version" => cli.min_usage_version = Some(string_value(&meta)?), "usage" => cli.usage = Some(string_value(&meta)?), "version" => { @@ -804,8 +815,18 @@ impl Cli { // in one CLI" against "mise prepares your development environment before // each command runs." There is no comment that says both, so they can be // declared. - "about" => cli.about_attr = Some(metadata_expr(&meta)?), - "long_about" => cli.long_about_attr = Some(metadata_expr(&meta)?), + "about" => { + cli.about_attr = Some(match &meta { + Meta::Path(_) => quote::quote!(env!("CARGO_PKG_DESCRIPTION")), + _ => metadata_expr(&meta)?, + }); + } + "long_about" => { + cli.long_about_attr = Some(match &meta { + Meta::Path(_) => quote::quote!(env!("CARGO_PKG_DESCRIPTION")), + _ => metadata_expr(&meta)?, + }); + } "deprecated" => cli.deprecated = Some(string_value(&meta)?), "deprecated_warn_at" => cli.deprecated_warn_at = Some(string_value(&meta)?), "deprecated_remove_at" => cli.deprecated_remove_at = Some(string_value(&meta)?), @@ -875,7 +896,7 @@ impl Cli { path, format!( "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`, `usage`, `deprecated`, `deprecated_warn_at`, `deprecated_remove_at`, `verbatim_doc_comment`, `unknown_flags`, \ + `name`, `name_spec`, `bin`, `bin_spec`, `version`, `version_spec`, `long_version`, `long_version_spec`, `author`, `license`, `repository`, `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`, \ `subcommand_value_name`, `restart_token`, `mount` and \ @@ -4356,12 +4377,17 @@ impl Variant { let mut after_long_help = None; for attr in attrs(&variant.attrs) { + let clap_attr = attr.path().is_ident("command"); for meta in nested(attr)? { let path = meta.path().clone(); match ident_of(&path).as_str() { "name" => name = strip_dashes(&string_value(&meta)?), // One as a value or several as a list, as the relationship options do. - "alias" => aliases.extend(selectors(&meta)?), + "alias" | "aliases" if clap_attr => { + hidden_aliases.extend(selectors(&meta)?); + } + "alias" | "aliases" => aliases.extend(selectors(&meta)?), + "visible_alias" | "visible_aliases" => aliases.extend(selectors(&meta)?), "alias_hidden" => hidden_aliases.extend(selectors(&meta)?), "hide" => hide = flag_value(&meta)?, "help_heading" => help_heading = Some(string_value(&meta)?), diff --git a/lib/src/docs/markdown/renderer.rs b/lib/src/docs/markdown/renderer.rs index 8e03fc89b..0a114de8f 100644 --- a/lib/src/docs/markdown/renderer.rs +++ b/lib/src/docs/markdown/renderer.rs @@ -5,6 +5,11 @@ use itertools::Itertools; fn escape_md(value: &str, html_encode: bool) -> String { let mut in_fenced_code_block = false; + // Help text is allowed to contain terminal styling. clap-era applications commonly build + // their examples with `color_print::cstr!`, which embeds SGR sequences even when color is + // disabled at runtime. Terminal styling has no meaning in generated Markdown, and leaving + // it here publishes literal escape bytes in docs and downstream static sites. + let value = xx::regex!(r"\x1b\[[0-?]*[ -/]*[@-~]").replace_all(value, ""); value .lines() @@ -249,4 +254,14 @@ mod tests { assert_eq!(escape_md(input, false), input); } + + #[test] + fn strips_terminal_styling_from_generated_markdown() { + let input = + "\u{1b}[1m\u{1b}[4mExamples:\u{1b}[22m\u{1b}[24m\n\n \u{1b}[1mmise run\u{1b}[22m"; + let expected = "Examples:\n\n mise run"; + + assert_eq!(escape_md(input, true), expected); + assert_eq!(escape_md(input, false), expected); + } } diff --git a/usage-rs/tests/facade.rs b/usage-rs/tests/facade.rs index 879d758e4..cc34a72f1 100644 --- a/usage-rs/tests/facade.rs +++ b/usage-rs/tests/facade.rs @@ -207,6 +207,28 @@ struct PresentedSubcommands { command: Option, } +#[derive(Args)] +#[command( + visible_alias = "go", + alias = "secret-run", + hide, + after_long_help = "More details." +)] +struct StructCommandMetadata; + +#[derive(Subcommands)] +enum StructMetadataCommands { + Run(StructCommandMetadata), +} + +#[derive(Cli)] +#[command(bin = "struct-metadata", about)] +#[allow(dead_code)] +struct StructMetadataCli { + #[command(subcommand)] + command: StructMetadataCommands, +} + #[derive(Cli)] #[command(bin = "ordered")] #[allow(dead_code)] @@ -1424,6 +1446,19 @@ fn typed_subcommand_presentation_reaches_help_and_the_spec() { assert!(page.contains("Actions:"), "{page}"); } +#[test] +fn clap_command_metadata_can_stay_on_the_args_struct() { + let spec = StructMetadataCli::spec(); + let meta = spec.root.subcommands[0]; + assert_eq!(meta.cmd.aliases, ["go", "secret-run"]); + assert_eq!(meta.hidden_aliases, ["secret-run"]); + assert!(meta.hide); + assert_eq!(meta.after_long_help, Some("More details.")); + + assert!(StructMetadataCli::parse_from(&[OsStr::new("go")]).is_ok()); + assert!(StructMetadataCli::parse_from(&[OsStr::new("secret-run")]).is_ok()); +} + #[test] fn explicit_display_order_reaches_help_and_the_portable_spec() { let spec = OrderedHelp::spec();