This commit is contained in:
atagen 2026-05-19 23:32:51 +10:00
parent da4bc139eb
commit 0aa6ae9fbf
49 changed files with 10554 additions and 5482 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"
);
}

150
tests/manpage_cli.rs Normal file
View file

@ -0,0 +1,150 @@
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);
}
#[test]
fn manpage_command_falls_back_when_synopsis_starts_with_prose() {
let root = unique_temp_dir("inshellah-manpage-cli");
fs::create_dir_all(&root).expect("temp dir");
let manpage = root.join("ld.so.8");
fs::write(
&manpage,
r#".SH SYNOPSIS
The dynamic linker can be run either indirectly by running some
dynamically linked program or shared object
(in which case no command-line options
to the dynamic linker can be passed and, in the ELF case, the dynamic linker
which is stored in the
.B .interp
section of the program is executed) or directly by running:
.P
.I /lib/ld\-linux.so.*
[OPTIONS] [PROGRAM [ARGUMENTS]]
.SH OPTIONS
.TP
.BI \-\-argv0\~ string
Set argv[0] to the value string.
"#,
)
.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 \"ld.so\""),
"stdout = {stdout}"
);
assert!(
!stdout.contains("export extern \"The\""),
"stdout = {stdout}"
);
let _ = fs::remove_dir_all(root);
}

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

@ -0,0 +1,128 @@
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 systemctl_prefixed_units = do $completer [systemctl start g]
assert-eq ($systemctl_prefixed_units | get 0.value) "greetd.service" "systemctl unit completions accept typed prefixes"
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

915
tests/ports.rs Normal file
View file

