Skip to main content

usage/docs/cli/
style.rs

1use crate::docs::models::{SpecCommand, SpecFlag};
2
3/// Whether terminal help is rendered with ANSI styling.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub struct Style {
6    pub(super) coloured: bool,
7}
8
9impl Style {
10    /// Plain text, suitable for a pipe or generated artifact.
11    ///
12    /// ANSI CSI escapes already present in authored help are removed as well.
13    pub const PLAIN: Style = Style { coloured: false };
14    /// ANSI-coloured text, regardless of the output destination.
15    pub const COLOURED: Style = Style { coloured: true };
16
17    /// Colour when stdout is a terminal and the environment permits it.
18    pub fn auto() -> Style {
19        use std::io::IsTerminal as _;
20        Self::auto_for(std::io::stdout().is_terminal())
21    }
22
23    fn auto_for(is_terminal: bool) -> Style {
24        let forced = std::env::var_os("CLICOLOR_FORCE").is_some_and(|value| value != "0");
25        let refused = std::env::var_os("NO_COLOR").is_some_and(|value| !value.is_empty());
26        if refused {
27            Style::PLAIN
28        } else if forced || is_terminal {
29            Style::COLOURED
30        } else {
31            Style::PLAIN
32        }
33    }
34
35    fn semantic(self, specification: &str, text: &str) -> String {
36        crate::help_template::semantic(specification, text, self.coloured)
37    }
38
39    fn inline(self, text: &str) -> String {
40        if self.coloured {
41            styled_inline(text, None)
42        } else {
43            text.to_string()
44        }
45    }
46}
47
48pub(super) struct Styling {
49    headings: Vec<String>,
50    command_usages: Vec<String>,
51    flag_usages: Vec<String>,
52    arg_usages: Vec<String>,
53    synopsis: Vec<String>,
54}
55
56impl Styling {
57    pub(super) fn new(
58        command: &SpecCommand,
59        global_flags: &[SpecFlag],
60        usage_section: &str,
61        show_help_subcommand: bool,
62    ) -> Self {
63        let mut headings = vec!["Examples".to_string()];
64        headings.extend(command.subcommand_groups.iter().map(|group| {
65            group
66                .heading
67                .clone()
68                .or_else(|| command.subcommand_help_heading.clone())
69                .unwrap_or_else(|| "Commands".to_string())
70        }));
71        headings.extend(command.arg_groups.iter().map(|group| {
72            group
73                .heading
74                .clone()
75                .unwrap_or_else(|| "Arguments".to_string())
76        }));
77        headings.extend(
78            command
79                .flag_groups
80                .iter()
81                .map(|group| group.heading.clone().unwrap_or_else(|| "Flags".to_string())),
82        );
83        if !global_flags.is_empty() {
84            headings.push("Global flags".to_string());
85        }
86
87        let mut command_usages: Vec<String> = if command.flatten_help {
88            Vec::new()
89        } else {
90            command
91                .subcommand_groups
92                .iter()
93                .flat_map(|group| group.items.iter())
94                .map(|command| command.name.clone())
95                .collect()
96        };
97        if !command_usages.is_empty() && show_help_subcommand {
98            command_usages.push("help".to_string());
99        }
100        command_usages.sort_unstable_by_key(|usage| std::cmp::Reverse(usage.len()));
101        let mut flag_usages = command
102            .flag_groups
103            .iter()
104            .flat_map(|group| group.items.iter())
105            .map(|flag| flag.display_usage.clone())
106            .chain(global_flags.iter().map(|flag| flag.display_usage.clone()))
107            .collect();
108        let mut arg_usages = command
109            .arg_groups
110            .iter()
111            .flat_map(|group| group.items.iter())
112            .map(|arg| arg.usage.trim().to_string())
113            .collect();
114        collect_flattened(
115            &command.flattened_subcommands,
116            &mut headings,
117            &mut flag_usages,
118            &mut arg_usages,
119        );
120        flag_usages.sort_unstable_by_key(|usage| std::cmp::Reverse(usage.len()));
121        arg_usages.sort_unstable_by_key(|usage| std::cmp::Reverse(usage.len()));
122        Self {
123            headings,
124            command_usages,
125            flag_usages,
126            arg_usages,
127            synopsis: usage_section.lines().map(str::to_string).collect(),
128        }
129    }
130
131    pub(super) fn apply(&self, page: &str, style: Style) -> String {
132        if !style.coloured {
133            return page.to_string();
134        }
135        let mut out = String::with_capacity(page.len());
136        for line in page.split_inclusive('\n') {
137            let (body, newline) = line
138                .strip_suffix('\n')
139                .map_or((line, ""), |body| (body, "\n"));
140            if self.synopsis.iter().any(|known| known == body) && body.starts_with("Usage:") {
141                let usage = body.strip_prefix("Usage:").unwrap_or_default();
142                out.push_str(&style.semantic("heading", "Usage:"));
143                out.push_str(&styled_usage(usage, style));
144            } else if self.synopsis.iter().any(|known| known == body) {
145                out.push_str(&styled_usage(body, style));
146            } else if body
147                .strip_suffix(':')
148                .is_some_and(|heading| self.headings.iter().any(|known| known == heading))
149            {
150                out.push_str(&style.semantic("heading", body));
151            } else {
152                let styled = body.strip_prefix("  ").and_then(|entry| {
153                    self.flag_usages
154                        .iter()
155                        .find_map(|usage| style_entry(entry, usage, style))
156                        .or_else(|| {
157                            self.arg_usages
158                                .iter()
159                                .find_map(|usage| style_entry(entry, usage, style))
160                        })
161                        .or_else(|| {
162                            self.command_usages
163                                .iter()
164                                .find_map(|usage| style_command_entry(entry, usage, style))
165                        })
166                });
167                let body = styled.as_deref().unwrap_or(body);
168                if body.trim_start().starts_with("$ ") {
169                    out.push_str(body);
170                } else {
171                    out.push_str(&style.inline(body));
172                }
173            }
174            out.push_str(newline);
175        }
176        out
177    }
178}
179
180fn collect_flattened(
181    commands: &[SpecCommand],
182    headings: &mut Vec<String>,
183    flags: &mut Vec<String>,
184    args: &mut Vec<String>,
185) {
186    for command in commands {
187        headings.push(command.full_cmd.join(" "));
188        flags.extend(
189            command
190                .flag_groups
191                .iter()
192                .flat_map(|group| group.items.iter())
193                .map(|flag| flag.display_usage.clone()),
194        );
195        args.extend(
196            command
197                .arg_groups
198                .iter()
199                .flat_map(|group| group.items.iter())
200                .map(|arg| arg.usage.trim().to_string()),
201        );
202        collect_flattened(&command.flattened_subcommands, headings, flags, args);
203    }
204}
205
206fn style_entry(entry: &str, usage: &str, style: Style) -> Option<String> {
207    entry
208        .strip_prefix(usage)
209        .filter(|rest| rest.is_empty() || rest.starts_with(char::is_whitespace))
210        .map(|rest| format!("  {}{rest}", styled_usage(usage, style)))
211}
212
213fn style_command_entry(entry: &str, usage: &str, style: Style) -> Option<String> {
214    entry
215        .strip_prefix(usage)
216        // A rendered row either ends after the name or has the table's two-space column
217        // separator. Group prose has the same indentation, but ordinary word spacing.
218        .filter(|rest| rest.is_empty() || rest.starts_with("  "))
219        .map(|rest| format!("  {}{rest}", style.semantic("command", usage)))
220}
221
222fn styled_usage(usage: &str, style: Style) -> String {
223    let mut out = String::with_capacity(usage.len());
224    let mut at = 0;
225    while at < usage.len() {
226        let rest = &usage[at..];
227        let previous = usage[..at].chars().next_back();
228        if rest.starts_with('-')
229            && previous.is_none_or(|c| c.is_whitespace() || matches!(c, ',' | ':' | '[' | '<'))
230        {
231            let end = rest
232                .char_indices()
233                .skip(1)
234                .find_map(|(index, c)| {
235                    (c.is_whitespace() || matches!(c, ',' | '=' | '[' | ']' | '<' | '>'))
236                        .then_some(index)
237                })
238                .unwrap_or(rest.len());
239            out.push_str(&style.semantic("option", &rest[..end]));
240            at += end;
241            continue;
242        }
243        if rest.starts_with("<-") {
244            out.push('<');
245            at += 1;
246            continue;
247        }
248        if rest.starts_with('<') {
249            if let Some(end) = rest.find('>') {
250                let end = end + 1;
251                out.push_str(&style.semantic("metavar", &rest[..end]));
252                at += end;
253                continue;
254            }
255        }
256        if let Some(value) = rest.strip_prefix("[=") {
257            if let Some(end) = value.find(']') {
258                out.push_str("[=");
259                out.push_str(&style.semantic("metavar", &value[..end]));
260                out.push(']');
261                at += end + 3;
262                continue;
263            }
264        }
265        if let Some(value) = rest.strip_prefix('=') {
266            out.push('=');
267            at += 1;
268            if !value.starts_with('<') {
269                let end = value
270                    .find(|c: char| c.is_whitespace() || matches!(c, ',' | ']' | '>'))
271                    .unwrap_or(value.len());
272                if end > 0 {
273                    out.push_str(&style.semantic("metavar", &value[..end]));
274                    at += end;
275                }
276            }
277            continue;
278        }
279        if previous == Some('[') && !rest.starts_with('-') {
280            let end = rest.find(']').unwrap_or(rest.len());
281            if end > 0 {
282                out.push_str(&style.semantic("metavar", &rest[..end]));
283                at += end;
284                continue;
285            }
286        }
287        if rest.starts_with(|c: char| c.is_ascii_uppercase())
288            && previous.is_none_or(|c| c.is_whitespace() || matches!(c, '=' | '[' | '<'))
289        {
290            let end = rest
291                .find(|c: char| {
292                    !(c.is_ascii_uppercase() || c.is_ascii_digit() || matches!(c, '_' | '-' | '@'))
293                })
294                .unwrap_or(rest.len());
295            let boundary = rest[end..].chars().next();
296            if boundary.is_none_or(|c| {
297                c.is_whitespace() || matches!(c, ',' | '=' | '[' | ']' | '<' | '>' | '.')
298            }) {
299                out.push_str(&style.semantic("metavar", &rest[..end]));
300                at += end;
301                continue;
302            }
303        }
304        let ch = rest.chars().next().expect("at is on a character boundary");
305        out.push(ch);
306        at += ch.len_utf8();
307    }
308    out
309}
310
311fn styled_inline(text: &str, parent: Option<&str>) -> String {
312    let mut out = String::with_capacity(text.len());
313    let mut at = 0;
314    let mut allow_run_remainder = false;
315    while at < text.len() {
316        let rest = &text[at..];
317        if let Some(escaped) = rest
318            .strip_prefix('\\')
319            .and_then(|after| after.chars().next())
320        {
321            if matches!(escaped, '*' | '_' | '~' | '`' | '\\') {
322                out.push(escaped);
323                at += 1 + escaped.len_utf8();
324                allow_run_remainder = false;
325                continue;
326            }
327        }
328        let span = [
329            ("***", "1;3", "22;23", false, true),
330            ("___", "1;3", "22;23", true, true),
331            ("**", "1", "22", false, true),
332            ("__", "1", "22", true, true),
333            ("~~", "9", "29", false, true),
334            ("*", "3", "23", false, true),
335            ("_", "3", "23", true, true),
336            ("`", "36", "39", false, false),
337        ]
338        .into_iter()
339        .find_map(|(delimiter, open, close, word_boundary, recurse)| {
340            rest.strip_prefix(delimiter)?;
341            let marker = delimiter.chars().next()?;
342            let previous = text[..at].chars().next_back();
343            if (previous == Some(marker) && !allow_run_remainder)
344                || (delimiter.len() == 1 && rest[delimiter.len()..].starts_with(marker))
345                || (word_boundary && previous.is_some_and(char::is_alphanumeric))
346            {
347                return None;
348            }
349            let content_start = at + delimiter.len();
350            let (end, after) = closing_delimiter(text, content_start, delimiter, word_boundary, 0)?;
351            Some((delimiter, open, close, recurse, content_start, end, after))
352        });
353        if let Some((delimiter, open, close, recurse, content_start, end, after)) = span {
354            out.push_str("\u{1b}[");
355            out.push_str(open);
356            out.push('m');
357            if recurse {
358                out.push_str(&styled_inline(&text[content_start..end], Some(open)));
359            } else {
360                out.push_str(&text[content_start..end]);
361            }
362            out.push_str("\u{1b}[");
363            out.push_str(close);
364            out.push('m');
365            if let Some(parent) = parent {
366                out.push_str("\u{1b}[");
367                out.push_str(parent);
368                out.push('m');
369            }
370            let marker = delimiter.chars().next().expect("a delimiter has a marker");
371            allow_run_remainder =
372                text[after..].starts_with(marker) && text[..after].ends_with(marker);
373            at = after;
374            continue;
375        }
376        let ch = rest.chars().next().expect("at is on a character boundary");
377        out.push(ch);
378        at += ch.len_utf8();
379        allow_run_remainder = false;
380    }
381    out
382}
383
384fn closing_delimiter(
385    text: &str,
386    content_start: usize,
387    delimiter: &str,
388    word_boundary: bool,
389    reserve: usize,
390) -> Option<(usize, usize)> {
391    let marker = delimiter.chars().next()?;
392    let width = delimiter.len();
393    let mut search_at = content_start;
394    while let Some(found) = text[search_at..].find(marker) {
395        let run_start = search_at + found;
396        let run_len = text[run_start..]
397            .chars()
398            .take_while(|ch| *ch == marker)
399            .count();
400        let run_end = run_start + run_len;
401        let escaped = text[..run_start]
402            .chars()
403            .rev()
404            .take_while(|ch| *ch == '\\')
405            .count()
406            % 2
407            == 1;
408        if escaped {
409            search_at = run_start + marker.len_utf8();
410            continue;
411        }
412        let nested_width = match (run_len, marker) {
413            (1..=3, '*' | '_') if run_len != width => run_len,
414            _ => 0,
415        };
416        if nested_width != 0 {
417            let nested = &text[run_start..run_start + nested_width];
418            if let Some((_, after)) =
419                closing_delimiter(text, run_start + nested_width, nested, marker == '_', width)
420            {
421                search_at = after;
422                continue;
423            }
424        }
425        if run_len >= width {
426            let after = run_start + width;
427            let left_in_run = run_end - after;
428            let boundary_ok = !word_boundary
429                || !text[after..]
430                    .chars()
431                    .next()
432                    .is_some_and(char::is_alphanumeric);
433            if run_start > content_start
434                && !text[content_start..run_start].trim().is_empty()
435                && (left_in_run == 0 || left_in_run >= reserve)
436                && boundary_ok
437            {
438                return Some((run_start, after));
439            }
440        }
441        search_at = run_end;
442    }
443    None
444}