Skip to main content

usage/
help_template.rs

1//! The named sections a `help_template` may place, and how one is filled in.
2//!
3//! A spec can say what order its help sections come in — `help_template "{{about}}{{usage}}…"`
4//! — and nothing more than that. The template holds a closed vocabulary of *pre-rendered*
5//! sections rather than the metadata behind them, which is what lets an interpreter, a compiled
6//! parser and a generated Go program agree: they agree on where each section starts and ends and
7//! on a small colour-tag vocabulary, not on the metadata behind a section.
8//!
9//! The twins of this module are `usage_argv::help`'s `SECTIONS` and `Sections`, and Go's
10//! `helpSections`. `conformance/tests/render.rs` is what says the three still agree.
11
12/// The sections a template may name, and nothing else.
13///
14/// | section      | content                                                             |
15/// | ------------ | ------------------------------------------------------------------- |
16/// | `about`      | `before_help`, the version banner, and the description              |
17/// | `usage`      | the `Usage:` synopsis, however many lines it takes                  |
18/// | `commands`   | the subcommand list, or the flattened bodies under `flatten_help`   |
19/// | `args`       | every argument group, each under its heading                        |
20/// | `flags`      | this command's flag groups, then the globals it inherits            |
21/// | `grouped_args` | arguments with a declared help heading                            |
22/// | `ungrouped_args` | arguments under the default `Arguments` heading                 |
23/// | `grouped_flags` | flags with a declared help heading                               |
24/// | `ungrouped_flags` | flags under `Flags`, plus inherited global flags                |
25/// | `after_help` | examples, `after_help`, and the author/license footer on a long page |
26pub 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
39/// The styles a template may apply to its own text or to a rendered section.
40pub 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
165/// Whether a template is one an author wrote, rather than an empty or whitespace-only
166/// string that should render as the default page.
167///
168/// `help_template ""` is accepted by KDL because it has no unknown placeholders, but it
169/// names no layout. Treating it as unset keeps the three renderers on one page instead of
170/// Rust substituting an empty string into `"\n"` while Go concatenates the default order.
171pub fn is_set(template: &str) -> bool {
172    !template.trim().is_empty()
173}
174
175/// Whether every `{{…}}` in a template names a section.
176///
177/// The check a template is held to when a spec is read, so nothing renders a page with a
178/// section it cannot fill. The message names the vocabulary, and names the two clap
179/// placeholders whose spellings differ, because a template being ported is where this is most
180/// likely to be read.
181pub 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
206/// Fill a template in, asking `section` for each name it holds.
207///
208/// A placeholder naming no section is left exactly as it was written: the vocabulary is checked
209/// where a spec is read, so one arriving here is text an author meant literally.
210///
211/// Every section a template names is optional in practice — most commands have no arguments,
212/// most have no examples — so a template is written with the separators a full page wants and
213/// the empty sections are what [`collapse_blank_runs`] then takes back out.
214pub 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
541/// A page's runs of blank lines, each reduced to a single blank line.
542///
543/// What makes a section optional. `"{{flags}}\n\n{{args}}\n\n{{commands}}"` is written for a
544/// command that has all three, and a command with no arguments would otherwise render the two
545/// separators back to back and push its commands down the page. Collapsing means a template
546/// describes an order rather than a page, so one template can serve a whole CLI.
547///
548/// The cost is that a template cannot open a gap wider than one blank line, which is a
549/// deliberate trade: an author who wants a run of them is asking for something no help page
550/// wants, and the alternative is that every optional section needs its own template.
551///
552/// The rule applies to a template's output and nothing else, so a page assembled in the default
553/// order is untouched by it.
554/// A line with only spaces on it counts as blank, since that is what an empty placeholder on an
555/// indented line leaves behind. Leading and trailing blank lines go entirely; the caller puts back
556/// the single newline a page ends with.
557fn 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        // And says what to write instead, since this is what a ported clap template hits.
594        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        // One template, two commands: the separators a full page wants do not become blank
639        // lines on the page that has no arguments.
640        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        // The rule is about blank lines between sections, so the two spaces a flag's row is
666        // indented by are not whitespace it may take.
667        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}