Skip to main content

rich_ext/cli_doc/
help.rs

1//! Help output (#38, #407): usage, about, option groups, arguments,
2//! subcommands, examples and extra sections.
3//!
4//! ## Layout
5//!
6//! Entries (`-w, --width <SIZE>` plus help) are laid out in two columns when
7//! they fit, else stacked:
8//!
9//! - **Natural:** when the width holds the widest names, a two-cell gap and
10//!   the widest help line, the names column is exactly as wide as the widest
11//!   names and nothing wraps.
12//! - **Two-column:** from [`STACK_BELOW`] cells, the names column is capped at
13//!   40% of the width; wider names sit on their own line with the help below,
14//!   in the help column. Help wraps within its column.
15//! - **Stacked:** below [`STACK_BELOW`] cells each entry's names take a line
16//!   and its help follows, indented six cells.
17//!
18//! The layout depends only on the width and the content, and measuring
19//! reports the natural width, so a help view rendered at its measured width
20//! (inside a fitted panel, for example) lays out exactly as it did at full
21//! width.
22
23use super::spec::{ArgSpec, CommandSpec};
24use super::{join_lines, paragraphs, style, wrap_indented};
25use rich::measure::Measurement;
26use rich::{Console, ConsoleOptions, Renderable, Segment, Style, Text};
27
28/// Widths below this many cells use the stacked layout (unless every entry
29/// fits naturally).
30pub const STACK_BELOW: usize = 60;
31const INDENT: usize = 2;
32const GAP: usize = 2;
33const STACK_INDENT: usize = 6;
34const USAGE_LABEL: &str = "Usage: ";
35
36/// A renderable help page for a [`CommandSpec`].
37///
38/// ```
39/// use rich::Console;
40/// use rich_ext::cli_doc::{ArgSpec, CommandSpec, HelpView};
41///
42/// let spec = CommandSpec::new("tool")
43///     .arg(ArgSpec::option("level").choices(["low", "high"]).default_value("low").help("How much"));
44/// let out = Console::builder().width(80).build().render_to_string(&HelpView::new(&spec));
45/// assert!(out.contains("--level <LEVEL>  How much [default: low] [possible values: low, high]"));
46/// ```
47#[derive(Clone, Debug)]
48pub struct HelpView {
49    spec: CommandSpec,
50    long: bool,
51    command_path: Option<String>,
52}
53
54/// One help row: names, help (with hints) and an optional list of choices.
55struct Entry {
56    names: Text,
57    help: Text,
58    choices: Vec<Text>,
59}
60
61enum Mode {
62    /// Two columns; the names column is this wide.
63    Columns(usize),
64    Stacked,
65}
66
67impl HelpView {
68    pub fn new(spec: &CommandSpec) -> Self {
69        HelpView {
70            spec: spec.clone(),
71            long: false,
72            command_path: None,
73        }
74    }
75
76    /// The help for the subcommand at `path` below `root` (such as
77    /// `["config", "show"]`), with the full command path in its usage.
78    pub fn for_path(root: &CommandSpec, path: &[&str]) -> Option<Self> {
79        let mut spec = root;
80        let mut shown = root.display_name().to_string();
81        for name in path {
82            spec = spec.find_subcommand(name)?;
83            shown.push(' ');
84            shown.push_str(&spec.name);
85        }
86        Some(HelpView::new(spec).command_path(shown))
87    }
88
89    /// Long help (`--help`): long about and long argument help where given,
90    /// and a blank line between entries.
91    pub fn long(mut self, long: bool) -> Self {
92        self.long = long;
93        self
94    }
95
96    /// The command as usage shows it, such as `rich config`.
97    pub fn command_path(mut self, path: impl Into<String>) -> Self {
98        self.command_path = Some(path.into());
99        self
100    }
101
102    fn usage_lines(&self) -> Vec<String> {
103        match &self.command_path {
104            Some(path) => self.spec.usage_lines_as(path),
105            None => self.spec.usage_lines(),
106        }
107    }
108
109    fn about(&self) -> &str {
110        match (&self.spec.long_about, self.long) {
111            (Some(long), true) => long,
112            _ => &self.spec.about,
113        }
114    }
115
116    /// `indent_long`: pad a long-only option so its `--` lines up with the
117    /// longs of options that have a short, as clap does.
118    fn arg_entry(&self, c: &Console, arg: &ArgSpec, indent_long: bool) -> Entry {
119        let option = style(c, "help.option");
120        let metavar = style(c, "help.metavar");
121        let hint = style(c, "help.hint");
122        let mut names = Text::new("");
123        if arg.positional {
124            append(&mut names, &arg.names(), &metavar);
125        } else {
126            if indent_long && arg.short.is_none() {
127                names.append("    ", None);
128            }
129            for (i, switch) in arg.switches().iter().enumerate() {
130                if i > 0 {
131                    names.append(", ", None);
132                }
133                append(&mut names, switch, &option);
134            }
135            if let Some(value) = arg.metavar() {
136                names.append(" ", None);
137                append(&mut names, &value, &metavar);
138            }
139        }
140        let text = match (&arg.long_help, self.long) {
141            (Some(long), true) => long.as_str(),
142            _ => arg.help.as_str(),
143        };
144        let mut help = Text::new("");
145        append(&mut help, text.trim_end(), &style(c, "help.description"));
146        let mut hints = Vec::new();
147        if let Some(default) = &arg.default {
148            hints.push(format!("[default: {default}]"));
149        }
150        if let Some(env) = &arg.env {
151            hints.push(format!("[env: {env}]"));
152        }
153        if let Some(key) = &arg.config_key {
154            hints.push(format!("[config: {key}]"));
155        }
156        let choices = arg.choice_list();
157        let described = choices.iter().any(|choice| !choice.help.is_empty());
158        if !choices.is_empty() && !described {
159            let values: Vec<&str> = choices.iter().map(|c| c.value.as_str()).collect();
160            hints.push(format!("[possible values: {}]", values.join(", ")));
161        }
162        for hint_text in hints {
163            if !help.is_empty() {
164                help.append(" ", None);
165            }
166            append(&mut help, &hint_text, &hint);
167        }
168        let mut list = Vec::new();
169        if described {
170            list.push(Text::styled("Possible values:", hint.clone()));
171            for choice in choices {
172                let mut line = Text::new("- ");
173                append(&mut line, &choice.value, &metavar);
174                if !choice.help.is_empty() {
175                    line.append(": ", None);
176                    append(&mut line, &choice.help, &style(c, "help.description"));
177                }
178                list.push(line);
179            }
180        }
181        Entry {
182            names,
183            help,
184            choices: list,
185        }
186    }
187
188    fn command_entry(&self, c: &Console, command: &CommandSpec) -> Entry {
189        let mut names = Text::new("");
190        append(&mut names, &command.name, &style(c, "help.command"));
191        let mut help = Text::new("");
192        let about = paragraphs(&command.about)
193            .into_iter()
194            .next()
195            .unwrap_or_default();
196        append(&mut help, &about, &style(c, "help.description"));
197        if !command.aliases.is_empty() {
198            if !help.is_empty() {
199                help.append(" ", None);
200            }
201            let aliases = format!("[aliases: {}]", command.aliases.join(", "));
202            append(&mut help, &aliases, &style(c, "help.hint"));
203        }
204        Entry {
205            names,
206            help,
207            choices: Vec::new(),
208        }
209    }
210
211    /// Titled groups of entries, in display order.
212    fn groups(&self, c: &Console) -> Vec<(String, Option<String>, Vec<Entry>)> {
213        let indent_long = self
214            .spec
215            .visible_args()
216            .any(|a| !a.positional && a.short.is_some());
217        let mut out: Vec<(String, Option<String>, Vec<Entry>)> = self
218            .spec
219            .groups()
220            .into_iter()
221            .map(|(title, args)| {
222                let note = self.spec.note_for(&title).map(str::to_string);
223                let entries = args
224                    .into_iter()
225                    .map(|a| self.arg_entry(c, a, indent_long))
226                    .collect();
227                (title, note, entries)
228            })
229            .collect();
230        let commands: Vec<Entry> = self
231            .spec
232            .visible_subcommands()
233            .map(|command| self.command_entry(c, command))
234            .collect();
235        if !commands.is_empty() {
236            let title = self
237                .spec
238                .subcommand_heading
239                .clone()
240                .unwrap_or_else(|| "Commands".into());
241            let note = self.spec.note_for(&title).map(str::to_string);
242            out.push((title, note, commands));
243        }
244        out
245    }
246
247    /// The width at which every entry fits in two columns without wrapping,
248    /// and the widest names.
249    fn natural_columns(groups: &[(String, Option<String>, Vec<Entry>)]) -> (usize, usize) {
250        let entries = groups.iter().flat_map(|(_, _, entries)| entries);
251        let names = entries
252            .clone()
253            .map(|e| e.names.cell_len())
254            .max()
255            .unwrap_or(0);
256        let help = entries
257            .flat_map(|e| std::iter::once(&e.help).chain(&e.choices))
258            .map(|t| t.measurement().1)
259            .max()
260            .unwrap_or(0);
261        (INDENT + names + GAP + help, names)
262    }
263
264    /// The widest line of everything outside the entries, unwrapped.
265    fn natural_prose(&self, groups: &[(String, Option<String>, Vec<Entry>)]) -> usize {
266        let widest = |text: &str| text.lines().map(rich::cells::cell_len).max().unwrap_or(0);
267        let mut width = self
268            .usage_lines()
269            .iter()
270            .map(|line| USAGE_LABEL.len() + widest(line))
271            .max()
272            .unwrap_or(0);
273        width = width.max(widest(self.about()));
274        for (title, note, _) in groups {
275            width = width.max(heading_text(title).len());
276            if let Some(note) = note {
277                width = width.max(INDENT + widest(note));
278            }
279        }
280        for example in &self.spec.examples {
281            width = width
282                .max(INDENT + widest(&example.description))
283                .max(2 * INDENT + 2 + widest(&example.command));
284        }
285        for section in &self.spec.sections {
286            let indent = if section.title.is_empty() { 0 } else { INDENT };
287            width = width
288                .max(heading_text(&section.title).len())
289                .max(indent + widest(&section.body));
290        }
291        width
292    }
293
294    fn mode(width: usize, natural: usize, names: usize) -> Mode {
295        if width >= natural {
296            Mode::Columns(names)
297        } else if width >= STACK_BELOW {
298            Mode::Columns(names.min(width * 2 / 5))
299        } else {
300            Mode::Stacked
301        }
302    }
303
304    fn render_entry(
305        &self,
306        c: &Console,
307        entry: &Entry,
308        mode: &Mode,
309        width: usize,
310    ) -> Vec<Vec<Segment>> {
311        let theme = c.theme();
312        let plain = Style::new();
313        let mut rows = Vec::new();
314        let (help_indent, names_inline) = match mode {
315            Mode::Columns(col) => (INDENT + col + GAP, entry.names.cell_len() <= *col),
316            Mode::Stacked => (STACK_INDENT, false),
317        };
318        let mut help_lines = if entry.help.is_empty() {
319            Vec::new()
320        } else {
321            wrap_indented(c, &entry.help, help_indent, width)
322        };
323        for choice in &entry.choices {
324            let inner = width.saturating_sub(help_indent + 2).max(1);
325            let lines = choice.render_lines(theme, &plain, Some(inner));
326            for (i, line) in lines.into_iter().enumerate() {
327                let pad = help_indent + if i == 0 { 0 } else { 2 };
328                let mut row = vec![Segment::new(" ".repeat(pad), None)];
329                row.extend(line);
330                help_lines.push(row);
331            }
332        }
333        if names_inline && !help_lines.is_empty() {
334            let mut first = vec![Segment::new(" ".repeat(INDENT), None)];
335            first.extend(entry.names.render(theme, &plain));
336            let used = INDENT + entry.names.cell_len();
337            // The help line already carries its full indent; keep only the
338            // part past the names.
339            let mut help = help_lines.remove(0);
340            if let Some(lead) = help.first_mut() {
341                lead.text = " ".repeat(help_indent - used);
342            }
343            first.extend(help);
344            rows.push(first);
345        } else {
346            rows.extend(wrap_indented(c, &entry.names, INDENT, width));
347        }
348        rows.extend(help_lines);
349        rows
350    }
351}
352
353fn append(text: &mut Text, value: &str, style: &Style) {
354    if style.is_null() {
355        text.append(value, None);
356    } else {
357        text.append(value, Some(style.clone().into()));
358    }
359}
360
361fn heading_text(title: &str) -> String {
362    if title.ends_with(':') {
363        title.to_string()
364    } else {
365        format!("{title}:")
366    }
367}
368
369impl Renderable for HelpView {
370    fn rich_render(&self, c: &Console, o: &ConsoleOptions) -> Vec<Segment> {
371        let width = o.max_width;
372        if width == 0 {
373            return Vec::new();
374        }
375        let groups = self.groups(c);
376        let (natural, names) = Self::natural_columns(&groups);
377        let mode = Self::mode(width, natural, names);
378        let heading = style(c, "help.heading");
379        let heading_row =
380            |title: &str| vec![Segment::new(heading_text(title), Some(heading.clone()))];
381        let mut rows: Vec<Vec<Segment>> = Vec::new();
382
383        // Usage: the label, then each line aligned after it.
384        let usage_style = style(c, "help.usage");
385        let command = self
386            .command_path
387            .clone()
388            .unwrap_or_else(|| self.spec.display_name().to_string());
389        for (i, line) in self.usage_lines().iter().enumerate() {
390            let mut text = Text::new("");
391            match line.strip_prefix(&command) {
392                Some(rest) => {
393                    append(&mut text, &command, &usage_style);
394                    text.append(rest, None);
395                }
396                None => text.append(line, None),
397            }
398            let mut lines = wrap_indented(c, &text, USAGE_LABEL.len(), width);
399            if i == 0 {
400                if let Some(first) = lines.first_mut() {
401                    first[0] = Segment::new(USAGE_LABEL.trim_end(), Some(heading.clone()));
402                    first.insert(1, Segment::new(" ", None));
403                }
404            }
405            rows.extend(lines);
406        }
407
408        let about = paragraphs(self.about());
409        if !about.is_empty() {
410            for paragraph in about {
411                rows.push(Vec::new());
412                rows.extend(wrap_indented(c, &Text::new(paragraph), 0, width));
413            }
414        }
415
416        for (title, note, entries) in &groups {
417            rows.push(Vec::new());
418            rows.push(heading_row(title));
419            if let Some(note) = note {
420                rows.extend(wrap_indented(c, &Text::new(note.as_str()), INDENT, width));
421            }
422            for (i, entry) in entries.iter().enumerate() {
423                if self.long && i > 0 {
424                    rows.push(Vec::new());
425                }
426                rows.extend(self.render_entry(c, entry, &mode, width));
427            }
428        }
429
430        if !self.spec.examples.is_empty() {
431            rows.push(Vec::new());
432            rows.push(heading_row("Examples"));
433            let example = style(c, "help.example");
434            for item in &self.spec.examples {
435                let mut indent = INDENT;
436                if !item.description.is_empty() {
437                    rows.extend(wrap_indented(
438                        c,
439                        &Text::new(item.description.as_str()),
440                        INDENT,
441                        width,
442                    ));
443                    indent += INDENT;
444                }
445                let mut command = Text::new("");
446                append(&mut command, &format!("$ {}", item.command), &example);
447                rows.extend(wrap_indented(c, &command, indent, width));
448            }
449        }
450
451        for section in &self.spec.sections {
452            let indent = if section.title.is_empty() {
453                0
454            } else {
455                rows.push(Vec::new());
456                rows.push(heading_row(&section.title));
457                INDENT
458            };
459            for (i, paragraph) in paragraphs(&section.body).into_iter().enumerate() {
460                if i > 0 || section.title.is_empty() {
461                    rows.push(Vec::new());
462                }
463                rows.extend(wrap_indented(c, &Text::new(paragraph), indent, width));
464            }
465        }
466
467        // A leading blank row appears only when there is no usage at all.
468        while rows.first().is_some_and(Vec::is_empty) {
469            rows.remove(0);
470        }
471        if let Some(height) = o.height {
472            rows.truncate(height);
473        }
474        join_lines(rows)
475    }
476
477    fn measure(&self, c: &Console, o: &ConsoleOptions) -> Measurement {
478        let groups = self.groups(c);
479        let (columns, names) = Self::natural_columns(&groups);
480        let natural = columns.max(self.natural_prose(&groups));
481        let maximum = natural.min(o.max_width);
482        let word = |text: &str| {
483            text.split_whitespace()
484                .map(rich::cells::cell_len)
485                .max()
486                .unwrap_or(0)
487        };
488        let longest_word = groups
489            .iter()
490            .flat_map(|(_, _, entries)| entries)
491            .map(|e| word(e.help.plain()))
492            .chain(std::iter::once(word(self.about())))
493            .max()
494            .unwrap_or(0);
495        let minimum = (INDENT + names).max(STACK_INDENT + longest_word);
496        Measurement::new(minimum.min(maximum), maximum)
497    }
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503    use crate::cli_doc::{ArgSpec, ValueHint};
504
505    fn render(view: &HelpView, width: usize) -> String {
506        Console::builder()
507            .width(width)
508            .build()
509            .render_to_string(view)
510    }
511
512    #[test]
513    fn entries_without_help_render_names_only() {
514        let spec = CommandSpec::new("x").arg(ArgSpec::flag("quiet"));
515        assert_eq!(
516            render(&HelpView::new(&spec), 40),
517            "Usage: x [OPTIONS]\n\nOptions:\n  --quiet"
518        );
519    }
520
521    #[test]
522    fn value_hint_none_is_a_flag() {
523        assert!(!ArgSpec::flag("x").takes_value());
524        assert!(ArgSpec::flag("x").value(ValueHint::File).takes_value());
525    }
526}