feat: am tip cheat sheet + readable man pages + REPL completion refresh
This commit is contained in:
+338
-3
@@ -1,11 +1,15 @@
|
||||
//! 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::Write;
|
||||
use std::io::{IsTerminal, Write};
|
||||
use std::path::Path;
|
||||
|
||||
pub fn run(app: &App, command: Option<&str>, output: Option<&Path>) -> Result<i32> {
|
||||
@@ -30,7 +34,7 @@ pub fn run(app: &App, command: Option<&str>, output: Option<&Path>) -> Result<i3
|
||||
dir.join(format!("am-{name}.1")).display()
|
||||
));
|
||||
}
|
||||
None => man.render(&mut std::io::stdout())?,
|
||||
None => print_page(app, &man)?,
|
||||
}
|
||||
}
|
||||
None => match output {
|
||||
@@ -51,7 +55,7 @@ pub fn run(app: &App, command: Option<&str>, output: Option<&Path>) -> Result<i3
|
||||
cmd.get_subcommands().count()
|
||||
));
|
||||
}
|
||||
None => clap_mangen::Man::new(cmd).render(&mut std::io::stdout())?,
|
||||
None => print_page(app, &clap_mangen::Man::new(cmd))?,
|
||||
},
|
||||
}
|
||||
Ok(0)
|
||||
@@ -68,3 +72,334 @@ fn write_page(dir: &Path, name: &str, man: &clap_mangen::Man) -> Result<()> {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user