915 lines
27 KiB
Rust
915 lines
27 KiB
Rust
//! Tests ported from ../inshellah/test/test_inshellah.ml.
|
|
//!
|
|
//! Covers the help parser, manpage parser, groff stripping, and nushell
|
|
//! generation. The single .nu store parser test (`test_nu_file_parsing`) is
|
|
//! not included — it requires porting store.ml first.
|
|
|
|
use inshellah::parsers::help::help_parser;
|
|
use inshellah::parsers::manpage::{
|
|
ManpageResult, OwnedParam, OwnedSwitch, extract_synopsis_command, parse_manpage_string,
|
|
strip_groff_escapes,
|
|
};
|
|
use inshellah::parsers::nushell::{generate_extern, generate_module};
|
|
use inshellah::store::{json_of_result, parse_nu_completions, result_from_json};
|
|
use inshellah::types::{HelpResult, Param, Switch};
|
|
|
|
fn parse(txt: &str) -> HelpResult<'_> {
|
|
match help_parser(txt) {
|
|
Ok((_, r)) => r,
|
|
Err(e) => panic!("parse_help failed: {e:?}"),
|
|
}
|
|
}
|
|
|
|
// --- Help parser tests ---
|
|
|
|
#[test]
|
|
fn gnu_basic() {
|
|
let r = parse(" -a, --all do not ignore entries starting with .\n");
|
|
assert_eq!(r.entries.len(), 1);
|
|
let e = &r.entries[0];
|
|
assert!(matches!(&e.switch, Switch::Both('a', l) if *l == "all"));
|
|
assert!(e.param.is_none());
|
|
assert!(!e.desc.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn gnu_eq_param() {
|
|
let r = parse(" --block-size=SIZE scale sizes by SIZE\n");
|
|
assert_eq!(r.entries.len(), 1);
|
|
let e = &r.entries[0];
|
|
assert!(matches!(&e.switch, Switch::Long(l) if *l == "block-size"));
|
|
assert!(matches!(&e.param, Some(Param::Mandatory(p)) if *p == "SIZE"));
|
|
}
|
|
|
|
#[test]
|
|
fn gnu_opt_param() {
|
|
let r = parse(" --color[=WHEN] color the output WHEN\n");
|
|
assert_eq!(r.entries.len(), 1);
|
|
let e = &r.entries[0];
|
|
assert!(matches!(&e.switch, Switch::Long(l) if *l == "color"));
|
|
assert!(matches!(&e.param, Some(Param::Optional(p)) if *p == "WHEN"));
|
|
}
|
|
|
|
#[test]
|
|
fn underscore_param() {
|
|
let r = parse(" --time-style=TIME_STYLE time/date format\n");
|
|
assert_eq!(r.entries.len(), 1);
|
|
let e = &r.entries[0];
|
|
assert!(matches!(&e.param, Some(Param::Mandatory(p)) if *p == "TIME_STYLE"));
|
|
}
|
|
|
|
#[test]
|
|
fn short_only() {
|
|
let r = parse(" -v verbose output\n");
|
|
assert_eq!(r.entries.len(), 1);
|
|
assert!(matches!(r.entries[0].switch, Switch::Short('v')));
|
|
}
|
|
|
|
#[test]
|
|
fn long_only() {
|
|
let r = parse(" --help display help\n");
|
|
assert_eq!(r.entries.len(), 1);
|
|
assert!(matches!(&r.entries[0].switch, Switch::Long(l) if *l == "help"));
|
|
}
|
|
|
|
#[test]
|
|
fn multiline_desc() {
|
|
let txt = " --block-size=SIZE with -l, scale sizes by SIZE when printing them;\n e.g., '--block-size=M'; see SIZE format below\n";
|
|
let r = parse(txt);
|
|
assert_eq!(r.entries.len(), 1);
|
|
let combined: String = r.entries[0].desc.join(" ");
|
|
assert!(combined.len() > 50, "desc was: {combined}");
|
|
}
|
|
|
|
#[test]
|
|
fn multiple_entries() {
|
|
let txt = " -a, --all do not ignore entries starting with .\n -A, --almost-all do not list implied . and ..\n --author with -l, print the author of each file\n";
|
|
let r = parse(txt);
|
|
assert_eq!(r.entries.len(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn clap_short_sections() {
|
|
let txt = "INPUT OPTIONS:\n -e, --regexp=PATTERN A pattern to search for.\n -f, --file=PATTERNFILE Search for patterns from the given file.\nSEARCH OPTIONS:\n -s, --case-sensitive Search case sensitively.\n";
|
|
let r = parse(txt);
|
|
assert_eq!(r.entries.len(), 3);
|
|
let e = &r.entries[0];
|
|
assert!(matches!(&e.switch, Switch::Both('e', l) if *l == "regexp"));
|
|
assert!(matches!(&e.param, Some(Param::Mandatory(p)) if *p == "PATTERN"));
|
|
}
|
|
|
|
#[test]
|
|
fn clap_long_style() {
|
|
let txt = " -H, --hidden\n Include hidden directories and files.\n\n --no-ignore\n Do not respect ignore files.\n";
|
|
let r = parse(txt);
|
|
assert_eq!(r.entries.len(), 2);
|
|
let e = &r.entries[0];
|
|
assert!(matches!(&e.switch, Switch::Both('H', l) if *l == "hidden"));
|
|
assert!(!e.desc.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn clap_long_angle_param() {
|
|
let txt = " --nonprintable-notation <notation>\n Set notation for non-printable characters.\n";
|
|
let r = parse(txt);
|
|
assert_eq!(r.entries.len(), 1);
|
|
let e = &r.entries[0];
|
|
assert!(matches!(&e.switch, Switch::Long(l) if *l == "nonprintable-notation"));
|
|
assert!(matches!(&e.param, Some(Param::Mandatory(p)) if *p == "notation"));
|
|
}
|
|
|
|
#[test]
|
|
fn space_upper_param() {
|
|
let r = parse(" -f, --foo FOO foo help\n");
|
|
assert_eq!(r.entries.len(), 1);
|
|
let e = &r.entries[0];
|
|
assert!(matches!(&e.switch, Switch::Both('f', l) if *l == "foo"));
|
|
assert!(matches!(&e.param, Some(Param::Mandatory(p)) if *p == "FOO"));
|
|
}
|
|
|
|
#[test]
|
|
fn go_cobra_flags() {
|
|
let txt = "Flags:\n -D, --debug Enable debug mode\n -H, --host string Daemon socket to connect to\n -v, --version Print version information\n";
|
|
let r = parse(txt);
|
|
assert_eq!(r.entries.len(), 3);
|
|
let host = &r.entries[1];
|
|
assert!(matches!(&host.switch, Switch::Both('H', l) if *l == "host"));
|
|
assert!(matches!(&host.param, Some(Param::Mandatory(p)) if *p == "string"));
|
|
}
|
|
|
|
#[test]
|
|
fn go_cobra_subcommands() {
|
|
let txt = "Common Commands:\n run Create and run a new container from an image\n exec Execute a command in a running container\n build Build an image from a Dockerfile\n";
|
|
let r = parse(txt);
|
|
assert!(
|
|
!r.subcommands.is_empty(),
|
|
"expected subcommands, got: {:?}",
|
|
r.subcommands.len()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn help_parser_ignores_value_enums_and_defaults() {
|
|
let txt = r#"Usage: tar [OPTION...] [FILE]...
|
|
|
|
Main operation mode:
|
|
-c, --create create a new archive
|
|
|
|
Archive format selection:
|
|
|
|
-H, --format=FORMAT create archive of the given format
|
|
|
|
FORMAT is one of the following:
|
|
gnu GNU tar 1.13.x format
|
|
oldgnu GNU format as per tar <= 1.12
|
|
pax POSIX 1003.1-2001 (pax) format
|
|
posix same as pax
|
|
ustar POSIX 1003.1-1988 (ustar) format
|
|
v7 old V7 tar format
|
|
|
|
*This* tar defaults to:
|
|
--format=gnu -f- -b20 --quoting-style=escape
|
|
--rmt-command=/nix/store/example/libexec/rmt
|
|
"#;
|
|
let r = parse(txt);
|
|
assert!(
|
|
r.subcommands.is_empty(),
|
|
"enum values became subcommands: {:?}",
|
|
r.subcommands.len()
|
|
);
|
|
assert!(
|
|
!r.entries
|
|
.iter()
|
|
.any(|e| matches!(&e.switch, Switch::Long(l) if *l == "rmt-command")),
|
|
"default lines should not become flags"
|
|
);
|
|
assert!(
|
|
r.entries
|
|
.iter()
|
|
.any(|e| matches!(&e.switch, Switch::Both('H', l) if *l == "format")),
|
|
"real option should still be parsed"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn busybox_tab() {
|
|
let r = parse("\t-1\tOne column output\n\t-a\tInclude names starting with .\n");
|
|
assert_eq!(r.entries.len(), 2);
|
|
assert!(matches!(r.entries[0].switch, Switch::Short('1')));
|
|
}
|
|
|
|
#[test]
|
|
fn no_debug_prints() {
|
|
// the old ocaml parser had print_endline at module load time; this test
|
|
// documents that no such side effects exist in the rust port.
|
|
let _ = parse(" -v verbose\n");
|
|
}
|
|
|
|
#[test]
|
|
fn slash_switch_separator() {
|
|
let r = parse(" --verbose / -v Increase verbosity\n");
|
|
assert_eq!(r.entries.len(), 1);
|
|
let e = &r.entries[0];
|
|
assert!(matches!(&e.switch, Switch::Both('v', l) if *l == "verbose"));
|
|
assert!(e.param.is_none());
|
|
let combined: String = e.desc.join(" ");
|
|
assert_eq!(combined.trim(), "Increase verbosity");
|
|
}
|
|
|
|
// --- Manpage parser tests ---
|
|
|
|
#[test]
|
|
fn manpage_tp_style() {
|
|
let groff = r#".SH OPTIONS
|
|
.TP
|
|
\fB\-a\fR, \fB\-\-all\fR
|
|
do not ignore entries starting with .
|
|
.TP
|
|
\fB\-A\fR, \fB\-\-almost\-all\fR
|
|
do not list implied . and ..
|
|
.TP
|
|
\fB\-\-block\-size\fR=\fISIZE\fR
|
|
with \fB\-l\fR, scale sizes by SIZE
|
|
.SH AUTHOR
|
|
Written by someone.
|
|
"#;
|
|
let r = parse_manpage_string(groff);
|
|
assert_eq!(r.entries.len(), 3, "entries: {:?}", r.entries);
|
|
assert!(matches!(&r.entries[0].switch, OwnedSwitch::Both('a', l) if l == "all"));
|
|
assert!(!r.entries[0].desc.is_empty());
|
|
assert!(matches!(&r.entries[2].switch, OwnedSwitch::Long(l) if l == "block-size"));
|
|
assert!(matches!(&r.entries[2].param, Some(OwnedParam::Mandatory(p)) if p == "SIZE"));
|
|
}
|
|
|
|
#[test]
|
|
fn manpage_ip_style() {
|
|
let groff = r#".SH OPTIONS
|
|
.IP "\fB\-k\fR, \fB\-\-insecure\fR"
|
|
Allow insecure connections.
|
|
.IP "\fB\-o\fR, \fB\-\-output\fR \fIfile\fR"
|
|
Write output to file.
|
|
.SH SEE ALSO
|
|
"#;
|
|
let r = parse_manpage_string(groff);
|
|
assert_eq!(r.entries.len(), 2, "entries: {:?}", r.entries);
|
|
assert!(matches!(&r.entries[0].switch, OwnedSwitch::Both('k', l) if l == "insecure"));
|
|
}
|
|
|
|
#[test]
|
|
fn manpage_groff_stripping() {
|
|
let s = strip_groff_escapes(r#"\fB\-\-color\fR[=\fIWHEN\fR]"#);
|
|
// font escapes removed
|
|
assert!(!(s.contains('f') && s.contains('B') && s.contains('\\')));
|
|
// dashes converted
|
|
assert!(s.contains('-'));
|
|
let s2 = strip_groff_escapes(r#"\(aqhello\(aq"#);
|
|
assert!(s2.contains('\''), "expected apostrophe in: {s2}");
|
|
}
|
|
|
|
#[test]
|
|
fn manpage_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);
|
|
}
|