Files
agent-manager/src/commands/man_cmd.rs
T

406 lines
15 KiB
Rust

//! man: generate roff man pages from the clap definition (issue #41).
//! 'am man' prints am.1 for the whole CLI; 'am man <command>' prints one
//! page; '--output DIR' writes am.1 plus one am-<command>.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<i32> {
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<u8> = 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<String> = 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::<Vec<_>>().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<char> = 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<FILE>\\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}");
}
}