1use crate::docs::models::{SpecCommand, SpecFlag};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub struct Style {
6 pub(super) coloured: bool,
7}
8
9impl Style {
10 pub const PLAIN: Style = Style { coloured: false };
12 pub const COLOURED: Style = Style { coloured: true };
14
15 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}