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 };
14 pub const COLOURED: Style = Style { coloured: true };
16
17 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 .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}