use std::borrow::Cow;
use std::process::Command;
use crate::models::{ScannedFlag, ValueType};
use crate::scanner::protocol::ParsedHelp;
fn is_bsd_option_intro(lowered: &str) -> bool {
lowered.starts_with("the ")
&& lowered.contains("options are")
&& (lowered.contains("as follows") || lowered.contains("available"))
}
struct FlagLine {
flag: ScannedFlag,
inline_description: String,
}
pub struct ManPageParser;
impl ManPageParser {
pub fn parse_man_page(&self, tool_name: &str) -> Option<ParsedHelp> {
let output = Command::new("man")
.args(["-P", "cat", tool_name])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let raw = String::from_utf8_lossy(&output.stdout);
let text = strip_overstrike(&raw);
let description = extract_man_description(&text);
let flags = extract_man_options(&text);
let examples = extract_man_examples(&text, tool_name);
if description.is_empty() && flags.is_empty() && examples.is_empty() {
return None;
}
Some(ParsedHelp {
description,
flags,
examples,
..Default::default()
})
}
}
pub fn strip_overstrike(text: &str) -> Cow<'_, str> {
if !text.contains('\u{8}') {
return Cow::Borrowed(text);
}
let mut out = String::with_capacity(text.len());
for ch in text.chars() {
if ch == '\u{8}' {
out.pop();
} else {
out.push(ch);
}
}
Cow::Owned(out)
}
pub fn extract_man_description(text: &str) -> String {
let mut in_description = false;
let mut lines: Vec<&str> = Vec::new();
for line in text.lines() {
let trimmed = line.trim();
if trimmed == "DESCRIPTION" || trimmed == "Description" {
in_description = true;
continue;
}
if !in_description {
continue;
}
if is_section_header(line) && !lines.is_empty() {
break;
}
if trimmed.is_empty() {
if !lines.is_empty() {
break;
}
continue;
}
lines.push(trimmed);
}
lines.join(" ").chars().take(200).collect()
}
const MAX_MAN_EXAMPLES: usize = 20;
const MAX_MAN_EXAMPLE_LEN: usize = 300;
pub fn extract_man_examples(text: &str, tool_name: &str) -> Vec<String> {
let Some(body) = section_body(text, "EXAMPLES") else {
return Vec::new();
};
let mut examples: Vec<String> = Vec::new();
for line in body.lines() {
let trimmed = line.trim();
let candidate = if let Some(rest) = trimmed.strip_prefix("$ ") {
rest.trim()
} else if trimmed == tool_name
|| trimmed
.strip_prefix(tool_name)
.is_some_and(|rest| rest.starts_with(char::is_whitespace))
{
trimmed
} else {
continue;
};
if candidate.is_empty() || candidate.len() > MAX_MAN_EXAMPLE_LEN {
continue;
}
let candidate = candidate.to_string();
if !examples.contains(&candidate) {
examples.push(candidate);
}
if examples.len() >= MAX_MAN_EXAMPLES {
break;
}
}
examples
}
fn section_body<'a>(text: &'a str, header: &str) -> Option<&'a str> {
let mut start: Option<usize> = None;
let mut offset = 0usize;
for line in text.lines() {
let line_start = offset;
offset += line.len() + 1;
match start {
None => {
if line.trim() == header {
start = Some(offset.min(text.len()));
}
}
Some(begin) => {
if is_section_header(line) {
return Some(&text[begin..line_start.min(text.len())]);
}
}
}
}
start.map(|begin| &text[begin.min(text.len())..])
}
pub fn extract_man_options(text: &str) -> Vec<ScannedFlag> {
let Some(body) = options_body(text) else {
return Vec::new();
};
let mut flags: Vec<ScannedFlag> = Vec::new();
let mut current: Option<ScannedFlag> = None;
let mut description_lines: Vec<String> = Vec::new();
let mut flag_indent: Option<usize> = None;
for line in body {
if is_section_header(line) {
break;
}
let trimmed = line.trim();
if is_flag_line(line, flag_indent) {
flush_flag(&mut flags, current.take(), &mut description_lines);
flag_indent.get_or_insert_with(|| indent_width(line));
let parsed = parse_flag_line(trimmed);
if !parsed.inline_description.is_empty() {
description_lines.push(parsed.inline_description);
}
current = Some(parsed.flag);
} else if trimmed.is_empty() {
flush_flag(&mut flags, current.take(), &mut description_lines);
} else if current.is_some() {
description_lines.push(trimmed.to_string());
}
}
flush_flag(&mut flags, current.take(), &mut description_lines);
flags
}
fn options_body(text: &str) -> Option<Vec<&str>> {
let lines: Vec<&str> = text.lines().collect();
let start = lines.iter().position(|line| is_options_start(line))?;
Some(lines[start + 1..].to_vec())
}
fn is_options_start(line: &str) -> bool {
let trimmed = line.trim();
if trimmed == "OPTIONS" || trimmed == "Options" {
return true;
}
is_bsd_option_intro(&trimmed.to_ascii_lowercase())
}
fn is_section_header(line: &str) -> bool {
let trimmed = line.trim();
!trimmed.is_empty()
&& !line.starts_with(char::is_whitespace)
&& trimmed == trimmed.to_uppercase()
}
fn is_flag_line(line: &str, flag_indent: Option<usize>) -> bool {
let trimmed = line.trim_start();
if trimmed.len() == line.len() || !trimmed.starts_with('-') {
return false;
}
if let Some(expected) = flag_indent {
if indent_width(line) > expected {
return false;
}
}
trimmed
.split_whitespace()
.next()
.is_some_and(|token| token.len() > 1)
}
fn indent_width(line: &str) -> usize {
line.len() - line.trim_start().len()
}
fn flush_flag(
flags: &mut Vec<ScannedFlag>,
current: Option<ScannedFlag>,
description_lines: &mut Vec<String>,
) {
if let Some(mut flag) = current {
flag.description = description_lines.join(" ").trim().to_string();
flags.push(flag);
}
description_lines.clear();
}
fn parse_flag_line(trimmed: &str) -> FlagLine {
let tokens: Vec<&str> = trimmed.split_whitespace().collect();
let mut short_name: Option<String> = None;
let mut long_name: Option<String> = None;
let mut value_name: Option<String> = None;
let mut consumed = 0;
while consumed < tokens.len() {
let token = tokens[consumed].trim_end_matches(',');
if !token.starts_with('-') || token.len() < 2 {
break;
}
let (name, inline_value) = match token.split_once('=') {
Some((name, value)) => (name, Some(value)),
None => (token, None),
};
if let Some(value) = inline_value.filter(|value| !value.is_empty()) {
value_name = Some(strip_placeholder_brackets(value));
}
if name.starts_with("--") {
long_name = Some(name.to_string());
} else if short_name.is_none() {
short_name = Some(name.to_string());
}
consumed += 1;
}
let remainder = &tokens[consumed..];
let mut inline_description = remainder.join(" ");
if value_name.is_none() && remainder.len() == 1 && is_value_placeholder(remainder[0]) {
value_name = Some(strip_placeholder_brackets(remainder[0]));
inline_description = String::new();
}
let value_type = if value_name.is_some() {
ValueType::String
} else {
ValueType::Boolean
};
FlagLine {
flag: ScannedFlag {
long_name,
short_name,
description: String::new(),
value_type,
required: false,
default: None,
enum_values: None,
repeatable: false,
value_name,
..Default::default()
},
inline_description,
}
}
fn is_value_placeholder(token: &str) -> bool {
if token.is_empty() {
return false;
}
if token.starts_with('<') && token.ends_with('>') {
return true;
}
if token.contains(['.', ',', ';', ':', '(', ')']) {
return false;
}
let has_lowercase = token.chars().any(char::is_lowercase);
let has_uppercase = token.chars().any(char::is_uppercase);
!(has_lowercase && has_uppercase)
}
fn strip_placeholder_brackets(value: &str) -> String {
value.trim_matches(['<', '>', '[', ']']).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn bold(text: &str) -> String {
text.chars().map(|c| format!("{c}\u{8}{c}")).collect()
}
#[test]
fn test_strip_overstrike_bold() {
assert_eq!(strip_overstrike(&bold("DESCRIPTION")), "DESCRIPTION");
}
#[test]
fn test_strip_overstrike_italic() {
assert_eq!(strip_overstrike("_\u{8}f_\u{8}m_\u{8}t"), "fmt");
}
#[test]
fn test_strip_overstrike_borrows_clean_text() {
assert!(matches!(strip_overstrike("plain text"), Cow::Borrowed(_)));
}
#[test]
fn test_extract_man_description_basic() {
let man_text = r#"NAME
git - the stupid content tracker
SYNOPSIS
git [--version] [--help] <command> [<args>]
DESCRIPTION
Git is a fast, scalable, distributed revision control system with
an unusually rich command set.
OPTIONS
--version
Prints the Git suite version.
"#;
let desc = extract_man_description(man_text);
assert!(desc.contains("Git is a fast"));
}
#[test]
fn test_extract_man_description_missing() {
let man_text = "NAME\n tool - does things\n\nOPTIONS\n --help\n";
assert!(extract_man_description(man_text).is_empty());
}
#[test]
fn test_extract_man_description_truncated() {
let long_desc = "A".repeat(300);
let man_text = format!("DESCRIPTION\n {long_desc}\n\nOPTIONS\n");
assert_eq!(extract_man_description(&man_text).len(), 200);
}
#[test]
fn test_extract_man_description_strips_overstrike() {
let man_text = format!("{}\n A useful tool.\n", bold("DESCRIPTION"));
let cleaned = strip_overstrike(&man_text);
assert_eq!(extract_man_description(&cleaned), "A useful tool.");
}
#[test]
fn test_parse_man_page_nonexistent_tool() {
let parser = ManPageParser;
assert!(parser
.parse_man_page("zzz_no_such_tool_xyz_12345")
.is_none());
}
#[test]
fn test_extract_man_examples_shell_prompt_layout() {
let man_text = "\
EXAMPLES
List the contents of the current working directory in long format:
$ ls -l
Show inode numbers as well:
$ ls -lioF
SEE ALSO
chflags(1)
";
assert_eq!(
extract_man_examples(man_text, "ls"),
vec!["ls -l".to_string(), "ls -lioF".to_string()]
);
}
#[test]
fn test_extract_man_examples_bare_invocation_layout() {
let man_text = "\
EXAMPLES
The following creates a new archive called file.tar.gz:
tar -czf file.tar.gz source.c source.h
To view a detailed table of contents for this archive:
tar -tvf file.tar.gz
SEE ALSO
";
assert_eq!(
extract_man_examples(man_text, "tar"),
vec![
"tar -czf file.tar.gz source.c source.h".to_string(),
"tar -tvf file.tar.gz".to_string()
]
);
}
#[test]
fn test_extract_man_examples_skips_the_prose() {
let man_text = "\
EXAMPLES
The following creates a new archive called file.tar.gz that contains two
files source.c and source.h:
tar -czf file.tar.gz source.c source.h
";
assert_eq!(
extract_man_examples(man_text, "tar"),
vec!["tar -czf file.tar.gz source.c source.h".to_string()]
);
}
#[test]
fn test_extract_man_examples_stops_at_the_next_section() {
let man_text = "\
EXAMPLES
$ ls -l
SEE ALSO
$ ls -Z
";
assert_eq!(
extract_man_examples(man_text, "ls"),
vec!["ls -l".to_string()]
);
}
#[test]
fn test_extract_man_examples_absent_section() {
let man_text = "DESCRIPTION\n A tool.\n\nSEE ALSO\n other(1)\n";
assert!(extract_man_examples(man_text, "tool").is_empty());
}
#[test]
fn test_extract_man_examples_deduplicates() {
let man_text = "EXAMPLES\n $ ls -l\n\n $ ls -l\n";
assert_eq!(
extract_man_examples(man_text, "ls"),
vec!["ls -l".to_string()]
);
}
#[test]
fn test_extract_man_options_basic() {
let man_text = r#"NAME
mytool - does things
DESCRIPTION
A useful tool.
OPTIONS
--verbose
Enable verbose output.
--format
Set output format.
ENVIRONMENT
HOME User home directory.
"#;
let flags = extract_man_options(man_text);
assert_eq!(flags.len(), 2);
assert_eq!(flags[0].long_name.as_deref(), Some("--verbose"));
assert!(flags[0].description.contains("Enable verbose output"));
assert_eq!(flags[1].long_name.as_deref(), Some("--format"));
assert!(flags[1].description.contains("Set output format"));
}
#[test]
fn test_extract_man_options_empty() {
let man_text = "NAME\n tool - does things\n\nDESCRIPTION\n A tool.\n";
assert!(extract_man_options(man_text).is_empty());
}
#[test]
fn test_extract_man_options_multi_line_desc() {
let man_text = r#"OPTIONS
--output
Specify the output file path. This flag accepts
an absolute or relative filesystem path and will
create intermediate directories as needed.
ENVIRONMENT
HOME User home.
"#;
let flags = extract_man_options(man_text);
assert_eq!(flags.len(), 1);
assert_eq!(flags[0].long_name.as_deref(), Some("--output"));
assert!(flags[0]
.description
.contains("Specify the output file path"));
assert!(flags[0]
.description
.contains("create intermediate directories"));
}
#[test]
fn test_extract_man_options_bsd_description_layout() {
let man_text = r#"DESCRIPTION
For each operand that names a file, ls displays its name.
The following options are available:
-@ Display extended attribute keys and sizes.
-A Include directory entries whose names begin with a dot.
ENVIRONMENT
COLUMNS Screen width.
"#;
let flags = extract_man_options(man_text);
assert_eq!(flags.len(), 2);
assert_eq!(flags[0].short_name.as_deref(), Some("-@"));
assert!(flags[0].description.contains("Display extended attribute"));
assert_eq!(flags[1].short_name.as_deref(), Some("-A"));
}
#[test]
fn test_is_bsd_option_intro_accepts_every_observed_wording() {
for wording in [
"the following options are available:",
"the options are as follows:",
"the command line options are as follows:",
"the generic options are as follows:",
] {
assert!(is_bsd_option_intro(wording), "should accept: {wording}");
}
for wording in [
"the tool reads options from a file",
"these options are documented elsewhere",
"the following environment variables are used",
] {
assert!(!is_bsd_option_intro(wording), "should reject: {wording}");
}
}
#[test]
fn test_extract_man_options_bsd_command_line_options_intro() {
let man_text = r#"DESCRIPTION
Sorts lines.
The command line options are as follows:
-r Reverse the sort order.
ENVIRONMENT
LANG Locale.
"#;
let flags = extract_man_options(man_text);
assert_eq!(flags.len(), 1);
assert_eq!(flags[0].short_name.as_deref(), Some("-r"));
}
#[test]
fn test_extract_man_options_bsd_options_are_as_follows() {
let man_text = r#"DESCRIPTION
Copies files.
The options are as follows:
-f Force an existing file to be overwritten.
ENVIRONMENT
HOME User home.
"#;
let flags = extract_man_options(man_text);
assert_eq!(flags.len(), 1);
assert_eq!(flags[0].short_name.as_deref(), Some("-f"));
}
#[test]
fn test_extract_man_options_parses_alias_list() {
let man_text = "OPTIONS\n -a, --all\n Stage all files.\n";
let flags = extract_man_options(man_text);
assert_eq!(flags.len(), 1);
assert_eq!(flags[0].short_name.as_deref(), Some("-a"));
assert_eq!(flags[0].long_name.as_deref(), Some("--all"));
assert!(flags[0].description.contains("Stage all files"));
}
#[test]
fn test_extract_man_options_boolean_flag_has_no_value() {
let man_text = "OPTIONS\n --verbose\n Be verbose.\n";
let flags = extract_man_options(man_text);
assert_eq!(flags[0].value_type, ValueType::Boolean);
assert!(flags[0].value_name.is_none());
}
#[test]
fn test_extract_man_options_detects_value_placeholder() {
let man_text = "OPTIONS\n -o FILE\n Write output.\n\n -D format\n Use format for dates.\n\n -q Quiet mode.\n";
let flags = extract_man_options(man_text);
assert_eq!(flags.len(), 3);
assert_eq!(flags[0].value_name.as_deref(), Some("FILE"));
assert_eq!(flags[0].value_type, ValueType::String);
assert_eq!(flags[1].value_name.as_deref(), Some("format"));
assert_eq!(flags[1].value_type, ValueType::String);
assert!(flags[2].value_name.is_none());
assert!(flags[2].description.contains("Quiet mode"));
}
#[test]
fn test_extract_man_options_detects_inline_value() {
let man_text = "OPTIONS\n --color=when\n Colorize output.\n";
let flags = extract_man_options(man_text);
assert_eq!(flags.len(), 1);
assert_eq!(flags[0].long_name.as_deref(), Some("--color"));
assert_eq!(flags[0].value_name.as_deref(), Some("when"));
}
#[test]
fn test_extract_man_options_ignores_deeper_indented_continuation() {
let man_text = "OPTIONS\n -B Force printing of non-printable characters\n -like control codes- in file names.\n";
let flags = extract_man_options(man_text);
assert_eq!(flags.len(), 1);
assert!(flags[0].description.contains("-like control codes-"));
}
#[test]
fn test_extract_man_options_stops_at_next_section() {
let man_text = "OPTIONS\n --keep\n Keep it.\n\nEXAMPLES\n --not-a-flag\n Ignored.\n";
let flags = extract_man_options(man_text);
assert_eq!(flags.len(), 1);
assert_eq!(flags[0].long_name.as_deref(), Some("--keep"));
}
}