mod entry;
pub mod escape;
mod sections;
mod types;
mod writer;
pub use types::{InvalidManSection, ManSection, RoffConfig, RoffOutput};
use crate::error::OrthohelpError;
use crate::ir::LocalizedDocMetadata;
pub fn generate(
metadata: &LocalizedDocMetadata,
config: &RoffConfig,
) -> Result<RoffOutput, OrthohelpError> {
let mut output = RoffOutput::new();
let content = generate_man_page(metadata, config);
let bin_name = metadata.bin_name.as_deref().unwrap_or(&metadata.app_name);
let info = writer::ManPageInfo::new(bin_name, config.section);
let main_path = writer::write_man_page(&config.out_dir, &info, &content)?;
output.add_file(main_path);
if config.should_split_subcommands {
for subcommand in &metadata.subcommands {
let sub_name = subcommand
.bin_name
.as_deref()
.unwrap_or(&subcommand.app_name);
let composite_name = format!("{bin_name}-{sub_name}");
let sub_content = generate_subcommand_page(subcommand, config, &composite_name);
let sub_info = writer::ManPageInfo::with_subcommand(bin_name, sub_name, config.section);
let sub_path = writer::write_man_page(&config.out_dir, &sub_info, &sub_content)?;
output.add_file(sub_path);
}
}
Ok(output)
}
fn generate_man_page(metadata: &LocalizedDocMetadata, config: &RoffConfig) -> String {
let bin_name = metadata.bin_name.as_deref().unwrap_or(&metadata.app_name);
generate_man_page_with_name(metadata, config, bin_name)
}
fn generate_subcommand_page(
metadata: &LocalizedDocMetadata,
config: &RoffConfig,
composite_name: &str,
) -> String {
generate_man_page_with_name(metadata, config, composite_name)
}
fn generate_man_page_with_name(
metadata: &LocalizedDocMetadata,
config: &RoffConfig,
display_name: &str,
) -> String {
let mut content = String::with_capacity(4096);
let title_meta = sections::TitleMetadata::new(
config.date.as_deref(),
config.source.as_deref(),
config.manual.as_deref(),
);
content.push_str(§ions::title_header(
display_name,
config.section,
&title_meta,
));
append_standard_sections(&mut content, metadata, config, display_name);
append_inline_subcommands(&mut content, metadata, config);
content
}
fn append_standard_sections(
content: &mut String,
metadata: &LocalizedDocMetadata,
config: &RoffConfig,
display_name: &str,
) {
let headings = &metadata.sections.headings;
content.push_str(§ions::name_section(
headings,
display_name,
&metadata.about,
));
content.push_str(§ions::synopsis_section(
headings,
display_name,
metadata.synopsis.as_deref(),
&metadata.fields,
));
content.push_str(§ions::description_section(headings, &metadata.about));
content.push_str(§ions::options_section(headings, &metadata.fields));
content.push_str(§ions::environment_section(headings, &metadata.fields));
content.push_str(§ions::files_section(
headings,
&metadata.fields,
metadata.sections.discovery.as_ref(),
));
content.push_str(§ions::precedence_section(
headings,
metadata.sections.precedence.as_ref(),
));
content.push_str(§ions::examples_section(
headings,
&metadata.sections.examples,
));
let related_commands = collect_related_commands(metadata, display_name, config);
content.push_str(§ions::see_also_section(
headings,
&metadata.sections.links,
&related_commands,
config.section,
));
content.push_str(§ions::exit_status_section(headings));
}
fn collect_related_commands(
metadata: &LocalizedDocMetadata,
bin_name: &str,
config: &RoffConfig,
) -> Vec<String> {
if !config.should_split_subcommands {
return Vec::new();
}
metadata
.subcommands
.iter()
.map(|s| {
let sub_name = s.bin_name.as_deref().unwrap_or(&s.app_name);
format!("{bin_name}-{sub_name}")
})
.collect()
}
fn append_inline_subcommands(
content: &mut String,
metadata: &LocalizedDocMetadata,
config: &RoffConfig,
) {
if config.should_split_subcommands || metadata.subcommands.is_empty() {
return;
}
content.push_str(".SH ");
content.push_str(&escape::escape_macro_arg(
&metadata.sections.headings.commands,
));
content.push('\n');
for subcommand in &metadata.subcommands {
content.push_str(&generate_subcommand_section(subcommand));
}
}
fn generate_subcommand_section(metadata: &LocalizedDocMetadata) -> String {
let mut content = String::new();
let name = metadata.bin_name.as_deref().unwrap_or(&metadata.app_name);
content.push_str(".SS ");
content.push_str(&escape::escape_macro_arg(name));
content.push('\n');
content.push_str(&escape::escape_text(&metadata.about));
content.push('\n');
let cli_fields: Vec<_> = metadata
.fields
.iter()
.filter_map(|f| f.cli.as_ref().filter(|c| !c.hide_in_help).map(|c| (f, c)))
.collect();
if !cli_fields.is_empty() {
content.push_str(".PP\n");
content.push_str(&escape::escape_text(&metadata.sections.headings.options));
content.push_str(":\n");
for (field, cli) in cli_fields {
content.push_str(".TP\n");
let placeholder = field.value.as_ref().map(escape::value_type_placeholder);
let flag_line = if cli.takes_value {
let value_name = cli
.value_name
.as_deref()
.or(placeholder.as_deref())
.unwrap_or("VALUE");
escape::format_flag_with_value(cli.long.as_deref(), cli.short, value_name)
} else {
escape::format_flag(cli.long.as_deref(), cli.short)
};
content.push_str(&flag_line);
content.push('\n');
content.push_str(&escape::escape_text(&field.help));
content.push('\n');
}
}
content
}
#[must_use]
pub fn generate_to_string(metadata: &LocalizedDocMetadata, config: &RoffConfig) -> String {
generate_man_page(metadata, config)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ir::{LocalizedHeadings, LocalizedSectionsMetadata};
fn minimal_metadata() -> LocalizedDocMetadata {
LocalizedDocMetadata {
ir_version: "1.1".to_owned(),
locale: "en-US".to_owned(),
app_name: "test-app".to_owned(),
bin_name: None,
about: "A test application.".to_owned(),
synopsis: None,
sections: LocalizedSectionsMetadata {
headings: LocalizedHeadings {
name: "NAME".to_owned(),
synopsis: "SYNOPSIS".to_owned(),
description: "DESCRIPTION".to_owned(),
options: "OPTIONS".to_owned(),
environment: "ENVIRONMENT".to_owned(),
files: "FILES".to_owned(),
precedence: "PRECEDENCE".to_owned(),
exit_status: "EXIT STATUS".to_owned(),
examples: "EXAMPLES".to_owned(),
see_also: "SEE ALSO".to_owned(),
commands: "COMMANDS".to_owned(),
},
discovery: None,
precedence: None,
examples: vec![],
links: vec![],
notes: vec![],
},
fields: vec![],
subcommands: vec![],
windows: None,
}
}
#[test]
fn generate_to_string_produces_valid_roff() {
let metadata = minimal_metadata();
let config = RoffConfig::default();
let result = generate_to_string(&metadata, &config);
assert!(result.starts_with(".TH \"TEST-APP\" \"1\""));
assert!(result.contains(".SH NAME"));
assert!(result.contains("test-app \\- A test application."));
assert!(result.contains(".SH SYNOPSIS"));
assert!(result.contains(".SH DESCRIPTION"));
}
}