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