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 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}