diff options
author | John Högberg <john@erlang.org> | 2023-04-20 12:28:15 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-04-20 12:28:15 +0200 |
commit | 77430f2040b63648b8d2ffd9bdff81e950c51a4f (patch) | |
tree | a991b5864d3f551b876fc077bc45b8be1f8810cd /lib/stdlib/test | |
parent | aaf77a2d0a3b68935e9ee03dada4c671aa19443e (diff) | |
parent | 3b21b0ce49e6db62002c8d6a2948bccb48541e5f (diff) | |
download | erlang-77430f2040b63648b8d2ffd9bdff81e950c51a4f.tar.gz |
Merge pull request #6852 from max-au/max-au/argparse
[argparse] Command line parser for Erlang
OTP-18558
Diffstat (limited to 'lib/stdlib/test')
-rw-r--r-- | lib/stdlib/test/Makefile | 1 | ||||
-rw-r--r-- | lib/stdlib/test/argparse_SUITE.erl | 1063 |
2 files changed, 1064 insertions, 0 deletions
diff --git a/lib/stdlib/test/Makefile b/lib/stdlib/test/Makefile index 5d4ffcf86e..2597157004 100644 --- a/lib/stdlib/test/Makefile +++ b/lib/stdlib/test/Makefile @@ -7,6 +7,7 @@ include $(ERL_TOP)/make/$(TARGET)/otp.mk MODULES= \ array_SUITE \ + argparse_SUITE \ base64_SUITE \ base64_property_test_SUITE \ beam_lib_SUITE \ diff --git a/lib/stdlib/test/argparse_SUITE.erl b/lib/stdlib/test/argparse_SUITE.erl new file mode 100644 index 0000000000..fb7eaecda1 --- /dev/null +++ b/lib/stdlib/test/argparse_SUITE.erl @@ -0,0 +1,1063 @@ +%% +%% +%% Copyright Maxim Fedorov +%% +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. + +-module(argparse_SUITE). +-author("maximfca@gmail.com"). + +-export([suite/0, all/0, groups/0]). + +-export([ + readme/0, readme/1, + basic/0, basic/1, + long_form_eq/0, long_form_eq/1, + built_in_types/0, built_in_types/1, + type_validators/0, type_validators/1, + invalid_arguments/0, invalid_arguments/1, + complex_command/0, complex_command/1, + unicode/0, unicode/1, + parser_error/0, parser_error/1, + nargs/0, nargs/1, + argparse/0, argparse/1, + negative/0, negative/1, + nodigits/0, nodigits/1, + pos_mixed_with_opt/0, pos_mixed_with_opt/1, + default_for_not_required/0, default_for_not_required/1, + global_default/0, global_default/1, + subcommand/0, subcommand/1, + very_short/0, very_short/1, + multi_short/0, multi_short/1, + proxy_arguments/0, proxy_arguments/1, + + usage/0, usage/1, + usage_required_args/0, usage_required_args/1, + usage_template/0, usage_template/1, + parser_error_usage/0, parser_error_usage/1, + command_usage/0, command_usage/1, + usage_width/0, usage_width/1, + + validator_exception/0, validator_exception/1, + validator_exception_format/0, validator_exception_format/1, + + run_handle/0, run_handle/1 +]). + +-include_lib("stdlib/include/assert.hrl"). + +suite() -> + [{timetrap, {seconds, 30}}]. + +groups() -> + [ + {parser, [parallel], [ + readme, basic, long_form_eq, built_in_types, type_validators, + invalid_arguments, complex_command, unicode, parser_error, + nargs, argparse, negative, nodigits, pos_mixed_with_opt, + default_for_not_required, global_default, subcommand, + very_short, multi_short, proxy_arguments + ]}, + {usage, [parallel], [ + usage, usage_required_args, usage_template, + parser_error_usage, command_usage, usage_width + ]}, + {validator, [parallel], [ + validator_exception, validator_exception_format + ]}, + {run, [parallel], [ + run_handle + ]} + ]. + +all() -> + [{group, parser}, {group, validator}, {group, usage}]. + +%%-------------------------------------------------------------------- +%% Helpers + +prog() -> + {ok, [[ProgStr]]} = init:get_argument(progname), ProgStr. + +parser_error(CmdLine, CmdMap) -> + {error, Reason} = parse(CmdLine, CmdMap), + unicode:characters_to_list(argparse:format_error(Reason)). + +parse_opts(Args, Opts) -> + argparse:parse(string:lexemes(Args, " "), #{arguments => Opts}). + +parse(Args, Command) -> + argparse:parse(string:lexemes(Args, " "), Command). + +parse_cmd(Args, Command) -> + argparse:parse(string:lexemes(Args, " "), #{commands => Command}). + +%% ubiquitous command, containing sub-commands, and all possible option types +%% with all nargs. Not all combinations though. +ubiq_cmd() -> + #{ + arguments => [ + #{name => r, short => $r, type => boolean, help => "recursive"}, + #{name => f, short => $f, type => boolean, long => "-force", help => "force"}, + #{name => v, short => $v, type => boolean, action => count, help => "verbosity level"}, + #{name => interval, short => $i, type => {integer, [{min, 1}]}, help => "interval set"}, + #{name => weird, long => "-req", help => "required optional, right?"}, + #{name => float, long => "-float", type => float, default => 3.14, help => "floating-point long form argument"} + ], + commands => #{ + "start" => #{help => "verifies configuration and starts server", + arguments => [ + #{name => server, help => "server to start"}, + #{name => shard, short => $s, type => integer, nargs => nonempty_list, help => "initial shards"}, + #{name => part, short => $p, type => integer, nargs => list, help => hidden}, + #{name => z, short => $z, type => {integer, [{min, 1}, {max, 10}]}, help => "between"}, + #{name => l, short => $l, type => {integer, [{max, 10}]}, nargs => 'maybe', help => "maybe lower"}, + #{name => more, short => $m, type => {integer, [{max, 10}]}, help => "less than 10"}, + #{name => optpos, required => false, type => {integer, []}, help => "optional positional"}, + #{name => bin, short => $b, type => {binary, <<"m">>}, help => "binary with re"}, + #{name => g, short => $g, type => {binary, <<"m">>, []}, help => "binary with re"}, + #{name => t, short => $t, type => {string, "m"}, help => "string with re"}, + #{name => e, long => "--maybe-req", required => true, type => integer, nargs => 'maybe', help => "maybe required int"}, + #{name => y, required => true, long => "-yyy", short => $y, type => {string, "m", []}, help => "string with re"}, + #{name => u, short => $u, type => {string, ["1", "2"]}, help => "string choices"}, + #{name => choice, short => $c, type => {integer, [1,2,3]}, help => "tough choice"}, + #{name => fc, short => $q, type => {float, [2.1,1.2]}, help => "floating choice"}, + #{name => ac, short => $w, type => {atom, [one, two]}, help => "atom choice"}, + #{name => au, long => "-unsafe", type => {atom, unsafe}, help => "unsafe atom"}, + #{name => as, long => "-safe", type => atom, help => <<"safe atom">>}, + #{name => name, required => false, nargs => list, help => hidden}, + #{name => long, long => "foobar", required => false, help => [<<"foobaring option">>]} + ], commands => #{ + "crawler" => #{arguments => [ + #{name => extra, long => "--extra", help => "extra option very deep"} + ], + help => "controls crawler behaviour"}, + "doze" => #{help => "dozes a bit"}} + }, + "stop" => #{help => <<"stops running server">>, arguments => [] + }, + "status" => #{help => "prints server status", arguments => [], + commands => #{ + "crawler" => #{ + arguments => [#{name => extra, long => "--extra", help => "extra option very deep"}], + help => "crawler status"}} + }, + "restart" => #{help => hidden, arguments => [ + #{name => server, help => "server to restart"}, + #{name => duo, short => $d, long => "-duo", help => "dual option"} + ]} + } + }. + +%%-------------------------------------------------------------------- +%% Parser Test Cases + +readme() -> + [{doc, "Test cases covered in the README"}]. + +readme(Config) when is_list(Config) -> + Prog = prog(), + Rm = #{ + arguments => [ + #{name => dir}, + #{name => force, short => $f, type => boolean, default => false}, + #{name => recursive, short => $r, type => boolean} + ] + }, + ?assertEqual({ok, #{dir => "dir", force => true, recursive => true}, [Prog], Rm}, + argparse:parse(["-rf", "dir"], Rm)), + %% override progname + ?assertEqual("Usage:\n readme\n", + unicode:characters_to_list(argparse:help(#{}, #{progname => "readme"}))), + ?assertEqual("Usage:\n readme\n", + unicode:characters_to_list(argparse:help(#{}, #{progname => readme}))), + ?assertEqual("Usage:\n readme\n", + unicode:characters_to_list(argparse:help(#{}, #{progname => <<"readme">>}))), + %% test that command has priority over just a positional argument: + %% - parsing "opt sub" means "find positional argument "pos", then enter subcommand + %% - parsing "sub opt" means "enter sub-command, and then find positional argument" + Cmd = #{ + commands => #{"sub" => #{}}, + arguments => [#{name => pos}] + }, + ?assertEqual(parse("opt sub", Cmd), parse("sub opt", Cmd)). + +basic() -> + [{doc, "Basic cases"}]. + +basic(Config) when is_list(Config) -> + Prog = prog(), + %% empty command, with full options path + ?assertMatch({ok, #{}, [Prog, "cmd"], #{}}, + argparse:parse(["cmd"], #{commands => #{"cmd" => #{}}})), + %% sub-command, with no path, but user-supplied argument + ?assertEqual({ok, #{}, [Prog, "cmd", "sub"], #{attr => pos}}, + argparse:parse(["cmd", "sub"], #{commands => #{"cmd" => #{commands => #{"sub" => #{attr => pos}}}}})), + %% command with positional argument + PosCmd = #{arguments => [#{name => pos}]}, + ?assertEqual({ok, #{pos => "arg"}, [Prog, "cmd"], PosCmd}, + argparse:parse(["cmd", "arg"], #{commands => #{"cmd" => PosCmd}})), + %% command with optional argument + OptCmd = #{arguments => [#{name => force, short => $f, type => boolean}]}, + ?assertEqual({ok, #{force => true}, [Prog, "rm"], OptCmd}, + parse(["rm -f"], #{commands => #{"rm" => OptCmd}}), "rm -f"), + %% command with optional and positional argument + PosOptCmd = #{arguments => [#{name => force, short => $f, type => boolean}, #{name => dir}]}, + ?assertEqual({ok, #{force => true, dir => "dir"}, [Prog, "rm"], PosOptCmd}, + parse(["rm -f dir"], #{commands => #{"rm" => PosOptCmd}}), "rm -f dir"), + %% no command, just argument list + KernelCmd = #{arguments => [#{name => kernel, long => "kernel", type => atom, nargs => 2}]}, + ?assertEqual({ok, #{kernel => [port, dist]}, [Prog], KernelCmd}, + parse(["-kernel port dist"], KernelCmd)), + %% same but positional + ArgListCmd = #{arguments => [#{name => arg, nargs => 2, type => boolean}]}, + ?assertEqual({ok, #{arg => [true, false]}, [Prog], ArgListCmd}, + parse(["true false"], ArgListCmd)). + +long_form_eq() -> + [{doc, "Tests that long form supports --arg=value"}]. + +long_form_eq(Config) when is_list(Config) -> + Prog = prog(), + %% cmd --arg=value + PosOptCmd = #{arguments => [#{name => arg, long => "-arg"}]}, + ?assertEqual({ok, #{arg => "value"}, [Prog, "cmd"], PosOptCmd}, + parse(["cmd --arg=value"], #{commands => #{"cmd" => PosOptCmd}})), + %% --integer=10 + ?assertMatch({ok, #{int := 10}, _, _}, + parse(["--int=10"], #{arguments => [#{name => int, type => integer, long => "-int"}]})). + +built_in_types() -> + [{doc, "Tests all built-in types supplied as a single argument"}]. + +% built-in types testing +built_in_types(Config) when is_list(Config) -> + Prog = [prog()], + Bool = #{arguments => [#{name => meta, type => boolean, short => $b, long => "-boolean"}]}, + ?assertEqual({ok, #{}, Prog, Bool}, parse([""], Bool)), + ?assertEqual({ok, #{meta => true}, Prog, Bool}, parse(["-b"], Bool)), + ?assertEqual({ok, #{meta => true}, Prog, Bool}, parse(["--boolean"], Bool)), + ?assertEqual({ok, #{meta => false}, Prog, Bool}, parse(["--boolean false"], Bool)), + %% integer tests + Int = #{arguments => [#{name => int, type => integer, short => $i, long => "-int"}]}, + ?assertEqual({ok, #{int => 1}, Prog, Int}, parse([" -i 1"], Int)), + ?assertEqual({ok, #{int => 1}, Prog, Int}, parse(["--int 1"], Int)), + ?assertEqual({ok, #{int => -1}, Prog, Int}, parse(["-i -1"], Int)), + %% floating point + Float = #{arguments => [#{name => f, type => float, short => $f}]}, + ?assertEqual({ok, #{f => 44.44}, Prog, Float}, parse(["-f 44.44"], Float)), + %% atoms, existing + Atom = #{arguments => [#{name => atom, type => atom, short => $a, long => "-atom"}]}, + ?assertEqual({ok, #{atom => atom}, Prog, Atom}, parse(["-a atom"], Atom)), + ?assertEqual({ok, #{atom => atom}, Prog, Atom}, parse(["--atom atom"], Atom)). + +type_validators() -> + [{doc, "Test that parser return expected conversions for valid arguments"}]. + +type_validators(Config) when is_list(Config) -> + %% successful string regexes + ?assertMatch({ok, #{str := "me"}, _, _}, + parse_opts("me", [#{name => str, type => {string, "m."}}])), + ?assertMatch({ok, #{str := "me"}, _, _}, + parse_opts("me", [#{name => str, type => {string, "m.", []}}])), + ?assertMatch({ok, #{"str" := "me"}, _, _}, + parse_opts("me", [#{name => "str", type => {string, "m.", [{capture, none}]}}])), + %% and binary too... + ?assertMatch({ok, #{bin := <<"me">>}, _, _}, + parse_opts("me", [#{name => bin, type => {binary, <<"m.">>}}])), + ?assertMatch({ok, #{<<"bin">> := <<"me">>}, _, _}, + parse_opts("me", [#{name => <<"bin">>, type => {binary, <<"m.">>, []}}])), + ?assertMatch({ok, #{bin := <<"me">>}, _, _}, + parse_opts("me", [#{name => bin, type => {binary, <<"m.">>, [{capture, none}]}}])), + %% successful integer with range validators + ?assertMatch({ok, #{int := 5}, _, _}, + parse_opts("5", [#{name => int, type => {integer, [{min, 0}, {max, 10}]}}])), + ?assertMatch({ok, #{bin := <<"5">>}, _, _}, + parse_opts("5", [#{name => bin, type => binary}])), + ?assertMatch({ok, #{str := "011"}, _, _}, + parse_opts("11", [#{name => str, type => {custom, fun(S) -> [$0|S] end}}])), + %% choices: valid + ?assertMatch({ok, #{bin := <<"K">>}, _, _}, + parse_opts("K", [#{name => bin, type => {binary, [<<"M">>, <<"K">>]}}])), + ?assertMatch({ok, #{str := "K"}, _, _}, + parse_opts("K", [#{name => str, type => {string, ["K", "N"]}}])), + ?assertMatch({ok, #{atom := one}, _, _}, + parse_opts("one", [#{name => atom, type => {atom, [one, two]}}])), + ?assertMatch({ok, #{int := 12}, _, _}, + parse_opts("12", [#{name => int, type => {integer, [10, 12]}}])), + ?assertMatch({ok, #{float := 1.3}, _, _}, + parse_opts("1.3", [#{name => float, type => {float, [1.3, 1.4]}}])), + %% test for unsafe atom + %% ensure the atom does not exist + ?assertException(error, badarg, list_to_existing_atom("$can_never_be")), + {ok, ArgMap, _, _} = parse_opts("$can_never_be", [#{name => atom, type => {atom, unsafe}}]), + argparse:validate(#{arguments => [#{name => atom, type => {atom, unsafe}}]}), + %% now that atom exists, because argparse created it (in an unsafe way!) + ?assertEqual(list_to_existing_atom("$can_never_be"), maps:get(atom, ArgMap)), + %% test successful user-defined conversion + ?assertMatch({ok, #{user := "VER"}, _, _}, + parse_opts("REV", [#{name => user, type => {custom, fun (Str) -> lists:reverse(Str) end}}])). + +invalid_arguments() -> + [{doc, "Test that parser return errors for invalid arguments"}]. + +invalid_arguments(Config) when is_list(Config) -> + %% {float, [{min, float()} | {max, float()}]} | + Prog = [prog()], + MinFloat = #{name => float, type => {float, [{min, 1.0}]}}, + ?assertEqual({error, {Prog, MinFloat, "0.0", <<"is less than accepted minimum">>}}, + parse_opts("0.0", [MinFloat])), + MaxFloat = #{name => float, type => {float, [{max, 1.0}]}}, + ?assertEqual({error, {Prog, MaxFloat, "2.0", <<"is greater than accepted maximum">>}}, + parse_opts("2.0", [MaxFloat])), + %% {int, [{min, integer()} | {max, integer()}]} | + MinInt = #{name => int, type => {integer, [{min, 20}]}}, + ?assertEqual({error, {Prog, MinInt, "10", <<"is less than accepted minimum">>}}, + parse_opts("10", [MinInt])), + MaxInt = #{name => int, type => {integer, [{max, -10}]}}, + ?assertEqual({error, {Prog, MaxInt, "-5", <<"is greater than accepted maximum">>}}, + parse_opts("-5", [MaxInt])), + %% string: regex & regex with options + %% {string, string()} | {string, string(), []} + StrRegex = #{name => str, type => {string, "me.me"}}, + ?assertEqual({error, {Prog, StrRegex, "me", <<"does not match">>}}, + parse_opts("me", [StrRegex])), + StrRegexOpt = #{name => str, type => {string, "me.me", []}}, + ?assertEqual({error, {Prog, StrRegexOpt, "me", <<"does not match">>}}, + parse_opts("me", [StrRegexOpt])), + %% {binary, {re, binary()} | {re, binary(), []} + BinRegex = #{name => bin, type => {binary, <<"me.me">>}}, + ?assertEqual({error, {Prog, BinRegex, "me", <<"does not match">>}}, + parse_opts("me", [BinRegex])), + BinRegexOpt = #{name => bin, type => {binary, <<"me.me">>, []}}, + ?assertEqual({error, {Prog, BinRegexOpt, "me", <<"does not match">>}}, + parse_opts("me", [BinRegexOpt])), + %% invalid integer (comma , is not parsed) + ?assertEqual({error, {Prog, MinInt, "1,", <<"is not an integer">>}}, + parse_opts(["1,"], [MinInt])), + %% test invalid choices + BinChoices = #{name => bin, type => {binary, [<<"M">>, <<"N">>]}}, + ?assertEqual({error, {Prog, BinChoices, "K", <<"is not one of the choices">>}}, + parse_opts("K", [BinChoices])), + StrChoices = #{name => str, type => {string, ["M", "N"]}}, + ?assertEqual({error, {Prog, StrChoices, "K", <<"is not one of the choices">>}}, + parse_opts("K", [StrChoices])), + AtomChoices = #{name => atom, type => {atom, [one, two]}}, + ?assertEqual({error, {Prog, AtomChoices, "K", <<"is not one of the choices">>}}, + parse_opts("K", [AtomChoices])), + IntChoices = #{name => int, type => {integer, [10, 11]}}, + ?assertEqual({error, {Prog, IntChoices, "12", <<"is not one of the choices">>}}, + parse_opts("12", [IntChoices])), + FloatChoices = #{name => float, type => {float, [1.2, 1.4]}}, + ?assertEqual({error, {Prog, FloatChoices, "1.3", <<"is not one of the choices">>}}, + parse_opts("1.3", [FloatChoices])), + %% unsuccessful user-defined conversion + ?assertMatch({error, {Prog, _, "REV", <<"failed faildation">>}}, + parse_opts("REV", [#{name => user, type => {custom, fun (Str) -> integer_to_binary(Str) end}}])). + +complex_command() -> + [{doc, "Parses a complex command that has a mix of optional and positional arguments"}]. + +complex_command(Config) when is_list(Config) -> + Command = #{arguments => [ + %% options + #{name => string, short => $s, long => "-string", action => append, help => "String list option"}, + #{name => boolean, type => boolean, short => $b, action => append, help => "Boolean list option"}, + #{name => float, type => float, short => $f, long => "-float", action => append, help => "Float option"}, + %% positional args + #{name => integer, type => integer, help => "Integer variable"}, + #{name => string, help => "alias for string option", action => extend, nargs => list} + ]}, + CmdMap = #{commands => #{"start" => Command}}, + Parsed = argparse:parse(string:lexemes("start --float 1.04 -f 112 -b -b -s s1 42 --string s2 s3 s4", " "), CmdMap), + Expected = #{float => [1.04, 112], boolean => [true, true], integer => 42, string => ["s1", "s2", "s3", "s4"]}, + ?assertEqual({ok, Expected, [prog(), "start"], Command}, Parsed). + +unicode() -> + [{doc, "Tests basic unicode support"}]. + +unicode(Config) when is_list(Config) -> + %% test unicode short & long + ?assertMatch({ok, #{one := true}, _, _}, + parse(["-Ф"], #{arguments => [#{name => one, short => $Ф, type => boolean}]})), + ?assertMatch({ok, #{long := true}, _, _}, + parse(["--åäö"], #{arguments => [#{name => long, long => "-åäö", type => boolean}]})), + %% test default, help and value in unicode + Cmd = #{arguments => [#{name => text, type => binary, help => "åäö", default => <<"★"/utf8>>}]}, + Expected = #{text => <<"★"/utf8>>}, + Prog = [prog()], + ?assertEqual({ok, Expected, Prog, Cmd}, argparse:parse([], Cmd)), %% default + ?assertEqual({ok, Expected, Prog, Cmd}, argparse:parse(["★"], Cmd)), %% specified in the command line + ?assertEqual("Usage:\n " ++ prog() ++ " <text>\n\nArguments:\n text åäö (binary, ★)\n", + unicode:characters_to_list(argparse:help(Cmd))), + %% test command name and argument name in unicode + Uni = #{commands => #{"åäö" => #{help => "öФ"}}, handler => optional, + arguments => [#{name => "Ф", short => $ä, long => "åäö"}]}, + UniExpected = "Usage:\n " ++ prog() ++ + " {åäö} [-ä <Ф>] [-åäö <Ф>]\n\nSubcommands:\n åäö öФ\n\nOptional arguments:\n -ä, -åäö Ф\n", + ?assertEqual(UniExpected, unicode:characters_to_list(argparse:help(Uni))), + ParsedExpected = #{"Ф" => "öФ"}, + ?assertEqual({ok, ParsedExpected, Prog, Uni}, argparse:parse(["-ä", "öФ"], Uni)). + +parser_error() -> + [{doc, "Tests error tuples that the parser returns"}]. + +parser_error(Config) when is_list(Config) -> + Prog = prog(), + %% unknown option at the top of the path + ?assertEqual({error, {[Prog], undefined, "arg", <<>>}}, + parse_cmd(["arg"], #{})), + %% positional argument missing in a sub-command + Opt = #{name => mode, required => true}, + ?assertMatch({error, {[Prog, "start"], _, undefined, <<>>}}, + parse_cmd(["start"], #{"start" => #{arguments => [Opt]}})), + %% optional argument missing in a sub-command + Opt1 = #{name => mode, short => $o, required => true}, + ?assertMatch({error, {[Prog, "start"], _, undefined, <<>>}}, + parse_cmd(["start"], #{"start" => #{arguments => [Opt1]}})), + %% positional argument: an atom that does not exist + Opt2 = #{name => atom, type => atom}, + ?assertEqual({error, {[Prog], Opt2, "boo-foo", <<"is not an existing atom">>}}, + parse_opts(["boo-foo"], [Opt2])), + %% optional argument missing some items + Opt3 = #{name => kernel, long => "kernel", type => atom, nargs => 2}, + ?assertEqual({error, {[Prog], Opt3, ["port"], "expected 2, found 1 argument(s)"}}, + parse_opts(["-kernel port"], [Opt3])), + %% positional argument missing some items + Opt4 = #{name => arg, type => atom, nargs => 3}, + ?assertEqual({error, {[Prog], Opt4, ["p1"], "expected 3, found 1 argument(s)"}}, + parse_opts(["p1"], [Opt4])), + %% short option with no argument, when it's needed + ?assertMatch({error, {_, _, undefined, <<"expected argument">>}}, + parse("-1", #{arguments => [#{name => short49, short => 49}]})). + +nargs() -> + [{doc, "Tests argument consumption option, with nargs"}]. + +nargs(Config) when is_list(Config) -> + Prog = [prog()], + %% consume optional list arguments + Opts = [ + #{name => arg, short => $s, nargs => list, type => integer}, + #{name => bool, short => $b, type => boolean} + ], + ?assertMatch({ok, #{arg := [1, 2, 3], bool := true}, _, _}, + parse_opts(["-s 1 2 3 -b"], Opts)), + %% consume one_or_more arguments in an optional list + Opts2 = [ + #{name => arg, short => $s, nargs => nonempty_list}, + #{name => extra, short => $x} + ], + ?assertMatch({ok, #{extra := "X", arg := ["a","b","c"]}, _, _}, + parse_opts(["-s port -s a b c -x X"], Opts2)), + %% error if there is no argument to consume + ?assertMatch({error, {_, _, ["-x"], <<"expected argument">>}}, + parse_opts(["-s -x"], Opts2)), + %% error when positional has nargs = nonempty_list or pos_integer + ?assertMatch({error, {_, _, undefined, <<>>}}, + parse_opts([""], [#{name => req, nargs => nonempty_list}])), + %% positional arguments consumption: one or more positional argument + OptsPos1 = #{arguments => [ + #{name => arg, nargs => nonempty_list}, + #{name => extra, short => $x} + ]}, + ?assertEqual({ok, #{extra => "X", arg => ["b","c"]}, Prog, OptsPos1}, + parse(["-x port -x a b c -x X"], OptsPos1)), + %% positional arguments consumption, any number (maybe zero) + OptsPos2 = #{arguments => [ + #{name => arg, nargs => list}, + #{name => extra, short => $x} + ]}, + ?assertEqual({ok, #{extra => "X", arg => ["a","b","c"]}, Prog, OptsPos2}, + parse(["-x port a b c -x X"], OptsPos2)), + %% positional: consume ALL arguments! + OptsAll = #{arguments => [ + #{name => arg, nargs => all}, + #{name => extra, short => $x} + ]}, + ?assertEqual({ok, #{extra => "port", arg => ["a","b","c", "-x", "X"]}, Prog, OptsAll}, + parse(["-x port a b c -x X"], OptsAll)), + %% maybe with a specified default + OptMaybe = [ + #{name => foo, long => "-foo", nargs => {'maybe', c}, default => d}, + #{name => bar, nargs => 'maybe', default => d} + ], + ?assertMatch({ok, #{foo := "YY", bar := "XX"}, Prog, _}, + parse_opts(["XX --foo YY"], OptMaybe)), + ?assertMatch({ok, #{foo := c, bar := "XX"}, Prog, _}, + parse_opts(["XX --foo"], OptMaybe)), + ?assertMatch({ok, #{foo := d, bar := d}, Prog, _}, + parse_opts([""], OptMaybe)), + %% maybe with default provided by argparse + ?assertMatch({ok, #{foo := d, bar := "XX", baz := ok}, _, _}, + parse_opts(["XX -b"], [#{name => baz, nargs => 'maybe', short => $b, default => ok} | OptMaybe])), + %% maybe arg - with no default given + ?assertMatch({ok, #{foo := d, bar := "XX", baz := 0}, _, _}, + parse_opts(["XX -b"], [#{name => baz, nargs => 'maybe', short => $b, type => integer} | OptMaybe])), + ?assertMatch({ok, #{foo := d, bar := "XX", baz := ""}, _, _}, + parse_opts(["XX -b"], [#{name => baz, nargs => 'maybe', short => $b, type => string} | OptMaybe])), + ?assertMatch({ok, #{foo := d, bar := "XX", baz := undefined}, _, _}, + parse_opts(["XX -b"], [#{name => baz, nargs => 'maybe', short => $b, type => atom} | OptMaybe])), + ?assertMatch({ok, #{foo := d, bar := "XX", baz := <<"">>}, _, _}, + parse_opts(["XX -b"], [#{name => baz, nargs => 'maybe', short => $b, type => binary} | OptMaybe])), + %% nargs: optional list, yet it still needs to be 'not required'! + OptList = [#{name => arg, nargs => list, required => false, type => integer}], + ?assertEqual({ok, #{}, Prog, #{arguments => OptList}}, parse_opts("", OptList)), + %% tests that action "count" with nargs "maybe" counts two times, first time + %% consuming an argument (for "maybe"), second time just counting + Cmd = #{arguments => [ + #{name => short49, short => $1, long => "-force", action => count, nargs => 'maybe'}]}, + ?assertEqual({ok, #{short49 => 2}, Prog, Cmd}, + parse("-1 arg1 --force", Cmd)). + +argparse() -> + [{doc, "Tests success cases, inspired by argparse in Python"}]. + +argparse(Config) when is_list(Config) -> + Prog = [prog()], + Parser = #{arguments => [ + #{name => sum, long => "-sum", action => {store, sum}, default => max}, + #{name => integers, type => integer, nargs => nonempty_list} + ]}, + ?assertEqual({ok, #{integers => [1, 2, 3, 4], sum => max}, Prog, Parser}, + parse("1 2 3 4", Parser)), + ?assertEqual({ok, #{integers => [1, 2, 3, 4], sum => sum}, Prog, Parser}, + parse("1 2 3 4 --sum", Parser)), + ?assertEqual({ok, #{integers => [7, -1, 42], sum => sum}, Prog, Parser}, + parse("--sum 7 -1 42", Parser)), + %% name or flags + Parser2 = #{arguments => [ + #{name => bar, required => true}, + #{name => foo, short => $f, long => "-foo"} + ]}, + ?assertEqual({ok, #{bar => "BAR"}, Prog, Parser2}, parse("BAR", Parser2)), + ?assertEqual({ok, #{bar => "BAR", foo => "FOO"}, Prog, Parser2}, parse("BAR --foo FOO", Parser2)), + %PROG: error: the following arguments are required: bar + ?assertMatch({error, {Prog, _, undefined, <<>>}}, parse("--foo FOO", Parser2)), + %% action tests: default + ?assertMatch({ok, #{foo := "1"}, Prog, _}, + parse("--foo 1", #{arguments => [#{name => foo, long => "-foo"}]})), + %% action test: store + ?assertMatch({ok, #{foo := 42}, Prog, _}, + parse("--foo", #{arguments => [#{name => foo, long => "-foo", action => {store, 42}}]})), + %% action tests: boolean (variants) + ?assertMatch({ok, #{foo := true}, Prog, _}, + parse("--foo", #{arguments => [#{name => foo, long => "-foo", action => {store, true}}]})), + ?assertMatch({ok, #{foo := 42}, Prog, _}, + parse("--foo", #{arguments => [#{name => foo, long => "-foo", type => boolean, action => {store, 42}}]})), + ?assertMatch({ok, #{foo := true}, Prog, _}, + parse("--foo", #{arguments => [#{name => foo, long => "-foo", type => boolean}]})), + ?assertMatch({ok, #{foo := true}, Prog, _}, + parse("--foo true", #{arguments => [#{name => foo, long => "-foo", type => boolean}]})), + ?assertMatch({ok, #{foo := false}, Prog, _}, + parse("--foo false", #{arguments => [#{name => foo, long => "-foo", type => boolean}]})), + %% action tests: append & append_const + ?assertMatch({ok, #{all := [1, "1"]}, Prog, _}, + parse("--x 1 -x 1", #{arguments => [ + #{name => all, long => "-x", type => integer, action => append}, + #{name => all, short => $x, action => append}]})), + ?assertMatch({ok, #{all := ["Z", 2]}, Prog, _}, + parse("--x -x", #{arguments => [ + #{name => all, long => "-x", action => {append, "Z"}}, + #{name => all, short => $x, action => {append, 2}}]})), + %% count: + ?assertMatch({ok, #{v := 3}, Prog, _}, + parse("-v -v -v", #{arguments => [#{name => v, short => $v, action => count}]})). + +negative() -> + [{doc, "Test negative number parser"}]. + +negative(Config) when is_list(Config) -> + Parser = #{arguments => [ + #{name => x, short => $x, type => integer, action => store}, + #{name => foo, nargs => 'maybe', required => false} + ]}, + ?assertMatch({ok, #{x := -1}, _, _}, parse("-x -1", Parser)), + ?assertMatch({ok, #{x := -1, foo := "-5"}, _, _}, parse("-x -1 -5", Parser)), + %% + Parser2 = #{arguments => [ + #{name => one, short => $1}, + #{name => foo, nargs => 'maybe', required => false} + ]}, + + %% negative number options present, so -1 is an option + ?assertMatch({ok, #{one := "X"}, _, _}, parse("-1 X", Parser2)), + %% negative number options present, so -2 is an option + ?assertMatch({error, {_, undefined, "-2", _}}, parse("-2", Parser2)), + + %% negative number options present, so both -1s are options + ?assertMatch({error, {_, _, undefined, _}}, parse("-1 -1", Parser2)), + %% no "-" prefix, can only be an integer + ?assertMatch({ok, #{foo := "-1"}, _, _}, argparse:parse(["-1"], Parser2, #{prefixes => "+"})), + %% no "-" prefix, can only be an integer, but just one integer! + ?assertMatch({error, {_, undefined, "-1", _}}, + argparse:parse(["-2", "-1"], Parser2, #{prefixes => "+"})), + %% just in case, floats work that way too... + ?assertMatch({error, {_, undefined, "-2", _}}, + parse("-2", #{arguments => [#{name => one, long => "1.2"}]})). + +nodigits() -> + [{doc, "Test prefixes and negative numbers together"}]. + +nodigits(Config) when is_list(Config) -> + %% verify nodigits working as expected + Parser3 = #{arguments => [ + #{name => extra, short => $3}, + #{name => arg, nargs => list} + ]}, + %% ensure not to consume optional prefix + ?assertEqual({ok, #{extra => "X", arg => ["a","b","3"]}, [prog()], Parser3}, + argparse:parse(string:lexemes("-3 port a b 3 +3 X", " "), Parser3, #{prefixes => "-+"})). + +pos_mixed_with_opt() -> + [{doc, "Tests that optional argument correctly consumes expected argument" + "inspired by https://github.com/python/cpython/issues/59317"}]. + +pos_mixed_with_opt(Config) when is_list(Config) -> + Parser = #{arguments => [ + #{name => pos}, + #{name => opt, default => 24, type => integer, long => "-opt"}, + #{name => vars, nargs => list} + ]}, + ?assertEqual({ok, #{pos => "1", opt => 8, vars => ["8", "9"]}, [prog()], Parser}, + parse("1 2 --opt 8 8 9", Parser)). + +default_for_not_required() -> + [{doc, "Tests that default value is used for non-required positional argument"}]. + +default_for_not_required(Config) when is_list(Config) -> + ?assertMatch({ok, #{def := 1}, _, _}, + parse("", #{arguments => [#{name => def, short => $d, required => false, default => 1}]})), + ?assertMatch({ok, #{def := 1}, _, _}, + parse("", #{arguments => [#{name => def, required => false, default => 1}]})). + +global_default() -> + [{doc, "Tests that a global default can be enabled for all non-required arguments"}]. + +global_default(Config) when is_list(Config) -> + ?assertMatch({ok, #{def := "global"}, _, _}, + argparse:parse("", #{arguments => [#{name => def, type => integer, required => false}]}, + #{default => "global"})). + +subcommand() -> + [{doc, "Tests subcommands parser"}]. + +subcommand(Config) when is_list(Config) -> + TwoCmd = #{arguments => [#{name => bar}]}, + Cmd = #{ + arguments => [#{name => force, type => boolean, short => $f}], + commands => #{"one" => #{ + arguments => [#{name => foo, type => boolean, long => "-foo"}, #{name => baz}], + commands => #{ + "two" => TwoCmd}}}}, + ?assertEqual({ok, #{force => true, baz => "N1O1O", foo => true, bar => "bar"}, [prog(), "one", "two"], TwoCmd}, + parse("one N1O1O -f two --foo bar", Cmd)), + %% it is an error not to choose subcommand + ?assertEqual({error, {[prog(), "one"], undefined, undefined, <<"subcommand expected">>}}, + parse("one N1O1O -f", Cmd)). + +very_short() -> + [{doc, "Tests short option appended to the optional itself"}]. + +very_short(Config) when is_list(Config) -> + ?assertMatch({ok, #{x := "V"}, _, _}, + parse("-xV", #{arguments => [#{name => x, short => $x}]})). + +multi_short() -> + [{doc, "Tests multiple short arguments blend into one"}]. + +multi_short(Config) when is_list(Config) -> + %% ensure non-flammable argument does not explode, even when it's possible + ?assertMatch({ok, #{v := "xv"}, _, _}, + parse("-vxv", #{arguments => [#{name => v, short => $v}, #{name => x, short => $x}]})), + %% ensure 'verbosity' use-case works + ?assertMatch({ok, #{v := 3}, _, _}, + parse("-vvv", #{arguments => [#{name => v, short => $v, action => count}]})), + %% + ?assertMatch({ok, #{recursive := true, force := true, path := "dir"}, _, _}, + parse("-rf dir", #{arguments => [ + #{name => recursive, short => $r, type => boolean}, + #{name => force, short => $f, type => boolean}, + #{name => path} + ]})). + +proxy_arguments() -> + [{doc, "Tests nargs => all used to proxy arguments to another script"}]. + +proxy_arguments(Config) when is_list(Config) -> + Cmd = #{ + commands => #{ + "start" => #{ + arguments => [ + #{name => shell, short => $s, long => "-shell", type => boolean}, + #{name => skip, short => $x, long => "-skip", type => boolean}, + #{name => args, required => false, nargs => all} + ] + }, + "stop" => #{}, + "status" => #{ + arguments => [ + #{name => skip, required => false, default => "ok"}, + #{name => args, required => false, nargs => all} + ]}, + "state" => #{ + arguments => [ + #{name => skip, required => false}, + #{name => args, required => false, nargs => all} + ]} + }, + arguments => [ + #{name => node} + ], + handler => fun (#{}) -> ok end + }, + Prog = prog(), + ?assertMatch({ok, #{node := "node1"}, _, _}, parse("node1", Cmd)), + ?assertMatch({ok, #{node := "node1"}, [Prog, "stop"], #{}}, parse("node1 stop", Cmd)), + ?assertMatch({ok, #{node := "node2.org", shell := true, skip := true}, _, _}, parse("node2.org start -x -s", Cmd)), + ?assertMatch({ok, #{args := ["-app","key","value"],node := "node1.org"}, [Prog, "start"], _}, + parse("node1.org start -app key value", Cmd)), + ?assertMatch({ok, #{args := ["-app","key","value", "-app2", "key2", "value2"], + node := "node3.org", shell := true}, [Prog, "start"], _}, + parse("node3.org start -s -app key value -app2 key2 value2", Cmd)), + %% test that any non-required positionals are skipped + ?assertMatch({ok, #{args := ["-a","bcd"], node := "node2.org", skip := "ok"}, _, _}, parse("node2.org status -a bcd", Cmd)), + ?assertMatch({ok, #{args := ["-app", "key"], node := "node2.org"}, _, _}, parse("node2.org state -app key", Cmd)). + +%%-------------------------------------------------------------------- +%% Usage Test Cases + +usage() -> + [{doc, "Basic tests for help formatter, including 'hidden' help"}]. + +usage(Config) when is_list(Config) -> + Cmd = ubiq_cmd(), + Usage = "Usage:\n erl start {crawler|doze} [-lrfv] [-s <shard>...] [-z <z>] [-m <more>] [-b <bin>]\n" + " [-g <g>] [-t <t>] ---maybe-req -y <y> --yyy <y> [-u <u>] [-c <choice>]\n" + " [-q <fc>] [-w <ac>] [--unsafe <au>] [--safe <as>] [-foobar <long>] [--force]\n" + " [-i <interval>] [--req <weird>] [--float <float>] <server> [<optpos>]\n\n" + "Subcommands:\n" + " crawler controls crawler behaviour\n" + " doze dozes a bit\n\n" + "Arguments:\n" + " server server to start\n" + " optpos optional positional (int)\n\n" + "Optional arguments:\n" + " -s initial shards (int)\n" + " -z between (1 <= int <= 10)\n" + " -l maybe lower (int <= 10)\n" + " -m less than 10 (int <= 10)\n" + " -b binary with re (binary re: m)\n" + " -g binary with re (binary re: m)\n" + " -t string with re (string re: m)\n" + " ---maybe-req maybe required int (int)\n" + " -y, --yyy string with re (string re: m)\n" + " -u string choices (choice: 1, 2)\n" + " -c tough choice (choice: 1, 2, 3)\n" + " -q floating choice (choice: 2.10000, 1.20000)\n" + " -w atom choice (choice: one, two)\n" + " --unsafe unsafe atom (atom)\n" + " --safe safe atom (existing atom)\n" + " -foobar foobaring option\n" + " -r recursive\n" + " -f, --force force\n" + " -v verbosity level\n" + " -i interval set (int >= 1)\n" + " --req required optional, right?\n" + " --float floating-point long form argument (float, 3.14)\n", + ?assertEqual(Usage, unicode:characters_to_list(argparse:help(Cmd, + #{progname => "erl", command => ["start"]}))), + FullCmd = "Usage:\n erl" + " <command> [-rfv] [--force] [-i <interval>] [--req <weird>] [--float <float>]\n\n" + "Subcommands:\n" + " start verifies configuration and starts server\n" + " status prints server status\n" + " stop stops running server\n\n" + "Optional arguments:\n" + " -r recursive\n" + " -f, --force force\n" + " -v verbosity level\n" + " -i interval set (int >= 1)\n" + " --req required optional, right?\n" + " --float floating-point long form argument (float, 3.14)\n", + ?assertEqual(FullCmd, unicode:characters_to_list(argparse:help(Cmd, + #{progname => erl}))), + CrawlerStatus = "Usage:\n erl status crawler [-rfv] [---extra <extra>] [--force] [-i <interval>]\n" + " [--req <weird>] [--float <float>]\n\nOptional arguments:\n" + " ---extra extra option very deep\n -r recursive\n" + " -f, --force force\n -v verbosity level\n" + " -i interval set (int >= 1)\n" + " --req required optional, right?\n" + " --float floating-point long form argument (float, 3.14)\n", + ?assertEqual(CrawlerStatus, unicode:characters_to_list(argparse:help(Cmd, + #{progname => "erl", command => ["status", "crawler"]}))), + ok. + +usage_required_args() -> + [{doc, "Verify that required args are printed as required in usage"}]. + +usage_required_args(Config) when is_list(Config) -> + Cmd = #{commands => #{"test" => #{arguments => [#{name => required, required => true, long => "-req"}]}}}, + Expected = "Usage:\n " ++ prog() ++ " test --req <required>\n\nOptional arguments:\n --req required\n", + ?assertEqual(Expected, unicode:characters_to_list(argparse:help(Cmd, #{command => ["test"]}))). + +usage_template() -> + [{doc, "Tests templates in help/usage"}]. + +usage_template(Config) when is_list(Config) -> + %% Argument (positional) + Cmd = #{arguments => [#{ + name => shard, + type => integer, + default => 0, + help => {"[-s SHARD]", ["initial number, ", type, <<" with a default value of ">>, default]}} + ]}, + ?assertEqual("Usage:\n " ++ prog() ++ " [-s SHARD]\n\nArguments:\n shard initial number, int with a default value of 0\n", + unicode:characters_to_list(argparse:help(Cmd, #{}))), + %% Optional + Cmd1 = #{arguments => [#{ + name => shard, + short => $s, + type => integer, + default => 0, + help => {<<"[-s SHARD]">>, ["initial number"]}} + ]}, + ?assertEqual("Usage:\n " ++ prog() ++ " [-s SHARD]\n\nOptional arguments:\n -s initial number\n", + unicode:characters_to_list(argparse:help(Cmd1, #{}))), + %% ISO Date example + DefaultRange = {{2020, 1, 1}, {2020, 6, 22}}, + CmdISO = #{ + arguments => [ + #{ + name => range, + long => "-range", + short => $r, + help => {"[--range RNG]", fun() -> + {{FY, FM, FD}, {TY, TM, TD}} = DefaultRange, + lists:flatten(io_lib:format("date range, ~b-~b-~b..~b-~b-~b", [FY, FM, FD, TY, TM, TD])) + end}, + type => {custom, fun(S) -> [S, DefaultRange] end}, + default => DefaultRange + } + ] + }, + ?assertEqual("Usage:\n " ++ prog() ++ " [--range RNG]\n\nOptional arguments:\n -r, --range date range, 2020-1-1..2020-6-22\n", + unicode:characters_to_list(argparse:help(CmdISO, #{}))), + ok. + +parser_error_usage() -> + [{doc, "Tests that parser errors have corresponding usage text"}]. + +parser_error_usage(Config) when is_list(Config) -> + %% unknown arguments + Prog = prog(), + ?assertEqual(Prog ++ ": unknown argument: arg", parser_error(["arg"], #{})), + ?assertEqual(Prog ++ ": unknown argument: -a", parser_error(["-a"], #{})), + %% missing argument + ?assertEqual(Prog ++ ": required argument missing: need", parser_error([""], + #{arguments => [#{name => need}]})), + ?assertEqual(Prog ++ ": required argument missing: need", parser_error([""], + #{arguments => [#{name => need, short => $n, required => true}]})), + %% invalid value + ?assertEqual(Prog ++ ": invalid argument for need: foo is not an integer", parser_error(["foo"], + #{arguments => [#{name => need, type => integer}]})), + ?assertEqual(Prog ++ ": invalid argument for need: cAnNotExIsT is not an existing atom", parser_error(["cAnNotExIsT"], + #{arguments => [#{name => need, type => atom}]})). + +command_usage() -> + [{doc, "Test command help template"}]. + +command_usage(Config) when is_list(Config) -> + Cmd = #{arguments => [ + #{name => arg, help => "argument help"}, #{name => opt, short => $o, help => "option help"}], + help => ["Options:\n", options, arguments, <<"NOTAUSAGE">>, usage, "\n"] + }, + ?assertEqual("Options:\n -o option help\n arg argument help\nNOTAUSAGE " ++ prog() ++ " [-o <opt>] <arg>\n", + unicode:characters_to_list(argparse:help(Cmd, #{}))). + +usage_width() -> + [{doc, "Test usage fitting in the viewport"}]. + +usage_width(Config) when is_list(Config) -> + Cmd = #{arguments => [ + #{name => arg, help => "argument help that spans way over allowed viewport width, wrapping words"}, + #{name => opt, short => $o, long => "-option_long_name", + help => "another quite long word wrapped thing spanning over several lines"}, + #{name => v, short => $v, type => boolean}, + #{name => q, short => $q, type => boolean}], + commands => #{ + "cmd1" => #{help => "Help for command number 1, not fitting at all"}, + "cmd2" => #{help => <<"Short help">>}, + "cmd3" => #{help => "Yet another instance of a very long help message"} + }, + help => " Very long help line taking much more than 40 characters allowed by the test case. +Also containing a few newlines. + + Indented new lines must be honoured!" + }, + + Expected = "Usage:\n erl {cmd1|cmd2|cmd3} [-vq] [-o <opt>]\n" + " [--option_long_name <opt>] <arg>\n\n" + " Very long help line taking much more\n" + "than 40 characters allowed by the test\n" + "case.\n" + "Also containing a few newlines.\n\n" + " Indented new lines must be honoured!\n\n" + "Subcommands:\n" + " cmd1 Help for\n" + " command number\n" + " 1, not fitting\n" + " at all\n" + " cmd2 Short help\n" + " cmd3 Yet another\n" + " instance of a\n" + " very long help\n" + " message\n\n" + "Arguments:\n" + " arg argument help\n" + " that spans way\n" + " over allowed\n" + " viewport width,\n" + " wrapping words\n\n" + "Optional arguments:\n" + " -o, --option_long_name another quite\n" + " long word\n" + " wrapped thing\n" + " spanning over\n" + " several lines\n" + " -v v\n" + " -q q\n", + + ?assertEqual(Expected, unicode:characters_to_list(argparse:help(Cmd, #{columns => 40, progname => "erl"}))). + +%%-------------------------------------------------------------------- +%% Validator Test Cases + +validator_exception() -> + [{doc, "Tests that the validator throws expected exceptions"}]. + +validator_exception(Config) when is_list(Config) -> + Prg = [prog()], + %% conflicting option names + ?assertException(error, {argparse, argument, Prg, short, "short conflicting with previously defined short for one"}, + argparse:validate(#{arguments => [#{name => one, short => $$}, #{name => two, short => $$}]})), + ?assertException(error, {argparse, argument, Prg, long, "long conflicting with previously defined long for one"}, + argparse:validate(#{arguments => [#{name => one, long => "a"}, #{name => two, long => "a"}]})), + %% broken options + %% long must be a string + ?assertException(error, {argparse, argument, Prg, long, _}, + argparse:validate(#{arguments => [#{name => one, long => ok}]})), + %% short must be a printable character + ?assertException(error, {argparse, argument, Prg, short, _}, + argparse:validate(#{arguments => [#{name => one, short => ok}]})), + ?assertException(error, {argparse, argument, Prg, short, _}, + argparse:validate(#{arguments => [#{name => one, short => 7}]})), + %% required is a boolean + ?assertException(error, {argparse, argument, Prg, required, _}, + argparse:validate(#{arguments => [#{name => one, required => ok}]})), + ?assertException(error, {argparse, argument, Prg, help, _}, + argparse:validate(#{arguments => [#{name => one, help => ok}]})), + %% broken commands + try argparse:help(#{}, #{progname => 123}), ?assert(false) + catch error:badarg:Stack -> + [{_, _, _, Ext} | _] = Stack, + #{cause := #{2 := Detail}} = proplists:get_value(error_info, Ext), + ?assertEqual(<<"progname is not valid">>, Detail) + end, + %% not-a-list of arguments provided to a subcommand + Prog = prog(), + ?assertException(error, {argparse, command, [Prog, "start"], arguments, <<"expected a list, [argument()]">>}, + argparse:validate(#{commands => #{"start" => #{arguments => atom}}})), + %% command is not a map + ?assertException(error, {argparse, command, Prg, commands, <<"expected map of #{string() => command()}">>}, + argparse:validate(#{commands => []})), + %% invalid commands field + ?assertException(error, {argparse, command, Prg, commands, _}, + argparse:validate(#{commands => ok})), + ?assertException(error, {argparse, command, _, commands, _}, + argparse:validate(#{commands => #{ok => #{}}})), + ?assertException(error, {argparse, command, _, help, + <<"must be a printable unicode list, or a command help template">>}, + argparse:validate(#{commands => #{"ok" => #{help => ok}}})), + ?assertException(error, {argparse, command, _, handler, _}, + argparse:validate(#{commands => #{"ok" => #{handler => fun validator_exception/0}}})), + %% extend + maybe: validator exception + ?assertException(error, {argparse, argument, _, action, <<"extend action works only with lists">>}, + parse("-1 -1", #{arguments => + [#{action => extend, name => short49, nargs => 'maybe', short => 49}]})). + +validator_exception_format() -> + [{doc, "Tests human-readable (EEP-54) format for exceptions thrown by the validator"}]. + +validator_exception_format(Config) when is_list(Config) -> + %% set up as a contract: test that EEP-54 transformation is done (but don't check strings) + try + argparse:validate(#{commands => #{"one" => #{commands => #{"two" => atom}}}}), + ?assert(false) + catch + error:R1:S1 -> + #{1 := Cmd, reason := RR1, general := G} = argparse:format_error(R1, S1), + ?assertEqual("command specification is invalid", unicode:characters_to_list(G)), + ?assertEqual("command \"" ++ prog() ++ " one two\": invalid field 'commands', reason: expected command()", + unicode:characters_to_list(RR1)), + ?assertEqual(["atom"], Cmd) + end, + %% check argument + try + argparse:validate(#{arguments => [#{}]}), + ?assert(false) + catch + error:R2:S2 -> + #{1 := Cmd2, reason := RR2, general := G2} = argparse:format_error(R2, S2), + ?assertEqual("argument specification is invalid", unicode:characters_to_list(G2)), + ?assertEqual("command \"" ++ prog() ++ + "\", argument '', invalid field 'name': argument must be a map containing 'name' field", + unicode:characters_to_list(RR2)), + ?assertEqual(["#{}"], Cmd2) + end. + +%%-------------------------------------------------------------------- +%% Validator Test Cases + +run_handle() -> + [{doc, "Very basic tests for argparse:run/3, choice of handlers formats"}]. + +%% fun((arg_map()) -> term()) | %% handler accepting arg_map +%% {module(), Fn :: atom()} | %% handler, accepting arg_map, Fn exported from module() +%% {fun(() -> term()), term()} | %% handler, positional form (term() is supplied for omitted args) +%% {module(), atom(), term()} + +run_handle(Config) when is_list(Config) -> + %% no subcommand, basic fun handler with argmap + ?assertEqual(6, + argparse:run(["-i", "3"], #{handler => fun (#{in := Val}) -> Val * 2 end, + arguments => [#{name => in, short => $i, type => integer}]}, #{})), + %% subcommand, positional fun() handler + ?assertEqual(6, + argparse:run(["mul", "2", "3"], #{commands => #{"mul" => #{ + handler => {fun (match, L, R) -> L * R end, match}, + arguments => [#{name => opt, short => $o}, + #{name => l, type => integer}, #{name => r, type => integer}]}}}, + #{})), + %% no subcommand, positional module-based function + ?assertEqual(6, + argparse:run(["2", "3"], #{handler => {erlang, '*', undefined}, + arguments => [#{name => l, type => integer}, #{name => r, type => integer}]}, + #{})), + %% subcommand, module-based function accepting argmap + ?assertEqual([{arg, "arg"}], + argparse:run(["map", "arg"], #{commands => #{"map" => #{ + handler => {maps, to_list}, + arguments => [#{name => arg}]}}}, + #{})).
\ No newline at end of file |