This commit is contained in:
atagen 2026-05-19 23:32:51 +10:00
parent da4bc139eb
commit 9745ef9c56
49 changed files with 9039 additions and 5483 deletions

78
tests/git_clone_fix.rs Normal file
View file

@ -0,0 +1,78 @@
use inshellah::parsers::help::help_parser;
#[test]
fn parser_recovers_past_no_bracket_long_form() {
// git clone -h produces lines like `--[no-]progress` that switch_parser
// can't parse. previously the help parser got stuck on these because
// skip_non_option_line refused to skip option-looking lines. now it falls
// through to skip, letting the parser continue to the next real entry.
let text = r#"usage: git clone [<options>] [--] <repo> [<dir>]
-v, --[no-]verbose be more verbose
-q, --[no-]quiet be more quiet
--[no-]progress force progress reporting
--[no-]reject-shallow don't clone shallow repository
-n, --no-checkout don't create a checkout
--checkout opposite of --no-checkout
-s, --[no-]shared setup as shared repository
"#;
let (_, r) = help_parser(text).expect("parse");
// before the fix: only 2 entries (-v, -q) before the parser got stuck.
// after: -v, -q, -n/--no-checkout, --checkout, -s, plus any others.
assert!(
r.entries.len() >= 4,
"expected ≥4 entries, got {}",
r.entries.len()
);
assert!(
r.entries.iter().any(|e| {
matches!(
&e.switch,
inshellah::types::Switch::Both('v', l) if *l == "verbose"
)
}),
"expected -v/--verbose from --[no-]verbose, got {:?}",
r.entries.len()
);
}
#[test]
fn parser_keeps_negatable_params() {
let text = r#"usage: git clone [<options>] [--] <repo> [<dir>]
-j, --[no-]jobs <n> number of submodules cloned in parallel
--[no-]recurse-submodules[=<pathspec>]
initialize submodules in the clone
--[no-]reject-shallow don't clone shallow repository
"#;
let (_, r) = help_parser(text).expect("parse");
let jobs = r
.entries
.iter()
.find(|e| matches!(&e.switch, inshellah::types::Switch::Both('j', l) if *l == "jobs"))
.expect("jobs entry");
assert!(matches!(
&jobs.param,
Some(inshellah::types::Param::Mandatory("n"))
));
let recurse = r
.entries
.iter()
.find(|e| matches!(&e.switch, inshellah::types::Switch::Long(l) if *l == "recurse-submodules"))
.expect("recurse-submodules entry");
assert!(matches!(
&recurse.param,
Some(inshellah::types::Param::Optional("pathspec"))
));
let reject = r
.entries
.iter()
.find(|e| matches!(&e.switch, inshellah::types::Switch::Long(l) if *l == "reject-shallow"))
.expect("reject-shallow entry");
assert!(
reject.param.is_none(),
"reject-shallow should not parse prose as a param"
);
}

100
tests/manpage_cli.rs Normal file
View file

