Sign Up
Log In
Log In
or
Sign Up
Places
All Projects
Status Monitor
Collapse sidebar
home:Ledest:erlang:24
erlang
6131-argparse-Command-line-parser-for-Erlang.patch
Overview
Repositories
Revisions
Requests
Users
Attributes
Meta
File 6131-argparse-Command-line-parser-for-Erlang.patch of Package erlang
From 1b53f38d321e3ca870518578b086cf4562a522e5 Mon Sep 17 00:00:00 2001 From: Maxim Fedorov <maximfca@gmail.com> Date: Sun, 12 Feb 2023 21:39:43 -0800 Subject: [PATCH 1/3] [argparse] Command line parser for Erlang Inspired by Python argparse library. --- lib/stdlib/doc/src/Makefile | 1 + lib/stdlib/doc/src/argparse.xml | 739 +++++++++++++++ lib/stdlib/doc/src/ref_man.xml | 1 + lib/stdlib/doc/src/specs.xml | 1 + lib/stdlib/src/Makefile | 1 + lib/stdlib/src/argparse.erl | 1357 ++++++++++++++++++++++++++++ lib/stdlib/src/stdlib.app.src | 3 +- lib/stdlib/test/Makefile | 1 + lib/stdlib/test/argparse_SUITE.erl | 1063 ++++++++++++++++++++++ 9 files changed, 3166 insertions(+), 1 deletion(-) create mode 100644 lib/stdlib/doc/src/argparse.xml create mode 100644 lib/stdlib/src/argparse.erl create mode 100644 lib/stdlib/test/argparse_SUITE.erl diff --git a/lib/stdlib/doc/src/Makefile b/lib/stdlib/doc/src/Makefile index 5b1bc2b483..d13fa47064 100644 --- a/lib/stdlib/doc/src/Makefile +++ b/lib/stdlib/doc/src/Makefile @@ -33,6 +33,7 @@ APPLICATION=stdlib XML_APPLICATION_FILES = ref_man.xml XML_REF3_FILES = \ + argparse.xml \ array.xml \ base64.xml \ beam_lib.xml \ diff --git a/lib/stdlib/doc/src/argparse.xml b/lib/stdlib/doc/src/argparse.xml new file mode 100644 index 0000000000..20e1f3a721 --- /dev/null +++ b/lib/stdlib/doc/src/argparse.xml @@ -0,0 +1,739 @@ +<?xml version="1.0" encoding="utf-8" ?> +<!DOCTYPE erlref SYSTEM "erlref.dtd"> + +<!-- %ExternalCopyright% --> + +<erlref> + <header> + <copyright> + <year>2020</year><year>2023</year> + <holder>Maxim Fedorov</holder> + </copyright> + <legalnotice> + 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. + + </legalnotice> + + <title>argparse</title> + <prepared>maximfca@gmail.com</prepared> + <responsible></responsible> + <docno></docno> + <approved></approved> + <checked></checked> + <date></date> + <rev>A</rev> + <file>argparse.xml</file> + </header> + <module since="OTP 26.0">argparse</module> + <modulesummary>Command line arguments parser.</modulesummary> + <description> + + <p>This module implements command line parser. Parser operates with + <em>commands</em> and <em>arguments</em> represented as a tree. Commands + are branches, and arguments are leaves of the tree. Parser always starts with the + root command, named after <c>progname</c> (the name of the program which started Erlang). + </p> + + <p> + A <seetype marker="#command"><c>command specification</c></seetype> may contain handler + definition for each command, and a number argument specifications. When parser is + successful, <c>argparse</c> calls the matching handler, passing arguments extracted + from the command line. Arguments can be positional (occupying specific position in + the command line), and optional, residing anywhere but prefixed with a specified + character. + </p> + + <p> + <c>argparse</c> automatically generates help and usage messages. It will also issue + errors when users give the program invalid arguments. + </p> + + </description> + + <section> + <title>Quick start</title> + + <p><c>argparse</c> is designed to work with <seecom marker="erts:escript"><c>escript</c></seecom>. + The example below is a fully functioning Erlang program accepting two command line + arguments and printing their product.</p> + + <code> +#!/usr/bin/env escript + +main(Args) -> + argparse:run(Args, cli(), #{progname => mul}). + +cli() -> + #{ + arguments => [ + #{name => left, type => integer}, + #{name => right, type => integer} + ], + handler => + fun (#{left := Left, right := Right}) -> + io:format("~b~n", [Left * Right]) + end + }. + </code> + + <p>Running this script with no arguments results in an error, accompanied + by the usage information.</p> + + <p> + The <c>cli</c> function defines a single command with embedded handler + accepting a map. Keys of the map are argument names as defined by + the <c>argument</c> field of the command, <c>left</c> and <c>right</c> + in the example. Values are taken from the command line, and converted + into integers, as requested by the type specification. Both arguments + in the example above are required (and therefore defined as positional). + </p> + </section> + + <section> + <title>Command hierarchy</title> + + <p>A command may contain nested commands, forming a hierarchy. Arguments + defined at the upper level command are automatically added to all nested + commands. Nested commands example (assuming <c>progname</c> is <c>nested</c>): + </p> + + <code> +cli() -> + #{ + %% top level argument applicable to all commands + arguments => [#{name => top}], + commands => #{ + "first" => #{ + %% argument applicable to "first" command and + %% all commands nested into "first" + arguments => [#{name => mid}], + commands => #{ + "second" => #{ + %% argument only applicable for "second" command + arguments => [#{name => bottom}], + handler => fun (A) -> io:format("~p~n", [A]) end + } + } + } + } + }. + </code> + + <p>In the example above, a 3-level hierarchy is defined. First is the script + itself (<c>nested</c>), accepting the only argument <c>top</c>. Since it + has no associated handler, <seemfa marker="#run/3">run/3</seemfa> will + not accept user input omitting nested command selection. For this example, + user has to supply 5 arguments in the command line, two being command + names, and another 3 - required positional arguments:</p> + + <code> +./nested.erl one first second two three +#{top => "one",mid => "two",bottom => "three"} + </code> + + <p>Commands have preference over positional argument values. In the example + above, commands and positional arguments are interleaving, and <c>argparse</c> + matches command name first.</p> + + </section> + + <section> + <title>Arguments</title> + <p><c>argparse</c> supports positional and optional arguments. Optional arguments, + or options for short, must be prefixed with a special character (<c>-</c> is the default + on all operating systems). Both options and positional arguments have 1 or more associated + values. See <seetype marker="#argument"><c>argument specification</c></seetype> to + find more details about supported combinations.</p> + + <p>In the user input, short options may be concatenated with their values. Long + options support values separated by <c>=</c>. Consider this definition:</p> + + <code> +cli() -> + #{ + arguments => [ + #{name => long, long => "-long"}, + #{name => short, short => $s} + ], + handler => fun (Args) -> io:format("~p~n", [Args]) end + }. + </code> + + <p>Running <c>./args --long=VALUE</c> prints <c>#{long => "VALUE"}</c>, running + <c>./args -sVALUE</c> prints <c>#{short => "VALUE"}</c></p> + + <p><c>argparse</c> supports boolean flags concatenation: it is possible to shorten + <c>-r -f -v</c> to <c>-rfv</c>.</p> + + <p>Shortened option names are not supported: it is not possible to use <c>--my-argum</c> + instead of <c>--my-argument-name</c> even when such option can be unambiguously found.</p> + </section> + + <datatypes> + <datatype> + <name name="arg_type"/> + <desc> + <p>Defines type conversion applied to the string retrieved from the user input. + If the conversion is successful, resulting value is validated using optional + <c>Choices</c>, or minimums and maximums (for integer and floating point values + only). Strings and binary values may be validated using regular expressions. + It's possible to define custom type conversion function, accepting a string + and returning Erlang term. If this function raises error with <c>badarg</c> + reason, argument is treated as invalid. + </p> + </desc> + </datatype> + + <datatype> + <name name="argument_help"/> + <desc> + <p>User-defined help template to print in the command usage. First element of + a tuple must be a string. It is printed as a part of the usage header. Second + element of the tuple can be either a string printed as-is, a list + containing strings, <c>type</c> and <c>default</c> atoms, or a user-defined + function that must return a string.</p> + </desc> + </datatype> + + <datatype> + <name name="argument_name"/> + <desc> + <p>Argument name is used to populate argument map.</p> + </desc> + </datatype> + + <datatype> + <name name="argument"/> + <desc> + <p>Argument specification. Defines a single named argument that is returned + in the <seetype marker="#arg_map"><c>argument map</c></seetype>. The only + required field is <c>name</c>, all other fields have defaults.</p> + <p>If either of the <c>short</c> or <c>long</c> fields is specified, the + argument is treated as optional. Optional arguments do not have specific + order and may appear anywhere in the command line. Positional arguments + are ordered the same way as they appear in the arguments list of the command + specification.</p> + <p>By default, all positional arguments must be present in the command line. + The parser will return an error otherwise. Options, however, may be omitted, + in which case resulting argument map will either contain the default value, + or not have the key at all.</p> + <taglist> + <tag><c>name</c></tag> + <item> + <p>Sets the argument name in the parsed argument map. If <c>help</c> is not defined, + name is also used to generate the default usage message. + </p> + </item> + <tag><c>short</c></tag> + <item> + <p>Defines a short (single character) form of an optional argument.</p> + <code> +%% Define a command accepting argument named myarg, with short form $a: +1> Cmd = #{arguments => [#{name => myarg, short => $a}]}. +%% Parse command line "-a str": +2> {ok, ArgMap, _, _} = argparse:parse(["-a", "str"], Cmd), ArgMap. + +#{myarg => "str"} + +%% Option value can be concatenated with the switch: "-astr" +3> {ok, ArgMap, _, _} = argparse:parse(["-astr"], Cmd), ArgMap. + +#{myarg => "str"} + </code> + <p>By default all options expect a single value following the option switch. + The only exception is an option of a boolean type.</p> + </item> + <tag><c>long</c></tag> + <item> + <p>Defines a long form of an optional argument.</p> + <code> +1> Cmd = #{arguments => [#{name => myarg, long => "name"}]}. +%% Parse command line "-name Erlang": +2> {ok, ArgMap, _, _} = argparse:parse(["-name", "Erlang"], Cmd), ArgMap. + +#{myarg => "Erlang"} +%% Or use "=" to separate the switch and the value: +3> {ok, ArgMap, _, _} = argparse:parse(["-name=Erlang"], Cmd), ArgMap. + +#{myarg => "Erlang"} + </code> + <p>If neither <c>short</c> not <c>long</c> is defined, the + argument is treated as positional.</p> + </item> + <tag><c>required</c></tag> + <item> + <p>Forces the parser to expect the argument to be present in the + command line. By default, all positional argument are required, + and all options are not.</p> + </item> + <tag><c>default</c></tag> + <item> + <p>Specifies the default value to put in the parsed argument map + if the value is not supplied in the command line.</p> + <code> +1> argparse:parse([], #{arguments => [#{name => myarg, short => $m}]}). + +{ok,#{}, ... +2> argparse:parse([], #{arguments => [#{name => myarg, short => $m, default => "def"}]}). + +{ok,#{myarg => "def"}, ... + </code> + </item> + <tag><c>type</c></tag> + <item> + <p>Defines type conversion and validation routine. The default is <c>string</c>, + assuming no conversion.</p> + </item> + <tag><c>nargs</c></tag> + <item> + <p>Defines the number of following arguments to consume from the command line. + By default, the parser consumes the next argument and converts it into an + Erlang term according to the specified type. + </p> + <taglist> + <tag><c>pos_integer()</c></tag> + <item><p> Consume exactly this number of positional arguments, fail if there + is not enough. Value in the argument map contains a list of exactly this + length. Example, defining a positional argument expecting 3 integer values:</p> + <code> +1> Cmd = #{arguments => [#{name => ints, type => integer, nargs => 3}]}, +argparse:parse(["1", "2", "3"], Cmd). + +{ok, #{ints => [1, 2, 3]}, ... + </code> + <p>Another example defining an option accepted as <c>-env</c> and + expecting two string arguments:</p> + <code> +1> Cmd = #{arguments => [#{name => env, long => "env", nargs => 2}]}, +argparse:parse(["-env", "key", "value"], Cmd). + +{ok, #{env => ["key", "value"]}, ... + </code> + </item> + <tag><c>list</c></tag> + <item> + <p>Consume all following arguments until hitting the next option (starting + with an option prefix). May result in an empty list added to the arguments + map.</p> + <code> +1> Cmd = #{arguments => [ + #{name => nodes, long => "nodes", nargs => list}, + #{name => verbose, short => $v, type => boolean} +]}, +argparse:parse(["-nodes", "one", "two", "-v"], Cmd). + +{ok, #{nodes => ["one", "two"], verbose => true}, ... + </code> + </item> + <tag><c>nonempty_list</c></tag> + <item> + <p>Same as <c>list</c>, but expects at least one argument. Returns an error + if the following command line argument is an option switch (starting with the + prefix).</p> + </item> + <tag><c>'maybe'</c></tag> + <item> + <p>Consumes the next argument from the command line, if it does not start + with an option prefix. Otherwise, adds a default value to the arguments + map.</p> + <code> +1> Cmd = #{arguments => [ + #{name => level, short => $l, nargs => 'maybe', default => "error"}, + #{name => verbose, short => $v, type => boolean} +]}, +argparse:parse(["-l", "info", "-v"], Cmd). + +{ok,#{level => "info",verbose => true}, ... + +%% When "info" is omitted, argument maps receives the default "error" +2> argparse:parse(["-l", "-v"], Cmd). + +{ok,#{level => "error",verbose => true}, ... + </code> + </item> + <tag><c>{'maybe', term()}</c></tag> + <item> + <p>Consumes the next argument from the command line, if it does not start + with an option prefix. Otherwise, adds a specified Erlang term to the + arguments map.</p> + </item> + <tag><c>all</c></tag> + <item> + <p>Fold all remaining command line arguments into a list, ignoring + any option prefixes or switches. Useful for proxying arguments + into another command line utility.</p> + <code> +1> Cmd = #{arguments => [ + #{name => verbose, short => $v, type => boolean}, + #{name => raw, long => "-", nargs => all} +]}, +argparse:parse(["-v", "--", "-kernel", "arg", "opt"], Cmd). + +{ok,#{raw => ["-kernel","arg","opt"],verbose => true}, ... + </code> + </item> + </taglist> + </item> + <tag><c>action</c></tag> + <item> + <p>Defines an action to take when the argument is found in the command line. The + default action is <c>store</c>.</p> + <taglist> + <tag><c>store</c></tag> + <item><p> + Store the value in the arguments map. Overwrites the value previously written. + </p> + <code> +1> Cmd = #{arguments => [#{name => str, short => $s}]}, +argparse:parse(["-s", "one", "-s", "two"], Cmd). + +{ok, #{str => "two"}, ... + </code> + </item> + <tag><c>{store, term()}</c></tag> + <item><p> + Stores the specified term instead of reading the value from the command line. + </p> + <code> +1> Cmd = #{arguments => [#{name => str, short => $s, action => {store, "two"}}]}, +argparse:parse(["-s"], Cmd). + +{ok, #{str => "two"}, ... + </code> + </item> + <tag><c>append</c></tag> + <item><p> + Appends the repeating occurrences of the argument instead of overwriting. + </p> + <code> +1> Cmd = #{arguments => [#{name => node, short => $n, action => append}]}, +argparse:parse(["-n", "one", "-n", "two", "-n", "three"], Cmd). + +{ok, #{node => ["one", "two", "three"]}, ... + +%% Always produces a list - even if there is one occurrence +2> argparse:parse(["-n", "one"], Cmd). + +{ok, #{node => ["one"]}, ... + </code> + </item> + <tag><c>{append, term()}</c></tag> + <item><p> + Same as <c>append</c>, but instead of consuming the argument from the + command line, appends a provided <c>term()</c>. + </p></item> + <tag><c>count</c></tag> + <item><p> + Puts a counter as a value in the arguments map. Useful for implementing + verbosity option: + </p> + <code> +1> Cmd = #{arguments => [#{name => verbose, short => $v, action => count}]}, +argparse:parse(["-v"], Cmd). + +{ok, #{verbose => 1}, ... + +2> argparse:parse(["-vvvv"], Cmd). + +{ok, #{verbose => 4}, ... + </code> + </item> + <tag><c>extend</c></tag> + <item><p> + Works as <c>append</c>, but flattens the resulting list. + Valid only for <c>nargs</c> set to <c>list</c>, <c>nonempty_list</c>, + <c>all</c> or <c>pos_integer()</c>. + </p> + <code> +1> Cmd = #{arguments => [#{name => duet, short => $d, nargs => 2, action => extend}]}, +argparse:parse(["-d", "a", "b", "-d", "c", "d"], Cmd). + +{ok, #{duet => ["a", "b", "c", "d"]}, ... + +%% 'append' would result in {ok, #{duet => [["a", "b"],["c", "d"]]}, + </code> + </item> + </taglist> + </item> + <tag><c>help</c></tag> + <item> + <p>Specifies help/usage text for the argument. <c>argparse</c> provides automatic + generation based on the argument name, type and default value, but for better + usability it is recommended to have a proper description. Setting this field + to <c>hidden</c> suppresses usage output for this argument.</p> + </item> + </taglist> + </desc> + </datatype> + + <datatype> + <name name="arg_map"/> + <desc> + <p>Arguments map is the map of argument names to the values extracted from the + command line. It is passed to the matching command handler. + If an argument is omitted, but has the default value is specified, + it is added to the map. When no default value specified, and argument is not + present in the command line, corresponding key is not present in the resulting + map.</p> + </desc> + </datatype> + + <datatype> + <name name="handler"/> + <desc> + <p>Command handler specification. Called by <seemfa marker="#run/3"><c>run/3</c> + </seemfa> upon successful parser return.</p> + <taglist> + <tag><c>fun((arg_map()) -> term())</c></tag> + <item><p> + Function accepting <seetype marker="#arg_map"><c>argument map</c></seetype>. + See the basic example in the <seeerl marker="#quick-start">Quick Start</seeerl> + section. + </p></item> + <tag><c>{Module :: module(), Function :: atom()}</c></tag> + <item><p> + Function named <c>Function</c>, exported from <c>Module</c>, accepting + <seetype marker="#arg_map"><c>argument map</c></seetype>. + </p></item> + <tag><c>{fun(() -> term()), Default :: term()}</c></tag> + <item><p> + Function accepting as many arguments as there are in the <c>arguments</c> + list for this command. Arguments missing from the parsed map are replaced + with the <c>Default</c>. Convenient way to expose existing functions. + </p> + <code> +1> Cmd = #{arguments => [ + #{name => x, type => float}, + #{name => y, type => float, short => $p}], + handler => {fun math:pow/2, 1}}, +argparse:run(["2", "-p", "3"], Cmd, #{}). + +8.0 + +%% default term 1 is passed to math:pow/2 +2> argparse:run(["2"], Cmd, #{}). + +2.0 + </code> + </item> + <tag><c>{Module :: module(), Function :: atom(), Default :: term()}</c></tag> + <item><p>Function named <c>Function</c>, exported from <c>Module</c>, accepting + as many arguments as defined for this command. Arguments missing from the parsed + map are replaced with the <c>Default</c>. Effectively, just a different syntax + to the same functionality as demonstrated in the code above.</p></item> + </taglist> + </desc> + </datatype> + + <datatype> + <name name="command_help"/> + <desc> + <p>User-defined help template. Use this option to mix custom and predefined usage text. + Help template may contain unicode strings, and following atoms:</p> + <taglist> + <tag>usage</tag> + <item><p> + Formatted command line usage text, e.g. <c>rm [-rf] <directory></c>. + </p></item> + <tag>commands</tag> + <item><p> + Expanded list of sub-commands. + </p></item> + <tag>arguments</tag> + <item><p> + Detailed description of positional arguments. + </p></item> + <tag>options</tag> + <item><p> + Detailed description of optional arguments. + </p></item> + </taglist> + </desc> + </datatype> + + <datatype> + <name name="command"/> + <desc> + <p>Command specification. May contain nested commands, forming a hierarchy.</p> + <taglist> + <tag><c>commands</c></tag> + <item><p> + Maps of nested commands. Keys must be strings, matching command line input. + Basic utilities do not need to specify any nested commands. + </p> + </item> + <tag><c>arguments</c></tag> + <item><p> + List of arguments accepted by this command, and all nested commands in the + hierarchy. + </p></item> + <tag><c>help</c></tag> + <item><p> + Specifies help/usage text for this command. Pass <c>hidden</c> to remove + this command from the usage output. + </p></item> + <tag><c>handler</c></tag> + <item><p> + Specifies a callback function to call by <seemfa marker="#run/3">run/3</seemfa> + when the parser is successful. + </p></item> + </taglist> + </desc> + </datatype> + + <datatype> + <name name="cmd_path"/> + <desc> + <p>Path to the nested command. First element is always the <c>progname</c>, + subsequent elements are nested command names.</p> + </desc> + </datatype> + + <datatype> + <name name="parser_error"/> + <desc> + <p>Returned from <seemfa marker="#parse/3"><c>parse/2,3</c></seemfa> when the + user input cannot be parsed according to the command specification.</p> + <p>First element is the path to the command that was considered when the + parser detected an error. Second element, <c>Expected</c>, is the argument + specification that caused an error. It could be <c>undefined</c>, meaning + that <c>Actual</c> argument had no corresponding specification in the + arguments list for the current command. </p> + <p>When <c>Actual</c> is set to <c>undefined</c>, it means that a required + argument is missing from the command line. If both <c>Expected</c> and + <c>Actual</c> have values, it means validation error.</p> + <p>Use <seemfa marker="#format_error/1"><c>format_error/1</c></seemfa> to + generate a human-readable error description, unless there is a need to + provide localised error messages.</p> + </desc> + </datatype> + + <datatype> + <name name="parser_options"/> + <desc> + <p>Options changing parser behaviour.</p> + <taglist> + <tag><c>prefixes</c></tag> + <item><p> + Changes the option prefix (the default is <c>-</c>). + </p></item> + <tag><c>default</c></tag> + <item><p> + Specifies the default value for all optional arguments. When + this field is set, resulting argument map will contain all + argument names. Useful for easy pattern matching on the + argument map in the handler function. + </p></item> + <tag><c>progname</c></tag> + <item><p> + Specifies the program (root command) name. Returned as the + first element of the command path, and printed in help/usage + text. It is recommended to have this value set, otherwise the + default one is determined with <c>init:get_argument(progname)</c> + and is often set to <c>erl</c> instead of the actual script name. + </p></item> + <tag><c>command</c></tag> + <item><p> + Specifies the path to the nested command for + <seemfa marker="#help/2"><c>help/2</c></seemfa>. Useful to + limit output for complex utilities with multiple commands, + and used by the default error handling logic. + </p></item> + <tag><c>columns</c></tag> + <item><p> + Specifies the help/usage text width (characters) for + <seemfa marker="#help/2"><c>help/2</c></seemfa>. Default value + is 80. + </p></item> + </taglist> + </desc> + </datatype> + + <datatype> + <name name="parse_result"/> + <desc> + <p>Returned from <seemfa marker="#parse/3"><c>parse/2,3</c></seemfa>. Contains + arguments extracted from the command line, path to the nested command (if any), + and a (potentially nested) command specification that was considered when + the parser finished successfully. It is expected that the command contains + a handler definition, that will be called passing the argument map.</p> + </desc> + </datatype> + + </datatypes> + + <funcs> + + <func> + <name name="format_error" arity="1" since="OTP 26.0"/> + <fsummary>Generates human-readable text for parser errors.</fsummary> + <desc> + <p>Generates human-readable text for + <seetype marker="#parser_error"><c>parser error</c></seetype>. Does + not include help/usage information, and does not provide localisation. + </p> + </desc> + </func> + + <func> + <name name="help" arity="1" since="OTP 26.0"/> + <name name="help" arity="2" since="OTP 26.0"/> + <fsummary>Generates help/usage information text.</fsummary> + <desc> + <p>Generates help/usage information text for the command + supplied, or any nested command when <c>command</c> + option is specified. Does not provide localisaton. + Expects <c>progname</c> to be set, otherwise defaults to + return value of <c>init:get_argument(progname)</c>.</p> + </desc> + </func> + + <func> + <name name="parse" arity="2" since="OTP 26.0"/> + <name name="parse" arity="3" since="OTP 26.0"/> + <fsummary>Parses command line arguments according to the command specification.</fsummary> + <desc> + <p>Parses command line arguments according to the command specification. + Raises an exception if the command specification is not valid. Use + <seemfa marker="erl_error#format_exception/3"><c>erl_error:format_exception/3,4</c> + </seemfa> to see a friendlier message. Invalid command line input + does not raise an exception, but makes <c>parse/2,3</c> to return a tuple + <seetype marker="#parser_error"><c>{error, parser_error()}</c></seetype>. + </p> + <p>This function does not call command handler.</p> + </desc> + </func> + + <func> + <name name="run" arity="3" since="OTP 26.0"/> + <fsummary>Parses command line arguments and calls the matching command handler.</fsummary> + <desc> + <p>Parses command line arguments and calls the matching command handler. + Prints human-readable error, help/usage information for the discovered + command, and halts the emulator with code 1 if there is any error in the + command specification or user-provided command line input. + </p> + <warning> + <p>This function is designed to work as an entry point to a standalone + <seecom marker="erts:escript"><c>escript</c></seecom>. Therefore, it halts + the emulator for any error detected. Do not use this function through + remote procedure call, or it may result in an unexpected shutdown of a remote + node.</p> + </warning> + </desc> + </func> + + </funcs> + +</erlref> + diff --git a/lib/stdlib/doc/src/ref_man.xml b/lib/stdlib/doc/src/ref_man.xml index 961c5a0a77..04990db408 100644 --- a/lib/stdlib/doc/src/ref_man.xml +++ b/lib/stdlib/doc/src/ref_man.xml @@ -32,6 +32,7 @@ <description> </description> <xi:include href="stdlib_app.xml"/> + <xi:include href="argparse.xml"/> <xi:include href="array.xml"/> <xi:include href="assert_hrl.xml"/> <xi:include href="base64.xml"/> diff --git a/lib/stdlib/doc/src/specs.xml b/lib/stdlib/doc/src/specs.xml index 8279c5a5d8..fc19db4bf3 100644 --- a/lib/stdlib/doc/src/specs.xml +++ b/lib/stdlib/doc/src/specs.xml @@ -1,5 +1,6 @@ <?xml version="1.0" encoding="utf-8" ?> <specs xmlns:xi="http://www.w3.org/2001/XInclude"> + <xi:include href="../specs/specs_argparse.xml"/> <xi:include href="../specs/specs_array.xml"/> <xi:include href="../specs/specs_base64.xml"/> <xi:include href="../specs/specs_beam_lib.xml"/> diff --git a/lib/stdlib/src/Makefile b/lib/stdlib/src/Makefile index e546172856..abdb665b09 100644 --- a/lib/stdlib/src/Makefile +++ b/lib/stdlib/src/Makefile @@ -42,6 +42,7 @@ RELSYSDIR = $(RELEASE_PATH)/lib/stdlib-$(VSN) # ---------------------------------------------------- MODULES= \ array \ + argparse \ base64 \ beam_lib \ binary \ diff --git a/lib/stdlib/src/argparse.erl b/lib/stdlib/src/argparse.erl new file mode 100644 index 0000000000..7c7e14963e --- /dev/null +++ b/lib/stdlib/src/argparse.erl @@ -0,0 +1,1357 @@ +%% +%% +%% 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). +-author("maximfca@gmail.com"). + +%% API Exports +-export([ + run/3, + parse/2, parse/3, + help/1, help/2, + format_error/1 +]). + +%% Internal exports for validation and error reporting. +-export([validate/1, validate/2, format_error/2]). + +%%-------------------------------------------------------------------- +%% API + +-type arg_type() :: + boolean | + float | + {float, Choice :: [float()]} | + {float, [{min, float()} | {max, float()}]} | + integer | + {integer, Choices :: [integer()]} | + {integer, [{min, integer()} | {max, integer()}]} | + string | + {string, Choices :: [string()]} | + {string, Re :: string()} | + {string, Re :: string(), ReOptions :: [term()]} | + binary | + {binary, Choices :: [binary()]} | + {binary, Re :: binary()} | + {binary, Re :: binary(), ReOptions :: [term()]} | + atom | + {atom, Choices :: [atom()]} | + {atom, unsafe} | + {custom, fun((string()) -> term())}. +%% Built-in types include basic validation abilities +%% String and binary validation may use regex match (ignoring captured value). +%% For float, integer, string, binary and atom type, it is possible to specify +%% available choices instead of regex/min/max. + +-type argument_help() :: { + unicode:chardata(), %% short form, printed in command usage, e.g. "[--dir <dirname>]", developer is + %% responsible for proper formatting (e.g. adding <>, dots... and so on) + [unicode:chardata() | type | default] | fun(() -> unicode:chardata()) +}. +%% Help template definition for argument. Short and long forms exist for every argument. +%% Short form is printed together with command definition, e.g. "usage: rm [--force]", +%% while long description is printed in detailed section below: "--force forcefully remove". + +-type argument_name() :: atom() | string() | binary(). + +-type argument() :: #{ + %% Argument name, and a destination to store value too + %% It is allowed to have several arguments named the same, setting or appending to the same variable. + name := argument_name(), + + %% short, single-character variant of command line option, omitting dash (example: $b, meaning -b), + %% when present, the argument is considered optional + short => char(), + + %% long command line option, omitting first dash (example: "kernel" means "-kernel" in the command line) + %% long command always wins over short abbreviation (e.g. -kernel is considered before -k -e -r -n -e -l) + %% when present, the argument is considered optional + long => string(), + + %% makes parser to return an error if the argument is not present in the command line + required => boolean(), + + %% default value, produced if the argument is not present in the command line + %% parser also accepts a global default + default => term(), + + %% parameter type (string by default) + type => arg_type(), + + %% action to take when argument is matched + action => store | %% default: store argument consumed (last stored wins) + {store, term()} | %% does not consume argument, stores term() instead + append | %% appends consumed argument to a list + {append, term()} | %% does not consume an argument, appends term() to a list + count | %% does not consume argument, bumps counter + extend, %% uses when nargs is list/nonempty_list/all - appends every element to the list + + %% how many positional arguments to consume + nargs => + pos_integer() | %% consume exactly this amount, e.g. '-kernel key value' #{long => "-kernel", args => 2} + %% returns #{kernel => ["key", "value"]} + 'maybe' | %% if the next argument is positional, consume it, otherwise produce default + {'maybe', term()} | %% if the next argument is positional, consume it, otherwise produce term() + list | %% consume zero or more positional arguments, until next optional + nonempty_list | %% consume at least one positional argument, until next optional + all, %% fold remaining command line into this argument + + %% help string printed in usage, hidden help is not printed at all + help => hidden | unicode:chardata() | argument_help() +}. +%% Command line argument specification. +%% Argument can be optional - starting with - (dash), and positional. + +-type arg_map() :: #{argument_name() => term()}. +%% Arguments map: argument name to a term, produced by parser. Supplied to the command handler + +-type handler() :: + optional | %% valid for commands with sub-commands, suppresses parser error when no + %% sub-command is selected + 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()}. %% handler, positional form, exported from module() +%% Command handler. May produce some output. Can accept a map, or be +%% arbitrary mfa() for handlers accepting positional list. +%% Special value 'optional' may be used to suppress an error that +%% otherwise raised when command contains sub-commands, but arguments +%% supplied via command line do not select any. + +-type command_help() :: [unicode:chardata() | usage | commands | arguments | options]. +%% Template for the command help/usage message. + +%% Command descriptor +-type command() :: #{ + %% Sub-commands are arranged into maps. Command name must not start with <em>prefix</em>. + commands => #{string() => command()}, + %% accepted arguments list. Order is important! + arguments => [argument()], + %% help line + help => hidden | unicode:chardata() | command_help(), + %% recommended handler function + handler => handler() +}. + +-type cmd_path() :: [string()]. +%% Command path, for nested commands + +-export_type([arg_type/0, argument_help/0, argument/0, + command/0, handler/0, cmd_path/0, arg_map/0]). + +-type parser_error() :: {Path :: cmd_path(), + Expected :: argument() | undefined, + Actual :: string() | undefined, + Details :: unicode:chardata()}. +%% Returned from `parse/2,3' when command spec is valid, but the command line +%% cannot be parsed using the spec. +%% When `Expected' is undefined, but `Actual' is not, it means that the input contains +%% an unexpected argument which cannot be parsed according to command spec. +%% When `Expected' is an argument, and `Actual' is undefined, it means that a mandatory +%% argument is not provided in the command line. +%% When both `Expected' and `Actual' are defined, it means that the supplied argument +%% is failing validation. +%% When both are `undefined', there is some logical issue (e.g. a sub-command is required, +%% but was not selected). + +-type parser_options() :: #{ + %% allowed prefixes (default is [$-]). + prefixes => [char()], + %% default value for all missing optional arguments + default => term(), + %% root command name (program name) + progname => string() | atom(), + %% considered by `help/2' only + command => cmd_path(), %% command to print the help for + columns => pos_integer() %% viewport width, in characters +}. +%% Parser options + +-type parse_result() :: + {ok, arg_map(), Path :: cmd_path(), command()} | + {error, parser_error()}. +%% Parser result: argument map, path leading to successfully +%% matching command (contains only ["progname"] if there were +%% no subcommands matched), and a matching command. + +%% @equiv validate(Command, #{}) +-spec validate(command()) -> Progname :: string(). +validate(Command) -> + validate(Command, #{}). + +%% @doc Validate command specification, taking Options into account. +%% Raises an error if the command specification is invalid. +-spec validate(command(), parser_options()) -> Progname :: string(). +validate(Command, Options) -> + Prog = executable(Options), + is_list(Prog) orelse erlang:error(badarg, [Command, Options], + [{error_info, #{cause => #{2 => <<"progname is not valid">>}}}]), + Prefixes = maps:from_list([{P, true} || P <- maps:get(prefixes, Options, [$-])]), + validate_command([{Prog, Command}], Prefixes), + Prog. + +%% @equiv parse(Args, Command, #{}) +-spec parse(Args :: [string()], command()) -> parse_result(). +parse(Args, Command) -> + parse(Args, Command, #{}). + +%% @doc Parses supplied arguments according to expected command specification. +%% @param Args command line arguments (e.g. `init:get_plain_arguments()') +%% @returns argument map, or argument map with deepest matched command +%% definition. +-spec parse(Args :: [string()], command(), Options :: parser_options()) -> parse_result(). +parse(Args, Command, Options) -> + Prog = validate(Command, Options), + %% use maps and not sets v2, because sets:is_element/2 cannot be used in guards (unlike is_map_key) + Prefixes = maps:from_list([{P, true} || P <- maps:get(prefixes, Options, [$-])]), + try + parse_impl(Args, merge_arguments(Prog, Command, init_parser(Prefixes, Command, Options))) + catch + %% Parser error may happen at any depth, and bubbling the error is really + %% cumbersome. Use exceptions and catch it before returning from `parse/2,3' instead. + throw:Reason -> + {error, Reason} + end. + +%% @equiv help(Command, #{}) +-spec help(command()) -> string(). +help(Command) -> + help(Command, #{}). + +%% @doc Returns help for Command formatted according to Options specified +-spec help(command(), parser_options()) -> unicode:chardata(). +help(Command, Options) -> + Prog = validate(Command, Options), + format_help({Prog, Command}, Options). + +%% @doc +-spec run(Args :: [string()], command(), parser_options()) -> term(). +run(Args, Command, Options) -> + try parse(Args, Command, Options) of + {ok, ArgMap, Path, SubCmd} -> + handle(Command, ArgMap, tl(Path), SubCmd); + {error, Reason} -> + io:format("error: ~ts~n", [argparse:format_error(Reason)]), + io:format("~ts", [argparse:help(Command, Options#{command => tl(element(1, Reason))})]), + erlang:halt(1) + catch + error:Reason:Stack -> + io:format(erl_error:format_exception(error, Reason, Stack)), + erlang:halt(1) + end. + +%% @doc Basic formatter for the parser error reason. +-spec format_error(Reason :: parser_error()) -> unicode:chardata(). +format_error({Path, undefined, undefined, Details}) -> + io_lib:format("~ts: ~ts", [format_path(Path), Details]); +format_error({Path, undefined, Actual, Details}) -> + io_lib:format("~ts: unknown argument: ~ts~ts", [format_path(Path), Actual, Details]); +format_error({Path, #{name := Name}, undefined, Details}) -> + io_lib:format("~ts: required argument missing: ~ts~ts", [format_path(Path), Name, Details]); +format_error({Path, #{name := Name}, Value, Details}) -> + io_lib:format("~ts: invalid argument for ~ts: ~ts ~ts", [format_path(Path), Name, Value, Details]). + +-type validator_error() :: + {?MODULE, command | argument, cmd_path(), Field :: atom(), Detail :: unicode:chardata()}. + +%% @doc Transforms exception thrown by `validate/1,2' according to EEP54. +%% Use `erl_error:format_exception/3,4' to get the shell-like output. +-spec format_error(Reason :: validator_error(), erlang:stacktrace()) -> map(). +format_error({?MODULE, command, Path, Field, Reason}, [{_M, _F, [Cmd], Info} | _]) -> + #{cause := Cause} = proplists:get_value(error_info, Info, #{}), + Cause#{general => <<"command specification is invalid">>, 1 => io_lib:format("~tp", [Cmd]), + reason => io_lib:format("command \"~ts\": invalid field '~ts', reason: ~ts", [format_path(Path), Field, Reason])}; +format_error({?MODULE, argument, Path, Field, Reason}, [{_M, _F, [Arg], Info} | _]) -> + #{cause := Cause} = proplists:get_value(error_info, Info, #{}), + ArgName = maps:get(name, Arg, ""), + Cause#{general => "argument specification is invalid", 1 => io_lib:format("~tp", [Arg]), + reason => io_lib:format("command \"~ts\", argument '~ts', invalid field '~ts': ~ts", + [format_path(Path), ArgName, Field, Reason])}. + +%%-------------------------------------------------------------------- +%% Parser implementation + +%% Parser state (not available via API) +-record(eos, { + %% prefix character map, by default, only - + prefixes :: #{char() => true}, + %% argument map to be returned + argmap = #{} :: arg_map(), + %% sub-commands, in reversed orders, allowing to recover the path taken + commands = [] :: cmd_path(), + %% command being matched + current :: command(), + %% unmatched positional arguments, in the expected match order + pos = [] :: [argument()], + %% expected optional arguments, mapping between short/long form and an argument + short = #{} :: #{integer() => argument()}, + long = #{} :: #{string() => argument()}, + %% flag, whether there are no options that can be confused with negative numbers + no_digits = true :: boolean(), + %% global default for not required arguments + default :: error | {ok, term()} +}). + +init_parser(Prefixes, Cmd, Options) -> + #eos{prefixes = Prefixes, current = Cmd, default = maps:find(default, Options)}. + +%% Optional or positional argument? +-define(IS_OPTION(Arg), is_map_key(short, Arg) orelse is_map_key(long, Arg)). + +%% helper function to match either a long form of "--arg=value", or just "--arg" +match_long(Arg, LongOpts) -> + case maps:find(Arg, LongOpts) of + {ok, Option} -> + {ok, Option}; + error -> + %% see if there is '=' equals sign in the Arg + case string:split(Arg, "=") of + [MaybeLong, Value] -> + case maps:find(MaybeLong, LongOpts) of + {ok, Option} -> + {ok, Option, Value}; + error -> + nomatch + end; + _ -> + nomatch + end + end. + +%% parse_impl implements entire internal parse logic. + +%% Clause: option starting with any prefix +%% No separate clause for single-character short form, because there could be a single-character +%% long form taking precedence. +parse_impl([[Prefix | Name] | Tail], #eos{prefixes = Pref} = Eos) when is_map_key(Prefix, Pref) -> + %% match "long" option from the list of currently known + case match_long(Name, Eos#eos.long) of + {ok, Option} -> + consume(Tail, Option, Eos); + {ok, Option, Value} -> + consume([Value | Tail], Option, Eos); + nomatch -> + %% try to match single-character flag + case Name of + [Flag] when is_map_key(Flag, Eos#eos.short) -> + %% found a flag + consume(Tail, maps:get(Flag, Eos#eos.short), Eos); + [Flag | Rest] when is_map_key(Flag, Eos#eos.short) -> + %% can be a combination of flags, or flag with value, + %% but can never be a negative integer, because otherwise + %% it will be reflected in no_digits + case abbreviated(Name, [], Eos#eos.short) of + false -> + %% short option with Rest being an argument + consume([Rest | Tail], maps:get(Flag, Eos#eos.short), Eos); + Expanded -> + %% expand multiple flags into actual list, adding prefix + parse_impl([[Prefix,E] || E <- Expanded] ++ Tail, Eos) + end; + MaybeNegative when Prefix =:= $-, Eos#eos.no_digits -> + case is_digits(MaybeNegative) of + true -> + %% found a negative number + parse_positional([Prefix|Name], Tail, Eos); + false -> + catch_all_positional([[Prefix|Name] | Tail], Eos) + end; + _Unknown -> + catch_all_positional([[Prefix|Name] | Tail], Eos) + end + end; + +%% Arguments not starting with Prefix: attempt to match sub-command, if available +parse_impl([Positional | Tail], #eos{current = #{commands := SubCommands}} = Eos) -> + case maps:find(Positional, SubCommands) of + error -> + %% sub-command not found, try positional argument + parse_positional(Positional, Tail, Eos); + {ok, SubCmd} -> + %% found matching sub-command with arguments, descend into it + parse_impl(Tail, merge_arguments(Positional, SubCmd, Eos)) + end; + +%% Clause for arguments that don't have sub-commands (therefore check for +%% positional argument). +parse_impl([Positional | Tail], Eos) -> + parse_positional(Positional, Tail, Eos); + +%% Entire command line has been matched, go over missing arguments, +%% add defaults etc +parse_impl([], #eos{argmap = ArgMap0, commands = Commands, current = Current, pos = Pos, default = Def} = Eos) -> + %% error if stopped at sub-command with no handler + map_size(maps:get(commands, Current, #{})) >0 andalso + (not is_map_key(handler, Current)) andalso + throw({Commands, undefined, undefined, <<"subcommand expected">>}), + + %% go over remaining positional, verify they are all not required + ArgMap1 = fold_args_map(Commands, true, ArgMap0, Pos, Def), + %% go over optionals, and either raise an error, or set default + ArgMap2 = fold_args_map(Commands, false, ArgMap1, maps:values(Eos#eos.short), Def), + ArgMap3 = fold_args_map(Commands, false, ArgMap2, maps:values(Eos#eos.long), Def), + + %% return argument map, command path taken, and the deepest + %% last command matched (usually it contains a handler to run) + {ok, ArgMap3, Eos#eos.commands, Eos#eos.current}. + +%% Generate error for missing required argument, and supply defaults for +%% missing optional arguments that have defaults. +fold_args_map(Commands, Req, ArgMap, Args, GlobalDefault) -> + lists:foldl( + fun (#{name := Name}, Acc) when is_map_key(Name, Acc) -> + %% argument present + Acc; + (#{required := true} = Opt, _Acc) -> + %% missing, and required explicitly + throw({Commands, Opt, undefined, <<>>}); + (#{name := Name, required := false, default := Default}, Acc) -> + %% explicitly not required argument with default + Acc#{Name => Default}; + (#{name := Name, required := false}, Acc) -> + %% explicitly not required with no local default, try global one + try_global_default(Name, Acc, GlobalDefault); + (#{name := Name, default := Default}, Acc) when Req =:= true -> + %% positional argument with default + Acc#{Name => Default}; + (Opt, _Acc) when Req =:= true -> + %% missing, for positional argument, implicitly required + throw({Commands, Opt, undefined, <<>>}); + (#{name := Name, default := Default}, Acc) -> + %% missing, optional, and there is a default + Acc#{Name => Default}; + (#{name := Name}, Acc) -> + %% missing, optional, no local default, try global default + try_global_default(Name, Acc, GlobalDefault) + end, ArgMap, Args). + +try_global_default(_Name, Acc, error) -> + Acc; +try_global_default(Name, Acc, {ok, Term}) -> + Acc#{Name => Term}. + +%%-------------------------------------------------------------------- +%% argument consumption (nargs) handling + +catch_all_positional(Tail, #eos{pos = [#{nargs := all} = Opt]} = Eos) -> + action([], Tail, Opt#{type => {list, maps:get(type, Opt, string)}}, Eos); +%% it is possible that some positional arguments are not required, +%% and therefore it is possible to catch all skipping those +catch_all_positional(Tail, #eos{argmap = Args, pos = [#{name := Name, default := Default, required := false} | Pos]} = Eos) -> + catch_all_positional(Tail, Eos#eos{argmap = Args#{Name => Default}, pos = Pos}); +%% same as above, but no default specified +catch_all_positional(Tail, #eos{pos = [#{required := false} | Pos]} = Eos) -> + catch_all_positional(Tail, Eos#eos{pos = Pos}); +catch_all_positional([Arg | _Tail], #eos{commands = Commands}) -> + throw({Commands, undefined, Arg, <<>>}). + +parse_positional(Arg, _Tail, #eos{pos = [], commands = Commands}) -> + throw({Commands, undefined, Arg, <<>>}); +parse_positional(Arg, Tail, #eos{pos = Pos} = Eos) -> + %% positional argument itself is a value + consume([Arg | Tail], hd(Pos), Eos). + +%% Adds CmdName to path, and includes any arguments found there +merge_arguments(CmdName, #{arguments := Args} = SubCmd, Eos) -> + add_args(Args, Eos#eos{current = SubCmd, commands = Eos#eos.commands ++ [CmdName]}); +merge_arguments(CmdName, SubCmd, Eos) -> + Eos#eos{current = SubCmd, commands = Eos#eos.commands ++ [CmdName]}. + +%% adds arguments into current set of discovered pos/opts +add_args([], Eos) -> + Eos; +add_args([#{short := S, long := L} = Option | Tail], #eos{short = Short, long = Long} = Eos) -> + %% remember if this option can be confused with negative number + NoDigits = no_digits(Eos#eos.no_digits, Eos#eos.prefixes, S, L), + add_args(Tail, Eos#eos{short = Short#{S => Option}, long = Long#{L => Option}, no_digits = NoDigits}); +add_args([#{short := S} = Option | Tail], #eos{short = Short} = Eos) -> + %% remember if this option can be confused with negative number + NoDigits = no_digits(Eos#eos.no_digits, Eos#eos.prefixes, S, 0), + add_args(Tail, Eos#eos{short = Short#{S => Option}, no_digits = NoDigits}); +add_args([#{long := L} = Option | Tail], #eos{long = Long} = Eos) -> + %% remember if this option can be confused with negative number + NoDigits = no_digits(Eos#eos.no_digits, Eos#eos.prefixes, 0, L), + add_args(Tail, Eos#eos{long = Long#{L => Option}, no_digits = NoDigits}); +add_args([PosOpt | Tail], #eos{pos = Pos} = Eos) -> + add_args(Tail, Eos#eos{pos = Pos ++ [PosOpt]}). + +%% If no_digits is still true, try to find out whether it should turn false, +%% because added options look like negative numbers, and prefixes include - +no_digits(false, _, _, _) -> + false; +no_digits(true, Prefixes, _, _) when not is_map_key($-, Prefixes) -> + true; +no_digits(true, _, Short, _) when Short >= $0, Short =< $9 -> + false; +no_digits(true, _, _, Long) -> + not is_digits(Long). + +%%-------------------------------------------------------------------- +%% additional functions for optional arguments processing + +%% Returns true when option (!) description passed requires a positional argument, +%% hence cannot be treated as a flag. +requires_argument(#{nargs := {'maybe', _Term}}) -> + false; +requires_argument(#{nargs := 'maybe'}) -> + false; +requires_argument(#{nargs := _Any}) -> + true; +requires_argument(Opt) -> + case maps:get(action, Opt, store) of + store -> + maps:get(type, Opt, string) =/= boolean; + append -> + maps:get(type, Opt, string) =/= boolean; + _ -> + false + end. + +%% Attempts to find if passed list of flags can be expanded +abbreviated([Last], Acc, AllShort) when is_map_key(Last, AllShort) -> + lists:reverse([Last | Acc]); +abbreviated([_], _Acc, _Eos) -> + false; +abbreviated([Flag | Tail], Acc, AllShort) -> + case maps:find(Flag, AllShort) of + error -> + false; + {ok, Opt} -> + case requires_argument(Opt) of + true -> + false; + false -> + abbreviated(Tail, [Flag | Acc], AllShort) + end + end. + +%%-------------------------------------------------------------------- +%% argument consumption (nargs) handling + +%% consume predefined amount (none of which can be an option?) +consume(Tail, #{nargs := Count} = Opt, Eos) when is_integer(Count) -> + {Consumed, Remain} = split_to_option(Tail, Count, Eos, []), + length(Consumed) < Count andalso + throw({Eos#eos.commands, Opt, Tail, + io_lib:format("expected ~b, found ~b argument(s)", [Count, length(Consumed)])}), + action(Remain, Consumed, Opt#{type => {list, maps:get(type, Opt, string)}}, Eos); + +%% handle 'reminder' by just dumping everything in +consume(Tail, #{nargs := all} = Opt, Eos) -> + action([], Tail, Opt#{type => {list, maps:get(type, Opt, string)}}, Eos); + +%% require at least one argument +consume(Tail, #{nargs := nonempty_list} = Opt, Eos) -> + {Consumed, Remains} = split_to_option(Tail, -1, Eos, []), + Consumed =:= [] andalso throw({Eos#eos.commands, Opt, Tail, <<"expected argument">>}), + action(Remains, Consumed, Opt#{type => {list, maps:get(type, Opt, string)}}, Eos); + +%% consume all until next option +consume(Tail, #{nargs := list} = Opt, Eos) -> + {Consumed, Remains} = split_to_option(Tail, -1, Eos, []), + action(Remains, Consumed, Opt#{type => {list, maps:get(type, Opt, string)}}, Eos); + +%% maybe consume one, maybe not... +%% special cases for 'boolean maybe', only consume 'true' and 'false' +consume(["true" | Tail], #{type := boolean} = Opt, Eos) -> + action(Tail, true, Opt#{type => raw}, Eos); +consume(["false" | Tail], #{type := boolean} = Opt, Eos) -> + action(Tail, false, Opt#{type => raw}, Eos); +consume(Tail, #{type := boolean} = Opt, Eos) -> + %% neither true nor false means 'undefined' (with the default for boolean being true) + action(Tail, undefined, Opt, Eos); + +%% maybe behaviour, as '?' +consume(Tail, #{nargs := 'maybe'} = Opt, Eos) -> + case split_to_option(Tail, 1, Eos, []) of + {[], _} -> + %% no argument given, produce default argument (if not present, + %% then produce default value of the specified type) + action(Tail, default(Opt), Opt#{type => raw}, Eos); + {[Consumed], Remains} -> + action(Remains, Consumed, Opt, Eos) + end; + +%% maybe consume one, maybe not... +consume(Tail, #{nargs := {'maybe', Const}} = Opt, Eos) -> + case split_to_option(Tail, 1, Eos, []) of + {[], _} -> + action(Tail, Const, Opt, Eos); + {[Consumed], Remains} -> + action(Remains, Consumed, Opt, Eos) + end; + +%% default case, which depends on action +consume(Tail, #{action := count} = Opt, Eos) -> + action(Tail, undefined, Opt, Eos); + +%% for {store, ...} and {append, ...} don't take argument out +consume(Tail, #{action := {Act, _Const}} = Opt, Eos) when Act =:= store; Act =:= append -> + action(Tail, undefined, Opt, Eos); + +%% optional: ensure not to consume another option start +consume([[Prefix | _] = ArgValue | Tail], Opt, Eos) when ?IS_OPTION(Opt), is_map_key(Prefix, Eos#eos.prefixes) -> + case Eos#eos.no_digits andalso is_digits(ArgValue) of + true -> + action(Tail, ArgValue, Opt, Eos); + false -> + throw({Eos#eos.commands, Opt, undefined, <<"expected argument">>}) + end; + +consume([ArgValue | Tail], Opt, Eos) -> + action(Tail, ArgValue, Opt, Eos); + +%% we can only be here if it's optional argument, but there is no value supplied, +%% and type is not 'boolean' - this is an error! +consume([], Opt, Eos) -> + throw({Eos#eos.commands, Opt, undefined, <<"expected argument">>}). + +%% no more arguments for consumption, but last optional may still be action-ed +%%consume([], Current, Opt, Eos) -> +%% action([], Current, undefined, Opt, Eos). + +%% smart split: ignore arguments that can be parsed as negative numbers, +%% unless there are arguments that look like negative numbers +split_to_option([], _, _Eos, Acc) -> + {lists:reverse(Acc), []}; +split_to_option(Tail, 0, _Eos, Acc) -> + {lists:reverse(Acc), Tail}; +split_to_option([[Prefix | _] = MaybeNumber | Tail] = All, Left, + #eos{no_digits = true, prefixes = Prefixes} = Eos, Acc) when is_map_key(Prefix, Prefixes) -> + case is_digits(MaybeNumber) of + true -> + split_to_option(Tail, Left - 1, Eos, [MaybeNumber | Acc]); + false -> + {lists:reverse(Acc), All} + end; +split_to_option([[Prefix | _] | _] = All, _Left, + #eos{no_digits = false, prefixes = Prefixes}, Acc) when is_map_key(Prefix, Prefixes) -> + {lists:reverse(Acc), All}; +split_to_option([Head | Tail], Left, Opts, Acc) -> + split_to_option(Tail, Left - 1, Opts, [Head | Acc]). + +%%-------------------------------------------------------------------- +%% Action handling + +action(Tail, ArgValue, #{name := ArgName, action := store} = Opt, #eos{argmap = ArgMap} = Eos) -> + Value = convert_type(maps:get(type, Opt, string), ArgValue, Opt, Eos), + continue_parser(Tail, Opt, Eos#eos{argmap = ArgMap#{ArgName => Value}}); + +action(Tail, undefined, #{name := ArgName, action := {store, Value}} = Opt, #eos{argmap = ArgMap} = Eos) -> + continue_parser(Tail, Opt, Eos#eos{argmap = ArgMap#{ArgName => Value}}); + +action(Tail, ArgValue, #{name := ArgName, action := append} = Opt, #eos{argmap = ArgMap} = Eos) -> + Value = convert_type(maps:get(type, Opt, string), ArgValue, Opt, Eos), + continue_parser(Tail, Opt, Eos#eos{argmap = ArgMap#{ArgName => maps:get(ArgName, ArgMap, []) ++ [Value]}}); + +action(Tail, undefined, #{name := ArgName, action := {append, Value}} = Opt, #eos{argmap = ArgMap} = Eos) -> + continue_parser(Tail, Opt, Eos#eos{argmap = ArgMap#{ArgName => maps:get(ArgName, ArgMap, []) ++ [Value]}}); + +action(Tail, ArgValue, #{name := ArgName, action := extend} = Opt, #eos{argmap = ArgMap} = Eos) -> + Value = convert_type(maps:get(type, Opt, string), ArgValue, Opt, Eos), + Extended = maps:get(ArgName, ArgMap, []) ++ Value, + continue_parser(Tail, Opt, Eos#eos{argmap = ArgMap#{ArgName => Extended}}); + +action(Tail, _, #{name := ArgName, action := count} = Opt, #eos{argmap = ArgMap} = Eos) -> + continue_parser(Tail, Opt, Eos#eos{argmap = ArgMap#{ArgName => maps:get(ArgName, ArgMap, 0) + 1}}); + +%% default action is `store' (important to sync the code with the first clause above) +action(Tail, ArgValue, #{name := ArgName} = Opt, #eos{argmap = ArgMap} = Eos) -> + Value = convert_type(maps:get(type, Opt, string), ArgValue, Opt, Eos), + continue_parser(Tail, Opt, Eos#eos{argmap = ArgMap#{ArgName => Value}}). + +%% pop last positional, unless nargs is list/nonempty_list +continue_parser(Tail, Opt, Eos) when ?IS_OPTION(Opt) -> + parse_impl(Tail, Eos); +continue_parser(Tail, #{nargs := List}, Eos) when List =:= list; List =:= nonempty_list -> + parse_impl(Tail, Eos); +continue_parser(Tail, _Opt, Eos) -> + parse_impl(Tail, Eos#eos{pos = tl(Eos#eos.pos)}). + +%%-------------------------------------------------------------------- +%% Type conversion + +%% Handle "list" variant for nargs returning list +convert_type({list, Type}, Arg, Opt, Eos) -> + [convert_type(Type, Var, Opt, Eos) || Var <- Arg]; + +%% raw - no conversion applied (most likely default) +convert_type(raw, Arg, _Opt, _Eos) -> + Arg; + +%% Handle actual types +convert_type(string, Arg, _Opt, _Eos) -> + Arg; +convert_type({string, Choices}, Arg, Opt, Eos) when is_list(Choices), is_list(hd(Choices)) -> + lists:member(Arg, Choices) orelse + throw({Eos#eos.commands, Opt, Arg, <<"is not one of the choices">>}), + Arg; +convert_type({string, Re}, Arg, Opt, Eos) -> + case re:run(Arg, Re) of + {match, _X} -> Arg; + _ -> throw({Eos#eos.commands, Opt, Arg, <<"does not match">>}) + end; +convert_type({string, Re, ReOpt}, Arg, Opt, Eos) -> + case re:run(Arg, Re, ReOpt) of + match -> Arg; + {match, _} -> Arg; + _ -> throw({Eos#eos.commands, Opt, Arg, <<"does not match">>}) + end; +convert_type(integer, Arg, Opt, Eos) -> + get_int(Arg, Opt, Eos); +convert_type({integer, Opts}, Arg, Opt, Eos) -> + minimax(get_int(Arg, Opt, Eos), Opts, Eos, Opt, Arg); +convert_type(boolean, "true", _Opt, _Eos) -> + true; +convert_type(boolean, undefined, _Opt, _Eos) -> + true; +convert_type(boolean, "false", _Opt, _Eos) -> + false; +convert_type(boolean, Arg, Opt, Eos) -> + throw({Eos#eos.commands, Opt, Arg, <<"is not a boolean">>}); +convert_type(binary, Arg, _Opt, _Eos) -> + unicode:characters_to_binary(Arg); +convert_type({binary, Choices}, Arg, Opt, Eos) when is_list(Choices), is_binary(hd(Choices)) -> + Conv = unicode:characters_to_binary(Arg), + lists:member(Conv, Choices) orelse + throw({Eos#eos.commands, Opt, Arg, <<"is not one of the choices">>}), + Conv; +convert_type({binary, Re}, Arg, Opt, Eos) -> + case re:run(Arg, Re) of + {match, _X} -> unicode:characters_to_binary(Arg); + _ -> throw({Eos#eos.commands, Opt, Arg, <<"does not match">>}) + end; +convert_type({binary, Re, ReOpt}, Arg, Opt, Eos) -> + case re:run(Arg, Re, ReOpt) of + match -> unicode:characters_to_binary(Arg); + {match, _} -> unicode:characters_to_binary(Arg); + _ -> throw({Eos#eos.commands, Opt, Arg, <<"does not match">>}) + end; +convert_type(float, Arg, Opt, Eos) -> + get_float(Arg, Opt, Eos); +convert_type({float, Opts}, Arg, Opt, Eos) -> + minimax(get_float(Arg, Opt, Eos), Opts, Eos, Opt, Arg); +convert_type(atom, Arg, Opt, Eos) -> + try list_to_existing_atom(Arg) + catch error:badarg -> + throw({Eos#eos.commands, Opt, Arg, <<"is not an existing atom">>}) + end; +convert_type({atom, unsafe}, Arg, _Opt, _Eos) -> + list_to_atom(Arg); +convert_type({atom, Choices}, Arg, Opt, Eos) -> + try + Atom = list_to_existing_atom(Arg), + lists:member(Atom, Choices) orelse throw({Eos#eos.commands, Opt, Arg, <<"is not one of the choices">>}), + Atom + catch error:badarg -> + throw({Eos#eos.commands, Opt, Arg, <<"is not an existing atom">>}) + end; +convert_type({custom, Fun}, Arg, Opt, Eos) -> + try Fun(Arg) + catch error:badarg -> + throw({Eos#eos.commands, Opt, Arg, <<"failed faildation">>}) + end. + +%% Given Var, and list of {min, X}, {max, Y}, ensure that +%% value falls within defined limits. +minimax(Var, [], _Eos, _Opt, _Orig) -> + Var; +minimax(Var, [{min, Min} | _], Eos, Opt, Orig) when Var < Min -> + throw({Eos#eos.commands, Opt, Orig, <<"is less than accepted minimum">>}); +minimax(Var, [{max, Max} | _], Eos, Opt, Orig) when Var > Max -> + throw({Eos#eos.commands, Opt, Orig, <<"is greater than accepted maximum">>}); +minimax(Var, [Num | Tail], Eos, Opt, Orig) when is_number(Num) -> + lists:member(Var, [Num|Tail]) orelse + throw({Eos#eos.commands, Opt, Orig, <<"is not one of the choices">>}), + Var; +minimax(Var, [_ | Tail], Eos, Opt, Orig) -> + minimax(Var, Tail, Eos, Opt, Orig). + +%% returns integer from string, or errors out with debugging info +get_int(Arg, Opt, Eos) -> + case string:to_integer(Arg) of + {Int, []} -> + Int; + _ -> + throw({Eos#eos.commands, Opt, Arg, <<"is not an integer">>}) + end. + +%% returns float from string, that is floating-point, or integer +get_float(Arg, Opt, Eos) -> + case string:to_float(Arg) of + {Float, []} -> + Float; + _ -> + %% possibly in disguise + case string:to_integer(Arg) of + {Int, []} -> + Int; + _ -> + throw({Eos#eos.commands, Opt, Arg, <<"is not a number">>}) + end + end. + +%% Returns 'true' if String can be converted to a number +is_digits(String) -> + case string:to_integer(String) of + {_Int, []} -> + true; + {_, _} -> + case string:to_float(String) of + {_Float, []} -> + true; + {_, _} -> + false + end + end. + +%% 'maybe' nargs for an option that does not have default set still have +%% to produce something, let's call it hardcoded default. +default(#{default := Default}) -> + Default; +default(#{type := boolean}) -> + true; +default(#{type := integer}) -> + 0; +default(#{type := float}) -> + 0.0; +default(#{type := string}) -> + ""; +default(#{type := binary}) -> + <<"">>; +default(#{type := atom}) -> + undefined; +%% no type given, consider it 'undefined' atom +default(_) -> + undefined. + +%% command path is now in direct order +format_path(Commands) -> + lists:join(" ", Commands). + +%%-------------------------------------------------------------------- +%% Validation and preprocessing +%% Theoretically, Dialyzer should do that too. +%% Practically, so many people ignore Dialyzer and then spend hours +%% trying to understand why things don't work, that is makes sense +%% to provide a mini-Dialyzer here. + +%% to simplify throwing errors with the right reason +-define (INVALID(Kind, Entity, Path, Field, Text), + erlang:error({?MODULE, Kind, clean_path(Path), Field, Text}, [Entity], [{error_info, #{cause => #{}}}])). + +executable(#{progname := Prog}) when is_atom(Prog) -> + atom_to_list(Prog); +executable(#{progname := Prog}) when is_binary(Prog) -> + binary_to_list(Prog); +executable(#{progname := Prog}) -> + Prog; +executable(_) -> + {ok, [[Prog]]} = init:get_argument(progname), + Prog. + +%% Recursive command validator +validate_command([{Name, Cmd} | _] = Path, Prefixes) -> + (is_list(Name) andalso (not is_map_key(hd(Name), Prefixes))) orelse + ?INVALID(command, Cmd, tl(Path), commands, + <<"command name must be a string not starting with option prefix">>), + is_map(Cmd) orelse + ?INVALID(command, Cmd, Path, commands, <<"expected command()">>), + is_valid_command_help(maps:get(help, Cmd, [])) orelse + ?INVALID(command, Cmd, Path, help, <<"must be a printable unicode list, or a command help template">>), + is_map(maps:get(commands, Cmd, #{})) orelse + ?INVALID(command, Cmd, Path, commands, <<"expected map of #{string() => command()}">>), + case maps:get(handler, Cmd, optional) of + optional -> ok; + {Mod, ModFun} when is_atom(Mod), is_atom(ModFun) -> ok; %% map form + {Mod, ModFun, _} when is_atom(Mod), is_atom(ModFun) -> ok; %% positional form + {Fun, _} when is_function(Fun) -> ok; %% positional form + Fun when is_function(Fun, 1) -> ok; + _ -> ?INVALID(command, Cmd, Path, handler, <<"handler must be a valid callback, or an atom 'optional'">>) + end, + Cmd1 = + case maps:find(arguments, Cmd) of + error -> + Cmd; + {ok, Opts} when not is_list(Opts) -> + ?INVALID(command, Cmd, Path, arguments, <<"expected a list, [argument()]">>); + {ok, Opts} -> + Cmd#{arguments => [validate_option(Path, Opt) || Opt <- Opts]} + end, + %% collect all short & long option identifiers - to figure out any conflicts + lists:foldl( + fun ({_, #{arguments := Opts}}, Acc) -> + lists:foldl( + fun (#{short := Short, name := OName} = Arg, {AllS, AllL}) -> + is_map_key(Short, AllS) andalso + ?INVALID(argument, Arg, Path, short, + "short conflicting with previously defined short for " + ++ atom_to_list(maps:get(Short, AllS))), + {AllS#{Short => OName}, AllL}; + (#{long := Long, name := OName} = Arg, {AllS, AllL}) -> + is_map_key(Long, AllL) andalso + ?INVALID(argument, Arg, Path, long, + "long conflicting with previously defined long for " + ++ atom_to_list(maps:get(Long, AllL))), + {AllS, AllL#{Long => OName}}; + (_, AccIn) -> + AccIn + end, Acc, Opts); + (_, Acc) -> + Acc + end, {#{}, #{}}, Path), + %% verify all sub-commands + case maps:find(commands, Cmd1) of + error -> + {Name, Cmd1}; + {ok, Sub} -> + {Name, Cmd1#{commands => maps:map( + fun (K, V) -> + {K, Updated} = validate_command([{K, V} | Path], Prefixes), + Updated + end, Sub)}} + end. + +%% validates option spec +validate_option(Path, #{name := Name} = Arg) when is_atom(Name); is_list(Name); is_binary(Name) -> + %% verify specific arguments + %% help: string, 'hidden', or a tuple of {string(), ...} + is_valid_option_help(maps:get(help, Arg, [])) orelse + ?INVALID(argument, Arg, Path, help, <<"must be a string or valid help template">>), + io_lib:printable_unicode_list(maps:get(long, Arg, [])) orelse + ?INVALID(argument, Arg, Path, long, <<"must be a printable string">>), + is_boolean(maps:get(required, Arg, true)) orelse + ?INVALID(argument, Arg, Path, required, <<"must be a boolean">>), + io_lib:printable_unicode_list([maps:get(short, Arg, $a)]) orelse + ?INVALID(argument, Arg, Path, short, <<"must be a printable character">>), + Opt1 = maybe_validate(action, Arg, fun validate_action/3, Path), + Opt2 = maybe_validate(type, Opt1, fun validate_type/3, Path), + maybe_validate(nargs, Opt2, fun validate_args/3, Path); +validate_option(Path, Arg) -> + ?INVALID(argument, Arg, Path, name, <<"argument must be a map containing 'name' field">>). + +maybe_validate(Key, Map, Fun, Path) when is_map_key(Key, Map) -> + maps:put(Key, Fun(maps:get(Key, Map), Path, Map), Map); +maybe_validate(_Key, Map, _Fun, _Path) -> + Map. + +%% validate action field +validate_action(store, _Path, _Opt) -> + store; +validate_action({store, Term}, _Path, _Opt) -> + {store, Term}; +validate_action(append, _Path, _Opt) -> + append; +validate_action({append, Term}, _Path, _Opt) -> + {append, Term}; +validate_action(count, _Path, _Opt) -> + count; +validate_action(extend, _Path, #{nargs := Nargs}) when + Nargs =:= list; Nargs =:= nonempty_list; Nargs =:= all; is_integer(Nargs) -> + extend; +validate_action(extend, _Path, #{type := {custom, _}}) -> + extend; +validate_action(extend, Path, Arg) -> + ?INVALID(argument, Arg, Path, action, <<"extend action works only with lists">>); +validate_action(_Action, Path, Arg) -> + ?INVALID(argument, Arg, Path, action, <<"unsupported">>). + +%% validate type field +validate_type(Simple, _Path, _Opt) when Simple =:= boolean; Simple =:= integer; Simple =:= float; + Simple =:= string; Simple =:= binary; Simple =:= atom; Simple =:= {atom, unsafe} -> + Simple; +validate_type({custom, Fun}, _Path, _Opt) when is_function(Fun, 1) -> + {custom, Fun}; +validate_type({float, Opts}, Path, Arg) -> + [?INVALID(argument, Arg, Path, type, <<"invalid validator">>) + || {Kind, Val} <- Opts, (Kind =/= min andalso Kind =/= max) orelse (not is_float(Val))], + {float, Opts}; +validate_type({integer, Opts}, Path, Arg) -> + [?INVALID(argument, Arg, Path, type, <<"invalid validator">>) + || {Kind, Val} <- Opts, (Kind =/= min andalso Kind =/= max) orelse (not is_integer(Val))], + {integer, Opts}; +validate_type({atom, Choices} = Valid, Path, Arg) when is_list(Choices) -> + [?INVALID(argument, Arg, Path, type, <<"unsupported">>) || C <- Choices, not is_atom(C)], + Valid; +validate_type({string, Re} = Valid, _Path, _Opt) when is_list(Re) -> + Valid; +validate_type({string, Re, L} = Valid, _Path, _Opt) when is_list(Re), is_list(L) -> + Valid; +validate_type({binary, Re} = Valid, _Path, _Opt) when is_binary(Re) -> + Valid; +validate_type({binary, Choices} = Valid, _Path, _Opt) when is_list(Choices), is_binary(hd(Choices)) -> + Valid; +validate_type({binary, Re, L} = Valid, _Path, _Opt) when is_binary(Re), is_list(L) -> + Valid; +validate_type(_Type, Path, Arg) -> + ?INVALID(argument, Arg, Path, type, <<"unsupported">>). + +validate_args(N, _Path, _Opt) when is_integer(N), N >= 1 -> N; +validate_args(Simple, _Path, _Opt) when Simple =:= all; Simple =:= list; Simple =:= 'maybe'; Simple =:= nonempty_list -> + Simple; +validate_args({'maybe', Term}, _Path, _Opt) -> {'maybe', Term}; +validate_args(_Nargs, Path, Arg) -> + ?INVALID(argument, Arg, Path, nargs, <<"unsupported">>). + +%% used to throw an error - strips command component out of path +clean_path(Path) -> + {Cmds, _} = lists:unzip(Path), + lists:reverse(Cmds). + +is_valid_option_help(hidden) -> + true; +is_valid_option_help(Help) when is_list(Help); is_binary(Help) -> + true; +is_valid_option_help({Short, Desc}) when is_list(Short) orelse is_binary(Short), is_list(Desc) -> + %% verify that Desc is a list of string/type/default + lists:all(fun(type) -> true; + (default) -> true; + (S) when is_list(S); is_binary(S) -> true; + (_) -> false + end, Desc); +is_valid_option_help({Short, Desc}) when is_list(Short) orelse is_binary(Short), is_function(Desc, 0) -> + true; +is_valid_option_help(_) -> + false. + +is_valid_command_help(hidden) -> + true; +is_valid_command_help(Help) when is_binary(Help) -> + true; +is_valid_command_help(Help) when is_list(Help) -> + %% allow printable lists + case io_lib:printable_unicode_list(Help) of + true -> + true; + false -> + %% ... or a command help template + lists:all( + fun (Atom) when Atom =:= usage; Atom =:= commands; Atom =:= arguments; Atom =:= options -> true; + (Bin) when is_binary(Bin) -> true; + (Str) -> io_lib:printable_unicode_list(Str) + end, Help) + end; +is_valid_command_help(_) -> + false. + +%%-------------------------------------------------------------------- +%% Built-in Help formatter + +format_help({ProgName, Root}, Format) -> + Prefix = hd(maps:get(prefixes, Format, [$-])), + Nested = maps:get(command, Format, []), + %% descent into commands collecting all options on the way + {_CmdName, Cmd, AllArgs} = collect_options(ProgName, Root, Nested, []), + %% split arguments into Flags, Options, Positional, and create help lines + {_, Longest, Flags, Opts, Args, OptL, PosL} = lists:foldl(fun format_opt_help/2, + {Prefix, 0, "", [], [], [], []}, AllArgs), + %% collect and format sub-commands + Immediate = maps:get(commands, Cmd, #{}), + {Long, Subs} = lists:foldl( + fun ({_Name, #{help := hidden}}, {Long, SubAcc}) -> + {Long, SubAcc}; + ({Name, Sub}, {Long, SubAcc}) -> + Help = maps:get(help, Sub, ""), + {max(Long, string:length(Name)), [{Name, Help}|SubAcc]} + end, {Longest, []}, lists:sort(maps:to_list(Immediate))), + %% format sub-commands + ShortCmd0 = + case map_size(Immediate) of + 0 -> + []; + Small when Small < 4 -> + Keys = lists:sort(maps:keys(Immediate)), + ["{" ++ lists:append(lists:join("|", Keys)) ++ "}"]; + _Largs -> + ["<command>"] + end, + %% was it nested command? + ShortCmd = if Nested =:= [] -> ShortCmd0; true -> [lists:append(lists:join(" ", Nested)) | ShortCmd0] end, + %% format flags + FlagsForm = if Flags =:= [] -> []; + true -> [unicode:characters_to_list(io_lib:format("[~tc~ts]", [Prefix, Flags]))] + end, + %% format extended view + %% usage line has hardcoded format for now + Usage = [ProgName, ShortCmd, FlagsForm, Opts, Args], + %% format usage according to help template + Template0 = maps:get(help, Root, ""), + %% when there is no help defined for the command, or help is a string, + %% use the default format (original argparse behaviour) + Template = + case Template0 =:= "" orelse io_lib:printable_unicode_list(Template0) of + true -> + %% classic/compatibility format + NL = [io_lib:nl()], + Template1 = ["Usage:" ++ NL, usage, NL], + Template2 = maybe_add("~n", Template0, Template0 ++ NL, Template1), + Template3 = maybe_add("~nSubcommands:~n", Subs, commands, Template2), + Template4 = maybe_add("~nArguments:~n", PosL, arguments, Template3), + maybe_add("~nOptional arguments:~n", OptL, options, Template4); + false -> + Template0 + end, + + %% produce formatted output, taking viewport width into account + Parts = #{usage => Usage, commands => {Long, Subs}, + arguments => {Longest, PosL}, options => {Longest, OptL}}, + Width = maps:get(columns, Format, 80), %% might also use io:columns() here + lists:append([format_width(maps:find(Part, Parts), Part, Width) || Part <- Template]). + +%% collects options on the Path, and returns found Command +collect_options(CmdName, Command, [], Args) -> + {CmdName, Command, maps:get(arguments, Command, []) ++ Args}; +collect_options(CmdName, Command, [Cmd|Tail], Args) -> + Sub = maps:get(commands, Command), + SubCmd = maps:get(Cmd, Sub), + collect_options(CmdName ++ " " ++ Cmd, SubCmd, Tail, maps:get(arguments, Command, []) ++ Args). + +%% conditionally adds text and empty lines +maybe_add(_ToAdd, [], _Element, Template) -> + Template; +maybe_add(ToAdd, _List, Element, Template) -> + Template ++ [io_lib:format(ToAdd, []), Element]. + +format_width(error, Part, Width) -> + wrap_text(Part, 0, Width); +format_width({ok, [ProgName, ShortCmd, FlagsForm, Opts, Args]}, usage, Width) -> + %% make every separate command/option to be a "word", and then + %% wordwrap it indented by the ProgName length + 3 + Words = ShortCmd ++ FlagsForm ++ Opts ++ Args, + if Words =:= [] -> io_lib:format(" ~ts", [ProgName]); + true -> + Indent = string:length(ProgName), + Wrapped = wordwrap(Words, Width - Indent, 0, [], []), + Pad = lists:append(lists:duplicate(Indent + 3, " ")), + ArgLines = lists:join([io_lib:nl() | Pad], Wrapped), + io_lib:format(" ~ts~ts", [ProgName, ArgLines]) + end; +format_width({ok, {Len, Texts}}, _Part, Width) -> + SubFormat = io_lib:format(" ~~-~bts ~~ts~n", [Len]), + [io_lib:format(SubFormat, [N, wrap_text(D, Len + 3, Width)]) || {N, D} <- lists:reverse(Texts)]. + +wrap_text(Text, Indent, Width) -> + %% split text into separate lines (paragraphs) + NL = io_lib:nl(), + Lines = string:split(Text, NL, all), + %% wordwrap every paragraph + Paragraphs = lists:append([wrap_line(L, Width, Indent) || L <- Lines]), + Pad = lists:append(lists:duplicate(Indent, " ")), + lists:join([NL | Pad], Paragraphs). + +wrap_line([], _Width, _Indent) -> + [[]]; +wrap_line(Line, Width, Indent) -> + [First | Tail] = string:split(Line, " ", all), + wordwrap(Tail, Width - Indent, string:length(First), First, []). + +wordwrap([], _Max, _Len, [], Lines) -> + lists:reverse(Lines); +wordwrap([], _Max, _Len, Line, Lines) -> + lists:reverse([Line | Lines]); +wordwrap([Word | Tail], Max, Len, Line, Lines) -> + WordLen = string:length(Word), + case Len + 1 + WordLen > Max of + true -> + wordwrap(Tail, Max, WordLen, Word, [Line | Lines]); + false -> + wordwrap(Tail, Max, WordLen + 1 + Len, [Line, <<" ">>, Word], Lines) + end. + +%% create help line for every option, collecting together all flags, short options, +%% long options, and positional arguments + +%% format optional argument +format_opt_help(#{help := hidden}, Acc) -> + Acc; +format_opt_help(Opt, {Prefix, Longest, Flags, Opts, Args, OptL, PosL}) when ?IS_OPTION(Opt) -> + Desc = format_description(Opt), + %% does it need an argument? look for nargs and action + RequiresArg = requires_argument(Opt), + %% long form always added to Opts + NonOption = maps:get(required, Opt, false) =:= true, + {Name0, MaybeOpt0} = + case maps:find(long, Opt) of + error -> + {"", []}; + {ok, Long} when NonOption, RequiresArg -> + FN = [Prefix | Long], + {FN, [format_required(true, [FN, " "], Opt)]}; + {ok, Long} when RequiresArg -> + FN = [Prefix | Long], + {FN, [format_required(false, [FN, " "], Opt)]}; + {ok, Long} when NonOption -> + FN = [Prefix | Long], + {FN, [FN]}; + {ok, Long} -> + FN = [Prefix | Long], + {FN, [io_lib:format("[~ts]", [FN])]} + end, + %% short may go to flags, or Opts + {Name, MaybeFlag, MaybeOpt1} = + case maps:find(short, Opt) of + error -> + {Name0, [], MaybeOpt0}; + {ok, Short} when RequiresArg -> + SN = [Prefix, Short], + {maybe_concat(SN, Name0), [], + [format_required(NonOption, [SN, " "], Opt) | MaybeOpt0]}; + {ok, Short} -> + {maybe_concat([Prefix, Short], Name0), [Short], MaybeOpt0} + end, + %% apply override for non-default usage (in form of {Quick, Advanced} tuple + MaybeOpt2 = + case maps:find(help, Opt) of + {ok, {Str, _}} -> + [Str]; + _ -> + MaybeOpt1 + end, + %% name length, capped at 24 + NameLen = string:length(Name), + Capped = min(24, NameLen), + {Prefix, max(Capped, Longest), Flags ++ MaybeFlag, Opts ++ MaybeOpt2, Args, [{Name, Desc} | OptL], PosL}; + +%% format positional argument +format_opt_help(#{name := Name} = Opt, {Prefix, Longest, Flags, Opts, Args, OptL, PosL}) -> + Desc = format_description(Opt), + %% positional, hence required + LName = io_lib:format("~ts", [Name]), + LPos = case maps:find(help, Opt) of + {ok, {Str, _}} -> + Str; + _ -> + format_required(maps:get(required, Opt, true), "", Opt) + end, + {Prefix, max(Longest, string:length(LName)), Flags, Opts, Args ++ [LPos], OptL, [{LName, Desc} | PosL]}. + +%% custom format +format_description(#{help := {_Short, Fun}}) when is_function(Fun, 0) -> + Fun(); +format_description(#{help := {_Short, Desc}} = Opt) -> + lists:map( + fun (type) -> + format_type(Opt); + (default) -> + format_default(Opt); + (String) -> + String + end, Desc + ); +%% default format: "desc", "desc (type)", "desc (default)", "desc (type, default)" +format_description(#{name := Name} = Opt) -> + NameStr = maps:get(help, Opt, io_lib:format("~ts", [Name])), + case {NameStr, format_type(Opt), format_default(Opt)} of + {"", "", Type} -> Type; + {"", Default, ""} -> Default; + {Desc, "", ""} -> Desc; + {Desc, "", Default} -> [Desc, " (", Default, ")"]; + {Desc, Type, ""} -> [Desc, " (", Type, ")"]; + {"", Type, Default} -> [Type, ", ", Default]; + {Desc, Type, Default} -> [Desc, " (", Type, ", ", Default, ")"] + end. + +%% option formatting helpers +maybe_concat(No, []) -> No; +maybe_concat(No, L) -> [No, ", ", L]. + +format_required(true, Extra, #{name := Name} = Opt) -> + io_lib:format("~ts<~ts>~ts", [Extra, Name, format_nargs(Opt)]); +format_required(false, Extra, #{name := Name} = Opt) -> + io_lib:format("[~ts<~ts>~ts]", [Extra, Name, format_nargs(Opt)]). + +format_nargs(#{nargs := Dots}) when Dots =:= list; Dots =:= all; Dots =:= nonempty_list -> + "..."; +format_nargs(_) -> + "". + +format_type(#{type := {integer, Choices}}) when is_list(Choices), is_integer(hd(Choices)) -> + io_lib:format("choice: ~s", [lists:join(", ", [integer_to_list(C) || C <- Choices])]); +format_type(#{type := {float, Choices}}) when is_list(Choices), is_number(hd(Choices)) -> + io_lib:format("choice: ~s", [lists:join(", ", [io_lib:format("~g", [C]) || C <- Choices])]); +format_type(#{type := {Num, Valid}}) when Num =:= integer; Num =:= float -> + case {proplists:get_value(min, Valid), proplists:get_value(max, Valid)} of + {undefined, undefined} -> + io_lib:format("~s", [format_type(#{type => Num})]); + {Min, undefined} -> + io_lib:format("~s >= ~tp", [format_type(#{type => Num}), Min]); + {undefined, Max} -> + io_lib:format("~s <= ~tp", [format_type(#{type => Num}), Max]); + {Min, Max} -> + io_lib:format("~tp <= ~s <= ~tp", [Min, format_type(#{type => Num}), Max]) + end; +format_type(#{type := {string, Re, _}}) when is_list(Re), not is_list(hd(Re)) -> + io_lib:format("string re: ~ts", [Re]); +format_type(#{type := {string, Re}}) when is_list(Re), not is_list(hd(Re)) -> + io_lib:format("string re: ~ts", [Re]); +format_type(#{type := {binary, Re}}) when is_binary(Re) -> + io_lib:format("binary re: ~ts", [Re]); +format_type(#{type := {binary, Re, _}}) when is_binary(Re) -> + io_lib:format("binary re: ~ts", [Re]); +format_type(#{type := {StrBin, Choices}}) when StrBin =:= string orelse StrBin =:= binary, is_list(Choices) -> + io_lib:format("choice: ~ts", [lists:join(", ", Choices)]); +format_type(#{type := atom}) -> + "existing atom"; +format_type(#{type := {atom, unsafe}}) -> + "atom"; +format_type(#{type := {atom, Choices}}) -> + io_lib:format("choice: ~ts", [lists:join(", ", [atom_to_list(C) || C <- Choices])]); +format_type(#{type := boolean}) -> + ""; +format_type(#{type := integer}) -> + "int"; +format_type(#{type := Type}) when is_atom(Type) -> + io_lib:format("~ts", [Type]); +format_type(_Opt) -> + "". + +format_default(#{default := Def}) when is_list(Def); is_binary(Def); is_atom(Def) -> + io_lib:format("~ts", [Def]); +format_default(#{default := Def}) -> + io_lib:format("~tp", [Def]); +format_default(_) -> + "". + +%%-------------------------------------------------------------------- +%% Basic handler execution +handle(CmdMap, ArgMap, Path, #{handler := {Mod, ModFun, Default}}) -> + ArgList = arg_map_to_arg_list(CmdMap, Path, ArgMap, Default), + %% if argument count may not match, better error can be produced + erlang:apply(Mod, ModFun, ArgList); +handle(_CmdMap, ArgMap, _Path, #{handler := {Mod, ModFun}}) when is_atom(Mod), is_atom(ModFun) -> + Mod:ModFun(ArgMap); +handle(CmdMap, ArgMap, Path, #{handler := {Fun, Default}}) when is_function(Fun) -> + ArgList = arg_map_to_arg_list(CmdMap, Path, ArgMap, Default), + %% if argument count may not match, better error can be produced + erlang:apply(Fun, ArgList); +handle(_CmdMap, ArgMap, _Path, #{handler := Handler}) when is_function(Handler, 1) -> + Handler(ArgMap). + +%% Given command map, path to reach a specific command, and a parsed argument +%% map, returns a list of arguments (effectively used to transform map-based +%% callback handler into positional). +arg_map_to_arg_list(Command, Path, ArgMap, Default) -> + AllArgs = collect_arguments(Command, Path, []), + [maps:get(Arg, ArgMap, Default) || #{name := Arg} <- AllArgs]. + +%% recursively descend into Path, ignoring arguments with duplicate names +collect_arguments(Command, [], Acc) -> + Acc ++ maps:get(arguments, Command, []); +collect_arguments(Command, [H|Tail], Acc) -> + Args = maps:get(arguments, Command, []), + Next = maps:get(H, maps:get(commands, Command, H)), + collect_arguments(Next, Tail, Acc ++ Args). diff --git a/lib/stdlib/src/stdlib.app.src b/lib/stdlib/src/stdlib.app.src index 69bff1511b..a71ad0a954 100644 --- a/lib/stdlib/src/stdlib.app.src +++ b/lib/stdlib/src/stdlib.app.src @@ -21,7 +21,8 @@ {application, stdlib, [{description, "ERTS CXC 138 10"}, {vsn, "%VSN%"}, - {modules, [array, + {modules, [argparse, + array, base64, beam_lib, binary, 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 -- 2.35.3
Locations
Projects
Search
Status Monitor
Help
OpenBuildService.org
Documentation
API Documentation
Code of Conduct
Contact
Support
@OBShq
Terms
openSUSE Build Service is sponsored by
The Open Build Service is an
openSUSE project
.
Sign Up
Log In
Places
Places
All Projects
Status Monitor