From be0b197ef00736eddf56a8add98014cb6a750422 Mon Sep 17 00:00:00 2001 From: Maciej Dziardziel Date: Tue, 12 Nov 2024 01:25:51 +0000 Subject: [PATCH 1/6] feat: Add CommandGroup support --- clap_builder/src/builder/command.rs | 61 +++++++++++++ clap_builder/src/builder/mod.rs | 2 + clap_builder/src/lib.rs | 1 + clap_builder/src/output/help_template.rs | 110 ++++++++++++++++++++--- examples/git.rs | 8 +- 5 files changed, 169 insertions(+), 13 deletions(-) diff --git a/clap_builder/src/builder/command.rs b/clap_builder/src/builder/command.rs index 6472071fcee..1f9d244cc5c 100644 --- a/clap_builder/src/builder/command.rs +++ b/clap_builder/src/builder/command.rs @@ -20,6 +20,7 @@ use crate::builder::Str; use crate::builder::StyledStr; use crate::builder::Styles; use crate::builder::{Arg, ArgGroup, ArgPredicate}; +use crate::builder::{CommandGroup}; use crate::error::ErrorKind; use crate::error::Result as ClapResult; use crate::mkeymap::MKeyMap; @@ -100,6 +101,7 @@ pub struct Command { args: MKeyMap, subcommands: Vec, groups: Vec, + command_groups: Vec, current_help_heading: Option, current_disp_ord: Option, subcommand_value_name: Option, @@ -340,6 +342,25 @@ impl Command { self.groups.push(f(a)); self } + + #[must_use] + #[cfg_attr(debug_assertions, track_caller)] + pub fn mut_command_group(mut self, cmd_id: impl AsRef, f: F) -> Self + where + F: FnOnce(CommandGroup) -> CommandGroup, + { + let id = cmd_id.as_ref(); + let index = self + .command_groups + .iter() + .position(|g| g.get_id() == id) + .unwrap_or_else(|| panic!("Command group `{id}` is undefined")); + let a = self.command_groups.remove(index); + + self.command_groups.push(f(a)); + self + } + /// Allows one to mutate a [`Command`] after it's been added as a subcommand. /// /// This can be useful for modifying auto-generated arguments of nested subcommands with @@ -425,6 +446,14 @@ impl Command { self } + #[inline] + #[must_use] + pub fn command_group(mut self, group: impl Into) -> Self { + self.command_groups.push(group.into()); + self + } + + /// Adds multiple [`ArgGroup`]s to the [`Command`] at once. /// /// # Examples @@ -456,6 +485,15 @@ impl Command { self } + + #[must_use] + pub fn command_groups(mut self, groups: impl IntoIterator>) -> Self { + for g in groups { + self = self.command_group(g.into()); + } + self + } + /// Adds a subcommand to the list of valid possibilities. /// /// Subcommands are effectively sub-[`Command`]s, because they can contain their own arguments, @@ -3694,6 +3732,12 @@ impl Command { self.groups.iter() } + /// Iterate through the set of command groups. + #[inline] + pub fn get_command_groups(&self) -> impl Iterator { + self.command_groups.iter() + } + /// Iterate through the set of arguments. #[inline] pub fn get_arguments(&self) -> impl Iterator { @@ -4139,6 +4183,22 @@ impl Command { self.args._build(); + for c in self.subcommands.iter_mut() { + // Fill in the command_groups + for g in &c.command_groups { + if let Some(cg) = self.command_groups.iter_mut().find(|grp| grp.id == g.id) { + cg.commands.push(c.name.clone()); + } else { + let mut cg = CommandGroup::new(g.get_id().clone()); + cg.commands.push(c.name.clone()); + self.command_groups.push(cg); + } + } + } + + + + #[allow(deprecated)] { let highest_idx = self @@ -4899,6 +4959,7 @@ impl Default for Command { args: Default::default(), subcommands: Default::default(), groups: Default::default(), + command_groups: Default::default(), current_help_heading: Default::default(), current_disp_ord: Some(0), subcommand_value_name: Default::default(), diff --git a/clap_builder/src/builder/mod.rs b/clap_builder/src/builder/mod.rs index 9f871d7ff7c..9fa7570672e 100644 --- a/clap_builder/src/builder/mod.rs +++ b/clap_builder/src/builder/mod.rs @@ -7,6 +7,7 @@ mod arg_group; mod arg_predicate; mod arg_settings; mod command; +mod command_group; mod ext; mod os_str; mod possible_value; @@ -33,6 +34,7 @@ pub use arg::ArgExt; pub use arg_group::ArgGroup; pub use arg_predicate::ArgPredicate; pub use command::Command; +pub use command_group::CommandGroup; #[cfg(feature = "unstable-ext")] pub use command::CommandExt; pub use os_str::OsStr; diff --git a/clap_builder/src/lib.rs b/clap_builder/src/lib.rs index 602c2fb654e..00d2c567b12 100644 --- a/clap_builder/src/lib.rs +++ b/clap_builder/src/lib.rs @@ -16,6 +16,7 @@ compile_error!("`std` feature is currently required to build `clap`"); pub use crate::builder::ArgAction; pub use crate::builder::Command; +pub use crate::builder::CommandGroup; pub use crate::builder::ValueHint; pub use crate::builder::{Arg, ArgGroup}; pub use crate::parser::ArgMatches; diff --git a/clap_builder/src/output/help_template.rs b/clap_builder/src/output/help_template.rs index da08ccd3895..aed447babbc 100644 --- a/clap_builder/src/output/help_template.rs +++ b/clap_builder/src/output/help_template.rs @@ -370,7 +370,7 @@ impl<'cmd, 'writer> HelpTemplate<'cmd, 'writer> { pub(crate) fn write_all_args(&mut self) { debug!("HelpTemplate::write_all_args"); use std::fmt::Write as _; - let header = &self.styles.get_header(); + let header = &self.styles.get_header(); let pos = self .cmd @@ -401,12 +401,14 @@ impl<'cmd, 'writer> HelpTemplate<'cmd, 'writer> { self.writer.push_str("\n\n"); } first = false; - let default_help_heading = Str::from("Commands"); - let help_heading = self - .cmd - .get_subcommand_help_heading() - .unwrap_or(&default_help_heading); - let _ = write!(self.writer, "{header}{help_heading}:{header:#}\n",); + if self.needs_subcmd_help_header() { + let default_help_heading = Str::from("Commands"); + let help_heading = self + .cmd + .get_subcommand_help_heading() + .unwrap_or(&default_help_heading); + let _ = write!(self.writer, "{header}{help_heading}:{header:#}\n",); + } self.write_subcommands(self.cmd); } @@ -907,6 +909,37 @@ impl<'cmd, 'writer> HelpTemplate<'cmd, 'writer> { } } +#[cfg(any(feature = "usage", feature = "help"))] + pub(crate) fn has_visible_subcommands(&self) -> bool { + self.visible_subcommands() + .next() + .is_some() + } + + //// Check if this subcommand should display help header + /// which will be the case if either there are subcommands and no command groups + /// or there are subcommands and some of them do not belong to any group + #[cfg(any(feature = "usage", feature = "help"))] + pub(crate) fn needs_subcmd_help_header(&self) -> bool { + self.visible_ungroupped_subcommands() + .next() + .is_some() + } + + + pub(crate) fn visible_subcommands(&self) -> impl Iterator { + self.cmd + .get_subcommands() + .filter(|subcommand| should_show_subcommand(subcommand)) + } + + + #[cfg(any(feature = "usage", feature = "help"))] + pub(crate) fn visible_ungroupped_subcommands(&self) -> impl Iterator { + self.visible_subcommands() + .filter(|sc| self.cmd.get_command_groups().filter(|cg| cg.commands.contains(sc.get_name_str())).next().is_none()) + } + /// Writes help for subcommands of a Parser Object to the wrapped stream. fn write_subcommands(&mut self, cmd: &Command) { debug!("HelpTemplate::write_subcommands"); @@ -930,20 +963,73 @@ impl<'cmd, 'writer> HelpTemplate<'cmd, 'writer> { let _ = write!(styled, ", {literal}--{long}{literal:#}",); } longest = longest.max(styled.display_width()); - ord_v.push((subcommand.get_display_order(), styled, subcommand)); + ord_v.push((subcommand.get_display_order(), styled, subcommand, true)); } ord_v.sort_by(|a, b| (a.0, &a.1).cmp(&(b.0, &b.1))); + //self.cmd.visible_ungroupped_subcommands(); + + let has_groups = cmd.get_command_groups().next().is_some(); + //find subcommands that do not belong to any group + if has_groups { + for cmd_group in cmd.get_command_groups() { + for cmd_name in cmd_group.commands.iter() { + match ord_v.iter_mut().filter(|(_, _, sc, _)| sc.get_name() == cmd_name).next() { + None => {}, + Some((_, _, _, ref mut show)) => { + *show = false; + } + } + } + } + } + debug!("HelpTemplate::write_subcommands longest = {longest}"); let next_line_help = self.will_subcommands_wrap(cmd.get_subcommands(), longest); + for (i, (_, sc_str, sc, show)) in ord_v.iter().enumerate() { + if *show { + if 0 < i { + self.writer.push_str("\n"); + } + self.write_subcommand(sc_str.clone(), sc, next_line_help, longest); + } + } - for (i, (_, sc_str, sc)) in ord_v.into_iter().enumerate() { - if 0 < i { - self.writer.push_str("\n"); + + if has_groups { + let header = &self.styles.get_header(); + + for cmd_group in cmd.get_command_groups() { + if let Some(ref heading) = cmd_group.heading { + + let _ = write!(self.writer, "{header}{heading}:{header:#}\n",); + }; + let next_line_help = { + let it = cmd.get_subcommands() + .filter(|sc| { + let s: &Str = sc.get_name_str(); + cmd_group.commands.contains(&s ) + }); + self.will_subcommands_wrap(it, longest) + }; + for cmd_name in cmd_group.commands.iter() { + match ord_v.iter_mut().filter(|(_, sc_str, sc, _)| sc.get_name() == cmd_name).next() { + None => {}, + Some((_, sc_str, sc, ref mut show)) => { + self.write_subcommand(sc_str.clone(), sc, next_line_help, longest); + //if i < 0 { + self.writer.push_str("\n"); + //} + + } + } + } } - self.write_subcommand(sc_str, sc, next_line_help, longest); + + } + } /// Will use next line help on writing subcommands. diff --git a/examples/git.rs b/examples/git.rs index fc8fd01f79e..a75a791f841 100644 --- a/examples/git.rs +++ b/examples/git.rs @@ -1,7 +1,7 @@ use std::ffi::OsString; use std::path::PathBuf; -use clap::{arg, Command}; +use clap::{arg, Command, CommandGroup}; fn cli() -> Command { Command::new("git") @@ -30,6 +30,12 @@ fn cli() -> Command { .default_missing_value("always"), ), ) + .command_group(CommandGroup::new("clone") + .help_heading("clone/diff commands") + .commands(&["clone", "diff"])) + .command_group(CommandGroup::new("add") + .help_heading("adding commands") + .commands(&["add"])) .subcommand( Command::new("push") .about("pushes things") From 73e4c36dd30d5a95ef080623718c64f232eed161 Mon Sep 17 00:00:00 2001 From: Maciej Dziardziel Date: Wed, 13 Nov 2024 00:15:33 +0000 Subject: [PATCH 2/6] add tests --- clap_builder/src/builder/command_group.rs | 278 ++++++++++++++++++++++ clap_builder/src/output/help_template.rs | 45 ++-- tests/builder/command_group.rs | 167 +++++++++++++ 3 files changed, 462 insertions(+), 28 deletions(-) create mode 100644 clap_builder/src/builder/command_group.rs create mode 100644 tests/builder/command_group.rs diff --git a/clap_builder/src/builder/command_group.rs b/clap_builder/src/builder/command_group.rs new file mode 100644 index 00000000000..187f6f9ddc8 --- /dev/null +++ b/clap_builder/src/builder/command_group.rs @@ -0,0 +1,278 @@ +// Internal +use crate::builder::IntoResettable; +use crate::builder::Str; +use crate::util::Id; + +/// Family of related [commands]. +/// +/// By placing commands in a logical group, you can make help and documentation easier to +/// understand and navigate. +/// +/// # Examples +/// +/// The following example demonstrates using a `CommandGroup` separate subcommands into two groups. +/// +/// ```rust +/// # use clap_builder as clap; +/// # use clap::{Command, command, CommandGroup, error::ErrorKind}; +/// let result = Command::new("git") +/// .subcommands([ +/// Command::new("git-apply"), +/// Command::new("git-mktree"), +/// Command::new("git-cat-file"), +/// Command::new("git-cherry"), +/// ]) +/// .command_group(CommandGroup::new("manipulation") +/// .help_heading("Manipulation commands") +/// .commands(["git-apply", "git-mktree"])) +/// .command_group(CommandGroup::new("interrogation") +/// .help_heading("Interrogation commands") +/// .commands(["git-cat-file", "git-cherry"])) +/// +/// .try_get_matches_from(vec!["git", "git-apply"]); +/// assert!(result.is_ok()); +/// ``` +/// +/// This next example demonstrates having only some commands belonging to a group +/// In the documentation, groups of commands will be displayed last, +/// while commands not belonging to any group will be displayed first. +/// ```rust +/// # use clap_builder as clap; +/// # use clap::{Command, arg, ArgGroup, Id}; +/// let result = Command::new("cmd") +/// .subcommands([ +/// Command::new("add"), +/// Command::new("remove"), +/// Command::new("show"), +/// ]) +/// .command_group(CommandGroup::new("manipulation") +/// .help_heading("Manipulation commands") +/// .commands(["add", "remove"])) +/// .try_get_matches_from(vec!["cmd", "add"]); +/// assert!(result.is_ok()); +/// ``` +/// +#[derive(Default, Clone, Debug, PartialEq, Eq)] +pub struct CommandGroup { + pub(crate) id: Id, + pub(crate) commands: Vec, + pub(crate) heading: Option, +} + +/// # Builder +impl CommandGroup { + /// Create a `CommandGroup` using a unique name. + /// + /// The name will be used to get values from the group and to refer to the group + /// by subcommands. + /// + /// # Examples + /// + /// ```rust + /// # use clap_builder as clap; + /// # use clap::{Command, ArgGroup}; + /// CommandGroup::new("config") + /// # ; + /// ``` + pub fn new(id: impl Into) -> Self { + CommandGroup::default().id(id) + } + + /// Sets the group name. + /// + /// # Examples + /// + /// ```rust + /// # use clap_builder as clap; + /// # use clap::{Command, ArgGroup}; + /// CommandGroup::default().id("config") + /// # ; + /// ``` + #[must_use] + pub fn id(mut self, id: impl Into) -> Self { + self.id = id.into(); + self + } + + /// Adds an [command] to this group by name + /// + /// # Examples + /// + /// ```rust + /// # use clap_builder as clap; + /// # use clap::{Command, CommandGroup}; + /// let m = Command::new("myprog") + /// .subcommands([ + /// Command::new("add"), + /// Command::new("remove"), + /// Command::new("show"), + /// ]) + /// .command_group(CommandGroup::new("manipulation") + /// .help_heading("Manipulation commands") + /// .command("add")) + /// .get_matches_from(vec!["myprog", "add"]); + /// ``` + /// [argument]: crate::Str + #[must_use] + pub fn command(mut self, cmd_name: impl IntoResettable) -> Self { + if let Some(cmd_name) = cmd_name.into_resettable().into_option() { + self.commands.push(cmd_name); + } else { + self.commands.clear(); + } + self + } + + /// Adds multiple [commands] to this group by name + /// + /// # Examples + /// + /// ```rust + /// # use clap_builder as clap; + /// # use clap::{Command, Arg, ArgGroup, ArgAction}; + /// let m = Command::new("myprog") + /// .arg(Arg::new("flag") + /// .short('f') + /// .action(ArgAction::SetTrue)) + /// .arg(Arg::new("color") + /// .short('c') + /// .action(ArgAction::SetTrue)) + /// .group(ArgGroup::new("req_flags") + /// .args(["flag", "color"])) + /// .get_matches_from(vec!["myprog", "-f"]); + /// // maybe we don't know which of the two flags was used... + /// assert!(m.contains_id("req_flags")); + /// // but we can also check individually if needed + /// assert!(m.contains_id("flag")); + /// ``` + /// [commands]: crate::Arg + #[must_use] + pub fn commands(mut self, ns: impl IntoIterator>) -> Self { + for n in ns { + self = self.command(n); + } + self + } + + + #[must_use] + pub fn help_heading(mut self, heading: impl IntoResettable) -> Self { + self.heading = heading.into_resettable().into_option(); + self + } + + + /// Getters for all args. It will return a vector of `Id` + /// + /// # Example + /// + /// ```rust + /// # use clap_builder as clap; + /// # use clap::{ArgGroup}; + /// let args: Vec<&str> = vec!["a1".into(), "a4".into()]; + /// let grp = ArgGroup::new("program").args(&args); + /// + /// for (pos, arg) in grp.get_args().enumerate() { + /// assert_eq!(*arg, args[pos]); + /// } + /// ``` + pub fn get_commands(&self) -> impl Iterator { + self.commands.iter() + } +} + +/// # Reflection +impl CommandGroup { + /// Get the name of the group + #[inline] + pub fn get_id(&self) -> &Id { + &self.id + } + +} + +impl From<&'_ CommandGroup> for CommandGroup { + fn from(g: &CommandGroup) -> Self { + g.clone() + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn groups() { + let g = ArgGroup::new("test") + .arg("a1") + .arg("a4") + .args(["a2", "a3"]) + .required(true) + .conflicts_with("c1") + .conflicts_with_all(["c2", "c3"]) + .conflicts_with("c4") + .requires("r1") + .requires_all(["r2", "r3"]) + .requires("r4"); + + let args: Vec = vec!["a1".into(), "a4".into(), "a2".into(), "a3".into()]; + let reqs: Vec = vec!["r1".into(), "r2".into(), "r3".into(), "r4".into()]; + let confs: Vec = vec!["c1".into(), "c2".into(), "c3".into(), "c4".into()]; + + assert_eq!(g.args, args); + assert_eq!(g.requires, reqs); + assert_eq!(g.conflicts, confs); + } + + #[test] + fn test_from() { + let g = ArgGroup::new("test") + .arg("a1") + .arg("a4") + .args(["a2", "a3"]) + .required(true) + .conflicts_with("c1") + .conflicts_with_all(["c2", "c3"]) + .conflicts_with("c4") + .requires("r1") + .requires_all(["r2", "r3"]) + .requires("r4"); + + let args: Vec = vec!["a1".into(), "a4".into(), "a2".into(), "a3".into()]; + let reqs: Vec = vec!["r1".into(), "r2".into(), "r3".into(), "r4".into()]; + let confs: Vec = vec!["c1".into(), "c2".into(), "c3".into(), "c4".into()]; + + let g2 = ArgGroup::from(&g); + assert_eq!(g2.args, args); + assert_eq!(g2.requires, reqs); + assert_eq!(g2.conflicts, confs); + } + + // This test will *fail to compile* if ArgGroup is not Send + Sync + #[test] + fn arg_group_send_sync() { + fn foo(_: T) {} + foo(ArgGroup::new("test")); + } + + #[test] + fn arg_group_expose_is_multiple_helper() { + let args: Vec = vec!["a1".into(), "a4".into()]; + + let mut grp_multiple = ArgGroup::new("test_multiple").args(&args).multiple(true); + assert!(grp_multiple.is_multiple()); + + let mut grp_not_multiple = ArgGroup::new("test_multiple").args(&args).multiple(false); + assert!(!grp_not_multiple.is_multiple()); + } + + #[test] + fn arg_group_expose_get_args_helper() { + let args: Vec = vec!["a1".into(), "a4".into()]; + let grp = ArgGroup::new("program").args(&args); + + for (pos, arg) in grp.get_args().enumerate() { + assert_eq!(*arg, args[pos]); + } + } +} diff --git a/clap_builder/src/output/help_template.rs b/clap_builder/src/output/help_template.rs index aed447babbc..80b4eb79ae4 100644 --- a/clap_builder/src/output/help_template.rs +++ b/clap_builder/src/output/help_template.rs @@ -963,41 +963,28 @@ impl<'cmd, 'writer> HelpTemplate<'cmd, 'writer> { let _ = write!(styled, ", {literal}--{long}{literal:#}",); } longest = longest.max(styled.display_width()); - ord_v.push((subcommand.get_display_order(), styled, subcommand, true)); + let no_group = self.cmd.get_command_groups().filter(|cg| cg.commands.contains(subcommand.get_name_str())).next().is_none(); + ord_v.push((subcommand.get_display_order(), styled, subcommand, no_group)); } ord_v.sort_by(|a, b| (a.0, &a.1).cmp(&(b.0, &b.1))); - //self.cmd.visible_ungroupped_subcommands(); - - let has_groups = cmd.get_command_groups().next().is_some(); - //find subcommands that do not belong to any group - if has_groups { - for cmd_group in cmd.get_command_groups() { - for cmd_name in cmd_group.commands.iter() { - match ord_v.iter_mut().filter(|(_, _, sc, _)| sc.get_name() == cmd_name).next() { - None => {}, - Some((_, _, _, ref mut show)) => { - *show = false; - } - } - } - } - } - debug!("HelpTemplate::write_subcommands longest = {longest}"); + //first show commands that do not belong to any group + //if groups are not used, that means all visible commands let next_line_help = self.will_subcommands_wrap(cmd.get_subcommands(), longest); - for (i, (_, sc_str, sc, show)) in ord_v.iter().enumerate() { - if *show { - if 0 < i { - self.writer.push_str("\n"); - } + for (i, (_, sc_str, sc, no_group)) in ord_v.iter().enumerate() { + if *no_group { self.write_subcommand(sc_str.clone(), sc, next_line_help, longest); + //if 0 < i { + self.writer.push_str("\n"); + //} + } } - - - if has_groups { + //if groups are defined, show group header followed by all commands in group + if cmd.get_command_groups().next().is_some() { + self.writer.push_str("\n"); let header = &self.styles.get_header(); for cmd_group in cmd.get_command_groups() { @@ -1014,9 +1001,10 @@ impl<'cmd, 'writer> HelpTemplate<'cmd, 'writer> { self.will_subcommands_wrap(it, longest) }; for cmd_name in cmd_group.commands.iter() { - match ord_v.iter_mut().filter(|(_, sc_str, sc, _)| sc.get_name() == cmd_name).next() { + match ord_v.iter_mut().filter(|(_, _, sc, _)| sc.get_name() == cmd_name).next() { None => {}, - Some((_, sc_str, sc, ref mut show)) => { + Some((_, sc_str, sc, _)) => { + self.write_subcommand(sc_str.clone(), sc, next_line_help, longest); //if i < 0 { self.writer.push_str("\n"); @@ -1025,6 +1013,7 @@ impl<'cmd, 'writer> HelpTemplate<'cmd, 'writer> { } } } + self.writer.push_str("\n"); } diff --git a/tests/builder/command_group.rs b/tests/builder/command_group.rs new file mode 100644 index 00000000000..9738ad511a5 --- /dev/null +++ b/tests/builder/command_group.rs @@ -0,0 +1,167 @@ +use clap::{arg, error::ErrorKind, Arg, ArgAction, Command, CommandGroup}; + +use super::utils; +use snapbox::assert_data_eq; +use snapbox::str; + +#[test] +fn command_group_help_output_one_ungrouped_command() { + let visible_help: &str = "\ +Usage: clap-test [COMMAND] + +Commands: + help Print this message or the help of the given subcommand(s) + + test Some help + +Options: + -h, --help Print help + -V, --version Print version +"; + + let cmd = Command::new("clap-test") + .version("2.6") + .subcommand( + Command::new("test") + .about("Some help") + ) + .command_group(CommandGroup::new("Test commands") + .commands(&["test"])); + + + utils::assert_output(cmd, "clap-test --help", visible_help, false); +} + +#[test] +fn command_group_help_output_one_ungrouped_command_one_group() { + let visible_help: &str = "\ +Usage: clap-test [COMMAND] + +Commands: + help Print this message or the help of the given subcommand(s) + +Test: + test Some help + +Options: + -h, --help Print help + -V, --version Print version +"; + + let cmd = Command::new("clap-test") + .version("2.6") + .subcommand( + Command::new("test") + .about("Some help") + ) + .command_group(CommandGroup::new("Test commands") + .help_heading("Test") + .commands(&["test"])); + + + utils::assert_output(cmd, "clap-test --help", visible_help, false); +} + +#[test] +fn command_group_help_output_no_ungrouped_commands_no_heading() { + let visible_help: &str = "\ +Usage: clap-test [COMMAND] + + test Some help + help Print this message or the help of the given subcommand(s) + +Options: + -h, --help Print help + -V, --version Print version +"; + + let cmd = Command::new("clap-test") + .version("2.6") + .subcommand( + Command::new("test") + .about("Some help") + ) + .command_group(CommandGroup::new("test_commands") + .commands(&["test", "help"])); + + + utils::assert_output(cmd, "clap-test --help", visible_help, false); +} + +#[test] +fn command_group_help_output_group_with_heading() { + let visible_help: &str = "\ +Usage: clap-test [COMMAND] + +Test: + + test Some help + help Print this message or the help of the given subcommand(s) + +Options: + -h, --help Print help + -V, --version Print version +"; + + let cmd = Command::new("clap-test") + .version("2.6") + .subcommand( + Command::new("test") + .about("Some help") + ) + .command_group(CommandGroup::new("test_commands") + .help_heading("Test") + .commands(&["test", "help"])); + + + utils::assert_output(cmd, "clap-test --help", visible_help, false); +} + + +#[test] +fn command_group_help_output_two_groups_with_headings() { + let visible_help: &str = "\ +Usage: clap-test [COMMAND] + +TestGroup1: + + test1 Some help + test2 Some help + +TestGroup2: + + test3 Some help + test4 Some help + help Print this message or the help of the given subcommand(s) + + +Options: + -h, --help Print help + -V, --version Print version +"; + + let cmd = Command::new("clap-test") + .version("2.6") + .subcommand( + Command::new("test1").about("Some help") + ) + .subcommand( + Command::new("test2").about("Some help") + ) + .subcommand( + Command::new("test3").about("Some help") + ) + .subcommand( + Command::new("test4").about("Some help") + ) + .command_group(CommandGroup::new("test_commands1") + .help_heading("TestGroup1") + .commands(&["test1", "test2"])) + .command_group(CommandGroup::new("test_commands2") + .help_heading("TestGroup2") + .commands(&["test3", "test4", "help"])); + + + utils::assert_output(cmd, "clap-test --help", visible_help, false); +} + From 007b560f4a8a38481fe2209eb13d84de926e6875 Mon Sep 17 00:00:00 2001 From: Maciej Dziardziel Date: Wed, 13 Nov 2024 14:57:57 +0000 Subject: [PATCH 3/6] fix newlines --- clap_builder/src/output/help_template.rs | 40 +++++++++++++----------- examples/git.rs | 6 ---- tests/builder/command_group.rs | 4 --- 3 files changed, 21 insertions(+), 29 deletions(-) diff --git a/clap_builder/src/output/help_template.rs b/clap_builder/src/output/help_template.rs index 80b4eb79ae4..ffc9800e367 100644 --- a/clap_builder/src/output/help_template.rs +++ b/clap_builder/src/output/help_template.rs @@ -969,56 +969,58 @@ impl<'cmd, 'writer> HelpTemplate<'cmd, 'writer> { ord_v.sort_by(|a, b| (a.0, &a.1).cmp(&(b.0, &b.1))); debug!("HelpTemplate::write_subcommands longest = {longest}"); + let mut first = true; //first show commands that do not belong to any group //if groups are not used, that means all visible commands let next_line_help = self.will_subcommands_wrap(cmd.get_subcommands(), longest); for (i, (_, sc_str, sc, no_group)) in ord_v.iter().enumerate() { if *no_group { - self.write_subcommand(sc_str.clone(), sc, next_line_help, longest); - //if 0 < i { + if 0 < i && !first { self.writer.push_str("\n"); - //} + } + self.write_subcommand(sc_str.clone(), sc, next_line_help, longest); + first = false; } } //if groups are defined, show group header followed by all commands in group if cmd.get_command_groups().next().is_some() { - self.writer.push_str("\n"); let header = &self.styles.get_header(); for cmd_group in cmd.get_command_groups() { - if let Some(ref heading) = cmd_group.heading { + if !first { + self.writer.push_str("\n\n"); + } + if let Some(ref heading) = cmd_group.heading { let _ = write!(self.writer, "{header}{heading}:{header:#}\n",); - }; + first=false; + } + let next_line_help = { - let it = cmd.get_subcommands() - .filter(|sc| { - let s: &Str = sc.get_name_str(); - cmd_group.commands.contains(&s ) - }); + let it = cmd.get_subcommands() + .filter(|sc| { + let s: &Str = sc.get_name_str(); + cmd_group.commands.contains(&s ) + }); self.will_subcommands_wrap(it, longest) }; - for cmd_name in cmd_group.commands.iter() { + for (j, cmd_name) in cmd_group.commands.iter().enumerate() { match ord_v.iter_mut().filter(|(_, _, sc, _)| sc.get_name() == cmd_name).next() { None => {}, Some((_, sc_str, sc, _)) => { + if 0 < j { + self.writer.push_str("\n"); + } self.write_subcommand(sc_str.clone(), sc, next_line_help, longest); - //if i < 0 { - self.writer.push_str("\n"); - //} } } } - self.writer.push_str("\n"); } - - } - } /// Will use next line help on writing subcommands. diff --git a/examples/git.rs b/examples/git.rs index a75a791f841..3ce9f3b504b 100644 --- a/examples/git.rs +++ b/examples/git.rs @@ -30,12 +30,6 @@ fn cli() -> Command { .default_missing_value("always"), ), ) - .command_group(CommandGroup::new("clone") - .help_heading("clone/diff commands") - .commands(&["clone", "diff"])) - .command_group(CommandGroup::new("add") - .help_heading("adding commands") - .commands(&["add"])) .subcommand( Command::new("push") .about("pushes things") diff --git a/tests/builder/command_group.rs b/tests/builder/command_group.rs index 9738ad511a5..4e762cafbd6 100644 --- a/tests/builder/command_group.rs +++ b/tests/builder/command_group.rs @@ -94,7 +94,6 @@ fn command_group_help_output_group_with_heading() { Usage: clap-test [COMMAND] Test: - test Some help help Print this message or the help of the given subcommand(s) @@ -124,17 +123,14 @@ fn command_group_help_output_two_groups_with_headings() { Usage: clap-test [COMMAND] TestGroup1: - test1 Some help test2 Some help TestGroup2: - test3 Some help test4 Some help help Print this message or the help of the given subcommand(s) - Options: -h, --help Print help -V, --version Print version From 82a4198bc83eab383d1f6467267304dda936d5b4 Mon Sep 17 00:00:00 2001 From: Maciej Dziardziel Date: Thu, 14 Nov 2024 01:16:17 +0000 Subject: [PATCH 4/6] docstrings --- clap_builder/src/builder/command.rs | 76 +++++++++++++++++++++++- clap_builder/src/output/help_template.rs | 6 -- 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/clap_builder/src/builder/command.rs b/clap_builder/src/builder/command.rs index 1f9d244cc5c..c058149a71f 100644 --- a/clap_builder/src/builder/command.rs +++ b/clap_builder/src/builder/command.rs @@ -108,7 +108,7 @@ pub struct Command { subcommand_heading: Option, external_value_parser: Option, long_help_exists: bool, - deferred: Option Command>, + deferred: Option Command>, #[cfg(feature = "unstable-ext")] ext: Extensions, app_ext: Extensions, @@ -343,6 +343,25 @@ impl Command { self } + /// Allows one to mutate an [`CommandGroup`] after it's been added to a [`Command`]. + /// + /// # Panics + /// + /// If the argument is undefined + /// + /// # Examples + /// + /// ```rust + /// # use clap_builder as clap; + /// # use clap::{Command, CommandGroup, arg, ArgGroup}; + /// + /// Command::new("foo") + /// .command_group(CommandGroup::new("bar") + /// .help_heading("Bar commands") + /// .command("bar") + /// .mut_command_group("bar", |g| g.clear()); + /// ``` + #[must_use] #[cfg_attr(debug_assertions, track_caller)] pub fn mut_command_group(mut self, cmd_id: impl AsRef, f: F) -> Self @@ -446,6 +465,32 @@ impl Command { self } + /// Adds an [`CommandGroup`] to the application. + /// + /// [`CommandGroup`]s are a family of related subcommands. + /// By placing them in a logical group, you can display help more clearly. + /// + /// # Examples + /// + /// The following example demonstrates using an [`CommandGroup`] to group related commands + /// when usage help is displayed. + /// + /// ```rust + /// # use clap_builder as clap; + /// # use clap::{Command, CommandGroup}; + /// Command::new("add-pizza-ingredient") + /// .subcommand(Command::new("pepperoni")) + /// .subcommand(Command::new("ham")) + /// .subcommand(Command::new("peppers")) + /// .subcommand(Command::new("onion")) + /// .command_group(CommandGroup::new("meats") + /// .help_heading("Meaty ingredients") + /// .commands(["pepperoni", "ham"])) + /// .command_group(CommandGroup::new("veggies") + /// .help_heading("Vegetables") + /// .commands(["peppers", "onion"])) + /// # ; + /// ``` #[inline] #[must_use] pub fn command_group(mut self, group: impl Into) -> Self { @@ -485,7 +530,34 @@ impl Command { self } - + /// Adds an [`CommandGroup`] to the application. + /// + /// [`CommandGroup`]s are a family of related subcommands. + /// By placing them in a logical group, you can display help more clearly. + /// + /// # Examples + /// + /// The following example demonstrates using an [`CommandGroup`] to group related commands + /// when usage help is displayed. + /// + /// ```rust + /// # use clap_builder as clap; + /// # use clap::{Command, CommandGroup}; + /// Command::new("add-pizza-ingredient") + /// .subcommand(Command::new("pepperoni")) + /// .subcommand(Command::new("ham")) + /// .subcommand(Command::new("peppers")) + /// .subcommand(Command::new("onion")) + /// .command_groups([ + /// CommandGroup::new("meats") + /// .help_heading("Meaty ingredients") + /// .commands(["pepperoni", "ham"]), + /// CommandGroup::new("veggies") + /// .help_heading("Vegetables") + /// .commands(["peppers", "onion"]) + /// ]) + /// # ; + /// ``` #[must_use] pub fn command_groups(mut self, groups: impl IntoIterator>) -> Self { for g in groups { diff --git a/clap_builder/src/output/help_template.rs b/clap_builder/src/output/help_template.rs index ffc9800e367..ddf658ffd1c 100644 --- a/clap_builder/src/output/help_template.rs +++ b/clap_builder/src/output/help_template.rs @@ -909,12 +909,6 @@ impl<'cmd, 'writer> HelpTemplate<'cmd, 'writer> { } } -#[cfg(any(feature = "usage", feature = "help"))] - pub(crate) fn has_visible_subcommands(&self) -> bool { - self.visible_subcommands() - .next() - .is_some() - } //// Check if this subcommand should display help header /// which will be the case if either there are subcommands and no command groups From 828d24ff0575f20be1a0af3dd0fe04a3d9d9ccd1 Mon Sep 17 00:00:00 2001 From: Maciej Dziardziel Date: Thu, 14 Nov 2024 01:24:01 +0000 Subject: [PATCH 5/6] docstrings --- clap_builder/src/builder/command.rs | 2 +- clap_builder/src/output/help_template.rs | 25 +++++++++++------------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/clap_builder/src/builder/command.rs b/clap_builder/src/builder/command.rs index c058149a71f..b72c51cf6b9 100644 --- a/clap_builder/src/builder/command.rs +++ b/clap_builder/src/builder/command.rs @@ -108,7 +108,7 @@ pub struct Command { subcommand_heading: Option, external_value_parser: Option, long_help_exists: bool, - deferred: Option Command>, + deferred: Option Command>, #[cfg(feature = "unstable-ext")] ext: Extensions, app_ext: Extensions, diff --git a/clap_builder/src/output/help_template.rs b/clap_builder/src/output/help_template.rs index ddf658ffd1c..ea94f93da19 100644 --- a/clap_builder/src/output/help_template.rs +++ b/clap_builder/src/output/help_template.rs @@ -370,7 +370,7 @@ impl<'cmd, 'writer> HelpTemplate<'cmd, 'writer> { pub(crate) fn write_all_args(&mut self) { debug!("HelpTemplate::write_all_args"); use std::fmt::Write as _; - let header = &self.styles.get_header(); + let header = &self.styles.get_header(); let pos = self .cmd @@ -909,29 +909,26 @@ impl<'cmd, 'writer> HelpTemplate<'cmd, 'writer> { } } - - //// Check if this subcommand should display help header - /// which will be the case if either there are subcommands and no command groups - /// or there are subcommands and some of them do not belong to any group + /// Check if this subcommand should display help header + /// which will be the case if either there are subcommands and no command groups + /// or there are subcommands and some of them do not belong to any group #[cfg(any(feature = "usage", feature = "help"))] pub(crate) fn needs_subcmd_help_header(&self) -> bool { - self.visible_ungroupped_subcommands() - .next() - .is_some() + self.visible_ungroupped_subcommands() + .next() + .is_some() } - pub(crate) fn visible_subcommands(&self) -> impl Iterator { - self.cmd + self.cmd .get_subcommands() .filter(|subcommand| should_show_subcommand(subcommand)) - } - + } #[cfg(any(feature = "usage", feature = "help"))] pub(crate) fn visible_ungroupped_subcommands(&self) -> impl Iterator { - self.visible_subcommands() - .filter(|sc| self.cmd.get_command_groups().filter(|cg| cg.commands.contains(sc.get_name_str())).next().is_none()) + self.visible_subcommands() + .filter(|sc| self.cmd.get_command_groups().filter(|cg| cg.commands.contains(sc.get_name_str())).next().is_none()) } /// Writes help for subcommands of a Parser Object to the wrapped stream. From d6e81a5bbbd8c5df34c206f4b9a45ea3fac667be Mon Sep 17 00:00:00 2001 From: Maciej Dziardziel Date: Thu, 14 Nov 2024 20:05:32 +0000 Subject: [PATCH 6/6] make it modelled after current Arg implementation --- clap_builder/src/builder/command.rs | 41 ++++++++++++++++++++++++ clap_builder/src/output/help_template.rs | 18 ++++++++--- examples/git.rs | 2 +- 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/clap_builder/src/builder/command.rs b/clap_builder/src/builder/command.rs index b72c51cf6b9..2d2cb27c9d9 100644 --- a/clap_builder/src/builder/command.rs +++ b/clap_builder/src/builder/command.rs @@ -103,9 +103,11 @@ pub struct Command { groups: Vec, command_groups: Vec, current_help_heading: Option, + current_subcommand_help_heading: Option, current_disp_ord: Option, subcommand_value_name: Option, subcommand_heading: Option, + subcommand_help_heading: Option>, external_value_parser: Option, long_help_exists: bool, deferred: Option Command>, @@ -600,6 +602,8 @@ impl Command { subcmd.disp_ord.get_or_insert(current); *current_disp_ord = current + 1; } + subcmd.subcommand_help_heading + .get_or_insert_with(|| self.current_subcommand_help_heading.clone()); self.subcommands.push(subcmd); self } @@ -2247,6 +2251,32 @@ impl Command { self } + /// Set the default section heading for future subcommands. + /// + /// This will be used for any subcommand that hasn't had [`Command::subcommand_help_heading`] called. + /// + /// This is useful if the default `Commands` heading is + /// not specific enough for one's use case. + /// + /// [`Command::subcommand`]: Command::subcommand() + /// [`Command::subcommand_help_heading`]: crate::Command::subcommand_help_heading() + #[inline] + #[must_use] + pub fn next_subcommand_help_heading(mut self, heading: impl IntoResettable) -> Self { + self.current_subcommand_help_heading = heading.into_resettable().into_option(); + self + } + + /*/// Change the starting value for assigning future display orders for args. + /// + /// This will be used for any arg that hasn't had [`Arg::display_order`] called. + #[inline] + #[must_use] + pub fn next_subcommand_display_order(mut self, disp_ord: impl IntoResettable) -> Self { + self.current_subocommand_disp_ord = disp_ord.into_resettable().into_option(); + self + }*/ + /// Change the starting value for assigning future display orders for args. /// /// This will be used for any arg that hasn't had [`Arg::display_order`] called. @@ -3637,6 +3667,14 @@ impl Command { self.current_help_heading.as_deref() } + /// Get the custom section heading specified via [`Command::next_subcommand_help_heading`]. + /// + /// [`Command::subcommand_help_heading`]: Command::subcommand_help_heading() + #[inline] + pub fn get_next_subcommand_help_heading(&self) -> Option<&str> { + self.current_subcommand_help_heading.as_deref() + } + /// Iterate through the *visible* aliases for this subcommand. #[inline] pub fn get_visible_aliases(&self) -> impl Iterator + '_ { @@ -5033,9 +5071,12 @@ impl Default for Command { groups: Default::default(), command_groups: Default::default(), current_help_heading: Default::default(), + current_subcommand_help_heading: Default::default(), current_disp_ord: Some(0), + //current_subcommand_disp_ord: Some(0), subcommand_value_name: Default::default(), subcommand_heading: Default::default(), + subcommand_help_heading: Default::default(), external_value_parser: Default::default(), long_help_exists: false, deferred: None, diff --git a/clap_builder/src/output/help_template.rs b/clap_builder/src/output/help_template.rs index ea94f93da19..bbc87fe6d92 100644 --- a/clap_builder/src/output/help_template.rs +++ b/clap_builder/src/output/help_template.rs @@ -955,21 +955,29 @@ impl<'cmd, 'writer> HelpTemplate<'cmd, 'writer> { } longest = longest.max(styled.display_width()); let no_group = self.cmd.get_command_groups().filter(|cg| cg.commands.contains(subcommand.get_name_str())).next().is_none(); - ord_v.push((subcommand.get_display_order(), styled, subcommand, no_group)); + ord_v.push((subcommand.get_display_order(), styled, subcommand, no_group, subcommand.get_subcommand_help_heading())); } - ord_v.sort_by(|a, b| (a.0, &a.1).cmp(&(b.0, &b.1))); + ord_v.sort_by(|a, b| (a.4, a.0, &a.1).cmp(&(b.4, b.0, &b.1))); debug!("HelpTemplate::write_subcommands longest = {longest}"); let mut first = true; + let mut current_help_heading = &ord_v[0].4; //first show commands that do not belong to any group //if groups are not used, that means all visible commands let next_line_help = self.will_subcommands_wrap(cmd.get_subcommands(), longest); - for (i, (_, sc_str, sc, no_group)) in ord_v.iter().enumerate() { + for (i, (_, sc_str, sc, no_group, help_heading)) in ord_v.iter().enumerate() { if *no_group { if 0 < i && !first { self.writer.push_str("\n"); } + if current_help_heading != help_heading { + if let Some(_help_heading) = help_heading { + let header = &self.styles.get_header(); + let _ = write!(self.writer, "\n{header}{_help_heading}:{header:#}\n",); + } + + } self.write_subcommand(sc_str.clone(), sc, next_line_help, longest); first = false; @@ -998,9 +1006,9 @@ impl<'cmd, 'writer> HelpTemplate<'cmd, 'writer> { self.will_subcommands_wrap(it, longest) }; for (j, cmd_name) in cmd_group.commands.iter().enumerate() { - match ord_v.iter_mut().filter(|(_, _, sc, _)| sc.get_name() == cmd_name).next() { + match ord_v.iter_mut().filter(|(_, _, sc, _, _)| sc.get_name() == cmd_name).next() { None => {}, - Some((_, sc_str, sc, _)) => { + Some((_, sc_str, sc, _, _)) => { if 0 < j { self.writer.push_str("\n"); } diff --git a/examples/git.rs b/examples/git.rs index 3ce9f3b504b..d33c713ef2a 100644 --- a/examples/git.rs +++ b/examples/git.rs @@ -28,7 +28,7 @@ fn cli() -> Command { .require_equals(true) .default_value("auto") .default_missing_value("always"), - ), + ).subcommand_help_heading(Some("DIFF")), ) .subcommand( Command::new("push")