Skip to main content

usage/docs/cli/
mod.rs

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