riir
This commit is contained in:
parent
da4bc139eb
commit
7567f202e8
49 changed files with 8338 additions and 5483 deletions
78
tests/git_clone_fix.rs
Normal file
78
tests/git_clone_fix.rs
Normal 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
100
tests/manpage_cli.rs
Normal 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);
|
||||
}
|
||||
58
tests/nushell-completer.nu
Normal file
58
tests/nushell-completer.nu
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
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 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"
|
||||
|
||||
"{" | 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"
|
||||
|
||||
let git_push = do $completer [git push ""]
|
||||
assert-eq (values $git_push) [origin upstream] "git push first argument completes remotes"
|
||||
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"
|
||||
661
tests/ports.rs
Normal file
661
tests/ports.rs
Normal 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);
|
||||
}
|
||||
104
tests/runtime_complete.rs
Normal file
104
tests/runtime_complete.rs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::process::Command;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use inshellah::parsers::manpage::{ManpageResult, ManpageSubcommand};
|
||||
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);
|
||||
}
|
||||
31
tests/self_completions.rs
Normal file
31
tests/self_completions.rs
Normal 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue