%%%-------------------------------------------------------------------
%%% @author Maxim Fedorov, <maximfca@gmail.com>
%%% @doc
%%% Command line utility behaviour. Usage example:
%%%
%%% From an escript main/1 function (requires `-mode(compile)'):
%%% ```
%%% cli:run(Args).
%%% '''
%%%
%%% Or, to limit cli behaviour discovery,
%%% ```
%%% cli:run(Args, #{modules => ?MODULE, progname => ?MODULE}).
%%% '''
%%% Other options available for run/2:
%%% <ul><li>`modules':<ul>
%%% <li>`all_loaded' - search all loaded modules (`code:all_loaded()') for `cli' behaviour</li>
%%% <li>`module()' - use this module (must export `cli/0')</li>
%%% <li>[module()] - list of modules (must export `cli/0')</li></ul></li>
%%% <li>`warn': set to `suppress' suppresses warnings logged</li>
%%% <li>`error': defines what action is taken upon parser error. Use `ok' to completely ignore the
%%% error (historical behaviour, useful for testing), `error' to raise an exception,
%%% `halt' to halt the emulator with exit code 1 (default behaviour), and `{halt, non_neg_integer()}'
%%% for a custom exit code halting the emulator</li>
%%% <li>`help': set to false suppresses printing `usage' when parser produces
%%% an error, and disables default --help/-h behaviour</li>
%%% <li>`prefixes': prefixes passed to argparse</li>
%%% <li>`progname': specifies executable name instead of 'erl'</li>
%%% </ul>
%%%
%%% Warnings are printed to OTP logger, unless suppressed.
%%%
%%% cli framework attempts to create a handler for each
%%% command exported, including intermediate (non-leaf)
%%% commands, if it can find function exported with
%%% suitable signature.
%%%
%%% cli examples are <a href="https://github.com/max-au/argparse/tree/master/doc/examples">available on GitHub</a>
%%%
%%% @end
-module(cli).
-author("maximfca@gmail.com").
-export([
run/1,
run/2
]).
%%--------------------------------------------------------------------
%% Behaviour definition
%% Callback returning CLI mappings.
%% Must return a command, that may contain sub-commands.
%% Also returns arguments, and handler.
-callback cli() -> args:command().
%%--------------------------------------------------------------------
%% API
-compile(warn_missing_spec).
-spec run(Args :: [string()]) -> term().
%% @equiv run(Args, #{})
run(Args) ->
run(Args, #{}).
%% Options map.
%% Allows to choose which modules to consider, and error handling mode.
%% `modules' can be:
%% `all_loaded' - code:all_loaded(), search for `cli' behaviour,
%% module() for a single module (may not have `cli' behaviour),
%% [module()] for a list of modules (may not have `cli' behaviour)
%% `warn' set to `suppress' suppresses warnings logged
%% `help' set to false suppresses printing `usage' when parser produces
%% an error, and disables default --help/-h behaviour
-type run_options() :: #{
modules => all_loaded | module() | [module()],
warn => suppress | warn,
help => boolean(),
error => ok | error | halt | {halt, non_neg_integer()},
prefixes => [integer()],%% prefixes passed to argparse
%% default value for all missing not required arguments
default => term(),
progname => string() | atom() %% specifies executable name instead of 'erl'
}.
%% @doc CLI entry point, parses arguments and executes selected function.
%% Finds all modules loaded, and implementing cli behaviour,
%% then matches a command and runs handler defined for
%% a command.
%% @param Args arguments used to run CLI, e.g. init:get_plain_arguments().
%% @returns callback result, or 'ok' when help/error message printed, and
%% `error' parameter is set to `ok' (meaning, ignore errors, always return ok)
-spec run([string()], run_options()) -> term().
run(Args, Options) ->
Modules = modules(maps:get(modules, Options, all_loaded)),
CmdMap = discover_commands(Modules, Options),
dispatch(Args, CmdMap, Modules, Options).
%%--------------------------------------------------------------------
%% Internal implementation
-include_lib("kernel/include/logger.hrl").
%% Returns a list of all modules providing 'cli' behaviour, or
%% a list of modules.
modules(all_loaded) ->
[
Module || {Module, _} <- code:all_loaded(),
lists:member(?MODULE, behaviours(Module))
];
modules(Mod) when is_atom(Mod) ->
[Mod];
modules(Mods) when is_list(Mods) ->
Mods.
behaviours(Module) ->
Attrs = proplists:get_value(attributes, Module:module_info(), []),
lists:flatten(proplists:get_all_values(behavior, Attrs) ++
proplists:get_all_values(behaviour, Attrs)).
%%
discover_commands(Modules, Options) ->
Warn = maps:get(warn, Options, warn),
ModCount = length(Modules),
lists:foldl(
fun (Mod, Cmds) ->
ModCmd =
try {_, MCmd} = args:validate(Mod:cli(), Options), MCmd
catch
Class:Reason:Stack when Warn =:= warn ->
?LOG_WARNING("Error calling ~s:cli(): ~s:~p~n~p",
[Mod, Class, Reason, Stack]), #{};
_:_ when Warn =:= suppress ->
#{}
end,
%% handlers: use first non-empty handler
Cmds1 = case maps:find(handler, ModCmd) of
{ok, Handler} when is_map_key(handler, Cmds) ->
%% merge handler - and warn when not suppressed
Warn =:= warn andalso
?LOG_WARNING("Multiple handlers defined for top-level command, ~p chosen, ~p ignored",
[maps:get(handler, Cmds), Handler]),
Cmds;
{ok, Handler} ->
Cmds#{handler => Handler};
error ->
Cmds
end,
%% help: concatenate help lines
Cmds2 =
if is_map_key(help, ModCmd) ->
Cmds1#{help => maps:get(help, ModCmd) ++ maps:get(help, Cmds1, "")};
true -> Cmds1
end,
%% merge arguments, and warn if warnings are not suppressed, and there
%% is more than a single module
Cmds3 = merge_arguments(maps:get(arguments, ModCmd, []),
(ModCount > 1 andalso Warn =:= warn), Cmds2),
%% merge commands
merge_commands(maps:get(commands, ModCmd, #{}), Mod, Options, Cmds3)
end, #{}, Modules).
%% Dispatches Args over Modules, with specified ErrMode
dispatch(Args, CmdMap, Modules, Options) ->
HelpEnabled = maps:get(help, Options, true),
%% attempt to dispatch the command
try args:parse(Args, CmdMap, Options) of
{ArgMap, PathTo} ->
run_handler(CmdMap, ArgMap, PathTo, undefined);
ArgMap ->
%{ maps:find(default, Options), Modules, Options}
run_handler(CmdMap, ArgMap, {[], CmdMap}, {Modules, Options})
catch
error:{args, Reason} when HelpEnabled =:= false ->
io:format("error: ~s", [args:format_error(Reason)]),
dispatch_error(Options, Reason);
error:{args, Reason} ->
%% see if it was cry for help that triggered error message
Prefixes = maps:get(prefixes, Options, "-"),
case help_requested(Reason, Prefixes) of
false ->
Fmt = args:format_error(Reason, CmdMap, Options),
io:format("error: ~s", [Fmt]);
CmdPath ->
Fmt = args:help(CmdMap, Options#{command => tl(CmdPath)}),
io:format("~s", [Fmt])
end,
dispatch_error(Options, Reason)
end.
dispatch_error(#{error := ok}, _Reason) ->
ok;
dispatch_error(#{error := error}, Reason) ->
error(Reason);
dispatch_error(#{error := halt}, _Reason) ->
erlang:halt(1);
dispatch_error(#{error := {halt, Exit}}, _Reason) ->
erlang:halt(Exit);
%% default is halt(1)
dispatch_error(_Options, _Reason) ->
erlang:halt(1).
%% Executes handler
run_handler(CmdMap, ArgMap, {Path, #{handler := {Mod, ModFun, Default}}}, _MO) ->
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);
run_handler(_CmdMap, ArgMap, {_Path, #{handler := {Mod, ModFun}}}, _MO) when is_atom(Mod), is_atom(ModFun) ->
Mod:ModFun(ArgMap);
run_handler(CmdMap, ArgMap, {Path, #{handler := {Fun, Default}}}, _MO) 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);
run_handler(_CmdMap, ArgMap, {_Path, #{handler := Handler}}, _MO) when is_function(Handler, 1) ->
Handler(ArgMap);
run_handler(CmdMap, ArgMap, {[], _}, {Modules, Options}) ->
% {undefined, {ok, Default}, Modules, Options}
exec_cli(Modules, CmdMap, [ArgMap], Options).
%% finds first module that exports ctl/1 and execute it
exec_cli([], CmdMap, _ArgMap, ArgOpts) ->
%% command not found, let's print usage
io:format(args:help(CmdMap, ArgOpts));
exec_cli([Mod|Tail], CmdMap, Args, ArgOpts) ->
case erlang:function_exported(Mod, cli, length(Args)) of
true ->
erlang:apply(Mod, cli, Args);
false ->
exec_cli(Tail, CmdMap, Args, ArgOpts)
end.
%% argparse does not allow clashing options, so if cli is ever to support
%% that, logic to un-clash should be here
merge_arguments([], _Warn, Existing) ->
Existing;
merge_arguments(Args, Warn, Existing) ->
Warn andalso
?LOG_WARNING("cli: multiple modules may export global attributes: ~p", [Args]),
ExistingArgs = maps:get(arguments, Existing, []),
Existing#{arguments => ExistingArgs ++ Args}.
%% argparse accepts a map of commands, which means, commands names
%% can never clash. Yet for cli it is possible when multiple modules
%% export command with the same name. For this case, skip duplicate
%% command names, emitting a warning.
merge_commands(Cmds, Mod, Options, Existing) ->
Warn = maps:get(warn, Options, warn),
MergedCmds = maps:fold(
fun (Name, Cmd, Acc) ->
case maps:find(Name, Acc) of
error ->
%% merge command with name Name into Acc-umulator
Acc#{Name => create_handlers(Mod, Name, Cmd, maps:find(default, Options))};
{ok, Another} when Warn =:= warn ->
%% do not merge this command, another module already exports it
?LOG_WARNING("cli: duplicate definition for ~s found, skipping ~P",
[Name, 8, Another]), Acc;
{ok, _Another} when Warn =:= suppress ->
%% don't merge duplicate, and don't complain about it
Acc
end
end, maps:get(commands, Existing, #{}), Cmds
),
Existing#{commands => MergedCmds}.
%% Descends into sub-commands creating handlers where applicable
create_handlers(Mod, CmdName, Cmd0, DefaultTerm) ->
Handler =
case maps:find(handler, Cmd0) of
error ->
make_handler(CmdName, Mod, DefaultTerm);
{ok, optional} ->
make_handler(CmdName, Mod, DefaultTerm);
{ok, Existing} ->
Existing
end,
%%
Cmd = Cmd0#{handler => Handler},
case maps:find(commands, Cmd) of
error ->
Cmd;
{ok, Sub} ->
NewCmds = maps:map(fun (CN, CV) -> create_handlers(Mod, CN, CV, DefaultTerm) end, Sub),
Cmd#{commands => NewCmds}
end.
%% makes handler in required format
make_handler(CmdName, Mod, error) ->
try
{Mod, list_to_existing_atom(CmdName)}
catch
error:badarg ->
error({invalid_command, [CmdName], handler, "handler for command does not exist"})
end;
make_handler(CmdName, Mod, {ok, Default}) ->
{Mod, list_to_existing_atom(CmdName), Default}.
%% Finds out whether it was --help/-h requested, and exception was thrown due to that
help_requested({unknown_argument, CmdPath, [Prefix, $h]}, Prefixes) ->
is_prefix(Prefix, Prefixes, CmdPath);
help_requested({unknown_argument, CmdPath, [Prefix, Prefix, $h, $e, $l, $p]}, Prefixes) ->
is_prefix(Prefix, Prefixes, CmdPath);
help_requested(_, _) ->
false.
%% returns CmdPath when Prefix is one of supplied Prefixes
is_prefix(Prefix, Prefixes, CmdPath) ->
case lists:member(Prefix, Prefixes) of
true ->
CmdPath;
false ->
false
end.
%% 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).