├── .gitignore ├── tests ├── nim.cfg ├── testOptionalMultiple.nim ├── testMultiple.nim ├── testSpace.nim ├── testErrors.nim ├── testDefaultValues.nim ├── testBasics.nim ├── testSubCommands.nim └── tests.json ├── commandeer.nimble ├── .circleci └── config.yml ├── CHANGELOG.md ├── circle.yml ├── LICENSE ├── runTests.nim ├── README.md └── commandeer.nim /.gitignore: -------------------------------------------------------------------------------- 1 | *.sublime-* 2 | nimcache/ 3 | *.html 4 | 5 | commandeer 6 | testCommandeer -------------------------------------------------------------------------------- /tests/nim.cfg: -------------------------------------------------------------------------------- 1 | path = ".." #This is used for convenience when running one of the 2 | #programs in this directory from this directory 3 | -------------------------------------------------------------------------------- /tests/testOptionalMultiple.nim: -------------------------------------------------------------------------------- 1 | ## commandeer test file (it doubles as an example file too!) 2 | import commandeer 3 | 4 | 5 | commandline: 6 | arguments(expendables, int, false) 7 | option testing, bool, "testing", "t" # option is placed here for testing purposes. 8 | 9 | if testing: 10 | doAssert(len(expendables) == 0) 11 | -------------------------------------------------------------------------------- /commandeer.nimble: -------------------------------------------------------------------------------- 1 | # Package 2 | 3 | version = "0.12.3" 4 | author = "Guillaume Viger" 5 | description = "A small command line parsing DSL" 6 | license = "MIT" 7 | 8 | installFiles = @["commandeer.nim"] 9 | 10 | # Dependencies 11 | 12 | requires "nim >= 0.16.0" 13 | 14 | task tests, "Run the Commandeer tester": 15 | exec "nim compile --run runTests" 16 | -------------------------------------------------------------------------------- /tests/testMultiple.nim: -------------------------------------------------------------------------------- 1 | ## commandeer test file (it doubles as an example file too!) 2 | import commandeer 3 | 4 | 5 | commandline: 6 | arguments(expendables, int, false) 7 | option testing, bool, "testing", "t" # option is placed here for testing purposes. 8 | argument required, string 9 | 10 | if testing: 11 | doAssert(len(expendables) == 0) 12 | doAssert(required == "required") 13 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: nimlang/nim # got all the building libraries 6 | 7 | steps: 8 | - checkout 9 | 10 | - run: echo $PATH 11 | 12 | - run: nim --version 13 | 14 | - run: nimble --version 15 | 16 | - run: nimble install --accept 17 | 18 | - run: nimble tests -------------------------------------------------------------------------------- /tests/testSpace.nim: -------------------------------------------------------------------------------- 1 | import commandeer 2 | 3 | 4 | commandline: 5 | argument first_required, string 6 | option first_optional, string, "optional1", "o1" 7 | option testing, bool, "testing", "t" 8 | 9 | echo "First Required: ", first_required 10 | echo "First Optional: ", first_optional 11 | 12 | if testing: 13 | doAssert(first_required == "1") 14 | doAssert(first_optional == "2") 15 | else: 16 | if first_optional == "2": 17 | quit "--testing was supposed to be true", QuitFailure 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/), but 6 | because it is pre 1.0.0, it has a *lot* of leeway :). 7 | 8 | ## [0.11.0] - 2017-03-04 9 | ### Changed 10 | - Complete rewrite but it should be completely backwards compatible 11 | - Error message when failed conversion is clearer 12 | ### Added 13 | - The space option syntax is now possible: `--option value` 14 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | 2 | dependencies: 3 | pre: 4 | - | 5 | if [ ! -x ${HOME}/nim-debs ]; then 6 | mkdir ${HOME}/nim-debs 7 | cd ${HOME}/nim-debs 8 | wget http://http.us.debian.org/debian/pool/main/n/nim/nim_0.16.0-1_amd64.deb 9 | wget http://http.us.debian.org/debian/pool/main/o/openssl1.0/libssl1.0.2_1.0.2k-1_amd64.deb 10 | fi 11 | sudo dpkg --install ${HOME}/nim-debs/*_amd64.deb 12 | cache_directories: 13 | - ~/nim-debs 14 | 15 | test: 16 | pre: 17 | - nimble install --accept 18 | override: 19 | - nimble tests 20 | post: 21 | - nimble uninstall commandeer --accept 22 | -------------------------------------------------------------------------------- /tests/testErrors.nim: -------------------------------------------------------------------------------- 1 | ## commandeer test file (it doubles as an example file too!) 2 | 3 | import commandeer 4 | 5 | 6 | commandline: 7 | arguments numbers, int 8 | option fraction, float, "fraction", "f" 9 | option testing, bool, "testing", "t" 10 | errormsg "Usage: [--fraction|-f: float] [--testing]" 11 | 12 | echo "numbers ", numbers 13 | echo "fraction ", fraction 14 | echo "testing ", testing 15 | 16 | # if testing: 17 | # doAssert(file == "foo.txt") 18 | # doAssert(mode == 'r') 19 | # doAssert(number == 1) 20 | # doAssert(rational == 3.6) 21 | # doAssert(silly == false) 22 | 23 | # nothing on the commandline: Missing command line arguments\nUsage... 24 | # 1.0: Couldn't convert '1.0' to int 25 | # -------------------------------------------------------------------------------- /tests/testDefaultValues.nim: -------------------------------------------------------------------------------- 1 | ## commandeer test file (it doubles as an example file too!) 2 | 3 | import commandeer 4 | 5 | proc usage(): string = 6 | result = """ 7 | Usage: testDefaultValues [--file | -f] [--silly | -s] [--mode | -m] 8 | [--number | -n] [--rational | -r] [--testing | -t] 9 | """ 10 | 11 | commandline: 12 | option(file, string, "file", "f", default="foo.txt") 13 | option silly, bool, "silly", "s", true 14 | option(mode, char, "mode", "m", default='r') 15 | option(number, int, short="n", long="number", default=1) 16 | option(rational, float, "rational", "r", default=3.6) 17 | option testing, bool, "testing", "t" 18 | 19 | echo "file ", file 20 | echo "silly ", silly 21 | echo "mode ", mode 22 | echo "number ", number 23 | echo "rational ", rational 24 | 25 | if testing: 26 | doAssert(file == "foo.txt") 27 | doAssert(mode == 'r') 28 | doAssert(number == 1) 29 | doAssert(rational == 3.6) 30 | doAssert(silly == false) 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Guillaume Viger 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /runTests.nim: -------------------------------------------------------------------------------- 1 | import 2 | osproc, 3 | os, 4 | json, 5 | strutils 6 | 7 | 8 | var compiled = -1 9 | 10 | for nimFile in walkDir("tests/"): 11 | if nimFile.kind == pcFile and nimFile.path.endswith(".nim"): 12 | compiled = execCmd("nim compile --verbosity:0 --hints:off --warnings:off " & nimFile.path) 13 | if compiled != 0: 14 | echo "Could not compile " & nimFile.path 15 | quit(QuitFailure) 16 | 17 | if compiled == 0: 18 | var j = parseFile("tests/tests.json") 19 | var exitTuple : tuple[output: string, exitCode: int] 20 | 21 | for jo in j["tests"].items(): 22 | var cmd = "tests" / jo["file name"].str & " " & jo["args"].str 23 | try: 24 | exitTuple = execCmdEx(cmd) 25 | doAssert(exitTuple.exitCode == jo["expect"].num) 26 | if jo.hasKey("msg"): doAssert(jo["msg"].str == exitTuple.output) 27 | write(stdout, ".") 28 | except: 29 | write(stdout, "F") 30 | echo "" 31 | echo "Test '", jo["test name"].str, "' failed." 32 | echo "Ran " & cmd 33 | echo "Expected: ", if jo.hasKey("msg"): repr(jo["msg"].str) else: $jo["expect"].num 34 | echo "Got: ", repr(exitTuple.output) 35 | quit(QuitFailure) 36 | 37 | echo "" 38 | echo "Tests pass!" 39 | -------------------------------------------------------------------------------- /tests/testBasics.nim: -------------------------------------------------------------------------------- 1 | ## commandeer test file (it doubles as an example file too!) 2 | import commandeer 3 | 4 | 5 | proc usage(): string = 6 | result = "Usage: program [--testing|--int=|--help] ..." 7 | 8 | commandline: 9 | argument integer, int 10 | argument floatingPoint, float 11 | argument character, char 12 | option testing, bool, "testing", "t" #option is placed here for testing purposes. 13 | argument boolean, bool #please don't do this for real 14 | arguments strings, string 15 | option optionalInteger, int, "int", "i" 16 | exitoption "help", "h", usage() 17 | exitoption "version", "v", "1.0.0" 18 | errormsg usage() 19 | 20 | echo("integer = ", integer) 21 | echo("floatingPoint = ", floatingPoint) 22 | echo("character = ", character) 23 | echo("strings (one or more) = ", strings) 24 | 25 | if optionalInteger != 0: 26 | echo "optionalInteger = ", optionalInteger 27 | 28 | if testing: 29 | #Test all possible argument types 30 | #use doAssert b/c of bug in unittest 31 | doAssert(integer == 1) 32 | doAssert(floatingPoint == 2.0) 33 | doAssert(character == '?') 34 | doAssert(strings == @["one", "two", "three"]) 35 | doAssert(optionalInteger == 10) 36 | doAssert(boolean == false) 37 | -------------------------------------------------------------------------------- /tests/testSubCommands.nim: -------------------------------------------------------------------------------- 1 | ## commandeer test file (it doubles as an example file too!) 2 | 3 | import commandeer 4 | 5 | 6 | proc usage(): string = 7 | result = "Usage: testSubCommands [--noop | --version] []" 8 | 9 | commandline: 10 | subcommand add, "add", "a": 11 | arguments filenames, string 12 | option force, bool, "force", "f" 13 | option interactive, bool, "interactive", "i" 14 | exitoption "help", "h", "add help" 15 | subcommand clone, "clone": 16 | argument gitUrl, string 17 | exitoption "help", "h", "clone help" 18 | subcommand push, ["push", "p","theoppositeofpull"]: 19 | exitoption "help", "h", "push help" 20 | option testing, bool, "testing", "t" 21 | exitoption "help", "h", "general help" 22 | errormsg usage() 23 | 24 | 25 | if add: 26 | echo("adding ", filenames) 27 | 28 | if force: 29 | echo " with force" 30 | if interactive: 31 | echo " and interaction" 32 | elif interactive: 33 | echo " with interaction" 34 | 35 | elif clone: 36 | echo "clone subcommand chosen" 37 | echo "cloning ", gitUrl, "..." 38 | 39 | elif push: 40 | echo "push subcommand chosen" 41 | echo "pushin ..." 42 | 43 | else: 44 | echo "no subcommands have been chosen" 45 | 46 | if testing: 47 | if add: 48 | doAssert(filenames == @["clone", "bar", "baz"]) 49 | doAssert(force == true) 50 | doAssert(interactive == false) 51 | doAssert(clone == false) 52 | else: 53 | doAssert(push == true) 54 | 55 | else: 56 | doAssert(false) 57 | -------------------------------------------------------------------------------- /tests/tests.json: -------------------------------------------------------------------------------- 1 | { 2 | "tests": 3 | [ 4 | { 5 | "test name": "Argument, option and arguments map correctly", 6 | "file name": "testBasics", 7 | "args": "1 2.0 '?' -i:10 false one two three --testing", 8 | "expect": 0 9 | }, 10 | { 11 | "test name": "Exitoption ignores need for other arguments", 12 | "file name": "testBasics", 13 | "args": "--help --testing", 14 | "expect": 0, 15 | "msg": "Usage: program [--testing|--int=|--help] ...\n" 16 | }, 17 | { 18 | "test name": "Subcommand exitoption is specific", 19 | "file name": "testSubCommands", 20 | "args": "clone --help", 21 | "expect": 0, 22 | "msg": "clone help\n" 23 | }, 24 | { 25 | "test name": "Subcommands map correctly", 26 | "file name": "testSubCommands", 27 | "args": "add -f clone bar baz --testing", 28 | "expect": 0 29 | }, 30 | { 31 | "test name": "Subcommands with name and alias map correctly", 32 | "file name": "testSubCommands", 33 | "args": "a -f clone bar baz --testing", 34 | "expect": 0 35 | }, 36 | { 37 | "test name": "Subcommands with an array of aliases map correctly", 38 | "file name": "testSubCommands", 39 | "args": "p --testing", 40 | "expect": 0, 41 | "msg": "push subcommand chosen\npushin ...\n" 42 | }, 43 | { 44 | "test name": "Default values work", 45 | "file name": "testDefaultValues", 46 | "args": "create --silly=false --testing", 47 | "expect": 0 48 | }, 49 | { 50 | "test name": "Spaced option before argument is correctly identified", 51 | "file name": "testSpace", 52 | "args": "--optional1 2 1 --testing", 53 | "expect": 0 54 | }, 55 | { 56 | "test name": "Missing arguments echoes a message", 57 | "file name": "testBasics", 58 | "args": "1 2.0 '?' -i:10 false --testing", 59 | "expect": 1, 60 | "msg": "Missing command line arguments\nUsage: program [--testing|--int=|--help] ...\n" 61 | }, 62 | { 63 | "test name": "Incorrect argument type echoes a message", 64 | "file name": "testErrors", 65 | "args": "1.0", 66 | "expect": 1, 67 | "msg": "Couldn't convert '1.0' to int\nUsage: [--fraction|-f: float] [--testing]\n" 68 | }, 69 | { 70 | "test name": "Missing option echoes a message", 71 | "file name": "testErrors", 72 | "args": "1 --fraction", 73 | "expect": 1, 74 | "msg": "Missing value for option 'fraction'\nUsage: [--fraction|-f: float] [--testing]\n" 75 | }, 76 | { 77 | "test name": "Incorrect option type echoes a message", 78 | "file name": "testErrors", 79 | "args": "1 --fraction abc", 80 | "expect": 1, 81 | "msg": "Couldn't convert 'abc' to float\nUsage: [--fraction|-f: float] [--testing]\n" 82 | }, 83 | { 84 | "test name": "Missing arguments for arguments template without atLeast1 doesn't echo a message", 85 | "file name": "testOptionalMultiple", 86 | "args": "", 87 | "expect": 0, 88 | }, 89 | { 90 | "test name": "Missing required argument after optional arguments echoes a message", 91 | "file name": "testMultiple", 92 | "args": "", 93 | "expect": 1, 94 | "msg": "Missing command line arguments\n" 95 | } 96 | ] 97 | } 98 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Commandeer 2 | ========== 3 | 4 | [![Build Status](https://circleci.com/gh/fenekku/commandeer/tree/master.png?style=shield&circle-token=7697da2b7caad879ca17ab6ea7acf8729163a06b)](https://circleci.com/gh/fenekku/commandeer) 5 | 6 | Take command of your command line. 7 | 8 | Commandeer gets data from the command line to your variables and exits 9 | gracefully if there is any issue. 10 | 11 | It does this little thing well and lets *you* deal with the rest. 12 | 13 | 14 | Usage 15 | ----- 16 | 17 | **In code** 18 | 19 | ```nim 20 | ## myCLApp.nim 21 | 22 | import commandeer 23 | 24 | commandline: 25 | argument integer, int 26 | argument floatingPoint, float 27 | argument character, char 28 | arguments strings, string 29 | option optionalInteger, int, "int", "i", -1 30 | option testing, bool, "testing", "t" 31 | exitoption "help", "h", 32 | "Usage: myCLApp [--testing|--int=|--help] " & 33 | " ..." 34 | errormsg "You made a mistake!" 35 | 36 | echo("integer = ", integer) 37 | echo("floatingPoint = ", floatingPoint) 38 | echo("character = ", character) 39 | echo("strings (one or more) = ", strings) 40 | 41 | if optionalInteger != 0: 42 | echo("optionalInteger = ", optionalInteger) 43 | 44 | if testing: 45 | echo("Testing enabled") 46 | 47 | ``` 48 | 49 | **On the command line** 50 | 51 | ``` 52 | $ myCLApp --testing 4 8.0 a one two -i:100 53 | integer = 4 54 | floatingPoint = 8.0 55 | character = a 56 | strings (one or more) = @[one, two] 57 | optionalInteger = 100 58 | Testing enabled 59 | $ myCLApp 10 --help 60 | Usage: myCLApp [--testing|--int=|--help] ... 61 | ``` 62 | 63 | When you have commandeer installed, try passing an incorrect set of 64 | command line arguments for fun! 65 | 66 | See the `tests` folder for other examples. 67 | 68 | It doesn't seek to do too much; it just does what's needed. 69 | 70 | 71 | Installation 72 | ------------ 73 | 74 | There are 2 ways to install commandeer: 75 | 76 | **nimble** 77 | 78 | Install [nimble](https://github.com/nim-lang/nimble). Then do: 79 | 80 | $ nimble install commandeer 81 | 82 | This will install the latest tagged version of commandeer. 83 | 84 | **raw** 85 | 86 | Copy the commandeer.nim file to your project and import it. 87 | 88 | When I go this way for Nim libraries, I like to create a `libs/` 89 | folder in my project and put third-party files in it. I then add the 90 | line `path = "libs"` to my `nim.cfg` file so that the `libs/` 91 | directory is looked into at compile time. 92 | 93 | 94 | Documentation 95 | ------------- 96 | 97 | **commandline** 98 | 99 | `commandline` is used to delimit the space where you define the command line 100 | arguments and options you expect. All other commandeer constructs (described below) 101 | are placed under it. They are all optional - although you probably want to use 102 | at least one, right? 103 | 104 | **subcommand `identifier`, `name`[, `alias1`, `alias2`...]** 105 | 106 | `subcommand` declares `identifier` to be a variable of type `bool` that is `true` 107 | if the first command line argument passed is `name` or one of the aliases (`alias1`, `alias2`, etc.) and is `false` otherwise. 108 | Under it, you define the subcommand arguments and options you expect. 109 | All other commandeer constructs (described below) *can be* placed under it. 110 | 111 | For example: 112 | 113 | ```nim 114 | commandline: 115 | subcommand add, "add", "a": 116 | arguments filenames, string 117 | option force, bool, "force", "f" 118 | option globalOption, bool, "global", "g" 119 | 120 | if add: 121 | echo "Adding", filenames 122 | if globalOption: 123 | echo "Global option activated" 124 | ``` 125 | 126 | See `tests/testSubcommands.nim` for a larger example. 127 | 128 | **argument `identifier`, `type`** 129 | 130 | `argument` declares a variable named `identifier` of type `type` initialized with 131 | the value of the corresponding command line argument converted to type `type`. 132 | 133 | Correspondence works as follows: the first occurrence of `argument` corresponds 134 | to the first argument, the second to the second argument and so on. Note that 135 | if a `subcommand` is declared then 1) any top-level occurrence of `argument` is 136 | ignored, 2) the first subcommand `argument` corresponds to the first command line argument 137 | after the subcommand, the second to the second argument after the subcommand and so on. 138 | 139 | 140 | **arguments `identifier`, `type` [, `atLeast1`]** 141 | 142 | `arguments` declares a variable named `identifier` of type `seq[type]` initialized with 143 | the value of the sequential command line arguments that can be converted to type `type`. 144 | By default `atLeast1` is `true` which means there must be at least one argument of type 145 | `type` or else an error is thrown. Passing `false` there allows for 0 or more arguments of the 146 | same type to be stored at `identifier`. 147 | 148 | *Warning*: `arguments myListOfStrings, string` will eat all arguments on 149 | the command line. The same applies to other situations where one type is 150 | a supertype of another type in terms of conversion e.g., floats eat ints. 151 | 152 | 153 | **option `identifier`, `type`, `long name`, `short name` [, `default`]** 154 | 155 | `option` declares a variable named `identifier` of type `type` initialized with 156 | the value of the corresponding command line option `--long name` or `-short name` 157 | converted to type `type` if it is present. The `--` and `-` are added 158 | by commandeer for your convenience. If the option is not present, 159 | `identifier` is initialized to its default type value or the passed 160 | `default` value. 161 | 162 | The command line option syntax follows Nim's one and adds space (!) i.e., 163 | `--times=3`, `--times:3`, `-t=3`, `-t:3`, `--times 3` and `-t 3` are all valid. 164 | 165 | Syntactic sugar is provided for boolean options such that only the presence of 166 | the option is needed to give a true value. 167 | 168 | 169 | **exitoption `long name`, `short name`, `exit message`** 170 | 171 | `exitoption` declares a long and short option string for which the application 172 | will immediately output `exit message` and exit. This can be used for subcommand specific exit messages too: 173 | 174 | ```nim 175 | commandline: 176 | subcommand add, "add": 177 | arguments filenames, string 178 | exitoption "help", "h", "add help" 179 | exitoption "help", "h", "general help" 180 | ``` 181 | 182 | This is mostly used for printing the version or the help message. 183 | 184 | 185 | **errormsg `custom error message`** 186 | 187 | `errormsg` sets a string `custom error message` that will be displayed after the other error messages if the command line arguments or options are invalid. 188 | 189 | 190 | **Valid types for `type` are:** 191 | 192 | - `int`, `float`, `string`, `bool`, `char` 193 | 194 | 195 | Design 196 | ------ 197 | 198 | - Keep as much logic out of the module and into the hands of 199 | the developer as possible 200 | - No magical variables should be made implicitly available. All created 201 | variables should be explicitly chosen by the developer. 202 | - Keep it simple and streamlined. Command line parsers can do a lot for 203 | you, but I prefer to be in adequate control. 204 | - Test in context. Tests are run on the installed package because that 205 | is what people get. 206 | 207 | 208 | Tests 209 | ----- 210 | 211 | Run the test suite: 212 | 213 | nimble tests 214 | 215 | TODO and Contribution 216 | --------------------- 217 | 218 | - Use and see what needs to be added 219 | -------------------------------------------------------------------------------- /commandeer.nim: -------------------------------------------------------------------------------- 1 | import algorithm 2 | import parseopt2 3 | import sequtils 4 | import strutils 5 | import tables 6 | import typetraits 7 | 8 | 9 | type 10 | assignmentProc = proc(value: string) 11 | Quantifier {.pure.} = enum 12 | single, oneOrMore, zeroOrMore 13 | Assigner = tuple[assign: assignmentProc, quantity: Quantifier] 14 | # We only allow one level of Subcommand, so not a recursive definition 15 | Subcommand = ref object 16 | argumentAssigners: seq[Assigner] 17 | index: int 18 | shortOptionAssigners: TableRef[string, Assigner] 19 | longOptionAssigners: TableRef[string, Assigner] 20 | activate: proc() 21 | 22 | 23 | proc newSubcommand(p: proc() = proc()=discard): Subcommand = 24 | new(result) 25 | result.argumentAssigners = newSeq[Assigner]() 26 | result.index = 0 27 | result.shortOptionAssigners = newTable[string, Assigner]() 28 | result.longOptionAssigners = newTable[string, Assigner]() 29 | result.activate = p 30 | 31 | 32 | proc mergeIn(s1: var Subcommand, s2: Subcommand) = 33 | s1.argumentAssigners = s2.argumentAssigners 34 | for key, value in s2.shortOptionAssigners.pairs(): 35 | s1.shortOptionAssigners[key] = value 36 | for key, value in s2.longOptionAssigners.pairs(): 37 | s1.longOptionAssigners[key] = value 38 | s1.activate = s2.activate 39 | 40 | 41 | proc getOptionAssigner(s: Subcommand, key: string): Assigner = 42 | if key in s.longOptionAssigners: 43 | return s.longOptionAssigners[key] 44 | elif key in s.shortOptionAssigners: 45 | return s.shortOptionAssigners[key] 46 | else: 47 | # Ignore superfluous extra option 48 | return (proc(value: string) {.closure.} = discard, Quantifier.single) 49 | 50 | 51 | var errorMessage: string = "" 52 | var currentSubcommand = newSubcommand() 53 | var subCommands = newTable[string, Subcommand]() 54 | var cliTokens: seq[GetoptResult] 55 | var inSubcommand = false 56 | 57 | ## Debugging procs ## 58 | 59 | proc `$`*(f: assignmentProc): string = "assignmentProc" 60 | 61 | 62 | proc `$`*(s: Subcommand): string = 63 | result = 64 | "{" & 65 | "argumentAssigners: " & $s.argumentAssigners & ", " & 66 | "shorts: " & $s.shortOptionAssigners & ", " & 67 | "longs: " & $s.longOptionAssigners & 68 | "}" 69 | 70 | 71 | ## Conversion procs ## 72 | 73 | proc assignConversion(variable: var int, value: string) = 74 | variable = strutils.parseInt(value) 75 | 76 | 77 | proc assignConversion(variable: var seq[int], value: string) = 78 | variable.add(strutils.parseInt(value)) 79 | 80 | 81 | proc assignConversion(variable: var float, value: string) = 82 | variable = strutils.parseFloat(value) 83 | 84 | 85 | proc assignConversion(variable: var seq[float], value: string) = 86 | variable.add(strutils.parseFloat(value)) 87 | 88 | 89 | proc assignConversion(variable: var string, value: string) = 90 | if value == "": raise newException(ValueError, "Empty string") 91 | variable = value 92 | 93 | 94 | proc assignConversion(variable: var seq[string], value: string) = 95 | variable.add(value) 96 | 97 | 98 | proc assignConversion(variable: var bool, value: string) = 99 | ## will accept "yes", "true", "on", "1" as true values 100 | ## the only way we get an empty string here is because of a key 101 | ## with no value, in which case the presence of the key is enough 102 | ## to return true 103 | variable = if value == "": true else: strutils.parseBool(value) 104 | 105 | 106 | proc assignConversion(variable: var seq[bool], value: string) = 107 | variable.add(if value == "": true else: strutils.parseBool(value)) 108 | 109 | 110 | proc assignConversion(variable: var char, value: string) = 111 | if value == "": raise newException(ValueError, "Empty string") 112 | variable = value[0] 113 | 114 | 115 | proc assignConversion(variable: var seq[char], value: string) = 116 | variable.add(value[0]) 117 | 118 | 119 | ## Interpretation of the tokens ## 120 | 121 | type 122 | CmdTokenKind {.pure.} = enum 123 | argument, option, subcommand, empty 124 | CmdToken = tuple 125 | getOptResult: GetoptResult 126 | kind: CmdTokenKind 127 | 128 | 129 | proc key(cmdToken: CmdToken): string = 130 | return cmdToken.getOptResult.key 131 | 132 | 133 | proc value(cmdToken: CmdToken): string = 134 | if cmdToken.kind == CmdTokenKind.argument: 135 | # key is the value for cmdArgument 136 | return cmdToken.getOptResult.key 137 | return cmdToken.getOptResult.val 138 | 139 | 140 | proc obtainCmdToken(consume: bool): CmdToken = 141 | if cliTokens.len() > 0: 142 | let cliToken = if consume: cliTokens.pop() else: cliTokens[^1] 143 | if not inSubcommand and currentSubcommand.index == 0 and cliToken.key in subcommands: 144 | return (getOptResult: cliToken, kind: CmdTokenKind.subcommand) 145 | elif cliToken.kind in [parseopt2.cmdLongOption, parseopt2.cmdShortOption]: 146 | return (getOptResult: cliToken, kind: CmdTokenKind.option) 147 | else: 148 | return (getOptResult: cliToken, kind: CmdTokenKind.argument) 149 | return (getOptResult: (kind: CmdLineKind.cmdEnd, key: "", val: ""), kind: CmdTokenKind.empty) 150 | 151 | 152 | proc readCmdToken(): CmdToken = 153 | return obtainCmdToken(consume=true) 154 | 155 | 156 | proc peekCmdToken(): CmdToken = 157 | return obtainCmdToken(consume=false) 158 | 159 | 160 | proc addToken(token: CmdToken) = 161 | cliTokens.add(token.getOptResult) 162 | 163 | 164 | proc exitWithErrorMessage(msg="") = 165 | if msg != "" and errorMessage != "": 166 | quit msg & "\n" & errorMessage, QuitFailure 167 | elif msg != "": 168 | quit msg, QuitFailure 169 | elif errorMessage != "": 170 | quit errorMessage, QuitFailure 171 | else: 172 | quit QuitFailure 173 | 174 | 175 | proc interpretCli() = 176 | while true: 177 | var token = readCmdToken() 178 | 179 | case token.kind 180 | of CmdTokenKind.empty: 181 | # If didn't fulfill required arguments 182 | if currentSubcommand.index < len(currentSubcommand.argumentAssigners): 183 | let last = high(currentSubcommand.argumentAssigners) 184 | for assigner in currentSubcommand.argumentAssigners[currentSubcommand.index..last]: 185 | if assigner.quantity != Quantifier.zeroOrMore: 186 | exitWithErrorMessage("Missing command line arguments") 187 | break 188 | 189 | of CmdTokenKind.subcommand: 190 | currentSubcommand.mergeIn(subcommands[token.key]) 191 | currentSubcommand.activate() 192 | 193 | of CmdTokenKind.argument: 194 | # Ignore superfluous extra arguments 195 | if currentSubcommand.index >= len(currentSubcommand.argumentAssigners): 196 | continue 197 | 198 | var assigner = currentSubcommand.argumentAssigners[currentSubcommand.index] 199 | var atLeastOneAssignment = false 200 | 201 | try: 202 | assigner.assign(token.value) 203 | atLeastOneAssignment = true 204 | 205 | # arguments? 206 | if assigner.quantity in [Quantifier.zeroOrMore, Quantifier.oneOrMore]: 207 | # broken by emptiness, conversion, option 208 | while peekCmdToken().kind == CmdTokenKind.argument: 209 | token = readCmdToken() 210 | assigner.assign(token.value) 211 | except ValueError: 212 | case assigner.quantity 213 | of Quantifier.zeroOrMore: 214 | addToken(token) 215 | of Quantifier.single, Quantifier.oneOrMore: 216 | if atLeastOneAssignment: 217 | addToken(token) 218 | else: 219 | exitWithErrorMessage(getCurrentExceptionMsg()) 220 | 221 | inc(currentSubcommand.index) 222 | 223 | of CmdTokenKind.option: 224 | var assigner = currentSubcommand.getOptionAssigner(token.key) 225 | 226 | try: 227 | assigner.assign(token.value) 228 | except ValueError: 229 | # There might be a space separating key and value 230 | # The value is the next token 231 | if peekCmdToken().kind == CmdTokenKind.argument: 232 | token = readCmdToken() 233 | try: 234 | assigner.assign(token.value) 235 | except ValueError: 236 | exitWithErrorMessage(getCurrentExceptionMsg()) 237 | elif token.value != "": 238 | # Conversion error 239 | exitWithErrorMessage(getCurrentExceptionMsg()) 240 | else: 241 | exitWithErrorMessage("Missing value for option '" & token.key & "'") 242 | 243 | 244 | ## Command line dsl keywords ## 245 | 246 | template argument*(identifier: untyped, t: typeDesc): untyped = 247 | var identifier: t 248 | currentSubcommand.argumentAssigners.add(( 249 | proc(value: string) {.closure.} = 250 | try: 251 | assignConversion(identifier, value) 252 | except ValueError: 253 | raise newException( 254 | ValueError, 255 | "Couldn't convert '" & value & "' to " & name(t) 256 | ) 257 | , 258 | Quantifier.single 259 | )) 260 | 261 | 262 | template arguments*(identifier: untyped, t: typeDesc, atLeast1: bool=true): untyped = 263 | var identifier = newSeq[t]() 264 | currentSubcommand.argumentAssigners.add( 265 | ( 266 | proc(value: string) {.closure.} = 267 | try: 268 | assignConversion(identifier, value) 269 | except ValueError: 270 | raise newException( 271 | ValueError, 272 | "Couldn't convert '" & value & "' to " & name(t) 273 | ) 274 | , 275 | if atLeast1: Quantifier.oneOrMore else: Quantifier.zeroOrMore 276 | ) 277 | ) 278 | 279 | 280 | template option*(identifier: untyped, t: typeDesc, long, short: string, 281 | default: t): untyped = 282 | var identifier: t = default 283 | var assigner = ( 284 | proc(value: string) {.closure.} = 285 | try: 286 | assignConversion(identifier, value) 287 | except ValueError: 288 | raise newException( 289 | ValueError, 290 | "Couldn't convert '" & value & "' to " & name(t) 291 | ) 292 | , 293 | Quantifier.single 294 | ) 295 | currentSubcommand.longOptionAssigners[long] = assigner 296 | currentSubcommand.shortOptionAssigners[short] = assigner 297 | 298 | 299 | template option*(identifier: untyped, t: typeDesc, long, short: string): untyped = 300 | var identifier: t 301 | var assigner = ( 302 | proc(value: string) {.closure.} = 303 | try: 304 | assignConversion(identifier, value) 305 | except ValueError: 306 | raise newException( 307 | ValueError, 308 | "Couldn't convert '" & value & "' to " & name(t) 309 | ) 310 | , 311 | Quantifier.single 312 | ) 313 | currentSubcommand.longOptionAssigners[long] = assigner 314 | currentSubcommand.shortOptionAssigners[short] = assigner 315 | 316 | 317 | template exitoption*(long, short, msg: string): untyped = 318 | var exiter = ( 319 | (proc(value: string) {.closure.} = quit msg, QuitSuccess), 320 | Quantifier.single 321 | ) 322 | currentSubcommand.longOptionAssigners[long] = exiter 323 | currentSubcommand.shortOptionAssigners[short] = exiter 324 | 325 | 326 | template errormsg*(msg: string): untyped = 327 | errorMessage = msg 328 | 329 | 330 | template subcommand*(identifier: untyped, subcommandNames: varargs[string], 331 | statements: untyped): untyped = 332 | var identifier: bool = false 333 | var thisSubcommand = newSubcommand( 334 | proc() = 335 | identifier = true 336 | inSubcommand = true 337 | ) 338 | 339 | var tmpSubcommand = currentSubcommand 340 | currentSubcommand = thisSubcommand 341 | statements 342 | currentSubcommand = tmpSubcommand 343 | 344 | for subcommandName in subcommandNames: 345 | subCommands[subcommandName] = thisSubcommand 346 | 347 | template commandline*(statements: untyped): untyped = 348 | cliTokens = reversed(toSeq(parseopt2.getopt())) 349 | statements 350 | interpretCli() 351 | --------------------------------------------------------------------------------