diff --git a/argv/src/help.rs b/argv/src/help.rs index ca16b990e..d673d3c02 100644 --- a/argv/src/help.rs +++ b/argv/src/help.rs @@ -18,6 +18,7 @@ //! corpus: usage-lib is the reference, and a divergence is a decision rather than an accident. use core::fmt::Write as _; +use std::borrow::Cow; use crate::spec::{ AdmonitionKind, AdmonitionMeta, ArgMeta, CommandMeta, Example, FlagMeta, Spec, ViewMeta, @@ -1136,11 +1137,16 @@ fn short_sections( command_deprecation(out, meta, 0); usage_section(&mut sections.usage, spec, path, meta); - // The path without the binary, which is what a listed subcommand shows: usage-lib prints - // `tool-alias get ` under `mise tool-alias`, the whole path from the root rather - // than the child's own name. + // The path without the binary: it is the sort key the reference orders the list by, even + // now that the row itself shows the child's own name. if !meta.flatten_help { - commands_section(&mut sections.commands, &path[1.min(path.len())..], meta); + commands_section( + &mut sections.commands, + &path[1.min(path.len())..], + meta, + terminal_width(meta), + false, + ); } // The short page lines its columns up too. It did not: every description began directly @@ -1306,7 +1312,23 @@ fn short_sections( } /// The list of subcommands, and the `help` command every CLI with subcommands has. -fn commands_section(out: &mut String, path: &[&str], meta: &CommandMeta<'_>) { +/// The entry every command list ends with, unless the CLI turned it off. +const HELP_SUBCOMMAND: &str = "help"; +const HELP_SUBCOMMAND_SUMMARY: &str = "Print this message or the help of the given subcommand(s)"; + +/// The command list, identical on both pages. +/// +/// The name alone occupies the column, so the summaries line up down the page and the syntax a +/// command takes belongs to that command's own page. Both pages read the same summary: a +/// parent's list says what each child is *for*, and what a child does at length belongs on the +/// child's own page rather than repeated in every ancestor's. +fn commands_section( + out: &mut String, + path: &[&str], + meta: &CommandMeta<'_>, + width: usize, + long: bool, +) { let mut visible: Vec<&&CommandMeta<'_>> = meta.subcommands.iter().filter(|c| !c.hide).collect(); order_commands(&mut visible); // Nothing visible, no section — `mise direnv` and `mise dotfiles` have subcommands and @@ -1318,7 +1340,8 @@ fn commands_section(out: &mut String, path: &[&str], meta: &CommandMeta<'_>) { } // Sorted by the rendered usage rather than by name, as usage-lib sorts them — for a // command with no flags or arguments the two agree, and where they differ this is the - // order a reader sees in the reference. + // order a reader sees in the reference. The usage is the sort key and nothing else now: + // the row shows the name. let mut lines: Vec<(String, &&CommandMeta<'_>)> = visible .iter() .map(|sub| { @@ -1334,6 +1357,16 @@ fn commands_section(out: &mut String, path: &[&str], meta: &CommandMeta<'_>) { .then_with(|| a.0.cmp(&b.0)) }); + // One column for the page, not for the section: a CLI that groups its commands reads as one + // table with rules through it, which is what the flag list already does. + let show_help = !meta.cmd.disable_help_subcommand; + let col = lines + .iter() + .map(|(_, sub)| sub.cmd.name.chars().count()) + .chain(show_help.then(|| HELP_SUBCOMMAND.chars().count())) + .max() + .unwrap_or(0); + let default_title = meta.subcommand_help_heading.unwrap_or("Commands"); let mut headings = vec![None]; for (_, sub) in &lines { @@ -1345,56 +1378,92 @@ fn commands_section(out: &mut String, path: &[&str], meta: &CommandMeta<'_>) { for heading in headings { let title = heading.unwrap_or(default_title); let _ = writeln!(out, "\n{title}:"); - for (usage, sub) in lines + // A `help_heading` on a subcommand builds a section like a flag's does, so it takes + // prose on the same terms: the long page only, and only once declared. + if long { + if let Some(prose) = heading.and_then(|title| heading_help(meta, title)) { + write_indented(out, prose, 2); + out.push('\n'); + } + } + for (_, sub) in lines .iter() .filter(|(_, sub)| command_help_section(sub, default_title) == heading) { - let _ = write!(out, " {usage}"); - // Visible aliases only: a hidden alias works and is not advertised, which is the - // whole of the distinction. - let visible_aliases: Vec<&str> = sub - .cmd - .aliases - .iter() - .copied() - .filter(|a| !sub.hidden_aliases.contains(a)) - .collect(); - if !visible_aliases.is_empty() { - let _ = write!(out, " [aliases: {}]", visible_aliases.join(", ")); - } - if let Some(about) = sub.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()); - } - if let Some(label) = deprecation_label( - sub.deprecated, - sub.deprecated_warn_at, - sub.deprecated_remove_at, - ) { - let _ = write!(out, " {label}"); - } - out.push('\n'); + entry( + out, + sub.cmd.name, + command_row(sub).as_deref(), + col, + width, + meta.next_line_help, + ); } - if heading.is_none() && !meta.cmd.disable_help_subcommand { - 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)" - ); + if heading.is_none() && show_help { + entry( + out, + HELP_SUBCOMMAND, + Some(HELP_SUBCOMMAND_SUMMARY), + col, + width, + meta.next_line_help, + ); + } + } +} + +/// Everything that follows a command's name in its parent's list, as one string. +/// +/// Aliases and deprecation trail the summary rather than sitting beside the name, so they wrap +/// with the text instead of pushing every description out of the column. +fn command_row<'a>(sub: &'a CommandMeta<'a>) -> Option> { + // A command that wrote only a long description still has a summary: its first line. Both + // pages read the same one, so `-h` never says less about a command than `--help` does. + let summary = summarize(sub.about) + .or_else(|| summarize(sub.long_about.and_then(|about| about.lines().next()))); + // Visible aliases only: a hidden alias works and is not advertised, which is the whole of + // the distinction. + let mut visible_aliases = sub + .cmd + .aliases + .iter() + .copied() + .filter(|a| !sub.hidden_aliases.contains(a)) + .peekable(); + let label = deprecation_label( + sub.deprecated, + sub.deprecated_warn_at, + sub.deprecated_remove_at, + ); + // A summary and nothing else is what almost every row is, and borrowing it there keeps the + // whole list off the allocator — which `usage --help` notices, since it renders one. + if visible_aliases.peek().is_none() && label.is_none() { + return summary.map(Cow::Borrowed); + } + let mut row = String::new(); + if let Some(summary) = summary { + row.push_str(summary); + } + if visible_aliases.peek().is_some() { + if !row.is_empty() { + row.push(' '); + } + row.push_str("[aliases: "); + for (index, alias) in visible_aliases.enumerate() { + if index > 0 { + row.push_str(", "); } + row.push_str(alias); } + row.push(']'); } + if let Some(label) = label { + if !row.is_empty() { + row.push(' '); + } + row.push_str(&label); + } + Some(Cow::Owned(row)) } fn flat_commands_short(out: &mut String, path: &[&str], meta: &CommandMeta<'_>) { @@ -1941,7 +2010,13 @@ fn long_sections( usage_section(&mut sections.usage, spec, path, meta); if !meta.flatten_help { - long_commands_section(&mut sections.commands, &path[1.min(path.len())..], meta); + commands_section( + &mut sections.commands, + &path[1.min(path.len())..], + meta, + width, + true, + ); } // One column width per section, over its visible entries — the same two the reference @@ -2173,6 +2248,24 @@ fn entry( return; } + // Text that already fits is text `wrap` would hand straight back, so skip it and the two + // allocations it makes. Worth the check because it is the common case — most descriptions + // are shorter than the column leaves room for. + if fits(help, room) { + // Assembled rather than formatted. This is the row every entry on every page takes, and + // `{usage: bool { + if text.len() > room { + return false; + } + // The start counts as a space so that a leading one, which `wrap` would drop, fails here. + let mut after_space = true; + for &byte in text.as_bytes() { + match byte { + // A run of spaces collapses, which is `wrap` rewriting the text. + b' ' if after_space => return false, + b' ' => after_space = true, + // Any other whitespace becomes a space, and any non-ASCII byte may be some. + b'\t' | b'\n' | b'\r' | 0x0b | 0x0c => return false, + 0x80.. => return false, + _ => after_space = false, + } + } + // A trailing space would be dropped too. + !after_space +} + /// Break text at word boundaries to fit a width, keeping any breaks it already has. +/// +/// The width of the line under construction is carried along rather than recounted for each +/// word. Recounting made this quadratic in the length of a line, which went unnoticed while +/// only the long page wrapped and was 3% of `usage --help` once the command list did too. fn wrap(text: &str, width: usize) -> Vec { let mut lines = Vec::new(); for paragraph in text.split('\n') { @@ -2192,15 +2320,19 @@ fn wrap(text: &str, width: usize) -> Vec { continue; } let mut line = String::new(); + let mut line_width = 0; for word in paragraph.split_whitespace() { let word_width = word.chars().count(); - if !line.is_empty() && line.chars().count() + 1 + word_width > width { + if !line.is_empty() && line_width + 1 + word_width > width { lines.push(std::mem::take(&mut line)); + line_width = 0; } if !line.is_empty() { line.push(' '); + line_width += 1; } line.push_str(word); + line_width += word_width; } if !line.is_empty() { lines.push(line); @@ -2243,6 +2375,11 @@ fn admonitions(out: &mut String, blocks: &[AdmonitionMeta<'_>]) { } } +/// A description reduced to what a list can show, or nothing if it says nothing. +fn summarize(text: Option<&str>) -> Option<&str> { + text.map(str::trim_end).filter(|text| !text.is_empty()) +} + fn deprecation_label( message: Option<&str>, warn_at: Option<&str>, @@ -2306,82 +2443,6 @@ fn flag_notes(out: &mut String, meta: &FlagMeta<'_>, indent: usize) { } } -/// The commands list, with each command's help beneath its usage. -fn long_commands_section(out: &mut String, path: &[&str], meta: &CommandMeta<'_>) { - let mut visible: Vec<&&CommandMeta<'_>> = meta.subcommands.iter().filter(|c| !c.hide).collect(); - order_commands(&mut visible); - if visible.is_empty() { - return; - } - let mut lines: Vec<(String, &&CommandMeta<'_>)> = visible - .iter() - .map(|sub| { - let mut sub_path: Vec<&str> = path.to_vec(); - sub_path.push(sub.cmd.name); - (usage_line(&sub_path, sub), *sub) - }) - .collect(); - lines.sort_unstable_by(|a, b| { - a.1.display_order - .unwrap_or(999) - .cmp(&b.1.display_order.unwrap_or(999)) - .then_with(|| a.0.cmp(&b.0)) - }); - - let default_title = meta.subcommand_help_heading.unwrap_or("Commands"); - let mut headings = vec![None]; - for (_, sub) in &lines { - let heading = command_help_section(sub, default_title); - if !headings.contains(&heading) { - headings.push(heading); - } - } - for heading in headings { - let title = heading.unwrap_or(default_title); - let _ = writeln!(out, "\n{title}:"); - // A `help_heading` on a subcommand builds a section like a flag's does, so it takes - // prose on the same terms: the long page only, and only once declared. - if let Some(prose) = heading.and_then(|title| heading_help(meta, title)) { - write_indented(out, prose, 2); - out.push('\n'); - } - for (usage, sub) in lines - .iter() - .filter(|(_, sub)| command_help_section(sub, default_title) == heading) - { - let _ = write!(out, " {usage}"); - let visible_aliases: Vec<&str> = sub - .cmd - .aliases - .iter() - .copied() - .filter(|a| !sub.hidden_aliases.contains(a)) - .collect(); - if !visible_aliases.is_empty() { - let _ = write!(out, " [aliases: {}]", visible_aliases.join(", ")); - } - out.push('\n'); - if let Some(about) = sub.long_about.or(sub.about) { - // Trailing whitespace trimmed: the blank line after each entry is written below, and - // a description that happens to end in a newline — which clap's `long_about` often - // does, reaching the spec verbatim — added a second one and left a stray blank in - // the middle of the list. - write_indented(out, about.trim_end(), 4); - } - command_deprecation(out, sub, 4); - // A blank line between entries, which the wider layout can afford and which keeps a - // multi-line description from running into the next command's name. - out.push('\n'); - } - if heading.is_none() && !meta.cmd.disable_help_subcommand { - let _ = writeln!( - out, - " help\n Print this message or the help of the given subcommand(s)" - ); - } - } -} - fn flat_commands_long(out: &mut String, path: &[&str], meta: &CommandMeta<'_>, width: usize) { let mut visible: Vec<_> = meta.subcommands.iter().filter(|sub| !sub.hide).collect(); order_commands(&mut visible); @@ -3665,10 +3726,10 @@ mod style_tests { }; let mut page = String::new(); - commands_section(&mut page, &[], &root_meta); + commands_section(&mut page, &[], &root_meta, 80, false); - assert!(page.contains(" run run it\n help")); - assert!(!page.contains(" run run it\n\n help")); + assert!(page.contains(" run run it\n help")); + assert!(!page.contains(" run run it\n\n help")); } #[test] diff --git a/conformance/tests/help_request.rs b/conformance/tests/help_request.rs index 62b71db90..33d84e517 100644 --- a/conformance/tests/help_request.rs +++ b/conformance/tests/help_request.rs @@ -29,6 +29,10 @@ enum Commands { } /// A tool whose help is worth asking for +/// +/// The long form has this paragraph and the short form does not, which is what gives the two +/// pages something to differ by — the command list no longer does, since a parent says the same +/// thing about a child on either page. #[derive(Cli)] #[usage(bin = "ex")] struct Ex { @@ -443,7 +447,9 @@ fn the_page_advertises_exactly_where_the_word_works() { let spec = Deep::spec(); let root_page = usage_argv::help::short_help(spec, &["deep"], &[spec.root]); assert!( - root_page.contains("\n help Print this message"), + root_page + .lines() + .any(|line| line.starts_with(" help") && line.contains("Print this message")), "{root_page}" ); diff --git a/conformance/tests/trailing_whitespace.rs b/conformance/tests/trailing_whitespace.rs index 1e2d6796e..90a5f3e8b 100644 --- a/conformance/tests/trailing_whitespace.rs +++ b/conformance/tests/trailing_whitespace.rs @@ -1,10 +1,10 @@ //! A description that ends in a newline should not add a blank line to the page. //! //! clap's `long_about` often ends with one — a `///` block whose last line is empty, or an -//! examples section written with a trailing break — and it reaches the spec verbatim. Both -//! renderers write their own blank line after a description, so one already in the text doubled -//! it: a stray blank in the middle of `Commands:`, and a second gap under the program's own -//! description. +//! examples section written with a trailing break — and it reaches the spec verbatim. Wherever +//! a renderer writes its own blank line after a description, one already in the text doubled it: +//! a stray blank in the middle of `Commands:` back when that list printed long descriptions, and +//! a second gap under the program's own description, which it still would. //! //! Found on pitchfork's `daemons add` and mise's `plugins ls-remote`, which is why the fix is in //! both renderers rather than one — trimming either side alone traded one CLI's divergence for @@ -103,13 +103,17 @@ fn the_commands_still_parse() { } #[test] -fn the_entries_still_have_one_blank_between_them() { - // Trimming must not run the entries together: the separator is written by the renderer and - // stays whether or not the text ended in a break. +fn the_entries_sit_on_consecutive_lines() { + // The long page lists commands the way the short one does — one line each, in one column, + // with no separator between them — so a description ending in a break has nowhere to leave + // a blank behind, and the entries must not gain one either. let page = help::render(Ex::spec(), Ex::command(), true).expect("a page"); let commands = page .split_once("Commands:\n") .expect("a commands section") .1; - assert!(commands.contains("\n\n two"), "{commands:?}"); + assert!( + commands.starts_with(" one Short one\n two Short two\n"), + "{commands:?}" + ); } diff --git a/corpus/render/03-sections.json b/corpus/render/03-sections.json index ef4aa52c0..cb669f4b3 100644 --- a/corpus/render/03-sections.json +++ b/corpus/render/03-sections.json @@ -120,7 +120,7 @@ }, { "id": "commands-list-with-aliases", - "doc": "Subcommands are listed by their rendered usage with visible aliases beside them; a hidden alias works and is not advertised. Note the supplied `help` entry, which is written flush rather than into the column the declared commands share — the reference's behaviour, pinned here so that fixing it is a decision rather than an accident.", + "doc": "Subcommands are listed by name in one column, with the summary after it and the aliases after that; a hidden alias works and is not advertised. The supplied `help` entry sits in the same column as the declared commands, because it is a row like any other.", "spec": "name \"ex\"\nbin \"ex\"\nabout \"An example\"\ncmd \"install\" help=\"Install a tool\" {\n alias \"i\"\n alias \"add\" hide=#true\n arg \"\"\n}\ncmd \"remove\" help=\"Remove a tool\" hide=#true\n", "expect": { "usage": "ex ", @@ -130,8 +130,8 @@ "Usage: ex ", "", "Commands:", - " install [aliases: i] Install a tool", - " help Print this message or the help of the given subcommand(s)", + " install Install a tool [aliases: i]", + " help Print this message or the help of the given subcommand(s)", "", "Flags:", " -h, --help Print help" @@ -204,7 +204,7 @@ "Usage: ex [--shown] [shown] ", "", "Commands:", - " go Go", + " go Go", " help Print this message or the help of the given subcommand(s)", "", "Arguments:", diff --git a/docs/go/help.md b/docs/go/help.md index 9d743768f..e8480ef63 100644 --- a/docs/go/help.md +++ b/docs/go/help.md @@ -36,14 +36,19 @@ Global flags: The output is not merely similar to the reference implementation's — all 211 of mise's usage lines, `-h` pages, and `--help` pages are compared **byte for byte** against usage-lib's -rendering in CI. Layout details you get for free: sections in canonical order, commands sorted -with `[aliases: …]` shown for visible aliases, `help_heading` groups (first-seen order, unheaded -entries first), a 4-column short-flag gutter, required entries in angle brackets, `[env: X]` and -default annotations, and the long page wrapped at a fixed 80 columns. +rendering in CI. Layout details you get for free: sections in canonical order, commands listed by +name in one column per page with `[aliases: …]` after the summary for visible aliases, +`help_heading` groups (first-seen order, unheaded entries first), a 4-column short-flag gutter, +required entries in angle brackets, `[env: X]` and default annotations, and both pages wrapped at +a fixed 80 columns. + +Both pages list a command's children identically: the name, then the summary — `help`, or the +first line of `long_help` when there is no `help`. A child's full `long_help` appears on the +child's own page, not repeated in every ancestor's list. The short page appends `[choices]`, `[env: X]`, and (for arguments) `(default: …)` inline; the -long page gives each its own line and prefers `long_help` over `help`. Examples declared on the -root are inherited by commands that declare none. +long page gives each its own line and prefers `long_help` over `help` for the command's own +description. Examples declared on the root are inherited by commands that declare none. One rule is load-bearing: a page only advertises a flag spelling where that flag is the one that would _bind_ it. Masking is per spelling — a subcommand redeclaring `--jobs` leaves an inherited diff --git a/docs/rust/help.md b/docs/rust/help.md index 94697dff7..a86b369c8 100644 --- a/docs/rust/help.md +++ b/docs/rust/help.md @@ -9,6 +9,11 @@ declares its own `--help`, your declaration wins for that spelling. `-h` renders the short page, `--help` the long page: the first paragraph of each doc comment versus the whole comment, `long_help` over `help`, `long_about` over `about`. +That preference is about the page's own subject. A command's _list_ of children reads the same on +both pages — each child's name in one column, then its summary — because a parent says what each +child is for, and what a child does at length belongs on the child's own page rather than +repeated in every ancestor's list. A child with only a `long_help` contributes its first line. + With `parse()`, help is handled for you — printed to stdout, exit `0`. With `parse_from`, a help request comes back as an _error_, because a parse that stopped to print help has not produced a value (clap models it the same way): diff --git a/docs/rust/quickstart.md b/docs/rust/quickstart.md index a08a4768b..25cb14744 100644 --- a/docs/rust/quickstart.md +++ b/docs/rust/quickstart.md @@ -147,14 +147,9 @@ Greets people, politely Usage: greet Commands: - completion <--shell > - Print a completion script - - hello [NAME] - Greet someone - - help - Print this message or the help of the given subcommand(s) + completion Print a completion script + hello Greet someone + help Print this message or the help of the given subcommand(s) Flags: -h, --help Print help diff --git a/go/argv/page.go b/go/argv/page.go index e87f201c1..a7c7fda94 100644 --- a/go/argv/page.go +++ b/go/argv/page.go @@ -241,6 +241,17 @@ func ShortHelp(spec HelpSpec, path []string, chain []*Command, help HelpTable) s // commandsSection lists the subcommands, and the `help` command every CLI with // subcommands has. +// helpSubcommand is the entry every command list ends with, unless the CLI turned it off. +const ( + helpSubcommand = "help" + helpSubcommandSummary = "Print this message or the help of the given subcommand(s)" +) + +// commandsSection writes the command list, which is identical on both pages. The name +// alone occupies the column, so the summaries line up down the page and the syntax a +// command takes belongs to that command's own page. Both pages read the same summary: a +// parent's list says what each child is *for*, and what a child does at length belongs on +// the child's own page rather than repeated in every ancestor's. func commandsSection(out *strings.Builder, path []string, cmd *Command, help HelpTable) { type line struct { usage string @@ -269,6 +280,8 @@ func commandsSection(out *strings.Builder, path []string, cmd *Command, help Hel } nextLineHelp = h.NextLineHelp } + // Sorted by the rendered usage, which is how usage-lib sorts them. That is all the + // usage is used for now: the row shows the name. sort.SliceStable(lines, func(i, j int) bool { left, right := helpOrder(help, lines[i].sub.Key, 999), helpOrder(help, lines[j].sub.Key, 999) if left != right { @@ -277,6 +290,19 @@ func commandsSection(out *strings.Builder, path []string, cmd *Command, help Hel return lines[i].usage < lines[j].usage }) + // One column for the page, not for the section: a CLI that groups its commands reads + // as one table with rules through it, which is what the flag list already does. + showHelp := !cmd.DisableHelpSubcommand + col := 0 + if showHelp { + col = len([]rune(helpSubcommand)) + } + for _, l := range lines { + if n := len([]rune(l.sub.Name)); n > col { + col = n + } + } + headings := []string{""} for _, l := range lines { if h := headingOf(help, l.sub.Key); h == heading { @@ -299,41 +325,42 @@ func commandsSection(out *strings.Builder, path []string, cmd *Command, help Hel if itemSection != section { continue } - out.WriteString(" " + l.usage) - if h := help.Lookup(l.sub.Key); h != nil { - // Visible aliases only: a hidden alias works and is not advertised, - // which is the whole of the distinction. - if len(h.VisibleAliases) > 0 { - out.WriteString(" [aliases: " + strings.Join(h.VisibleAliases, ", ") + "]") - } - if nextLineHelp { - out.WriteString("\n") - if strings.TrimSpace(h.Short) != "" { - writeIndented(out, trimEnd(h.Short), 4) - } - if label := deprecationLabel(h); label != "" { - writeIndented(out, label, 4) - } - continue - } - if text := helpText(h); text != "" { - // The row owns its terminating newline. Trim the description in both - // layouts, as usage-lib does before selecting a layout. - out.WriteString(" " + trimEnd(text)) - } - } - out.WriteString("\n") + entry(out, l.sub.Name, commandRow(help.Lookup(l.sub.Key)), col, nextLineHelp) } - if section == "" && !cmd.DisableHelpSubcommand { - 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") - } + if section == "" && showHelp { + entry(out, helpSubcommand, helpSubcommandSummary, col, nextLineHelp) } } } +// commandRow is everything that follows a command's name in its parent's list, as one +// string. Aliases and deprecation trail the summary rather than sitting beside the name, +// so they wrap with the text instead of pushing every description out of the column. +func commandRow(h *Help) string { + if h == nil { + return "" + } + parts := []string{} + // A command that wrote only a long description still has a summary: its first line. + // Both pages read the same one, so `-h` never says less than `--help` does. + summary := trimEnd(h.Short) + if summary == "" { + summary = trimEnd(strings.SplitN(h.Long, "\n", 2)[0]) + } + if summary != "" { + parts = append(parts, summary) + } + // Visible aliases only: a hidden alias works and is not advertised, which is the + // whole of the distinction. + if len(h.VisibleAliases) > 0 { + parts = append(parts, "[aliases: "+strings.Join(h.VisibleAliases, ", ")+"]") + } + if label := deprecationLabel(h); label != "" { + parts = append(parts, label) + } + return strings.Join(parts, " ") +} + func flatCommandsShort(out *strings.Builder, path []string, cmd *Command, help HelpTable, nextLine bool) { visible := append([]*Command{}, cmd.Subcommands...) orderCommands(visible, help) diff --git a/go/argv/page_long.go b/go/argv/page_long.go index a996120ab..b6890f7a2 100644 --- a/go/argv/page_long.go +++ b/go/argv/page_long.go @@ -1,7 +1,6 @@ package argv import ( - "slices" "sort" "strings" ) @@ -77,7 +76,7 @@ func LongHelp(spec HelpSpec, path []string, chain []*Command, help HelpTable) st } if meta == nil || !meta.FlattenHelp { - longCommandsSection(§ions.commands, path[min(1, len(path)):], cmd, help) + commandsSection(§ions.commands, path[min(1, len(path)):], cmd, help) } // One column width per section, over its visible entries — separately, so a @@ -208,83 +207,6 @@ func AllHelp(spec HelpSpec, path []string, chain []*Command, help HelpTable) str // longCommandsSection lists the subcommands, each description on its own indented // line rather than beside the name. -func longCommandsSection(out *strings.Builder, path []string, cmd *Command, help HelpTable) { - type line struct { - usage string - sub *Command - } - var lines []line - for _, sub := range cmd.Subcommands { - if h := help.Lookup(sub.Key); h != nil && h.Hide { - continue - } - subPath := append(append([]string{}, path...), sub.Name) - lines = append(lines, line{UsageLine(subPath, sub, help), sub}) - } - if len(lines) == 0 { - return - } - heading := "Commands" - if h := help.Lookup(cmd.Key); h != nil && h.SubcommandHelpHeading != "" { - heading = h.SubcommandHelpHeading - } - sort.SliceStable(lines, func(i, j int) bool { - left, right := helpOrder(help, lines[i].sub.Key, 999), helpOrder(help, lines[j].sub.Key, 999) - if left != right { - return left < right - } - return lines[i].usage < lines[j].usage - }) - - headings := []string{""} - for _, l := range lines { - if h := headingOf(help, l.sub.Key); h == heading { - continue - } else if h != "" && !slices.Contains(headings, h) { - headings = append(headings, h) - } - } - for _, section := range headings { - title := section - if title == "" { - title = heading - } - out.WriteString("\n" + title + ":\n") - for _, l := range lines { - itemSection := headingOf(help, l.sub.Key) - if itemSection == heading { - itemSection = "" - } - if itemSection != section { - continue - } - out.WriteString(" " + l.usage) - h := help.Lookup(l.sub.Key) - if h != nil && len(h.VisibleAliases) > 0 { - out.WriteString(" [aliases: " + strings.Join(h.VisibleAliases, ", ") + "]") - } - out.WriteString("\n") - if h != nil { - // Trailing whitespace trimmed: the blank line after each entry is - // written below, and a description that happens to end in a newline - // added a second one — a stray blank in the middle of the list. - if about := trimEnd(firstOf(h.Long, h.Short)); about != "" { - writeIndented(out, about, 4) - } - if label := deprecationLabel(h); label != "" { - writeIndented(out, label, 4) - } - } - // A blank line between entries, which the wider layout can afford and - // which keeps a multi-line description from running into the next name. - out.WriteString("\n") - } - if section == "" && !cmd.DisableHelpSubcommand { - out.WriteString(" help\n Print this message or the help of the given subcommand(s)\n") - } - } -} - func flatCommandsLong(out *strings.Builder, path []string, cmd *Command, help HelpTable, nextLine bool) { visible := append([]*Command{}, cmd.Subcommands...) orderCommands(visible, help) diff --git a/go/argv/page_test.go b/go/argv/page_test.go index 475732e5c..e3bdce6b0 100644 --- a/go/argv/page_test.go +++ b/go/argv/page_test.go @@ -169,8 +169,10 @@ func TestCommandDeprecationAppearsInListingsAndFlattenedHelp(t *testing.T) { help[0].NextLineHelp = true nextLinePage := ShortHelp(HelpSpec{Bin: "ex"}, []string{"ex"}, []*Command{root}, help) - if !strings.Contains(nextLinePage, "old\n old command\n [deprecated: warns at 6.1]") { - t.Fatalf("next-line command listing glued deprecation to its description:\n%s", nextLinePage) + // In a command list the label trails the summary rather than taking a line of its own, + // in either layout: it wraps with the text instead of pushing it out of the column. + if !strings.Contains(nextLinePage, "old\n old command [deprecated: warns at 6.1]") { + t.Fatalf("next-line command listing misplaced the deprecation:\n%s", nextLinePage) } help[0].NextLineHelp = false diff --git a/lib/src/docs/cli/mod.rs b/lib/src/docs/cli/mod.rs index 496f4af3b..1f2f3628c 100644 --- a/lib/src/docs/cli/mod.rs +++ b/lib/src/docs/cli/mod.rs @@ -72,6 +72,40 @@ pub fn render_help(spec: &Spec, cmd: &SpecCommand, long: bool) -> String { } lay_out(&mut inherited, width, col); + // The command list, laid out the way the flag list is: one column for the whole page, the + // name in it, and everything else — summary, aliases, deprecation — trailing as text that + // wraps under itself. Both pages get the same rows, so `--help` no longer reprints every + // child's long help on its parent's page. + let help_row = { + let show_help_row = !docs_cmd.subcommands.is_empty() + && !docs_cmd.flatten_help + && !cmd.disable_help_subcommand; + let cmd_col = crate::docs::layout::max_usage_width( + docs_cmd + .subcommand_groups + .iter() + .flat_map(|g| g.items.iter()) + .map(|c| c.name.as_str()) + .chain(show_help_row.then_some(HELP_SUBCOMMAND)), + ); + for group in &mut docs_cmd.subcommand_groups { + lay_out_commands(&mut group.items, width, cmd_col); + } + // Rendered here rather than in the template because it is a row like any other: it + // sits in the same column and wraps by the same rule, and neither is something a + // template should be working out. + show_help_row.then(|| { + render_row( + HELP_SUBCOMMAND, + HELP_SUBCOMMAND_SUMMARY, + width, + cmd_col, + cmd.next_line_help, + ) + }) + }; + ctx.insert("help_row", &help_row); + let arg_has_ungrouped = docs_cmd .arg_groups .iter() @@ -343,6 +377,112 @@ fn lay_out(flags: &mut [crate::docs::models::SpecFlag], terminal_width: usize, c } } +/// The entry every command list ends with, unless the CLI turned it off. +const HELP_SUBCOMMAND: &str = "help"; +const HELP_SUBCOMMAND_SUMMARY: &str = "Print this message or the help of the given subcommand(s)"; + +/// Fit a list of subcommand summaries to the page's command column. +/// +/// `lay_out`'s counterpart for the command list: the same column, the same wrapping, the same +/// "an empty rendering means use the block layout" signal to the template. +fn lay_out_commands( + commands: &mut [crate::docs::models::HelpCommand], + terminal_width: usize, + col: usize, +) { + for command in commands { + command.usage_col_width = col; + command.row = command_row(command); + command.help_rendered = None; + command.help_is_multiline = false; + if let Some(row) = command.row.as_deref() { + let (rendered, is_multiline) = + crate::docs::layout::render_help_text(row, terminal_width, col); + if !rendered.is_empty() { + command.help_rendered = Some(rendered); + command.help_is_multiline = is_multiline; + } + } + } +} + +/// Everything that follows a command's name in its parent's list, as one string. +/// +/// The name alone occupies the column, so the summaries line up down the page and the syntax a +/// command takes belongs to that command's own page. What qualifies the command rather than +/// describing it — the names it also answers to, that it is going away — trails the summary, +/// where it wraps with the text instead of pushing it out of the column. +fn command_row(cmd: &crate::docs::models::HelpCommand) -> Option { + let mut parts = Vec::new(); + // A command that wrote only `help_long` still has a summary: its first line. Both pages + // read the same one, so `-h` never says less about a command than `--help` does. + let summary = summarize(cmd.help.as_deref()).or_else(|| { + summarize( + cmd.help_long + .as_deref() + .and_then(|help| help.lines().next()), + ) + }); + if let Some(summary) = summary { + parts.push(summary.to_string()); + } + if !cmd.aliases.is_empty() { + parts.push(format!("[aliases: {}]", cmd.aliases.join(", "))); + } + if let Some(label) = deprecation_label( + cmd.deprecated.as_deref(), + cmd.deprecated_warn_at.as_deref(), + cmd.deprecated_remove_at.as_deref(), + ) { + parts.push(label); + } + (!parts.is_empty()).then(|| parts.join(" ")) +} + +/// A description reduced to what a list can show, or nothing if it says nothing. +fn summarize(text: Option<&str>) -> Option<&str> { + text.map(str::trim_end).filter(|text| !text.is_empty()) +} + +/// How a page says something is going away, in the one place both lists read it from. +fn deprecation_label( + message: Option<&str>, + warn_at: Option<&str>, + remove_at: Option<&str>, +) -> Option { + if message.is_none() && warn_at.is_none() && remove_at.is_none() { + return None; + } + let mut parts = Vec::new(); + if let Some(message) = message { + parts.push(message.to_string()); + } + if let Some(at) = warn_at { + parts.push(format!("warns at {at}")); + } + if let Some(at) = remove_at { + parts.push(format!("removed at {at}")); + } + Some(format!("[deprecated: {}]", parts.join("; "))) +} + +/// One command row, ready to print — the form the synthetic `help` entry takes. +fn render_row( + name: &str, + row: &str, + terminal_width: usize, + col: usize, + next_line_help: bool, +) -> String { + if !next_line_help { + let (rendered, _) = crate::docs::layout::render_help_text(row, terminal_width, col); + if !rendered.is_empty() { + return format!(" {name:, pub deprecated_warn_at: Option, @@ -107,11 +109,18 @@ pub struct HelpCommand { pub help_heading: Option, pub surface: Option, pub available_if: Vec, + /// Everything that follows the name, as one string: summary, aliases, deprecation. + /// Composed once the page's column is known — see `lay_out_commands`. + pub row: Option, + pub usage_col_width: usize, + pub help_rendered: Option, + pub help_is_multiline: bool, } impl From<&SpecCommand> for HelpCommand { fn from(cmd: &SpecCommand) -> Self { Self { + name: cmd.name.clone(), usage: cmd.usage.clone(), deprecated: cmd.deprecated.clone(), deprecated_warn_at: cmd.deprecated_warn_at.clone(), @@ -122,6 +131,10 @@ impl From<&SpecCommand> for HelpCommand { help_heading: cmd.help_heading.clone(), surface: cmd.surface.clone(), available_if: cmd.available_if.clone(), + row: None, + usage_col_width: 0, + help_rendered: None, + help_is_multiline: false, } } } diff --git a/lib/tests/parse.rs b/lib/tests/parse.rs index 1bef59720..d1fbae3f0 100644 --- a/lib/tests/parse.rs +++ b/lib/tests/parse.rs @@ -171,7 +171,7 @@ cmd_help_short: expected=r#"Usage: Commands: - cmd shorthelp + cmd shorthelp help Print this message or the help of the given subcommand(s) Flags: @@ -184,13 +184,8 @@ cmd_help_long: expected=r#"Usage: Commands: - cmd - help - fooo - bar - - help - Print this message or the help of the given subcommand(s) + cmd shorthelp + help Print this message or the help of the given subcommand(s) Flags: -h, --help Print help @@ -204,8 +199,8 @@ subcommand_help_short: expected=r#"Usage: plugins Commands: - plugins install shorthelp - help Print this message or the help of the given subcommand(s) + install shorthelp + help Print this message or the help of the given subcommand(s) Flags: -h, --help Print help diff --git a/usage-dynamic/tests/catalog.rs b/usage-dynamic/tests/catalog.rs index d58d69f77..41b053c42 100644 --- a/usage-dynamic/tests/catalog.rs +++ b/usage-dynamic/tests/catalog.rs @@ -208,17 +208,17 @@ fn nested_help_merges_summaries_aliases_headings_and_ordering() { for long in [false, true] { let help = app.help("plugins", long).unwrap(); assert!(help.contains("Installed plugins:"), "{help}"); - assert!(help.contains("plugins formatter"), "{help}"); + assert!(help.contains(" formatter Format a project"), "{help}"); assert!(help.contains("[aliases: fmt]"), "{help}"); assert!(!help.contains("oldfmt"), "{help}"); assert!( - help.find("plugins audit").unwrap() < help.find("plugins formatter").unwrap(), + help.find(" audit").unwrap() < help.find(" formatter").unwrap(), "{help}" ); } let plugin_help = app.help("plugins formatter", false).unwrap(); assert!(plugin_help.contains("--color"), "{plugin_help}"); - assert!(plugin_help.contains("formatter check"), "{plugin_help}"); + assert!(plugin_help.contains("\n check"), "{plugin_help}"); let nested_help = app.help("plugins fmt check", false).unwrap(); assert!(nested_help.contains("--fix"), "{nested_help}"); } diff --git a/usage-rs/tests/facade.rs b/usage-rs/tests/facade.rs index 60821de3c..045495a60 100644 --- a/usage-rs/tests/facade.rs +++ b/usage-rs/tests/facade.rs @@ -2448,8 +2448,9 @@ fn explicit_display_order_reaches_help_and_the_portable_spec() { flags.find("--first").unwrap() < flags.find("--second").unwrap(), "{page}" ); + let commands = page.split_once("\nCommands:\n").unwrap().1; assert!( - page.find("first Shown first.").unwrap() < page.find("second Shown second.").unwrap(), + commands.find(" first ").unwrap() < commands.find(" second ").unwrap(), "{page}" ); let child = &spec.root.subcommands[0];