src/args.erl

%%%-------------------------------------------------------------------
%%% @author Maxim Fedorov, <maximfca@gmail.com>
%%% @doc
%%% Command line parser, made with hierarchy of commands in mind.
%%% Parser operates with <em>arguments</em> and <em>commands</em>, organised in a hierarchy. It is possible
%%% to define multiple commands, or none. Parsing always starts with root <em>command</em>,
%%% named after `init:get_argument(progname)'. Empty command produces empty argument map:
%%% ```
%%% 1> parse("", #{}).
%%%    #{}
%%% '''
%%%
%%%
%%% If root level command does not contain any sub-commands, parser returns plain map of
%%% argument names to their values:
%%% ```
%%% 3> args:parse(["value"], #{arguments => [#{name => arg}]}).
%%% #{arg => "value"}
%%% '''
%%% This map contains all arguments matching command line passed, initialised with
%%% corresponding values. If argument is omitted, but default value is specified for it,
%%% it is added to the map. When no default value specified, and argument is not
%%% present, corresponding key is not present in the map.
%%%
%%% Missing required (field <strong>required</strong> is set to true for optional arguments,
%%% or missing for positional) arguments raises an error.
%%%
%%% When there are sub-commands, parser returns argument map, deepest matched command
%%% name, and a sub-spec passed for this command:
%%% ```
%%% 4> Cmd =  #{arguments => [#{name => arg}]}.
%%% #{arguments => [#{name => arg}]}
%%% 5> args:parse(["cmd", "value"], #{commands => #{"cmd" => Cmd}}).
%%% {#{arg => "value"},{"cmd",#{arguments => [#{name => arg}]}}}
%%% '''
%%% @end

-module(args).
-author("maximfca@gmail.com").

-export([
    validate/1,
    validate/2,
    parse/2,
    parse/3,
    help/1,
    help/2,
    format_error/1,
    format_error/3
]).

%%--------------------------------------------------------------------
%% API

-compile(warn_missing_spec).

%% @doc
%% Built-in types include basic validation abilities
%% String and binary validation may use regex match (ignoring captured value).
%% For float, int, string, binary and atom type, it is possible to specify
%%  available choices instead of regex/min/max.
%% @end
-type arg_type() ::
    boolean |
    float |
    {float, [float()]} |
    {float, [{min, float()} | {max, float()}]} |
    int |
    {int, [integer()]} |
    {int, [{min, integer()} | {max, integer()}]} |
    string |
    {string, [string()]} |
    {string, string()} |
    {string, string(), [term()]} |
    binary |
    {binary, [binary()]} |
    {binary, binary()} |
    {binary, binary(), [term()]} |
    atom |
    {atom, [atom()]} |
    {atom, unsafe} |
    {custom, fun((string()) -> term())}.

%% 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_help() :: {
    string(), %% short form, printed in command usage, e.g. "[--dir <dirname>]", developer is
              %%    responsible for proper formatting (e.g. adding <>, dots... and so on)
    [string() | type | default]  %% long description, default is [help, " (", type, ", ", default, ")"] -
                                 %%    "floating-point long form argument, float, [3.14]"
}.

%% Command line argument specification.
%% Argument can be optional - starting with - (dash), and positional.
-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.
    %% It is used to format the name, hence it should be format-table with "~ts".
    name := atom() | string() | binary(),

    %% short, single-character variant of command line option, omitting dash (example: $b, meaning -b),
    %%  when present, this is optional argument
    short => char(),

    %% long command line option, omitting first dash (example: "kernel", or "-long", meaning "-kernel" and "--long"
    %% long command always wins over short abbreviation (e.g. -kernel is considered before -k -e -r -n -e -l)
    %%  when present, this is optional argument
    long => string(),

    %% throws an error if value is not present in command line
    required => boolean(),

    %% default value, produced if value is not present in command line
    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 next argument is positional, consume it, otherwise produce default
        {'maybe', term()} | %% if 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 | string() | argument_help()
}.

-type arg_map() :: #{term() => term()}. %% Arguments map: argument name to a term, produced by parser. Supplied to command handler

%% 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 handler() ::
    optional |                          %% valid for commands with sub-commands, suppresses 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((arg_map()) -> term()), term()} |  %% handler, positional form
    {module(), atom(), term()}.         %% handler, positional form, exported from module()

-type command_map() :: #{string() => command()}. %% Sub-commands are arranged into maps (cannot start with <em>prefix</em>)

%% Command help template, RFC for future implementation
%% Default is ["usage: ", name, " ", flags, " ", options, " ", arguments, "\n", help, "\n", commands, "\n",
%%      {arguments, long}, "\n", {options, long}, "\n"]
%% -type command_help() :: [
%%     string() |          %% text string, as is
%%     name |              %% command name (or progname, if it's top-level command)
%%     flags |             %% flags: [-rfv]
%%     options |           %% options: [--force] [-i <interval>] [--dir <dir>]
%%     arguments |         %% <server> [<optpos>]
%%     commands |          %%   status      prints server status
%%     {arguments, long} | %%   server       server to start
%%     {options, long}     %%   -f, --force force
%% ].

%% Command descriptor
-type command() :: #{
    %% Sub-commands
    commands => command_map(),

    %% accepted arguments list. Positional order is important!
    arguments => [argument()],

    %% help line
    help => hidden | string(),

    %% recommended handler function, cli behaviour deduces handler from
    %%  command name and module implementing cli behaviour
    handler => handler()
}.

-export_type([
    argument/0,
    command/0,
    handler/0,
    cmd_path/0,
    arg_map/0
]).

%% Optional or positional argument?
-define(IS_OPTIONAL(Arg), is_map_key(short, Arg) orelse is_map_key(long, Arg)).

-type cmd_path() :: [string()]. %% Command path, for deeply nested sub-commands

%% Parser state (not available via API)
-record(eos, {
    %% prefix character map, by default, only -
    prefixes :: #{integer() => true},
    %% argument map to be returned
    argmap = #{} :: arg_map(),
    %% sub-commands, in reversed orders, allowing to recover path taken
    commands = [] :: cmd_path(),
    %% command being matched
    current :: command(),
    %% unmatched positional arguments, in 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()}
}).

%% Error Reason thrown by parser (feed it into format_error to get human-readable error).
-type argparse_reason() ::
    {invalid_command, cmd_path(), Field :: atom(), Reason :: string()} |
    {invalid_option, cmd_path(), Name :: string(), Field :: atom(), Reason :: string()} |
    {unknown_argument, cmd_path(), Argument :: string()} |
    {missing_argument, cmd_path(), argument()} |
    {invalid_argument, cmd_path(), argument(), Argument :: string()}.

%% Parser options
-type parser_options() :: #{
    %% allowed prefixes (default is [$-]).
    prefixes => [integer()],
    %% default value for all missing not required arguments
    default => term(),
    %% next fields are only considered when printing usage
    progname => string() | atom(),   %% program name override
    command => [string()]   %% nested command (missing/empty for top-level command)
}.

-type command_spec() :: {Name :: [string()], command()}. %% Command name with command spec

-type parse_result() :: arg_map() | {arg_map(), command_spec()}. %% Result returned from parse/2,3: can be only argument map,  or argument map with command_spec.

%% @equiv validate(Command, #{})
-spec validate(command()) -> {Progname :: string(), command()}.
validate(Command) ->
    validate(Command, #{}).

%% @doc Validate command specification, taking Options into account.
%% Generates error signal when command specification is invalid.
-spec validate(command(), parser_options()) -> {Progname :: string(), command()}.
validate(Command, Options) ->
    validate_impl(Command, Options).


%% @equiv parse(Args, Command, #{})
-spec parse(Args :: [string()], command() | command_spec()) -> parse_result().
parse(Args, Command) ->
    parse(Args, Command, #{}).

%% @doc Parses supplied arguments according to expected command definition.
%% @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() | command_spec(),
    Options :: parser_options()) -> parse_result().
parse(Args, Command, Options) ->
    {Prog, Cmd} = validate(Command, Options),
    Prefixes = maps:from_list([{P, true} || P <- maps:get(prefixes, Options, [$-])]),
    parse_impl(Args, merge_arguments(Prog, Cmd, #eos{prefixes = Prefixes, current = Cmd,
        default = maps:find(default, Options)})).

%% By default, options are indented with 2 spaces for each level of
%%  sub-command.
-define (DEFAULT_INDENT, "  ").

%% @equiv help(Command, #{})
-spec help(command() | command_spec()) -> string().
help(Command) ->
    help(Command, #{}).

%% @doc
%% Returns help for Command formatted according to Options specified
-spec help(command() | command_spec(), parser_options()) -> string().
help(Command, Options) ->
    unicode:characters_to_list(format_help(validate(Command, Options), Options)).

%% @doc Format exception reasons produced by parse/2.
%% Exception of class error with reason {args, Reason} is normally
%%  raised, and format_error accepts only the Reason part, leaving
%%  other exceptions that do not belong to argparse out.
%% @returns string, ready to be printed via io:format().
-spec format_error(Reason :: argparse_reason()) -> string().
format_error({invalid_command, Path, Field, Text}) ->
    unicode:characters_to_list(io_lib:format("~tsinternal error, invalid field '~ts': ~ts~n",
        [format_path(Path), Field, Text]));
format_error({invalid_option, Path, Name, Field, Text}) ->
    unicode:characters_to_list(io_lib:format("~tsinternal error, option ~ts field '~ts': ~ts~n",
        [format_path(Path), Name, Field, Text]));
format_error({unknown_argument, Path, Argument}) ->
    unicode:characters_to_list(io_lib:format("~tsunrecognised argument: ~ts~n",
        [format_path(Path), Argument]));
format_error({missing_argument, Path, Name}) ->
    unicode:characters_to_list(io_lib:format("~tsrequired argument missing: ~ts~n",
        [format_path(Path), Name]));
format_error({invalid_argument, Path, Name, Value}) ->
    unicode:characters_to_list(io_lib:format("~tsinvalid argument ~ts for: ~ts~n",
        [format_path(Path), Value, Name])).

%% @doc Formats exception, and adds command usage information for
%%  command that was known/parsed when exception was raised.
%% @returns string, ready to be printed via io:format().
-spec format_error(argparse_reason(), command() | command_spec(), parser_options()) -> string().
format_error(Reason, Command, Options) ->
    Path = tl(element(2, Reason)),
    ErrorText = format_error(Reason),
    UsageText = help(Command, Options#{command => Path}),
    ErrorText ++ UsageText.

%%--------------------------------------------------------------------
%% Parser implementation

%% 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
        fail({missing_argument, Commands, "missing handler"}),
    %% 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),
    case Eos#eos.commands of
        [_] ->
            %% if there were no commands specified, only the argument map
            ArgMap3;
        [_|_] ->
            %% otherwise return argument map, command path taken, and the
            %%  last command matched (usually it contains a handler to run)
            {ArgMap3, {tl(lists:reverse(Eos#eos.commands)), Eos#eos.current}}
    end.

%% 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;
            (#{name := Name, required := true}, _Acc) ->
                %% missing, and required explicitly
                fail({missing_argument, Commands, Name});
            (#{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};
            (#{name := Name}, _Acc) when Req =:= true ->
                %% missing, for positional argument, implicitly required
                fail({missing_argument, Commands, Name});
            (#{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) ->
    fail({unknown_argument, Eos#eos.commands, Arg}).

parse_positional(Arg, _Tail, #eos{pos = [], commands = Commands}) ->
    fail({unknown_argument, Commands, 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 = [CmdName | Eos#eos.commands]});
merge_arguments(CmdName, SubCmd, Eos) ->
    Eos#eos{current = SubCmd, commands = [CmdName | Eos#eos.commands]}.

%% 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 fail({invalid_argument, Eos#eos.commands, maps:get(name, Opt), Tail}),
    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 fail({invalid_argument, Eos#eos.commands, maps:get(name, Opt), Tail}),
    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) ->
    %% if neither true or false, don't consume, just do the action with 'true' as arg
    action(Tail, true, Opt#{type => raw}, 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_OPTIONAL(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 ->
            fail({missing_argument, Eos#eos.commands, maps:get(name, Opt)})
    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) ->
    fail({missing_argument, Eos#eos.commands, maps:get(name, Opt)}).

%% 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, ArgName, 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, ArgName, 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, ArgName, 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: same as set
action(Tail, ArgValue, Opt, Eos) ->
    action(Tail, ArgValue, Opt#{action => store}, Eos).

%% pop last positional, unless nargs is list/nonempty_list
continue_parser(Tail, Opt, Eos) when ?IS_OPTIONAL(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
        fail({invalid_argument, Eos#eos.commands, Opt, Arg}),
    Arg;
convert_type({string, Re}, Arg, Opt, Eos) ->
    case re:run(Arg, Re) of
        {match, _X} -> Arg;
        _ -> fail({invalid_argument, Eos#eos.commands, Opt, Arg})
    end;
convert_type({string, Re, ReOpt}, Arg, Opt, Eos) ->
    case re:run(Arg, Re, ReOpt) of
        match -> Arg;
        {match, _} -> Arg;
        _ -> fail({invalid_argument, Eos#eos.commands, Opt, Arg})
    end;
convert_type(int, Arg, Opt, Eos) ->
    get_int(Arg, Opt, Eos);
convert_type({int, Opts}, Arg, Opt, Eos) ->
    minimax(get_int(Arg, Opt, Eos), Opts, Eos, Opt);
convert_type(boolean, "true", _Opt, _Eos) ->
    true;
convert_type(boolean, "false", _Opt, _Eos) ->
    false;
convert_type(boolean, Arg, Opt, Eos) ->
    fail({invalid_argument, Eos#eos.commands, Opt, Arg});
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
        fail({invalid_argument, Eos#eos.commands, Opt, Arg}),
    Conv;
convert_type({binary, Re}, Arg, Opt, Eos) ->
    case re:run(Arg, Re) of
        {match, _X} -> unicode:characters_to_binary(Arg);
        _ -> fail({invalid_argument, Eos#eos.commands, Opt, Arg})
    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);
        _ -> fail({invalid_argument, Eos#eos.commands, Opt, Arg})
    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);
convert_type(atom, Arg, Opt, Eos) ->
    try list_to_existing_atom(Arg)
    catch error:badarg ->
        fail({invalid_argument, Eos#eos.commands, Opt, Arg})
    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 fail({invalid_argument, Eos#eos.commands, Opt, Arg}),
        Atom
    catch error:badarg ->
        fail({invalid_argument, Eos#eos.commands, Opt, Arg})
    end;
convert_type({custom, Fun}, Arg, Opt, Eos) ->
    try Fun(Arg)
    catch error:invalid_argument ->
        fail({invalid_argument, Eos#eos.commands, Opt, Arg})
    end.

%% Given Var, and list of {min, X}, {max, Y}, ensure that
%%  value falls within defined limits.
minimax(Var, [], _Eos, _Opt) ->
    Var;
minimax(Var, [{min, Min} | _], Eos, Opt) when Var < Min ->
    fail({invalid_argument, Eos#eos.commands, Opt, Var});
minimax(Var, [{max, Max} | _], Eos, Opt) when Var > Max ->
    fail({invalid_argument, Eos#eos.commands, Opt, Var});
minimax(Var, [Num | Tail], Eos, Opt) when is_number(Num) ->
    lists:member(Var, [Num|Tail]) orelse
        fail({invalid_argument, Eos#eos.commands, Opt, Var}),
    Var;
minimax(Var, [_ | Tail], Eos, Opt) ->
    minimax(Var, Tail, Eos, Opt).

%% returns int from string, or errors out with debugging info
get_int(Arg, Opt, Eos) ->
    case string:to_integer(Arg) of
        {Int, []} ->
            Int;
        _ ->
            fail({invalid_argument, Eos#eos.commands, Opt, Arg})
    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;
                _ ->
                    fail({invalid_argument, Eos#eos.commands, Opt, Arg})
            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 := int}) ->
    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:concat(lists:join(" ", Commands)) ++ ": ".

%% to simplify throwing errors with the right reason
fail(Reason) ->
    FixedPath = lists:reverse(element(2, Reason)),
    erlang:error({?MODULE, setelement(2, Reason, FixedPath)}).

%%--------------------------------------------------------------------
%% 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.

validate_impl(Command, #{progname := Prog} = Options) when is_list(Prog) ->
    Prefixes = maps:from_list([{P, true} || P <- maps:get(prefixes, Options, [$-])]),
    validate_command([{Prog, Command}], Prefixes);
validate_impl(Command, #{progname := Prog} = Options) when is_atom(Prog) ->
    Prefixes = maps:from_list([{P, true} || P <- maps:get(prefixes, Options, [$-])]),
    validate_command([{atom_to_list(Prog), Command}], Prefixes);
validate_impl(_Command, #{progname := _Prog} = _Options) ->
    fail({invalid_command, [], progname, "progname must be a list or an atom"});
validate_impl(Command, Options) ->
    {ok, [[Prog]]} = init:get_argument(progname),
    validate_impl(Command, Options#{progname => Prog}).

%% validates commands, throws invalid_command or invalid_option error
validate_command([{Name, Cmd} | _] = Path, Prefixes) ->
    (is_list(Name) andalso (not is_map_key(hd(Name), Prefixes))) orelse
        fail({invalid_command, clean_path(Path), commands, "command name must be a string, not starting with optional prefix"}),
    is_map(Cmd) orelse
        fail({invalid_command, clean_path(Path), commands, "command description must be a map"}),
    is_list(maps:get(help, Cmd, [])) orelse (maps:get(help, Cmd) =:= hidden) orelse
        fail({invalid_command, clean_path(Path), help, "help must be a string"}),
    is_map(maps:get(commands, Cmd, #{})) orelse
        fail({invalid_command, clean_path(Path), commands, "sub-commands must be a map"}),
    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;
        _ -> fail({invalid_command, clean_path(Path), handler,
            "handler must be a fun(ArgMap), {Mod, Fun}, {fun(...), Default}, {Mod, Fun, Default} or 'optional'"})
    end,
    Cmd1 =
        case maps:find(arguments, Cmd) of
            error ->
                Cmd;
            {ok, Opts} when not is_list(Opts) ->
                fail({invalid_command, clean_path(Path), commands, "options must be a list"});
            {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}, {AllS, AllL}) ->
                        is_map_key(Short, AllS) andalso
                            fail({invalid_option, clean_path(Path), OName, short,
                                "short conflicting with " ++ atom_to_list(maps:get(Short, AllS))}),
                        {AllS#{Short => OName}, AllL};
                    (#{long := Long, name := OName}, {AllS, AllL}) ->
                        is_map_key(Long, AllL) andalso
                            fail({invalid_option, clean_path(Path), OName, long,
                                    "long conflicting with " ++ 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} = Opt) when is_atom(Name); is_list(Name); is_binary(Name) ->
    %% arguments cannot have unrecognised map items
    Unknown = maps:keys(maps:without([name, help, short, long, action, nargs, type, default, required], Opt)),
    Unknown =/= [] andalso fail({invalid_option, clean_path(Path), hd(Unknown), "unrecognised field"}),
    %% verify specific arguments
    %% help: string, 'hidden', or a tuple of {string(), ...}
    is_valid_option_help(maps:get(help, Opt, [])) orelse
        fail({invalid_option, clean_path(Path), Name, help, "must be a string or valid help template, ensure help template is a list"}),
    io_lib:printable_unicode_list(maps:get(long, Opt, [])) orelse
        fail({invalid_option, clean_path(Path), Name, long, "must be a printable string"}),
    is_boolean(maps:get(required, Opt, true)) orelse
        fail({invalid_option, clean_path(Path), Name, required, "must be boolean"}),
    io_lib:printable_unicode_list([maps:get(short, Opt, $a)]) orelse
        fail({invalid_option, clean_path(Path), Name, short, "must be a printable character"}),
    Opt1 = maybe_validate(action, Opt, 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, _Opt) ->
    fail({invalid_option, clean_path(Path), "", name, "argument must be a map, and specify 'name'"}).

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, #{name := Name}) ->
    fail({invalid_option, clean_path(Path), Name, action, "extend action works only with lists"});
validate_action(_Action, Path, #{name := Name}) ->
    fail({invalid_option, clean_path(Path), Name, action, "unsupported"}).

%% validate type field
validate_type(Simple, _Path, _Opt) when Simple =:= boolean; Simple =:= int; 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, #{name := Name}) ->
    [fail({invalid_option, clean_path(Path), Name, type, "invalid validator"})
        || {Kind, Val} <- Opts, (Kind =/= min andalso Kind =/= max) orelse (not is_float(Val))],
    {float, Opts};
validate_type({int, Opts}, Path, #{name := Name}) ->
    [fail({invalid_option, clean_path(Path), Name, type, "invalid validator"})
        || {Kind, Val} <- Opts, (Kind =/= min andalso Kind =/= max) orelse (not is_integer(Val))],
    {int, Opts};
validate_type({atom, Choices} = Valid, Path, #{name := Name}) when is_list(Choices) ->
    [fail({invalid_option, clean_path(Path), Name, 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, #{name := Name}) ->
    fail({invalid_option, clean_path(Path), Name, 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, #{name := Name}) ->
    fail({invalid_option, clean_path(Path), Name, nargs, "unsupported"}).

%% used to throw an error - strips command component out of path
clean_path(Path) ->
    [Cmd || {Cmd, _} <- Path].

is_valid_option_help(hidden) ->
    true;
is_valid_option_help(Help) when is_list(Help) ->
    true;
is_valid_option_help({Short, Desc}) when is_list(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) -> true; (_) -> false end, Desc);
is_valid_option_help({Short, Desc}) when is_list(Short), is_function(Desc, 0) ->
    true;
is_valid_option_help(_) ->
    false.

%%--------------------------------------------------------------------
%% Built-in Help formatter

%% Example format:
%%
%% usage: utility [-rxvf] [-i <int>] [--float <float>] <command> [<ARGS>]
%%
%% Commands:
%%   start   verifies configuration and starts server
%%   stop    stops running server
%%
%% Optional arguments:
%%  -r       recursive
%%  -v       increase verbosity level
%%  -f       force
%%  -i <int> interval set
%%  --float <float> floating-point long form argument
%%

%% Example for deeper nested help (amount of flags reduced from previous example)
%%
%% usage: utility [-rz] [-i <int>] start <SERVER> [<NAME>]
%%
%% Optional arguments:
%%  -r       recursive
%%  -z       use zlib compression
%%  -i <int> integer variable
%%  SERVER   server to start
%%  NAME     extra name to pass
%%

format_help({RootCmd, 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(RootCmd, 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} = maps:fold(
        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, []}, Immediate),
    %% format sub-commands
    SubFormat = io_lib:format("  ~~-~bts ~~ts~n", [Long]),
    Commands = [io_lib:format(SubFormat, [N, D]) || {N, D} <- lists:reverse(Subs)],
    ShortCmd =
        case map_size(Immediate) of
            0 when Nested =:= [] ->
                "";
            0 ->
                [$ | lists:concat(lists:join(" ", Nested))];
            Small when Small < 4 ->
                " " ++ lists:concat(lists:join(" ", Nested)) ++  " {" ++
                    lists:concat(lists:join("|", maps:keys(Immediate))) ++ "}";
            _Largs ->
                io_lib:format("~ts <command>", [lists:concat(lists:join(" ", Nested))])
        end,
    %% format flags
    FlagsForm = if Flags =:=[] -> ""; true -> io_lib:format(" [~tc~ts]", [Prefix, Flags]) end,
    %% format extended view
    OptFormat = io_lib:format("  ~~-~bts ~~ts~n", [Longest]),
    %% split OptLines into positional and optional arguments
    FormattedOpts = [io_lib:format(OptFormat, [Hdr, Dsc]) || {Hdr, Dsc} <- lists:reverse(OptL)],
    FormattedArgs = [io_lib:format(OptFormat, [Hdr, Dsc]) || {Hdr, Dsc} <- lists:reverse(PosL)],
    %% format first usage line
    io_lib:format("usage: ~ts~ts~ts~ts~ts~ts~n~ts~ts~ts", [RootCmd, ShortCmd, FlagsForm, Opts, Args,
        maybe_add("~n~ts", maps:get(help, Root, "")),
        maybe_add("~nSubcommands:~n~ts", Commands),
        maybe_add("~nArguments:~n~ts", FormattedArgs),
        maybe_add("~nOptional arguments:~n~ts", FormattedOpts)]).

%% 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, []) ->
    [];
maybe_add(ToAdd, List) ->
    io_lib:format(ToAdd, [List]).

%% 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_OPTIONAL(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:flatmap(
        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 := {int, 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 =:= int; Num =:= float ->
    case {proplists:get_value(min, Valid), proplists:get_value(max, Valid)} of
        {undefined, undefined} ->
            io_lib:format("~s", [Num]);
        {Min, undefined} ->
            io_lib:format("~s >= ~tp", [Num, Min]);
        {undefined, Max} ->
            io_lib:format("~s <= ~tp", [Num, Max]);
        {Min, Max} ->
            io_lib:format("~tp <= ~s <= ~tp", [Min, 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 := 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(_) ->
    "".