clapdown 0.1.0

Generate well-structured Markdown documentation from a clap CLI.
Documentation
//! The core recursive renderer.
//!
//! Everything funnels through [`render`], which walks the command tree and
//! writes headings whose level tracks nesting depth so the outline never skips
//! a level. Flavor-dependent pieces are isolated in small helpers so future
//! flavors can be added with minimal churn.

use std::fmt::Write;

use clap::{Arg, ArgAction, Command};

use crate::options::Options;

/// Render `root` to Markdown using `opts`.
pub(crate) fn render(root: &Command, opts: &Options) -> String {
    let mut buf = String::new();
    let path = vec![canonical_name(root)];

    if opts.table_of_contents {
        buf.push_str("**Contents:**\n\n");
        write_toc(&mut buf, root, &path, 0);
        buf.push('\n');
    }

    render_command(&mut buf, root, &path, 0, opts);

    if opts.footer {
        buf.push_str("---\n\n");
        buf.push_str("_Generated by [clapdown](https://crates.io/crates/clapdown)._\n");
    }

    // Normalize to a single trailing newline.
    let trimmed = buf.trim_end();
    format!("{trimmed}\n")
}

/// Recursively render a command and its subcommands.
fn render_command(buf: &mut String, cmd: &Command, path: &[String], depth: usize, opts: &Options) {
    let level = opts.base_heading_level as usize + depth;

    // Heading. The root heading can be overridden by `title`.
    let heading = if depth == 0 {
        match &opts.title {
            Some(title) => title.clone(),
            None => format!("`{}`", path.join(" ")),
        }
    } else {
        format!("`{}`", path.join(" "))
    };
    write_heading(buf, level, &heading);

    // Description: prefer the long about, fall back to the short about.
    if let Some(about) = cmd.get_long_about().or_else(|| cmd.get_about()) {
        let _ = writeln!(buf, "{about}\n");
    }

    // Usage.
    let _ = writeln!(buf, "**Usage:** `{}`\n", usage_line(cmd, path));

    // Aliases.
    if opts.aliases {
        let aliases: Vec<_> = cmd.get_visible_aliases().collect();
        if !aliases.is_empty() {
            let label = if aliases.len() == 1 {
                "Alias"
            } else {
                "Aliases"
            };
            let joined = aliases
                .iter()
                .map(|a| format!("`{a}`"))
                .collect::<Vec<_>>()
                .join(", ");
            let _ = writeln!(buf, "**{label}:** {joined}\n");
        }
    }

    // Arguments (positionals).
    let positionals: Vec<&Arg> = cmd
        .get_arguments()
        .filter(|a| a.is_positional() && !a.is_hide_set())
        .collect();
    if !positionals.is_empty() {
        write_heading(buf, level + 1, "Arguments");
        for arg in positionals {
            buf.push_str(&render_arg(arg));
            buf.push('\n');
        }
    }

    // Options (everything non-positional except the auto help/version flags).
    let options: Vec<&Arg> = cmd
        .get_arguments()
        .filter(|a| !a.is_positional() && !a.is_hide_set())
        .filter(|a| a.get_id() != "help" && a.get_id() != "version")
        .collect();
    if !options.is_empty() {
        write_heading(buf, level + 1, "Options");
        for arg in options {
            buf.push_str(&render_arg(arg));
            buf.push('\n');
        }
    }

    // Subcommands: each recurses one level deeper, so its heading is a sibling
    // of this command's Arguments/Options headings.
    for sub in cmd.get_subcommands().filter(|s| !s.is_hide_set()) {
        let mut child_path = path.to_vec();
        child_path.push(sub.get_name().to_string());
        render_command(buf, sub, &child_path, depth + 1, opts);
    }
}

/// Write a heading, or a bold label if the level would exceed h6.
fn write_heading(buf: &mut String, level: usize, text: &str) {
    if level <= 6 {
        let _ = writeln!(buf, "{} {text}\n", "#".repeat(level));
    } else {
        let _ = writeln!(buf, "**{text}**\n");
    }
}

/// Render one argument as a definition-list item.
fn render_arg(arg: &Arg) -> String {
    let term = arg_term(arg);
    let mut blocks: Vec<String> = Vec::new();

    if let Some(help) = arg.get_long_help().or_else(|| arg.get_help()) {
        blocks.push(help.to_string());
    }

    let defaults: Vec<String> = arg
        .get_default_values()
        .iter()
        .map(|v| format!("`{}`", v.to_string_lossy()))
        .collect();
    if !defaults.is_empty() {
        let label = if defaults.len() == 1 {
            "Default value"
        } else {
            "Default values"
        };
        blocks.push(format!("{label}: {}", defaults.join(", ")));
    }

    if !matches!(arg.get_action(), ArgAction::SetTrue | ArgAction::SetFalse) {
        let possible: Vec<_> = arg
            .get_possible_values()
            .into_iter()
            .filter(|pv| !pv.is_hide_set())
            .collect();
        if !possible.is_empty() {
            if possible.iter().any(|pv| pv.get_help().is_some()) {
                let mut list = String::from("Possible values:\n");
                for pv in &possible {
                    match pv.get_help() {
                        Some(help) => {
                            let _ = write!(list, "\n- `{}`: {help}", pv.get_name());
                        }
                        None => {
                            let _ = write!(list, "\n- `{}`", pv.get_name());
                        }
                    }
                }
                blocks.push(list);
            } else {
                let names = possible
                    .iter()
                    .map(|pv| format!("`{}`", pv.get_name()))
                    .collect::<Vec<_>>()
                    .join(", ");
                blocks.push(format!("Possible values: {names}"));
            }
        }
    }

    definition(&term, &blocks)
}

