Files
sausage/userland/brush/source/brush-clap-adapter/tests/impl_parse_args_tests.rs
T
2026-11-11 22:04:16 +00:00

500 lines
14 KiB
Rust

//! Tests of what `impl_parse_args!` implements, in each of its forms, as a
//! crate that uses it sees it.
#![cfg(test)]
#![allow(clippy::unwrap_used)]
use brush_core::CommandArg;
use brush_core::builtins::{
ContentOptions, ContentType, DeclarationRole, Invocation, ParseArgs, UsageError,
};
use brush_core::parser::{SourceSpan, ast};
use clap::Parser;
/// Parses `args`, as the builtin `T` invoked under `name`.
fn parse<T: ParseArgs>(
name: &str,
args: impl IntoIterator<Item = CommandArg>,
) -> Result<T, UsageError> {
T::parse(Invocation::new(name, args))
}
/// Returns words as arguments.
fn words<const N: usize>(words: [&str; N]) -> Vec<CommandArg> {
words.into_iter().map(CommandArg::from).collect()
}
/// Returns an assignment argument, as the shell passes one to a declaration
/// utility.
fn assignment(name: &str, value: &str) -> CommandArg {
CommandArg::Assignment(ast::Assignment {
name: ast::AssignmentName::VariableName(name.to_owned()),
value: ast::AssignmentValue::Scalar(ast::Word::new(value)),
append: false,
loc: SourceSpan::default(),
})
}
/// Describes arguments by kind and text, e.g. `["a=1" (assignment), "b"]`.
fn describe(args: &[CommandArg]) -> Vec<String> {
args.iter()
.map(|arg| match arg {
CommandArg::String(s) => s.clone(),
CommandArg::Assignment(a) => format!("{a} (assignment)"),
})
.collect()
}
fn content<T: ParseArgs>(name: &str, content_type: ContentType, colorized: bool) -> String {
T::content(name, content_type, &ContentOptions { colorized }).unwrap()
}
//
// impl_parse_args!(Type)
//
#[derive(Parser, Debug)]
#[command(about = "Does plain things")]
struct Plain {
#[arg(short = 'v')]
verbose: bool,
#[arg(short = 'n')]
count: Option<u32>,
args: Vec<String>,
}
brush_clap_adapter::impl_parse_args!(Plain);
#[test]
fn plain_form_is_not_a_declaration_utility() {
assert_eq!(Plain::DECLARATION_ROLE, DeclarationRole::None);
}
#[test]
fn plain_form_parses_every_argument_with_clap() {
let plain: Plain = parse("plain", words(["-v", "-n", "3", "a", "b"])).unwrap();
assert!(plain.verbose);
assert_eq!(plain.count, Some(3));
assert_eq!(plain.args, ["a", "b"]);
}
#[test]
fn plain_form_flattens_an_assignment_to_its_text() {
let plain: Plain = parse("plain", [assignment("a", "1"), "b".into()]).unwrap();
assert_eq!(plain.args, ["a=1", "b"]);
}
#[test]
fn plain_form_rejects_what_clap_rejects() {
let err = parse::<Plain>("plain", words(["-z"])).unwrap_err();
assert!(err.to_string().contains("'-z'"), "{err}");
let err = parse::<Plain>("plain", words(["-n", "x"])).unwrap_err();
assert!(err.to_string().contains("'x'"), "{err}");
}
#[test]
fn plain_form_takes_a_double_dash_as_clap_does() {
let plain: Plain = parse("plain", words(["--", "-v"])).unwrap();
assert!(!plain.verbose);
assert_eq!(plain.args, ["-v"]);
}
//
// impl_parse_args!(Type, plus_options)
//
#[derive(Parser, Debug)]
struct PlusOptions {
#[arg(short = 'x')]
x: bool,
#[arg(long = "+x")]
plus_x: bool,
#[arg(short = 'y')]
y: bool,
#[arg(long = "+y")]
plus_y: bool,
args: Vec<String>,
}
brush_clap_adapter::impl_parse_args!(PlusOptions, plus_options,);
#[test]
fn plus_options_form_is_not_a_declaration_utility() {
assert_eq!(PlusOptions::DECLARATION_ROLE, DeclarationRole::None);
}
#[test]
fn plus_options_form_takes_options_with_either_sign() {
let opts: PlusOptions = parse("p", words(["+x", "-y"])).unwrap();
assert!(opts.plus_x && opts.y && !opts.x && !opts.plus_y);
}
#[test]
fn plus_options_form_splits_combined_plus_options() {
let opts: PlusOptions = parse("p", words(["+xy"])).unwrap();
assert!(opts.plus_x && opts.plus_y && !opts.x && !opts.y);
}
#[test]
fn plus_options_form_leaves_words_after_double_dash_alone() {
let opts: PlusOptions = parse("p", words(["+x", "--", "+y", "-x"])).unwrap();
assert!(opts.plus_x && !opts.plus_y && !opts.x);
assert_eq!(opts.args, ["+y", "-x"]);
}
#[test]
fn plus_options_form_rejects_an_unknown_plus_option() {
assert!(parse::<PlusOptions>("p", words(["+z"])).is_err());
}
//
// impl_parse_args!(Type, keep_double_dash = field)
//
#[derive(Parser, Debug)]
struct KeepDoubleDash {
#[arg(short = 'n')]
n: bool,
args: Vec<String>,
}
brush_clap_adapter::impl_parse_args!(KeepDoubleDash, keep_double_dash = args);
#[test]
fn keep_double_dash_form_is_not_a_declaration_utility() {
assert_eq!(KeepDoubleDash::DECLARATION_ROLE, DeclarationRole::None);
}
#[test]
fn keep_double_dash_form_appends_the_double_dash_and_what_follows() {
let parsed: KeepDoubleDash = parse("k", words(["-n", "a", "--", "-n", "--", "b"])).unwrap();
assert!(parsed.n);
assert_eq!(parsed.args, ["a", "--", "-n", "--", "b"]);
}
#[test]
fn keep_double_dash_form_without_a_double_dash_parses_as_usual() {
let parsed: KeepDoubleDash = parse("k", words(["-n", "a"])).unwrap();
assert!(parsed.n);
assert_eq!(parsed.args, ["a"]);
}
#[test]
fn keep_double_dash_form_with_a_leading_double_dash_parses_no_options() {
let parsed: KeepDoubleDash = parse("k", words(["--", "-n"])).unwrap();
assert!(!parsed.n);
assert_eq!(parsed.args, ["--", "-n"]);
}
#[test]
fn keep_double_dash_form_rejects_a_bad_option_before_the_double_dash_only() {
assert!(parse::<KeepDoubleDash>("k", words(["-z", "--"])).is_err());
assert!(parse::<KeepDoubleDash>("k", words(["--", "-z"])).is_ok());
}
//
// impl_parse_args!(Type, declarations = field[, plus_options])
//
#[derive(Parser, Debug)]
struct DeclarationUtility {
#[arg(short = 'i')]
integer: bool,
#[arg(long = "+i")]
plus_integer: bool,
#[arg(skip)]
declarations: Vec<CommandArg>,
}
brush_clap_adapter::impl_parse_args!(DeclarationUtility, declarations = declarations);
#[derive(Parser, Debug)]
struct PlusDeclarationUtility {
#[arg(short = 'i')]
integer: bool,
#[arg(long = "+i")]
plus_integer: bool,
#[arg(skip)]
declarations: Vec<CommandArg>,
}
brush_clap_adapter::impl_parse_args!(
PlusDeclarationUtility,
declarations = declarations,
plus_options,
);
#[test]
fn declarations_forms_are_declaration_utilities() {
assert_eq!(
DeclarationUtility::DECLARATION_ROLE,
DeclarationRole::Utility
);
assert_eq!(
PlusDeclarationUtility::DECLARATION_ROLE,
DeclarationRole::Utility
);
}
#[test]
fn declarations_form_keeps_each_declaration_as_it_arrived() {
let parsed: DeclarationUtility = parse(
"d",
[
"-i".into(),
assignment("a", "1 2"),
"b".into(),
"-".into(),
"+".into(),
],
)
.unwrap();
assert!(parsed.integer);
assert_eq!(
describe(&parsed.declarations),
["a=1 2 (assignment)", "b", "-", "+"]
);
}
#[test]
fn declarations_form_takes_options_only_before_the_first_declaration() {
let parsed: DeclarationUtility = parse("d", words(["a", "-i", "b"])).unwrap();
assert!(!parsed.integer);
assert_eq!(describe(&parsed.declarations), ["a", "-i", "b"]);
}
#[test]
fn declarations_form_takes_what_follows_a_double_dash_as_declarations() {
let parsed: DeclarationUtility = parse("d", words(["-i", "--", "-i"])).unwrap();
assert!(parsed.integer);
assert_eq!(describe(&parsed.declarations), ["-i"]);
}
#[test]
fn declarations_form_without_plus_options_rejects_a_plus_option() {
assert!(parse::<DeclarationUtility>("d", words(["+i"])).is_err());
}
#[test]
fn declarations_form_rejects_an_unknown_option() {
assert!(parse::<DeclarationUtility>("d", words(["-z", "a"])).is_err());
}
#[test]
fn declarations_form_with_plus_options_takes_them() {
let parsed: PlusDeclarationUtility = parse("d", words(["+i", "a"])).unwrap();
assert!(parsed.plus_integer && !parsed.integer);
assert_eq!(describe(&parsed.declarations), ["a"]);
}
//
// impl_parse_args!(Type, parse = parser[, declaration_utility])
//
/// Parses by hand.
#[derive(Parser, Debug)]
#[command(about = "Parses by hand")]
struct Custom {
#[arg(skip)]
args: Vec<CommandArg>,
}
impl Custom {
fn parse_args(invocation: Invocation<'_>) -> Result<Self, UsageError> {
let name = invocation.name().to_owned();
let args = invocation.into_args();
if args
.iter()
.any(|arg| matches!(arg, CommandArg::String(s) if s == "bad"))
{
return Err(UsageError::new(format!("{name}: bad argument")));
}
Ok(Self { args })
}
}
brush_clap_adapter::impl_parse_args!(Custom, parse = Custom::parse_args);
/// Parses by hand, as a declaration utility.
#[derive(Parser, Debug)]
struct CustomDeclarations {
#[arg(skip)]
args: Vec<CommandArg>,
}
brush_clap_adapter::impl_parse_args!(
CustomDeclarations,
parse = |invocation: Invocation<'_>| Ok(Self {
args: invocation.into_args()
}),
declaration_utility,
);
#[test]
fn parse_form_role_follows_declaration_utility() {
assert_eq!(Custom::DECLARATION_ROLE, DeclarationRole::None);
assert_eq!(
CustomDeclarations::DECLARATION_ROLE,
DeclarationRole::Utility
);
}
#[test]
fn parse_form_hands_the_whole_invocation_to_the_parser() {
let parsed: Custom = parse("c", [CommandArg::from("-z"), assignment("a", "1")]).unwrap();
assert_eq!(describe(&parsed.args), ["-z", "a=1 (assignment)"]);
let parsed: CustomDeclarations = parse("c", words(["--help", "--"])).unwrap();
assert_eq!(describe(&parsed.args), ["--help", "--"]);
}
#[test]
fn parse_form_returns_the_parser_s_error() {
let err = parse::<Custom>("custom", words(["ok", "bad"])).unwrap_err();
assert_eq!(err, UsageError::new("custom: bad argument"));
}
#[test]
fn parse_form_takes_its_help_from_clap() {
assert_eq!(
content::<Custom>("custom", ContentType::ShortDescription, false),
"custom - Parses by hand\n"
);
}
//
// Help content, the same in every form.
//
/// Exercises the short usage line.
#[derive(Parser, Debug)]
#[command(about = "Shows its usage")]
struct Usage {
#[arg(short = 'a')]
all: bool,
#[arg(short = 'b', value_name = "B")]
bee: Option<String>,
#[arg(short = 'r', required = true)]
required: Option<String>,
#[arg(short = 's', hide = true)]
secret: bool,
#[arg(long = "long-only")]
long_only: bool,
#[arg(value_name = "FILE")]
file: String,
#[arg(hide = true)]
hidden: Option<String>,
#[arg(value_name = "REST")]
rest: Vec<String>,
}
brush_clap_adapter::impl_parse_args!(Usage);
// Has no options, operands, or description (which clap would take from a
// doc comment).
#[derive(Parser, Debug)]
struct Bare {}
brush_clap_adapter::impl_parse_args!(Bare);
#[test]
fn short_usage_lists_optional_then_required_short_options_then_operands() {
assert_eq!(
content::<Usage>("usage", ContentType::ShortUsage, false),
"usage: usage [-ab] -r FILE [REST]\n"
);
}
#[test]
fn short_usage_is_given_under_the_invoked_name() {
assert_eq!(
content::<Usage>("alias", ContentType::ShortUsage, false),
"alias: alias [-ab] -r FILE [REST]\n"
);
}
/// Takes operands only.
#[derive(Parser, Debug)]
struct OperandsOnly {
#[arg(value_name = "OPTIONAL")]
optional: Option<String>,
#[arg(value_name = "NEXT")]
next: Option<String>,
}
brush_clap_adapter::impl_parse_args!(OperandsOnly);
/// Takes a required option only.
#[derive(Parser, Debug)]
struct RequiredOnly {
#[arg(short = 'r', required = true)]
required: Option<String>,
#[arg(short = 'q', required = true)]
quiet: Option<String>,
}
brush_clap_adapter::impl_parse_args!(RequiredOnly);
#[test]
fn short_usage_of_operands_alone_brackets_each_optional_one() {
assert_eq!(
content::<OperandsOnly>("ops", ContentType::ShortUsage, false),
"ops: ops [OPTIONAL] [NEXT]\n"
);
}
#[test]
fn short_usage_of_required_options_alone_groups_them() {
assert_eq!(
content::<RequiredOnly>("req", ContentType::ShortUsage, false),
"req: req -rq\n"
);
}
#[test]
fn short_description_is_the_name_and_clap_s_about() {
assert_eq!(
content::<Usage>("alias", ContentType::ShortDescription, false),
"alias - Shows its usage\n"
);
assert_eq!(
content::<Bare>("bare", ContentType::ShortDescription, false),
"bare - \n"
);
}
#[test]
fn detailed_help_is_clap_s_rendering_under_the_invoked_name() {
let help = content::<Usage>("alias", ContentType::DetailedHelp, false);
assert!(help.starts_with("Shows its usage\n"), "{help}");
assert!(help.contains("Usage: alias "), "{help}");
assert!(help.contains("--long-only"), "{help}");
assert!(!help.contains("-s"), "a hidden option is listed: {help}");
assert!(!help.contains('\x1b'), "uncolored help has escapes: {help}");
}
#[test]
fn detailed_help_is_colored_on_request() {
use clap::builder::styling::{AnsiColor, Effects};
let colored = content::<Usage>("alias", ContentType::DetailedHelp, true);
let header = AnsiColor::Yellow.on_default() | Effects::BOLD | Effects::UNDERLINE;
let usage = AnsiColor::Green.on_default() | Effects::BOLD;
let literal = AnsiColor::Magenta.on_default() | Effects::BOLD;
for expected in [
format!("{header}Options:"),
format!("{usage}Usage:"),
format!("{literal}--long-only"),
] {
assert!(
colored.contains(&expected),
"{expected:?} not in {colored:?}"
);
}
}
#[test]
fn a_man_page_is_not_implemented() {
assert!(Usage::content("usage", ContentType::ManPage, &ContentOptions::default()).is_err());
}