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,
7//! not on a template language's semantics.
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/// | `after_help` | examples, `after_help`, and the author/license footer on a long page |
22pub const SECTIONS: [&str; 6] = ["about", "usage", "commands", "args", "flags", "after_help"];
23
24/// Whether a template is one an author wrote, rather than an empty or whitespace-only
25/// string that should render as the default page.
26///
27/// `help_template ""` is accepted by KDL because it has no unknown placeholders, but it
28/// names no layout. Treating it as unset keeps the three renderers on one page instead of
29/// Rust substituting an empty string into `"\n"` while Go concatenates the default order.
30pub fn is_set(template: &str) -> bool {
31    !template.trim().is_empty()
32}
33
34/// Whether every `{{…}}` in a template names a section.
35///
36/// The check a template is held to when a spec is read, so nothing renders a page with a
37/// section it cannot fill. The message names the vocabulary, and names the two clap
38/// placeholders whose spellings differ, because a template being ported is where this is most
39/// likely to be read.
40pub fn check(template: &str) -> Result<(), String> {
41    let mut rest = template;
42    while let Some(at) = rest.find("{{") {
43        let after = &rest[at + 2..];
44        let Some(end) = after.find("}}") else {
45            return Err(format!(
46                "help_template has a `{{{{` with no `}}}}` after it; the sections are {}",
47                SECTIONS.join(", ")
48            ));
49        };
50        let name = after[..end].trim();
51        if !SECTIONS.contains(&name) {
52            return Err(format!(
53                "help_template names no section \"{name}\"; a page is assembled from {} — \
54                 reorder, omit or wrap those, and note that clap's `{{options}}` is \
55                 `{{{{flags}}}}` here and its `{{positionals}}` is `{{{{args}}}}`",
56                SECTIONS.join(", ")
57            ));
58        }
59        rest = &after[end + 2..];
60    }
61    Ok(())
62}
63
64/// Fill a template in, asking `section` for each name it holds.
65///
66/// A placeholder naming no section is left exactly as it was written: the vocabulary is checked
67/// where a spec is read, so one arriving here is text an author meant literally.
68///
69/// Every section a template names is optional in practice — most commands have no arguments,
70/// most have no examples — so a template is written with the separators a full page wants and
71/// the empty sections are what [`collapse_blank_runs`] then takes back out.
72pub fn substitute(template: &str, section: impl Fn(&str) -> Option<String>) -> String {
73    let mut out = String::with_capacity(template.len());
74    let mut rest = template;
75    while let Some(at) = rest.find("{{") {
76        out.push_str(&rest[..at]);
77        let after = &rest[at + 2..];
78        let Some(end) = after.find("}}") else {
79            out.push_str(&rest[at..]);
80            return collapse_blank_runs(&out);
81        };
82        match section(after[..end].trim()) {
83            Some(text) => out.push_str(&text),
84            None => out.push_str(&rest[at..at + 2 + end + 2]),
85        }
86        rest = &after[end + 2..];
87    }
88    out.push_str(rest);
89    collapse_blank_runs(&out)
90}
91
92/// A page's runs of blank lines, each reduced to a single blank line.
93///
94/// What makes a section optional. `"{{flags}}\n\n{{args}}\n\n{{commands}}"` is written for a
95/// command that has all three, and a command with no arguments would otherwise render the two
96/// separators back to back and push its commands down the page. Collapsing means a template
97/// describes an order rather than a page, so one template can serve a whole CLI.
98///
99/// The cost is that a template cannot open a gap wider than one blank line, which is a
100/// deliberate trade: an author who wants a run of them is asking for something no help page
101/// wants, and the alternative is that every optional section needs its own template.
102///
103/// The rule applies to a template's output and nothing else, so a page assembled in the default
104/// order is untouched by it.
105/// A line with only spaces on it counts as blank, since that is what an empty placeholder on an
106/// indented line leaves behind. Leading and trailing blank lines go entirely; the caller puts back
107/// the single newline a page ends with.
108fn collapse_blank_runs(page: &str) -> String {
109    let mut out = String::with_capacity(page.len());
110    let mut blank = false;
111    for line in page.split('\n') {
112        if line.trim().is_empty() {
113            blank = !out.is_empty();
114            continue;
115        }
116        if !out.is_empty() {
117            out.push('\n');
118            if blank {
119                out.push('\n');
120            }
121        }
122        blank = false;
123        out.push_str(line);
124    }
125    out
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn whitespace_alone_is_not_a_layout() {
134        assert!(!is_set(""));
135        assert!(!is_set("  \n\t"));
136        assert!(is_set("{{usage}}"));
137        assert!(check("").is_ok());
138    }
139
140    #[test]
141    fn a_placeholder_naming_no_section_is_refused_by_name() {
142        let err = check("{{about}}{{options}}").expect_err("no section is called options");
143        assert!(err.contains("\"options\""), "{err}");
144        // And says what to write instead, since this is what a ported clap template hits.
145        assert!(err.contains("`{{flags}}`"), "{err}");
146        assert!(check("{{ about }} {{usage}}").is_ok());
147        assert!(check("no placeholders at all").is_ok());
148        assert!(check("{{usage").is_err());
149    }
150
151    #[test]
152    fn substitution_takes_only_the_names_it_is_given() {
153        let filled = substitute("[{{usage}}]{{ nope }}", |name| {
154            (name == "usage").then(|| "Usage: ex".to_string())
155        });
156        assert_eq!(filled, "[Usage: ex]{{ nope }}");
157    }
158
159    #[test]
160    fn a_section_that_came_out_empty_leaves_no_gap_behind() {
161        // One template, two commands: the separators a full page wants do not become blank
162        // lines on the page that has no arguments.
163        let template = "{{usage}}\n\n{{args}}\n\n{{flags}}";
164        let full = substitute(template, |name| {
165            Some(match name {
166                "usage" => "Usage: ex".to_string(),
167                "args" => "Arguments:\n  <file>".to_string(),
168                _ => "Flags:\n  --force".to_string(),
169            })
170        });
171        assert_eq!(
172            full,
173            "Usage: ex\n\nArguments:\n  <file>\n\nFlags:\n  --force"
174        );
175
176        let no_args = substitute(template, |name| {
177            Some(match name {
178                "usage" => "Usage: ex".to_string(),
179                "args" => String::new(),
180                _ => "Flags:\n  --force".to_string(),
181            })
182        });
183        assert_eq!(no_args, "Usage: ex\n\nFlags:\n  --force");
184    }
185
186    #[test]
187    fn a_sections_own_indentation_survives_the_collapsing() {
188        // The rule is about blank lines between sections, so the two spaces a flag's row is
189        // indented by are not whitespace it may take.
190        let page = substitute("  {{flags}}", |_| {
191            Some("Flags:\n      --force  Do it anyway".to_string())
192        });
193        assert_eq!(page, "  Flags:\n      --force  Do it anyway");
194    }
195}