Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
321 changes: 191 additions & 130 deletions argv/src/help.rs

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion conformance/tests/help_request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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}"
);

Expand Down
20 changes: 12 additions & 8 deletions conformance/tests/trailing_whitespace.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:?}"
);
}
8 changes: 4 additions & 4 deletions corpus/render/03-sections.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 \"<TOOL>\"\n}\ncmd \"remove\" help=\"Remove a tool\" hide=#true\n",
"expect": {
"usage": "ex <SUBCOMMAND>",
Expand All @@ -130,8 +130,8 @@
"Usage: ex <SUBCOMMAND>",
"",
"Commands:",
" install <TOOL> [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"
Expand Down Expand Up @@ -204,7 +204,7 @@
"Usage: ex [--shown] [shown] <SUBCOMMAND>",
"",
"Commands:",
" go Go",
" go Go",
" help Print this message or the help of the given subcommand(s)",
"",
"Arguments:",
Expand Down
17 changes: 11 additions & 6 deletions docs/go/help.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions docs/rust/help.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
11 changes: 3 additions & 8 deletions docs/rust/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,14 +147,9 @@ Greets people, politely
Usage: greet <SUBCOMMAND>

Commands:
completion <--shell <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
Expand Down
87 changes: 57 additions & 30 deletions go/argv/page.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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)
Expand Down
80 changes: 1 addition & 79 deletions go/argv/page_long.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package argv

import (
"slices"
"sort"
"strings"
)
Expand Down Expand Up @@ -77,7 +76,7 @@ func LongHelp(spec HelpSpec, path []string, chain []*Command, help HelpTable) st
}

if meta == nil || !meta.FlattenHelp {
longCommandsSection(&sections.commands, path[min(1, len(path)):], cmd, help)
commandsSection(&sections.commands, path[min(1, len(path)):], cmd, help)
}

// One column width per section, over its visible entries — separately, so a
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 4 additions & 2 deletions go/argv/page_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading