diff options
Diffstat (limited to 'lib/stdlib/src/argparse.erl')
-rw-r--r-- | lib/stdlib/src/argparse.erl | 1357 |
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). |