Skip to content
89 changes: 89 additions & 0 deletions argv/src/diagnostic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,41 @@ fn shown<'a>(meta: Option<&'a CommandMeta<'a>>, name: &str) -> String {
name.to_string()
}

/// A group member is stored as a selector (`--file` or `-f`), not a field name.
fn group_member_shown(meta: Option<&CommandMeta<'_>>, selector: &str) -> String {
let Some(meta) = meta else {
return selector.to_string();
};
let found = meta.flags.iter().find(|flag| {
flag.flag
.longs
.iter()
.any(|long| selector == format!("--{long}"))
|| flag
.flag
.shorts
.iter()
.any(|short| selector == format!("-{}", *short as char))
|| flag
.flag
.negate
.is_some_and(|negate| selector == format!("--{negate}"))
});
found
.map(|flag| {
let mut shown = crate::help::flag_spelling(flag);
if flag.flag.takes_value {
let name = flag.value_name.unwrap_or(flag.flag.name);
let _ = write!(shown, " <{name}>");
if flag.flag.variadic {
shown.push('…');
}
}
shown
})
.unwrap_or_else(|| selector.to_string())
}

/// The word that was bound to a named argument, recovered from argv.
///
/// The parse itself does not carry it: an error that owned the offending text would allocate on
Expand Down Expand Up @@ -591,6 +626,20 @@ pub fn render(
invalid.reason
);
}
Error::MissingGroup { group, members } => {
with_usage = true;
// clap's own shape for a required group, which is the required-arguments
// message with the members listed under it. The group's name goes on the
// first line rather than into the list, since it is not something to type.
let _ = writeln!(
out,
"{} one of the following required arguments was not provided ({group}):",
style.error("error:")
);
for member in *members {
let _ = writeln!(out, " {}", style.valid(&group_member_shown(here, member)));
}
}
Error::ConflictingFlags { name, other } => {
// Spelled by `help`, like every other name in this module — and like clap, which
// writes `the argument '--force' cannot be used with '--jobs <JOBS>'`.
Expand Down Expand Up @@ -818,6 +867,46 @@ mod tests {
assert_eq!(line, crate::help::usage_line(&["ex", "use"], &USE_META));
}

#[test]
fn a_missing_group_lists_value_taking_members_completely() {
static FILE: Flag = Flag {
name: "file",
longs: &["file"],
takes_value: true,
..Flag::BOOL
};
static ROOT: Command = Command {
name: "grouped",
flags: &[&FILE],
..Command::EMPTY
};
static META: CommandMeta = CommandMeta {
cmd: &ROOT,
flags: &[FlagMeta {
flag: &FILE,
value_name: Some("PATH"),
..FlagMeta::EMPTY
}],
..CommandMeta::EMPTY
};
static SPEC: Spec = Spec {
name: "grouped",
bin: Some("grouped"),
root: &META,
..Spec::EMPTY
};
let message = render(
&SPEC,
&[],
&Error::MissingGroup {
group: "input",
members: &["--file"],
},
Style::PLAIN,
);
assert!(message.contains(" --file <PATH>"), "{message}");
}

#[test]
fn a_missing_subcommand_prints_the_choices() {
let message = rendered(&[], Error::MissingSubcommand);
Expand Down
12 changes: 12 additions & 0 deletions argv/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,18 @@ pub enum Error<'t, 'v> {
/// does not grow. A value that will not convert has already failed, and a message
/// worth reading is worth one allocation.
InvalidValue(::std::boxed::Box<InvalidValue<'t>>),
/// A required group had none of its members given.
///
/// Carries the members as members rather than as a rendered sentence: the caller
/// decides how to say it, and a completion asking what would satisfy this needs the
/// list rather than the prose.
MissingGroup {
/// The group's declared name, which appears in the message so a command with
/// several groups does not report the same sentence twice.
group: &'t str,
/// The flags that would satisfy it, as the declaration spells them.
members: &'t [&'t str],
},
/// A subcommand was required, and none was given.
MissingSubcommand,
/// `--help` or `-h` was given, and `cmd` is what it was asked about.
Expand Down
146 changes: 146 additions & 0 deletions argv/src/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,27 @@ fn duplicate_flag_form(cmd: &Command<'_>) -> Option<std::string::String> {
.find_map(|sub| duplicate_flag_form(sub))
}

/// A group name that two declarations on the same command both claim, if any.
///
/// Within one struct the derive catches this, and `#[usage(flatten)]` joins declarations
/// from two expansions that cannot see each other — so a parent and the struct it
/// flattens can each declare `input`, and each then checks only its own members. One
/// member from either side would satisfy neither exclusion, and the emitted KDL would
/// carry two `group "input"` nodes saying different things.
///
/// Checked here for the same reason duplicate flag forms are: this is where the joined
/// tables are visible.
fn duplicate_group_name(meta: &CommandMeta<'_>) -> Option<std::string::String> {
let mut names: std::vec::Vec<&str> = meta.groups.iter().map(|g| g.name).collect();
names.sort_unstable();
if let Some(pair) = names.windows(2).find(|pair| pair[0] == pair[1]) {
return Some(pair[0].to_string());
}
meta.subcommands
.iter()
.find_map(|sub| duplicate_group_name(sub))
}

/// An argument that no word could ever reach, if any.
///
/// An unbounded variadic takes every remaining word, so what follows it can never be filled —
Expand Down Expand Up @@ -390,6 +411,79 @@ pub const fn concat_aliases<const N: usize>(groups: &[&[&'static str]]) -> [&'st
out
}

/// A set of one command's flags that relate to one another as a set.
///
/// Pairwise `conflicts` can say "at most one of these", once per pair; what it cannot
/// say is that one of them is *needed*, which is a statement about the set.
#[derive(Debug, Clone, Copy)]
pub struct GroupMeta<'a> {
/// What the group is called. It appears in the message a failed check produces, and
/// it is how a reader tells two groups on one command apart.
pub name: &'a str,
/// The flags in the group, as selectors — `--long` or `-s`, the way every other
/// relationship names a flag.
pub members: &'a [&'a str],
/// Whether at least one member has to be given.
pub required: bool,
/// Whether more than one member may be given. False is what makes a bare group
/// mutual exclusion, as it does in clap.
pub multiple: bool,
}

impl GroupMeta<'_> {
/// A group with nothing in it, for the array initialiser a const concat needs.
pub const EMPTY: GroupMeta<'static> = GroupMeta {
name: "",
members: &[],
required: false,
multiple: false,
};
}

/// Join groups of group metadata into one, at compile time.
///
/// The same shape as [`concat_flag_metas`], and needed for the same reason: a flattened
/// struct's groups describe flags that are now in the parent's table, so they belong in
/// the parent's emitted spec. Without this a group declared on a flattened struct would
/// be enforced — the child's own `check` runs — and invisible to the KDL, which is
/// exactly the drift the spec-as-definition rule exists to prevent.
///
/// `N` must be [`table_len`](crate::table_len) of the same groups.
pub const fn concat_group_metas<const N: usize>(
groups: &[&[GroupMeta<'static>]],
) -> [GroupMeta<'static>; N] {
let mut out = [GroupMeta::EMPTY; 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() {
// This function initialises a generated `static`, so a collision across a parent
// and a flattened child is rejected while the adopter compiles. Leaving this to
// `to_kdl` let direct parsing enforce two independent groups with the same name.
let mut seen = 0;
while seen < at {
assert!(
!crate::str_eq(out[seen].name, group[i].name),
"two flattened groups on one command have the same name"
);
seen += 1;
}
out[at] = group[i];
at += 1;
i += 1;
}
g += 1;
}
assert!(
at == N,
"`N` must be `table_len` of the same groups, or the metadata would describe a \
group that does not exist"
);
out
}

/// What a command knows about itself beyond how it parses.
#[derive(Debug, Clone, Copy)]
pub struct CommandMeta<'a> {
Expand Down Expand Up @@ -441,6 +535,11 @@ pub struct CommandMeta<'a> {
pub args: &'a [ArgMeta<'a>],
/// Metadata for `cmd.subcommands`, in the same order.
pub subcommands: &'a [&'a CommandMeta<'a>],
/// Sets of this command's flags that relate to one another as a set.
///
/// Cold like everything else here: a group is checked once the last token has been
/// read, by code the derive generates, and a successful parse never reads this.
pub groups: &'a [GroupMeta<'a>],
}

