1use std::{
10 collections::BTreeMap,
11 fmt::{self, Write},
12};
13
14use clap::builder::PossibleValue;
15
16#[non_exhaustive]
18pub struct MarkdownOptions {
19 title: Option<String>,
20 show_footer: bool,
21 show_table_of_contents: bool,
22 show_aliases: bool,
23}
24
25impl MarkdownOptions {
26 pub fn new() -> Self {
28 Self { title: None, show_footer: true, show_table_of_contents: true, show_aliases: true }
29 }
30
31 pub fn title(mut self, title: String) -> Self {
33 self.title = Some(title);
34 self
35 }
36
37 pub fn show_footer(mut self, show: bool) -> Self {
39 self.show_footer = show;
40 self
41 }
42
43 pub fn show_table_of_contents(mut self, show: bool) -> Self {
45 self.show_table_of_contents = show;
46 self
47 }
48
49 pub fn show_aliases(mut self, show: bool) -> Self {
51 self.show_aliases = show;
52 self
53 }
54}
55
56impl Default for MarkdownOptions {
57 fn default() -> Self {
58 Self::new()
59 }
60}
61
62pub fn help_markdown<C: clap::CommandFactory>() -> String {
64 let command = C::command();
65 help_markdown_command(&command)
66}
67
68pub fn help_markdown_custom<C: clap::CommandFactory>(options: &MarkdownOptions) -> String {
70 let command = C::command();
71 help_markdown_command_custom(&command, options)
72}
73
74pub fn help_markdown_command(command: &clap::Command) -> String {
76 help_markdown_command_custom(command, &Default::default())
77}
78
79pub fn help_markdown_command_custom(command: &clap::Command, options: &MarkdownOptions) -> String {
81 let mut buffer = String::with_capacity(100);
82 write_help_markdown(&mut buffer, command, options);
83 buffer
84}
85
86#[allow(clippy::disallowed_macros)]
90pub fn print_help_markdown<C: clap::CommandFactory>() {
91 let command = C::command();
92 let mut buffer = String::with_capacity(100);
93 write_help_markdown(&mut buffer, &command, &Default::default());
94 println!("{buffer}");
95}
96
97fn write_help_markdown(buffer: &mut String, command: &clap::Command, options: &MarkdownOptions) {
98 let title_name = get_canonical_name(command);
99
100 let title = match options.title {
101 Some(ref title) => title.to_owned(),
102 None => format!("Command-Line Help for `{title_name}`"),
103 };
104 writeln!(buffer, "# {title}\n",).unwrap();
105
106 writeln!(
107 buffer,
108 "This document contains the help content for the `{title_name}` command-line program.\n",
109 )
110 .unwrap();
111
112 if let Some(version) = command.get_version() {
114 let version_str = version.to_string();
115
116 if version_str.contains('\n') {
117 writeln!(buffer, "**Version:**\n\n```\n{}\n```\n", version_str.trim()).unwrap();
119 } else {
120 writeln!(buffer, "**Version:** `{version_str}`\n").unwrap();
122 }
123 }
124
125 if options.show_table_of_contents {
127 writeln!(buffer, "**Command Overview:**\n").unwrap();
128 build_table_of_contents_markdown(buffer, Vec::new(), command, 0).unwrap();
129 writeln!(buffer).unwrap();
130 }
131
132 build_command_markdown(buffer, Vec::new(), command, 0, options).unwrap();
134
135 if options.show_footer {
137 write!(
138 buffer,
139 r#"<hr/>
140
141<small><i>
142 This document was generated automatically by
143 <a href="https://crates.io/crates/clap-markdown"><code>clap-markdown</code></a>.
144</i></small>
145"#
146 )
147 .unwrap();
148 }
149}
150
151fn build_table_of_contents_markdown(
152 buffer: &mut String,
153 parent_command_path: Vec<String>,
154 command: &clap::Command,
155 _depth: usize,
156) -> std::fmt::Result {
157 if command.is_hide_set() {
159 return Ok(());
160 }
161
162 let title_name = get_canonical_name(command);
163
164 let command_path = {
165 let mut command_path = parent_command_path;
166 command_path.push(title_name);
167 command_path
168 };
169
170 writeln!(buffer, "* [`{}`↴](#{})", command_path.join(" "), command_path.join("-"),)?;
171
172 for subcommand in command.get_subcommands() {
173 build_table_of_contents_markdown(buffer, command_path.clone(), subcommand, _depth + 1)?;
174 }
175
176 Ok(())
177}
178
179fn build_command_markdown(
180 buffer: &mut String,
181 parent_command_path: Vec<String>,
182 command: &clap::Command,
183 _depth: usize,
184 options: &MarkdownOptions,
185) -> std::fmt::Result {
186 if command.is_hide_set() {
188 return Ok(());
189 }
190
191 let title_name = get_canonical_name(command);
192
193 let command_path = {
194 let mut command_path = parent_command_path.clone();
195 command_path.push(title_name);
196 command_path
197 };
198
199 writeln!(buffer, "## `{}`\n", command_path.join(" "))?;
201
202 if let Some(long_about) = command.get_long_about() {
203 writeln!(buffer, "{long_about}\n")?;
204 } else if let Some(about) = command.get_about() {
205 writeln!(buffer, "{about}\n")?;
206 }
207
208 if let Some(help) = command.get_before_long_help() {
209 writeln!(buffer, "{help}\n")?;
210 } else if let Some(help) = command.get_before_help() {
211 writeln!(buffer, "{help}\n")?;
212 }
213
214 writeln!(
215 buffer,
216 "**Usage:** `{}{}`\n",
217 if parent_command_path.is_empty() {
218 String::new()
219 } else {
220 let mut s = parent_command_path.join(" ");
221 s.push(' ');
222 s
223 },
224 command.clone().render_usage().to_string().replace("Usage: ", "")
225 )?;
226
227 if options.show_aliases {
228 let aliases = command.get_visible_aliases().collect::<Vec<&str>>();
229 if let Some(aliases_str) = get_alias_string(&aliases) {
230 writeln!(
231 buffer,
232 "**{}:** {aliases_str}\n",
233 pluralize(aliases.len(), "Command Alias", "Command Aliases")
234 )?;
235 }
236 }
237
238 if let Some(help) = command.get_after_long_help() {
239 writeln!(buffer, "{help}\n")?;
240 } else if let Some(help) = command.get_after_help() {
241 writeln!(buffer, "{help}\n")?;
242 }
243
244 if command.get_subcommands().next().is_some() {
246 writeln!(buffer, "###### **Subcommands:**\n")?;
247
248 for subcommand in command.get_subcommands() {
249 if subcommand.is_hide_set() {
250 continue;
251 }
252
253 let title_name = get_canonical_name(subcommand);
254 let about = match subcommand.get_about() {
255 Some(about) => about.to_string(),
256 None => String::new(),
257 };
258
259 writeln!(buffer, "* `{title_name}` — {about}",)?;
260 }
261
262 writeln!(buffer)?;
263 }
264
265 if command.get_positionals().next().is_some() {
267 writeln!(buffer, "###### **Arguments:**\n")?;
268
269 for pos_arg in command.get_positionals() {
270 write_arg_markdown(buffer, pos_arg)?;
271 }
272
273 writeln!(buffer)?;
274 }
275
276 let non_pos: Vec<_> =
278 command.get_arguments().filter(|arg| !arg.is_positional() && !arg.is_hide_set()).collect();
279
280 if !non_pos.is_empty() {
281 let mut grouped_args: BTreeMap<&str, Vec<&clap::Arg>> = BTreeMap::new();
283
284 for arg in non_pos {
285 let heading = arg.get_help_heading().unwrap_or("Options");
286 grouped_args.entry(heading).or_default().push(arg);
287 }
288
289 for (heading, args) in grouped_args {
291 writeln!(buffer, "###### **{heading}:**\n")?;
292
293 for arg in args {
294 write_arg_markdown(buffer, arg)?;
295 }
296
297 writeln!(buffer)?;
298 }
299 }
300
301 write!(buffer, "\n\n")?;
303
304 for subcommand in command.get_subcommands() {
305 build_command_markdown(buffer, command_path.clone(), subcommand, _depth + 1, options)?;
306 }
307
308 Ok(())
309}
310
311fn write_arg_markdown(buffer: &mut String, arg: &clap::Arg) -> fmt::Result {
312 write!(buffer, "* ")?;
314
315 let value_name: String = match arg.get_value_names() {
316 Some([name, ..]) => name.as_str().to_owned(),
317 Some([]) => unreachable!("clap Arg::get_value_names() returned Some(..) of empty list"),
318 None => arg.get_id().to_string().to_ascii_uppercase(),
319 };
320
321 match (arg.get_short(), arg.get_long()) {
322 (Some(short), Some(long)) => {
323 if arg.get_action().takes_values() {
324 write!(buffer, "`-{short}`, `--{long} <{value_name}>`")?
325 } else {
326 write!(buffer, "`-{short}`, `--{long}`")?
327 }
328 }
329 (Some(short), None) => {
330 if arg.get_action().takes_values() {
331 write!(buffer, "`-{short} <{value_name}>`")?
332 } else {
333 write!(buffer, "`-{short}`")?
334 }
335 }
336 (None, Some(long)) => {
337 if arg.get_action().takes_values() {
338 write!(buffer, "`--{long} <{value_name}>`")?
339 } else {
340 write!(buffer, "`--{long}`")?
341 }
342 }
343 (None, None) => {
344 debug_assert!(
345 arg.is_positional(),
346 "unexpected non-positional Arg with neither short nor long name: {arg:?}"
347 );
348 write!(buffer, "`<{value_name}>`",)?;
349 }
350 }
351
352 if let Some(aliases) = arg.get_visible_aliases().as_deref()
353 && let Some(aliases_str) = get_alias_string(aliases)
354 {
355 write!(buffer, " [{}: {aliases_str}]", pluralize(aliases.len(), "alias", "aliases"))?;
356 }
357
358 if let Some(help) = arg.get_long_help() {
359 buffer.push_str(&indent(&help.to_string(), " — ", " "))
360 } else if let Some(short_help) = arg.get_help() {
361 writeln!(buffer, " — {short_help}")?;
362 } else {
363 writeln!(buffer)?;
364 }
365
366 if !arg.get_default_values().is_empty() {
368 let default_values: String = arg
369 .get_default_values()
370 .iter()
371 .map(|value| format!("`{}`", value.to_string_lossy()))
372 .collect::<Vec<String>>()
373 .join(", ");
374
375 if arg.get_default_values().len() > 1 {
376 writeln!(buffer, "\n Default values: {default_values}")?;
377 } else {
378 writeln!(buffer, "\n Default value: {default_values}")?;
379 }
380 }
381
382 let possible_values: Vec<PossibleValue> =
384 arg.get_possible_values().into_iter().filter(|pv| !pv.is_hide_set()).collect();
385
386 if !possible_values.is_empty() && !matches!(arg.get_action(), clap::ArgAction::SetTrue) {
387 let any_have_help: bool = possible_values.iter().any(|pv| pv.get_help().is_some());
388
389 if any_have_help {
390 let text: String = possible_values
391 .iter()
392 .map(|pv| match pv.get_help() {
393 Some(help) => {
394 format!(" - `{}`:\n {}\n", pv.get_name(), help)
395 }
396 None => format!(" - `{}`\n", pv.get_name()),
397 })
398 .collect::<Vec<String>>()
399 .join("");
400
401 writeln!(buffer, "\n Possible values:\n{text}")?;
402 } else {
403 let text: String = possible_values
404 .iter()
405 .map(|pv| format!("`{}`", pv.get_name()))
406 .collect::<Vec<String>>()
407 .join(", ");
408
409 writeln!(buffer, "\n Possible values: {text}\n")?;
410 }
411 }
412
413 if !arg.is_hide_env_set()
415 && let Some(env) = arg.get_env()
416 {
417 writeln!(buffer, "\n Environment variable: `{}`", env.to_string_lossy())?;
418 }
419
420 Ok(())
421}
422
423fn get_canonical_name(command: &clap::Command) -> String {
425 command
426 .get_display_name()
427 .or_else(|| command.get_bin_name())
428 .map(|name| name.to_owned())
429 .unwrap_or_else(|| command.get_name().to_owned())
430}
431
432fn indent(s: &str, first: &str, rest: &str) -> String {
434 if s.is_empty() {
435 return "\n".to_string();
436 }
437 let mut result = String::new();
438 let mut first_line = true;
439
440 for line in s.lines() {
441 if !line.is_empty() {
442 result.push_str(if first_line { first } else { rest });
443 result.push_str(line);
444 first_line = false;
445 }
446 result.push('\n');
447 }
448 result
449}
450
451fn get_alias_string(aliases: &[&str]) -> Option<String> {
452 if aliases.is_empty() {
453 return None;
454 }
455
456 Some(aliases.iter().map(|alias| format!("`{alias}`")).collect::<Vec<_>>().join(", "))
457}
458
459fn pluralize<'a>(count: usize, singular: &'a str, plural: &'a str) -> &'a str {
460 if count == 1 { singular } else { plural }
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466 use clap::{Arg, Command};
467 use pretty_assertions::assert_eq;
468
469 #[test]
470 fn test_indent() {
471 assert_eq!(&indent("Header\n\nMore info", "___", "~~~~"), "___Header\n\n~~~~More info\n");
472 assert_eq!(
473 &indent("Header\n\nMore info\n", "___", "~~~~"),
474 &indent("Header\n\nMore info", "___", "~~~~"),
475 );
476 assert_eq!(&indent("", "___", "~~~~"), "\n");
477 assert_eq!(&indent("\n", "___", "~~~~"), "\n");
478 }
479
480 #[test]
481 fn test_version_output() {
482 let app = Command::new("test-app").version("1.2.3").about("A test application");
483
484 let markdown =
485 help_markdown_command_custom(&app, &MarkdownOptions::new().show_footer(false));
486
487 assert!(markdown.contains("**Version:** `1.2.3`"), "Should contain version");
488 }
489
490 #[test]
491 fn test_multiline_version() {
492 let multi_line_version = "my-cli 1.2.3 (abc123)\nmy-lib 2.0.0 (789xyz)";
493
494 let app = Command::new("my-cli").version(multi_line_version).about("Multi-version CLI");
495
496 let markdown =
497 help_markdown_command_custom(&app, &MarkdownOptions::new().show_footer(false));
498
499 assert!(markdown.contains("**Version:**\n\n```"), "Should use code block for multi-line");
500 }
501
502 #[test]
503 fn test_env_var_output() {
504 let app = Command::new("env-test").about("Test env var output").arg(
505 Arg::new("config")
506 .short('c')
507 .long("config")
508 .env("CONFIG_PATH")
509 .help("Path to config file"),
510 );
511
512 let markdown =
513 help_markdown_command_custom(&app, &MarkdownOptions::new().show_footer(false));
514
515 assert!(
516 markdown.contains("Environment variable: `CONFIG_PATH`"),
517 "Should show env var. Output: {markdown}"
518 );
519 }
520
521 #[test]
522 fn test_grouped_options() {
523 let app = Command::new("grouped-app")
524 .about("Test app with grouped options")
525 .arg(
526 Arg::new("verbose")
527 .short('v')
528 .long("verbose")
529 .help("Enable verbose output")
530 .help_heading("General Options")
531 .action(clap::ArgAction::SetTrue),
532 )
533 .arg(
534 Arg::new("input")
535 .short('i')
536 .long("input")
537 .help("Input file")
538 .help_heading("File Options")
539 .value_name("FILE"),
540 )
541 .arg(
542 Arg::new("format")
543 .short('f')
544 .long("format")
545 .help("Output format")
546 .value_name("FORMAT"),
547 );
548
549 let markdown =
550 help_markdown_command_custom(&app, &MarkdownOptions::new().show_footer(false));
551
552 assert!(markdown.contains("###### **File Options:**"), "Should have File Options heading");
553 assert!(
554 markdown.contains("###### **General Options:**"),
555 "Should have General Options heading"
556 );
557 assert!(markdown.contains("###### **Options:**"), "Should have default Options heading");
558 }
559
560 #[test]
561 fn test_no_grouped_options_backward_compatibility() {
562 let app = Command::new("simple-app")
563 .about("Test app without grouped options")
564 .arg(
565 Arg::new("verbose")
566 .short('v')
567 .long("verbose")
568 .help("Enable verbose output")
569 .action(clap::ArgAction::SetTrue),
570 )
571 .arg(
572 Arg::new("output").short('o').long("output").help("Output file").value_name("FILE"),
573 );
574
575 let markdown =
576 help_markdown_command_custom(&app, &MarkdownOptions::new().show_footer(false));
577
578 assert!(markdown.contains("###### **Options:**"), "Should have default Options heading");
579 assert!(markdown.contains("`-v`, `--verbose`"), "Should have verbose option");
580 assert!(markdown.contains("`-o`, `--output <FILE>`"), "Should have output option");
581 }
582}