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    flag_usages: Vec<String>,
51    arg_usages: Vec<String>,
52    synopsis: Vec<String>,
53}
54
55impl Styling {
56    pub(super) fn new(
57        command: &SpecCommand,
58        global_flags: &[SpecFlag],
59        usage_section: &str,
60    ) -> Self {
61        let mut headings = vec!["Examples".to_string()];
62        headings.extend(command.subcommand_groups.iter().map(|group| {
63            group
64                .heading
65                .clone()
66                .or_else(|| command.subcommand_help_heading.clone())
67                .unwrap_or_else(|| "Commands".to_string())
68        }));
69        headings.extend(command.arg_groups.iter().map(|group| {
70            group
71                .heading
72                .clone()
73                .unwrap_or_else(|| "Arguments".to_string())
74        }));
75        headings.extend(
76            command
77                .flag_groups
78                .iter()
79                .map(|group| group.heading.clone().unwrap_or_else(|| "Flags".to_string())),
80        );
81        if !global_flags.is_empty() {
82            headings.push("Global flags".to_string());
83        }
84
85        let mut flag_usages = command
86            .flag_groups
87            .iter()
88            .flat_map(|group| group.items.iter())
89            .map(|flag| flag.display_usage.clone())
90            .chain(global_flags.iter().map(|flag| flag.display_usage.clone()))
91            .collect();
92        let mut arg_usages = command
93            .arg_groups
94            .iter()
95            .flat_map(|group| group.items.iter())
96            .map(|arg| arg.usage.trim().to_string())
97            .collect();
98        collect_flattened(
99            &command.flattened_subcommands,
100            &mut headings,
101            &mut flag_usages,
102            &mut arg_usages,
103        );
104        flag_usages.sort_unstable_by_key(|usage| std::cmp::Reverse(usage.len()));
105        arg_usages.sort_unstable_by_key(|usage| std::cmp::Reverse(usage.len()));
106        Self {
107            headings,
108            flag_usages,
109            arg_usages,
110            synopsis: usage_section.lines().map(str::to_string).collect(),
111        }
112    }
113
114    pub(super) fn apply(&self, page: &str, style: Style) -> String {
115        if !style.coloured {
116            return page.to_string();
117        }
118        let mut out = String::with_capacity(page.len());
119        for line in page.split_inclusive('\n') {
120            let (body, newline) = line
121                .strip_suffix('\n')
122                .map_or((line, ""), |body| (body, "\n"));
123            if self.synopsis.iter().any(|known| known == body) && body.starts_with("Usage:") {
124                let usage = body.strip_prefix("Usage:").unwrap_or_default();
125                out.push_str(&style.semantic("heading", "Usage:"));
126                out.push_str(&styled_usage(usage, style));
127            } else if self.synopsis.iter().any(|known| known == body) {
128                out.push_str(&styled_usage(body, style));
129            } else if body
130                .strip_suffix(':')
131                .is_some_and(|heading| self.headings.iter().any(|known| known == heading))
132            {
133                out.push_str(&style.semantic("heading", body));
134            } else {
135                let styled = body.strip_prefix("  ").and_then(|entry| {
136                    self.flag_usages
137                        .iter()
138                        .find_map(|usage| style_entry(entry, usage, style))
139                        .or_else(|| {
140                            self.arg_usages
141                                .iter()
142                                .find_map(|usage| style_entry(entry, usage, style))
143                        })
144                });
145                let body = styled.as_deref().unwrap_or(body);
146                if body.trim_start().starts_with("$ ") {
147                    out.push_str(body);
148                } else {
149                    out.push_str(&style.inline(body));
150                }
151            }
152            out.push_str(newline);
153        }
154        out
155    }
156}
157
158fn collect_flattened(
159    commands: &[SpecCommand],
160    headings: &mut Vec<String>,
161    flags: &mut Vec<String>,
162    args: &mut Vec<String>,
163) {
164    for command in commands {
165        headings.push(command.full_cmd.join(" "));
166        flags.extend(
167            command
168                .flag_groups
169                .iter()
170                .flat_map(|group| group.items.iter())
171                .map(|flag| flag.display_usage.clone()),
172        );
173        args.extend(
174            command
175                .arg_groups
176                .iter()
177                .flat_map(|group| group.items.iter())
178                .map(|arg| arg.usage.trim().to_string()),
179        );
180        collect_flattened(&command.flattened_subcommands, headings, flags, args);
181    }
182}
183
184fn style_entry(entry: &str, usage: &str, style: Style) -> Option<String> {
185    entry
186        .strip_prefix(usage)
187        .filter(|rest| rest.is_empty() || rest.starts_with(char::is_whitespace))
188        .map(|rest| format!("  {}{rest}", styled_usage(usage, style)))
189}
190
191fn styled_usage(usage: &str, style: Style) -> String {
192    let mut out = String::with_capacity(usage.len());
193    let mut at = 0;
194    while at < usage.len() {
195        let rest = &usage[at..];
196        let previous = usage[..at].chars().next_back();
197        if rest.starts_with('-')
198            && previous.is_none_or(|c| c.is_whitespace() || matches!(c, ',' | ':' | '[' | '<'))
199        {
200            let end = rest
201                .char_indices()
202                .skip(1)
203                .find_map(|(index, c)| {
204                    (c.is_whitespace() || matches!(c, ',' | '=' | '[' | ']' | '<' | '>'))
205                        .then_some(index)
206                })
207                .unwrap_or(rest.len());
208            out.push_str(&style.semantic("option", &rest[..end]));
209            at += end;
210            continue;
211        }
212        if rest.starts_with("<-") {
213            out.push('<');
214            at += 1;
215            continue;
216        }
217        if rest.starts_with('<') {
218            if let Some(end) = rest.find('>') {
219                let end = end + 1;
220                out.push_str(&style.semantic("metavar", &rest[..end]));
221                at += end;
222                continue;
223            }
224        }
225        if let Some(value) = rest.strip_prefix("[=") {
226            if let Some(end) = value.find(']') {
227                out.push_str("[=");
228                out.push_str(&style.semantic("metavar", &value[..end]));
229                out.push(']');
230                at += end + 3;
231                continue;
232            }
233        }
234        if let Some(value) = rest.strip_prefix('=') {
235            out.push('=');
236            at += 1;
237            if !value.starts_with('<') {
238                let end = value
239                    .find(|c: char| c.is_whitespace() || matches!(c, ',' | ']' | '>'))
240                    .unwrap_or(value.len());
241                if end > 0 {
242                    out.push_str(&style.semantic("metavar", &value[..end]));
243                    at += end;
244                }
245            }
246            continue;
247        }
248        if previous == Some('[') && !rest.starts_with('-') {
249            let end = rest.find(']').unwrap_or(rest.len());
250            if end > 0 {
251                out.push_str(&style.semantic("metavar", &rest[..end]));
252                at += end;
253                continue;
254            }
255        }
256        if rest.starts_with(|c: char| c.is_ascii_uppercase())
257            && previous.is_none_or(|c| c.is_whitespace() || matches!(c, '=' | '[' | '<'))
258        {
259            let end = rest
260                .find(|c: char| {
261                    !(c.is_ascii_uppercase() || c.is_ascii_digit() || matches!(c, '_' | '-' | '@'))
262                })
263                .unwrap_or(rest.len());
264            let boundary = rest[end..].chars().next();
265            if boundary.is_none_or(|c| {
266                c.is_whitespace() || matches!(c, ',' | '=' | '[' | ']' | '<' | '>' | '.')
267            }) {
268                out.push_str(&style.semantic("metavar", &rest[..end]));
269                at += end;
270                continue;
271            }
272        }
273        let ch = rest.chars().next().expect("at is on a character boundary");
274        out.push(ch);
275        at += ch.len_utf8();
276    }
277    out
278}
279
280fn styled_inline(text: &str, parent: Option<&str>) -> String {
281    let mut out = String::with_capacity(text.len());
282    let mut at = 0;
283    let mut allow_run_remainder = false;
284    while at < text.len() {
285        let rest = &text[at..];
286        if let Some(escaped) = rest
287            .strip_prefix('\\')
288            .and_then(|after| after.chars().next())
289        {
290            if matches!(escaped, '*' | '_' | '~' | '`' | '\\') {
291                out.push(escaped);
292                at += 1 + escaped.len_utf8();
293                allow_run_remainder = false;
294                continue;
295            }
296        }
297        let span = [
298            ("***", "1;3", "22;23", false, true),
299            ("___", "1;3", "22;23", true, true),
300            ("**", "1", "22", false, true),
301            ("__", "1", "22", true, true),
302            ("~~", "9", "29", false, true),
303            ("*", "3", "23", false, true),
304            ("_", "3", "23", true, true),
305            ("`", "36", "39", false, false),
306        ]
307        .into_iter()
308        .find_map(|(delimiter, open, close, word_boundary, recurse)| {
309            rest.strip_prefix(delimiter)?;
310            let marker = delimiter.chars().next()?;
311            let previous = text[..at].chars().next_back();
312            if (previous == Some(marker) && !allow_run_remainder)
313                || (delimiter.len() == 1 && rest[delimiter.len()..].starts_with(marker))
314                || (word_boundary && previous.is_some_and(char::is_alphanumeric))
315            {
316                return None;
317            }
318            let content_start = at + delimiter.len();
319            let (end, after) = closing_delimiter(text, content_start, delimiter, word_boundary, 0)?;
320            Some((delimiter, open, close, recurse, content_start, end, after))
321        });
322        if let Some((delimiter, open, close, recurse, content_start, end, after)) = span {
323            out.push_str("\u{1b}[");
324            out.push_str(open);
325            out.push('m');
326            if recurse {
327                out.push_str(&styled_inline(&text[content_start..end], Some(open)));
328            } else {
329                out.push_str(&text[content_start..end]);
330            }
331            out.push_str("\u{1b}[");
332            out.push_str(close);
333            out.push('m');
334            if let Some(parent) = parent {
335                out.push_str("\u{1b}[");
336                out.push_str(parent);
337                out.push('m');
338            }
339            let marker = delimiter.chars().next().expect("a delimiter has a marker");
340            allow_run_remainder =
341                text[after..].starts_with(marker) && text[..after].ends_with(marker);
342            at = after;
343            continue;
344        }
345        let ch = rest.chars().next().expect("at is on a character boundary");
346        out.push(ch);
347        at += ch.len_utf8();
348        allow_run_remainder = false;
349    }
350    out
351}
352
353fn closing_delimiter(
354    text: &str,
355    content_start: usize,
356    delimiter: &str,
357    word_boundary: bool,
358    reserve: usize,
359) -> Option<(usize, usize)> {
360    let marker = delimiter.chars().next()?;
361    let width = delimiter.len();
362    let mut search_at = content_start;
363    while let Some(found) = text[search_at..].find(marker) {
364        let run_start = search_at + found;
365        let run_len = text[run_start..]
366            .chars()
367            .take_while(|ch| *ch == marker)
368            .count();
369        let run_end = run_start + run_len;
370        let escaped = text[..run_start]
371            .chars()
372            .rev()
373            .take_while(|ch| *ch == '\\')
374            .count()
375            % 2
376            == 1;
377        if escaped {
378            search_at = run_start + marker.len_utf8();
379            continue;
380        }
381        let nested_width = match (run_len, marker) {
382            (1..=3, '*' | '_') if run_len != width => run_len,
383            _ => 0,
384        };
385        if nested_width != 0 {
386            let nested = &text[run_start..run_start + nested_width];
387            if let Some((_, after)) =
388                closing_delimiter(text, run_start + nested_width, nested, marker == '_', width)
389            {
390                search_at = after;
391                continue;
392            }
393        }
394        if run_len >= width {
395            let after = run_start + width;
396            let left_in_run = run_end - after;
397            let boundary_ok = !word_boundary
398                || !text[after..]
399                    .chars()
400                    .next()
401                    .is_some_and(char::is_alphanumeric);
402            if run_start > content_start
403                && !text[content_start..run_start].trim().is_empty()
404                && (left_in_run == 0 || left_in_run >= reserve)
405                && boundary_ok
406            {
407                return Some((run_start, after));
408            }
409        }
410        search_at = run_end;
411    }
412    None
413}