1pub 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
39pub 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
66pub fn is_set(template: &str) -> bool {
73 !template.trim().is_empty()
74}
75
76pub 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
107pub 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
254fn 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 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 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 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}