impl CommandMeta<'_> {
Expand All @@ -460,6 +559,7 @@ impl CommandMeta<'_> {
after_help: None,
after_long_help: None,
examples: &[],
groups: &[],
flags: &[],
args: &[],
subcommands: &[],
Expand Down Expand Up @@ -647,6 +747,14 @@ impl Spec<'_> {
the parent and the struct it flattens each declared it.",
duplicate_flag_form(self.root.cmd)
);
assert!(
Comment thread
greptile-apps[bot] marked this conversation as resolved.
duplicate_group_name(self.root).is_none(),
"two groups on the same command are called {:?}, so each would enforce only \
its own members and one from either side would satisfy neither. With \
`flatten` this is the collision neither expansion can see: the parent and \
the struct it flattens each declared it. Give one of them another name.",
duplicate_group_name(self.root)
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
debug_assert!(
unfillable_arg(self.root.cmd).is_none(),
"no word could ever reach the argument {:?}, because an unbounded variadic before \
Expand Down Expand Up @@ -807,6 +915,11 @@ fn write_body(
write_arg(out, arg, depth)?;
}
write_completion_types(out, meta, depth)?;
// After the flags and arguments they name, so a reader meets the members before the
// rule about them — the order usage-lib writes, so a round trip reads the same way.
for group in meta.groups {
write_group(out, group, depth)?;
}
#[cfg(feature = "complete")]
write_completers(out, meta, bin, depth)?;
for sub in meta.subcommands {
Expand Down Expand Up @@ -928,6 +1041,22 @@ fn write_command(
Ok(())
}

fn write_group(out: &mut String, group: &GroupMeta<'_>, depth: usize) -> core::fmt::Result {
indent(out, depth)?;
write!(out, "group {}", quoted(group.name))?;
for member in group.members {
write!(out, " {}", quoted(member))?;
}
if group.required {
out.push_str(" required=#true");
}
if group.multiple {
out.push_str(" multiple=#true");
}
out.push('\n');
Ok(())
}

fn write_example(out: &mut String, example: &Example<'_>, depth: usize) -> core::fmt::Result {
indent(out, depth)?;
write!(out, "example {}", quoted(example.code))?;
Expand Down Expand Up @@ -1735,3 +1864,20 @@ mod tests {
assert_eq!(placeholder("BUMP", false, true), "[BUMP]");
}
}
#[test]
#[should_panic(expected = "two flattened groups on one command have the same name")]
fn concatenating_group_metadata_rejects_duplicate_names() {
static LEFT: [GroupMeta; 1] = [GroupMeta {
name: "input",
members: &["--file", "--url"],
required: false,
multiple: false,
}];
static RIGHT: [GroupMeta; 1] = [GroupMeta {
name: "input",
members: &["--json", "--yaml"],
required: false,
multiple: false,
}];
let _ = concat_group_metas::<2>(&[&LEFT, &RIGHT]);
}
29 changes: 27 additions & 2 deletions conformance/src/tables.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@
//! turned up a rule the two implementations disagree about that nothing had recorded.

use usage::spec::cmd::SpecExample;
use usage::{Spec, SpecArg, SpecCommand, SpecComplete, SpecFlag};
use usage_argv::spec::{ArgMeta, CommandMeta, Effect, Example, FlagMeta};
use usage::{Spec, SpecArg, SpecCommand, SpecComplete, SpecFlag, SpecGroup};
use usage_argv::spec::{ArgMeta, CommandMeta, Effect, Example, FlagMeta, GroupMeta};
use usage_argv::{Arg, Command, DoubleDash, Flag, UnknownFlags as ArgvUnknownFlags};

/// A command's two tables, built together so the metadata can borrow the parse table.
Expand Down Expand Up @@ -144,6 +144,7 @@ pub fn build(
after_help: opt(&cmd.after_help),
after_long_help: opt(&cmd.after_help_long),
examples: examples(&cmd.examples),
groups: groups(&cmd.groups),
flags: Box::leak(flag_metas.into_boxed_slice()),
args: Box::leak(arg_metas.into_boxed_slice()),
subcommands: Box::leak(
Expand Down Expand Up @@ -413,6 +414,30 @@ fn example(e: &SpecExample) -> Example<'static> {
}
}

/// The command's groups, as usage-argv's cold model of them.
///
/// Selectors are leaked one at a time rather than joined: a group names flags the way every
/// other relationship does, and the metadata holds them in that form.
fn groups(list: &[SpecGroup]) -> &'static [GroupMeta<'static>] {
Box::leak(
list.iter()
.map(|g| GroupMeta {
name: leak(&g.name),
members: Box::leak(
g.members
.iter()
.map(|m| leak(m))
.collect::<Vec<_>>()
.into_boxed_slice(),
),
required: g.required,
multiple: g.multiple,
})
.collect::<Vec<_>>()
.into_boxed_slice(),
)
}

fn examples(list: &[SpecExample]) -> &'static [Example<'static>] {
Box::leak(
list.iter()
Expand Down
Loading