use std::fmt::Write;
use clap::{Arg, ArgAction, Command};
use crate::options::{Flavor, MetadataStyle, Options};
pub(crate) fn render(root: &Command, opts: &Options) -> String {
let mut buf = String::new();
let path = vec![canonical_name(root)];
if emits_metadata(opts) {
write_yaml_metadata(&mut buf, &path, opts);
}
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");
}
let trimmed = buf.trim_end();
format!("{trimmed}\n")
}
fn render_command(buf: &mut String, cmd: &Command, path: &[String], depth: usize, opts: &Options) {
let level = opts.base_heading_level as usize + depth;
if depth != 0 || !emits_metadata(opts) {
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);
}
if let Some(about) = cmd.get_long_about().or_else(|| cmd.get_about()) {
let _ = writeln!(buf, "{about}\n");
}
let _ = writeln!(buf, "**Usage:** `{}`\n", usage_line(cmd, path));
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");
}
}
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, opts.flavor));
buf.push('\n');
}
}
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, opts.flavor));
buf.push('\n');
}
}
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);
}
}
fn emits_metadata(opts: &Options) -> bool {
opts.flavor == Flavor::Pandoc && opts.metadata
}
fn write_yaml_metadata(buf: &mut String, path: &[String], opts: &Options) {
let title = opts.title.clone().unwrap_or_else(|| path.join(" "));
buf.push_str("---\n");
let _ = writeln!(buf, "title: {}", yaml_scalar(&title, MetadataStyle::Auto));
for (key, value, style) in &opts.metadata_fields {
if key == "title" {
continue;
}
let _ = writeln!(buf, "{key}: {}", yaml_scalar(value, *style));
}
buf.push_str("---\n\n");
}
fn yaml_scalar(value: &str, style: MetadataStyle) -> String {
let needs_escapes = value.chars().any(|c| {
(c.is_control() && !matches!(c, '\n' | '\t')) || matches!(c, '\u{2028}' | '\u{2029}')
});
if !needs_escapes && (style != MetadataStyle::Auto || value.contains('\n')) {
return yaml_block(value, style);
}
if is_plain_scalar(value) {
value.to_string()
} else {
let mut quoted = String::from("\"");
for c in value.chars() {
match c {
'\\' => quoted.push_str("\\\\"),
'"' => quoted.push_str("\\\""),
'\n' => quoted.push_str("\\n"),
'\r' => quoted.push_str("\\r"),
'\t' => quoted.push_str("\\t"),
c if c.is_control() || matches!(c, '\u{2028}' | '\u{2029}') => {
let _ = write!(quoted, "\\u{:04X}", c as u32);
}
c => quoted.push(c),
}
}
if style != MetadataStyle::Auto && !value.is_empty() && !value.ends_with('\n') {
quoted.push_str("\\n");
}
quoted.push('"');
quoted
}
}
fn yaml_block(value: &str, style: MetadataStyle) -> String {
let indicator = if style == MetadataStyle::Folded {
'>'
} else {
'|'
};
let trailing_newlines = value.chars().rev().take_while(|&c| c == '\n').count();
let chomping =
if trailing_newlines > 1 || (!value.is_empty() && value.trim_matches('\n').is_empty()) {
"+"
} else if trailing_newlines == 0 && style == MetadataStyle::Auto {
"-"
} else {
""
};
let indentation = if value
.split('\n')
.find(|line| !line.is_empty())
.is_some_and(|line| line.starts_with([' ', '\t']))
{
"2"
} else {
""
};
let mut block = format!("{indicator}{indentation}{chomping}");
for line in value.split_terminator('\n') {
let _ = write!(block, "\n {line}");
}
block
}
fn is_plain_scalar(value: &str) -> bool {
if value.is_empty() || value != value.trim() {
return false;
}
let first = value.chars().next().unwrap();
if "-?:,[]{}#&*!|>'\"%@`".contains(first) {
return false;
}
if value.ends_with(':') || value.contains(": ") || value.contains(" #") {
return false;
}
!value
.chars()
.any(|c| c.is_control() || matches!(c, '\u{2028}' | '\u{2029}'))
}
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");
}
}
fn render_arg(arg: &Arg, flavor: Flavor) -> 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}"));
}
}
}
if flavor == Flavor::CommonMark {
bullet(&term, &blocks)
} else {
definition(&term, &blocks)
}
}
fn bullet(term: &str, blocks: &[String]) -> String {
let mut out = format!("- **{term}**\n");
for block in blocks {
out.push('\n');
for line in block.lines() {
if !line.is_empty() {
let _ = write!(out, " {line}");
}
out.push('\n');
}
}
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(", ")
}
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}");
}
}
if !wrote_marker {
out.push_str(":\n");
}
out
}
fn positional_name(arg: &Arg) -> String {
let mut name = format!("<{}>", value_name(arg));
if is_variadic(arg) {
name.push_str("...");
}
name
}
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()
}
fn takes_value(arg: &Arg) -> bool {
matches!(arg.get_action(), ArgAction::Set | ArgAction::Append)
}
fn is_variadic(arg: &Arg) -> bool {
arg.get_num_args()
.map(|r| r.max_values() > 1)
.unwrap_or(false)
}
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()
}
}
fn canonical_name(cmd: &Command) -> String {
cmd.get_display_name()
.or_else(|| cmd.get_bin_name())
.unwrap_or_else(|| cmd.get_name())
.to_string()
}
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);
}
}
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
}