//! man: generate roff man pages from the clap definition (issue #41). //! 'am man' prints am.1 for the whole CLI; 'am man ' prints one //! page; '--output DIR' writes am.1 plus one am-.1 per command. //! //! On a terminal the page is rendered to readable, themed text (there is no //! man viewer on Windows); when stdout is piped the raw roff source is kept //! so 'am man | man -l -' and scripts keep working unchanged. use super::*; use anyhow::Context; use clap::CommandFactory; use std::io::{IsTerminal, Write}; use std::path::Path; pub fn run(app: &App, command: Option<&str>, output: Option<&Path>) -> Result { let mut cmd = crate::cli::Cli::command(); if app.dry_run() { app.log.dry("would generate man pages"); return Ok(0); } match command { Some(name) => { let sub = cmd.find_subcommand_mut(name).ok_or_else(|| { anyhow!( "unknown command '{name}' — 'am help commands' lists every command" ) })?; let man = clap_mangen::Man::new(sub.clone()).title(format!("am-{name}")); match output { Some(dir) => { write_page(dir, &format!("am-{name}"), &man)?; app.log.success(&format!( "man page written to {}", dir.join(format!("am-{name}.1")).display() )); } None => print_page(app, &man)?, } } None => match output { Some(dir) => { let main = clap_mangen::Man::new(cmd.clone()); write_page(dir, "am", &main)?; for sub in cmd.get_subcommands() { if sub.is_hide_set() { continue; } let name = format!("am-{}", sub.get_name()); let m = clap_mangen::Man::new(sub.clone()).title(name.clone()); write_page(dir, &name, &m)?; } app.log.success(&format!( "man pages written to {} (am.1 + {} pages)", dir.display(), cmd.get_subcommands().count() )); } None => print_page(app, &clap_mangen::Man::new(cmd))?, }, } Ok(0) } fn write_page(dir: &Path, name: &str, man: &clap_mangen::Man) -> Result<()> { std::fs::create_dir_all(dir) .with_context(|| format!("cannot create {}", dir.display()))?; let path = dir.join(format!("{name}.1")); let mut file = std::fs::File::create(&path) .with_context(|| format!("cannot create {}", path.display()))?; man.render(&mut file) .with_context(|| format!("cannot render {}", path.display()))?; file.flush().ok(); Ok(()) } /// Print one man page: raw roff when stdout is piped (scripts, 'man -l -'), /// a readable themed rendering when it is a terminal. fn print_page(app: &App, man: &clap_mangen::Man) -> Result<()> { let mut buf: Vec = Vec::new(); man.render(&mut buf) .context("cannot render the man page")?; let roff = String::from_utf8_lossy(&buf); if std::io::stdout().is_terminal() { print!("{}", roff_to_text(&roff, app.theme(), app.color())); } else { std::io::stdout().write_all(roff.as_bytes())?; } Ok(()) } // --------------------------------------------------------------------------- // Miniature roff viewer: renders the groff man macro subset emitted by // clap_mangen (.TH .SH .SS .PP .TP .IP .B .I .BR .RB .BI .IB .IR .RI .RS // .RE .nf .fi ...) to plain, themed text. // --------------------------------------------------------------------------- const IND_TERM: &str = " "; const IND_BODY: &str = " "; /// Convert the roff source of a man page into readable text, coloured with /// the active theme when color is on. pub fn roff_to_text(roff: &str, theme: &crate::theme::Theme, color: bool) -> String { let mut out = String::new(); let mut level = 0usize; // .RS/.RE nesting let mut in_tp = false; // inside a .TP definition list let mut tp_term: Option = None; // the pending term of a .TP let mut tp_printed = false; // term emitted, further lines are bodies for raw in roff.lines() { let line = raw.trim_end(); let trimmed = line.trim_start(); if trimmed.is_empty() || trimmed.starts_with(".\\\"") { continue; } // Groff conditionals defining \*(Aq — not needed by the renderer. if trimmed.starts_with(".ie ") || trimmed.starts_with(".el ") { continue; } if let Some(rest) = trimmed.strip_prefix('.') { let (name, args) = match rest.split_once(char::is_whitespace) { Some((n, a)) => (n, a.trim()), None => (rest, ""), }; // Any other macro closes a pending .TP list. if let Some(term) = tp_term.take() { out.push_str(&format!("{IND_TERM}{term}\n")); } in_tp = false; tp_printed = false; match name { "TH" => { let mut parts = args.split_whitespace(); let title = parts.next().unwrap_or("am"); let sec = parts.next().unwrap_or("1"); let meta = parts.collect::>().join(" "); let meta = meta.trim_matches('"').to_string(); let mut line = theme.acc(&format!("{title}({sec})")); if !meta.is_empty() { line.push_str(&format!(" {}", theme.dimmed(&meta))); } out.push_str(&format!("{line}\n\n")); } "SH" => { out.push('\n'); let title = conv(args, theme, color); out.push_str(&format!( "{}\n", if color { theme.hdr(&format!("▸ {title}")) } else { format!("▸ {title}") } )); } "SS" => { out.push('\n'); let title = conv(args, theme, color); out.push_str(&format!( "{}\n", if color { theme.inf(&format!("· {title}")) } else { format!("· {title}") } )); } "PP" | "P" | "sp" => out.push('\n'), "br" => out.push('\n'), "TP" => { in_tp = true; tp_term = None; tp_printed = false; } "IP" => { // Indented paragraph, often a bullet (see also lists). in_tp = true; tp_printed = true; out.push_str(&format!("{IND_TERM}{}\n", conv(args, theme, color))); } "B" => out.push_str(&format!("{IND_TERM}{}\n", bold(conv(args, theme, color), color))), "I" => out.push_str(&format!( "{IND_TERM}{}\n", if color { theme.inf(&conv(args, theme, color)) } else { conv(args, theme, color) } )), "BR" | "RB" | "BI" | "IB" | "IR" | "RI" => { let mut parts = args.splitn(2, char::is_whitespace); let a = parts.next().unwrap_or(""); let b = parts.next().unwrap_or(""); let (bold_first, italic_second) = match name { "BR" | "BI" | "IB" | "IR" => (true, matches!(name, "BI" | "IB")), _ => (false, false), }; let left = if bold_first { bold(conv(a, theme, color), color) } else { conv(a, theme, color) }; let right = if italic_second { if color { theme.inf(&conv(b, theme, color)) } else { conv(b, theme, color) } } else { conv(b, theme, color) }; out.push_str(&format!("{IND_TERM}{left} {right}\n")); } "RS" => level += 1, "RE" => level = level.saturating_sub(1), // Filling/formatting directives that do not affect this // line-based rendering. "nf" | "fi" | "ad" | "hy" | "in" | "ds" | "EX" | "EE" => {} other => { // Unknown macro: keep its arguments as plain text when // there is something to show. if !args.is_empty() { out.push_str(&format!("{}{}\n", indent(level), conv(args, theme, color))); } let _ = other; } } continue; } // Plain text line: the first line after a .TP is the term, the // following ones its body. The term is emitted as soon as the // first body line arrives, so terms and bodies stay in order. let text = conv(trimmed, theme, color); if in_tp && !tp_printed { if tp_term.is_none() { tp_term = Some(text); } else { if let Some(term) = tp_term.take() { out.push_str(&format!("{IND_TERM}{term}\n")); } out.push_str(&format!("{IND_BODY}{text}\n")); tp_printed = true; } } else if in_tp { out.push_str(&format!("{IND_BODY}{text}\n")); } else { out.push_str(&format!("{}{}\n", indent(level), text)); } } if let Some(term) = tp_term.take() { out.push_str(&format!("{IND_TERM}{term}\n")); } out } /// Indentation for .RS/.RE nesting (plus a small base margin). fn indent(level: usize) -> String { format!("{}{}", " ".repeat(level), " ") } /// Bold text: ANSI bold when colors are on, plain otherwise. fn bold(s: String, color: bool) -> String { if color { format!("\x1b[1m{s}\x1b[0m") } else { s } } /// Convert roff inline escapes to text: \\fB/\\fI/\\fR font switches, /// \\*(Aq, \\- (hyphen), \\(bu (bullet), \\& and friends. fn conv(s: &str, theme: &crate::theme::Theme, color: bool) -> String { let mut out = String::new(); let chars: Vec = s.chars().collect(); let mut i = 0; while i < chars.len() { if chars[i] != '\\' { out.push(chars[i]); i += 1; continue; } let Some(next) = chars.get(i + 1).copied() else { out.push('\\'); break; }; match next { 'f' => { let mode = chars.get(i + 2).copied().unwrap_or('P'); if color { match mode { 'B' => out.push_str("\x1b[1m"), 'I' => out.push_str(&format!("\x1b[{}m", theme.info)), _ => out.push_str("\x1b[0m"), } } i += 3; } '(' => { let a = chars.get(i + 2).copied().unwrap_or(' '); let d = chars.get(i + 3).copied().unwrap_or(' '); let two = format!("{a}{d}"); out.push_str(match two.as_str() { "aq" => "'", "bu" => "•", "em" => "—", "lq" => "\u{201c}", "rq" => "\u{201d}", _ => two.as_str(), }); i += 4; } '*' => { // \*(xx : the '(' opens the two-character string name. let a = chars.get(i + 3).copied().unwrap_or(' '); let d = chars.get(i + 4).copied().unwrap_or(' '); let name = format!("{a}{d}"); out.push_str(match name.as_str() { "Aq" | "Sq" => "'", _ => "", }); i += 5; } '-' => { out.push('-'); i += 2; } '&' => i += 2, 'e' => { out.push('\\'); i += 2; } ' ' => { out.push(' '); i += 2; } '\\' => { out.push('\\'); i += 2; } _ => { out.push('\\'); i += 1; } } } out } #[cfg(test)] mod tests { use super::*; use crate::theme::default_theme; fn plain(roff: &str) -> String { roff_to_text(roff, default_theme(), false) } #[test] fn renders_sections_terms_and_bodies() { let text = plain( ".TH am 1 \"am 0.4.3\"\n\ .SH NAME\n\ am \\- agent manager\n\ .SH OPTIONS\n\ .TP\n\ \\fB\\-c\\fR, \\fB\\-\\-config\\fR \\fI\\fR\n\ Use an alternate configuration file\n", ); assert!(text.contains("am(1)"), "{text}"); assert!(text.contains("▸ NAME"), "{text}"); assert!(text.contains("▸ OPTIONS"), "{text}"); assert!(text.contains("am - agent manager"), "{text}"); assert!(text.contains("--config"), "{text}"); assert!(text.contains("Use an alternate configuration file"), "{text}"); assert!(!text.contains("\\fB"), "font escapes must be converted: {text}"); assert!(!text.contains(".SH"), "macros must be removed: {text}"); } #[test] fn converts_special_characters() { let t = default_theme(); assert_eq!(conv("\\*(Aqhello\\*(Aq", t, false), "'hello'"); assert_eq!(conv("\\-\\-dry\\-run", t, false), "--dry-run"); assert_eq!(conv("\\(bu", t, false), "•"); assert_eq!(conv("\\&x", t, false), "x"); assert_eq!(conv("a\\e\\eb", t, false), "a\\\\b"); } #[test] fn colors_bold_and_italic_segments_when_enabled() { let t = default_theme(); let s = conv("\\fBam\\fR \\fIvalue\\fR", t, true); assert!(s.contains("\x1b[1m"), "{s}"); assert!(s.contains(&format!("\x1b[{}m", t.info)), "{s}"); assert!(s.contains("\x1b[0m"), "{s}"); // Without colors the same text is plain. assert_eq!(conv("\\fBam\\fR \\fIvalue\\fR", t, false), "am value"); } #[test] fn tp_term_is_flushed_at_the_next_macro() { let text = plain(".TP\n\\fBam\\fR\nbody line\n.PP\nnext paragraph\n"); assert!(text.contains(" am\n"), "{text}"); assert!(text.contains(" body line"), "{text}"); assert!(text.contains("next paragraph"), "{text}"); } }