|
| 1 | +local cli = {} |
| 2 | +cli.__index = cli |
| 3 | + |
| 4 | +type ArgKind = "positional" | "flag" | "option" |
| 5 | +type ArgOptions = { |
| 6 | + help: string?, |
| 7 | + aliases: { string }?, |
| 8 | + default: string?, |
| 9 | + required: boolean?, |
| 10 | +} |
| 11 | + |
| 12 | +type ArgData = { |
| 13 | + name: string, |
| 14 | + kind: ArgKind, |
| 15 | + options: ArgOptions, |
| 16 | +} |
| 17 | + |
| 18 | +type ParseResult = { |
| 19 | + values: { [string]: string }, |
| 20 | + flags: { [string]: boolean }, |
| 21 | + fwdArgs: { string }, |
| 22 | +} |
| 23 | + |
| 24 | +type ParserData = { |
| 25 | + arguments: { [number]: ArgData }, |
| 26 | + positional: { [number] : ArgData}, |
| 27 | + parsed: ParseResult, |
| 28 | +} |
| 29 | + |
| 30 | +type ParserInterface = typeof(cli) |
| 31 | +export type Parser = setmetatable<ParserData, ParserInterface> |
| 32 | + |
| 33 | +function cli.parser(): Parser |
| 34 | + local self = { |
| 35 | + arguments = {}, |
| 36 | + positional = {}, |
| 37 | + parsed = { values = {}, flags = {}, fwdArgs = {} }, |
| 38 | + } |
| 39 | + |
| 40 | + return setmetatable(self, cli) |
| 41 | +end |
| 42 | + |
| 43 | +function cli.add(self: Parser, name: string, kind: ArgKind, options: ArgOptions): () |
| 44 | + local argument = { |
| 45 | + name = name, |
| 46 | + kind = kind, |
| 47 | + options = options or { aliases = {}, required = false }, |
| 48 | + } |
| 49 | + |
| 50 | + table.insert(self.arguments, argument) |
| 51 | + if kind == "positional" then |
| 52 | + table.insert(self.positional, argument) |
| 53 | + end |
| 54 | +end |
| 55 | + |
| 56 | +function cli.parse(self: Parser, args: {string}): () |
| 57 | + local i = 0 |
| 58 | + local pos_index = 1 |
| 59 | + |
| 60 | + while i < #args do |
| 61 | + i += 1 |
| 62 | + |
| 63 | + local arg = args[i] |
| 64 | + |
| 65 | + -- if the argument is exactly "--", we pass everything along |
| 66 | + if arg == "--" then |
| 67 | + table.move(args, i + 1, #args, 1, self.parsed.fwdArgs) |
| 68 | + break |
| 69 | + end |
| 70 | + |
| 71 | + -- if the argument starts with two dashes, we're parsing either a flag or an option |
| 72 | + if string.sub(arg, 1, 2) == "--" then |
| 73 | + local name = string.sub(arg, 3) |
| 74 | + local found = false |
| 75 | + |
| 76 | + for _, argument in self.arguments do |
| 77 | + local aliases = argument.options.aliases or {} |
| 78 | + |
| 79 | + if argument.name == name or table.find(aliases, name) then |
| 80 | + found = true |
| 81 | + |
| 82 | + if argument.kind == "option" then |
| 83 | + -- advance past the argument |
| 84 | + i += 1 |
| 85 | + |
| 86 | + assert(i <= #args, "Missing value for argument: " .. argument.name) |
| 87 | + self.parsed.values[argument.name] = args[i] |
| 88 | + |
| 89 | + break |
| 90 | + end |
| 91 | + |
| 92 | + self.parsed.flags[argument.name] = true |
| 93 | + break |
| 94 | + end |
| 95 | + end |
| 96 | + |
| 97 | + assert(found, "Unknown argument: " .. name) |
| 98 | + continue |
| 99 | + end |
| 100 | + |
| 101 | + -- if the argument starts with a single dash, we're parsing a flag |
| 102 | + if string.sub(arg, 1, 1) == "-" then |
| 103 | + local flags = string.sub(arg, 2) |
| 104 | + |
| 105 | + for j = 1, #flags do |
| 106 | + local name = string.sub(flags, j, j) |
| 107 | + local found = false |
| 108 | + for _, argument in self.arguments do |
| 109 | + local aliases = argument.options.aliases or {} |
| 110 | + if argument.name == name or table.find(aliases, name) then |
| 111 | + found = true |
| 112 | + if argument.kind == "option" then |
| 113 | + i += 1 |
| 114 | + assert(i <= #args, "Missing value for argument: " .. argument.name) |
| 115 | + self.parsed.values[argument.name] = args[i] |
| 116 | + else |
| 117 | + self.parsed.flags[argument.name] = true |
| 118 | + end |
| 119 | + break |
| 120 | + end |
| 121 | + end |
| 122 | + |
| 123 | + assert(found, "Unknown argument: " .. name) |
| 124 | + end |
| 125 | + |
| 126 | + continue |
| 127 | + end |
| 128 | + |
| 129 | + -- if we have positional arguments left, we can take this argument as one |
| 130 | + if pos_index <= #self.positional then |
| 131 | + self.parsed.values[self.positional[pos_index].name] = arg |
| 132 | + pos_index += 1 |
| 133 | + continue |
| 134 | + end |
| 135 | + |
| 136 | + -- otherwise, the argument is forwarded on |
| 137 | + table.insert(self.parsed.fwdArgs, arg) |
| 138 | + end |
| 139 | + |
| 140 | + -- check that all required arguments are present, and set any default values as needed |
| 141 | + for _, argument in self.arguments do |
| 142 | + assert(argument) |
| 143 | + |
| 144 | + if argument.options.required and self.parsed.values[argument.name] == nil then |
| 145 | + assert(self.parsed.values[argument.name], "Missing required argument: " .. argument.name) |
| 146 | + end |
| 147 | + |
| 148 | + if self.parsed.values[argument.name] == nil and argument.options.default then |
| 149 | + self.parsed.values[argument.name] = argument.options.default |
| 150 | + end |
| 151 | + end |
| 152 | +end |
| 153 | + |
| 154 | +function cli.get(self: Parser, name: string): boolean |
| 155 | + return self.parsed.values[name] |
| 156 | +end |
| 157 | + |
| 158 | +function cli.has(self: Parser, name: string): boolean |
| 159 | + return self.parsed.flags[name] ~= nil |
| 160 | +end |
| 161 | + |
| 162 | +function cli.forwaded(): { string }? |
| 163 | + return self.parsed.fwdArgs |
| 164 | +end |
| 165 | + |
| 166 | +function cli.help(self: Parser): () |
| 167 | + print("Usage:") |
| 168 | + for _, argument in self.arguments do |
| 169 | + local aliasStr = table.concat(argument.options.aliases or {}, ", ") |
| 170 | + local reqStr = if argument.options.required then "(required) " else "" |
| 171 | + print(string.format(" --%s (-%s) - %s%s", argument.name, aliasStr, reqStr, argument.options.help or "")) |
| 172 | + end |
| 173 | +end |
| 174 | + |
| 175 | +return cli |
0 commit comments