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 mut flags = extract_man_options(&text);
crate::scanner::value_placeholder::recover_values_from_descriptions(&mut flags);
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
}
pub fn is_man_page(text: &str) -> bool {
let mut has_name = false;
let mut has_synopsis = false;
for line in text.lines() {
if !is_section_header(line) {
continue;
}
match line.trim() {
"NAME" => has_name = true,
"SYNOPSIS" => has_synopsis = true,
_ => {}
}
if has_name && has_synopsis {
return true;
}
}
false
}
pub fn extract_man_summary(text: &str) -> String {
let Some(body) = section_body(text, "NAME") else {
return String::new();
};
let Some(line) = body.lines().map(str::trim).find(|line| !line.is_empty()) else {
return String::new();
};
match line.split_once(" - ") {
Some((_, summary)) => summary.trim().to_string(),
None => line.to_string(),
}
}
pub fn extract_man_synopsis(text: &str) -> String {
let Some(body) = section_body(text, "SYNOPSIS") else {
return String::new();
};
let mut lines = body
.lines()
.skip_while(|line| line.trim().is_empty())
.peekable();
let Some(first) = lines.next() else {
return String::new();
};
let base_indent = indent_width(first);
let mut form = first.trim().to_string();
for line in lines {
if line.trim().is_empty() || indent_width(line) <= base_indent {
break;
}
form.push(' ');
form.push_str(line.trim());
}
form
}
fn section_body<'a>(text: &'a str, header: &str) -> Option<&'a str> {
let mut start: Option<usize> = None;
let mut offset = 0usize;
for raw_line in text.split_inclusive('\n') {
let line = raw_line.trim_end_matches(['\r', '\n']);
let line_start = offset;
offset += raw_line.len();
match start {
None => {
if line.trim() == header {
start = Some(offset);
}
}
Some(begin) => {
if is_section_header(line) {
return Some(&text[begin..line_start]);
}
}
}
}
start.map(|begin| &text[begin..])
}
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();
if flag.long_name.is_some() || flag.short_name.is_some() {
flags.push(flag);
}
}
description_lines.clear();
}
#[derive(Default)]
struct FlagSpelling {
short_name: Option<String>,
long_name: Option<String>,
value_name: Option<String>,
value_optional: bool,
}
impl FlagSpelling {
fn note_value_if_unset(&mut self, placeholder: &str) {
if self.value_name.is_none() {
self.value_name = Some(strip_placeholder_brackets(placeholder));
}
}
fn note_name(&mut self, name: String) {
if name.starts_with("--") {
self.long_name = Some(name);
} else if self.short_name.is_none() {
self.short_name = Some(name);
}
}
fn into_flag(self) -> ScannedFlag {
let value_type = match self.value_name.as_deref() {
Some(placeholder) => crate::scanner::value_placeholder::infer_value_type(placeholder),
None => ValueType::Boolean,
};
ScannedFlag {
long_name: self.long_name,
short_name: self.short_name,
description: String::new(),
value_type,
required: false,
default: None,
enum_values: None,
repeatable: false,
value_name: self.value_name,
value_optional: self.value_optional,
..Default::default()
}
}
}
fn absorb_option_token(spelling: &mut FlagSpelling, token: &str) {
let (name, inline_value) = match token.split_once('=') {
Some((name, value)) => (name, Some(value)),
None => (token, None),
};
let name = match name.strip_suffix('[') {
Some(stripped) => {
spelling.value_optional = true;
stripped
}
None => name,
};
if let Some(value) = inline_value.filter(|value| !value.is_empty()) {
spelling.value_name = Some(strip_placeholder_brackets(value));
}
let name = match name.split_once('<') {
Some((head, tail)) if !head.is_empty() => {
spelling.note_value_if_unset(tail);
head
}
_ => name,
};
let name = strip_optional_group(name);
if name.trim_start_matches('-').is_empty() {
return;
}
spelling.note_name(name);
}
fn parse_flag_line(trimmed: &str) -> FlagLine {
let tokens: Vec<&str> = trimmed.split_whitespace().collect();
let mut spelling = FlagSpelling::default();
let mut consumed = 0;
while consumed < tokens.len() {
let raw = tokens[consumed];
let token = raw.trim_end_matches(',');
if !token.starts_with('-') || token.len() < 2 {
let continues = raw.ends_with(',');
if continues && consumed > 0 && is_value_placeholder(token) {
spelling.note_value_if_unset(token);
consumed += 1;
continue;
}
break;
}
absorb_option_token(&mut spelling, token);
consumed += 1;
}
let remainder = &tokens[consumed..];
let mut inline_description = remainder.join(" ");
if spelling.value_name.is_none() && remainder.len() == 1 && is_value_placeholder(remainder[0]) {
spelling.value_name = Some(strip_placeholder_brackets(remainder[0]));
inline_description = String::new();
}
FlagLine {
flag: spelling.into_flag(),
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()
}
fn strip_optional_group(name: &str) -> String {
if !name.contains('[') {
return name.to_string();
}
let mut out = String::with_capacity(name.len());
let mut depth = 0u32;
for ch in name.chars() {
match ch {
'[' => depth += 1,
']' => depth = depth.saturating_sub(1),
_ if depth == 0 => out.push(ch),
_ => {}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn bold(text: &str) -> String {
text.chars().map(|c| format!("{c}\u{8}{c}")).collect()
}
#[test]
fn test_section_body_computes_correct_offsets_on_crlf_input() {
let text = "NAME\r\n tool - do a thing\r\nDESCRIPTION\r\n This is what it does.\r\nSYNOPSIS\r\n tool [options]\r\n";
assert_eq!(
section_body(text, "DESCRIPTION"),
Some(" This is what it does.\r\n")
);
}
#[test]
fn test_section_body_handles_a_multibyte_character_near_a_crlf_section_boundary() {
let text = "NAME\r\n na\u{e9}ve tool\r\nDESCRIPTION\r\n Uses \u{e9} throughout.\r\nSYNOPSIS\r\n naive [options]\r\n";
assert_eq!(
section_body(text, "DESCRIPTION"),
Some(" Uses \u{e9} throughout.\r\n")
);
}
#[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::Path);
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_prefers_the_long_forms_value_placeholder() {
let man_text = "OPTIONS\n -o <out>, --output=<FILE>\n Write here.\n";
let flags = extract_man_options(man_text);
assert_eq!(flags.len(), 1);
assert_eq!(flags[0].short_name.as_deref(), Some("-o"));
assert_eq!(flags[0].long_name.as_deref(), Some("--output"));
assert_eq!(
flags[0].value_name.as_deref(),
Some("FILE"),
"the long form's inline placeholder must win over the short form's detached one"
);
}
#[test]
fn test_extract_man_options_strips_optional_value_bracket() {
let man_text =
"OPTIONS\n --exec-path[=<path>]\n Path to core programs.\n";
let flags = extract_man_options(man_text);
assert_eq!(flags.len(), 1);
assert_eq!(flags[0].long_name.as_deref(), Some("--exec-path"));
assert_eq!(flags[0].value_name.as_deref(), Some("path"));
assert_eq!(flags[0].canonical_name(), "exec_path");
assert!(
flags[0].value_optional,
"the bracket is the only marker of an optional value; dropping it loses the fact"
);
}
#[test]
fn test_extract_man_options_strips_optional_name_segment() {
let man_text = "OPTIONS\n --[no-]verify\n Run hooks.\n\n --reference[-if-able] <repo>\n Reference repository.\n";
let flags = extract_man_options(man_text);
assert_eq!(flags.len(), 2);
assert_eq!(flags[0].long_name.as_deref(), Some("--verify"));
assert_eq!(flags[0].canonical_name(), "verify");
assert_eq!(flags[1].long_name.as_deref(), Some("--reference"));
assert_eq!(flags[1].canonical_name(), "reference");
}
#[test]
fn test_extract_man_options_splits_an_attached_short_value() {
let man_text = "OPTIONS\n -n<num>\n Limit output.\n\n -S<string>\n Search for string.\n";
let flags = extract_man_options(man_text);
assert_eq!(flags.len(), 2);
assert_eq!(flags[0].short_name.as_deref(), Some("-n"));
assert_eq!(flags[0].value_name.as_deref(), Some("num"));
assert_eq!(flags[0].canonical_name(), "n");
assert_eq!(flags[1].short_name.as_deref(), Some("-S"));
assert_eq!(flags[1].value_name.as_deref(), Some("string"));
}
#[test]
fn test_extract_man_options_parses_alias_after_a_detached_value() {
let man_text =
"OPTIONS\n -n <number>, --max-count=<number>\n Limit commits.\n";
let flags = extract_man_options(man_text);
assert_eq!(flags.len(), 1);
assert_eq!(flags[0].short_name.as_deref(), Some("-n"));
assert_eq!(flags[0].long_name.as_deref(), Some("--max-count"));
assert_eq!(flags[0].value_name.as_deref(), Some("number"));
}
#[test]
fn test_extract_man_options_skips_the_end_of_options_marker() {
let man_text = "OPTIONS\n --\n Do not interpret any more arguments as options.\n\n --all\n Everything.\n";
let flags = extract_man_options(man_text);
assert_eq!(
flags.len(),
1,
"the end-of-options marker must not become a flag: {flags:?}"
);
assert_eq!(flags[0].long_name.as_deref(), Some("--all"));
assert!(
flags
.iter()
.all(|f| f.long_name.is_some() || f.short_name.is_some()),
"a flag with no usable name must not reach the schema"
);
}
#[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"));
}
}