406 lines
15 KiB
Rust
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}");
|
|
}
|
|
}
|