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 root long page's package footer      |
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; 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
170/// Whether a template is one an author wrote, rather than an empty or whitespace-only
171/// string that should render as the default page.
172///
173/// `help_template ""` is accepted by KDL because it has no unknown placeholders, but it
174/// names no layout. Treating it as unset keeps the three renderers on one page instead of
175/// Rust substituting an empty string into `"\n"` while Go concatenates the default order.
176pub fn is_set(template: &str) -> bool {
177    !template.trim().is_empty()
178}
179
180/// Whether every `{{…}}` in a template names a section.
181///
182/// The check a template is held to when a spec is read, so nothing renders a page with a
183/// section it cannot fill. The message names the vocabulary, and names the two clap
184/// placeholders whose spellings differ, because a template being ported is where this is most
185/// likely to be read.
186pub 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
211/// Fill a template in, asking `section` for each name it holds.
212///
213/// A placeholder naming no section is left exactly as it was written: the vocabulary is checked
214/// where a spec is read, so one arriving here is text an author meant literally.
215///
216/// Every section a template names is optional in practice — most commands have no arguments,
217/// most have no examples — so a template is written with the separators a full page wants and
218/// the empty sections are what [`collapse_blank_runs`] then takes back out.
219pub 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
286/// Whether a `+`-combined style specification names only styles that exist.
287///
288/// Shared by `help_template`'s own tags and by every other place a spec names a style —
289/// `logo "…" style="cyan+bold"` — so that one vocabulary is checked one way and a spec is
290/// refused where it is written rather than rendered without the colour it asked for.
291pub 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
559/// A page's runs of blank lines, each reduced to a single blank line.
560///
561/// What makes a section optional. `"{{flags}}\n\n{{args}}\n\n{{commands}}"` is written for a
562/// command that has all three, and a command with no arguments would otherwise render the two
563/// separators back to back and push its commands down the page. Collapsing means a template
564/// describes an order rather than a page, so one template can serve a whole CLI.
565///
566/// The cost is that a template cannot open a gap wider than one blank line, which is a
567/// deliberate trade: an author who wants a run of them is asking for something no help page
568/// wants, and the alternative is that every optional section needs its own template.
569///
570/// The rule applies to a template's output and nothing else, so a page assembled in the default
571/// order is untouched by it.
572/// A line with only spaces on it counts as blank, since that is what an empty placeholder on an
573/// indented line leaves behind. Leading and trailing blank lines go entirely; the caller puts back
574/// the single newline a page ends with.
575fn 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        // And says what to write instead, since this is what a ported clap template hits.
612        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        // One template, two commands: the separators a full page wants do not become blank
659        // lines on the page that has no arguments.
660        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        // The rule is about blank lines between sections, so the two spaces a flag's row is
686        // indented by are not whitespace it may take.
687        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}