inshellah/tests/ports.rs
2026-05-20 23:39:15 +10:00

661 lines
21 KiB
Rust

//! Tests ported from ../inshellah/test/test_inshellah.ml.
//!
//! Covers the help parser, manpage parser, groff stripping, and nushell
//! generation. The single .nu store parser test (`test_nu_file_parsing`) is
//! not included — it requires porting store.ml first.
use inshellah::parsers::help::help_parser;
use inshellah::parsers::manpage::{
ManpageResult, OwnedParam, OwnedSwitch, extract_synopsis_command, parse_manpage_string,
strip_groff_escapes,
};
use inshellah::parsers::nushell::{generate_extern, generate_module};
use inshellah::store::{json_of_result, parse_nu_completions, result_from_json};
use inshellah::types::{HelpResult, Param, Switch};
fn parse(txt: &str) -> HelpResult<'_> {
match help_parser(txt) {
Ok((_, r)) => r,
Err(e) => panic!("parse_help failed: {e:?}"),
}
}
// --- Help parser tests ---
#[test]
fn gnu_basic() {
let r = parse(" -a, --all do not ignore entries starting with .\n");
assert_eq!(r.entries.len(), 1);
let e = &r.entries[0];
assert!(matches!(&e.switch, Switch::Both('a', l) if *l == "all"));
assert!(e.param.is_none());
assert!(!e.desc.is_empty());
}
#[test]
fn gnu_eq_param() {
let r = parse(" --block-size=SIZE scale sizes by SIZE\n");
assert_eq!(r.entries.len(), 1);
let e = &r.entries[0];
assert!(matches!(&e.switch, Switch::Long(l) if *l == "block-size"));
assert!(matches!(&e.param, Some(Param::Mandatory(p)) if *p == "SIZE"));
}
#[test]
fn gnu_opt_param() {
let r = parse(" --color[=WHEN] color the output WHEN\n");
assert_eq!(r.entries.len(), 1);
let e = &r.entries[0];
assert!(matches!(&e.switch, Switch::Long(l) if *l == "color"));
assert!(matches!(&e.param, Some(Param::Optional(p)) if *p == "WHEN"));
}
#[test]
fn underscore_param() {
let r = parse(" --time-style=TIME_STYLE time/date format\n");
assert_eq!(r.entries.len(), 1);
let e = &r.entries[0];
assert!(matches!(&e.param, Some(Param::Mandatory(p)) if *p == "TIME_STYLE"));
}
#[test]
fn short_only() {
let r = parse(" -v verbose output\n");
assert_eq!(r.entries.len(), 1);
assert!(matches!(r.entries[0].switch, Switch::Short('v')));
}
#[test]
fn long_only() {
let r = parse(" --help display help\n");
assert_eq!(r.entries.len(), 1);
assert!(matches!(&r.entries[0].switch, Switch::Long(l) if *l == "help"));
}
#[test]
fn multiline_desc() {
let txt = " --block-size=SIZE with -l, scale sizes by SIZE when printing them;\n e.g., '--block-size=M'; see SIZE format below\n";
let r = parse(txt);
assert_eq!(r.entries.len(), 1);
let combined: String = r.entries[0].desc.join(" ");
assert!(combined.len() > 50, "desc was: {combined}");
}
#[test]
fn multiple_entries() {
let txt = " -a, --all do not ignore entries starting with .\n -A, --almost-all do not list implied . and ..\n --author with -l, print the author of each file\n";
let r = parse(txt);
assert_eq!(r.entries.len(), 3);
}
#[test]
fn clap_short_sections() {
let txt = "INPUT OPTIONS:\n -e, --regexp=PATTERN A pattern to search for.\n -f, --file=PATTERNFILE Search for patterns from the given file.\nSEARCH OPTIONS:\n -s, --case-sensitive Search case sensitively.\n";
let r = parse(txt);
assert_eq!(r.entries.len(), 3);
let e = &r.entries[0];
assert!(matches!(&e.switch, Switch::Both('e', l) if *l == "regexp"));
assert!(matches!(&e.param, Some(Param::Mandatory(p)) if *p == "PATTERN"));
}
#[test]
fn clap_long_style() {
let txt = " -H, --hidden\n Include hidden directories and files.\n\n --no-ignore\n Do not respect ignore files.\n";
let r = parse(txt);
assert_eq!(r.entries.len(), 2);
let e = &r.entries[0];
assert!(matches!(&e.switch, Switch::Both('H', l) if *l == "hidden"));
assert!(!e.desc.is_empty());
}
#[test]
fn clap_long_angle_param() {
let txt = " --nonprintable-notation <notation>\n Set notation for non-printable characters.\n";
let r = parse(txt);
assert_eq!(r.entries.len(), 1);
let e = &r.entries[0];
assert!(matches!(&e.switch, Switch::Long(l) if *l == "nonprintable-notation"));
assert!(matches!(&e.param, Some(Param::Mandatory(p)) if *p == "notation"));
}
#[test]
fn space_upper_param() {
let r = parse(" -f, --foo FOO foo help\n");
assert_eq!(r.entries.len(), 1);
let e = &r.entries[0];
assert!(matches!(&e.switch, Switch::Both('f', l) if *l == "foo"));
assert!(matches!(&e.param, Some(Param::Mandatory(p)) if *p == "FOO"));
}
#[test]
fn go_cobra_flags() {
let txt = "Flags:\n -D, --debug Enable debug mode\n -H, --host string Daemon socket to connect to\n -v, --version Print version information\n";
let r = parse(txt);
assert_eq!(r.entries.len(), 3);
let host = &r.entries[1];
assert!(matches!(&host.switch, Switch::Both('H', l) if *l == "host"));
assert!(matches!(&host.param, Some(Param::Mandatory(p)) if *p == "string"));
}
#[test]
fn go_cobra_subcommands() {
let txt = "Common Commands:\n run Create and run a new container from an image\n exec Execute a command in a running container\n build Build an image from a Dockerfile\n";
let r = parse(txt);
assert!(
!r.subcommands.is_empty(),
"expected subcommands, got: {:?}",
r.subcommands.len()
);
}
#[test]
fn help_parser_ignores_value_enums_and_defaults() {
let txt = r#"Usage: tar [OPTION...] [FILE]...
Main operation mode:
-c, --create create a new archive
Archive format selection:
-H, --format=FORMAT create archive of the given format
FORMAT is one of the following:
gnu GNU tar 1.13.x format
oldgnu GNU format as per tar <= 1.12
pax POSIX 1003.1-2001 (pax) format
posix same as pax
ustar POSIX 1003.1-1988 (ustar) format
v7 old V7 tar format
*This* tar defaults to:
--format=gnu -f- -b20 --quoting-style=escape
--rmt-command=/nix/store/example/libexec/rmt
"#;
let r = parse(txt);
assert!(
r.subcommands.is_empty(),
"enum values became subcommands: {:?}",
r.subcommands.len()
);
assert!(
!r.entries
.iter()
.any(|e| matches!(&e.switch, Switch::Long(l) if *l == "rmt-command")),
"default lines should not become flags"
);
assert!(
r.entries
.iter()
.any(|e| matches!(&e.switch, Switch::Both('H', l) if *l == "format")),
"real option should still be parsed"
);
}
#[test]
fn busybox_tab() {
let r = parse("\t-1\tOne column output\n\t-a\tInclude names starting with .\n");
assert_eq!(r.entries.len(), 2);
assert!(matches!(r.entries[0].switch, Switch::Short('1')));
}
#[test]
fn no_debug_prints() {
// the old ocaml parser had print_endline at module load time; this test
// documents that no such side effects exist in the rust port.
let _ = parse(" -v verbose\n");
}
#[test]
fn slash_switch_separator() {
let r = parse(" --verbose / -v Increase verbosity\n");
assert_eq!(r.entries.len(), 1);
let e = &r.entries[0];
assert!(matches!(&e.switch, Switch::Both('v', l) if *l == "verbose"));
assert!(e.param.is_none());
let combined: String = e.desc.join(" ");
assert_eq!(combined.trim(), "Increase verbosity");
}
// --- Manpage parser tests ---
#[test]
fn manpage_tp_style() {
let groff = r#".SH OPTIONS
.TP
\fB\-a\fR, \fB\-\-all\fR
do not ignore entries starting with .
.TP
\fB\-A\fR, \fB\-\-almost\-all\fR
do not list implied . and ..
.TP
\fB\-\-block\-size\fR=\fISIZE\fR
with \fB\-l\fR, scale sizes by SIZE
.SH AUTHOR
Written by someone.
"#;
let r = parse_manpage_string(groff);
assert_eq!(r.entries.len(), 3, "entries: {:?}", r.entries);
assert!(matches!(&r.entries[0].switch, OwnedSwitch::Both('a', l) if l == "all"));
assert!(!r.entries[0].desc.is_empty());
assert!(matches!(&r.entries[2].switch, OwnedSwitch::Long(l) if l == "block-size"));
assert!(matches!(&r.entries[2].param, Some(OwnedParam::Mandatory(p)) if p == "SIZE"));
}
#[test]
fn manpage_ip_style() {
let groff = r#".SH OPTIONS
.IP "\fB\-k\fR, \fB\-\-insecure\fR"
Allow insecure connections.
.IP "\fB\-o\fR, \fB\-\-output\fR \fIfile\fR"
Write output to file.
.SH SEE ALSO
"#;
let r = parse_manpage_string(groff);
assert_eq!(r.entries.len(), 2, "entries: {:?}", r.entries);
assert!(matches!(&r.entries[0].switch, OwnedSwitch::Both('k', l) if l == "insecure"));
}
#[test]
fn manpage_groff_stripping() {
let s = strip_groff_escapes(r#"\fB\-\-color\fR[=\fIWHEN\fR]"#);
// font escapes removed
assert!(!(s.contains('f') && s.contains('B') && s.contains('\\')));
// dashes converted
assert!(s.contains('-'));
let s2 = strip_groff_escapes(r#"\(aqhello\(aq"#);
assert!(s2.contains('\''), "expected apostrophe in: {s2}");
}
#[test]
fn manpage_empty_options() {
let groff = ".SH NAME\nfoo \\- does stuff\n.SH DESCRIPTION\nDoes stuff.\n";
let r = parse_manpage_string(groff);
assert_eq!(r.entries.len(), 0);
}
#[test]
fn manpage_nix3_style() {
let groff = r#".SH Options
.SS Logging-related options
.IP "\(bu" 3
.UR #opt-verbose
\f(CR--verbose\fR
.UE
/ \f(CR-v\fR
.IP
Increase the logging verbosity level.
.IP "\(bu" 3
.UR #opt-quiet
\f(CR--quiet\fR
.UE
.IP
Decrease the logging verbosity level.
.SH SEE ALSO
"#;
let r = parse_manpage_string(groff);
assert_eq!(r.entries.len(), 2, "entries: {:?}", r.entries);
assert!(matches!(&r.entries[0].switch, OwnedSwitch::Both('v', l) if l == "verbose"));
assert!(!r.entries[0].desc.is_empty());
assert!(matches!(&r.entries[1].switch, OwnedSwitch::Long(l) if l == "quiet"));
assert!(!r.entries[1].desc.is_empty());
}
#[test]
fn manpage_nix3_with_params() {
let groff = r#".SH Options
.IP "\(bu" 3
.UR #opt-arg
\f(CR--arg\fR
.UE
\fIname\fR \fIexpr\fR
.IP
Pass the value as the argument name to Nix functions.
.IP "\(bu" 3
.UR #opt-include
\f(CR--include\fR
.UE
/ \f(CR-I\fR \fIpath\fR
.IP
Add path to search path entries.
.IP
This option may be given multiple times.
.SH SEE ALSO
"#;
let r = parse_manpage_string(groff);
assert_eq!(r.entries.len(), 2, "entries: {:?}", r.entries);
assert!(matches!(&r.entries[0].switch, OwnedSwitch::Long(l) if l == "arg"));
assert!(r.entries[0].param.is_some());
assert!(matches!(&r.entries[1].switch, OwnedSwitch::Both('I', l) if l == "include"));
assert!(matches!(&r.entries[1].param, Some(OwnedParam::Mandatory(p)) if p == "path"));
}
#[test]
fn synopsis_subcommand() {
let groff = r#".SH "SYNOPSIS"
.sp
.nf
\fBgit\fR \fBcommit\fR [\fB\-a\fR | \fB\-\-interactive\fR]
.fi
.SH "DESCRIPTION"
"#;
let cmd = extract_synopsis_command(groff);
assert_eq!(cmd.as_deref(), Some("git commit"));
}
#[test]
fn synopsis_standalone() {
let groff = ".SH Synopsis\n.LP\n\\f(CRnix-build\\fR [\\fIpaths\\fR]\n.SH Description\n";
let cmd = extract_synopsis_command(groff);
assert_eq!(cmd.as_deref(), Some("nix-build"));
}
#[test]
fn synopsis_nix3() {
let groff = ".SH Synopsis\n.LP\n\\f(CRnix run\\fR [\\fIoption\\fR] \\fIinstallable\\fR\n.SH Description\n";
let cmd = extract_synopsis_command(groff);
assert_eq!(cmd.as_deref(), Some("nix run"));
}
#[test]
fn italic_synopsis() {
let groff = ".SH Synopsis\n.LP\n\\f(CRnix-env\\fR \\fIoperation\\fR [\\fIoptions\\fR] [\\fIarguments…\\fR]\n.SH Description\n";
let cmd = extract_synopsis_command(groff);
assert_eq!(cmd.as_deref(), Some("nix-env"));
}
#[test]
fn synopsis_italic_command_name() {
// git-am.1 (and many other git manpages) put the entire command
// invocation in italics: `\fIgit am\fR [...]`. should still resolve
// to "git am" rather than treating it as a placeholder.
let groff = ".SH \"SYNOPSIS\"\n.sp\n.nf\n\\fIgit am\\fR [\\-\\-signoff] [\\-\\-keep]\n.fi\n.SH \"DESCRIPTION\"\n";
let cmd = extract_synopsis_command(groff);
assert_eq!(cmd.as_deref(), Some("git am"));
}
// --- Font/dedup tests (only the font-spacing one is portable) ---
#[test]
fn font_boundary_spacing() {
// \fB--max-results\fR\fIcount\fR should become "--max-results count"
let s = strip_groff_escapes(r#"\fB\-\-max\-results\fR\fIcount\fR"#);
assert!(s.contains("--max-results count"), "got: {s}");
// \fB--color\fR[=\fIWHEN\fR] should NOT insert space before =
let s2 = strip_groff_escapes(r#"\fB\-\-color\fR[=\fIWHEN\fR]"#);
assert!(s2.contains("--color[=WHEN]"), "got: {s2}");
}
// --- COMMANDS section tests ---
#[test]
fn commands_section_subcommands() {
let groff = r#".SH OPTIONS
.TP
\fB\-\-user\fR
Talk to the service manager of the calling user.
.TP
\fB\-\-system\fR
Talk to the service manager of the system.
.SH COMMANDS
.PP
\fBstart\fR \fIUNIT\fR\&...
.RS 4
Start (activate) one or more units.
.RE
.PP
\fBstop\fR \fIUNIT\fR\&...
.RS 4
Stop (deactivate) one or more units.
.RE
.PP
\fBreload\fR \fIUNIT\fR\&...
.RS 4
Asks all units to reload their configuration.
.RE
.SH SEE ALSO
"#;
let r = parse_manpage_string(groff);
assert_eq!(r.entries.len(), 2, "options entries: {:?}", r.entries);
assert_eq!(r.subcommands.len(), 3, "subcommands: {:?}", r.subcommands);
let names: Vec<&str> = r.subcommands.iter().map(|sc| sc.name.as_str()).collect();
assert!(names.contains(&"start"));
assert!(names.contains(&"stop"));
assert!(names.contains(&"reload"));
let start_sc = r.subcommands.iter().find(|sc| sc.name == "start").unwrap();
assert!(!start_sc.desc.is_empty());
}
#[test]
fn commands_section_git_style_refs() {
let groff = r#".SH OPTIONS
.TP
\fB\-\-version\fR
Show version.
.SH "GIT COMMANDS"
.SS "Main porcelain commands"
.PP
.BR git-add (1)
.RS 4
Add file contents to the index.
.RE
.PP
\fBgit-commit\fR(1)
.RS 4
Record changes to the repository.
.RE
"#;
let r = parse_manpage_string(groff);
let names: Vec<&str> = r.subcommands.iter().map(|sc| sc.name.as_str()).collect();
assert!(
names.contains(&"git-add"),
"subcommands: {:?}",
r.subcommands
);
assert!(
names.contains(&"git-commit"),
"subcommands: {:?}",
r.subcommands
);
let add = r
.subcommands
.iter()
.find(|sc| sc.name == "git-add")
.unwrap();
assert!(add.desc.contains("Add file contents"));
}
// --- Nushell generation tests ---
fn to_owned_result(r: &HelpResult<'_>) -> ManpageResult {
r.into()
}
#[test]
fn nushell_basic() {
let r = parse(" -a, --all do not ignore entries starting with .\n");
let nu = generate_extern("ls", &to_owned_result(&r));
assert!(nu.contains("export extern \"ls\""), "nu = {nu}");
assert!(nu.contains("--all(-a)"), "nu = {nu}");
assert!(nu.contains("# do not ignore"), "nu = {nu}");
}
#[test]
fn nushell_param_types() {
let txt = " -w, --width=COLS set output width\n --block-size=SIZE scale sizes\n -o, --output FILE output file\n";
let r = parse(txt);
let nu = generate_extern("ls", &to_owned_result(&r));
assert!(nu.contains("--width(-w): int"), "nu = {nu}");
assert!(nu.contains("--block-size: string"), "nu = {nu}");
assert!(nu.contains("--output(-o): path"), "nu = {nu}");
}
#[test]
fn nushell_subcommands() {
let txt = "Common Commands:\n run Create and run a new container\n exec Execute a command\n\nFlags:\n -D, --debug Enable debug mode\n";
let r = parse(txt);
let nu = generate_extern("docker", &to_owned_result(&r));
assert!(nu.contains("export extern \"docker\""), "nu = {nu}");
assert!(nu.contains("--debug(-D)"), "nu = {nu}");
assert!(nu.contains("export extern \"docker run\""), "nu = {nu}");
assert!(nu.contains("export extern \"docker exec\""), "nu = {nu}");
}
#[test]
fn positional_order_survives_cache_and_generation() {
let txt = "usage: git clone [<options>] [--] <repository> [directory]\n";
let result = to_owned_result(&parse(txt));
assert_eq!(
result
.positionals
.iter()
.map(|(name, _)| name.as_str())
.collect::<Vec<_>>(),
vec!["repository", "directory"]
);
let json = json_of_result("help", &result);
let value = serde_json::from_str(&json).expect("cache json");
let cached = result_from_json(&value);
assert_eq!(
cached
.positionals
.iter()
.map(|(name, _)| name.as_str())
.collect::<Vec<_>>(),
vec!["repository", "directory"]
);
let nu = generate_extern("git clone", &cached);
let repository = nu
.find("repository: string")
.expect("repository positional");
let directory = nu.find("directory?: path").expect("directory positional");
assert!(repository < directory, "nu = {nu}");
}
#[test]
fn nushell_from_manpage() {
let groff = r#".SH OPTIONS
.TP
\fB\-a\fR, \fB\-\-all\fR
do not ignore entries starting with .
.TP
\fB\-\-block\-size\fR=\fISIZE\fR
scale sizes by SIZE
.SH AUTHOR
"#;
let result = parse_manpage_string(groff);
let nu = generate_extern("ls", &result);
assert!(nu.contains("export extern \"ls\""), "nu = {nu}");
assert!(nu.contains("--all(-a)"), "nu = {nu}");
assert!(nu.contains("--block-size: string"), "nu = {nu}");
}
#[test]
fn nushell_module() {
let r = parse(" -v, --verbose verbose output\n");
let nu = generate_module("myapp", &to_owned_result(&r));
assert!(nu.contains("module myapp-completions"), "nu = {nu}");
assert!(nu.contains("export extern \"myapp\""), "nu = {nu}");
assert!(nu.contains("--verbose(-v)"), "nu = {nu}");
}
#[test]
fn dedup_entries_help() {
let txt = " -v, --verbose verbose output\n --verbose verbose mode\n -v be verbose\n";
let r = parse(txt);
let nu = generate_extern("test", &to_owned_result(&r));
let count = nu.matches("--verbose").count();
assert_eq!(count, 1, "expected --verbose to appear once, nu = {nu}");
assert!(nu.contains("--verbose(-v)"), "nu = {nu}");
}
#[test]
fn dedup_manpage_entries() {
let groff = r#".SH OPTIONS
.TP
\fB\-v\fR, \fB\-\-verbose\fR
Be verbose.
.SH DESCRIPTION
Use \fB\-v\fR for verbose output.
Use \fB\-\-verbose\fR to see more.
"#;
let result = parse_manpage_string(groff);
let nu = generate_extern("test", &result);
assert!(nu.contains("--verbose(-v)"), "nu = {nu}");
let verbose_lines: Vec<&str> = nu.lines().filter(|l| l.contains("verbose")).collect();
assert_eq!(
verbose_lines.len(),
1,
"expected 1 verbose line, got: {verbose_lines:?}"
);
}
#[test]
fn nu_file_parsing() {
let nu_source = r#"module completions {
# Unofficial CLI tool
export extern mytool [
--help(-h) # Print help
--version(-V) # Print version
]
# List all items
export extern "mytool list" [
--raw # Output as JSON
--format(-f): string # Output format
--help(-h) # Print help
name?: string # Filter by name
]
}
use completions *
"#;
let r = parse_nu_completions("mytool", nu_source);
assert_eq!(r.entries.len(), 2, "entries: {:?}", r.entries);
assert!(
!r.subcommands.is_empty(),
"subcommands: {:?}",
r.subcommands
);
assert!(r.subcommands.iter().any(|sc| sc.name == "list"));
assert_eq!(r.description, "Unofficial CLI tool");
let r2 = parse_nu_completions("mytool list", nu_source);
assert_eq!(r2.entries.len(), 3, "list entries: {:?}", r2.entries);
let has_format = r2
.entries
.iter()
.any(|e| matches!(&e.switch, OwnedSwitch::Both('f', l) if l == "format"));
assert!(
has_format,
"list should have --format(-f): {:?}",
r2.entries
);
assert!(!r2.positionals.is_empty(), "list should have a positional");
}
#[test]
fn self_listing_detection() {
let txt = r#"systemctl [OPTIONS...] COMMAND ...
Unit Commands:
start UNIT... Start (activate) one or more units
stop UNIT... Stop (deactivate) one or more units
status [PATTERN...] Show runtime status
Options:
--user Talk to the user service manager
--system Talk to the system service manager
"#;
let r = parse(txt);
let has_start = r.subcommands.iter().any(|sc| sc.name == "start");
assert!(
has_start,
"expected start in subcommands: {:?}",
r.subcommands.iter().map(|sc| sc.name).collect::<Vec<_>>()
);
assert!(r.entries.len() >= 2);
}