diff --git a/PLAN.md b/PLAN.md index 766b86c13..6248c89b9 100644 --- a/PLAN.md +++ b/PLAN.md @@ -538,7 +538,9 @@ Groups are the opposite case: `Command::get_groups`, `ArgGroup::get_args` and - [x] **The granular hides** — `hide_default_value`, `hide_env`, `hide_env_values`, `hide_possible_values`, `hide_short_help`, and `hide_long_help` round-trip through KDL and the clap bridge and are honored by Rust and Go help output. -- [ ] **`help_template`, `next_line_help`, and `flatten_help`.** +- [x] **`next_line_help`.** Command-wide block layout survives typed metadata, + KDL, the clap bridge, and both Rust and generated Go help renderers. +- [ ] **`help_template` and `flatten_help`.** - [x] **`subcommand_help_heading` / `subcommand_value_name`.** Custom subcommand section labels and synopsis placeholders survive KDL, typed Rust, generated Go, and the clap bridge and are rendered by both help implementations. diff --git a/argv/src/help.rs b/argv/src/help.rs index b99ce493a..e10098a47 100644 --- a/argv/src/help.rs +++ b/argv/src/help.rs @@ -638,6 +638,23 @@ pub fn short_help(spec: &Spec<'_>, path: &[&str], chain: &[&CommandMeta<'_>]) -> |a| a.help_heading, |out, a| { let usage = arg_usage(a); + if meta.next_line_help { + let _ = writeln!(out, " {usage}"); + if let Some(help) = a.help.filter(|h| !h.trim().is_empty()) { + write_indented(out, help, 4); + } + long_annotations( + out, + if a.hide_possible_values { + &[] + } else { + a.choices + }, + if a.hide_env { None } else { a.env }, + if a.hide_default_value { &[] } else { a.default }, + ); + return; + } match a.help.filter(|h| !h.trim().is_empty()) { Some(help) => { let _ = write!(out, " {usage:, path: &[&str], chain: &[&CommandMeta<'_>]) -> .max() .unwrap_or(0); let short_entry = |out: &mut String, f: &FlagMeta<'_>, usage: String| { + if meta.next_line_help { + let _ = writeln!(out, " {usage}"); + if let Some(help) = f.help.filter(|h| !h.trim().is_empty()) { + write_indented(out, help, 4); + } + long_annotations( + out, + if f.hide_possible_values { + &[] + } else { + f.choices + }, + if f.hide_env { None } else { f.env }, + if f.hide_default_value { &[] } else { f.default }, + ); + return; + } match f.help.filter(|h| !h.trim().is_empty()) { Some(help) => { let _ = write!(out, " {usage:) { let _ = write!(out, " [aliases: {}]", visible_aliases.join(", ")); } if let Some(about) = sub.about { - let _ = write!(out, " {about}"); + if meta.next_line_help { + out.push('\n'); + write_indented(out, about.trim_end(), 4); + continue; + } + // The row writes its own newline below. Trim trailing whitespace in both + // layouts, as usage-lib does before choosing a layout. + let _ = write!(out, " {}", about.trim_end()); } out.push('\n'); } - let _ = writeln!( - out, - " help Print this message or the help of the given subcommand(s)" - ); + if meta.next_line_help { + let _ = writeln!( + out, + " help\n Print this message or the help of the given subcommand(s)" + ); + } else { + let _ = writeln!( + out, + " help Print this message or the help of the given subcommand(s)" + ); + } } /// One section per heading, unheaded first, in the order the headings first appear. @@ -1017,7 +1065,14 @@ pub fn long_help(spec: &Spec<'_>, path: &[&str], chain: &[&CommandMeta<'_>]) -> |a| a.help_heading, |out, a| { let text = a.long_help.or(a.help); - entry(out, &arg_usage(a), text, arg_col, width); + entry( + out, + &arg_usage(a), + text, + arg_col, + width, + meta.next_line_help, + ); long_annotations( out, if a.hide_possible_values { @@ -1046,7 +1101,14 @@ pub fn long_help(spec: &Spec<'_>, path: &[&str], chain: &[&CommandMeta<'_>]) -> |f| f.help_heading, |out, f| { let text = f.long_help.or(f.help); - entry(out, &column_usage(f), text, flag_col, width); + entry( + out, + &column_usage(f), + text, + flag_col, + width, + meta.next_line_help, + ); long_annotations( out, if f.hide_possible_values { @@ -1070,7 +1132,7 @@ pub fn long_help(spec: &Spec<'_>, path: &[&str], chain: &[&CommandMeta<'_>]) -> |_| None, |out, (f, usage)| { let text = f.long_help.or(f.help); - entry(out, usage, text, flag_col, width); + entry(out, usage, text, flag_col, width, meta.next_line_help); long_annotations( out, if f.hide_possible_values { @@ -1146,7 +1208,14 @@ fn write_indented(out: &mut String, text: &str, indent: usize) { } /// One entry: its usage, and its help either beside it or beneath it. -fn entry(out: &mut String, usage: &str, help: Option<&str>, col: usize, width: usize) { +fn entry( + out: &mut String, + usage: &str, + help: Option<&str>, + col: usize, + width: usize, + next_line: bool, +) { let Some(help) = help.filter(|h| !h.trim().is_empty()) else { let _ = writeln!(out, " {usage}"); return; @@ -1156,7 +1225,7 @@ fn entry(out: &mut String, usage: &str, help: Option<&str>, col: usize, width: u // there is room left for it to say anything. let indent = 2 + col + 2; let room = width.saturating_sub(indent); - if help.contains('\n') || room < 10 { + if next_line || help.contains('\n') || room < 10 { let _ = writeln!(out, " {usage}"); write_indented(out, help, 4); return; @@ -1688,7 +1757,33 @@ pub fn render_at_styled( #[cfg(test)] mod style_tests { - use super::{styled_help, Style}; + use super::{commands_section, styled_help, Style}; + use crate::spec::CommandMeta; + use crate::Command; + + #[test] + fn short_command_rows_trim_trailing_help_whitespace() { + let sub_cmd = Command { + name: "run", + ..Command::EMPTY + }; + let sub_meta = CommandMeta { + cmd: &sub_cmd, + about: Some("run it\n"), + ..CommandMeta::EMPTY + }; + let subcommands = [&sub_meta]; + let root_meta = CommandMeta { + subcommands: &subcommands, + ..CommandMeta::EMPTY + }; + let mut page = String::new(); + + commands_section(&mut page, &[], &root_meta); + + assert!(page.contains(" run run it\n help")); + assert!(!page.contains(" run run it\n\n help")); + } #[test] fn coloured_help_styles_structure_without_changing_plain_text() { diff --git a/argv/src/spec.rs b/argv/src/spec.rs index 3af974f84..7a57ea3e7 100644 --- a/argv/src/spec.rs +++ b/argv/src/spec.rs @@ -724,6 +724,8 @@ pub struct CommandMeta<'a> { pub subcommand_help_heading: Option<&'a str>, /// Placeholder used for a subcommand in the usage synopsis. pub subcommand_value_name: Option<&'a str>, + /// Put each argument, flag, and subcommand description on the following line. + pub next_line_help: bool, /// Fixed help width. Zero disables wrapping. pub term_width: Option, /// Maximum detected terminal width when `term_width` is unset. Zero disables the cap. @@ -767,6 +769,7 @@ impl CommandMeta<'_> { subcommand_required: false, subcommand_help_heading: None, subcommand_value_name: None, + next_line_help: false, term_width: None, max_term_width: None, args_override_self: true, @@ -1215,6 +1218,9 @@ impl Spec<'_> { if let Some(name) = self.root.subcommand_value_name { prop(out, "subcommand_value_name", name)?; } + if self.root.next_line_help { + writeln!(out, "next_line_help #true")?; + } if let Some(width) = self.root.term_width { writeln!(out, "term_width {width}")?; } @@ -1464,6 +1470,9 @@ fn write_command<'a>( if let Some(name) = meta.subcommand_value_name { write!(out, " subcommand_value_name={}", quoted(name))?; } + if meta.next_line_help { + out.push_str(" next_line_help=#true"); + } if let Some(width) = meta.term_width { write!(out, " term_width={width}")?; } diff --git a/clap_usage/src/report.rs b/clap_usage/src/report.rs index b2ed2d0af..b85123c9b 100644 --- a/clap_usage/src/report.rs +++ b/clap_usage/src/report.rs @@ -80,11 +80,6 @@ fn visit(cmd: &Command, ancestors: &[String], losses: &mut BTreeSet TokenStream { let allow_missing_positional = cli.allow_missing_positional; let subcommand_help_heading = option_str(cli.subcommand_help_heading.as_deref()); let subcommand_value_name = option_str(cli.subcommand_value_name.as_deref()); + let next_line_help = cli.next_line_help; let term_width = option_usize(cli.term_width); let max_term_width = option_usize(cli.max_term_width); let usage = option_str(cli.usage.as_deref()); @@ -470,6 +471,7 @@ pub fn emit(cli: &Cli) -> TokenStream { subcommand_required: #subcommand_required, subcommand_help_heading: #subcommand_help_heading, subcommand_value_name: #subcommand_value_name, + next_line_help: #next_line_help, term_width: #term_width, max_term_width: #max_term_width, args_override_self: #args_override_self, @@ -3568,6 +3570,7 @@ pub fn emit_args(cli: &Cli) -> TokenStream { }); let subcommand_help_heading = option_str(cli.subcommand_help_heading.as_deref()); let subcommand_value_name = option_str(cli.subcommand_value_name.as_deref()); + let next_line_help = cli.next_line_help; let term_width = option_usize(cli.term_width); let max_term_width = option_usize(cli.max_term_width); let unknown_flags = unknown_flags_tokens(cli); @@ -3716,6 +3719,7 @@ pub fn emit_args(cli: &Cli) -> TokenStream { subcommand_required: #subcommand_required, subcommand_help_heading: #subcommand_help_heading, subcommand_value_name: #subcommand_value_name, + next_line_help: #next_line_help, term_width: #term_width, max_term_width: #max_term_width, args_override_self: #args_override_self, diff --git a/derive/src/model.rs b/derive/src/model.rs index afb505e64..2c93945c3 100644 --- a/derive/src/model.rs +++ b/derive/src/model.rs @@ -124,6 +124,7 @@ pub struct Cli { pub allow_missing_positional: bool, pub subcommand_help_heading: Option, pub subcommand_value_name: Option, + pub next_line_help: bool, pub term_width: Option, pub max_term_width: Option, /// Declared descriptions, for the case a doc comment cannot express: a long form that does @@ -585,6 +586,7 @@ impl Cli { allow_missing_positional: false, subcommand_help_heading: None, subcommand_value_name: None, + next_line_help: false, term_width: None, max_term_width: None, about_attr: None, @@ -746,6 +748,7 @@ impl Cli { "subcommand_value_name" => { cli.subcommand_value_name = Some(string_value(&meta)?) } + "next_line_help" => cli.next_line_help = flag_value(&meta)?, "term_width" => cli.term_width = Some(int_value(&meta)?), "max_term_width" => cli.max_term_width = Some(int_value(&meta)?), "restart_token" => cli.restart_token = Some(string_value(&meta)?), @@ -760,7 +763,7 @@ impl Cli { "unknown option `{other}` on a struct; usage::Cli takes \ `name`, `name_spec`, `bin`, `bin_spec`, `version`, `version_spec`, `author`, `license`, `repository`, `usage`, `verbatim_doc_comment`, `unknown_flags`, \ `default_subcommand`, `multicall`, `no_binary_name`, `arg_required_else_help`, `dont_delimit_trailing_values`, `args_override_self`, `subcommand_negates_reqs`, `args_conflicts_with_subcommands`, `subcommand_precedence_over_arg`, `allow_missing_positional`, \ - `next_help_heading`, `subcommand_help_heading`, `term_width`, `max_term_width`, \ + `next_help_heading`, `subcommand_help_heading`, `next_line_help`, `term_width`, `max_term_width`, \ `subcommand_value_name`, `restart_token`, `mount` and \ `group` here, and the description comes from the doc \ comment" diff --git a/docs/rust/clap-compatibility.md b/docs/rust/clap-compatibility.md index d39373e94..05d1ab8f3 100644 --- a/docs/rust/clap-compatibility.md +++ b/docs/rust/clap-compatibility.md @@ -115,7 +115,8 @@ the Rust declaration, not only from generated KDL, wherever the bridge column sa | `subcommand_help_heading`, `subcommand_value_name` | yes | yes | yes | yes | yes | yes | Customize the subcommand section label and the synopsis placeholder. | | `verbatim_doc_comment` | yes | n/a | yes | yes | yes | n/a | Commands, fields, and variants preserve line breaks and indentation when requested. | | `rename_all`, `rename_all_env` | yes | n/a | yes | yes | yes | n/a | Full clap casing vocabulary; bare `env` uses the environment casing policy. | -| `help_template`, `next_line_help`, `flatten_help` | no | n/a | no | no | no | no | No equivalent yet. | +| `next_line_help` | yes | yes | yes | yes | yes | yes | Put command, argument, and flag descriptions below their usage instead of beside it. | +| `help_template`, `flatten_help` | no | n/a | no | no | no | no | No equivalent yet. | | `term_width`, `max_term_width` | yes | yes | yes | yes | yes | no | Fixed width overrides a detected-width cap; clap exposes no bridge getters for these settings. | | help styles and color | n/a | n/a | n/a | yes | lossy | no | Help and diagnostics use automatic ANSI styles; clap's custom style palette is not portable. | | built-in help/version action and flag control | no | no | no | no | no | no | Custom actions and disabling or relocating built-ins are not represented. | diff --git a/docs/rust/help.md b/docs/rust/help.md index c14b98633..71f81c1d3 100644 --- a/docs/rust/help.md +++ b/docs/rust/help.md @@ -52,6 +52,8 @@ convenience entry point for CLIs that want immediate print-and-exit behavior. - `before_help`, `after_help`, `before_long_help`, `after_long_help` add text around the page — `after_long_help` is the conventional home for an Examples section. - `help_heading` on a field groups it under a heading. +- `next_line_help` on a command puts every argument, flag, and subcommand description beneath + its usage instead of in an aligned column beside it. - `hide` removes an entry from help, docs, and completions while still parsing. The rendered output matches what usage-lib renders from the same spec — the two renderers are diff --git a/docs/rust/migrating-from-clap.md b/docs/rust/migrating-from-clap.md index db3bfa2ab..5413d4987 100644 --- a/docs/rust/migrating-from-clap.md +++ b/docs/rust/migrating-from-clap.md @@ -205,7 +205,11 @@ Command-level presentation settings keep their clap spellings: ```rust #[derive(usage::Cli)] -#[command(subcommand_help_heading = "Actions", subcommand_value_name = "ACTION")] +#[command( + subcommand_help_heading = "Actions", + subcommand_value_name = "ACTION", + next_line_help +)] struct Cli { #[command(subcommand)] command: Option, diff --git a/docs/spec/reference/cmd.md b/docs/spec/reference/cmd.md index fe5084e88..75729f100 100644 --- a/docs/spec/reference/cmd.md +++ b/docs/spec/reference/cmd.md @@ -10,6 +10,7 @@ cmd "config" help="Manage the CLI config" { cmd "config" hide=#true // hide command from docs and completions cmd "config" subcommand_required=#true // subcommand is not optional cmd "config" subcommand_help_heading="Actions" subcommand_value_name="ACTION" +cmd "config" next_line_help=#true // put descriptions below each entry // these are shown under -h cmd "config" before_help="shown before the command" diff --git a/go/argv/help.go b/go/argv/help.go index c5f99f270..d5c0a5d9e 100644 --- a/go/argv/help.go +++ b/go/argv/help.go @@ -85,6 +85,7 @@ type Help struct { AfterLongHelp string SubcommandHelpHeading string SubcommandValueName string + NextLineHelp bool // Examples are worked invocations, printed last. Examples []Example } diff --git a/go/argv/page.go b/go/argv/page.go index 660f88f1b..0725b9004 100644 --- a/go/argv/page.go +++ b/go/argv/page.go @@ -104,6 +104,7 @@ func ShortHelp(spec HelpSpec, path []string, chain []*Command, help HelpTable) s commandsSection(&out, path[min(1, len(path)):], cmd, help) args := visibleArgs(cmd, help, false) + nextLineHelp := meta != nil && meta.NextLineHelp argCol := 0 for _, a := range args { if n := width(argUsage(a, help.Lookup(a.Key))); n > argCol { @@ -116,6 +117,14 @@ func ShortHelp(spec HelpSpec, path []string, chain []*Command, help HelpTable) s a := args[i] h := help.Lookup(a.Key) usage := argUsage(a, h) + if nextLineHelp { + w.WriteString(" " + usage + "\n") + if text := helpText(h); text != "" { + writeIndented(w, text, 4) + } + longAnnotations(w, h, true) + return + } if help := helpText(h); help != "" { w.WriteString(" " + pad(usage, argCol) + " " + help) } else { @@ -145,6 +154,13 @@ func ShortHelp(spec HelpSpec, path []string, chain []*Command, help HelpTable) s h := help.Lookup(f.key) if f.supplied != "" { // A flag the parser supplies has no table entry; its help is fixed. + if nextLineHelp { + w.WriteString(" " + f.usage + "\n") + if text := f.suppliedHelp; text != "" { + writeIndented(w, text, 4) + } + return + } if text := f.suppliedHelp; text != "" { w.WriteString(" " + pad(f.usage, flagCol) + " " + text) } else { @@ -153,6 +169,14 @@ func ShortHelp(spec HelpSpec, path []string, chain []*Command, help HelpTable) s w.WriteString("\n") return } + if nextLineHelp { + w.WriteString(" " + f.usage + "\n") + if text := helpText(h); text != "" { + writeIndented(w, text, 4) + } + longAnnotations(w, h, true) + return + } if text := helpText(h); text != "" { w.WriteString(" " + pad(f.usage, flagCol) + " " + text) } else { @@ -213,8 +237,12 @@ func commandsSection(out *strings.Builder, path []string, cmd *Command, help Hel return } heading := "Commands" - if h := help.Lookup(cmd.Key); h != nil && h.SubcommandHelpHeading != "" { - heading = h.SubcommandHelpHeading + nextLineHelp := false + if h := help.Lookup(cmd.Key); h != nil { + if h.SubcommandHelpHeading != "" { + heading = h.SubcommandHelpHeading + } + nextLineHelp = h.NextLineHelp } out.WriteString("\n" + heading + ":\n") @@ -230,12 +258,23 @@ func commandsSection(out *strings.Builder, path []string, cmd *Command, help Hel out.WriteString(" [aliases: " + strings.Join(h.VisibleAliases, ", ") + "]") } if h.Short != "" { - out.WriteString(" " + h.Short) + if nextLineHelp { + out.WriteString("\n") + writeIndented(out, trimEnd(h.Short), 4) + continue + } + // The row owns its terminating newline. Trim the description in both + // layouts, as usage-lib does before selecting a layout. + out.WriteString(" " + trimEnd(h.Short)) } } out.WriteString("\n") } - out.WriteString(" help Print this message or the help of the given subcommand(s)\n") + if nextLineHelp { + out.WriteString(" help\n Print this message or the help of the given subcommand(s)\n") + } else { + out.WriteString(" help Print this message or the help of the given subcommand(s)\n") + } } // groupsSection writes one section per heading, unheaded first, in the order the diff --git a/go/argv/page_long.go b/go/argv/page_long.go index 7ba52513f..dca9c57fe 100644 --- a/go/argv/page_long.go +++ b/go/argv/page_long.go @@ -24,6 +24,7 @@ func LongHelp(spec HelpSpec, path []string, chain []*Command, help HelpTable) st } cmd := chain[len(chain)-1] meta := help.Lookup(cmd.Key) + nextLineHelp := meta != nil && meta.NextLineHelp var out strings.Builder before := firstOf(metaField(meta, func(h *Help) string { return h.BeforeLongHelp }), @@ -77,7 +78,7 @@ func LongHelp(spec HelpSpec, path []string, chain []*Command, help HelpTable) st func(w *strings.Builder, i int) { h := help.Lookup(args[i].Key) entry(w, argUsage(args[i], h), firstOf(metaField(h, func(x *Help) string { return x.Long }), - metaField(h, func(x *Help) string { return x.Short })), argCol) + metaField(h, func(x *Help) string { return x.Short })), argCol, nextLineHelp) longAnnotations(w, h, true) }) @@ -95,12 +96,12 @@ func LongHelp(spec HelpSpec, path []string, chain []*Command, help HelpTable) st } writeFlag := func(w *strings.Builder, f shownFlag) { if f.supplied != "" { - entry(w, f.usage, f.suppliedHelp, flagCol) + entry(w, f.usage, f.suppliedHelp, flagCol, nextLineHelp) return } h := help.Lookup(f.key) entry(w, f.usage, firstOf(metaField(h, func(x *Help) string { return x.Long }), - metaField(h, func(x *Help) string { return x.Short })), flagCol) + metaField(h, func(x *Help) string { return x.Short })), flagCol, nextLineHelp) longAnnotations(w, h, true) } groupsSection(&out, "Flags", len(own), @@ -193,7 +194,7 @@ func longCommandsSection(out *strings.Builder, path []string, cmd *Command, help // entry writes one flag or argument: its help in a column beside it, wrapped — or // indented underneath, where the text has line breaks of its own. -func entry(out *strings.Builder, usage, help string, col int) { +func entry(out *strings.Builder, usage, help string, col int, nextLine bool) { if strings.TrimSpace(help) == "" { out.WriteString(" " + usage + "\n") return @@ -206,7 +207,7 @@ func entry(out *strings.Builder, usage, help string, col int) { if room < 0 { room = 0 } - if strings.Contains(help, "\n") || room < 10 { + if nextLine || strings.Contains(help, "\n") || room < 10 { out.WriteString(" " + usage + "\n") writeIndented(out, help, 4) return diff --git a/go/argv/page_test.go b/go/argv/page_test.go index aecdca36c..058bb417b 100644 --- a/go/argv/page_test.go +++ b/go/argv/page_test.go @@ -139,6 +139,41 @@ func TestSubcommandPresentation(t *testing.T) { } } +func TestNextLineHelp(t *testing.T) { + arg := &Arg{Name: "input", Key: 2, Required: true} + flag := &Flag{Name: "verbose", Key: 3, Longs: []string{"verbose"}} + annotated := &Flag{Name: "mode", Key: 5, Longs: []string{"mode"}} + sub := &Command{Name: "run", Key: 4} + root := &Command{Name: "ex", Key: 1, Args: []*Arg{arg}, Flags: []*Flag{flag, annotated}, Subcommands: []*Command{sub}} + help := HelpTable{ + {Key: 1, NextLineHelp: true}, + {Key: 2, Short: "Input file"}, + {Key: 3, Short: "Enable verbose output"}, + {Key: 4, Short: "Run it\n"}, + {Key: 5, Env: "MODE", Default: []string{"fast"}, Choices: []string{"fast", "slow"}}, + } + + shortPage := ShortHelp(HelpSpec{Bin: "ex"}, []string{"ex"}, []*Command{root}, help) + if strings.Contains(shortPage, " Run it\n\n help") { + t.Fatalf("trailing command help newline became a blank line:\n%s", shortPage) + } + for _, page := range []string{ + shortPage, + LongHelp(HelpSpec{Bin: "ex"}, []string{"ex"}, []*Command{root}, help), + } { + for _, want := range []string{ + " [input]\n Input file", + " --verbose\n Enable verbose output", + " run\n Run it", + " --mode\n [possible values: fast, slow]\n [env: MODE]\n (default: fast)", + } { + if !strings.Contains(page, want) { + t.Fatalf("missing %q in:\n%s", want, page) + } + } + } +} + // A description that ends in a break adds no blank line. // // clap's `long_about` often ends with one — a `///` block whose last line is @@ -155,7 +190,7 @@ func TestADescriptionEndingInABreakAddsNoBlankLine(t *testing.T) { root := &Command{Name: "ex", Key: 1, Subcommands: []*Command{sub}} help := HelpTable{ {Key: 1}, - {Key: 2, Short: "run it", Long: "run it\n\nExamples:\n\n $ ex run\n"}, + {Key: 2, Short: "run it\n", Long: "run it\n\nExamples:\n\n $ ex run\n"}, } spec := HelpSpec{Name: "ex", Bin: "ex"} @@ -174,4 +209,9 @@ func TestADescriptionEndingInABreakAddsNoBlankLine(t *testing.T) { if strings.Contains(parent, "$ ex run\n\n\n") { t.Errorf("a listed command's description should not double it either:\n%q", parent) } + + shortParent := ShortHelp(spec, []string{"ex"}, []*Command{root}, help) + if strings.Contains(shortParent, "run it\n\n help") { + t.Errorf("short command help should not add a blank line either:\n%q", shortParent) + } } diff --git a/go/internal/spec/spec.go b/go/internal/spec/spec.go index 56018f257..d8544ee06 100644 --- a/go/internal/spec/spec.go +++ b/go/internal/spec/spec.go @@ -107,6 +107,7 @@ type Cmd struct { AllowMissingPositional bool `json:"allow_missing_positional"` SubcommandHelpHeading string `json:"subcommand_help_heading"` SubcommandValueName string `json:"subcommand_value_name"` + NextLineHelp bool `json:"next_line_help"` } // Subcommands is a command's children, in the order the spec declared them. @@ -655,6 +656,7 @@ func (b *builder) command(c *Cmd, inherited argv.UnknownFlags) *argv.Command { AfterLongHelp: c.AfterHelpLong, SubcommandHelpHeading: c.SubcommandHelpHeading, SubcommandValueName: c.SubcommandValueName, + NextLineHelp: c.NextLineHelp, Examples: examples, }) diff --git a/lib/src/docs/cli/mod.rs b/lib/src/docs/cli/mod.rs index 552a5b463..92b317165 100644 --- a/lib/src/docs/cli/mod.rs +++ b/lib/src/docs/cli/mod.rs @@ -914,7 +914,7 @@ cmd "new-cmd" help="Do something better" bin "testcli" subcommand_help_heading "Actions" subcommand_value_name "ACTION" -cmd "run" help="Run it" +cmd "run" help="Run it\n" "# } .unwrap(); @@ -922,4 +922,38 @@ cmd "run" help="Run it" assert!(page.contains("Usage: testcli "), "{page}"); assert!(page.contains("\nActions:\n"), "{page}"); } + + #[test] + fn test_render_help_with_next_line_layout() { + let spec = crate::spec! { r#" +bin "testcli" +next_line_help #true +arg "" help="Input file" env="INPUT" default="fast" { + choices { + choice "fast" + choice "slow" + } +} +flag "--verbose" help="Enable verbose output" +cmd "run" help="Run it" + "# } + .unwrap(); + + let short = render_help(&spec, &spec.cmd, false); + assert!(!short.contains(" Run it\n\n help"), "{short}"); + for page in [short, render_help(&spec, &spec.cmd, true)] { + assert!(page.contains(" [input]\n Input file"), "{page}"); + assert!( + page.contains("--verbose\n Enable verbose output"), + "{page}" + ); + assert!( + page.contains( + " [possible values: fast, slow]\n [env: INPUT]\n (default: fast)" + ), + "{page}" + ); + assert!(page.contains(" run\n Run it"), "{page}"); + } + } } diff --git a/lib/src/docs/cli/templates/spec_template_long.tera b/lib/src/docs/cli/templates/spec_template_long.tera index 5e78c70f6..a7e099af8 100644 --- a/lib/src/docs/cli/templates/spec_template_long.tera +++ b/lib/src/docs/cli/templates/spec_template_long.tera @@ -45,7 +45,13 @@ Usage: {{ (spec.bin ~ " " ~ cmd.usage) | trim }} {{ group.heading | default(value="Arguments") }}: {%- for arg in group.items %} -{%- if arg.help_rendered %} +{%- if cmd.next_line_help %} + {{ arg.usage | trim }} +{%- set help = arg.help_long | default(value=arg.help | default(value='')) %} +{%- if help %} + {{ help | indent(width=4) }} +{%- endif %} +{%- elif arg.help_rendered %} {{ arg.usage | ljust(width=arg.usage_col_width) }} {{ arg.help_rendered }} {%- if arg.help_is_multiline %} @@ -76,7 +82,14 @@ Usage: {{ (spec.bin ~ " " ~ cmd.usage) | trim }} {{ group.heading | default(value="Flags") }}: {%- for flag in group.items %} -{%- if flag.help_rendered %} +{%- if cmd.next_line_help %} + {{ flag.display_usage }} +{%- if flag.aliases %} [aliases: {{ flag.aliases | join(sep=", ") }}]{% endif %} +{%- set help = flag.help_long | default(value=flag.help | default(value='')) %} +{%- if help %} + {{ help | indent(width=4) }} +{%- endif %} +{%- elif flag.help_rendered %} {{ flag.display_usage | ljust(width=flag.usage_col_width) }} {{ flag.help_rendered }} {%- if flag.help_is_multiline %} @@ -108,7 +121,14 @@ Usage: {{ (spec.bin ~ " " ~ cmd.usage) | trim }} Global flags: {%- for flag in global_flags %} -{%- if flag.help_rendered %} +{%- if cmd.next_line_help %} + {{ flag.display_usage }} +{%- if flag.aliases %} [aliases: {{ flag.aliases | join(sep=", ") }}]{% endif %} +{%- set help = flag.help_long | default(value=flag.help | default(value='')) %} +{%- if help %} + {{ help | indent(width=4) }} +{%- endif %} +{%- elif flag.help_rendered %} {{ flag.display_usage | ljust(width=flag.usage_col_width) }} {{ flag.help_rendered }} {%- if flag.help_is_multiline %} diff --git a/lib/src/docs/cli/templates/spec_template_short.tera b/lib/src/docs/cli/templates/spec_template_short.tera index 3a332715a..60fa4fa80 100644 --- a/lib/src/docs/cli/templates/spec_template_short.tera +++ b/lib/src/docs/cli/templates/spec_template_short.tera @@ -20,24 +20,39 @@ Usage: {{ (spec.bin ~ " " ~ cmd.usage) | trim }} {%- if cmd.subcommands %} {{ cmd.subcommand_help_heading | default(value="Commands") }}: +{%- set next_line_help = cmd.next_line_help %} {%- for cmd in cmd.subcommands | values | sort(attribute="usage") %} {{ cmd.usage | trim }} {%- if cmd.deprecated %} [deprecated: {{ cmd.deprecated }}]{%- endif %} {%- if cmd.aliases %} [aliases: {{ cmd.aliases | join(sep=", ") }}]{% endif %} -{%- if cmd.help %} {{ cmd.help }}{%- endif %} +{%- if cmd.help %}{% if next_line_help %} + {{ cmd.help | indent(width=4) }}{% else %} {{ cmd.help }}{% endif %}{%- endif %} {%- endfor %} - help Print this message or the help of the given subcommand(s) +{% if next_line_help %} help + Print this message or the help of the given subcommand(s){% else %} help Print this message or the help of the given subcommand(s){% endif %} {%- endif %} {%- for group in cmd.arg_groups %} {{ group.heading | default(value="Arguments") }}: {%- for arg in group.items %} - {% if arg.help %}{{ arg.usage | trim | ljust(width=arg.usage_col_width) }} {{ arg.help }}{% else %}{{ arg.usage | trim }}{% endif %} + {% if arg.help %}{% if cmd.next_line_help %}{{ arg.usage | trim }} + {{ arg.help | indent(width=4) }}{% else %}{{ arg.usage | trim | ljust(width=arg.usage_col_width) }} {{ arg.help }}{% endif %}{% else %}{{ arg.usage | trim }}{% endif %} +{%- if cmd.next_line_help %} +{%- if not arg.hide_possible_values and arg.choices and arg.choices.choices %} + [possible values: {{ arg.choices.choices | join(sep=", ") }}]{%- endif %} +{%- if not arg.hide_possible_values and arg.choices and arg.choices.env %} + [choices env: {{ arg.choices.env }}]{%- endif %} +{%- if not arg.hide_env and arg.env %} + [env: {{ arg.env }}]{%- endif %} +{%- if not arg.hide_default_value and arg.default %} + (default: {{ arg.default | join(sep=", ") }}){%- endif %} +{%- else %} {%- if not arg.hide_possible_values and arg.choices and arg.choices.choices %} [{{ arg.choices.choices | join(sep=", ") }}]{%- endif %} {%- if not arg.hide_possible_values and arg.choices and arg.choices.env %} [choices env: {{ arg.choices.env }}]{%- endif %} {%- if not arg.hide_env and arg.env %} [env: {{ arg.env }}]{%- endif %} {%- if not arg.hide_default_value and arg.default %} (default: {{ arg.default | join(sep=", ") }}){%- endif %} +{%- endif %} {%- endfor %} {%- endfor %} @@ -45,11 +60,23 @@ Usage: {{ (spec.bin ~ " " ~ cmd.usage) | trim }} {{ group.heading | default(value="Flags") }}: {%- for flag in group.items %} - {% if flag.help %}{{ flag.display_usage | ljust(width=flag.usage_col_width) }}{% if flag.aliases %} [aliases: {{ flag.aliases | join(sep=", ") }}]{% endif %} {{ flag.help }}{% else %}{{ flag.display_usage }}{% if flag.aliases %} [aliases: {{ flag.aliases | join(sep=", ") }}]{% endif %}{% endif %} + {% if flag.help %}{% if cmd.next_line_help %}{{ flag.display_usage }}{% if flag.aliases %} [aliases: {{ flag.aliases | join(sep=", ") }}]{% endif %} + {{ flag.help | indent(width=4) }}{% else %}{{ flag.display_usage | ljust(width=flag.usage_col_width) }}{% if flag.aliases %} [aliases: {{ flag.aliases | join(sep=", ") }}]{% endif %} {{ flag.help }}{% endif %}{% else %}{{ flag.display_usage }}{% if flag.aliases %} [aliases: {{ flag.aliases | join(sep=", ") }}]{% endif %}{% endif %} +{%- if cmd.next_line_help %} +{%- if not flag.hide_possible_values and flag.arg.choices and flag.arg.choices.choices %} + [possible values: {{ flag.arg.choices.choices | join(sep=", ") }}]{%- endif %} +{%- if not flag.hide_possible_values and flag.arg.choices and flag.arg.choices.env %} + [choices env: {{ flag.arg.choices.env }}]{%- endif %} +{%- if not flag.hide_env and flag.env %} + [env: {{ flag.env }}]{%- endif %} +{%- if not flag.hide_default_value and flag.default %} + (default: {{ flag.default | join(sep=", ") }}){%- endif %} +{%- else %} {%- if not flag.hide_possible_values and flag.arg.choices and flag.arg.choices.choices %} [{{ flag.arg.choices.choices | join(sep=", ") }}]{%- endif %} {%- if not flag.hide_possible_values and flag.arg.choices and flag.arg.choices.env %} [choices env: {{ flag.arg.choices.env }}]{%- endif %} {%- if not flag.hide_env and flag.env %} [env: {{ flag.env }}]{%- endif %} {%- if not flag.hide_default_value and flag.default %} (default: {{ flag.default | join(sep=", ") }}){%- endif %} +{%- endif %} {%- endfor %} {%- endfor %} @@ -57,11 +84,23 @@ Usage: {{ (spec.bin ~ " " ~ cmd.usage) | trim }} Global flags: {%- for flag in global_flags %} - {% if flag.help %}{{ flag.display_usage | ljust(width=flag.usage_col_width) }}{% if flag.aliases %} [aliases: {{ flag.aliases | join(sep=", ") }}]{% endif %} {{ flag.help }}{% else %}{{ flag.display_usage }}{% if flag.aliases %} [aliases: {{ flag.aliases | join(sep=", ") }}]{% endif %}{% endif %} + {% if flag.help %}{% if cmd.next_line_help %}{{ flag.display_usage }}{% if flag.aliases %} [aliases: {{ flag.aliases | join(sep=", ") }}]{% endif %} + {{ flag.help | indent(width=4) }}{% else %}{{ flag.display_usage | ljust(width=flag.usage_col_width) }}{% if flag.aliases %} [aliases: {{ flag.aliases | join(sep=", ") }}]{% endif %} {{ flag.help }}{% endif %}{% else %}{{ flag.display_usage }}{% if flag.aliases %} [aliases: {{ flag.aliases | join(sep=", ") }}]{% endif %}{% endif %} +{%- if cmd.next_line_help %} +{%- if not flag.hide_possible_values and flag.arg.choices and flag.arg.choices.choices %} + [possible values: {{ flag.arg.choices.choices | join(sep=", ") }}]{%- endif %} +{%- if not flag.hide_possible_values and flag.arg.choices and flag.arg.choices.env %} + [choices env: {{ flag.arg.choices.env }}]{%- endif %} +{%- if not flag.hide_env and flag.env %} + [env: {{ flag.env }}]{%- endif %} +{%- if not flag.hide_default_value and flag.default %} + (default: {{ flag.default | join(sep=", ") }}){%- endif %} +{%- else %} {%- if not flag.hide_possible_values and flag.arg.choices and flag.arg.choices.choices %} [{{ flag.arg.choices.choices | join(sep=", ") }}]{%- endif %} {%- if not flag.hide_possible_values and flag.arg.choices and flag.arg.choices.env %} [choices env: {{ flag.arg.choices.env }}]{%- endif %} {%- if not flag.hide_env and flag.env %} [env: {{ flag.env }}]{%- endif %} {%- if not flag.hide_default_value and flag.default %} (default: {{ flag.default | join(sep=", ") }}){%- endif %} +{%- endif %} {%- endfor %} {%- endif %} diff --git a/lib/src/docs/models.rs b/lib/src/docs/models.rs index 74743e973..134a10611 100644 --- a/lib/src/docs/models.rs +++ b/lib/src/docs/models.rs @@ -47,6 +47,7 @@ pub struct SpecCommand { pub hide: bool, pub subcommand_required: bool, pub subcommand_help_heading: Option, + pub next_line_help: bool, pub restart_token: Option, pub help: Option, pub help_long: Option, @@ -480,6 +481,7 @@ impl From<&crate::SpecCommand> for SpecCommand { subcommand_required, subcommand_help_heading, subcommand_value_name: _, + next_line_help, // Consumed above while laying help out; templates need only the result. term_width: _, max_term_width: _, @@ -536,8 +538,11 @@ impl From<&crate::SpecCommand> for SpecCommand { hide: *hide, subcommand_required: *subcommand_required, subcommand_help_heading: subcommand_help_heading.clone(), + next_line_help: *next_line_help, restart_token: restart_token.clone(), - help: help.clone(), + // The renderer owns the line break after a command description. Keeping one + // embedded in the text creates an extra blank in next-line help. + help: help.as_deref().map(|help| help.trim_end().to_string()), // Trailing whitespace trimmed, matching `long_commands_section` in usage-argv. // Never intent, and it showed: pitchfork's `daemons add` ends its examples block // with a newline, which put a stray blank line in the middle of `Commands:`. diff --git a/lib/src/go/mod.rs b/lib/src/go/mod.rs index 9b4a0f276..5e00510fb 100644 --- a/lib/src/go/mod.rs +++ b/lib/src/go/mod.rs @@ -987,6 +987,9 @@ fn command_help(e: &Emitted) -> String { if let Some(name) = &e.cmd.subcommand_value_name { fields.push(format!("SubcommandValueName: {}", go_string(name))); } + if e.cmd.next_line_help { + fields.push("NextLineHelp: true".to_string()); + } // Visible only: the parse table merges hidden aliases in beside these, // because binding does not care which is which. A page does. let visible: Vec = e diff --git a/lib/src/spec/cmd.rs b/lib/src/spec/cmd.rs index 6d002cacd..b38f812fb 100644 --- a/lib/src/spec/cmd.rs +++ b/lib/src/spec/cmd.rs @@ -100,6 +100,9 @@ pub struct SpecCommand { /// Placeholder used for subcommands in the synopsis. #[serde(skip_serializing_if = "Option::is_none")] pub subcommand_value_name: Option, + /// Put each argument, flag, and subcommand description on the following line. + #[serde(skip_serializing_if = "is_false")] + pub next_line_help: bool, /// Fixed help width. Zero disables wrapping. #[serde(skip_serializing_if = "Option::is_none")] pub term_width: Option, @@ -204,6 +207,7 @@ impl Default for SpecCommand { subcommand_required: false, subcommand_help_heading: None, subcommand_value_name: None, + next_line_help: false, term_width: None, max_term_width: None, external_subcommand: false, @@ -321,6 +325,7 @@ impl SpecCommand { "subcommand_required" => cmd.subcommand_required = v.ensure_bool()?, "subcommand_help_heading" => cmd.subcommand_help_heading = Some(v.ensure_string()?), "subcommand_value_name" => cmd.subcommand_value_name = Some(v.ensure_string()?), + "next_line_help" => cmd.next_line_help = v.ensure_bool()?, "term_width" => cmd.term_width = Some(v.ensure_usize()?), "max_term_width" => cmd.max_term_width = Some(v.ensure_usize()?), "external_subcommand" => cmd.external_subcommand = v.ensure_bool()?, @@ -466,6 +471,9 @@ impl SpecCommand { cmd.subcommand_value_name = Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_string()?) } + "next_line_help" => { + cmd.next_line_help = child.ensure_arg_len(1..=1)?.arg(0)?.ensure_bool()? + } "term_width" => { cmd.term_width = Some(child.ensure_arg_len(1..=1)?.arg(0)?.ensure_usize()?) } @@ -617,6 +625,7 @@ impl SpecCommand { subcommand_required, subcommand_help_heading, subcommand_value_name, + next_line_help, term_width, max_term_width, external_subcommand, @@ -705,6 +714,7 @@ impl SpecCommand { if subcommand_value_name.is_some() { self.subcommand_value_name = subcommand_value_name; } + self.next_line_help = next_line_help; if term_width.is_some() { self.term_width = term_width; } @@ -823,6 +833,7 @@ impl From<&SpecCommand> for KdlNode { subcommand_required, subcommand_help_heading, subcommand_value_name, + next_line_help, term_width, max_term_width, external_subcommand, @@ -880,6 +891,9 @@ impl From<&SpecCommand> for KdlNode { if let Some(name) = subcommand_value_name { node.push(KdlEntry::new_prop("subcommand_value_name", name.clone())); } + if *next_line_help { + node.push(KdlEntry::new_prop("next_line_help", true)); + } if let Some(width) = term_width { node.push(KdlEntry::new_prop("term_width", *width as i128)); } @@ -1162,6 +1176,7 @@ impl From<&clap::Command> for SpecCommand { spec.subcommand_required = cmd.is_subcommand_required_set(); spec.subcommand_help_heading = cmd.get_subcommand_help_heading().map(str::to_string); spec.subcommand_value_name = cmd.get_subcommand_value_name().map(str::to_string); + spec.next_line_help = cmd.is_next_line_help_set(); spec.arg_required_else_help = cmd.is_arg_required_else_help_set(); spec.dont_delimit_trailing_values = cmd.is_dont_delimit_trailing_values_set(); spec.args_override_self = cmd.is_args_override_self(); diff --git a/lib/src/spec/mod.rs b/lib/src/spec/mod.rs index 49fb6eb04..ea0673406 100644 --- a/lib/src/spec/mod.rs +++ b/lib/src/spec/mod.rs @@ -328,6 +328,9 @@ impl Spec { "subcommand_value_name" => { schema.cmd.subcommand_value_name = Some(node.arg(0)?.ensure_string()?); } + "next_line_help" => { + schema.cmd.next_line_help = node.arg(0)?.ensure_bool()?; + } "term_width" => { schema.cmd.term_width = Some(node.arg(0)?.ensure_usize()?); } @@ -662,6 +665,11 @@ impl Display for Spec { node.push(string_entry(None, name)); nodes.push(node); } + if self.cmd.next_line_help { + let mut node = KdlNode::new("next_line_help"); + node.push(true); + nodes.push(node); + } if let Some(width) = self.cmd.term_width { let mut node = KdlNode::new("term_width"); node.push(width as i128); diff --git a/usage-rs/tests/facade.rs b/usage-rs/tests/facade.rs index dd52fb3b1..e6dd563c1 100644 --- a/usage-rs/tests/facade.rs +++ b/usage-rs/tests/facade.rs @@ -153,6 +153,17 @@ struct SizedHelp { output: Option, } +#[derive(Debug, Cli)] +#[usage(bin = "next-help", next_line_help)] +#[allow(dead_code)] +struct NextLineHelp { + /// Config file. + #[usage(long)] + config: Option, + #[arg(long, env = "NEXT_HELP_MODE", default = "fast")] + mode: String, +} + #[derive(Cli)] #[command( bin = "presented", @@ -1182,6 +1193,26 @@ fn typed_help_width_reaches_help_and_the_portable_spec() { assert_eq!(portable.cmd.max_term_width, Some(20)); } +#[test] +fn typed_next_line_help_reaches_help_and_the_portable_spec() { + let spec = NextLineHelp::spec(); + assert!(spec.root.next_line_help); + let page = usage::argv::help::short_help(spec, &["next-help"], &[spec.root]); + assert!( + page.contains("--config \n Config file."), + "{page}" + ); + assert!( + page.contains("--mode \n [env: NEXT_HELP_MODE]\n (default: fast)"), + "{page}" + ); + + let kdl = NextLineHelp::to_kdl(); + assert!(kdl.contains("next_line_help #true"), "{kdl}"); + let portable: usage_parser::Spec = kdl.parse().unwrap(); + assert!(portable.cmd.next_line_help); +} + #[test] fn emitted_parser_settings_are_portable_spec_metadata() { let Err(_) = StrictEx::parse_from(&[OsStr::new("--wat")]) else {