├── .github
└── workflows
│ └── erlang.yml
├── .gitignore
├── .gitlab-ci.yml
├── CHANGELOG.md
├── LICENSE
├── README.md
├── doc
├── ARGPARSE_REF.md
├── CLI_REF.md
├── examples
│ ├── conf_reader
│ │ ├── rebar.config
│ │ └── src
│ │ │ ├── conf_reader.app.src
│ │ │ └── conf_reader.erl
│ ├── escript
│ │ ├── calc
│ │ ├── erm
│ │ ├── mixed
│ │ └── simple
│ └── multi
│ │ ├── rebar.config
│ │ └── src
│ │ ├── multi.app.src
│ │ ├── multi.erl
│ │ ├── multi_math.erl
│ │ └── multi_string.erl
└── overview.edoc
├── rebar.config
├── rebar.lock
├── src
├── argparse.app.src
├── args.erl
└── cli.erl
└── test
├── args_SUITE.erl
├── cli_SUITE.erl
└── cli_SUITE_data
└── simple
/.github/workflows/erlang.yml:
--------------------------------------------------------------------------------
1 | name: Build, Test, Dialyze
2 |
3 | on:
4 | pull_request:
5 | types: [ opened, reopened, synchronize ]
6 | push:
7 | branches:
8 | - 'master'
9 | jobs:
10 | linux:
11 | name: Test on OTP ${{ matrix.otp_version }} and ${{ matrix.os }}
12 | runs-on: ${{ matrix.os }}
13 |
14 | strategy:
15 | matrix:
16 | otp_version: [21.2, 22, 23, 24, 25, 26]
17 | os: [ubuntu-latest]
18 |
19 | container:
20 | image: erlang:${{ matrix.otp_version }}
21 |
22 | steps:
23 | - uses: actions/checkout@v2
24 | - name: Compile
25 | run: rebar3 compile
26 | - name: CT tests
27 | run: rebar3 ct
28 | - name: Documentation
29 | run: rebar3 edoc
30 | #- name: ExDoc Documentation
31 | # run: if [ $(rebar3 version | awk '{print $5}') -gt 23 ]; then rebar3 ex_doc; fi;
32 | - shell: bash
33 | name: Dialyzer
34 | run: rebar3 dialyzer
35 |
36 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | _build
2 | .idea
3 | *.iml
4 | rebar3.crashdump
5 | *~
6 | /doc/*.html
7 | /doc/edoc-info
8 | /doc/erlang.png
9 | /doc/stylesheet.css
10 | /doc/.build
11 | /doc/*.epub
12 | /doc/dist
13 | /site/*
--------------------------------------------------------------------------------
/.gitlab-ci.yml:
--------------------------------------------------------------------------------
1 | stages:
2 | - test
3 | - deploy
4 |
5 | test-default-docker:
6 | tags:
7 | - linux
8 | image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/erlang:latest
9 | stage: test
10 | script:
11 | - rebar3 compile
12 | - rebar3 edoc
13 | - rebar3 dialyzer
14 | - rebar3 ct
15 | artifacts:
16 | when: always
17 | paths:
18 | - "_build/test/logs/**"
19 | expire_in: 3 days
20 | reports:
21 | junit:
22 | - _build/test/logs/last/junit_report.xml
23 |
24 | # Pages: publishing Common Test results
25 | pages:
26 | stage: deploy
27 | needs:
28 | - test-default-docker
29 | script:
30 | - mv _build/test/logs ./public
31 | artifacts:
32 | paths:
33 | - public
34 | rules:
35 | - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ### Version 2.0.0
4 | * renamed `argparse` module to `args`, to avoid name clash with OTP 26
5 |
6 | ### Version 1.2.4:
7 | * minor spec fixes
8 |
9 | ### Version 1.2.3:
10 | * implemented global default
11 | * minor bugfixes
12 |
13 | ### Version 1.2.1:
14 | * minor bugfixes, support for choices of atoms
15 |
16 | ### Version 1.2.0:
17 | * CLI incompatible change: `cli:run/1,2` by default now calls halt(1) in case of a parser error
18 | * bugfixes
19 |
20 | ### Version 1.1.3:
21 | * added `help => hidden` for commands and options
22 | * changed default formatting for better readability
23 | * fixed bug causing `nargs => all` to be ignored
24 |
25 | ### Version 1.1.2:
26 | * support for "--arg=value" form
27 |
28 | ### Version 1.1.1:
29 | * added templates for help text
30 |
31 | ### Version 1.1.0:
32 | * Handler support for minimal CLI
33 | * cli/1 optional behaviour callback deprecated
34 | * Ability to provide progname as an atom
35 |
36 | ### Version 1.0.2:
37 | * Improved documentation
38 | * Validation run for CLI commands
39 |
40 | ### Version 1.0.0:
41 | * First public release
42 |
43 | Version 0.1.0:
44 | * First usable version (API is unstable yet)
45 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | BSD 3-Clause License
2 |
3 | Copyright (c) 2020-2023, Maxim Fedorov
4 | All rights reserved.
5 |
6 | Redistribution and use in source and binary forms, with or without
7 | modification, are permitted provided that the following conditions are met:
8 |
9 | 1. Redistributions of source code must retain the above copyright notice, this
10 | list of conditions and the following disclaimer.
11 |
12 | 2. Redistributions in binary form must reproduce the above copyright notice,
13 | this list of conditions and the following disclaimer in the documentation
14 | and/or other materials provided with the distribution.
15 |
16 | 3. Neither the name of the copyright holder nor the names of its
17 | contributors may be used to endorse or promote products derived from
18 | this software without specific prior written permission.
19 |
20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # argparse: command line parser for Erlang
2 |
3 | [](https://github.com/max-au/argparse/actions) [](https://hex.pm/packages/argparse) [](https://hexdocs.pm/argparse)
4 |
5 | A mini-framework to create complex cli. Inspired by Python argparse.
6 |
7 | > **Warning**
8 | > This project is no longer maintained, because argparse is now a part of
9 | > Erlang/OTP: https://www.erlang.org/doc/man/argparse.html (starting with OTP 26).
10 | >
11 | > To accommodate with this change, `argparse` module has been renamed to `args`
12 | > in version 2.0.0. Applications that need `argparse` for earlier OTP versions
13 | > can use this library until the lowest supported version is OTP 26, and then
14 | > just move forward to `argparse` provided by OTP.
15 | >
16 | > There are minor differences in the command line options description between
17 | > `args` and OTP `argparse`:
18 | > * `int` type is renamed `integer`
19 | > * exceptions are accompanied by extended error information
20 |
21 | Follows conventions of Unix Utility Argument Syntax.
22 |
23 | ```shell
24 | argparse [-abcDxyz][-p arg][operand]
25 | ```
26 |
27 | ## Argument parser
28 | Converts list of strings (command line) into an argument map,and a
29 | command path; see [argparse reference](doc/ARGPARSE_REF.md) for detailed description.
30 |
31 | ## cli mini-framework
32 | Make a step beyond parser, and export existing Erlang functions: [cli reference](doc/CLI_REF.md)
33 |
34 |
35 | ## Basic [example](doc/examples/escript/simple)
36 |
37 | cli is naturally suitable for building small escript-based apps:
38 |
39 | ```erlang
40 | #!/usr/bin/env escript
41 |
42 | -export([main/1, cli/0, rm/1]).
43 | -behaviour(cli).
44 | -mode(compile). %% evaluated module cannot contain callbacks
45 |
46 | main(Args) ->
47 | cli:run(Args, #{progname => "simple"}).
48 |
49 | cli() ->
50 | #{
51 | handler => {?MODULE, rm},
52 | arguments => [
53 | #{name => force, short => $f, type => boolean, default => false},
54 | #{name => recursive, short => $r, type => boolean, default => false},
55 | #{name => dir}
56 | ]}.
57 |
58 | rm(#{force := Force, recursive := Recursive, dir := Dir}) ->
59 | io:format("Removing ~s (force ~s, recursive: ~s)~n",
60 | [Dir, Force, Recursive]).
61 | ```
62 |
63 | The example above does not have sub-commands, and implements `rm/1`
64 | handler, that serves as an entry point with parsed arguments. Help options are
65 | added automatically:
66 |
67 | ```shell
68 | $ ./erm --help
69 | usage: erm [-fr]
70 |
71 | Optional arguments:
72 | dir
73 | -r recursive, [false]
74 | -f force, [false]
75 | ```
76 |
77 | ## Calc: cli with [multiple commands](doc/examples/escript/calc)
78 |
79 | Calculator implements several commands, with sub-commands available. Full
80 | source code here: [doc/examples/escript/calc](doc/examples/escript/calc)
81 |
82 | Command definitions:
83 |
84 | ```erlang
85 | cli() ->
86 | #{
87 | commands => #{
88 | "sum" => #{
89 | arguments => [
90 | #{name => num, nargs => nonempty_list, type => int, help => "Numbers to sum"}
91 | ]
92 | },
93 | "math" => #{
94 | commands => #{
95 | "sin" => #{handler => {math, sin, undefined}},
96 | "cos" => #{},
97 | "tan" => #{handler => {math, tan, undefined}}
98 | },
99 | arguments => [
100 | #{name => in, type => float, help => "Input value"}
101 | ]
102 | },
103 | "mul" => #{
104 | arguments => [
105 | #{name => left, type => int},
106 | #{name => right, type => int}
107 | ]
108 | }
109 | }
110 | }.
111 | ```
112 |
113 | The calculator provides "sum" command that prints a sum of integer numbers:
114 |
115 | ```shell
116 | $ ./calc sum 1 2 3
117 | 6
118 | ```
119 |
120 | Math sub-commands provide trigonometric functions:
121 |
122 | ```shell
123 | $ ./calc math cos 1.4
124 | 0.16996714290024104
125 | $ ./calc math sin 1.4
126 | 0.9854497299884601
127 | ```
128 |
129 | ## Complex applications
130 |
131 | cli is capable of handling releases containing hundreds of modules
132 | implementing cli behaviour. Commands may be exported from multiple modules and
133 | applications. cli makes best efforts to merge commands exported,
134 | format usage output and error messages.
135 |
136 | See example: [doc/examples/multi](https://github.com/max-au/argparse/tree/master/doc/examples/multi)
137 |
138 | This example contains two modules, multi_math.erl and multi_string.erl.
139 |
140 | Use ```rebar3 escriptize``` to build the application. Try various commands,
141 | e.g. ```./multi math cos 1.0```, or ```./multi string lexemes 1+2+3+4 -s +```
142 | to get a feeling!
143 |
144 | ## Argument [parser alone](doc/examples/escript/erm)
145 |
146 | It is possible to use argument parser alone, without the cli mini-framework:
147 |
148 | ```erlang
149 | #!/usr/bin/env escript
150 |
151 | main(Args) ->
152 | #{force := Force, recursive := Recursive, dir := Dir} =
153 | args:parse(Args, cli()),
154 | io:format("Removing ~s (force: ~s, recursive: ~s)~n",
155 | [Dir, Force, Recursive]).
156 |
157 | cli() ->
158 | #{arguments => [
159 | #{name => force, short => $f, type => boolean, default => false},
160 | #{name => recursive, short => $r, type => boolean, default => false},
161 | #{name => dir}
162 | ]}.
163 | ```
164 |
165 | ## Help and usage information
166 | cli automatically prints usage, if command line parser reports an
167 | error. An attempt is made to guess the most relevant command.
168 | Argument help can be customised.
169 |
170 | ## Build
171 | This project requires OTP-22 or above. Simple integration is available via Hex and
172 | rebar3.
173 |
174 | ```erlang
175 | {deps, [argparse]}.
176 | ```
177 |
178 | ## Known incompatibilities
179 | In comparison with Python implementation, `argparse` for Erlang:
180 | * boolean flag (option) automatically uses {store, true}
181 | * all positional arguments are required by default (even when nargs is 'maybe')
182 | * first-class (sub) commands, slightly differently from argparse
183 | * implicit --help/-h is not a part of argparse (but implemented in cli)
184 |
185 | Commands vs. positional arguments: command always takes precedence
186 | over a positional argument.
187 | Commands form exclusive groups, e.g. only one command can
188 | be followed at a time.
189 |
190 | ## Supported command line syntax
191 | * command (priority positional argument) : ectl {crawler|reader|writer}
192 | * command, and sub-command: ectl crawler {start|stop|check}
193 | * positional argument (required): ectl
194 | * positional argument (with default): ectl []
195 | * boolean flag: ectl [-rf]
196 | * required flag: ectl -r
197 | * short optional argument: ectl [-i ]
198 | * short optional: ectl [-i []]
199 | * required short option: ectl -i
200 | * long option flag: ectl [--foo]
201 | * long optional argument: ectl [--foo ]
202 | * required long: ectl --foo
203 | * long, arg=value form: ectl --foo=arg
204 | * list of arguments: ectl , ...
205 |
206 | ## Expected features
207 |
208 | To be considered after 1.2.0:
209 | * search for commands and arguments (mini-man)
210 | * abbreviated long forms
211 | * mutual exclusion groups
212 | * shell auto-complete
213 | * automatically generated negative boolean long forms "--no-XXXX"
214 |
--------------------------------------------------------------------------------
/doc/ARGPARSE_REF.md:
--------------------------------------------------------------------------------
1 | # argparse reference
2 |
3 | Parser operates with *arguments* and *commands*, organised in a hierarchy. It is possible
4 | to define multiple commands, or none. Parsing always starts with root *command*,
5 | named after ```init:get_argument(progname)```. Empty command produces empty argument map:
6 |
7 | 1> parse("", #{}).
8 | #{}
9 |
10 | ## Options specification
11 |
12 | It's possible to override program name using **progname** option:
13 |
14 | 2> io:format(args:help(#{}, #{progname => "readme"})).
15 | usage: readme
16 |
17 | To override default optional argument prefix (**-**), use **prefixes** option:
18 |
19 | 3> args:parse(["+sbwt"], #{arguments => [#{name => mode, short => $s}]}, #{prefixes => "+"}).
20 | #{mode => "bwt"}
21 |
22 | To define a global default for arguments that are not required, use **default** option:
23 |
24 | 4> args:parse([], #{arguments => [#{name => mode, required => false}]}, #{default => undef}).
25 | #{mode => undef}
26 |
27 | When global default is not set, resulting argument map does not include keys for arguments
28 | that are not specified in the command line and there is no locally defined default value.
29 |
30 | ## Validation, help & usage information
31 |
32 | Function ```validate/1``` may be used to validate command with all sub-commands
33 | and options without actual parsing done.
34 |
35 | 4> args:validate(#{arguments => [#{short => $4}]}).
36 | ** exception error: {args,{invalid_option,["erl"],
37 | [],name,"argument must be a map, and specify 'name'"}}
38 |
39 | Human-readable errors and usage information is accessible via ```help/2``` and ```format_error/1,2```.
40 |
41 | ## Return value
42 |
43 | If root level command does not contain any sub-commands, parser returns plain map of
44 | argument names to their values:
45 |
46 | 3> args:parse(["value"], #{arguments => [#{name => arg}]}).
47 | #{arg => "value"}
48 |
49 | This map contains all arguments matching command line passed, initialised with
50 | corresponding values. If argument is omitted, but default value is specified for it,
51 | it is added to the map. When no local default value specified, and argument is not
52 | present, corresponding key is not present in the map, unless there is a global default
53 | passed with `parse/3` options.
54 |
55 | Missing required (field **required** is set to true for optional arguments,
56 | or missing for positional) arguments raises an error.
57 |
58 | When there are sub-commands, parser returns argument map, deepest matched command
59 | name, and a sub-spec passed for this command:
60 |
61 | 4> Cmd = #{arguments => [#{name => arg}]}.
62 | #{arguments => [#{name => arg}]}
63 | 5> args:parse(["cmd", "value"], #{commands => #{"cmd" => Cmd}}).
64 | {#{arg => "value"},{"cmd",#{arguments => [#{name => arg}]}}}
65 |
66 | ## Command specification
67 | Command specification may contain following fields:
68 | * **commands** - sub-commands, name to nested command spec
69 | * **arguments** - list of argument specifications
70 | * **help** - string to generate usage information, can be set to `hidden`, causing this command to be omitted in usage output
71 | * **handler** - function expected to process this command, or *optional* atom
72 | * user-defined fields
73 |
74 | Missing handler field is automatically populated by CLI framework, when a module
75 | exporting ```cli/0``` also exports function function with arity 1, named
76 | after command:
77 |
78 | cli() -> #{commands => #{"run" => #{...}}}.
79 |
80 | run(ArgMap) -> ....
81 |
82 | If command contains sub-commands, and handler is not present, it is an error
83 | if arguments supplied to ```parse``` do not select one of the sub-commands. Set
84 | handler to *optional* to not require sub-command selected.
85 |
86 | Command is matched as a positional argument, taking precedence over other
87 | positional arguments. For any positional argument found, parser will first
88 | attempt to match a sub-command in currently evaluated command. If there is no
89 | match, parser considers next argument as positional. Example:
90 |
91 | 1> Cmd = #{
92 | commands => #{sub => #{}},
93 | arguments => [#{name => positional}]
94 | },
95 | 2> parse(["arg", "sub"], Cmd) == parse(["arg", "sub"], Cmd).
96 | true
97 |
98 | Command names cannot start with prefix character.
99 |
100 | ## Argument specification
101 | Every argument spec must have **name** field. Name defines key in the parsed
102 | arguments map, similar to *dest* field of Python library. It is allowed to have
103 | multiple arguments with the same name (option aliases):
104 | ```erlang
105 | options => [
106 | #{name => foo, short => $f},
107 | #{name => foo, long => "-foo"},
108 | #{name => foo}
109 | ].
110 | ```
111 |
112 | An argument can be:
113 | * *optional*: matching command line arguments with prefix character
114 | * *positional*: arguments not starting with a prefix
115 |
116 | Negative number is considered positional argument, when **-** prefix is used, and
117 | there is no no optional argument spec that has short or long form
118 | defined as number (```#{name => one, short => $1}```).
119 |
120 | Argument is *optional* when **short** or **long** field is defined. Short form may only
121 | contain a single character, and long form may contain any string. A single prefix
122 | character is automatically prepended to long forms, so for this example:
123 | ```erlang
124 | #{name => myarg, short => $m, long => "-myarg"}
125 | ```
126 | Argument ```myarg``` can be specified as ```-m``` or as ```--myarg```. Please note that it
127 | is possible to have long forms with a single prefix character:
128 | ```erlang
129 | #{name => kernel, long => "kernel"}
130 | ```
131 | By default, optional arguments are not required and may be omitted from command line.
132 | Positional arguments are required. It is possible to override this behaviour by
133 | setting **required** field.
134 |
135 | It is supported to specify long-form value using this syntax:
136 | ```shell
137 | mycli --arg=value
138 | ```
139 | It is treated the same way as `mycli --arg value`.
140 |
141 | For every argument matched, parser may consume several following positional arguments
142 | (not starting with a prefix). Analysis is based on **nargs**, **action** and **type** fields.
143 | * when nargs is **'maybe'** and next argument is positional, it gets consumed and produced
144 | as value, and if next argument starts with option prefix, **default** value is produced
145 | (when no default present, default value deduced from argument **type**)
146 | * when nargs is **{'maybe', Term}**, and next argument starts with option prefix, Term is
147 | produced
148 | * when nargs is set to a positive integer, parser tries to consume exactly this
149 | number of positional arguments, and fails with an error if there is not enough.
150 | Produced value is a list.
151 | * when nargs is set to **nonempty_list**, parser consumes at least one following positional
152 | argument (fails if first argument is not positional), until it finds next optional
153 | argument. Produced value is a list.
154 | * when nargs is **list**, parser consumes positional arguments until it finds next
155 | optional. Produced value is a list.
156 | * when nargs is **all**, all remaining arguments (whether positional or not) are folded
157 | into single produced list.
158 |
159 | When nargs is not specified, **action** determines how many arguments are consumed. For
160 | store/append, single argument is consumed. Special handling is done for boolean **type**:
161 | no argument is consumed, even for store/append. If it is necessary to consume an argument
162 | for boolean type, use ```type => atom``` with ```choices => ["true", "false"]```.
163 |
164 | Action can have following values:
165 | * **store** - replace value in the argument map (last value wins)
166 | * **append** - append value to existing list (when value is also a list, resulting
167 | value can be a list of lists)
168 | * **{store, Term}** - do not consume argument, replace current value with Term
169 | * **{append, Term}** - do not consume argument, append Term to list of existing values
170 | * **count** - do not consume any arguments, bump the counter - useful for bumping logging
171 | verbosity level, e.g. ```cmd -vvv```
172 | * **extend** - valid only for nargs set to list/nonempty_list/all/(pos_integer()), explodes
173 | this list and appends every single value to existing list, so result is just a list, not
174 | a list of lists
175 |
176 | Arguments have **type** associated. Default type is string, taken unprocessed from command
177 | line input.
178 | * int, {int, Limits} - where Limits is a list containing {min, Int}, {max, Int} tuples
179 | * float, {float, Limits} - same as int, but for floating point
180 | * string, {string, Regex}, {string, Regex, RegexOption} - string validated with Regex
181 | * binary, {binary, Regex}, {binary, Regex, RegexOption} - same as string, but of binary type
182 | * atom - existing atom (error when atom does not exist)
183 | * {atom, unsafe} - atom, creates new atoms when needed
184 | * {custom, Fun} - custom type conversion from string() into term()
185 |
186 | For int, float, string, binary and atom, it is also possible to specify list of
187 | choices that will pass validation:
188 | ```erlang
189 | #{name => choices, short => $c, type => {string, ["one", "two", "three"]}}
190 | ```
191 | Custom function may throw ```erlang:error(invalid_argument)```, to utilise built-in
192 | validation information. If any other exception is raised, it is passed with no conversion.
193 |
194 | An error is thrown when argument cannot be converted to required type, or does not pass validation.
195 | If custom conversion function throws error(invalid_argument), exception is augmented with necessary
196 | parser state information (any other exception passes through unchanged).
197 |
198 | It is not allowed to store user-defined fields in arguments.
199 |
200 | ## Errors
201 |
202 | Argparse throws exceptions of class error, and Reason tuple:
203 | ```erlang
204 | {args, ArgParseError}
205 | ```
206 | To get human-readable representation:
207 | ```erlang
208 | try args:parse(Args, Command)
209 | catch error:{args, Reason} ->
210 | io:format(args:format_error(Reason, Command, #{}))
211 | end.
212 | ```
213 |
214 | ## Help templates
215 |
216 | To completely hide the argument or command from the output, supply `help => hidden`.
217 |
218 | It is possible to override help text generated for arguments. By default,
219 | options are formatted with "help text, type, default", e.g.:
220 |
221 | crawl [-s ...] [-z ] [-c ]
222 |
223 | Optional arguments:
224 | -s initial shards, int
225 | -z last shard, between, 100 < int < 200, 150
226 | -c tough choice, choice: 1, 2, 3
227 |
228 | It is possible to override the description, and print it this way:
229 |
230 | crawl [-s SHARD] [-c CHOICE]
231 |
232 | Optional arguments:
233 | -s initial number, int, with a default value of 0
234 | -z 150, number of last shard, unless overridden
235 | -c custom parameter, to choose from 1, 2 or 3
236 |
237 | Example:
238 | ```erlang
239 | #{
240 | arguments => [
241 | #{
242 | name => shard,
243 | default => 0,
244 | help => {"[-s SHARD]", ["initial number, ", type, " with a default value of ", default]}}
245 | ]}
246 | ```
247 | First element of the tuple replaces `[-s ]` with `[-s SHARD]` in command line example, and
248 | second defines detailed help template.
249 |
--------------------------------------------------------------------------------
/doc/CLI_REF.md:
--------------------------------------------------------------------------------
1 | # cli mini-framework
2 | cli is designed to simplify command-line interface integration. From a tiny escript,
3 | to a system exporting several hundred commands.
4 |
5 | ## Basic [example](examples/escript/simple)
6 |
7 | Ensure `argparse` is in your code path when running examples.
8 | ```shell
9 | cd doc/examples/escript
10 | ERL_FLAGS="-pa ../../../_build/default/lib/argparse/ebin" ./calc mul 2 2
11 | ```
12 |
13 | Implementing a utility with a single command to run requires:
14 | 1. Compiled code (as evaluated code does not work with callbacks)
15 | 2. 'cli' behaviour declared
16 | 3. cli/0 callback returning arguments map and a handler
17 |
18 |
19 | #!/usr/bin/env escript
20 |
21 | -export([main/1, cli/0, cli/1]).
22 | -behaviour(cli).
23 | -mode(compile). %% evaluated module cannot contain callbacks
24 |
25 | main(Args) ->
26 | cli:run(Args, #{progname => "simple"}).
27 |
28 | cli() ->
29 | #{arguments => [
30 | #{name => force, short => $f, type => boolean, default => false},
31 | #{name => recursive, short => $r, type => boolean, default => false},
32 | #{name => dir}
33 | ]}.
34 |
35 | cli(#{force := Force, recursive := Recursive, dir := Dir}) ->
36 | io:format("Removing ~s (force ~s, recursive: ~s)~n",
37 | [Dir, Force, Recursive]).
38 |
39 | ## rebar3 escript [example](https://github.com/max-au/argparse/tree/master/doc/examples/conf_reader)
40 | Creating a new application exposing CLI is as simple as:
41 | 1. Running `rebar3 new escript conf_reader`
42 | 2. Adding `argparse` to `deps` and `escript_incl_apps` in rebar.config
43 | 3. Add a function (`cli/0`) declaring CLI arguments
44 | 4. Use the specification: `args:parse(Args, cli())`
45 | 5. Run `rebar3 escriptize` to build the application.
46 |
47 | ## Command-line interface discovery
48 |
49 | By default, ```cli:run/1``` scans all loaded modules to find those implementing
50 | **cli** behaviour.
51 | Use ```run/2``` with **modules** option to specify a single module, or a
52 | list of modules to search (default is **all_loaded**):
53 |
54 | cli:run(["arg"], #{modules => ?MODULE}).
55 |
56 | When there are multiple modules in the list, cli merges all arguments and
57 | commands exported by these modules. In case of a conflict (e.g. clashing
58 | short or long option name, or top-level command name), first match is
59 | accepted, and all others are discarded (with warning emitted to OTP logger,
60 | can be switch off with **warn** flag set to false).
61 |
62 | Be careful with top-level arguments exported: they are added to
63 | *all* commands produced by *all* modules - this side effect is usually
64 | undesired. There is a warning emitted if more than one module implements
65 | cli behaviour, and global arguments are exported.
66 |
67 |
68 | ## Handler specification
69 |
70 | Handler is a callback invoked by cli:run when command line parser completes successfully.
71 |
72 | Handler may accept a single argument (map of argument names to their values, as returned
73 | by argparse):
74 |
75 | sum(#{numbers := Numbers}) ->
76 | lists:sum(Numbers).
77 |
78 | In this case, command spec should define handler either as fun, or as a tuple
79 | ```{module(), atom()}```, this requires function to be exported:
80 |
81 | %% map form:
82 | cli() -> #{commands => {"sum" => #{handler => fun sum/1}}}.
83 |
84 | %% exported function, map form:
85 | cli() -> #{commands => {"sum" => #{handler => {?MODULE, sum}}}}.
86 |
87 | Handler may accept positional arguments:
88 |
89 | start(Server, Mode) ->
90 | supervisor:start_child(my_server_sup,
91 | #{id => Server, start => {my_server, start_link, [Mode]}}).
92 |
93 | In this case handler specification must define default value for missing arguments:
94 |
95 | handler => {fun start/2, undefined}
96 | %% ... or, for exported function:
97 | handler => {?MODULE, start, undefined}
98 |
99 | ## Default handler
100 |
101 | When handler is not specified, cli generates default one, based on the module
102 | implementing cli behaviour, and **default** field passed to ```run/2```. When
103 | the field it set, positional form for handler is generated, with default set to
104 | the term supplied.
105 |
106 | cli does not check whether handler function exists or exported.
107 |
108 | ## Help/usage information
109 |
110 | By default, when parser encounters an error, cli generates both error message and help/usage
111 | information. It is possible to suppress usage messages by providing **help** option
112 | set to *false*. This also disables usage output for *--help* or *-h* request.
113 |
114 |
115 | As most escripts are interpreted via *erl*, it is recommended to define **progname**
116 | to provide correct help/usage line:
117 |
118 | cli:run(["arg"], #{progname => "mycli"}).
119 |
120 | ## Reference
121 |
122 | cli is able to pass **prefixes** option to argparse (this also changes *-h* and *--help*
123 | prefix). There are also additional options to explore, see `cli:run/2` function reference.
124 |
125 | cli also passes **default** option to argparse.
--------------------------------------------------------------------------------
/doc/examples/conf_reader/rebar.config:
--------------------------------------------------------------------------------
1 | {erl_opts, [no_debug_info]}.
2 | {deps, [argparse]}.
3 |
4 | {escript_incl_apps,
5 | [argparse, conf_reader]}.
6 | {escript_main_app, conf_reader}.
7 | {escript_name, conf_reader}.
8 | {escript_emu_args, "%%! +sbtu +A1\n"}.
9 |
10 | %% Profiles
11 | {profiles, [{test,
12 | [{erl_opts, [debug_info]}
13 | ]}]}.
14 |
--------------------------------------------------------------------------------
/doc/examples/conf_reader/src/conf_reader.app.src:
--------------------------------------------------------------------------------
1 | {application, conf_reader,
2 | [{description, "An escript"},
3 | {vsn, "0.1.0"},
4 | {registered, []},
5 | {applications,
6 | [kernel,
7 | stdlib
8 | ]},
9 | {env,[]},
10 | {modules, []}
11 | ]}.
12 |
--------------------------------------------------------------------------------
/doc/examples/conf_reader/src/conf_reader.erl:
--------------------------------------------------------------------------------
1 | -module(conf_reader).
2 |
3 | -export([main/1]).
4 |
5 | main(Args) ->
6 | try
7 | #{file := File} = Parsed = args:parse(Args, cli()),
8 | {ok, Terms} = file:consult(File),
9 | Filtered = filter(key, Parsed, filter(app, Parsed, Terms)),
10 | io:format("~tp.~n", [Filtered])
11 | catch
12 | error:{args, Reason} ->
13 | io:format("error: ~s~n", [args:format_error(Reason)])
14 | end.
15 |
16 | filter(ArgKey, Parsed, Terms) when is_map_key(ArgKey, Parsed) ->
17 | ArgVal = maps:get(ArgKey, Parsed),
18 | {_, Val} = lists:keyfind(ArgVal, 1, Terms),
19 | Val;
20 | filter(_ArgKey, _Parsed, Terms) ->
21 | Terms.
22 |
23 | %% parser specification
24 | cli() ->
25 | #{arguments => [
26 | #{name => file},
27 | #{name => app, short => $a, type => {atom, unsafe}},
28 | #{name => key, short => $k, type => {atom, unsafe}}
29 | ]}.
30 |
--------------------------------------------------------------------------------
/doc/examples/escript/calc:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env escript
2 |
3 | %% calculator, demonstrating sub-commands, and handler specifications
4 | %% how to run from:
5 | %% ERL_FLAGS="-pa ../../../_build/default/lib/argparse/ebin" ./calc mul 2 2
6 |
7 | -behaviour(cli).
8 | -mode(compile).
9 | -export([cli/0, sum/1, cos/1, mul/1]).
10 |
11 | main(Args) ->
12 | Out = cli:run(Args, #{progname => "calc"}),
13 | io:format("~p~n", [Out]).
14 |
15 | cli() ->
16 | #{
17 | commands => #{
18 | "sum" => #{
19 | arguments => [
20 | #{name => num, nargs => nonempty_list, type => int, help => "Numbers to sum"}
21 | ]
22 | },
23 | "math" => #{
24 | commands => #{
25 | "sin" => #{handler => {math, sin, undefined}},
26 | "cos" => #{},
27 | "tan" => #{handler => {math, tan, undefined}}
28 | },
29 | arguments => [
30 | #{name => in, type => float, help => "Input value"}
31 | ]
32 | },
33 | "mul" => #{
34 | arguments => [
35 | #{name => left, type => int},
36 | #{name => right, type => int}
37 | ]
38 | }
39 | }
40 | }.
41 |
42 | sum(#{num := Nums}) ->
43 | lists:sum(Nums).
44 |
45 | cos(#{in := In}) ->
46 | math:cos(In).
47 |
48 | mul(#{left := Left, right := Right}) ->
49 | Left * Right.
50 |
--------------------------------------------------------------------------------
/doc/examples/escript/erm:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env escript
2 |
3 | %% argparse example, without cli behaviour used
4 | %% how to try:
5 | %% ERL_FLAGS="-pa ../../../_build/default/lib/argparse/ebin" ./erm -rf dir
6 |
7 | main(Args) ->
8 | #{force := Force, recursive := Recursive, dir := Dir} =
9 | args:parse(Args, cli()),
10 | io:format("Removing ~s (force: ~s, recursive: ~s)~n",
11 | [Dir, Force, Recursive]).
12 |
13 | %% parser specification
14 | cli() ->
15 | #{arguments => [
16 | #{name => force, short => $f, type => boolean, default => false},
17 | #{name => recursive, short => $r, type => boolean, default => false},
18 | #{name => dir}
19 | ]}.
20 |
--------------------------------------------------------------------------------
/doc/examples/escript/mixed:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env escript
2 |
3 | %% mixed commands and sub-commands
4 | %% examples to try:
5 | %% ERL_FLAGS="-pa ../../../_build/default/lib/argparse/ebin" escript mixed start name
6 |
7 | -behaviour(cli).
8 | -mode(compile).
9 | -export([cli/0]).
10 |
11 | main(Args) ->
12 | Out = cli:run(Args, #{progname => "mixed"}),
13 | io:format("~p~n", [Out]).
14 |
15 | cli() ->
16 | #{
17 | commands => #{
18 | "start" => #{
19 | handler => fun (#{node := Node}) -> net_kernel:start([Node, shortnames]), node() end,
20 | help => "start distribution dynamically"
21 | },
22 | "node" => #{
23 | handler => fun (#{}) -> node() end,
24 | help => "name of this node"
25 | }
26 | },
27 | arguments => [
28 | #{name => node, required => false, type => {atom, unsafe}, help => "node name to start"}
29 | ]
30 | }.
31 |
--------------------------------------------------------------------------------
/doc/examples/escript/simple:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env escript
2 |
3 | %% simple cli using cli behaviour
4 |
5 | -behaviour(cli).
6 | -mode(compile).
7 | -export([cli/0, rm/3]).
8 |
9 | main(Args) ->
10 | cli:run(Args, #{progname => "simple"}).
11 |
12 | cli() ->
13 | #{
14 | handler => {?MODULE, rm, undefined},
15 | arguments => [
16 | #{name => force, short => $f, type => boolean, default => false},
17 | #{name => recursive, short => $r, type => boolean, default => false},
18 | #{name => dir}
19 | ]
20 | }.
21 |
22 | rm(Force, Recursive, Dir) ->
23 | io:format("Removing ~s (force: ~s, recursive: ~s)~n",
24 | [Dir, Force, Recursive]).
25 |
--------------------------------------------------------------------------------
/doc/examples/multi/rebar.config:
--------------------------------------------------------------------------------
1 | {deps, [argparse]}.
2 |
--------------------------------------------------------------------------------
/doc/examples/multi/src/multi.app.src:
--------------------------------------------------------------------------------
1 | {application, multi,
2 | [{description, "multi: example CLI application"},
3 | {vsn, "0.1.0"},
4 | {applications,
5 | [kernel,
6 | stdlib
7 | ]}
8 | ]}.
9 |
--------------------------------------------------------------------------------
/doc/examples/multi/src/multi.erl:
--------------------------------------------------------------------------------
1 | %%-------------------------------------------------------------------
2 | %%% @copyright (c) Maxim Fedorov
3 | %%% @doc Example from argparse framework
4 | -module(multi).
5 | -author("maximfca@gmail.com").
6 |
7 | %% API
8 | -export([
9 | main/1
10 | ]).
11 |
12 | main(Args) ->
13 | multi_math:module_info(),
14 | multi_string:module_info(),
15 | %% ensure code loaded
16 | %% normally it is done by application starting up supervision tree,
17 | %% yet this example is kept simple on purpose
18 | cli:run(Args).
19 |
--------------------------------------------------------------------------------
/doc/examples/multi/src/multi_math.erl:
--------------------------------------------------------------------------------
1 | %%-------------------------------------------------------------------
2 | %%% @copyright (c) Maxim Fedorov
3 | %%% @doc Example from argparse framework
4 | -module(multi_math).
5 | -author("maximfca@gmail.com").
6 |
7 | %% API
8 | -export([
9 | cli/0,
10 | sum/1,
11 | cos/1
12 | ]).
13 |
14 | -behaviour(cli).
15 |
16 | cli() ->
17 | #{
18 | commands => #{
19 | "sum" => #{
20 | arguments => [
21 | #{name => num, nargs => nonempty_list, type => int, help => "Numbers to sum"}
22 | ]
23 | },
24 | "math" => #{
25 | commands => #{
26 | "sin" => #{handler => {math, sin, undefined}},
27 | "cos" => #{},
28 | "tan" => #{handler => {math, tan, undefined}}
29 | },
30 | arguments => [
31 | #{name => in, type => float, help => "Input value"}
32 | ]
33 | },
34 | "mul" => #{
35 | handler => fun (#{left := Left, right := Right}) -> Left * Right end,
36 | arguments => [
37 | #{name => left, type => int},
38 | #{name => right, type => int}
39 | ]
40 | }
41 | }
42 | }.
43 |
44 | sum(#{num := Nums}) ->
45 | lists:sum(Nums).
46 |
47 | cos(#{in := In}) ->
48 | math:cos(In).
49 |
--------------------------------------------------------------------------------
/doc/examples/multi/src/multi_string.erl:
--------------------------------------------------------------------------------
1 | %%-------------------------------------------------------------------
2 | %%% @copyright (c) Maxim Fedorov
3 | %%% @doc Example from argparse framework
4 | -module(multi_string).
5 | -author("maximfca@gmail.com").
6 |
7 | %% API
8 | -export([
9 | cli/0
10 | ]).
11 |
12 | -behaviour(cli).
13 |
14 | cli() ->
15 | #{
16 | commands => #{
17 | "lowercase" => #{
18 | help => "lowercase strings",
19 | handler => fun (#{str := Str}) ->
20 | [io:format("Lowercase: ~s~n", [string:lowercase(S)]) || S <- Str]
21 | end,
22 | arguments => [
23 | #{name => str, nargs => nonempty_list, type => string, help => "strings to lowercase"}
24 | ]
25 | },
26 | "lexemes" => #{
27 | handler => {fun(Str, Sep) -> io:format("Lexemes: ~p~n", [string:lexemes(Str, Sep)]) end, undefined},
28 | arguments => [
29 | #{name => str, type => string, help => "string to split"},
30 | #{name => separator, short => $s, type => string, default => " ", help => "separator to use"}
31 | ]
32 | }
33 | }
34 | }.
35 |
--------------------------------------------------------------------------------
/doc/overview.edoc:
--------------------------------------------------------------------------------
1 | ** this is the overview.doc file for the application 'argparse' **
2 |
3 | @version 1.2.4
4 | @author Maxim Fedorov,
5 | @title argparse: A simple framework to create complex CLI.
6 |
7 | @doc
8 | Inspired by Python argparse.
9 |
10 | Follows conventions of Unix Utility Argument Syntax.
11 |
12 | `argparse [-abcDxyz][-p arg][operand]'
13 |
14 | == Argument parser ==
15 | Converts list of strings (command line) into an argument map,and a
16 | command path; see argparse reference for detailed description.
17 |
18 | == CLI Framework ==
19 | Make a step beyond parser, and export existing Erlang functions: cli reference
20 |
21 |
22 | == Basic example ==
23 |
24 | CLI framework is naturally suitable for building small escript-based apps:
25 |
26 | ```
27 | #!/usr/bin/env escript
28 |
29 | -export([main/1, cli/0, cli/1]).
30 | -behaviour(cli).
31 | -mode(compile). %% evaluated module cannot contain callbacks
32 |
33 | main(Args) ->
34 | cli:run(Args, #{progname => "simple").
35 |
36 | cli() ->
37 | #{arguments => [
38 | #{name => force, short => $f, type => boolean, default => false},
39 | #{name => recursive, short => $r, type => boolean, default => false},
40 | #{name => dir}
41 | ]}.
42 |
43 | cli(#{force := Force, recursive := Recursive, dir := Dir}) ->
44 | io:format("Removing ~s (force ~s, recursive: ~s)~n",
45 | [Dir, Force, Recursive]).
46 | '''
47 |
48 | The example above does not have sub-commands, and implements optional cli/1
49 | callback, that serves as an entry point with parsed arguments. Help options are
50 | added automatically:
51 |
52 | ```
53 | $ ./erm --help
54 | usage: erm [-fr]
55 |
56 | Optional arguments:
57 | dir
58 | -r recursive, [false]
59 | -f force, [false]
60 | '''
61 |
62 |
63 | == Calc: CLI with multiple commands ==
64 |
65 | Calculator implements several commands, with sub-commands available. Full
66 | source code here
67 |
68 | Command definitions:
69 |
70 | ```
71 | cli() ->
72 | #{
73 | commands => #{
74 | "sum" => #{
75 | arguments => [
76 | #{name => num, nargs => nonempty_list, type => int, help => "Numbers to sum"}
77 | ]
78 | },
79 | "math" => #{
80 | commands => #{
81 | "sin" => #{handler => {math, sin, undefined}},
82 | "cos" => #{},
83 | "tan" => #{handler => {math, tan, undefined}}
84 | },
85 | arguments => [
86 | #{name => in, type => float, help => "Input value"}
87 | ]
88 | },
89 | "mul" => #{
90 | arguments => [
91 | #{name => left, type => int},
92 | #{name => right, type => int}
93 | ]
94 | }
95 | }
96 | }.
97 | '''
98 |
99 | Calculator provides "sum" command that prints a sum of integer numbers:
100 |
101 | ```
102 | $ ./calc sum 1 2 3
103 | 6
104 | '''
105 |
106 | Math sub-commands provide trigonometric functions:
107 |
108 | ```
109 | $ ./calc math cos 1.4
110 | 0.16996714290024104
111 | $ ./calc math sin 1.4
112 | 0.9854497299884601
113 | '''
114 |
115 | == Complex applications ==
116 |
117 | CLI framework is capable of handling releases containing hundreds of modules
118 | implementing cli behaviour. Commands may be exported from multiple modules and
119 | applications. cli framework makes best efforts to merge commands exported,
120 | format usage output and error messages.
121 |
122 | See example
123 |
124 | This example contains two modules, multi_math.erl and multi_string.erl.
125 |
126 | Use `rebar3 escriptize' to build the application. Try various commands,
127 | e.g.
128 | ```
129 | ./multi math cos 1.0
130 | '''
131 | ```
132 | ./multi string lexemes 1+2+3+4 -s +
133 | '''
134 | to get a feeling!
135 |
136 | == Argument parser ==
137 |
138 | It is possible to use argument parser alone, without CLI framework:
139 |
140 | ```
141 | #!/usr/bin/env escript
142 |
143 | main(Args) ->
144 | #{force := Force, recursive := Recursive, dir := Dir} =
145 | args:parse(Args, cli()),
146 | io:format("Removing ~s (force: ~s, recursive: ~s)~n",
147 | [Dir, Force, Recursive]).
148 |
149 | cli() ->
150 | #{arguments => [
151 | #{name => force, short => $f, type => boolean, default => false},
152 | #{name => recursive, short => $r, type => boolean, default => false},
153 | #{name => dir}
154 | ]}.
155 | '''
156 |
157 | == Help and usage information ==
158 | CLI framework automatically prints usage, if command line parser reports an
159 | error. An attempt is made to guess most relevant command.
160 |
--------------------------------------------------------------------------------
/rebar.config:
--------------------------------------------------------------------------------
1 | {erl_opts, [debug_info]}.
2 | {deps, []}.
3 |
4 | {cover_enabled, true}.
5 | {cover_opts, [verbose]}.
6 |
7 | {ct_opts, [
8 | {ct_hooks, [cth_surefire]},
9 | {keep_logs, 1}
10 | ]}.
11 |
12 | {project_plugins, [rebar3_ex_doc]}.
13 |
14 | {ex_doc, [
15 | {extras, [
16 | {"README.md", #{title => "Overview"}},
17 | {"doc/CLI_REF.md", #{title => "CLI API"}},
18 | {"doc/ARGPARSE_REF.md", #{title => "argparse API"}},
19 | {"doc/examples/escript/simple", #{title => "simple"}},
20 | {"doc/examples/escript/calc", #{title => "calculator"}},
21 | {"doc/examples/escript/erm", #{title => "erm"}},
22 | {"CHANGELOG.md", #{title => "Changelog"}},
23 | {"LICENSE", #{title => "License"}}
24 | ]},
25 | {main, "README.md"},
26 | {source_url, "https://github.com/max-au/argparse"},
27 | {output, <<"_build/ex_doc">>},
28 | {source_ref, <<"master">>},
29 | {groups_for_extras, [
30 | {examples, [
31 | <<"doc/examples/escript/simple">>,
32 | <<"doc/examples/escript/calc">>,
33 | <<"doc/examples/escript/erm">>]}
34 | ]}
35 | ]}.
36 |
37 | {hex, [
38 | {doc, #{provider => ex_doc}}
39 | ]}.
--------------------------------------------------------------------------------
/rebar.lock:
--------------------------------------------------------------------------------
1 | [].
2 |
--------------------------------------------------------------------------------
/src/argparse.app.src:
--------------------------------------------------------------------------------
1 | {application, argparse,
2 | [{description, "argparse: arguments parser, and cli framework"},
3 | {vsn, "2.0.0"},
4 | {applications,
5 | [kernel,
6 | stdlib
7 | ]},
8 | {licenses, ["BSD 3-clause clear"]},
9 | {links, [{"GitHub", "https://github.com/max-au/argparse"}]}
10 | ]}.
11 |
--------------------------------------------------------------------------------
/src/args.erl:
--------------------------------------------------------------------------------
1 | %%%-------------------------------------------------------------------
2 | %%% @author Maxim Fedorov,
3 | %%% @doc
4 | %%% Command line parser, made with hierarchy of commands in mind.
5 | %%% Parser operates with arguments and commands, organised in a hierarchy. It is possible
6 | %%% to define multiple commands, or none. Parsing always starts with root command,
7 | %%% named after `init:get_argument(progname)'. Empty command produces empty argument map:
8 | %%% ```
9 | %%% 1> parse("", #{}).
10 | %%% #{}
11 | %%% '''
12 | %%%
13 | %%%
14 | %%% If root level command does not contain any sub-commands, parser returns plain map of
15 | %%% argument names to their values:
16 | %%% ```
17 | %%% 3> args:parse(["value"], #{arguments => [#{name => arg}]}).
18 | %%% #{arg => "value"}
19 | %%% '''
20 | %%% This map contains all arguments matching command line passed, initialised with
21 | %%% corresponding values. If argument is omitted, but default value is specified for it,
22 | %%% it is added to the map. When no default value specified, and argument is not
23 | %%% present, corresponding key is not present in the map.
24 | %%%
25 | %%% Missing required (field required is set to true for optional arguments,
26 | %%% or missing for positional) arguments raises an error.
27 | %%%
28 | %%% When there are sub-commands, parser returns argument map, deepest matched command
29 | %%% name, and a sub-spec passed for this command:
30 | %%% ```
31 | %%% 4> Cmd = #{arguments => [#{name => arg}]}.
32 | %%% #{arguments => [#{name => arg}]}
33 | %%% 5> args:parse(["cmd", "value"], #{commands => #{"cmd" => Cmd}}).
34 | %%% {#{arg => "value"},{"cmd",#{arguments => [#{name => arg}]}}}
35 | %%% '''
36 | %%% @end
37 |
38 | -module(args).
39 | -author("maximfca@gmail.com").
40 |
41 | -export([
42 | validate/1,
43 | validate/2,
44 | parse/2,
45 | parse/3,
46 | help/1,
47 | help/2,
48 | format_error/1,
49 | format_error/3
50 | ]).
51 |
52 | %%--------------------------------------------------------------------
53 | %% API
54 |
55 | -compile(warn_missing_spec).
56 |
57 | %% @doc
58 | %% Built-in types include basic validation abilities
59 | %% String and binary validation may use regex match (ignoring captured value).
60 | %% For float, int, string, binary and atom type, it is possible to specify
61 | %% available choices instead of regex/min/max.
62 | %% @end
63 | -type arg_type() ::
64 | boolean |
65 | float |
66 | {float, [float()]} |
67 | {float, [{min, float()} | {max, float()}]} |
68 | int |
69 | {int, [integer()]} |
70 | {int, [{min, integer()} | {max, integer()}]} |
71 | string |
72 | {string, [string()]} |
73 | {string, string()} |
74 | {string, string(), [term()]} |
75 | binary |
76 | {binary, [binary()]} |
77 | {binary, binary()} |
78 | {binary, binary(), [term()]} |
79 | atom |
80 | {atom, [atom()]} |
81 | {atom, unsafe} |
82 | {custom, fun((string()) -> term())}.
83 |
84 | %% Help template definition for argument. Short and long forms exist for every argument.
85 | %% Short form is printed together with command definition, e.g. "usage: rm [--force]",
86 | %% while long description is printed in detailed section below: "--force forcefully remove".
87 | -type argument_help() :: {
88 | string(), %% short form, printed in command usage, e.g. "[--dir ]", developer is
89 | %% responsible for proper formatting (e.g. adding <>, dots... and so on)
90 | [string() | type | default] %% long description, default is [help, " (", type, ", ", default, ")"] -
91 | %% "floating-point long form argument, float, [3.14]"
92 | }.
93 |
94 | %% Command line argument specification.
95 | %% Argument can be optional - starting with - (dash), and positional.
96 | -type argument() :: #{
97 | %% Argument name, and a destination to store value too
98 | %% It is allowed to have several arguments named the same, setting or appending to the same variable.
99 | %% It is used to format the name, hence it should be format-table with "~ts".
100 | name := atom() | string() | binary(),
101 |
102 | %% short, single-character variant of command line option, omitting dash (example: $b, meaning -b),
103 | %% when present, this is optional argument
104 | short => char(),
105 |
106 | %% long command line option, omitting first dash (example: "kernel", or "-long", meaning "-kernel" and "--long"
107 | %% long command always wins over short abbreviation (e.g. -kernel is considered before -k -e -r -n -e -l)
108 | %% when present, this is optional argument
109 | long => string(),
110 |
111 | %% throws an error if value is not present in command line
112 | required => boolean(),
113 |
114 | %% default value, produced if value is not present in command line
115 | default => term(),
116 |
117 | %% parameter type (string by default)
118 | type => arg_type(),
119 |
120 | %% action to take when argument is matched
121 | action => store | %% default: store argument consumed (last stored wins)
122 | {store, term()} | %% does not consume argument, stores term() instead
123 | append | %% appends consumed argument to a list
124 | {append, term()} | %% does not consume an argument, appends term() to a list
125 | count | %% does not consume argument, bumps counter
126 | extend, %% uses when nargs is list/nonempty_list/all - appends every element to the list
127 |
128 | %% how many positional arguments to consume
129 | nargs =>
130 | pos_integer() | %% consume exactly this amount, e.g. '-kernel key value' #{long => "-kernel", args => 2}
131 | %% returns #{kernel => ["key", "value"]}
132 | 'maybe' | %% if next argument is positional, consume it, otherwise produce default
133 | {'maybe', term()} | %% if next argument is positional, consume it, otherwise produce term()
134 | list | %% consume zero or more positional arguments, until next optional
135 | nonempty_list | %% consume at least one positional argument, until next optional
136 | all, %% fold remaining command line into this argument
137 |
138 | %% help string printed in usage, hidden help is not printed at all
139 | help => hidden | string() | argument_help()
140 | }.
141 |
142 | -type arg_map() :: #{term() => term()}. %% Arguments map: argument name to a term, produced by parser. Supplied to command handler
143 |
144 | %% Command handler. May produce some output. Can accept a map, or be
145 | %% arbitrary mfa() for handlers accepting positional list.
146 | %% Special value 'optional' may be used to suppress an error that
147 | %% otherwise raised when command contains sub-commands, but arguments
148 | %% supplied via command line do not select any.
149 | -type handler() ::
150 | optional | %% valid for commands with sub-commands, suppresses error when no
151 | %% sub-command is selected
152 | fun((arg_map()) -> term()) | %% handler accepting arg_map
153 | {module(), Fn :: atom()} | %% handler, accepting arg_map, Fn exported from module()
154 | {fun((arg_map()) -> term()), term()} | %% handler, positional form
155 | {module(), atom(), term()}. %% handler, positional form, exported from module()
156 |
157 | -type command_map() :: #{string() => command()}. %% Sub-commands are arranged into maps (cannot start with prefix)
158 |
159 | %% Command help template, RFC for future implementation
160 | %% Default is ["usage: ", name, " ", flags, " ", options, " ", arguments, "\n", help, "\n", commands, "\n",
161 | %% {arguments, long}, "\n", {options, long}, "\n"]
162 | %% -type command_help() :: [
163 | %% string() | %% text string, as is
164 | %% name | %% command name (or progname, if it's top-level command)
165 | %% flags | %% flags: [-rfv]
166 | %% options | %% options: [--force] [-i ] [--dir ]
167 | %% arguments | %% []
168 | %% commands | %% status prints server status
169 | %% {arguments, long} | %% server server to start
170 | %% {options, long} %% -f, --force force
171 | %% ].
172 |
173 | %% Command descriptor
174 | -type command() :: #{
175 | %% Sub-commands
176 | commands => command_map(),
177 |
178 | %% accepted arguments list. Positional order is important!
179 | arguments => [argument()],
180 |
181 | %% help line
182 | help => hidden | string(),
183 |
184 | %% recommended handler function, cli behaviour deduces handler from
185 | %% command name and module implementing cli behaviour
186 | handler => handler()
187 | }.
188 |
189 | -export_type([
190 | argument/0,
191 | command/0,
192 | handler/0,
193 | cmd_path/0,
194 | arg_map/0
195 | ]).
196 |
197 | %% Optional or positional argument?
198 | -define(IS_OPTIONAL(Arg), is_map_key(short, Arg) orelse is_map_key(long, Arg)).
199 |
200 | -type cmd_path() :: [string()]. %% Command path, for deeply nested sub-commands
201 |
202 | %% Parser state (not available via API)
203 | -record(eos, {
204 | %% prefix character map, by default, only -
205 | prefixes :: #{integer() => true},
206 | %% argument map to be returned
207 | argmap = #{} :: arg_map(),
208 | %% sub-commands, in reversed orders, allowing to recover path taken
209 | commands = [] :: cmd_path(),
210 | %% command being matched
211 | current :: command(),
212 | %% unmatched positional arguments, in expected match order
213 | pos = [] :: [argument()],
214 | %% expected optional arguments, mapping between short/long form and an argument
215 | short = #{} :: #{integer() => argument()},
216 | long = #{} :: #{string() => argument()},
217 | %% flag, whether there are no options that can be confused with negative numbers
218 | no_digits = true :: boolean(),
219 | %% global default for not required arguments
220 | default :: error | {ok, term()}
221 | }).
222 |
223 | %% Error Reason thrown by parser (feed it into format_error to get human-readable error).
224 | -type argparse_reason() ::
225 | {invalid_command, cmd_path(), Field :: atom(), Reason :: string()} |
226 | {invalid_option, cmd_path(), Name :: string(), Field :: atom(), Reason :: string()} |
227 | {unknown_argument, cmd_path(), Argument :: string()} |
228 | {missing_argument, cmd_path(), argument()} |
229 | {invalid_argument, cmd_path(), argument(), Argument :: string()}.
230 |
231 | %% Parser options
232 | -type parser_options() :: #{
233 | %% allowed prefixes (default is [$-]).
234 | prefixes => [integer()],
235 | %% default value for all missing not required arguments
236 | default => term(),
237 | %% next fields are only considered when printing usage
238 | progname => string() | atom(), %% program name override
239 | command => [string()] %% nested command (missing/empty for top-level command)
240 | }.
241 |
242 | -type command_spec() :: {Name :: [string()], command()}. %% Command name with command spec
243 |
244 | -type parse_result() :: arg_map() | {arg_map(), command_spec()}. %% Result returned from parse/2,3: can be only argument map, or argument map with command_spec.
245 |
246 | %% @equiv validate(Command, #{})
247 | -spec validate(command()) -> {Progname :: string(), command()}.
248 | validate(Command) ->
249 | validate(Command, #{}).
250 |
251 | %% @doc Validate command specification, taking Options into account.
252 | %% Generates error signal when command specification is invalid.
253 | -spec validate(command(), parser_options()) -> {Progname :: string(), command()}.
254 | validate(Command, Options) ->
255 | validate_impl(Command, Options).
256 |
257 |
258 | %% @equiv parse(Args, Command, #{})
259 | -spec parse(Args :: [string()], command() | command_spec()) -> parse_result().
260 | parse(Args, Command) ->
261 | parse(Args, Command, #{}).
262 |
263 | %% @doc Parses supplied arguments according to expected command definition.
264 | %% @param Args command line arguments (e.g. `init:get_plain_arguments()')
265 | %% @returns argument map, or argument map with deepest matched command
266 | %% definition.
267 | -spec parse(Args :: [string()], command() | command_spec(),
268 | Options :: parser_options()) -> parse_result().
269 | parse(Args, Command, Options) ->
270 | {Prog, Cmd} = validate(Command, Options),
271 | Prefixes = maps:from_list([{P, true} || P <- maps:get(prefixes, Options, [$-])]),
272 | parse_impl(Args, merge_arguments(Prog, Cmd, #eos{prefixes = Prefixes, current = Cmd,
273 | default = maps:find(default, Options)})).
274 |
275 | %% By default, options are indented with 2 spaces for each level of
276 | %% sub-command.
277 | -define (DEFAULT_INDENT, " ").
278 |
279 | %% @equiv help(Command, #{})
280 | -spec help(command() | command_spec()) -> string().
281 | help(Command) ->
282 | help(Command, #{}).
283 |
284 | %% @doc
285 | %% Returns help for Command formatted according to Options specified
286 | -spec help(command() | command_spec(), parser_options()) -> string().
287 | help(Command, Options) ->
288 | unicode:characters_to_list(format_help(validate(Command, Options), Options)).
289 |
290 | %% @doc Format exception reasons produced by parse/2.
291 | %% Exception of class error with reason {args, Reason} is normally
292 | %% raised, and format_error accepts only the Reason part, leaving
293 | %% other exceptions that do not belong to argparse out.
294 | %% @returns string, ready to be printed via io:format().
295 | -spec format_error(Reason :: argparse_reason()) -> string().
296 | format_error({invalid_command, Path, Field, Text}) ->
297 | unicode:characters_to_list(io_lib:format("~tsinternal error, invalid field '~ts': ~ts~n",
298 | [format_path(Path), Field, Text]));
299 | format_error({invalid_option, Path, Name, Field, Text}) ->
300 | unicode:characters_to_list(io_lib:format("~tsinternal error, option ~ts field '~ts': ~ts~n",
301 | [format_path(Path), Name, Field, Text]));
302 | format_error({unknown_argument, Path, Argument}) ->
303 | unicode:characters_to_list(io_lib:format("~tsunrecognised argument: ~ts~n",
304 | [format_path(Path), Argument]));
305 | format_error({missing_argument, Path, Name}) ->
306 | unicode:characters_to_list(io_lib:format("~tsrequired argument missing: ~ts~n",
307 | [format_path(Path), Name]));
308 | format_error({invalid_argument, Path, Name, Value}) ->
309 | unicode:characters_to_list(io_lib:format("~tsinvalid argument ~ts for: ~ts~n",
310 | [format_path(Path), Value, Name])).
311 |
312 | %% @doc Formats exception, and adds command usage information for
313 | %% command that was known/parsed when exception was raised.
314 | %% @returns string, ready to be printed via io:format().
315 | -spec format_error(argparse_reason(), command() | command_spec(), parser_options()) -> string().
316 | format_error(Reason, Command, Options) ->
317 | Path = tl(element(2, Reason)),
318 | ErrorText = format_error(Reason),
319 | UsageText = help(Command, Options#{command => Path}),
320 | ErrorText ++ UsageText.
321 |
322 | %%--------------------------------------------------------------------
323 | %% Parser implementation
324 |
325 | %% helper function to match either a long form of "--arg=value", or just "--arg"
326 | match_long(Arg, LongOpts) ->
327 | case maps:find(Arg, LongOpts) of
328 | {ok, Option} ->
329 | {ok, Option};
330 | error ->
331 | %% see if there is '=' equals sign in the Arg
332 | case string:split(Arg, "=") of
333 | [MaybeLong, Value] ->
334 | case maps:find(MaybeLong, LongOpts) of
335 | {ok, Option} ->
336 | {ok, Option, Value};
337 | error ->
338 | nomatch
339 | end;
340 | _ ->
341 | nomatch
342 | end
343 | end.
344 |
345 | %% parse_impl implements entire internal parse logic.
346 |
347 | %% Clause: option starting with any prefix
348 | %% No separate clause for single-character short form, because there could be a single-character
349 | %% long form taking precedence.
350 | parse_impl([[Prefix | Name] | Tail], #eos{prefixes = Pref} = Eos) when is_map_key(Prefix, Pref) ->
351 | %% match "long" option from the list of currently known
352 | case match_long(Name, Eos#eos.long) of
353 | {ok, Option} ->
354 | consume(Tail, Option, Eos);
355 | {ok, Option, Value} ->
356 | consume([Value | Tail], Option, Eos);
357 | nomatch ->
358 | %% try to match single-character flag
359 | case Name of
360 | [Flag] when is_map_key(Flag, Eos#eos.short) ->
361 | %% found a flag
362 | consume(Tail, maps:get(Flag, Eos#eos.short), Eos);
363 | [Flag | Rest] when is_map_key(Flag, Eos#eos.short) ->
364 | %% can be a combination of flags, or flag with value,
365 | %% but can never be a negative integer, because otherwise
366 | %% it will be reflected in no_digits
367 | case abbreviated(Name, [], Eos#eos.short) of
368 | false ->
369 | %% short option with Rest being an argument
370 | consume([Rest | Tail], maps:get(Flag, Eos#eos.short), Eos);
371 | Expanded ->
372 | %% expand multiple flags into actual list, adding prefix
373 | parse_impl([[Prefix,E] || E <- Expanded] ++ Tail, Eos)
374 | end;
375 | MaybeNegative when Prefix =:= $-, Eos#eos.no_digits ->
376 | case is_digits(MaybeNegative) of
377 | true ->
378 | %% found a negative number
379 | parse_positional([Prefix|Name], Tail, Eos);
380 | false ->
381 | catch_all_positional([[Prefix|Name] | Tail], Eos)
382 | end;
383 | _Unknown ->
384 | catch_all_positional([[Prefix|Name] | Tail], Eos)
385 | end
386 | end;
387 |
388 | %% Arguments not starting with Prefix: attempt to match sub-command, if available
389 | parse_impl([Positional | Tail], #eos{current = #{commands := SubCommands}} = Eos) ->
390 | case maps:find(Positional, SubCommands) of
391 | error ->
392 | %% sub-command not found, try positional argument
393 | parse_positional(Positional, Tail, Eos);
394 | {ok, SubCmd} ->
395 | %% found matching sub-command with arguments, descend into it
396 | parse_impl(Tail, merge_arguments(Positional, SubCmd, Eos))
397 | end;
398 |
399 | %% Clause for arguments that don't have sub-commands (therefore check for
400 | %% positional argument).
401 | parse_impl([Positional | Tail], Eos) ->
402 | parse_positional(Positional, Tail, Eos);
403 |
404 | %% Entire command line has been matched, go over missing arguments,
405 | %% add defaults etc
406 | parse_impl([], #eos{argmap = ArgMap0, commands = Commands, current = Current, pos = Pos, default = Def} = Eos) ->
407 | %% error if stopped at sub-command with no handler
408 | map_size(maps:get(commands, Current, #{})) >0 andalso
409 | (not is_map_key(handler, Current)) andalso
410 | fail({missing_argument, Commands, "missing handler"}),
411 | %% go over remaining positional, verify they are all not required
412 | ArgMap1 = fold_args_map(Commands, true, ArgMap0, Pos, Def),
413 | %% go over optionals, and either raise an error, or set default
414 | ArgMap2 = fold_args_map(Commands, false, ArgMap1, maps:values(Eos#eos.short), Def),
415 | ArgMap3 = fold_args_map(Commands, false, ArgMap2, maps:values(Eos#eos.long), Def),
416 | case Eos#eos.commands of
417 | [_] ->
418 | %% if there were no commands specified, only the argument map
419 | ArgMap3;
420 | [_|_] ->
421 | %% otherwise return argument map, command path taken, and the
422 | %% last command matched (usually it contains a handler to run)
423 | {ArgMap3, {tl(lists:reverse(Eos#eos.commands)), Eos#eos.current}}
424 | end.
425 |
426 | %% Generate error for missing required argument, and supply defaults for
427 | %% missing optional arguments that have defaults.
428 | fold_args_map(Commands, Req, ArgMap, Args, GlobalDefault) ->
429 | lists:foldl(
430 | fun (#{name := Name}, Acc) when is_map_key(Name, Acc) ->
431 | %% argument present
432 | Acc;
433 | (#{name := Name, required := true}, _Acc) ->
434 | %% missing, and required explicitly
435 | fail({missing_argument, Commands, Name});
436 | (#{name := Name, required := false, default := Default}, Acc) ->
437 | %% explicitly not required argument with default
438 | Acc#{Name => Default};
439 | (#{name := Name, required := false}, Acc) ->
440 | %% explicitly not required with no local default, try global one
441 | try_global_default(Name, Acc, GlobalDefault);
442 | (#{name := Name, default := Default}, Acc) when Req =:= true ->
443 | %% positional argument with default
444 | Acc#{Name => Default};
445 | (#{name := Name}, _Acc) when Req =:= true ->
446 | %% missing, for positional argument, implicitly required
447 | fail({missing_argument, Commands, Name});
448 | (#{name := Name, default := Default}, Acc) ->
449 | %% missing, optional, and there is a default
450 | Acc#{Name => Default};
451 | (#{name := Name}, Acc) ->
452 | %% missing, optional, no local default, try global default
453 | try_global_default(Name, Acc, GlobalDefault)
454 | end, ArgMap, Args).
455 |
456 | try_global_default(_Name, Acc, error) ->
457 | Acc;
458 | try_global_default(Name, Acc, {ok, Term}) ->
459 | Acc#{Name => Term}.
460 |
461 | %%--------------------------------------------------------------------
462 | %% argument consumption (nargs) handling
463 |
464 | catch_all_positional(Tail, #eos{pos = [#{nargs := all} = Opt]} = Eos) ->
465 | action([], Tail, Opt#{type => {list, maps:get(type, Opt, string)}}, Eos);
466 | %% it is possible that some positional arguments are not required,
467 | %% and therefore it is possible to catch all skipping those
468 | catch_all_positional(Tail, #eos{argmap = Args, pos = [#{name := Name, default := Default, required := false} | Pos]} = Eos) ->
469 | catch_all_positional(Tail, Eos#eos{argmap = Args#{Name => Default}, pos = Pos});
470 | %% same as above, but no default specified
471 | catch_all_positional(Tail, #eos{pos = [#{required := false} | Pos]} = Eos) ->
472 | catch_all_positional(Tail, Eos#eos{pos = Pos});
473 | catch_all_positional([Arg | _Tail], Eos) ->
474 | fail({unknown_argument, Eos#eos.commands, Arg}).
475 |
476 | parse_positional(Arg, _Tail, #eos{pos = [], commands = Commands}) ->
477 | fail({unknown_argument, Commands, Arg});
478 | parse_positional(Arg, Tail, #eos{pos = Pos} = Eos) ->
479 | %% positional argument itself is a value
480 | consume([Arg | Tail], hd(Pos), Eos).
481 |
482 | %% Adds CmdName to path, and includes any arguments found there
483 | merge_arguments(CmdName, #{arguments := Args} = SubCmd, Eos) ->
484 | add_args(Args, Eos#eos{current = SubCmd, commands = [CmdName | Eos#eos.commands]});
485 | merge_arguments(CmdName, SubCmd, Eos) ->
486 | Eos#eos{current = SubCmd, commands = [CmdName | Eos#eos.commands]}.
487 |
488 | %% adds arguments into current set of discovered pos/opts
489 | add_args([], Eos) ->
490 | Eos;
491 | add_args([#{short := S, long := L} = Option | Tail], #eos{short = Short, long = Long} = Eos) ->
492 | %% remember if this option can be confused with negative number
493 | NoDigits = no_digits(Eos#eos.no_digits, Eos#eos.prefixes, S, L),
494 | add_args(Tail, Eos#eos{short = Short#{S => Option}, long = Long#{L => Option}, no_digits = NoDigits});
495 | add_args([#{short := S} = Option | Tail], #eos{short = Short} = Eos) ->
496 | %% remember if this option can be confused with negative number
497 | NoDigits = no_digits(Eos#eos.no_digits, Eos#eos.prefixes, S, 0),
498 | add_args(Tail, Eos#eos{short = Short#{S => Option}, no_digits = NoDigits});
499 | add_args([#{long := L} = Option | Tail], #eos{long = Long} = Eos) ->
500 | %% remember if this option can be confused with negative number
501 | NoDigits = no_digits(Eos#eos.no_digits, Eos#eos.prefixes, 0, L),
502 | add_args(Tail, Eos#eos{long = Long#{L => Option}, no_digits = NoDigits});
503 | add_args([PosOpt | Tail], #eos{pos = Pos} = Eos) ->
504 | add_args(Tail, Eos#eos{pos = Pos ++ [PosOpt]}).
505 |
506 | %% If no_digits is still true, try to find out whether it should turn false,
507 | %% because added options look like negative numbers, and prefixes include -
508 | no_digits(false, _, _, _) ->
509 | false;
510 | no_digits(true, Prefixes, _, _) when not is_map_key($-, Prefixes) ->
511 | true;
512 | no_digits(true, _, Short, _) when Short >= $0, Short =< $9 ->
513 | false;
514 | no_digits(true, _, _, Long) ->
515 | not is_digits(Long).
516 |
517 | %%--------------------------------------------------------------------
518 | %% additional functions for optional arguments processing
519 |
520 | %% Returns true when option (!) description passed requires a positional argument,
521 | %% hence cannot be treated as a flag.
522 | requires_argument(#{nargs := {'maybe', _Term}}) ->
523 | false;
524 | requires_argument(#{nargs := 'maybe'}) ->
525 | false;
526 | requires_argument(#{nargs := _Any}) ->
527 | true;
528 | requires_argument(Opt) ->
529 | case maps:get(action, Opt, store) of
530 | store ->
531 | maps:get(type, Opt, string) =/= boolean;
532 | append ->
533 | maps:get(type, Opt, string) =/= boolean;
534 | _ ->
535 | false
536 | end.
537 |
538 | %% Attempts to find if passed list of flags can be expanded
539 | abbreviated([Last], Acc, AllShort) when is_map_key(Last, AllShort) ->
540 | lists:reverse([Last | Acc]);
541 | abbreviated([_], _Acc, _Eos) ->
542 | false;
543 | abbreviated([Flag | Tail], Acc, AllShort) ->
544 | case maps:find(Flag, AllShort) of
545 | error ->
546 | false;
547 | {ok, Opt} ->
548 | case requires_argument(Opt) of
549 | true ->
550 | false;
551 | false ->
552 | abbreviated(Tail, [Flag | Acc], AllShort)
553 | end
554 | end.
555 |
556 | %%--------------------------------------------------------------------
557 | %% argument consumption (nargs) handling
558 |
559 | %% consume predefined amount (none of which can be an option?)
560 | consume(Tail, #{nargs := Count} = Opt, Eos) when is_integer(Count) ->
561 | {Consumed, Remain} = split_to_option(Tail, Count, Eos, []),
562 | length(Consumed) < Count andalso fail({invalid_argument, Eos#eos.commands, maps:get(name, Opt), Tail}),
563 | action(Remain, Consumed, Opt#{type => {list, maps:get(type, Opt, string)}}, Eos);
564 |
565 | %% handle 'reminder' by just dumping everything in
566 | consume(Tail, #{nargs := all} = Opt, Eos) ->
567 | action([], Tail, Opt#{type => {list, maps:get(type, Opt, string)}}, Eos);
568 |
569 | %% require at least one argument
570 | consume(Tail, #{nargs := nonempty_list} = Opt, Eos) ->
571 | {Consumed, Remains} = split_to_option(Tail, -1, Eos, []),
572 | Consumed =:= [] andalso fail({invalid_argument, Eos#eos.commands, maps:get(name, Opt), Tail}),
573 | action(Remains, Consumed, Opt#{type => {list, maps:get(type, Opt, string)}}, Eos);
574 |
575 | %% consume all until next option
576 | consume(Tail, #{nargs := list} = Opt, Eos) ->
577 | {Consumed, Remains} = split_to_option(Tail, -1, Eos, []),
578 | action(Remains, Consumed, Opt#{type => {list, maps:get(type, Opt, string)}}, Eos);
579 |
580 | %% maybe consume one, maybe not...
581 | %% special cases for 'boolean maybe', only consume 'true' and 'false'
582 | consume(["true" | Tail], #{type := boolean} = Opt, Eos) ->
583 | action(Tail, true, Opt#{type => raw}, Eos);
584 | consume(["false" | Tail], #{type := boolean} = Opt, Eos) ->
585 | action(Tail, false, Opt#{type => raw}, Eos);
586 | consume(Tail, #{type := boolean} = Opt, Eos) ->
587 | %% if neither true or false, don't consume, just do the action with 'true' as arg
588 | action(Tail, true, Opt#{type => raw}, Eos);
589 |
590 | %% maybe behaviour, as '?'
591 | consume(Tail, #{nargs := 'maybe'} = Opt, Eos) ->
592 | case split_to_option(Tail, 1, Eos, []) of
593 | {[], _} ->
594 | %% no argument given, produce default argument (if not present,
595 | %% then produce default value of the specified type)
596 | action(Tail, default(Opt), Opt#{type => raw}, Eos);
597 | {[Consumed], Remains} ->
598 | action(Remains, Consumed, Opt, Eos)
599 | end;
600 |
601 | %% maybe consume one, maybe not...
602 | consume(Tail, #{nargs := {'maybe', Const}} = Opt, Eos) ->
603 | case split_to_option(Tail, 1, Eos, []) of
604 | {[], _} ->
605 | action(Tail, Const, Opt, Eos);
606 | {[Consumed], Remains} ->
607 | action(Remains, Consumed, Opt, Eos)
608 | end;
609 |
610 | %% default case, which depends on action
611 | consume(Tail, #{action := count} = Opt, Eos) ->
612 | action(Tail, undefined, Opt, Eos);
613 |
614 | %% for {store, ...} and {append, ...} don't take argument out
615 | consume(Tail, #{action := {Act, _Const}} = Opt, Eos) when Act =:= store; Act =:= append ->
616 | action(Tail, undefined, Opt, Eos);
617 |
618 | %% optional: ensure not to consume another option start
619 | consume([[Prefix | _] = ArgValue | Tail], Opt, Eos) when ?IS_OPTIONAL(Opt), is_map_key(Prefix, Eos#eos.prefixes) ->
620 | case Eos#eos.no_digits andalso is_digits(ArgValue) of
621 | true ->
622 | action(Tail, ArgValue, Opt, Eos);
623 | false ->
624 | fail({missing_argument, Eos#eos.commands, maps:get(name, Opt)})
625 | end;
626 |
627 | consume([ArgValue | Tail], Opt, Eos) ->
628 | action(Tail, ArgValue, Opt, Eos);
629 |
630 | %% we can only be here if it's optional argument, but there is no value supplied,
631 | %% and type is not 'boolean' - this is an error!
632 | consume([], Opt, Eos) ->
633 | fail({missing_argument, Eos#eos.commands, maps:get(name, Opt)}).
634 |
635 | %% no more arguments for consumption, but last optional may still be action-ed
636 | %%consume([], Current, Opt, Eos) ->
637 | %% action([], Current, undefined, Opt, Eos).
638 |
639 | %% smart split: ignore arguments that can be parsed as negative numbers,
640 | %% unless there are arguments that look like negative numbers
641 | split_to_option([], _, _Eos, Acc) ->
642 | {lists:reverse(Acc), []};
643 | split_to_option(Tail, 0, _Eos, Acc) ->
644 | {lists:reverse(Acc), Tail};
645 | split_to_option([[Prefix | _] = MaybeNumber | Tail] = All, Left,
646 | #eos{no_digits = true, prefixes = Prefixes} = Eos, Acc) when is_map_key(Prefix, Prefixes) ->
647 | case is_digits(MaybeNumber) of
648 | true ->
649 | split_to_option(Tail, Left - 1, Eos, [MaybeNumber | Acc]);
650 | false ->
651 | {lists:reverse(Acc), All}
652 | end;
653 | split_to_option([[Prefix | _] | _] = All, _Left,
654 | #eos{no_digits = false, prefixes = Prefixes}, Acc) when is_map_key(Prefix, Prefixes) ->
655 | {lists:reverse(Acc), All};
656 | split_to_option([Head | Tail], Left, Opts, Acc) ->
657 | split_to_option(Tail, Left - 1, Opts, [Head | Acc]).
658 |
659 | %%--------------------------------------------------------------------
660 | %% Action handling
661 |
662 | action(Tail, ArgValue, #{name := ArgName, action := store} = Opt, #eos{argmap = ArgMap} = Eos) ->
663 | Value = convert_type(maps:get(type, Opt, string), ArgValue, ArgName, Eos),
664 | continue_parser(Tail, Opt, Eos#eos{argmap = ArgMap#{ArgName => Value}});
665 |
666 | action(Tail, undefined, #{name := ArgName, action := {store, Value}} = Opt, #eos{argmap = ArgMap} = Eos) ->
667 | continue_parser(Tail, Opt, Eos#eos{argmap = ArgMap#{ArgName => Value}});
668 |
669 | action(Tail, ArgValue, #{name := ArgName, action := append} = Opt, #eos{argmap = ArgMap} = Eos) ->
670 | Value = convert_type(maps:get(type, Opt, string), ArgValue, ArgName, Eos),
671 | continue_parser(Tail, Opt, Eos#eos{argmap = ArgMap#{ArgName => maps:get(ArgName, ArgMap, []) ++ [Value]}});
672 |
673 | action(Tail, undefined, #{name := ArgName, action := {append, Value}} = Opt, #eos{argmap = ArgMap} = Eos) ->
674 | continue_parser(Tail, Opt, Eos#eos{argmap = ArgMap#{ArgName => maps:get(ArgName, ArgMap, []) ++ [Value]}});
675 |
676 | action(Tail, ArgValue, #{name := ArgName, action := extend} = Opt, #eos{argmap = ArgMap} = Eos) ->
677 | Value = convert_type(maps:get(type, Opt, string), ArgValue, ArgName, Eos),
678 | Extended = maps:get(ArgName, ArgMap, []) ++ Value,
679 | continue_parser(Tail, Opt, Eos#eos{argmap = ArgMap#{ArgName => Extended}});
680 |
681 | action(Tail, _, #{name := ArgName, action := count} = Opt, #eos{argmap = ArgMap} = Eos) ->
682 | continue_parser(Tail, Opt, Eos#eos{argmap = ArgMap#{ArgName => maps:get(ArgName, ArgMap, 0) + 1}});
683 |
684 | %% default: same as set
685 | action(Tail, ArgValue, Opt, Eos) ->
686 | action(Tail, ArgValue, Opt#{action => store}, Eos).
687 |
688 | %% pop last positional, unless nargs is list/nonempty_list
689 | continue_parser(Tail, Opt, Eos) when ?IS_OPTIONAL(Opt) ->
690 | parse_impl(Tail, Eos);
691 | continue_parser(Tail, #{nargs := List}, Eos) when List =:= list; List =:= nonempty_list ->
692 | parse_impl(Tail, Eos);
693 | continue_parser(Tail, _Opt, Eos) ->
694 | parse_impl(Tail, Eos#eos{pos = tl(Eos#eos.pos)}).
695 |
696 | %%--------------------------------------------------------------------
697 | %% Type conversion
698 |
699 | %% Handle "list" variant for nargs returning list
700 | convert_type({list, Type}, Arg, Opt, Eos) ->
701 | [convert_type(Type, Var, Opt, Eos) || Var <- Arg];
702 |
703 | %% raw - no conversion applied (most likely default)
704 | convert_type(raw, Arg, _Opt, _Eos) ->
705 | Arg;
706 |
707 | %% Handle actual types
708 | convert_type(string, Arg, _Opt, _Eos) ->
709 | Arg;
710 | convert_type({string, Choices}, Arg, Opt, Eos) when is_list(Choices), is_list(hd(Choices)) ->
711 | lists:member(Arg, Choices) orelse
712 | fail({invalid_argument, Eos#eos.commands, Opt, Arg}),
713 | Arg;
714 | convert_type({string, Re}, Arg, Opt, Eos) ->
715 | case re:run(Arg, Re) of
716 | {match, _X} -> Arg;
717 | _ -> fail({invalid_argument, Eos#eos.commands, Opt, Arg})
718 | end;
719 | convert_type({string, Re, ReOpt}, Arg, Opt, Eos) ->
720 | case re:run(Arg, Re, ReOpt) of
721 | match -> Arg;
722 | {match, _} -> Arg;
723 | _ -> fail({invalid_argument, Eos#eos.commands, Opt, Arg})
724 | end;
725 | convert_type(int, Arg, Opt, Eos) ->
726 | get_int(Arg, Opt, Eos);
727 | convert_type({int, Opts}, Arg, Opt, Eos) ->
728 | minimax(get_int(Arg, Opt, Eos), Opts, Eos, Opt);
729 | convert_type(boolean, "true", _Opt, _Eos) ->
730 | true;
731 | convert_type(boolean, "false", _Opt, _Eos) ->
732 | false;
733 | convert_type(boolean, Arg, Opt, Eos) ->
734 | fail({invalid_argument, Eos#eos.commands, Opt, Arg});
735 | convert_type(binary, Arg, _Opt, _Eos) ->
736 | unicode:characters_to_binary(Arg);
737 | convert_type({binary, Choices}, Arg, Opt, Eos) when is_list(Choices), is_binary(hd(Choices)) ->
738 | Conv = unicode:characters_to_binary(Arg),
739 | lists:member(Conv, Choices) orelse
740 | fail({invalid_argument, Eos#eos.commands, Opt, Arg}),
741 | Conv;
742 | convert_type({binary, Re}, Arg, Opt, Eos) ->
743 | case re:run(Arg, Re) of
744 | {match, _X} -> unicode:characters_to_binary(Arg);
745 | _ -> fail({invalid_argument, Eos#eos.commands, Opt, Arg})
746 | end;
747 | convert_type({binary, Re, ReOpt}, Arg, Opt, Eos) ->
748 | case re:run(Arg, Re, ReOpt) of
749 | match -> unicode:characters_to_binary(Arg);
750 | {match, _} -> unicode:characters_to_binary(Arg);
751 | _ -> fail({invalid_argument, Eos#eos.commands, Opt, Arg})
752 | end;
753 | convert_type(float, Arg, Opt, Eos) ->
754 | get_float(Arg, Opt, Eos);
755 | convert_type({float, Opts}, Arg, Opt, Eos) ->
756 | minimax(get_float(Arg, Opt, Eos), Opts, Eos, Opt);
757 | convert_type(atom, Arg, Opt, Eos) ->
758 | try list_to_existing_atom(Arg)
759 | catch error:badarg ->
760 | fail({invalid_argument, Eos#eos.commands, Opt, Arg})
761 | end;
762 | convert_type({atom, unsafe}, Arg, _Opt, _Eos) ->
763 | list_to_atom(Arg);
764 | convert_type({atom, Choices}, Arg, Opt, Eos) ->
765 | try
766 | Atom = list_to_existing_atom(Arg),
767 | lists:member(Atom, Choices) orelse fail({invalid_argument, Eos#eos.commands, Opt, Arg}),
768 | Atom
769 | catch error:badarg ->
770 | fail({invalid_argument, Eos#eos.commands, Opt, Arg})
771 | end;
772 | convert_type({custom, Fun}, Arg, Opt, Eos) ->
773 | try Fun(Arg)
774 | catch error:invalid_argument ->
775 | fail({invalid_argument, Eos#eos.commands, Opt, Arg})
776 | end.
777 |
778 | %% Given Var, and list of {min, X}, {max, Y}, ensure that
779 | %% value falls within defined limits.
780 | minimax(Var, [], _Eos, _Opt) ->
781 | Var;
782 | minimax(Var, [{min, Min} | _], Eos, Opt) when Var < Min ->
783 | fail({invalid_argument, Eos#eos.commands, Opt, Var});
784 | minimax(Var, [{max, Max} | _], Eos, Opt) when Var > Max ->
785 | fail({invalid_argument, Eos#eos.commands, Opt, Var});
786 | minimax(Var, [Num | Tail], Eos, Opt) when is_number(Num) ->
787 | lists:member(Var, [Num|Tail]) orelse
788 | fail({invalid_argument, Eos#eos.commands, Opt, Var}),
789 | Var;
790 | minimax(Var, [_ | Tail], Eos, Opt) ->
791 | minimax(Var, Tail, Eos, Opt).
792 |
793 | %% returns int from string, or errors out with debugging info
794 | get_int(Arg, Opt, Eos) ->
795 | case string:to_integer(Arg) of
796 | {Int, []} ->
797 | Int;
798 | _ ->
799 | fail({invalid_argument, Eos#eos.commands, Opt, Arg})
800 | end.
801 |
802 | %% returns float from string, that is floating-point, or integer
803 | get_float(Arg, Opt, Eos) ->
804 | case string:to_float(Arg) of
805 | {Float, []} ->
806 | Float;
807 | _ ->
808 | %% possibly in disguise
809 | case string:to_integer(Arg) of
810 | {Int, []} ->
811 | Int;
812 | _ ->
813 | fail({invalid_argument, Eos#eos.commands, Opt, Arg})
814 | end
815 | end.
816 |
817 | %% Returns 'true' if String can be converted to a number
818 | is_digits(String) ->
819 | case string:to_integer(String) of
820 | {_Int, []} ->
821 | true;
822 | {_, _} ->
823 | case string:to_float(String) of
824 | {_Float, []} ->
825 | true;
826 | {_, _} ->
827 | false
828 | end
829 | end.
830 |
831 | %% 'maybe' nargs for an option that does not have default set still have
832 | %% to produce something, let's call it hardcoded default.
833 | default(#{default := Default}) ->
834 | Default;
835 | default(#{type := boolean}) ->
836 | true;
837 | default(#{type := int}) ->
838 | 0;
839 | default(#{type := float}) ->
840 | 0.0;
841 | default(#{type := string}) ->
842 | "";
843 | default(#{type := binary}) ->
844 | <<"">>;
845 | default(#{type := atom}) ->
846 | undefined;
847 | %% no type given, consider it 'undefined' atom
848 | default(_) ->
849 | undefined.
850 |
851 | %% command path is now in direct order
852 | format_path(Commands) ->
853 | lists:concat(lists:join(" ", Commands)) ++ ": ".
854 |
855 | %% to simplify throwing errors with the right reason
856 | fail(Reason) ->
857 | FixedPath = lists:reverse(element(2, Reason)),
858 | erlang:error({?MODULE, setelement(2, Reason, FixedPath)}).
859 |
860 | %%--------------------------------------------------------------------
861 | %% Validation and preprocessing
862 | %% Theoretically, Dialyzer should do that too.
863 | %% Practically, so many people ignore Dialyzer and then spend hours
864 | %% trying to understand why things don't work, that is makes sense
865 | %% to provide a mini-Dialyzer here.
866 |
867 | validate_impl(Command, #{progname := Prog} = Options) when is_list(Prog) ->
868 | Prefixes = maps:from_list([{P, true} || P <- maps:get(prefixes, Options, [$-])]),
869 | validate_command([{Prog, Command}], Prefixes);
870 | validate_impl(Command, #{progname := Prog} = Options) when is_atom(Prog) ->
871 | Prefixes = maps:from_list([{P, true} || P <- maps:get(prefixes, Options, [$-])]),
872 | validate_command([{atom_to_list(Prog), Command}], Prefixes);
873 | validate_impl(_Command, #{progname := _Prog} = _Options) ->
874 | fail({invalid_command, [], progname, "progname must be a list or an atom"});
875 | validate_impl(Command, Options) ->
876 | {ok, [[Prog]]} = init:get_argument(progname),
877 | validate_impl(Command, Options#{progname => Prog}).
878 |
879 | %% validates commands, throws invalid_command or invalid_option error
880 | validate_command([{Name, Cmd} | _] = Path, Prefixes) ->
881 | (is_list(Name) andalso (not is_map_key(hd(Name), Prefixes))) orelse
882 | fail({invalid_command, clean_path(Path), commands, "command name must be a string, not starting with optional prefix"}),
883 | is_map(Cmd) orelse
884 | fail({invalid_command, clean_path(Path), commands, "command description must be a map"}),
885 | is_list(maps:get(help, Cmd, [])) orelse (maps:get(help, Cmd) =:= hidden) orelse
886 | fail({invalid_command, clean_path(Path), help, "help must be a string"}),
887 | is_map(maps:get(commands, Cmd, #{})) orelse
888 | fail({invalid_command, clean_path(Path), commands, "sub-commands must be a map"}),
889 | case maps:get(handler, Cmd, optional) of
890 | optional -> ok;
891 | {Mod, ModFun} when is_atom(Mod), is_atom(ModFun) -> ok; %% map form
892 | {Mod, ModFun, _} when is_atom(Mod), is_atom(ModFun) -> ok; %% positional form
893 | {Fun, _} when is_function(Fun) -> ok; %% positional form
894 | Fun when is_function(Fun, 1) -> ok;
895 | _ -> fail({invalid_command, clean_path(Path), handler,
896 | "handler must be a fun(ArgMap), {Mod, Fun}, {fun(...), Default}, {Mod, Fun, Default} or 'optional'"})
897 | end,
898 | Cmd1 =
899 | case maps:find(arguments, Cmd) of
900 | error ->
901 | Cmd;
902 | {ok, Opts} when not is_list(Opts) ->
903 | fail({invalid_command, clean_path(Path), commands, "options must be a list"});
904 | {ok, Opts} ->
905 | Cmd#{arguments => [validate_option(Path, Opt) || Opt <- Opts]}
906 | end,
907 | %% collect all short & long option identifiers - to figure out any conflicts
908 | lists:foldl(
909 | fun ({_, #{arguments := Opts}}, Acc) ->
910 | lists:foldl(
911 | fun (#{short := Short, name := OName}, {AllS, AllL}) ->
912 | is_map_key(Short, AllS) andalso
913 | fail({invalid_option, clean_path(Path), OName, short,
914 | "short conflicting with " ++ atom_to_list(maps:get(Short, AllS))}),
915 | {AllS#{Short => OName}, AllL};
916 | (#{long := Long, name := OName}, {AllS, AllL}) ->
917 | is_map_key(Long, AllL) andalso
918 | fail({invalid_option, clean_path(Path), OName, long,
919 | "long conflicting with " ++ atom_to_list(maps:get(Long, AllL))}),
920 | {AllS, AllL#{Long => OName}};
921 | (_, AccIn) ->
922 | AccIn
923 | end, Acc, Opts);
924 | (_, Acc) ->
925 | Acc
926 | end, {#{}, #{}}, Path),
927 | %% verify all sub-commands
928 | case maps:find(commands, Cmd1) of
929 | error ->
930 | {Name, Cmd1};
931 | {ok, Sub} ->
932 | {Name, Cmd1#{commands => maps:map(
933 | fun (K, V) ->
934 | {K, Updated} = validate_command([{K, V} | Path], Prefixes),
935 | Updated
936 | end, Sub)}}
937 | end.
938 |
939 | %% validates option spec
940 | validate_option(Path, #{name := Name} = Opt) when is_atom(Name); is_list(Name); is_binary(Name) ->
941 | %% arguments cannot have unrecognised map items
942 | Unknown = maps:keys(maps:without([name, help, short, long, action, nargs, type, default, required], Opt)),
943 | Unknown =/= [] andalso fail({invalid_option, clean_path(Path), hd(Unknown), "unrecognised field"}),
944 | %% verify specific arguments
945 | %% help: string, 'hidden', or a tuple of {string(), ...}
946 | is_valid_option_help(maps:get(help, Opt, [])) orelse
947 | fail({invalid_option, clean_path(Path), Name, help, "must be a string or valid help template, ensure help template is a list"}),
948 | io_lib:printable_unicode_list(maps:get(long, Opt, [])) orelse
949 | fail({invalid_option, clean_path(Path), Name, long, "must be a printable string"}),
950 | is_boolean(maps:get(required, Opt, true)) orelse
951 | fail({invalid_option, clean_path(Path), Name, required, "must be boolean"}),
952 | io_lib:printable_unicode_list([maps:get(short, Opt, $a)]) orelse
953 | fail({invalid_option, clean_path(Path), Name, short, "must be a printable character"}),
954 | Opt1 = maybe_validate(action, Opt, fun validate_action/3, Path),
955 | Opt2 = maybe_validate(type, Opt1, fun validate_type/3, Path),
956 | maybe_validate(nargs, Opt2, fun validate_args/3, Path);
957 | validate_option(Path, _Opt) ->
958 | fail({invalid_option, clean_path(Path), "", name, "argument must be a map, and specify 'name'"}).
959 |
960 | maybe_validate(Key, Map, Fun, Path) when is_map_key(Key, Map) ->
961 | maps:put(Key, Fun(maps:get(Key, Map), Path, Map), Map);
962 | maybe_validate(_Key, Map, _Fun, _Path) ->
963 | Map.
964 |
965 | %% validate action field
966 | validate_action(store, _Path, _Opt) ->
967 | store;
968 | validate_action({store, Term}, _Path, _Opt) ->
969 | {store, Term};
970 | validate_action(append, _Path, _Opt) ->
971 | append;
972 | validate_action({append, Term}, _Path, _Opt) ->
973 | {append, Term};
974 | validate_action(count, _Path, _Opt) ->
975 | count;
976 | validate_action(extend, _Path, #{nargs := Nargs}) when
977 | Nargs =:= list; Nargs =:= nonempty_list; Nargs =:= all; is_integer(Nargs) ->
978 | extend;
979 | validate_action(extend, Path, #{name := Name}) ->
980 | fail({invalid_option, clean_path(Path), Name, action, "extend action works only with lists"});
981 | validate_action(_Action, Path, #{name := Name}) ->
982 | fail({invalid_option, clean_path(Path), Name, action, "unsupported"}).
983 |
984 | %% validate type field
985 | validate_type(Simple, _Path, _Opt) when Simple =:= boolean; Simple =:= int; Simple =:= float;
986 | Simple =:= string; Simple =:= binary; Simple =:= atom; Simple =:= {atom, unsafe} ->
987 | Simple;
988 | validate_type({custom, Fun}, _Path, _Opt) when is_function(Fun, 1) ->
989 | {custom, Fun};
990 | validate_type({float, Opts}, Path, #{name := Name}) ->
991 | [fail({invalid_option, clean_path(Path), Name, type, "invalid validator"})
992 | || {Kind, Val} <- Opts, (Kind =/= min andalso Kind =/= max) orelse (not is_float(Val))],
993 | {float, Opts};
994 | validate_type({int, Opts}, Path, #{name := Name}) ->
995 | [fail({invalid_option, clean_path(Path), Name, type, "invalid validator"})
996 | || {Kind, Val} <- Opts, (Kind =/= min andalso Kind =/= max) orelse (not is_integer(Val))],
997 | {int, Opts};
998 | validate_type({atom, Choices} = Valid, Path, #{name := Name}) when is_list(Choices) ->
999 | [fail({invalid_option, clean_path(Path), Name, type, "unsupported"}) || C <- Choices, not is_atom(C)],
1000 | Valid;
1001 | validate_type({string, Re} = Valid, _Path, _Opt) when is_list(Re) ->
1002 | Valid;
1003 | validate_type({string, Re, L} = Valid, _Path, _Opt) when is_list(Re), is_list(L) ->
1004 | Valid;
1005 | validate_type({binary, Re} = Valid, _Path, _Opt) when is_binary(Re) ->
1006 | Valid;
1007 | validate_type({binary, Choices} = Valid, _Path, _Opt) when is_list(Choices), is_binary(hd(Choices)) ->
1008 | Valid;
1009 | validate_type({binary, Re, L} = Valid, _Path, _Opt) when is_binary(Re), is_list(L) ->
1010 | Valid;
1011 | validate_type(_Type, Path, #{name := Name}) ->
1012 | fail({invalid_option, clean_path(Path), Name, type, "unsupported"}).
1013 |
1014 | validate_args(N, _Path, _Opt) when is_integer(N), N >= 1 -> N;
1015 | validate_args(Simple, _Path, _Opt) when Simple =:= all; Simple =:= list; Simple =:= 'maybe'; Simple =:= nonempty_list ->
1016 | Simple;
1017 | validate_args({'maybe', Term}, _Path, _Opt) -> {'maybe', Term};
1018 | validate_args(_Nargs, Path, #{name := Name}) ->
1019 | fail({invalid_option, clean_path(Path), Name, nargs, "unsupported"}).
1020 |
1021 | %% used to throw an error - strips command component out of path
1022 | clean_path(Path) ->
1023 | [Cmd || {Cmd, _} <- Path].
1024 |
1025 | is_valid_option_help(hidden) ->
1026 | true;
1027 | is_valid_option_help(Help) when is_list(Help) ->
1028 | true;
1029 | is_valid_option_help({Short, Desc}) when is_list(Short), is_list(Desc) ->
1030 | %% verify that Desc is a list of string/type/default
1031 | lists:all(fun(type) -> true; (default) -> true; (S) when is_list(S) -> true; (_) -> false end, Desc);
1032 | is_valid_option_help({Short, Desc}) when is_list(Short), is_function(Desc, 0) ->
1033 | true;
1034 | is_valid_option_help(_) ->
1035 | false.
1036 |
1037 | %%--------------------------------------------------------------------
1038 | %% Built-in Help formatter
1039 |
1040 | %% Example format:
1041 | %%
1042 | %% usage: utility [-rxvf] [-i ] [--float ] []
1043 | %%
1044 | %% Commands:
1045 | %% start verifies configuration and starts server
1046 | %% stop stops running server
1047 | %%
1048 | %% Optional arguments:
1049 | %% -r recursive
1050 | %% -v increase verbosity level
1051 | %% -f force
1052 | %% -i interval set
1053 | %% --float floating-point long form argument
1054 | %%
1055 |
1056 | %% Example for deeper nested help (amount of flags reduced from previous example)
1057 | %%
1058 | %% usage: utility [-rz] [-i ] start []
1059 | %%
1060 | %% Optional arguments:
1061 | %% -r recursive
1062 | %% -z use zlib compression
1063 | %% -i integer variable
1064 | %% SERVER server to start
1065 | %% NAME extra name to pass
1066 | %%
1067 |
1068 | format_help({RootCmd, Root}, Format) ->
1069 | Prefix = hd(maps:get(prefixes, Format, [$-])),
1070 | Nested = maps:get(command, Format, []),
1071 | %% descent into commands collecting all options on the way
1072 | {_CmdName, Cmd, AllArgs} = collect_options(RootCmd, Root, Nested, []),
1073 | %% split arguments into Flags, Options, Positional, and create help lines
1074 | {_, Longest, Flags, Opts, Args, OptL, PosL} = lists:foldl(fun format_opt_help/2,
1075 | {Prefix, 0, "", [], [], [], []}, AllArgs),
1076 | %% collect and format sub-commands
1077 | Immediate = maps:get(commands, Cmd, #{}),
1078 | {Long, Subs} = maps:fold(
1079 | fun (_Name, #{help := hidden}, {Long, SubAcc}) ->
1080 | {Long, SubAcc};
1081 | (Name, Sub, {Long, SubAcc}) ->
1082 | Help = maps:get(help, Sub, ""),
1083 | {max(Long, string:length(Name)), [{Name, Help}|SubAcc]}
1084 | end, {Longest, []}, Immediate),
1085 | %% format sub-commands
1086 | SubFormat = io_lib:format(" ~~-~bts ~~ts~n", [Long]),
1087 | Commands = [io_lib:format(SubFormat, [N, D]) || {N, D} <- lists:reverse(Subs)],
1088 | ShortCmd =
1089 | case map_size(Immediate) of
1090 | 0 when Nested =:= [] ->
1091 | "";
1092 | 0 ->
1093 | [$ | lists:concat(lists:join(" ", Nested))];
1094 | Small when Small < 4 ->
1095 | " " ++ lists:concat(lists:join(" ", Nested)) ++ " {" ++
1096 | lists:concat(lists:join("|", maps:keys(Immediate))) ++ "}";
1097 | _Largs ->
1098 | io_lib:format("~ts ", [lists:concat(lists:join(" ", Nested))])
1099 | end,
1100 | %% format flags
1101 | FlagsForm = if Flags =:=[] -> ""; true -> io_lib:format(" [~tc~ts]", [Prefix, Flags]) end,
1102 | %% format extended view
1103 | OptFormat = io_lib:format(" ~~-~bts ~~ts~n", [Longest]),
1104 | %% split OptLines into positional and optional arguments
1105 | FormattedOpts = [io_lib:format(OptFormat, [Hdr, Dsc]) || {Hdr, Dsc} <- lists:reverse(OptL)],
1106 | FormattedArgs = [io_lib:format(OptFormat, [Hdr, Dsc]) || {Hdr, Dsc} <- lists:reverse(PosL)],
1107 | %% format first usage line
1108 | io_lib:format("usage: ~ts~ts~ts~ts~ts~ts~n~ts~ts~ts", [RootCmd, ShortCmd, FlagsForm, Opts, Args,
1109 | maybe_add("~n~ts", maps:get(help, Root, "")),
1110 | maybe_add("~nSubcommands:~n~ts", Commands),
1111 | maybe_add("~nArguments:~n~ts", FormattedArgs),
1112 | maybe_add("~nOptional arguments:~n~ts", FormattedOpts)]).
1113 |
1114 | %% collects options on the Path, and returns found Command
1115 | collect_options(CmdName, Command, [], Args) ->
1116 | {CmdName, Command, maps:get(arguments, Command, []) ++ Args};
1117 | collect_options(CmdName, Command, [Cmd|Tail], Args) ->
1118 | Sub = maps:get(commands, Command),
1119 | SubCmd = maps:get(Cmd, Sub),
1120 | collect_options(CmdName ++ " " ++ Cmd, SubCmd, Tail, maps:get(arguments, Command, []) ++ Args).
1121 |
1122 | %% conditionally adds text and empty lines
1123 | maybe_add(_ToAdd, []) ->
1124 | [];
1125 | maybe_add(ToAdd, List) ->
1126 | io_lib:format(ToAdd, [List]).
1127 |
1128 | %% create help line for every option, collecting together all flags, short options,
1129 | %% long options, and positional arguments
1130 |
1131 | %% format optional argument
1132 | format_opt_help(#{help := hidden}, Acc) ->
1133 | Acc;
1134 | format_opt_help(Opt, {Prefix, Longest, Flags, Opts, Args, OptL, PosL}) when ?IS_OPTIONAL(Opt) ->
1135 | Desc = format_description(Opt),
1136 | %% does it need an argument? look for nargs and action
1137 | RequiresArg = requires_argument(Opt),
1138 | %% long form always added to Opts
1139 | NonOption = maps:get(required, Opt, false) =:= true,
1140 | {Name0, MaybeOpt0} =
1141 | case maps:find(long, Opt) of
1142 | error ->
1143 | {"", []};
1144 | {ok, Long} when NonOption, RequiresArg ->
1145 | FN = [Prefix | Long],
1146 | {FN, [format_required(true, FN ++ " ", Opt)]};
1147 | {ok, Long} when RequiresArg ->
1148 | FN = [Prefix | Long],
1149 | {FN, [format_required(false, FN ++ " ", Opt)]};
1150 | {ok, Long} when NonOption ->
1151 | FN = [Prefix | Long],
1152 | {FN, [[$ |FN]]};
1153 | {ok, Long} ->
1154 | FN = [Prefix | Long],
1155 | {FN, [io_lib:format(" [~ts]", [FN])]}
1156 | end,
1157 | %% short may go to flags, or Opts
1158 | {Name, MaybeFlag, MaybeOpt1} =
1159 | case maps:find(short, Opt) of
1160 | error ->
1161 | {Name0, [], MaybeOpt0};
1162 | {ok, Short} when RequiresArg ->
1163 | SN = [Prefix, Short],
1164 | {maybe_concat(SN, Name0), [],
1165 | [format_required(NonOption, SN ++ " ", Opt) | MaybeOpt0]};
1166 | {ok, Short} ->
1167 | {maybe_concat([Prefix, Short], Name0), [Short], MaybeOpt0}
1168 | end,
1169 | %% apply override for non-default usage (in form of {Quick, Advanced} tuple
1170 | MaybeOpt2 =
1171 | case maps:find(help, Opt) of
1172 | {ok, {Str, _}} ->
1173 | [$ | Str];
1174 | _ ->
1175 | MaybeOpt1
1176 | end,
1177 | %% name length, capped at 24
1178 | NameLen = string:length(Name),
1179 | Capped = min(24, NameLen),
1180 | {Prefix, max(Capped, Longest), Flags ++ MaybeFlag, Opts ++ MaybeOpt2, Args, [{Name, Desc} | OptL], PosL};
1181 |
1182 | %% format positional argument
1183 | format_opt_help(#{name := Name} = Opt, {Prefix, Longest, Flags, Opts, Args, OptL, PosL}) ->
1184 | Desc = format_description(Opt),
1185 | %% positional, hence required
1186 | LName = io_lib:format("~ts", [Name]),
1187 | LPos = case maps:find(help, Opt) of
1188 | {ok, {Str, _}} ->
1189 | [$ | Str];
1190 | _ ->
1191 | format_required(maps:get(required, Opt, true), "", Opt)
1192 | end,
1193 | {Prefix, max(Longest, string:length(LName)), Flags, Opts, Args ++ LPos, OptL, [{LName, Desc}|PosL]}.
1194 |
1195 | %% custom format
1196 | format_description(#{help := {_Short, Fun}}) when is_function(Fun, 0) ->
1197 | Fun();
1198 | format_description(#{help := {_Short, Desc}} = Opt) ->
1199 | lists:flatmap(
1200 | fun (type) ->
1201 | format_type(Opt);
1202 | (default) ->
1203 | format_default(Opt);
1204 | (String) ->
1205 | String
1206 | end, Desc
1207 | );
1208 | %% default format: "desc", "desc (type)", "desc (default)", "desc (type, default)"
1209 | format_description(#{name := Name} = Opt) ->
1210 | NameStr = maps:get(help, Opt, io_lib:format("~ts", [Name])),
1211 | case {NameStr, format_type(Opt), format_default(Opt)} of
1212 | {"", "", Type} -> Type;
1213 | {"", Default, ""} -> Default;
1214 | {Desc, "", ""} -> Desc;
1215 | {Desc, "", Default} -> [Desc, " (", Default, ")"];
1216 | {Desc, Type, ""} -> [Desc, " (", Type, ")"];
1217 | {"", Type, Default} -> [Type, ", ", Default];
1218 | {Desc, Type, Default} -> [Desc, " (", Type, ", ", Default, ")"]
1219 | end.
1220 |
1221 | %% option formatting helpers
1222 | maybe_concat(No, []) -> No;
1223 | maybe_concat(No, L) -> No ++ ", " ++ L.
1224 |
1225 | format_required(true, Extra, #{name := Name} = Opt) ->
1226 | io_lib:format(" ~ts<~ts>~ts", [Extra, Name, format_nargs(Opt)]);
1227 | format_required(false, Extra, #{name := Name} = Opt) ->
1228 | io_lib:format(" [~ts<~ts>~ts]", [Extra, Name, format_nargs(Opt)]).
1229 |
1230 | format_nargs(#{nargs := Dots}) when Dots =:= list; Dots =:= all; Dots =:= nonempty_list ->
1231 | "...";
1232 | format_nargs(_) ->
1233 | "".
1234 |
1235 | format_type(#{type := {int, Choices}}) when is_list(Choices), is_integer(hd(Choices)) ->
1236 | io_lib:format("choice: ~s", [lists:join(", ", [integer_to_list(C) || C <- Choices])]);
1237 | format_type(#{type := {float, Choices}}) when is_list(Choices), is_number(hd(Choices)) ->
1238 | io_lib:format("choice: ~s", [lists:join(", ", [io_lib:format("~g", [C]) || C <- Choices])]);
1239 | format_type(#{type := {Num, Valid}}) when Num =:= int; Num =:= float ->
1240 | case {proplists:get_value(min, Valid), proplists:get_value(max, Valid)} of
1241 | {undefined, undefined} ->
1242 | io_lib:format("~s", [Num]);
1243 | {Min, undefined} ->
1244 | io_lib:format("~s >= ~tp", [Num, Min]);
1245 | {undefined, Max} ->
1246 | io_lib:format("~s <= ~tp", [Num, Max]);
1247 | {Min, Max} ->
1248 | io_lib:format("~tp <= ~s <= ~tp", [Min, Num, Max])
1249 | end;
1250 | format_type(#{type := {string, Re, _}}) when is_list(Re), not is_list(hd(Re)) ->
1251 | io_lib:format("string re: ~ts", [Re]);
1252 | format_type(#{type := {string, Re}}) when is_list(Re), not is_list(hd(Re)) ->
1253 | io_lib:format("string re: ~ts", [Re]);
1254 | format_type(#{type := {binary, Re}}) when is_binary(Re) ->
1255 | io_lib:format("binary re: ~ts", [Re]);
1256 | format_type(#{type := {binary, Re, _}}) when is_binary(Re) ->
1257 | io_lib:format("binary re: ~ts", [Re]);
1258 | format_type(#{type := {StrBin, Choices}}) when StrBin =:= string orelse StrBin =:= binary, is_list(Choices) ->
1259 | io_lib:format("choice: ~ts", [lists:join(", ", Choices)]);
1260 | format_type(#{type := atom}) ->
1261 | "existing atom";
1262 | format_type(#{type := {atom, unsafe}}) ->
1263 | "atom";
1264 | format_type(#{type := {atom, Choices}}) ->
1265 | io_lib:format("choice: ~ts", [lists:join(", ", [atom_to_list(C) || C <- Choices])]);
1266 | format_type(#{type := boolean}) ->
1267 | "";
1268 | format_type(#{type := Type}) when is_atom(Type) ->
1269 | io_lib:format("~ts", [Type]);
1270 | format_type(_Opt) ->
1271 | "".
1272 |
1273 | format_default(#{default := Def}) when is_list(Def); is_binary(Def); is_atom(Def) ->
1274 | io_lib:format("~ts", [Def]);
1275 | format_default(#{default := Def}) ->
1276 | io_lib:format("~tp", [Def]);
1277 | format_default(_) ->
1278 | "".
1279 |
--------------------------------------------------------------------------------
/src/cli.erl:
--------------------------------------------------------------------------------
1 | %%%-------------------------------------------------------------------
2 | %%% @author Maxim Fedorov,
3 | %%% @doc
4 | %%% Command line utility behaviour. Usage example:
5 | %%%
6 | %%% From an escript main/1 function (requires `-mode(compile)'):
7 | %%% ```
8 | %%% cli:run(Args).
9 | %%% '''
10 | %%%
11 | %%% Or, to limit cli behaviour discovery,
12 | %%% ```
13 | %%% cli:run(Args, #{modules => ?MODULE, progname => ?MODULE}).
14 | %%% '''
15 | %%% Other options available for run/2:
16 | %%% - `modules':
17 | %%% - `all_loaded' - search all loaded modules (`code:all_loaded()') for `cli' behaviour
18 | %%% - `module()' - use this module (must export `cli/0')
19 | %%% - [module()] - list of modules (must export `cli/0')
20 | %%% - `warn': set to `suppress' suppresses warnings logged
21 | %%% - `error': defines what action is taken upon parser error. Use `ok' to completely ignore the
22 | %%% error (historical behaviour, useful for testing), `error' to raise an exception,
23 | %%% `halt' to halt the emulator with exit code 1 (default behaviour), and `{halt, non_neg_integer()}'
24 | %%% for a custom exit code halting the emulator
25 | %%% - `help': set to false suppresses printing `usage' when parser produces
26 | %%% an error, and disables default --help/-h behaviour
27 | %%% - `prefixes': prefixes passed to argparse
28 | %%% - `progname': specifies executable name instead of 'erl'
29 | %%%
30 | %%%
31 | %%% Warnings are printed to OTP logger, unless suppressed.
32 | %%%
33 | %%% cli framework attempts to create a handler for each
34 | %%% command exported, including intermediate (non-leaf)
35 | %%% commands, if it can find function exported with
36 | %%% suitable signature.
37 | %%%
38 | %%% cli examples are available on GitHub
39 | %%%
40 | %%% @end
41 |
42 | -module(cli).
43 | -author("maximfca@gmail.com").
44 |
45 | -export([
46 | run/1,
47 | run/2
48 | ]).
49 |
50 | %%--------------------------------------------------------------------
51 | %% Behaviour definition
52 |
53 | %% Callback returning CLI mappings.
54 | %% Must return a command, that may contain sub-commands.
55 | %% Also returns arguments, and handler.
56 | -callback cli() -> args:command().
57 |
58 | %%--------------------------------------------------------------------
59 | %% API
60 |
61 | -compile(warn_missing_spec).
62 |
63 | -spec run(Args :: [string()]) -> term().
64 | %% @equiv run(Args, #{})
65 | run(Args) ->
66 | run(Args, #{}).
67 |
68 | %% Options map.
69 | %% Allows to choose which modules to consider, and error handling mode.
70 | %% `modules' can be:
71 | %% `all_loaded' - code:all_loaded(), search for `cli' behaviour,
72 | %% module() for a single module (may not have `cli' behaviour),
73 | %% [module()] for a list of modules (may not have `cli' behaviour)
74 | %% `warn' set to `suppress' suppresses warnings logged
75 | %% `help' set to false suppresses printing `usage' when parser produces
76 | %% an error, and disables default --help/-h behaviour
77 | -type run_options() :: #{
78 | modules => all_loaded | module() | [module()],
79 | warn => suppress | warn,
80 | help => boolean(),
81 | error => ok | error | halt | {halt, non_neg_integer()},
82 | prefixes => [integer()],%% prefixes passed to argparse
83 | %% default value for all missing not required arguments
84 | default => term(),
85 | progname => string() | atom() %% specifies executable name instead of 'erl'
86 | }.
87 |
88 | %% @doc CLI entry point, parses arguments and executes selected function.
89 | %% Finds all modules loaded, and implementing cli behaviour,
90 | %% then matches a command and runs handler defined for
91 | %% a command.
92 | %% @param Args arguments used to run CLI, e.g. init:get_plain_arguments().
93 | %% @returns callback result, or 'ok' when help/error message printed, and
94 | %% `error' parameter is set to `ok' (meaning, ignore errors, always return ok)
95 | -spec run([string()], run_options()) -> term().
96 | run(Args, Options) ->
97 | Modules = modules(maps:get(modules, Options, all_loaded)),
98 | CmdMap = discover_commands(Modules, Options),
99 | dispatch(Args, CmdMap, Modules, Options).
100 |
101 | %%--------------------------------------------------------------------
102 | %% Internal implementation
103 |
104 | -include_lib("kernel/include/logger.hrl").
105 |
106 | %% Returns a list of all modules providing 'cli' behaviour, or
107 | %% a list of modules.
108 | modules(all_loaded) ->
109 | [
110 | Module || {Module, _} <- code:all_loaded(),
111 | lists:member(?MODULE, behaviours(Module))
112 | ];
113 | modules(Mod) when is_atom(Mod) ->
114 | [Mod];
115 | modules(Mods) when is_list(Mods) ->
116 | Mods.
117 |
118 | behaviours(Module) ->
119 | Attrs = proplists:get_value(attributes, Module:module_info(), []),
120 | lists:flatten(proplists:get_all_values(behavior, Attrs) ++
121 | proplists:get_all_values(behaviour, Attrs)).
122 |
123 | %%
124 | discover_commands(Modules, Options) ->
125 | Warn = maps:get(warn, Options, warn),
126 | ModCount = length(Modules),
127 | lists:foldl(
128 | fun (Mod, Cmds) ->
129 | ModCmd =
130 | try {_, MCmd} = args:validate(Mod:cli(), Options), MCmd
131 | catch
132 | Class:Reason:Stack when Warn =:= warn ->
133 | ?LOG_WARNING("Error calling ~s:cli(): ~s:~p~n~p",
134 | [Mod, Class, Reason, Stack]), #{};
135 | _:_ when Warn =:= suppress ->
136 | #{}
137 | end,
138 | %% handlers: use first non-empty handler
139 | Cmds1 = case maps:find(handler, ModCmd) of
140 | {ok, Handler} when is_map_key(handler, Cmds) ->
141 | %% merge handler - and warn when not suppressed
142 | Warn =:= warn andalso
143 | ?LOG_WARNING("Multiple handlers defined for top-level command, ~p chosen, ~p ignored",
144 | [maps:get(handler, Cmds), Handler]),
145 | Cmds;
146 | {ok, Handler} ->
147 | Cmds#{handler => Handler};
148 | error ->
149 | Cmds
150 | end,
151 | %% help: concatenate help lines
152 | Cmds2 =
153 | if is_map_key(help, ModCmd) ->
154 | Cmds1#{help => maps:get(help, ModCmd) ++ maps:get(help, Cmds1, "")};
155 | true -> Cmds1
156 | end,
157 | %% merge arguments, and warn if warnings are not suppressed, and there
158 | %% is more than a single module
159 | Cmds3 = merge_arguments(maps:get(arguments, ModCmd, []),
160 | (ModCount > 1 andalso Warn =:= warn), Cmds2),
161 | %% merge commands
162 | merge_commands(maps:get(commands, ModCmd, #{}), Mod, Options, Cmds3)
163 | end, #{}, Modules).
164 |
165 | %% Dispatches Args over Modules, with specified ErrMode
166 | dispatch(Args, CmdMap, Modules, Options) ->
167 | HelpEnabled = maps:get(help, Options, true),
168 | %% attempt to dispatch the command
169 | try args:parse(Args, CmdMap, Options) of
170 | {ArgMap, PathTo} ->
171 | run_handler(CmdMap, ArgMap, PathTo, undefined);
172 | ArgMap ->
173 | %{ maps:find(default, Options), Modules, Options}
174 | run_handler(CmdMap, ArgMap, {[], CmdMap}, {Modules, Options})
175 | catch
176 | error:{args, Reason} when HelpEnabled =:= false ->
177 | io:format("error: ~s", [args:format_error(Reason)]),
178 | dispatch_error(Options, Reason);
179 | error:{args, Reason} ->
180 | %% see if it was cry for help that triggered error message
181 | Prefixes = maps:get(prefixes, Options, "-"),
182 | case help_requested(Reason, Prefixes) of
183 | false ->
184 | Fmt = args:format_error(Reason, CmdMap, Options),
185 | io:format("error: ~s", [Fmt]);
186 | CmdPath ->
187 | Fmt = args:help(CmdMap, Options#{command => tl(CmdPath)}),
188 | io:format("~s", [Fmt])
189 | end,
190 | dispatch_error(Options, Reason)
191 | end.
192 |
193 | dispatch_error(#{error := ok}, _Reason) ->
194 | ok;
195 | dispatch_error(#{error := error}, Reason) ->
196 | error(Reason);
197 | dispatch_error(#{error := halt}, _Reason) ->
198 | erlang:halt(1);
199 | dispatch_error(#{error := {halt, Exit}}, _Reason) ->
200 | erlang:halt(Exit);
201 | %% default is halt(1)
202 | dispatch_error(_Options, _Reason) ->
203 | erlang:halt(1).
204 |
205 | %% Executes handler
206 | run_handler(CmdMap, ArgMap, {Path, #{handler := {Mod, ModFun, Default}}}, _MO) ->
207 | ArgList = arg_map_to_arg_list(CmdMap, Path, ArgMap, Default),
208 | %% if argument count may not match, better error can be produced
209 | erlang:apply(Mod, ModFun, ArgList);
210 | run_handler(_CmdMap, ArgMap, {_Path, #{handler := {Mod, ModFun}}}, _MO) when is_atom(Mod), is_atom(ModFun) ->
211 | Mod:ModFun(ArgMap);
212 | run_handler(CmdMap, ArgMap, {Path, #{handler := {Fun, Default}}}, _MO) when is_function(Fun) ->
213 | ArgList = arg_map_to_arg_list(CmdMap, Path, ArgMap, Default),
214 | %% if argument count may not match, better error can be produced
215 | erlang:apply(Fun, ArgList);
216 | run_handler(_CmdMap, ArgMap, {_Path, #{handler := Handler}}, _MO) when is_function(Handler, 1) ->
217 | Handler(ArgMap);
218 | run_handler(CmdMap, ArgMap, {[], _}, {Modules, Options}) ->
219 | % {undefined, {ok, Default}, Modules, Options}
220 | exec_cli(Modules, CmdMap, [ArgMap], Options).
221 |
222 | %% finds first module that exports ctl/1 and execute it
223 | exec_cli([], CmdMap, _ArgMap, ArgOpts) ->
224 | %% command not found, let's print usage
225 | io:format(args:help(CmdMap, ArgOpts));
226 | exec_cli([Mod|Tail], CmdMap, Args, ArgOpts) ->
227 | case erlang:function_exported(Mod, cli, length(Args)) of
228 | true ->
229 | erlang:apply(Mod, cli, Args);
230 | false ->
231 | exec_cli(Tail, CmdMap, Args, ArgOpts)
232 | end.
233 |
234 | %% argparse does not allow clashing options, so if cli is ever to support
235 | %% that, logic to un-clash should be here
236 | merge_arguments([], _Warn, Existing) ->
237 | Existing;
238 | merge_arguments(Args, Warn, Existing) ->
239 | Warn andalso
240 | ?LOG_WARNING("cli: multiple modules may export global attributes: ~p", [Args]),
241 | ExistingArgs = maps:get(arguments, Existing, []),
242 | Existing#{arguments => ExistingArgs ++ Args}.
243 |
244 | %% argparse accepts a map of commands, which means, commands names
245 | %% can never clash. Yet for cli it is possible when multiple modules
246 | %% export command with the same name. For this case, skip duplicate
247 | %% command names, emitting a warning.
248 | merge_commands(Cmds, Mod, Options, Existing) ->
249 | Warn = maps:get(warn, Options, warn),
250 | MergedCmds = maps:fold(
251 | fun (Name, Cmd, Acc) ->
252 | case maps:find(Name, Acc) of
253 | error ->
254 | %% merge command with name Name into Acc-umulator
255 | Acc#{Name => create_handlers(Mod, Name, Cmd, maps:find(default, Options))};
256 | {ok, Another} when Warn =:= warn ->
257 | %% do not merge this command, another module already exports it
258 | ?LOG_WARNING("cli: duplicate definition for ~s found, skipping ~P",
259 | [Name, 8, Another]), Acc;
260 | {ok, _Another} when Warn =:= suppress ->
261 | %% don't merge duplicate, and don't complain about it
262 | Acc
263 | end
264 | end, maps:get(commands, Existing, #{}), Cmds
265 | ),
266 | Existing#{commands => MergedCmds}.
267 |
268 | %% Descends into sub-commands creating handlers where applicable
269 | create_handlers(Mod, CmdName, Cmd0, DefaultTerm) ->
270 | Handler =
271 | case maps:find(handler, Cmd0) of
272 | error ->
273 | make_handler(CmdName, Mod, DefaultTerm);
274 | {ok, optional} ->
275 | make_handler(CmdName, Mod, DefaultTerm);
276 | {ok, Existing} ->
277 | Existing
278 | end,
279 | %%
280 | Cmd = Cmd0#{handler => Handler},
281 | case maps:find(commands, Cmd) of
282 | error ->
283 | Cmd;
284 | {ok, Sub} ->
285 | NewCmds = maps:map(fun (CN, CV) -> create_handlers(Mod, CN, CV, DefaultTerm) end, Sub),
286 | Cmd#{commands => NewCmds}
287 | end.
288 |
289 | %% makes handler in required format
290 | make_handler(CmdName, Mod, error) ->
291 | try
292 | {Mod, list_to_existing_atom(CmdName)}
293 | catch
294 | error:badarg ->
295 | error({invalid_command, [CmdName], handler, "handler for command does not exist"})
296 | end;
297 | make_handler(CmdName, Mod, {ok, Default}) ->
298 | {Mod, list_to_existing_atom(CmdName), Default}.
299 |
300 | %% Finds out whether it was --help/-h requested, and exception was thrown due to that
301 | help_requested({unknown_argument, CmdPath, [Prefix, $h]}, Prefixes) ->
302 | is_prefix(Prefix, Prefixes, CmdPath);
303 | help_requested({unknown_argument, CmdPath, [Prefix, Prefix, $h, $e, $l, $p]}, Prefixes) ->
304 | is_prefix(Prefix, Prefixes, CmdPath);
305 | help_requested(_, _) ->
306 | false.
307 |
308 | %% returns CmdPath when Prefix is one of supplied Prefixes
309 | is_prefix(Prefix, Prefixes, CmdPath) ->
310 | case lists:member(Prefix, Prefixes) of
311 | true ->
312 | CmdPath;
313 | false ->
314 | false
315 | end.
316 |
317 | %% Given command map, path to reach a specific command, and a parsed argument
318 | %% map, returns a list of arguments (effectively used to transform map-based
319 | %% callback handler into positional).
320 | arg_map_to_arg_list(Command, Path, ArgMap, Default) ->
321 | AllArgs = collect_arguments(Command, Path, []),
322 | [maps:get(Arg, ArgMap, Default) || #{name := Arg} <- AllArgs].
323 |
324 | %% recursively descend into Path, ignoring arguments with duplicate names
325 | collect_arguments(Command, [], Acc) ->
326 | Acc ++ maps:get(arguments, Command, []);
327 | collect_arguments(Command, [H|Tail], Acc) ->
328 | Args = maps:get(arguments, Command, []),
329 | Next = maps:get(H, maps:get(commands, Command, H)),
330 | collect_arguments(Next, Tail, Acc ++ Args).
331 |
--------------------------------------------------------------------------------
/test/args_SUITE.erl:
--------------------------------------------------------------------------------
1 | %%%-------------------------------------------------------------------
2 | %%% @copyright (C) 2020-2021, Maxim Fedorov
3 | %%% @doc
4 | %%% Tests for argparse library.
5 | %%% @end
6 | -module(args_SUITE).
7 | -author("maximfca@gmail.com").
8 |
9 | -export([suite/0, all/0, groups/0]).
10 |
11 | -export([
12 | basic/0, basic/1,
13 | long_form_eq/0, long_form_eq/1,
14 | single_arg_built_in_types/0, single_arg_built_in_types/1,
15 | complex_command/0, complex_command/1,
16 | unicode/0, unicode/1,
17 | errors/0, errors/1,
18 | args/0, args/1,
19 | argparse/0, argparse/1,
20 | negative/0, negative/1,
21 | nodigits/0, nodigits/1,
22 | python_issue_15112/0, python_issue_15112/1,
23 | default_for_not_required/0, default_for_not_required/1,
24 | global_default/0, global_default/1,
25 | type_validators/0, type_validators/1,
26 | error_format/0, error_format/1,
27 | subcommand/0, subcommand/1,
28 | very_short/0, very_short/1,
29 | multi_short/0, multi_short/1,
30 | proxy_arguments/0, proxy_arguments/1,
31 | usage/0, usage/1,
32 | usage_required_args/0, usage_required_args/1,
33 | readme/0, readme/1,
34 | error_usage/0, error_usage/1,
35 | meta/0, meta/1,
36 | usage_template/0, usage_template/1
37 | ]).
38 |
39 | -include_lib("common_test/include/ct.hrl").
40 | -include_lib("stdlib/include/assert.hrl").
41 |
42 |
43 | suite() ->
44 | [{timetrap, {seconds, 5}}].
45 |
46 | groups() ->
47 | [{parallel, [parallel], [
48 | basic, long_form_eq, single_arg_built_in_types, complex_command, errors,
49 | unicode, args, args, negative, proxy_arguments, default_for_not_required,
50 | nodigits, python_issue_15112, type_validators, subcommand, error_format,
51 | very_short, multi_short, usage, readme, error_usage, meta, usage_template,
52 | global_default
53 | ]}].
54 |
55 | all() ->
56 | [{group, parallel}].
57 |
58 | %%--------------------------------------------------------------------
59 | %% Helpers
60 |
61 | prog() ->
62 | {ok, [[ProgStr]]} = init:get_argument(progname), ProgStr.
63 |
64 | make_error(CmdLine, CmdMap) ->
65 | try parse(CmdLine, CmdMap), exit(must_never_succeed)
66 | catch error:{args, Reason} ->
67 | args:format_error(Reason)
68 | end.
69 |
70 | parse_opts(Args, Opts) ->
71 | args:parse(string:lexemes(Args, " "), #{arguments => Opts}).
72 |
73 | parse(Args, Command) ->
74 | args:parse(string:lexemes(Args, " "), Command).
75 |
76 | parse_cmd(Args, Command) ->
77 | args:parse(string:lexemes(Args, " "), #{commands => Command}).
78 |
79 | %% ubiquitous command - contains *every* combination
80 | ubiq_cmd() ->
81 | #{
82 | arguments => [
83 | #{name => r, short => $r, type => boolean, help => "recursive"},
84 | #{name => f, short => $f, type => boolean, long => "-force", help => "force"},
85 | #{name => v, short => $v, type => boolean, action => count, help => "verbosity level"},
86 | #{name => interval, short => $i, type => {int, [{min, 1}]}, help => "interval set"},
87 | #{name => weird, long => "-req", help => "required optional, right?"},
88 | #{name => float, long => "-float", type => float, default => 3.14, help => "floating-point long form argument"}
89 | ],
90 | commands => #{
91 | "start" => #{help => "verifies configuration and starts server",
92 | arguments => [
93 | #{name => server, help => "server to start"},
94 | #{name => shard, short => $s, type => int, nargs => nonempty_list, help => "initial shards"},
95 | #{name => part, short => $p, type => int, nargs => list, help => hidden},
96 | #{name => z, short => $z, type => {int, [{min, 1}, {max, 10}]}, help => "between"},
97 | #{name => l, short => $l, type => {int, [{max, 10}]}, nargs => 'maybe', help => "maybe lower"},
98 | #{name => more, short => $m, type => {int, [{max, 10}]}, help => "less than 10"},
99 | #{name => optpos, required => false, type => {int, []}, help => "optional positional"},
100 | #{name => bin, short => $b, type => {binary, <<"m">>}, help => "binary with re"},
101 | #{name => g, short => $g, type => {binary, <<"m">>, []}, help => "binary with re"},
102 | #{name => t, short => $t, type => {string, "m"}, help => "string with re"},
103 | #{name => e, long => "--maybe-req", required => true, type => int, nargs => 'maybe', help => "maybe required int"},
104 | #{name => y, required => true, long => "-yyy", short => $y, type => {string, "m", []}, help => "string with re"},
105 | #{name => u, short => $u, type => {string, ["1", "2"]}, help => "string choices"},
106 | #{name => choice, short => $c, type => {int, [1,2,3]}, help => "tough choice"},
107 | #{name => fc, short => $q, type => {float, [2.1,1.2]}, help => "floating choice"},
108 | #{name => ac, short => $w, type => {atom, [one, two]}, help => "atom choice"},
109 | #{name => au, long => "-unsafe", type => {atom, unsafe}, help => "unsafe atom"},
110 | #{name => as, long => "-safe", type => atom, help => "safe atom"},
111 | #{name => name, required => false, nargs => list, help => hidden},
112 | #{name => long, long => "foobar", required => false, help => "foobaring option"}
113 | ], commands => #{
114 | "crawler" => #{arguments => [
115 | #{name => extra, long => "--extra", help => "extra option very deep"}
116 | ],
117 | help => "controls crawler behaviour"},
118 | "doze" => #{help => "dozes a bit"}}
119 | },
120 | "stop" => #{help => "stops running server", arguments => []
121 | },
122 | "status" => #{help => "prints server status", arguments => [],
123 | commands => #{
124 | "crawler" => #{
125 | arguments => [#{name => extra, long => "--extra", help => "extra option very deep"}],
126 | help => "crawler status"}}
127 | },
128 | "restart" => #{help => hidden, arguments => [
129 | #{name => server, help => "server to restart"},
130 | #{name => duo, short => $d, long => "-duo", help => "dual option"}
131 | ]}
132 | }
133 | }.
134 |
135 | %%--------------------------------------------------------------------
136 | %% Test Cases
137 |
138 | readme() ->
139 | [{doc, "Test cases covered in README.md"}].
140 |
141 | readme(Config) when is_list(Config) ->
142 | Rm = #{
143 | arguments => [
144 | #{name => dir},
145 | #{name => force, short => $f, type => boolean, default => false},
146 | #{name => recursive, short => $r, type => boolean}
147 | ]
148 | },
149 | ?assertEqual(#{dir => "dir", force => true, recursive => true},
150 | args:parse(["-rf", "dir"], Rm, #{result => argmap})),
151 | %% override progname
152 | ?assertEqual("usage: readme\n",
153 | args:help(#{}, #{progname => "readme"})),
154 | ?assertEqual("usage: readme\n",
155 | args:help(#{}, #{progname => readme})),
156 | ?assertException(error, {args,
157 | {invalid_command, [], progname, "progname must be a list or an atom"}},
158 | args:help(#{}, #{progname => 123})),
159 | %% command example
160 | Cmd = #{
161 | commands => #{"sub" => #{}},
162 | arguments => [#{name => pos}]
163 | },
164 | ?assertEqual(parse("opt sub", Cmd), parse("sub opt", Cmd)).
165 |
166 | basic() ->
167 | [{doc, "Basic cases"}].
168 |
169 | basic(Config) when is_list(Config) ->
170 | %% empty command, with full options path
171 | ?assertMatch({#{}, {["cmd"],#{}}},
172 | args:parse(["cmd"], #{commands => #{"cmd" => #{}}}, #{result => full})),
173 | %% sub-command, with no path, but user-supplied argument
174 | ?assertEqual({#{},{["cmd", "sub"],#{attr => pos}}},
175 | args:parse(["cmd", "sub"], #{commands => #{"cmd" => #{commands => #{"sub" => #{attr => pos}}}}})),
176 | %% command with positional argument
177 | PosCmd = #{arguments => [#{name => pos}]},
178 | ?assertEqual({#{pos => "arg"}, {["cmd"], PosCmd}},
179 | args:parse(["cmd", "arg"], #{commands => #{"cmd" => PosCmd}})),
180 | %% command with optional argument
181 | OptCmd = #{arguments => [#{name => force, short => $f, type => boolean}]},
182 | ?assertEqual({#{force => true}, {["rm"], OptCmd}},
183 | parse(["rm -f"], #{commands => #{"rm" => OptCmd}}), "rm -f"),
184 | %% command with optional and positional argument
185 | PosOptCmd = #{arguments => [#{name => force, short => $f, type => boolean}, #{name => dir}]},
186 | ?assertEqual({#{force => true, dir => "dir"}, {["rm"], PosOptCmd}},
187 | parse(["rm -f dir"], #{commands => #{"rm" => PosOptCmd}}), "rm -f dir"),
188 | %% no command, just argument list
189 | Kernel = #{name => kernel, long => "kernel", type => atom, nargs => 2},
190 | ?assertEqual(#{kernel => [port, dist]},
191 | parse(["-kernel port dist"], #{arguments => [Kernel]})),
192 | %% same but positional
193 | ArgList = #{name => arg, nargs => 2, type => boolean},
194 | ?assertEqual(#{arg => [true, false]},
195 | parse(["true false"], #{arguments => [ArgList]})).
196 |
197 | long_form_eq() ->
198 | [{doc, "Tests that long form supports --arg=value"}].
199 |
200 | long_form_eq(Config) when is_list(Config) ->
201 | %% cmd --arg=value
202 | PosOptCmd = #{arguments => [#{name => arg, long => "-arg"}]},
203 | ?assertEqual({#{arg => "value"}, {["cmd"], PosOptCmd}},
204 | parse(["cmd --arg=value"], #{commands => #{"cmd" => PosOptCmd}})),
205 | %% --int=10
206 | ?assertEqual(#{int => 10}, parse(["--int=10"], #{arguments => [#{name => int, type => int, long => "-int"}]})).
207 |
208 | single_arg_built_in_types() ->
209 | [{doc, "Tests all built-in types supplied as a single argument"}].
210 |
211 | % built-in types testing
212 | single_arg_built_in_types(Config) when is_list(Config) ->
213 | Bool = #{arguments => [#{name => meta, type => boolean, short => $b, long => "-boolean"}]},
214 | ?assertEqual(#{}, parse([""], Bool)),
215 | ?assertEqual(#{meta => true}, parse(["-b"], Bool)),
216 | ?assertEqual(#{meta => true}, parse(["--boolean"], Bool)),
217 | ?assertEqual(#{meta => false}, parse(["--boolean false"], Bool)),
218 | %% integer tests
219 | Int = #{arguments => [#{name => int, type => int, short => $i, long => "-int"}]},
220 | ?assertEqual(#{int => 1}, parse([" -i 1"], Int)),
221 | Prog = prog(),
222 | ?assertException(error, {args,{invalid_argument,[Prog],int,"1,"}}, parse([" -i 1, 3"], Int)),
223 | ?assertEqual(#{int => 1}, parse(["--int 1"], Int)),
224 | ?assertEqual(#{int => -1}, parse(["-i -1"], Int)),
225 | %% floating point
226 | Float = #{arguments => [#{name => f, type => float, short => $f}]},
227 | ?assertEqual(#{f => 44.44}, parse(["-f 44.44"], Float)),
228 | %% atoms, existing
229 | Atom = #{arguments => [#{name => atom, type => atom, short => $a, long => "-atom"}]},
230 | ?assertEqual(#{atom => atom}, parse(["-a atom"], Atom)),
231 | ?assertEqual(#{atom => atom}, parse(["--atom atom"], Atom)).
232 |
233 | type_validators() ->
234 | [{doc, "Validators for built-in types"}].
235 |
236 | type_validators(Config) when is_list(Config) ->
237 | %% {float, [{min, float()} | {max, float()}]} |
238 | Prog = [prog()],
239 | ?assertException(error, {args, {invalid_argument,Prog,float, 0.0}},
240 | parse_opts("0.0", [#{name => float, type => {float, [{min, 1.0}]}}])),
241 | ?assertException(error, {args, {invalid_argument,Prog,float, 2.0}},
242 | parse_opts("2.0", [#{name => float, type => {float, [{max, 1.0}]}}])),
243 | %% {int, [{min, integer()} | {max, integer()}]} |
244 | ?assertException(error, {args, {invalid_argument,Prog,int, 10}},
245 | parse_opts("10", [#{name => int, type => {int, [{min, 20}]}}])),
246 | ?assertException(error, {args, {invalid_argument,Prog,int, -5}},
247 | parse_opts("-5", [#{name => int, type => {int, [{max, -10}]}}])),
248 | %% string: regex & regex with options
249 | %% {string, string()} | {string, string(), []}
250 | ?assertException(error, {args, {invalid_argument,Prog,str, "me"}},
251 | parse_opts("me", [#{name => str, type => {string, "me.me"}}])),
252 | ?assertException(error, {args, {invalid_argument,Prog,str, "me"}},
253 | parse_opts("me", [#{name => str, type => {string, "me.me", []}}])),
254 | %% {binary, {re, binary()} | {re, binary(), []}
255 | ?assertException(error, {args, {invalid_argument,Prog, bin, "me"}},
256 | parse_opts("me", [#{name => bin, type => {binary, <<"me.me">>}}])),
257 | ?assertException(error, {args, {invalid_argument,Prog,bin, "me"}},
258 | parse_opts("me", [#{name => bin, type => {binary, <<"me.me">>, []}}])),
259 | %% now successful regexes
260 | ?assertEqual(#{str => "me"},
261 | parse_opts("me", [#{name => str, type => {string, "m."}}])),
262 | ?assertEqual(#{str => "me"},
263 | parse_opts("me", [#{name => str, type => {string, "m.", []}}])),
264 | ?assertEqual(#{str => "me"},
265 | parse_opts("me", [#{name => str, type => {string, "m.", [{capture, none}]}}])),
266 | %% and for binary too...
267 | ?assertEqual(#{bin => <<"me">>},
268 | parse_opts("me", [#{name => bin, type => {binary, <<"m.">>}}])),
269 | ?assertEqual(#{bin => <<"me">>},
270 | parse_opts("me", [#{name => bin, type => {binary, <<"m.">>, []}}])),
271 | ?assertEqual(#{bin => <<"me">>},
272 | parse_opts("me", [#{name => bin, type => {binary, <<"m.">>, [{capture, none}]}}])),
273 | %% more successes
274 | ?assertEqual(#{int => 5},
275 | parse_opts("5", [#{name => int, type => {int, [{min, 0}, {max, 10}]}}])),
276 | ?assertEqual(#{bin => <<"5">>},
277 | parse_opts("5", [#{name => bin, type => binary}])),
278 | ?assertEqual(#{str => "011"},
279 | parse_opts("11", [#{name => str, type => {custom, fun(S) -> [$0|S] end}}])),
280 | %% %% funny non-atom-atom: ensure the atom does not exist
281 | ?assertException(error, badarg, list_to_existing_atom("$can_never_be")),
282 | ArgMap = parse_opts("$can_never_be", [#{name => atom, type => {atom, unsafe}}]),
283 | args:validate(#{arguments => [#{name => atom, type => {atom, unsafe}}]}),
284 | %% must be successful, but really we can't create an atom in code!
285 | ?assertEqual(list_to_existing_atom("$can_never_be"), maps:get(atom, ArgMap)),
286 | %% choices: exceptions
287 | ?assertException(error, {args, {invalid_argument, Prog, bin, "K"}},
288 | parse_opts("K", [#{name => bin, type => {binary, [<<"M">>, <<"N">>]}}])),
289 | ?assertException(error, {args, {invalid_argument, Prog, str, "K"}},
290 | parse_opts("K", [#{name => str, type => {string, ["M", "N"]}}])),
291 | ?assertException(error, {args, {invalid_argument, Prog, atom, "K"}},
292 | parse_opts("K", [#{name => atom, type => {atom, [one, two]}}])),
293 | ?assertException(error, {args, {invalid_argument, Prog, int, 12}},
294 | parse_opts("12", [#{name => int, type => {int, [10, 11]}}])),
295 | ?assertException(error, {args, {invalid_argument, Prog, float, 1.3}},
296 | parse_opts("1.3", [#{name => float, type => {float, [1.2, 1.4]}}])),
297 | %% choices: valid
298 | ?assertEqual(#{bin => <<"K">>},
299 | parse_opts("K", [#{name => bin, type => {binary, [<<"M">>, <<"K">>]}}])),
300 | ?assertEqual(#{str => "K"},
301 | parse_opts("K", [#{name => str, type => {string, ["K", "N"]}}])),
302 | ?assertEqual(#{atom => one},
303 | parse_opts("one", [#{name => atom, type => {atom, [one, two]}}])),
304 | ?assertEqual(#{int => 12},
305 | parse_opts("12", [#{name => int, type => {int, [10, 12]}}])),
306 | ?assertEqual(#{float => 1.3},
307 | parse_opts("1.3", [#{name => float, type => {float, [1.3, 1.4]}}])),
308 | ok.
309 |
310 | complex_command() ->
311 | [{doc, "Parses a complex command that has a mix of optional and positional arguments"}].
312 |
313 | complex_command(Config) when is_list(Config) ->
314 | Command = #{arguments => [
315 | %% options
316 | #{name => string, short => $s, long => "-string", action => append, help => "String list option"},
317 | #{name => boolean, type => boolean, short => $b, action => append, help => "Boolean list option"},
318 | #{name => float, type => float, short => $f, long => "-float", action => append, help => "Float option"},
319 | %% positional args
320 | #{name => integer, type => int, help => "Integer variable"},
321 | #{name => string, help => "alias for string option", action => extend, nargs => list}
322 | ]},
323 | CmdMap = #{commands => #{"start" => Command}},
324 | Parsed = args:parse(string:lexemes("start --float 1.04 -f 112 -b -b -s s1 42 --string s2 s3 s4", " "), CmdMap),
325 | Expected = #{float => [1.04, 112], boolean => [true, true], integer => 42, string => ["s1", "s2", "s3", "s4"]},
326 | ?assertEqual({Expected, {["start"], Command}}, Parsed).
327 |
328 | unicode() ->
329 | [{doc, "Ensure unicode support"}].
330 |
331 | unicode(Config) when is_list(Config) ->
332 | %% test unicode short & long
333 | ?assertEqual(#{one => true}, parse(["-Ф"], #{arguments => [#{name => one, short => $Ф, type => boolean}]})),
334 | ?assertEqual(#{long => true}, parse(["--åäö"], #{arguments => [#{name => long, long => "-åäö", type => boolean}]})),
335 | %% test default, help and value in unicode
336 | Cmd = #{arguments => [#{name => text, type => binary, help => "åäö", default => <<"★"/utf8>>}]},
337 | Expected = #{text => <<"★"/utf8>>},
338 | ?assertEqual(Expected, args:parse([], Cmd)), %% default
339 | ?assertEqual(Expected, args:parse(["★"], Cmd)), %% specified in the command line
340 | ?assertEqual("usage: erl \n\nArguments:\n text åäö (binary, ★)\n", args:help(Cmd)),
341 | %% test command name and argument name in unicode
342 | Uni = #{commands => #{"åäö" => #{help => "öФ"}}, handler => optional,
343 | arguments => [#{name => "Ф", short => $ä, long => "åäö"}]},
344 | UniExpected = "usage: erl {åäö} [-ä <Ф>] [-åäö <Ф>]\n\nSubcommands:\n åäö öФ\n\nOptional arguments:\n -ä, -åäö Ф\n",
345 | ?assertEqual(UniExpected, args:help(Uni)),
346 | ParsedExpected = #{"Ф" => "öФ"},
347 | ?assertEqual(ParsedExpected, args:parse(["-ä", "öФ"], Uni)).
348 |
349 | errors() ->
350 | [{doc, "Tests for various errors, missing arguments etc"}].
351 |
352 | errors(Config) when is_list(Config) ->
353 | Prog = [prog()],
354 | %% conflicting option names
355 | ?assertException(error, {args, {invalid_option, _, two, short, "short conflicting with one"}},
356 | parse("", #{arguments => [#{name => one, short => $$}, #{name => two, short => $$}]})),
357 | ?assertException(error, {args, {invalid_option, _, two, long, "long conflicting with one"}},
358 | parse("", #{arguments => [#{name => one, long => "a"}, #{name => two, long => "a"}]})),
359 | %% broken options
360 | %% long must be a string
361 | ?assertException(error, {args, {invalid_option, _, one, long, _}},
362 | parse("", #{arguments => [#{name => one, long => ok}]})),
363 | %% short must be a printable character
364 | ?assertException(error, {args, {invalid_option, _, one, short, _}},
365 | parse("", #{arguments => [#{name => one, short => ok}]})),
366 | ?assertException(error, {args, {invalid_option, _, one, short, _}},
367 | parse("", #{arguments => [#{name => one, short => 7}]})),
368 | %% required is a boolean
369 | ?assertException(error, {args, {invalid_option, _, one, required, _}},
370 | parse("", #{arguments => [#{name => one, required => ok}]})),
371 | ?assertException(error, {args, {invalid_option, _, one, help, _}},
372 | parse("", #{arguments => [#{name => one, help => ok}]})),
373 | %% broken commands
374 | ?assertException(error, {args, {invalid_command, _, commands, _}},
375 | parse("", #{commands => ok})),
376 | ?assertException(error, {args, {invalid_command, _, commands, _}},
377 | parse("", #{commands => #{ok => #{}}})),
378 | ?assertException(error, {args, {invalid_command, _, help, _}},
379 | parse("", #{commands => #{"ok" => #{help => ok}}})),
380 | ?assertException(error, {args, {invalid_command, _, handler, _}},
381 | parse("", #{commands => #{"ok" => #{handler => fun errors/0}}})),
382 | %% unknown option at the top of the path
383 | ?assertException(error, {args, {unknown_argument, Prog, "arg"}},
384 | parse_cmd(["arg"], #{})),
385 | %% positional argument missing
386 | Opt = #{name => mode, required => true},
387 | ?assertException(error, {args, {missing_argument, _, mode}},
388 | parse_cmd(["start"], #{"start" => #{arguments => [Opt]}})),
389 | %% optional argument missing
390 | Opt1 = #{name => mode, short => $o, required => true},
391 | ?assertException(error, {args, {missing_argument, _, mode}},
392 | parse_cmd(["start"], #{"start" => #{arguments => [Opt1]}})),
393 | %% atom that does not exist
394 | Opt2 = #{name => atom, type => atom},
395 | ?assertException(error, {args, {invalid_argument, _, atom, "boo-foo"}},
396 | parse_cmd(["start boo-foo"], #{"start" => #{arguments => [Opt2]}})),
397 | %% optional argument missing some items
398 | Opt3 = #{name => kernel, long => "kernel", type => atom, nargs => 2},
399 | ?assertException(error, {args, {invalid_argument, _, kernel, ["port"]}},
400 | parse_cmd(["start -kernel port"], #{"start" => #{arguments => [Opt3]}})),
401 | %% not-a-list of arguments
402 | ?assertException(error, {args, {invalid_command, _, commands,"options must be a list"}},
403 | parse_cmd([], #{"start" => #{arguments => atom}})),
404 | %% command is not a map
405 | ?assertException(error, {args, {invalid_command, _, commands,"command description must be a map"}},
406 | parse_cmd([], #{"start" => []})),
407 | %% positional argument missing some items
408 | Opt4 = #{name => arg, type => atom, nargs => 2},
409 | ?assertException(error, {args, {invalid_argument, _, arg, ["p1"]}},
410 | parse_cmd(["start p1"], #{"start" => #{arguments => [Opt4]}})).
411 |
412 | args() ->
413 | [{doc, "Tests argument consumption option, with nargs"}].
414 |
415 | args(Config) when is_list(Config) ->
416 | %% consume optional list arguments
417 | Opts = [
418 | #{name => arg, short => $s, nargs => list, type => int},
419 | #{name => bool, short => $b, type => boolean}
420 | ],
421 | ?assertEqual(#{arg => [1, 2, 3], bool => true},
422 | parse_opts(["-s 1 2 3 -b"], Opts)),
423 | %% consume one_or_more arguments in an optional list
424 | Opts2 = [
425 | #{name => arg, short => $s, nargs => nonempty_list},
426 | #{name => extra, short => $x}
427 | ],
428 | ?assertEqual(#{extra => "X", arg => ["a","b","c"]},
429 | parse_opts(["-s port -s a b c -x X"], Opts2)),
430 | %% error if there is no argument to consume
431 | ?assertException(error, {args, {invalid_argument, _, arg, ["-x"]}},
432 | parse_opts(["-s -x"], Opts2)),
433 | %% error when positional has nargs = nonempty_list or pos_integer
434 | ?assertException(error, {args, {missing_argument, _, req}},
435 | parse_opts([""], [#{name => req, nargs => nonempty_list}])),
436 | %% positional arguments consumption: one or more positional argument
437 | OptsPos1 = [
438 | #{name => arg, nargs => nonempty_list},
439 | #{name => extra, short => $x}
440 | ],
441 | ?assertEqual(#{extra => "X", arg => ["b","c"]},
442 | parse_opts(["-x port -x a b c -x X"], OptsPos1)),
443 | %% positional arguments consumption, any number (maybe zero)
444 | OptsPos2 = #{arguments => [
445 | #{name => arg, nargs => list},
446 | #{name => extra, short => $x}
447 | ]},
448 | ?assertEqual(#{extra => "X", arg => ["a","b","c"]}, parse(["-x port a b c -x X"], OptsPos2)),
449 | %% positional: consume ALL arguments!
450 | OptsAll = [
451 | #{name => arg, nargs => all},
452 | #{name => extra, short => $x}
453 | ],
454 | ?assertEqual(#{extra => "port", arg => ["a","b","c", "-x", "X"]},
455 | parse_opts(["-x port a b c -x X"], OptsAll)),
456 | %%
457 | OptMaybe = [
458 | #{name => foo, long => "-foo", nargs => {'maybe', c}, default => d},
459 | #{name => bar, nargs => 'maybe', default => d}
460 | ],
461 | ?assertEqual(#{foo => "YY", bar => "XX"},
462 | parse_opts(["XX --foo YY"], OptMaybe)),
463 | ?assertEqual(#{foo => c, bar => "XX"},
464 | parse_opts(["XX --foo"], OptMaybe)),
465 | ?assertEqual(#{foo => d, bar => d},
466 | parse_opts([""], OptMaybe)),
467 | %% maybe with default
468 | ?assertEqual(#{foo => d, bar => "XX", baz => ok},
469 | parse_opts(["XX -b"], [#{name => baz, nargs => 'maybe', short => $b, default => ok} | OptMaybe])),
470 | %% maybe arg - with no default given
471 | ?assertEqual(#{foo => d, bar => "XX", baz => 0},
472 | parse_opts(["XX -b"], [#{name => baz, nargs => 'maybe', short => $b, type => int} | OptMaybe])),
473 | ?assertEqual(#{foo => d, bar => "XX", baz => ""},
474 | parse_opts(["XX -b"], [#{name => baz, nargs => 'maybe', short => $b, type => string} | OptMaybe])),
475 | ?assertEqual(#{foo => d, bar => "XX", baz => undefined},
476 | parse_opts(["XX -b"], [#{name => baz, nargs => 'maybe', short => $b, type => atom} | OptMaybe])),
477 | ?assertEqual(#{foo => d, bar => "XX", baz => <<"">>},
478 | parse_opts(["XX -b"], [#{name => baz, nargs => 'maybe', short => $b, type => binary} | OptMaybe])),
479 | %% nargs: optional list, yet it still needs to be 'not required'!
480 | OptList = [#{name => arg, nargs => list, required => false, type => int}],
481 | ?assertEqual(#{}, parse_opts("", OptList)),
482 | ok.
483 |
484 | argparse() ->
485 | [{doc, "Tests examples from argparse Python library"}].
486 |
487 | argparse(Config) when is_list(Config) ->
488 | Parser = #{arguments => [
489 | #{name => sum, long => "-sum", action => {store, sum}, default => max},
490 | #{name => integers, type => int, nargs => nonempty_list}
491 | ]},
492 | ?assertEqual(#{integers => [1, 2, 3, 4], sum => max}, parse("1 2 3 4", Parser)),
493 | ?assertEqual(#{integers => [1, 2, 3, 4], sum => sum}, parse("1 2 3 4 --sum", Parser)),
494 | ?assertEqual(#{integers => [7, -1, 42], sum => sum}, parse("--sum 7 -1 42", Parser)),
495 | %% name or flags
496 | Parser2 = #{arguments => [
497 | #{name => bar, required => true},
498 | #{name => foo, short => $f, long => "-foo"}
499 | ]},
500 | ?assertEqual(#{bar => "BAR"}, parse("BAR", Parser2)),
501 | ?assertEqual(#{bar => "BAR", foo => "FOO"}, parse("BAR --foo FOO", Parser2)),
502 | %PROG: error: the following arguments are required: bar
503 | ?assertException(error, {args, {missing_argument, _, bar}}, parse("--foo FOO", Parser2)),
504 | %% action tests: default
505 | ?assertEqual(#{foo => "1"},
506 | parse("--foo 1", #{arguments => [#{name => foo, long => "-foo"}]})),
507 | %% action test: store
508 | ?assertEqual(#{foo => 42},
509 | parse("--foo", #{arguments => [#{name => foo, long => "-foo", action => {store, 42}}]})),
510 | %% action tests: boolean (variants)
511 | ?assertEqual(#{foo => true},
512 | parse("--foo", #{arguments => [#{name => foo, long => "-foo", action => {store, true}}]})),
513 | ?assertEqual(#{foo => true},
514 | parse("--foo", #{arguments => [#{name => foo, long => "-foo", type => boolean}]})),
515 | ?assertEqual(#{foo => true},
516 | parse("--foo true", #{arguments => [#{name => foo, long => "-foo", type => boolean}]})),
517 | ?assertEqual(#{foo => false},
518 | parse("--foo false", #{arguments => [#{name => foo, long => "-foo", type => boolean}]})),
519 | %% action tests: append & append_const
520 | ?assertEqual(#{all => [1, "1"]},
521 | parse("--x 1 -x 1", #{arguments => [
522 | #{name => all, long => "-x", type => int, action => append},
523 | #{name => all, short => $x, action => append}]})),
524 | ?assertEqual(#{all => ["Z", 2]},
525 | parse("--x -x", #{arguments => [
526 | #{name => all, long => "-x", action => {append, "Z"}},
527 | #{name => all, short => $x, action => {append, 2}}]})),
528 | %% count:
529 | ?assertEqual(#{v => 3},
530 | parse("-v -v -v", #{arguments => [#{name => v, short => $v, action => count}]})),
531 | ok.
532 |
533 | negative() ->
534 | [{doc, "Test negative number parser"}].
535 |
536 | negative(Config) when is_list(Config) ->
537 | Parser = #{arguments => [
538 | #{name => x, short => $x, type => int, action => store},
539 | #{name => foo, nargs => 'maybe', required => false}
540 | ]},
541 | ?assertEqual(#{x => -1}, parse("-x -1", Parser)),
542 | ?assertEqual(#{x => -1, foo => "-5"}, parse("-x -1 -5", Parser)),
543 | %%
544 | Parser2 = #{arguments => [
545 | #{name => one, short => $1},
546 | #{name => foo, nargs => 'maybe', required => false}
547 | ]},
548 |
549 | %% negative number options present, so -1 is an option
550 | ?assertEqual(#{one => "X"}, parse("-1 X", Parser2)),
551 | %% negative number options present, so -2 is an option
552 | ?assertException(error, {args, {unknown_argument, _, "-2"}}, parse("-2", Parser2)),
553 |
554 | %% negative number options present, so both -1s are options
555 | ?assertException(error, {args, {missing_argument,_,one}}, parse("-1 -1", Parser2)),
556 | %% no "-" prefix, can only be an integer
557 | ?assertEqual(#{foo => "-1"}, args:parse(["-1"], Parser2, #{prefixes => "+"})),
558 | %% no "-" prefix, can only be an integer, but just one integer!
559 | ?assertException(error, {args, {unknown_argument, _, "-1"}},
560 | args:parse(["-2", "-1"], Parser2, #{prefixes => "+"})),
561 | %% just in case, floats work that way too...
562 | ?assertException(error, {args, {unknown_argument, _, "-2"}},
563 | parse("-2", #{arguments => [#{name => one, long => "1.2"}]})).
564 |
565 | nodigits() ->
566 | [{doc, "Test prefixes and negative numbers together"}].
567 |
568 | nodigits(Config) when is_list(Config) ->
569 | %% verify nodigits working as expected
570 | Parser3 = #{arguments => [
571 | #{name => extra, short => $3},
572 | #{name => arg, nargs => list}
573 | ]},
574 | %% ensure not to consume optional prefix
575 | ?assertEqual(#{extra => "X", arg => ["a","b","3"]},
576 | args:parse(string:lexemes("-3 port a b 3 +3 X", " "), Parser3, #{prefixes => "-+"})).
577 | %% verify split_to_option working with weird prefix
578 | %?assertEqual(#{extra => "X", arg => ["a","b","-3"]},
579 | % args:parse(string:lexemes("-3 port a b -3 -3 X", " "), Parser3, #{prefixes => "-+"})).
580 |
581 | python_issue_15112() ->
582 | [{doc, "Tests for https://bugs.python.org/issue15112"}].
583 |
584 | python_issue_15112(Config) when is_list(Config) ->
585 | Parser = #{arguments => [
586 | #{name => pos},
587 | #{name => foo},
588 | #{name => spam, default => 24, type => int, long => "-spam"},
589 | #{name => vars, nargs => list}
590 | ]},
591 | ?assertEqual(#{pos => "1", foo => "2", spam => 8, vars => ["8", "9"]},
592 | parse("1 2 --spam 8 8 9", Parser)).
593 |
594 | default_for_not_required() ->
595 | [{doc, "Tests that default value is used for non-required positional argument"}].
596 |
597 | default_for_not_required(Config) when is_list(Config) ->
598 | ?assertEqual(#{def => 1}, parse("", #{arguments => [#{name => def, short => $d, required => false, default => 1}]})),
599 | ?assertEqual(#{def => 1}, parse("", #{arguments => [#{name => def, required => false, default => 1}]})).
600 |
601 | global_default() ->
602 | [{doc, "Tests that a global default can be enabled for all non-required arguments"}].
603 |
604 | global_default(Config) when is_list(Config) ->
605 | ?assertEqual(#{def => "global"}, args:parse("", #{arguments => [#{name => def, type => int, required => false}]},
606 | #{default => "global"})).
607 |
608 | subcommand() ->
609 | [{doc, "Tests subcommands parser"}].
610 |
611 | subcommand(Config) when is_list(Config) ->
612 | TwoCmd = #{arguments => [#{name => bar}]},
613 | Cmd = #{
614 | arguments => [#{name => force, type => boolean, short => $f}],
615 | commands => #{"one" => #{
616 | arguments => [#{name => foo, type => boolean, long => "-foo"}, #{name => baz}],
617 | commands => #{
618 | "two" => TwoCmd}}}},
619 | ?assertEqual(
620 | {#{force => true, baz => "N1O1O", foo => true, bar => "bar"}, {["one", "two"], TwoCmd}},
621 | parse("one N1O1O -f two --foo bar", Cmd)),
622 | %% it is an error not to choose subcommand
623 | ?assertException(error, {args, {missing_argument,_,"missing handler"}},
624 | parse("one N1O1O -f", Cmd)).
625 |
626 | error_format() ->
627 | [{doc, "Tests error output formatter"}].
628 |
629 | error_format(Config) when is_list(Config) ->
630 | %% does not really require testing, but serve well as contract,
631 | %% and good for coverage
632 | {ok, [[Prog]]} = init:get_argument(progname),
633 | ?assertEqual(Prog ++ ": internal error, invalid field 'commands': sub-commands must be a map\n",
634 | make_error([""], #{commands => []})),
635 | ?assertEqual(Prog ++ " one: internal error, invalid field 'commands': sub-commands must be a map\n",
636 | make_error([""], #{commands => #{"one" => #{commands => []}}})),
637 | ?assertEqual(Prog ++ " one two: internal error, invalid field 'commands': sub-commands must be a map\n",
638 | make_error([""], #{commands => #{"one" => #{commands => #{"two" => #{commands => []}}}}})),
639 | %%
640 | ?assertEqual(Prog ++ ": internal error, option field 'name': argument must be a map, and specify 'name'\n",
641 | make_error([""], #{arguments => [#{}]})),
642 | %%
643 | ?assertEqual(Prog ++ ": internal error, option name field 'type': unsupported\n",
644 | make_error([""], #{arguments => [#{name => name, type => foo}]})),
645 | ?assertEqual(Prog ++ ": internal error, option name field 'nargs': unsupported\n",
646 | make_error([""], #{arguments => [#{name => name, nargs => foo}]})),
647 | ?assertEqual(Prog ++ ": internal error, option name field 'action': unsupported\n",
648 | make_error([""], #{arguments => [#{name => name, action => foo}]})),
649 | %% unknown arguments
650 | ?assertEqual(Prog ++ ": unrecognised argument: arg\n", make_error(["arg"], #{})),
651 | ?assertEqual(Prog ++ ": unrecognised argument: -a\n", make_error(["-a"], #{})),
652 | %% missing argument
653 | ?assertEqual(Prog ++ ": required argument missing: need\n", make_error([""],
654 | #{arguments => [#{name => need}]})),
655 | ?assertEqual(Prog ++ ": required argument missing: need\n", make_error([""],
656 | #{arguments => [#{name => need, short => $n, required => true}]})),
657 | %% invalid value
658 | ?assertEqual(Prog ++ ": invalid argument foo for: need\n", make_error(["foo"],
659 | #{arguments => [#{name => need, type => int}]})),
660 | ?assertEqual(Prog ++ ": invalid argument cAnNotExIsT for: need\n", make_error(["cAnNotExIsT"],
661 | #{arguments => [#{name => need, type => atom}]})),
662 | ok.
663 |
664 | very_short() ->
665 | [{doc, "Tests short option appended to the optional itself"}].
666 |
667 | very_short(Config) when is_list(Config) ->
668 | ?assertEqual(#{x => "V"},
669 | parse("-xV", #{arguments => [#{name => x, short => $x}]})).
670 |
671 | multi_short() ->
672 | [{doc, "Tests multiple short arguments blend into one"}].
673 |
674 | multi_short(Config) when is_list(Config) ->
675 | %% ensure non-flammable argument does not explode, even when it's possible
676 | ?assertEqual(#{v => "xv"},
677 | parse("-vxv", #{arguments => [#{name => v, short => $v}, #{name => x, short => $x}]})),
678 | %% ensure 'verbosity' use-case works
679 | ?assertEqual(#{v => 3},
680 | parse("-vvv", #{arguments => [#{name => v, short => $v, action => count}]})),
681 | %%
682 | ?assertEqual(#{recursive => true, force => true, path => "dir"},
683 | parse("-rf dir", #{arguments => [
684 | #{name => recursive, short => $r, type => boolean},
685 | #{name => force, short => $f, type => boolean},
686 | #{name => path}
687 | ]})).
688 |
689 | proxy_arguments() ->
690 | [{doc, "Tests nargs => all used to proxy arguments to another script"}].
691 |
692 | proxy_arguments(Config) when is_list(Config) ->
693 | Cmd = #{
694 | commands => #{
695 | "start" => #{
696 | arguments => [
697 | #{name => shell, short => $s, long => "-shell", type => boolean},
698 | #{name => skip, short => $x, long => "-skip", type => boolean},
699 | #{name => args, required => false, nargs => all}
700 | ]
701 | },
702 | "stop" => #{},
703 | "status" => #{
704 | arguments => [
705 | #{name => skip, required => false, default => "ok"},
706 | #{name => args, required => false, nargs => all}
707 | ]},
708 | "state" => #{
709 | arguments => [
710 | #{name => skip, required => false},
711 | #{name => args, required => false, nargs => all}
712 | ]}
713 | },
714 | arguments => [
715 | #{name => node}
716 | ],
717 | handler => fun (#{}) -> ok end
718 | },
719 | ?assertEqual(#{node => "node1"}, parse("node1", Cmd)),
720 | ?assertEqual({#{node => "node1"}, {["stop"], #{}}}, parse("node1 stop", Cmd)),
721 | ?assertMatch({#{node := "node2.org", shell := true, skip := true}, _}, parse("node2.org start -x -s", Cmd)),
722 | ?assertMatch({#{args := ["-app","key","value"],node := "node1.org"}, {["start"], _}},
723 | parse("node1.org start -app key value", Cmd)),
724 | ?assertMatch({#{args := ["-app","key","value", "-app2", "key2", "value2"],node := "node3.org", shell := true}, {["start"], _}},
725 | parse("node3.org start -s -app key value -app2 key2 value2", Cmd)),
726 | %% test that any non-required positionals are skipped
727 | ?assertMatch({#{args := ["-a","bcd"], node := "node2.org", skip := "ok"}, _}, parse("node2.org status -a bcd", Cmd)),
728 | ?assertMatch({#{args := ["-app", "key"], node := "node2.org"}, _}, parse("node2.org state -app key", Cmd)).
729 |
730 |
731 | usage() ->
732 | [{doc, "Basic tests for help formatter, including 'hidden' help"}].
733 |
734 | usage(Config) when is_list(Config) ->
735 | Cmd = ubiq_cmd(),
736 | Usage = "usage: " ++ prog() ++ " start {crawler|doze} [-lrfv] [-s ...] [-z ] [-m ] [-b ] [-g ] [-t ] ---maybe-req -y "
737 | " --yyy [-u ] [-c ] [-q ] [-w ] [--unsafe ] [--safe ] [-foobar ] [--force] [-i ] [--req ] [--float ] []"
738 | "\n\nSubcommands:\n crawler controls crawler behaviour\n doze dozes a bit\n\nArguments:\n server server to start\n optpos optional positional (int)"
739 | "\n\nOptional arguments:\n -s initial shards (int)\n -z between (1 <= int <= 10)\n -l maybe lower (int <= 10)"
740 | "\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)"
741 | "\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)"
742 | "\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)"
743 | "\n -foobar foobaring option\n -r recursive\n -f, --force force\n -v verbosity level"
744 | "\n -i interval set (int >= 1)\n --req required optional, right?\n --float floating-point long form argument (float, 3.14)\n",
745 | ?assertEqual(Usage, args:help(Cmd, #{command => ["start"]})),
746 | FullCmd = "usage: " ++ prog() ++ " [-rfv] [--force] [-i ] [--req ] [--float ]\n\nSubcommands:\n start verifies configuration and starts server"
747 | "\n status prints server status\n stop stops running server\n\nOptional arguments:\n -r recursive\n -f, --force force"
748 | "\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",
749 | ?assertEqual(FullCmd, args:help(Cmd)),
750 | CrawlerStatus = "usage: " ++ prog() ++ " status crawler [-rfv] [---extra ] [--force] [-i ] [--req ] [--float ]\n\nOptional arguments:\n"
751 | " ---extra extra option very deep\n -r recursive\n -f, --force force\n -v verbosity level"
752 | "\n -i interval set (int >= 1)\n --req required optional, right?\n --float floating-point long form argument (float, 3.14)\n",
753 | ?assertEqual(CrawlerStatus, args:help(Cmd, #{command => ["status", "crawler"]})),
754 | ok.
755 |
756 | usage_required_args() ->
757 | [{doc, "Verify that required args are printed as required in usage"}].
758 |
759 | usage_required_args(Config) when is_list(Config) ->
760 | Cmd = #{commands => #{"test" => #{arguments => [#{name => required, required => true, long => "-req"}]}}},
761 | Expected = "",
762 | ?assertEqual(Expected, args:help(Cmd, #{command => ["test"]})).
763 |
764 | error_usage() ->
765 | [{doc, "Test that usage information is added to errors"}].
766 |
767 | %% This test does not verify usage printed,
768 | %% but at least ensures formatter does not crash.
769 | error_usage(Config) when is_list(Config) ->
770 | try parse("start -rf", ubiq_cmd())
771 | catch error:{args, Reason} ->
772 | Actual = args:format_error(Reason, ubiq_cmd(), #{}),
773 | ct:pal("error: ~s", [Actual])
774 | end,
775 | ok.
776 |
777 | meta() ->
778 | [{doc, "Tests found while performing meta-testing"}].
779 |
780 | %% This test does not verify usage printed,
781 | %% but at least ensures formatter does not crash.
782 | meta(Config) when is_list(Config) ->
783 | %% short option with no argument, when it's needed
784 | ?assertException(error, {args, {missing_argument, _, short49}},
785 | parse("-1", #{arguments => [#{name => short49, short => 49}]})),
786 | %% extend + maybe
787 | ?assertException(error, {args, {invalid_option, _, short49, action, _}},
788 | parse("-1 -1", #{arguments =>
789 | [#{action => extend, name => short49, nargs => 'maybe', short => 49}]})),
790 | %%
791 | ?assertEqual(#{short49 => 2},
792 | parse("-1 arg1 --force", #{arguments =>
793 | [#{action => count, long => "-force", name => short49, nargs => 'maybe', short => 49}]})),
794 | ok.
795 |
796 | usage_template() ->
797 | [{doc, "Tests templates in help/usage"}].
798 |
799 | usage_template(Config) when is_list(Config) ->
800 | %% Argument (positional)
801 | Cmd = #{arguments => [#{
802 | name => shard,
803 | type => int,
804 | default => 0,
805 | help => {"[-s SHARD]", ["initial number, ", type, " with a default value of ", default]}}
806 | ]},
807 | ?assertEqual("usage: " ++ prog() ++ " [-s SHARD]\n\nArguments:\n shard initial number, int with a default value of 0\n",
808 | args:help(Cmd, #{})),
809 | %% Optional
810 | Cmd1 = #{arguments => [#{
811 | name => shard,
812 | short => $s,
813 | type => int,
814 | default => 0,
815 | help => {"[-s SHARD]", ["initial number"]}}
816 | ]},
817 | ?assertEqual("usage: " ++ prog() ++ " [-s SHARD]\n\nOptional arguments:\n -s initial number\n",
818 | args:help(Cmd1, #{})),
819 | %% ISO Date example
820 | DefaultRange = {{2020, 1, 1}, {2020, 6, 22}},
821 | CmdISO = #{
822 | arguments => [
823 | #{
824 | name => range,
825 | long => "-range",
826 | short => $r,
827 | help => {"[--range RNG]", fun() ->
828 | {{FY, FM, FD}, {TY, TM, TD}} = DefaultRange,
829 | lists:flatten(io_lib:format("date range, ~b-~b-~b..~b-~b-~b", [FY, FM, FD, TY, TM, TD]))
830 | end},
831 | type => {custom, fun(S) -> [S, DefaultRange] end},
832 | default => DefaultRange
833 | }
834 | ]
835 | },
836 | ?assertEqual("usage: " ++ prog() ++ " [--range RNG]\n\nOptional arguments:\n -r, --range date range, 2020-1-1..2020-6-22\n",
837 | args:help(CmdISO, #{})),
838 | ok.
839 |
--------------------------------------------------------------------------------
/test/cli_SUITE.erl:
--------------------------------------------------------------------------------
1 | %%%-------------------------------------------------------------------
2 | %%% @author Maxim Fedorov
3 | %%% @doc
4 | %%% cli: test suite to provide CLI functionality for escript
5 | %%% @end
6 | -module(cli_SUITE).
7 | -author("maximfca@gmail.com").
8 |
9 | -include_lib("common_test/include/ct.hrl").
10 | -include_lib("stdlib/include/assert.hrl").
11 |
12 | %% API exports
13 |
14 | %% Test server callbacks
15 | -export([
16 | suite/0,
17 | all/0
18 | ]).
19 |
20 | %% Test cases
21 | -export([
22 | test_cli/0, test_cli/1,
23 | auto_help/0, auto_help/1,
24 | subcmd_help/0, subcmd_help/1,
25 | missing_handler/0, missing_handler/1,
26 | bare_cli/0, bare_cli/1,
27 | multi_module/0, multi_module/1,
28 | warnings/0, warnings/1,
29 | simple/0, simple/1,
30 | global_default/0, global_default/1,
31 | malformed_behaviour/0, malformed_behaviour/1,
32 | exit_code/0, exit_code/1
33 | ]).
34 |
35 | %% Internal exports
36 | -export([
37 | cli/0,
38 | cli/1,
39 | cos/1,
40 | mul/2
41 | ]).
42 |
43 | -export([log/2]).
44 |
45 | -behaviour(cli).
46 |
47 | suite() ->
48 | [{timetrap, {seconds, 5}}].
49 |
50 | all() ->
51 | [test_cli, auto_help, subcmd_help, missing_handler, bare_cli, multi_module, warnings,
52 | malformed_behaviour, exit_code, global_default].
53 |
54 | %%--------------------------------------------------------------------
55 | %% Helpers
56 | prog() ->
57 | {ok, [[Prog]]} = init:get_argument(progname),
58 | Prog.
59 |
60 | %% OTP logger redirection
61 |
62 | log(LogEvent, #{forward := Pid}) ->
63 | Pid ! {log, LogEvent}.
64 |
65 | capture_log(Fun) ->
66 | Tracer = spawn_link(fun () -> tracer([]) end),
67 | logger:add_handler(?MODULE, ?MODULE, #{forward => Tracer}),
68 | Ret =
69 | try Fun()
70 | after
71 | logger:remove_handler(?MODULE)
72 | end,
73 | Captured = lists:flatten(lists:reverse(gen_server:call(Tracer, get))),
74 | {Ret, Captured}.
75 |
76 | %% I/O redirection
77 |
78 | %% {io_request, From, ReplyAs, Request}
79 | %% {io_reply, ReplyAs, Reply}
80 |
81 | tracer(Trace) ->
82 | receive
83 | {io_request, From, ReplyAs, {put_chars, _Encoding, Characters}} ->
84 | From ! {io_reply, ReplyAs, ok},
85 | tracer([Characters | Trace]);
86 | {io_request, From, ReplyAs, {put_chars, _Encoding, Module, Function, Args}} ->
87 | Text = erlang:apply(Module, Function, Args),
88 | From ! {io_reply, ReplyAs, ok},
89 | tracer([Text | Trace]);
90 | {log, LogEvent} ->
91 | tracer([LogEvent | Trace]);
92 | {'$gen_call', From, get} ->
93 | gen:reply(From, Trace);
94 | Other ->
95 | ct:pal("Unexpected I/O request: ~p", [Other]),
96 | tracer(Trace)
97 | end.
98 |
99 | capture_output(Fun) ->
100 | OldLeader = group_leader(),
101 | Tracer = spawn_link(fun () -> tracer([]) end),
102 | true = group_leader(Tracer, self()),
103 | Ret = try Fun() catch C:R -> {C, R}
104 | after
105 | group_leader(OldLeader, self())
106 | end,
107 | Captured = lists:flatten(lists:reverse(gen_server:call(Tracer, get))),
108 | {Ret, Captured}.
109 |
110 | capture_output_and_log(Fun) ->
111 | {{Ret, IO}, Log} = capture_log(fun () -> capture_output(Fun) end),
112 | {Ret, IO, Log}.
113 |
114 | cli_module(Mod, CliRet, FunExport, FunDefs) ->
115 | Code = [
116 | io_lib:format("-module(~s).\n", [Mod]),
117 | "-export([cli/0]).\n",
118 | if is_list(FunExport) -> lists:flatten(io_lib:format("-export([~s]).\n", [FunExport])); true -> undefined end,
119 | "-behaviour(cli).\n",
120 | lists:flatten(io_lib:format("cli() -> ~s.\n", [CliRet])) |
121 | FunDefs
122 | ],
123 | ct:pal("~s~n", [lists:concat(Code)]),
124 | Tokens = [begin {ok, Tokens, _} = erl_scan:string(C), Tokens end || C <- Code, C =/= undefined],
125 | %ct:pal("~p", [Tokens]),
126 | Forms = [begin {ok, F} = erl_parse:parse_form(T), F end || T <- Tokens],
127 | %ct:pal("~p", [Forms]),
128 | {ok, Mod, Bin} = compile:forms(Forms),
129 | {module, Mod} = code:load_binary(Mod, atom_to_list(Mod) ++ ".erl", Bin).
130 |
131 | cli_module(Mod, Calc) ->
132 | CliRet = lists:flatten(
133 | io_lib:format("#{commands => #{\"~s\" => #{arguments => [#{name => arg, nargs => list, type => int}]}}}", [Mod])),
134 | FunExport = lists:flatten(io_lib:format("~s/1", [Mod])),
135 | FunDefs = lists:flatten(
136 | io_lib:format("~s(#{arg := Args}) -> ~s(Args).", [Mod, Calc])),
137 | cli_module(Mod, CliRet, FunExport, [FunDefs]).
138 |
139 | %%--------------------------------------------------------------------
140 | %% Command Map definition
141 |
142 | cli() ->
143 | #{
144 | handler => optional,
145 | commands => #{
146 | "sum" => #{
147 | help => "Sums a list of arguments",
148 | handler => fun (#{num := Nums}) -> lists:sum(Nums) end,
149 | arguments => [
150 | #{name => num, nargs => nonempty_list, type => int, help => "Numbers to sum"}
151 | ]
152 | },
153 | "math" => #{
154 | commands => #{
155 | "sin" => #{},
156 | "cos" => #{handler => {fun (X) -> math:cos(X) end, undefined}, help => "Calculates cosinus"},
157 | "extra" => #{commands => #{"ok" => #{}, "fail" => #{}}, handler => optional}
158 | },
159 | arguments => [
160 | #{name => in, type => float, help => "Input value"}
161 | ]
162 | },
163 | "mul" => #{
164 | help => "Multiplies two arguments",
165 | arguments => [
166 | #{name => left, type => int},
167 | #{name => right, type => int}
168 | ]
169 | }
170 | }
171 | }.
172 |
173 | %%--------------------------------------------------------------------
174 | %% handlers
175 |
176 | cli(#{}) ->
177 | success.
178 |
179 | cos(#{in := In}) ->
180 | math:cos(In).
181 |
182 | mul(Left, Right) ->
183 | Left * Right.
184 |
185 | %%--------------------------------------------------------------------
186 | %% TEST CASES
187 |
188 | test_cli() ->
189 | [{doc, "Tests CLI commands"}].
190 |
191 | test_cli(Config) when is_list(Config) ->
192 | ?assertEqual(math:cos(3.14), cli:run(["math", "cos", "3.14"])),
193 | ?assertEqual(4, cli:run(["sum", "2", "2"])),
194 | ?assertEqual(6, cli:run(["sum", "3", "3"], #{modules => ?MODULE})),
195 | ?assertEqual(6, cli:run(["sum", "3", "3"], #{modules => [?MODULE]})),
196 | Expected = "error: erm sum: required argument missing: num\nusage: erm",
197 | {ok, Actual} = capture_output(fun () -> cli:run(["sum"], #{progname => "erm", error => ok}) end),
198 | ?assertEqual(Expected, lists:sublist(Actual, length(Expected))),
199 | %% test when help => false
200 | Expected1 = "error: erm sum: required argument missing: num\n",
201 | %% catch exception thrown by run/2 with "error => error" mode
202 | {{error, _}, Actual1} = capture_output(fun () -> cli:run(["sum"], #{progname => "erm", help => false, error => error}) end),
203 | ?assertEqual(Expected1, Actual1),
204 | %% test "catch-all" handler
205 | ?assertEqual(success, cli:run([])).
206 |
207 | auto_help() ->
208 | [{doc, "Tests automatic --help and -h switch"}].
209 |
210 | auto_help(Config) when is_list(Config) ->
211 | erlang:system_flag(backtrace_depth, 42),
212 | Expected = "usage: erm {math|mul|sum}\n\nSubcommands:\n math \n mul"
213 | " Multiplies two arguments\n sum Sums a list of arguments\n",
214 | ?assertEqual({ok, Expected}, capture_output(fun () -> cli:run(["--help"], #{progname => "erm", error => ok}) end)),
215 | %% add more modules
216 | cli_module(auto_help, "#{help => \"description\"}", undefined, []),
217 | Expected1 = "usage: erm {math|mul|sum}\ndescription\n\nSubcommands:\n math"
218 | " \n mul Multiplies two arguments\n sum Sums a list of arguments\n",
219 | ?assertEqual({ok, Expected1}, capture_output(fun () -> cli:run(["-h"], #{progname => "erm",
220 | modules => [?MODULE, auto_help], error => ok}) end)),
221 | %% request help for a subcommand
222 | Expected2 = "usage: " ++ prog() ++ " math {cos|extra|sin} \n\nSubcommands:\n cos "
223 | "Calculates cosinus\n extra \n sin \n\nArguments:\n in Input value (float)\n",
224 | ?assertEqual({ok, Expected2}, capture_output(fun () -> cli:run(["math", "--help"],
225 | #{modules => [?MODULE], error => ok}) end)),
226 | %% request help for a sub-subcommand
227 | Expected3 = "usage: " ++ prog() ++ " math extra {fail|ok} \n\nSubcommands:\n fail \n"
228 | " ok \n\nArguments:\n in Input value (float)\n",
229 | ?assertEqual({ok, Expected3}, capture_output(fun () -> cli:run(["math", "extra", "--help"],
230 | #{modules => ?MODULE, error => ok}) end)),
231 | %% request help for a sub-sub-subcommand
232 | Expected4 = "usage: " ++ prog() ++ " math cos \n\nArguments:\n in Input value (float)\n",
233 | ?assertEqual({ok, Expected4}, capture_output(fun () -> cli:run(["math", "cos", "--help"],
234 | #{modules => ?MODULE, error => ok}) end)),
235 | %% request help in a really wrong way (subcommand does not exist)
236 | Expected5 =
237 | "error: " ++ prog() ++ " math: invalid argument bad for: in\nusage: " ++ prog() ++ " math {cos|extra|sin} \n\nSubcommands:\n"
238 | " cos Calculates cosinus\n extra \n sin \n\nArguments:\n in Input value (float)\n",
239 | ?assertEqual({ok, Expected5}, capture_output(fun () -> cli:run(["math", "bad", "--help"],
240 | #{modules => ?MODULE, error => ok}) end)).
241 |
242 | subcmd_help() ->
243 | [{doc, "Tests that help for an empty command list does not fail"}].
244 |
245 | subcmd_help(Config) when is_list(Config) ->
246 | CliRet = "#{commands => #{\"foo\" => #{help => \"myfoo\", arguments => [#{name => left, help => \"lefty\"}]}}}",
247 | cli_module(empty, CliRet, undefined, []),
248 | %% capture good help output
249 | {_Ret, IO} = capture_output(fun () -> cli:run(["foo", "--help"], #{modules => empty, error => ok}) end),
250 | ?assertEqual("usage: " ++ prog() ++ " foo \n\nArguments:\n left lefty\n", IO),
251 | %% capture global help output
252 | {_Ret1, IO1} = capture_output(fun () -> cli:run(["--help"], #{modules => empty, error => ok}) end),
253 | ?assertEqual("usage: " ++ prog() ++ " {foo}\n\nSubcommands:\n foo myfoo\n", IO1),
254 | %% capture broken help output
255 | {_Ret2, IO2} = capture_output(fun () -> cli:run(["mycool", "--help"], #{modules => [empty], error => ok}) end),
256 | ?assertEqual("error: " ++ prog() ++ ": unrecognised argument: mycool\nusage: " ++ prog() ++ " {foo}\n\nSubcommands:\n foo myfoo\n", IO2),
257 | ct:pal("~s", [IO]).
258 |
259 | missing_handler() ->
260 | [{doc, "Handler can be missing from the module"}].
261 |
262 | missing_handler(Config) when is_list(Config) ->
263 | CliRet = "#{handler => {missing, foobar}}",
264 | FunExport = "foobar/1", FunDefs = "foobar(#{}) -> success.",
265 | cli_module(missing, CliRet, FunExport, [FunDefs]),
266 | {Ret, IO} = capture_output(fun () -> cli:run([], #{modules => [missing, bare, none], error => ok}) end),
267 | ?assertEqual(success, Ret),
268 | ?assertEqual("", IO).
269 |
270 | bare_cli() ->
271 | [{doc, "Bare cli, no sub-commands"}].
272 |
273 | bare_cli(Config) when is_list(Config) ->
274 | CliRet = "#{arguments => [#{name => arg, nargs => list, type => int}]}",
275 | FunExport = "cli/1",
276 | FunDefs = "cli(#{arg := Args}) -> lists:sum(Args).",
277 | cli_module(bare, CliRet, FunExport, [FunDefs]),
278 | {Ret, IO} = capture_output(fun () -> cli:run(["4", "7"], #{modules => bare, error => ok}) end),
279 | ct:pal("~s", [IO]),
280 | ?assertEqual(11, Ret),
281 | %% check usage/help working, and not starting with "error: "
282 | cli_module(bad, "#{arguments => [#{name => arg, short => $s}]}", "none/0", ["none() -> ok."]),
283 | Expected = "usage: ",
284 | {ok, Usage} = capture_output(fun () -> cli:run([], #{modules => bad, error => ok}) end),
285 | ?assertEqual(Expected, lists:sublist(Usage, length(Expected))).
286 |
287 | multi_module() ->
288 | [{doc, "Creates several modules, for cli interface to work"}].
289 |
290 | multi_module(Config) when is_list(Config) ->
291 | cli_module(sum, "lists:sum"), %% funny enough, this causes a duplicate definition!
292 | cli_module(max, "lists:max"),
293 | ?assertEqual(3, cli:run(["sum", "1", "2"])),
294 | ?assertEqual(20, cli:run(["max", "10", "20"])).
295 |
296 | warnings() ->
297 | [{doc, "Ensure warnings are skipped, or emitted"}].
298 |
299 | warnings(Config) when is_list(Config) ->
300 | {ok, IO} = capture_output(fun () -> cli:run(["sum"], #{modules => nomodule, error => ok}) end),
301 | ?assertNotEqual(nomatch, string:find(IO, "unrecognised argument: sum")),
302 | %% ensure log line added
303 | {ok, IO, Log} = capture_output_and_log(fun () -> cli:run(["sum"], #{modules => nomodule, error => ok}) end),
304 | [#{level := Lvl, msg := {Fmt, _}}] = Log,
305 | ?assertEqual(warning, Lvl),
306 | ?assertEqual("Error calling ~s:cli(): ~s:~p~n~p", Fmt),
307 | %% ensure no log line added when suppression is requested
308 | cli_module(warn, "#{commands => #{\"sum\" => #{}}}", undefined, []),
309 | {Sum, SumText, LogZero} = capture_output_and_log(fun () -> cli:run(["sum", "0"],
310 | #{modules => [?MODULE, warn], warn => suppress, error => ok}) end),
311 | ?assertEqual("", SumText),
312 | ?assertEqual(0, Sum),
313 | ?assertEqual([], LogZero).
314 |
315 | simple() ->
316 | [{doc, "Runs simple example from examples"}].
317 |
318 | simple(Config) when is_list(Config) ->
319 | CliRet = "#{arguments => [#{name => force, short => $f, type => boolean, default => false},"
320 | "#{name => recursive, short => $r, type => boolean, default => false},"
321 | "#{name => dir}]}",
322 | FunExport = "cli/3",
323 | FunDefs = "cli(Force, Recursive, Dir) -> io:format(\"Removing ~s (force: ~s, recursive: ~s)~n\", [Dir, Force, Recursive]).",
324 | cli_module(simple, CliRet, FunExport, [FunDefs]),
325 | {ok, IO} = capture_output(fun () -> cli:run(["4"], #{modules => simple, error => ok}) end),
326 | ct:pal("~s", [IO]),
327 | ?assertEqual("Removing 4 (force: false, recursive: false)\n", IO).
328 |
329 | global_default() ->
330 | [{doc, "Verifies that global default for maps works"}].
331 |
332 | global_default(Config) when is_list(Config) ->
333 | CliRet = "#{arguments => [#{name => foo, short => $f}, #{name => bar, short => $b, default => \"1\"}]}",
334 | FunExport = "cli/1",
335 | FunDefs = "cli(#{foo := Foo, bar := Bar}) -> io:format(\"Foo ~s, bar ~s~n\", [Foo, Bar]).",
336 | cli_module(simple, CliRet, FunExport, [FunDefs]),
337 | {ok, IO} = capture_output(fun () -> cli:run([], #{modules => simple, error => ok, default => undefined}) end),
338 | ?assertEqual("Foo undefined, bar 1\n", IO).
339 |
340 | malformed_behaviour() ->
341 | [{doc, "Tests for cli/0 callback returning invalid command map"}].
342 |
343 | malformed_behaviour(Config) when is_list(Config) ->
344 | CliRet = "#{commands => #{deploy => #{}}}",
345 | FunExport = "cli/1", FunDefs = "cli(#{arg := Args}) -> lists:sum(Args).",
346 | cli_module(malformed, CliRet, FunExport, [FunDefs]),
347 | {ok, IO, Log} = capture_output_and_log(fun () -> cli:run(["4"], #{modules => malformed, error => ok}) end),
348 | ?assertEqual("error: " ++ prog() ++ ": unrecognised argument: 4\nusage: " ++ prog() ++ "\n", IO),
349 | [#{level := Lvl, msg := {Fmt, _Args}}] = Log,
350 | ?assertEqual("Error calling ~s:cli(): ~s:~p~n~p", Fmt),
351 | ?assertEqual(warning, Lvl).
352 |
353 | exit_code() ->
354 | [{doc, "Tests 'error' setting for CLI"}].
355 |
356 | exit_code(Config) when is_list(Config) ->
357 | Script = filename:join(proplists:get_value(data_dir, Config), "simple"),
358 | ?assertMatch({ok, 1, _}, escript(Script, [], 5000)).
359 |
360 | escript(Script, Args, Timeout) ->
361 | CodePath = filename:dirname(code:where_is_file("cli.beam")),
362 | Escript = os:find_executable("escript"),
363 | Port = erlang:open_port({spawn_executable, Escript}, [{args, [Script | Args]},
364 | {env, [{"ERL_FLAGS", "-pa " ++ CodePath}]}, hide, binary, exit_status, stderr_to_stdout, {line, 1024*1024}]),
365 | read_full(Port, [], Timeout).
366 |
367 | read_full(Port, IoList, Timeout) ->
368 | receive
369 | {Port, {exit_status, Status}} ->
370 | {ok, Status, lists:reverse(IoList)};
371 | {Port, {data, {AnyLine, Data}}} when AnyLine =:= eol; AnyLine =:= noeol ->
372 | read_full(Port, [Data | IoList], Timeout)
373 | after Timeout ->
374 | {error, timeout, lists:reverse(IoList)}
375 | end.
376 |
--------------------------------------------------------------------------------
/test/cli_SUITE_data/simple:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env escript
2 |
3 | %% simple cli using cli behaviour
4 |
5 | -behaviour(cli).
6 | -mode(compile).
7 | -export([cli/0, rm/3]).
8 |
9 | main(Args) ->
10 | cli:run(Args, #{progname => "simple"}).
11 |
12 | cli() ->
13 | #{
14 | handler => {?MODULE, rm, undefined},
15 | arguments => [
16 | #{name => force, short => $f, type => boolean, default => false},
17 | #{name => recursive, short => $r, type => boolean, default => false},
18 | #{name => dir}
19 | ]
20 | }.
21 |
22 | rm(Force, Recursive, Dir) ->
23 | io:format("Removing ~s (force: ~s, recursive: ~s)~n",
24 | [Dir, Force, Recursive]).
25 |
--------------------------------------------------------------------------------