File 7091-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] &lt;directory&gt;</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,1359 @@
+%%
+%%
+%% 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]),
+    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(1, error, Reason, Stack,
+                                                 fun(_, _, _) -> false end,
+                                                 fun(Term, I) -> io_lib:print(Term, I, 80, 30) end,
+                                                 unicode)),
+            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 = maps:get(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 = maps:get(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])).
+
+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

openSUSE Build Service is sponsored by