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
66/// Whether a template is one an author wrote, rather than an empty or whitespace-only
67/// string that should render as the default page.
68///
69/// `help_template ""` is accepted by KDL because it has no unknown placeholders, but it
70/// names no layout. Treating it as unset keeps the three renderers on one page instead of
71/// Rust substituting an empty string into `"\n"` while Go concatenates the default order.
72pub fn is_set(template: &str) -> bool {
73    !template.trim().is_empty()
74}
75
76/// Whether every `{{…}}` in a template names a section.
77///
78/// The check a template is held to when a spec is read, so nothing renders a page with a
79/// section it cannot fill. The message names the vocabulary, and names the two clap
80/// placeholders whose spellings differ, because a template being ported is where this is most
81/// likely to be read.
82pub fn check(template: &str) -> Result<(), String> {
83    check_styles(template)?;
84    let mut rest = template;
85    while let Some(at) = rest.find("{{") {
86        let after = &rest[at + 2..];
87        let Some(end) = after.find("}}") else {
88            return Err(format!(
89                "help_template has a `{{{{` with no `}}}}` after it; the sections are {}",
90                SECTIONS.join(", ")
91            ));
92        };
93        let name = after[..end].trim();
94        if !SECTIONS.contains(&name) {
95            return Err(format!(
96                "help_template names no section \"{name}\"; a page is assembled from {} — \
97                 reorder, omit or wrap those, and note that clap's `{{options}}` is \
98                 `{{{{flags}}}}` here and its `{{positionals}}` is `{{{{args}}}}`",
99                SECTIONS.join(", ")
100            ));
101        }
102        rest = &after[end + 2..];
103    }
104    Ok(())
105}
106
107/// Fill a template in, asking `section` for each name it holds.
108///
109/// A placeholder naming no section is left exactly as it was written: the vocabulary is checked
110/// where a spec is read, so one arriving here is text an author meant literally.
111///
112/// Every section a template names is optional in practice — most commands have no arguments,
113/// most have no examples — so a template is written with the separators a full page wants and
114/// the empty sections are what [`collapse_blank_runs`] then takes back out.
115pub fn substitute(template: &str, section: impl Fn(&str) -> Option<String>) -> String {
116    if check_styles(template).is_err() {
117        return substitute_sections_only(template, section);
118    }
119    let mut out = String::with_capacity(template.len());
120    let mut rest = template;
121    loop {
122        let placeholder = rest.find("{{").map(|at| (at, 0));
123        let style = next_style_event(rest).map(|(at, event)| (at, event as u8 + 1));
124        let Some((at, kind)) = [placeholder, style]
125            .into_iter()
126            .flatten()
127            .min_by_key(|(at, _)| *at)
128        else {
129            out.push_str(rest);
130            break;
131        };
132        out.push_str(&rest[..at]);
133        rest = &rest[at..];
134        match kind {
135            0 => {
136                let after = &rest[2..];
137                let Some(end) = after.find("}}") else {
138                    out.push_str(rest);
139                    break;
140                };
141                match section(after[..end].trim()) {
142                    Some(text) => out.push_str(&text),
143                    None => out.push_str(&rest[..2 + end + 2]),
144                }
145                rest = &after[end + 2..];
146            }
147            1 => {
148                let Some(end) = rest.find('}') else {
149                    out.push_str(rest);
150                    break;
151                };
152                rest = &rest[end + 1..];
153            }
154            2 => rest = &rest[4..],
155            3 => {
156                out.push_str("{$");
157                rest = &rest[3..];
158            }
159            _ => {
160                out.push_str("{/$}");
161                rest = &rest[5..];
162            }
163        }
164    }
165    collapse_blank_runs(&out)
166}
167
168fn check_styles(template: &str) -> Result<(), String> {
169    let mut rest = template;
170    let mut depth = 0usize;
171    while let Some((at, event)) = next_style_event(rest) {
172        let tag = &rest[at..];
173        match event {
174            StyleEvent::EscapeOpen => rest = &tag[3..],
175            StyleEvent::EscapeClose => rest = &tag[5..],
176            StyleEvent::Open => {
177                let Some(end) = tag.find('}') else {
178                    return Err("help_template has a `{$` with no `}` after it".to_string());
179                };
180                let specification = &tag[2..end];
181                if specification.is_empty() {
182                    return Err("help_template has an empty style tag `{$}`".to_string());
183                }
184                if let Some(unknown) = specification
185                    .split('+')
186                    .find(|fragment| !STYLES.contains(fragment))
187                {
188                    return Err(format!(
189                        "help_template names no style \"{unknown}\"; use {}",
190                        STYLES.join(", ")
191                    ));
192                }
193                depth += 1;
194                rest = &tag[end + 1..];
195            }
196            StyleEvent::Close => {
197                if depth == 0 {
198                    return Err("help_template has a `{/$}` with no open style tag".to_string());
199                }
200                depth -= 1;
201                rest = &tag[4..];
202            }
203        }
204    }
205    if depth == 0 {
206        Ok(())
207    } else {
208        Err("help_template has a style tag with no `{/$}` after it".to_string())
209    }
210}
211
212#[derive(Clone, Copy)]
213enum StyleEvent {
214    Open = 0,
215    Close = 1,
216    EscapeOpen = 2,
217    EscapeClose = 3,
218}
219
220fn next_style_event(template: &str) -> Option<(usize, StyleEvent)> {
221    [
222        ("{$$", StyleEvent::EscapeOpen),
223        ("{/$$}", StyleEvent::EscapeClose),
224        ("{$", StyleEvent::Open),
225        ("{/$}", StyleEvent::Close),
226    ]
227    .into_iter()
228    .enumerate()
229    .filter_map(|(priority, (token, event))| template.find(token).map(|at| ((at, priority), event)))
230    .min_by_key(|(position, _)| *position)
231    .map(|((at, _), event)| (at, event))
232}
233
234fn substitute_sections_only(template: &str, section: impl Fn(&str) -> Option<String>) -> String {
235    let mut out = String::with_capacity(template.len());
236    let mut rest = template;
237    while let Some(at) = rest.find("{{") {
238        out.push_str(&rest[..at]);
239        let after = &rest[at + 2..];
240        let Some(end) = after.find("}}") else {
241            out.push_str(&rest[at..]);
242            return collapse_blank_runs(&out);
243        };
244        match section(after[..end].trim()) {
245            Some(text) => out.push_str(&text),
246            None => out.push_str(&rest[at..at + 2 + end + 2]),
247        }
248        rest = &after[end + 2..];
249    }
250    out.push_str(rest);
251    collapse_blank_runs(&out)
252}
253
254/// A page's runs of blank lines, each reduced to a single blank line.
255///
256/// What makes a section optional. `"{{flags}}\n\n{{args}}\n\n{{commands}}"` is written for a
257/// command that has all three, and a command with no arguments would otherwise render the two
258/// separators back to back and push its commands down the page. Collapsing means a template
259/// describes an order rather than a page, so one template can serve a whole CLI.
260///
261/// The cost is that a template cannot open a gap wider than one blank line, which is a
262/// deliberate trade: an author who wants a run of them is asking for something no help page
263/// wants, and the alternative is that every optional section needs its own template.
264///
265/// The rule applies to a template's output and nothing else, so a page assembled in the default
266/// order is untouched by it.
267/// A line with only spaces on it counts as blank, since that is what an empty placeholder on an
268/// indented line leaves behind. Leading and trailing blank lines go entirely; the caller puts back
269/// the single newline a page ends with.
270fn collapse_blank_runs(page: &str) -> String {
271    let mut out = String::with_capacity(page.len());
272    let mut blank = false;
273    for line in page.split('\n') {
274        if line.trim().is_empty() {
275            blank = !out.is_empty();
276            continue;
277        }
278        if !out.is_empty() {
279            out.push('\n');
280            if blank {
281                out.push('\n');
282            }
283        }
284        blank = false;
285        out.push_str(line);
286    }
287    out
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn whitespace_alone_is_not_a_layout() {
296        assert!(!is_set(""));
297        assert!(!is_set("  \n\t"));
298        assert!(is_set("{{usage}}"));
299        assert!(check("").is_ok());
300    }
301
302    #[test]
303    fn a_placeholder_naming_no_section_is_refused_by_name() {
304        let err = check("{{about}}{{options}}").expect_err("no section is called options");
305        assert!(err.contains("\"options\""), "{err}");
306        // And says what to write instead, since this is what a ported clap template hits.
307        assert!(err.contains("`{{flags}}`"), "{err}");
308        assert!(check("{{ about }} {{usage}}").is_ok());
309        assert!(check("no placeholders at all").is_ok());
310        assert!(check("{{usage").is_err());
311    }
312
313    #[test]
314    fn substitution_takes_only_the_names_it_is_given() {
315        let filled = substitute("[{{usage}}]{{ nope }}", |name| {
316            (name == "usage").then(|| "Usage: ex".to_string())
317        });
318        assert_eq!(filled, "[Usage: ex]{{ nope }}");
319    }
320
321    #[test]
322    fn colour_markup_is_checked_and_removed_from_plain_pages() {
323        assert!(check("{$heading}Usage:{/$} {{usage}}").is_ok());
324        assert!(check("{$orange}no{/$}").is_err());
325        assert!(check("{$red}unclosed").is_err());
326        assert!(check("orphan{/$}").is_err());
327
328        let filled = substitute("{$heading}Custom{/$}\n{{about}}", |_| {
329            Some("Literal {$red} prose".to_string())
330        });
331        assert_eq!(filled, "Custom\nLiteral {$red} prose");
332
333        assert!(check("{$$heading}literal{/$$}").is_ok());
334        assert_eq!(
335            substitute("{$$heading}literal{/$$}", |_| None),
336            "{$heading}literal{/$}"
337        );
338        assert_eq!(
339            substitute("before {$red and {{usage}}", |_| {
340                Some("Usage: ex".to_string())
341            }),
342            "before {$red and Usage: ex"
343        );
344        assert!(check("{$}")
345            .expect_err("an empty tag is invalid")
346            .contains("empty style tag"));
347    }
348
349    #[test]
350    fn a_section_that_came_out_empty_leaves_no_gap_behind() {
351        // One template, two commands: the separators a full page wants do not become blank
352        // lines on the page that has no arguments.
353        let template = "{{usage}}\n\n{{args}}\n\n{{flags}}";
354        let full = substitute(template, |name| {
355            Some(match name {
356                "usage" => "Usage: ex".to_string(),
357                "args" => "Arguments:\n  <file>".to_string(),
358                _ => "Flags:\n  --force".to_string(),
359            })
360        });
361        assert_eq!(
362            full,
363            "Usage: ex\n\nArguments:\n  <file>\n\nFlags:\n  --force"
364        );
365
366        let no_args = substitute(template, |name| {
367            Some(match name {
368                "usage" => "Usage: ex".to_string(),
369                "args" => String::new(),
370                _ => "Flags:\n  --force".to_string(),
371            })
372        });
373        assert_eq!(no_args, "Usage: ex\n\nFlags:\n  --force");
374    }
375
376    #[test]
377    fn a_sections_own_indentation_survives_the_collapsing() {
378        // The rule is about blank lines between sections, so the two spaces a flag's row is
379        // indented by are not whitespace it may take.
380        let page = substitute("  {{flags}}", |_| {
381            Some("Flags:\n      --force  Do it anyway".to_string())
382        });
383        assert_eq!(page, "  Flags:\n      --force  Do it anyway");
384    }
385}