diff --git a/argv/src/complete.rs b/argv/src/complete.rs index ddc505c4c..3a17ee7a3 100644 --- a/argv/src/complete.rs +++ b/argv/src/complete.rs @@ -198,7 +198,8 @@ pub fn for_name<'a>( // Tree order last, and only as a fallback: two sibling commands may take a `tool` and mean // different things by it, and the one the line reached is the one being asked about. let reached = walk(spec.root.cmd, ctx.command_words_start()); - let reached_meta = crate::help::find(spec, reached.cmd).map(|(_, meta)| meta); + let reached_meta = + crate::help::find(spec, reached.cmd).and_then(|(_, chain)| chain.last().copied()); let owner = if on(spec.root, name).is_some() { spec.root } else if let Some(meta) = reached_meta.filter(|meta| on(meta, name).is_some()) { @@ -407,7 +408,7 @@ fn files_for(name: &str) -> Option { /// for a word nothing is known about, and the `run=` completions a spec can declare. pub fn complete<'a>(spec: &'a Spec<'a>, split: &Split) -> Completions<'a> { let position = walk(spec.root.cmd, split.argv()); - let meta = crate::help::find(spec, position.cmd).map(|(_, meta)| meta); + let meta = crate::help::find(spec, position.cmd).and_then(|(_, chain)| chain.last().copied()); let token = split.prefix.as_str(); let candidates = candidates(spec, split); @@ -476,7 +477,7 @@ pub fn complete<'a>(spec: &'a Spec<'a>, split: &Split) -> Completions<'a> { /// Just the candidates this CLI knows about, without the question of paths. pub fn candidates<'a>(spec: &'a Spec<'a>, split: &Split) -> Vec> { let position = walk(spec.root.cmd, split.argv()); - let meta = crate::help::find(spec, position.cmd).map(|(_, meta)| meta); + let meta = crate::help::find(spec, position.cmd).and_then(|(_, chain)| chain.last().copied()); let token = split.prefix.as_str(); let mut out = if position.flags_possible && token == "-" { diff --git a/argv/src/help.rs b/argv/src/help.rs index 3559926a4..377bcfffd 100644 --- a/argv/src/help.rs +++ b/argv/src/help.rs @@ -112,18 +112,97 @@ pub fn usage_line(path: &[&str], meta: &CommandMeta<'_>) -> String { /// How one flag appears in the usage line: `-f --force`, plus its value if it takes one. fn flag_usage(meta: &FlagMeta<'_>) -> String { + flag_usage_masked(meta, &Shown::all(meta)) +} + +/// The spellings of one flag that a page should offer. +/// +/// Not "hide the long" and "hide the short": a flag may answer to several of each, and a +/// descendant claiming `--jobs` leaves an inherited `--workers` working. What is shown is the +/// first of each kind that nothing nearer has taken. +struct Shown<'a> { + long: Option<&'a str>, + short: Option, + /// Whether the negation is still this flag's to offer. `--no-color` is a spelling like any + /// other and something nearer can claim it. + negate: bool, +} + +impl<'a> Shown<'a> { + /// Everything the flag has, for a command's own flags — nothing above them to claim any. + fn all(meta: &'a FlagMeta<'a>) -> Self { + Shown { + long: meta.flag.longs.first().copied(), + short: meta.flag.shorts.first().copied(), + negate: meta.flag.negate.is_some(), + } + } + + /// What is left of a flag once everything nearer has had its pick. + /// + /// `taken` is the longs and shorts already claimed; `taken_negations` the negations; + /// `every_form` every long and short in scope at any distance, because the parser resolves + /// a word against all of those before it looks at a negation at all. + fn surviving( + meta: &'a FlagMeta<'a>, + taken: &[String], + taken_negations: &[String], + every_form: &[String], + ) -> Self { + let mine: Vec = meta + .flag + .longs + .iter() + .map(|l| format!("--{l}")) + .chain(meta.flag.shorts.iter().map(|s| format!("-{}", *s as char))) + .collect(); + Shown { + long: meta + .flag + .longs + .iter() + .copied() + .find(|l| !taken.contains(&format!("--{l}"))), + short: meta + .flag + .shorts + .iter() + .copied() + .find(|s| !taken.contains(&format!("-{}", *s as char))), + negate: meta.flag.negate.is_some_and(|n| { + let spelling = format!("--{n}"); + // A long anywhere in scope wins over this, this flag's own excepted. + !taken_negations.contains(&spelling) + && (!every_form.contains(&spelling) || mine.contains(&spelling)) + }), + } + } + + fn nothing(&self) -> bool { + self.long.is_none() && self.short.is_none() && !self.negate + } +} + +/// The same, with a spelling left out because something nearer claimed it. +/// +/// A descendant may take one of an ancestor's two spellings — its own `-v` beside the root's +/// `-v, --verbose` — and the parser still accepts the other, so the page has to offer the other +/// and not the one that now means something else. +fn flag_usage_masked(meta: &FlagMeta<'_>, show: &Shown) -> String { let flag = meta.flag; let mut out = String::new(); // The declared name, when it is not the one the forms would imply. A flag called // `verbose` reachable only as `-v` has to say so, or help would name something the // spec does not. - let implied = flag - .longs - .first() - .copied() - .or_else(|| flag.shorts.first().map(|_| "")); - let implied_matches = match (implied, flag.shorts.first()) { + // + // Judged on the forms this page is *showing*. mise's root has a global `-E --env`; a + // descendant that claims `--env` leaves `-E` inherited, and `-E… ` alone gives a + // reader nothing to connect it to the `--env` they saw elsewhere. `env: -E… ` does. + let long = show.long; + let short = show.short.as_ref(); + let implied = long.or_else(|| short.map(|_| "")); + let implied_matches = match (implied, short) { (Some(long), _) if !long.is_empty() => long == flag.name, (Some(_), Some(short)) => { let mut buf = [0u8; 4]; @@ -134,13 +213,13 @@ fn flag_usage(meta: &FlagMeta<'_>) -> String { if !implied_matches { let _ = write!(out, "{}:", flag.name); } - if let Some(short) = flag.shorts.first() { + if let Some(short) = short { if !out.is_empty() { out.push(' '); } let _ = write!(out, "-{}", *short as char); } - if let Some(long) = flag.longs.first() { + if let Some(long) = long { if !out.is_empty() { out.push(' '); } @@ -219,7 +298,9 @@ pub(crate) fn arg_usage(meta: &ArgMeta<'_>) -> String { /// and in which help text they prefer, not in what they cover. /// /// `path` is the command as invoked, as for [`usage_line`]. -pub fn short_help(spec: &Spec<'_>, path: &[&str], meta: &CommandMeta<'_>) -> String { +pub fn short_help(spec: &Spec<'_>, path: &[&str], chain: &[&CommandMeta<'_>]) -> String { + let meta = *chain.last().expect("a page is always about some command"); + let (own, inherited) = own_and_global(chain); let mut out = String::new(); // Text the command puts above everything else, and below it. The short form has only the @@ -281,29 +362,41 @@ pub fn short_help(spec: &Spec<'_>, path: &[&str], meta: &CommandMeta<'_>) -> Str annotations(out, a.choices, a.env, a.default); }, ); - let flags: Vec<&FlagMeta<'_>> = meta.flags.iter().filter(|f| !f.hide).collect(); - let flag_col = flags + // One column over *both* lists, so the two sections read as one table with a rule through + // it rather than two tables that happen to be adjacent. + let flag_col = own .iter() .map(|f| column_usage(f).chars().count()) + .chain(inherited.iter().map(|(_, u)| u.chars().count())) .max() .unwrap_or(0); + let short_entry = |out: &mut String, f: &FlagMeta<'_>, usage: String| { + match f.help.filter(|h| !h.trim().is_empty()) { + Some(help) => { + let _ = write!(out, " {usage: { + let _ = write!(out, " {usage}"); + } + } + annotations(out, f.choices, f.env, &[]); + }; groups_section( &mut out, "Flags", - flags.iter().copied(), + own.iter().copied(), |f| f.help_heading, - |out, f| { - let usage = column_usage(f); - match f.help.filter(|h| !h.trim().is_empty()) { - Some(help) => { - let _ = write!(out, " {usage: { - let _ = write!(out, " {usage}"); - } - } - annotations(out, f.choices, f.env, &[]); - }, + |out, f| short_entry(out, f, column_usage(f)), + ); + // 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( + &mut out, + "Global flags", + inherited.iter(), + |_| None, + |out, (f, usage)| short_entry(out, f, usage.clone()), ); examples_section(&mut out, spec, meta); if let Some(after) = meta.after_help.or(spec.root.after_help) { @@ -429,9 +522,9 @@ pub(crate) fn flag_spelling(meta: &FlagMeta<'_>) -> String { .unwrap_or_else(|| meta.flag.name.to_string()) } -fn display_usage(meta: &FlagMeta<'_>) -> String { - let usage = flag_usage(meta); - match meta.flag.negate { +fn display_usage_masked(meta: &FlagMeta<'_>, show: &Shown) -> String { + let usage = flag_usage_masked(meta, show); + match meta.flag.negate.filter(|_| show.negate) { Some(negate) => format!("{usage} / --{negate}"), None => usage, } @@ -459,8 +552,12 @@ const SHORT_COL: usize = 4; /// what clap does. And a flag with neither — usage can name one the forms do not imply, /// `verbose: -v`, which clap has no equivalent for — takes the same path as short-only. fn column_usage(meta: &FlagMeta<'_>) -> String { - let rest = display_usage(meta); - let Some(long) = meta.flag.longs.first() else { + column_usage_masked(meta, &Shown::all(meta)) +} + +fn column_usage_masked(meta: &FlagMeta<'_>, show: &Shown) -> String { + let rest = display_usage_masked(meta, show); + let Some(long) = show.long else { return rest; }; // Only when the text actually begins with the long form. The `name:` prefix case does not, @@ -533,7 +630,9 @@ fn terminal_width() -> usize { /// An entry whose help contains a line break is laid out as a block instead, its text indented /// under the usage rather than beside it, because there is no column that keeps a line the /// author already broke readable. -pub fn long_help(spec: &Spec<'_>, path: &[&str], meta: &CommandMeta<'_>) -> String { +pub fn long_help(spec: &Spec<'_>, path: &[&str], chain: &[&CommandMeta<'_>]) -> String { + let meta = *chain.last().expect("a page is always about some command"); + let (own, inherited) = own_and_global(chain); let width = terminal_width(); let mut out = String::new(); @@ -593,16 +692,18 @@ pub fn long_help(spec: &Spec<'_>, path: &[&str], meta: &CommandMeta<'_>) -> Stri }, ); - let flags: Vec<&FlagMeta<'_>> = meta.flags.iter().filter(|f| !f.hide).collect(); - let flag_col = flags + // One column over *both* lists, so the two sections read as one table with a rule through + // it rather than two tables that happen to be adjacent. + let flag_col = own .iter() .map(|f| column_usage(f).chars().count()) + .chain(inherited.iter().map(|(_, u)| u.chars().count())) .max() .unwrap_or(0); groups_section( &mut out, "Flags", - flags.iter().copied(), + own.iter().copied(), |f| f.help_heading, |out, f| { let text = f.long_help.or(f.help); @@ -610,6 +711,21 @@ pub fn long_help(spec: &Spec<'_>, path: &[&str], meta: &CommandMeta<'_>) -> Stri long_annotations(out, f.choices, f.env, &[]); }, ); + // 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. + // 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( + &mut out, + "Global flags", + inherited.iter(), + |_| None, + |out, (f, usage)| { + let text = f.long_help.or(f.help); + entry(out, usage, text, flag_col, width); + long_annotations(out, f.choices, f.env, &[]); + }, + ); let examples = page_examples(spec, meta); if !examples.is_empty() { @@ -796,37 +912,127 @@ fn long_commands_section(out: &mut String, path: &[&str], meta: &CommandMeta<'_> pub fn find<'a>( spec: &'a Spec<'a>, cmd: &Command<'_>, -) -> Option<(Vec<&'a str>, &'a CommandMeta<'a>)> { +) -> Option<(Vec<&'a str>, Vec<&'a CommandMeta<'a>>)> { fn walk<'a>( path: &mut Vec<&'a str>, + chain: &mut Vec<&'a CommandMeta<'a>>, meta: &'a CommandMeta<'a>, cmd: &Command<'_>, - ) -> Option<&'a CommandMeta<'a>> { + ) -> bool { + chain.push(meta); if core::ptr::eq(meta.cmd, cmd) { - return Some(meta); + return true; } for sub in meta.subcommands { path.push(sub.cmd.name); - if let Some(found) = walk(path, sub, cmd) { - return Some(found); + if walk(path, chain, sub, cmd) { + return true; } path.pop(); } - None + chain.pop(); + false } let mut path = vec![spec.bin.unwrap_or(spec.name)]; - walk(&mut path, spec.root, cmd).map(|meta| (path, meta)) + let mut chain = Vec::new(); + walk(&mut path, &mut chain, spec.root, cmd).then_some((path, chain)) +} + +/// Every flag a page should list, split into the command's own and the ones it inherits. +/// +/// The rule the parser follows on the way down, and the same one the diagnostics suggest +/// from: a command's own flags, and from each ancestor only what it declared `global`. +/// +/// Inherited flags were listed nowhere. `communique generate` accepts `--config`, `--verbose` +/// and `--quiet` from its root, and its page mentioned none of them — a flag a user can type +/// and cannot discover, which is the worst way for help to be wrong. +fn own_and_global<'a>( + chain: &[&'a CommandMeta<'a>], +) -> (Vec<&'a FlagMeta<'a>>, Vec<(&'a FlagMeta<'a>, String)>) { + let Some((here, ancestors)) = chain.split_last() else { + return (Vec::new(), Vec::new()); + }; + let own: Vec<&FlagMeta<'_>> = here.flags.iter().filter(|f| !f.hide).collect(); + + // Which spellings are already spoken for at this command, and by whom. + // + // The parser's rule, exactly: `in_scope` chains a command's own flags before its + // ancestors' — nearest first — and takes the first match. So a page offers a spelling only + // where the flag it is describing is the one that would bind it. + // + // Three things this counts that an earlier version did not. **Hidden flags**, which `hide` + // keeps off the page while the parser still binds them — on the command *and* on an + // ancestor, or a farther global gets advertised while a nearer hidden one answers. + // **Negations**, which are spellings like any other and can be claimed. And **every** long + // and short a flag answers to rather than only its first: a descendant taking `--jobs` + // leaves an inherited `--workers` working, and it should still be findable. + // Two sets, because the parser has two passes. `long_flag` asks `find_long` over the whole + // scope before it asks `find_negation`, so *any* long beats *any* negation — a nearer + // command's `--cache` negation does not take the spelling from a farther command's `--cache` + // long, and reading them as one set said it did. + fn forms<'f>(f: &'f FlagMeta<'_>) -> impl Iterator + 'f { + f.flag + .longs + .iter() + .map(|l| format!("--{l}")) + .chain(f.flag.shorts.iter().map(|s| format!("-{}", *s as char))) + } + fn negation(f: &FlagMeta<'_>) -> Option { + f.flag.negate.map(|n| format!("--{n}")) + } + + // Every long and short anything in scope answers to, near or far: one of these always + // beats a negation, so a negation survives only where none of them is the same word. + let every_form: Vec = here + .flags + .iter() + .chain( + ancestors + .iter() + .flat_map(|m| m.flags.iter()) + .filter(|f| f.flag.global), + ) + .flat_map(forms) + .collect(); + + let mut taken: Vec = here.flags.iter().flat_map(forms).collect(); + let mut taken_negations: Vec = here.flags.iter().filter_map(negation).collect(); + let mut keep: Vec<(*const FlagMeta<'_>, Shown<'_>)> = Vec::new(); + for meta in ancestors.iter().rev() { + for f in meta.flags.iter().filter(|f| f.flag.global) { + let show = Shown::surviving(f, &taken, &taken_negations, &every_form); + // Reserved whether or not it is shown: a hidden one still binds, and so does one + // whose every spelling something nearer already took. + taken.extend(forms(f)); + taken_negations.extend(negation(f)); + if f.hide || show.nothing() { + continue; + } + keep.push((f as *const _, show)); + } + } + let inherited: Vec<(&FlagMeta<'_>, String)> = ancestors + .iter() + .flat_map(|meta| meta.flags.iter()) + .filter_map(|f| { + keep.iter() + .find(|(p, _)| core::ptr::eq(*p, f as *const _)) + .map(|(_, show)| (f, column_usage_masked(f, show))) + }) + .collect(); + + (own, inherited) } /// The page a help request asks for, ready to print. /// /// The two forms differ as clap has them: `-h` is the short one and `--help` the long one. pub fn render(spec: &Spec<'_>, cmd: &Command<'_>, long: bool) -> Option { - let (path, meta) = find(spec, cmd)?; + let (path, chain) = find(spec, cmd)?; Some(if long { - long_help(spec, &path, meta) + long_help(spec, &path, &chain) } else { - short_help(spec, &path, meta) + short_help(spec, &path, &chain) }) } diff --git a/benches/gate/tests/help.rs b/benches/gate/tests/help.rs index f58b03b3e..d76ac8962 100644 --- a/benches/gate/tests/help.rs +++ b/benches/gate/tests/help.rs @@ -23,15 +23,20 @@ fn mise_spec() -> LibSpec { /// Every command in the tree, as (path, metadata) — the path being what a user types. fn walk<'a>( path: Vec<&'a str>, + chain: Vec<&'a CommandMeta<'a>>, meta: &'a CommandMeta<'a>, - out: &mut Vec<(Vec<&'a str>, &'a CommandMeta<'a>)>, + out: &mut Vec<(Vec<&'a str>, Vec<&'a CommandMeta<'a>>)>, ) { - out.push((path.clone(), meta)); + // The chain and not just the command: a page lists what it inherits, which only the + // ancestors know. + let mut chain = chain; + chain.push(meta); + out.push((path.clone(), chain.clone())); for sub in meta.subcommands { // Aliases live in the parse table beside the canonical name; help names the command. let mut child = path.clone(); child.push(sub.cmd.name); - walk(child, sub, out); + walk(child, chain.clone(), sub, out); } } @@ -50,7 +55,7 @@ fn every_usage_line_matches_the_reference() { let root = shadow_mise::Cli::spec().root; let mut commands = Vec::new(); - walk(vec!["mise"], root, &mut commands); + walk(vec!["mise"], Vec::new(), root, &mut commands); assert!( commands.len() > 200, "the shadow should cover mise's whole tree, found {}", @@ -58,7 +63,9 @@ fn every_usage_line_matches_the_reference() { ); let mut differences = Vec::new(); - for (path, meta) in &commands { + for (path, chain) in &commands { + // The usage line is about the command itself; the chain is for what it inherits. + let meta = chain.last().expect("a command"); let ours = usage_line(path, meta); // usage-lib's `usage()` omits the binary and starts at the command path, so the // comparison puts it back — the same string the template writes after `Usage: `. @@ -129,7 +136,7 @@ fn every_short_help_matches_the_reference() { let root = shadow_mise::Cli::spec(); let mut commands = Vec::new(); - walk(vec!["mise"], root.root, &mut commands); + walk(vec!["mise"], Vec::new(), root.root, &mut commands); let mut differences = Vec::new(); for (path, meta) in &commands { @@ -173,7 +180,7 @@ fn every_long_help_matches_the_reference() { let root = shadow_mise::Cli::spec(); let mut commands = Vec::new(); - walk(vec!["mise"], root.root, &mut commands); + walk(vec!["mise"], Vec::new(), root.root, &mut commands); let mut differences = Vec::new(); for (path, meta) in &commands { @@ -276,12 +283,12 @@ fn the_text_around_a_page_is_rendered_where_the_reference_puts_it() { }; assert_eq!( - short_help(&SPEC, &["ex", "go"], &GO_META), + short_help(&SPEC, &["ex", "go"], &[&GO_META]), usage::docs::cli::render_help(&spec, go, false), "short form" ); assert_eq!( - long_help(&SPEC, &["ex", "go"], &GO_META), + long_help(&SPEC, &["ex", "go"], &[&GO_META]), usage::docs::cli::render_help(&spec, go, true), "long form" ); @@ -330,9 +337,9 @@ fn a_spec_can_surround_every_page_at_once() { for long in [false, true] { let ours = if long { - long_help(&SPEC, &["ex", "go"], &GO_META) + long_help(&SPEC, &["ex", "go"], &[&GO_META]) } else { - short_help(&SPEC, &["ex", "go"], &GO_META) + short_help(&SPEC, &["ex", "go"], &[&GO_META]) }; assert_eq!( ours, @@ -442,9 +449,9 @@ fn a_specs_examples_reach_a_page_that_has_none() { let cmd = spec.cmd.subcommands.get(name).expect("in the spec"); for long in [false, true] { let ours = if long { - long_help(&SPEC, &["ex", name], meta) + long_help(&SPEC, &["ex", name], &[meta]) } else { - short_help(&SPEC, &["ex", name], meta) + short_help(&SPEC, &["ex", name], &[meta]) }; assert_eq!( ours, diff --git a/conformance/tests/global_flags.rs b/conformance/tests/global_flags.rs new file mode 100644 index 000000000..ea1f664ec --- /dev/null +++ b/conformance/tests/global_flags.rs @@ -0,0 +1,430 @@ +//! Flags a command inherits, listed where they can be used. +//! +//! `communique generate` accepts `--config`, `--verbose` and `--quiet` from its root, and its +//! page mentioned none of them — a flag a user can type and cannot discover, which is the worst +//! way for help to be wrong. +//! +//! Under a heading of their own rather than mixed in, which is where this differs from clap on +//! purpose: `--config` belongs to the program and not to `generate`, and a reader should be +//! able to see which is which. + +use usage_argv::help; +use usage_derive::{Args, Cli, Subcommands}; + +/// Read something back +#[derive(Args)] +struct Get { + /// Only this command has one + #[usage(long)] + plain: bool, + /// Declared here as well as on the root, which the parser resolves in this one's favour + #[usage(long)] + raw: bool, +} + +/// Settings, which nest +#[derive(Args)] +struct Config { + /// A global of its own, one level down + #[usage(long, global)] + file: Option, + /// Hidden, global, and nearer than the root's `--trace` — so this is the one that binds + #[usage(long = "trace", global, hide)] + trace: bool, + #[usage(subcommand)] + command: Option, +} + +#[derive(Subcommands)] +enum Inner { + /// Read something back + Get(Box), +} + +/// Claims the first of several spellings the root offers +#[derive(Args)] +struct Narrow { + /// Takes `--jobs`, leaving `--workers` to the root + #[usage(long = "jobs")] + jobs: Option, + /// Takes the root's negation, leaving its positive form + #[usage(long = "no-colour")] + plain: bool, + /// Its *negation* is the root's plain long form, which still counts as claiming it + #[usage(long = "cache", negate = "--no-cache")] + cache: bool, +} + +#[derive(Subcommands)] +enum Command { + /// Settings, which nest + Config(Box), + /// Claims one of the root's two spellings + Claimer(Box), + /// Claims a spelling with a flag nobody can see + Quiet(Box), + /// Claims the first of several spellings the root offers + Narrow(Box), +} + +/// Claims a spelling with a flag nobody can see +#[derive(Args)] +struct Quiet { + /// Hidden, and still binds — which is what makes it shadow + #[usage(long = "raw", hide)] + raw: bool, +} + +/// Claims one of the root's two spellings for itself +#[derive(Args)] +struct Claimer { + /// A `-v` of its own, which is not the root's + #[usage(short = 'v')] + level: Option, +} + +/// A tool with flags at every level +#[derive(Cli)] +#[usage(bin = "ex")] +struct Ex { + /// Say more + #[usage(long, short = 'v', global)] + verbose: bool, + /// Read and write directly + #[usage(long, global)] + raw: bool, + /// A plain long that a descendant claims with its negation + #[usage(long = "no-cache", global)] + no_cache: bool, + /// Shadowed further down by a hidden global, which still binds + #[usage(long, global)] + trace: bool, + /// Two long forms, so claiming one leaves the other + #[usage(long = "jobs", long = "workers", global)] + jobs: Option, + /// A flag with a negation, which is a spelling like any other + #[usage(long = "colour", negate = "--no-colour", global)] + colour: bool, + /// Not global, so it belongs to the root alone + #[usage(long)] + root_only: bool, + #[usage(subcommand)] + command: Option, +} + +/// The listing part of a page: everything from the first `Flags:` heading on. +/// +/// The usage line names flags too — `Usage: ex config get [--plain] [--raw]` — and an assertion +/// about what is *listed* must not count it. +fn listing(page: &str) -> &str { + page.split_once("\nFlags:") + .map(|(_, rest)| rest) + .unwrap_or(page) +} + +fn page_of(names: &[&str], long: bool) -> String { + let mut cmd = Ex::spec().root.cmd; + for name in names { + cmd = cmd + .subcommands + .iter() + .find(|c| c.name == *name) + .unwrap_or_else(|| panic!("no {name}")); + } + help::render(Ex::spec(), cmd, long).expect("a page") +} + +#[test] +fn a_page_lists_what_it_inherits_under_its_own_heading() { + for long in [false, true] { + let page = page_of(&["config", "get"], long); + let (own, global) = page + .split_once("Global flags:") + .unwrap_or_else(|| panic!("long={long}: no global section:\n{page}")); + + // The command's own, above the rule. + assert!(own.contains("--plain"), "long={long}: {page}"); + + // What it inherits, below it — from both ancestors, not just the nearest. + assert!(global.contains("--verbose"), "long={long}: {page}"); + assert!(global.contains("--file"), "long={long}: {page}"); + } +} + +#[test] +fn a_flag_the_command_declares_itself_is_not_listed_twice() { + // The parser looks a command's own flags up before its ancestors' and takes the first + // match, so `ex config get --raw` is *get's* `--raw` and never the root's. Listing both + // would print two descriptions for one spelling, one of which can never apply. + for long in [false, true] { + let page = page_of(&["config", "get"], long); + assert_eq!( + listing(&page).matches("--raw").count(), + 1, + "long={long}: `--raw` should appear once:\n{page}" + ); + assert!( + // A short phrase on purpose: the long page wraps this description, so anything + // longer would be split across lines and fail for the wrong reason. + page.contains("Declared here as well"), + "long={long}: and it should be the command's own: {page}" + ); + } +} + +#[test] +fn a_flag_that_is_not_global_stays_where_it_was_declared() { + // `--root-only` is the root's and is not inherited, so a descendant must not offer it — + // the parser would refuse it there. + for long in [false, true] { + let page = page_of(&["config", "get"], long); + assert!(!page.contains("--root-only"), "long={long}: {page}"); + } +} + +#[test] +fn the_programs_own_page_has_no_global_section() { + // The root's flags are the root's, `global` or not: the heading is about provenance + // relative to *this* page, and there is nowhere above the root to inherit from. + for long in [false, true] { + let page = page_of(&[], long); + assert!(!page.contains("Global flags:"), "long={long}: {page}"); + assert!(page.contains("--verbose"), "long={long}: {page}"); + assert!(page.contains("--root-only"), "long={long}: {page}"); + } +} + +#[test] +fn both_sections_share_one_column() { + // So the page reads as one table with a rule through it rather than two that happen to be + // adjacent. The width also drives where a wrapped description resumes, so it cannot be + // decided per section. + let page = page_of(&["config", "get"], true); + let column = |needle: &str, help: &str| { + let line = listing(&page) + .lines() + .find(|l| l.contains(needle)) + .unwrap_or_else(|| panic!("no line for {needle}:\n{page}")); + line.find(help) + .unwrap_or_else(|| panic!("no help on {line:?}")) + }; + assert_eq!( + column("--plain", "Only this command"), + column("--verbose", "Say more"), + "own and inherited should start in one column:\n{page}" + ); +} + +#[test] +fn the_fields_are_bound() { + use std::ffi::OsStr; + let argv = [ + "--verbose", + "config", + "--file", + "f", + "get", + "--plain", + "--raw", + ] + .map(OsStr::new); + let ex = Ex::parse_from(&argv).expect("should parse"); + assert!(ex.verbose && !ex.raw && !ex.root_only); + let Some(Command::Config(config)) = ex.command else { + panic!("expected config") + }; + assert_eq!(config.file.as_deref(), Some("f")); + assert!(!config.trace, "not given, and it is the nearer of the two"); + let Some(Inner::Get(get)) = config.command else { + panic!("expected get") + }; + assert!( + get.plain && get.raw, + "the command's own `--raw` is the one that binds" + ); +} + +#[test] +fn claiming_one_spelling_leaves_the_other_on_offer() { + // `partial` declares its own `-v`; the root's global is `-v, --verbose`. The parser still + // binds `--verbose` there, so dropping the whole inherited entry made a working name + // undiscoverable. What survives is offered, and what was claimed is not. + for long in [false, true] { + let page = page_of(&["claimer"], long); + let (own, global) = page + .split_once("Global flags:") + .unwrap_or_else(|| panic!("long={long}: {page}")); + assert!(own.contains("-v "), "long={long}: {page}"); + assert!(global.contains("--verbose"), "long={long}: {page}"); + assert!( + !global.contains("-v, --verbose"), + "long={long}: `-v` is the subcommand's here: {page}" + ); + } + + // And the parser agrees, which is the whole point of matching it. + use std::ffi::OsStr; + let argv = ["claimer", "--verbose"].map(OsStr::new); + assert!( + Ex::parse_from(&argv).is_ok(), + "the root's long form still binds" + ); +} + +#[test] +fn a_hidden_flag_still_shadows() { + // `hide` keeps a flag off the page; the parser still binds it. usage-lib counted hidden + // own flags when deciding what an ancestor could still offer and this did not, so the two + // renderers disagreed wherever a hidden local shared a spelling with an inherited global. + // Read the *visible* own flags only, while usage-lib read all of them — so the two + // renderers disagreed wherever a hidden local shared a spelling with an inherited global, + // and the page offered a `--raw` that binds something the reader cannot see. + for long in [false, true] { + let page = page_of(&["quiet"], long); + assert!( + !page.contains("--raw"), + "long={long}: a hidden local claims this spelling: {page}" + ); + // The other inherited globals are unaffected — this is about one spelling. + assert!(page.contains("--verbose"), "long={long}: {page}"); + } +} + +#[test] +fn the_claimer_fields_are_bound() { + use std::ffi::OsStr; + let argv = ["claimer", "-v", "3"].map(OsStr::new); + let ex = Ex::parse_from(&argv).expect("should parse"); + let Some(Command::Claimer(p)) = ex.command else { + panic!("expected claimer") + }; + assert_eq!(p.level.as_deref(), Some("3")); +} + +#[test] +fn the_quiet_fields_are_bound() { + use std::ffi::OsStr; + let argv = ["quiet", "--raw"].map(OsStr::new); + let ex = Ex::parse_from(&argv).expect("should parse"); + let Some(Command::Quiet(q)) = ex.command else { + panic!("expected quiet") + }; + assert!(q.raw, "a hidden flag still binds, which is why it shadows"); +} + +#[test] +fn claiming_one_of_several_longs_leaves_the_rest() { + // The root's global answers to `--jobs` and `--workers`. A descendant taking `--jobs` + // leaves `--workers` bound, and masking by *category* — the whole long form — made it + // undiscoverable. What is offered is the first spelling nothing nearer has taken. + for long in [false, true] { + let page = page_of(&["narrow"], long); + let (_, global) = page + .split_once("Global flags:") + .unwrap_or_else(|| panic!("long={long}: {page}")); + assert!(global.contains("--workers"), "long={long}: {page}"); + assert!( + !global.contains("--jobs"), + "long={long}: `--jobs` is the subcommand's here: {page}" + ); + } + + // And the parser agrees, which is the only reason any of this is right. + use std::ffi::OsStr; + let argv = ["narrow", "--workers", "4"].map(OsStr::new); + assert!( + Ex::parse_from(&argv).is_ok(), + "the other long form still binds" + ); +} + +#[test] +fn a_negation_is_a_spelling_like_any_other() { + // `--no-colour` taken by the subcommand leaves the root's `--colour` working, and the + // entry must not go on advertising a negation that now means something else. Negations + // were not counted at all, so one flag claimed it and another offered it. + for long in [false, true] { + let page = page_of(&["narrow"], long); + let (_, global) = page + .split_once("Global flags:") + .unwrap_or_else(|| panic!("long={long}: {page}")); + assert!(global.contains("--colour"), "long={long}: {page}"); + assert!( + !global.contains("--no-colour"), + "long={long}: the subcommand owns that spelling: {page}" + ); + } +} + +#[test] +fn a_long_beats_a_negation_however_far_away_it_is() { + // `narrow` declares `--cache` with a `--no-cache` negation; the root has a plain global + // long `--no-cache`. Which one binds is not about distance: `long_flag` asks `find_long` + // over the whole scope *before* it asks `find_negation`, so the root's long wins even + // though the negation is nearer. Measured rather than reasoned about: + // + // $ ex narrow --no-cache → the root's `no_cache` is set + // + // So the root's flag is still offered here. Reading the two as one set of claims said the + // negation had taken the spelling, and the page hid a flag that works. + for long in [false, true] { + let page = page_of(&["narrow"], long); + let (_, global) = page + .split_once("Global flags:") + .unwrap_or_else(|| panic!("long={long}: {page}")); + assert!( + global.contains("--no-cache"), + "long={long}: a long beats a negation, so this still binds here: {page}" + ); + } +} + +#[test] +fn the_narrow_fields_are_bound() { + use std::ffi::OsStr; + let argv = ["narrow", "--jobs", "2", "--no-colour"].map(OsStr::new); + let ex = Ex::parse_from(&argv).expect("should parse"); + let Some(Command::Narrow(n)) = ex.command else { + panic!("expected narrow") + }; + assert_eq!(n.jobs.as_deref(), Some("2")); + assert!(n.plain); + + let argv = ["narrow", "--cache"].map(OsStr::new); + let ex = Ex::parse_from(&argv).expect("should parse"); + let Some(Command::Narrow(n)) = ex.command else { + panic!("expected narrow") + }; + assert!(n.cache); +} + +#[test] +fn a_nearer_hidden_global_keeps_a_farther_one_off_the_page() { + // The root declares `--trace`; `config` declares a hidden `--trace` of its own, also + // global. Under `config get` the nearer one binds, so advertising the root's would + // describe an action that typing it does not perform. A hidden flag reserves its spelling + // even though it is never shown — on an ancestor exactly as on the command itself. + for long in [false, true] { + let page = page_of(&["config", "get"], long); + assert!( + !page.contains("--trace"), + "long={long}: a nearer hidden global owns that spelling: {page}" + ); + assert!(page.contains("--verbose"), "long={long}: {page}"); + } +} + +#[test] +fn the_roots_own_multi_spelling_flags_are_bound() { + // Keeps every declared field read, and checks the spellings the tests above rely on. + use std::ffi::OsStr; + let argv = ["--jobs", "8", "--colour", "--trace"].map(OsStr::new); + let ex = Ex::parse_from(&argv).expect("should parse"); + assert_eq!(ex.jobs.as_deref(), Some("8")); + assert!(ex.colour && ex.trace && !ex.no_cache); + + let argv = ["--workers", "8", "--no-colour"].map(OsStr::new); + let ex = Ex::parse_from(&argv).expect("the other spellings bind the same fields"); + assert_eq!(ex.jobs.as_deref(), Some("8")); + assert!(!ex.colour); +} diff --git a/conformance/tests/help_request.rs b/conformance/tests/help_request.rs index 779156bd6..8da0d2fb1 100644 --- a/conformance/tests/help_request.rs +++ b/conformance/tests/help_request.rs @@ -299,7 +299,7 @@ fn the_page_advertises_exactly_where_the_word_works() { // word has to work wherever that line appears and nowhere else — a page promising a // command that does nothing, or a command no page mentions, are the same defect twice. let spec = Deep::spec(); - let root_page = usage_argv::help::short_help(spec, &["deep"], spec.root); + let root_page = usage_argv::help::short_help(spec, &["deep"], &[spec.root]); assert!( root_page.contains("\n help Print this message"), "{root_page}" @@ -307,7 +307,10 @@ fn the_page_advertises_exactly_where_the_word_works() { let config = spec.root.subcommands[0]; let set = config.subcommands[0]; - let leaf_page = usage_argv::help::short_help(spec, &["deep", "config", "set"], set); + // The whole chain, `config` included. A gap in it is a gap in what the page can see: the + // globals `config` declares would go unlisted, and an ancestry regression would pass. + let leaf_page = + usage_argv::help::short_help(spec, &["deep", "config", "set"], &[spec.root, config, set]); assert!( !leaf_page.contains("help Print this message"), "a leaf promises nothing: {leaf_page}" diff --git a/conformance/tests/metadata.rs b/conformance/tests/metadata.rs index 21ee706d5..80258ef36 100644 --- a/conformance/tests/metadata.rs +++ b/conformance/tests/metadata.rs @@ -393,7 +393,7 @@ fn the_roots_surrounding_text_reaches_every_page() { assert_eq!(spec.root.after_help, Some("And this after.")); let go = spec.root.subcommands[0]; - let page = usage_argv::help::short_help(spec, &["surrounded", "go"], go); + let page = usage_argv::help::short_help(spec, &["surrounded", "go"], &[spec.root, go]); assert!(page.starts_with("Read this first.\n"), "{page}"); assert!(page.trim_end().ends_with("And this after."), "{page}"); diff --git a/lib/src/docs/cli/mod.rs b/lib/src/docs/cli/mod.rs index 05e4417de..891b800e3 100644 --- a/lib/src/docs/cli/mod.rs +++ b/lib/src/docs/cli/mod.rs @@ -5,16 +5,45 @@ use tera::Tera; pub fn render_help(spec: &Spec, cmd: &SpecCommand, long: bool) -> String { // Convert to docs models to get layout calculations let docs_spec = crate::docs::models::Spec::from(spec.clone()); - let docs_cmd = crate::docs::models::SpecCommand::from(&without_hidden(cmd)); + let mut docs_cmd = crate::docs::models::SpecCommand::from(&without_hidden(cmd)); let mut ctx = tera::Context::new(); ctx.insert("spec", &docs_spec); - ctx.insert("cmd", &docs_cmd); ctx.insert("long", &long); // Which page this is. The banner and the program's own description belong to the // program's page; a subcommand's page describes the subcommand, which is the question // that was asked. `full_cmd` is the path a user would type, so the root's is empty. ctx.insert("root", &docs_cmd.full_cmd.is_empty()); + // Everything this command inherits: from each ancestor, only what it declared `global` — + // the rule the parser follows on the way down. `full_cmd` is the typed path, so walking it + // from the root gives the exact ancestry with none of the ambiguity a search would have. + // + // Listed nowhere before this: `communique generate` accepts `--config` from its root and + // its page mentioned none of it — a flag a user can type and cannot discover. + let mut inherited = inherited_flags(spec, cmd, &docs_cmd.full_cmd); + + // One column over both lists, so the two sections read as one table with a rule through it + // rather than two that happen to be adjacent. The width feeds the wrapping as well as the + // padding — a continuation line is indented to sit under the description — so both lists + // are laid out again once the width is known. + let width = crate::docs::layout::get_terminal_width(); + let col = crate::docs::layout::max_usage_width( + docs_cmd + .flag_groups + .iter() + .flat_map(|g| g.items.iter()) + .chain(inherited.iter()) + .map(|f| f.display_usage.as_str()), + ); + for group in &mut docs_cmd.flag_groups { + lay_out(&mut group.items, width, col); + } + lay_out(&mut inherited, width, col); + + // Inserted after the layout, not before: the template reads the widths, and a `cmd` put + // into the context first would carry the ones computed before the two lists were joined. + ctx.insert("cmd", &docs_cmd); + ctx.insert("global_flags", &inherited); let template = if long { "spec_template_long.tera" } else { @@ -23,6 +52,141 @@ pub fn render_help(spec: &Spec, cmd: &SpecCommand, long: bool) -> String { TERA.render(template, &ctx).unwrap().trim().to_string() + "\n" } +/// Fit a list of flags to a column: how wide their names are, and where their help wraps. +/// +/// The same pass `SpecCommand::from` makes, run again once the width is known over *both* the +/// command's own flags and the ones it inherits. The width is not only padding — a wrapped +/// description is indented to sit under itself — so it cannot be decided per section and then +/// shared. +fn lay_out(flags: &mut [crate::docs::models::SpecFlag], terminal_width: usize, col: usize) { + for flag in flags { + flag.usage_col_width = col; + flag.help_rendered = None; + flag.help_is_multiline = false; + let help = flag.help_long.as_deref().or(flag.help.as_deref()); + if let Some(help) = help { + let (rendered, is_multiline) = + crate::docs::layout::render_help_text(help, terminal_width, col); + // An empty rendering is how this says "use the block layout instead". + if !rendered.is_empty() { + flag.help_rendered = Some(rendered); + flag.help_is_multiline = is_multiline; + } + } + } +} + +/// The flags a command inherits, as its page should list them. +/// +/// Walked down `full_cmd` from the root, which is the path a user would type — so the chain is +/// exact. Each ancestor contributes only what it declared `global`, and hidden ones are left +/// out here as they are everywhere else. +/// +/// The twin of `own_and_global` in `usage-argv`'s `help` module; the two must agree, and the +/// gate over mise's spec is what says they do. +fn inherited_flags( + spec: &Spec, + cmd: &SpecCommand, + full_cmd: &[String], +) -> Vec { + // Every ancestor, root first, which is the order a reader meets them walking down. + let mut ancestors: Vec<&SpecCommand> = Vec::new(); + let mut at = &spec.cmd; + for name in full_cmd.iter().take(full_cmd.len().saturating_sub(1)) { + ancestors.push(at); + let Some(next) = at.subcommands.get(name) else { + return Vec::new(); + }; + at = next; + } + if !full_cmd.is_empty() { + ancestors.push(at); + } + + // Shadowing, which the parser does and the page has to agree with: a command's own flags + // are looked up before its ancestors', so `mise use --raw` is *use's* and never the root's. + // Listing both would print two descriptions for one spelling, one of which can never apply. + // Nearest ancestor first for the decision, then emitted root-first. + // Two sets, because the parser has two passes: it resolves a word against every long and + // short in scope before it looks at a negation at all, so *any* long beats *any* negation + // however far away it is. Reading them as one said a nearer negation had taken a spelling + // that a farther long actually wins. + // + // usage-lib stores a negation *with* its dashes — `negate="--no-colour"` reaches the model + // as `--no-colour` — where usage-argv stores it without. Prefixing here produced + // `----no-colour`, which matched nothing, so negations were counted in name only. + let forms = |f: &crate::SpecFlag| -> Vec { + f.long + .iter() + .map(|l| format!("--{l}")) + .chain(f.short.iter().map(|s| format!("-{s}"))) + .collect() + }; + let every_form: Vec = cmd + .flags + .iter() + .chain( + ancestors + .iter() + .flat_map(|a| a.flags.iter()) + .filter(|f| f.global), + ) + .flat_map(&forms) + .collect(); + + let mut taken: Vec = cmd.flags.iter().flat_map(&forms).collect(); + let mut taken_negations: Vec = + cmd.flags.iter().filter_map(|f| f.negate.clone()).collect(); + let mut keep: Vec<(&crate::SpecFlag, Option, Option, bool)> = Vec::new(); + for ancestor in ancestors.iter().rev() { + for f in ancestor.flags.iter().filter(|f| f.global) { + let long = f + .long + .iter() + .find(|l| !taken.contains(&format!("--{l}"))) + .cloned(); + let short = f + .short + .iter() + .find(|s| !taken.contains(&format!("-{s}"))) + .copied(); + let mine = forms(f); + let negate = f.negate.as_ref().is_some_and(|n| { + !taken_negations.contains(n) && (!every_form.contains(n) || mine.contains(n)) + }); + // Reserved whether or not it is shown: a hidden one still binds, and so does one + // whose every spelling something nearer already took. + taken.extend(forms(f)); + taken_negations.extend(f.negate.clone()); + if f.hide || (long.is_none() && short.is_none() && !negate) { + continue; + } + keep.push((f, long, short, negate)); + } + } + ancestors + .iter() + .flat_map(|a| a.flags.iter()) + .filter_map(|f| { + keep.iter() + .find(|(k, _, _, _)| std::ptr::eq(*k, f)) + .map(|(_, l, s, n)| (f, l.clone(), *s, *n)) + }) + .map(|(f, long, short, negate)| { + // Only the spellings that survived, so the entry offers what the parser would + // actually accept here. + let mut shown = f.clone(); + shown.long = long.into_iter().collect(); + shown.short = short.into_iter().collect(); + if !negate { + shown.negate = None; + } + shown.usage = shown.usage(); + crate::docs::models::SpecFlag::from(&shown) + }) + .collect() +} + /// The command without anything marked `hide`. /// /// Help showed hidden flags, hidden arguments and hidden subcommands — everything `hide` @@ -84,6 +248,45 @@ mod tests { use super::*; use insta::assert_snapshot; + #[test] + fn a_long_beats_a_negation_however_far_away_it_is() { + // A negation is stored *with* its dashes here and without them in usage-argv, so the + // spelling was being looked up as `----no-cache` and matched nothing — negations were + // counted in name only. And which one binds is not about distance: a word is resolved + // against every long in scope before any negation is considered, so the root's plain + // `--no-cache` wins over the subcommand's negation and belongs on its page. + let spec = crate::spec! { r#" +bin "ex" +flag "--no-cache" global=#true help="the root's plain long" +flag "--colour" negate="--no-colour" global=#true help="the root's, with a negation" +cmd narrow help="a command" { + flag "--cache" negate="--no-cache" help="its own, with a negation" + flag "--tint" negate="--no-colour" help="claims the root's negation" +} + "# } + .unwrap(); + + let narrow = spec.cmd.subcommands.get("narrow").expect("narrow"); + for long in [false, true] { + let page = super::render_help(&spec, narrow, long); + assert!( + page.contains("--no-cache"), + "long={long}: a long beats a negation, so this still binds here:\n{page}" + ); + // And a negation *is* claimed by a nearer negation — which is what the dashes + // matter for. `--colour` stays; the negation it used to carry does not. + assert!(page.contains("--colour"), "long={long}:\n{page}"); + let global = page + .split_once("Global flags:") + .expect("a global section") + .1; + assert!( + !global.contains("--colour / --no-colour"), + "long={long}: the nearer negation owns that spelling:\n{page}" + ); + } + } + #[test] fn a_description_of_only_spaces_is_no_description() { // `usage-argv` filters a blank description wherever it reads one, and this template diff --git a/lib/src/docs/cli/templates/spec_template_long.tera b/lib/src/docs/cli/templates/spec_template_long.tera index 5e96a0371..d1adc5dde 100644 --- a/lib/src/docs/cli/templates/spec_template_long.tera +++ b/lib/src/docs/cli/templates/spec_template_long.tera @@ -101,6 +101,35 @@ Commands: {%- endfor %} {%- endfor %} +{%- if global_flags %} + +Global flags: +{%- for flag in global_flags %} +{%- if flag.help_rendered %} + {{ flag.display_usage | ljust(width=flag.usage_col_width) }} {{ flag.help_rendered }} +{%- if flag.help_is_multiline %} + +{%- endif %} +{%- else %} + {{ 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 %} +{%- endif %} +{%- if flag.arg.choices and flag.arg.choices.choices %} + [possible values: {{ flag.arg.choices.choices | join(sep=", ") }}] +{%- endif %} +{%- if flag.arg.choices and flag.arg.choices.env %} + [choices env: {{ flag.arg.choices.env }}] +{%- endif %} +{%- if flag.env %} + [env: {{ flag.env }}] +{%- endif %} +{%- endfor %} +{%- endif %} + {%- if cmd.examples %} Examples: diff --git a/lib/src/docs/cli/templates/spec_template_short.tera b/lib/src/docs/cli/templates/spec_template_short.tera index fde98bf61..8e7c8dc60 100644 --- a/lib/src/docs/cli/templates/spec_template_short.tera +++ b/lib/src/docs/cli/templates/spec_template_short.tera @@ -52,6 +52,17 @@ Commands: {%- endfor %} {%- endfor %} +{%- if global_flags %} + +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.arg.choices and flag.arg.choices.choices %} [{{ flag.arg.choices.choices | join(sep=", ") }}]{%- endif %} +{%- if flag.arg.choices and flag.arg.choices.env %} [choices env: {{ flag.arg.choices.env }}]{%- endif %} +{%- if flag.env %} [env: {{ flag.env }}]{%- endif %} +{%- endfor %} +{%- endif %} + {%- if cmd.examples %} Examples: