use crate::docs::models::{Spec, SpecArg, SpecCommand, SpecFlag};
use crate::error::UsageErr;
use itertools::Itertools;
use roff::{bold, italic, roman, Roff};
#[derive(Debug, Clone)]
pub struct ManpageRenderer {
spec: Spec,
section: u8,
}
impl ManpageRenderer {
pub fn new(spec: crate::Spec) -> Self {
Self {
spec: spec.into(),
section: 1,
}
}
pub fn with_section(mut self, section: u8) -> Self {
self.section = section;
self
}
pub fn render(&self) -> Result<String, UsageErr> {
let mut roff = Roff::new();
let section_str = self.section.to_string();
roff.control(
"TH",
[self.spec.name.to_uppercase().as_str(), section_str.as_str()],
);
self.render_name(&mut roff);
self.render_synopsis(&mut roff);
self.render_description(&mut roff);
self.render_command(&mut roff, &self.spec.cmd, true);
self.render_subcommand_details(&mut roff, &self.spec.cmd, &self.spec.bin);
if !self.spec.examples.is_empty() {
roff.control("SH", ["EXAMPLES"]);
for (i, example) in self.spec.examples.iter().enumerate() {
if i > 0 {
roff.control("PP", [] as [&str; 0]);
}
if let Some(header) = &example.header {
roff.text([bold(header)]);
}
if let Some(help) = &example.help {
roff.text([roman(help.as_str())]);
}
roff.control("PP", [] as [&str; 0]);
roff.control("RS", ["4"]);
roff.text([roman(example.code.as_str())]);
roff.control("RE", [] as [&str; 0]);
}
}
self.render_configuration(&mut roff);
if let Some(license) = &self.spec.license {
roff.control("SH", ["LICENSE"]);
roff.text([roman(license)]);
}
if let Some(repository) = &self.spec.repository {
roff.control("SH", ["SOURCE"]);
roff.text([roman(repository)]);
}
if let Some(author) = &self.spec.author {
roff.control("SH", ["AUTHOR"]);
roff.text([roman(author)]);
}
Ok(roff.to_roff())
}
fn render_configuration(&self, roff: &mut Roff) {
let config = &self.spec.config;
if config.is_empty() {
return;
}
roff.control("SH", ["CONFIGURATION"]);
if !config.files.is_empty() {
roff.text([roman("Read from the following, in ascending precedence:")]);
roff.control("RS", ["4"]);
for file in &config.files {
let mut line = file.path.clone();
if file.findup {
line.push_str(" (and in every parent directory)");
}
roff.control("PP", [] as [&str; 0]);
roff.text([roman(line)]);
}
roff.control("RE", [] as [&str; 0]);
}
for group in &config.prop_groups {
if let Some(heading) = &group.heading {
roff.control("SS", [heading.as_str()]);
}
for prop in &group.items {
self.render_prop(roff, prop);
}
}
}
fn render_prop(&self, roff: &mut Roff, prop: &crate::docs::models::SpecConfigProp) {
{
roff.control("PP", [] as [&str; 0]);
roff.text([bold(&prop.key)]);
roff.control("RS", ["4"]);
if let Some(help) = prop.help.as_deref() {
roff.text([roman(help)]);
}
let mut facts = Vec::new();
if let Some(ty) = &prop.type_ {
facts.push(format!("type: {ty}"));
}
if !prop.aliases.is_empty() {
facts.push(format!("aliases: {}", prop.aliases.join(", ")));
}
if let Some(optional) = prop.optional {
facts.push(format!("optional: {optional}"));
}
if let Some(default) = &prop.default {
facts.push(format!("default: {default}"));
}
if !prop.sources.is_empty() {
let plain: Vec<String> = prop
.sources
.iter()
.map(|source| source.replace('`', ""))
.collect();
facts.push(format!("set with: {}", plain.join(", ")));
}
if !prop.choices.is_empty() {
let values: Vec<&str> = prop.choices.iter().map(|c| c.value.as_str()).collect();
facts.push(format!("one of: {}", values.join(", ")));
}
if !facts.is_empty() {
roff.control("PP", [] as [&str; 0]);
roff.text([roman(facts.join("; "))]);
}
if let Some(deprecated) = &prop.deprecated {
roff.control("PP", [] as [&str; 0]);
let mut notice = format!("Deprecated: {deprecated}");
if let Some(remove_at) = &prop.deprecated_remove_at {
notice.push_str(&format!(" Removed in {remove_at}."));
}
roff.text([roman(notice)]);
}
roff.control("RE", [] as [&str; 0]);
}
}
fn render_name(&self, roff: &mut Roff) {
roff.control("SH", ["NAME"]);
let description = self
.spec
.about
.as_deref()
.unwrap_or("No description available");
roff.text([roman(format!("{} - {}", self.spec.name, description))]);
}
fn render_synopsis(&self, roff: &mut Roff) {
roff.control("SH", ["SYNOPSIS"]);
if !self.spec.usage.trim().is_empty() {
for line in self.spec.usage.lines() {
let line = line.trim().strip_prefix("Usage: ").unwrap_or(line.trim());
if let Some(rest) = line.strip_prefix(&self.spec.bin) {
roff.text([bold(&self.spec.bin), roman(rest)]);
} else {
roff.text([roman(line)]);
}
}
return;
}
let synopsis = self.build_synopsis(&self.spec.cmd, &self.spec.bin);
roff.text([bold(&self.spec.bin), roman(" "), roman(&synopsis)]);
}
fn build_synopsis(&self, cmd: &SpecCommand, _prefix: &str) -> String {
let mut parts = Vec::new();
if !cmd.flags.is_empty() {
parts.push("[OPTIONS]".to_string());
}
for arg in &cmd.args {
if arg.required {
parts.push(format!("<{}>", arg.name));
} else {
parts.push(format!("[<{}>]", arg.name));
}
if arg.var {
parts.push("...".to_string());
}
}
if !cmd.subcommands.is_empty() {
if cmd.subcommand_required {
parts.push("<COMMAND>".to_string());
} else {
parts.push("[COMMAND]".to_string());
}
}
parts.join(" ")
}
fn render_description(&self, roff: &mut Roff) {
roff.control("SH", ["DESCRIPTION"]);
if let Some(about) = &self.spec.about_long.as_ref().or(self.spec.about.as_ref()) {
for paragraph in about.split("\n\n") {
roff.text([roman(paragraph.trim())]);
roff.control("PP", [] as [&str; 0]);
}
}
if let Some(help) = &self
.spec
.cmd
.help_long
.as_ref()
.or(self.spec.cmd.help.as_ref())
{
for paragraph in help.split("\n\n") {
roff.text([roman(paragraph.trim())]);
roff.control("PP", [] as [&str; 0]);
}
}
if let Some(notice) = deprecation_notice(
self.spec.cmd.deprecated.as_deref(),
self.spec.cmd.deprecated_warn_at.as_deref(),
self.spec.cmd.deprecated_remove_at.as_deref(),
) {
roff.text([italic(notice)]);
roff.control("PP", [] as [&str; 0]);
}
}
fn render_command(&self, roff: &mut Roff, cmd: &SpecCommand, is_root: bool) {
if !cmd.flags.is_empty() {
roff.control("SH", ["OPTIONS"]);
for flag in &cmd.flags {
self.render_flag(roff, flag);
}
}
if !cmd.args.is_empty()
&& (!is_root
|| cmd
.args
.iter()
.any(|a| a.help.is_some() || a.help_long.is_some()))
{
if is_root {
roff.control("SH", ["ARGUMENTS"]);
}
for arg in &cmd.args {
self.render_arg(roff, arg);
}
}
let all_subcommands = cmd.all_subcommands();
if !all_subcommands.is_empty() {
roff.control("SH", ["COMMANDS"]);
self.render_all_subcommands(roff, &self.spec.cmd, "");
}
if !cmd.examples.is_empty() {
roff.control("SH", ["EXAMPLES"]);
for (i, example) in cmd.examples.iter().enumerate() {
if i > 0 {
roff.control("PP", [] as [&str; 0]);
}
if let Some(header) = &example.header {
roff.text([bold(header)]);
}
if let Some(help) = &example.help {
roff.text([roman(help.as_str())]);
}
roff.control("PP", [] as [&str; 0]);
roff.control("RS", ["4"]);
roff.text([roman(example.code.as_str())]);
roff.control("RE", [] as [&str; 0]);
}
}
}
fn render_flag(&self, roff: &mut Roff, flag: &SpecFlag) {
roff.control("TP", [] as [&str; 0]);
let mut flag_parts = Vec::new();
for short in &flag.short {
flag_parts.push(format!("-{}", short));
}
for long in &flag.long {
flag_parts.push(format!("--{}", long));
}
let flag_usage = flag_parts.join(", ");
if let Some(arg) = &flag.arg {
roff.text([
bold(&flag_usage),
roman(" "),
italic(format!("<{}>", arg.name)),
]);
} else {
roff.text([bold(&flag_usage)]);
}
if let Some(help) = &flag.help_long.as_ref().or(flag.help.as_ref()) {
roff.text([roman(help.as_str())]);
}
if let Some(notice) = deprecation_notice(
flag.deprecated.as_deref(),
flag.deprecated_warn_at.as_deref(),
flag.deprecated_remove_at.as_deref(),
) {
roff.text([italic(notice)]);
}
if !flag.default.is_empty() {
roff.control("RS", [] as [&str; 0]);
let default_str = flag.default.join(", ");
roff.text([italic("Default: "), roman(default_str.as_str())]);
roff.control("RE", [] as [&str; 0]);
}
if let Some(env) = &flag.env {
roff.control("RS", [] as [&str; 0]);
roff.text([italic("Environment: "), bold(env.as_str())]);
roff.control("RE", [] as [&str; 0]);
}
for env in &flag.env_fallback {
roff.control("RS", [] as [&str; 0]);
roff.text([italic("Environment fallback: "), bold(env.as_str())]);
roff.control("RE", [] as [&str; 0]);
}
for env in &flag.deprecated_env {
roff.control("RS", [] as [&str; 0]);
roff.text([italic("Deprecated environment: "), bold(env.as_str())]);
roff.control("RE", [] as [&str; 0]);
}
}
fn render_arg(&self, roff: &mut Roff, arg: &SpecArg) {
if arg.help.is_none() && arg.help_long.is_none() {
return;
}
roff.control("TP", [] as [&str; 0]);
roff.text([bold(format!("<{}>", arg.name))]);
if let Some(help) = &arg.help_long.as_ref().or(arg.help.as_ref()) {
roff.text([roman(help.as_str())]);
}
if !arg.default.is_empty() {
roff.control("RS", [] as [&str; 0]);
let default_str = arg.default.join(", ");
roff.text([italic("Default: "), roman(default_str.as_str())]);
roff.control("RE", [] as [&str; 0]);
}
if let Some(env) = &arg.env {
roff.control("RS", [] as [&str; 0]);
roff.text([italic("Environment: "), bold(env.as_str())]);
roff.control("RE", [] as [&str; 0]);
}
for env in &arg.env_fallback {
roff.control("RS", [] as [&str; 0]);
roff.text([italic("Environment fallback: "), bold(env.as_str())]);
roff.control("RE", [] as [&str; 0]);
}
for env in &arg.deprecated_env {
roff.control("RS", [] as [&str; 0]);
roff.text([italic("Deprecated environment: "), bold(env.as_str())]);
roff.control("RE", [] as [&str; 0]);
}
}
fn render_all_subcommands(&self, roff: &mut Roff, cmd: &SpecCommand, prefix: &str) {
for (name, subcmd) in &cmd.subcommands {
if subcmd.hide {
continue;
}
let full_name = if prefix.is_empty() {
name.to_string()
} else {
format!("{} {}", prefix, name)
};
self.render_subcommand_summary(roff, &full_name, subcmd);
self.render_all_subcommands(roff, subcmd, &full_name);
}
}
fn render_subcommand_details(&self, roff: &mut Roff, cmd: &SpecCommand, prefix: &str) {
for (name, subcmd) in &cmd.subcommands {
if subcmd.hide {
continue;
}
let full_name = if prefix.is_empty() {
name.to_string()
} else {
format!("{} {}", prefix, name)
};
let has_flags = !subcmd.flags.is_empty();
let has_documented_args = subcmd
.args
.iter()
.any(|a| a.help.is_some() || a.help_long.is_some());
let has_examples = !subcmd.examples.is_empty();
if has_flags || has_documented_args || has_examples {
roff.control("SH", [full_name.to_uppercase().as_str()]);
if let Some(help) = &subcmd.help_long.as_ref().or(subcmd.help.as_ref()) {
roff.text([roman(help.as_str())]);
roff.control("PP", [] as [&str; 0]);
}
if let Some(notice) = deprecation_notice(
subcmd.deprecated.as_deref(),
subcmd.deprecated_warn_at.as_deref(),
subcmd.deprecated_remove_at.as_deref(),
) {
roff.text([italic(notice)]);
roff.control("PP", [] as [&str; 0]);
}
let synopsis = self.build_synopsis(subcmd, &full_name);
roff.text([
bold("Usage:"),
roman(" "),
roman(&full_name),
roman(" "),
roman(&synopsis),
]);
roff.control("PP", [] as [&str; 0]);
if !subcmd.flags.is_empty() {
roff.text([bold("Options:")]);
roff.control("PP", [] as [&str; 0]);
for flag in &subcmd.flags {
self.render_flag(roff, flag);
}
}
if has_documented_args {
roff.text([bold("Arguments:")]);
roff.control("PP", [] as [&str; 0]);
for arg in &subcmd.args {
self.render_arg(roff, arg);
}
}
if has_examples {
roff.text([bold("Examples:")]);
roff.control("PP", [] as [&str; 0]);
for (i, example) in subcmd.examples.iter().enumerate() {
if i > 0 {
roff.control("PP", [] as [&str; 0]);
}
if let Some(header) = &example.header {
roff.text([bold(header)]);
}
if let Some(help) = &example.help {
roff.text([roman(help.as_str())]);
}
roff.control("PP", [] as [&str; 0]);
roff.control("RS", ["4"]);
roff.text([roman(example.code.as_str())]);
roff.control("RE", [] as [&str; 0]);
}
}
}
self.render_subcommand_details(roff, subcmd, &full_name);
}
}
fn render_subcommand_summary(&self, roff: &mut Roff, name: &str, cmd: &SpecCommand) {
roff.control("TP", [] as [&str; 0]);
roff.text([bold(name)]);
if let Some(help) = &cmd.help_long.as_ref().or(cmd.help.as_ref()) {
let first_line = help.lines().next().unwrap_or("");
roff.text([roman(first_line)]);
}
if let Some(notice) = deprecation_notice(
cmd.deprecated.as_deref(),
cmd.deprecated_warn_at.as_deref(),
cmd.deprecated_remove_at.as_deref(),
) {
roff.text([italic(notice)]);
}
if !cmd.aliases.is_empty() {
let aliases = cmd.aliases.iter().join(", ");
roff.control("RS", [] as [&str; 0]);
roff.text([italic("Aliases: "), roman(aliases.as_str())]);
roff.control("RE", [] as [&str; 0]);
}
}
}
fn deprecation_notice(
message: Option<&str>,
warn_at: Option<&str>,
remove_at: Option<&str>,
) -> Option<String> {
if message.is_none() && warn_at.is_none() && remove_at.is_none() {
return None;
}
let mut parts = Vec::new();
if let Some(message) = message {
parts.push(message.to_string());
}
if let Some(at) = warn_at {
parts.push(format!("warns at {at}"));
}
if let Some(at) = remove_at {
parts.push(format!("removed at {at}"));
}
Some(format!("Deprecated: {}", parts.join("; ")))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Spec;
#[test]
fn the_settings_get_a_section_of_their_own() {
let spec: Spec = r##"
name "hk"
bin "hk"
config {
source "git" name="git config" doc_hint="git config `{key}`"
file "hk.toml" findup=#true
prop "jobs" type="uint" default=4 help="Number of parallel jobs" {
cli "--jobs" "-j"
env "HK_JOBS"
source "git" "hk.jobs"
}
prop "old" deprecated="Use jobs instead." deprecated_remove_at="2027.12.0" help="Old"
prop "stash" type="string" help="How to stash" {
choices {
choice "git" help="Use `git stash`"
choice "none" help="No stashing"
}
}
prop "secret" hide=#true help="Not in the page"
}
"##
.parse()
.unwrap();
let page = ManpageRenderer::new(spec).render().unwrap();
assert!(page.contains(".SH CONFIGURATION"), "{page}");
assert!(
page.contains("hk.toml (and in every parent directory)"),
"{page}"
);
assert!(page.contains("jobs"), "{page}");
assert!(
page.contains(
"type: uint; default: 4; set with: \\-\\-jobs, \\-j, HK_JOBS, git config hk.jobs"
),
"{page}"
);
assert!(
page.contains("Deprecated: Use jobs instead. Removed in 2027.12.0."),
"{page}"
);
assert!(page.contains("one of: git, none"), "{page}");
assert!(
!page.contains("secret"),
"a hidden prop should not be here:\n{page}"
);
assert!(!page.contains('`'), "no backticks in a man page:\n{page}");
}
#[test]
fn the_manpage_groups_settings_by_heading_like_the_page_does() {
let spec: Spec = r##"
name "hk"
bin "hk"
config {
prop "jobs" type="uint" help="How many" help_heading="Performance"
prop "cache" type="bool" help="Cache things" help_heading="Performance"
prop "colour" type="bool" help="Colourize"
}
"##
.parse()
.unwrap();
let page = ManpageRenderer::new(spec).render().unwrap();
assert!(page.contains(".SS Performance"), "{page}");
let colour = page.find("colour").expect("colour");
let heading = page.find(".SS Performance").expect("heading");
let jobs = page.find("jobs").expect("jobs");
let cache = page.find("cache").expect("cache");
assert!(colour < heading, "unheaded settings come first:\n{page}");
assert!(heading < cache && heading < jobs, "{page}");
}
#[test]
fn a_cli_with_no_settings_has_no_configuration_section() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\n".parse().unwrap();
let page = ManpageRenderer::new(spec).render().unwrap();
assert!(!page.contains("CONFIGURATION"), "{page}");
}
#[test]
fn an_explicit_usage_renders_each_alternative_in_the_synopsis() {
let spec: Spec = r#"
name "ex"
bin "ex"
usage "Usage: ex <COMMAND>\n ex --print-spec"
cmd "run"
"#
.parse()
.unwrap();
let page = ManpageRenderer::new(spec).render().unwrap();
assert!(page.contains("\\fBex\\fR <COMMAND>"), "{page}");
assert!(page.contains("\\fBex\\fR \\-\\-print\\-spec"), "{page}");
assert!(!page.contains("[COMMAND]"), "{page}");
}
#[test]
fn where_the_files_live_is_documented_even_with_nothing_to_put_in_them() {
let spec: Spec = r##"
name "ex"
bin "ex"
config {
file "/etc/ex/config.toml" scope="system"
file "ex.toml" findup=#true
}
"##
.parse()
.unwrap();
let page = ManpageRenderer::new(spec).render().unwrap();
assert!(page.contains(".SH CONFIGURATION"), "{page}");
assert!(
page.contains("ex.toml (and in every parent directory)"),
"{page}"
);
}
#[test]
fn test_basic_manpage() {
let spec: Spec = r#"
name "mycli"
bin "mycli"
about "A sample CLI tool"
flag "-v --verbose" help="Enable verbose output"
flag "-o --output <file>" help="Output file path"
arg "<input>" help="Input file to process"
"#
.parse()
.unwrap();
let renderer = ManpageRenderer::new(spec);
let output = renderer.render().unwrap();
println!("Generated manpage:\n{}", output);
assert!(output.contains(".TH MYCLI 1"));
assert!(output.contains(".SH NAME"));
assert!(output.contains(".SH SYNOPSIS"));
assert!(output.contains(".SH DESCRIPTION"));
assert!(output.contains(".SH OPTIONS"));
assert!(output.contains("verbose"));
assert!(output.contains("output"));
}
#[test]
fn package_metadata_reaches_the_manpage() {
let spec: Spec = r#"
name "metadata"
bin "metadata"
author "Example Maintainers"
license "MIT OR Apache-2.0"
repository "https://example.com/tool"
"#
.parse()
.unwrap();
let output = ManpageRenderer::new(spec).render().unwrap();
assert!(output.contains(".SH LICENSE"), "{output}");
assert!(output.contains("MIT OR Apache\\-2.0"), "{output}");
assert!(output.contains(".SH SOURCE"), "{output}");
assert!(output.contains("https://example.com/tool"), "{output}");
assert!(output.contains(".SH AUTHOR"), "{output}");
}
#[test]
fn test_with_custom_section() {
let spec: Spec = r#"
name "myconfig"
bin "myconfig"
about "A configuration file format"
"#
.parse()
.unwrap();
let renderer = ManpageRenderer::new(spec).with_section(5);
let output = renderer.render().unwrap();
assert!(output.contains(".TH MYCONFIG 5"));
}
#[test]
fn test_with_subcommands() {
let spec: Spec = r#"
name "git"
bin "git"
about "The Git version control system"
cmd "clone" help="Clone a repository"
cmd "commit" help="Record changes to the repository"
"#
.parse()
.unwrap();
let renderer = ManpageRenderer::new(spec);
let output = renderer.render().unwrap();
assert!(output.contains(".SH COMMANDS"));
assert!(output.contains("clone"));
assert!(output.contains("commit"));
}
#[test]
fn test_arguments_with_only_long_help() {
let spec: Spec = r#"
name "mycli"
bin "mycli"
about "A CLI tool"
arg "<input>" help_long="This is a long help text for the input argument"
"#
.parse()
.unwrap();
let renderer = ManpageRenderer::new(spec);
let output = renderer.render().unwrap();
assert!(output.contains(".SH ARGUMENTS"));
assert!(output.contains("<input>"));
assert!(output.contains("long help text"));
}
#[test]
fn test_subcommand_with_only_long_help() {
let spec: Spec = r#"
name "mycli"
bin "mycli"
about "A CLI tool"
cmd "deploy" help_long="This is a detailed deployment command description that should appear in the summary"
"#
.parse()
.unwrap();
let renderer = ManpageRenderer::new(spec);
let output = renderer.render().unwrap();
assert!(output.contains("deploy"));
assert!(output.contains("detailed deployment command"));
}
#[test]
fn test_subcommand_prefers_long_over_short_help() {
let spec: Spec = r#"
name "mycli"
bin "mycli"
about "A CLI tool"
cmd "test" help="Short help" help_long="Long detailed help that should be preferred"
"#
.parse()
.unwrap();
let renderer = ManpageRenderer::new(spec);
let output = renderer.render().unwrap();
assert!(output.contains("Long detailed help"));
}
}