summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--lib/dialyzer/doc/src/dialyzer.xml6
-rw-r--r--lib/dialyzer/src/dialyzer.hrl5
-rw-r--r--lib/dialyzer/src/dialyzer_cl_parse.erl821
-rw-r--r--lib/dialyzer/src/dialyzer_options.erl23
-rw-r--r--lib/dialyzer/src/typer.erl18
-rw-r--r--lib/stdlib/doc/src/Makefile1
-rw-r--r--lib/stdlib/doc/src/argparse.xml739
-rw-r--r--lib/stdlib/doc/src/ref_man.xml1
-rw-r--r--lib/stdlib/doc/src/specs.xml1
-rw-r--r--lib/stdlib/src/Makefile1
-rw-r--r--lib/stdlib/src/argparse.erl1357
-rw-r--r--lib/stdlib/src/stdlib.app.src3
-rw-r--r--lib/stdlib/test/Makefile1
-rw-r--r--lib/stdlib/test/argparse_SUITE.erl1063
14 files changed, 3489 insertions, 551 deletions
diff --git a/lib/dialyzer/doc/src/dialyzer.xml b/lib/dialyzer/doc/src/dialyzer.xml
index 05abcc4daf..9a2b409348 100644
--- a/lib/dialyzer/doc/src/dialyzer.xml
+++ b/lib/dialyzer/doc/src/dialyzer.xml
@@ -593,6 +593,12 @@ dialyzer --plts plt_1 ... plt_n -- files_to_analyze</code>
<name name="file_location"></name>
</datatype>
<datatype>
+ <name name="filename_opt"></name>
+ </datatype>
+ <datatype>
+ <name name="format_option"></name>
+ </datatype>
+ <datatype>
<name name="warn_option"></name>
<desc>
<p>See section <seeerl
diff --git a/lib/dialyzer/src/dialyzer.hrl b/lib/dialyzer/src/dialyzer.hrl
index f34acb3410..26a4b0b0a5 100644
--- a/lib/dialyzer/src/dialyzer.hrl
+++ b/lib/dialyzer/src/dialyzer.hrl
@@ -157,6 +157,11 @@
'incremental'}
| {'warnings', [warn_option()]}
| {'get_warnings', boolean()}
+ | {'use_spec', boolean()}
+ | {'filename_opt', filename_opt()}
+ | {'callgraph_file', file:filename()}
+ | {'mod_deps_file', file:filename()}
+ | {'warning_files_rec', [DirName :: file:filename()]}
| {'error_location', error_location()}.
-type dial_options() :: [dial_option()].
-type filename_opt() :: 'basename' | 'fullpath'.
diff --git a/lib/dialyzer/src/dialyzer_cl_parse.erl b/lib/dialyzer/src/dialyzer_cl_parse.erl
index bb792bb15a..fe7ad2af01 100644
--- a/lib/dialyzer/src/dialyzer_cl_parse.erl
+++ b/lib/dialyzer/src/dialyzer_cl_parse.erl
@@ -1,5 +1,3 @@
-%% -*- erlang-indent-level: 2 -*-
-%%
%% 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
@@ -14,8 +12,7 @@
-module(dialyzer_cl_parse).
--export([start/0, get_lib_dir/1]).
--export([collect_args/1]). % used also by typer
+-export([start/0]).
-include("dialyzer.hrl").
@@ -27,550 +24,228 @@
| {'gui', #options{}}
| {'error', string()}.
--type deep_string() :: string() | [deep_string()].
-
-%%-----------------------------------------------------------------------
-
-spec start() -> dial_cl_parse_ret().
-
start() ->
- init(),
- Args = init:get_plain_arguments(),
- try
- Ret = cl(Args),
- Ret
- catch
- throw:{dialyzer_cl_parse_error, Msg} -> {error, Msg};
- _:R:S ->
- Msg = io_lib:format("~tp\n~tp\n", [R, S]),
- {error, lists:flatten(Msg)}
- end.
-
-cl(["--add_to_plt"|T]) ->
- put(dialyzer_options_analysis_type, plt_add),
- cl(T);
-cl(["--apps"|T]) ->
- T1 = get_lib_dir(T),
- {Args, T2} = collect_args(T1),
- append_var(dialyzer_options_files_rec, Args),
- cl(T2);
-cl(["--warning_apps"|T]) ->
- T1 = get_lib_dir(T),
- {Args, T2} = collect_args(T1),
- append_var(dialyzer_options_warning_files_rec, Args),
- cl(T2);
-cl(["--build_plt"|T]) ->
- put(dialyzer_options_analysis_type, plt_build),
- cl(T);
-cl(["--check_plt"|T]) ->
- put(dialyzer_options_analysis_type, plt_check),
- cl(T);
-cl(["-n"|T]) ->
- cl(["--no_check_plt"|T]);
-cl(["--no_check_plt"|T]) ->
- put(dialyzer_options_check_plt, false),
- cl(T);
-cl(["-nn"|T]) ->
- %% Ignored since Erlang/OTP 24.0.
- cl(T);
-cl(["--no_native"|T]) ->
- %% Ignored since Erlang/OTP 24.0.
- cl(T);
-cl(["--no_native_cache"|T]) ->
- %% Ignored since Erlang/OTP 24.0.
- cl(T);
-cl(["--plt_info"|T]) ->
- put(dialyzer_options_analysis_type, plt_info),
- cl(T);
-cl(["--get_warnings"|T]) ->
- put(dialyzer_options_get_warnings, true),
- cl(T);
-cl(["-D"|_]) ->
- cl_error("No defines specified after -D");
-cl(["-D"++Define|T]) ->
- Def = re:split(Define, "=", [{return, list}, unicode]),
- append_defines(Def),
- cl(T);
-cl(["-h"|_]) ->
- help_message();
-cl(["--help"|_]) ->
- help_message();
-cl(["-I"]) ->
- cl_error("no include directory specified after -I");
-cl(["-I", Dir|T]) ->
- append_include(Dir),
- cl(T);
-cl(["-I"++Dir|T]) ->
- append_include(Dir),
- cl(T);
-cl(["--input_list_file"]) ->
- cl_error("No input list file specified");
-cl(["--input_list_file",File|L]) ->
- read_input_list_file(File),
- cl(L);
-cl(["-c"++_|T]) ->
- NewTail = command_line(T),
- cl(NewTail);
-cl(["-r"++_|T0]) ->
- {Args, T} = collect_args(T0),
- append_var(dialyzer_options_files_rec, Args),
- cl(T);
-cl(["--remove_from_plt"|T]) ->
- put(dialyzer_options_analysis_type, plt_remove),
- cl(T);
-cl(["--incremental"|T]) ->
- put(dialyzer_options_analysis_type, incremental),
- cl(T);
-cl(["--com"++_|T]) ->
- NewTail = command_line(T),
- cl(NewTail);
-cl(["--output"]) ->
- cl_error("No outfile specified");
-cl(["-o"]) ->
- cl_error("No outfile specified");
-cl(["--output",Output|T]) ->
- put(dialyzer_output, Output),
- cl(T);
-cl(["--metrics_file",MetricsFile|T]) ->
- put(dialyzer_metrics, MetricsFile),
- cl(T);
-cl(["--module_lookup_file",ModuleLookupFile|T]) ->
- put(dialyzer_module_lookup, ModuleLookupFile),
- cl(T);
-cl(["--output_plt"]) ->
- cl_error("No outfile specified for --output_plt");
-cl(["--output_plt",Output|T]) ->
- put(dialyzer_output_plt, Output),
- cl(T);
-cl(["-o", Output|T]) ->
- put(dialyzer_output, Output),
- cl(T);
-cl(["-o"++Output|T]) ->
- put(dialyzer_output, Output),
- cl(T);
-cl(["--raw"|T]) ->
- put(dialyzer_output_format, raw),
- cl(T);
-cl(["--fullpath"|T]) ->
- put(dialyzer_filename_opt, fullpath),
- cl(T);
-cl(["--no_indentation"|T]) ->
- put(dialyzer_indent_opt, false),
- cl(T);
-cl(["-pa", Path|T]) ->
- case code:add_patha(Path) of
- true -> cl(T);
- {error, _} -> cl_error("Bad directory for -pa: " ++ Path)
- end;
-cl(["--plt"]) ->
- error("No plt specified for --plt");
-cl(["--plt", PLT|T]) ->
- put(dialyzer_init_plts, [PLT]),
- cl(T);
-cl(["--plts"]) ->
- error("No plts specified for --plts");
-cl(["--plts"|T]) ->
- {PLTs, NewT} = get_plts(T, []),
- put(dialyzer_init_plts, PLTs),
- cl(NewT);
-cl(["-q"|T]) ->
- put(dialyzer_options_report_mode, quiet),
- cl(T);
-cl(["--quiet"|T]) ->
- put(dialyzer_options_report_mode, quiet),
- cl(T);
-cl(["--src"|T]) ->
- put(dialyzer_options_from, src_code),
- cl(T);
-cl(["--no_spec"|T]) ->
- put(dialyzer_options_use_contracts, false),
- cl(T);
-cl(["--statistics"|T]) ->
- put(dialyzer_timing, true),
- cl(T);
-cl(["--resources"|T]) ->
- put(dialyzer_options_report_mode, quiet),
- put(dialyzer_timing, debug),
- cl(T);
-cl(["-v"|_]) ->
- io:format("Dialyzer version "++?VSN++"\n"),
- erlang:halt(?RET_NOTHING_SUSPICIOUS);
-cl(["--version"|_]) ->
- io:format("Dialyzer version "++?VSN++"\n"),
- erlang:halt(?RET_NOTHING_SUSPICIOUS);
-cl(["--verbose"|T]) ->
- put(dialyzer_options_report_mode, verbose),
- cl(T);
-cl(["-W"|_]) ->
- cl_error("-W given without warning");
-cl(["-Whelp"|_]) ->
- help_warnings();
-cl(["-W"++Warn|T]) ->
- append_var(dialyzer_warnings, [list_to_atom(Warn)]),
- cl(T);
-cl(["--dump_callgraph"]) ->
- cl_error("No outfile specified for --dump_callgraph");
-cl(["--dump_callgraph", File|T]) ->
- put(dialyzer_callgraph_file, File),
- cl(T);
-cl(["--dump_full_dependencies_graph"]) ->
- cl_error("No outfile specified for --dump_full_dependencies_graph");
-cl(["--dump_full_dependencies_graph", File|T]) ->
- put(dialyzer_mod_deps_file, File),
- cl(T);
-cl(["--gui"|T]) ->
- put(dialyzer_options_mode, gui),
- cl(T);
-cl(["--error_location", LineOrColumn|T]) ->
- put(dialyzer_error_location_opt, list_to_atom(LineOrColumn)),
- cl(T);
-cl(["--solver", Solver|T]) -> % not documented
- append_var(dialyzer_solvers, [list_to_atom(Solver)]),
- cl(T);
-cl([H|_] = L) ->
- case filelib:is_file(H) orelse filelib:is_dir(H) of
- true ->
- NewTail = command_line(L),
- cl(NewTail);
- false ->
- cl_error("Unknown option: " ++ H)
- end;
-cl([]) ->
- {RetTag, Opts} =
- case get(dialyzer_options_analysis_type) =:= plt_info of
- true ->
- put(dialyzer_options_analysis_type, plt_check),
- {plt_info, cl_options()};
- false ->
- case get(dialyzer_options_mode) of
- gui -> {gui, common_options()};
- cl ->
- case get(dialyzer_options_analysis_type) =:= plt_check of
- true -> {check_init, cl_options()};
- false -> {cl, cl_options()}
- end
- end
- end,
- case dialyzer_options:build(Opts) of
- {error, Msg} -> cl_error(Msg);
- OptsRecord -> {RetTag, OptsRecord}
- end.
-
-%%-----------------------------------------------------------------------
-
-command_line(T0) ->
- {Args, T} = collect_args(T0),
- append_var(dialyzer_options_files, Args),
- %% if all files specified are ".erl" files, set the 'src' flag automatically
- case lists:all(fun(F) -> filename:extension(F) =:= ".erl" end, Args) of
- true -> put(dialyzer_options_from, src_code);
- false -> ok
- end,
- T.
-
-read_input_list_file(File) ->
- case file:read_file(File) of
- {ok,Bin} ->
- Files = binary:split(Bin, <<"\n">>, [trim_all,global]),
- NewFiles = [binary_to_list(string:trim(F)) || F <- Files],
- append_var(dialyzer_options_files, NewFiles);
- {error,Reason} ->
- cl_error(io_lib:format("Reading of ~s failed: ~s", [File,file:format_error(Reason)]))
- end.
-
--spec cl_error(deep_string()) -> no_return().
-
-cl_error(Str) ->
- Msg = lists:flatten(Str),
- throw({dialyzer_cl_parse_error, Msg}).
-
-init() ->
- %% By not initializing every option, the modified options can be
- %% found. If every option were to be returned by cl_options() and
- %% common_options(), then the environment variables (currently only
- %% ERL_COMPILER_OPTIONS) would be overwritten by default values.
- put(dialyzer_options_mode, cl),
- put(dialyzer_options_files_rec, []),
- put(dialyzer_options_warning_files_rec, []),
- put(dialyzer_options_report_mode, normal),
- put(dialyzer_warnings, []),
- ok.
-
-append_defines([Def, Val]) ->
- {ok, Tokens, _} = erl_scan:string(Val++"."),
- {ok, ErlVal} = erl_parse:parse_term(Tokens),
- append_var(dialyzer_options_defines, [{list_to_atom(Def), ErlVal}]);
-append_defines([Def]) ->
- append_var(dialyzer_options_defines, [{list_to_atom(Def), true}]).
-
-append_include(Dir) ->
- append_var(dialyzer_include, [Dir]).
-
-append_var(Var, List) when is_list(List) ->
- case get(Var) of
- undefined ->
- put(Var, List);
- L ->
- put(Var, L ++ List)
- end,
- ok.
-
-%%-----------------------------------------------------------------------
-
--spec collect_args([string()]) -> {[string()], [string()]}.
-
-collect_args(List) ->
- collect_args_1(List, []).
-
-collect_args_1(["-"++_|_] = L, Acc) ->
- {lists:reverse(Acc), L};
-collect_args_1([Arg|T], Acc) ->
- collect_args_1(T, [Arg|Acc]);
-collect_args_1([], Acc) ->
- {lists:reverse(Acc), []}.
+ Args = init:get_plain_arguments(),
+ try argparse:parse(Args, cli(), #{progname => dialyzer}) of
+ {ok, ArgMap, _, _} ->
+ {Command, Opts} = postprocess_side_effects(ArgMap),
+ case dialyzer_options:build(maps:to_list(Opts)) of
+ {error, Msg2} ->
+ {error, Msg2};
+ OptsRecord ->
+ {Command, OptsRecord}
+ end;
+ {error, Error} ->
+ {error, argparse:format_error(Error)}
+ catch
+ throw:{dialyzer_cl_parse_error, Msg} ->
+ {error, Msg};
+ _:R:S ->
+ Msg = io_lib:format("~tp\n~tp\n", [R, S]),
+ {error, lists:flatten(Msg)}
+ end.
%%-----------------------------------------------------------------------
-cl_options() ->
- OptsList = [{files, dialyzer_options_files},
- {files_rec, dialyzer_options_files_rec},
- {warning_files_rec, dialyzer_options_warning_files_rec},
- {output_file, dialyzer_output},
- {metrics_file, dialyzer_metrics},
- {module_lookup_file, dialyzer_module_lookup},
- {output_format, dialyzer_output_format},
- {filename_opt, dialyzer_filename_opt},
- {indent_opt, dialyzer_indent_opt},
- {analysis_type, dialyzer_options_analysis_type},
- {get_warnings, dialyzer_options_get_warnings},
- {timing, dialyzer_timing},
- {callgraph_file, dialyzer_callgraph_file},
- {mod_deps_file, dialyzer_mod_deps_file}],
- get_options(OptsList) ++ common_options().
-
-common_options() ->
- OptsList = [{defines, dialyzer_options_defines},
- {from, dialyzer_options_from},
- {include_dirs, dialyzer_include},
- {plts, dialyzer_init_plts},
- {output_plt, dialyzer_output_plt},
- {report_mode, dialyzer_options_report_mode},
- {use_spec, dialyzer_options_use_contracts},
- {warnings, dialyzer_warnings},
- {check_plt, dialyzer_options_check_plt},
- {solvers, dialyzer_solvers}],
- get_options(OptsList).
-
-get_options(TagOptionList) ->
- lists:append([get_opt(Tag, Opt) || {Tag, Opt} <- TagOptionList]).
-
-get_opt(Tag, Opt) ->
- case get(Opt) of
- undefined ->
- [];
- V ->
- [{Tag, V}]
- end.
-
-%%-----------------------------------------------------------------------
-
--spec get_lib_dir([string()]) -> [string()].
-
-get_lib_dir(Apps) ->
- get_lib_dir(Apps, []).
-
-get_lib_dir([H|T], Acc) ->
- NewElem =
- case code:lib_dir(list_to_atom(H)) of
- {error, bad_name} -> H;
- LibDir when H =:= "erts" -> % hack for including erts in an un-installed system
- EbinDir = filename:join([LibDir,"ebin"]),
- case file:read_file_info(EbinDir) of
- {error,enoent} ->
- filename:join([LibDir,"preloaded","ebin"]);
- _ ->
- EbinDir
- end;
- LibDir -> filename:join(LibDir,"ebin")
- end,
- get_lib_dir(T, [NewElem|Acc]);
-get_lib_dir([], Acc) ->
- lists:reverse(Acc).
-
-%%-----------------------------------------------------------------------
-
-get_plts(["--"|T], Acc) -> {lists:reverse(Acc), T};
-get_plts(["-"++_Opt = H|T], Acc) -> {lists:reverse(Acc), [H|T]};
-get_plts([H|T], Acc) -> get_plts(T, [H|Acc]);
-get_plts([], Acc) -> {lists:reverse(Acc), []}.
-
-%%-----------------------------------------------------------------------
-
--spec help_warnings() -> no_return().
-
-help_warnings() ->
- S = warning_options_msg(),
- io:put_chars(S),
- erlang:halt(?RET_NOTHING_SUSPICIOUS).
-
--spec help_message() -> no_return().
-
-help_message() ->
- S = "Usage: dialyzer [--add_to_plt] [--apps applications] [--build_plt]
- [--check_plt] [-Ddefine]* [-Dname]* [--dump_callgraph file]
- [--error_location flag] [files_or_dirs] [--fullpath]
- [--get_warnings] [--gui] [--help] [-I include_dir]*
- [--incremental] [--metrics_file] [--no_check_plt] [--no_indentation] [--no_spec]
- [-o outfile] [--output_plt file] [-pa dir]* [--plt plt] [--plt_info]
- [--plts plt*] [--quiet] [-r dirs] [--raw] [--remove_from_plt]
- [--shell] [--src] [--statistics] [--verbose] [--version]
- [--warning_apps] [-Wwarn]*
-
-Options:
- files_or_dirs (for backwards compatibility also as: -c files_or_dirs)
- Use Dialyzer from the command line to detect defects in the
- specified files or directories containing .erl or .beam files,
- depending on the type of the analysis.
- -r dirs
- Same as the previous but the specified directories are searched
- recursively for subdirectories containing .erl or .beam files in
- them, depending on the type of analysis.
- --input_list_file file
- Specify the name of a file that contains the names of the files
- to be analyzed (one file name per line).
- --apps applications
- Option typically used when building or modifying a plt as in:
- dialyzer --build_plt --apps erts kernel stdlib mnesia ...
- to conveniently refer to library applications corresponding to the
- Erlang/OTP installation. However, the option is general and can also
- be used during analysis in order to refer to Erlang/OTP applications.
- In addition, file or directory names can also be included, as in:
- dialyzer --apps inets ssl ./ebin ../other_lib/ebin/my_module.beam
- --warning_apps applications
- By default, warnings will be reported to all applications given by
- --apps. However, if --warning_apps is used, only those applications
- given to --warning_apps will have warnings reported. All applications
- given by --apps, but not --warning_apps, will be analysed to provide
- context to the analysis, but warnings will not be reported for them.
- For example, you may want to include libraries you depend on in the
- analysis with --apps so discrepancies in their usage can be found,
- but only include your own code with --warning_apps so that
- discrepancies are only reported in code that you own.
- -o outfile (or --output outfile)
- When using Dialyzer from the command line, send the analysis
- results to the specified outfile rather than to stdout.
- --raw
- When using Dialyzer from the command line, output the raw analysis
- results (Erlang terms) instead of the formatted result.
- The raw format is easier to post-process (for instance, to filter
- warnings or to output HTML pages).
- --src
- Override the default, which is to analyze BEAM files, and
- analyze starting from Erlang source code instead.
- -Dname (or -Dname=value)
- When analyzing from source, pass the define to Dialyzer. (**)
- -I include_dir
- When analyzing from source, pass the include_dir to Dialyzer. (**)
- -pa dir
- Include dir in the path for Erlang (useful when analyzing files
- that have '-include_lib()' directives).
- --output_plt file
- Store the plt at the specified file after building it.
- --plt plt
- Use the specified plt as the initial plt (if the plt was built
- during setup the files will be checked for consistency).
- --plts plt*
- Merge the specified plts to create the initial plt -- requires
- that the plts are disjoint (i.e., do not have any module
- appearing in more than one plt).
- The plts are created in the usual way:
- dialyzer --build_plt --output_plt plt_1 files_to_include
- ...
- dialyzer --build_plt --output_plt plt_n files_to_include
- and then can be used in either of the following ways:
- dialyzer files_to_analyze --plts plt_1 ... plt_n
- or:
- dialyzer --plts plt_1 ... plt_n -- files_to_analyze
- (Note the -- delimiter in the second case)
- -Wwarn
- A family of options which selectively turn on/off warnings
- (for help on the names of warnings use dialyzer -Whelp).
- --shell
- Do not disable the Erlang shell while running the GUI.
- --version (or -v)
- Print the Dialyzer version and some more information and exit.
- --help (or -h)
- Print this message and exit.
- --quiet (or -q)
- Make Dialyzer a bit more quiet.
- --verbose
- Make Dialyzer a bit more verbose.
- --statistics
- Prints information about the progress of execution (analysis phases,
- time spent in each and size of the relative input).
- --build_plt
- The analysis starts from an empty plt and creates a new one from the
- files specified with -c and -r. Only works for beam files.
- Use --plt(s) or --output_plt to override the default plt location.
- --add_to_plt
- The plt is extended to also include the files specified with -c and -r.
- Use --plt(s) to specify which plt to start from, and --output_plt to
- specify where to put the plt. Note that the analysis might include
- files from the plt if they depend on the new files.
- This option only works with beam files.
- --remove_from_plt
- The information from the files specified with -c and -r is removed
- from the plt. Note that this may cause a re-analysis of the remaining
- dependent files.
- --check_plt
- Check the plt for consistency and rebuild it if it is not up-to-date.
- Actually, this option is of rare use as it is on by default.
- --no_check_plt (or -n)
- Skip the plt check when running Dialyzer. Useful when working with
- installed plts that never change.
- --incremental
- The analysis starts from an existing incremental PLT, or builds one from
- scratch if one doesn't exist, and runs the minimal amount of additional
- analysis to report all issues in the given set of apps. Notably, incremental
- PLT files are not compatible with \"classic\" PLT files, and vice versa.
- The initial incremental PLT will be updated unless an alternative output
- incremental PLT is given.
- --plt_info
- Make Dialyzer print information about the plt and then quit. The plt
- can be specified with --plt(s).
- --get_warnings
- Make Dialyzer emit warnings even when manipulating the plt. Warnings
- are only emitted for files that are actually analyzed.
- --dump_callgraph file
- Dump the call graph into the specified file whose format is determined
- by the file name extension. Supported extensions are: raw, dot, and ps.
- If something else is used as file name extension, default format '.raw'
- will be used.
- --dump_full_dependencies_graph file
- Dump the full dependency graph (i.e. dependencies induced by function
- calls, usages of types in specs, behaviour implementations, etc.) into
- the specified file whose format is determined by the file name
- extension. Supported extensions are: dot and ps.
- --metrics_file file
- Write metrics about Dialyzer's incrementality (for example, total number of
- modules considered, how many modules were changed since the PLT was
- last updated, how many modules needed to be analyzed) to a file. This
- can be useful for tracking and debugging Dialyzer's incrementality.
- --error_location column | line
- Use a pair {Line, Column} or an integer Line to pinpoint the location
- of warnings. The default is to use a pair {Line, Column}. When
- formatted, the line and the column are separated by a colon.
- --fullpath
- Display the full path names of files for which warnings are emitted.
- --no_indentation
- Do not indent contracts and success typings. Note that this option has
- no effect when combined with the --raw option.
- --no_spec
- Ignore functions specs. This is useful for debugging when one suspects
- that some specs are incorrect.
- --gui
- Use the GUI.
-
+parse_app(AppOrDir) ->
+ case code:lib_dir(list_to_atom(AppOrDir)) of
+ {error, bad_name} -> AppOrDir;
+ LibDir when AppOrDir =:= "erts" -> % hack for including erts in an un-installed system
+ EbinDir = filename:join([LibDir, "ebin"]),
+ case file:read_file_info(EbinDir) of
+ {error, enoent} ->
+ filename:join([LibDir, "preloaded", "ebin"]);
+ _ ->
+ EbinDir
+ end;
+ LibDir -> filename:join(LibDir, "ebin")
+ end.
+
+parse_input_list(File) ->
+ case file:read_file(File) of
+ {ok, Bin} ->
+ Files = binary:split(Bin, <<"\n">>, [trim_all, global]),
+ [binary_to_list(string:trim(F)) || F <- Files];
+ {error, Reason} ->
+ cl_error(io_lib:format("Reading of ~s failed: ~s", [File, file:format_error(Reason)]))
+ end.
+
+parse_define(Arg) ->
+ case re:split(Arg, "=", [{return, list}, unicode]) of
+ [Def, Val] ->
+ {ok, Tokens, _} = erl_scan:string(Val++"."),
+ {ok, ErlVal} = erl_parse:parse_term(Tokens),
+ {list_to_atom(Def), ErlVal};
+ [Def] ->
+ {list_to_atom(Def), true}
+ end.
+
+cli() ->
+ #{
+ arguments => [
+ #{name => files, action => extend, nargs => list, required => false,
+ help => <<"Use Dialyzer from the command line to detect defects in the "
+ "specified files or directories containing .erl or .beam files, "
+ "depending on the type of the analysis.">>},
+ #{name => files, short => $c, long => "-com", action => extend, nargs => list,
+ help => <<"Same as files, specifies files to run the analysis on (left for compatibility)">>},
+ #{name => files_rec, short => $r, action => extend, nargs => list,
+ help => <<"Search the specified directories "
+ "recursively for subdirectories containing .erl or .beam files in "
+ "them, depending on the type of analysis.">>},
+ #{name => files, long => "-input_list_file", type => {custom, fun parse_input_list/1},
+ action => extend,
+ help => <<"Specify the name of a file that contains the names of the files "
+ "to be analyzed (one file name per line).">>},
+ #{name => files_rec, long => "-apps", type => {custom, fun parse_app/1},
+ nargs => list, action => extend,
+ help => <<"Option typically used when building or modifying a plt as in: \n"
+ "dialyzer --build_plt --apps erts kernel stdlib mnesia ... \n"
+ "to conveniently refer to library applications corresponding to the "
+ "Erlang/OTP installation. However, the option is general and can also "
+ "be used during analysis in order to refer to Erlang/OTP applications. "
+ "In addition, file or directory names can also be included, as in: \n"
+ "dialyzer --apps inets ssl ./ebin ../other_lib/ebin/my_module.beam">>},
+
+ #{name => output_file, short => $o, long => "--output",
+ help => <<"When using Dialyzer from the command line, send the analysis "
+ "results to the specified outfile rather than to stdout.">>},
+ #{name => output_format, long => "-raw", type => boolean, action => {store, raw},
+ help => <<"When using Dialyzer from the command line, output the raw analysis "
+ "results (Erlang terms) instead of the formatted result. "
+ "The raw format is easier to post-process (for instance, to filter "
+ "warnings or to output HTML pages).">>},
+ #{name => from, long => "-src", type => boolean, action => {store, src_code},
+ help => <<"Override the default, which is to analyze BEAM files, and "
+ "analyze starting from Erlang source code instead.">>},
+ #{name => defines, short=>$D, type => {custom, fun parse_define/1}, action => append,
+ help => <<"When analyzing from source, pass the define to Dialyzer. (**)">>},
+ #{name => include_dirs, short=>$I, action => append,
+ help => <<"When analyzing from source, pass the include_dir to Dialyzer. (**)">>},
+ #{name => pa, long => "pa", action => append,
+ help => <<"Include dir in the path for Erlang (useful when analyzing files "
+ "that have '-include_lib()' directives).">>},
+ #{name => output_plt, long => "-output_plt",
+ help => <<"Store the plt at the specified file after building it.">>},
+ #{name => plts, long => "-plt", nargs => 1,
+ help => <<"Use the specified plt as the initial plt (if the plt was built "
+ "during setup the files will be checked for consistency).">>},
+ #{name => plts, long => "-plts", nargs => nonempty_list,
+ help => <<"Merge the specified plts to create the initial plt -- requires "
+ "that the plts are disjoint (i.e., do not have any module "
+ "appearing in more than one plt). "
+ "The plts are created in the usual way: \n"
+ " dialyzer --build_plt --output_plt plt_1 files_to_include "
+ " ... \n"
+ " dialyzer --build_plt --output_plt plt_n files_to_include "
+ "and then can be used in either of the following ways: \n"
+ " dialyzer files_to_analyze --plts plt_1 ... plt_n \n"
+ "or: \n"
+ " dialyzer --plts plt_1 ... plt_n -- files_to_analyze \n"
+ "(Note the -- delimiter in the second case)">>},
+ #{name => warnings, short => $W, action => append, type => {atom, [error_handling,
+ no_behaviours, no_contracts, no_fail_call, no_fun_app, no_improper_lists,
+ no_match, no_missing_calls, no_opaque, no_return, no_undefined_callbacks,
+ no_underspecs, no_unknown, no_unused, underspecs, unknown, unmatched_returns,
+ overspecs, specdiffs, extra_return, no_extra_return, missing_return, no_missing_return]},
+ help => {<<"[-Wwarn]*">>, [<<"A family of options which selectively turn on/off warnings">>]}},
+ #{name => shell, long => "-shell", type => boolean,
+ help => <<"Do not disable the Erlang shell while running the GUI.">>},
+ #{name => version, short => $v, long => "-version", type => boolean,
+ help => <<"Print the Dialyzer version and some more information and exit.">>},
+ #{name => help, short => $h, long => "-help", type => boolean,
+ help => <<"Print this message and exit.">>},
+ #{name => report_mode, short => $q, long => "-quiet", type => boolean, action => {store, quiet},
+ default => normal, help => <<"Make Dialyzer a bit more quiet.">>},
+ #{name => report_mode, long => "-verbose", type => boolean, action => {store, verbose},
+ help => <<"Make Dialyzer a bit more verbose.">>},
+ #{name => timing, long => "-statistics", type => boolean,
+ help => <<"Prints information about the progress of execution (analysis phases, "
+ "time spent in each and size of the relative input).">>},
+ #{name => analysis_type, long => "-build_plt", type => boolean, action => {store, plt_build},
+ help => <<"The analysis starts from an empty plt and creates a new one from the "
+ "files specified with -c and -r. Only works for beam files. "
+ "Use --plt(s) or --output_plt to override the default plt location.">>},
+ #{name => analysis_type, long=> "-add_to_plt", type => boolean, action => {store, plt_add},
+ help => <<"The plt is extended to also include the files specified with -c and -r. "
+ "Use --plt(s) to specify which plt to start from, and --output_plt to "
+ "specify where to put the plt. Note that the analysis might include "
+ "files from the plt if they depend on the new files. "
+ "This option only works with beam files.">>},
+ #{name => analysis_type, long => "-remove_from_plt", type => boolean, action => {store, plt_remove},
+ help => <<"The information from the files specified with -c and -r is removed "
+ "from the plt. Note that this may cause a re-analysis of the remaining "
+ "dependent files.">>},
+ #{name => analysis_type, long => "-check_plt", type => boolean, action => {store, plt_check},
+ help => <<"Check the plt for consistency and rebuild it if it is not up-to-date. "
+ "Actually, this option is of rare use as it is on by default.">>},
+ #{name => check_plt, long => "-no_check_plt", short => $n, type => boolean, action => {store, false},
+ help => <<"Skip the plt check when running Dialyzer. Useful when working with "
+ "installed plts that never change.">>},
+ #{name => analysis_type, long => "-incremental", type => boolean, action => {store, incremental},
+ help => <<"The analysis starts from an existing incremental PLT, or builds one from "
+ "scratch if one doesn't exist, and runs the minimal amount of additional "
+ "analysis to report all issues in the given set of apps. Notably, incremental "
+ "PLT files are not compatible with \"classic\" PLT files, and vice versa. "
+ "The initial incremental PLT will be updated unless an alternative output "
+ "incremental PLT is given.">>},
+ #{name => analysis_type, long => "-plt_info", type => boolean, action => {store, plt_info},
+ help => <<"Make Dialyzer print information about the plt and then quit. The plt "
+ "can be specified with --plt(s).">>},
+ #{name => get_warnings, long => "-get_warnings", type => boolean,
+ help => <<"Make Dialyzer emit warnings even when manipulating the plt. Warnings "
+ "are only emitted for files that are actually analyzed.">>},
+ #{name => callgraph_file, long => "-dump_callgraph",
+ help => <<"Dump the call graph into the specified file whose format is determined "
+ "by the file name extension. Supported extensions are: raw, dot, and ps. "
+ "If something else is used as file name extension, default format '.raw' "
+ "will be used.">>},
+ #{name => mod_deps_file, long => "-dump_full_dependencies_graph",
+ help => <<"Dump the full dependency graph (i.e. dependencies induced by function "
+ "calls, usages of types in specs, behaviour implementations, etc.) into "
+ "the specified file whose format is determined by the file name "
+ "extension. Supported extensions are: dot and ps.">>},
+ #{name => error_location, long => "-error_location", type => {atom, [column, line]},
+ help => <<"Use a pair {Line, Column} or an integer Line to pinpoint the location "
+ "of warnings. The default is to use a pair {Line, Column}. When "
+ "formatted, the line and the column are separated by a colon.">>},
+ #{name => filename_opt, long => "-fullpath", type => boolean, action => {store, fullpath},
+ help => <<"Display the full path names of files for which warnings are emitted.">>},
+ #{name => indent_opt, long => "-no_indentation", type => boolean, action => {store, false},
+ help => <<"Do not indent contracts and success typings. Note that this option has "
+ "no effect when combined with the --raw option.">>},
+ #{name => gui, long => "-gui", type => boolean,
+ help => <<"Use the GUI.">>},
+ #{name => metrics_file, long => "-metrics_file",
+ help => <<"Write metrics about Dialyzer's incrementality (for example, total number of "
+ "modules considered, how many modules were changed since the PLT was "
+ "last updated, how many modules needed to be analyzed) to a file. This "
+ "can be useful for tracking and debugging Dialyzer's incrementality.">>},
+ #{name => warning_files_rec, long => "-warning_apps", type => {custom, fun parse_app/1},
+ nargs => list, action => extend,
+ help => <<"By default, warnings will be reported to all applications given by "
+ "--apps. However, if --warning_apps is used, only those applications "
+ "given to --warning_apps will have warnings reported. All applications "
+ "given by --apps, but not --warning_apps, will be analysed to provide "
+ "context to the analysis, but warnings will not be reported for them. "
+ "For example, you may want to include libraries you depend on in the "
+ "analysis with --apps so discrepancies in their usage can be found, "
+ "but only include your own code with --warning_apps so that "
+ "discrepancies are only reported in code that you own.">>},
+
+ %% Intentionally undocumented options
+ #{name => solvers, long => "-solver", type => {atom, [v1, v2]}, action => append,
+ help => hidden},
+ #{name => timing, long => "-resources", type => boolean, action => {store, debug},
+ help => hidden},
+
+ %% next definition is necessary to ignore '--' left for compatibility reasons
+ #{name => shell, short => $-, type => boolean, help => hidden}
+ ],
+
+ help => [<<"Usage: ">>, usage, <<"\n\nOptions:\n">>,
+ arguments, options, "
Note:
* denotes that multiple occurrences of these options are possible.
** options -D and -I work both from command-line and in the Dialyzer GUI;
@@ -582,9 +257,63 @@ The exit status of the command line version is:
warnings were emitted.
1 - Problems were encountered during the analysis.
2 - No problems were encountered, but warnings were emitted.
-",
- io:put_chars(S),
- erlang:halt(?RET_NOTHING_SUSPICIOUS).
+
+"]
+ }.
+
+postprocess_side_effects(ArgMap) when is_map_key(version, ArgMap) ->
+ %% Version handling
+ io:format("Dialyzer version " ++ ?VSN ++ "\n"),
+ erlang:halt(?RET_NOTHING_SUSPICIOUS);
+
+postprocess_side_effects(ArgMap) when is_map_key(help, ArgMap) ->
+ %% Help message
+ io:format(argparse:help(cli(), #{progname => dialyzer})),
+ erlang:halt(?RET_NOTHING_SUSPICIOUS);
+
+postprocess_side_effects(ArgMap) when is_map_key(pa, ArgMap) ->
+ %% Code path side effect
+ [code:add_patha(Path) =/= true andalso cl_error("Bad directory for -pa: " ++ Path) ||
+ Path <- map_get(pa, ArgMap)],
+ postprocess_side_effects(maps:remove(pa, ArgMap));
+
+postprocess_side_effects(ArgMap) when is_map_key(shell, ArgMap) ->
+ %% --shell option is processed by C executable (left here only for help/usage)
+ postprocess_side_effects(maps:remove(shell, ArgMap));
+
+postprocess_side_effects(ArgMap) ->
+ %% if all files specified are ".erl" files, set the 'src' flag automatically
+ %% it is compatibility behaviour, potentially incorrect, because it does not take
+ %% directories (rec_files) into account
+ ArgMap1 =
+ case (is_map_key(files, ArgMap) andalso
+ lists:all(fun(F) -> filename:extension(F) =:= ".erl" end, maps:get(files, ArgMap))) of
+ true ->
+ ArgMap#{from => src_code};
+ false ->
+ ArgMap
+ end,
+
+ %% Run mode (command) is defined by the flag combination
+ case maps:get(analysis_type, ArgMap1, undefined) of
+ plt_info ->
+ %% plt_info is plt_check analysis type
+ {plt_info, ArgMap1#{analysis_type => plt_check}};
+ plt_check ->
+ %% plt_check is a hidden "check_init" command
+ {check_init, ArgMap1};
+ _ when map_get(gui, ArgMap1) ->
+ %% filter out command-line only arguments
+ Allowed = [defines, from, include_dirs, plts, output_plt, report_mode,
+ use_spec, warnings, check_plt, solvers],
+ {gui, maps:with(Allowed, ArgMap1)};
+ _ ->
+ {cl, ArgMap1}
+ end.
+
+cl_error(Str) ->
+ Msg = lists:flatten(Str),
+ throw({dialyzer_cl_parse_error, Msg}).
warning_options_msg() ->
"Warning options:
diff --git a/lib/dialyzer/src/dialyzer_options.erl b/lib/dialyzer/src/dialyzer_options.erl
index 73a1bbb589..27da2f9c83 100644
--- a/lib/dialyzer/src/dialyzer_options.erl
+++ b/lib/dialyzer/src/dialyzer_options.erl
@@ -339,10 +339,31 @@ build_options([], Options) ->
Options.
get_app_dirs(Apps) when is_list(Apps) ->
- dialyzer_cl_parse:get_lib_dir([atom_to_list(A) || A <- Apps]);
+ get_lib_dir([atom_to_list(A) || A <- Apps]);
get_app_dirs(Apps) ->
bad_option("Use a list of otp applications", Apps).
+get_lib_dir(Apps) ->
+ get_lib_dir(Apps, []).
+
+get_lib_dir([H|T], Acc) ->
+ NewElem =
+ case code:lib_dir(list_to_atom(H)) of
+ {error, bad_name} -> H;
+ LibDir when H =:= "erts" -> % hack for including erts in an un-installed system
+ EbinDir = filename:join([LibDir,"ebin"]),
+ case file:read_file_info(EbinDir) of
+ {error,enoent} ->
+ filename:join([LibDir,"preloaded","ebin"]);
+ _ ->
+ EbinDir
+ end;
+ LibDir -> filename:join(LibDir,"ebin")
+ end,
+ get_lib_dir(T, [NewElem|Acc]);
+get_lib_dir([], Acc) ->
+ lists:reverse(Acc).
+
assert_filenames(Term, Files) ->
assert_filenames_form(Term, Files),
assert_filenames_exist(Files).
diff --git a/lib/dialyzer/src/typer.erl b/lib/dialyzer/src/typer.erl
index 3b88ca6282..6467ae0093 100644
--- a/lib/dialyzer/src/typer.erl
+++ b/lib/dialyzer/src/typer.erl
@@ -93,21 +93,33 @@ cl(["-I",Dir|Opts]) -> {{inc, Dir}, Opts};
cl(["-I"|_Opts]) -> fatal_error("no include directory specified after -I");
cl(["-I"++Dir|Opts]) -> {{inc, Dir}, Opts};
cl(["-T"|Opts]) ->
- {Files, RestOpts} = dialyzer_cl_parse:collect_args(Opts),
+ {Files, RestOpts} = collect_args(Opts),
case Files of
[] -> fatal_error("no file or directory specified after -T");
[_|_] -> {{trusted, Files}, RestOpts}
end;
cl(["-r"|Opts]) ->
- {Files, RestOpts} = dialyzer_cl_parse:collect_args(Opts),
+ {Files, RestOpts} = collect_args(Opts),
{{files_r, Files}, RestOpts};
cl(["-pa",Dir|Opts]) -> {{pa,Dir}, Opts};
cl(["-pz",Dir|Opts]) -> {{pz,Dir}, Opts};
cl(["-"++H|_]) -> fatal_error("unknown option -"++H);
cl(Opts) ->
- {Files, RestOpts} = dialyzer_cl_parse:collect_args(Opts),
+ {Files, RestOpts} = collect_args(Opts),
{{files, Files}, RestOpts}.
+-spec collect_args([string()]) -> {[string()], [string()]}.
+
+collect_args(List) ->
+ collect_args_1(List, []).
+
+collect_args_1(["-"++_|_] = L, Acc) ->
+ {lists:reverse(Acc), L};
+collect_args_1([Arg|T], Acc) ->
+ collect_args_1(T, [Arg|Acc]);
+collect_args_1([], Acc) ->
+ {lists:reverse(Acc), []}.
+
process_def_list(L) ->
case L of
[Name, Value] ->
diff --git a/lib/stdlib/doc/src/Makefile b/lib/stdlib/doc/src/Makefile
index 5b1bc2b483..d13fa47064 100644
--- a/lib/stdlib/doc/src/Makefile
+++ b/lib/stdlib/doc/src/Makefile
@@ -33,6 +33,7 @@ APPLICATION=stdlib
XML_APPLICATION_FILES = ref_man.xml
XML_REF3_FILES = \
+ argparse.xml \
array.xml \
base64.xml \
beam_lib.xml \
diff --git a/lib/stdlib/doc/src/argparse.xml b/lib/stdlib/doc/src/argparse.xml
new file mode 100644
index 0000000000..20e1f3a721
--- /dev/null
+++ b/lib/stdlib/doc/src/argparse.xml
@@ -0,0 +1,739 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<!DOCTYPE erlref SYSTEM "erlref.dtd">
+
+<!-- %ExternalCopyright% -->
+
+<erlref>
+ <header>
+ <copyright>
+ <year>2020</year><year>2023</year>
+ <holder>Maxim Fedorov</holder>
+ </copyright>
+ <legalnotice>
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+ </legalnotice>
+
+ <title>argparse</title>
+ <prepared>maximfca@gmail.com</prepared>
+ <responsible></responsible>
+ <docno></docno>
+ <approved></approved>
+ <checked></checked>
+ <date></date>
+ <rev>A</rev>
+ <file>argparse.xml</file>
+ </header>
+ <module since="OTP 26.0">argparse</module>
+ <modulesummary>Command line arguments parser.</modulesummary>
+ <description>
+
+ <p>This module implements command line parser. Parser operates with
+ <em>commands</em> and <em>arguments</em> represented as a tree. Commands
+ are branches, and arguments are leaves of the tree. Parser always starts with the
+ root command, named after <c>progname</c> (the name of the program which started Erlang).
+ </p>
+
+ <p>
+ A <seetype marker="#command"><c>command specification</c></seetype> may contain handler
+ definition for each command, and a number argument specifications. When parser is
+ successful, <c>argparse</c> calls the matching handler, passing arguments extracted
+ from the command line. Arguments can be positional (occupying specific position in
+ the command line), and optional, residing anywhere but prefixed with a specified
+ character.
+ </p>
+
+ <p>
+ <c>argparse</c> automatically generates help and usage messages. It will also issue
+ errors when users give the program invalid arguments.
+ </p>
+
+ </description>
+
+ <section>
+ <title>Quick start</title>
+
+ <p><c>argparse</c> is designed to work with <seecom marker="erts:escript"><c>escript</c></seecom>.
+ The example below is a fully functioning Erlang program accepting two command line
+ arguments and printing their product.</p>
+
+ <code>
+#!/usr/bin/env escript
+
+main(Args) ->
+ argparse:run(Args, cli(), #{progname => mul}).
+
+cli() ->
+ #{
+ arguments => [
+ #{name => left, type => integer},
+ #{name => right, type => integer}
+ ],
+ handler =>
+ fun (#{left := Left, right := Right}) ->
+ io:format("~b~n", [Left * Right])
+ end
+ }.
+ </code>
+
+ <p>Running this script with no arguments results in an error, accompanied
+ by the usage information.</p>
+
+ <p>
+ The <c>cli</c> function defines a single command with embedded handler
+ accepting a map. Keys of the map are argument names as defined by
+ the <c>argument</c> field of the command, <c>left</c> and <c>right</c>
+ in the example. Values are taken from the command line, and converted
+ into integers, as requested by the type specification. Both arguments
+ in the example above are required (and therefore defined as positional).
+ </p>
+ </section>
+
+ <section>
+ <title>Command hierarchy</title>
+
+ <p>A command may contain nested commands, forming a hierarchy. Arguments
+ defined at the upper level command are automatically added to all nested
+ commands. Nested commands example (assuming <c>progname</c> is <c>nested</c>):
+ </p>
+
+ <code>
+cli() ->
+ #{
+ %% top level argument applicable to all commands
+ arguments => [#{name => top}],
+ commands => #{
+ "first" => #{
+ %% argument applicable to "first" command and
+ %% all commands nested into "first"
+ arguments => [#{name => mid}],
+ commands => #{
+ "second" => #{
+ %% argument only applicable for "second" command
+ arguments => [#{name => bottom}],
+ handler => fun (A) -> io:format("~p~n", [A]) end
+ }
+ }
+ }
+ }
+ }.
+ </code>
+
+ <p>In the example above, a 3-level hierarchy is defined. First is the script
+ itself (<c>nested</c>), accepting the only argument <c>top</c>. Since it
+ has no associated handler, <seemfa marker="#run/3">run/3</seemfa> will
+ not accept user input omitting nested command selection. For this example,
+ user has to supply 5 arguments in the command line, two being command
+ names, and another 3 - required positional arguments:</p>
+
+ <code>
+./nested.erl one first second two three
+#{top => "one",mid => "two",bottom => "three"}
+ </code>
+
+ <p>Commands have preference over positional argument values. In the example
+ above, commands and positional arguments are interleaving, and <c>argparse</c>
+ matches command name first.</p>
+
+ </section>
+
+ <section>
+ <title>Arguments</title>
+ <p><c>argparse</c> supports positional and optional arguments. Optional arguments,
+ or options for short, must be prefixed with a special character (<c>-</c> is the default
+ on all operating systems). Both options and positional arguments have 1 or more associated
+ values. See <seetype marker="#argument"><c>argument specification</c></seetype> to
+ find more details about supported combinations.</p>
+
+ <p>In the user input, short options may be concatenated with their values. Long
+ options support values separated by <c>=</c>. Consider this definition:</p>
+
+ <code>
+cli() ->
+ #{
+ arguments => [
+ #{name => long, long => "-long"},
+ #{name => short, short => $s}
+ ],
+ handler => fun (Args) -> io:format("~p~n", [Args]) end
+ }.
+ </code>
+
+ <p>Running <c>./args --long=VALUE</c> prints <c>#{long => "VALUE"}</c>, running
+ <c>./args -sVALUE</c> prints <c>#{short => "VALUE"}</c></p>
+
+ <p><c>argparse</c> supports boolean flags concatenation: it is possible to shorten
+ <c>-r -f -v</c> to <c>-rfv</c>.</p>
+
+ <p>Shortened option names are not supported: it is not possible to use <c>--my-argum</c>
+ instead of <c>--my-argument-name</c> even when such option can be unambiguously found.</p>
+ </section>
+
+ <datatypes>
+ <datatype>
+ <name name="arg_type"/>
+ <desc>
+ <p>Defines type conversion applied to the string retrieved from the user input.
+ If the conversion is successful, resulting value is validated using optional
+ <c>Choices</c>, or minimums and maximums (for integer and floating point values
+ only). Strings and binary values may be validated using regular expressions.
+ It's possible to define custom type conversion function, accepting a string
+ and returning Erlang term. If this function raises error with <c>badarg</c>
+ reason, argument is treated as invalid.
+ </p>
+ </desc>
+ </datatype>
+
+ <datatype>
+ <name name="argument_help"/>
+ <desc>
+ <p>User-defined help template to print in the command usage. First element of
+ a tuple must be a string. It is printed as a part of the usage header. Second
+ element of the tuple can be either a string printed as-is, a list
+ containing strings, <c>type</c> and <c>default</c> atoms, or a user-defined
+ function that must return a string.</p>
+ </desc>
+ </datatype>
+
+ <datatype>
+ <name name="argument_name"/>
+ <desc>
+ <p>Argument name is used to populate argument map.</p>
+ </desc>
+ </datatype>
+
+ <datatype>
+ <name name="argument"/>
+ <desc>
+ <p>Argument specification. Defines a single named argument that is returned
+ in the <seetype marker="#arg_map"><c>argument map</c></seetype>. The only
+ required field is <c>name</c>, all other fields have defaults.</p>
+ <p>If either of the <c>short</c> or <c>long</c> fields is specified, the
+ argument is treated as optional. Optional arguments do not have specific
+ order and may appear anywhere in the command line. Positional arguments
+ are ordered the same way as they appear in the arguments list of the command
+ specification.</p>
+ <p>By default, all positional arguments must be present in the command line.
+ The parser will return an error otherwise. Options, however, may be omitted,
+ in which case resulting argument map will either contain the default value,
+ or not have the key at all.</p>
+ <taglist>
+ <tag><c>name</c></tag>
+ <item>
+ <p>Sets the argument name in the parsed argument map. If <c>help</c> is not defined,
+ name is also used to generate the default usage message.
+ </p>
+ </item>
+ <tag><c>short</c></tag>
+ <item>
+ <p>Defines a short (single character) form of an optional argument.</p>
+ <code>
+%% Define a command accepting argument named myarg, with short form $a:
+1> Cmd = #{arguments => [#{name => myarg, short => $a}]}.
+%% Parse command line "-a str":
+2> {ok, ArgMap, _, _} = argparse:parse(["-a", "str"], Cmd), ArgMap.
+
+#{myarg => "str"}
+
+%% Option value can be concatenated with the switch: "-astr"
+3> {ok, ArgMap, _, _} = argparse:parse(["-astr"], Cmd), ArgMap.
+
+#{myarg => "str"}
+ </code>
+ <p>By default all options expect a single value following the option switch.
+ The only exception is an option of a boolean type.</p>
+ </item>
+ <tag><c>long</c></tag>
+ <item>
+ <p>Defines a long form of an optional argument.</p>
+ <code>
+1> Cmd = #{arguments => [#{name => myarg, long => "name"}]}.
+%% Parse command line "-name Erlang":
+2> {ok, ArgMap, _, _} = argparse:parse(["-name", "Erlang"], Cmd), ArgMap.
+
+#{myarg => "Erlang"}
+%% Or use "=" to separate the switch and the value:
+3> {ok, ArgMap, _, _} = argparse:parse(["-name=Erlang"], Cmd), ArgMap.
+
+#{myarg => "Erlang"}
+ </code>
+ <p>If neither <c>short</c> not <c>long</c> is defined, the
+ argument is treated as positional.</p>
+ </item>
+ <tag><c>required</c></tag>
+ <item>
+ <p>Forces the parser to expect the argument to be present in the
+ command line. By default, all positional argument are required,
+ and all options are not.</p>
+ </item>
+ <tag><c>default</c></tag>
+ <item>
+ <p>Specifies the default value to put in the parsed argument map
+ if the value is not supplied in the command line.</p>
+ <code>
+1> argparse:parse([], #{arguments => [#{name => myarg, short => $m}]}).
+
+{ok,#{}, ...
+2> argparse:parse([], #{arguments => [#{name => myarg, short => $m, default => "def"}]}).
+
+{ok,#{myarg => "def"}, ...
+ </code>
+ </item>
+ <tag><c>type</c></tag>
+ <item>
+ <p>Defines type conversion and validation routine. The default is <c>string</c>,
+ assuming no conversion.</p>
+ </item>
+ <tag><c>nargs</c></tag>
+ <item>
+ <p>Defines the number of following arguments to consume from the command line.
+ By default, the parser consumes the next argument and converts it into an
+ Erlang term according to the specified type.
+ </p>
+ <taglist>
+ <tag><c>pos_integer()</c></tag>
+ <item><p> Consume exactly this number of positional arguments, fail if there
+ is not enough. Value in the argument map contains a list of exactly this
+ length. Example, defining a positional argument expecting 3 integer values:</p>
+ <code>
+1> Cmd = #{arguments => [#{name => ints, type => integer, nargs => 3}]},
+argparse:parse(["1", "2", "3"], Cmd).
+
+{ok, #{ints => [1, 2, 3]}, ...
+ </code>
+ <p>Another example defining an option accepted as <c>-env</c> and
+ expecting two string arguments:</p>
+ <code>
+1> Cmd = #{arguments => [#{name => env, long => "env", nargs => 2}]},
+argparse:parse(["-env", "key", "value"], Cmd).
+
+{ok, #{env => ["key", "value"]}, ...
+ </code>
+ </item>
+ <tag><c>list</c></tag>
+ <item>
+ <p>Consume all following arguments until hitting the next option (starting
+ with an option prefix). May result in an empty list added to the arguments
+ map.</p>
+ <code>
+1> Cmd = #{arguments => [
+ #{name => nodes, long => "nodes", nargs => list},
+ #{name => verbose, short => $v, type => boolean}
+]},
+argparse:parse(["-nodes", "one", "two", "-v"], Cmd).
+
+{ok, #{nodes => ["one", "two"], verbose => true}, ...
+ </code>
+ </item>
+ <tag><c>nonempty_list</c></tag>
+ <item>
+ <p>Same as <c>list</c>, but expects at least one argument. Returns an error
+ if the following command line argument is an option switch (starting with the
+ prefix).</p>
+ </item>
+ <tag><c>'maybe'</c></tag>
+ <item>
+ <p>Consumes the next argument from the command line, if it does not start
+ with an option prefix. Otherwise, adds a default value to the arguments
+ map.</p>
+ <code>
+1> Cmd = #{arguments => [
+ #{name => level, short => $l, nargs => 'maybe', default => "error"},
+ #{name => verbose, short => $v, type => boolean}
+]},
+argparse:parse(["-l", "info", "-v"], Cmd).
+
+{ok,#{level => "info",verbose => true}, ...
+
+%% When "info" is omitted, argument maps receives the default "error"
+2> argparse:parse(["-l", "-v"], Cmd).
+
+{ok,#{level => "error",verbose => true}, ...
+ </code>
+ </item>
+ <tag><c>{'maybe', term()}</c></tag>
+ <item>
+ <p>Consumes the next argument from the command line, if it does not start
+ with an option prefix. Otherwise, adds a specified Erlang term to the
+ arguments map.</p>
+ </item>
+ <tag><c>all</c></tag>
+ <item>
+ <p>Fold all remaining command line arguments into a list, ignoring
+ any option prefixes or switches. Useful for proxying arguments
+ into another command line utility.</p>
+ <code>
+1> Cmd = #{arguments => [
+ #{name => verbose, short => $v, type => boolean},
+ #{name => raw, long => "-", nargs => all}
+]},
+argparse:parse(["-v", "--", "-kernel", "arg", "opt"], Cmd).
+
+{ok,#{raw => ["-kernel","arg","opt"],verbose => true}, ...
+ </code>
+ </item>
+ </taglist>
+ </item>
+ <tag><c>action</c></tag>
+ <item>
+ <p>Defines an action to take when the argument is found in the command line. The
+ default action is <c>store</c>.</p>
+ <taglist>
+ <tag><c>store</c></tag>
+ <item><p>
+ Store the value in the arguments map. Overwrites the value previously written.
+ </p>
+ <code>
+1> Cmd = #{arguments => [#{name => str, short => $s}]},
+argparse:parse(["-s", "one", "-s", "two"], Cmd).
+
+{ok, #{str => "two"}, ...
+ </code>
+ </item>
+ <tag><c>{store, term()}</c></tag>
+ <item><p>
+ Stores the specified term instead of reading the value from the command line.
+ </p>
+ <code>
+1> Cmd = #{arguments => [#{name => str, short => $s, action => {store, "two"}}]},
+argparse:parse(["-s"], Cmd).
+
+{ok, #{str => "two"}, ...
+ </code>
+ </item>
+ <tag><c>append</c></tag>
+ <item><p>
+ Appends the repeating occurrences of the argument instead of overwriting.
+ </p>
+ <code>
+1> Cmd = #{arguments => [#{name => node, short => $n, action => append}]},
+argparse:parse(["-n", "one", "-n", "two", "-n", "three"], Cmd).
+
+{ok, #{node => ["one", "two", "three"]}, ...
+
+%% Always produces a list - even if there is one occurrence
+2> argparse:parse(["-n", "one"], Cmd).
+
+{ok, #{node => ["one"]}, ...
+ </code>
+ </item>
+ <tag><c>{append, term()}</c></tag>
+ <item><p>
+ Same as <c>append</c>, but instead of consuming the argument from the
+ command line, appends a provided <c>term()</c>.
+ </p></item>
+ <tag><c>count</c></tag>
+ <item><p>
+ Puts a counter as a value in the arguments map. Useful for implementing
+ verbosity option:
+ </p>
+ <code>
+1> Cmd = #{arguments => [#{name => verbose, short => $v, action => count}]},
+argparse:parse(["-v"], Cmd).
+
+{ok, #{verbose => 1}, ...
+
+2> argparse:parse(["-vvvv"], Cmd).
+
+{ok, #{verbose => 4}, ...
+ </code>
+ </item>
+ <tag><c>extend</c></tag>
+ <item><p>
+ Works as <c>append</c>, but flattens the resulting list.
+ Valid only for <c>nargs</c> set to <c>list</c>, <c>nonempty_list</c>,
+ <c>all</c> or <c>pos_integer()</c>.
+ </p>
+ <code>
+1> Cmd = #{arguments => [#{name => duet, short => $d, nargs => 2, action => extend}]},
+argparse:parse(["-d", "a", "b", "-d", "c", "d"], Cmd).
+
+{ok, #{duet => ["a", "b", "c", "d"]}, ...
+
+%% 'append' would result in {ok, #{duet => [["a", "b"],["c", "d"]]},
+ </code>
+ </item>
+ </taglist>
+ </item>
+ <tag><c>help</c></tag>
+ <item>
+ <p>Specifies help/usage text for the argument. <c>argparse</c> provides automatic
+ generation based on the argument name, type and default value, but for better
+ usability it is recommended to have a proper description. Setting this field
+ to <c>hidden</c> suppresses usage output for this argument.</p>
+ </item>
+ </taglist>
+ </desc>
+ </datatype>
+
+ <datatype>
+ <name name="arg_map"/>
+ <desc>
+ <p>Arguments map is the map of argument names to the values extracted from the
+ command line. It is passed to the matching command handler.
+ If an argument is omitted, but has the default value is specified,
+ it is added to the map. When no default value specified, and argument is not
+ present in the command line, corresponding key is not present in the resulting
+ map.</p>
+ </desc>
+ </datatype>
+
+ <datatype>
+ <name name="handler"/>
+ <desc>
+ <p>Command handler specification. Called by <seemfa marker="#run/3"><c>run/3</c>
+ </seemfa> upon successful parser return.</p>
+ <taglist>
+ <tag><c>fun((arg_map()) -> term())</c></tag>
+ <item><p>
+ Function accepting <seetype marker="#arg_map"><c>argument map</c></seetype>.
+ See the basic example in the <seeerl marker="#quick-start">Quick Start</seeerl>
+ section.
+ </p></item>
+ <tag><c>{Module :: module(), Function :: atom()}</c></tag>
+ <item><p>
+ Function named <c>Function</c>, exported from <c>Module</c>, accepting
+ <seetype marker="#arg_map"><c>argument map</c></seetype>.
+ </p></item>
+ <tag><c>{fun(() -> term()), Default :: term()}</c></tag>
+ <item><p>
+ Function accepting as many arguments as there are in the <c>arguments</c>
+ list for this command. Arguments missing from the parsed map are replaced
+ with the <c>Default</c>. Convenient way to expose existing functions.
+ </p>
+ <code>
+1> Cmd = #{arguments => [
+ #{name => x, type => float},
+ #{name => y, type => float, short => $p}],
+ handler => {fun math:pow/2, 1}},
+argparse:run(["2", "-p", "3"], Cmd, #{}).
+
+8.0
+
+%% default term 1 is passed to math:pow/2
+2> argparse:run(["2"], Cmd, #{}).
+
+2.0
+ </code>
+ </item>
+ <tag><c>{Module :: module(), Function :: atom(), Default :: term()}</c></tag>
+ <item><p>Function named <c>Function</c>, exported from <c>Module</c>, accepting
+ as many arguments as defined for this command. Arguments missing from the parsed
+ map are replaced with the <c>Default</c>. Effectively, just a different syntax
+ to the same functionality as demonstrated in the code above.</p></item>
+ </taglist>
+ </desc>
+ </datatype>
+
+ <datatype>
+ <name name="command_help"/>
+ <desc>
+ <p>User-defined help template. Use this option to mix custom and predefined usage text.
+ Help template may contain unicode strings, and following atoms:</p>
+ <taglist>
+ <tag>usage</tag>
+ <item><p>
+ Formatted command line usage text, e.g. <c>rm [-rf] &lt;directory&gt;</c>.
+ </p></item>
+ <tag>commands</tag>
+ <item><p>
+ Expanded list of sub-commands.
+ </p></item>
+ <tag>arguments</tag>
+ <item><p>
+ Detailed description of positional arguments.
+ </p></item>
+ <tag>options</tag>
+ <item><p>
+ Detailed description of optional arguments.
+ </p></item>
+ </taglist>
+ </desc>
+ </datatype>
+
+ <datatype>
+ <name name="command"/>
+ <desc>
+ <p>Command specification. May contain nested commands, forming a hierarchy.</p>
+ <taglist>
+ <tag><c>commands</c></tag>
+ <item><p>
+ Maps of nested commands. Keys must be strings, matching command line input.
+ Basic utilities do not need to specify any nested commands.
+ </p>
+ </item>
+ <tag><c>arguments</c></tag>
+ <item><p>
+ List of arguments accepted by this command, and all nested commands in the
+ hierarchy.
+ </p></item>
+ <tag><c>help</c></tag>
+ <item><p>
+ Specifies help/usage text for this command. Pass <c>hidden</c> to remove
+ this command from the usage output.
+ </p></item>
+ <tag><c>handler</c></tag>
+ <item><p>
+ Specifies a callback function to call by <seemfa marker="#run/3">run/3</seemfa>
+ when the parser is successful.
+ </p></item>
+ </taglist>
+ </desc>
+ </datatype>
+
+ <datatype>
+ <name name="cmd_path"/>
+ <desc>
+ <p>Path to the nested command. First element is always the <c>progname</c>,
+ subsequent elements are nested command names.</p>
+ </desc>
+ </datatype>
+
+ <datatype>
+ <name name="parser_error"/>
+ <desc>
+ <p>Returned from <seemfa marker="#parse/3"><c>parse/2,3</c></seemfa> when the
+ user input cannot be parsed according to the command specification.</p>
+ <p>First element is the path to the command that was considered when the
+ parser detected an error. Second element, <c>Expected</c>, is the argument
+ specification that caused an error. It could be <c>undefined</c>, meaning
+ that <c>Actual</c> argument had no corresponding specification in the
+ arguments list for the current command. </p>
+ <p>When <c>Actual</c> is set to <c>undefined</c>, it means that a required
+ argument is missing from the command line. If both <c>Expected</c> and
+ <c>Actual</c> have values, it means validation error.</p>
+ <p>Use <seemfa marker="#format_error/1"><c>format_error/1</c></seemfa> to
+ generate a human-readable error description, unless there is a need to
+ provide localised error messages.</p>
+ </desc>
+ </datatype>
+
+ <datatype>
+ <name name="parser_options"/>
+ <desc>
+ <p>Options changing parser behaviour.</p>
+ <taglist>
+ <tag><c>prefixes</c></tag>
+ <item><p>
+ Changes the option prefix (the default is <c>-</c>).
+ </p></item>
+ <tag><c>default</c></tag>
+ <item><p>
+ Specifies the default value for all optional arguments. When
+ this field is set, resulting argument map will contain all
+ argument names. Useful for easy pattern matching on the
+ argument map in the handler function.
+ </p></item>
+ <tag><c>progname</c></tag>
+ <item><p>
+ Specifies the program (root command) name. Returned as the
+ first element of the command path, and printed in help/usage
+ text. It is recommended to have this value set, otherwise the
+ default one is determined with <c>init:get_argument(progname)</c>
+ and is often set to <c>erl</c> instead of the actual script name.
+ </p></item>
+ <tag><c>command</c></tag>
+ <item><p>
+ Specifies the path to the nested command for
+ <seemfa marker="#help/2"><c>help/2</c></seemfa>. Useful to
+ limit output for complex utilities with multiple commands,
+ and used by the default error handling logic.
+ </p></item>
+ <tag><c>columns</c></tag>
+ <item><p>
+ Specifies the help/usage text width (characters) for
+ <seemfa marker="#help/2"><c>help/2</c></seemfa>. Default value
+ is 80.
+ </p></item>
+ </taglist>
+ </desc>
+ </datatype>
+
+ <datatype>
+ <name name="parse_result"/>
+ <desc>
+ <p>Returned from <seemfa marker="#parse/3"><c>parse/2,3</c></seemfa>. Contains
+ arguments extracted from the command line, path to the nested command (if any),
+ and a (potentially nested) command specification that was considered when
+ the parser finished successfully. It is expected that the command contains
+ a handler definition, that will be called passing the argument map.</p>
+ </desc>
+ </datatype>
+
+ </datatypes>
+
+ <funcs>
+
+ <func>
+ <name name="format_error" arity="1" since="OTP 26.0"/>
+ <fsummary>Generates human-readable text for parser errors.</fsummary>
+ <desc>
+ <p>Generates human-readable text for
+ <seetype marker="#parser_error"><c>parser error</c></seetype>. Does
+ not include help/usage information, and does not provide localisation.
+ </p>
+ </desc>
+ </func>
+
+ <func>
+ <name name="help" arity="1" since="OTP 26.0"/>
+ <name name="help" arity="2" since="OTP 26.0"/>
+ <fsummary>Generates help/usage information text.</fsummary>
+ <desc>
+ <p>Generates help/usage information text for the command
+ supplied, or any nested command when <c>command</c>
+ option is specified. Does not provide localisaton.
+ Expects <c>progname</c> to be set, otherwise defaults to
+ return value of <c>init:get_argument(progname)</c>.</p>
+ </desc>
+ </func>
+
+ <func>
+ <name name="parse" arity="2" since="OTP 26.0"/>
+ <name name="parse" arity="3" since="OTP 26.0"/>
+ <fsummary>Parses command line arguments according to the command specification.</fsummary>
+ <desc>
+ <p>Parses command line arguments according to the command specification.
+ Raises an exception if the command specification is not valid. Use
+ <seemfa marker="erl_error#format_exception/3"><c>erl_error:format_exception/3,4</c>
+ </seemfa> to see a friendlier message. Invalid command line input
+ does not raise an exception, but makes <c>parse/2,3</c> to return a tuple
+ <seetype marker="#parser_error"><c>{error, parser_error()}</c></seetype>.
+ </p>
+ <p>This function does not call command handler.</p>
+ </desc>
+ </func>
+
+ <func>
+ <name name="run" arity="3" since="OTP 26.0"/>
+ <fsummary>Parses command line arguments and calls the matching command handler.</fsummary>
+ <desc>
+ <p>Parses command line arguments and calls the matching command handler.
+ Prints human-readable error, help/usage information for the discovered
+ command, and halts the emulator with code 1 if there is any error in the
+ command specification or user-provided command line input.
+ </p>
+ <warning>
+ <p>This function is designed to work as an entry point to a standalone
+ <seecom marker="erts:escript"><c>escript</c></seecom>. Therefore, it halts
+ the emulator for any error detected. Do not use this function through
+ remote procedure call, or it may result in an unexpected shutdown of a remote
+ node.</p>
+ </warning>
+ </desc>
+ </func>
+
+ </funcs>
+
+</erlref>
+
diff --git a/lib/stdlib/doc/src/ref_man.xml b/lib/stdlib/doc/src/ref_man.xml
index 961c5a0a77..04990db408 100644
--- a/lib/stdlib/doc/src/ref_man.xml
+++ b/lib/stdlib/doc/src/ref_man.xml
@@ -32,6 +32,7 @@
<description>
</description>
<xi:include href="stdlib_app.xml"/>
+ <xi:include href="argparse.xml"/>
<xi:include href="array.xml"/>
<xi:include href="assert_hrl.xml"/>
<xi:include href="base64.xml"/>
diff --git a/lib/stdlib/doc/src/specs.xml b/lib/stdlib/doc/src/specs.xml
index 8279c5a5d8..fc19db4bf3 100644
--- a/lib/stdlib/doc/src/specs.xml
+++ b/lib/stdlib/doc/src/specs.xml
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8" ?>
<specs xmlns:xi="http://www.w3.org/2001/XInclude">
+ <xi:include href="../specs/specs_argparse.xml"/>
<xi:include href="../specs/specs_array.xml"/>
<xi:include href="../specs/specs_base64.xml"/>
<xi:include href="../specs/specs_beam_lib.xml"/>
diff --git a/lib/stdlib/src/Makefile b/lib/stdlib/src/Makefile
index e546172856..abdb665b09 100644
--- a/lib/stdlib/src/Makefile
+++ b/lib/stdlib/src/Makefile
@@ -42,6 +42,7 @@ RELSYSDIR = $(RELEASE_PATH)/lib/stdlib-$(VSN)
# ----------------------------------------------------
MODULES= \
array \
+ argparse \
base64 \
beam_lib \
binary \
diff --git a/lib/stdlib/src/argparse.erl b/lib/stdlib/src/argparse.erl
new file mode 100644
index 0000000000..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).
diff --git a/lib/stdlib/src/stdlib.app.src b/lib/stdlib/src/stdlib.app.src
index 69bff1511b..a71ad0a954 100644
--- a/lib/stdlib/src/stdlib.app.src
+++ b/lib/stdlib/src/stdlib.app.src
@@ -21,7 +21,8 @@
{application, stdlib,
[{description, "ERTS CXC 138 10"},
{vsn, "%VSN%"},
- {modules, [array,
+ {modules, [argparse,
+ array,
base64,
beam_lib,
binary,
diff --git a/lib/stdlib/test/Makefile b/lib/stdlib/test/Makefile
index 5d4ffcf86e..2597157004 100644
--- a/lib/stdlib/test/Makefile
+++ b/lib/stdlib/test/Makefile
@@ -7,6 +7,7 @@ include $(ERL_TOP)/make/$(TARGET)/otp.mk
MODULES= \
array_SUITE \
+ argparse_SUITE \
base64_SUITE \
base64_property_test_SUITE \
beam_lib_SUITE \
diff --git a/lib/stdlib/test/argparse_SUITE.erl b/lib/stdlib/test/argparse_SUITE.erl
new file mode 100644
index 0000000000..fb7eaecda1
--- /dev/null
+++ b/lib/stdlib/test/argparse_SUITE.erl
@@ -0,0 +1,1063 @@
+%%
+%%
+%% Copyright Maxim Fedorov
+%%
+%%
+%% Licensed under the Apache License, Version 2.0 (the "License");
+%% you may not use this file except in compliance with the License.
+%% You may obtain a copy of the License at
+%%
+%% http://www.apache.org/licenses/LICENSE-2.0
+%%
+%% Unless required by applicable law or agreed to in writing, software
+%% distributed under the License is distributed on an "AS IS" BASIS,
+%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+%% See the License for the specific language governing permissions and
+%% limitations under the License.
+
+-module(argparse_SUITE).
+-author("maximfca@gmail.com").
+
+-export([suite/0, all/0, groups/0]).
+
+-export([
+ readme/0, readme/1,
+ basic/0, basic/1,
+ long_form_eq/0, long_form_eq/1,
+ built_in_types/0, built_in_types/1,
+ type_validators/0, type_validators/1,
+ invalid_arguments/0, invalid_arguments/1,
+ complex_command/0, complex_command/1,
+ unicode/0, unicode/1,
+ parser_error/0, parser_error/1,
+ nargs/0, nargs/1,
+ argparse/0, argparse/1,
+ negative/0, negative/1,
+ nodigits/0, nodigits/1,
+ pos_mixed_with_opt/0, pos_mixed_with_opt/1,
+ default_for_not_required/0, default_for_not_required/1,
+ global_default/0, global_default/1,
+ subcommand/0, subcommand/1,
+ very_short/0, very_short/1,
+ multi_short/0, multi_short/1,
+ proxy_arguments/0, proxy_arguments/1,
+
+ usage/0, usage/1,
+ usage_required_args/0, usage_required_args/1,
+ usage_template/0, usage_template/1,
+ parser_error_usage/0, parser_error_usage/1,
+ command_usage/0, command_usage/1,
+ usage_width/0, usage_width/1,
+
+ validator_exception/0, validator_exception/1,
+ validator_exception_format/0, validator_exception_format/1,
+
+ run_handle/0, run_handle/1
+]).
+
+-include_lib("stdlib/include/assert.hrl").
+
+suite() ->
+ [{timetrap, {seconds, 30}}].
+
+groups() ->
+ [
+ {parser, [parallel], [
+ readme, basic, long_form_eq, built_in_types, type_validators,
+ invalid_arguments, complex_command, unicode, parser_error,
+ nargs, argparse, negative, nodigits, pos_mixed_with_opt,
+ default_for_not_required, global_default, subcommand,
+ very_short, multi_short, proxy_arguments
+ ]},
+ {usage, [parallel], [
+ usage, usage_required_args, usage_template,
+ parser_error_usage, command_usage, usage_width
+ ]},
+ {validator, [parallel], [
+ validator_exception, validator_exception_format
+ ]},
+ {run, [parallel], [
+ run_handle
+ ]}
+ ].
+
+all() ->
+ [{group, parser}, {group, validator}, {group, usage}].
+
+%%--------------------------------------------------------------------
+%% Helpers
+
+prog() ->
+ {ok, [[ProgStr]]} = init:get_argument(progname), ProgStr.
+
+parser_error(CmdLine, CmdMap) ->
+ {error, Reason} = parse(CmdLine, CmdMap),
+ unicode:characters_to_list(argparse:format_error(Reason)).
+
+parse_opts(Args, Opts) ->
+ argparse:parse(string:lexemes(Args, " "), #{arguments => Opts}).
+
+parse(Args, Command) ->
+ argparse:parse(string:lexemes(Args, " "), Command).
+
+parse_cmd(Args, Command) ->
+ argparse:parse(string:lexemes(Args, " "), #{commands => Command}).
+
+%% ubiquitous command, containing sub-commands, and all possible option types
+%% with all nargs. Not all combinations though.
+ubiq_cmd() ->
+ #{
+ arguments => [
+ #{name => r, short => $r, type => boolean, help => "recursive"},
+ #{name => f, short => $f, type => boolean, long => "-force", help => "force"},
+ #{name => v, short => $v, type => boolean, action => count, help => "verbosity level"},
+ #{name => interval, short => $i, type => {integer, [{min, 1}]}, help => "interval set"},
+ #{name => weird, long => "-req", help => "required optional, right?"},
+ #{name => float, long => "-float", type => float, default => 3.14, help => "floating-point long form argument"}
+ ],
+ commands => #{
+ "start" => #{help => "verifies configuration and starts server",
+ arguments => [
+ #{name => server, help => "server to start"},
+ #{name => shard, short => $s, type => integer, nargs => nonempty_list, help => "initial shards"},
+ #{name => part, short => $p, type => integer, nargs => list, help => hidden},
+ #{name => z, short => $z, type => {integer, [{min, 1}, {max, 10}]}, help => "between"},
+ #{name => l, short => $l, type => {integer, [{max, 10}]}, nargs => 'maybe', help => "maybe lower"},
+ #{name => more, short => $m, type => {integer, [{max, 10}]}, help => "less than 10"},
+ #{name => optpos, required => false, type => {integer, []}, help => "optional positional"},
+ #{name => bin, short => $b, type => {binary, <<"m">>}, help => "binary with re"},
+ #{name => g, short => $g, type => {binary, <<"m">>, []}, help => "binary with re"},
+ #{name => t, short => $t, type => {string, "m"}, help => "string with re"},
+ #{name => e, long => "--maybe-req", required => true, type => integer, nargs => 'maybe', help => "maybe required int"},
+ #{name => y, required => true, long => "-yyy", short => $y, type => {string, "m", []}, help => "string with re"},
+ #{name => u, short => $u, type => {string, ["1", "2"]}, help => "string choices"},
+ #{name => choice, short => $c, type => {integer, [1,2,3]}, help => "tough choice"},
+ #{name => fc, short => $q, type => {float, [2.1,1.2]}, help => "floating choice"},
+ #{name => ac, short => $w, type => {atom, [one, two]}, help => "atom choice"},
+ #{name => au, long => "-unsafe", type => {atom, unsafe}, help => "unsafe atom"},
+ #{name => as, long => "-safe", type => atom, help => <<"safe atom">>},
+ #{name => name, required => false, nargs => list, help => hidden},
+ #{name => long, long => "foobar", required => false, help => [<<"foobaring option">>]}
+ ], commands => #{
+ "crawler" => #{arguments => [
+ #{name => extra, long => "--extra", help => "extra option very deep"}
+ ],
+ help => "controls crawler behaviour"},
+ "doze" => #{help => "dozes a bit"}}
+ },
+ "stop" => #{help => <<"stops running server">>, arguments => []
+ },
+ "status" => #{help => "prints server status", arguments => [],
+ commands => #{
+ "crawler" => #{
+ arguments => [#{name => extra, long => "--extra", help => "extra option very deep"}],
+ help => "crawler status"}}
+ },
+ "restart" => #{help => hidden, arguments => [
+ #{name => server, help => "server to restart"},
+ #{name => duo, short => $d, long => "-duo", help => "dual option"}
+ ]}
+ }
+ }.
+
+%%--------------------------------------------------------------------
+%% Parser Test Cases
+
+readme() ->
+ [{doc, "Test cases covered in the README"}].
+
+readme(Config) when is_list(Config) ->
+ Prog = prog(),
+ Rm = #{
+ arguments => [
+ #{name => dir},
+ #{name => force, short => $f, type => boolean, default => false},
+ #{name => recursive, short => $r, type => boolean}
+ ]
+ },
+ ?assertEqual({ok, #{dir => "dir", force => true, recursive => true}, [Prog], Rm},
+ argparse:parse(["-rf", "dir"], Rm)),
+ %% override progname
+ ?assertEqual("Usage:\n readme\n",
+ unicode:characters_to_list(argparse:help(#{}, #{progname => "readme"}))),
+ ?assertEqual("Usage:\n readme\n",
+ unicode:characters_to_list(argparse:help(#{}, #{progname => readme}))),
+ ?assertEqual("Usage:\n readme\n",
+ unicode:characters_to_list(argparse:help(#{}, #{progname => <<"readme">>}))),
+ %% test that command has priority over just a positional argument:
+ %% - parsing "opt sub" means "find positional argument "pos", then enter subcommand
+ %% - parsing "sub opt" means "enter sub-command, and then find positional argument"
+ Cmd = #{
+ commands => #{"sub" => #{}},
+ arguments => [#{name => pos}]
+ },
+ ?assertEqual(parse("opt sub", Cmd), parse("sub opt", Cmd)).
+
+basic() ->
+ [{doc, "Basic cases"}].
+
+basic(Config) when is_list(Config) ->
+ Prog = prog(),
+ %% empty command, with full options path
+ ?assertMatch({ok, #{}, [Prog, "cmd"], #{}},
+ argparse:parse(["cmd"], #{commands => #{"cmd" => #{}}})),
+ %% sub-command, with no path, but user-supplied argument
+ ?assertEqual({ok, #{}, [Prog, "cmd", "sub"], #{attr => pos}},
+ argparse:parse(["cmd", "sub"], #{commands => #{"cmd" => #{commands => #{"sub" => #{attr => pos}}}}})),
+ %% command with positional argument
+ PosCmd = #{arguments => [#{name => pos}]},
+ ?assertEqual({ok, #{pos => "arg"}, [Prog, "cmd"], PosCmd},
+ argparse:parse(["cmd", "arg"], #{commands => #{"cmd" => PosCmd}})),
+ %% command with optional argument
+ OptCmd = #{arguments => [#{name => force, short => $f, type => boolean}]},
+ ?assertEqual({ok, #{force => true}, [Prog, "rm"], OptCmd},
+ parse(["rm -f"], #{commands => #{"rm" => OptCmd}}), "rm -f"),
+ %% command with optional and positional argument
+ PosOptCmd = #{arguments => [#{name => force, short => $f, type => boolean}, #{name => dir}]},
+ ?assertEqual({ok, #{force => true, dir => "dir"}, [Prog, "rm"], PosOptCmd},
+ parse(["rm -f dir"], #{commands => #{"rm" => PosOptCmd}}), "rm -f dir"),
+ %% no command, just argument list
+ KernelCmd = #{arguments => [#{name => kernel, long => "kernel", type => atom, nargs => 2}]},
+ ?assertEqual({ok, #{kernel => [port, dist]}, [Prog], KernelCmd},
+ parse(["-kernel port dist"], KernelCmd)),
+ %% same but positional
+ ArgListCmd = #{arguments => [#{name => arg, nargs => 2, type => boolean}]},
+ ?assertEqual({ok, #{arg => [true, false]}, [Prog], ArgListCmd},
+ parse(["true false"], ArgListCmd)).
+
+long_form_eq() ->
+ [{doc, "Tests that long form supports --arg=value"}].
+
+long_form_eq(Config) when is_list(Config) ->
+ Prog = prog(),
+ %% cmd --arg=value
+ PosOptCmd = #{arguments => [#{name => arg, long => "-arg"}]},
+ ?assertEqual({ok, #{arg => "value"}, [Prog, "cmd"], PosOptCmd},
+ parse(["cmd --arg=value"], #{commands => #{"cmd" => PosOptCmd}})),
+ %% --integer=10
+ ?assertMatch({ok, #{int := 10}, _, _},
+ parse(["--int=10"], #{arguments => [#{name => int, type => integer, long => "-int"}]})).
+
+built_in_types() ->
+ [{doc, "Tests all built-in types supplied as a single argument"}].
+
+% built-in types testing
+built_in_types(Config) when is_list(Config) ->
+ Prog = [prog()],
+ Bool = #{arguments => [#{name => meta, type => boolean, short => $b, long => "-boolean"}]},
+ ?assertEqual({ok, #{}, Prog, Bool}, parse([""], Bool)),
+ ?assertEqual({ok, #{meta => true}, Prog, Bool}, parse(["-b"], Bool)),
+ ?assertEqual({ok, #{meta => true}, Prog, Bool}, parse(["--boolean"], Bool)),
+ ?assertEqual({ok, #{meta => false}, Prog, Bool}, parse(["--boolean false"], Bool)),
+ %% integer tests
+ Int = #{arguments => [#{name => int, type => integer, short => $i, long => "-int"}]},
+ ?assertEqual({ok, #{int => 1}, Prog, Int}, parse([" -i 1"], Int)),
+ ?assertEqual({ok, #{int => 1}, Prog, Int}, parse(["--int 1"], Int)),
+ ?assertEqual({ok, #{int => -1}, Prog, Int}, parse(["-i -1"], Int)),
+ %% floating point
+ Float = #{arguments => [#{name => f, type => float, short => $f}]},
+ ?assertEqual({ok, #{f => 44.44}, Prog, Float}, parse(["-f 44.44"], Float)),
+ %% atoms, existing
+ Atom = #{arguments => [#{name => atom, type => atom, short => $a, long => "-atom"}]},
+ ?assertEqual({ok, #{atom => atom}, Prog, Atom}, parse(["-a atom"], Atom)),
+ ?assertEqual({ok, #{atom => atom}, Prog, Atom}, parse(["--atom atom"], Atom)).
+
+type_validators() ->
+ [{doc, "Test that parser return expected conversions for valid arguments"}].
+
+type_validators(Config) when is_list(Config) ->
+ %% successful string regexes
+ ?assertMatch({ok, #{str := "me"}, _, _},
+ parse_opts("me", [#{name => str, type => {string, "m."}}])),
+ ?assertMatch({ok, #{str := "me"}, _, _},
+ parse_opts("me", [#{name => str, type => {string, "m.", []}}])),
+ ?assertMatch({ok, #{"str" := "me"}, _, _},
+ parse_opts("me", [#{name => "str", type => {string, "m.", [{capture, none}]}}])),
+ %% and binary too...
+ ?assertMatch({ok, #{bin := <<"me">>}, _, _},
+ parse_opts("me", [#{name => bin, type => {binary, <<"m.">>}}])),
+ ?assertMatch({ok, #{<<"bin">> := <<"me">>}, _, _},
+ parse_opts("me", [#{name => <<"bin">>, type => {binary, <<"m.">>, []}}])),
+ ?assertMatch({ok, #{bin := <<"me">>}, _, _},
+ parse_opts("me", [#{name => bin, type => {binary, <<"m.">>, [{capture, none}]}}])),
+ %% successful integer with range validators
+ ?assertMatch({ok, #{int := 5}, _, _},
+ parse_opts("5", [#{name => int, type => {integer, [{min, 0}, {max, 10}]}}])),
+ ?assertMatch({ok, #{bin := <<"5">>}, _, _},
+ parse_opts("5", [#{name => bin, type => binary}])),
+ ?assertMatch({ok, #{str := "011"}, _, _},
+ parse_opts("11", [#{name => str, type => {custom, fun(S) -> [$0|S] end}}])),
+ %% choices: valid
+ ?assertMatch({ok, #{bin := <<"K">>}, _, _},
+ parse_opts("K", [#{name => bin, type => {binary, [<<"M">>, <<"K">>]}}])),
+ ?assertMatch({ok, #{str := "K"}, _, _},
+ parse_opts("K", [#{name => str, type => {string, ["K", "N"]}}])),
+ ?assertMatch({ok, #{atom := one}, _, _},
+ parse_opts("one", [#{name => atom, type => {atom, [one, two]}}])),
+ ?assertMatch({ok, #{int := 12}, _, _},
+ parse_opts("12", [#{name => int, type => {integer, [10, 12]}}])),
+ ?assertMatch({ok, #{float := 1.3}, _, _},
+ parse_opts("1.3", [#{name => float, type => {float, [1.3, 1.4]}}])),
+ %% test for unsafe atom
+ %% ensure the atom does not exist
+ ?assertException(error, badarg, list_to_existing_atom("$can_never_be")),
+ {ok, ArgMap, _, _} = parse_opts("$can_never_be", [#{name => atom, type => {atom, unsafe}}]),
+ argparse:validate(#{arguments => [#{name => atom, type => {atom, unsafe}}]}),
+ %% now that atom exists, because argparse created it (in an unsafe way!)
+ ?assertEqual(list_to_existing_atom("$can_never_be"), maps:get(atom, ArgMap)),
+ %% test successful user-defined conversion
+ ?assertMatch({ok, #{user := "VER"}, _, _},
+ parse_opts("REV", [#{name => user, type => {custom, fun (Str) -> lists:reverse(Str) end}}])).
+
+invalid_arguments() ->
+ [{doc, "Test that parser return errors for invalid arguments"}].
+
+invalid_arguments(Config) when is_list(Config) ->
+ %% {float, [{min, float()} | {max, float()}]} |
+ Prog = [prog()],
+ MinFloat = #{name => float, type => {float, [{min, 1.0}]}},
+ ?assertEqual({error, {Prog, MinFloat, "0.0", <<"is less than accepted minimum">>}},
+ parse_opts("0.0", [MinFloat])),
+ MaxFloat = #{name => float, type => {float, [{max, 1.0}]}},
+ ?assertEqual({error, {Prog, MaxFloat, "2.0", <<"is greater than accepted maximum">>}},
+ parse_opts("2.0", [MaxFloat])),
+ %% {int, [{min, integer()} | {max, integer()}]} |
+ MinInt = #{name => int, type => {integer, [{min, 20}]}},
+ ?assertEqual({error, {Prog, MinInt, "10", <<"is less than accepted minimum">>}},
+ parse_opts("10", [MinInt])),
+ MaxInt = #{name => int, type => {integer, [{max, -10}]}},
+ ?assertEqual({error, {Prog, MaxInt, "-5", <<"is greater than accepted maximum">>}},
+ parse_opts("-5", [MaxInt])),
+ %% string: regex & regex with options
+ %% {string, string()} | {string, string(), []}
+ StrRegex = #{name => str, type => {string, "me.me"}},
+ ?assertEqual({error, {Prog, StrRegex, "me", <<"does not match">>}},
+ parse_opts("me", [StrRegex])),
+ StrRegexOpt = #{name => str, type => {string, "me.me", []}},
+ ?assertEqual({error, {Prog, StrRegexOpt, "me", <<"does not match">>}},
+ parse_opts("me", [StrRegexOpt])),
+ %% {binary, {re, binary()} | {re, binary(), []}
+ BinRegex = #{name => bin, type => {binary, <<"me.me">>}},
+ ?assertEqual({error, {Prog, BinRegex, "me", <<"does not match">>}},
+ parse_opts("me", [BinRegex])),
+ BinRegexOpt = #{name => bin, type => {binary, <<"me.me">>, []}},
+ ?assertEqual({error, {Prog, BinRegexOpt, "me", <<"does not match">>}},
+ parse_opts("me", [BinRegexOpt])),
+ %% invalid integer (comma , is not parsed)
+ ?assertEqual({error, {Prog, MinInt, "1,", <<"is not an integer">>}},
+ parse_opts(["1,"], [MinInt])),
+ %% test invalid choices
+ BinChoices = #{name => bin, type => {binary, [<<"M">>, <<"N">>]}},
+ ?assertEqual({error, {Prog, BinChoices, "K", <<"is not one of the choices">>}},
+ parse_opts("K", [BinChoices])),
+ StrChoices = #{name => str, type => {string, ["M", "N"]}},
+ ?assertEqual({error, {Prog, StrChoices, "K", <<"is not one of the choices">>}},
+ parse_opts("K", [StrChoices])),
+ AtomChoices = #{name => atom, type => {atom, [one, two]}},
+ ?assertEqual({error, {Prog, AtomChoices, "K", <<"is not one of the choices">>}},
+ parse_opts("K", [AtomChoices])),
+ IntChoices = #{name => int, type => {integer, [10, 11]}},
+ ?assertEqual({error, {Prog, IntChoices, "12", <<"is not one of the choices">>}},
+ parse_opts("12", [IntChoices])),
+ FloatChoices = #{name => float, type => {float, [1.2, 1.4]}},
+ ?assertEqual({error, {Prog, FloatChoices, "1.3", <<"is not one of the choices">>}},
+ parse_opts("1.3", [FloatChoices])),
+ %% unsuccessful user-defined conversion
+ ?assertMatch({error, {Prog, _, "REV", <<"failed faildation">>}},
+ parse_opts("REV", [#{name => user, type => {custom, fun (Str) -> integer_to_binary(Str) end}}])).
+
+complex_command() ->
+ [{doc, "Parses a complex command that has a mix of optional and positional arguments"}].
+
+complex_command(Config) when is_list(Config) ->
+ Command = #{arguments => [
+ %% options
+ #{name => string, short => $s, long => "-string", action => append, help => "String list option"},
+ #{name => boolean, type => boolean, short => $b, action => append, help => "Boolean list option"},
+ #{name => float, type => float, short => $f, long => "-float", action => append, help => "Float option"},
+ %% positional args
+ #{name => integer, type => integer, help => "Integer variable"},
+ #{name => string, help => "alias for string option", action => extend, nargs => list}
+ ]},
+ CmdMap = #{commands => #{"start" => Command}},
+ Parsed = argparse:parse(string:lexemes("start --float 1.04 -f 112 -b -b -s s1 42 --string s2 s3 s4", " "), CmdMap),
+ Expected = #{float => [1.04, 112], boolean => [true, true], integer => 42, string => ["s1", "s2", "s3", "s4"]},
+ ?assertEqual({ok, Expected, [prog(), "start"], Command}, Parsed).
+
+unicode() ->
+ [{doc, "Tests basic unicode support"}].
+
+unicode(Config) when is_list(Config) ->
+ %% test unicode short & long
+ ?assertMatch({ok, #{one := true}, _, _},
+ parse(["-Ф"], #{arguments => [#{name => one, short => $Ф, type => boolean}]})),
+ ?assertMatch({ok, #{long := true}, _, _},
+ parse(["--åäö"], #{arguments => [#{name => long, long => "-åäö", type => boolean}]})),
+ %% test default, help and value in unicode
+ Cmd = #{arguments => [#{name => text, type => binary, help => "åäö", default => <<"★"/utf8>>}]},
+ Expected = #{text => <<"★"/utf8>>},
+ Prog = [prog()],
+ ?assertEqual({ok, Expected, Prog, Cmd}, argparse:parse([], Cmd)), %% default
+ ?assertEqual({ok, Expected, Prog, Cmd}, argparse:parse(["★"], Cmd)), %% specified in the command line
+ ?assertEqual("Usage:\n " ++ prog() ++ " <text>\n\nArguments:\n text åäö (binary, ★)\n",
+ unicode:characters_to_list(argparse:help(Cmd))),
+ %% test command name and argument name in unicode
+ Uni = #{commands => #{"åäö" => #{help => "öФ"}}, handler => optional,
+ arguments => [#{name => "Ф", short => $ä, long => "åäö"}]},
+ UniExpected = "Usage:\n " ++ prog() ++
+ " {åäö} [-ä <Ф>] [-åäö <Ф>]\n\nSubcommands:\n åäö öФ\n\nOptional arguments:\n -ä, -åäö Ф\n",
+ ?assertEqual(UniExpected, unicode:characters_to_list(argparse:help(Uni))),
+ ParsedExpected = #{"Ф" => "öФ"},
+ ?assertEqual({ok, ParsedExpected, Prog, Uni}, argparse:parse(["-ä", "öФ"], Uni)).
+
+parser_error() ->
+ [{doc, "Tests error tuples that the parser returns"}].
+
+parser_error(Config) when is_list(Config) ->
+ Prog = prog(),
+ %% unknown option at the top of the path
+ ?assertEqual({error, {[Prog], undefined, "arg", <<>>}},
+ parse_cmd(["arg"], #{})),
+ %% positional argument missing in a sub-command
+ Opt = #{name => mode, required => true},
+ ?assertMatch({error, {[Prog, "start"], _, undefined, <<>>}},
+ parse_cmd(["start"], #{"start" => #{arguments => [Opt]}})),
+ %% optional argument missing in a sub-command
+ Opt1 = #{name => mode, short => $o, required => true},
+ ?assertMatch({error, {[Prog, "start"], _, undefined, <<>>}},
+ parse_cmd(["start"], #{"start" => #{arguments => [Opt1]}})),
+ %% positional argument: an atom that does not exist
+ Opt2 = #{name => atom, type => atom},
+ ?assertEqual({error, {[Prog], Opt2, "boo-foo", <<"is not an existing atom">>}},
+ parse_opts(["boo-foo"], [Opt2])),
+ %% optional argument missing some items
+ Opt3 = #{name => kernel, long => "kernel", type => atom, nargs => 2},
+ ?assertEqual({error, {[Prog], Opt3, ["port"], "expected 2, found 1 argument(s)"}},
+ parse_opts(["-kernel port"], [Opt3])),
+ %% positional argument missing some items
+ Opt4 = #{name => arg, type => atom, nargs => 3},
+ ?assertEqual({error, {[Prog], Opt4, ["p1"], "expected 3, found 1 argument(s)"}},
+ parse_opts(["p1"], [Opt4])),
+ %% short option with no argument, when it's needed
+ ?assertMatch({error, {_, _, undefined, <<"expected argument">>}},
+ parse("-1", #{arguments => [#{name => short49, short => 49}]})).
+
+nargs() ->
+ [{doc, "Tests argument consumption option, with nargs"}].
+
+nargs(Config) when is_list(Config) ->
+ Prog = [prog()],
+ %% consume optional list arguments
+ Opts = [
+ #{name => arg, short => $s, nargs => list, type => integer},
+ #{name => bool, short => $b, type => boolean}
+ ],
+ ?assertMatch({ok, #{arg := [1, 2, 3], bool := true}, _, _},
+ parse_opts(["-s 1 2 3 -b"], Opts)),
+ %% consume one_or_more arguments in an optional list
+ Opts2 = [
+ #{name => arg, short => $s, nargs => nonempty_list},
+ #{name => extra, short => $x}
+ ],
+ ?assertMatch({ok, #{extra := "X", arg := ["a","b","c"]}, _, _},
+ parse_opts(["-s port -s a b c -x X"], Opts2)),
+ %% error if there is no argument to consume
+ ?assertMatch({error, {_, _, ["-x"], <<"expected argument">>}},
+ parse_opts(["-s -x"], Opts2)),
+ %% error when positional has nargs = nonempty_list or pos_integer
+ ?assertMatch({error, {_, _, undefined, <<>>}},
+ parse_opts([""], [#{name => req, nargs => nonempty_list}])),
+ %% positional arguments consumption: one or more positional argument
+ OptsPos1 = #{arguments => [
+ #{name => arg, nargs => nonempty_list},
+ #{name => extra, short => $x}
+ ]},
+ ?assertEqual({ok, #{extra => "X", arg => ["b","c"]}, Prog, OptsPos1},
+ parse(["-x port -x a b c -x X"], OptsPos1)),
+ %% positional arguments consumption, any number (maybe zero)
+ OptsPos2 = #{arguments => [
+ #{name => arg, nargs => list},
+ #{name => extra, short => $x}
+ ]},
+ ?assertEqual({ok, #{extra => "X", arg => ["a","b","c"]}, Prog, OptsPos2},
+ parse(["-x port a b c -x X"], OptsPos2)),
+ %% positional: consume ALL arguments!
+ OptsAll = #{arguments => [
+ #{name => arg, nargs => all},
+ #{name => extra, short => $x}
+ ]},
+ ?assertEqual({ok, #{extra => "port", arg => ["a","b","c", "-x", "X"]}, Prog, OptsAll},
+ parse(["-x port a b c -x X"], OptsAll)),
+ %% maybe with a specified default
+ OptMaybe = [
+ #{name => foo, long => "-foo", nargs => {'maybe', c}, default => d},
+ #{name => bar, nargs => 'maybe', default => d}
+ ],
+ ?assertMatch({ok, #{foo := "YY", bar := "XX"}, Prog, _},
+ parse_opts(["XX --foo YY"], OptMaybe)),
+ ?assertMatch({ok, #{foo := c, bar := "XX"}, Prog, _},
+ parse_opts(["XX --foo"], OptMaybe)),
+ ?assertMatch({ok, #{foo := d, bar := d}, Prog, _},
+ parse_opts([""], OptMaybe)),
+ %% maybe with default provided by argparse
+ ?assertMatch({ok, #{foo := d, bar := "XX", baz := ok}, _, _},
+ parse_opts(["XX -b"], [#{name => baz, nargs => 'maybe', short => $b, default => ok} | OptMaybe])),
+ %% maybe arg - with no default given
+ ?assertMatch({ok, #{foo := d, bar := "XX", baz := 0}, _, _},
+ parse_opts(["XX -b"], [#{name => baz, nargs => 'maybe', short => $b, type => integer} | OptMaybe])),
+ ?assertMatch({ok, #{foo := d, bar := "XX", baz := ""}, _, _},
+ parse_opts(["XX -b"], [#{name => baz, nargs => 'maybe', short => $b, type => string} | OptMaybe])),
+ ?assertMatch({ok, #{foo := d, bar := "XX", baz := undefined}, _, _},
+ parse_opts(["XX -b"], [#{name => baz, nargs => 'maybe', short => $b, type => atom} | OptMaybe])),
+ ?assertMatch({ok, #{foo := d, bar := "XX", baz := <<"">>}, _, _},
+ parse_opts(["XX -b"], [#{name => baz, nargs => 'maybe', short => $b, type => binary} | OptMaybe])),
+ %% nargs: optional list, yet it still needs to be 'not required'!
+ OptList = [#{name => arg, nargs => list, required => false, type => integer}],
+ ?assertEqual({ok, #{}, Prog, #{arguments => OptList}}, parse_opts("", OptList)),
+ %% tests that action "count" with nargs "maybe" counts two times, first time
+ %% consuming an argument (for "maybe"), second time just counting
+ Cmd = #{arguments => [
+ #{name => short49, short => $1, long => "-force", action => count, nargs => 'maybe'}]},
+ ?assertEqual({ok, #{short49 => 2}, Prog, Cmd},
+ parse("-1 arg1 --force", Cmd)).
+
+argparse() ->
+ [{doc, "Tests success cases, inspired by argparse in Python"}].
+
+argparse(Config) when is_list(Config) ->
+ Prog = [prog()],
+ Parser = #{arguments => [
+ #{name => sum, long => "-sum", action => {store, sum}, default => max},
+ #{name => integers, type => integer, nargs => nonempty_list}
+ ]},
+ ?assertEqual({ok, #{integers => [1, 2, 3, 4], sum => max}, Prog, Parser},
+ parse("1 2 3 4", Parser)),
+ ?assertEqual({ok, #{integers => [1, 2, 3, 4], sum => sum}, Prog, Parser},
+ parse("1 2 3 4 --sum", Parser)),
+ ?assertEqual({ok, #{integers => [7, -1, 42], sum => sum}, Prog, Parser},
+ parse("--sum 7 -1 42", Parser)),
+ %% name or flags
+ Parser2 = #{arguments => [
+ #{name => bar, required => true},
+ #{name => foo, short => $f, long => "-foo"}
+ ]},
+ ?assertEqual({ok, #{bar => "BAR"}, Prog, Parser2}, parse("BAR", Parser2)),
+ ?assertEqual({ok, #{bar => "BAR", foo => "FOO"}, Prog, Parser2}, parse("BAR --foo FOO", Parser2)),
+ %PROG: error: the following arguments are required: bar
+ ?assertMatch({error, {Prog, _, undefined, <<>>}}, parse("--foo FOO", Parser2)),
+ %% action tests: default
+ ?assertMatch({ok, #{foo := "1"}, Prog, _},
+ parse("--foo 1", #{arguments => [#{name => foo, long => "-foo"}]})),
+ %% action test: store
+ ?assertMatch({ok, #{foo := 42}, Prog, _},
+ parse("--foo", #{arguments => [#{name => foo, long => "-foo", action => {store, 42}}]})),
+ %% action tests: boolean (variants)
+ ?assertMatch({ok, #{foo := true}, Prog, _},
+ parse("--foo", #{arguments => [#{name => foo, long => "-foo", action => {store, true}}]})),
+ ?assertMatch({ok, #{foo := 42}, Prog, _},
+ parse("--foo", #{arguments => [#{name => foo, long => "-foo", type => boolean, action => {store, 42}}]})),
+ ?assertMatch({ok, #{foo := true}, Prog, _},
+ parse("--foo", #{arguments => [#{name => foo, long => "-foo", type => boolean}]})),
+ ?assertMatch({ok, #{foo := true}, Prog, _},
+ parse("--foo true", #{arguments => [#{name => foo, long => "-foo", type => boolean}]})),
+ ?assertMatch({ok, #{foo := false}, Prog, _},
+ parse("--foo false", #{arguments => [#{name => foo, long => "-foo", type => boolean}]})),
+ %% action tests: append & append_const
+ ?assertMatch({ok, #{all := [1, "1"]}, Prog, _},
+ parse("--x 1 -x 1", #{arguments => [
+ #{name => all, long => "-x", type => integer, action => append},
+ #{name => all, short => $x, action => append}]})),
+ ?assertMatch({ok, #{all := ["Z", 2]}, Prog, _},
+ parse("--x -x", #{arguments => [
+ #{name => all, long => "-x", action => {append, "Z"}},
+ #{name => all, short => $x, action => {append, 2}}]})),
+ %% count:
+ ?assertMatch({ok, #{v := 3}, Prog, _},
+ parse("-v -v -v", #{arguments => [#{name => v, short => $v, action => count}]})).
+
+negative() ->
+ [{doc, "Test negative number parser"}].
+
+negative(Config) when is_list(Config) ->
+ Parser = #{arguments => [
+ #{name => x, short => $x, type => integer, action => store},
+ #{name => foo, nargs => 'maybe', required => false}
+ ]},
+ ?assertMatch({ok, #{x := -1}, _, _}, parse("-x -1", Parser)),
+ ?assertMatch({ok, #{x := -1, foo := "-5"}, _, _}, parse("-x -1 -5", Parser)),
+ %%
+ Parser2 = #{arguments => [
+ #{name => one, short => $1},
+ #{name => foo, nargs => 'maybe', required => false}
+ ]},
+
+ %% negative number options present, so -1 is an option
+ ?assertMatch({ok, #{one := "X"}, _, _}, parse("-1 X", Parser2)),
+ %% negative number options present, so -2 is an option
+ ?assertMatch({error, {_, undefined, "-2", _}}, parse("-2", Parser2)),
+
+ %% negative number options present, so both -1s are options
+ ?assertMatch({error, {_, _, undefined, _}}, parse("-1 -1", Parser2)),
+ %% no "-" prefix, can only be an integer
+ ?assertMatch({ok, #{foo := "-1"}, _, _}, argparse:parse(["-1"], Parser2, #{prefixes => "+"})),
+ %% no "-" prefix, can only be an integer, but just one integer!
+ ?assertMatch({error, {_, undefined, "-1", _}},
+ argparse:parse(["-2", "-1"], Parser2, #{prefixes => "+"})),
+ %% just in case, floats work that way too...
+ ?assertMatch({error, {_, undefined, "-2", _}},
+ parse("-2", #{arguments => [#{name => one, long => "1.2"}]})).
+
+nodigits() ->
+ [{doc, "Test prefixes and negative numbers together"}].
+
+nodigits(Config) when is_list(Config) ->
+ %% verify nodigits working as expected
+ Parser3 = #{arguments => [
+ #{name => extra, short => $3},
+ #{name => arg, nargs => list}
+ ]},
+ %% ensure not to consume optional prefix
+ ?assertEqual({ok, #{extra => "X", arg => ["a","b","3"]}, [prog()], Parser3},
+ argparse:parse(string:lexemes("-3 port a b 3 +3 X", " "), Parser3, #{prefixes => "-+"})).
+
+pos_mixed_with_opt() ->
+ [{doc, "Tests that optional argument correctly consumes expected argument"
+ "inspired by https://github.com/python/cpython/issues/59317"}].
+
+pos_mixed_with_opt(Config) when is_list(Config) ->
+ Parser = #{arguments => [
+ #{name => pos},
+ #{name => opt, default => 24, type => integer, long => "-opt"},
+ #{name => vars, nargs => list}
+ ]},
+ ?assertEqual({ok, #{pos => "1", opt => 8, vars => ["8", "9"]}, [prog()], Parser},
+ parse("1 2 --opt 8 8 9", Parser)).
+
+default_for_not_required() ->
+ [{doc, "Tests that default value is used for non-required positional argument"}].
+
+default_for_not_required(Config) when is_list(Config) ->
+ ?assertMatch({ok, #{def := 1}, _, _},
+ parse("", #{arguments => [#{name => def, short => $d, required => false, default => 1}]})),
+ ?assertMatch({ok, #{def := 1}, _, _},
+ parse("", #{arguments => [#{name => def, required => false, default => 1}]})).
+
+global_default() ->
+ [{doc, "Tests that a global default can be enabled for all non-required arguments"}].
+
+global_default(Config) when is_list(Config) ->
+ ?assertMatch({ok, #{def := "global"}, _, _},
+ argparse:parse("", #{arguments => [#{name => def, type => integer, required => false}]},
+ #{default => "global"})).
+
+subcommand() ->
+ [{doc, "Tests subcommands parser"}].
+
+subcommand(Config) when is_list(Config) ->
+ TwoCmd = #{arguments => [#{name => bar}]},
+ Cmd = #{
+ arguments => [#{name => force, type => boolean, short => $f}],
+ commands => #{"one" => #{
+ arguments => [#{name => foo, type => boolean, long => "-foo"}, #{name => baz}],
+ commands => #{
+ "two" => TwoCmd}}}},
+ ?assertEqual({ok, #{force => true, baz => "N1O1O", foo => true, bar => "bar"}, [prog(), "one", "two"], TwoCmd},
+ parse("one N1O1O -f two --foo bar", Cmd)),
+ %% it is an error not to choose subcommand
+ ?assertEqual({error, {[prog(), "one"], undefined, undefined, <<"subcommand expected">>}},
+ parse("one N1O1O -f", Cmd)).
+
+very_short() ->
+ [{doc, "Tests short option appended to the optional itself"}].
+
+very_short(Config) when is_list(Config) ->
+ ?assertMatch({ok, #{x := "V"}, _, _},
+ parse("-xV", #{arguments => [#{name => x, short => $x}]})).
+
+multi_short() ->
+ [{doc, "Tests multiple short arguments blend into one"}].
+
+multi_short(Config) when is_list(Config) ->
+ %% ensure non-flammable argument does not explode, even when it's possible
+ ?assertMatch({ok, #{v := "xv"}, _, _},
+ parse("-vxv", #{arguments => [#{name => v, short => $v}, #{name => x, short => $x}]})),
+ %% ensure 'verbosity' use-case works
+ ?assertMatch({ok, #{v := 3}, _, _},
+ parse("-vvv", #{arguments => [#{name => v, short => $v, action => count}]})),
+ %%
+ ?assertMatch({ok, #{recursive := true, force := true, path := "dir"}, _, _},
+ parse("-rf dir", #{arguments => [
+ #{name => recursive, short => $r, type => boolean},
+ #{name => force, short => $f, type => boolean},
+ #{name => path}
+ ]})).
+
+proxy_arguments() ->
+ [{doc, "Tests nargs => all used to proxy arguments to another script"}].
+
+proxy_arguments(Config) when is_list(Config) ->
+ Cmd = #{
+ commands => #{
+ "start" => #{
+ arguments => [
+ #{name => shell, short => $s, long => "-shell", type => boolean},
+ #{name => skip, short => $x, long => "-skip", type => boolean},
+ #{name => args, required => false, nargs => all}
+ ]
+ },
+ "stop" => #{},
+ "status" => #{
+ arguments => [
+ #{name => skip, required => false, default => "ok"},
+ #{name => args, required => false, nargs => all}
+ ]},
+ "state" => #{
+ arguments => [
+ #{name => skip, required => false},
+ #{name => args, required => false, nargs => all}
+ ]}
+ },
+ arguments => [
+ #{name => node}
+ ],
+ handler => fun (#{}) -> ok end
+ },
+ Prog = prog(),
+ ?assertMatch({ok, #{node := "node1"}, _, _}, parse("node1", Cmd)),
+ ?assertMatch({ok, #{node := "node1"}, [Prog, "stop"], #{}}, parse("node1 stop", Cmd)),
+ ?assertMatch({ok, #{node := "node2.org", shell := true, skip := true}, _, _}, parse("node2.org start -x -s", Cmd)),
+ ?assertMatch({ok, #{args := ["-app","key","value"],node := "node1.org"}, [Prog, "start"], _},
+ parse("node1.org start -app key value", Cmd)),
+ ?assertMatch({ok, #{args := ["-app","key","value", "-app2", "key2", "value2"],
+ node := "node3.org", shell := true}, [Prog, "start"], _},
+ parse("node3.org start -s -app key value -app2 key2 value2", Cmd)),
+ %% test that any non-required positionals are skipped
+ ?assertMatch({ok, #{args := ["-a","bcd"], node := "node2.org", skip := "ok"}, _, _}, parse("node2.org status -a bcd", Cmd)),
+ ?assertMatch({ok, #{args := ["-app", "key"], node := "node2.org"}, _, _}, parse("node2.org state -app key", Cmd)).
+
+%%--------------------------------------------------------------------
+%% Usage Test Cases
+
+usage() ->
+ [{doc, "Basic tests for help formatter, including 'hidden' help"}].
+
+usage(Config) when is_list(Config) ->
+ Cmd = ubiq_cmd(),
+ Usage = "Usage:\n erl start {crawler|doze} [-lrfv] [-s <shard>...] [-z <z>] [-m <more>] [-b <bin>]\n"
+ " [-g <g>] [-t <t>] ---maybe-req -y <y> --yyy <y> [-u <u>] [-c <choice>]\n"
+ " [-q <fc>] [-w <ac>] [--unsafe <au>] [--safe <as>] [-foobar <long>] [--force]\n"
+ " [-i <interval>] [--req <weird>] [--float <float>] <server> [<optpos>]\n\n"
+ "Subcommands:\n"
+ " crawler controls crawler behaviour\n"
+ " doze dozes a bit\n\n"
+ "Arguments:\n"
+ " server server to start\n"
+ " optpos optional positional (int)\n\n"
+ "Optional arguments:\n"
+ " -s initial shards (int)\n"
+ " -z between (1 <= int <= 10)\n"
+ " -l maybe lower (int <= 10)\n"
+ " -m less than 10 (int <= 10)\n"
+ " -b binary with re (binary re: m)\n"
+ " -g binary with re (binary re: m)\n"
+ " -t string with re (string re: m)\n"
+ " ---maybe-req maybe required int (int)\n"
+ " -y, --yyy string with re (string re: m)\n"
+ " -u string choices (choice: 1, 2)\n"
+ " -c tough choice (choice: 1, 2, 3)\n"
+ " -q floating choice (choice: 2.10000, 1.20000)\n"
+ " -w atom choice (choice: one, two)\n"
+ " --unsafe unsafe atom (atom)\n"
+ " --safe safe atom (existing atom)\n"
+ " -foobar foobaring option\n"
+ " -r recursive\n"
+ " -f, --force force\n"
+ " -v verbosity level\n"
+ " -i interval set (int >= 1)\n"
+ " --req required optional, right?\n"
+ " --float floating-point long form argument (float, 3.14)\n",
+ ?assertEqual(Usage, unicode:characters_to_list(argparse:help(Cmd,
+ #{progname => "erl", command => ["start"]}))),
+ FullCmd = "Usage:\n erl"
+ " <command> [-rfv] [--force] [-i <interval>] [--req <weird>] [--float <float>]\n\n"
+ "Subcommands:\n"
+ " start verifies configuration and starts server\n"
+ " status prints server status\n"
+ " stop stops running server\n\n"
+ "Optional arguments:\n"
+ " -r recursive\n"
+ " -f, --force force\n"
+ " -v verbosity level\n"
+ " -i interval set (int >= 1)\n"
+ " --req required optional, right?\n"
+ " --float floating-point long form argument (float, 3.14)\n",
+ ?assertEqual(FullCmd, unicode:characters_to_list(argparse:help(Cmd,
+ #{progname => erl}))),
+ CrawlerStatus = "Usage:\n erl status crawler [-rfv] [---extra <extra>] [--force] [-i <interval>]\n"
+ " [--req <weird>] [--float <float>]\n\nOptional arguments:\n"
+ " ---extra extra option very deep\n -r recursive\n"
+ " -f, --force force\n -v verbosity level\n"
+ " -i interval set (int >= 1)\n"
+ " --req required optional, right?\n"
+ " --float floating-point long form argument (float, 3.14)\n",
+ ?assertEqual(CrawlerStatus, unicode:characters_to_list(argparse:help(Cmd,
+ #{progname => "erl", command => ["status", "crawler"]}))),
+ ok.
+
+usage_required_args() ->
+ [{doc, "Verify that required args are printed as required in usage"}].
+
+usage_required_args(Config) when is_list(Config) ->
+ Cmd = #{commands => #{"test" => #{arguments => [#{name => required, required => true, long => "-req"}]}}},
+ Expected = "Usage:\n " ++ prog() ++ " test --req <required>\n\nOptional arguments:\n --req required\n",
+ ?assertEqual(Expected, unicode:characters_to_list(argparse:help(Cmd, #{command => ["test"]}))).
+
+usage_template() ->
+ [{doc, "Tests templates in help/usage"}].
+
+usage_template(Config) when is_list(Config) ->
+ %% Argument (positional)
+ Cmd = #{arguments => [#{
+ name => shard,
+ type => integer,
+ default => 0,
+ help => {"[-s SHARD]", ["initial number, ", type, <<" with a default value of ">>, default]}}
+ ]},
+ ?assertEqual("Usage:\n " ++ prog() ++ " [-s SHARD]\n\nArguments:\n shard initial number, int with a default value of 0\n",
+ unicode:characters_to_list(argparse:help(Cmd, #{}))),
+ %% Optional
+ Cmd1 = #{arguments => [#{
+ name => shard,
+ short => $s,
+ type => integer,
+ default => 0,
+ help => {<<"[-s SHARD]">>, ["initial number"]}}
+ ]},
+ ?assertEqual("Usage:\n " ++ prog() ++ " [-s SHARD]\n\nOptional arguments:\n -s initial number\n",
+ unicode:characters_to_list(argparse:help(Cmd1, #{}))),
+ %% ISO Date example
+ DefaultRange = {{2020, 1, 1}, {2020, 6, 22}},
+ CmdISO = #{
+ arguments => [
+ #{
+ name => range,
+ long => "-range",
+ short => $r,
+ help => {"[--range RNG]", fun() ->
+ {{FY, FM, FD}, {TY, TM, TD}} = DefaultRange,
+ lists:flatten(io_lib:format("date range, ~b-~b-~b..~b-~b-~b", [FY, FM, FD, TY, TM, TD]))
+ end},
+ type => {custom, fun(S) -> [S, DefaultRange] end},
+ default => DefaultRange
+ }
+ ]
+ },
+ ?assertEqual("Usage:\n " ++ prog() ++ " [--range RNG]\n\nOptional arguments:\n -r, --range date range, 2020-1-1..2020-6-22\n",
+ unicode:characters_to_list(argparse:help(CmdISO, #{}))),
+ ok.
+
+parser_error_usage() ->
+ [{doc, "Tests that parser errors have corresponding usage text"}].
+
+parser_error_usage(Config) when is_list(Config) ->
+ %% unknown arguments
+ Prog = prog(),
+ ?assertEqual(Prog ++ ": unknown argument: arg", parser_error(["arg"], #{})),
+ ?assertEqual(Prog ++ ": unknown argument: -a", parser_error(["-a"], #{})),
+ %% missing argument
+ ?assertEqual(Prog ++ ": required argument missing: need", parser_error([""],
+ #{arguments => [#{name => need}]})),
+ ?assertEqual(Prog ++ ": required argument missing: need", parser_error([""],
+ #{arguments => [#{name => need, short => $n, required => true}]})),
+ %% invalid value
+ ?assertEqual(Prog ++ ": invalid argument for need: foo is not an integer", parser_error(["foo"],
+ #{arguments => [#{name => need, type => integer}]})),
+ ?assertEqual(Prog ++ ": invalid argument for need: cAnNotExIsT is not an existing atom", parser_error(["cAnNotExIsT"],
+ #{arguments => [#{name => need, type => atom}]})).
+
+command_usage() ->
+ [{doc, "Test command help template"}].
+
+command_usage(Config) when is_list(Config) ->
+ Cmd = #{arguments => [
+ #{name => arg, help => "argument help"}, #{name => opt, short => $o, help => "option help"}],
+ help => ["Options:\n", options, arguments, <<"NOTAUSAGE">>, usage, "\n"]
+ },
+ ?assertEqual("Options:\n -o option help\n arg argument help\nNOTAUSAGE " ++ prog() ++ " [-o <opt>] <arg>\n",
+ unicode:characters_to_list(argparse:help(Cmd, #{}))).
+
+usage_width() ->
+ [{doc, "Test usage fitting in the viewport"}].
+
+usage_width(Config) when is_list(Config) ->
+ Cmd = #{arguments => [
+ #{name => arg, help => "argument help that spans way over allowed viewport width, wrapping words"},
+ #{name => opt, short => $o, long => "-option_long_name",
+ help => "another quite long word wrapped thing spanning over several lines"},
+ #{name => v, short => $v, type => boolean},
+ #{name => q, short => $q, type => boolean}],
+ commands => #{
+ "cmd1" => #{help => "Help for command number 1, not fitting at all"},
+ "cmd2" => #{help => <<"Short help">>},
+ "cmd3" => #{help => "Yet another instance of a very long help message"}
+ },
+ help => " Very long help line taking much more than 40 characters allowed by the test case.
+Also containing a few newlines.
+
+ Indented new lines must be honoured!"
+ },
+
+ Expected = "Usage:\n erl {cmd1|cmd2|cmd3} [-vq] [-o <opt>]\n"
+ " [--option_long_name <opt>] <arg>\n\n"
+ " Very long help line taking much more\n"
+ "than 40 characters allowed by the test\n"
+ "case.\n"
+ "Also containing a few newlines.\n\n"
+ " Indented new lines must be honoured!\n\n"
+ "Subcommands:\n"
+ " cmd1 Help for\n"
+ " command number\n"
+ " 1, not fitting\n"
+ " at all\n"
+ " cmd2 Short help\n"
+ " cmd3 Yet another\n"
+ " instance of a\n"
+ " very long help\n"
+ " message\n\n"
+ "Arguments:\n"
+ " arg argument help\n"
+ " that spans way\n"
+ " over allowed\n"
+ " viewport width,\n"
+ " wrapping words\n\n"
+ "Optional arguments:\n"
+ " -o, --option_long_name another quite\n"
+ " long word\n"
+ " wrapped thing\n"
+ " spanning over\n"
+ " several lines\n"
+ " -v v\n"
+ " -q q\n",
+
+ ?assertEqual(Expected, unicode:characters_to_list(argparse:help(Cmd, #{columns => 40, progname => "erl"}))).
+
+%%--------------------------------------------------------------------
+%% Validator Test Cases
+
+validator_exception() ->
+ [{doc, "Tests that the validator throws expected exceptions"}].
+
+validator_exception(Config) when is_list(Config) ->
+ Prg = [prog()],
+ %% conflicting option names
+ ?assertException(error, {argparse, argument, Prg, short, "short conflicting with previously defined short for one"},
+ argparse:validate(#{arguments => [#{name => one, short => $$}, #{name => two, short => $$}]})),
+ ?assertException(error, {argparse, argument, Prg, long, "long conflicting with previously defined long for one"},
+ argparse:validate(#{arguments => [#{name => one, long => "a"}, #{name => two, long => "a"}]})),
+ %% broken options
+ %% long must be a string
+ ?assertException(error, {argparse, argument, Prg, long, _},
+ argparse:validate(#{arguments => [#{name => one, long => ok}]})),
+ %% short must be a printable character
+ ?assertException(error, {argparse, argument, Prg, short, _},
+ argparse:validate(#{arguments => [#{name => one, short => ok}]})),
+ ?assertException(error, {argparse, argument, Prg, short, _},
+ argparse:validate(#{arguments => [#{name => one, short => 7}]})),
+ %% required is a boolean
+ ?assertException(error, {argparse, argument, Prg, required, _},
+ argparse:validate(#{arguments => [#{name => one, required => ok}]})),
+ ?assertException(error, {argparse, argument, Prg, help, _},
+ argparse:validate(#{arguments => [#{name => one, help => ok}]})),
+ %% broken commands
+ try argparse:help(#{}, #{progname => 123}), ?assert(false)
+ catch error:badarg:Stack ->
+ [{_, _, _, Ext} | _] = Stack,
+ #{cause := #{2 := Detail}} = proplists:get_value(error_info, Ext),
+ ?assertEqual(<<"progname is not valid">>, Detail)
+ end,
+ %% not-a-list of arguments provided to a subcommand
+ Prog = prog(),
+ ?assertException(error, {argparse, command, [Prog, "start"], arguments, <<"expected a list, [argument()]">>},
+ argparse:validate(#{commands => #{"start" => #{arguments => atom}}})),
+ %% command is not a map
+ ?assertException(error, {argparse, command, Prg, commands, <<"expected map of #{string() => command()}">>},
+ argparse:validate(#{commands => []})),
+ %% invalid commands field
+ ?assertException(error, {argparse, command, Prg, commands, _},
+ argparse:validate(#{commands => ok})),
+ ?assertException(error, {argparse, command, _, commands, _},
+ argparse:validate(#{commands => #{ok => #{}}})),
+ ?assertException(error, {argparse, command, _, help,
+ <<"must be a printable unicode list, or a command help template">>},
+ argparse:validate(#{commands => #{"ok" => #{help => ok}}})),
+ ?assertException(error, {argparse, command, _, handler, _},
+ argparse:validate(#{commands => #{"ok" => #{handler => fun validator_exception/0}}})),
+ %% extend + maybe: validator exception
+ ?assertException(error, {argparse, argument, _, action, <<"extend action works only with lists">>},
+ parse("-1 -1", #{arguments =>
+ [#{action => extend, name => short49, nargs => 'maybe', short => 49}]})).
+
+validator_exception_format() ->
+ [{doc, "Tests human-readable (EEP-54) format for exceptions thrown by the validator"}].
+
+validator_exception_format(Config) when is_list(Config) ->
+ %% set up as a contract: test that EEP-54 transformation is done (but don't check strings)
+ try
+ argparse:validate(#{commands => #{"one" => #{commands => #{"two" => atom}}}}),
+ ?assert(false)
+ catch
+ error:R1:S1 ->
+ #{1 := Cmd, reason := RR1, general := G} = argparse:format_error(R1, S1),
+ ?assertEqual("command specification is invalid", unicode:characters_to_list(G)),
+ ?assertEqual("command \"" ++ prog() ++ " one two\": invalid field 'commands', reason: expected command()",
+ unicode:characters_to_list(RR1)),
+ ?assertEqual(["atom"], Cmd)
+ end,
+ %% check argument
+ try
+ argparse:validate(#{arguments => [#{}]}),
+ ?assert(false)
+ catch
+ error:R2:S2 ->
+ #{1 := Cmd2, reason := RR2, general := G2} = argparse:format_error(R2, S2),
+ ?assertEqual("argument specification is invalid", unicode:characters_to_list(G2)),
+ ?assertEqual("command \"" ++ prog() ++
+ "\", argument '', invalid field 'name': argument must be a map containing 'name' field",
+ unicode:characters_to_list(RR2)),
+ ?assertEqual(["#{}"], Cmd2)
+ end.
+
+%%--------------------------------------------------------------------
+%% Validator Test Cases
+
+run_handle() ->
+ [{doc, "Very basic tests for argparse:run/3, choice of handlers formats"}].
+
+%% fun((arg_map()) -> term()) | %% handler accepting arg_map
+%% {module(), Fn :: atom()} | %% handler, accepting arg_map, Fn exported from module()
+%% {fun(() -> term()), term()} | %% handler, positional form (term() is supplied for omitted args)
+%% {module(), atom(), term()}
+
+run_handle(Config) when is_list(Config) ->
+ %% no subcommand, basic fun handler with argmap
+ ?assertEqual(6,
+ argparse:run(["-i", "3"], #{handler => fun (#{in := Val}) -> Val * 2 end,
+ arguments => [#{name => in, short => $i, type => integer}]}, #{})),
+ %% subcommand, positional fun() handler
+ ?assertEqual(6,
+ argparse:run(["mul", "2", "3"], #{commands => #{"mul" => #{
+ handler => {fun (match, L, R) -> L * R end, match},
+ arguments => [#{name => opt, short => $o},
+ #{name => l, type => integer}, #{name => r, type => integer}]}}},
+ #{})),
+ %% no subcommand, positional module-based function
+ ?assertEqual(6,
+ argparse:run(["2", "3"], #{handler => {erlang, '*', undefined},
+ arguments => [#{name => l, type => integer}, #{name => r, type => integer}]},
+ #{})),
+ %% subcommand, module-based function accepting argmap
+ ?assertEqual([{arg, "arg"}],
+ argparse:run(["map", "arg"], #{commands => #{"map" => #{
+ handler => {maps, to_list},
+ arguments => [#{name => arg}]}}},
+ #{})). \ No newline at end of file