/// Format the invocation term for an argument, e.g. `` `-o`, `--output <OUT>` ``.
fn arg_term(arg: &Arg) -> String {
    if arg.is_positional() {
        return format!("`{}`", positional_name(arg));
    }

    let mut tokens: Vec<String> = Vec::new();
    if let Some(short) = arg.get_short() {
        tokens.push(format!("-{short}"));
    }
    if let Some(long) = arg.get_long() {
        tokens.push(format!("--{long}"));
    }
    if takes_value(arg)
        && let Some(last) = tokens.last_mut()
    {
        let _ = write!(last, " <{}>", value_name(arg));
    }
    tokens
        .into_iter()
        .map(|t| format!("`{t}`"))
        .collect::<Vec<_>>()
        .join(", ")
}

/// Assemble a definition-list item: a term line followed by an indented,
/// possibly multi-block, definition.
fn definition(term: &str, blocks: &[String]) -> String {
    let mut out = String::new();
    let _ = writeln!(out, "{term}");

    let body = blocks.join("\n\n");
    let mut wrote_marker = false;
    for line in body.lines() {
        if !wrote_marker {
            if line.is_empty() {
                out.push_str(":\n");
            } else {
                let _ = writeln!(out, ":   {line}");
            }
            wrote_marker = true;
        } else if line.is_empty() {
            out.push('\n');
        } else {
            let _ = writeln!(out, "    {line}");
        }
    }

    // A term with no definition is not a valid definition list; emit an empty
    // definition marker so the structure holds.
    if !wrote_marker {
        out.push_str(":\n");
    }

    out
}

/// The display name for a positional, including a trailing `...` if variadic.
fn positional_name(arg: &Arg) -> String {
    let mut name = format!("<{}>", value_name(arg));
    if is_variadic(arg) {
        name.push_str("...");
    }
    name
}

/// The value placeholder for an argument (first value name, else the upper-cased id).
fn value_name(arg: &Arg) -> String {
    if let Some(names) = arg.get_value_names()
        && let Some(first) = names.first()
    {
        return first.to_string();
    }
    arg.get_id().as_str().to_uppercase()
}

/// Whether the argument consumes a value.
fn takes_value(arg: &Arg) -> bool {
    matches!(arg.get_action(), ArgAction::Set | ArgAction::Append)
}

/// Whether the argument accepts more than one value.
fn is_variadic(arg: &Arg) -> bool {
    arg.get_num_args()
        .map(|r| r.max_values() > 1)
        .unwrap_or(false)
}

/// Build the usage string, prefixing the parent command path.
fn usage_line(cmd: &Command, path: &[String]) -> String {
    let usage = cmd.clone().render_usage().to_string();
    let body = usage.strip_prefix("Usage: ").unwrap_or(&usage).trim();
    if path.len() > 1 {
        format!("{} {body}", path[..path.len() - 1].join(" "))
    } else {
        body.to_string()
    }
}

/// Resolve a command's display string.
fn canonical_name(cmd: &Command) -> String {
    cmd.get_display_name()
        .or_else(|| cmd.get_bin_name())
        .unwrap_or_else(|| cmd.get_name())
        .to_string()
}

/// Write a nested table-of-contents bullet list.
fn write_toc(buf: &mut String, cmd: &Command, path: &[String], indent: usize) {
    let text = path.join(" ");
    let anchor = slug(&format!("`{text}`"));
    let _ = writeln!(buf, "{}- [`{text}`](#{anchor})", "  ".repeat(indent));
    for sub in cmd.get_subcommands().filter(|s| !s.is_hide_set()) {
        let mut child_path = path.to_vec();
        child_path.push(sub.get_name().to_string());
        write_toc(buf, sub, &child_path, indent + 1);
    }
}

/// Slugify heading text the way mdBook builds heading anchors.
fn slug(text: &str) -> String {
    let mut out = String::new();
    for c in text.chars() {
        if c.is_alphanumeric() {
            out.extend(c.to_lowercase());
        } else if c == ' ' || c == '-' || c == '_' {
            out.push('-');
        }
    }
    out
}