Skip to main content

foundry_cli_markdown/
lib.rs

1//! Generate Markdown documentation for clap command-line tools.
2//!
3//! This is a fork of [`clap-markdown`](https://crates.io/crates/clap-markdown) with the following
4//! enhancements:
5//! - Support for grouped options by help heading ([PR #48](https://github.com/ConnorGray/clap-markdown/pull/48))
6//! - Show environment variable names for arguments ([PR #50](https://github.com/ConnorGray/clap-markdown/pull/50))
7//! - Add version information to generated Markdown ([PR #52](https://github.com/ConnorGray/clap-markdown/pull/52))
8
9use std::{
10    collections::BTreeMap,
11    fmt::{self, Write},
12};
13
14use clap::builder::PossibleValue;
15
16/// Options to customize the structure of the output Markdown document.
17#[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    /// Construct a default instance of `MarkdownOptions`.
27    pub fn new() -> Self {
28        Self { title: None, show_footer: true, show_table_of_contents: true, show_aliases: true }
29    }
30
31    /// Set a custom title to use in the generated document.
32    pub fn title(mut self, title: String) -> Self {
33        self.title = Some(title);
34        self
35    }
36
37    /// Whether to show the default footer advertising `clap-markdown`.
38    pub fn show_footer(mut self, show: bool) -> Self {
39        self.show_footer = show;
40        self
41    }
42
43    /// Whether to show the default table of contents.
44    pub fn show_table_of_contents(mut self, show: bool) -> Self {
45        self.show_table_of_contents = show;
46        self
47    }
48
49    /// Whether to show aliases for arguments and commands.
50    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
62/// Format the help information for `command` as Markdown.
63pub fn help_markdown<C: clap::CommandFactory>() -> String {
64    let command = C::command();
65    help_markdown_command(&command)
66}
67
68/// Format the help information for `command` as Markdown, with custom options.
69pub fn help_markdown_custom<C: clap::CommandFactory>(options: &MarkdownOptions) -> String {
70    let command = C::command();
71    help_markdown_command_custom(&command, options)
72}
73
74/// Format the help information for `command` as Markdown.
75pub fn help_markdown_command(command: &clap::Command) -> String {
76    help_markdown_command_custom(command, &Default::default())
77}
78
79/// Format the help information for `command` as Markdown, with custom options.
80pub 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/// Format the help information for `command` as Markdown and print it.
87///
88/// Output is printed to the standard output.
89#[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    // Write the version if available (PR #52)
113    if let Some(version) = command.get_version() {
114        let version_str = version.to_string();
115
116        if version_str.contains('\n') {
117            // Multi-line version: use a code block
118            writeln!(buffer, "**Version:**\n\n```\n{}\n```\n", version_str.trim()).unwrap();
119        } else {
120            // Single-line version: use inline code
121            writeln!(buffer, "**Version:** `{version_str}`\n").unwrap();
122        }
123    }
124
125    // Write the table of contents
126    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    // Write the commands/subcommands sections
133    build_command_markdown(buffer, Vec::new(), command, 0, options).unwrap();
134
135    // Write the footer
136    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    // Don't document commands marked with `clap(hide = true)`
158    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    // Don't document commands marked with `clap(hide = true)`
187    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    // Write the markdown heading
200    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    // Subcommands
245    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    // Arguments (positional)
266    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    // Options (grouped by help heading) - PR #48
277    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        // Group arguments by help heading
282        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        // Write each group with its heading
290        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    // Include extra space between commands
302    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    // Markdown list item
313    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    // Arg default values
367    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    // Arg possible values
383    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    // Arg environment variable (PR #50)
414    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
423/// Utility function to get the canonical name of a command.
424fn 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
432/// Indents non-empty lines. The output always ends with a newline.
433fn 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}