@ -0,0 +1,100 @@
use std::fs;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
fn unique_temp_dir(name: &str) -> std::path::PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time")
.as_nanos();
std::env::temp_dir().join(format!("{name}-{}-{nanos}", std::process::id()))
}
#[test]
fn manpage_command_uses_synopsis_name() {
let root = unique_temp_dir("inshellah-manpage-cli");
fs::create_dir_all(&root).expect("temp dir");
let manpage = root.join("btrfs-check.8");
fs::write(
&manpage,
r#".SH SYNOPSIS
btrfs check [options] <device>
.SH OPTIONS
.TP
\fB\-\-repair\fR
try to repair the filesystem
"#,
)
.expect("write manpage");
let output = Command::new(env!("CARGO_BIN_EXE_inshellah"))
.arg("manpage")
.arg(&manpage)
.output()
.expect("run inshellah manpage");
assert!(
output.status.success(),
"stderr = {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("stdout");
assert!(
stdout.contains("export extern \"btrfs check\""),
"stdout = {stdout}"
);
assert!(
!stdout.contains("export extern \"btrfs-check\""),
"stdout = {stdout}"
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn manpage_command_strips_git_style_subcommand_prefixes() {
let root = unique_temp_dir("inshellah-manpage-cli");
fs::create_dir_all(&root).expect("temp dir");
let manpage = root.join("git.1");
fs::write(
&manpage,
r#".SH SYNOPSIS
git [--version] [--help] <command> [<args>]
.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
"#,
)
.expect("write manpage");
let output = Command::new(env!("CARGO_BIN_EXE_inshellah"))
.arg("manpage")
.arg(&manpage)
.output()
.expect("run inshellah manpage");
assert!(
output.status.success(),
"stderr = {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("stdout");
assert!(
stdout.contains("export extern \"git add\""),
"stdout = {stdout}"
);
assert!(
!stdout.contains("export extern \"git git-add\""),
"stdout = {stdout}"
);
let _ = fs::remove_dir_all(root);
}

126
tests/nushell-completer.nu Normal file
View file

@ -0,0 +1,126 @@
def fail [msg: string] {
error make {msg: $msg}
}
def assert-eq [actual expected msg: string] {
if $actual != $expected {
fail $"($msg): expected ($expected | to nuon), got ($actual | to nuon)"
}
}
def assert-contains [items needle msg: string] {
if not ($needle in $items) {
fail $"($msg): expected ($items | to nuon) to contain ($needle | to nuon)"
}
}
def values [items] {
$items | default [] | get value
}
let completer = $env.config.completions.external.completer
def _assert_elevation_wrappers_accept_command_tails [p: path] {
sudo nix-env --set -p /nix/var/nix/profiles/system $p
doas nix-env --set -p /nix/var/nix/profiles/system $p
}
'[{"value":"--static","description":"from static cache"}]' | save --force $env.INSHELLAH_STATIC_FILE
let static_result = do $completer [demo ""]
assert-eq ($static_result | get 0.value) "--static" "static completion pass-through"
'[{"value":"--server","description":"from static cache"},{"value":"--preserve","description":"from static cache"}]' | save --force $env.INSHELLAH_STATIC_FILE
let static_fuzzy_result = do $completer [demo ser]
assert-eq (values $static_fuzzy_result) ['--server' '--preserve'] "static fuzzy completions are not refiltered by shim"
"{" | save --force $env.INSHELLAH_STATIC_FILE
let bad_static_result = do $completer [demo ""]
assert-eq $bad_static_result null "bad static JSON falls back cleanly"
"" | save --force $env.INSHELLAH_STATIC_FILE
assert-eq (do $completer [nix]) null "nix completion ignores too-short spans"
let nix_commands = do $completer [nix ""]
assert-eq ($nix_commands | get 0.value) "build" "nix command completion uses NIX_GET_COMPLETIONS"
let nix_pkg = do $completer [nix "flake#pkg"]
assert-eq ($nix_pkg | get 0.description) "raw package description" "nix descriptions are raw strings"
let systemctl_empty = do $completer [systemctl daemon-reload ""]
assert-eq $systemctl_empty null "systemctl does not offer units for non-unit verbs"
let systemctl_units = do $completer [systemctl status ""]
assert-eq ($systemctl_units | get 0.value) "demo.service" "systemctl offers units for unit verbs"
let kubectl_pods = do $completer [kubectl get pods -n prod ""]
assert-eq ($kubectl_pods | get 0.value) "pod-a" "kubectl resource names complete"
assert-eq (open $env.KUBECTL_ARGS_FILE | str contains "-n prod") true "kubectl preserves namespace flags"
let kubectl_rollout = do $completer [kubectl rollout status deployment ""]
assert-eq ($kubectl_rollout | get 0.description) "deployment" "kubectl rollout uses resource kind, not action"
let cargo_packages = do $completer [cargo test -p ""]
assert-eq (values $cargo_packages) [app-lib helper-lib] "cargo -p completes packages"
let cargo_bins = do $completer [cargo run --bin ""]
assert-eq (values $cargo_bins) [app-cli] "cargo --bin completes only bin targets"
"[]" | save --force $env.INSHELLAH_STATIC_FILE
let git_top = do $completer [git ""]
assert-contains (values $git_top) "remote" "git top-level completes common commands"
assert-contains (values $git_top) "stash" "git top-level includes stash"
let git_push = do $completer [git push ""]
assert-eq (values $git_push) [origin upstream] "empty static completions fall through to git remotes"
let git_remote_verbs = do $completer [git remote ""]
assert-eq (values $git_remote_verbs) [add rename remove rm set-head set-branches get-url set-url show prune update] "git remote completes subcommands"
let git_remote_filtered = do $completer [git remote sho]
assert-eq (values $git_remote_filtered) [show] "git remote subcommands filter by typed prefix"
let git_remote_fuzzy = do $completer [git remote shw]
assert-eq (values $git_remote_fuzzy) [show] "git remote subcommands use fuzzy filtering"
let git_remote_exact = do $completer [git remote show]
assert-eq $git_remote_exact null "exact dynamic completion disappears"
let git_remote_show = do $completer [git remote show ""]
assert-eq (values $git_remote_show) [origin upstream] "git remote show completes remote names"
let git_fetch = do $completer [git fetch ""]
assert-eq (values $git_fetch) [origin upstream] "git fetch completes remotes"
let git_fetch_ref = do $completer [git fetch origin ""]
assert-contains (values $git_fetch_ref) "main" "git fetch after remote completes refs"
let git_branch_delete = do $completer [git branch -d ""]
assert-eq (values $git_branch_delete) [main feature] "git branch delete completes local branches"
let git_tag_delete = do $completer [git tag -d ""]
assert-eq (values $git_tag_delete) [v1.0 v2.0] "git tag delete completes tags"
let git_stash_apply = do $completer [git stash apply ""]
assert-eq (values $git_stash_apply) ['stash@{0}'] "git stash apply completes stashes"
let git_submodule_update = do $completer [git submodule update ""]
assert-eq (values $git_submodule_update) [deps/demo] "git submodule update completes submodule paths"
let git_bisect = do $completer [git bisect ""]
assert-contains (values $git_bisect) "good" "git bisect completes subcommands"
let git_bisect_good = do $completer [git bisect good ""]
assert-contains (values $git_bisect_good) "main" "git bisect good completes refs"
let git_add_paths = do $completer [git add ""]
assert-eq (values $git_add_paths) [src/main.rs new-file.txt renamed.txt] "git add completes changed paths"
let git_rm_paths = do $completer [git rm ""]
assert-eq (values $git_rm_paths) [src/main.rs README.md] "git rm completes tracked paths"
"" | save --force $env.INSHELLAH_STATIC_FILE
let git_worktree_add = do $completer [git worktree add ""]
assert-eq $git_worktree_add null "git worktree add first argument falls back to files"
let git_worktree_remove = do $completer [git worktree remove ""]
assert-eq ($git_worktree_remove | get 0.value) "/repo/linked" "git worktree remove completes existing worktrees"
"[]" | save --force $env.INSHELLAH_STATIC_FILE
let jj_top = do $completer [jj ""]
assert-contains (values $jj_top) "bookmark" "jj top-level completes common commands"
assert-contains (values $jj_top) "git" "jj top-level includes git command"
let jj_bookmarks = do $completer [jj bookmark delete ""]
assert-eq (values $jj_bookmarks) [main feature origin/main] "jj bookmark delete completes bookmarks"
let jj_tags = do $completer [jj tag delete ""]
assert-eq (values $jj_tags) [v1.0 v2.0] "jj tag delete completes tags"
let jj_git_fetch = do $completer [jj git fetch ""]
assert-eq (values $jj_git_fetch) [origin upstream] "jj git fetch completes remotes"
let jj_git_remote_verbs = do $completer [jj git remote ""]
assert-eq (values $jj_git_remote_verbs) [add list remove rename set-url] "jj git remote completes subcommands"
let jj_git_remote_remove = do $completer [jj git remote remove ""]
assert-eq (values $jj_git_remote_remove) [origin upstream] "jj git remote remove completes remotes"
let jj_revs = do $completer [jj rebase -d ""]
assert-eq (values $jj_revs) [k m] "jj revision flags complete revisions"
let jj_ops = do $completer [jj op restore ""]
assert-eq (values $jj_ops) [abc123] "jj op restore completes operations"
let jj_files = do $completer [jj file show ""]
assert-eq (values $jj_files) [src/main.rs README.md] "jj file show completes repo files"
let jj_workspaces = do $completer [jj workspace forget ""]
assert-eq (values $jj_workspaces) [default linked] "jj workspace forget completes workspaces"
"" | save --force $env.INSHELLAH_STATIC_FILE

661
tests/ports.rs Normal file
View file

@ -0,0 +1,661 @@
//! 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);
}

235
tests/runtime_complete.rs Normal file
View file

@ -0,0 +1,235 @@
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use inshellah::parsers::manpage::{ManpageEntry, ManpageResult, ManpageSubcommand, OwnedSwitch};
use inshellah::store::write_result;
fn unique_temp_dir(name: &str) -> std::path::PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time")
.as_nanos();
std::env::temp_dir().join(format!("{name}-{}-{nanos}", std::process::id()))
}
#[test]
fn complete_scrapes_missing_subcommand_when_parent_is_cached() {
let root = unique_temp_dir("inshellah-runtime-complete");
let bin_dir = root.join("bin");
let cache_dir = root.join("cache");
fs::create_dir_all(&bin_dir).expect("bin dir");
fs::create_dir_all(&cache_dir).expect("cache dir");
let fakecmd = bin_dir.join("fakecmd");
fs::write(
&fakecmd,
r#"#!/bin/sh
if [ "$1" = "clone" ]; then
if [ "$2" = "--help" ] || [ "$2" = "-h" ]; then
cat <<'EOF'
Usage: fakecmd clone [OPTIONS] <repository> [directory]
Options:
--depth <n> clone depth
-v, --verbose verbose
EOF
exit 0
fi
fi
if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then
cat <<'EOF'
Usage: fakecmd [OPTIONS] COMMAND
Commands:
clone Clone a repository
Options:
-h, --help show help
EOF
exit 0
fi
exit 2
"#,
)
.expect("write fakecmd");
let mut perms = fs::metadata(&fakecmd).expect("metadata").permissions();
perms.set_mode(0o755);
fs::set_permissions(&fakecmd, perms).expect("chmod");
let parent = ManpageResult {
entries: Vec::new(),
subcommands: vec![ManpageSubcommand {
name: "clone".to_string(),
desc: "Clone a repository".to_string(),
}],
positionals: Vec::new(),
description: String::new(),
};
write_result(&cache_dir, "fakecmd", "help", &parent).expect("parent cache");
let old_path = std::env::var_os("PATH").unwrap_or_default();
let output = Command::new(env!("CARGO_BIN_EXE_inshellah"))
.arg("complete")
.arg("--dir")
.arg(&cache_dir)
.arg("--timeout-ms")
.arg("1000")
.arg("fakecmd")
.arg("clone")
.arg("--")
.env(
"PATH",
format!("{}:{}", bin_dir.display(), old_path.to_string_lossy()),
)
.output()
.expect("run inshellah complete");
assert!(
output.status.success(),
"stderr = {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("stdout");
assert!(stdout.contains("--depth"), "stdout = {stdout}");
assert!(
cache_dir.join("fakecmd_clone.json").is_file(),
"subcommand cache was not written"
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn complete_does_not_scan_path_at_command_position() {
let root = unique_temp_dir("inshellah-command-position-complete");
let bin_dir = root.join("bin");
let cache_dir = root.join("cache");
fs::create_dir_all(&bin_dir).expect("bin dir");
fs::create_dir_all(&cache_dir).expect("cache dir");
let fake_git = bin_dir.join("git");
fs::write(&fake_git, "#!/bin/sh\nexit 0\n").expect("write fake git");
let mut perms = fs::metadata(&fake_git).expect("metadata").permissions();
perms.set_mode(0o755);
fs::set_permissions(&fake_git, perms).expect("chmod");
let output = Command::new(env!("CARGO_BIN_EXE_inshellah"))
.arg("complete")
.arg("--dir")
.arg(&cache_dir)
.arg("gi")
.env("PATH", &bin_dir)
.output()
.expect("run inshellah complete");
assert!(
output.status.success(),
"stderr = {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("stdout");
assert_eq!(stdout.trim(), "null", "stdout = {stdout}");
let _ = fs::remove_dir_all(root);
}
#[test]
fn complete_uses_boundary_aware_fuzzy_ranking() {
let root = unique_temp_dir("inshellah-fuzzy-complete");
let cache_dir = root.join("cache");
fs::create_dir_all(&cache_dir).expect("cache dir");
let result = ManpageResult {
entries: vec![ManpageEntry {
switch: OwnedSwitch::Long("load".to_string()),
param: None,
desc: "load something".to_string(),
}],
subcommands: vec![ManpageSubcommand {
name: "clone".to_string(),
desc: "clone something".to_string(),
}],
positionals: Vec::new(),
description: String::new(),
};
write_result(&cache_dir, "demo", "help", &result).expect("cache");
let output = Command::new(env!("CARGO_BIN_EXE_inshellah"))
.arg("complete")
.arg("--dir")
.arg(&cache_dir)
.arg("demo")
.arg("lo")
.output()
.expect("run inshellah complete");
assert!(
output.status.success(),
"stderr = {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("stdout");
let load_pos = stdout.find(r#""value":"--load""#).unwrap_or(usize::MAX);
let clone_pos = stdout.find(r#""value":"clone""#).unwrap_or(usize::MAX);
assert!(
load_pos < clone_pos,
"expected boundary match to outrank substring match, stdout = {stdout}"
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn complete_resolves_absolute_path_after_elevation_wrapper() {
let root = unique_temp_dir("inshellah-absolute-elevation-complete");
let bin_dir = root.join("bin");
let cache_dir = root.join("cache");
fs::create_dir_all(&bin_dir).expect("bin dir");
fs::create_dir_all(&cache_dir).expect("cache dir");
let fakecmd = bin_dir.join("fakecmd");
fs::write(
&fakecmd,
r#"#!/bin/sh
if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then
printf '%s\n' 'Usage: fakecmd [OPTIONS]' '' 'Options:' ' --verbose verbose output'
exit 0
fi
exit 2
"#,
)
.expect("write fakecmd");
let mut perms = fs::metadata(&fakecmd).expect("metadata").permissions();
perms.set_mode(0o755);
fs::set_permissions(&fakecmd, perms).expect("chmod");
let output = Command::new(env!("CARGO_BIN_EXE_inshellah"))
.arg("complete")
.arg("--dir")
.arg(&cache_dir)
.arg("--timeout-ms")
.arg("1000")
.arg("sudo")
.arg(&fakecmd)
.arg("--")
.env("PATH", "")
.output()
.expect("run inshellah complete");
assert!(
output.status.success(),
"stderr = {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("stdout");
assert!(
stdout.contains(r#""value":"--verbose""#),
"stdout = {stdout}"
);
let _ = fs::remove_dir_all(root);
}

31
tests/self_completions.rs Normal file
View file

@ -0,0 +1,31 @@
use std::process::Command;
#[test]
fn inshellah_completions_include_all_subcommands() {
let output = Command::new(env!("CARGO_BIN_EXE_inshellah"))
.arg("completions")
.output()
.expect("run inshellah completions");
assert!(
output.status.success(),
"stderr = {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("stdout");
for subcommand in [
"index",
"manpage",
"manpage-dir",
"complete",
"query",
"dump",
"completions",
] {
let extern_name = format!("export extern \"inshellah {subcommand}\"");
assert!(
stdout.contains(&extern_name),
"missing {extern_name}; stdout = {stdout}"
);
}
}