From c3ddb6d68243b30b255621bf1b400d2fc02878e4 Mon Sep 17 00:00:00 2001 From: Oleksii Stroganov Date: Wed, 4 Jun 2025 18:12:20 +0300 Subject: [PATCH 1/6] Add stylua config and reformat source code --- src/argparse.lua | 3081 +++++++++++++++++++++++----------------------- stylua.toml | 6 + 2 files changed, 1571 insertions(+), 1516 deletions(-) create mode 100644 stylua.toml diff --git a/src/argparse.lua b/src/argparse.lua index 6b52962..4e288f0 100644 --- a/src/argparse.lua +++ b/src/argparse.lua @@ -21,1300 +21,1329 @@ -- CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. local function deep_update(t1, t2) - for k, v in pairs(t2) do - if type(v) == "table" then - v = deep_update({}, v) - end + for k, v in pairs(t2) do + if type(v) == "table" then + v = deep_update({}, v) + end - t1[k] = v - end + t1[k] = v + end - return t1 + return t1 end -- A property is a tuple {name, callback}. -- properties.args is number of properties that can be set as arguments -- when calling an object. local function class(prototype, properties, parent) - -- Class is the metatable of its instances. - local cl = {} - cl.__index = cl - - if parent then - cl.__prototype = deep_update(deep_update({}, parent.__prototype), prototype) - else - cl.__prototype = prototype - end - - if properties then - local names = {} - - -- Create setter methods and fill set of property names. - for _, property in ipairs(properties) do - local name, callback = property[1], property[2] - - cl[name] = function(self, value) - if not callback(self, value) then - self["_" .. name] = value - end + -- Class is the metatable of its instances. + local cl = {} + cl.__index = cl - return self - end - - names[name] = true - end - - function cl.__call(self, ...) - -- When calling an object, if the first argument is a table, - -- interpret keys as property names, else delegate arguments - -- to corresponding setters in order. - if type((...)) == "table" then - for name, value in pairs((...)) do - if names[name] then - self[name](self, value) - end + if parent then + cl.__prototype = deep_update(deep_update({}, parent.__prototype), prototype) + else + cl.__prototype = prototype + end + + if properties then + local names = {} + + -- Create setter methods and fill set of property names. + for _, property in ipairs(properties) do + local name, callback = property[1], property[2] + + cl[name] = function(self, value) + if not callback(self, value) then + self["_" .. name] = value + end + + return self end - else - local nargs = select("#", ...) - for i, property in ipairs(properties) do - if i > nargs or i > properties.args then - break - end + names[name] = true + end + + function cl.__call(self, ...) + -- When calling an object, if the first argument is a table, + -- interpret keys as property names, else delegate arguments + -- to corresponding setters in order. + if type((...)) == "table" then + for name, value in pairs((...)) do + if names[name] then + self[name](self, value) + end + end + else + local nargs = select("#", ...) + + for i, property in ipairs(properties) do + if i > nargs or i > properties.args then + break + end - local arg = select(i, ...) + local arg = select(i, ...) - if arg ~= nil then - self[property[1]](self, arg) - end + if arg ~= nil then + self[property[1]](self, arg) + end + end end - end - return self - end - end + return self + end + end - -- If indexing class fails, fallback to its parent. - local class_metatable = {} - class_metatable.__index = parent + -- If indexing class fails, fallback to its parent. + local class_metatable = {} + class_metatable.__index = parent - function class_metatable.__call(self, ...) - -- Calling a class returns its instance. - -- Arguments are delegated to the instance. - local object = deep_update({}, self.__prototype) - setmetatable(object, self) - return object(...) - end + function class_metatable.__call(self, ...) + -- Calling a class returns its instance. + -- Arguments are delegated to the instance. + local object = deep_update({}, self.__prototype) + setmetatable(object, self) + return object(...) + end - return setmetatable(cl, class_metatable) + return setmetatable(cl, class_metatable) end local function typecheck(name, types, value) - for _, type_ in ipairs(types) do - if type(value) == type_ then - return true - end - end + for _, type_ in ipairs(types) do + if type(value) == type_ then + return true + end + end - error(("bad property '%s' (%s expected, got %s)"):format(name, table.concat(types, " or "), type(value))) + error(("bad property '%s' (%s expected, got %s)"):format(name, table.concat(types, " or "), type(value))) end local function typechecked(name, ...) - local types = {...} - return {name, function(_, value) typecheck(name, types, value) end} -end - -local multiname = {"name", function(self, value) - typecheck("name", {"string"}, value) - - for alias in value:gmatch("%S+") do - self._name = self._name or alias - table.insert(self._aliases, alias) - table.insert(self._public_aliases, alias) - -- If alias contains '_', accept '-' also. - if alias:find("_", 1, true) then - table.insert(self._aliases, (alias:gsub("_", "-"))) - end - end + local types = { ... } + return { + name, + function(_, value) + typecheck(name, types, value) + end, + } +end + +local multiname = { + "name", + function(self, value) + typecheck("name", { "string" }, value) + + for alias in value:gmatch("%S+") do + self._name = self._name or alias + table.insert(self._aliases, alias) + table.insert(self._public_aliases, alias) + -- If alias contains '_', accept '-' also. + if alias:find("_", 1, true) then + table.insert(self._aliases, (alias:gsub("_", "-"))) + end + end - -- Do not set _name as with other properties. - return true -end} + -- Do not set _name as with other properties. + return true + end, +} -local multiname_hidden = {"hidden_name", function(self, value) - typecheck("hidden_name", {"string"}, value) +local multiname_hidden = { + "hidden_name", + function(self, value) + typecheck("hidden_name", { "string" }, value) - for alias in value:gmatch("%S+") do - table.insert(self._aliases, alias) - if alias:find("_", 1, true) then - table.insert(self._aliases, (alias:gsub("_", "-"))) - end - end + for alias in value:gmatch("%S+") do + table.insert(self._aliases, alias) + if alias:find("_", 1, true) then + table.insert(self._aliases, (alias:gsub("_", "-"))) + end + end - return true -end} + return true + end, +} local function parse_boundaries(str) - if tonumber(str) then - return tonumber(str), tonumber(str) - end + if tonumber(str) then + return tonumber(str), tonumber(str) + end - if str == "*" then - return 0, math.huge - end + if str == "*" then + return 0, math.huge + end - if str == "+" then - return 1, math.huge - end + if str == "+" then + return 1, math.huge + end - if str == "?" then - return 0, 1 - end + if str == "?" then + return 0, 1 + end - if str:match "^%d+%-%d+$" then - local min, max = str:match "^(%d+)%-(%d+)$" - return tonumber(min), tonumber(max) - end + if str:match("^%d+%-%d+$") then + local min, max = str:match("^(%d+)%-(%d+)$") + return tonumber(min), tonumber(max) + end - if str:match "^%d+%+$" then - local min = str:match "^(%d+)%+$" - return tonumber(min), math.huge - end + if str:match("^%d+%+$") then + local min = str:match("^(%d+)%+$") + return tonumber(min), math.huge + end end local function boundaries(name) - return {name, function(self, value) - typecheck(name, {"number", "string"}, value) + return { + name, + function(self, value) + typecheck(name, { "number", "string" }, value) - local min, max = parse_boundaries(value) + local min, max = parse_boundaries(value) - if not min then - error(("bad property '%s'"):format(name)) - end + if not min then + error(("bad property '%s'"):format(name)) + end - self["_min" .. name], self["_max" .. name] = min, max - end} + self["_min" .. name], self["_max" .. name] = min, max + end, + } end local actions = {} -local option_action = {"action", function(_, value) - typecheck("action", {"function", "string"}, value) - - if type(value) == "string" and not actions[value] then - error(("unknown action '%s'"):format(value)) - end -end} - -local option_init = {"init", function(self) - self._has_init = true -end} - -local option_default = {"default", function(self, value) - if type(value) ~= "string" then - self._init = value - self._has_init = true - return true - end -end} - -local add_help = {"add_help", function(self, value) - typecheck("add_help", {"boolean", "string", "table"}, value) - - if self._help_option_idx then - table.remove(self._options, self._help_option_idx) - self._help_option_idx = nil - end - - if value then - local help = self:flag() - :description "Show this help message and exit." - :action(function() - print(self:get_help()) - os.exit(0) - end) +local option_action = { + "action", + function(_, value) + typecheck("action", { "function", "string" }, value) - if value ~= true then - help = help(value) - end + if type(value) == "string" and not actions[value] then + error(("unknown action '%s'"):format(value)) + end + end, +} - if not help._name then - help "-h" "--help" - end +local option_init = { + "init", + function(self) + self._has_init = true + end, +} - self._help_option_idx = #self._options - end -end} +local option_default = { + "default", + function(self, value) + if type(value) ~= "string" then + self._init = value + self._has_init = true + return true + end + end, +} + +local add_help = { + "add_help", + function(self, value) + typecheck("add_help", { "boolean", "string", "table" }, value) + + if self._help_option_idx then + table.remove(self._options, self._help_option_idx) + self._help_option_idx = nil + end + + if value then + local help = self:flag():description("Show this help message and exit."):action(function() + print(self:get_help()) + os.exit(0) + end) + + if value ~= true then + help = help(value) + end + + if not help._name then + help("-h")("--help") + end + + self._help_option_idx = #self._options + end + end, +} local Parser = class({ - _arguments = {}, - _options = {}, - _commands = {}, - _mutexes = {}, - _groups = {}, - _require_command = true, - _handle_options = true + _arguments = {}, + _options = {}, + _commands = {}, + _mutexes = {}, + _groups = {}, + _require_command = true, + _handle_options = true, }, { - args = 3, - typechecked("name", "string"), - typechecked("description", "string"), - typechecked("epilog", "string"), - typechecked("usage", "string"), - typechecked("help", "string"), - typechecked("require_command", "boolean"), - typechecked("handle_options", "boolean"), - typechecked("action", "function"), - typechecked("command_target", "string"), - typechecked("help_vertical_space", "number"), - typechecked("usage_margin", "number"), - typechecked("usage_max_width", "number"), - typechecked("help_usage_margin", "number"), - typechecked("help_description_margin", "number"), - typechecked("help_max_width", "number"), - add_help + args = 3, + typechecked("name", "string"), + typechecked("description", "string"), + typechecked("epilog", "string"), + typechecked("usage", "string"), + typechecked("help", "string"), + typechecked("require_command", "boolean"), + typechecked("handle_options", "boolean"), + typechecked("action", "function"), + typechecked("command_target", "string"), + typechecked("help_vertical_space", "number"), + typechecked("usage_margin", "number"), + typechecked("usage_max_width", "number"), + typechecked("help_usage_margin", "number"), + typechecked("help_description_margin", "number"), + typechecked("help_max_width", "number"), + add_help, }) local Command = class({ - _aliases = {}, - _public_aliases = {} + _aliases = {}, + _public_aliases = {}, }, { - args = 3, - multiname, - typechecked("description", "string"), - typechecked("epilog", "string"), - multiname_hidden, - typechecked("summary", "string"), - typechecked("target", "string"), - typechecked("usage", "string"), - typechecked("help", "string"), - typechecked("require_command", "boolean"), - typechecked("handle_options", "boolean"), - typechecked("action", "function"), - typechecked("command_target", "string"), - typechecked("help_vertical_space", "number"), - typechecked("usage_margin", "number"), - typechecked("usage_max_width", "number"), - typechecked("help_usage_margin", "number"), - typechecked("help_description_margin", "number"), - typechecked("help_max_width", "number"), - typechecked("hidden", "boolean"), - add_help + args = 3, + multiname, + typechecked("description", "string"), + typechecked("epilog", "string"), + multiname_hidden, + typechecked("summary", "string"), + typechecked("target", "string"), + typechecked("usage", "string"), + typechecked("help", "string"), + typechecked("require_command", "boolean"), + typechecked("handle_options", "boolean"), + typechecked("action", "function"), + typechecked("command_target", "string"), + typechecked("help_vertical_space", "number"), + typechecked("usage_margin", "number"), + typechecked("usage_max_width", "number"), + typechecked("help_usage_margin", "number"), + typechecked("help_description_margin", "number"), + typechecked("help_max_width", "number"), + typechecked("hidden", "boolean"), + add_help, }, Parser) local Argument = class({ - _minargs = 1, - _maxargs = 1, - _mincount = 1, - _maxcount = 1, - _defmode = "unused", - _show_default = true + _minargs = 1, + _maxargs = 1, + _mincount = 1, + _maxcount = 1, + _defmode = "unused", + _show_default = true, }, { - args = 5, - typechecked("name", "string"), - typechecked("description", "string"), - option_default, - typechecked("convert", "function", "table"), - boundaries("args"), - typechecked("target", "string"), - typechecked("defmode", "string"), - typechecked("show_default", "boolean"), - typechecked("argname", "string", "table"), - typechecked("choices", "table"), - typechecked("hidden", "boolean"), - option_action, - option_init + args = 5, + typechecked("name", "string"), + typechecked("description", "string"), + option_default, + typechecked("convert", "function", "table"), + boundaries("args"), + typechecked("target", "string"), + typechecked("defmode", "string"), + typechecked("show_default", "boolean"), + typechecked("argname", "string", "table"), + typechecked("choices", "table"), + typechecked("hidden", "boolean"), + option_action, + option_init, }) local Option = class({ - _aliases = {}, - _public_aliases = {}, - _mincount = 0, - _overwrite = true + _aliases = {}, + _public_aliases = {}, + _mincount = 0, + _overwrite = true, }, { - args = 6, - multiname, - typechecked("description", "string"), - option_default, - typechecked("convert", "function", "table"), - boundaries("args"), - boundaries("count"), - multiname_hidden, - typechecked("target", "string"), - typechecked("defmode", "string"), - typechecked("show_default", "boolean"), - typechecked("overwrite", "boolean"), - typechecked("argname", "string", "table"), - typechecked("choices", "table"), - typechecked("hidden", "boolean"), - option_action, - option_init + args = 6, + multiname, + typechecked("description", "string"), + option_default, + typechecked("convert", "function", "table"), + boundaries("args"), + boundaries("count"), + multiname_hidden, + typechecked("target", "string"), + typechecked("defmode", "string"), + typechecked("show_default", "boolean"), + typechecked("overwrite", "boolean"), + typechecked("argname", "string", "table"), + typechecked("choices", "table"), + typechecked("hidden", "boolean"), + option_action, + option_init, }, Argument) function Parser:_inherit_property(name, default) - local element = self + local element = self - while true do - local value = element["_" .. name] + while true do + local value = element["_" .. name] - if value ~= nil then - return value - end + if value ~= nil then + return value + end - if not element._parent then - return default - end + if not element._parent then + return default + end - element = element._parent - end + element = element._parent + end end function Argument:_get_argument_list() - local buf = {} - local i = 1 + local buf = {} + local i = 1 - while i <= math.min(self._minargs, 3) do - local argname = self:_get_argname(i) + while i <= math.min(self._minargs, 3) do + local argname = self:_get_argname(i) - if self._default and self._defmode:find "a" then - argname = "[" .. argname .. "]" - end + if self._default and self._defmode:find("a") then + argname = "[" .. argname .. "]" + end - table.insert(buf, argname) - i = i+1 - end + table.insert(buf, argname) + i = i + 1 + end - while i <= math.min(self._maxargs, 3) do - table.insert(buf, "[" .. self:_get_argname(i) .. "]") - i = i+1 + while i <= math.min(self._maxargs, 3) do + table.insert(buf, "[" .. self:_get_argname(i) .. "]") + i = i + 1 - if self._maxargs == math.huge then - break - end - end + if self._maxargs == math.huge then + break + end + end - if i < self._maxargs then - table.insert(buf, "...") - end + if i < self._maxargs then + table.insert(buf, "...") + end - return buf + return buf end function Argument:_get_usage() - local usage = table.concat(self:_get_argument_list(), " ") + local usage = table.concat(self:_get_argument_list(), " ") - if self._default and self._defmode:find "u" then - if self._maxargs > 1 or (self._minargs == 1 and not self._defmode:find "a") then - usage = "[" .. usage .. "]" - end - end + if self._default and self._defmode:find("u") then + if self._maxargs > 1 or (self._minargs == 1 and not self._defmode:find("a")) then + usage = "[" .. usage .. "]" + end + end - return usage + return usage end function actions.store_true(result, target) - result[target] = true + result[target] = true end function actions.store_false(result, target) - result[target] = false + result[target] = false end function actions.store(result, target, argument) - result[target] = argument + result[target] = argument end function actions.count(result, target, _, overwrite) - if not overwrite then - result[target] = result[target] + 1 - end + if not overwrite then + result[target] = result[target] + 1 + end end function actions.append(result, target, argument, overwrite) - result[target] = result[target] or {} - table.insert(result[target], argument) + result[target] = result[target] or {} + table.insert(result[target], argument) - if overwrite then - table.remove(result[target], 1) - end + if overwrite then + table.remove(result[target], 1) + end end function actions.concat(result, target, arguments, overwrite) - if overwrite then - error("'concat' action can't handle too many invocations") - end + if overwrite then + error("'concat' action can't handle too many invocations") + end - result[target] = result[target] or {} + result[target] = result[target] or {} - for _, argument in ipairs(arguments) do - table.insert(result[target], argument) - end + for _, argument in ipairs(arguments) do + table.insert(result[target], argument) + end end function Argument:_get_action() - local action, init + local action, init - if self._maxcount == 1 then - if self._maxargs == 0 then - action, init = "store_true", nil - else - action, init = "store", nil - end - else - if self._maxargs == 0 then - action, init = "count", 0 - else - action, init = "append", {} - end - end + if self._maxcount == 1 then + if self._maxargs == 0 then + action, init = "store_true", nil + else + action, init = "store", nil + end + else + if self._maxargs == 0 then + action, init = "count", 0 + else + action, init = "append", {} + end + end - if self._action then - action = self._action - end + if self._action then + action = self._action + end - if self._has_init then - init = self._init - end + if self._has_init then + init = self._init + end - if type(action) == "string" then - action = actions[action] - end + if type(action) == "string" then + action = actions[action] + end - return action, init + return action, init end -- Returns placeholder for `narg`-th argument. function Argument:_get_argname(narg) - local argname = self._argname or self:_get_default_argname() + local argname = self._argname or self:_get_default_argname() - if type(argname) == "table" then - return argname[narg] - else - return argname - end + if type(argname) == "table" then + return argname[narg] + else + return argname + end end function Argument:_get_choices_list() - return "{" .. table.concat(self._choices, ",") .. "}" + return "{" .. table.concat(self._choices, ",") .. "}" end function Argument:_get_default_argname() - if self._choices then - return self:_get_choices_list() - else - return "<" .. self._name .. ">" - end + if self._choices then + return self:_get_choices_list() + else + return "<" .. self._name .. ">" + end end function Option:_get_default_argname() - if self._choices then - return self:_get_choices_list() - else - return "<" .. self:_get_default_target() .. ">" - end + if self._choices then + return self:_get_choices_list() + else + return "<" .. self:_get_default_target() .. ">" + end end -- Returns labels to be shown in the help message. function Argument:_get_label_lines() - if self._choices then - return {self:_get_choices_list()} - else - return {self._name} - end + if self._choices then + return { self:_get_choices_list() } + else + return { self._name } + end end function Option:_get_label_lines() - local argument_list = self:_get_argument_list() + local argument_list = self:_get_argument_list() - if #argument_list == 0 then - -- Don't put aliases for simple flags like `-h` on different lines. - return {table.concat(self._public_aliases, ", ")} - end + if #argument_list == 0 then + -- Don't put aliases for simple flags like `-h` on different lines. + return { table.concat(self._public_aliases, ", ") } + end - local longest_alias_length = -1 + local longest_alias_length = -1 - for _, alias in ipairs(self._public_aliases) do - longest_alias_length = math.max(longest_alias_length, #alias) - end + for _, alias in ipairs(self._public_aliases) do + longest_alias_length = math.max(longest_alias_length, #alias) + end - local argument_list_repr = table.concat(argument_list, " ") - local lines = {} + local argument_list_repr = table.concat(argument_list, " ") + local lines = {} - for i, alias in ipairs(self._public_aliases) do - local line = (" "):rep(longest_alias_length - #alias) .. alias .. " " .. argument_list_repr + for i, alias in ipairs(self._public_aliases) do + local line = (" "):rep(longest_alias_length - #alias) .. alias .. " " .. argument_list_repr - if i ~= #self._public_aliases then - line = line .. "," - end + if i ~= #self._public_aliases then + line = line .. "," + end - table.insert(lines, line) - end + table.insert(lines, line) + end - return lines + return lines end function Command:_get_label_lines() - return {table.concat(self._public_aliases, ", ")} + return { table.concat(self._public_aliases, ", ") } end function Argument:_get_description() - if self._default and self._show_default then - if self._description then - return ("%s (default: %s)"):format(self._description, self._default) - else - return ("default: %s"):format(self._default) - end - else - return self._description or "" - end + if self._default and self._show_default then + if self._description then + return ("%s (default: %s)"):format(self._description, self._default) + else + return ("default: %s"):format(self._default) + end + else + return self._description or "" + end end function Command:_get_description() - return self._summary or self._description or "" + return self._summary or self._description or "" end function Option:_get_usage() - local usage = self:_get_argument_list() - table.insert(usage, 1, self._name) - usage = table.concat(usage, " ") + local usage = self:_get_argument_list() + table.insert(usage, 1, self._name) + usage = table.concat(usage, " ") - if self._mincount == 0 or self._default then - usage = "[" .. usage .. "]" - end + if self._mincount == 0 or self._default then + usage = "[" .. usage .. "]" + end - return usage + return usage end function Argument:_get_default_target() - return self._name + return self._name end function Option:_get_default_target() - local res + local res - for _, alias in ipairs(self._public_aliases) do - if alias:sub(1, 1) == alias:sub(2, 2) then - res = alias:sub(3) - break - end - end + for _, alias in ipairs(self._public_aliases) do + if alias:sub(1, 1) == alias:sub(2, 2) then + res = alias:sub(3) + break + end + end - res = res or self._name:sub(2) - return (res:gsub("-", "_")) + res = res or self._name:sub(2) + return (res:gsub("-", "_")) end function Option:_is_vararg() - return self._maxargs ~= self._minargs + return self._maxargs ~= self._minargs end function Parser:_get_fullname(exclude_root) - local parent = self._parent - if exclude_root and not parent then - return "" - end - local buf = {self._name} + local parent = self._parent + if exclude_root and not parent then + return "" + end + local buf = { self._name } - while parent do - if not exclude_root or parent._parent then - table.insert(buf, 1, parent._name) - end - parent = parent._parent - end + while parent do + if not exclude_root or parent._parent then + table.insert(buf, 1, parent._name) + end + parent = parent._parent + end - return table.concat(buf, " ") + return table.concat(buf, " ") end function Parser:_update_charset(charset) - charset = charset or {} + charset = charset or {} - for _, command in ipairs(self._commands) do - command:_update_charset(charset) - end + for _, command in ipairs(self._commands) do + command:_update_charset(charset) + end - for _, option in ipairs(self._options) do - for _, alias in ipairs(option._aliases) do - charset[alias:sub(1, 1)] = true - end - end + for _, option in ipairs(self._options) do + for _, alias in ipairs(option._aliases) do + charset[alias:sub(1, 1)] = true + end + end - return charset + return charset end function Parser:argument(...) - local argument = Argument(...) - table.insert(self._arguments, argument) - return argument + local argument = Argument(...) + table.insert(self._arguments, argument) + return argument end function Parser:option(...) - local option = Option(...) - table.insert(self._options, option) - return option + local option = Option(...) + table.insert(self._options, option) + return option end function Parser:flag(...) - return self:option():args(0)(...) + return self:option():args(0)(...) end function Parser:command(...) - local command = Command():add_help(true)(...) - command._parent = self - table.insert(self._commands, command) - return command + local command = Command():add_help(true)(...) + command._parent = self + table.insert(self._commands, command) + return command end function Parser:mutex(...) - local elements = {...} + local elements = { ... } - for i, element in ipairs(elements) do - local mt = getmetatable(element) - assert(mt == Option or mt == Argument, ("bad argument #%d to 'mutex' (Option or Argument expected)"):format(i)) - end + for i, element in ipairs(elements) do + local mt = getmetatable(element) + assert(mt == Option or mt == Argument, ("bad argument #%d to 'mutex' (Option or Argument expected)"):format(i)) + end - table.insert(self._mutexes, elements) - return self + table.insert(self._mutexes, elements) + return self end function Parser:group(name, ...) - assert(type(name) == "string", ("bad argument #1 to 'group' (string expected, got %s)"):format(type(name))) + assert(type(name) == "string", ("bad argument #1 to 'group' (string expected, got %s)"):format(type(name))) - local group = {name = name, ...} + local group = { name = name, ... } - for i, element in ipairs(group) do - local mt = getmetatable(element) - assert(mt == Option or mt == Argument or mt == Command, - ("bad argument #%d to 'group' (Option or Argument or Command expected)"):format(i + 1)) - end + for i, element in ipairs(group) do + local mt = getmetatable(element) + assert( + mt == Option or mt == Argument or mt == Command, + ("bad argument #%d to 'group' (Option or Argument or Command expected)"):format(i + 1) + ) + end - table.insert(self._groups, group) - return self + table.insert(self._groups, group) + return self end local usage_welcome = "Usage: " function Parser:get_usage() - if self._usage then - return self._usage - end - - local usage_margin = self:_inherit_property("usage_margin", #usage_welcome) - local max_usage_width = self:_inherit_property("usage_max_width", 70) - local lines = {usage_welcome .. self:_get_fullname()} - - local function add(s) - if #lines[#lines]+1+#s <= max_usage_width then - lines[#lines] = lines[#lines] .. " " .. s - else - lines[#lines+1] = (" "):rep(usage_margin) .. s - end - end - - -- Normally options are before positional arguments in usage messages. - -- However, vararg options should be after, because they can't be reliable used - -- before a positional argument. - -- Mutexes come into play, too, and are shown as soon as possible. - -- Overall, output usages in the following order: - -- 1. Mutexes that don't have positional arguments or vararg options. - -- 2. Options that are not in any mutexes and are not vararg. - -- 3. Positional arguments - on their own or as a part of a mutex. - -- 4. Remaining mutexes. - -- 5. Remaining options. - - local elements_in_mutexes = {} - local added_elements = {} - local added_mutexes = {} - local argument_to_mutexes = {} - - local function add_mutex(mutex, main_argument) - if added_mutexes[mutex] then - return - end - - added_mutexes[mutex] = true - local buf = {} - - for _, element in ipairs(mutex) do - if not element._hidden and not added_elements[element] then - if getmetatable(element) == Option or element == main_argument then - table.insert(buf, element:_get_usage()) - added_elements[element] = true + if self._usage then + return self._usage + end + + local usage_margin = self:_inherit_property("usage_margin", #usage_welcome) + local max_usage_width = self:_inherit_property("usage_max_width", 70) + local lines = { usage_welcome .. self:_get_fullname() } + + local function add(s) + if #lines[#lines] + 1 + #s <= max_usage_width then + lines[#lines] = lines[#lines] .. " " .. s + else + lines[#lines + 1] = (" "):rep(usage_margin) .. s + end + end + + -- Normally options are before positional arguments in usage messages. + -- However, vararg options should be after, because they can't be reliable used + -- before a positional argument. + -- Mutexes come into play, too, and are shown as soon as possible. + -- Overall, output usages in the following order: + -- 1. Mutexes that don't have positional arguments or vararg options. + -- 2. Options that are not in any mutexes and are not vararg. + -- 3. Positional arguments - on their own or as a part of a mutex. + -- 4. Remaining mutexes. + -- 5. Remaining options. + + local elements_in_mutexes = {} + local added_elements = {} + local added_mutexes = {} + local argument_to_mutexes = {} + + local function add_mutex(mutex, main_argument) + if added_mutexes[mutex] then + return + end + + added_mutexes[mutex] = true + local buf = {} + + for _, element in ipairs(mutex) do + if not element._hidden and not added_elements[element] then + if getmetatable(element) == Option or element == main_argument then + table.insert(buf, element:_get_usage()) + added_elements[element] = true + end end - end - end - - if #buf == 1 then - add(buf[1]) - elseif #buf > 1 then - add("(" .. table.concat(buf, " | ") .. ")") - end - end - - local function add_element(element) - if not element._hidden and not added_elements[element] then - add(element:_get_usage()) - added_elements[element] = true - end - end - - for _, mutex in ipairs(self._mutexes) do - local is_vararg = false - local has_argument = false - - for _, element in ipairs(mutex) do - if getmetatable(element) == Option then - if element:_is_vararg() then - is_vararg = true + end + + if #buf == 1 then + add(buf[1]) + elseif #buf > 1 then + add("(" .. table.concat(buf, " | ") .. ")") + end + end + + local function add_element(element) + if not element._hidden and not added_elements[element] then + add(element:_get_usage()) + added_elements[element] = true + end + end + + for _, mutex in ipairs(self._mutexes) do + local is_vararg = false + local has_argument = false + + for _, element in ipairs(mutex) do + if getmetatable(element) == Option then + if element:_is_vararg() then + is_vararg = true + end + else + has_argument = true + argument_to_mutexes[element] = argument_to_mutexes[element] or {} + table.insert(argument_to_mutexes[element], mutex) end - else - has_argument = true - argument_to_mutexes[element] = argument_to_mutexes[element] or {} - table.insert(argument_to_mutexes[element], mutex) - end - - elements_in_mutexes[element] = true - end - - if not is_vararg and not has_argument then - add_mutex(mutex) - end - end - - for _, option in ipairs(self._options) do - if not elements_in_mutexes[option] and not option:_is_vararg() then - add_element(option) - end - end - - -- Add usages for positional arguments, together with one mutex containing them, if they are in a mutex. - for _, argument in ipairs(self._arguments) do - -- Pick a mutex as a part of which to show this argument, take the first one that's still available. - local mutex - - if elements_in_mutexes[argument] then - for _, argument_mutex in ipairs(argument_to_mutexes[argument]) do - if not added_mutexes[argument_mutex] then - mutex = argument_mutex + + elements_in_mutexes[element] = true + end + + if not is_vararg and not has_argument then + add_mutex(mutex) + end + end + + for _, option in ipairs(self._options) do + if not elements_in_mutexes[option] and not option:_is_vararg() then + add_element(option) + end + end + + -- Add usages for positional arguments, together with one mutex containing them, if they are in a mutex. + for _, argument in ipairs(self._arguments) do + -- Pick a mutex as a part of which to show this argument, take the first one that's still available. + local mutex + + if elements_in_mutexes[argument] then + for _, argument_mutex in ipairs(argument_to_mutexes[argument]) do + if not added_mutexes[argument_mutex] then + mutex = argument_mutex + end end - end - end + end - if mutex then - add_mutex(mutex, argument) - else - add_element(argument) - end - end + if mutex then + add_mutex(mutex, argument) + else + add_element(argument) + end + end - for _, mutex in ipairs(self._mutexes) do - add_mutex(mutex) - end + for _, mutex in ipairs(self._mutexes) do + add_mutex(mutex) + end - for _, option in ipairs(self._options) do - add_element(option) - end + for _, option in ipairs(self._options) do + add_element(option) + end - if #self._commands > 0 then - if self._require_command then - add("") - else - add("[]") - end + if #self._commands > 0 then + if self._require_command then + add("") + else + add("[]") + end - add("...") - end + add("...") + end - return table.concat(lines, "\n") + return table.concat(lines, "\n") end local function split_lines(s) - if s == "" then - return {} - end + if s == "" then + return {} + end - local lines = {} + local lines = {} - if s:sub(-1) ~= "\n" then - s = s .. "\n" - end + if s:sub(-1) ~= "\n" then + s = s .. "\n" + end - for line in s:gmatch("([^\n]*)\n") do - table.insert(lines, line) - end + for line in s:gmatch("([^\n]*)\n") do + table.insert(lines, line) + end - return lines + return lines end local function autowrap_line(line, max_length) - -- Algorithm for splitting lines is simple and greedy. - local result_lines = {} + -- Algorithm for splitting lines is simple and greedy. + local result_lines = {} - -- Preserve original indentation of the line, put this at the beginning of each result line. - -- If the first word looks like a list marker ('*', '+', or '-'), add spaces so that starts - -- of the second and the following lines vertically align with the start of the second word. - local indentation = line:match("^ *") + -- Preserve original indentation of the line, put this at the beginning of each result line. + -- If the first word looks like a list marker ('*', '+', or '-'), add spaces so that starts + -- of the second and the following lines vertically align with the start of the second word. + local indentation = line:match("^ *") - if line:find("^ *[%*%+%-]") then - indentation = indentation .. " " .. line:match("^ *[%*%+%-]( *)") - end + if line:find("^ *[%*%+%-]") then + indentation = indentation .. " " .. line:match("^ *[%*%+%-]( *)") + end - -- Parts of the last line being assembled. - local line_parts = {} + -- Parts of the last line being assembled. + local line_parts = {} - -- Length of the current line. - local line_length = 0 + -- Length of the current line. + local line_length = 0 - -- Index of the next character to consider. - local index = 1 + -- Index of the next character to consider. + local index = 1 - while true do - local word_start, word_finish, word = line:find("([^ ]+)", index) + while true do + local word_start, word_finish, word = line:find("([^ ]+)", index) - if not word_start then - -- Ignore trailing spaces, if any. - break - end + if not word_start then + -- Ignore trailing spaces, if any. + break + end - local preceding_spaces = line:sub(index, word_start - 1) - index = word_finish + 1 + local preceding_spaces = line:sub(index, word_start - 1) + index = word_finish + 1 - if (#line_parts == 0) or (line_length + #preceding_spaces + #word <= max_length) then - -- Either this is the very first word or it fits as an addition to the current line, add it. - table.insert(line_parts, preceding_spaces) -- For the very first word this adds the indentation. - table.insert(line_parts, word) - line_length = line_length + #preceding_spaces + #word - else - -- Does not fit, finish current line and put the word into a new one. - table.insert(result_lines, table.concat(line_parts)) - line_parts = {indentation, word} - line_length = #indentation + #word - end - end + if (#line_parts == 0) or (line_length + #preceding_spaces + #word <= max_length) then + -- Either this is the very first word or it fits as an addition to the current line, add it. + table.insert(line_parts, preceding_spaces) -- For the very first word this adds the indentation. + table.insert(line_parts, word) + line_length = line_length + #preceding_spaces + #word + else + -- Does not fit, finish current line and put the word into a new one. + table.insert(result_lines, table.concat(line_parts)) + line_parts = { indentation, word } + line_length = #indentation + #word + end + end - if #line_parts > 0 then - table.insert(result_lines, table.concat(line_parts)) - end + if #line_parts > 0 then + table.insert(result_lines, table.concat(line_parts)) + end - if #result_lines == 0 then - -- Preserve empty lines. - result_lines[1] = "" - end + if #result_lines == 0 then + -- Preserve empty lines. + result_lines[1] = "" + end - return result_lines + return result_lines end -- Automatically wraps lines within given array, -- attempting to limit line length to `max_length`. -- Existing line splits are preserved. local function autowrap(lines, max_length) - local result_lines = {} + local result_lines = {} - for _, line in ipairs(lines) do - local autowrapped_lines = autowrap_line(line, max_length) + for _, line in ipairs(lines) do + local autowrapped_lines = autowrap_line(line, max_length) - for _, autowrapped_line in ipairs(autowrapped_lines) do - table.insert(result_lines, autowrapped_line) - end - end + for _, autowrapped_line in ipairs(autowrapped_lines) do + table.insert(result_lines, autowrapped_line) + end + end - return result_lines + return result_lines end function Parser:_get_element_help(element) - local label_lines = element:_get_label_lines() - local description_lines = split_lines(element:_get_description()) + local label_lines = element:_get_label_lines() + local description_lines = split_lines(element:_get_description()) - local result_lines = {} + local result_lines = {} - -- All label lines should have the same length (except the last one, it has no comma). - -- If too long, start description after all the label lines. - -- Otherwise, combine label and description lines. + -- All label lines should have the same length (except the last one, it has no comma). + -- If too long, start description after all the label lines. + -- Otherwise, combine label and description lines. - local usage_margin_len = self:_inherit_property("help_usage_margin", 3) - local usage_margin = (" "):rep(usage_margin_len) - local description_margin_len = self:_inherit_property("help_description_margin", 25) - local description_margin = (" "):rep(description_margin_len) + local usage_margin_len = self:_inherit_property("help_usage_margin", 3) + local usage_margin = (" "):rep(usage_margin_len) + local description_margin_len = self:_inherit_property("help_description_margin", 25) + local description_margin = (" "):rep(description_margin_len) - local help_max_width = self:_inherit_property("help_max_width") + local help_max_width = self:_inherit_property("help_max_width") - if help_max_width then - local description_max_width = math.max(help_max_width - description_margin_len, 10) - description_lines = autowrap(description_lines, description_max_width) - end + if help_max_width then + local description_max_width = math.max(help_max_width - description_margin_len, 10) + description_lines = autowrap(description_lines, description_max_width) + end - if #label_lines[1] >= (description_margin_len - usage_margin_len) then - for _, label_line in ipairs(label_lines) do - table.insert(result_lines, usage_margin .. label_line) - end + if #label_lines[1] >= (description_margin_len - usage_margin_len) then + for _, label_line in ipairs(label_lines) do + table.insert(result_lines, usage_margin .. label_line) + end - for _, description_line in ipairs(description_lines) do - table.insert(result_lines, description_margin .. description_line) - end - else - for i = 1, math.max(#label_lines, #description_lines) do - local label_line = label_lines[i] - local description_line = description_lines[i] + for _, description_line in ipairs(description_lines) do + table.insert(result_lines, description_margin .. description_line) + end + else + for i = 1, math.max(#label_lines, #description_lines) do + local label_line = label_lines[i] + local description_line = description_lines[i] - local line = "" + local line = "" - if label_line then - line = usage_margin .. label_line - end + if label_line then + line = usage_margin .. label_line + end - if description_line and description_line ~= "" then - line = line .. (" "):rep(description_margin_len - #line) .. description_line - end + if description_line and description_line ~= "" then + line = line .. (" "):rep(description_margin_len - #line) .. description_line + end - table.insert(result_lines, line) - end - end + table.insert(result_lines, line) + end + end - return table.concat(result_lines, "\n") + return table.concat(result_lines, "\n") end local function get_group_types(group) - local types = {} + local types = {} - for _, element in ipairs(group) do - types[getmetatable(element)] = true - end + for _, element in ipairs(group) do + types[getmetatable(element)] = true + end - return types + return types end function Parser:_add_group_help(blocks, added_elements, label, elements) - local buf = {label} + local buf = { label } - for _, element in ipairs(elements) do - if not element._hidden and not added_elements[element] then - added_elements[element] = true - table.insert(buf, self:_get_element_help(element)) - end - end + for _, element in ipairs(elements) do + if not element._hidden and not added_elements[element] then + added_elements[element] = true + table.insert(buf, self:_get_element_help(element)) + end + end - if #buf > 1 then - table.insert(blocks, table.concat(buf, ("\n"):rep(self:_inherit_property("help_vertical_space", 0) + 1))) - end + if #buf > 1 then + table.insert(blocks, table.concat(buf, ("\n"):rep(self:_inherit_property("help_vertical_space", 0) + 1))) + end end function Parser:get_help() - if self._help then - return self._help - end + if self._help then + return self._help + end - local blocks = {self:get_usage()} + local blocks = { self:get_usage() } - local help_max_width = self:_inherit_property("help_max_width") + local help_max_width = self:_inherit_property("help_max_width") - if self._description then - local description = self._description + if self._description then + local description = self._description - if help_max_width then - description = table.concat(autowrap(split_lines(description), help_max_width), "\n") - end + if help_max_width then + description = table.concat(autowrap(split_lines(description), help_max_width), "\n") + end - table.insert(blocks, description) - end + table.insert(blocks, description) + end - -- 1. Put groups containing arguments first, then other arguments. - -- 2. Put remaining groups containing options, then other options. - -- 3. Put remaining groups containing commands, then other commands. - -- Assume that an element can't be in several groups. - local groups_by_type = { - [Argument] = {}, - [Option] = {}, - [Command] = {} - } + -- 1. Put groups containing arguments first, then other arguments. + -- 2. Put remaining groups containing options, then other options. + -- 3. Put remaining groups containing commands, then other commands. + -- Assume that an element can't be in several groups. + local groups_by_type = { + [Argument] = {}, + [Option] = {}, + [Command] = {}, + } - for _, group in ipairs(self._groups) do - local group_types = get_group_types(group) + for _, group in ipairs(self._groups) do + local group_types = get_group_types(group) - for _, mt in ipairs({Argument, Option, Command}) do - if group_types[mt] then - table.insert(groups_by_type[mt], group) - break - end - end - end + for _, mt in ipairs({ Argument, Option, Command }) do + if group_types[mt] then + table.insert(groups_by_type[mt], group) + break + end + end + end - local default_groups = { - {name = "Arguments", type = Argument, elements = self._arguments}, - {name = "Options", type = Option, elements = self._options}, - {name = "Commands", type = Command, elements = self._commands} - } + local default_groups = { + { name = "Arguments", type = Argument, elements = self._arguments }, + { name = "Options", type = Option, elements = self._options }, + { name = "Commands", type = Command, elements = self._commands }, + } - local added_elements = {} + local added_elements = {} - for _, default_group in ipairs(default_groups) do - local type_groups = groups_by_type[default_group.type] + for _, default_group in ipairs(default_groups) do + local type_groups = groups_by_type[default_group.type] - for _, group in ipairs(type_groups) do - self:_add_group_help(blocks, added_elements, group.name .. ":", group) - end + for _, group in ipairs(type_groups) do + self:_add_group_help(blocks, added_elements, group.name .. ":", group) + end - local default_label = default_group.name .. ":" + local default_label = default_group.name .. ":" - if #type_groups > 0 then - default_label = "Other " .. default_label:gsub("^.", string.lower) - end + if #type_groups > 0 then + default_label = "Other " .. default_label:gsub("^.", string.lower) + end - self:_add_group_help(blocks, added_elements, default_label, default_group.elements) - end + self:_add_group_help(blocks, added_elements, default_label, default_group.elements) + end - if self._epilog then - local epilog = self._epilog + if self._epilog then + local epilog = self._epilog - if help_max_width then - epilog = table.concat(autowrap(split_lines(epilog), help_max_width), "\n") - end + if help_max_width then + epilog = table.concat(autowrap(split_lines(epilog), help_max_width), "\n") + end - table.insert(blocks, epilog) - end + table.insert(blocks, epilog) + end - return table.concat(blocks, "\n\n") + return table.concat(blocks, "\n\n") end function Parser:add_help_command(value) - if value then - assert(type(value) == "string" or type(value) == "table", - ("bad argument #1 to 'add_help_command' (string or table expected, got %s)"):format(type(value))) - end - - local help = self:command() - :description "Show help for commands." - help:argument "command" - :description "The command to show help for." - :args "?" - :action(function(_, _, cmd) - if not cmd then + if value then + assert( + type(value) == "string" or type(value) == "table", + ("bad argument #1 to 'add_help_command' (string or table expected, got %s)"):format(type(value)) + ) + end + + local help = self:command():description("Show help for commands.") + help:argument("command"):description("The command to show help for."):args("?"):action(function(_, _, cmd) + if not cmd then print(self:get_help()) os.exit(0) - else + else for _, command in ipairs(self._commands) do - for _, alias in ipairs(command._aliases) do - if alias == cmd then - print(command:get_help()) - os.exit(0) - end - end + for _, alias in ipairs(command._aliases) do + if alias == cmd then + print(command:get_help()) + os.exit(0) + end + end end - end - help:error(("unknown command '%s'"):format(cmd)) - end) + end + help:error(("unknown command '%s'"):format(cmd)) + end) - if value then - help = help(value) - end + if value then + help = help(value) + end - if not help._name then - help "help" - end + if not help._name then + help("help") + end - help._is_help_command = true - return self + help._is_help_command = true + return self end function Parser:_is_shell_safe() - if self._basename then - if self._basename:find("[^%w_%-%+%.]") then - return false - end - else - for _, alias in ipairs(self._aliases) do - if alias:find("[^%w_%-%+%.]") then + if self._basename then + if self._basename:find("[^%w_%-%+%.]") then return false - end - end - end - for _, option in ipairs(self._options) do - for _, alias in ipairs(option._aliases) do - if alias:find("[^%w_%-%+%.]") then - return false - end - end - if option._choices then - for _, choice in ipairs(option._choices) do - if choice:find("[%s'\"]") then - return false + end + else + for _, alias in ipairs(self._aliases) do + if alias:find("[^%w_%-%+%.]") then + return false + end + end + end + for _, option in ipairs(self._options) do + for _, alias in ipairs(option._aliases) do + if alias:find("[^%w_%-%+%.]") then + return false + end + end + if option._choices then + for _, choice in ipairs(option._choices) do + if choice:find("[%s'\"]") then + return false + end end - end - end - end - for _, argument in ipairs(self._arguments) do - if argument._choices then - for _, choice in ipairs(argument._choices) do - if choice:find("[%s'\"]") then - return false + end + end + for _, argument in ipairs(self._arguments) do + if argument._choices then + for _, choice in ipairs(argument._choices) do + if choice:find("[%s'\"]") then + return false + end end - end - end - end - for _, command in ipairs(self._commands) do - if not command:_is_shell_safe() then - return false - end - end - return true + end + end + for _, command in ipairs(self._commands) do + if not command:_is_shell_safe() then + return false + end + end + return true end function Parser:add_complete(value) - if value then - assert(type(value) == "string" or type(value) == "table", - ("bad argument #1 to 'add_complete' (string or table expected, got %s)"):format(type(value))) - end - - local complete = self:option() - :description "Output a shell completion script for the specified shell." - :args(1) - :choices {"bash", "zsh", "fish"} - :action(function(_, _, shell) - io.write(self["get_" .. shell .. "_complete"](self)) - os.exit(0) - end) + if value then + assert( + type(value) == "string" or type(value) == "table", + ("bad argument #1 to 'add_complete' (string or table expected, got %s)"):format(type(value)) + ) + end + + local complete = self:option() + :description("Output a shell completion script for the specified shell.") + :args(1) + :choices({ "bash", "zsh", "fish" }) + :action(function(_, _, shell) + io.write(self["get_" .. shell .. "_complete"](self)) + os.exit(0) + end) - if value then - complete = complete(value) - end + if value then + complete = complete(value) + end - if not complete._name then - complete "--completion" - end + if not complete._name then + complete("--completion") + end - return self + return self end function Parser:add_complete_command(value) - if value then - assert(type(value) == "string" or type(value) == "table", - ("bad argument #1 to 'add_complete_command' (string or table expected, got %s)"):format(type(value))) - end - - local complete = self:command() - :description "Output a shell completion script." - complete:argument "shell" - :description "The shell to output a completion script for." - :choices {"bash", "zsh", "fish"} - :action(function(_, _, shell) - io.write(self["get_" .. shell .. "_complete"](self)) - os.exit(0) - end) + if value then + assert( + type(value) == "string" or type(value) == "table", + ("bad argument #1 to 'add_complete_command' (string or table expected, got %s)"):format(type(value)) + ) + end + + local complete = self:command():description("Output a shell completion script.") + complete + :argument("shell") + :description("The shell to output a completion script for.") + :choices({ "bash", "zsh", "fish" }) + :action(function(_, _, shell) + io.write(self["get_" .. shell .. "_complete"](self)) + os.exit(0) + end) - if value then - complete = complete(value) - end + if value then + complete = complete(value) + end - if not complete._name then - complete "completion" - end + if not complete._name then + complete("completion") + end - return self + return self end local function base_name(pathname) - return pathname:gsub("[/\\]*$", ""):match(".*[/\\]([^/\\]*)") or pathname + return pathname:gsub("[/\\]*$", ""):match(".*[/\\]([^/\\]*)") or pathname end local function get_short_description(element) - local short = element:_get_description():match("^(.-)%.%s") - return short or element:_get_description():match("^(.-)%.?$") + local short = element:_get_description():match("^(.-)%.%s") + return short or element:_get_description():match("^(.-)%.?$") end function Parser:_get_options() - local options = {} - for _, option in ipairs(self._options) do - for _, alias in ipairs(option._aliases) do - table.insert(options, alias) - end - end - return table.concat(options, " ") + local options = {} + for _, option in ipairs(self._options) do + for _, alias in ipairs(option._aliases) do + table.insert(options, alias) + end + end + return table.concat(options, " ") end function Parser:_get_commands() - local commands = {} - for _, command in ipairs(self._commands) do - for _, alias in ipairs(command._aliases) do - table.insert(commands, alias) - end - end - return table.concat(commands, " ") + local commands = {} + for _, command in ipairs(self._commands) do + for _, alias in ipairs(command._aliases) do + table.insert(commands, alias) + end + end + return table.concat(commands, " ") end function Parser:_bash_option_args(buf, indent) - local opts = {} - for _, option in ipairs(self._options) do - if option._choices or option._minargs > 0 then - local compreply - if option._choices then - compreply = 'COMPREPLY=($(compgen -W "' .. table.concat(option._choices, " ") .. '" -- "$cur"))' - else - compreply = 'COMPREPLY=($(compgen -f -- "$cur"))' - end - table.insert(opts, (" "):rep(indent + 4) .. table.concat(option._aliases, "|") .. ")") - table.insert(opts, (" "):rep(indent + 8) .. compreply) - table.insert(opts, (" "):rep(indent + 8) .. "return 0") - table.insert(opts, (" "):rep(indent + 8) .. ";;") - end - end - - if #opts > 0 then - table.insert(buf, (" "):rep(indent) .. 'case "$prev" in') - table.insert(buf, table.concat(opts, "\n")) - table.insert(buf, (" "):rep(indent) .. "esac\n") - end + local opts = {} + for _, option in ipairs(self._options) do + if option._choices or option._minargs > 0 then + local compreply + if option._choices then + compreply = 'COMPREPLY=($(compgen -W "' .. table.concat(option._choices, " ") .. '" -- "$cur"))' + else + compreply = 'COMPREPLY=($(compgen -f -- "$cur"))' + end + table.insert(opts, (" "):rep(indent + 4) .. table.concat(option._aliases, "|") .. ")") + table.insert(opts, (" "):rep(indent + 8) .. compreply) + table.insert(opts, (" "):rep(indent + 8) .. "return 0") + table.insert(opts, (" "):rep(indent + 8) .. ";;") + end + end + + if #opts > 0 then + table.insert(buf, (" "):rep(indent) .. 'case "$prev" in') + table.insert(buf, table.concat(opts, "\n")) + table.insert(buf, (" "):rep(indent) .. "esac\n") + end end function Parser:_bash_get_cmd(buf, indent) - if #self._commands == 0 then - return - end - - table.insert(buf, (" "):rep(indent) .. 'args=("${args[@]:1}")') - table.insert(buf, (" "):rep(indent) .. 'for arg in "${args[@]}"; do') - table.insert(buf, (" "):rep(indent + 4) .. 'case "$arg" in') - - for _, command in ipairs(self._commands) do - table.insert(buf, (" "):rep(indent + 8) .. table.concat(command._aliases, "|") .. ")") - if self._parent then - table.insert(buf, (" "):rep(indent + 12) .. 'cmd="$cmd ' .. command._name .. '"') - else - table.insert(buf, (" "):rep(indent + 12) .. 'cmd="' .. command._name .. '"') - end - table.insert(buf, (" "):rep(indent + 12) .. 'opts="$opts ' .. command:_get_options() .. '"') - command:_bash_get_cmd(buf, indent + 12) - table.insert(buf, (" "):rep(indent + 12) .. "break") - table.insert(buf, (" "):rep(indent + 12) .. ";;") - end - - table.insert(buf, (" "):rep(indent + 4) .. "esac") - table.insert(buf, (" "):rep(indent) .. "done") + if #self._commands == 0 then + return + end + + table.insert(buf, (" "):rep(indent) .. 'args=("${args[@]:1}")') + table.insert(buf, (" "):rep(indent) .. 'for arg in "${args[@]}"; do') + table.insert(buf, (" "):rep(indent + 4) .. 'case "$arg" in') + + for _, command in ipairs(self._commands) do + table.insert(buf, (" "):rep(indent + 8) .. table.concat(command._aliases, "|") .. ")") + if self._parent then + table.insert(buf, (" "):rep(indent + 12) .. 'cmd="$cmd ' .. command._name .. '"') + else + table.insert(buf, (" "):rep(indent + 12) .. 'cmd="' .. command._name .. '"') + end + table.insert(buf, (" "):rep(indent + 12) .. 'opts="$opts ' .. command:_get_options() .. '"') + command:_bash_get_cmd(buf, indent + 12) + table.insert(buf, (" "):rep(indent + 12) .. "break") + table.insert(buf, (" "):rep(indent + 12) .. ";;") + end + + table.insert(buf, (" "):rep(indent + 4) .. "esac") + table.insert(buf, (" "):rep(indent) .. "done") end function Parser:_bash_cmd_completions(buf) - local cmd_buf = {} - if self._parent then - self:_bash_option_args(cmd_buf, 12) - end - if #self._commands > 0 then - table.insert(cmd_buf, (" "):rep(12) .. 'COMPREPLY=($(compgen -W "' .. self:_get_commands() .. '" -- "$cur"))') - elseif self._is_help_command then - table.insert(cmd_buf, (" "):rep(12) - .. 'COMPREPLY=($(compgen -W "' - .. self._parent:_get_commands() - .. '" -- "$cur"))') - end - if #cmd_buf > 0 then - table.insert(buf, (" "):rep(8) .. "'" .. self:_get_fullname(true) .. "')") - table.insert(buf, table.concat(cmd_buf, "\n")) - table.insert(buf, (" "):rep(12) .. ";;") - end - - for _, command in ipairs(self._commands) do - command:_bash_cmd_completions(buf) - end + local cmd_buf = {} + if self._parent then + self:_bash_option_args(cmd_buf, 12) + end + if #self._commands > 0 then + table.insert(cmd_buf, (" "):rep(12) .. 'COMPREPLY=($(compgen -W "' .. self:_get_commands() .. '" -- "$cur"))') + elseif self._is_help_command then + table.insert( + cmd_buf, + (" "):rep(12) .. 'COMPREPLY=($(compgen -W "' .. self._parent:_get_commands() .. '" -- "$cur"))' + ) + end + if #cmd_buf > 0 then + table.insert(buf, (" "):rep(8) .. "'" .. self:_get_fullname(true) .. "')") + table.insert(buf, table.concat(cmd_buf, "\n")) + table.insert(buf, (" "):rep(12) .. ";;") + end + + for _, command in ipairs(self._commands) do + command:_bash_cmd_completions(buf) + end end function Parser:get_bash_complete() - self._basename = base_name(self._name) - assert(self:_is_shell_safe()) - local buf = {([[ + self._basename = base_name(self._name) + assert(self:_is_shell_safe()) + local buf = { + ([[ _%s() { local IFS=$' \t\n' local args cur prev cmd opts arg @@ -1322,254 +1351,271 @@ _%s() { cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}" opts="%s" -]]):format(self._basename, self:_get_options())} - - self:_bash_option_args(buf, 4) - self:_bash_get_cmd(buf, 4) - if #self._commands > 0 then - table.insert(buf, "") - table.insert(buf, (" "):rep(4) .. 'case "$cmd" in') - self:_bash_cmd_completions(buf) - table.insert(buf, (" "):rep(4) .. "esac\n") - end - - table.insert(buf, ([=[ +]]):format(self._basename, self:_get_options()), + } + + self:_bash_option_args(buf, 4) + self:_bash_get_cmd(buf, 4) + if #self._commands > 0 then + table.insert(buf, "") + table.insert(buf, (" "):rep(4) .. 'case "$cmd" in') + self:_bash_cmd_completions(buf) + table.insert(buf, (" "):rep(4) .. "esac\n") + end + + table.insert( + buf, + ([=[ if [[ "$cur" = -* ]]; then COMPREPLY=($(compgen -W "$opts" -- "$cur")) fi } complete -F _%s -o bashdefault -o default %s -]=]):format(self._basename, self._basename)) +]=]):format(self._basename, self._basename) + ) - return table.concat(buf, "\n") + return table.concat(buf, "\n") end function Parser:_zsh_arguments(buf, cmd_name, indent) - if self._parent then - table.insert(buf, (" "):rep(indent) .. "options=(") - table.insert(buf, (" "):rep(indent + 2) .. "$options") - else - table.insert(buf, (" "):rep(indent) .. "local -a options=(") - end - - for _, option in ipairs(self._options) do - local line = {} - if #option._aliases > 1 then - if option._maxcount > 1 then - table.insert(line, '"*"') - end - table.insert(line, "{" .. table.concat(option._aliases, ",") .. '}"') - else - table.insert(line, '"') - if option._maxcount > 1 then - table.insert(line, "*") - end - table.insert(line, option._name) - end - if option._description then - local description = get_short_description(option):gsub('["%]:`$]', "\\%0") - table.insert(line, "[" .. description .. "]") - end - if option._maxargs == math.huge then - table.insert(line, ":*") - end - if option._choices then - table.insert(line, ": :(" .. table.concat(option._choices, " ") .. ")") - elseif option._maxargs > 0 then - table.insert(line, ": :_files") - end - table.insert(line, '"') - table.insert(buf, (" "):rep(indent + 2) .. table.concat(line)) - end - - table.insert(buf, (" "):rep(indent) .. ")") - table.insert(buf, (" "):rep(indent) .. "_arguments -s -S \\") - table.insert(buf, (" "):rep(indent + 2) .. "$options \\") - - if self._is_help_command then - table.insert(buf, (" "):rep(indent + 2) .. '": :(' .. self._parent:_get_commands() .. ')" \\') - else - for _, argument in ipairs(self._arguments) do - local spec - if argument._choices then - spec = ": :(" .. table.concat(argument._choices, " ") .. ")" - else - spec = ": :_files" - end - if argument._maxargs == math.huge then - table.insert(buf, (" "):rep(indent + 2) .. '"*' .. spec .. '" \\') - break - end - for _ = 1, argument._maxargs do - table.insert(buf, (" "):rep(indent + 2) .. '"' .. spec .. '" \\') - end - end + if self._parent then + table.insert(buf, (" "):rep(indent) .. "options=(") + table.insert(buf, (" "):rep(indent + 2) .. "$options") + else + table.insert(buf, (" "):rep(indent) .. "local -a options=(") + end + + for _, option in ipairs(self._options) do + local line = {} + if #option._aliases > 1 then + if option._maxcount > 1 then + table.insert(line, '"*"') + end + table.insert(line, "{" .. table.concat(option._aliases, ",") .. '}"') + else + table.insert(line, '"') + if option._maxcount > 1 then + table.insert(line, "*") + end + table.insert(line, option._name) + end + if option._description then + local description = get_short_description(option):gsub('["%]:`$]', "\\%0") + table.insert(line, "[" .. description .. "]") + end + if option._maxargs == math.huge then + table.insert(line, ":*") + end + if option._choices then + table.insert(line, ": :(" .. table.concat(option._choices, " ") .. ")") + elseif option._maxargs > 0 then + table.insert(line, ": :_files") + end + table.insert(line, '"') + table.insert(buf, (" "):rep(indent + 2) .. table.concat(line)) + end + + table.insert(buf, (" "):rep(indent) .. ")") + table.insert(buf, (" "):rep(indent) .. "_arguments -s -S \\") + table.insert(buf, (" "):rep(indent + 2) .. "$options \\") + + if self._is_help_command then + table.insert(buf, (" "):rep(indent + 2) .. '": :(' .. self._parent:_get_commands() .. ')" \\') + else + for _, argument in ipairs(self._arguments) do + local spec + if argument._choices then + spec = ": :(" .. table.concat(argument._choices, " ") .. ")" + else + spec = ": :_files" + end + if argument._maxargs == math.huge then + table.insert(buf, (" "):rep(indent + 2) .. '"*' .. spec .. '" \\') + break + end + for _ = 1, argument._maxargs do + table.insert(buf, (" "):rep(indent + 2) .. '"' .. spec .. '" \\') + end + end - if #self._commands > 0 then - table.insert(buf, (" "):rep(indent + 2) .. '": :_' .. cmd_name .. '_cmds" \\') - table.insert(buf, (" "):rep(indent + 2) .. '"*:: :->args" \\') - end - end + if #self._commands > 0 then + table.insert(buf, (" "):rep(indent + 2) .. '": :_' .. cmd_name .. '_cmds" \\') + table.insert(buf, (" "):rep(indent + 2) .. '"*:: :->args" \\') + end + end - table.insert(buf, (" "):rep(indent + 2) .. "&& return 0") + table.insert(buf, (" "):rep(indent + 2) .. "&& return 0") end function Parser:_zsh_cmds(buf, cmd_name) - table.insert(buf, "\n_" .. cmd_name .. "_cmds() {") - table.insert(buf, " local -a commands=(") + table.insert(buf, "\n_" .. cmd_name .. "_cmds() {") + table.insert(buf, " local -a commands=(") - for _, command in ipairs(self._commands) do - local line = {} - if #command._aliases > 1 then - table.insert(line, "{" .. table.concat(command._aliases, ",") .. '}"') - else - table.insert(line, '"' .. command._name) - end - if command._description then - table.insert(line, ":" .. get_short_description(command):gsub('["`$]', "\\%0")) - end - table.insert(buf, " " .. table.concat(line) .. '"') - end + for _, command in ipairs(self._commands) do + local line = {} + if #command._aliases > 1 then + table.insert(line, "{" .. table.concat(command._aliases, ",") .. '}"') + else + table.insert(line, '"' .. command._name) + end + if command._description then + table.insert(line, ":" .. get_short_description(command):gsub('["`$]', "\\%0")) + end + table.insert(buf, " " .. table.concat(line) .. '"') + end - table.insert(buf, ' )\n _describe "command" commands\n}') + table.insert(buf, ' )\n _describe "command" commands\n}') end function Parser:_zsh_complete_help(buf, cmds_buf, cmd_name, indent) - if #self._commands == 0 then - return - end + if #self._commands == 0 then + return + end - self:_zsh_cmds(cmds_buf, cmd_name) - table.insert(buf, "\n" .. (" "):rep(indent) .. "case $words[1] in") + self:_zsh_cmds(cmds_buf, cmd_name) + table.insert(buf, "\n" .. (" "):rep(indent) .. "case $words[1] in") - for _, command in ipairs(self._commands) do - local name = cmd_name .. "_" .. command._name - table.insert(buf, (" "):rep(indent + 2) .. table.concat(command._aliases, "|") .. ")") - command:_zsh_arguments(buf, name, indent + 4) - command:_zsh_complete_help(buf, cmds_buf, name, indent + 4) - table.insert(buf, (" "):rep(indent + 4) .. ";;\n") - end + for _, command in ipairs(self._commands) do + local name = cmd_name .. "_" .. command._name + table.insert(buf, (" "):rep(indent + 2) .. table.concat(command._aliases, "|") .. ")") + command:_zsh_arguments(buf, name, indent + 4) + command:_zsh_complete_help(buf, cmds_buf, name, indent + 4) + table.insert(buf, (" "):rep(indent + 4) .. ";;\n") + end - table.insert(buf, (" "):rep(indent) .. "esac") + table.insert(buf, (" "):rep(indent) .. "esac") end function Parser:get_zsh_complete() - self._basename = base_name(self._name) - assert(self:_is_shell_safe()) - local buf = {("#compdef %s\n"):format(self._basename)} - local cmds_buf = {} - table.insert(buf, "_" .. self._basename .. "() {") - if #self._commands > 0 then - table.insert(buf, " local context state state_descr line") - table.insert(buf, " typeset -A opt_args\n") - end - self:_zsh_arguments(buf, self._basename, 2) - self:_zsh_complete_help(buf, cmds_buf, self._basename, 2) - table.insert(buf, "\n return 1") - table.insert(buf, "}") - - local result = table.concat(buf, "\n") - if #cmds_buf > 0 then - result = result .. "\n" .. table.concat(cmds_buf, "\n") - end - return result .. "\n\n_" .. self._basename .. "\n" + self._basename = base_name(self._name) + assert(self:_is_shell_safe()) + local buf = { ("#compdef %s\n"):format(self._basename) } + local cmds_buf = {} + table.insert(buf, "_" .. self._basename .. "() {") + if #self._commands > 0 then + table.insert(buf, " local context state state_descr line") + table.insert(buf, " typeset -A opt_args\n") + end + self:_zsh_arguments(buf, self._basename, 2) + self:_zsh_complete_help(buf, cmds_buf, self._basename, 2) + table.insert(buf, "\n return 1") + table.insert(buf, "}") + + local result = table.concat(buf, "\n") + if #cmds_buf > 0 then + result = result .. "\n" .. table.concat(cmds_buf, "\n") + end + return result .. "\n\n_" .. self._basename .. "\n" end local function fish_escape(string) - return string:gsub("[\\']", "\\%0") + return string:gsub("[\\']", "\\%0") end function Parser:_fish_get_cmd(buf, indent) - if #self._commands == 0 then - return - end + if #self._commands == 0 then + return + end - table.insert(buf, (" "):rep(indent) .. "set -e cmdline[1]") - table.insert(buf, (" "):rep(indent) .. "for arg in $cmdline") - table.insert(buf, (" "):rep(indent + 4) .. "switch $arg") + table.insert(buf, (" "):rep(indent) .. "set -e cmdline[1]") + table.insert(buf, (" "):rep(indent) .. "for arg in $cmdline") + table.insert(buf, (" "):rep(indent + 4) .. "switch $arg") - for _, command in ipairs(self._commands) do - table.insert(buf, (" "):rep(indent + 8) .. "case " .. table.concat(command._aliases, " ")) - table.insert(buf, (" "):rep(indent + 12) .. "set cmd $cmd " .. command._name) - command:_fish_get_cmd(buf, indent + 12) - table.insert(buf, (" "):rep(indent + 12) .. "break") - end + for _, command in ipairs(self._commands) do + table.insert(buf, (" "):rep(indent + 8) .. "case " .. table.concat(command._aliases, " ")) + table.insert(buf, (" "):rep(indent + 12) .. "set cmd $cmd " .. command._name) + command:_fish_get_cmd(buf, indent + 12) + table.insert(buf, (" "):rep(indent + 12) .. "break") + end - table.insert(buf, (" "):rep(indent + 4) .. "end") - table.insert(buf, (" "):rep(indent) .. "end") + table.insert(buf, (" "):rep(indent + 4) .. "end") + table.insert(buf, (" "):rep(indent) .. "end") end function Parser:_fish_complete_help(buf, basename) - local prefix = "complete -c " .. basename - table.insert(buf, "") - - for _, command in ipairs(self._commands) do - local aliases = table.concat(command._aliases, " ") - local line - if self._parent then - line = ("%s -n '__fish_%s_using_command %s' -xa '%s'") - :format(prefix, basename, self:_get_fullname(true), aliases) - else - line = ("%s -n '__fish_%s_using_command' -xa '%s'"):format(prefix, basename, aliases) - end - if command._description then - line = ("%s -d '%s'"):format(line, fish_escape(get_short_description(command))) - end - table.insert(buf, line) - end - - if self._is_help_command then - local line = ("%s -n '__fish_%s_using_command %s' -xa '%s'") - :format(prefix, basename, self:_get_fullname(true), self._parent:_get_commands()) - table.insert(buf, line) - end - - for _, option in ipairs(self._options) do - local parts = {prefix} - - if self._parent then - table.insert(parts, "-n '__fish_" .. basename .. "_seen_command " .. self:_get_fullname(true) .. "'") - end - - for _, alias in ipairs(option._aliases) do - if alias:match("^%-.$") then - table.insert(parts, "-s " .. alias:sub(2)) - elseif alias:match("^%-%-.+") then - table.insert(parts, "-l " .. alias:sub(3)) - end - end - - if option._choices then - table.insert(parts, "-xa '" .. table.concat(option._choices, " ") .. "'") - elseif option._minargs > 0 then - table.insert(parts, "-r") - end - - if option._description then - table.insert(parts, "-d '" .. fish_escape(get_short_description(option)) .. "'") - end - - table.insert(buf, table.concat(parts, " ")) - end - - for _, command in ipairs(self._commands) do - command:_fish_complete_help(buf, basename) - end + local prefix = "complete -c " .. basename + table.insert(buf, "") + + for _, command in ipairs(self._commands) do + local aliases = table.concat(command._aliases, " ") + local line + if self._parent then + line = ("%s -n '__fish_%s_using_command %s' -xa '%s'"):format( + prefix, + basename, + self:_get_fullname(true), + aliases + ) + else + line = ("%s -n '__fish_%s_using_command' -xa '%s'"):format(prefix, basename, aliases) + end + if command._description then + line = ("%s -d '%s'"):format(line, fish_escape(get_short_description(command))) + end + table.insert(buf, line) + end + + if self._is_help_command then + local line = ("%s -n '__fish_%s_using_command %s' -xa '%s'"):format( + prefix, + basename, + self:_get_fullname(true), + self._parent:_get_commands() + ) + table.insert(buf, line) + end + + for _, option in ipairs(self._options) do + local parts = { prefix } + + if self._parent then + table.insert(parts, "-n '__fish_" .. basename .. "_seen_command " .. self:_get_fullname(true) .. "'") + end + + for _, alias in ipairs(option._aliases) do + if alias:match("^%-.$") then + table.insert(parts, "-s " .. alias:sub(2)) + elseif alias:match("^%-%-.+") then + table.insert(parts, "-l " .. alias:sub(3)) + end + end + + if option._choices then + table.insert(parts, "-xa '" .. table.concat(option._choices, " ") .. "'") + elseif option._minargs > 0 then + table.insert(parts, "-r") + end + + if option._description then + table.insert(parts, "-d '" .. fish_escape(get_short_description(option)) .. "'") + end + + table.insert(buf, table.concat(parts, " ")) + end + + for _, command in ipairs(self._commands) do + command:_fish_complete_help(buf, basename) + end end function Parser:get_fish_complete() - self._basename = base_name(self._name) - assert(self:_is_shell_safe()) - local buf = {} - - if #self._commands > 0 then - table.insert(buf, ([[ + self._basename = base_name(self._name) + assert(self:_is_shell_safe()) + local buf = {} + + if #self._commands > 0 then + table.insert( + buf, + ([[ function __fish_%s_print_command set -l cmdline (commandline -poc) - set -l cmd]]):format(self._basename)) - self:_fish_get_cmd(buf, 4) - table.insert(buf, ([[ + set -l cmd]]):format(self._basename) + ) + self:_fish_get_cmd(buf, 4) + table.insert( + buf, + ([[ echo "$cmd" end @@ -1583,518 +1629,521 @@ function __fish_%s_seen_command string match -q "$argv*" (__fish_%s_print_command) and return 0 or return 1 -end]]):format(self._basename, self._basename, self._basename, self._basename)) - end +end]]):format(self._basename, self._basename, self._basename, self._basename) + ) + end - self:_fish_complete_help(buf, self._basename) - return table.concat(buf, "\n") .. "\n" + self:_fish_complete_help(buf, self._basename) + return table.concat(buf, "\n") .. "\n" end local function get_tip(context, wrong_name) - local context_pool = {} - local possible_name - local possible_names = {} + local context_pool = {} + local possible_name + local possible_names = {} - for name in pairs(context) do - if type(name) == "string" then - for i = 1, #name do - possible_name = name:sub(1, i - 1) .. name:sub(i + 1) + for name in pairs(context) do + if type(name) == "string" then + for i = 1, #name do + possible_name = name:sub(1, i - 1) .. name:sub(i + 1) - if not context_pool[possible_name] then - context_pool[possible_name] = {} - end + if not context_pool[possible_name] then + context_pool[possible_name] = {} + end - table.insert(context_pool[possible_name], name) - end - end - end + table.insert(context_pool[possible_name], name) + end + end + end - for i = 1, #wrong_name + 1 do - possible_name = wrong_name:sub(1, i - 1) .. wrong_name:sub(i + 1) + for i = 1, #wrong_name + 1 do + possible_name = wrong_name:sub(1, i - 1) .. wrong_name:sub(i + 1) - if context[possible_name] then - possible_names[possible_name] = true - elseif context_pool[possible_name] then - for _, name in ipairs(context_pool[possible_name]) do - possible_names[name] = true - end - end - end + if context[possible_name] then + possible_names[possible_name] = true + elseif context_pool[possible_name] then + for _, name in ipairs(context_pool[possible_name]) do + possible_names[name] = true + end + end + end - local first = next(possible_names) + local first = next(possible_names) - if first then - if next(possible_names, first) then - local possible_names_arr = {} + if first then + if next(possible_names, first) then + local possible_names_arr = {} - for name in pairs(possible_names) do - table.insert(possible_names_arr, "'" .. name .. "'") - end + for name in pairs(possible_names) do + table.insert(possible_names_arr, "'" .. name .. "'") + end - table.sort(possible_names_arr) - return "\nDid you mean one of these: " .. table.concat(possible_names_arr, " ") .. "?" - else - return "\nDid you mean '" .. first .. "'?" - end - else - return "" - end + table.sort(possible_names_arr) + return "\nDid you mean one of these: " .. table.concat(possible_names_arr, " ") .. "?" + else + return "\nDid you mean '" .. first .. "'?" + end + else + return "" + end end local ElementState = class({ - invocations = 0 + invocations = 0, }) function ElementState:__call(state, element) - self.state = state - self.result = state.result - self.element = element - self.target = element._target or element:_get_default_target() - self.action, self.result[self.target] = element:_get_action() - return self + self.state = state + self.result = state.result + self.element = element + self.target = element._target or element:_get_default_target() + self.action, self.result[self.target] = element:_get_action() + return self end function ElementState:error(fmt, ...) - self.state:error(fmt, ...) + self.state:error(fmt, ...) end function ElementState:convert(argument, index) - local converter = self.element._convert + local converter = self.element._convert - if converter then - local ok, err + if converter then + local ok, err - if type(converter) == "function" then - ok, err = converter(argument) - elseif type(converter[index]) == "function" then - ok, err = converter[index](argument) - else - ok = converter[argument] - end + if type(converter) == "function" then + ok, err = converter(argument) + elseif type(converter[index]) == "function" then + ok, err = converter[index](argument) + else + ok = converter[argument] + end - if ok == nil then - self:error(err and "%s" or "malformed argument '%s'", err or argument) - end + if ok == nil then + self:error(err and "%s" or "malformed argument '%s'", err or argument) + end - argument = ok - end + argument = ok + end - return argument + return argument end function ElementState:default(mode) - return self.element._defmode:find(mode) and self.element._default + return self.element._defmode:find(mode) and self.element._default end local function bound(noun, min, max, is_max) - local res = "" + local res = "" - if min ~= max then - res = "at " .. (is_max and "most" or "least") .. " " - end + if min ~= max then + res = "at " .. (is_max and "most" or "least") .. " " + end - local number = is_max and max or min - return res .. tostring(number) .. " " .. noun .. (number == 1 and "" or "s") + local number = is_max and max or min + return res .. tostring(number) .. " " .. noun .. (number == 1 and "" or "s") end function ElementState:set_name(alias) - self.name = ("%s '%s'"):format(alias and "option" or "argument", alias or self.element._name) + self.name = ("%s '%s'"):format(alias and "option" or "argument", alias or self.element._name) end function ElementState:invoke() - self.open = true - self.overwrite = false + self.open = true + self.overwrite = false - if self.invocations >= self.element._maxcount then - if self.element._overwrite then - self.overwrite = true - else - local num_times_repr = bound("time", self.element._mincount, self.element._maxcount, true) - self:error("%s must be used %s", self.name, num_times_repr) - end - else - self.invocations = self.invocations + 1 - end + if self.invocations >= self.element._maxcount then + if self.element._overwrite then + self.overwrite = true + else + local num_times_repr = bound("time", self.element._mincount, self.element._maxcount, true) + self:error("%s must be used %s", self.name, num_times_repr) + end + else + self.invocations = self.invocations + 1 + end - self.args = {} + self.args = {} - if self.element._maxargs <= 0 then - self:close() - end + if self.element._maxargs <= 0 then + self:close() + end - return self.open + return self.open end function ElementState:check_choices(argument) - if self.element._choices then - for _, choice in ipairs(self.element._choices) do - if argument == choice then - return - end - end - local choices_list = "'" .. table.concat(self.element._choices, "', '") .. "'" - local is_option = getmetatable(self.element) == Option - self:error("%s%s must be one of %s", is_option and "argument for " or "", self.name, choices_list) - end + if self.element._choices then + for _, choice in ipairs(self.element._choices) do + if argument == choice then + return + end + end + local choices_list = "'" .. table.concat(self.element._choices, "', '") .. "'" + local is_option = getmetatable(self.element) == Option + self:error("%s%s must be one of %s", is_option and "argument for " or "", self.name, choices_list) + end end function ElementState:pass(argument) - self:check_choices(argument) - argument = self:convert(argument, #self.args + 1) - table.insert(self.args, argument) + self:check_choices(argument) + argument = self:convert(argument, #self.args + 1) + table.insert(self.args, argument) - if #self.args >= self.element._maxargs then - self:close() - end + if #self.args >= self.element._maxargs then + self:close() + end - return self.open + return self.open end function ElementState:complete_invocation() - while #self.args < self.element._minargs do - self:pass(self.element._default) - end + while #self.args < self.element._minargs do + self:pass(self.element._default) + end end function ElementState:close() - if self.open then - self.open = false - - if #self.args < self.element._minargs then - if self:default("a") then - self:complete_invocation() - else - if #self.args == 0 then - if getmetatable(self.element) == Argument then - self:error("missing %s", self.name) - elseif self.element._maxargs == 1 then - self:error("%s requires an argument", self.name) - end + if self.open then + self.open = false + + if #self.args < self.element._minargs then + if self:default("a") then + self:complete_invocation() + else + if #self.args == 0 then + if getmetatable(self.element) == Argument then + self:error("missing %s", self.name) + elseif self.element._maxargs == 1 then + self:error("%s requires an argument", self.name) + end + end + + self:error("%s requires %s", self.name, bound("argument", self.element._minargs, self.element._maxargs)) end + end - self:error("%s requires %s", self.name, bound("argument", self.element._minargs, self.element._maxargs)) - end - end + local args - local args - - if self.element._maxargs == 0 then - args = self.args[1] - elseif self.element._maxargs == 1 then - if self.element._minargs == 0 and self.element._mincount ~= self.element._maxcount then - args = self.args - else + if self.element._maxargs == 0 then args = self.args[1] - end - else - args = self.args - end + elseif self.element._maxargs == 1 then + if self.element._minargs == 0 and self.element._mincount ~= self.element._maxcount then + args = self.args + else + args = self.args[1] + end + else + args = self.args + end - self.action(self.result, self.target, args, self.overwrite) - end + self.action(self.result, self.target, args, self.overwrite) + end end local ParseState = class({ - result = {}, - options = {}, - arguments = {}, - argument_i = 1, - element_to_mutexes = {}, - mutex_to_element_state = {}, - command_actions = {} + result = {}, + options = {}, + arguments = {}, + argument_i = 1, + element_to_mutexes = {}, + mutex_to_element_state = {}, + command_actions = {}, }) function ParseState:__call(parser, error_handler) - self.parser = parser - self.error_handler = error_handler - self.charset = parser:_update_charset() - self:switch(parser) - return self + self.parser = parser + self.error_handler = error_handler + self.charset = parser:_update_charset() + self:switch(parser) + return self end function ParseState:error(fmt, ...) - self.error_handler(self.parser, fmt:format(...)) + self.error_handler(self.parser, fmt:format(...)) end function ParseState:switch(parser) - self.parser = parser + self.parser = parser - if parser._action then - table.insert(self.command_actions, {action = parser._action, name = parser._name}) - end + if parser._action then + table.insert(self.command_actions, { action = parser._action, name = parser._name }) + end - for _, option in ipairs(parser._options) do - option = ElementState(self, option) - table.insert(self.options, option) + for _, option in ipairs(parser._options) do + option = ElementState(self, option) + table.insert(self.options, option) - for _, alias in ipairs(option.element._aliases) do - self.options[alias] = option - end - end + for _, alias in ipairs(option.element._aliases) do + self.options[alias] = option + end + end - for _, mutex in ipairs(parser._mutexes) do - for _, element in ipairs(mutex) do - if not self.element_to_mutexes[element] then - self.element_to_mutexes[element] = {} - end + for _, mutex in ipairs(parser._mutexes) do + for _, element in ipairs(mutex) do + if not self.element_to_mutexes[element] then + self.element_to_mutexes[element] = {} + end - table.insert(self.element_to_mutexes[element], mutex) - end - end + table.insert(self.element_to_mutexes[element], mutex) + end + end - for _, argument in ipairs(parser._arguments) do - argument = ElementState(self, argument) - table.insert(self.arguments, argument) - argument:set_name() - argument:invoke() - end + for _, argument in ipairs(parser._arguments) do + argument = ElementState(self, argument) + table.insert(self.arguments, argument) + argument:set_name() + argument:invoke() + end - self.handle_options = parser._handle_options - self.argument = self.arguments[self.argument_i] - self.commands = parser._commands + self.handle_options = parser._handle_options + self.argument = self.arguments[self.argument_i] + self.commands = parser._commands - for _, command in ipairs(self.commands) do - for _, alias in ipairs(command._aliases) do - self.commands[alias] = command - end - end + for _, command in ipairs(self.commands) do + for _, alias in ipairs(command._aliases) do + self.commands[alias] = command + end + end end function ParseState:get_option(name) - local option = self.options[name] + local option = self.options[name] - if not option then - self:error("unknown option '%s'%s", name, get_tip(self.options, name)) - else - return option - end + if not option then + self:error("unknown option '%s'%s", name, get_tip(self.options, name)) + else + return option + end end function ParseState:get_command(name) - local command = self.commands[name] + local command = self.commands[name] - if not command then - if #self.commands > 0 then - self:error("unknown command '%s'%s", name, get_tip(self.commands, name)) - else - self:error("too many arguments") - end - else - return command - end + if not command then + if #self.commands > 0 then + self:error("unknown command '%s'%s", name, get_tip(self.commands, name)) + else + self:error("too many arguments") + end + else + return command + end end function ParseState:check_mutexes(element_state) - if self.element_to_mutexes[element_state.element] then - for _, mutex in ipairs(self.element_to_mutexes[element_state.element]) do - local used_element_state = self.mutex_to_element_state[mutex] - - if used_element_state and used_element_state ~= element_state then - self:error("%s can not be used together with %s", element_state.name, used_element_state.name) - else - self.mutex_to_element_state[mutex] = element_state - end - end - end + if self.element_to_mutexes[element_state.element] then + for _, mutex in ipairs(self.element_to_mutexes[element_state.element]) do + local used_element_state = self.mutex_to_element_state[mutex] + + if used_element_state and used_element_state ~= element_state then + self:error("%s can not be used together with %s", element_state.name, used_element_state.name) + else + self.mutex_to_element_state[mutex] = element_state + end + end + end end function ParseState:invoke(option, name) - self:close() - option:set_name(name) - self:check_mutexes(option, name) + self:close() + option:set_name(name) + self:check_mutexes(option, name) - if option:invoke() then - self.option = option - end + if option:invoke() then + self.option = option + end end function ParseState:pass(arg) - if self.option then - if not self.option:pass(arg) then - self.option = nil - end - elseif self.argument then - self:check_mutexes(self.argument) + if self.option then + if not self.option:pass(arg) then + self.option = nil + end + elseif self.argument then + 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] - end - else - local command = self:get_command(arg) - self.result[command._target or command._name] = true + if not self.argument:pass(arg) then + self.argument_i = self.argument_i + 1 + self.argument = self.arguments[self.argument_i] + end + else + local command = self:get_command(arg) + self.result[command._target or command._name] = true - if self.parser._command_target then - self.result[self.parser._command_target] = command._name - end + if self.parser._command_target then + self.result[self.parser._command_target] = command._name + end - self:switch(command) - end + self:switch(command) + end end function ParseState:close() - if self.option then - self.option:close() - self.option = nil - end + if self.option then + self.option:close() + self.option = nil + end end function ParseState:finalize() - self:close() - - for i = self.argument_i, #self.arguments do - local argument = self.arguments[i] - if #argument.args == 0 and argument:default("u") then - argument:complete_invocation() - else - argument:close() - end - end - - if self.parser._require_command and #self.commands > 0 then - self:error("a command is required") - end - - for _, option in ipairs(self.options) do - option.name = option.name or ("option '%s'"):format(option.element._name) - - if option.invocations == 0 then - if option:default("u") then - option:invoke() - option:complete_invocation() - option:close() - end - end - - local mincount = option.element._mincount - - if option.invocations < mincount then - if option:default("a") then - while option.invocations < mincount do - option:invoke() - option:close() + self:close() + + for i = self.argument_i, #self.arguments do + local argument = self.arguments[i] + if #argument.args == 0 and argument:default("u") then + argument:complete_invocation() + else + argument:close() + end + end + + if self.parser._require_command and #self.commands > 0 then + self:error("a command is required") + end + + for _, option in ipairs(self.options) do + option.name = option.name or ("option '%s'"):format(option.element._name) + + if option.invocations == 0 then + if option:default("u") then + option:invoke() + option:complete_invocation() + option:close() end - elseif option.invocations == 0 then - self:error("missing %s", option.name) - else - self:error("%s must be used %s", option.name, bound("time", mincount, option.element._maxcount)) - end - end - end + end + + local mincount = option.element._mincount + + if option.invocations < mincount then + if option:default("a") then + while option.invocations < mincount do + option:invoke() + option:close() + end + elseif option.invocations == 0 then + self:error("missing %s", option.name) + else + self:error("%s must be used %s", option.name, bound("time", mincount, option.element._maxcount)) + end + end + end - for i = #self.command_actions, 1, -1 do - self.command_actions[i].action(self.result, self.command_actions[i].name) - end + for i = #self.command_actions, 1, -1 do + self.command_actions[i].action(self.result, self.command_actions[i].name) + end end function ParseState:parse(args) - for _, arg in ipairs(args) do - local plain = true - - if self.handle_options then - local first = arg:sub(1, 1) - - if self.charset[first] then - if #arg > 1 then - plain = false - - if arg:sub(2, 2) == first then - if #arg == 2 then - if self.options[arg] then - local option = self:get_option(arg) - self:invoke(option, arg) - else - self:close() - end - - self.handle_options = false - else - local equals = arg:find "=" - if equals then - local name = arg:sub(1, equals - 1) - local option = self:get_option(name) - - if option.element._maxargs <= 0 then - self:error("option '%s' does not take arguments", name) + for _, arg in ipairs(args) do + local plain = true + + if self.handle_options then + local first = arg:sub(1, 1) + + if self.charset[first] then + if #arg > 1 then + plain = false + + if arg:sub(2, 2) == first then + if #arg == 2 then + if self.options[arg] then + local option = self:get_option(arg) + self:invoke(option, arg) + else + self:close() + end + + self.handle_options = false + else + local equals = arg:find("=") + if equals then + local name = arg:sub(1, equals - 1) + local option = self:get_option(name) + + if option.element._maxargs <= 0 then + self:error("option '%s' does not take arguments", name) + end + + self:invoke(option, name) + self:pass(arg:sub(equals + 1)) + else + local option = self:get_option(arg) + self:invoke(option, arg) + end end - - self:invoke(option, name) - self:pass(arg:sub(equals + 1)) - else - local option = self:get_option(arg) - self:invoke(option, arg) - end - end - else - for i = 2, #arg do - local name = first .. arg:sub(i, i) - local option = self:get_option(name) - self:invoke(option, name) - - if i ~= #arg and option.element._maxargs > 0 then - self:pass(arg:sub(i + 1)) - break - end - end - end + else + for i = 2, #arg do + local name = first .. arg:sub(i, i) + local option = self:get_option(name) + self:invoke(option, name) + + if i ~= #arg and option.element._maxargs > 0 then + self:pass(arg:sub(i + 1)) + break + end + end + end + end end - end - end + end - if plain then - self:pass(arg) - end - end + if plain then + self:pass(arg) + end + end - self:finalize() - return self.result + self:finalize() + return self.result end function Parser:error(msg) - io.stderr:write(("%s\n\nError: %s\n"):format(self:get_usage(), msg)) - os.exit(1) + io.stderr:write(("%s\n\nError: %s\n"):format(self:get_usage(), msg)) + os.exit(1) end -- Compatibility with strict.lua and other checkers: local default_cmdline = rawget(_G, "arg") or {} function Parser:_parse(args, error_handler) - return ParseState(self, error_handler):parse(args or default_cmdline) + return ParseState(self, error_handler):parse(args or default_cmdline) end function Parser:parse(args) - return self:_parse(args, self.error) + return self:_parse(args, self.error) end local function xpcall_error_handler(err) - return tostring(err) .. "\noriginal " .. debug.traceback("", 2):sub(2) + return tostring(err) .. "\noriginal " .. debug.traceback("", 2):sub(2) end function Parser:pparse(args) - local parse_error + local parse_error - local ok, result = xpcall(function() - return self:_parse(args, function(_, err) - parse_error = err - error(err, 0) - end) - end, xpcall_error_handler) + local ok, result = xpcall(function() + return self:_parse(args, function(_, err) + parse_error = err + error(err, 0) + end) + end, xpcall_error_handler) - if ok then - return true, result - elseif not parse_error then - error(result, 0) - else - return false, parse_error - end + if ok then + return true, result + elseif not parse_error then + error(result, 0) + else + return false, parse_error + end end local argparse = {} argparse.version = "0.7.1" -setmetatable(argparse, {__call = function(_, ...) - return Parser(default_cmdline[0]):add_help(true)(...) -end}) +setmetatable(argparse, { + __call = function(_, ...) + return Parser(default_cmdline[0]):add_help(true)(...) + end, +}) return argparse diff --git a/stylua.toml b/stylua.toml new file mode 100644 index 0000000..bb258b9 --- /dev/null +++ b/stylua.toml @@ -0,0 +1,6 @@ +column_width = 120 +line_endings = "Unix" +indent_type = "Spaces" +indent_width = 4 +quote_style = "AutoPreferDouble" +no_call_parentheses = false From bb724b94301654241c16db4bea33dd2b666c43f4 Mon Sep 17 00:00:00 2001 From: Oleksii Stroganov Date: Wed, 4 Jun 2025 18:24:32 +0300 Subject: [PATCH 2/6] Add busted configuration file --- .busted | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .busted diff --git a/.busted b/.busted new file mode 100644 index 0000000..4ccdf60 --- /dev/null +++ b/.busted @@ -0,0 +1,12 @@ +return { + _all = { + coverage = false, + lpath = "spec/?.lua" + }, + default = { + verbose = true, + }, + tests = { + verbose = true, + }, +} From a9a355f6dd264970f8dd65a4325480ff0762ff32 Mon Sep 17 00:00:00 2001 From: Oleksii Stroganov Date: Tue, 1 Jul 2025 18:57:45 +0300 Subject: [PATCH 3/6] WIP: remove completion, restructure and format codebase, add TODOs --- .gitignore | 1 + LICENSE | 1 + argparse-scm-2.rockspec | 21 +- src/argparse.lua | 1051 ++++++++++++--------------------------- src/completion.lua | 484 ++++++++++++++++++ 5 files changed, 805 insertions(+), 753 deletions(-) create mode 100644 src/completion.lua diff --git a/.gitignore b/.gitignore index d8bb7ba..44e182d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ luacov.report.out luacov.stats.out doc +.DS_Store diff --git a/LICENSE b/LICENSE index b59cc73..105ea62 100644 --- a/LICENSE +++ b/LICENSE @@ -2,6 +2,7 @@ The MIT License (MIT) Copyright (c) 2013 - 2018 Peter Melnichenko 2019 Paul Ouellette + 2025 Oleksii Stroganov Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in diff --git a/argparse-scm-2.rockspec b/argparse-scm-2.rockspec index 61be564..05c5c71 100644 --- a/argparse-scm-2.rockspec +++ b/argparse-scm-2.rockspec @@ -1,20 +1,21 @@ package = "argparse" version = "scm-2" source = { - url = "git+https://github.com/luarocks/argparse.git" + url = "git+https://github.com/luarocks/argparse.git" } description = { - summary = "A feature-rich command-line argument parser", - detailed = "Argparse supports positional arguments, options, flags, optional arguments, subcommands and more. Argparse automatically generates usage, help, and error messages, and can generate shell completion scripts.", - homepage = "https://github.com/luarocks/argparse", - license = "MIT" + summary = "A feature-rich command-line argument parser", + detailed = + "Argparse supports positional arguments, options, flags, optional arguments, subcommands and more. Argparse automatically generates usage, help, and error messages, and can generate shell completion scripts.", + homepage = "https://github.com/luarocks/argparse", + license = "MIT" } dependencies = { - "lua >= 5.1, < 5.5" + "lua >= 5.1, < 5.5" } build = { - type = "builtin", - modules = { - argparse = "src/argparse.lua" - } + type = "builtin", + modules = { + argparse = "src/argparse.lua" + } } diff --git a/src/argparse.lua b/src/argparse.lua index 4e288f0..53e6ff5 100644 --- a/src/argparse.lua +++ b/src/argparse.lua @@ -2,6 +2,7 @@ -- Copyright (c) 2013 - 2018 Peter Melnichenko -- 2019 Paul Ouellette +-- 2025 Oleksii Stroganov -- Permission is hereby granted, free of charge, to any person obtaining a copy of -- this software and associated documentation files (the "Software"), to deal in @@ -20,6 +21,10 @@ -- IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -- CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +------------------------ +--- Helper functions --- +------------------------ + local function deep_update(t1, t2) for k, v in pairs(t2) do if type(v) == "table" then @@ -210,8 +215,142 @@ local function boundaries(name) } end +local function split_lines(s) + if s == "" then + return {} + end + + local lines = {} + + if s:sub(-1) ~= "\n" then + s = s .. "\n" + end + + for line in s:gmatch("([^\n]*)\n") do + table.insert(lines, line) + end + + return lines +end + +local function autowrap_line(line, max_length) + -- Algorithm for splitting lines is simple and greedy. + local result_lines = {} + + -- Preserve original indentation of the line, put this at the beginning of each result line. + -- If the first word looks like a list marker ('*', '+', or '-'), add spaces so that starts + -- of the second and the following lines vertically align with the start of the second word. + local indentation = line:match("^ *") + + if line:find("^ *[%*%+%-]") then + indentation = indentation .. " " .. line:match("^ *[%*%+%-]( *)") + end + + -- Parts of the last line being assembled. + local line_parts = {} + + -- Length of the current line. + local line_length = 0 + + -- Index of the next character to consider. + local index = 1 + + while true do + local word_start, word_finish, word = line:find("([^ ]+)", index) + + if not word_start then + -- Ignore trailing spaces, if any. + break + end + + local preceding_spaces = line:sub(index, word_start - 1) + index = word_finish + 1 + + if (#line_parts == 0) or (line_length + #preceding_spaces + #word <= max_length) then + -- Either this is the very first word or it fits as an addition to the current line, add it. + table.insert(line_parts, preceding_spaces) -- For the very first word this adds the indentation. + table.insert(line_parts, word) + line_length = line_length + #preceding_spaces + #word + else + -- Does not fit, finish current line and put the word into a new one. + table.insert(result_lines, table.concat(line_parts)) + line_parts = { indentation, word } + line_length = #indentation + #word + end + end + + if #line_parts > 0 then + table.insert(result_lines, table.concat(line_parts)) + end + + if #result_lines == 0 then + -- Preserve empty lines. + result_lines[1] = "" + end + + return result_lines +end + +-- Automatically wraps lines within given array, +-- attempting to limit line length to `max_length`. +-- Existing line splits are preserved. +local function autowrap(lines, max_length) + local result_lines = {} + + for _, line in ipairs(lines) do + local autowrapped_lines = autowrap_line(line, max_length) + + for _, autowrapped_line in ipairs(autowrapped_lines) do + table.insert(result_lines, autowrapped_line) + end + end + + return result_lines +end + +-- Actions + local actions = {} +function actions.store_true(result, target) + result[target] = true +end + +function actions.store_false(result, target) + result[target] = false +end + +function actions.store(result, target, argument) + result[target] = argument +end + +function actions.count(result, target, _, overwrite) + if not overwrite then + result[target] = result[target] + 1 + end +end + +function actions.append(result, target, argument, overwrite) + result[target] = result[target] or {} + table.insert(result[target], argument) + + if overwrite then + table.remove(result[target], 1) + end +end + +function actions.concat(result, target, arguments, overwrite) + if overwrite then + error("'concat' action can't handle too many invocations") + end + + result[target] = result[target] or {} + + for _, argument in ipairs(arguments) do + table.insert(result[target], argument) + end +end + local option_action = { "action", function(_, value) @@ -270,6 +409,10 @@ local add_help = { end, } +------------------------- +--- Class definitions --- +------------------------- + local Parser = class({ _arguments = {}, _options = {}, @@ -374,24 +517,122 @@ local Option = class({ option_init, }, Argument) -function Parser:_inherit_property(name, default) - local element = self +-- The code define helper function in the following order: +-- Options -> Arguments -> Commands -> Parser - while true do - local value = element["_" .. name] +------------------------------- +--- Option helper functions --- +------------------------------- - if value ~= nil then - return value +function Option:_get_default_argname() + if self._choices then + return self:_get_choices_list() + else + return "<" .. self:_get_default_target() .. ">" + end +end + +function Option:_get_label_lines() + local argument_list = self:_get_argument_list() + + if #argument_list == 0 then + -- Don't put aliases for simple flags like `-h` on different lines. + return { table.concat(self._public_aliases, ", ") } + end + + local longest_alias_length = -1 + + for _, alias in ipairs(self._public_aliases) do + longest_alias_length = math.max(longest_alias_length, #alias) + end + + local argument_list_repr = table.concat(argument_list, " ") + local lines = {} + + for i, alias in ipairs(self._public_aliases) do + local line = (" "):rep(longest_alias_length - #alias) .. alias .. " " .. argument_list_repr + + if i ~= #self._public_aliases then + line = line .. "," end - if not element._parent then - return default + table.insert(lines, line) + end + + return lines +end + +function Option:_get_usage() + local usage = self:_get_argument_list() + table.insert(usage, 1, self._name) + usage = table.concat(usage, " ") + + if self._mincount == 0 or self._default then + usage = "[" .. usage .. "]" + end + + return usage +end + +function Option:_get_default_target() + local res + + for _, alias in ipairs(self._public_aliases) do + if alias:sub(1, 1) == alias:sub(2, 2) then + res = alias:sub(3) + break end + end - element = element._parent + res = res or self._name:sub(2) + return (res:gsub("-", "_")) +end + +function Option:_is_vararg() + return self._maxargs ~= self._minargs +end + +--------------------------------- +--- Argument helper functions --- +--------------------------------- + +function Argument:_get_choices_list() + return "{" .. table.concat(self._choices, ",") .. "}" +end + +function Argument:_get_default_argname() + if self._choices then + return self:_get_choices_list() + else + return "<" .. self._name .. ">" end end +-- Returns labels to be shown in the help message. +function Argument:_get_label_lines() + if self._choices then + return { self:_get_choices_list() } + else + return { self._name } + end +end + +function Argument:_get_description() + if self._default and self._show_default then + if self._description then + return ("%s (default: %s)"):format(self._description, self._default) + else + return ("default: %s"):format(self._default) + end + else + return self._description or "" + end +end + +function Argument:_get_default_target() + return self._name +end + function Argument:_get_argument_list() local buf = {} local i = 1 @@ -435,45 +676,6 @@ function Argument:_get_usage() return usage end -function actions.store_true(result, target) - result[target] = true -end - -function actions.store_false(result, target) - result[target] = false -end - -function actions.store(result, target, argument) - result[target] = argument -end - -function actions.count(result, target, _, overwrite) - if not overwrite then - result[target] = result[target] + 1 - end -end - -function actions.append(result, target, argument, overwrite) - result[target] = result[target] or {} - table.insert(result[target], argument) - - if overwrite then - table.remove(result[target], 1) - end -end - -function actions.concat(result, target, arguments, overwrite) - if overwrite then - error("'concat' action can't handle too many invocations") - end - - result[target] = result[target] or {} - - for _, argument in ipairs(arguments) do - table.insert(result[target], argument) - end -end - function Argument:_get_action() local action, init @@ -517,117 +719,38 @@ function Argument:_get_argname(narg) end end -function Argument:_get_choices_list() - return "{" .. table.concat(self._choices, ",") .. "}" -end - -function Argument:_get_default_argname() - if self._choices then - return self:_get_choices_list() - else - return "<" .. self._name .. ">" - end -end - -function Option:_get_default_argname() - if self._choices then - return self:_get_choices_list() - else - return "<" .. self:_get_default_target() .. ">" - end -end - --- Returns labels to be shown in the help message. -function Argument:_get_label_lines() - if self._choices then - return { self:_get_choices_list() } - else - return { self._name } - end -end - -function Option:_get_label_lines() - local argument_list = self:_get_argument_list() - - if #argument_list == 0 then - -- Don't put aliases for simple flags like `-h` on different lines. - return { table.concat(self._public_aliases, ", ") } - end - - local longest_alias_length = -1 - - for _, alias in ipairs(self._public_aliases) do - longest_alias_length = math.max(longest_alias_length, #alias) - end - - local argument_list_repr = table.concat(argument_list, " ") - local lines = {} - - for i, alias in ipairs(self._public_aliases) do - local line = (" "):rep(longest_alias_length - #alias) .. alias .. " " .. argument_list_repr - - if i ~= #self._public_aliases then - line = line .. "," - end - - table.insert(lines, line) - end - - return lines -end +-------------------------------- +--- Command helper functions --- +-------------------------------- function Command:_get_label_lines() return { table.concat(self._public_aliases, ", ") } end -function Argument:_get_description() - if self._default and self._show_default then - if self._description then - return ("%s (default: %s)"):format(self._description, self._default) - else - return ("default: %s"):format(self._default) - end - else - return self._description or "" - end -end - function Command:_get_description() return self._summary or self._description or "" end -function Option:_get_usage() - local usage = self:_get_argument_list() - table.insert(usage, 1, self._name) - usage = table.concat(usage, " ") - - if self._mincount == 0 or self._default then - usage = "[" .. usage .. "]" - end - - return usage -end +------------------------------ +--- Parser helper function --- +------------------------------ -function Argument:_get_default_target() - return self._name -end +function Parser:_inherit_property(name, default) + local element = self -function Option:_get_default_target() - local res + while true do + local value = element["_" .. name] - for _, alias in ipairs(self._public_aliases) do - if alias:sub(1, 1) == alias:sub(2, 2) then - res = alias:sub(3) - break + if value ~= nil then + return value end - end - res = res or self._name:sub(2) - return (res:gsub("-", "_")) -end + if not element._parent then + return default + end -function Option:_is_vararg() - return self._maxargs ~= self._minargs + element = element._parent + end end function Parser:_get_fullname(exclude_root) @@ -663,6 +786,10 @@ function Parser:_update_charset(charset) return charset end +----------------------------- +--- Parser main functions --- +----------------------------- + function Parser:argument(...) local argument = Argument(...) table.insert(self._arguments, argument) @@ -675,6 +802,8 @@ function Parser:option(...) return option end +-- TODO: add no-prefix flags + function Parser:flag(...) return self:option():args(0)(...) end @@ -698,6 +827,8 @@ function Parser:mutex(...) return self end +-- TODO: add mutins + function Parser:group(name, ...) assert(type(name) == "string", ("bad argument #1 to 'group' (string expected, got %s)"):format(type(name))) @@ -812,136 +943,43 @@ function Parser:get_usage() -- Add usages for positional arguments, together with one mutex containing them, if they are in a mutex. for _, argument in ipairs(self._arguments) do - -- Pick a mutex as a part of which to show this argument, take the first one that's still available. - local mutex - - if elements_in_mutexes[argument] then - for _, argument_mutex in ipairs(argument_to_mutexes[argument]) do - if not added_mutexes[argument_mutex] then - mutex = argument_mutex - end - end - end - - if mutex then - add_mutex(mutex, argument) - else - add_element(argument) - end - end - - for _, mutex in ipairs(self._mutexes) do - add_mutex(mutex) - end - - for _, option in ipairs(self._options) do - add_element(option) - end - - if #self._commands > 0 then - if self._require_command then - add("") - else - add("[]") - end - - add("...") - end - - return table.concat(lines, "\n") -end - -local function split_lines(s) - if s == "" then - return {} - end - - local lines = {} - - if s:sub(-1) ~= "\n" then - s = s .. "\n" - end - - for line in s:gmatch("([^\n]*)\n") do - table.insert(lines, line) - end - - return lines -end - -local function autowrap_line(line, max_length) - -- Algorithm for splitting lines is simple and greedy. - local result_lines = {} - - -- Preserve original indentation of the line, put this at the beginning of each result line. - -- If the first word looks like a list marker ('*', '+', or '-'), add spaces so that starts - -- of the second and the following lines vertically align with the start of the second word. - local indentation = line:match("^ *") - - if line:find("^ *[%*%+%-]") then - indentation = indentation .. " " .. line:match("^ *[%*%+%-]( *)") - end - - -- Parts of the last line being assembled. - local line_parts = {} - - -- Length of the current line. - local line_length = 0 - - -- Index of the next character to consider. - local index = 1 - - while true do - local word_start, word_finish, word = line:find("([^ ]+)", index) + -- Pick a mutex as a part of which to show this argument, take the first one that's still available. + local mutex - if not word_start then - -- Ignore trailing spaces, if any. - break + if elements_in_mutexes[argument] then + for _, argument_mutex in ipairs(argument_to_mutexes[argument]) do + if not added_mutexes[argument_mutex] then + mutex = argument_mutex + end + end end - local preceding_spaces = line:sub(index, word_start - 1) - index = word_finish + 1 - - if (#line_parts == 0) or (line_length + #preceding_spaces + #word <= max_length) then - -- Either this is the very first word or it fits as an addition to the current line, add it. - table.insert(line_parts, preceding_spaces) -- For the very first word this adds the indentation. - table.insert(line_parts, word) - line_length = line_length + #preceding_spaces + #word + if mutex then + add_mutex(mutex, argument) else - -- Does not fit, finish current line and put the word into a new one. - table.insert(result_lines, table.concat(line_parts)) - line_parts = { indentation, word } - line_length = #indentation + #word + add_element(argument) end end - if #line_parts > 0 then - table.insert(result_lines, table.concat(line_parts)) + for _, mutex in ipairs(self._mutexes) do + add_mutex(mutex) end - if #result_lines == 0 then - -- Preserve empty lines. - result_lines[1] = "" + for _, option in ipairs(self._options) do + add_element(option) end - return result_lines -end - --- Automatically wraps lines within given array, --- attempting to limit line length to `max_length`. --- Existing line splits are preserved. -local function autowrap(lines, max_length) - local result_lines = {} - - for _, line in ipairs(lines) do - local autowrapped_lines = autowrap_line(line, max_length) - - for _, autowrapped_line in ipairs(autowrapped_lines) do - table.insert(result_lines, autowrapped_line) + if #self._commands > 0 then + if self._require_command then + add("") + else + add("[]") end + + add("...") end - return result_lines + return table.concat(lines, "\n") end function Parser:_get_element_help(element) @@ -1063,8 +1101,8 @@ function Parser:get_help() local default_groups = { { name = "Arguments", type = Argument, elements = self._arguments }, - { name = "Options", type = Option, elements = self._options }, - { name = "Commands", type = Command, elements = self._commands }, + { name = "Options", type = Option, elements = self._options }, + { name = "Commands", type = Command, elements = self._commands }, } local added_elements = {} @@ -1136,115 +1174,6 @@ function Parser:add_help_command(value) return self end -function Parser:_is_shell_safe() - if self._basename then - if self._basename:find("[^%w_%-%+%.]") then - return false - end - else - for _, alias in ipairs(self._aliases) do - if alias:find("[^%w_%-%+%.]") then - return false - end - end - end - for _, option in ipairs(self._options) do - for _, alias in ipairs(option._aliases) do - if alias:find("[^%w_%-%+%.]") then - return false - end - end - if option._choices then - for _, choice in ipairs(option._choices) do - if choice:find("[%s'\"]") then - return false - end - end - end - end - for _, argument in ipairs(self._arguments) do - if argument._choices then - for _, choice in ipairs(argument._choices) do - if choice:find("[%s'\"]") then - return false - end - end - end - end - for _, command in ipairs(self._commands) do - if not command:_is_shell_safe() then - return false - end - end - return true -end - -function Parser:add_complete(value) - if value then - assert( - type(value) == "string" or type(value) == "table", - ("bad argument #1 to 'add_complete' (string or table expected, got %s)"):format(type(value)) - ) - end - - local complete = self:option() - :description("Output a shell completion script for the specified shell.") - :args(1) - :choices({ "bash", "zsh", "fish" }) - :action(function(_, _, shell) - io.write(self["get_" .. shell .. "_complete"](self)) - os.exit(0) - end) - - if value then - complete = complete(value) - end - - if not complete._name then - complete("--completion") - end - - return self -end - -function Parser:add_complete_command(value) - if value then - assert( - type(value) == "string" or type(value) == "table", - ("bad argument #1 to 'add_complete_command' (string or table expected, got %s)"):format(type(value)) - ) - end - - local complete = self:command():description("Output a shell completion script.") - complete - :argument("shell") - :description("The shell to output a completion script for.") - :choices({ "bash", "zsh", "fish" }) - :action(function(_, _, shell) - io.write(self["get_" .. shell .. "_complete"](self)) - os.exit(0) - end) - - if value then - complete = complete(value) - end - - if not complete._name then - complete("completion") - end - - return self -end - -local function base_name(pathname) - return pathname:gsub("[/\\]*$", ""):match(".*[/\\]([^/\\]*)") or pathname -end - -local function get_short_description(element) - local short = element:_get_description():match("^(.-)%.%s") - return short or element:_get_description():match("^(.-)%.?$") -end - function Parser:_get_options() local options = {} for _, option in ipairs(self._options) do @@ -1265,378 +1194,6 @@ function Parser:_get_commands() return table.concat(commands, " ") end -function Parser:_bash_option_args(buf, indent) - local opts = {} - for _, option in ipairs(self._options) do - if option._choices or option._minargs > 0 then - local compreply - if option._choices then - compreply = 'COMPREPLY=($(compgen -W "' .. table.concat(option._choices, " ") .. '" -- "$cur"))' - else - compreply = 'COMPREPLY=($(compgen -f -- "$cur"))' - end - table.insert(opts, (" "):rep(indent + 4) .. table.concat(option._aliases, "|") .. ")") - table.insert(opts, (" "):rep(indent + 8) .. compreply) - table.insert(opts, (" "):rep(indent + 8) .. "return 0") - table.insert(opts, (" "):rep(indent + 8) .. ";;") - end - end - - if #opts > 0 then - table.insert(buf, (" "):rep(indent) .. 'case "$prev" in') - table.insert(buf, table.concat(opts, "\n")) - table.insert(buf, (" "):rep(indent) .. "esac\n") - end -end - -function Parser:_bash_get_cmd(buf, indent) - if #self._commands == 0 then - return - end - - table.insert(buf, (" "):rep(indent) .. 'args=("${args[@]:1}")') - table.insert(buf, (" "):rep(indent) .. 'for arg in "${args[@]}"; do') - table.insert(buf, (" "):rep(indent + 4) .. 'case "$arg" in') - - for _, command in ipairs(self._commands) do - table.insert(buf, (" "):rep(indent + 8) .. table.concat(command._aliases, "|") .. ")") - if self._parent then - table.insert(buf, (" "):rep(indent + 12) .. 'cmd="$cmd ' .. command._name .. '"') - else - table.insert(buf, (" "):rep(indent + 12) .. 'cmd="' .. command._name .. '"') - end - table.insert(buf, (" "):rep(indent + 12) .. 'opts="$opts ' .. command:_get_options() .. '"') - command:_bash_get_cmd(buf, indent + 12) - table.insert(buf, (" "):rep(indent + 12) .. "break") - table.insert(buf, (" "):rep(indent + 12) .. ";;") - end - - table.insert(buf, (" "):rep(indent + 4) .. "esac") - table.insert(buf, (" "):rep(indent) .. "done") -end - -function Parser:_bash_cmd_completions(buf) - local cmd_buf = {} - if self._parent then - self:_bash_option_args(cmd_buf, 12) - end - if #self._commands > 0 then - table.insert(cmd_buf, (" "):rep(12) .. 'COMPREPLY=($(compgen -W "' .. self:_get_commands() .. '" -- "$cur"))') - elseif self._is_help_command then - table.insert( - cmd_buf, - (" "):rep(12) .. 'COMPREPLY=($(compgen -W "' .. self._parent:_get_commands() .. '" -- "$cur"))' - ) - end - if #cmd_buf > 0 then - table.insert(buf, (" "):rep(8) .. "'" .. self:_get_fullname(true) .. "')") - table.insert(buf, table.concat(cmd_buf, "\n")) - table.insert(buf, (" "):rep(12) .. ";;") - end - - for _, command in ipairs(self._commands) do - command:_bash_cmd_completions(buf) - end -end - -function Parser:get_bash_complete() - self._basename = base_name(self._name) - assert(self:_is_shell_safe()) - local buf = { - ([[ -_%s() { - local IFS=$' \t\n' - local args cur prev cmd opts arg - args=("${COMP_WORDS[@]}") - cur="${COMP_WORDS[COMP_CWORD]}" - prev="${COMP_WORDS[COMP_CWORD-1]}" - opts="%s" -]]):format(self._basename, self:_get_options()), - } - - self:_bash_option_args(buf, 4) - self:_bash_get_cmd(buf, 4) - if #self._commands > 0 then - table.insert(buf, "") - table.insert(buf, (" "):rep(4) .. 'case "$cmd" in') - self:_bash_cmd_completions(buf) - table.insert(buf, (" "):rep(4) .. "esac\n") - end - - table.insert( - buf, - ([=[ - if [[ "$cur" = -* ]]; then - COMPREPLY=($(compgen -W "$opts" -- "$cur")) - fi -} - -complete -F _%s -o bashdefault -o default %s -]=]):format(self._basename, self._basename) - ) - - return table.concat(buf, "\n") -end - -function Parser:_zsh_arguments(buf, cmd_name, indent) - if self._parent then - table.insert(buf, (" "):rep(indent) .. "options=(") - table.insert(buf, (" "):rep(indent + 2) .. "$options") - else - table.insert(buf, (" "):rep(indent) .. "local -a options=(") - end - - for _, option in ipairs(self._options) do - local line = {} - if #option._aliases > 1 then - if option._maxcount > 1 then - table.insert(line, '"*"') - end - table.insert(line, "{" .. table.concat(option._aliases, ",") .. '}"') - else - table.insert(line, '"') - if option._maxcount > 1 then - table.insert(line, "*") - end - table.insert(line, option._name) - end - if option._description then - local description = get_short_description(option):gsub('["%]:`$]', "\\%0") - table.insert(line, "[" .. description .. "]") - end - if option._maxargs == math.huge then - table.insert(line, ":*") - end - if option._choices then - table.insert(line, ": :(" .. table.concat(option._choices, " ") .. ")") - elseif option._maxargs > 0 then - table.insert(line, ": :_files") - end - table.insert(line, '"') - table.insert(buf, (" "):rep(indent + 2) .. table.concat(line)) - end - - table.insert(buf, (" "):rep(indent) .. ")") - table.insert(buf, (" "):rep(indent) .. "_arguments -s -S \\") - table.insert(buf, (" "):rep(indent + 2) .. "$options \\") - - if self._is_help_command then - table.insert(buf, (" "):rep(indent + 2) .. '": :(' .. self._parent:_get_commands() .. ')" \\') - else - for _, argument in ipairs(self._arguments) do - local spec - if argument._choices then - spec = ": :(" .. table.concat(argument._choices, " ") .. ")" - else - spec = ": :_files" - end - if argument._maxargs == math.huge then - table.insert(buf, (" "):rep(indent + 2) .. '"*' .. spec .. '" \\') - break - end - for _ = 1, argument._maxargs do - table.insert(buf, (" "):rep(indent + 2) .. '"' .. spec .. '" \\') - end - end - - if #self._commands > 0 then - table.insert(buf, (" "):rep(indent + 2) .. '": :_' .. cmd_name .. '_cmds" \\') - table.insert(buf, (" "):rep(indent + 2) .. '"*:: :->args" \\') - end - end - - table.insert(buf, (" "):rep(indent + 2) .. "&& return 0") -end - -function Parser:_zsh_cmds(buf, cmd_name) - table.insert(buf, "\n_" .. cmd_name .. "_cmds() {") - table.insert(buf, " local -a commands=(") - - for _, command in ipairs(self._commands) do - local line = {} - if #command._aliases > 1 then - table.insert(line, "{" .. table.concat(command._aliases, ",") .. '}"') - else - table.insert(line, '"' .. command._name) - end - if command._description then - table.insert(line, ":" .. get_short_description(command):gsub('["`$]', "\\%0")) - end - table.insert(buf, " " .. table.concat(line) .. '"') - end - - table.insert(buf, ' )\n _describe "command" commands\n}') -end - -function Parser:_zsh_complete_help(buf, cmds_buf, cmd_name, indent) - if #self._commands == 0 then - return - end - - self:_zsh_cmds(cmds_buf, cmd_name) - table.insert(buf, "\n" .. (" "):rep(indent) .. "case $words[1] in") - - for _, command in ipairs(self._commands) do - local name = cmd_name .. "_" .. command._name - table.insert(buf, (" "):rep(indent + 2) .. table.concat(command._aliases, "|") .. ")") - command:_zsh_arguments(buf, name, indent + 4) - command:_zsh_complete_help(buf, cmds_buf, name, indent + 4) - table.insert(buf, (" "):rep(indent + 4) .. ";;\n") - end - - table.insert(buf, (" "):rep(indent) .. "esac") -end - -function Parser:get_zsh_complete() - self._basename = base_name(self._name) - assert(self:_is_shell_safe()) - local buf = { ("#compdef %s\n"):format(self._basename) } - local cmds_buf = {} - table.insert(buf, "_" .. self._basename .. "() {") - if #self._commands > 0 then - table.insert(buf, " local context state state_descr line") - table.insert(buf, " typeset -A opt_args\n") - end - self:_zsh_arguments(buf, self._basename, 2) - self:_zsh_complete_help(buf, cmds_buf, self._basename, 2) - table.insert(buf, "\n return 1") - table.insert(buf, "}") - - local result = table.concat(buf, "\n") - if #cmds_buf > 0 then - result = result .. "\n" .. table.concat(cmds_buf, "\n") - end - return result .. "\n\n_" .. self._basename .. "\n" -end - -local function fish_escape(string) - return string:gsub("[\\']", "\\%0") -end - -function Parser:_fish_get_cmd(buf, indent) - if #self._commands == 0 then - return - end - - table.insert(buf, (" "):rep(indent) .. "set -e cmdline[1]") - table.insert(buf, (" "):rep(indent) .. "for arg in $cmdline") - table.insert(buf, (" "):rep(indent + 4) .. "switch $arg") - - for _, command in ipairs(self._commands) do - table.insert(buf, (" "):rep(indent + 8) .. "case " .. table.concat(command._aliases, " ")) - table.insert(buf, (" "):rep(indent + 12) .. "set cmd $cmd " .. command._name) - command:_fish_get_cmd(buf, indent + 12) - table.insert(buf, (" "):rep(indent + 12) .. "break") - end - - table.insert(buf, (" "):rep(indent + 4) .. "end") - table.insert(buf, (" "):rep(indent) .. "end") -end - -function Parser:_fish_complete_help(buf, basename) - local prefix = "complete -c " .. basename - table.insert(buf, "") - - for _, command in ipairs(self._commands) do - local aliases = table.concat(command._aliases, " ") - local line - if self._parent then - line = ("%s -n '__fish_%s_using_command %s' -xa '%s'"):format( - prefix, - basename, - self:_get_fullname(true), - aliases - ) - else - line = ("%s -n '__fish_%s_using_command' -xa '%s'"):format(prefix, basename, aliases) - end - if command._description then - line = ("%s -d '%s'"):format(line, fish_escape(get_short_description(command))) - end - table.insert(buf, line) - end - - if self._is_help_command then - local line = ("%s -n '__fish_%s_using_command %s' -xa '%s'"):format( - prefix, - basename, - self:_get_fullname(true), - self._parent:_get_commands() - ) - table.insert(buf, line) - end - - for _, option in ipairs(self._options) do - local parts = { prefix } - - if self._parent then - table.insert(parts, "-n '__fish_" .. basename .. "_seen_command " .. self:_get_fullname(true) .. "'") - end - - for _, alias in ipairs(option._aliases) do - if alias:match("^%-.$") then - table.insert(parts, "-s " .. alias:sub(2)) - elseif alias:match("^%-%-.+") then - table.insert(parts, "-l " .. alias:sub(3)) - end - end - - if option._choices then - table.insert(parts, "-xa '" .. table.concat(option._choices, " ") .. "'") - elseif option._minargs > 0 then - table.insert(parts, "-r") - end - - if option._description then - table.insert(parts, "-d '" .. fish_escape(get_short_description(option)) .. "'") - end - - table.insert(buf, table.concat(parts, " ")) - end - - for _, command in ipairs(self._commands) do - command:_fish_complete_help(buf, basename) - end -end - -function Parser:get_fish_complete() - self._basename = base_name(self._name) - assert(self:_is_shell_safe()) - local buf = {} - - if #self._commands > 0 then - table.insert( - buf, - ([[ -function __fish_%s_print_command - set -l cmdline (commandline -poc) - set -l cmd]]):format(self._basename) - ) - self:_fish_get_cmd(buf, 4) - table.insert( - buf, - ([[ - echo "$cmd" -end - -function __fish_%s_using_command - test (__fish_%s_print_command) = "$argv" - and return 0 - or return 1 -end - -function __fish_%s_seen_command - string match -q "$argv*" (__fish_%s_print_command) - and return 0 - or return 1 -end]]):format(self._basename, self._basename, self._basename, self._basename) - ) - end - - self:_fish_complete_help(buf, self._basename) - return table.concat(buf, "\n") .. "\n" -end - local function get_tip(context, wrong_name) local context_pool = {} local possible_name @@ -1688,6 +1245,10 @@ local function get_tip(context, wrong_name) end end +----------------------------- +--- ElementState functions --- +----------------------------- + local ElementState = class({ invocations = 0, }) @@ -1841,6 +1402,10 @@ function ElementState:close() end end +----------------------------- +--- ParseState functions --- +----------------------------- + local ParseState = class({ result = {}, options = {}, diff --git a/src/completion.lua b/src/completion.lua new file mode 100644 index 0000000..3734c97 --- /dev/null +++ b/src/completion.lua @@ -0,0 +1,484 @@ +local function base_name(pathname) + return pathname:gsub("[/\\]*$", ""):match(".*[/\\]([^/\\]*)") or pathname +end + +local function get_short_description(element) + local short = element:_get_description():match("^(.-)%.%s") + return short or element:_get_description():match("^(.-)%.?$") +end + +----------------------------- +--- Parser main functions --- +----------------------------- + +function Parser:_is_shell_safe() + if self._basename then + if self._basename:find("[^%w_%-%+%.]") then + return false + end + else + for _, alias in ipairs(self._aliases) do + if alias:find("[^%w_%-%+%.]") then + return false + end + end + end + for _, option in ipairs(self._options) do + for _, alias in ipairs(option._aliases) do + if alias:find("[^%w_%-%+%.]") then + return false + end + end + if option._choices then + for _, choice in ipairs(option._choices) do + if choice:find("[%s'\"]") then + return false + end + end + end + end + for _, argument in ipairs(self._arguments) do + if argument._choices then + for _, choice in ipairs(argument._choices) do + if choice:find("[%s'\"]") then + return false + end + end + end + end + for _, command in ipairs(self._commands) do + if not command:_is_shell_safe() then + return false + end + end + return true +end + +function Parser:add_complete(value) + if value then + assert( + type(value) == "string" or type(value) == "table", + ("bad argument #1 to 'add_complete' (string or table expected, got %s)"):format(type(value)) + ) + end + + local complete = self:option() + :description("Output a shell completion script for the specified shell.") + :args(1) + :choices({ "bash", "zsh", "fish" }) + :action(function(_, _, shell) + io.write(self["get_" .. shell .. "_complete"](self)) + os.exit(0) + end) + + if value then + complete = complete(value) + end + + if not complete._name then + complete("--completion") + end + + return self +end + +function Parser:add_complete_command(value) + if value then + assert( + type(value) == "string" or type(value) == "table", + ("bad argument #1 to 'add_complete_command' (string or table expected, got %s)"):format(type(value)) + ) + end + + local complete = self:command():description("Output a shell completion script.") + complete + :argument("shell") + :description("The shell to output a completion script for.") + :choices({ "bash", "zsh", "fish" }) + :action(function(_, _, shell) + io.write(self["get_" .. shell .. "_complete"](self)) + os.exit(0) + end) + + if value then + complete = complete(value) + end + + if not complete._name then + complete("completion") + end + + return self +end + +function Parser:_bash_option_args(buf, indent) + local opts = {} + for _, option in ipairs(self._options) do + if option._choices or option._minargs > 0 then + local compreply + if option._choices then + compreply = 'COMPREPLY=($(compgen -W "' .. table.concat(option._choices, " ") .. '" -- "$cur"))' + else + compreply = 'COMPREPLY=($(compgen -f -- "$cur"))' + end + table.insert(opts, (" "):rep(indent + 4) .. table.concat(option._aliases, "|") .. ")") + table.insert(opts, (" "):rep(indent + 8) .. compreply) + table.insert(opts, (" "):rep(indent + 8) .. "return 0") + table.insert(opts, (" "):rep(indent + 8) .. ";;") + end + end + + if #opts > 0 then + table.insert(buf, (" "):rep(indent) .. 'case "$prev" in') + table.insert(buf, table.concat(opts, "\n")) + table.insert(buf, (" "):rep(indent) .. "esac\n") + end +end + +function Parser:_bash_get_cmd(buf, indent) + if #self._commands == 0 then + return + end + + table.insert(buf, (" "):rep(indent) .. 'args=("${args[@]:1}")') + table.insert(buf, (" "):rep(indent) .. 'for arg in "${args[@]}"; do') + table.insert(buf, (" "):rep(indent + 4) .. 'case "$arg" in') + + for _, command in ipairs(self._commands) do + table.insert(buf, (" "):rep(indent + 8) .. table.concat(command._aliases, "|") .. ")") + if self._parent then + table.insert(buf, (" "):rep(indent + 12) .. 'cmd="$cmd ' .. command._name .. '"') + else + table.insert(buf, (" "):rep(indent + 12) .. 'cmd="' .. command._name .. '"') + end + table.insert(buf, (" "):rep(indent + 12) .. 'opts="$opts ' .. command:_get_options() .. '"') + command:_bash_get_cmd(buf, indent + 12) + table.insert(buf, (" "):rep(indent + 12) .. "break") + table.insert(buf, (" "):rep(indent + 12) .. ";;") + end + + table.insert(buf, (" "):rep(indent + 4) .. "esac") + table.insert(buf, (" "):rep(indent) .. "done") +end + +function Parser:_bash_cmd_completions(buf) + local cmd_buf = {} + if self._parent then + self:_bash_option_args(cmd_buf, 12) + end + if #self._commands > 0 then + table.insert(cmd_buf, (" "):rep(12) .. 'COMPREPLY=($(compgen -W "' .. self:_get_commands() .. '" -- "$cur"))') + elseif self._is_help_command then + table.insert( + cmd_buf, + (" "):rep(12) .. 'COMPREPLY=($(compgen -W "' .. self._parent:_get_commands() .. '" -- "$cur"))' + ) + end + if #cmd_buf > 0 then + table.insert(buf, (" "):rep(8) .. "'" .. self:_get_fullname(true) .. "')") + table.insert(buf, table.concat(cmd_buf, "\n")) + table.insert(buf, (" "):rep(12) .. ";;") + end + + for _, command in ipairs(self._commands) do + command:_bash_cmd_completions(buf) + end +end + +function Parser:get_bash_complete() + self._basename = base_name(self._name) + assert(self:_is_shell_safe()) + local buf = { + ([[ +_%s() { + local IFS=$' \t\n' + local args cur prev cmd opts arg + args=("${COMP_WORDS[@]}") + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + opts="%s" +]]):format(self._basename, self:_get_options()), + } + + self:_bash_option_args(buf, 4) + self:_bash_get_cmd(buf, 4) + if #self._commands > 0 then + table.insert(buf, "") + table.insert(buf, (" "):rep(4) .. 'case "$cmd" in') + self:_bash_cmd_completions(buf) + table.insert(buf, (" "):rep(4) .. "esac\n") + end + + table.insert( + buf, + ([=[ + if [[ "$cur" = -* ]]; then + COMPREPLY=($(compgen -W "$opts" -- "$cur")) + fi +} + +complete -F _%s -o bashdefault -o default %s +]=]):format(self._basename, self._basename) + ) + + return table.concat(buf, "\n") +end + +function Parser:_zsh_arguments(buf, cmd_name, indent) + if self._parent then + table.insert(buf, (" "):rep(indent) .. "options=(") + table.insert(buf, (" "):rep(indent + 2) .. "$options") + else + table.insert(buf, (" "):rep(indent) .. "local -a options=(") + end + + for _, option in ipairs(self._options) do + local line = {} + if #option._aliases > 1 then + if option._maxcount > 1 then + table.insert(line, '"*"') + end + table.insert(line, "{" .. table.concat(option._aliases, ",") .. '}"') + else + table.insert(line, '"') + if option._maxcount > 1 then + table.insert(line, "*") + end + table.insert(line, option._name) + end + if option._description then + local description = get_short_description(option):gsub('["%]:`$]', "\\%0") + table.insert(line, "[" .. description .. "]") + end + if option._maxargs == math.huge then + table.insert(line, ":*") + end + if option._choices then + table.insert(line, ": :(" .. table.concat(option._choices, " ") .. ")") + elseif option._maxargs > 0 then + table.insert(line, ": :_files") + end + table.insert(line, '"') + table.insert(buf, (" "):rep(indent + 2) .. table.concat(line)) + end + + table.insert(buf, (" "):rep(indent) .. ")") + table.insert(buf, (" "):rep(indent) .. "_arguments -s -S \\") + table.insert(buf, (" "):rep(indent + 2) .. "$options \\") + + if self._is_help_command then + table.insert(buf, (" "):rep(indent + 2) .. '": :(' .. self._parent:_get_commands() .. ')" \\') + else + for _, argument in ipairs(self._arguments) do + local spec + if argument._choices then + spec = ": :(" .. table.concat(argument._choices, " ") .. ")" + else + spec = ": :_files" + end + if argument._maxargs == math.huge then + table.insert(buf, (" "):rep(indent + 2) .. '"*' .. spec .. '" \\') + break + end + for _ = 1, argument._maxargs do + table.insert(buf, (" "):rep(indent + 2) .. '"' .. spec .. '" \\') + end + end + + if #self._commands > 0 then + table.insert(buf, (" "):rep(indent + 2) .. '": :_' .. cmd_name .. '_cmds" \\') + table.insert(buf, (" "):rep(indent + 2) .. '"*:: :->args" \\') + end + end + + table.insert(buf, (" "):rep(indent + 2) .. "&& return 0") +end + +function Parser:_zsh_cmds(buf, cmd_name) + table.insert(buf, "\n_" .. cmd_name .. "_cmds() {") + table.insert(buf, " local -a commands=(") + + for _, command in ipairs(self._commands) do + local line = {} + if #command._aliases > 1 then + table.insert(line, "{" .. table.concat(command._aliases, ",") .. '}"') + else + table.insert(line, '"' .. command._name) + end + if command._description then + table.insert(line, ":" .. get_short_description(command):gsub('["`$]', "\\%0")) + end + table.insert(buf, " " .. table.concat(line) .. '"') + end + + table.insert(buf, ' )\n _describe "command" commands\n}') +end + +function Parser:_zsh_complete_help(buf, cmds_buf, cmd_name, indent) + if #self._commands == 0 then + return + end + + self:_zsh_cmds(cmds_buf, cmd_name) + table.insert(buf, "\n" .. (" "):rep(indent) .. "case $words[1] in") + + for _, command in ipairs(self._commands) do + local name = cmd_name .. "_" .. command._name + table.insert(buf, (" "):rep(indent + 2) .. table.concat(command._aliases, "|") .. ")") + command:_zsh_arguments(buf, name, indent + 4) + command:_zsh_complete_help(buf, cmds_buf, name, indent + 4) + table.insert(buf, (" "):rep(indent + 4) .. ";;\n") + end + + table.insert(buf, (" "):rep(indent) .. "esac") +end + +function Parser:get_zsh_complete() + self._basename = base_name(self._name) + assert(self:_is_shell_safe()) + local buf = { ("#compdef %s\n"):format(self._basename) } + local cmds_buf = {} + table.insert(buf, "_" .. self._basename .. "() {") + if #self._commands > 0 then + table.insert(buf, " local context state state_descr line") + table.insert(buf, " typeset -A opt_args\n") + end + self:_zsh_arguments(buf, self._basename, 2) + self:_zsh_complete_help(buf, cmds_buf, self._basename, 2) + table.insert(buf, "\n return 1") + table.insert(buf, "}") + + local result = table.concat(buf, "\n") + if #cmds_buf > 0 then + result = result .. "\n" .. table.concat(cmds_buf, "\n") + end + return result .. "\n\n_" .. self._basename .. "\n" +end + +local function fish_escape(string) + return string:gsub("[\\']", "\\%0") +end + +function Parser:_fish_get_cmd(buf, indent) + if #self._commands == 0 then + return + end + + table.insert(buf, (" "):rep(indent) .. "set -e cmdline[1]") + table.insert(buf, (" "):rep(indent) .. "for arg in $cmdline") + table.insert(buf, (" "):rep(indent + 4) .. "switch $arg") + + for _, command in ipairs(self._commands) do + table.insert(buf, (" "):rep(indent + 8) .. "case " .. table.concat(command._aliases, " ")) + table.insert(buf, (" "):rep(indent + 12) .. "set cmd $cmd " .. command._name) + command:_fish_get_cmd(buf, indent + 12) + table.insert(buf, (" "):rep(indent + 12) .. "break") + end + + table.insert(buf, (" "):rep(indent + 4) .. "end") + table.insert(buf, (" "):rep(indent) .. "end") +end + +function Parser:_fish_complete_help(buf, basename) + local prefix = "complete -c " .. basename + table.insert(buf, "") + + for _, command in ipairs(self._commands) do + local aliases = table.concat(command._aliases, " ") + local line + if self._parent then + line = ("%s -n '__fish_%s_using_command %s' -xa '%s'"):format( + prefix, + basename, + self:_get_fullname(true), + aliases + ) + else + line = ("%s -n '__fish_%s_using_command' -xa '%s'"):format(prefix, basename, aliases) + end + if command._description then + line = ("%s -d '%s'"):format(line, fish_escape(get_short_description(command))) + end + table.insert(buf, line) + end + + if self._is_help_command then + local line = ("%s -n '__fish_%s_using_command %s' -xa '%s'"):format( + prefix, + basename, + self:_get_fullname(true), + self._parent:_get_commands() + ) + table.insert(buf, line) + end + + for _, option in ipairs(self._options) do + local parts = { prefix } + + if self._parent then + table.insert(parts, "-n '__fish_" .. basename .. "_seen_command " .. self:_get_fullname(true) .. "'") + end + + for _, alias in ipairs(option._aliases) do + if alias:match("^%-.$") then + table.insert(parts, "-s " .. alias:sub(2)) + elseif alias:match("^%-%-.+") then + table.insert(parts, "-l " .. alias:sub(3)) + end + end + + if option._choices then + table.insert(parts, "-xa '" .. table.concat(option._choices, " ") .. "'") + elseif option._minargs > 0 then + table.insert(parts, "-r") + end + + if option._description then + table.insert(parts, "-d '" .. fish_escape(get_short_description(option)) .. "'") + end + + table.insert(buf, table.concat(parts, " ")) + end + + for _, command in ipairs(self._commands) do + command:_fish_complete_help(buf, basename) + end +end + +function Parser:get_fish_complete() + self._basename = base_name(self._name) + assert(self:_is_shell_safe()) + local buf = {} + + if #self._commands > 0 then + table.insert( + buf, + ([[ +function __fish_%s_print_command + set -l cmdline (commandline -poc) + set -l cmd]]):format(self._basename) + ) + self:_fish_get_cmd(buf, 4) + table.insert( + buf, + ([[ + echo "$cmd" +end + +function __fish_%s_using_command + test (__fish_%s_print_command) = "$argv" + and return 0 + or return 1 +end + +function __fish_%s_seen_command + string match -q "$argv*" (__fish_%s_print_command) + and return 0 + or return 1 +end]]):format(self._basename, self._basename, self._basename, self._basename) + ) + end + + self:_fish_complete_help(buf, self._basename) + return table.concat(buf, "\n") .. "\n" +end From df158c5e0f407afdc67eaf01193d6bb2cb0a3cc5 Mon Sep 17 00:00:00 2001 From: Oleksii Stroganov Date: Tue, 1 Jul 2025 19:11:20 +0300 Subject: [PATCH 4/6] Remove completion completely, reimplement it later --- spec/completion_spec.lua | 331 -------------------------- src/completion.lua | 484 --------------------------------------- 2 files changed, 815 deletions(-) delete mode 100644 spec/completion_spec.lua delete mode 100644 src/completion.lua diff --git a/spec/completion_spec.lua b/spec/completion_spec.lua deleted file mode 100644 index 1964376..0000000 --- a/spec/completion_spec.lua +++ /dev/null @@ -1,331 +0,0 @@ -local script = "./spec/comptest" -local script_cmd = "lua" - -if package.loaded["luacov.runner"] then - script_cmd = script_cmd .. " -lluacov" -end - -script_cmd = script_cmd .. " " .. script - -local function get_output(args) - local handler = io.popen(script_cmd .. " " .. args .. " 2>&1", "r") - local output = handler:read("*a") - handler:close() - return output -end - -describe("tests related to generation of shell completion scripts", function() - it("generates correct bash completion script", function() - assert.equal([=[ -_comptest() { - local IFS=$' \t\n' - local args cur prev cmd opts arg - args=("${COMP_WORDS[@]}") - cur="${COMP_WORDS[COMP_CWORD]}" - prev="${COMP_WORDS[COMP_CWORD-1]}" - opts="-h --help --completion -v --verbose -f --files" - - case "$prev" in - --completion) - COMPREPLY=($(compgen -W "bash zsh fish" -- "$cur")) - return 0 - ;; - -f|--files) - COMPREPLY=($(compgen -f -- "$cur")) - return 0 - ;; - esac - - args=("${args[@]:1}") - for arg in "${args[@]}"; do - case "$arg" in - help) - cmd="help" - opts="$opts -h --help" - break - ;; - completion) - cmd="completion" - opts="$opts -h --help" - break - ;; - install|i) - cmd="install" - opts="$opts -h --help --deps-mode --no-doc" - break - ;; - admin) - cmd="admin" - opts="$opts -h --help" - args=("${args[@]:1}") - for arg in "${args[@]}"; do - case "$arg" in - help) - cmd="$cmd help" - opts="$opts -h --help" - break - ;; - add) - cmd="$cmd add" - opts="$opts -h --help" - break - ;; - remove) - cmd="$cmd remove" - opts="$opts -h --help" - break - ;; - esac - done - break - ;; - esac - done - - case "$cmd" in - '') - COMPREPLY=($(compgen -W "help completion install i admin" -- "$cur")) - ;; - 'help') - COMPREPLY=($(compgen -W "help completion install i admin" -- "$cur")) - ;; - 'install') - case "$prev" in - --deps-mode) - COMPREPLY=($(compgen -W "all one order none" -- "$cur")) - return 0 - ;; - esac - - ;; - 'admin') - COMPREPLY=($(compgen -W "help add remove" -- "$cur")) - ;; - 'admin help') - COMPREPLY=($(compgen -W "help add remove" -- "$cur")) - ;; - esac - - if [[ "$cur" = -* ]]; then - COMPREPLY=($(compgen -W "$opts" -- "$cur")) - fi -} - -complete -F _comptest -o bashdefault -o default comptest -]=], get_output("completion bash")) - end) - - it("generates correct zsh completion script", function() - assert.equal([=[ -#compdef comptest - -_comptest() { - local context state state_descr line - typeset -A opt_args - - local -a options=( - {-h,--help}"[Show this help message and exit]" - "--completion[Output a shell completion script for the specified shell]: :(bash zsh fish)" - "*"{-v,--verbose}"[Set the verbosity level]" - {-f,--files}"[A description with illegal \"' characters]:*: :_files" - ) - _arguments -s -S \ - $options \ - ": :_comptest_cmds" \ - "*:: :->args" \ - && return 0 - - case $words[1] in - help) - options=( - $options - {-h,--help}"[Show this help message and exit]" - ) - _arguments -s -S \ - $options \ - ": :(help completion install i admin)" \ - && return 0 - ;; - - completion) - options=( - $options - {-h,--help}"[Show this help message and exit]" - ) - _arguments -s -S \ - $options \ - ": :(bash zsh fish)" \ - && return 0 - ;; - - install|i) - options=( - $options - {-h,--help}"[Show this help message and exit]" - "--deps-mode: :(all one order none)" - "--no-doc[Install without documentation]" - ) - _arguments -s -S \ - $options \ - && return 0 - ;; - - admin) - options=( - $options - {-h,--help}"[Show this help message and exit]" - ) - _arguments -s -S \ - $options \ - ": :_comptest_admin_cmds" \ - "*:: :->args" \ - && return 0 - - case $words[1] in - help) - options=( - $options - {-h,--help}"[Show this help message and exit]" - ) - _arguments -s -S \ - $options \ - ": :(help add remove)" \ - && return 0 - ;; - - add) - options=( - $options - {-h,--help}"[Show this help message and exit]" - ) - _arguments -s -S \ - $options \ - ": :_files" \ - && return 0 - ;; - - remove) - options=( - $options - {-h,--help}"[Show this help message and exit]" - ) - _arguments -s -S \ - $options \ - ": :_files" \ - && return 0 - ;; - - esac - ;; - - esac - - return 1 -} - -_comptest_cmds() { - local -a commands=( - "help:Show help for commands" - "completion:Output a shell completion script" - {install,i}":Install a rock" - "admin:Rock server administration interface" - ) - _describe "command" commands -} - -_comptest_admin_cmds() { - local -a commands=( - "help:Show help for commands" - "add:Add a rock to a server" - "remove:Remove a rock from a server" - ) - _describe "command" commands -} - -_comptest -]=], get_output("completion zsh")) - end) - - it("generates correct fish completion script", function() - assert.equal([=[ -function __fish_comptest_print_command - set -l cmdline (commandline -poc) - set -l cmd - set -e cmdline[1] - for arg in $cmdline - switch $arg - case help - set cmd $cmd help - break - case completion - set cmd $cmd completion - break - case install i - set cmd $cmd install - break - case admin - set cmd $cmd admin - set -e cmdline[1] - for arg in $cmdline - switch $arg - case help - set cmd $cmd help - break - case add - set cmd $cmd add - break - case remove - set cmd $cmd remove - break - end - end - break - end - end - echo "$cmd" -end - -function __fish_comptest_using_command - test (__fish_comptest_print_command) = "$argv" - and return 0 - or return 1 -end - -function __fish_comptest_seen_command - string match -q "$argv*" (__fish_comptest_print_command) - and return 0 - or return 1 -end - -complete -c comptest -n '__fish_comptest_using_command' -xa 'help' -d 'Show help for commands' -complete -c comptest -n '__fish_comptest_using_command' -xa 'completion' -d 'Output a shell completion script' -complete -c comptest -n '__fish_comptest_using_command' -xa 'install i' -d 'Install a rock' -complete -c comptest -n '__fish_comptest_using_command' -xa 'admin' -d 'Rock server administration interface' -complete -c comptest -s h -l help -d 'Show this help message and exit' -complete -c comptest -l completion -xa 'bash zsh fish' -d 'Output a shell completion script for the specified shell' -complete -c comptest -s v -l verbose -d 'Set the verbosity level' -complete -c comptest -s f -l files -r -d 'A description with illegal "\' characters' - -complete -c comptest -n '__fish_comptest_using_command help' -xa 'help completion install i admin' -complete -c comptest -n '__fish_comptest_seen_command help' -s h -l help -d 'Show this help message and exit' - -complete -c comptest -n '__fish_comptest_seen_command completion' -s h -l help -d 'Show this help message and exit' - -complete -c comptest -n '__fish_comptest_seen_command install' -s h -l help -d 'Show this help message and exit' -complete -c comptest -n '__fish_comptest_seen_command install' -l deps-mode -xa 'all one order none' -complete -c comptest -n '__fish_comptest_seen_command install' -l no-doc -d 'Install without documentation' - -complete -c comptest -n '__fish_comptest_using_command admin' -xa 'help' -d 'Show help for commands' -complete -c comptest -n '__fish_comptest_using_command admin' -xa 'add' -d 'Add a rock to a server' -complete -c comptest -n '__fish_comptest_using_command admin' -xa 'remove' -d 'Remove a rock from a server' -complete -c comptest -n '__fish_comptest_seen_command admin' -s h -l help -d 'Show this help message and exit' - -complete -c comptest -n '__fish_comptest_using_command admin help' -xa 'help add remove' -complete -c comptest -n '__fish_comptest_seen_command admin help' -s h -l help -d 'Show this help message and exit' - -complete -c comptest -n '__fish_comptest_seen_command admin add' -s h -l help -d 'Show this help message and exit' - -complete -c comptest -n '__fish_comptest_seen_command admin remove' -s h -l help -d 'Show this help message and exit' -]=], get_output("completion fish")) - end) -end) diff --git a/src/completion.lua b/src/completion.lua deleted file mode 100644 index 3734c97..0000000 --- a/src/completion.lua +++ /dev/null @@ -1,484 +0,0 @@ -local function base_name(pathname) - return pathname:gsub("[/\\]*$", ""):match(".*[/\\]([^/\\]*)") or pathname -end - -local function get_short_description(element) - local short = element:_get_description():match("^(.-)%.%s") - return short or element:_get_description():match("^(.-)%.?$") -end - ------------------------------ ---- Parser main functions --- ------------------------------ - -function Parser:_is_shell_safe() - if self._basename then - if self._basename:find("[^%w_%-%+%.]") then - return false - end - else - for _, alias in ipairs(self._aliases) do - if alias:find("[^%w_%-%+%.]") then - return false - end - end - end - for _, option in ipairs(self._options) do - for _, alias in ipairs(option._aliases) do - if alias:find("[^%w_%-%+%.]") then - return false - end - end - if option._choices then - for _, choice in ipairs(option._choices) do - if choice:find("[%s'\"]") then - return false - end - end - end - end - for _, argument in ipairs(self._arguments) do - if argument._choices then - for _, choice in ipairs(argument._choices) do - if choice:find("[%s'\"]") then - return false - end - end - end - end - for _, command in ipairs(self._commands) do - if not command:_is_shell_safe() then - return false - end - end - return true -end - -function Parser:add_complete(value) - if value then - assert( - type(value) == "string" or type(value) == "table", - ("bad argument #1 to 'add_complete' (string or table expected, got %s)"):format(type(value)) - ) - end - - local complete = self:option() - :description("Output a shell completion script for the specified shell.") - :args(1) - :choices({ "bash", "zsh", "fish" }) - :action(function(_, _, shell) - io.write(self["get_" .. shell .. "_complete"](self)) - os.exit(0) - end) - - if value then - complete = complete(value) - end - - if not complete._name then - complete("--completion") - end - - return self -end - -function Parser:add_complete_command(value) - if value then - assert( - type(value) == "string" or type(value) == "table", - ("bad argument #1 to 'add_complete_command' (string or table expected, got %s)"):format(type(value)) - ) - end - - local complete = self:command():description("Output a shell completion script.") - complete - :argument("shell") - :description("The shell to output a completion script for.") - :choices({ "bash", "zsh", "fish" }) - :action(function(_, _, shell) - io.write(self["get_" .. shell .. "_complete"](self)) - os.exit(0) - end) - - if value then - complete = complete(value) - end - - if not complete._name then - complete("completion") - end - - return self -end - -function Parser:_bash_option_args(buf, indent) - local opts = {} - for _, option in ipairs(self._options) do - if option._choices or option._minargs > 0 then - local compreply - if option._choices then - compreply = 'COMPREPLY=($(compgen -W "' .. table.concat(option._choices, " ") .. '" -- "$cur"))' - else - compreply = 'COMPREPLY=($(compgen -f -- "$cur"))' - end - table.insert(opts, (" "):rep(indent + 4) .. table.concat(option._aliases, "|") .. ")") - table.insert(opts, (" "):rep(indent + 8) .. compreply) - table.insert(opts, (" "):rep(indent + 8) .. "return 0") - table.insert(opts, (" "):rep(indent + 8) .. ";;") - end - end - - if #opts > 0 then - table.insert(buf, (" "):rep(indent) .. 'case "$prev" in') - table.insert(buf, table.concat(opts, "\n")) - table.insert(buf, (" "):rep(indent) .. "esac\n") - end -end - -function Parser:_bash_get_cmd(buf, indent) - if #self._commands == 0 then - return - end - - table.insert(buf, (" "):rep(indent) .. 'args=("${args[@]:1}")') - table.insert(buf, (" "):rep(indent) .. 'for arg in "${args[@]}"; do') - table.insert(buf, (" "):rep(indent + 4) .. 'case "$arg" in') - - for _, command in ipairs(self._commands) do - table.insert(buf, (" "):rep(indent + 8) .. table.concat(command._aliases, "|") .. ")") - if self._parent then - table.insert(buf, (" "):rep(indent + 12) .. 'cmd="$cmd ' .. command._name .. '"') - else - table.insert(buf, (" "):rep(indent + 12) .. 'cmd="' .. command._name .. '"') - end - table.insert(buf, (" "):rep(indent + 12) .. 'opts="$opts ' .. command:_get_options() .. '"') - command:_bash_get_cmd(buf, indent + 12) - table.insert(buf, (" "):rep(indent + 12) .. "break") - table.insert(buf, (" "):rep(indent + 12) .. ";;") - end - - table.insert(buf, (" "):rep(indent + 4) .. "esac") - table.insert(buf, (" "):rep(indent) .. "done") -end - -function Parser:_bash_cmd_completions(buf) - local cmd_buf = {} - if self._parent then - self:_bash_option_args(cmd_buf, 12) - end - if #self._commands > 0 then - table.insert(cmd_buf, (" "):rep(12) .. 'COMPREPLY=($(compgen -W "' .. self:_get_commands() .. '" -- "$cur"))') - elseif self._is_help_command then - table.insert( - cmd_buf, - (" "):rep(12) .. 'COMPREPLY=($(compgen -W "' .. self._parent:_get_commands() .. '" -- "$cur"))' - ) - end - if #cmd_buf > 0 then - table.insert(buf, (" "):rep(8) .. "'" .. self:_get_fullname(true) .. "')") - table.insert(buf, table.concat(cmd_buf, "\n")) - table.insert(buf, (" "):rep(12) .. ";;") - end - - for _, command in ipairs(self._commands) do - command:_bash_cmd_completions(buf) - end -end - -function Parser:get_bash_complete() - self._basename = base_name(self._name) - assert(self:_is_shell_safe()) - local buf = { - ([[ -_%s() { - local IFS=$' \t\n' - local args cur prev cmd opts arg - args=("${COMP_WORDS[@]}") - cur="${COMP_WORDS[COMP_CWORD]}" - prev="${COMP_WORDS[COMP_CWORD-1]}" - opts="%s" -]]):format(self._basename, self:_get_options()), - } - - self:_bash_option_args(buf, 4) - self:_bash_get_cmd(buf, 4) - if #self._commands > 0 then - table.insert(buf, "") - table.insert(buf, (" "):rep(4) .. 'case "$cmd" in') - self:_bash_cmd_completions(buf) - table.insert(buf, (" "):rep(4) .. "esac\n") - end - - table.insert( - buf, - ([=[ - if [[ "$cur" = -* ]]; then - COMPREPLY=($(compgen -W "$opts" -- "$cur")) - fi -} - -complete -F _%s -o bashdefault -o default %s -]=]):format(self._basename, self._basename) - ) - - return table.concat(buf, "\n") -end - -function Parser:_zsh_arguments(buf, cmd_name, indent) - if self._parent then - table.insert(buf, (" "):rep(indent) .. "options=(") - table.insert(buf, (" "):rep(indent + 2) .. "$options") - else - table.insert(buf, (" "):rep(indent) .. "local -a options=(") - end - - for _, option in ipairs(self._options) do - local line = {} - if #option._aliases > 1 then - if option._maxcount > 1 then - table.insert(line, '"*"') - end - table.insert(line, "{" .. table.concat(option._aliases, ",") .. '}"') - else - table.insert(line, '"') - if option._maxcount > 1 then - table.insert(line, "*") - end - table.insert(line, option._name) - end - if option._description then - local description = get_short_description(option):gsub('["%]:`$]', "\\%0") - table.insert(line, "[" .. description .. "]") - end - if option._maxargs == math.huge then - table.insert(line, ":*") - end - if option._choices then - table.insert(line, ": :(" .. table.concat(option._choices, " ") .. ")") - elseif option._maxargs > 0 then - table.insert(line, ": :_files") - end - table.insert(line, '"') - table.insert(buf, (" "):rep(indent + 2) .. table.concat(line)) - end - - table.insert(buf, (" "):rep(indent) .. ")") - table.insert(buf, (" "):rep(indent) .. "_arguments -s -S \\") - table.insert(buf, (" "):rep(indent + 2) .. "$options \\") - - if self._is_help_command then - table.insert(buf, (" "):rep(indent + 2) .. '": :(' .. self._parent:_get_commands() .. ')" \\') - else - for _, argument in ipairs(self._arguments) do - local spec - if argument._choices then - spec = ": :(" .. table.concat(argument._choices, " ") .. ")" - else - spec = ": :_files" - end - if argument._maxargs == math.huge then - table.insert(buf, (" "):rep(indent + 2) .. '"*' .. spec .. '" \\') - break - end - for _ = 1, argument._maxargs do - table.insert(buf, (" "):rep(indent + 2) .. '"' .. spec .. '" \\') - end - end - - if #self._commands > 0 then - table.insert(buf, (" "):rep(indent + 2) .. '": :_' .. cmd_name .. '_cmds" \\') - table.insert(buf, (" "):rep(indent + 2) .. '"*:: :->args" \\') - end - end - - table.insert(buf, (" "):rep(indent + 2) .. "&& return 0") -end - -function Parser:_zsh_cmds(buf, cmd_name) - table.insert(buf, "\n_" .. cmd_name .. "_cmds() {") - table.insert(buf, " local -a commands=(") - - for _, command in ipairs(self._commands) do - local line = {} - if #command._aliases > 1 then - table.insert(line, "{" .. table.concat(command._aliases, ",") .. '}"') - else - table.insert(line, '"' .. command._name) - end - if command._description then - table.insert(line, ":" .. get_short_description(command):gsub('["`$]', "\\%0")) - end - table.insert(buf, " " .. table.concat(line) .. '"') - end - - table.insert(buf, ' )\n _describe "command" commands\n}') -end - -function Parser:_zsh_complete_help(buf, cmds_buf, cmd_name, indent) - if #self._commands == 0 then - return - end - - self:_zsh_cmds(cmds_buf, cmd_name) - table.insert(buf, "\n" .. (" "):rep(indent) .. "case $words[1] in") - - for _, command in ipairs(self._commands) do - local name = cmd_name .. "_" .. command._name - table.insert(buf, (" "):rep(indent + 2) .. table.concat(command._aliases, "|") .. ")") - command:_zsh_arguments(buf, name, indent + 4) - command:_zsh_complete_help(buf, cmds_buf, name, indent + 4) - table.insert(buf, (" "):rep(indent + 4) .. ";;\n") - end - - table.insert(buf, (" "):rep(indent) .. "esac") -end - -function Parser:get_zsh_complete() - self._basename = base_name(self._name) - assert(self:_is_shell_safe()) - local buf = { ("#compdef %s\n"):format(self._basename) } - local cmds_buf = {} - table.insert(buf, "_" .. self._basename .. "() {") - if #self._commands > 0 then - table.insert(buf, " local context state state_descr line") - table.insert(buf, " typeset -A opt_args\n") - end - self:_zsh_arguments(buf, self._basename, 2) - self:_zsh_complete_help(buf, cmds_buf, self._basename, 2) - table.insert(buf, "\n return 1") - table.insert(buf, "}") - - local result = table.concat(buf, "\n") - if #cmds_buf > 0 then - result = result .. "\n" .. table.concat(cmds_buf, "\n") - end - return result .. "\n\n_" .. self._basename .. "\n" -end - -local function fish_escape(string) - return string:gsub("[\\']", "\\%0") -end - -function Parser:_fish_get_cmd(buf, indent) - if #self._commands == 0 then - return - end - - table.insert(buf, (" "):rep(indent) .. "set -e cmdline[1]") - table.insert(buf, (" "):rep(indent) .. "for arg in $cmdline") - table.insert(buf, (" "):rep(indent + 4) .. "switch $arg") - - for _, command in ipairs(self._commands) do - table.insert(buf, (" "):rep(indent + 8) .. "case " .. table.concat(command._aliases, " ")) - table.insert(buf, (" "):rep(indent + 12) .. "set cmd $cmd " .. command._name) - command:_fish_get_cmd(buf, indent + 12) - table.insert(buf, (" "):rep(indent + 12) .. "break") - end - - table.insert(buf, (" "):rep(indent + 4) .. "end") - table.insert(buf, (" "):rep(indent) .. "end") -end - -function Parser:_fish_complete_help(buf, basename) - local prefix = "complete -c " .. basename - table.insert(buf, "") - - for _, command in ipairs(self._commands) do - local aliases = table.concat(command._aliases, " ") - local line - if self._parent then - line = ("%s -n '__fish_%s_using_command %s' -xa '%s'"):format( - prefix, - basename, - self:_get_fullname(true), - aliases - ) - else - line = ("%s -n '__fish_%s_using_command' -xa '%s'"):format(prefix, basename, aliases) - end - if command._description then - line = ("%s -d '%s'"):format(line, fish_escape(get_short_description(command))) - end - table.insert(buf, line) - end - - if self._is_help_command then - local line = ("%s -n '__fish_%s_using_command %s' -xa '%s'"):format( - prefix, - basename, - self:_get_fullname(true), - self._parent:_get_commands() - ) - table.insert(buf, line) - end - - for _, option in ipairs(self._options) do - local parts = { prefix } - - if self._parent then - table.insert(parts, "-n '__fish_" .. basename .. "_seen_command " .. self:_get_fullname(true) .. "'") - end - - for _, alias in ipairs(option._aliases) do - if alias:match("^%-.$") then - table.insert(parts, "-s " .. alias:sub(2)) - elseif alias:match("^%-%-.+") then - table.insert(parts, "-l " .. alias:sub(3)) - end - end - - if option._choices then - table.insert(parts, "-xa '" .. table.concat(option._choices, " ") .. "'") - elseif option._minargs > 0 then - table.insert(parts, "-r") - end - - if option._description then - table.insert(parts, "-d '" .. fish_escape(get_short_description(option)) .. "'") - end - - table.insert(buf, table.concat(parts, " ")) - end - - for _, command in ipairs(self._commands) do - command:_fish_complete_help(buf, basename) - end -end - -function Parser:get_fish_complete() - self._basename = base_name(self._name) - assert(self:_is_shell_safe()) - local buf = {} - - if #self._commands > 0 then - table.insert( - buf, - ([[ -function __fish_%s_print_command - set -l cmdline (commandline -poc) - set -l cmd]]):format(self._basename) - ) - self:_fish_get_cmd(buf, 4) - table.insert( - buf, - ([[ - echo "$cmd" -end - -function __fish_%s_using_command - test (__fish_%s_print_command) = "$argv" - and return 0 - or return 1 -end - -function __fish_%s_seen_command - string match -q "$argv*" (__fish_%s_print_command) - and return 0 - or return 1 -end]]):format(self._basename, self._basename, self._basename, self._basename) - ) - end - - self:_fish_complete_help(buf, self._basename) - return table.concat(buf, "\n") .. "\n" -end From 45cf36dd52e19c0bf4fde788f3315f0effe86e88 Mon Sep 17 00:00:00 2001 From: Oleksii Stroganov Date: Fri, 3 Oct 2025 12:57:12 +0300 Subject: [PATCH 5/6] Put back completions, add mutins and no-prefix flags --- docsrc/conf.py | 143 ++++---- docsrc/index.rst | 2 +- docsrc/mutexes.rst | 40 --- docsrc/mutuals.rst | 85 +++++ docsrc/options.rst | 35 ++ spec/mutex_spec.lua | 122 ------- spec/mutuals_spec.lua | 227 +++++++++++++ spec/negation_spec.lua | 131 ++++++++ src/argparse.lua | 741 ++++++++++++++++++++++++++++++++++++----- 9 files changed, 1211 insertions(+), 315 deletions(-) delete mode 100644 docsrc/mutexes.rst create mode 100644 docsrc/mutuals.rst delete mode 100644 spec/mutex_spec.lua create mode 100644 spec/mutuals_spec.lua create mode 100644 spec/negation_spec.lua diff --git a/docsrc/conf.py b/docsrc/conf.py index ee88d61..9b21e68 100644 --- a/docsrc/conf.py +++ b/docsrc/conf.py @@ -18,12 +18,12 @@ # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) +# sys.path.insert(0, os.path.abspath('.')) # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +# needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom @@ -34,36 +34,36 @@ templates_path = [] # The suffix of source filenames. -source_suffix = '.rst' +source_suffix = ".rst" # The encoding of source files. -#source_encoding = 'utf-8-sig' +# source_encoding = 'utf-8-sig' # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. -project = u'argparse' -copyright = u'2013-2018 Peter Melnichenko; 2019 Paul Ouellette' +project = "argparse" +copyright = "2013-2018 Peter Melnichenko; 2019 Paul Ouellette; 2025 Oleksii Stroganov" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = '0.7.1' +version = "0.7.1" # The full version, including alpha/beta/rc tags. -release = '0.7.1' +release = "0.7.1" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. -#language = None +# language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: -#today = '' +# today = '' # Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. @@ -71,64 +71,64 @@ # The reST default role (used for this markup: `text`) to use for all # documents. -#default_role = None +# default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +# add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). -#add_module_names = True +# add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. -#keep_warnings = False +# keep_warnings = False # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -if os.environ.get('READTHEDOCS', None) != 'True': +if os.environ.get("READTHEDOCS", None) != "True": try: import sphinx_rtd_theme - html_theme = 'sphinx_rtd_theme' - html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] + + html_theme = "sphinx_rtd_theme" except ImportError: pass # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -#html_theme_options = {} +# html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] +# html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". html_title = "argparse " + version + " tutorial" # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +# html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. -#html_logo = None +# html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -#html_favicon = None +# html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, @@ -138,93 +138,90 @@ # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. -#html_extra_path = [] +# html_extra_path = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +# html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +# html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_domain_indices = True +# html_domain_indices = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +# html_split_index = False # If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True +# html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True +# html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True +# html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. -#html_use_opensearch = '' +# html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +# html_file_suffix = None # Output file base name for HTML help builder. -htmlhelp_basename = 'argparsetutorial' +htmlhelp_basename = "argparsetutorial" # -- Options for LaTeX output --------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', + # The paper size ('letterpaper' or 'a4paper'). + #'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + #'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + #'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - ('index', 'argparse.tex', u'argparse tutorial', - u'Peter Melnichenko', 'manual') + ("index", "argparse.tex", "argparse tutorial", "Peter Melnichenko", "manual") ] # The name of an image file (relative to this directory) to place at the top of # the title page. -#latex_logo = None +# latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. -#latex_use_parts = False +# latex_use_parts = False # If true, show page references after internal links. -#latex_show_pagerefs = False +# latex_show_pagerefs = False # If true, show URL addresses after external links. -#latex_show_urls = False +# latex_show_urls = False # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_domain_indices = True +# latex_domain_indices = True # -- Options for manual page output --------------------------------------- @@ -232,13 +229,17 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - ('index', 'argparse', u'argparse tutorial', - [u'Peter Melnichenko', u'Paul Ouellette'], - 1) + ( + "index", + "argparse", + "argparse tutorial", + ["Peter Melnichenko", "Paul Ouellette", "Oleksii Stroganov"], + 1, + ) ] # If true, show URL addresses after external links. -#man_show_urls = False +# man_show_urls = False # -- Options for Texinfo output ------------------------------------------- @@ -247,19 +248,25 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'argparse', u'argparse tutorial', - u'Peter Melnichenko, Paul Ouellette', 'argparse', 'Command line parser for Lua.', - 'Miscellaneous') + ( + "index", + "argparse", + "argparse tutorial", + "Peter Melnichenko, Paul Ouellette, Oleksii Stroganov", + "argparse", + "Command line parser for Lua.", + "Miscellaneous", + ) ] # Documents to append as an appendix to all manuals. -#texinfo_appendices = [] +# texinfo_appendices = [] # If false, no module index is generated. -#texinfo_domain_indices = True +# texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' +# texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. -#texinfo_no_detailmenu = False +# texinfo_no_detailmenu = False diff --git a/docsrc/index.rst b/docsrc/index.rst index 1d4b5bc..03c97e7 100644 --- a/docsrc/index.rst +++ b/docsrc/index.rst @@ -8,7 +8,7 @@ Contents: parsers arguments options - mutexes + mutuals commands defaults callbacks diff --git a/docsrc/mutexes.rst b/docsrc/mutexes.rst deleted file mode 100644 index b4b9363..0000000 --- a/docsrc/mutexes.rst +++ /dev/null @@ -1,40 +0,0 @@ -Mutually exclusive groups -========================= - -A group of arguments and options can be marked as mutually exclusive using ``:mutex(argument_or_option, ...)`` method of the Parser class. - -.. code-block:: lua - :linenos: - - parser:mutex( - parser:argument "input" - :args "?", - parser:flag "--process-stdin" - ) - - parser:mutex( - parser:flag "-q --quiet", - parser:flag "-v --verbose" - ) - -If more than one element of a mutually exclusive group is used, an error is raised. - -.. code-block:: none - - $ lua script.lua -qv - -.. code-block:: none - - Usage: script.lua ([-q] | [-v]) [-h] ([] | [--process-stdin]) - - Error: option '-v' can not be used together with option '-q' - -.. code-block:: none - - $ lua script.lua file --process-stdin - -.. code-block:: none - - Usage: script.lua ([-q] | [-v]) [-h] ([] | [--process-stdin]) - - Error: option '--process-stdin' can not be used together with argument 'input' diff --git a/docsrc/mutuals.rst b/docsrc/mutuals.rst new file mode 100644 index 0000000..bcc355a --- /dev/null +++ b/docsrc/mutuals.rst @@ -0,0 +1,85 @@ +Mutual groups +============= + +The argparse library supports two types of mutual groups: mutually exclusive groups (mutex) and mutually inclusive groups (mutin). + +Mutually exclusive groups (mutex) +--------------------------------- + +A group of arguments and options can be marked as mutually exclusive using ``:mutex(argument_or_option, ...)`` method of the Parser class. If more than one element of a mutually exclusive group is used, an error is raised. + +.. code-block:: lua + :linenos: + + parser:mutex( + parser:argument "input" + :args "?", + parser:flag "--process-stdin" + ) + parser:mutex( + parser:flag "-q --quiet", + parser:flag "-v --verbose" + ) + +.. code-block:: none + + $ lua script.lua -qv + +.. code-block:: none + + Usage: script.lua ([-q] | [-v]) [-h] ([] | [--process-stdin]) + + Error: option '-v' can not be used together with option '-q' + +.. code-block:: none + + $ lua script.lua file --process-stdin + +.. code-block:: none + + Usage: script.lua ([-q] | [-v]) [-h] ([] | [--process-stdin]) + + Error: option '--process-stdin' can not be used together with argument 'input' + +Mutually inclusive groups (mutin) +--------------------------------- + +A group of arguments and options can be marked as mutually inclusive using ``:mutin(argument_or_option, ...)`` method of the Parser class. If any element of a mutually inclusive group is used, all other elements must be used as well. + +.. code-block:: lua + :linenos: + + parser:mutin( + parser:option "--username", + parser:option "--password" + ) + parser:mutin( + parser:option "--host", + parser:option "--port" + :convert(tonumber) + ) + +.. code-block:: none + + $ lua script.lua --username john + +.. code-block:: none + + Usage: script.lua [-h] [--username USERNAME] [--password PASSWORD] [--host HOST] [--port PORT] + + Error: option '--username' requires option '--password' + +.. code-block:: none + + $ lua script.lua --host localhost --port 8080 --username john --password secret + +.. code-block:: lua + + { + host = "localhost", + port = 8080, + username = "john", + password = "secret" + } + +Unlike mutex groups, mutin groups are not shown in the usage message as they don't restrict which combinations can be used, only that certain options must appear together. diff --git a/docsrc/options.rst b/docsrc/options.rst index d8f7a6e..572b876 100644 --- a/docsrc/options.rst +++ b/docsrc/options.rst @@ -55,6 +55,41 @@ Flags are almost identical to options, except that they don't take an argument b quiet = true } +Negatable flags +--------------- + +Flags can be configured to accept a ``--no`` prefix for negation by setting the ``no_prefix`` property: + +.. code-block:: lua + :linenos: + + parser:flag("--verbose") + :no_prefix(true) + +This allows the flag to be used in both positive and negative forms: + +.. code-block:: none + + $ lua script.lua --verbose + +.. code-block:: lua + + { + verbose = true + } + +.. code-block:: none + + $ lua script.lua --noverbose + +.. code-block:: lua + + { + verbose = false + } + +When ``no_prefix`` is enabled, the help message will display the option as ``--[no]verbose`` to indicate both forms are accepted. + Control characters ------------------ diff --git a/spec/mutex_spec.lua b/spec/mutex_spec.lua deleted file mode 100644 index cf5b5fa..0000000 --- a/spec/mutex_spec.lua +++ /dev/null @@ -1,122 +0,0 @@ -local Parser = require "argparse" -getmetatable(Parser()).error = function(_, msg) error(msg) end - -describe("tests related to mutexes", function() - it("handles mutex correctly", function() - local parser = Parser() - parser:mutex( - parser:flag "-q" "--quiet" - :description "Supress logging. ", - parser:flag "-v" "--verbose" - :description "Print additional debug information. " - ) - - local args = parser:parse{"-q"} - assert.same({quiet = true}, args) - args = parser:parse{"-v"} - assert.same({verbose = true}, args) - args = parser:parse{} - assert.same({}, args) - end) - - it("handles mutex with an argument", function() - local parser = Parser() - parser:mutex( - parser:flag "-q" "--quiet" - :description "Supress output.", - parser:argument "log" - :args "?" - :description "Log file" - ) - - local args = parser:parse{"-q"} - assert.same({quiet = true}, args) - args = parser:parse{"log.txt"} - assert.same({log = "log.txt"}, args) - args = parser:parse{} - assert.same({}, args) - end) - - it("handles mutex with default value", function() - local parser = Parser() - parser:mutex( - parser:flag "-q" "--quiet", - parser:option "-o" "--output" - :default "a.out" - ) - - local args = parser:parse{"-q"} - assert.same({quiet = true, output = "a.out"}, args) - end) - - it("raises an error if mutex is broken", function() - local parser = Parser() - parser:mutex( - parser:flag "-q" "--quiet" - :description "Supress logging. ", - parser:flag "-v" "--verbose" - :description "Print additional debug information. " - ) - - assert.has_error(function() - parser:parse{"-qv"} - end, "option '-v' can not be used together with option '-q'") - assert.has_error(function() - parser:parse{"-v", "--quiet"} - end, "option '--quiet' can not be used together with option '-v'") - end) - - it("raises an error if mutex with an argument is broken", function() - local parser = Parser() - parser:mutex( - parser:flag "-q" "--quiet" - :description "Supress output.", - parser:argument "log" - :args "?" - :description "Log file" - ) - - assert.has_error(function() - parser:parse{"-q", "log.txt"} - end, "argument 'log' can not be used together with option '-q'") - assert.has_error(function() - parser:parse{"log.txt", "--quiet"} - end, "option '--quiet' can not be used together with argument 'log'") - end) - - it("handles multiple mutexes", function() - local parser = Parser() - parser:mutex( - parser:flag "-q" "--quiet", - parser:flag "-v" "--verbose" - ) - parser:mutex( - parser:flag "-l" "--local", - parser:option "-f" "--from" - ) - - local args = parser:parse{"-qq", "-fTHERE"} - assert.same({quiet = true, from = "THERE"}, args) - args = parser:parse{"-vl"} - assert.same({verbose = true, ["local"] = true}, args) - end) - - it("handles mutexes in commands", function() - local parser = Parser() - parser:mutex( - parser:flag "-q" "--quiet", - parser:flag "-v" "--verbose" - ) - local install = parser:command "install" - install:mutex( - install:flag "-l" "--local", - install:option "-f" "--from" - ) - - local args = parser:parse{"install", "-l"} - assert.same({install = true, ["local"] = true}, args) - assert.has_error(function() - parser:parse{"install", "-qlv"} - end, "option '-v' can not be used together with option '-q'") - end) -end) diff --git a/spec/mutuals_spec.lua b/spec/mutuals_spec.lua new file mode 100644 index 0000000..9488e9f --- /dev/null +++ b/spec/mutuals_spec.lua @@ -0,0 +1,227 @@ +local Parser = require "argparse" +getmetatable(Parser()).error = function(_, msg) error(msg) end + +describe("tests related to mutuals", function() + describe("mutex tests", function() + it("handles mutex correctly", function() + local parser = Parser() + parser:mutex( + parser:flag "-q" "--quiet" + :description "Supress logging. ", + parser:flag "-v" "--verbose" + :description "Print additional debug information. " + ) + + local args = parser:parse { "-q" } + assert.same({ quiet = true }, args) + + args = parser:parse { "-v" } + assert.same({ verbose = true }, args) + + args = parser:parse {} + assert.same({}, args) + end) + + it("handles mutex with an argument", function() + local parser = Parser() + parser:mutex( + parser:flag "-q" "--quiet" + :description "Supress output.", + parser:argument "log" + :args "?" + :description "Log file" + ) + + local args = parser:parse { "-q" } + assert.same({ quiet = true }, args) + + args = parser:parse { "log.txt" } + assert.same({ log = "log.txt" }, args) + + args = parser:parse {} + assert.same({}, args) + end) + + it("handles mutex with default value", function() + local parser = Parser() + parser:mutex( + parser:flag "-q" "--quiet", + parser:option "-o" "--output" + :default "a.out" + ) + + local args = parser:parse { "-q" } + assert.same({ quiet = true, output = "a.out" }, args) + end) + + it("raises an error if mutex is broken", function() + local parser = Parser() + parser:mutex( + parser:flag "-q" "--quiet" + :description "Supress logging. ", + parser:flag "-v" "--verbose" + :description "Print additional debug information. " + ) + + assert.has_error(function() + parser:parse { "-qv" } + end, "option '-v' can not be used together with option '-q'") + + assert.has_error(function() + parser:parse { "-v", "--quiet" } + end, "option '--quiet' can not be used together with option '-v'") + end) + + it("raises an error if mutex with an argument is broken", function() + local parser = Parser() + parser:mutex( + parser:flag "-q" "--quiet" + :description "Supress output.", + parser:argument "log" + :args "?" + :description "Log file" + ) + + assert.has_error(function() + parser:parse { "-q", "log.txt" } + end, "argument 'log' can not be used together with option '-q'") + + assert.has_error(function() + parser:parse { "log.txt", "--quiet" } + end, "option '--quiet' can not be used together with argument 'log'") + end) + + it("handles multiple mutexes", function() + local parser = Parser() + parser:mutex( + parser:flag "-q" "--quiet", + parser:flag "-v" "--verbose" + ) + parser:mutex( + parser:flag "-l" "--local", + parser:option "-f" "--from" + ) + + local args = parser:parse { "-q", "-q", "-fTHERE" } + assert.same({ quiet = true, from = "THERE" }, args) + + args = parser:parse { "-vl" } + assert.same({ verbose = true, ["local"] = true }, args) + end) + + it("handles mutexes in commands", function() + local parser = Parser() + parser:mutex( + parser:flag "-q" "--quiet", + parser:flag "-v" "--verbose" + ) + + local install = parser:command "install" + install:mutex( + install:flag "-l" "--local", + install:option "-f" "--from" + ) + + local args = parser:parse { "install", "-l" } + assert.same({ install = true, ["local"] = true }, args) + + assert.has_error(function() + parser:parse { "install", "-qlv" } + end, "option '-v' can not be used together with option '-q'") + end) + end) + + describe("mutin tests", function() + it("handles mutin correctly", function() + local parser = Parser() + parser:mutin( + parser:option "--username", + parser:option "--password" + ) + + local args = parser:parse { "--username", "john", "--password", "secret" } + assert.same({ username = "john", password = "secret" }, args) + + args = parser:parse {} + assert.same({}, args) + end) + + it("raises an error if mutin is incomplete", function() + local parser = Parser() + parser:mutin( + parser:option "--username", + parser:option "--password" + ) + + assert.has_error(function() + parser:parse { "--username", "john" } + end, "option '--username' requires option '--password'") + + assert.has_error(function() + parser:parse { "--password", "secret" } + end, "option '--password' requires option '--username'") + end) + + it("handles mutin with flags", function() + local parser = Parser() + parser:mutin( + parser:flag "--enable-ssl", + parser:option "--cert-path" + ) + + local args = parser:parse { "--enable-ssl", "--cert-path", "/path/to/cert" } + assert.same({ enable_ssl = true, cert_path = "/path/to/cert" }, args) + + assert.has_error(function() + parser:parse { "--enable-ssl" } + end, "option '--enable-ssl' requires option '--cert-path'") + end) + + it("handles mutin with arguments", function() + local parser = Parser() + parser:mutin( + parser:argument "source", + parser:argument "destination" + ) + + local args = parser:parse { "src.txt", "dst.txt" } + assert.same({ source = "src.txt", destination = "dst.txt" }, args) + + assert.has_error(function() + parser:parse { "src.txt" } + end, "missing argument 'destination'") + end) + + it("handles multiple mutins", function() + local parser = Parser() + parser:mutin( + parser:option "--host", + parser:option "--port" + ) + parser:mutin( + parser:option "--username", + parser:option "--password" + ) + + local args = parser:parse { "--host", "localhost", "--port", "8080" } + assert.same({ host = "localhost", port = "8080" }, args) + + assert.has_error(function() + parser:parse { "--host", "localhost", "--username", "john" } + end, "option '--host' requires option '--port'") + end) + + it("shows correct error for multiple elements in mutin", function() + local parser = Parser() + parser:mutin( + parser:option "-a", + parser:option "-b", + parser:option "-c" + ) + + assert.has_error(function() + parser:parse { "-a", "1", "-b", "2" } + end, "option '-a' and option '-b' require option '-c'") + end) + end) +end) diff --git a/spec/negation_spec.lua b/spec/negation_spec.lua new file mode 100644 index 0000000..249f69e --- /dev/null +++ b/spec/negation_spec.lua @@ -0,0 +1,131 @@ +local Parser = require "argparse" +getmetatable(Parser()).error = function(_, msg) error(msg) end + +describe("tests related to no-prefix negation", function() + it("handles no-prefix flag correctly", function() + local parser = Parser() + parser:flag "--verbose" + :no_prefix(true) + + local args = parser:parse { "--verbose" } + assert.same({ verbose = true }, args) + + args = parser:parse { "--noverbose" } + assert.same({ verbose = false }, args) + + args = parser:parse {} + assert.same({}, args) + end) + + it("preserves short options with no-prefix", function() + local parser = Parser() + parser:flag "-v --verbose" + :no_prefix(true) + + local args = parser:parse { "-v" } + assert.same({ verbose = true }, args) + + args = parser:parse { "--verbose" } + assert.same({ verbose = true }, args) + + args = parser:parse { "--noverbose" } + assert.same({ verbose = false }, args) + end) + + it("does not apply no-prefix to flags without it enabled", function() + local parser = Parser() + parser:flag "--verbose" + parser:flag "--debug" + :no_prefix(true) + + assert.has_error(function() + parser:parse { "--noverbose" } + end, "unknown option '--noverbose'") + + local args = parser:parse { "--nodebug" } + assert.same({ debug = false }, args) + end) + + it("handles multiple no-prefix flags", function() + local parser = Parser() + parser:flag "--verbose" + :no_prefix(true) + parser:flag "--color" + :no_prefix(true) + + local args = parser:parse { "--verbose", "--nocolor" } + assert.same({ verbose = true, color = false }, args) + + args = parser:parse { "--noverbose", "--color" } + assert.same({ verbose = false, color = true }, args) + + args = parser:parse { "--noverbose", "--nocolor" } + assert.same({ verbose = false, color = false }, args) + end) + + it("ignores no-prefix for options with arguments", function() + local parser = Parser() + parser:option "--output" + :no_prefix(true) + + assert.has_error(function() + parser:parse { "--nooutput" } + end, "unknown option '--nooutput'") + + local args = parser:parse { "--output", "file.txt" } + assert.same({ output = "file.txt" }, args) + end) + + it("handles no-prefix with count action", function() + local parser = Parser() + parser:flag "-v --verbose" + :no_prefix(true) + :count "*" + + local args = parser:parse { "-vv" } + assert.same({ verbose = 2 }, args) + + -- Count action doesn't switch to 0 with negation, it increments by 1 + args = parser:parse { "--noverbose" } + assert.same({ verbose = 1 }, args) + end) + + it("handles no-prefix in commands", function() + local parser = Parser() + local cmd = parser:command "run" + cmd:flag "--verbose" + :no_prefix(true) + + local args = parser:parse { "run", "--verbose" } + assert.same({ run = true, verbose = true }, args) + + args = parser:parse { "run", "--noverbose" } + assert.same({ run = true, verbose = false }, args) + end) + + it("does not parse no-prefix with equals syntax", function() + local parser = Parser() + parser:flag "--verbose" + :no_prefix(true) + + -- Parser doesn't recognize --noverbose=false at all + assert.has_error(function() + parser:parse { "--noverbose=false" } + end, "unknown option '--noverbose'") + end) + + it("handles no-prefix with overwrite", function() + local parser = Parser() + parser:flag "--verbose" + :no_prefix(true) + :count "*" + :overwrite(true) + + -- With count action, each invocation increments + local args = parser:parse { "--verbose", "--noverbose" } + assert.same({ verbose = 2 }, args) + + args = parser:parse { "--noverbose", "--verbose" } + assert.same({ verbose = 2 }, args) + end) +end) diff --git a/src/argparse.lua b/src/argparse.lua index 53e6ff5..e2a7b24 100644 --- a/src/argparse.lua +++ b/src/argparse.lua @@ -25,6 +25,10 @@ --- Helper functions --- ------------------------ +-- TODO: add healthcheck function to check for normal arguments and command +-- TODO: add fmt to tell user what they should charge +-- TODO: create protocol for argument parsing + local function deep_update(t1, t2) for k, v in pairs(t2) do if type(v) == "table" then @@ -417,7 +421,7 @@ local Parser = class({ _arguments = {}, _options = {}, _commands = {}, - _mutexes = {}, + _mutuals = {}, _groups = {}, _require_command = true, _handle_options = true, @@ -497,6 +501,7 @@ local Option = class({ _public_aliases = {}, _mincount = 0, _overwrite = true, + _no_prefix = false, }, { args = 6, multiname, @@ -513,6 +518,7 @@ local Option = class({ typechecked("argname", "string", "table"), typechecked("choices", "table"), typechecked("hidden", "boolean"), + typechecked("no_prefix", "boolean"), option_action, option_init, }, Argument) @@ -524,6 +530,8 @@ local Option = class({ --- Option helper functions --- ------------------------------- + + function Option:_get_default_argname() if self._choices then return self:_get_choices_list() @@ -532,27 +540,50 @@ function Option:_get_default_argname() end end +function Option:_get_display_aliases() + if not self._no_prefix or self._maxargs ~= 0 then + return self._public_aliases + end + + local display_aliases = {} + + for _, a in ipairs(self._public_aliases) do + if a:sub(1, 2) == "--" then + local name = a:gsub("^%-%-", "") + table.insert(display_aliases, "--[no]" .. name) + else + -- Keep short options unchanged + table.insert(display_aliases, a) + end + end + + return #display_aliases > 0 and display_aliases or self._public_aliases +end + function Option:_get_label_lines() local argument_list = self:_get_argument_list() if #argument_list == 0 then -- Don't put aliases for simple flags like `-h` on different lines. - return { table.concat(self._public_aliases, ", ") } + local display_aliases = self:_get_display_aliases() + return { table.concat(display_aliases, ", ") } end + local display_aliases = self:_get_display_aliases() + local longest_alias_length = -1 - for _, alias in ipairs(self._public_aliases) do + for _, alias in ipairs(display_aliases) do longest_alias_length = math.max(longest_alias_length, #alias) end local argument_list_repr = table.concat(argument_list, " ") local lines = {} - for i, alias in ipairs(self._public_aliases) do + for i, alias in ipairs(display_aliases) do local line = (" "):rep(longest_alias_length - #alias) .. alias .. " " .. argument_list_repr - if i ~= #self._public_aliases then + if i ~= #display_aliases then line = line .. "," end @@ -815,19 +846,27 @@ function Parser:command(...) return command end -function Parser:mutex(...) +function Parser:mutual(exclusive, ...) + local mutual_type = exclusive and "mutex" or "mutin" local elements = { ... } for i, element in ipairs(elements) do local mt = getmetatable(element) - assert(mt == Option or mt == Argument, ("bad argument #%d to 'mutex' (Option or Argument expected)"):format(i)) + assert(mt == Option or mt == Argument, + ("bad argument #%d to '%s' (Option or Argument expected)"):format(i, mutual_type)) end - table.insert(self._mutexes, elements) + table.insert(self._mutuals, { exclusive = exclusive, elements = elements }) return self end --- TODO: add mutins +function Parser:mutex(...) + return self:mutual(true, ...) +end + +function Parser:mutin(...) + return self:mutual(false, ...) +end function Parser:group(name, ...) assert(type(name) == "string", ("bad argument #1 to 'group' (string expected, got %s)"):format(type(name))) @@ -876,20 +915,20 @@ function Parser:get_usage() -- 4. Remaining mutexes. -- 5. Remaining options. - local elements_in_mutexes = {} + local elements_in_mutuals = {} local added_elements = {} - local added_mutexes = {} - local argument_to_mutexes = {} + local added_mutuals = {} + local argument_to_mutuals = {} - local function add_mutex(mutex, main_argument) - if added_mutexes[mutex] then + local function add_mutual(mutual, main_argument) + if added_mutuals[mutual] then return end - added_mutexes[mutex] = true + added_mutuals[mutual] = true local buf = {} - for _, element in ipairs(mutex) do + for _, element in ipairs(mutual.elements) do if not element._hidden and not added_elements[element] then if getmetatable(element) == Option or element == main_argument then table.insert(buf, element:_get_usage()) @@ -912,31 +951,33 @@ function Parser:get_usage() end end - for _, mutex in ipairs(self._mutexes) do - local is_vararg = false - local has_argument = false + for _, mutual in ipairs(self._mutuals) do + if mutual.exclusive then -- Only process mutex groups for usage + local is_vararg = false + local has_argument = false - for _, element in ipairs(mutex) do - if getmetatable(element) == Option then - if element:_is_vararg() then - is_vararg = true + for _, element in ipairs(mutual.elements) do + if getmetatable(element) == Option then + if element:_is_vararg() then + is_vararg = true + end + else + has_argument = true + argument_to_mutuals[element] = argument_to_mutuals[element] or {} + table.insert(argument_to_mutuals[element], mutual) end - else - has_argument = true - argument_to_mutexes[element] = argument_to_mutexes[element] or {} - table.insert(argument_to_mutexes[element], mutex) - end - elements_in_mutexes[element] = true - end + elements_in_mutuals[element] = true + end - if not is_vararg and not has_argument then - add_mutex(mutex) + if not is_vararg and not has_argument then + add_mutual(mutual) + end end end for _, option in ipairs(self._options) do - if not elements_in_mutexes[option] and not option:_is_vararg() then + if not elements_in_mutuals[option] and not option:_is_vararg() then add_element(option) end end @@ -944,25 +985,27 @@ function Parser:get_usage() -- Add usages for positional arguments, together with one mutex containing them, if they are in a mutex. for _, argument in ipairs(self._arguments) do -- Pick a mutex as a part of which to show this argument, take the first one that's still available. - local mutex + local mutual - if elements_in_mutexes[argument] then - for _, argument_mutex in ipairs(argument_to_mutexes[argument]) do - if not added_mutexes[argument_mutex] then - mutex = argument_mutex + if elements_in_mutuals[argument] and argument_to_mutuals[argument] then + for _, argument_mutual in ipairs(argument_to_mutuals[argument]) do + if not added_mutuals[argument_mutual] then + mutual = argument_mutual end end end - if mutex then - add_mutex(mutex, argument) + if mutual then + add_mutual(mutual, argument) else add_element(argument) end end - for _, mutex in ipairs(self._mutexes) do - add_mutex(mutex) + for _, mutual in ipairs(self._mutuals) do + if mutual.exclusive then -- Only show mutex groups + add_mutual(mutual) + end end for _, option in ipairs(self._options) do @@ -1174,26 +1217,6 @@ function Parser:add_help_command(value) return self end -function Parser:_get_options() - local options = {} - for _, option in ipairs(self._options) do - for _, alias in ipairs(option._aliases) do - table.insert(options, alias) - end - end - return table.concat(options, " ") -end - -function Parser:_get_commands() - local commands = {} - for _, command in ipairs(self._commands) do - for _, alias in ipairs(command._aliases) do - table.insert(commands, alias) - end - end - return table.concat(commands, " ") -end - local function get_tip(context, wrong_name) local context_pool = {} local possible_name @@ -1309,7 +1332,17 @@ function ElementState:set_name(alias) self.name = ("%s '%s'"):format(alias and "option" or "argument", alias or self.element._name) end -function ElementState:invoke() +function ElementState:switch() + if self.element._maxargs == 0 then -- Only for flags + if self.action == actions.store_true then + self.action = actions.store_false + elseif self.action == actions.store_false then + self.action = actions.store_true + end + end +end + +function ElementState:invoke(is_negated) self.open = true self.overwrite = false @@ -1326,6 +1359,10 @@ function ElementState:invoke() self.args = {} + if is_negated then + self:switch() + end + if self.element._maxargs <= 0 then self:close() end @@ -1411,8 +1448,8 @@ local ParseState = class({ options = {}, arguments = {}, argument_i = 1, - element_to_mutexes = {}, - mutex_to_element_state = {}, + element_to_mutuals = {}, + mutual_to_element_state = {}, command_actions = {}, }) @@ -1444,13 +1481,13 @@ function ParseState:switch(parser) end end - for _, mutex in ipairs(parser._mutexes) do - for _, element in ipairs(mutex) do - if not self.element_to_mutexes[element] then - self.element_to_mutexes[element] = {} + for _, mutual in ipairs(parser._mutuals) do + for _, element in ipairs(mutual.elements) do + if not self.element_to_mutuals[element] then + self.element_to_mutuals[element] = {} end - table.insert(self.element_to_mutexes[element], mutex) + table.insert(self.element_to_mutuals[element], mutual) end end @@ -1496,26 +1533,35 @@ function ParseState:get_command(name) end end -function ParseState:check_mutexes(element_state) - if self.element_to_mutexes[element_state.element] then - for _, mutex in ipairs(self.element_to_mutexes[element_state.element]) do - local used_element_state = self.mutex_to_element_state[mutex] +function ParseState:check_mutuals(element_state) + if self.element_to_mutuals[element_state.element] then + for _, mutual in ipairs(self.element_to_mutuals[element_state.element]) do + if mutual.exclusive then + -- Mutex: check immediately + local used_element_state = self.mutual_to_element_state[mutual] - if used_element_state and used_element_state ~= element_state then - self:error("%s can not be used together with %s", element_state.name, used_element_state.name) + if used_element_state and used_element_state ~= element_state then + self:error("%s can not be used together with %s", element_state.name, used_element_state.name) + else + self.mutual_to_element_state[mutual] = element_state + end else - self.mutex_to_element_state[mutex] = element_state + -- Mutin: just track usage + if not self.mutual_to_element_state[mutual] then + self.mutual_to_element_state[mutual] = {} + end + self.mutual_to_element_state[mutual][element_state.element] = element_state end end end end -function ParseState:invoke(option, name) +function ParseState:invoke(option, name, is_negated) self:close() option:set_name(name) - self:check_mutexes(option, name) + self:check_mutuals(option) - if option:invoke() then + if option:invoke(is_negated) then self.option = option end end @@ -1526,7 +1572,7 @@ function ParseState:pass(arg) self.option = nil end elseif self.argument then - self:check_mutexes(self.argument) + self:check_mutuals(self.argument) if not self.argument:pass(arg) then self.argument_i = self.argument_i + 1 @@ -1563,6 +1609,37 @@ function ParseState:finalize() end end + -- Check mutin constraints + for _, mutual in ipairs(self.parser._mutuals) do + if not mutual.exclusive then + local used_elements = self.mutual_to_element_state[mutual] or {} + local used_names = {} + local missing_names = {} + + for _, element in ipairs(mutual.elements) do + if used_elements[element] then + table.insert(used_names, used_elements[element].name) + else + local name + if getmetatable(element) == Option then + name = ("option '%s'"):format(element._name) + else + name = ("argument '%s'"):format(element._name) + end + table.insert(missing_names, name) + end + end + + if #used_names > 0 and #used_names < #mutual.elements then + if #used_names == 1 then + self:error("%s requires %s", used_names[1], table.concat(missing_names, " and ")) + else + self:error("%s require %s", table.concat(used_names, " and "), table.concat(missing_names, " and ")) + end + end + end + end + if self.parser._require_command and #self.commands > 0 then self:error("a command is required") end @@ -1633,8 +1710,22 @@ function ParseState:parse(args) self:invoke(option, name) self:pass(arg:sub(equals + 1)) else - local option = self:get_option(arg) - self:invoke(option, arg) + -- Check for --no prefix + local is_negated = false + local option_name = arg + + if arg:sub(1, 4) == "--no" and #arg > 4 then + local base_name = "--" .. arg:sub(5) -- "--noverbose" -> "--verbose" + local option = self.options[base_name] + + if option and option.element._no_prefix and option.element._maxargs == 0 then + is_negated = true + option_name = base_name + end + end + + local option = self:get_option(option_name) + self:invoke(option, option_name, is_negated) end end else @@ -1701,9 +1792,491 @@ function Parser:pparse(args) end end +------------------- +--- Completions --- +------------------- + +function Parser:_is_shell_safe() + if self._basename then + if self._basename:find("[^%w_%-%+%.]") then + return false + end + else + for _, alias in ipairs(self._aliases) do + if alias:find("[^%w_%-%+%.]") then + return false + end + end + end + for _, option in ipairs(self._options) do + for _, alias in ipairs(option._aliases) do + if alias:find("[^%w_%-%+%.]") then + return false + end + end + if option._choices then + for _, choice in ipairs(option._choices) do + if choice:find("[%s'\"]") then + return false + end + end + end + end + for _, argument in ipairs(self._arguments) do + if argument._choices then + for _, choice in ipairs(argument._choices) do + if choice:find("[%s'\"]") then + return false + end + end + end + end + for _, command in ipairs(self._commands) do + if not command:_is_shell_safe() then + return false + end + end + return true +end + +function Parser:add_complete(value) + if value then + assert(type(value) == "string" or type(value) == "table", + ("bad argument #1 to 'add_complete' (string or table expected, got %s)"):format(type(value))) + end + + local complete = self:option() + :description "Output a shell completion script for the specified shell." + :args(1) + :choices { "bash", "zsh", "fish" } + :action(function(_, _, shell) + io.write(self["get_" .. shell .. "_complete"](self)) + os.exit(0) + end) + + if value then + complete = complete(value) + end + + if not complete._name then + complete "--completion" + end + + return self +end + +function Parser:add_complete_command(value) + if value then + assert(type(value) == "string" or type(value) == "table", + ("bad argument #1 to 'add_complete_command' (string or table expected, got %s)"):format(type(value))) + end + + local complete = self:command() + :description "Output a shell completion script." + complete:argument "shell" + :description "The shell to output a completion script for." + :choices { "bash", "zsh", "fish" } + :action(function(_, _, shell) + io.write(self["get_" .. shell .. "_complete"](self)) + os.exit(0) + end) + + if value then + complete = complete(value) + end + + if not complete._name then + complete "completion" + end + + return self +end + +local function base_name(pathname) + return pathname:gsub("[/\\]*$", ""):match(".*[/\\]([^/\\]*)") or pathname +end + +local function get_short_description(element) + local short = element:_get_description():match("^(.-)%.%s") + return short or element:_get_description():match("^(.-)%.?$") +end + +function Parser:_get_options() + local options = {} + for _, option in ipairs(self._options) do + for _, alias in ipairs(option._aliases) do + table.insert(options, alias) + end + end + return table.concat(options, " ") +end + +function Parser:_get_commands() + local commands = {} + for _, command in ipairs(self._commands) do + for _, alias in ipairs(command._aliases) do + table.insert(commands, alias) + end + end + return table.concat(commands, " ") +end + +function Parser:_bash_option_args(buf, indent) + local opts = {} + for _, option in ipairs(self._options) do + if option._choices or option._minargs > 0 then + local compreply + if option._choices then + compreply = 'COMPREPLY=($(compgen -W "' .. table.concat(option._choices, " ") .. '" -- "$cur"))' + else + compreply = 'COMPREPLY=($(compgen -f -- "$cur"))' + end + table.insert(opts, (" "):rep(indent + 4) .. table.concat(option._aliases, "|") .. ")") + table.insert(opts, (" "):rep(indent + 8) .. compreply) + table.insert(opts, (" "):rep(indent + 8) .. "return 0") + table.insert(opts, (" "):rep(indent + 8) .. ";;") + end + end + + if #opts > 0 then + table.insert(buf, (" "):rep(indent) .. 'case "$prev" in') + table.insert(buf, table.concat(opts, "\n")) + table.insert(buf, (" "):rep(indent) .. "esac\n") + end +end + +function Parser:_bash_get_cmd(buf, indent) + if #self._commands == 0 then + return + end + + table.insert(buf, (" "):rep(indent) .. 'args=("${args[@]:1}")') + table.insert(buf, (" "):rep(indent) .. 'for arg in "${args[@]}"; do') + table.insert(buf, (" "):rep(indent + 4) .. 'case "$arg" in') + + for _, command in ipairs(self._commands) do + table.insert(buf, (" "):rep(indent + 8) .. table.concat(command._aliases, "|") .. ")") + if self._parent then + table.insert(buf, (" "):rep(indent + 12) .. 'cmd="$cmd ' .. command._name .. '"') + else + table.insert(buf, (" "):rep(indent + 12) .. 'cmd="' .. command._name .. '"') + end + table.insert(buf, (" "):rep(indent + 12) .. 'opts="$opts ' .. command:_get_options() .. '"') + command:_bash_get_cmd(buf, indent + 12) + table.insert(buf, (" "):rep(indent + 12) .. "break") + table.insert(buf, (" "):rep(indent + 12) .. ";;") + end + + table.insert(buf, (" "):rep(indent + 4) .. "esac") + table.insert(buf, (" "):rep(indent) .. "done") +end + +function Parser:_bash_cmd_completions(buf) + local cmd_buf = {} + if self._parent then + self:_bash_option_args(cmd_buf, 12) + end + if #self._commands > 0 then + table.insert(cmd_buf, (" "):rep(12) .. 'COMPREPLY=($(compgen -W "' .. self:_get_commands() .. '" -- "$cur"))') + elseif self._is_help_command then + table.insert(cmd_buf, (" "):rep(12) + .. 'COMPREPLY=($(compgen -W "' + .. self._parent:_get_commands() + .. '" -- "$cur"))') + end + if #cmd_buf > 0 then + table.insert(buf, (" "):rep(8) .. "'" .. self:_get_fullname(true) .. "')") + table.insert(buf, table.concat(cmd_buf, "\n")) + table.insert(buf, (" "):rep(12) .. ";;") + end + + for _, command in ipairs(self._commands) do + command:_bash_cmd_completions(buf) + end +end + +function Parser:get_bash_complete() + self._basename = base_name(self._name) + assert(self:_is_shell_safe()) + local buf = { ([[ +_%s() { + local IFS=$' \t\n' + local args cur prev cmd opts arg + args=("${COMP_WORDS[@]}") + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + opts="%s" +]]):format(self._basename, self:_get_options()) } + + self:_bash_option_args(buf, 4) + self:_bash_get_cmd(buf, 4) + if #self._commands > 0 then + table.insert(buf, "") + table.insert(buf, (" "):rep(4) .. 'case "$cmd" in') + self:_bash_cmd_completions(buf) + table.insert(buf, (" "):rep(4) .. "esac\n") + end + + table.insert(buf, ([=[ + if [[ "$cur" = -* ]]; then + COMPREPLY=($(compgen -W "$opts" -- "$cur")) + fi +} + +complete -F _%s -o bashdefault -o default %s +]=]):format(self._basename, self._basename)) + + return table.concat(buf, "\n") +end + +function Parser:_zsh_arguments(buf, cmd_name, indent) + if self._parent then + table.insert(buf, (" "):rep(indent) .. "options=(") + table.insert(buf, (" "):rep(indent + 2) .. "$options") + else + table.insert(buf, (" "):rep(indent) .. "local -a options=(") + end + + for _, option in ipairs(self._options) do + local line = {} + if #option._aliases > 1 then + if option._maxcount > 1 then + table.insert(line, '"*"') + end + table.insert(line, "{" .. table.concat(option._aliases, ",") .. '}"') + else + table.insert(line, '"') + if option._maxcount > 1 then + table.insert(line, "*") + end + table.insert(line, option._name) + end + if option._description then + local description = get_short_description(option):gsub('["%]:`$]', "\\%0") + table.insert(line, "[" .. description .. "]") + end + if option._maxargs == math.huge then + table.insert(line, ":*") + end + if option._choices then + table.insert(line, ": :(" .. table.concat(option._choices, " ") .. ")") + elseif option._maxargs > 0 then + table.insert(line, ": :_files") + end + table.insert(line, '"') + table.insert(buf, (" "):rep(indent + 2) .. table.concat(line)) + end + + table.insert(buf, (" "):rep(indent) .. ")") + table.insert(buf, (" "):rep(indent) .. "_arguments -s -S \\") + table.insert(buf, (" "):rep(indent + 2) .. "$options \\") + + if self._is_help_command then + table.insert(buf, (" "):rep(indent + 2) .. '": :(' .. self._parent:_get_commands() .. ')" \\') + else + for _, argument in ipairs(self._arguments) do + local spec + if argument._choices then + spec = ": :(" .. table.concat(argument._choices, " ") .. ")" + else + spec = ": :_files" + end + if argument._maxargs == math.huge then + table.insert(buf, (" "):rep(indent + 2) .. '"*' .. spec .. '" \\') + break + end + for _ = 1, argument._maxargs do + table.insert(buf, (" "):rep(indent + 2) .. '"' .. spec .. '" \\') + end + end + + if #self._commands > 0 then + table.insert(buf, (" "):rep(indent + 2) .. '": :_' .. cmd_name .. '_cmds" \\') + table.insert(buf, (" "):rep(indent + 2) .. '"*:: :->args" \\') + end + end + + table.insert(buf, (" "):rep(indent + 2) .. "&& return 0") +end + +function Parser:_zsh_cmds(buf, cmd_name) + table.insert(buf, "\n_" .. cmd_name .. "_cmds() {") + table.insert(buf, " local -a commands=(") + + for _, command in ipairs(self._commands) do + local line = {} + if #command._aliases > 1 then + table.insert(line, "{" .. table.concat(command._aliases, ",") .. '}"') + else + table.insert(line, '"' .. command._name) + end + if command._description then + table.insert(line, ":" .. get_short_description(command):gsub('["`$]', "\\%0")) + end + table.insert(buf, " " .. table.concat(line) .. '"') + end + + table.insert(buf, ' )\n _describe "command" commands\n}') +end + +function Parser:_zsh_complete_help(buf, cmds_buf, cmd_name, indent) + if #self._commands == 0 then + return + end + + self:_zsh_cmds(cmds_buf, cmd_name) + table.insert(buf, "\n" .. (" "):rep(indent) .. "case $words[1] in") + + for _, command in ipairs(self._commands) do + local name = cmd_name .. "_" .. command._name + table.insert(buf, (" "):rep(indent + 2) .. table.concat(command._aliases, "|") .. ")") + command:_zsh_arguments(buf, name, indent + 4) + command:_zsh_complete_help(buf, cmds_buf, name, indent + 4) + table.insert(buf, (" "):rep(indent + 4) .. ";;\n") + end + + table.insert(buf, (" "):rep(indent) .. "esac") +end + +function Parser:get_zsh_complete() + self._basename = base_name(self._name) + assert(self:_is_shell_safe()) + local buf = { ("#compdef %s\n"):format(self._basename) } + local cmds_buf = {} + table.insert(buf, "_" .. self._basename .. "() {") + if #self._commands > 0 then + table.insert(buf, " local context state state_descr line") + table.insert(buf, " typeset -A opt_args\n") + end + self:_zsh_arguments(buf, self._basename, 2) + self:_zsh_complete_help(buf, cmds_buf, self._basename, 2) + table.insert(buf, "\n return 1") + table.insert(buf, "}") + + local result = table.concat(buf, "\n") + if #cmds_buf > 0 then + result = result .. "\n" .. table.concat(cmds_buf, "\n") + end + return result .. "\n\n_" .. self._basename .. "\n" +end + +local function fish_escape(string) + return string:gsub("[\\']", "\\%0") +end + +function Parser:_fish_get_cmd(buf, indent) + if #self._commands == 0 then + return + end + + table.insert(buf, (" "):rep(indent) .. "set -e cmdline[1]") + table.insert(buf, (" "):rep(indent) .. "for arg in $cmdline") + table.insert(buf, (" "):rep(indent + 4) .. "switch $arg") + + for _, command in ipairs(self._commands) do + table.insert(buf, (" "):rep(indent + 8) .. "case " .. table.concat(command._aliases, " ")) + table.insert(buf, (" "):rep(indent + 12) .. "set cmd $cmd " .. command._name) + command:_fish_get_cmd(buf, indent + 12) + table.insert(buf, (" "):rep(indent + 12) .. "break") + end + + table.insert(buf, (" "):rep(indent + 4) .. "end") + table.insert(buf, (" "):rep(indent) .. "end") +end + +function Parser:_fish_complete_help(buf, basename) + local prefix = "complete -c " .. basename + table.insert(buf, "") + + for _, command in ipairs(self._commands) do + local aliases = table.concat(command._aliases, " ") + local line + if self._parent then + line = ("%s -n '__fish_%s_using_command %s' -xa '%s'") + :format(prefix, basename, self:_get_fullname(true), aliases) + else + line = ("%s -n '__fish_%s_using_command' -xa '%s'"):format(prefix, basename, aliases) + end + if command._description then + line = ("%s -d '%s'"):format(line, fish_escape(get_short_description(command))) + end + table.insert(buf, line) + end + + if self._is_help_command then + local line = ("%s -n '__fish_%s_using_command %s' -xa '%s'") + :format(prefix, basename, self:_get_fullname(true), self._parent:_get_commands()) + table.insert(buf, line) + end + + for _, option in ipairs(self._options) do + local parts = { prefix } + + if self._parent then + table.insert(parts, "-n '__fish_" .. basename .. "_seen_command " .. self:_get_fullname(true) .. "'") + end + + for _, alias in ipairs(option._aliases) do + if alias:match("^%-.$") then + table.insert(parts, "-s " .. alias:sub(2)) + elseif alias:match("^%-%-.+") then + table.insert(parts, "-l " .. alias:sub(3)) + end + end + + if option._choices then + table.insert(parts, "-xa '" .. table.concat(option._choices, " ") .. "'") + elseif option._minargs > 0 then + table.insert(parts, "-r") + end + + if option._description then + table.insert(parts, "-d '" .. fish_escape(get_short_description(option)) .. "'") + end + + table.insert(buf, table.concat(parts, " ")) + end + + for _, command in ipairs(self._commands) do + command:_fish_complete_help(buf, basename) + end +end + +function Parser:get_fish_complete() + self._basename = base_name(self._name) + assert(self:_is_shell_safe()) + local buf = {} + + if #self._commands > 0 then + table.insert(buf, ([[ +function __fish_%s_print_command + set -l cmdline (commandline -poc) + set -l cmd]]):format(self._basename)) + self:_fish_get_cmd(buf, 4) + table.insert(buf, ([[ + echo "$cmd" +end + +function __fish_%s_using_command + test (__fish_%s_print_command) = "$argv" + and return 0 + or return 1 +end + +function __fish_%s_seen_command + string match -q "$argv*" (__fish_%s_print_command) + and return 0 + or return 1 +end]]):format(self._basename, self._basename, self._basename, self._basename)) + end + + self:_fish_complete_help(buf, self._basename) + return table.concat(buf, "\n") .. "\n" +end + local argparse = {} -argparse.version = "0.7.1" +argparse.version = "0.8.0" setmetatable(argparse, { __call = function(_, ...) From 14f737416d194085afc5221dd6a12c762dc7b9f6 Mon Sep 17 00:00:00 2001 From: Oleksii Stroganov Date: Fri, 3 Oct 2025 13:06:49 +0300 Subject: [PATCH 6/6] Fix issue with choices label (#29) --- src/argparse.lua | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/argparse.lua b/src/argparse.lua index e2a7b24..4fe8b36 100644 --- a/src/argparse.lua +++ b/src/argparse.lua @@ -628,7 +628,11 @@ end --------------------------------- function Argument:_get_choices_list() - return "{" .. table.concat(self._choices, ",") .. "}" + local string_choices = {} + for _, choice in ipairs(self._choices) do + table.insert(string_choices, tostring(choice)) + end + return "{" .. table.concat(string_choices, ",") .. "}" end function Argument:_get_default_argname()