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
66 changes: 55 additions & 11 deletions argv/src/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,23 @@ const INLINE_LIMIT: usize = 2;
/// | `commands` | the subcommand list, or the flattened bodies under `flatten_help` |
/// | `args` | every argument group, each under its heading |
/// | `flags` | this command's flag groups, then the globals it inherits |
/// | `grouped_args` | arguments with a declared help heading |
/// | `ungrouped_args` | arguments under the default `Arguments` heading |
/// | `grouped_flags` | flags with a declared help heading |
/// | `ungrouped_flags` | flags under `Flags`, plus inherited global flags |
/// | `after_help` | examples, `after_help`, and the author/license footer on a long page |
pub const SECTIONS: [&str; 6] = ["about", "usage", "commands", "args", "flags", "after_help"];
pub const SECTIONS: [&str; 10] = [
"about",
"usage",
"commands",
"args",
"flags",
"grouped_args",
"ungrouped_args",
"grouped_flags",
"ungrouped_flags",
"after_help",
];

/// The first placeholder in a template that names no section, if there is one.
///
Expand Down Expand Up @@ -91,6 +106,10 @@ struct Sections {
commands: String,
args: String,
flags: String,
grouped_args: String,
ungrouped_args: String,
grouped_flags: String,
ungrouped_flags: String,
flattened: String,
after_help: String,
}
Expand Down Expand Up @@ -136,6 +155,10 @@ impl Sections {
}
"args" => self.args.trim().to_string(),
"flags" => self.flags.trim().to_string(),
"grouped_args" => self.grouped_args.trim().to_string(),
"ungrouped_args" => self.ungrouped_args.trim().to_string(),
"grouped_flags" => self.grouped_flags.trim().to_string(),
"ungrouped_flags" => self.ungrouped_flags.trim().to_string(),
"after_help" => self.after_help.trim().to_string(),
_ => return None,
})
Expand Down Expand Up @@ -1118,8 +1141,10 @@ fn short_help_with(
.map(|a| arg_usage(a).chars().count())
.max()
.unwrap_or(0);
groups_section(
split_groups_section(
&mut sections.args,
&mut sections.ungrouped_args,
&mut sections.grouped_args,
"Arguments",
args.iter().copied(),
|a| a.help_heading,
Expand Down Expand Up @@ -1221,8 +1246,10 @@ fn short_help_with(
deprecation.as_deref(),
);
};
groups_section(
split_groups_section(
&mut sections.flags,
&mut sections.ungrouped_flags,
&mut sections.grouped_flags,
"Flags",
own.iter().copied(),
|f| flag_help_heading(meta, f),
Expand All @@ -1231,8 +1258,10 @@ fn short_help_with(
// After the command's own, and under a heading that says where they came from: `--config`
// belongs to the program, not to this command, and a reader should be able to see that.
// The text is precomputed, since a spelling a descendant claimed is left out of it.
groups_section(
split_groups_section(
&mut sections.flags,
&mut sections.ungrouped_flags,
&mut sections.grouped_flags,
"Global flags",
inherited.iter(),
|_| None,
Expand Down Expand Up @@ -1527,9 +1556,12 @@ fn flatten_site_heading<'a>(
None
}

/// One section per heading, unheaded first, in the order the headings first appear.
fn groups_section<'m, T: 'm>(
/// One section per heading, unheaded first, while also keeping the named and default groups
/// available to a template.
fn split_groups_section<'m, T: 'm>(
out: &mut String,
ungrouped: &mut String,
grouped: &mut String,
default_title: &str,
items: impl Iterator<Item = &'m T> + Clone,
heading_of: impl Fn(&T) -> Option<&str>,
Expand All @@ -1547,9 +1579,15 @@ fn groups_section<'m, T: 'm>(
headings.sort_by_key(|h| h.is_some());

for heading in headings {
let _ = writeln!(out, "\n{}:", heading.unwrap_or(default_title));
let mut section = String::new();
let _ = writeln!(section, "\n{}:", heading.unwrap_or(default_title));
for item in items.clone().filter(|i| heading_of(i) == heading) {
write_item(out, item);
write_item(&mut section, item);
}
out.push_str(&section);
match heading {
Some(_) => grouped.push_str(&section),
None => ungrouped.push_str(&section),
}
}
}
Expand Down Expand Up @@ -1839,8 +1877,10 @@ fn long_help_with(
.map(|a| arg_usage(a).chars().count())
.max()
.unwrap_or(0);
groups_section(
split_groups_section(
&mut sections.args,
&mut sections.ungrouped_args,
&mut sections.grouped_args,
"Arguments",
args.iter().copied(),
|a| a.help_heading,
Expand Down Expand Up @@ -1877,8 +1917,10 @@ fn long_help_with(
.chain(inherited.iter().map(|(_, u)| u.chars().count()))
.max()
.unwrap_or(0);
groups_section(
split_groups_section(
&mut sections.flags,
&mut sections.ungrouped_flags,
&mut sections.grouped_flags,
"Flags",
own.iter().copied(),
|f| flag_help_heading(meta, f),
Expand Down Expand Up @@ -1911,8 +1953,10 @@ fn long_help_with(
// belongs to the program, not to this command, and a reader should be able to see that.
// Not grouped by `help_heading` — an ancestor's headings describe that command's page, and
// borrowing them here would put a section title on flags that are only visiting.
groups_section(
split_groups_section(
&mut sections.flags,
&mut sections.ungrouped_flags,
&mut sections.grouped_flags,
"Global flags",
inherited.iter(),
|_| None,
Expand Down
87 changes: 78 additions & 9 deletions conformance/tests/help_template.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,12 +142,12 @@ fn a_template_survives_the_round_trip_through_kdl() {
);
}

/// A CLI naming every section there is, in an order no default page uses.
/// A CLI naming every original, combined section, in an order no default page uses.
///
/// The fixture that holds the vocabularies together. The derive keeps its own copy of the six
/// names — a proc-macro crate cannot depend on the crate its output calls into — so a name
/// missing from that copy refuses this struct at compile time, and one missing from
/// `usage_argv::help` renders here as the braces somebody typed.
/// Together with `SplitSections`, this holds the vocabularies together. The derive keeps its own
/// copy of the ten names — a proc-macro crate cannot depend on the crate its output calls into —
/// so a name missing from that copy refuses one of those structs at compile time, and one missing
/// from `usage_argv::help` renders as the braces somebody typed.
#[derive(Cli)]
#[usage(
bin = "every-section",
Expand Down Expand Up @@ -178,7 +178,7 @@ fn every_section_the_vocabulary_holds_can_be_placed() {
// substitution as literal braces rather than fail, so the braces are what to look for.
assert!(!page.contains("{{"), "a section went unfilled:\n{page}");

// Each of the six put its own content where the template asked for it.
// Each combined section put its own content where the template asked for it.
let at = |needle: &str| {
page.find(needle)
.unwrap_or_else(|| panic!("{needle}:\n{page}"))
Expand All @@ -205,17 +205,73 @@ fn every_section_the_vocabulary_holds_can_be_placed() {
}

#[test]
fn the_two_rust_vocabularies_are_the_same_six_words() {
fn the_two_rust_vocabularies_are_the_same_words() {
// One list, three Rust copies: this one, usage-argv's, and the derive's. The derive's cannot
// be reached from here, which is what `every_section_the_vocabulary_holds_can_be_placed` is
// for; these two can be compared outright.
// be reached from here, which is what the two derived fixtures above are for; these two can
// be compared outright.
assert_eq!(
usage::help_template::SECTIONS,
usage_argv::help::SECTIONS,
"the reference and the compiled renderer name different sections"
);
}

/// A ported CLI that needs headed flags before its positional arguments, with the ordinary
/// flags after them. The combined `args` and `flags` sections cannot express that ordering.
#[derive(Cli)]
#[usage(
bin = "ported",
help_template = "{{grouped_flags}}\n\n{{ungrouped_args}}\n\n{{ungrouped_flags}}\n\n{{grouped_args}}"
)]
struct SplitSections {
/// Read settings from this file
#[usage(long, help_heading = "Configuration")]
config: Option<String>,

/// Print more detail
#[usage(long)]
verbose: bool,

/// File to process
#[usage(arg, name = "file")]
file: String,

/// Processing mode
#[usage(arg, name = "mode", help_heading = "Modes")]
mode: Option<String>,
}

#[test]
fn grouped_and_ungrouped_sections_can_be_interleaved() {
let spec = SplitSections::spec();
let page = usage_argv::help::short_help(spec, &["ported"], &[spec.root]);

let lib: LibSpec = spec
.to_kdl()
.parse()
.expect("the derive emits a valid spec");
assert_eq!(page, usage::docs::cli::render_help(&lib, &lib.cmd, false));

let at = |needle: &str| {
page.find(needle)
.unwrap_or_else(|| panic!("{needle}:\n{page}"))
};
let order = [
at("Configuration:"),
at("Arguments:"),
at("Flags:"),
at("Modes:"),
];
assert!(
order.windows(2).all(|pair| pair[0] < pair[1]),
"the split sections are not in the template's order:\n{page}"
);
assert_eq!(page.matches("Configuration:").count(), 1, "{page}");
assert_eq!(page.matches("Arguments:").count(), 1, "{page}");
assert_eq!(page.matches("Flags:").count(), 1, "{page}");
assert_eq!(page.matches("Modes:").count(), 1, "{page}");
}

#[test]
fn a_template_naming_no_section_is_refused_where_the_spec_is_read() {
// The vocabulary is closed, and a page cannot be assembled from a section nothing renders.
Expand Down Expand Up @@ -272,4 +328,17 @@ fn the_cli_a_template_describes_still_parses() {
assert!(every.force);
assert_eq!(every.file.as_deref(), Some("notes.txt"));
assert!(every.command.is_none());

let split = SplitSections::parse_from(&[
OsStr::new("--config"),
OsStr::new("settings.toml"),
OsStr::new("--verbose"),
OsStr::new("notes.txt"),
OsStr::new("fast"),
])
.expect("split-section fixture parses like the page says");
assert_eq!(split.config.as_deref(), Some("settings.toml"));
assert!(split.verbose);
assert_eq!(split.file, "notes.txt");
assert_eq!(split.mode.as_deref(), Some("fast"));
}
15 changes: 13 additions & 2 deletions derive/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3932,11 +3932,22 @@ pub(crate) fn string_value(meta: &Meta) -> syn::Result<String> {
///
/// The same closed vocabulary `usage_argv::help::SECTIONS` renders and the KDL parser accepts,
/// repeated rather than imported: a proc-macro crate cannot depend on the crate its output calls
/// into, and the list is six words. What keeps the copies together is
/// into. What keeps the copies together is
/// `conformance/tests/help_template.rs`, which renders a page from a template naming every one of
/// them — a section this copy had lost would refuse that fixture at compile time, and one it had
/// gained would render as literal braces.
const HELP_SECTIONS: [&str; 6] = ["about", "usage", "commands", "args", "flags", "after_help"];
const HELP_SECTIONS: [&str; 10] = [
"about",
"usage",
"commands",
"args",
"flags",
"grouped_args",
"ungrouped_args",
"grouped_flags",
"ungrouped_flags",
"after_help",
];

/// Whether every `{{…}}` in a template names a section.
fn check_help_template(template: &str) -> Result<(), String> {
Expand Down
35 changes: 25 additions & 10 deletions docs/rust/help.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,16 +137,22 @@ help_template "{{about}}\n\n{{usage}}\n\n{{flags}}\n\n{{args}}\n\n{{commands}}"
```

A template is placed on the root and lays out every page in the CLI, subcommands included. It
holds six named sections and nothing else:

| section | what it covers |
| ---------------- | ------------------------------------------------------------------------------------ |
| `{{about}}` | `before_help`, the `{bin} {version}` banner, and the description |
| `{{usage}}` | the `Usage:` synopsis, however many lines it takes |
| `{{commands}}` | the subcommand list — or, under `flatten_help`, the subcommands' own bodies |
| `{{args}}` | every argument group, each under its heading |
| `{{flags}}` | this command's flag groups, then the global flags it inherits |
| `{{after_help}}` | the Examples section, `after_help`, and the author and licence a long page ends with |
holds ten named sections and nothing else. `args` and `flags` retain the complete sections used by
existing templates; their grouped and ungrouped variants expose the same content in smaller pieces
when a port needs to interleave them:

| section | what it covers |
| --------------------- | ------------------------------------------------------------------------------------ |
| `{{about}}` | `before_help`, the `{bin} {version}` banner, and the description |
| `{{usage}}` | the `Usage:` synopsis, however many lines it takes |
| `{{commands}}` | the subcommand list — or, under `flatten_help`, the subcommands' own bodies |
| `{{args}}` | every argument group, each under its heading |
| `{{flags}}` | this command's flag groups, then the global flags it inherits |
| `{{grouped_args}}` | arguments with a declared help heading |
| `{{ungrouped_args}}` | arguments under the default `Arguments` heading |
| `{{grouped_flags}}` | flags with a declared help heading |
| `{{ungrouped_flags}}` | flags under `Flags`, plus inherited global flags |
| `{{after_help}}` | the Examples section, `after_help`, and the author and licence a long page ends with |

Reorder them, leave them out, or put text of your own around them. Two rules make that
predictable:
Expand All @@ -164,6 +170,15 @@ predictable:
The template applies to the terminal help page. Markdown, manpages, and JSON keep their own
structure.

For example, a CLI migrating from a renderer that put named option groups before positionals and
ordinary options after them can preserve that order without recreating help text itself:

```rust
#[usage(
help_template = "{{grouped_flags}}\n\n{{ungrouped_args}}\n\n{{ungrouped_flags}}"
)]
```

#### Coming from clap

clap's tags are single-braced and finer-grained, so a clap template has to be rewritten rather
Expand Down
10 changes: 7 additions & 3 deletions docs/spec/reference/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,20 @@ otherwise generated from its flags, arguments, and subcommands.

## Laying out help

`help_template` controls the order of the six pre-rendered sections in every
`help_template` controls the order of the ten pre-rendered sections in every
help page:

```kdl
help_template "{{about}}\n\n{{usage}}\n\n{{flags}}\n\n{{args}}\n\n{{commands}}\n\n{{after_help}}"
```

The supported placeholders are `about`, `usage`, `commands`, `args`, `flags`,
and `after_help`. A placeholder outside that closed vocabulary is rejected
when the spec is parsed. A template may omit a section or add literal text.
`grouped_args`, `ungrouped_args`, `grouped_flags`, `ungrouped_flags`, and
`after_help`. `args` and `flags` contain their complete sections for backwards
compatibility; the grouped and ungrouped forms expose the same content in smaller
pieces when a port needs to interleave it. Ungrouped flags include inherited global
flags. A placeholder outside that closed vocabulary is rejected when the spec is
parsed. A template may omit a section or add literal text.

## Root command policy

Expand Down
Loading