Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions argv/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions argv/src/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,32 @@ pub const fn concat_arg_metas<const N: usize>(
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<const N: usize>(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> {
Expand Down
7 changes: 4 additions & 3 deletions conformance/tests/subcommands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand All @@ -262,6 +262,7 @@ struct AliasedInstallArgs {

/// Remove a tool
#[derive(Args)]
#[usage(alias = "uninstall")]
struct AliasedRemoveArgs {
/// Say nothing
#[usage(long)]
Expand Down Expand Up @@ -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());

Expand Down
30 changes: 28 additions & 2 deletions derive/src/codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
};
}
Expand All @@ -2407,13 +2419,19 @@ 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
// metadata is possible in a const, so the tables stay static.
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
Expand All @@ -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
};
}
Expand Down Expand Up @@ -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;

Expand Down
7 changes: 4 additions & 3 deletions derive/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions derive/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ pub struct Cli {
/// Held as the tokens for an `Option<Effect>`, 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<proc_macro2::TokenStream>,
/// 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<String>,
pub hidden_aliases: Vec<String>,
/// 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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)?);
}
Expand Down Expand Up @@ -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(
Expand Down