├── .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 | [![Build Status](https://github.com/max-au/argparse/actions/workflows/erlang.yml/badge.svg?branch=master)](https://github.com/max-au/argparse/actions) [![Hex.pm](https://img.shields.io/hexpm/v/argparse.svg)](https://hex.pm/packages/argparse) [![Hex Docs](https://img.shields.io/badge/hex-docs-blue.svg)](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 | --------------------------------------------------------------------------------