1pub const SECTIONS: [&str; 10] = [
27 "about",
28 "usage",
29 "commands",
30 "args",
31 "flags",
32 "grouped_args",
33 "ungrouped_args",
34 "grouped_flags",
35 "ungrouped_flags",
36 "after_help",
37];
38
39pub const STYLES: [&str; 23] = [
41 "heading",
42 "option",
43 "metavar",
44 "black",
45 "red",
46 "green",
47 "yellow",
48 "blue",
49 "magenta",
50 "cyan",
51 "white",
52 "bright-black",
53 "bright-red",
54 "bright-green",
55 "bright-yellow",
56 "bright-blue",
57 "bright-magenta",
58 "bright-cyan",
59 "bright-white",
60 "bold",
61 "dim",
62 "italic",
63 "underline",
64];
65
66const MARK: char = '\u{2}';
67const END: char = '\u{3}';
68
69#[derive(Clone, Copy, Default)]
70struct AnsiStyle {
71 foreground: Option<u8>,
72 bold: bool,
73 dim: bool,
74 italic: bool,
75 underline: bool,
76}
77
78impl AnsiStyle {
79 fn apply(mut self, specification: &str) -> Option<Self> {
80 if specification.is_empty() {
81 return None;
82 }
83 for fragment in specification.split('+') {
84 match fragment {
85 "heading" => {
86 self.foreground = Some(33);
87 self.bold = true;
88 }
89 "option" => {
90 self.foreground = Some(32);
91 self.bold = true;
92 }
93 "metavar" => {
94 self.foreground = Some(35);
95 self.bold = true;
96 }
97 "black" => self.foreground = Some(30),
98 "red" => self.foreground = Some(31),
99 "green" => self.foreground = Some(32),
100 "yellow" => self.foreground = Some(33),
101 "blue" => self.foreground = Some(34),
102 "magenta" => self.foreground = Some(35),
103 "cyan" => self.foreground = Some(36),
104 "white" => self.foreground = Some(37),
105 "bright-black" => self.foreground = Some(90),
106 "bright-red" => self.foreground = Some(91),
107 "bright-green" => self.foreground = Some(92),
108 "bright-yellow" => self.foreground = Some(93),
109 "bright-blue" => self.foreground = Some(94),
110 "bright-magenta" => self.foreground = Some(95),
111 "bright-cyan" => self.foreground = Some(96),
112 "bright-white" => self.foreground = Some(97),
113 "bold" => self.bold = true,
114 "dim" => self.dim = true,
115 "italic" => self.italic = true,
116 "underline" => self.underline = true,
117 _ => return None,
118 }
119 }
120 Some(self)
121 }
122
123 fn write(self, out: &mut String) {
124 let mut separator = "";
125 out.push_str("\u{1b}[");
126 for (enabled, code) in [
127 (self.bold, 1),
128 (self.dim, 2),
129 (self.italic, 3),
130 (self.underline, 4),
131 ] {
132 if enabled {
133 out.push_str(separator);
134 out.push_str(&code.to_string());
135 separator = ";";
136 }
137 }
138 if let Some(foreground) = self.foreground {
139 out.push_str(separator);
140 out.push_str(&foreground.to_string());
141 separator = ";";
142 }
143 if separator.is_empty() {
144 out.push('0');
145 }
146 out.push('m');
147 }
148}
149
150#[cfg(feature = "cli-help")]
151pub(crate) fn semantic(specification: &str, text: &str, coloured: bool) -> String {
152 if !coloured {
153 return text.to_string();
154 }
155 let mut out = String::with_capacity(text.len() + 16);
156 AnsiStyle::default()
157 .apply(specification)
158 .unwrap_or_default()
159 .write(&mut out);
160 out.push_str(text);
161 AnsiStyle::default().write(&mut out);
162 out
163}
164
165pub fn is_set(template: &str) -> bool {
172 !template.trim().is_empty()
173}
174
175pub fn check(template: &str) -> Result<(), String> {
182 check_styles(template)?;
183 let mut rest = template;
184 while let Some(at) = rest.find("{{") {
185 let after = &rest[at + 2..];
186 let Some(end) = after.find("}}") else {
187 return Err(format!(
188 "help_template has a `{{{{` with no `}}}}` after it; the sections are {}",
189 SECTIONS.join(", ")
190 ));
191 };
192 let name = after[..end].trim();
193 if !SECTIONS.contains(&name) {
194 return Err(format!(
195 "help_template names no section \"{name}\"; a page is assembled from {} — \
196 reorder, omit or wrap those, and note that clap's `{{options}}` is \
197 `{{{{flags}}}}` here and its `{{positionals}}` is `{{{{args}}}}`",
198 SECTIONS.join(", ")
199 ));
200 }
201 rest = &after[end + 2..];
202 }
203 Ok(())
204}
205
206pub fn substitute(template: &str, section: impl Fn(&str) -> Option<String>) -> String {
215 substitute_with_style(template, false, section)
216}
217
218pub(crate) fn substitute_with_style(
219 template: &str,
220 coloured: bool,
221 section: impl FnMut(&str) -> Option<String>,
222) -> String {
223 if check_styles(template).is_err() {
224 return substitute_sections_only(template, section);
225 }
226 let mut marked = String::with_capacity(template.len());
227 let mut rest = template;
228 let mut section = section;
229 loop {
230 let placeholder = rest.find("{{").map(|at| (at, Event::Placeholder));
231 let style = next_style_event(rest);
232 let Some((at, event)) = earliest(placeholder, style) else {
233 push_escaped(&mut marked, rest);
234 break;
235 };
236 push_escaped(&mut marked, &rest[..at]);
237 rest = &rest[at..];
238 match event {
239 Event::Placeholder => {
240 let after = &rest[2..];
241 let Some(end) = after.find("}}") else {
242 push_escaped(&mut marked, rest);
243 break;
244 };
245 match section(after[..end].trim()) {
246 Some(text) => push_escaped(&mut marked, &text),
247 None => push_escaped(&mut marked, &rest[..2 + end + 2]),
248 }
249 rest = &after[end + 2..];
250 }
251 Event::Open => {
252 let Some(end) = rest.find('}') else {
253 push_escaped(&mut marked, rest);
254 break;
255 };
256 marked.push(MARK);
257 marked.push('+');
258 marked.push_str(&rest[2..end]);
259 marked.push(END);
260 rest = &rest[end + 1..];
261 }
262 Event::Close => {
263 marked.push(MARK);
264 marked.push('-');
265 marked.push(END);
266 rest = &rest[4..];
267 }
268 Event::EscapeOpen => {
269 push_escaped(&mut marked, "{$");
270 rest = &rest[3..];
271 }
272 Event::EscapeClose => {
273 push_escaped(&mut marked, "{/$}");
274 rest = &rest[5..];
275 }
276 }
277 }
278 render_marked(&collapse_styled_blank_runs(&marked), coloured)
279}
280
281fn check_styles(template: &str) -> Result<(), String> {
282 let mut rest = template;
283 let mut depth = 0usize;
284 while let Some((at, event)) = next_style_event(rest) {
285 let tag = &rest[at..];
286 match event {
287 Event::EscapeOpen => rest = &tag[3..],
288 Event::EscapeClose => rest = &tag[5..],
289 Event::Open => {
290 let Some(end) = tag.find('}') else {
291 return Err("help_template has a `{$` with no `}` after it".to_string());
292 };
293 let specification = &tag[2..end];
294 if specification.is_empty() {
295 return Err("help_template has an empty style tag `{$}`".to_string());
296 }
297 if let Some(unknown) = specification
298 .split('+')
299 .find(|fragment| !STYLES.contains(fragment))
300 {
301 return Err(format!(
302 "help_template names no style \"{unknown}\"; use {}",
303 STYLES.join(", ")
304 ));
305 }
306 depth += 1;
307 rest = &tag[end + 1..];
308 }
309 Event::Close => {
310 if depth == 0 {
311 return Err("help_template has a `{/$}` with no open style tag".to_string());
312 }
313 depth -= 1;
314 rest = &tag[4..];
315 }
316 Event::Placeholder => unreachable!("style scanning does not return placeholders"),
317 }
318 }
319 if depth == 0 {
320 Ok(())
321 } else {
322 Err("help_template has a style tag with no `{/$}` after it".to_string())
323 }
324}
325
326#[derive(Clone, Copy)]
327enum Event {
328 Placeholder,
329 Open,
330 Close,
331 EscapeOpen,
332 EscapeClose,
333}
334
335fn earliest(left: Option<(usize, Event)>, right: Option<(usize, Event)>) -> Option<(usize, Event)> {
336 match (left, right) {
337 (Some(left), Some(right)) => Some(if left.0 <= right.0 { left } else { right }),
338 (left, right) => left.or(right),
339 }
340}
341
342fn next_style_event(template: &str) -> Option<(usize, Event)> {
343 [
344 ("{$$", Event::EscapeOpen),
345 ("{/$$}", Event::EscapeClose),
346 ("{$", Event::Open),
347 ("{/$}", Event::Close),
348 ]
349 .into_iter()
350 .enumerate()
351 .filter_map(|(priority, (token, event))| template.find(token).map(|at| ((at, priority), event)))
352 .min_by_key(|(position, _)| *position)
353 .map(|((at, _), event)| (at, event))
354}
355
356fn push_escaped(out: &mut String, text: &str) {
357 if !text.contains(MARK) {
358 out.push_str(text);
359 return;
360 }
361 for ch in text.chars() {
362 out.push(ch);
363 if ch == MARK {
364 out.push(MARK);
365 }
366 }
367}
368
369fn collapse_styled_blank_runs(page: &str) -> String {
370 let mut out = String::with_capacity(page.len());
371 let mut blank = false;
372 let mut wrote_visible = false;
373 for line in page.split('\n') {
374 if visible_is_blank(line) {
375 push_markers(&mut out, line);
376 blank = wrote_visible;
377 continue;
378 }
379 if wrote_visible {
380 out.push('\n');
381 if blank {
382 out.push('\n');
383 }
384 }
385 blank = false;
386 out.push_str(line);
387 wrote_visible = true;
388 }
389 out
390}
391
392fn visible_is_blank(line: &str) -> bool {
393 let mut rest = line;
394 while let Some(at) = rest.find(MARK) {
395 if !rest[..at].trim().is_empty() {
396 return false;
397 }
398 let after = &rest[at + MARK.len_utf8()..];
399 if after.starts_with(MARK) {
400 return false;
401 } else if let Some(end) = after.find(END) {
402 rest = &after[end + END.len_utf8()..];
403 } else {
404 return false;
405 }
406 }
407 rest.trim().is_empty()
408}
409
410fn push_markers(out: &mut String, line: &str) {
411 let mut rest = line;
412 while let Some(at) = rest.find(MARK) {
413 let marker = &rest[at..];
414 let Some(end) = marker.find(END) else {
415 return;
416 };
417 out.push_str(&marker[..=end]);
418 rest = &marker[end + END.len_utf8()..];
419 }
420}
421
422fn render_marked(marked: &str, coloured: bool) -> String {
423 let mut out = String::with_capacity(marked.len());
424 let mut stack = vec![AnsiStyle::default()];
425 let mut rest = marked;
426 while let Some(at) = rest.find(MARK) {
427 push_content(
428 &mut out,
429 &rest[..at],
430 coloured,
431 stack.last().copied().unwrap_or_default(),
432 );
433 let after = &rest[at + MARK.len_utf8()..];
434 if after.starts_with(MARK) {
435 out.push(MARK);
436 rest = &after[MARK.len_utf8()..];
437 continue;
438 }
439 let Some(end) = after.find(END) else {
440 out.push(MARK);
441 out.push_str(after);
442 break;
443 };
444 let marker = &after[..end];
445 if let Some(specification) = marker.strip_prefix('+') {
446 let next = stack
447 .last()
448 .copied()
449 .unwrap_or_default()
450 .apply(specification)
451 .unwrap_or_default();
452 stack.push(next);
453 if coloured {
454 next.write(&mut out);
455 }
456 } else {
457 if stack.len() > 1 {
458 stack.pop();
459 }
460 if coloured {
461 AnsiStyle::default().write(&mut out);
462 let parent = stack.last().copied().unwrap_or_default();
463 if parent.foreground.is_some()
464 || parent.bold
465 || parent.dim
466 || parent.italic
467 || parent.underline
468 {
469 parent.write(&mut out);
470 }
471 }
472 }
473 rest = &after[end + END.len_utf8()..];
474 }
475 push_content(
476 &mut out,
477 rest,
478 coloured,
479 stack.last().copied().unwrap_or_default(),
480 );
481 out
482}
483
484fn push_content(out: &mut String, text: &str, coloured: bool, active: AnsiStyle) {
485 if !coloured
486 || (!active.bold
487 && !active.dim
488 && !active.italic
489 && !active.underline
490 && active.foreground.is_none())
491 {
492 out.push_str(text);
493 return;
494 }
495 let mut rest = text;
496 while let Some(at) = rest.find("\u{1b}[") {
497 out.push_str(&rest[..at]);
498 let sequence = &rest[at..];
499 let Some(end) = sequence.find('m') else {
500 out.push_str(sequence);
501 return;
502 };
503 out.push_str(&sequence[..=end]);
504 let parameters = &sequence[2..end];
505 if parameters.split(';').any(|parameter| {
506 matches!(
507 parameter,
508 "" | "0" | "22" | "23" | "24" | "25" | "27" | "28" | "29" | "39" | "49"
509 )
510 }) {
511 active.write(out);
512 }
513 rest = &sequence[end + 1..];
514 }
515 out.push_str(rest);
516}
517
518fn substitute_sections_only(
519 template: &str,
520 mut section: impl FnMut(&str) -> Option<String>,
521) -> String {
522 let mut out = String::with_capacity(template.len());
523 let mut rest = template;
524 while let Some(at) = rest.find("{{") {
525 out.push_str(&rest[..at]);
526 let after = &rest[at + 2..];
527 let Some(end) = after.find("}}") else {
528 out.push_str(&rest[at..]);
529 return collapse_blank_runs(&out);
530 };
531 match section(after[..end].trim()) {
532 Some(text) => out.push_str(&text),
533 None => out.push_str(&rest[at..at + 2 + end + 2]),
534 }
535 rest = &after[end + 2..];
536 }
537 out.push_str(rest);
538 collapse_blank_runs(&out)
539}
540
541fn collapse_blank_runs(page: &str) -> String {
558 let mut out = String::with_capacity(page.len());
559 let mut blank = false;
560 for line in page.split('\n') {
561 if line.trim().is_empty() {
562 blank = !out.is_empty();
563 continue;
564 }
565 if !out.is_empty() {
566 out.push('\n');
567 if blank {
568 out.push('\n');
569 }
570 }
571 blank = false;
572 out.push_str(line);
573 }
574 out
575}
576
577#[cfg(test)]
578mod tests {
579 use super::*;
580
581 #[test]
582 fn whitespace_alone_is_not_a_layout() {
583 assert!(!is_set(""));
584 assert!(!is_set(" \n\t"));
585 assert!(is_set("{{usage}}"));
586 assert!(check("").is_ok());
587 }
588
589 #[test]
590 fn a_placeholder_naming_no_section_is_refused_by_name() {
591 let err = check("{{about}}{{options}}").expect_err("no section is called options");
592 assert!(err.contains("\"options\""), "{err}");
593 assert!(err.contains("`{{flags}}`"), "{err}");
595 assert!(check("{{ about }} {{usage}}").is_ok());
596 assert!(check("no placeholders at all").is_ok());
597 assert!(check("{{usage").is_err());
598 }
599
600 #[test]
601 fn substitution_takes_only_the_names_it_is_given() {
602 let filled = substitute("[{{usage}}]{{ nope }}", |name| {
603 (name == "usage").then(|| "Usage: ex".to_string())
604 });
605 assert_eq!(filled, "[Usage: ex]{{ nope }}");
606 }
607
608 #[test]
609 fn colour_markup_is_checked_and_removed_from_plain_pages() {
610 assert!(check("{$heading}Usage:{/$} {{usage}}").is_ok());
611 assert!(check("{$orange}no{/$}").is_err());
612 assert!(check("{$red}unclosed").is_err());
613 assert!(check("orphan{/$}").is_err());
614
615 let filled = substitute("{$heading}Custom{/$}\n{{about}}", |_| {
616 Some("Literal {$red} prose".to_string())
617 });
618 assert_eq!(filled, "Custom\nLiteral {$red} prose");
619
620 assert!(check("{$$heading}literal{/$$}").is_ok());
621 assert_eq!(
622 substitute("{$$heading}literal{/$$}", |_| None),
623 "{$heading}literal{/$}"
624 );
625 assert_eq!(
626 substitute("before {$red and {{usage}}", |_| {
627 Some("Usage: ex".to_string())
628 }),
629 "before {$red and Usage: ex"
630 );
631 assert!(check("{$}")
632 .expect_err("an empty tag is invalid")
633 .contains("empty style tag"));
634 }
635
636 #[test]
637 fn a_section_that_came_out_empty_leaves_no_gap_behind() {
638 let template = "{{usage}}\n\n{{args}}\n\n{{flags}}";
641 let full = substitute(template, |name| {
642 Some(match name {
643 "usage" => "Usage: ex".to_string(),
644 "args" => "Arguments:\n <file>".to_string(),
645 _ => "Flags:\n --force".to_string(),
646 })
647 });
648 assert_eq!(
649 full,
650 "Usage: ex\n\nArguments:\n <file>\n\nFlags:\n --force"
651 );
652
653 let no_args = substitute(template, |name| {
654 Some(match name {
655 "usage" => "Usage: ex".to_string(),
656 "args" => String::new(),
657 _ => "Flags:\n --force".to_string(),
658 })
659 });
660 assert_eq!(no_args, "Usage: ex\n\nFlags:\n --force");
661 }
662
663 #[test]
664 fn a_sections_own_indentation_survives_the_collapsing() {
665 let page = substitute(" {{flags}}", |_| {
668 Some("Flags:\n --force Do it anyway".to_string())
669 });
670 assert_eq!(page, " Flags:\n --force Do it anyway");
671 }
672}