From 2cf120bdd3622dbc41d01a91a01edf10a85b633a Mon Sep 17 00:00:00 2001 From: "Wu, Zhenyu" Date: Sun, 27 Sep 2026 06:41:35 +0800 Subject: [PATCH] feat: support all remaining arguments --- docsrc/arguments.rst | 19 +++++++++ docsrc/options.rst | 19 +++++++++ spec/arguments_spec.lua | 29 ++++++++++++++ spec/commands_spec.lua | 57 ++++++++++++++++++++++++++ spec/options_spec.lua | 19 +++++++++ src/argparse.lua | 88 ++++++++++++++++++++++++++++++++++++++--- 6 files changed, 226 insertions(+), 5 deletions(-) diff --git a/docsrc/arguments.rst b/docsrc/arguments.rst index f946f47..96c0d3c 100644 --- a/docsrc/arguments.rst +++ b/docsrc/arguments.rst @@ -36,6 +36,7 @@ String ``N+``, where ``N`` is a number ``N`` or more arguments String ``?`` An optional argument String ``*`` Any number of arguments String ``+`` At least one argument +String ``...`` All remaining arguments ================================================= ============================= If more than one argument can be consumed, a table is used to store the data. @@ -89,3 +90,21 @@ The ``choices`` property can be used to restrict an argument to a set of choices Usage: script.lua [-h] {north,south,east,west} Error: argument 'direction' must be one of 'north', 'south', 'east', 'west' + +The ``...`` value can be used to capture all remaining command-line arguments, including options-like tokens. This is useful when passing arguments through to another command. + +.. code-block:: lua + :linenos: + + parser:argument "pass_through" + :args "..." + +.. code-block:: none + + $ lua script.lua foo --bar baz + +.. code-block:: lua + + { + pass_through = {"foo", "--bar", "baz"} + } diff --git a/docsrc/options.rst b/docsrc/options.rst index d8f7a6e..18f76c1 100644 --- a/docsrc/options.rst +++ b/docsrc/options.rst @@ -107,6 +107,25 @@ Just as arguments, options can be configured to take several command line argume Note that the data passed to ``optional`` option is stored in an array. That is necessary to distinguish whether the option was invoked without an argument or it was not invoked at all. +The ``...`` value works the same as for positional arguments: after the option is invoked, all remaining command-line arguments are captured as its values, including options-like tokens. + +.. code-block:: lua + :linenos: + + parser:option "--rest" + :args "..." + +.. code-block:: none + + $ lua script.lua --rest foo --bar baz + +.. code-block:: lua + + { + rest = {"foo", "--bar", "baz"} + } + + Setting argument choices ------------------------ diff --git a/spec/arguments_spec.lua b/spec/arguments_spec.lua index 5cf09c4..67a06ad 100644 --- a/spec/arguments_spec.lua +++ b/spec/arguments_spec.lua @@ -171,5 +171,34 @@ describe("tests related to positional arguments", function() parse:parse{"foo", "quu"} end, "argument 'foo' must be one of 'bar', 'baz', 'qu'") end) + + it("handles remainder argument correctly", function() + local parser = Parser() + parser:argument "foo" { + args = "..." + } + local args = parser:parse({"bar", "--baz", "-qu"}) + assert.same({foo = {"bar", "--baz", "-qu"}}, args) + end) + + it("stops parsing options after remainder argument starts", function() + local parser = Parser() + parser:option "-f" "--foo" + parser:argument "rest" { + args = "..." + } + local args = parser:parse({"--foo", "value", "--foo", "other"}) + assert.same({foo = "value", rest = {"--foo", "other"}}, args) + end) + + it("captures all remaining arguments including options-like", function() + local parser = Parser() + parser:argument "input" + parser:argument "rest" { + args = "..." + } + local args = parser:parse({"in", "--foo", "-bar", "baz"}) + assert.same({input = "in", rest = {"--foo", "-bar", "baz"}}, args) + end) end) end) diff --git a/spec/commands_spec.lua b/spec/commands_spec.lua index 97faf5c..d8da697 100644 --- a/spec/commands_spec.lua +++ b/spec/commands_spec.lua @@ -72,4 +72,61 @@ describe("tests related to commands", function() assert.has_error(function() parser:parse{"run"} end, "unknown command 'run'") end) + + it("stops parsing options after command with nargs='...'", function() + local parser = Parser "name" + :add_help(false) + local cmd = parser:command "run" + :args("...") + cmd:option "--foo" + + local args = parser:parse{"run", "--bar", "--baz", "qux"} + assert.same({run = {"--bar", "--baz", "qux"}}, args) + end) + + it("parses options before command with nargs='...'", function() + local parser = Parser "name" + :add_help(false) + parser:option "--global" + local cmd = parser:command "run" + :args("...") + cmd:option "--foo" + + local args = parser:parse{"--global", "value", "run", "--bar", "--baz"} + assert.same({global = "value", run = {"--bar", "--baz"}}, args) + end) + + it("handles command with nargs='...' without remainder args", function() + local parser = Parser "name" + :add_help(false) + local cmd = parser:command "run" + :args("...") + cmd:option "--foo" + + local args = parser:parse{"run"} + assert.same({run = true}, args) + end) + + it("handles nested commands with nargs='...'", function() + local parser = Parser "name" + :add_help(false) + local foo = parser:command "foo" + local bar = foo:command "bar" + :args("...") + bar:option "--opt" + + local args = parser:parse{"foo", "bar", "--something", "--else"} + assert.same({foo = true, bar = {"--something", "--else"}}, args) + end) + + it("command with nargs='...' captures options-like strings", function() + local parser = Parser "name" + :add_help(false) + parser:option "--before" + local cmd = parser:command "exec" + :args("...") + + local args = parser:parse{"--before", "val", "exec", "-x", "--flag", "arg"} + assert.same({before = "val", exec = {"-x", "--flag", "arg"}}, args) + end) end) diff --git a/spec/options_spec.lua b/spec/options_spec.lua index b4df9a6..3ab3192 100644 --- a/spec/options_spec.lua +++ b/spec/options_spec.lua @@ -339,5 +339,24 @@ describe("tests related to options", function() function() parser:parse{} end, "missing option '-f'") end) + + it("handles remainder option correctly", function() + local parser = Parser() + parser:option "-r" "--rest" { + args = "..." + } + local args = parser:parse({"--rest", "foo", "--bar", "-baz"}) + assert.same({rest = {"foo", "--bar", "-baz"}}, args) + end) + + it("stops parsing after remainder option starts", function() + local parser = Parser() + parser:option "-f" "--foo" + parser:option "-r" "--rest" { + args = "..." + } + local args = parser:parse({"--rest", "foo", "--foo", "bar"}) + assert.same({rest = {"foo", "--foo", "bar"}}, args) + end) end) end) diff --git a/src/argparse.lua b/src/argparse.lua index 858911e..e5b1190 100644 --- a/src/argparse.lua +++ b/src/argparse.lua @@ -171,6 +171,10 @@ local function parse_boundaries(str) return 0, 1 end + if str == "..." then + return 0, math.huge, true + end + if str:match "^%d+%-%d+$" then local min, max = str:match "^(%d+)%-(%d+)$" return tonumber(min), tonumber(max) @@ -186,13 +190,17 @@ local function boundaries(name) return {name, function(self, value) typecheck(name, {"number", "string"}, value) - local min, max = parse_boundaries(value) + local min, max, remainder = parse_boundaries(value) if not min then error(("bad property '%s'"):format(name)) end self["_min" .. name], self["_max" .. name] = min, max + + if remainder then + self["_remainder" .. name] = true + end end} end @@ -276,7 +284,9 @@ local Parser = class({ local Command = class({ _aliases = {}, - _public_aliases = {} + _public_aliases = {}, + _minargs = 0, + _maxargs = 0, }, { args = 3, multiname, @@ -298,7 +308,8 @@ local Command = class({ typechecked("help_description_margin", "number"), typechecked("help_max_width", "number"), typechecked("hidden", "boolean"), - add_help + add_help, + boundaries("args") }, Parser) local Argument = class({ @@ -396,6 +407,10 @@ function Argument:_get_argument_list() table.insert(buf, "...") end + if self._remainderargs then + table.insert(buf, "...") + end + return buf end @@ -1909,16 +1924,56 @@ function ParseState:invoke(option, name) end function ParseState:pass(arg) + if self._stop_parsing then + if self.option and self.option.element._remainderargs then + self.option:pass(arg) + return + end + + if self.argument and self.argument.element._remainderargs then + self.argument:pass(arg) + return + end + + -- If a command with nargs="..." triggered stop, collect remaining args + if self._command_remainderargs_table then + table.insert(self._command_remainderargs_table, arg) + return + end + end + if self.option then + if self.option.element._remainderargs then + self.option:pass(arg) + self._stop_parsing = true + return + end + if not self.option:pass(arg) then self.option = nil + -- If next argument is a remainder argument, stop option parsing + if self.argument and self.argument.element._remainderargs then + self._stop_parsing = true + end end elseif self.argument then + if self.argument.element._remainderargs then + self:check_mutexes(self.argument) + self.argument:pass(arg) + self._stop_parsing = true + self.handle_options = false + return + end + self:check_mutexes(self.argument) if not self.argument:pass(arg) then self.argument_i = self.argument_i + 1 self.argument = self.arguments[self.argument_i] + + if self.argument and self.argument.element._remainderargs then + self._stop_parsing = true + end end else local command = self:get_command(arg) @@ -1928,6 +1983,13 @@ function ParseState:pass(arg) self.result[self.parser._command_target] = command._name end + -- If command has nargs="...", prepare to collect remaining args + if command._remainderargs then + self._command_remainder_target = command._target or command._name + self._command_remainderargs_table = {} + self._stop_parsing = true + end + self:switch(command) end end @@ -1951,6 +2013,14 @@ function ParseState:finalize() end end + -- Store command remainder args into result + if self._command_remainderargs_table and self._command_remainder_target then + -- If there are remainder args, store them; otherwise keep the true value + if #self._command_remainderargs_table > 0 then + self.result[self._command_remainder_target] = self._command_remainderargs_table + end + end + if self.parser._require_command and #self.commands > 0 then self:error("a command is required") end @@ -1991,10 +2061,14 @@ function ParseState:parse(args) for _, arg in ipairs(args) do local plain = true - if self.handle_options then + if not self._stop_parsing and not self._command_remainderargs_table and self.handle_options then local first = arg:sub(1, 1) - if self.charset[first] then + -- Skip option parsing if current argument is remainder args and we've already collected at least one arg + -- But allow the first option to be parsed normally + if self.argument and self.argument.element._remainderargs and #self.argument.args > 0 then + plain = true + elseif self.charset[first] then if #arg > 1 then plain = false @@ -2044,6 +2118,10 @@ function ParseState:parse(args) if plain then self:pass(arg) end + + if self._stop_parsing then + self.handle_options = false + end end self:finalize()