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