diff --git a/argv/src/lib.rs b/argv/src/lib.rs index ef1549e35..9d5eab1d2 100644 --- a/argv/src/lib.rs +++ b/argv/src/lib.rs @@ -759,6 +759,44 @@ pub const fn find_subcommand<'a>( panic!("`default_subcommand` names a command that this one does not have") } +/// Refuse two subcommands that answer to the same name, aliases included. +/// +/// A derive expansion can validate aliases written on one enum, but aliases may also live on +/// the independently expanded `Args` structs its variants wrap. This final, joined-table check +/// is where both declarations are visible. +pub const fn assert_unique_subcommand_names(subcommands: &[&Command<'_>]) { + const fn form<'a>(cmd: &'a Command<'a>, at: usize) -> Option<&'a str> { + if at == 0 { + Some(cmd.name) + } else if at <= cmd.aliases.len() { + Some(cmd.aliases[at - 1]) + } else { + None + } + } + + let mut command = 0; + while command < subcommands.len() { + let mut at = 0; + while let Some(name) = form(subcommands[command], at) { + let mut other_command = command; + while other_command < subcommands.len() { + let mut other_at = if other_command == command { at + 1 } else { 0 }; + while let Some(other) = form(subcommands[other_command], other_at) { + assert!( + !str_eq(name, other), + "two subcommands answer to the same name, counting aliases" + ); + other_at += 1; + } + other_command += 1; + } + at += 1; + } + command += 1; + } +} + /// `==` on strings, in a `const fn`. const fn str_eq(a: &str, b: &str) -> bool { let (a, b) = (a.as_bytes(), b.as_bytes()); @@ -2008,6 +2046,17 @@ mod tests { )); } + #[test] + #[should_panic(expected = "two subcommands answer to the same name")] + fn an_alias_cannot_shadow_a_sibling_command() { + static ADD: Command = Command { + name: "add", + aliases: &["install"], + ..Command::EMPTY + }; + assert_unique_subcommand_names(&[&INSTALL, &ADD]); + } + #[test] fn the_word_is_re_examined_against_the_command_it_reached() { // The reason the cursor steps back rather than the token being consumed: `lint` names diff --git a/argv/src/spec.rs b/argv/src/spec.rs index 5d6ad7fa6..565eef04d 100644 --- a/argv/src/spec.rs +++ b/argv/src/spec.rs @@ -360,6 +360,32 @@ pub const fn concat_arg_metas( out } +/// Join command alias lists at compile time. +/// +/// An `Args` struct can declare aliases belonging to the command itself, while the +/// `Subcommands` variant mounting it can add aliases belonging to that route. The derive joins +/// both without building a command at runtime. +pub const fn concat_aliases(groups: &[&[&'static str]]) -> [&'static str; N] { + let mut out = [""; N]; + let mut at = 0; + let mut g = 0; + while g < groups.len() { + let group = groups[g]; + let mut i = 0; + while i < group.len() { + out[at] = group[i]; + at += 1; + i += 1; + } + g += 1; + } + assert!( + at == N, + "`N` must be `table_len` of the same groups, or an alias would be empty" + ); + out +} + /// What a command knows about itself beyond how it parses. #[derive(Debug, Clone, Copy)] pub struct CommandMeta<'a> { diff --git a/conformance/tests/subcommands.rs b/conformance/tests/subcommands.rs index aaaa3bb78..6fdc1d0c2 100644 --- a/conformance/tests/subcommands.rs +++ b/conformance/tests/subcommands.rs @@ -245,15 +245,15 @@ fn the_emitted_spec_reads_the_way_a_handwritten_one_would() { #[derive(Subcommands)] enum AliasedCommands { /// Install a tool - #[usage(alias = "i", alias_hidden = "add")] Install(AliasedInstallArgs), /// Remove a tool - #[usage(alias("rm", "uninstall"))] + #[usage(alias = "rm")] Remove(AliasedRemoveArgs), } /// Install a tool #[derive(Args)] +#[usage(alias = "i", alias_hidden = "add")] struct AliasedInstallArgs { /// What to install #[usage(arg, name = "TOOL")] @@ -262,6 +262,7 @@ struct AliasedInstallArgs { /// Remove a tool #[derive(Args)] +#[usage(alias = "uninstall")] struct AliasedRemoveArgs { /// Say nothing #[usage(long)] @@ -309,7 +310,7 @@ fn the_spec_says_which_aliases_are_hidden() { let remove = spec.cmd.subcommands.get("remove").expect("remove"); assert_eq!( remove.aliases, - vec!["rm".to_string(), "uninstall".to_string()] + vec!["uninstall".to_string(), "rm".to_string()] ); assert!(remove.hidden_aliases.is_empty()); diff --git a/derive/src/codegen.rs b/derive/src/codegen.rs index 509f3ecf4..e649cb108 100644 --- a/derive/src/codegen.rs +++ b/derive/src/codegen.rs @@ -2179,6 +2179,8 @@ pub fn emit_args(cli: &Cli) -> TokenStream { let arg_meta_table_ref = &tables.arg_metas; let name = &cli.name; + let aliases = cli.aliases.iter().chain(&cli.hidden_aliases); + let hidden_aliases = &cli.hidden_aliases; let about = option_str(cli.about.as_deref()); let long_about = option_str(cli.long_about.as_deref()); let partial = partial_struct(cli); @@ -2227,6 +2229,7 @@ pub fn emit_args(cli: &Cli) -> TokenStream { pub static COMMAND: ::usage_argv::Command = ::usage_argv::Command { name: #name, + aliases: &[#(#aliases),*], key: #command_key, unknown_flags: #unknown_flags, flags: #flag_table_ref, @@ -2244,6 +2247,7 @@ pub fn emit_args(cli: &Cli) -> TokenStream { effect: #effect, about: #about, long_about: #long_about, + hidden_aliases: &[#(#hidden_aliases),*], restart_token: #restart_token, subcommand_required: #subcommand_required, mount: #mount, @@ -2390,15 +2394,23 @@ pub fn emit_subcommands(subs: &Subcommands) -> TokenStream { // only looks right when the two happen to match. let command_overrides = subs.variants.iter().enumerate().map(|(i, v)| { let name = format_ident!("COMMAND_{i}"); + let alias_groups = format_ident!("ALIAS_GROUPS_{i}"); + let aliases_name = format_ident!("ALIASES_{i}"); let ty = &v.ty; let cmd_name = &v.name; // Both kinds of alias go in the table, because the parser matches both; which of // them help and completions mention is the metadata's business, below. let aliases = v.aliases.iter().chain(&v.hidden_aliases); quote! { + const #alias_groups: &[&[&str]] = &[ + <#ty as ::usage_argv::spec::CommandArgs>::COMMAND.aliases, + &[#(#aliases),*], + ]; + static #aliases_name: [&str; ::usage_argv::table_len(#alias_groups)] = + ::usage_argv::spec::concat_aliases(#alias_groups); pub static #name: ::usage_argv::Command = ::usage_argv::Command { name: #cmd_name, - aliases: &[#(#aliases),*], + aliases: &#aliases_name, ..*<#ty as ::usage_argv::spec::CommandArgs>::COMMAND }; } @@ -2407,6 +2419,10 @@ pub fn emit_subcommands(subs: &Subcommands) -> TokenStream { let name = format_ident!("COMMAND_{i}"); quote!(&#name) }); + let unique_commands = (0..subs.variants.len()).map(|i| { + let name = format_ident!("COMMAND_{i}"); + quote!(&#name) + }); // A doc comment on the variant wins over the struct's, since that is where a // reader of the enum expects to describe the command — and ignoring it would lose // the description without saying so. Overriding one field of the struct's @@ -2414,6 +2430,8 @@ pub fn emit_subcommands(subs: &Subcommands) -> TokenStream { let meta_overrides = subs.variants.iter().enumerate().map(|(i, v)| { let name = format_ident!("META_{i}"); let cmd = format_ident!("COMMAND_{i}"); + let hidden_groups = format_ident!("HIDDEN_ALIAS_GROUPS_{i}"); + let hidden_name = format_ident!("HIDDEN_ALIASES_{i}"); let ty = &v.ty; // A doc comment on the variant wins over the struct's, since that is where a // reader of the enum expects to describe the command. Absent one, the @@ -2437,13 +2455,19 @@ pub fn emit_subcommands(subs: &Subcommands) -> TokenStream { // the variant, which is where the command itself is declared. let hide = v.hide; quote! { + const #hidden_groups: &[&[&str]] = &[ + <#ty as ::usage_argv::spec::CommandArgs>::META.hidden_aliases, + &[#(#hidden),*], + ]; + static #hidden_name: [&str; ::usage_argv::table_len(#hidden_groups)] = + ::usage_argv::spec::concat_aliases(#hidden_groups); pub static #name: ::usage_argv::spec::CommandMeta = ::usage_argv::spec::CommandMeta { cmd: &#cmd, about: #about, long_about: #long_about, hide: #hide, - hidden_aliases: &[#(#hidden),*], + hidden_aliases: &#hidden_name, ..*<#ty as ::usage_argv::spec::CommandArgs>::META }; } @@ -2534,6 +2558,8 @@ pub fn emit_subcommands(subs: &Subcommands) -> TokenStream { #(#command_overrides)* #(#meta_overrides)* + const _: () = ::usage_argv::assert_unique_subcommand_names(&[#(#unique_commands),*]); + impl ::usage_argv::spec::Subcommands for #ident { type Partial = Partial; diff --git a/derive/src/lib.rs b/derive/src/lib.rs index 4d8756479..6f237419b 100644 --- a/derive/src/lib.rs +++ b/derive/src/lib.rs @@ -229,9 +229,10 @@ //! invocation move that much stack. Nothing else changes — the box is how the variant //! holds the struct, not something the CLI has, and the spec cannot tell. //! -//! A `Subcommands` variant takes `name`, and the two ways to give a command another -//! name: `alias = "i"` for one it should advertise, `alias_hidden = "add"` for one it -//! should answer to quietly, each accepting several as a list. The parser matches both; +//! A command takes `alias = "i"` for a name it should advertise and +//! `alias_hidden = "add"` for one it should answer to quietly, each accepting several as a +//! 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. //! //! # Settings and the flags that set them diff --git a/derive/src/model.rs b/derive/src/model.rs index 51659ea66..a4395a747 100644 --- a/derive/src/model.rs +++ b/derive/src/model.rs @@ -54,6 +54,12 @@ pub struct Cli { /// Held as the tokens for an `Option`, since the only thing it becomes is a field of /// a generated `static` — a second enum here would be a copy of the spec's to keep in step. pub effect: Option, + /// Other names this command answers to. + /// + /// Declared on an `Args` struct so clap command attributes can migrate in place. A + /// `Subcommands` variant may still add aliases for the particular route mounting it. + pub aliases: Vec, + pub hidden_aliases: Vec, /// Where `#[usage(...)]` was written on the struct, when it was. /// /// Every position rule in [`Cli::check_position`] is about an attribute in the wrong place, @@ -340,6 +346,8 @@ impl Cli { settings: false, min_usage_version: None, effect: None, + aliases: Vec::new(), + hidden_aliases: Vec::new(), attr_span: input .attrs .iter() @@ -376,6 +384,8 @@ impl Cli { "completion" => cli.completion = flag_value(&meta)?, "settings" => cli.settings = flag_value(&meta)?, "effect" => cli.effect = Some(effect_value(&meta)?), + "alias" => cli.aliases.extend(selectors(&meta)?), + "alias_hidden" => cli.hidden_aliases.extend(selectors(&meta)?), "min_usage_version" => cli.min_usage_version = Some(string_value(&meta)?), "version" => { cli.version = Some(match &meta { @@ -444,6 +454,25 @@ impl Cli { cli.long_about = Some(long); } + let alias_span = cli.attr_span.unwrap_or_else(Span::call_site); + let mut seen_aliases: Vec<(&str, Span)> = Vec::new(); + for alias in cli.aliases.iter().chain(&cli.hidden_aliases) { + if alias.is_empty() { + return Err(syn::Error::new( + alias_span, + "an alias with no name would answer to nothing", + )); + } + if let Some((_, first)) = seen_aliases.iter().find(|(name, _)| *name == alias) { + return Err(dup( + alias_span, + *first, + &format!("`{alias}` is declared twice as an alias for this command"), + )); + } + seen_aliases.push((alias, alias_span)); + } + for field in &named.named { cli.fields.push(Field::from_field(field)?); } @@ -564,6 +593,8 @@ impl Cli { // writer asserts the root carries none, so declaring one here would trip a // `debug_assert!` in the writer rather than say anything. (self.effect.is_some(), "effect"), + (!self.aliases.is_empty(), "alias"), + (!self.hidden_aliases.is_empty(), "alias_hidden"), ] { if present { return Err(self.misplaced(