Repository: kbknapp/clap-rs
Pull request #1211 (2018-03-18 12:24:01)

Partial Implementation of custom help sections
This partially implements the custom help sections from github
issue 805. There are still formatting issues with the output of the
custom help sections, but the general proof of design is there.

I wish that printing out custom help messages didn't have a nested
loop in it (see changes in src/app/help.rs) but this code only
runs when printing the help for a crate that has custom help sections,
so it has no performance impact in the majority of cases.

This is a rough draft at addressing https://github.com/kbknapp/clap-rs/issues/805

<!-- Reviewable:start -->
---
This change is [<img src="https://reviewable.io/review_button.svg" height="34" align="absmiddle" alt="Reviewable"/>](https://reviewable.io/reviews/kbknapp/clap-rs/1211)
<!-- Reviewable:end -->


kbknapp: This is awesome!!! :tada: 

I'm good with a nested loop in the help as it's not the path that needs top performance.

It looks like the test failed because it prints the args in the correct spots, but doesn't print the help message for the arg in custom headings.

It's bikeshedable, but lets change the name from `arg_heading` to `help_heading` as it's not really specific to a single arg and deals more with the help message the arg itself.

Also, I'd throw in a a test which has multiple custom headings to make sure they all get printed correctly. 

In the future we may want to expand this to allow an arg to be a member of more than one heading, however, I'd be good with a merge that only allows a single heading per arg right now.

Awesome work! :+1: 
kbknapp: I walked back some of those v3 deprecations I was talking about, so you may want to rebase on the latest v3-master :wink:
kbknapp: Actually the changes were straight forward. Sqash it down to a few commits (I'm good with separating tests, docs, and impls into their own commits) and I'm :+1: for a merge.

Patch:
diff --git a/src/app/help.rs b/src/app/help.rs
index 2ed1e0db..627975f2 100644
--- a/src/app/help.rs
+++ b/src/app/help.rs
@@ -693,6 +693,14 @@ impl<'w> Help<'w> {
         let opts = parser.has_opts();
         let subcmds = parser.has_visible_subcommands();
 
+        let custom_headings = custom_headings!(parser.app).fold(0, |acc, arg| {
+            if arg.help_heading.is_some() {
+                acc + 1
+            } else {
+                acc
+            }
+        }) > 0;
+
         let mut first = true;
 
         if pos {
@@ -731,6 +739,18 @@ impl<'w> Help<'w> {
                 self.write_args(opts!(parser.app))?;
                 first = false;
             }
+            if custom_headings {
+                for heading in parser.app.help_headings.iter()
+                    .filter(|heading| heading.is_some())
+                    .map(|heading| heading.unwrap()) {
+                        if !first {
+                            self.writer.write_all(b"\n\n")?;
+                        }
+                        color!(self, format!("{}:\n", heading), warning)?;
+                        self.write_args(custom_headings!(parser.app).filter(|a| a.help_heading.unwrap() == heading))?;
+                        first = false
+                    }
+            }
         }
 
         if subcmds {
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 9bcd5310..2e5d93b2 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -123,6 +123,8 @@ where
     pub help_message: Option<&'a str>,
     #[doc(hidden)]
     pub version_message: Option<&'a str>,
+    #[doc(hidden)]
+    pub help_headings: Vec<Option<&'a str>>,
 }
 
 impl<'a, 'b> App<'a, 'b> {
@@ -634,11 +636,31 @@ impl<'a, 'b> App<'a, 'b> {
     /// ```
     /// [argument]: ./struct.Arg.html
     pub fn arg<A: Into<Arg<'a, 'b>>>(mut self, a: A) -> Self {
-        self.args.push(a.into());
+        let help_heading : Option<&'a str> = if let Some(option_str) = self.help_headings.last() {
+            *option_str
+        } else {
+            None
+        };
+        let arg = a.into().help_heading(help_heading);
+        self.args.push(arg);
+        self
+    }
+
+    /// Set a custom section heading for future args. Every call to arg will
+    /// have this header (instead of its default header) until a subsequent
+    /// call to help_heading
+    pub fn help_heading(mut self, heading: &'a str) -> Self {
+        self.help_headings.push(Some(heading));
+        self
+    }
+
+    /// Stop using custom section headings.
+    pub fn stop_custom_headings(mut self) -> Self {
+        self.help_headings.push(None);
         self
     }
 
-    /// Adds multiple [arguments] to the list of valid possibilities
+    /// Adds multiple [arguments] to the list of valid possibilties
     ///
     /// # Examples
     ///
diff --git a/src/args/arg.rs b/src/args/arg.rs
index 57e6c7bc..824c862b 100644
--- a/src/args/arg.rs
+++ b/src/args/arg.rs
@@ -104,6 +104,8 @@ where
     pub index: Option<u64>,
     #[doc(hidden)]
     pub r_ifs: Option<Vec<(&'a str, &'b str)>>,
+    #[doc(hidden)]
+    pub help_heading: Option<&'a str>,
 }
 
 impl<'a, 'b> Arg<'a, 'b> {
@@ -3953,6 +3955,12 @@ impl<'a, 'b> Arg<'a, 'b> {
         self
     }
 
+    /// Set a custom heading for this arg to be printed under
+    pub fn help_heading(mut self, s: Option<&'a str>) -> Self {
+        self.help_heading = s;
+        self
+    }
+
     #[doc(hidden)]
     pub fn _build(&mut self) {
         if (self.is_set(ArgSettings::UseValueDelimiter)
diff --git a/src/macros.rs b/src/macros.rs
index 2712d13d..6d73e3b5 100644
--- a/src/macros.rs
+++ b/src/macros.rs
@@ -891,6 +891,7 @@ macro_rules! flags {
         $app.args.$how()
             .filter(|a| !a.settings.is_set(::args::settings::ArgSettings::TakesValue))
             .filter(|a| a.short.is_some() || a.long.is_some())
+            .filter(|a| !a.help_heading.is_some())
     };
     ($app:expr) => {
         flags!($app, iter)
@@ -909,6 +910,7 @@ macro_rules! opts {
         $app.args.$how()
             .filter(|a| a.settings.is_set(::args::settings::ArgSettings::TakesValue))
             .filter(|a| a.short.is_some() || a.long.is_some())
+            .filter(|a| !a.help_heading.is_some())
     };
     ($app:expr) => {
         opts!($app, iter)
@@ -924,7 +926,9 @@ macro_rules! opts_mut {
 
 macro_rules! positionals {
     ($app:expr, $how:ident) => {
-        $app.args.$how().filter(|a| !(a.short.is_some() || a.long.is_some()))
+        $app.args.$how()
+            .filter(|a| !a.help_heading.is_some())
+            .filter(|a| !(a.short.is_some() || a.long.is_some()))
     };
     ($app:expr) => {
         positionals!($app, iter)
@@ -938,6 +942,23 @@ macro_rules! positionals_mut {
     }
 }
 
+#[allow(unused_macros)]
+macro_rules! custom_headings {
+    ($app:expr, $how:ident) => {
+        $app.args.$how().filter(|a| (a.help_heading.is_some()))
+    };
+    ($app:expr) => {
+        custom_headings!($app, iter)
+    }
+}
+
+#[allow(unused_macros)]
+macro_rules! custom_headings_mut {
+    ($app:expr) => {
+        custom_headings!($app, iter_mut)
+    }
+}
+
 macro_rules! subcommands_cloned {
     ($app:expr, $how:ident) => {
         $app.subcommands.$how().cloned()


Test patch:
diff --git a/tests/help.rs b/tests/help.rs
index 7b72b88d..661f70ae 100644
--- a/tests/help.rs
+++ b/tests/help.rs
@@ -515,6 +515,23 @@ OPTIONS:
     -c, --cafe <FILE>    A coffeehouse, coffee shop, or café. [env: ENVVAR=MYVAL]
     -p, --pos <VAL>      Some vals [possible values: fast, slow]";
 
+static CUSTOM_HELP_SECTION: &'static str = "blorp 1.4
+Will M.
+does stuff
+
+USAGE:
+    test --fake <some>:<val>
+
+FLAGS:
+    -h, --help       Prints help information
+    -V, --version    Prints version information
+
+OPTIONS:
+    -f, --fake <some>:<val>    some help
+
+NETWORKING:
+    -n, --no-proxy    Do not use system proxy settings";
+
 fn setup() -> App<'static, 'static> {
     App::new("test")
         .author("Kevin K.")
@@ -1346,3 +1363,84 @@ fn show_env_vals() {
         false
     ));
 }
+
+#[test]
+fn custom_headers_headers() {
+    let app = App::new("blorp")
+        .author("Will M.")
+        .about("does stuff")
+        .version("1.4")
+        .arg(Arg::from_usage("-f, --fake <some> <val> 'some help'")
+                .require_delimiter(true)
+                .value_delimiter(":"),
+        )
+        .help_heading("NETWORKING")
+        .arg(Arg::with_name("no-proxy")
+                .short("n")
+                .long("no-proxy")
+                .help("Do not use system proxy settings")
+        );
+
+    assert!(test::compare_output(
+        app,
+        "test --help",
+        CUSTOM_HELP_SECTION,
+        false
+    ));
+}
+
+static MULTIPLE_CUSTOM_HELP_SECTIONS: &'static str = "blorp 1.4
+Will M.
+does stuff
+
+USAGE:
+    test [OPTIONS] --birthday-song <song> --fake <some>:<val>
+
+FLAGS:
+    -h, --help       Prints help information
+    -V, --version    Prints version information
+
+OPTIONS:
+    -f, --fake <some>:<val>    some help
+    -s, --speed <SPEED>        How fast? [possible values: fast, slow]
+
+NETWORKING:
+    -n, --no-proxy    Do not use system proxy settings
+
+SPECIAL:
+    -b, --birthday-song <song>    Change which song is played for birthdays";
+
+#[test]
+fn multiple_custom_help_headers() {
+    let app = App::new("blorp")
+        .author("Will M.")
+        .about("does stuff")
+        .version("1.4")
+        .arg(Arg::from_usage("-f, --fake <some> <val> 'some help'")
+                .require_delimiter(true)
+                .value_delimiter(":"),
+        )
+        .help_heading("NETWORKING")
+        .arg(Arg::with_name("no-proxy")
+                .short("n")
+                .long("no-proxy")
+                .help("Do not use system proxy settings")
+        )
+        .help_heading("SPECIAL")
+        .arg(Arg::from_usage("-b, --birthday-song <song> 'Change which song is played for birthdays'"))
+        .stop_custom_headings()
+        .arg(Arg::with_name("speed")
+                .long("speed")
+                .short("s")
+                .value_name("SPEED")
+                .possible_values(&["fast", "slow"])
+                .help("How fast?")
+                .takes_value(true));
+
+    assert!(test::compare_output(
+        app,
+        "test --help",
+        MULTIPLE_CUSTOM_HELP_SECTIONS,
+        false
+    ));
+}
\ No newline at end of file