@ -0,0 +1,915 @@
//! 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_getent_databases_from_description() {
let groff = r#".SH SYNOPSIS
.SY getent
.RI [ option \~.\|.\|.\&]
.I database
.IR key \~.\|.\|.
.YS
.SH DESCRIPTION
The
.I database
may be any of those supported by the GNU C Library, listed below:
.TP
.B passwd
When no
.I key
is provided, enumerate the passwd database.
.TP
.B services
When no
.I key
is provided, enumerate the services database.
.SH OPTIONS
.TP
.BI \-\-service\~ service
.TQ
.BI \-s\~ service
Override all databases with the specified service.
.TP
.BI \-\-service\~ database : service
.TQ
.BI \-s\~ database : service
Override only specified databases with the specified service.
.TP
.B \-\-usage
Print a short usage summary and exit.
"#;
let r = parse_manpage_string(groff);
let positional_names: Vec<&str> = r
.positionals
.iter()
.map(|(name, _)| name.as_str())
.collect();
assert_eq!(positional_names, vec!["database", "key"]);
let service = r
.entries
.iter()
.find(|e| matches!(&e.switch, OwnedSwitch::Both('s', name) if name == "service"))
.expect("expected --service(-s)");
assert!(matches!(
&service.param,
Some(OwnedParam::Mandatory(param)) if param == "service"
));
assert!(
!r.entries
.iter()
.any(|e| matches!(&e.switch, OwnedSwitch::Long(name) if name == "serviceservice" || name == "servicedatabase")),
"entries: {:?}",
r.entries
);
let subcommands: Vec<&str> = r.subcommands.iter().map(|sc| sc.name.as_str()).collect();
assert!(
subcommands.contains(&"passwd"),
"subcommands: {subcommands:?}"
);
assert!(
subcommands.contains(&"services"),
"subcommands: {subcommands:?}"
);
let nu = generate_extern("getent", &r);
assert!(nu.contains("database: string"), "nu = {nu}");
assert!(nu.contains("...key: string"), "nu = {nu}");
assert!(nu.contains("--service(-s): string"), "nu = {nu}");
assert!(!nu.contains("--servicedatabase"), "nu = {nu}");
assert!(nu.contains("export extern \"getent passwd\""), "nu = {nu}");
}
#[test]
fn manpage_b_macro_option_tag_with_embedded_quotes() {
let groff = r#".SH OPTIONS
.TP
.B "\-s ""\fIprogram\fR [\fIargument \fR...]\fB""\fR, \fB\-\-speller=""\fIprogram\fR [\fIargument \fR...]\fB"""
Use this command to perform spell checking and correcting.
"#;
let r = parse_manpage_string(groff);
assert!(
r.entries
.iter()
.any(|e| matches!(e.switch, OwnedSwitch::Short('s'))),
"entries: {:?}",
r.entries
);
}
#[test]
fn manpage_synopsis_b_macro_bracket_args_keep_spaces() {
let groff = r#".SH "SYNOPSIS"
.B "rtmon"
.RI "[ " OPTIONS " ] "
.BI "file " FILE
.BR "[ " all
.RI "| " OBJECTS
.RB "]"
.ti -8
.I OBJECTS
.B ":= [" link "]" "[" address "]" "[" route "]"
.SH OPTIONS
"#;
let r = parse_manpage_string(groff);
let positional_names: Vec<&str> = r
.positionals
.iter()
.map(|(name, _)| name.as_str())
.collect();
assert!(
!positional_names.contains(&"ptions")
&& positional_names.contains(&"link")
&& positional_names.contains(&"address"),
"positionals: {positional_names:?}"
);
}
#[test]
fn bracketed_angle_positionals_keep_inner_ellipsis() {
let groff = r#".SH SYNOPSIS
.B bzip2
.RB [ " \-cdfkqstvzVL123456789 " ]
[
.I "filenames \&..."
]
.SH OPTIONS
"#;
let r = parse_manpage_string(groff);
assert!(
r.positionals
.iter()
.any(|(name, positional)| name == "filenames" && positional.variadic),
"positionals: {:?}",
r.positionals
);
}
#[test]
fn nested_optional_positionals_keep_last_valid_inner_name() {
let groff = r#".SH SYNOPSIS
\fBfc-cat\fR [ \fB-rvVh\fR ]
[ \fB [ \fIfonts-cache-%version%-files\fB ] [ \fIdirs\fB ] \fR\fI...\fR ]
.SH OPTIONS
"#;
let r = parse_manpage_string(groff);
assert!(
r.positionals
.iter()
.any(|(name, positional)| name == "dirs" && positional.optional && positional.variadic),
"positionals: {:?}",
r.positionals
);
}
#[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"));
}
#[test]
fn synopsis_skips_prose_before_invocation() {
let groff = r#".SH SYNOPSIS
The dynamic linker can be run either indirectly by running some
dynamically linked program or shared object
(in which case no command-line options
to the dynamic linker can be passed and, in the ELF case, the dynamic linker
which is stored in the
.B .interp
section of the program is executed) or directly by running:
.P
.I /lib/ld\-linux.so.*
[OPTIONS] [PROGRAM [ARGUMENTS]]
.SH DESCRIPTION
"#;
let cmd = extract_synopsis_command(groff);
assert_eq!(cmd.as_deref(), None);
}
#[test]
fn synopsis_skips_labels_before_invocation() {
let groff = r#".SH "SYNOPSIS"
.sp
Set up a loop device:
.sp
\fBlosetup\fP [options] \fB\-f\fP|\fIloopdev file\fP
.sp
Get info:
.RS 4
\fBlosetup\fP \fIloopdev\fP
.RE
.SH "DESCRIPTION"
"#;
let cmd = extract_synopsis_command(groff);
assert_eq!(cmd.as_deref(), Some("losetup"));
}
#[test]
fn synopsis_b_macro_preserves_command_spaces() {
let groff = r#".SH "SYNOPSIS"
.sp
.B ip link
.RI " { " COMMAND " | "
.BR help " }"
.SH "DESCRIPTION"
"#;
let cmd = extract_synopsis_command(groff);
assert_eq!(cmd.as_deref(), Some("ip link"));
}
#[test]
fn synopsis_br_macro_preserves_quoted_command_spaces() {
let groff = r#".SH "SYNOPSIS"
.sp
.BR "ip monitor" " [ " all " |"
.IR OBJECT-LIST " ]"
.SH "DESCRIPTION"
"#;
let cmd = extract_synopsis_command(groff);
assert_eq!(cmd.as_deref(), Some("ip monitor"));
}
#[test]
fn synopsis_long_b_macro_is_not_prose() {
let groff = r#".SH SYNOPSIS
.ad l
.in +8
.ti -8
.B tipc peer remove address
.IR ADDRESS
.SH OPTIONS
"#;
let cmd = extract_synopsis_command(groff);
assert_eq!(cmd.as_deref(), Some("tipc peer remove address"));
}
#[test]
fn synopsis_ss_heading_is_accepted() {
let groff = r#".SH Name
.LP
\f(CRnix-env --set\fR - set profile to contain a specified derivation
.SS
Synopsis
.LP
\f(CRnix-env\fR \f(CR--set\fR \fIdrvname\fR
.SS
Description
"#;
let cmd = extract_synopsis_command(groff);
assert_eq!(cmd.as_deref(), Some("nix-env"));
}
// --- 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);
}

500
tests/runtime_complete.rs Normal file
View file

@ -0,0 +1,500 @@
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::new(),
subcommands: vec![
ManpageSubcommand {
name: "load".to_string(),
desc: "load something".to_string(),
},
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_returns_flags_only_after_hyphen() {
let root = unique_temp_dir("inshellah-flag-prefix-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("verbose".to_string()),
param: None,
desc: "verbose output".to_string(),
}],
subcommands: Vec::new(),
positionals: Vec::new(),
description: String::new(),
};
write_result(&cache_dir, "demo", "help", &result).expect("cache");
let argument_output = Command::new(env!("CARGO_BIN_EXE_inshellah"))
.arg("complete")
.arg("--dir")
.arg(&cache_dir)
.arg("demo")
.arg("")
.output()
.expect("run inshellah complete");
assert!(
argument_output.status.success(),
"stderr = {}",
String::from_utf8_lossy(&argument_output.stderr)
);
let argument_stdout = String::from_utf8(argument_output.stdout).expect("stdout");
assert_eq!(argument_stdout.trim(), "null", "stdout = {argument_stdout}");
let flag_output = Command::new(env!("CARGO_BIN_EXE_inshellah"))
.arg("complete")
.arg("--dir")
.arg(&cache_dir)
.arg("demo")
.arg("--")
.output()
.expect("run inshellah complete");
assert!(
flag_output.status.success(),
"stderr = {}",
String::from_utf8_lossy(&flag_output.stderr)
);
let flag_stdout = String::from_utf8(flag_output.stdout).expect("stdout");
assert!(
flag_stdout.contains(r#""value":"--verbose""#),
"stdout = {flag_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);
}
#[test]
fn complete_adb_dynamic_values_use_live_devices_and_packages() {
let root = unique_temp_dir("inshellah-adb-dynamic-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 adb = bin_dir.join("adb");
fs::write(
&adb,
r#"#!/bin/sh
selector=""
case "$1" in
-s|--serial|--one-device)
selector="$2"
shift 2
;;
-t|--transport-id)
selector="transport:$2"
shift 2
;;
--serial=*)
selector="${1#--serial=}"
shift
;;
--one-device=*)
selector="${1#--one-device=}"
shift
;;
--transport-id=*)
selector="transport:${1#--transport-id=}"
shift
;;
esac
if [ "$1" = "devices" ] && [ "$2" = "-l" ]; then
printf '%s\n' 'List of devices attached'
printf '%s\n' 'emulator-5554 device product:sdk_gphone_x86 model:Pixel_8 device:emu transport_id:1'
printf '%s\n' 'R58M123456 device product:oriole model:Pixel_6 device:oriole transport_id:2'
printf '%s\n' 'offline-1 offline transport_id:3'
exit 0
fi
if [ "$1" = "shell" ] && [ "$2" = "pm" ] && [ "$3" = "list" ] && [ "$4" = "packages" ]; then
case "$selector" in
emulator-5554)
printf '%s\n' 'package:com.example.emu'
printf '%s\n' 'package:org.example.shared'
;;
transport:2)
printf '%s\n' 'package:com.example.transport'
printf '%s\n' 'package:org.example.transport'
;;
*)
printf '%s\n' 'package:com.default.app'
printf '%s\n' 'package:/data/app/org.default.path/base.apk=org.default.path'
;;
esac
exit 0
fi
exit 2
"#,
)
.expect("write adb");
let mut perms = fs::metadata(&adb).expect("metadata").permissions();
perms.set_mode(0o755);
fs::set_permissions(&adb, perms).expect("chmod");
let run_complete = |args: &[&str]| -> String {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_inshellah"));
cmd.arg("complete")
.arg("--dir")
.arg(&cache_dir)
.arg("--timeout-ms")
.arg("1000");
for arg in args {
cmd.arg(arg);
}
let output = cmd
.env("PATH", &bin_dir)
.output()
.expect("run inshellah complete");
assert!(
output.status.success(),
"stderr = {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).expect("stdout")
};
let stdout = run_complete(&["adb", "-s", ""]);
assert!(
stdout.contains(r#""value":"emulator-5554""#),
"stdout = {stdout}"
);
assert!(
stdout.contains(r#""description":"device sdk gphone x86 Pixel 8""#),
"stdout = {stdout}"
);
assert!(
stdout.contains(r#""value":"R58M123456""#),
"stdout = {stdout}"
);
assert!(
stdout.contains(r#""value":"offline-1""#),
"stdout = {stdout}"
);
let prefixed_stdout = run_complete(&["adb", "--serial=R5"]);
assert!(
prefixed_stdout.contains(r#""value":"--serial=R58M123456""#),
"stdout = {prefixed_stdout}"
);
assert!(
!prefixed_stdout.contains(r#""value":"--serial=emulator-5554""#),
"stdout = {prefixed_stdout}"
);
let one_device_stdout = run_complete(&["adb", "--one-device", ""]);
assert!(
one_device_stdout.contains(r#""value":"emulator-5554""#),
"stdout = {one_device_stdout}"
);
let transport_stdout = run_complete(&["adb", "-t", ""]);
assert!(
transport_stdout.contains(r#""value":"1""#),
"stdout = {transport_stdout}"
);
assert!(
transport_stdout.contains(r#""description":"emulator-5554 device sdk gphone x86 Pixel 8""#),
"stdout = {transport_stdout}"
);
assert!(
transport_stdout.contains(r#""value":"2""#),
"stdout = {transport_stdout}"
);
let transport_prefixed_stdout = run_complete(&["adb", "--transport-id=2"]);
assert!(
transport_prefixed_stdout.contains(r#""value":"--transport-id=2""#),
"stdout = {transport_prefixed_stdout}"
);
assert!(
!transport_prefixed_stdout.contains(r#""value":"--transport-id=1""#),
"stdout = {transport_prefixed_stdout}"
);
let uninstall_stdout = run_complete(&["adb", "uninstall", "org"]);
assert!(
uninstall_stdout.contains(r#""value":"org.default.path""#),
"stdout = {uninstall_stdout}"
);
assert!(
!uninstall_stdout.contains(r#""value":"com.default.app""#),
"stdout = {uninstall_stdout}"
);
let clear_stdout = run_complete(&["adb", "-s", "emulator-5554", "shell", "pm", "clear", ""]);
assert!(
clear_stdout.contains(r#""value":"com.example.emu""#),
"stdout = {clear_stdout}"
);
assert!(
!clear_stdout.contains(r#""value":"com.example.transport""#),
"stdout = {clear_stdout}"
);
let force_stop_stdout = run_complete(&[
"adb",
"-t",
"2",
"shell",
"am",
"force-stop",
"--user",
"0",
"com.",
]);
assert!(
force_stop_stdout.contains(r#""value":"com.example.transport""#),
"stdout = {force_stop_stdout}"
);
assert!(
!force_stop_stdout.contains(r#""value":"com.example.emu""#),
"stdout = {force_stop_stdout}"
);
let flag_value_stdout = run_complete(&["adb", "shell", "pm", "enable", "--user", ""]);
assert_eq!(
flag_value_stdout.trim(),
"null",
"stdout = {flag_value_stdout}"
);
let shell_flag_stdout = run_complete(&["adb", "shell", "-s", ""]);
assert_eq!(
shell_flag_stdout.trim(),
"null",
"stdout = {shell_flag_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}"
);
}
}