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