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
286fn check_styles(template: &str) -> Result<(), String> {
287 let mut rest = template;
288 let mut depth = 0usize;
289 while let Some((at, event)) = next_style_event(rest) {
290 let tag = &rest[at..];
291 match event {
292 Event::EscapeOpen => rest = &tag[3..],
293 Event::EscapeClose => rest = &tag[5..],
294 Event::Open => {
295 let Some(end) = tag.find('}') else {
296 return Err("help_template has a `{$` with no `}` after it".to_string());
297 };
298 let specification = &tag[2..end];
299 if specification.is_empty() {
300 return Err("help_template has an empty style tag `{$}`".to_string());
301 }
302 if let Some(unknown) = specification
303 .split('+')
304 .find(|fragment| !STYLES.contains(fragment))
305 {
306 return Err(format!(
307 "help_template names no style \"{unknown}\"; use {}",
308 STYLES.join(", ")
309 ));
310 }
311 depth += 1;
312 rest = &tag[end + 1..];
313 }
314 Event::Close => {
315 if depth == 0 {
316 return Err("help_template has a `{/$}` with no open style tag".to_string());
317 }
318 depth -= 1;
319 rest = &tag[4..];
320 }
321 Event::Placeholder => unreachable!("style scanning does not return placeholders"),
322 }
323 }
324 if depth == 0 {
325 Ok(())
326 } else {
327 Err("help_template has a style tag with no `{/$}` after it".to_string())
328 }
329}
330
331#[derive(Clone, Copy)]
332enum Event {
333 Placeholder,
334 Open,
335 Close,
336 EscapeOpen,
337 EscapeClose,
338}
339
340fn earliest(left: Option<(usize, Event)>, right: Option<(usize, Event)>) -> Option<(usize, Event)> {
341 match (left, right) {
342 (Some(left), Some(right)) => Some(if left.0 <= right.0 { left } else { right }),
343 (left, right) => left.or(right),
344 }
345}
346
347fn next_style_event(template: &str) -> Option<(usize, Event)> {
348 [
349 ("{$$", Event::EscapeOpen),
350 ("{/$$}", Event::EscapeClose),
351 ("{$", Event::Open),
352 ("{/$}", Event::Close),
353 ]
354 .into_iter()
355 .enumerate()
356 .filter_map(|(priority, (token, event))| template.find(token).map(|at| ((at, priority), event)))
357 .min_by_key(|(position, _)| *position)
358 .map(|((at, _), event)| (at, event))
359}
360
361fn push_escaped(out: &mut String, text: &str) {
362 if !text.contains(MARK) {
363 out.push_str(text);
364 return;
365 }
366 for ch in text.chars() {
367 out.push(ch);
368 if ch == MARK {
369 out.push(MARK);
370 }
371 }
372}
373
374fn collapse_styled_blank_runs(page: &str) -> String {
375 let mut out = String::with_capacity(page.len());
376 let mut blank = false;
377 let mut wrote_visible = false;
378 for line in page.split('\n') {
379 if visible_is_blank(line) {
380 push_markers(&mut out, line);
381 blank = wrote_visible;
382 continue;
383 }
384 if wrote_visible {
385 out.push('\n');
386 if blank {
387 out.push('\n');
388 }
389 }
390 blank = false;
391 out.push_str(line);
392 wrote_visible = true;
393 }
394 out
395}
396
397fn visible_is_blank(line: &str) -> bool {
398 let mut rest = line;
399 while let Some(at) = rest.find(MARK) {
400 if !rest[..at].trim().is_empty() {
401 return false;
402 }
403 let after = &rest[at + MARK.len_utf8()..];
404 if after.starts_with(MARK) {
405 return false;
406 } else if let Some(end) = after.find(END) {
407 rest = &after[end + END.len_utf8()..];
408 } else {
409 return false;
410 }
411 }
412 rest.trim().is_empty()
413}
414
415fn push_markers(out: &mut String, line: &str) {
416 let mut rest = line;
417 while let Some(at) = rest.find(MARK) {
418 let marker = &rest[at..];
419 let Some(end) = marker.find(END) else {
420 return;
421 };
422 out.push_str(&marker[..=end]);
423 rest = &marker[end + END.len_utf8()..];
424 }
425}
426
427fn render_marked(marked: &str, coloured: bool) -> String {
428 let mut out = String::with_capacity(marked.len());
429 let mut stack = vec![AnsiStyle::default()];
430 let mut rest = marked;
431 while let Some(at) = rest.find(MARK) {
432 push_content(
433 &mut out,
434 &rest[..at],
435 coloured,
436 stack.last().copied().unwrap_or_default(),
437 );
438 let after = &rest[at + MARK.len_utf8()..];
439 if after.starts_with(MARK) {
440 out.push(MARK);
441 rest = &after[MARK.len_utf8()..];
442 continue;
443 }
444 let Some(end) = after.find(END) else {
445 out.push(MARK);
446 out.push_str(after);
447 break;
448 };
449 let marker = &after[..end];
450 if let Some(specification) = marker.strip_prefix('+') {
451 let next = stack
452 .last()
453 .copied()
454 .unwrap_or_default()
455 .apply(specification)
456 .unwrap_or_default();
457 stack.push(next);
458 if coloured {
459 next.write(&mut out);
460 }
461 } else {
462 if stack.len() > 1 {
463 stack.pop();
464 }
465 if coloured {
466 AnsiStyle::default().write(&mut out);
467 let parent = stack.last().copied().unwrap_or_default();
468 if parent.foreground.is_some()
469 || parent.bold
470 || parent.dim
471 || parent.italic
472 || parent.underline
473 {
474 parent.write(&mut out);
475 }
476 }
477 }
478 rest = &after[end + END.len_utf8()..];
479 }
480 push_content(
481 &mut out,
482 rest,
483 coloured,
484 stack.last().copied().unwrap_or_default(),
485 );
486 out
487}
488
489fn push_content(out: &mut String, text: &str, coloured: bool, active: AnsiStyle) {
490 if !coloured
491 || (!active.bold
492 && !active.dim
493 && !active.italic
494 && !active.underline
495 && active.foreground.is_none())
496 {
497 out.push_str(text);
498 return;
499 }
500 let mut rest = text;
501 while let Some(at) = rest.find("\u{1b}[") {
502 out.push_str(&rest[..at]);
503 let sequence = &rest[at..];
504 let Some(end) = sequence.find('m') else {
505 out.push_str(sequence);
506 return;
507 };
508 out.push_str(&sequence[..=end]);
509 let parameters = &sequence[2..end];
510 if parameters.split(';').any(|parameter| {
511 matches!(
512 parameter,
513 "" | "0" | "22" | "23" | "24" | "25" | "27" | "28" | "29" | "39" | "49"
514 )
515 }) {
516 active.write(out);
517 }
518 rest = &sequence[end + 1..];
519 }
520 out.push_str(rest);
521}
522
523fn substitute_sections_only(
524 template: &str,
525 mut section: impl FnMut(&str) -> Option<String>,
526) -> String {
527 let mut out = String::with_capacity(template.len());
528 let mut rest = template;
529 while let Some(at) = rest.find("{{") {
530 out.push_str(&rest[..at]);
531 let after = &rest[at + 2..];
532 let Some(end) = after.find("}}") else {
533 out.push_str(&rest[at..]);
534 return collapse_blank_runs(&out);
535 };
536 match section(after[..end].trim()) {
537 Some(text) => out.push_str(&text),
538 None => out.push_str(&rest[at..at + 2 + end + 2]),
539 }
540 rest = &after[end + 2..];
541 }
542 out.push_str(rest);
543 collapse_blank_runs(&out)
544}
545
546fn collapse_blank_runs(page: &str) -> String {
563 let mut out = String::with_capacity(page.len());
564 let mut blank = false;
565 for line in page.split('\n') {
566 if line.trim().is_empty() {
567 blank = !out.is_empty();
568 continue;
569 }
570 if !out.is_empty() {
571 out.push('\n');
572 if blank {
573 out.push('\n');
574 }
575 }
576 blank = false;
577 out.push_str(line);
578 }
579 out
580}
581
582#[cfg(test)]
583mod tests {
584 use super::*;
585
586 #[test]
587 fn whitespace_alone_is_not_a_layout() {
588 assert!(!is_set(""));
589 assert!(!is_set(" \n\t"));
590 assert!(is_set("{{usage}}"));
591 assert!(check("").is_ok());
592 }
593
594 #[test]
595 fn a_placeholder_naming_no_section_is_refused_by_name() {
596 let err = check("{{about}}{{options}}").expect_err("no section is called options");
597 assert!(err.contains("\"options\""), "{err}");
598 assert!(err.contains("`{{flags}}`"), "{err}");
600 assert!(check("{{ about }} {{usage}}").is_ok());
601 assert!(check("no placeholders at all").is_ok());
602 assert!(check("{{usage").is_err());
603 }
604
605 #[test]
606 fn substitution_takes_only_the_names_it_is_given() {
607 let filled = substitute("[{{usage}}]{{ nope }}", |name| {
608 (name == "usage").then(|| "Usage: ex".to_string())
609 });
610 assert_eq!(filled, "[Usage: ex]{{ nope }}");
611 }
612
613 #[test]
614 fn colour_markup_is_checked_and_removed_from_plain_pages() {
615 assert!(check("{$heading}Usage:{/$} {{usage}}").is_ok());
616 assert!(check("{$orange}no{/$}").is_err());
617 assert!(check("{$red}unclosed").is_err());
618 assert!(check("orphan{/$}").is_err());
619
620 let filled = substitute("{$heading}Custom{/$}\n{{about}}", |_| {
621 Some("Literal {$red} prose".to_string())
622 });
623 assert_eq!(filled, "Custom\nLiteral {$red} prose");
624
625 assert!(check("{$$heading}literal{/$$}").is_ok());
626 assert_eq!(
627 substitute("{$$heading}literal{/$$}", |_| None),
628 "{$heading}literal{/$}"
629 );
630 assert_eq!(
631 substitute("before {$red and {{usage}}", |_| {
632 Some("Usage: ex".to_string())
633 }),
634 "before {$red and Usage: ex"
635 );
636 assert!(check("{$}")
637 .expect_err("an empty tag is invalid")
638 .contains("empty style tag"));
639 }
640
641 #[test]
642 fn a_section_that_came_out_empty_leaves_no_gap_behind() {
643 let template = "{{usage}}\n\n{{args}}\n\n{{flags}}";
646 let full = substitute(template, |name| {
647 Some(match name {
648 "usage" => "Usage: ex".to_string(),
649 "args" => "Arguments:\n <file>".to_string(),
650 _ => "Flags:\n --force".to_string(),
651 })
652 });
653 assert_eq!(
654 full,
655 "Usage: ex\n\nArguments:\n <file>\n\nFlags:\n --force"
656 );
657
658 let no_args = substitute(template, |name| {
659 Some(match name {
660 "usage" => "Usage: ex".to_string(),
661 "args" => String::new(),
662 _ => "Flags:\n --force".to_string(),
663 })
664 });
665 assert_eq!(no_args, "Usage: ex\n\nFlags:\n --force");
666 }
667
668 #[test]
669 fn a_sections_own_indentation_survives_the_collapsing() {
670 let page = substitute(" {{flags}}", |_| {
673 Some("Flags:\n --force Do it anyway".to_string())
674 });
675 assert_eq!(page, " Flags:\n --force Do it anyway");
676 }
677}