use std::sync::LazyLock;
use regex::Regex;
use crate::models::{ScannedArg, ValueType};
static ARG_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"<([a-zA-Z_][\w -]*)>(\.\.\.)?").expect("valid static regex"));
static BRACKET_OPERAND_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\[([A-Za-z][\w-]*)(\s*\.\.\.)?\](\.\.\.)?").expect("valid static regex")
});
static BARE_OPERAND_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\b([A-Z][A-Z0-9_-]*)\b(\.\.\.)?").expect("valid static regex"));
const OPTION_GROUP_WORDS: &[&str] = &["option", "options", "opts", "flag", "flags"];
fn is_option_group_word(name: &str) -> bool {
let lowered = name.to_ascii_lowercase();
OPTION_GROUP_WORDS.contains(&lowered.as_str())
}
fn inside_option_group(line: &str, start: usize) -> bool {
let mut depth = 0i32;
for (index, ch) in line[..start].char_indices().rev() {
match ch {
']' => depth += 1,
'[' => {
if depth > 0 {
depth -= 1;
continue;
}
let group = line[index + 1..].trim_start();
let group = group.strip_prefix('(').map_or(group, str::trim_start);
if group.starts_with('-') {
return true;
}
}
_ => {}
}
}
false
}
static OPTION_VALUE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"-{1,2}[A-Za-z][\w-]*\[?=?\s*<[^>]+>(?:=<[^>]+>)*\]?").expect("valid static regex")
});
struct OptionValueRanges(Vec<(usize, usize)>);
impl OptionValueRanges {
fn of(line: &str) -> Self {
Self(
OPTION_VALUE_RE
.find_iter(line)
.map(|m| (m.start(), m.end()))
.collect(),
)
}
fn covers(&self, offset: usize) -> bool {
self.0
.iter()
.any(|&(start, end)| offset >= start && offset < end)
}
}
fn scanned_operand(name: String, required: bool, variadic: bool) -> ScannedArg {
ScannedArg {
name,
description: String::new(),
value_type: ValueType::String,
required,
variadic,
before_flags: false,
}
}
fn belongs_to_an_option(line: &str, offset: usize, name: &str) -> bool {
inside_option_group(line, offset) || is_option_group_word(name)
}
fn collect_angle_placeholders(
line: &str,
ranges: &OptionValueRanges,
found: &mut Vec<(usize, ScannedArg)>,
) {
for cap in ARG_RE.captures_iter(line) {
let Some(whole) = cap.get(0) else { continue };
if ranges.covers(whole.start()) {
continue;
}
let name = normalize_name(&cap[1]);
if belongs_to_an_option(line, whole.start(), &name) {
continue;
}
found.push((
whole.start(),
scanned_operand(
name,
bracket_depth_before(line, whole.start()) <= 0,
cap.get(2).is_some(),
),
));
}
}
fn collect_bracketed_operands(line: &str, found: &mut Vec<(usize, ScannedArg)>) {
for cap in BRACKET_OPERAND_RE.captures_iter(line) {
let (Some(whole), Some(name_match)) = (cap.get(0), cap.get(1)) else {
continue;
};
let name = normalize_name(name_match.as_str());
if belongs_to_an_option(line, name_match.start(), &name) {
continue;
}
let variadic = cap.get(2).is_some() || cap.get(3).is_some();
let is_placeholder_caps = !name.chars().any(|ch| ch.is_ascii_lowercase());
if !is_placeholder_caps && !variadic {
continue;
}
if found.iter().any(|(_, arg)| arg.name == name) {
continue;
}
found.push((whole.start(), scanned_operand(name, false, variadic)));
}
}
const OPERAND_GLUE_CHARS: &[char] = &[':', '|', '/', '=', ','];
fn glued_to_predecessor(line: &str, start: usize) -> bool {
line[..start]
.chars()
.next_back()
.is_some_and(|c| OPERAND_GLUE_CHARS.contains(&c))
}
fn collect_bare_operands(
line: &str,
ranges: &OptionValueRanges,
found: &mut Vec<(usize, ScannedArg)>,
) {
for cap in BARE_OPERAND_RE.captures_iter(line) {
let (Some(whole), Some(name_match)) = (cap.get(0), cap.get(1)) else {
continue;
};
let name = normalize_name(name_match.as_str());
if belongs_to_an_option(line, name_match.start(), &name)
|| ranges.covers(whole.start())
|| glued_to_predecessor(line, name_match.start())
{
continue;
}
if found.iter().any(|(_, arg)| arg.name == name) {
continue;
}
found.push((
whole.start(),
scanned_operand(
name,
bracket_depth_before(line, whole.start()) <= 0,
cap.get(2).is_some(),
),
));
}
}
pub fn extract_args_from_usage_line(line: &str) -> Vec<ScannedArg> {
let ranges = OptionValueRanges::of(line);
let mut found: Vec<(usize, ScannedArg)> = Vec::new();
collect_angle_placeholders(line, &ranges, &mut found);
collect_bracketed_operands(line, &mut found);
collect_bare_operands(line, &ranges, &mut found);
found.sort_by_key(|(offset, _)| *offset);
found.into_iter().map(|(_, arg)| arg).collect()
}
fn normalize_name(raw: &str) -> String {
raw.trim().replace(' ', "_")
}
fn bracket_depth_before(line: &str, pos: usize) -> i32 {
line[..pos].chars().fold(0, |depth, ch| match ch {
'[' => depth + 1,
']' => depth - 1,
_ => depth,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extracts_bare_required_arg() {
let args = extract_args_from_usage_line("Usage: tool <file>");
assert_eq!(args.len(), 1);
assert_eq!(args[0].name, "file");
assert!(args[0].required);
assert!(!args[0].variadic);
}
#[test]
fn test_bare_operands_do_not_split_a_punctuation_joined_placeholder() {
let args = extract_args_from_usage_line("docker tag SOURCE_IMAGE[:TAG] TARGET_IMAGE[:TAG]");
let names: Vec<&str> = args.iter().map(|a| a.name.as_str()).collect();
assert_eq!(names, vec!["SOURCE_IMAGE", "TARGET_IMAGE"]);
}
#[test]
fn test_bare_operands_collapse_a_pipe_alternation_to_one_operand() {
let args = extract_args_from_usage_line("docker inspect [OPTIONS] NAME|ID [NAME|ID...]");
let names: Vec<&str> = args.iter().map(|a| a.name.as_str()).collect();
assert_eq!(names, vec!["NAME"]);
}
#[test]
fn test_bare_operands_do_not_split_a_bracketed_slash_suffix() {
let args = extract_args_from_usage_line("docker port CONTAINER [PRIVATE_PORT[/PROTO]]");
let names: Vec<&str> = args.iter().map(|a| a.name.as_str()).collect();
assert_eq!(names, vec!["CONTAINER", "PRIVATE_PORT"]);
}
#[test]
fn test_extracts_variadic_arg() {
let args = extract_args_from_usage_line("Usage: tool <file>...");
assert_eq!(args.len(), 1);
assert!(args[0].variadic);
}
#[test]
fn test_optional_arg_in_brackets_is_not_required() {
let args = extract_args_from_usage_line("usage: git [--version] <command> [<args>]");
assert_eq!(args.len(), 2);
assert_eq!(args[0].name, "command");
assert!(args[0].required);
assert_eq!(args[1].name, "args");
assert!(!args[1].required);
}
#[test]
fn test_skips_short_option_value_placeholder() {
let args =
extract_args_from_usage_line("usage: git [-v | --version] [-h | --help] [-C <path>]");
assert!(args.is_empty());
}
#[test]
fn test_skips_chained_short_option_value_placeholders() {
let args = extract_args_from_usage_line("usage: git [-c <name>=<value>]");
assert!(args.is_empty());
}
#[test]
fn test_skips_long_option_inline_value_placeholder() {
let args =
extract_args_from_usage_line("usage: git [--git-dir=<path>] [--exec-path[=<path>]]");
assert!(args.is_empty());
}
#[test]
fn test_skips_variadic_long_option_value_placeholder() {
let args = extract_args_from_usage_line("Usage: tool [--include=<path>...]");
assert!(args.is_empty(), "expected no positional args, got {args:?}");
}
#[test]
fn test_skips_variadic_short_option_value_placeholder() {
let args = extract_args_from_usage_line("Usage: tool [-I <dir>...]");
assert!(args.is_empty(), "expected no positional args, got {args:?}");
}
#[test]
fn test_skips_an_unbracketed_option_value_placeholder() {
let args = extract_args_from_usage_line("Usage: tool --output=<file> <input>");
assert_eq!(
args.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(),
["input"],
"the option's value is not an operand: {args:?}"
);
}
#[test]
fn test_skips_an_unbracketed_option_value_written_with_a_space() {
let args = extract_args_from_usage_line("Usage: tool --output <file> <input>");
assert_eq!(
args.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(),
["input"],
"the option's value is not an operand: {args:?}"
);
}
#[test]
fn test_keeps_operand_following_an_option_value() {
let args = extract_args_from_usage_line("Usage: tool [-o <out>] <file>");
assert_eq!(args.len(), 1);
assert_eq!(args[0].name, "file");
assert!(args[0].required);
}
#[test]
fn test_full_git_root_usage_line_yields_no_positional_args() {
let line = "usage: git [-v | --version] [-h | --help] [-C <path>] [-c <name>=<value>]";
assert!(extract_args_from_usage_line(line).is_empty());
}
#[test]
fn test_extracts_multi_word_placeholder_name() {
let args = extract_args_from_usage_line("Usage:\tjq [options] <jq filter> [file...]");
assert_eq!(args.len(), 2);
assert_eq!(args[0].name, "jq_filter");
assert!(args[0].required);
assert!(!args[0].variadic);
}
#[test]
fn test_extracts_bracketed_variadic_operand() {
let args = extract_args_from_usage_line("Usage:\tjq [options] <jq filter> [file...]");
assert_eq!(args[1].name, "file");
assert!(!args[1].required);
assert!(args[1].variadic);
}
#[test]
fn test_bracketed_operand_requires_ellipsis() {
let args = extract_args_from_usage_line(
"usage: ls [options] [-l] [--color=when] [--exclude=<pat>]",
);
assert!(
args.is_empty(),
"bracketed groups without `...` are not operands: {args:?}"
);
}
#[test]
fn test_operands_are_returned_in_usage_order() {
let args = extract_args_from_usage_line("usage: tool [src ...] <dest>");
let names: Vec<&str> = args.iter().map(|a| a.name.as_str()).collect();
assert_eq!(names, vec!["src", "dest"]);
}
#[test]
fn test_bracketed_operand_does_not_duplicate_angle_placeholder() {
let args = extract_args_from_usage_line("usage: tool <file> [file ...]");
assert_eq!(args.len(), 1);
assert_eq!(args[0].name, "file");
}
#[derive(Debug, PartialEq)]
struct Operand {
name: String,
required: bool,
variadic: bool,
}
struct UsageCase {
line: &'static str,
operands: Vec<Operand>,
}
fn operand(name: &str, required: bool, variadic: bool) -> Operand {
Operand {
name: name.to_string(),
required,
variadic,
}
}
fn extract(line: &str) -> Vec<Operand> {
extract_args_from_usage_line(line)
.into_iter()
.map(|a| operand(&a.name, a.required, a.variadic))
.collect()
}
fn names(line: &str) -> Vec<String> {
extract(line).into_iter().map(|o| o.name).collect()
}
#[test]
fn test_gnu_usage_lines_from_real_binaries() {
let cases = vec![
UsageCase {
line: "Usage: ls [OPTION]... [FILE]...",
operands: vec![operand("FILE", false, true)],
},
UsageCase {
line: "Usage: cp [OPTION]... [-T] SOURCE DEST",
operands: vec![operand("SOURCE", true, false), operand("DEST", true, false)],
},
UsageCase {
line: "Usage: cut OPTION... [FILE]...",
operands: vec![operand("FILE", false, true)],
},
UsageCase {
line: "Usage: uniq [OPTION]... [INPUT [OUTPUT]]",
operands: vec![
operand("INPUT", false, false),
operand("OUTPUT", false, false),
],
},
UsageCase {
line: "Usage: mkdir [OPTION]... DIRECTORY...",
operands: vec![operand("DIRECTORY", true, true)],
},
UsageCase {
line: "Usage: touch [OPTION]... FILE...",
operands: vec![operand("FILE", true, true)],
},
UsageCase {
line: "Usage: ln [OPTION]... [-T] TARGET LINK_NAME",
operands: vec![
operand("TARGET", true, false),
operand("LINK_NAME", true, false),
],
},
UsageCase {
line: "Usage: grep [OPTION]... PATTERNS [FILE]...",
operands: vec![
operand("PATTERNS", true, false),
operand("FILE", false, true),
],
},
UsageCase {
line: "Usage: diff [OPTION]... FILES",
operands: vec![operand("FILES", true, false)],
},
UsageCase {
line: "Usage: xargs [OPTION]... COMMAND [INITIAL-ARGS]...",
operands: vec![
operand("COMMAND", true, false),
operand("INITIAL-ARGS", false, true),
],
},
];
for case in cases {
assert_eq!(
extract(case.line),
case.operands,
"mismatch for {:?}",
case.line
);
}
}
#[test]
fn test_gnu_option_group_placeholder_is_not_an_operand() {
for line in [
"Usage: ls [OPTION]... [FILE]...",
"Usage: cut OPTION... [FILE]...",
] {
let got = names(line);
assert!(
!got.iter().any(|n| n.eq_ignore_ascii_case("option")),
"OPTION must not be reported as an operand in {line:?}: {got:?}"
);
}
}
#[test]
fn test_dash_prefixed_bracket_group_is_not_an_operand() {
assert_eq!(
names("Usage: cp [OPTION]... [-T] SOURCE DEST"),
vec!["SOURCE", "DEST"]
);
}
#[test]
fn test_bsd_short_option_bundle_is_not_an_operand() {
assert_eq!(
extract("usage: ls [-@ABCF] [--color=when] [-D format] [file ...]"),
vec![operand("file", false, true)]
);
}
#[test]
fn test_gnu_find_usage_line_mixes_option_and_operand_brackets() {
assert_eq!(
extract("Usage: find [-H] [-L] [-P] [-Olevel] [-D debugopts] [path...] [expression]"),
vec![operand("path", false, true)]
);
}
#[test]
fn test_bracketed_lowercase_without_ellipsis_is_not_an_operand() {
assert!(extract("usage: basename string [suffix]").is_empty());
}
#[test]
fn test_parenthesised_alias_group_is_not_an_operand() {
let grep = extract(
"git grep [(-O | --open-files-in-pager) [<pager>]] [(-m | --max-count) <num>] \
[<pathspec>...]",
);
let names: Vec<&str> = grep.iter().map(|a| a.name.as_str()).collect();
assert_eq!(
names,
vec!["pathspec"],
"option letters and option values must not become operands: {names:?}"
);
let commit = extract("git commit [(-c | -C | --squash) <commit>] [--] [<pathspec>...]");
let names: Vec<&str> = commit.iter().map(|a| a.name.as_str()).collect();
assert!(
!names.contains(&"c") && !names.contains(&"commit"),
"an operand named `c` displaces git's real -c option: {names:?}"
);
assert_eq!(names, vec!["pathspec"]);
}
#[test]
fn test_optional_bracketed_operand_survives_the_option_group_check() {
let args = extract("usage: git [--version] <command> [<args>]");
let names: Vec<&str> = args.iter().map(|a| a.name.as_str()).collect();
assert_eq!(names, vec!["command", "args"]);
}
}