Skip to main content

usage/docs/cli/
mod.rs

1use crate::{Spec, SpecCommand};
2use std::sync::LazyLock;
3use tera::Tera;
4
5mod style;
6pub use style::Style;
7
8pub fn render_help(spec: &Spec, cmd: &SpecCommand, long: bool) -> String {
9    render_help_styled(spec, cmd, long, Style::PLAIN)
10}
11
12/// Render a terminal help page with an explicit colour policy.
13pub fn render_help_styled(spec: &Spec, cmd: &SpecCommand, long: bool, style: Style) -> String {
14    // Convert to docs models to get layout calculations
15    let docs_spec = crate::docs::models::Spec::from(spec);
16    let mut docs_cmd = crate::docs::models::SpecCommand::from(&without_hidden(cmd, long));
17
18    let mut ctx = tera::Context::new();
19    ctx.insert("spec", &docs_spec);
20    ctx.insert("long", &long);
21    // Which page this is. The banner and the program's own description belong to the
22    // program's page; a subcommand's page describes the subcommand, which is the question
23    // that was asked. `full_cmd` is the path a user would type, so the root's is empty.
24    ctx.insert("root", &docs_cmd.full_cmd.is_empty());
25    // Keep this out of the recursively serialized docs command. It controls this page only;
26    // carrying it on every descendant makes rendering a whole command tree pay for the same
27    // boolean at every level.
28    ctx.insert("show_help_subcommand", &!cmd.disable_help_subcommand);
29    // Everything this command inherits: from each ancestor, only what it declared `global` —
30    // the rule the parser follows on the way down. `full_cmd` is the typed path, so walking it
31    // from the root gives the exact ancestry with none of the ambiguity a search would have.
32    //
33    // Listed nowhere before this: `communique generate` accepts `--config` from its root and
34    // its page mentioned none of it — a flag a user can type and cannot discover.
35    let (mut inherited, ancestors_taken) = inherited_flags(spec, cmd, &docs_cmd.full_cmd, long);
36
37    // One column over both lists, so the two sections read as one table with a rule through it
38    // rather than two that happen to be adjacent. The width feeds the wrapping as well as the
39    // padding — a continuation line is indented to sit under the description — so both lists
40    // are laid out again once the width is known.
41    // Last in the command's own section, which is where clap has them: they carry no
42    // `help_heading`, so a CLI that groups its flags gets them at the end of the ungrouped
43    // list rather than inside somebody's section.
44    {
45        let supplied = supplied_flags(spec, cmd, &ancestors_taken, docs_cmd.full_cmd.is_empty());
46        if !supplied.is_empty() {
47            match docs_cmd
48                .flag_groups
49                .iter_mut()
50                .find(|g| g.heading.is_none())
51            {
52                Some(group) => group.items.extend(supplied),
53                // Inserted first, not pushed: `group_by_heading` sorts the unheaded group to
54                // the front and argv's `groups_section` emits it there, so a CLI that heads
55                // every one of its flags would otherwise get `Flags:` *after* the headed
56                // sections here and before them there.
57                None => docs_cmd.flag_groups.insert(
58                    0,
59                    crate::docs::models::Group {
60                        heading: None,
61                        help: None,
62                        help_rendered: None,
63                        items: supplied,
64                    },
65                ),
66            }
67        }
68    }
69
70    let width = crate::docs::layout::help_width(cmd.term_width, cmd.max_term_width);
71    ctx.insert("terminal_width", &width);
72    lay_out_group_help(&mut docs_cmd.subcommand_groups, width);
73    lay_out_group_help(&mut docs_cmd.arg_groups, width);
74    lay_out_group_help(&mut docs_cmd.flag_groups, width);
75    let col = crate::docs::layout::usage_column_width(
76        docs_cmd
77            .flag_groups
78            .iter()
79            .flat_map(|g| g.items.iter())
80            .chain(inherited.iter())
81            .map(|f| f.display_usage.as_str()),
82        width,
83    );
84    let flag_column = Column {
85        width,
86        col,
87        long,
88        next_line: cmd.next_line_help,
89    };
90    for group in &mut docs_cmd.flag_groups {
91        lay_out(&mut group.items, flag_column);
92    }
93    lay_out(&mut inherited, flag_column);
94
95    // The arguments get their own column, laid out here for the page being rendered rather than
96    // taken from the model's — which is the long page's, and would put a long description in the
97    // short page's column unwrapped.
98    let arg_col = crate::docs::layout::usage_column_width(
99        docs_cmd.args.iter().map(|a| a.usage.as_str()),
100        width,
101    );
102    for group in &mut docs_cmd.arg_groups {
103        lay_out_args(
104            &mut group.items,
105            Column {
106                width,
107                col: arg_col,
108                long,
109                next_line: cmd.next_line_help,
110            },
111        );
112    }
113
114    // Flattened descendants live on this page, so this page's width governs them. Their own
115    // `term_width` applies when they get a page of their own, not when an ancestor expands them.
116    lay_out_flattened(&mut docs_cmd.flattened_subcommands, width, long);
117
118    // The command list, laid out the way the flag list is: one column for the whole page, the
119    // name in it, and everything else — summary, aliases, deprecation — trailing as text that
120    // wraps under itself. Both pages get the same rows, so `--help` no longer reprints every
121    // child's long help on its parent's page.
122    let help_row = {
123        let show_help_row = !docs_cmd.subcommands.is_empty()
124            && !docs_cmd.flatten_help
125            && !cmd.disable_help_subcommand;
126        let cmd_col = crate::docs::layout::usage_column_width(
127            docs_cmd
128                .subcommand_groups
129                .iter()
130                .flat_map(|g| g.items.iter())
131                .map(|c| c.name.as_str())
132                .chain(show_help_row.then_some(HELP_SUBCOMMAND)),
133            width,
134        );
135        for group in &mut docs_cmd.subcommand_groups {
136            lay_out_commands(&mut group.items, width, cmd_col, cmd.next_line_help);
137        }
138        // Rendered here rather than in the template because it is a row like any other: it
139        // sits in the same column and wraps by the same rule, and neither is something a
140        // template should be working out.
141        show_help_row.then(|| {
142            render_row(
143                HELP_SUBCOMMAND,
144                HELP_SUBCOMMAND_SUMMARY,
145                width,
146                cmd_col,
147                cmd.next_line_help,
148            )
149        })
150    };
151    ctx.insert("help_row", &help_row);
152
153    let arg_has_ungrouped = docs_cmd
154        .arg_groups
155        .iter()
156        .any(|group| group.heading.is_none());
157    let arg_has_grouped = docs_cmd
158        .arg_groups
159        .iter()
160        .any(|group| group.heading.is_some());
161    let flag_has_ungrouped = docs_cmd
162        .flag_groups
163        .iter()
164        .any(|group| group.heading.is_none());
165    let flag_has_grouped = docs_cmd
166        .flag_groups
167        .iter()
168        .any(|group| group.heading.is_some());
169    ctx.insert("arg_has_ungrouped", &arg_has_ungrouped);
170    ctx.insert("arg_has_grouped", &arg_has_grouped);
171    ctx.insert("flag_has_ungrouped", &flag_has_ungrouped);
172    ctx.insert("flag_has_grouped", &flag_has_grouped);
173
174    // Inserted after the layout, not before: the template reads the widths, and a `cmd` put
175    // into the context first would carry the ones computed before the two lists were joined.
176    ctx.insert("cmd", &docs_cmd);
177    ctx.insert("global_flags", &inherited);
178    for (name, mark) in MARKS {
179        ctx.insert(name, &mark);
180    }
181    ctx.insert("mark_grouped_args", &MARK_GROUPED_ARGS);
182    ctx.insert("mark_grouped_flags", &MARK_GROUPED_FLAGS);
183    ctx.insert("mark_global_flags", &MARK_GLOBAL_FLAGS);
184    let template = if long {
185        "spec_template_long.tera"
186    } else {
187        "spec_template_short.tera"
188    };
189    let rendered = TERA.render(template, &ctx).unwrap();
190    let sections = Sections::split(&rendered);
191    let styling = style::Styling::new(&docs_cmd, &inherited, sections.usage);
192    let page = match spec
193        .help_template
194        .as_deref()
195        .filter(|t| crate::help_template::is_set(t))
196    {
197        Some(template) => {
198            crate::help_template::substitute_with_style(template, style.coloured, |name| {
199                sections
200                    .named(name)
201                    .map(|section| styling.apply(&section, style))
202            })
203        }
204        None => styling.apply(&sections.concatenated(), style),
205    };
206    page.trim().to_string() + "\n"
207}
208
209/// Where each section of a rendered page starts, as the templates write it.
210///
211/// The layout lives in the templates, and this is how it stays there: each one emits a marker
212/// at every section boundary, so the boundaries are declared beside the sections rather than
213/// worked out again here. A page with no `help_template` is the marks taken back out, which is
214/// the same string the templates produced before any of this existed — and what the fleet gate
215/// compares byte for byte.
216///
217/// Control characters, because a marker has to be something no help text contains and no
218/// terminal shows if one ever escapes.
219const MARKS: [(&str, &str); 6] = [
220    ("mark_usage", "\u{1}usage\u{1}"),
221    ("mark_commands", "\u{1}commands\u{1}"),
222    ("mark_args", "\u{1}args\u{1}"),
223    ("mark_flags", "\u{1}flags\u{1}"),
224    ("mark_flattened", "\u{1}flattened\u{1}"),
225    ("mark_after_help", "\u{1}after_help\u{1}"),
226];
227const MARK_GROUPED_ARGS: &str = "\u{1}grouped_args\u{1}";
228const MARK_GROUPED_FLAGS: &str = "\u{1}grouped_flags\u{1}";
229const MARK_GLOBAL_FLAGS: &str = "\u{1}global_flags\u{1}";
230
231/// A rendered page cut into the sections a `help_template` may reorder.
232///
233/// The twin of `usage_argv::help`'s `Sections`, down to `flattened` not being a section an
234/// author can name: it is the other half of `commands`, since `flatten_help` replaces a
235/// command list with the subcommands' own bodies, and only one of the two is ever there.
236struct Sections<'a> {
237    about: &'a str,
238    usage: &'a str,
239    commands: &'a str,
240    args: String,
241    flags: String,
242    grouped_args: &'a str,
243    ungrouped_args: &'a str,
244    grouped_flags: &'a str,
245    ungrouped_flags: String,
246    flattened: &'a str,
247    after_help: &'a str,
248}
249
250impl<'a> Sections<'a> {
251    fn split(rendered: &'a str) -> Self {
252        let mut rest = rendered;
253        let mut parts: Vec<&str> = Vec::with_capacity(MARKS.len() + 1);
254        for (_, mark) in MARKS {
255            // A missing marker leaves that section empty rather than swallowing the ones after
256            // it: every one is written at the top level of both templates, so this cannot
257            // happen, and it is not worth a panic in a help renderer if it ever does.
258            match rest.split_once(mark) {
259                Some((before, after)) => {
260                    parts.push(before);
261                    rest = after;
262                }
263                None => parts.push(""),
264            }
265        }
266        parts.push(rest);
267        let (ungrouped_args, grouped_args) = parts[3]
268            .split_once(MARK_GROUPED_ARGS)
269            .unwrap_or((parts[3], ""));
270        let (own_flags, global_flags) = parts[4]
271            .split_once(MARK_GLOBAL_FLAGS)
272            .unwrap_or((parts[4], ""));
273        let (own_ungrouped_flags, grouped_flags) = own_flags
274            .split_once(MARK_GROUPED_FLAGS)
275            .unwrap_or((own_flags, ""));
276        Self {
277            about: parts[0],
278            usage: parts[1],
279            commands: parts[2],
280            args: format!("{ungrouped_args}{grouped_args}"),
281            flags: format!("{own_ungrouped_flags}{grouped_flags}{global_flags}"),
282            grouped_args,
283            ungrouped_args,
284            grouped_flags,
285            ungrouped_flags: format!("{own_ungrouped_flags}{global_flags}"),
286            flattened: parts[5],
287            after_help: parts[6],
288        }
289    }
290
291    /// The default page: every section in the order the templates wrote them.
292    fn concatenated(&self) -> String {
293        [
294            self.about,
295            self.usage,
296            self.commands,
297            self.args.as_str(),
298            self.flags.as_str(),
299            self.flattened,
300            self.after_help,
301        ]
302        .concat()
303    }
304
305    /// One section by name, trimmed, so that a template owns the whitespace between them.
306    fn named(&self, name: &str) -> Option<String> {
307        Some(match name {
308            "about" => self.about.trim().to_string(),
309            "usage" => self.usage.trim().to_string(),
310            "commands" => {
311                let mut out = self.commands.trim().to_string();
312                let flattened = self.flattened.trim();
313                if !flattened.is_empty() {
314                    if !out.is_empty() {
315                        out.push_str("\n\n");
316                    }
317                    out.push_str(flattened);
318                }
319                out
320            }
321            "args" => self.args.trim().to_string(),
322            "flags" => self.flags.trim().to_string(),
323            "grouped_args" => self.grouped_args.trim().to_string(),
324            "ungrouped_args" => self.ungrouped_args.trim().to_string(),
325            "grouped_flags" => self.grouped_flags.trim().to_string(),
326            "ungrouped_flags" => self.ungrouped_flags.trim().to_string(),
327            "after_help" => self.after_help.trim().to_string(),
328            _ => return None,
329        })
330    }
331}
332
333/// The entries for `--help` and `--version`, which the parser supplies and no spec declares.
334///
335/// Listed because help is written for people: a reader looking for how to ask for help should
336/// find it on the page. This reverses the rule these two used to follow — that a page lists
337/// exactly what its spec declares — and the reason is that the spec has its own readers, and
338/// they are not the ones reading this.
339///
340/// `--version` only on the program's own page and only where a version is declared, which is
341/// where a parser accepts one. Each spelling is dropped where the CLI claimed it, since a page
342/// must not describe a flag that something else binds.
343///
344/// The twin of `supplied_entries` in `usage-argv`'s `help` module; the gate over mise's spec is
345/// what says the two agree.
346fn supplied_flags(
347    spec: &Spec,
348    cmd: &SpecCommand,
349    ancestors_taken: &[String],
350    is_root: bool,
351) -> Vec<crate::docs::models::SpecFlag> {
352    // The command's own spellings plus everything in scope above it — the set the inherited
353    // walk built, which counts hidden globals and negations. Rebuilding it from the *visible*
354    // inherited list lost both: a hidden ancestor that binds `--help` would have had the page
355    // offer it anyway.
356    let mut taken: Vec<String> = ancestors_taken.to_vec();
357    for f in &cmd.flags {
358        taken.extend(f.long.iter().map(|l| format!("--{l}")));
359        taken.extend(f.short.iter().map(|s| format!("-{s}")));
360        // Stored with its dashes here, unlike in usage-argv.
361        taken.extend(f.negate.clone());
362    }
363
364    let build = |name: &str, long: &str, short: char, help: &str| {
365        let long_free = !taken.contains(&format!("--{long}"));
366        let short_free = !taken.contains(&format!("-{short}"));
367        if !long_free && !short_free {
368            return None;
369        }
370        // Named after the form it shows: a short-only entry called `help` reads as a renamed
371        // flag and printed `help: -h`.
372        let name = if long_free { name } else { &short.to_string() };
373        let mut flag = crate::SpecFlag {
374            name: name.to_string(),
375            long: if long_free {
376                vec![long.to_string()]
377            } else {
378                vec![]
379            },
380            short: if short_free { vec![short] } else { vec![] },
381            help: Some(help.to_string()),
382            ..Default::default()
383        };
384        flag.usage = flag.usage();
385        Some(crate::docs::models::SpecFlag::from(&flag))
386    };
387
388    let mut out = Vec::new();
389    // `disable_help` turns the parser's answer off — `is_help_arg` refuses the spelling
390    // outright — so a page that still listed it would describe an action nothing performs.
391    // The same rule as a claimed or hidden spelling, with the claim made by the spec itself.
392    //
393    // usage-argv has no equivalent: `disable_help` is a KDL-only word, so no spec that crate
394    // can hold ever carries one, and the two renderers cannot disagree about it.
395    if spec.disable_help != Some(true) && !cmd.disable_help_flag {
396        out.extend(build("help", "help", 'h', "Print help"));
397    }
398    if is_root
399        && (spec.version.is_some() || spec.long_version.is_some())
400        && !cmd.disable_version_flag
401    {
402        out.extend(build("version", "version", 'V', "Print version"));
403    }
404    out
405}
406
407/// Fit a list of flags to a column: how wide their names are, and where their help wraps.
408///
409/// The same pass `SpecCommand::from` makes, run again once the width is known over *both* the
410/// command's own flags and the ones it inherits. The width is not only padding — a wrapped
411/// description is indented to sit under itself — so it cannot be decided per section and then
412/// shared.
413fn lay_out(flags: &mut [crate::docs::models::SpecFlag], column: Column) {
414    for flag in flags {
415        flag.usage_col_width = column.col;
416        flag.help_is_block =
417            !column.can_inline(crate::docs::layout::visible_width(&flag.display_usage));
418        let text = if column.long {
419            flag.help_long
420                .as_deref()
421                .or(flag.help.as_deref())
422                .map(str::to_string)
423        } else if column.next_line {
424            flag.help.clone()
425        } else {
426            with_annotations(flag.help.as_deref(), flag_annotations(flag))
427        };
428        wrap_into(
429            text,
430            column,
431            crate::docs::layout::visible_width(&flag.display_usage),
432            &mut flag.row,
433            &mut flag.help_rendered,
434            &mut flag.help_is_multiline,
435            &mut flag.ann_indent,
436        );
437    }
438}
439
440/// The same pass over a command's arguments.
441///
442/// `SpecCommand::from` already made one, but it made the long page's — the short page prefers
443/// the short description and carries the annotations in the text — so the page it is actually
444/// rendering gets the last word.
445fn lay_out_args(args: &mut [crate::docs::models::SpecArg], column: Column) {
446    for arg in args {
447        arg.usage_col_width = column.col;
448        arg.help_is_block = !column.can_inline(crate::docs::layout::visible_width(&arg.usage));
449        let text = if column.long {
450            arg.help_long
451                .as_deref()
452                .or(arg.help.as_deref())
453                .map(str::to_string)
454        } else if column.next_line {
455            arg.help.clone()
456        } else {
457            with_annotations(arg.help.as_deref(), arg_annotations(arg))
458        };
459        wrap_into(
460            text,
461            column,
462            crate::docs::layout::visible_width(&arg.usage),
463            &mut arg.row,
464            &mut arg.help_rendered,
465            &mut arg.help_is_multiline,
466            &mut arg.ann_indent,
467        );
468    }
469}
470
471fn lay_out_flattened(commands: &mut [crate::docs::models::SpecCommand], width: usize, long: bool) {
472    for command in commands {
473        lay_out_group_help(&mut command.arg_groups, width);
474        lay_out_group_help(&mut command.flag_groups, width);
475        let arg_col = crate::docs::layout::usage_column_width(
476            command
477                .arg_groups
478                .iter()
479                .flat_map(|group| group.items.iter())
480                .map(|arg| arg.usage.as_str()),
481            width,
482        );
483        let arg_column = Column {
484            width,
485            col: arg_col,
486            long,
487            next_line: command.flattened_next_line_help,
488        };
489        for group in &mut command.arg_groups {
490            lay_out_args(&mut group.items, arg_column);
491        }
492
493        let flag_col = crate::docs::layout::usage_column_width(
494            command
495                .flag_groups
496                .iter()
497                .flat_map(|group| group.items.iter())
498                .map(|flag| flag.display_usage.as_str()),
499            width,
500        );
501        let flag_column = Column {
502            width,
503            col: flag_col,
504            long,
505            next_line: command.flattened_next_line_help,
506        };
507        for group in &mut command.flag_groups {
508            lay_out(&mut group.items, flag_column);
509        }
510    }
511}
512
513fn lay_out_group_help<T>(groups: &mut [crate::docs::models::Group<T>], width: usize) {
514    for group in groups {
515        group.help_rendered = group
516            .help
517            .as_deref()
518            .map(|help| crate::docs::layout::render_indented_text(help, width, 2));
519    }
520}
521
522/// Fit one entry's text to the column, and say which layout it wants.
523///
524/// `row` is the text as composed and `help_rendered` the same text wrapped; an empty wrapping
525/// is how [`crate::docs::layout::render_help_text`] says "no room, put it underneath instead",
526/// which is the case the template reads `row` for.
527fn wrap_into(
528    text: Option<String>,
529    column: Column,
530    usage_width: usize,
531    row: &mut Option<String>,
532    help_rendered: &mut Option<String>,
533    help_is_multiline: &mut bool,
534    ann_indent: &mut String,
535) {
536    *row = None;
537    *help_rendered = None;
538    *help_is_multiline = false;
539    // An entry with nothing in the column still has annotations to place, and the column is
540    // where they go: it is the entry's own row that is empty, not the table's.
541    *ann_indent = column.annotation_indent(column.can_inline(usage_width));
542    let Some(text) = text else { return };
543    if !column.can_inline(usage_width) {
544        let indent = column.block_indent();
545        *row = Some(indent_text(
546            &crate::docs::layout::render_indented_text(&text, column.width, indent),
547            indent,
548        ));
549        return;
550    }
551    let first_indent = column.inline_help_start(usage_width);
552    let continuation_indent = column.description_indent();
553    let (rendered, is_multiline) = crate::docs::layout::render_help_text_at(
554        &text,
555        column.width,
556        first_indent,
557        continuation_indent,
558    );
559    // `render_help_text` wraps whatever it is given; whether the page *uses* that is the
560    // template's decision, and on a next-line page it does not. Both have to agree, or the
561    // annotations align to a column the description never entered.
562    *ann_indent = column.annotation_indent(!rendered.is_empty());
563    if !rendered.is_empty() {
564        *help_rendered = Some(rendered);
565        *help_is_multiline = is_multiline;
566    }
567    *row = Some(text);
568}
569
570fn indent_text(text: &str, indent: usize) -> String {
571    let pad = " ".repeat(indent);
572    text.lines()
573        .map(|line| {
574            if line.is_empty() {
575                String::new()
576            } else {
577                format!("{pad}{line}")
578            }
579        })
580        .collect::<Vec<_>>()
581        .join("\n")
582}
583
584/// A short entry's description with its annotations joined on.
585///
586/// The wide layout gives each annotation a line of its own; the narrow one has no room for
587/// that, so they ride along with the description — and they have to be joined *before* it is
588/// wrapped, or an entry with a long description keeps its `[env: …]` out past the column where
589/// the wrapping was supposed to bring the text back.
590fn with_annotations(help: Option<&str>, annotations: Vec<String>) -> Option<String> {
591    let mut parts = Vec::new();
592    if let Some(help) = summarize(help) {
593        parts.push(help.to_string());
594    }
595    parts.extend(annotations);
596    (!parts.is_empty()).then(|| parts.join(" "))
597}
598
599/// What a flag's short entry says about it beyond its description.
600fn flag_annotations(flag: &crate::docs::models::SpecFlag) -> Vec<String> {
601    let mut parts = value_annotations(
602        flag.arg.as_ref().and_then(|arg| arg.choices.as_ref()),
603        flag.hide_possible_values,
604        flag.env.as_deref(),
605        flag.hide_env,
606        &flag.env_fallback,
607        &flag.deprecated_env,
608        &flag.default,
609        flag.hide_default_value,
610    );
611    if let Some(label) = deprecation_label(
612        flag.deprecated.as_deref(),
613        flag.deprecated_warn_at.as_deref(),
614        flag.deprecated_remove_at.as_deref(),
615    ) {
616        parts.push(label);
617    }
618    parts
619}
620
621/// The same for an argument, which carries no deprecation on the narrow page.
622fn arg_annotations(arg: &crate::docs::models::SpecArg) -> Vec<String> {
623    value_annotations(
624        arg.choices.as_ref(),
625        arg.hide_possible_values,
626        arg.env.as_deref(),
627        arg.hide_env,
628        &arg.env_fallback,
629        &arg.deprecated_env,
630        &arg.default,
631        arg.hide_default_value,
632    )
633}
634
635/// What can be said about a value, in the order the narrow page says it.
636#[allow(clippy::too_many_arguments)]
637fn value_annotations(
638    choices: Option<&crate::SpecChoices>,
639    hide_possible_values: bool,
640    env: Option<&str>,
641    hide_env: bool,
642    env_fallback: &[String],
643    deprecated_env: &[String],
644    default: &[String],
645    hide_default_value: bool,
646) -> Vec<String> {
647    let mut parts = Vec::new();
648    if let Some(choices) = choices.filter(|_| !hide_possible_values) {
649        if !choices.choices.is_empty() {
650            parts.push(format!("[{}]", choices.choices.join(", ")));
651        }
652        if let Some(env) = choices.env() {
653            parts.push(format!("[choices env: {env}]"));
654        }
655    }
656    if !hide_env {
657        if let Some(env) = env {
658            parts.push(format!("[env: {env}]"));
659        }
660        parts.extend(
661            env_fallback
662                .iter()
663                .map(|env| format!("[env fallback: {env}]")),
664        );
665        parts.extend(
666            deprecated_env
667                .iter()
668                .map(|env| format!("[deprecated env: {env}]")),
669        );
670    }
671    if !hide_default_value && !default.is_empty() {
672        parts.push(format!("(default: {})", default.join(", ")));
673    }
674    parts
675}
676
677/// What a page is fitting its entries to.
678#[derive(Clone, Copy)]
679struct Column {
680    /// The width the page is laid out for.
681    width: usize,
682    /// How wide the usage column is.
683    col: usize,
684    /// The long page, which prefers the long description and gives each annotation a line.
685    long: bool,
686    /// A page that puts every description under its usage rather than beside it.
687    next_line: bool,
688}
689
690/// The indent a page uses when it cannot align to its column.
691const BLOCK_INDENT: usize = 4;
692
693impl Column {
694    fn usage_overflows(&self, usage_width: usize) -> bool {
695        !self.next_line && usage_width > self.col
696    }
697
698    fn description_indent(&self) -> usize {
699        2 + self.col + 2
700    }
701
702    fn inline_help_start(&self, usage_width: usize) -> usize {
703        if self.usage_overflows(usage_width) {
704            2 + usage_width + 2
705        } else {
706            self.description_indent()
707        }
708    }
709
710    /// Keep an outlier inline only when its spelling leaves a useful amount of prose.
711    fn can_inline(&self, usage_width: usize) -> bool {
712        if self.next_line {
713            return false;
714        }
715        let minimum = if self.usage_overflows(usage_width) {
716            crate::docs::layout::MIN_INLINE_HELP_WIDTH
717        } else {
718            10
719        };
720        self.width
721            .saturating_sub(self.inline_help_start(usage_width))
722            >= minimum
723    }
724
725    /// Where an entry's annotations are indented to.
726    ///
727    /// The description column, when the description reached it — an annotation is a note about
728    /// the same entry and belongs under the text it qualifies, not in the gutter beside a
729    /// column it is ignoring. Where the description is already a block underneath, there is no
730    /// column to align to and the annotations join it there.
731    fn annotation_indent(&self, reached_column: bool) -> String {
732        let indent = if reached_column {
733            self.description_indent()
734        } else {
735            self.block_indent()
736        };
737        " ".repeat(indent)
738    }
739
740    /// Prefer the normal description column for a stacked entry whenever it still leaves a
741    /// useful line. Exceptionally narrow pages retain the compact four-space fallback.
742    fn block_indent(&self) -> usize {
743        let description = self.description_indent();
744        if !self.next_line && self.width.saturating_sub(description) >= 10 {
745            description
746        } else {
747            BLOCK_INDENT
748        }
749    }
750}
751
752/// The entry every command list ends with, unless the CLI turned it off.
753const HELP_SUBCOMMAND: &str = "help";
754const HELP_SUBCOMMAND_SUMMARY: &str = "Print this message or the help of the given subcommand(s)";
755
756/// Fit a list of subcommand summaries to the page's command column.
757///
758/// `lay_out`'s counterpart for the command list: the same column, the same wrapping, the same
759/// "an empty rendering means use the block layout" signal to the template.
760fn lay_out_commands(
761    commands: &mut [crate::docs::models::HelpCommand],
762    terminal_width: usize,
763    col: usize,
764    next_line: bool,
765) {
766    let column = Column {
767        width: terminal_width,
768        col,
769        long: false,
770        next_line,
771    };
772    for command in commands {
773        command.usage_col_width = col;
774        command.row = command_row(command);
775        command.help_rendered = None;
776        command.help_is_multiline = false;
777        let usage_width = crate::docs::layout::visible_width(&command.name);
778        if let Some(row) = command.row.as_deref() {
779            if column.can_inline(usage_width) {
780                let (rendered, is_multiline) = crate::docs::layout::render_help_text_at(
781                    row,
782                    terminal_width,
783                    column.inline_help_start(usage_width),
784                    column.description_indent(),
785                );
786                if !rendered.is_empty() {
787                    command.help_rendered = Some(rendered);
788                    command.help_is_multiline = is_multiline;
789                }
790            } else if let Some(row) = command.row.as_mut() {
791                let indent = column.block_indent();
792                *row = indent_text(
793                    &crate::docs::layout::render_indented_text(row, terminal_width, indent),
794                    indent,
795                );
796            }
797        }
798    }
799}
800
801/// Everything that follows a command's name in its parent's list, as one string.
802///
803/// The name alone occupies the column, so the summaries line up down the page and the syntax a
804/// command takes belongs to that command's own page. What qualifies the command rather than
805/// describing it — the names it also answers to, that it is going away — trails the summary,
806/// where it wraps with the text instead of pushing it out of the column.
807fn command_row(cmd: &crate::docs::models::HelpCommand) -> Option<String> {
808    let mut parts = Vec::new();
809    // A command that wrote only `help_long` still has a summary: its first line. Both pages
810    // read the same one, so `-h` never says less about a command than `--help` does.
811    let summary = summarize(cmd.help.as_deref()).or_else(|| {
812        summarize(
813            cmd.help_long
814                .as_deref()
815                .and_then(|help| help.lines().next()),
816        )
817    });
818    if let Some(summary) = summary {
819        parts.push(summary.to_string());
820    }
821    if !cmd.aliases.is_empty() {
822        parts.push(format!("[aliases: {}]", cmd.aliases.join(", ")));
823    }
824    if let Some(label) = deprecation_label(
825        cmd.deprecated.as_deref(),
826        cmd.deprecated_warn_at.as_deref(),
827        cmd.deprecated_remove_at.as_deref(),
828    ) {
829        parts.push(label);
830    }
831    (!parts.is_empty()).then(|| parts.join(" "))
832}
833
834/// A description reduced to what a list can show, or nothing if it says nothing.
835fn summarize(text: Option<&str>) -> Option<&str> {
836    text.map(str::trim_end).filter(|text| !text.is_empty())
837}
838
839/// How a page says something is going away, in the one place both lists read it from.
840fn deprecation_label(
841    message: Option<&str>,
842    warn_at: Option<&str>,
843    remove_at: Option<&str>,
844) -> Option<String> {
845    if message.is_none() && warn_at.is_none() && remove_at.is_none() {
846        return None;
847    }
848    let mut parts = Vec::new();
849    if let Some(message) = message {
850        parts.push(message.to_string());
851    }
852    if let Some(at) = warn_at {
853        parts.push(format!("warns at {at}"));
854    }
855    if let Some(at) = remove_at {
856        parts.push(format!("removed at {at}"));
857    }
858    Some(format!("[deprecated: {}]", parts.join("; ")))
859}
860
861/// One command row, ready to print — the form the synthetic `help` entry takes.
862fn render_row(
863    name: &str,
864    row: &str,
865    terminal_width: usize,
866    col: usize,
867    next_line_help: bool,
868) -> String {
869    if !next_line_help {
870        if crate::docs::layout::visible_width(name) <= col {
871            let (rendered, _) = crate::docs::layout::render_help_text(row, terminal_width, col);
872            if !rendered.is_empty() {
873                return format!("  {name:<col$}  {rendered}");
874            }
875        } else if !row.contains('\n') {
876            return format!(
877                "  {name}\n    {}",
878                crate::docs::layout::render_block_text(row, terminal_width)
879            );
880        }
881    }
882    format!("  {name}\n    {row}")
883}
884
885/// The flags a command inherits, as its page should list them.
886///
887/// Walked down `full_cmd` from the root, which is the path a user would type — so the chain is
888/// exact. Each ancestor contributes only what it declared `global`, and hidden ones are left
889/// out here as they are everywhere else.
890///
891/// The twin of `own_and_global` in `usage-argv`'s `help` module; the two must agree, and the
892/// gate over mise's spec is what says they do.
893fn inherited_flags(
894    spec: &Spec,
895    cmd: &SpecCommand,
896    full_cmd: &[String],
897    long_help: bool,
898) -> (Vec<crate::docs::models::SpecFlag>, Vec<String>) {
899    // Every ancestor, root first, which is the order a reader meets them walking down.
900    let mut ancestors: Vec<&SpecCommand> = Vec::new();
901    let mut at = &spec.cmd;
902    for name in full_cmd.iter().take(full_cmd.len().saturating_sub(1)) {
903        ancestors.push(at);
904        let Some(next) = at.subcommands.get(name) else {
905            return (Vec::new(), Vec::new());
906        };
907        at = next;
908    }
909    if !full_cmd.is_empty() {
910        ancestors.push(at);
911    }
912
913    // Shadowing, which the parser does and the page has to agree with: a command's own flags
914    // are looked up before its ancestors', so `mise use --raw` is *use's* and never the root's.
915    // Listing both would print two descriptions for one spelling, one of which can never apply.
916    // Nearest ancestor first for the decision, then emitted root-first.
917    // Two sets, because the parser has two passes: it resolves a word against every long and
918    // short in scope before it looks at a negation at all, so *any* long beats *any* negation
919    // however far away it is. Reading them as one said a nearer negation had taken a spelling
920    // that a farther long actually wins.
921    //
922    // usage-lib stores a negation *with* its dashes — `negate="--no-colour"` reaches the model
923    // as `--no-colour` — where usage-argv stores it without. Prefixing here produced
924    // `----no-colour`, which matched nothing, so negations were counted in name only.
925    let forms = |f: &crate::SpecFlag| -> Vec<String> {
926        f.long
927            .iter()
928            .map(|l| format!("--{l}"))
929            .chain(f.short.iter().map(|s| format!("-{s}")))
930            .collect()
931    };
932    let every_form: Vec<String> = cmd
933        .flags
934        .iter()
935        .chain(
936            ancestors
937                .iter()
938                .flat_map(|a| a.flags.iter())
939                .filter(|f| f.global),
940        )
941        .flat_map(&forms)
942        .collect();
943
944    let mut taken: Vec<String> = cmd.flags.iter().flat_map(&forms).collect();
945    let mut taken_negations: Vec<String> =
946        cmd.flags.iter().filter_map(|f| f.negate.clone()).collect();
947    let mut keep: Vec<(&crate::SpecFlag, Option<String>, Option<char>, bool)> = Vec::new();
948    for ancestor in ancestors.iter().rev() {
949        for f in ancestor.flags.iter().filter(|f| f.global) {
950            let long = f
951                .long
952                .iter()
953                .find(|l| !f.hidden_aliases.contains(l) && !taken.contains(&format!("--{l}")))
954                .cloned();
955            let short = f
956                .short
957                .iter()
958                .find(|s| !f.hidden_short_aliases.contains(s) && !taken.contains(&format!("-{s}")))
959                .copied();
960            let mine = forms(f);
961            let negate = f.negate.as_ref().is_some_and(|n| {
962                !taken_negations.contains(n) && (!every_form.contains(n) || mine.contains(n))
963            });
964            // Reserved whether or not it is shown: a hidden one still binds, and so does one
965            // whose every spelling something nearer already took.
966            taken.extend(forms(f));
967            taken_negations.extend(f.negate.clone());
968            if f.hide
969                || if long_help {
970                    f.hide_long_help
971                } else {
972                    f.hide_short_help
973                }
974                || (long.is_none() && short.is_none() && !negate)
975            {
976                continue;
977            }
978            keep.push((f, long, short, negate));
979        }
980    }
981    let shown: Vec<crate::docs::models::SpecFlag> = ancestors
982        .iter()
983        .flat_map(|a| a.flags.iter())
984        .filter_map(|f| {
985            keep.iter()
986                .find(|(k, _, _, _)| std::ptr::eq(*k, f))
987                .map(|(_, l, s, n)| (f, l.clone(), *s, *n))
988        })
989        .map(|(f, long, short, negate)| {
990            // Only the spellings that survived, so the entry offers what the parser would
991            // actually accept here.
992            let mut shown = f.clone();
993            shown.long = long.into_iter().collect();
994            shown.short = short.into_iter().collect();
995            if !negate {
996                shown.negate = None;
997            }
998            shown.usage = shown.usage();
999            crate::docs::models::SpecFlag::from(&shown)
1000        })
1001        .collect();
1002    // The claim set travels with the result, forms and negations together: the supplied
1003    // `--help` and `--version` entries lose to both, since `find_negation` runs before either
1004    // is offered — even though a negation loses to a long.
1005    taken.extend(taken_negations);
1006    (shown, taken)
1007}
1008
1009/// The command without anything marked `hide`.
1010///
1011/// Help showed hidden flags, hidden arguments and hidden subcommands — everything `hide`
1012/// exists to keep out of it. The usage *line* filtered them already, through
1013/// `SpecCommand::usage`, so `ex --help` listed a `--secret` that the line above it did not
1014/// mention. Markdown and manpage rendering filter too; the help templates were the one place
1015/// that did not.
1016///
1017/// Filtered here rather than in the templates, and before the docs model builds its groups, so
1018/// that a heading whose every entry is hidden produces no section — the same rule markdown
1019/// already follows.
1020fn without_hidden(cmd: &SpecCommand, long: bool) -> SpecCommand {
1021    let mut visible = cmd.clone();
1022    visible.flags.retain(|flag| {
1023        !flag.hide
1024            && if long {
1025                !flag.hide_long_help
1026            } else {
1027                !flag.hide_short_help
1028            }
1029    });
1030    visible.args.retain(|arg| {
1031        !arg.hide
1032            && if long {
1033                !arg.hide_long_help
1034            } else {
1035                !arg.hide_short_help
1036            }
1037    });
1038    visible.subcommands.retain(|_, sub| !sub.hide);
1039    // Ordinary help only lists immediate subcommands, so their fields are never
1040    // rendered on this page. Walking and cloning the whole remaining tree here
1041    // makes rendering every page quadratic on a fleet-sized CLI. Flattened help
1042    // is the one mode that renders descendant fields and therefore needs the
1043    // recursive filtering.
1044    if visible.flatten_help {
1045        for sub in visible.subcommands.values_mut() {
1046            *sub = without_hidden(sub, long);
1047        }
1048    }
1049    visible
1050}
1051
1052static TERA: LazyLock<Tera> = LazyLock::new(|| {
1053    let mut tera = Tera::default();
1054
1055    // Register ljust filter for left-justifying text with padding
1056    tera.register_filter(
1057        "ljust",
1058        |value: &tera::Value, args: tera::Kwargs, _: &tera::State| -> tera::TeraResult<String> {
1059            let value = value.as_str().unwrap_or("");
1060            let width = args.get::<u64>("width")?.unwrap_or(0) as usize;
1061            Ok(format!("{:<width$}", value, width = width))
1062        },
1063    );
1064    tera.register_filter(
1065        "default",
1066        |value: &tera::Value,
1067         kwargs: tera::Kwargs,
1068         _: &tera::State|
1069         -> tera::TeraResult<tera::Value> {
1070            let default_val = kwargs.must_get::<tera::Value>("value")?;
1071            let boolean = kwargs.get::<bool>("boolean")?.unwrap_or_default();
1072            if value.is_undefined() || value.is_none() || (boolean && !value.is_truthy()) {
1073                Ok(default_val)
1074            } else {
1075                Ok(value.clone())
1076            }
1077        },
1078    );
1079    tera.register_filter(
1080        "terminal_wrap",
1081        |value: &tera::Value, args: tera::Kwargs, _: &tera::State| -> tera::TeraResult<String> {
1082            let value = value.as_str().unwrap_or("");
1083            let width = args.get::<u64>("width")?.unwrap_or(80) as usize;
1084            let indent = args.get::<u64>("indent")?.unwrap_or(0) as usize;
1085            Ok(crate::docs::layout::render_indented_text(
1086                value, width, indent,
1087            ))
1088        },
1089    );
1090    tera.register_filter(
1091        "terminal_label",
1092        |value: &tera::Value, args: tera::Kwargs, _: &tera::State| -> tera::TeraResult<String> {
1093            let value = value.as_str().unwrap_or("");
1094            let label = args.must_get::<String>("label")?;
1095            let width = args.get::<u64>("width")?.unwrap_or(80) as usize;
1096            let indent = args.get::<u64>("indent")?.unwrap_or(0) as usize;
1097            Ok(crate::docs::layout::render_labelled_text(
1098                &label, value, width, indent,
1099            ))
1100        },
1101    );
1102    tera.register_filter(
1103        "terminal_annotation",
1104        |value: &tera::Value, args: tera::Kwargs, _: &tera::State| -> tera::TeraResult<String> {
1105            let body = if let Some(values) = value.as_array() {
1106                values
1107                    .iter()
1108                    .filter_map(tera::Value::as_str)
1109                    .collect::<Vec<_>>()
1110                    .join(", ")
1111            } else {
1112                value.as_str().unwrap_or("").to_string()
1113            };
1114            let label = args.get::<String>("label")?.unwrap_or_default();
1115            let suffix = args.get::<String>("suffix")?.unwrap_or_default();
1116            let indent = args
1117                .get::<String>("indent")?
1118                .unwrap_or_default()
1119                .chars()
1120                .count();
1121            let width = args.get::<u64>("width")?.unwrap_or(80) as usize;
1122            let rendered = crate::docs::layout::render_indented_text(
1123                &format!("{label}{body}{suffix}"),
1124                width,
1125                indent,
1126            );
1127            Ok(indent_text(&rendered, indent))
1128        },
1129    );
1130    tera.register_filter(
1131        "terminal_deprecation",
1132        |value: &tera::Value, args: tera::Kwargs, _: &tera::State| -> tera::TeraResult<String> {
1133            let field = |name| value.get_from_path(name).and_then(tera::Value::as_str);
1134            let Some(label) = deprecation_label(
1135                field("deprecated"),
1136                field("deprecated_warn_at"),
1137                field("deprecated_remove_at"),
1138            ) else {
1139                return Ok(String::new());
1140            };
1141            let indent = args
1142                .get::<String>("indent")?
1143                .unwrap_or_default()
1144                .chars()
1145                .count();
1146            let width = args.get::<u64>("width")?.unwrap_or(80) as usize;
1147            let rendered = crate::docs::layout::render_indented_text(&label, width, indent);
1148            Ok(indent_text(&rendered, indent))
1149        },
1150    );
1151
1152    #[rustfmt::skip]
1153    tera.add_raw_templates([
1154        ("spec_template_short.tera", include_str!("templates/spec_template_short.tera")),
1155        ("spec_template_long.tera", include_str!("templates/spec_template_long.tera")),
1156    ]).unwrap();
1157
1158    tera
1159});
1160
1161#[cfg(test)]
1162mod tests {
1163    use super::*;
1164    use insta::assert_snapshot;
1165
1166    #[test]
1167    fn flag_aliases_do_not_leak_into_interactive_help() {
1168        let spec = crate::spec! { r#"
1169bin "ex"
1170flag "-t -f --tail --follow" help="Follow output"
1171        "# }
1172        .unwrap();
1173
1174        for long in [false, true] {
1175            let page = super::render_help(&spec, &spec.cmd, long);
1176            assert!(page.contains("-t, --tail"), "long={long}:\n{page}");
1177            assert!(!page.contains("aliases:"), "long={long}:\n{page}");
1178        }
1179    }
1180
1181    #[test]
1182    fn a_hidden_ancestor_claim_keeps_help_off_the_page() {
1183        // `--help` is supplied by the parser, and a hidden global that declares it still binds
1184        // first — `hide` keeps a flag off the page, not out of the parse. Deciding the supplied
1185        // entries from the *visible* inherited list lost exactly that, and the page offered a
1186        // `--help` that does something else.
1187        let spec = crate::spec! { r#"
1188bin "ex"
1189flag "--help" global=#true hide=#true help="the CLI's own, and invisible"
1190cmd inner help="a command" {
1191    flag "--plain" help="its own"
1192}
1193        "# }
1194        .unwrap();
1195
1196        let inner = spec.cmd.subcommands.get("inner").expect("inner");
1197        for long in [false, true] {
1198            let page = super::render_help(&spec, inner, long);
1199            assert!(
1200                !page.contains("--help"),
1201                "long={long}: a hidden ancestor binds this:\n{page}"
1202            );
1203            // The short form is untouched, since nothing claimed it.
1204            assert!(page.contains("-h"), "long={long}:\n{page}");
1205        }
1206    }
1207
1208    #[test]
1209    fn a_long_beats_a_negation_however_far_away_it_is() {
1210        // A negation is stored *with* its dashes here and without them in usage-argv, so the
1211        // spelling was being looked up as `----no-cache` and matched nothing — negations were
1212        // counted in name only. And which one binds is not about distance: a word is resolved
1213        // against every long in scope before any negation is considered, so the root's plain
1214        // `--no-cache` wins over the subcommand's negation and belongs on its page.
1215        let spec = crate::spec! { r#"
1216bin "ex"
1217flag "--no-cache" global=#true help="the root's plain long"
1218flag "--colour" negate="--no-colour" global=#true help="the root's, with a negation"
1219cmd narrow help="a command" {
1220    flag "--cache" negate="--no-cache" help="its own, with a negation"
1221    flag "--tint" negate="--no-colour" help="claims the root's negation"
1222}
1223        "# }
1224        .unwrap();
1225
1226        let narrow = spec.cmd.subcommands.get("narrow").expect("narrow");
1227        for long in [false, true] {
1228            let page = super::render_help(&spec, narrow, long);
1229            assert!(
1230                page.contains("--no-cache"),
1231                "long={long}: a long beats a negation, so this still binds here:\n{page}"
1232            );
1233            // And a negation *is* claimed by a nearer negation — which is what the dashes
1234            // matter for. `--colour` stays; the negation it used to carry does not.
1235            assert!(page.contains("--colour"), "long={long}:\n{page}");
1236            let global = page
1237                .split_once("Global flags:")
1238                .expect("a global section")
1239                .1;
1240            assert!(
1241                !global.contains("--colour / --no-colour"),
1242                "long={long}: the nearer negation owns that spelling:\n{page}"
1243            );
1244        }
1245    }
1246
1247    #[test]
1248    fn a_description_of_only_spaces_is_no_description() {
1249        // `usage-argv` filters a blank description wherever it reads one, and this template
1250        // asked only whether the string was there — so `help="   "` bought a column of padding
1251        // and a line of trailing spaces here and nothing there. Two renderings of one spec.
1252        //
1253        // Asserted on the trailing whitespace rather than by comparing the two renderers, so
1254        // the test says what is wrong with the line rather than only that they disagree.
1255        let spec = crate::spec! { r#"
1256bin "ex"
1257flag "--blank" help="   "
1258flag "--plain" help="plain"
1259        "# }
1260        .unwrap();
1261
1262        for long in [false, true] {
1263            let page = super::render_help(&spec, &spec.cmd, long);
1264            // In the flags section, not the usage line — `Usage: ex [--blank] [--plain]`
1265            // also contains the name and has no padding to get wrong.
1266            let listing = page.split_once("\nFlags:").expect("a flags section").1;
1267            let line = listing
1268                .lines()
1269                .find(|l| l.contains("--blank"))
1270                .unwrap_or_else(|| panic!("long={long}: {page}"));
1271            assert_eq!(
1272                line,
1273                line.trim_end(),
1274                "long={long}: trailing space on {line:?}"
1275            );
1276        }
1277    }
1278
1279    #[test]
1280    fn test_render_help_omits_hidden_entries() {
1281        let spec = crate::spec! { r#"
1282bin "ex"
1283flag "--visible" help="shown"
1284flag "--secret" hide=#true help="hidden"
1285flag "--filtered" hide=#true help="hidden" help_heading="Filtering"
1286arg "[SHOWN]" help="an arg"
1287arg "[HIDDEN]" hide=#true help="a hidden arg"
1288cmd open help="a command"
1289cmd sneaky hide=#true help="a hidden command"
1290        "# }
1291        .unwrap();
1292
1293        // `hide` keeps something out of help. The usage line filtered already — through
1294        // `SpecCommand::usage` — so before this, `ex --help` listed a `--secret` the line
1295        // above it did not mention. A heading whose every entry is hidden produces no
1296        // section, which is the rule markdown rendering already followed.
1297        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1298        Usage: ex [--visible] [SHOWN] <SUBCOMMAND>
1299
1300        Commands:
1301          open  a command
1302          help  Print this message or the help of the given subcommand(s)
1303
1304        Arguments:
1305          [SHOWN]  an arg
1306
1307        Flags:
1308              --visible  shown
1309          -h, --help     Print help
1310        ");
1311    }
1312
1313    #[test]
1314    fn test_render_help_groups_by_heading() {
1315        let spec = crate::spec! { r#"
1316bin "testcli"
1317flag "--verbose" help="Verbose output"
1318flag "--filter <pattern>" help="Only matching" help_heading="Filtering"
1319flag "--exclude <pattern>" help="Skip matching" help_heading="Filtering"
1320flag "--jobs <n>" help="How many at once" help_heading="Performance"
1321arg "<file>" help="The file"
1322arg "<mode>" help="How to run" help_heading="Behaviour"
1323        "# }
1324        .unwrap();
1325
1326        // Unheaded entries keep the default title and come first; each heading
1327        // then gets its own section, in the order the headings first appear.
1328        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1329        Usage: testcli [FLAGS] <file> <mode>
1330
1331        Arguments:
1332          <file>  The file
1333
1334        Behaviour:
1335          <mode>  How to run
1336
1337        Flags:
1338              --verbose            Verbose output
1339          -h, --help               Print help
1340
1341        Filtering:
1342              --filter <pattern>   Only matching
1343              --exclude <pattern>  Skip matching
1344
1345        Performance:
1346              --jobs <n>           How many at once
1347        ");
1348    }
1349
1350    #[test]
1351    fn test_render_help_with_only_headed_flags() {
1352        // No default section when nothing lands in it: a CLI that gives every
1353        // flag a heading should not get an empty "Flags:".
1354        let spec = crate::spec! { r#"
1355bin "testcli"
1356flag "--filter <pattern>" help="Only matching" help_heading="Filtering"
1357        "# }
1358        .unwrap();
1359
1360        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1361        Usage: testcli [--filter <pattern>]
1362
1363        Flags:
1364          -h, --help              Print help
1365
1366        Filtering:
1367              --filter <pattern>  Only matching
1368        ");
1369    }
1370
1371    #[test]
1372    fn test_render_help_with_env() {
1373        let spec = crate::spec! { r#"
1374bin "testcli"
1375flag "--color" env="MYCLI_COLOR" help="Enable color output"
1376flag "--verbose" env="MYCLI_VERBOSE" help="Verbose output"
1377flag "--debug" help="Debug mode"
1378        "# }
1379        .unwrap();
1380
1381        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1382        Usage: testcli [FLAGS]
1383
1384        Flags:
1385              --color    Enable color output [env: MYCLI_COLOR]
1386              --verbose  Verbose output [env: MYCLI_VERBOSE]
1387              --debug    Debug mode
1388          -h, --help     Print help
1389        ");
1390
1391        assert_snapshot!(render_help(&spec, &spec.cmd, true), @"
1392        Usage: testcli [FLAGS]
1393
1394        Flags:
1395              --color    Enable color output
1396                         [env: MYCLI_COLOR]
1397              --verbose  Verbose output
1398                         [env: MYCLI_VERBOSE]
1399              --debug    Debug mode
1400          -h, --help     Print help
1401        ");
1402    }
1403
1404    #[test]
1405    fn test_render_help_with_arg_env() {
1406        let spec = crate::spec! { r#"
1407bin "testcli"
1408arg "<input>" env="MY_INPUT" help="Input file"
1409arg "<output>" env="MY_OUTPUT" help="Output file"
1410arg "<extra>" help="Extra arg without env"
1411arg "[default]" help="Arg with default value" default="default value"
1412        "# }
1413        .unwrap();
1414
1415        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1416        Usage: testcli <ARGS>…
1417
1418        Arguments:
1419          <input>    Input file [env: MY_INPUT]
1420          <output>   Output file [env: MY_OUTPUT]
1421          <extra>    Extra arg without env
1422          [default]  Arg with default value (default: default value)
1423
1424        Flags:
1425          -h, --help  Print help
1426        ");
1427
1428        assert_snapshot!(render_help(&spec, &spec.cmd, true), @"
1429        Usage: testcli <ARGS>…
1430
1431        Arguments:
1432          <input>    Input file
1433                     [env: MY_INPUT]
1434          <output>   Output file
1435                     [env: MY_OUTPUT]
1436          <extra>    Extra arg without env
1437          [default]  Arg with default value
1438                     (default: default value)
1439
1440        Flags:
1441          -h, --help  Print help
1442        ");
1443    }
1444
1445    #[test]
1446    fn test_render_help_with_negated_flag() {
1447        let spec = crate::spec! { r#"
1448bin "testcli"
1449flag "--compress" negate="--no-compress" default=#true help="Compress output"
1450flag "--verbose" help="Verbose output"
1451        "# }
1452        .unwrap();
1453
1454        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1455        Usage: testcli [--compress] [--verbose]
1456
1457        Flags:
1458              --compress / --no-compress  Compress output (default: true)
1459              --verbose                   Verbose output
1460          -h, --help                      Print help
1461        ");
1462
1463        assert_snapshot!(render_help(&spec, &spec.cmd, true), @"
1464        Usage: testcli [--compress] [--verbose]
1465
1466        Flags:
1467              --compress / --no-compress  Compress output
1468                                          (default: true)
1469              --verbose                   Verbose output
1470          -h, --help                      Print help
1471        ");
1472    }
1473
1474    #[test]
1475    fn granular_help_hides_preserve_behavior_but_remove_presentation() {
1476        let spec = crate::spec! { r#"
1477bin "testcli"
1478flag "--mode <mode>" help="Select mode" env="MODE" default="fast" hide_default_value=#true hide_env=#true hide_possible_values=#true {
1479  choices {
1480    choice "fast"
1481    choice "slow"
1482  }
1483}
1484flag "--short-only" help="short" hide_long_help=#true
1485flag "--long-only" help="long" hide_short_help=#true
1486arg "[input]" help="Input" env="INPUT" default="file" hide_default_value=#true hide_env=#true
1487        "# }
1488        .unwrap();
1489
1490        let short = render_help(&spec, &spec.cmd, false);
1491        assert!(short.contains("--mode <mode>"), "{short}");
1492        assert!(short.contains("--short-only"), "{short}");
1493        assert!(!short.contains("--long-only"), "{short}");
1494        assert!(
1495            !short.contains("MODE") && !short.contains("fast, slow"),
1496            "{short}"
1497        );
1498        assert!(
1499            !short.contains("default: fast") && !short.contains("default: file"),
1500            "{short}"
1501        );
1502
1503        let long = render_help(&spec, &spec.cmd, true);
1504        assert!(long.contains("--long-only"), "{long}");
1505        assert!(!long.contains("--short-only"), "{long}");
1506        assert!(
1507            !long.contains("MODE") && !long.contains("possible values"),
1508            "{long}"
1509        );
1510
1511        let rendered = spec.to_string();
1512        let reparsed: crate::Spec = rendered.parse().unwrap();
1513        assert!(reparsed.cmd.flags[0].hide_default_value);
1514        assert!(reparsed.cmd.flags[0].hide_env);
1515        assert!(reparsed.cmd.flags[0].hide_possible_values);
1516    }
1517
1518    #[test]
1519    fn a_help_template_reorders_omits_and_wraps_the_sections() {
1520        // The whole of what a template can do: `{{flags}}` before `{{args}}`, no
1521        // `{{commands}}` at all, and text of the author's own around them. Nothing else is
1522        // substituted, so the layout is the spec's and the sections' contents are not.
1523        let spec = crate::spec! { r#"
1524bin "ex"
1525about "An example"
1526help_template "{{about}}\n\n{{usage}}\n\n{{flags}}\n\n{{args}}\n\n-- ask a person --"
1527flag "--force" help="Do it anyway"
1528arg "<file>" help="Which file"
1529cmd "run" help="Run it"
1530        "# }
1531        .unwrap();
1532
1533        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1534        An example
1535
1536        Usage: ex [--force] <file> <SUBCOMMAND>
1537
1538        Flags:
1539              --force  Do it anyway
1540          -h, --help   Print help
1541
1542        Arguments:
1543          <file>  Which file
1544
1545        -- ask a person --
1546        ");
1547    }
1548
1549    #[test]
1550    fn a_template_places_the_sections_a_page_actually_has() {
1551        // A template names every section, and this command has no arguments — the gap
1552        // `{{args}}` would leave closes up rather than pushing the commands down the page.
1553        // What lets one template serve a whole CLI, since most commands are missing most
1554        // sections. Here the version banner and description are last, and `after_help`
1555        // carries them nothing.
1556        let spec = crate::spec! { r#"
1557bin "ex"
1558version "1.2.3"
1559about "An example"
1560after_help "Read the docs."
1561help_template "{{usage}}\n\n{{flags}}\n\n{{args}}\n\n{{commands}}\n\n{{after_help}}\n\n{{about}}"
1562flag "--force" help="Do it anyway"
1563cmd "run" help="Run it"
1564        "# }
1565        .unwrap();
1566
1567        assert_snapshot!(render_help(&spec, &spec.cmd, true), @"
1568        Usage: ex [--force] <SUBCOMMAND>
1569
1570        Flags:
1571              --force    Do it anyway
1572          -h, --help     Print help
1573          -V, --version  Print version
1574
1575        Commands:
1576          run   Run it
1577          help  Print this message or the help of the given subcommand(s)
1578
1579        Read the docs.
1580
1581        ex 1.2.3
1582        An example
1583        ");
1584    }
1585
1586    #[test]
1587    fn a_flattened_page_puts_its_bodies_where_the_commands_would_go() {
1588        // `flatten_help` replaces a command list with the subcommands' own bodies, so a
1589        // template that places `{{commands}}` places whichever of the two this command has.
1590        let spec = crate::spec! { r#"
1591bin "ex"
1592flatten_help #true
1593help_template "{{usage}}\n\n{{commands}}\n\n{{flags}}"
1594cmd "run" help="Run it" {
1595    flag "--dry-run" help="Only show changes"
1596}
1597        "# }
1598        .unwrap();
1599
1600        let page = render_help(&spec, &spec.cmd, false);
1601        assert!(
1602            page.find("run:").unwrap() < page.find("Flags:").unwrap(),
1603            "{page}"
1604        );
1605        assert!(page.contains("--dry-run"), "{page}");
1606    }
1607
1608    #[test]
1609    fn test_render_help_with_before_after_help() {
1610        let spec = crate::spec! { r#"
1611bin "testcli"
1612before_help "This text appears before the help"
1613after_help "This text appears after the help"
1614flag "--verbose" help="Enable verbose output"
1615        "# }
1616        .unwrap();
1617
1618        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1619        This text appears before the help
1620
1621        Usage: testcli [--verbose]
1622
1623        Flags:
1624              --verbose  Enable verbose output
1625          -h, --help     Print help
1626
1627        This text appears after the help
1628        ");
1629    }
1630
1631    #[test]
1632    fn test_render_help_with_before_after_help_long() {
1633        let spec = crate::spec! { r#"
1634bin "testcli"
1635before_help "short before"
1636before_help_long "This is the long version of before help"
1637after_help "short after"
1638after_help_long "This is the long version of after help"
1639flag "--verbose" help="Enable verbose output"
1640        "# }
1641        .unwrap();
1642
1643        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1644        short before
1645
1646        Usage: testcli [--verbose]
1647
1648        Flags:
1649              --verbose  Enable verbose output
1650          -h, --help     Print help
1651
1652        short after
1653        ");
1654
1655        assert_snapshot!(render_help(&spec, &spec.cmd, true), @"
1656        This is the long version of before help
1657
1658        Usage: testcli [--verbose]
1659
1660        Flags:
1661              --verbose  Enable verbose output
1662          -h, --help     Print help
1663
1664        This is the long version of after help
1665        ");
1666    }
1667
1668    #[test]
1669    fn test_render_help_with_examples() {
1670        let spec = crate::spec! { r#"
1671bin "testcli"
1672flag "--verbose" help="Enable verbose output"
1673example "testcli --verbose" header="Run with verbose output"
1674example "testcli" header="Run normally" help="Just runs the tool"
1675        "# }
1676        .unwrap();
1677
1678        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1679        Usage: testcli [--verbose]
1680
1681        Flags:
1682              --verbose  Enable verbose output
1683          -h, --help     Print help
1684
1685        Examples:
1686          Run with verbose output:
1687            $ testcli --verbose
1688          Run normally:
1689            $ testcli
1690        ");
1691
1692        assert_snapshot!(render_help(&spec, &spec.cmd, true), @"
1693        Usage: testcli [--verbose]
1694
1695        Flags:
1696              --verbose  Enable verbose output
1697          -h, --help     Print help
1698
1699        Examples:
1700          Run with verbose output:
1701            $ testcli --verbose
1702          Run normally:
1703            Just runs the tool
1704            $ testcli
1705        ");
1706    }
1707
1708    #[test]
1709    fn test_render_help_with_version() {
1710        let spec = crate::spec! { r#"
1711bin "testcli"
1712name "TestCLI"
1713version "1.2.3"
1714flag "--verbose" help="Enable verbose output"
1715        "# }
1716        .unwrap();
1717
1718        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1719        TestCLI 1.2.3
1720        Usage: testcli [--verbose]
1721
1722        Flags:
1723              --verbose  Enable verbose output
1724          -h, --help     Print help
1725          -V, --version  Print version
1726        ");
1727    }
1728
1729    #[test]
1730    fn test_render_help_with_only_long_version() {
1731        let spec = crate::spec! { r#"
1732bin "testcli"
1733long_version "1.2.3\ncommit abc123"
1734flag "--verbose" help="Enable verbose output"
1735        "# }
1736        .unwrap();
1737
1738        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1739        Usage: testcli [--verbose]
1740
1741        Flags:
1742              --verbose  Enable verbose output
1743          -h, --help     Print help
1744          -V, --version  Print version
1745        ");
1746    }
1747
1748    #[test]
1749    fn test_render_help_omits_help_when_disabled() {
1750        // `disable_help` turns the parser's answer off, so the page must not offer it: the same
1751        // rule as a spelling the CLI claimed, with the spec doing the claiming. `--version`
1752        // stays, because nothing disabled that.
1753        let spec = crate::spec! { r#"
1754bin "testcli"
1755version "1.2.3"
1756disable_help #true
1757flag "--verbose" help="Enable verbose output"
1758        "# }
1759        .unwrap();
1760
1761        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1762        testcli 1.2.3
1763        Usage: testcli [--verbose]
1764
1765        Flags:
1766              --verbose  Enable verbose output
1767          -V, --version  Print version
1768        ");
1769    }
1770
1771    #[test]
1772    fn test_render_help_with_author_license() {
1773        let spec = crate::spec! { r#"
1774bin "testcli"
1775author "Test Author"
1776license "MIT"
1777flag "--verbose" help="Enable verbose output"
1778        "# }
1779        .unwrap();
1780
1781        // Short help should not show author/license
1782        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1783        Usage: testcli [--verbose]
1784
1785        Flags:
1786              --verbose  Enable verbose output
1787          -h, --help     Print help
1788        ");
1789
1790        // Long help should show author/license at the bottom
1791        assert_snapshot!(render_help(&spec, &spec.cmd, true), @"
1792        Usage: testcli [--verbose]
1793
1794        Flags:
1795              --verbose  Enable verbose output
1796          -h, --help     Print help
1797
1798        Author: Test Author
1799        License: MIT
1800        ");
1801    }
1802
1803    #[test]
1804    fn test_render_help_with_deprecated_command() {
1805        let spec = crate::spec! { r#"
1806bin "testcli"
1807flag "--old" help="Old switch" deprecated="use --new" deprecated_warn_at="6.1" deprecated_remove_at="7.0"
1808cmd "old-cmd" help="Do something" deprecated="use new-cmd instead" deprecated_warn_at="6.2" deprecated_remove_at="7.0"
1809cmd "new-cmd" help="Do something better"
1810        "# }
1811        .unwrap();
1812
1813        assert_snapshot!(render_help(&spec, &spec.cmd, false), @"
1814        Usage: testcli [--old] <SUBCOMMAND>
1815
1816        Commands:
1817          new-cmd  Do something better
1818          old-cmd  Do something [deprecated: use new-cmd instead; warns at 6.2; removed
1819                   at 7.0]
1820          help     Print this message or the help of the given subcommand(s)
1821
1822        Flags:
1823              --old   Old switch [deprecated: use --new; warns at 6.1; removed at 7.0]
1824          -h, --help  Print help
1825        ");
1826    }
1827
1828    #[test]
1829    fn deprecation_milestones_do_not_need_a_message() {
1830        let spec = crate::spec! { r#"
1831bin "testcli"
1832flag "--old" help="Old switch" deprecated_remove_at="7.0"
1833cmd "old-cmd" help="Do something" deprecated_warn_at="6.2"
1834        "# }
1835        .unwrap();
1836
1837        let page = render_help(&spec, &spec.cmd, false);
1838        assert!(
1839            page.contains("old-cmd  Do something [deprecated: warns at 6.2]"),
1840            "{page}"
1841        );
1842        assert!(page.contains("[deprecated: removed at 7.0]"), "{page}");
1843        assert!(!page.contains("[deprecated:;"), "{page}");
1844    }
1845
1846    #[test]
1847    fn test_render_help_with_subcommand_presentation() {
1848        let spec = crate::spec! { r#"
1849bin "testcli"
1850subcommand_help_heading "Actions"
1851subcommand_value_name "ACTION"
1852cmd "run" help="Run it\n"
1853        "# }
1854        .unwrap();
1855
1856        let page = render_help(&spec, &spec.cmd, false);
1857        assert!(page.contains("Usage: testcli <ACTION>"), "{page}");
1858        assert!(page.contains("\nActions:\n"), "{page}");
1859    }
1860
1861    #[test]
1862    fn test_render_help_honors_explicit_display_order() {
1863        let spec = crate::spec! { r#"
1864bin "testcli"
1865flag "--unset" help="Unordered"
1866flag "--later" help="Later" display_order=20
1867flag "--first" help="First" display_order=10
1868cmd "zulu" help="Unordered"
1869cmd "later" help="Later" display_order=20
1870cmd "first" help="First" display_order=10
1871cmd "alpha" help="Unordered"
1872        "# }
1873        .unwrap();
1874
1875        let page = render_help(&spec, &spec.cmd, false);
1876        let commands = page.split_once("\nCommands:\n").unwrap().1;
1877        assert!(
1878            commands.find("first").unwrap() < commands.find("later").unwrap()
1879                && commands.find("later").unwrap() < commands.find("alpha").unwrap()
1880                && commands.find("alpha").unwrap() < commands.find("zulu").unwrap(),
1881            "{page}"
1882        );
1883        let flags = page.split_once("\nFlags:\n").unwrap().1;
1884        assert!(
1885            flags.find("--first").unwrap() < flags.find("--later").unwrap()
1886                && flags.find("--later").unwrap() < flags.find("--unset").unwrap(),
1887            "{page}"
1888        );
1889    }
1890
1891    #[test]
1892    fn test_render_help_groups_subcommands_by_heading() {
1893        let spec = crate::spec! { r#"
1894bin "testcli"
1895cmd "run" help="Run it" help_heading="Core commands"
1896cmd "clean" help="Remove old state" help_heading="Maintenance"
1897cmd "status" help="Show status" help_heading="Commands"
1898        "# }
1899        .unwrap();
1900
1901        for page in [
1902            render_help(&spec, &spec.cmd, false),
1903            render_help(&spec, &spec.cmd, true),
1904        ] {
1905            let commands = page.find("\nCommands:\n").expect("default command section");
1906            assert_eq!(page.matches("\nCommands:\n").count(), 1, "{page}");
1907            let core = page.find("\nCore commands:\n").expect("core section");
1908            let maintenance = page.find("\nMaintenance:\n").expect("maintenance section");
1909            assert!(commands < core && commands < maintenance, "{page}");
1910            let default_end = core.min(maintenance);
1911            assert!(page[commands..default_end].contains("status"), "{page}");
1912            assert!(page[commands..default_end].contains("help"), "{page}");
1913            assert!(page[core..].contains("run"), "{page}");
1914            assert!(page[maintenance..].contains("clean"), "{page}");
1915        }
1916    }
1917
1918    #[test]
1919    fn test_render_help_with_next_line_layout() {
1920        let spec = crate::spec! { r#"
1921bin "testcli"
1922next_line_help #true
1923arg "<input>" help="Input file" env="INPUT" default="fast" {
1924    choices {
1925        choice "fast"
1926        choice "slow"
1927    }
1928}
1929flag "--verbose" help="Enable verbose output"
1930cmd "run" help="Run it"
1931        "# }
1932        .unwrap();
1933
1934        let short = render_help(&spec, &spec.cmd, false);
1935        assert!(!short.contains("    Run it\n\n  help"), "{short}");
1936        for page in [short, render_help(&spec, &spec.cmd, true)] {
1937            assert!(page.contains("  [input]\n    Input file"), "{page}");
1938            assert!(
1939                page.contains("--verbose\n    Enable verbose output"),
1940                "{page}"
1941            );
1942            assert!(
1943                page.contains(
1944                    "    [possible values: fast, slow]\n    [env: INPUT]\n    (default: fast)"
1945                ),
1946                "{page}"
1947            );
1948            assert!(page.contains("  run\n    Run it"), "{page}");
1949        }
1950    }
1951
1952    #[test]
1953    fn flatten_help_expands_subcommands_instead_of_listing_them() {
1954        let spec = crate::spec! { r#"
1955bin "testcli"
1956flatten_help #true
1957next_line_help #true
1958cmd "run" help="Run it" {
1959    arg "<task>" help="Task name" env="TASK" default="build" {
1960        choices {
1961            choice "build"
1962            choice "test"
1963        }
1964    }
1965    flag "--dry-run" help="Only show changes"
1966    flatten_help #true
1967    cmd "nested" help="Nested operation" {
1968        flag "--deep" help="Deep option"
1969    }
1970}
1971        "# }
1972        .unwrap();
1973
1974        for page in [
1975            render_help(&spec, &spec.cmd, false),
1976            render_help(&spec, &spec.cmd, true),
1977        ] {
1978            assert!(
1979                page.contains("Usage: testcli\n       testcli run"),
1980                "{page}"
1981            );
1982            assert!(!page.contains("\nCommands:\n"), "{page}");
1983            assert!(page.contains("\nrun:\nRun it"), "{page}");
1984            assert!(page.contains("[task]"), "{page}");
1985            assert!(page.contains("--dry-run"), "{page}");
1986            assert!(page.contains("\nrun nested:\nNested operation"), "{page}");
1987            assert!(page.contains("--deep"), "{page}");
1988            assert!(
1989                page.contains(
1990                    "    [possible values: build, test]\n    [env: TASK]\n    (default: build)"
1991                ),
1992                "{page}"
1993            );
1994        }
1995    }
1996
1997    #[test]
1998    fn styled_help_colours_semantics_and_template_styles() {
1999        let spec = crate::spec! { r#"
2000bin "testcli"
2001help_template "{$bright-blue}My tool{/$}\n\n{{usage}}\n\n{{flags}}"
2002flag "--output <FILE>" help="Write **the file**"
2003        "# }
2004        .unwrap();
2005
2006        let plain = render_help(&spec, &spec.cmd, true);
2007        assert!(plain.contains("My tool"), "{plain}");
2008        assert!(!plain.contains('\u{1b}'), "{plain:?}");
2009
2010        let coloured = render_help_styled(&spec, &spec.cmd, true, Style::COLOURED);
2011        assert!(
2012            coloured.contains("\u{1b}[94mMy tool\u{1b}[0m"),
2013            "{coloured:?}"
2014        );
2015        assert!(
2016            coloured.contains("\u{1b}[1;33mUsage:\u{1b}[0m"),
2017            "{coloured:?}"
2018        );
2019        assert!(
2020            coloured.contains("\u{1b}[1;32m--output\u{1b}[0m"),
2021            "{coloured:?}"
2022        );
2023        assert!(
2024            coloured.contains("\u{1b}[1;35m<FILE>\u{1b}[0m"),
2025            "{coloured:?}"
2026        );
2027        assert!(
2028            coloured.contains("\u{1b}[1mthe file\u{1b}[22m"),
2029            "{coloured:?}"
2030        );
2031    }
2032}