summaryrefslogtreecommitdiff
path: root/lib/stdlib/src/argparse.erl
diff options
context:
space:
mode:
Diffstat (limited to 'lib/stdlib/src/argparse.erl')
-rw-r--r--lib/stdlib/src/argparse.erl1357
1 files changed, 1357 insertions, 0 deletions
diff --git a/lib/stdlib/src/argparse.erl b/lib/stdlib/src/argparse.erl
new file mode 100644
index 0000000000..a5fdd8d3d9
--- /dev/null
+++ b/lib/stdlib/src/argparse.erl
@@ -0,0 +1,1357 @@
+%%
+%%
+%% Copyright Maxim Fedorov
+%%
+%%
+%% Licensed under the Apache License, Version 2.0 (the "License");
+%% you may not use this file except in compliance with the License.
+%% You may obtain a copy of the License at
+%%
+%% http://www.apache.org/licenses/LICENSE-2.0
+%%
+%% Unless required by applicable law or agreed to in writing, software
+%% distributed under the License is distributed on an "AS IS" BASIS,
+%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+%% See the License for the specific language governing permissions and
+%% limitations under the License.
+
+-module(argparse).
+-author("maximfca@gmail.com").
+
+%% API Exports
+-export([
+ run/3,
+ parse/2, parse/3,
+ help/1, help/2,
+ format_error/1
+]).
+
+%% Internal exports for validation and error reporting.
+-export([validate/1, validate/2, format_error/2]).
+
+%%--------------------------------------------------------------------
+%% API
+
+-type arg_type() ::
+ boolean |
+ float |
+ {float, Choice :: [float()]} |
+ {float, [{min, float()} | {max, float()}]} |
+ integer |
+ {integer, Choices :: [integer()]} |
+ {integer, [{min, integer()} | {max, integer()}]} |
+ string |
+ {string, Choices :: [string()]} |
+ {string, Re :: string()} |
+ {string, Re :: string(), ReOptions :: [term()]} |
+ binary |
+ {binary, Choices :: [binary()]} |
+ {binary, Re :: binary()} |
+ {binary, Re :: binary(), ReOptions :: [term()]} |
+ atom |
+ {atom, Choices :: [atom()]} |
+ {atom, unsafe} |
+ {custom, fun((string()) -> term())}.
+%% Built-in types include basic validation abilities
+%% String and binary validation may use regex match (ignoring captured value).
+%% For float, integer, string, binary and atom type, it is possible to specify
+%% available choices instead of regex/min/max.
+
+-type argument_help() :: {
+ unicode:chardata(), %% short form, printed in command usage, e.g. "[--dir <dirname>]", developer is
+ %% responsible for proper formatting (e.g. adding <>, dots... and so on)
+ [unicode:chardata() | type | default] | fun(() -> unicode:chardata())
+}.
+%% Help template definition for argument. Short and long forms exist for every argument.
+%% Short form is printed together with command definition, e.g. "usage: rm [--force]",
+%% while long description is printed in detailed section below: "--force forcefully remove".
+
+-type argument_name() :: atom() | string() | binary().
+
+-type argument() :: #{
+ %% Argument name, and a destination to store value too
+ %% It is allowed to have several arguments named the same, setting or appending to the same variable.
+ name := argument_name(),
+
+ %% short, single-character variant of command line option, omitting dash (example: $b, meaning -b),
+ %% when present, the argument is considered optional
+ short => char(),
+
+ %% long command line option, omitting first dash (example: "kernel" means "-kernel" in the command line)
+ %% long command always wins over short abbreviation (e.g. -kernel is considered before -k -e -r -n -e -l)
+ %% when present, the argument is considered optional
+ long => string(),
+
+ %% makes parser to return an error if the argument is not present in the command line
+ required => boolean(),
+
+ %% default value, produced if the argument is not present in the command line
+ %% parser also accepts a global default
+ default => term(),
+
+ %% parameter type (string by default)
+ type => arg_type(),
+
+ %% action to take when argument is matched
+ action => store | %% default: store argument consumed (last stored wins)
+ {store, term()} | %% does not consume argument, stores term() instead
+ append | %% appends consumed argument to a list
+ {append, term()} | %% does not consume an argument, appends term() to a list
+ count | %% does not consume argument, bumps counter
+ extend, %% uses when nargs is list/nonempty_list/all - appends every element to the list
+
+ %% how many positional arguments to consume
+ nargs =>
+ pos_integer() | %% consume exactly this amount, e.g. '-kernel key value' #{long => "-kernel", args => 2}
+ %% returns #{kernel => ["key", "value"]}
+ 'maybe' | %% if the next argument is positional, consume it, otherwise produce default
+ {'maybe', term()} | %% if the next argument is positional, consume it, otherwise produce term()
+ list | %% consume zero or more positional arguments, until next optional
+ nonempty_list | %% consume at least one positional argument, until next optional
+ all, %% fold remaining command line into this argument
+
+ %% help string printed in usage, hidden help is not printed at all
+ help => hidden | unicode:chardata() | argument_help()
+}.
+%% Command line argument specification.
+%% Argument can be optional - starting with - (dash), and positional.
+
+-type arg_map() :: #{argument_name() => term()}.
+%% Arguments map: argument name to a term, produced by parser. Supplied to the command handler
+
+-type handler() ::
+ optional | %% valid for commands with sub-commands, suppresses parser error when no
+ %% sub-command is selected
+ fun((arg_map()) -> term()) | %% handler accepting arg_map
+ {module(), Fn :: atom()} | %% handler, accepting arg_map, Fn exported from module()
+ {fun(() -> term()), term()} | %% handler, positional form (term() is supplied for omitted args)
+ {module(), atom(), term()}. %% handler, positional form, exported from module()
+%% Command handler. May produce some output. Can accept a map, or be
+%% arbitrary mfa() for handlers accepting positional list.
+%% Special value 'optional' may be used to suppress an error that
+%% otherwise raised when command contains sub-commands, but arguments
+%% supplied via command line do not select any.
+
+-type command_help() :: [unicode:chardata() | usage | commands | arguments | options].
+%% Template for the command help/usage message.
+
+%% Command descriptor
+-type command() :: #{
+ %% Sub-commands are arranged into maps. Command name must not start with <em>prefix</em>.
+ commands => #{string() => command()},
+ %% accepted arguments list. Order is important!
+ arguments => [argument()],
+ %% help line
+ help => hidden | unicode:chardata() | command_help(),
+ %% recommended handler function
+ handler => handler()
+}.
+
+-type cmd_path() :: [string()].
+%% Command path, for nested commands
+
+-export_type([arg_type/0, argument_help/0, argument/0,
+ command/0, handler/0, cmd_path/0, arg_map/0]).
+
+-type parser_error() :: {Path :: cmd_path(),
+ Expected :: argument() | undefined,
+ Actual :: string() | undefined,
+ Details :: unicode:chardata()}.
+%% Returned from `parse/2,3' when command spec is valid, but the command line
+%% cannot be parsed using the spec.
+%% When `Expected' is undefined, but `Actual' is not, it means that the input contains
+%% an unexpected argument which cannot be parsed according to command spec.
+%% When `Expected' is an argument, and `Actual' is undefined, it means that a mandatory
+%% argument is not provided in the command line.
+%% When both `Expected' and `Actual' are defined, it means that the supplied argument
+%% is failing validation.
+%% When both are `undefined', there is some logical issue (e.g. a sub-command is required,
+%% but was not selected).
+
+-type parser_options() :: #{
+ %% allowed prefixes (default is [$-]).
+ prefixes => [char()],
+ %% default value for all missing optional arguments
+ default => term(),
+ %% root command name (program name)
+ progname => string() | atom(),
+ %% considered by `help/2' only
+ command => cmd_path(), %% command to print the help for
+ columns => pos_integer() %% viewport width, in characters
+}.
+%% Parser options
+
+-type parse_result() ::
+ {ok, arg_map(), Path :: cmd_path(), command()} |
+ {error, parser_error()}.
+%% Parser result: argument map, path leading to successfully
+%% matching command (contains only ["progname"] if there were
+%% no subcommands matched), and a matching command.
+
+%% @equiv validate(Command, #{})
+-spec validate(command()) -> Progname :: string().
+validate(Command) ->
+ validate(Command, #{}).
+
+%% @doc Validate command specification, taking Options into account.
+%% Raises an error if the command specification is invalid.
+-spec validate(command(), parser_options()) -> Progname :: string().
+validate(Command, Options) ->
+ Prog = executable(Options),
+ is_list(Prog) orelse erlang:error(badarg, [Command, Options],
+ [{error_info, #{cause => #{2 => <<"progname is not valid">>}}}]),
+ Prefixes = maps:from_list([{P, true} || P <- maps:get(prefixes, Options, [$-])]),
+ _ = validate_command([{Prog, Command}], Prefixes),
+ Prog.
+
+%% @equiv parse(Args, Command, #{})
+-spec parse(Args :: [string()], command()) -> parse_result().
+parse(Args, Command) ->
+ parse(Args, Command, #{}).
+
+%% @doc Parses supplied arguments according to expected command specification.
+%% @param Args command line arguments (e.g. `init:get_plain_arguments()')
+%% @returns argument map, or argument map with deepest matched command
+%% definition.
+-spec parse(Args :: [string()], command(), Options :: parser_options()) -> parse_result().
+parse(Args, Command, Options) ->
+ Prog = validate(Command, Options),
+ %% use maps and not sets v2, because sets:is_element/2 cannot be used in guards (unlike is_map_key)
+ Prefixes = maps:from_list([{P, true} || P <- maps:get(prefixes, Options, [$-])]),
+ try
+ parse_impl(Args, merge_arguments(Prog, Command, init_parser(Prefixes, Command, Options)))
+ catch
+ %% Parser error may happen at any depth, and bubbling the error is really
+ %% cumbersome. Use exceptions and catch it before returning from `parse/2,3' instead.
+ throw:Reason ->
+ {error, Reason}
+ end.
+
+%% @equiv help(Command, #{})
+-spec help(command()) -> string().
+help(Command) ->
+ help(Command, #{}).
+
+%% @doc Returns help for Command formatted according to Options specified
+-spec help(command(), parser_options()) -> unicode:chardata().
+help(Command, Options) ->
+ Prog = validate(Command, Options),
+ format_help({Prog, Command}, Options).
+
+%% @doc
+-spec run(Args :: [string()], command(), parser_options()) -> term().
+run(Args, Command, Options) ->
+ try parse(Args, Command, Options) of
+ {ok, ArgMap, Path, SubCmd} ->
+ handle(Command, ArgMap, tl(Path), SubCmd);
+ {error, Reason} ->
+ io:format("error: ~ts~n", [argparse:format_error(Reason)]),
+ io:format("~ts", [argparse:help(Command, Options#{command => tl(element(1, Reason))})]),
+ erlang:halt(1)
+ catch
+ error:Reason:Stack ->
+ io:format(erl_error:format_exception(error, Reason, Stack)),
+ erlang:halt(1)
+ end.
+
+%% @doc Basic formatter for the parser error reason.
+-spec format_error(Reason :: parser_error()) -> unicode:chardata().
+format_error({Path, undefined, undefined, Details}) ->
+ io_lib:format("~ts: ~ts", [format_path(Path), Details]);
+format_error({Path, undefined, Actual, Details}) ->
+ io_lib:format("~ts: unknown argument: ~ts~ts", [format_path(Path), Actual, Details]);
+format_error({Path, #{name := Name}, undefined, Details}) ->
+ io_lib:format("~ts: required argument missing: ~ts~ts", [format_path(Path), Name, Details]);
+format_error({Path, #{name := Name}, Value, Details}) ->
+ io_lib:format("~ts: invalid argument for ~ts: ~ts ~ts", [format_path(Path), Name, Value, Details]).
+
+-type validator_error() ::
+ {?MODULE, command | argument, cmd_path(), Field :: atom(), Detail :: unicode:chardata()}.
+
+%% @doc Transforms exception thrown by `validate/1,2' according to EEP54.
+%% Use `erl_error:format_exception/3,4' to get the shell-like output.
+-spec format_error(Reason :: validator_error(), erlang:stacktrace()) -> map().
+format_error({?MODULE, command, Path, Field, Reason}, [{_M, _F, [Cmd], Info} | _]) ->
+ #{cause := Cause} = proplists:get_value(error_info, Info, #{}),
+ Cause#{general => <<"command specification is invalid">>, 1 => io_lib:format("~tp", [Cmd]),
+ reason => io_lib:format("command \"~ts\": invalid field '~ts', reason: ~ts", [format_path(Path), Field, Reason])};
+format_error({?MODULE, argument, Path, Field, Reason}, [{_M, _F, [Arg], Info} | _]) ->
+ #{cause := Cause} = proplists:get_value(error_info, Info, #{}),
+ ArgName = maps:get(name, Arg, ""),
+ Cause#{general => "argument specification is invalid", 1 => io_lib:format("~tp", [Arg]),
+ reason => io_lib:format("command \"~ts\", argument '~ts', invalid field '~ts': ~ts",
+ [format_path(Path), ArgName, Field, Reason])}.
+
+%%--------------------------------------------------------------------
+%% Parser implementation
+
+%% Parser state (not available via API)
+-record(eos, {
+ %% prefix character map, by default, only -
+ prefixes :: #{char() => true},
+ %% argument map to be returned
+ argmap = #{} :: arg_map(),
+ %% sub-commands, in reversed orders, allowing to recover the path taken
+ commands = [] :: cmd_path(),
+ %% command being matched
+ current :: command(),
+ %% unmatched positional arguments, in the expected match order
+ pos = [] :: [argument()],
+ %% expected optional arguments, mapping between short/long form and an argument
+ short = #{} :: #{integer() => argument()},
+ long = #{} :: #{string() => argument()},
+ %% flag, whether there are no options that can be confused with negative numbers
+ no_digits = true :: boolean(),
+ %% global default for not required arguments
+ default :: error | {ok, term()}
+}).
+
+init_parser(Prefixes, Cmd, Options) ->
+ #eos{prefixes = Prefixes, current = Cmd, default = maps:find(default, Options)}.
+
+%% Optional or positional argument?
+-define(IS_OPTION(Arg), is_map_key(short, Arg) orelse is_map_key(long, Arg)).
+
+%% helper function to match either a long form of "--arg=value", or just "--arg"
+match_long(Arg, LongOpts) ->
+ case maps:find(Arg, LongOpts) of
+ {ok, Option} ->
+ {ok, Option};
+ error ->
+ %% see if there is '=' equals sign in the Arg
+ case string:split(Arg, "=") of
+ [MaybeLong, Value] ->
+ case maps:find(MaybeLong, LongOpts) of
+ {ok, Option} ->
+ {ok, Option, Value};
+ error ->
+ nomatch
+ end;
+ _ ->
+ nomatch
+ end
+ end.
+
+%% parse_impl implements entire internal parse logic.
+
+%% Clause: option starting with any prefix
+%% No separate clause for single-character short form, because there could be a single-character
+%% long form taking precedence.
+parse_impl([[Prefix | Name] | Tail], #eos{prefixes = Pref} = Eos) when is_map_key(Prefix, Pref) ->
+ %% match "long" option from the list of currently known
+ case match_long(Name, Eos#eos.long) of
+ {ok, Option} ->
+ consume(Tail, Option, Eos);
+ {ok, Option, Value} ->
+ consume([Value | Tail], Option, Eos);
+ nomatch ->
+ %% try to match single-character flag
+ case Name of
+ [Flag] when is_map_key(Flag, Eos#eos.short) ->
+ %% found a flag
+ consume(Tail, maps:get(Flag, Eos#eos.short), Eos);
+ [Flag | Rest] when is_map_key(Flag, Eos#eos.short) ->
+ %% can be a combination of flags, or flag with value,
+ %% but can never be a negative integer, because otherwise
+ %% it will be reflected in no_digits
+ case abbreviated(Name, [], Eos#eos.short) of
+ false ->
+ %% short option with Rest being an argument
+ consume([Rest | Tail], maps:get(Flag, Eos#eos.short), Eos);
+ Expanded ->
+ %% expand multiple flags into actual list, adding prefix
+ parse_impl([[Prefix,E] || E <- Expanded] ++ Tail, Eos)
+ end;
+ MaybeNegative when Prefix =:= $-, Eos#eos.no_digits ->
+ case is_digits(MaybeNegative) of
+ true ->
+ %% found a negative number
+ parse_positional([Prefix|Name], Tail, Eos);
+ false ->
+ catch_all_positional([[Prefix|Name] | Tail], Eos)
+ end;
+ _Unknown ->
+ catch_all_positional([[Prefix|Name] | Tail], Eos)
+ end
+ end;
+
+%% Arguments not starting with Prefix: attempt to match sub-command, if available
+parse_impl([Positional | Tail], #eos{current = #{commands := SubCommands}} = Eos) ->
+ case maps:find(Positional, SubCommands) of
+ error ->
+ %% sub-command not found, try positional argument
+ parse_positional(Positional, Tail, Eos);
+ {ok, SubCmd} ->
+ %% found matching sub-command with arguments, descend into it
+ parse_impl(Tail, merge_arguments(Positional, SubCmd, Eos))
+ end;
+
+%% Clause for arguments that don't have sub-commands (therefore check for
+%% positional argument).
+parse_impl([Positional | Tail], Eos) ->
+ parse_positional(Positional, Tail, Eos);
+
+%% Entire command line has been matched, go over missing arguments,
+%% add defaults etc
+parse_impl([], #eos{argmap = ArgMap0, commands = Commands, current = Current, pos = Pos, default = Def} = Eos) ->
+ %% error if stopped at sub-command with no handler
+ map_size(maps:get(commands, Current, #{})) >0 andalso
+ (not is_map_key(handler, Current)) andalso
+ throw({Commands, undefined, undefined, <<"subcommand expected">>}),
+
+ %% go over remaining positional, verify they are all not required
+ ArgMap1 = fold_args_map(Commands, true, ArgMap0, Pos, Def),
+ %% go over optionals, and either raise an error, or set default
+ ArgMap2 = fold_args_map(Commands, false, ArgMap1, maps:values(Eos#eos.short), Def),
+ ArgMap3 = fold_args_map(Commands, false, ArgMap2, maps:values(Eos#eos.long), Def),
+
+ %% return argument map, command path taken, and the deepest
+ %% last command matched (usually it contains a handler to run)
+ {ok, ArgMap3, Eos#eos.commands, Eos#eos.current}.
+
+%% Generate error for missing required argument, and supply defaults for
+%% missing optional arguments that have defaults.
+fold_args_map(Commands, Req, ArgMap, Args, GlobalDefault) ->
+ lists:foldl(
+ fun (#{name := Name}, Acc) when is_map_key(Name, Acc) ->
+ %% argument present
+ Acc;
+ (#{required := true} = Opt, _Acc) ->
+ %% missing, and required explicitly
+ throw({Commands, Opt, undefined, <<>>});
+ (#{name := Name, required := false, default := Default}, Acc) ->
+ %% explicitly not required argument with default
+ Acc#{Name => Default};
+ (#{name := Name, required := false}, Acc) ->
+ %% explicitly not required with no local default, try global one
+ try_global_default(Name, Acc, GlobalDefault);
+ (#{name := Name, default := Default}, Acc) when Req =:= true ->
+ %% positional argument with default
+ Acc#{Name => Default};
+ (Opt, _Acc) when Req =:= true ->
+ %% missing, for positional argument, implicitly required
+ throw({Commands, Opt, undefined, <<>>});
+ (#{name := Name, default := Default}, Acc) ->
+ %% missing, optional, and there is a default
+ Acc#{Name => Default};
+ (#{name := Name}, Acc) ->
+ %% missing, optional, no local default, try global default
+ try_global_default(Name, Acc, GlobalDefault)
+ end, ArgMap, Args).
+
+try_global_default(_Name, Acc, error) ->
+ Acc;
+try_global_default(Name, Acc, {ok, Term}) ->
+ Acc#{Name => Term}.
+
+%%--------------------------------------------------------------------
+%% argument consumption (nargs) handling
+
+catch_all_positional(Tail, #eos{pos = [#{nargs := all} = Opt]} = Eos) ->
+ action([], Tail, Opt#{type => {list, maps:get(type, Opt, string)}}, Eos);
+%% it is possible that some positional arguments are not required,
+%% and therefore it is possible to catch all skipping those
+catch_all_positional(Tail, #eos{argmap = Args, pos = [#{name := Name, default := Default, required := false} | Pos]} = Eos) ->
+ catch_all_positional(Tail, Eos#eos{argmap = Args#{Name => Default}, pos = Pos});
+%% same as above, but no default specified
+catch_all_positional(Tail, #eos{pos = [#{required := false} | Pos]} = Eos) ->
+ catch_all_positional(Tail, Eos#eos{pos = Pos});
+catch_all_positional([Arg | _Tail], #eos{commands = Commands}) ->
+ throw({Commands, undefined, Arg, <<>>}).
+
+parse_positional(Arg, _Tail, #eos{pos = [], commands = Commands}) ->
+ throw({Commands, undefined, Arg, <<>>});
+parse_positional(Arg, Tail, #eos{pos = Pos} = Eos) ->
+ %% positional argument itself is a value
+ consume([Arg | Tail], hd(Pos), Eos).
+
+%% Adds CmdName to path, and includes any arguments found there
+merge_arguments(CmdName, #{arguments := Args} = SubCmd, Eos) ->
+ add_args(Args, Eos#eos{current = SubCmd, commands = Eos#eos.commands ++ [CmdName]});
+merge_arguments(CmdName, SubCmd, Eos) ->
+ Eos#eos{current = SubCmd, commands = Eos#eos.commands ++ [CmdName]}.
+
+%% adds arguments into current set of discovered pos/opts
+add_args([], Eos) ->
+ Eos;
+add_args([#{short := S, long := L} = Option | Tail], #eos{short = Short, long = Long} = Eos) ->
+ %% remember if this option can be confused with negative number
+ NoDigits = no_digits(Eos#eos.no_digits, Eos#eos.prefixes, S, L),
+ add_args(Tail, Eos#eos{short = Short#{S => Option}, long = Long#{L => Option}, no_digits = NoDigits});
+add_args([#{short := S} = Option | Tail], #eos{short = Short} = Eos) ->
+ %% remember if this option can be confused with negative number
+ NoDigits = no_digits(Eos#eos.no_digits, Eos#eos.prefixes, S, 0),
+ add_args(Tail, Eos#eos{short = Short#{S => Option}, no_digits = NoDigits});
+add_args([#{long := L} = Option | Tail], #eos{long = Long} = Eos) ->
+ %% remember if this option can be confused with negative number
+ NoDigits = no_digits(Eos#eos.no_digits, Eos#eos.prefixes, 0, L),
+ add_args(Tail, Eos#eos{long = Long#{L => Option}, no_digits = NoDigits});
+add_args([PosOpt | Tail], #eos{pos = Pos} = Eos) ->
+ add_args(Tail, Eos#eos{pos = Pos ++ [PosOpt]}).
+
+%% If no_digits is still true, try to find out whether it should turn false,
+%% because added options look like negative numbers, and prefixes include -
+no_digits(false, _, _, _) ->
+ false;
+no_digits(true, Prefixes, _, _) when not is_map_key($-, Prefixes) ->
+ true;
+no_digits(true, _, Short, _) when Short >= $0, Short =< $9 ->
+ false;
+no_digits(true, _, _, Long) ->
+ not is_digits(Long).
+
+%%--------------------------------------------------------------------
+%% additional functions for optional arguments processing
+
+%% Returns true when option (!) description passed requires a positional argument,
+%% hence cannot be treated as a flag.
+requires_argument(#{nargs := {'maybe', _Term}}) ->
+ false;
+requires_argument(#{nargs := 'maybe'}) ->
+ false;
+requires_argument(#{nargs := _Any}) ->
+ true;
+requires_argument(Opt) ->
+ case maps:get(action, Opt, store) of
+ store ->
+ maps:get(type, Opt, string) =/= boolean;
+ append ->
+ maps:get(type, Opt, string) =/= boolean;
+ _ ->
+ false
+ end.
+
+%% Attempts to find if passed list of flags can be expanded
+abbreviated([Last], Acc, AllShort) when is_map_key(Last, AllShort) ->
+ lists:reverse([Last | Acc]);
+abbreviated([_], _Acc, _Eos) ->
+ false;
+abbreviated([Flag | Tail], Acc, AllShort) ->
+ case maps:find(Flag, AllShort) of
+ error ->
+ false;
+ {ok, Opt} ->
+ case requires_argument(Opt) of
+ true ->
+ false;
+ false ->
+ abbreviated(Tail, [Flag | Acc], AllShort)
+ end
+ end.
+
+%%--------------------------------------------------------------------
+%% argument consumption (nargs) handling
+
+%% consume predefined amount (none of which can be an option?)
+consume(Tail, #{nargs := Count} = Opt, Eos) when is_integer(Count) ->
+ {Consumed, Remain} = split_to_option(Tail, Count, Eos, []),
+ length(Consumed) < Count andalso
+ throw({Eos#eos.commands, Opt, Tail,
+ io_lib:format("expected ~b, found ~b argument(s)", [Count, length(Consumed)])}),
+ action(Remain, Consumed, Opt#{type => {list, maps:get(type, Opt, string)}}, Eos);
+
+%% handle 'reminder' by just dumping everything in
+consume(Tail, #{nargs := all} = Opt, Eos) ->
+ action([], Tail, Opt#{type => {list, maps:get(type, Opt, string)}}, Eos);
+
+%% require at least one argument
+consume(Tail, #{nargs := nonempty_list} = Opt, Eos) ->
+ {Consumed, Remains} = split_to_option(Tail, -1, Eos, []),
+ Consumed =:= [] andalso throw({Eos#eos.commands, Opt, Tail, <<"expected argument">>}),
+ action(Remains, Consumed, Opt#{type => {list, maps:get(type, Opt, string)}}, Eos);
+
+%% consume all until next option
+consume(Tail, #{nargs := list} = Opt, Eos) ->
+ {Consumed, Remains} = split_to_option(Tail, -1, Eos, []),
+ action(Remains, Consumed, Opt#{type => {list, maps:get(type, Opt, string)}}, Eos);
+
+%% maybe consume one, maybe not...
+%% special cases for 'boolean maybe', only consume 'true' and 'false'
+consume(["true" | Tail], #{type := boolean} = Opt, Eos) ->
+ action(Tail, true, Opt#{type => raw}, Eos);
+consume(["false" | Tail], #{type := boolean} = Opt, Eos) ->
+ action(Tail, false, Opt#{type => raw}, Eos);
+consume(Tail, #{type := boolean} = Opt, Eos) ->
+ %% neither true nor false means 'undefined' (with the default for boolean being true)
+ action(Tail, undefined, Opt, Eos);
+
+%% maybe behaviour, as '?'
+consume(Tail, #{nargs := 'maybe'} = Opt, Eos) ->
+ case split_to_option(Tail, 1, Eos, []) of
+ {[], _} ->
+ %% no argument given, produce default argument (if not present,
+ %% then produce default value of the specified type)
+ action(Tail, default(Opt), Opt#{type => raw}, Eos);
+ {[Consumed], Remains} ->
+ action(Remains, Consumed, Opt, Eos)
+ end;
+
+%% maybe consume one, maybe not...
+consume(Tail, #{nargs := {'maybe', Const}} = Opt, Eos) ->
+ case split_to_option(Tail, 1, Eos, []) of
+ {[], _} ->
+ action(Tail, Const, Opt, Eos);
+ {[Consumed], Remains} ->
+ action(Remains, Consumed, Opt, Eos)
+ end;
+
+%% default case, which depends on action
+consume(Tail, #{action := count} = Opt, Eos) ->
+ action(Tail, undefined, Opt, Eos);
+
+%% for {store, ...} and {append, ...} don't take argument out
+consume(Tail, #{action := {Act, _Const}} = Opt, Eos) when Act =:= store; Act =:= append ->
+ action(Tail, undefined, Opt, Eos);
+
+%% optional: ensure not to consume another option start
+consume([[Prefix | _] = ArgValue | Tail], Opt, Eos) when ?IS_OPTION(Opt), is_map_key(Prefix, Eos#eos.prefixes) ->
+ case Eos#eos.no_digits andalso is_digits(ArgValue) of
+ true ->
+ action(Tail, ArgValue, Opt, Eos);
+ false ->
+ throw({Eos#eos.commands, Opt, undefined, <<"expected argument">>})
+ end;
+
+consume([ArgValue | Tail], Opt, Eos) ->
+ action(Tail, ArgValue, Opt, Eos);
+
+%% we can only be here if it's optional argument, but there is no value supplied,
+%% and type is not 'boolean' - this is an error!
+consume([], Opt, Eos) ->
+ throw({Eos#eos.commands, Opt, undefined, <<"expected argument">>}).
+
+%% no more arguments for consumption, but last optional may still be action-ed
+%%consume([], Current, Opt, Eos) ->
+%% action([], Current, undefined, Opt, Eos).
+
+%% smart split: ignore arguments that can be parsed as negative numbers,
+%% unless there are arguments that look like negative numbers
+split_to_option([], _, _Eos, Acc) ->
+ {lists:reverse(Acc), []};
+split_to_option(Tail, 0, _Eos, Acc) ->
+ {lists:reverse(Acc), Tail};
+split_to_option([[Prefix | _] = MaybeNumber | Tail] = All, Left,
+ #eos{no_digits = true, prefixes = Prefixes} = Eos, Acc) when is_map_key(Prefix, Prefixes) ->
+ case is_digits(MaybeNumber) of
+ true ->
+ split_to_option(Tail, Left - 1, Eos, [MaybeNumber | Acc]);
+ false ->
+ {lists:reverse(Acc), All}
+ end;
+split_to_option([[Prefix | _] | _] = All, _Left,
+ #eos{no_digits = false, prefixes = Prefixes}, Acc) when is_map_key(Prefix, Prefixes) ->
+ {lists:reverse(Acc), All};
+split_to_option([Head | Tail], Left, Opts, Acc) ->
+ split_to_option(Tail, Left - 1, Opts, [Head | Acc]).
+
+%%--------------------------------------------------------------------
+%% Action handling
+
+action(Tail, ArgValue, #{name := ArgName, action := store} = Opt, #eos{argmap = ArgMap} = Eos) ->
+ Value = convert_type(maps:get(type, Opt, string), ArgValue, Opt, Eos),
+ continue_parser(Tail, Opt, Eos#eos{argmap = ArgMap#{ArgName => Value}});
+
+action(Tail, undefined, #{name := ArgName, action := {store, Value}} = Opt, #eos{argmap = ArgMap} = Eos) ->
+ continue_parser(Tail, Opt, Eos#eos{argmap = ArgMap#{ArgName => Value}});
+
+action(Tail, ArgValue, #{name := ArgName, action := append} = Opt, #eos{argmap = ArgMap} = Eos) ->
+ Value = convert_type(maps:get(type, Opt, string), ArgValue, Opt, Eos),
+ continue_parser(Tail, Opt, Eos#eos{argmap = ArgMap#{ArgName => maps:get(ArgName, ArgMap, []) ++ [Value]}});
+
+action(Tail, undefined, #{name := ArgName, action := {append, Value}} = Opt, #eos{argmap = ArgMap} = Eos) ->
+ continue_parser(Tail, Opt, Eos#eos{argmap = ArgMap#{ArgName => maps:get(ArgName, ArgMap, []) ++ [Value]}});
+
+action(Tail, ArgValue, #{name := ArgName, action := extend} = Opt, #eos{argmap = ArgMap} = Eos) ->
+ Value = convert_type(maps:get(type, Opt, string), ArgValue, Opt, Eos),
+ Extended = maps:get(ArgName, ArgMap, []) ++ Value,
+ continue_parser(Tail, Opt, Eos#eos{argmap = ArgMap#{ArgName => Extended}});
+
+action(Tail, _, #{name := ArgName, action := count} = Opt, #eos{argmap = ArgMap} = Eos) ->
+ continue_parser(Tail, Opt, Eos#eos{argmap = ArgMap#{ArgName => maps:get(ArgName, ArgMap, 0) + 1}});
+
+%% default action is `store' (important to sync the code with the first clause above)
+action(Tail, ArgValue, #{name := ArgName} = Opt, #eos{argmap = ArgMap} = Eos) ->
+ Value = convert_type(maps:get(type, Opt, string), ArgValue, Opt, Eos),
+ continue_parser(Tail, Opt, Eos#eos{argmap = ArgMap#{ArgName => Value}}).
+
+%% pop last positional, unless nargs is list/nonempty_list
+continue_parser(Tail, Opt, Eos) when ?IS_OPTION(Opt) ->
+ parse_impl(Tail, Eos);
+continue_parser(Tail, #{nargs := List}, Eos) when List =:= list; List =:= nonempty_list ->
+ parse_impl(Tail, Eos);
+continue_parser(Tail, _Opt, Eos) ->
+ parse_impl(Tail, Eos#eos{pos = tl(Eos#eos.pos)}).
+
+%%--------------------------------------------------------------------
+%% Type conversion
+
+%% Handle "list" variant for nargs returning list
+convert_type({list, Type}, Arg, Opt, Eos) ->
+ [convert_type(Type, Var, Opt, Eos) || Var <- Arg];
+
+%% raw - no conversion applied (most likely default)
+convert_type(raw, Arg, _Opt, _Eos) ->
+ Arg;
+
+%% Handle actual types
+convert_type(string, Arg, _Opt, _Eos) ->
+ Arg;
+convert_type({string, Choices}, Arg, Opt, Eos) when is_list(Choices), is_list(hd(Choices)) ->
+ lists:member(Arg, Choices) orelse
+ throw({Eos#eos.commands, Opt, Arg, <<"is not one of the choices">>}),
+ Arg;
+convert_type({string, Re}, Arg, Opt, Eos) ->
+ case re:run(Arg, Re) of
+ {match, _X} -> Arg;
+ _ -> throw({Eos#eos.commands, Opt, Arg, <<"does not match">>})
+ end;
+convert_type({string, Re, ReOpt}, Arg, Opt, Eos) ->
+ case re:run(Arg, Re, ReOpt) of
+ match -> Arg;
+ {match, _} -> Arg;
+ _ -> throw({Eos#eos.commands, Opt, Arg, <<"does not match">>})
+ end;
+convert_type(integer, Arg, Opt, Eos) ->
+ get_int(Arg, Opt, Eos);
+convert_type({integer, Opts}, Arg, Opt, Eos) ->
+ minimax(get_int(Arg, Opt, Eos), Opts, Eos, Opt, Arg);
+convert_type(boolean, "true", _Opt, _Eos) ->
+ true;
+convert_type(boolean, undefined, _Opt, _Eos) ->
+ true;
+convert_type(boolean, "false", _Opt, _Eos) ->
+ false;
+convert_type(boolean, Arg, Opt, Eos) ->
+ throw({Eos#eos.commands, Opt, Arg, <<"is not a boolean">>});
+convert_type(binary, Arg, _Opt, _Eos) ->
+ unicode:characters_to_binary(Arg);
+convert_type({binary, Choices}, Arg, Opt, Eos) when is_list(Choices), is_binary(hd(Choices)) ->
+ Conv = unicode:characters_to_binary(Arg),
+ lists:member(Conv, Choices) orelse
+ throw({Eos#eos.commands, Opt, Arg, <<"is not one of the choices">>}),
+ Conv;
+convert_type({binary, Re}, Arg, Opt, Eos) ->
+ case re:run(Arg, Re) of
+ {match, _X} -> unicode:characters_to_binary(Arg);
+ _ -> throw({Eos#eos.commands, Opt, Arg, <<"does not match">>})
+ end;
+convert_type({binary, Re, ReOpt}, Arg, Opt, Eos) ->
+ case re:run(Arg, Re, ReOpt) of
+ match -> unicode:characters_to_binary(Arg);
+ {match, _} -> unicode:characters_to_binary(Arg);
+ _ -> throw({Eos#eos.commands, Opt, Arg, <<"does not match">>})
+ end;
+convert_type(float, Arg, Opt, Eos) ->
+ get_float(Arg, Opt, Eos);
+convert_type({float, Opts}, Arg, Opt, Eos) ->
+ minimax(get_float(Arg, Opt, Eos), Opts, Eos, Opt, Arg);
+convert_type(atom, Arg, Opt, Eos) ->
+ try list_to_existing_atom(Arg)
+ catch error:badarg ->
+ throw({Eos#eos.commands, Opt, Arg, <<"is not an existing atom">>})
+ end;
+convert_type({atom, unsafe}, Arg, _Opt, _Eos) ->
+ list_to_atom(Arg);
+convert_type({atom, Choices}, Arg, Opt, Eos) ->
+ try
+ Atom = list_to_existing_atom(Arg),
+ lists:member(Atom, Choices) orelse throw({Eos#eos.commands, Opt, Arg, <<"is not one of the choices">>}),
+ Atom
+ catch error:badarg ->
+ throw({Eos#eos.commands, Opt, Arg, <<"is not an existing atom">>})
+ end;
+convert_type({custom, Fun}, Arg, Opt, Eos) ->
+ try Fun(Arg)
+ catch error:badarg ->
+ throw({Eos#eos.commands, Opt, Arg, <<"failed faildation">>})
+ end.
+
+%% Given Var, and list of {min, X}, {max, Y}, ensure that
+%% value falls within defined limits.
+minimax(Var, [], _Eos, _Opt, _Orig) ->
+ Var;
+minimax(Var, [{min, Min} | _], Eos, Opt, Orig) when Var < Min ->
+ throw({Eos#eos.commands, Opt, Orig, <<"is less than accepted minimum">>});
+minimax(Var, [{max, Max} | _], Eos, Opt, Orig) when Var > Max ->
+ throw({Eos#eos.commands, Opt, Orig, <<"is greater than accepted maximum">>});
+minimax(Var, [Num | Tail], Eos, Opt, Orig) when is_number(Num) ->
+ lists:member(Var, [Num|Tail]) orelse
+ throw({Eos#eos.commands, Opt, Orig, <<"is not one of the choices">>}),
+ Var;
+minimax(Var, [_ | Tail], Eos, Opt, Orig) ->
+ minimax(Var, Tail, Eos, Opt, Orig).
+
+%% returns integer from string, or errors out with debugging info
+get_int(Arg, Opt, Eos) ->
+ case string:to_integer(Arg) of
+ {Int, []} ->
+ Int;
+ _ ->
+ throw({Eos#eos.commands, Opt, Arg, <<"is not an integer">>})
+ end.
+
+%% returns float from string, that is floating-point, or integer
+get_float(Arg, Opt, Eos) ->
+ case string:to_float(Arg) of
+ {Float, []} ->
+ Float;
+ _ ->
+ %% possibly in disguise
+ case string:to_integer(Arg) of
+ {Int, []} ->
+ Int;
+ _ ->
+ throw({Eos#eos.commands, Opt, Arg, <<"is not a number">>})
+ end
+ end.
+
+%% Returns 'true' if String can be converted to a number
+is_digits(String) ->
+ case string:to_integer(String) of
+ {_Int, []} ->
+ true;
+ {_, _} ->
+ case string:to_float(String) of
+ {_Float, []} ->
+ true;
+ {_, _} ->
+ false
+ end
+ end.
+
+%% 'maybe' nargs for an option that does not have default set still have
+%% to produce something, let's call it hardcoded default.
+default(#{default := Default}) ->
+ Default;
+default(#{type := boolean}) ->
+ true;
+default(#{type := integer}) ->
+ 0;
+default(#{type := float}) ->
+ 0.0;
+default(#{type := string}) ->
+ "";
+default(#{type := binary}) ->
+ <<"">>;
+default(#{type := atom}) ->
+ undefined;
+%% no type given, consider it 'undefined' atom
+default(_) ->
+ undefined.
+
+%% command path is now in direct order
+format_path(Commands) ->
+ lists:join(" ", Commands).
+
+%%--------------------------------------------------------------------
+%% Validation and preprocessing
+%% Theoretically, Dialyzer should do that too.
+%% Practically, so many people ignore Dialyzer and then spend hours
+%% trying to understand why things don't work, that is makes sense
+%% to provide a mini-Dialyzer here.
+
+%% to simplify throwing errors with the right reason
+-define (INVALID(Kind, Entity, Path, Field, Text),
+ erlang:error({?MODULE, Kind, clean_path(Path), Field, Text}, [Entity], [{error_info, #{cause => #{}}}])).
+
+executable(#{progname := Prog}) when is_atom(Prog) ->
+ atom_to_list(Prog);
+executable(#{progname := Prog}) when is_binary(Prog) ->
+ binary_to_list(Prog);
+executable(#{progname := Prog}) ->
+ Prog;
+executable(_) ->
+ {ok, [[Prog]]} = init:get_argument(progname),
+ Prog.
+
+%% Recursive command validator
+validate_command([{Name, Cmd} | _] = Path, Prefixes) ->
+ (is_list(Name) andalso (not is_map_key(hd(Name), Prefixes))) orelse
+ ?INVALID(command, Cmd, tl(Path), commands,
+ <<"command name must be a string not starting with option prefix">>),
+ is_map(Cmd) orelse
+ ?INVALID(command, Cmd, Path, commands, <<"expected command()">>),
+ is_valid_command_help(maps:get(help, Cmd, [])) orelse
+ ?INVALID(command, Cmd, Path, help, <<"must be a printable unicode list, or a command help template">>),
+ is_map(maps:get(commands, Cmd, #{})) orelse
+ ?INVALID(command, Cmd, Path, commands, <<"expected map of #{string() => command()}">>),
+ case maps:get(handler, Cmd, optional) of
+ optional -> ok;
+ {Mod, ModFun} when is_atom(Mod), is_atom(ModFun) -> ok; %% map form
+ {Mod, ModFun, _} when is_atom(Mod), is_atom(ModFun) -> ok; %% positional form
+ {Fun, _} when is_function(Fun) -> ok; %% positional form
+ Fun when is_function(Fun, 1) -> ok;
+ _ -> ?INVALID(command, Cmd, Path, handler, <<"handler must be a valid callback, or an atom 'optional'">>)
+ end,
+ Cmd1 =
+ case maps:find(arguments, Cmd) of
+ error ->
+ Cmd;
+ {ok, Opts} when not is_list(Opts) ->
+ ?INVALID(command, Cmd, Path, arguments, <<"expected a list, [argument()]">>);
+ {ok, Opts} ->
+ Cmd#{arguments => [validate_option(Path, Opt) || Opt <- Opts]}
+ end,
+ %% collect all short & long option identifiers - to figure out any conflicts
+ lists:foldl(
+ fun ({_, #{arguments := Opts}}, Acc) ->
+ lists:foldl(
+ fun (#{short := Short, name := OName} = Arg, {AllS, AllL}) ->
+ is_map_key(Short, AllS) andalso
+ ?INVALID(argument, Arg, Path, short,
+ "short conflicting with previously defined short for "
+ ++ atom_to_list(maps:get(Short, AllS))),
+ {AllS#{Short => OName}, AllL};
+ (#{long := Long, name := OName} = Arg, {AllS, AllL}) ->
+ is_map_key(Long, AllL) andalso
+ ?INVALID(argument, Arg, Path, long,
+ "long conflicting with previously defined long for "
+ ++ atom_to_list(maps:get(Long, AllL))),
+ {AllS, AllL#{Long => OName}};
+ (_, AccIn) ->
+ AccIn
+ end, Acc, Opts);
+ (_, Acc) ->
+ Acc
+ end, {#{}, #{}}, Path),
+ %% verify all sub-commands
+ case maps:find(commands, Cmd1) of
+ error ->
+ {Name, Cmd1};
+ {ok, Sub} ->
+ {Name, Cmd1#{commands => maps:map(
+ fun (K, V) ->
+ {K, Updated} = validate_command([{K, V} | Path], Prefixes),
+ Updated
+ end, Sub)}}
+ end.
+
+%% validates option spec
+validate_option(Path, #{name := Name} = Arg) when is_atom(Name); is_list(Name); is_binary(Name) ->
+ %% verify specific arguments
+ %% help: string, 'hidden', or a tuple of {string(), ...}
+ is_valid_option_help(maps:get(help, Arg, [])) orelse
+ ?INVALID(argument, Arg, Path, help, <<"must be a string or valid help template">>),
+ io_lib:printable_unicode_list(maps:get(long, Arg, [])) orelse
+ ?INVALID(argument, Arg, Path, long, <<"must be a printable string">>),
+ is_boolean(maps:get(required, Arg, true)) orelse
+ ?INVALID(argument, Arg, Path, required, <<"must be a boolean">>),
+ io_lib:printable_unicode_list([maps:get(short, Arg, $a)]) orelse
+ ?INVALID(argument, Arg, Path, short, <<"must be a printable character">>),
+ Opt1 = maybe_validate(action, Arg, fun validate_action/3, Path),
+ Opt2 = maybe_validate(type, Opt1, fun validate_type/3, Path),
+ maybe_validate(nargs, Opt2, fun validate_args/3, Path);
+validate_option(Path, Arg) ->
+ ?INVALID(argument, Arg, Path, name, <<"argument must be a map containing 'name' field">>).
+
+maybe_validate(Key, Map, Fun, Path) when is_map_key(Key, Map) ->
+ maps:put(Key, Fun(maps:get(Key, Map), Path, Map), Map);
+maybe_validate(_Key, Map, _Fun, _Path) ->
+ Map.
+
+%% validate action field
+validate_action(store, _Path, _Opt) ->
+ store;
+validate_action({store, Term}, _Path, _Opt) ->
+ {store, Term};
+validate_action(append, _Path, _Opt) ->
+ append;
+validate_action({append, Term}, _Path, _Opt) ->
+ {append, Term};
+validate_action(count, _Path, _Opt) ->
+ count;
+validate_action(extend, _Path, #{nargs := Nargs}) when
+ Nargs =:= list; Nargs =:= nonempty_list; Nargs =:= all; is_integer(Nargs) ->
+ extend;
+validate_action(extend, _Path, #{type := {custom, _}}) ->
+ extend;
+validate_action(extend, Path, Arg) ->
+ ?INVALID(argument, Arg, Path, action, <<"extend action works only with lists">>);
+validate_action(_Action, Path, Arg) ->
+ ?INVALID(argument, Arg, Path, action, <<"unsupported">>).
+
+%% validate type field
+validate_type(Simple, _Path, _Opt) when Simple =:= boolean; Simple =:= integer; Simple =:= float;
+ Simple =:= string; Simple =:= binary; Simple =:= atom; Simple =:= {atom, unsafe} ->
+ Simple;
+validate_type({custom, Fun}, _Path, _Opt) when is_function(Fun, 1) ->
+ {custom, Fun};
+validate_type({float, Opts}, Path, Arg) ->
+ [?INVALID(argument, Arg, Path, type, <<"invalid validator">>)
+ || {Kind, Val} <- Opts, (Kind =/= min andalso Kind =/= max) orelse (not is_float(Val))],
+ {float, Opts};
+validate_type({integer, Opts}, Path, Arg) ->
+ [?INVALID(argument, Arg, Path, type, <<"invalid validator">>)
+ || {Kind, Val} <- Opts, (Kind =/= min andalso Kind =/= max) orelse (not is_integer(Val))],
+ {integer, Opts};
+validate_type({atom, Choices} = Valid, Path, Arg) when is_list(Choices) ->
+ [?INVALID(argument, Arg, Path, type, <<"unsupported">>) || C <- Choices, not is_atom(C)],
+ Valid;
+validate_type({string, Re} = Valid, _Path, _Opt) when is_list(Re) ->
+ Valid;
+validate_type({string, Re, L} = Valid, _Path, _Opt) when is_list(Re), is_list(L) ->
+ Valid;
+validate_type({binary, Re} = Valid, _Path, _Opt) when is_binary(Re) ->
+ Valid;
+validate_type({binary, Choices} = Valid, _Path, _Opt) when is_list(Choices), is_binary(hd(Choices)) ->
+ Valid;
+validate_type({binary, Re, L} = Valid, _Path, _Opt) when is_binary(Re), is_list(L) ->
+ Valid;
+validate_type(_Type, Path, Arg) ->
+ ?INVALID(argument, Arg, Path, type, <<"unsupported">>).
+
+validate_args(N, _Path, _Opt) when is_integer(N), N >= 1 -> N;
+validate_args(Simple, _Path, _Opt) when Simple =:= all; Simple =:= list; Simple =:= 'maybe'; Simple =:= nonempty_list ->
+ Simple;
+validate_args({'maybe', Term}, _Path, _Opt) -> {'maybe', Term};
+validate_args(_Nargs, Path, Arg) ->
+ ?INVALID(argument, Arg, Path, nargs, <<"unsupported">>).
+
+%% used to throw an error - strips command component out of path
+clean_path(Path) ->
+ {Cmds, _} = lists:unzip(Path),
+ lists:reverse(Cmds).
+
+is_valid_option_help(hidden) ->
+ true;
+is_valid_option_help(Help) when is_list(Help); is_binary(Help) ->
+ true;
+is_valid_option_help({Short, Desc}) when is_list(Short) orelse is_binary(Short), is_list(Desc) ->
+ %% verify that Desc is a list of string/type/default
+ lists:all(fun(type) -> true;
+ (default) -> true;
+ (S) when is_list(S); is_binary(S) -> true;
+ (_) -> false
+ end, Desc);
+is_valid_option_help({Short, Desc}) when is_list(Short) orelse is_binary(Short), is_function(Desc, 0) ->
+ true;
+is_valid_option_help(_) ->
+ false.
+
+is_valid_command_help(hidden) ->
+ true;
+is_valid_command_help(Help) when is_binary(Help) ->
+ true;
+is_valid_command_help(Help) when is_list(Help) ->
+ %% allow printable lists
+ case io_lib:printable_unicode_list(Help) of
+ true ->
+ true;
+ false ->
+ %% ... or a command help template
+ lists:all(
+ fun (Atom) when Atom =:= usage; Atom =:= commands; Atom =:= arguments; Atom =:= options -> true;
+ (Bin) when is_binary(Bin) -> true;
+ (Str) -> io_lib:printable_unicode_list(Str)
+ end, Help)
+ end;
+is_valid_command_help(_) ->
+ false.
+
+%%--------------------------------------------------------------------
+%% Built-in Help formatter
+
+format_help({ProgName, Root}, Format) ->
+ Prefix = hd(maps:get(prefixes, Format, [$-])),
+ Nested = maps:get(command, Format, []),
+ %% descent into commands collecting all options on the way
+ {_CmdName, Cmd, AllArgs} = collect_options(ProgName, Root, Nested, []),
+ %% split arguments into Flags, Options, Positional, and create help lines
+ {_, Longest, Flags, Opts, Args, OptL, PosL} = lists:foldl(fun format_opt_help/2,
+ {Prefix, 0, "", [], [], [], []}, AllArgs),
+ %% collect and format sub-commands
+ Immediate = maps:get(commands, Cmd, #{}),
+ {Long, Subs} = 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, []}, maps:iterator(Immediate, ordered)),
+ %% 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).