Module:CLI
Jump to navigation
Jump to search
Documentation for this module may be created at Module:CLI/doc
-- <nowiki> --- CLI command-line generator for development & debugging tools. -- This module is used to execute command lines containing command line -- arguments. Command line strings are quoted similarly to shell quotes -- in Unix and Windows command lines. Unlike Unix, single quoting is -- supported. -- -- The command line string supports these environment variables: -- * `%PATH%` - calling module in the Lua stack. -- * `%OS%` - Lua version (5.1 for the foreseeable future). -- -- This module is supplied arguments in the Scribunto console. The -- general command synopsis (in man command format) is: -- {{#tag:pre|p "<command> [<arg>] [--flag [ <value>]]"}} -- The help command will output a command synopsis in the [man command -- format](https://linux.die.net/man/7/man-pages). -- -- This module supports the following syntax: -- * Positional arguments (space-delimited): `one two three` -- * Boolean command flags: `-one`, `--one` -- * Variable command flags: `--one "numero uno"` -- * Quoted arguments: `one "numero dos"` -- * Variable assignments: `one="one"` -- * Escapes: quotes as `\\"` or `\\'` and spaces as `\\ ` -- -- The first positional argument is recognised as a command word and is -- executed within the command object. The command sends its output to -- the [[Lua templating/Debug console|Scribunto console]] using -- @{mw.log}. Note that command flags must be placed after positional -- arguments to work. -- -- The command API prints the CLI usage if the `help` command word or -- `--help`/`-help` flag is supplied. When a command is combined with -- `--help` or `-help`, or a command word is defined as a second -- positional argument, the help statement is tailored to the specific -- command, its usage and options. -- -- @release beta -- @classmod CommandLine -- @pragma ulist -- @pragma noluaref -- @author [[User:8nml|8nml]] -- @attribution [[github:laurent|@laurent]] ([[github:laurent/massren|Github]]) -- @demo [[Module:Docbunto/cli]] -- @see [https://stackoverflow.com/a/46973603 Original code (Go)]. local CommandLine = {} -- Module dependencies. local util = require 'libraryUtil' local _VERSION = _VERSION local pairs, ipairs, next, rawget, error = pairs, ipairs, next, rawget, error local tostring, tonumber, type, setmetatable = tostring, tonumber, type, setmetatable local log, traceback, remove, insert = mw.log, debug.traceback, table.remove, table.insert -- Module variables. local CLI_INTERPRETER = 'LuaSandbox' local CLI_STATE_START = 'start' local CLI_STATE_QUOTE = 'quote' local CLI_STATE_ARG = 'argument' --- Converts a Lua string representation of a primitive. -- @param {string} str Unquoted string, or Lua primitive -- as a string representation. -- @return {number|boolean|string} Lua primitive that -- corresponds to string, or origiinal string. local function tovalue(str) if tonumber(str) then return tonumber(str) elseif str == 'true' then return true elseif str == 'false' then return false elseif str == 'nil' then return nil else return str end end --- Adds a metatable for indexing CLI options table by name. local function options_metatable(t, k) for _, opt in ipairs(t) do if k == opt.name or (opt.alias and k == opt.alias) then return opt end end return rawget(t, k) end --- Synopsis generator function. -- @param {table} options Array of option names or option -- configurations. -- @param {table} config Command line configuration. -- @return {string} Command line sypnosis. local function synopsis(options, config, word) local buffer, optconf = {} table.insert(buffer, ' p "') table.insert(buffer, word) table.insert(buffer, #options ~= 0 and ' ' or '') for index, opt in ipairs(options) do optconf = type(opt) == 'table' and opt or config.options[opt] table.insert(buffer, index ~= 1 and ' ' or '') table.insert(buffer, optconf.required and (optconf.alias and '{' or '') or '[') table.insert(buffer, type(optconf.name) == 'number' and ('<' .. (optconf.alias or tostring(optconf.name)) .. '>') or ( '--' .. optconf.name .. (optconf.type and ' ' or '') .. (optconf.type and ('<'.. optconf.type ..'>') or '') ) ) table.insert(buffer, optconf.alias and ' | ' or '') table.insert(buffer, optconf.alias and ( (#optconf.alias == 1 and '-' or '--') .. optconf.alias .. (optconf.type and ' ' or '') .. (optconf.type and ('<'.. optconf.type ..'>') or '') ) or '' ) table.insert(buffer, optconf.required and (optconf.alias and '}' or '') or ']') end table.insert(buffer, '"') return table.concat(buffer) end --- Okay, what do **YOU** think this does? local function padright(str, len, char) char = char or ' ' return str .. string.rep(char, len - #str) end --- Command line function generator. -- @param {table} config -- Command line configuration. -- @param {string} config.description -- Interfaced module description. -- @param {table} config.commands -- Object containing command methods. -- @param {table} config.words -- Configuration object with command methods as -- keys. Each table contains the following -- configuration table fields: -- ** `description` Description of command. -- (string) -- ** `options` Dependent options for command. -- (table) -- @param[opt] {table} config.options -- Command line options configuration. This option -- is defined as an array with option configuration -- tables as elements. Each table contains the -- following configuration table fields: -- ** `name` Name of option. Accepts number when -- the option is a positional argument. (string -- or number) -- ** `alias` Alias for option. (string; optional) -- ** `description` Description of option. (string) -- ** `type` Type of option. Omitted for boolean -- flags. (optional) -- ** `required` Whether the argument or flag is -- not optional. Default: `false`. (boolean; -- optional) -- @return {function} Command line handler. Help commands -- will produce a command synopsis detailing usage, -- a list of commands and a list of options. Other -- commands will be executed if a corresponding -- method is present in `config.commands`. function CommandLine:new(config) util.checkType('new', 1, config, 'table') util.checkTypeForNamedArg('new', 'description', config.description, 'string') util.checkTypeForNamedArg('new', 'commands', config.commands, 'table') util.checkTypeForNamedArg('new', 'words', config.words, 'table') util.checkTypeForNamedArg('new', 'options', config.options, 'table', true) setmetatable(config.options, { __index = options_metatable }) return function(command) local args, argv, argc = self:parse(command, config) local word = remove(args, 1) if ((word == 'help') or args.help) then if args[1] or (word and word ~= 'help' and args.help == true) or type(args.help) == 'string' then local command; if word == 'help' then command = args[1] elseif type(args.help) == 'boolean' then command = word elseif type(args.help) == 'string' then command = args.help end if not command or not config.words[command] then log('"' .. tostring(command) .. '" is not a recognised command. Printing help..') log('') CommandLine:new(config) 'help' return end log(tostring(command) .. ': ' .. config.words[command].description) if not config.words[command].options then return end log('') log('Command sypnosis:') log(synopsis(config.words[command].options, config, command)) log('') log('Command line options available in "' .. tostring(command) .. '":') for _, opt in ipairs(config.words[command].options) do log( ' ' .. padright( tostring(config.options[opt].name) .. (config.options[opt].alias and (' | ' .. config.options[opt].alias) or ''), 24 ) .. ': ' .. config.options[opt].description ) end else log(config.description) log('') log('General command line sypnosis:') log(synopsis(config.options, config, '<command>')) log('') log('Commands available in ' .. args[0] .. ':') for word, wordconf in pairs(config.words) do log(' ' .. padright(tostring(word), 24) .. ': ' .. wordconf.description) end log('') log('Command line options available in ' .. args[0] .. ':') for _, optconf in ipairs(config.options) do log( ' ' .. padright( tostring(optconf.name) .. (optconf.alias and (' | ' .. optconf.alias) or ''), 24 ) .. ': ' .. optconf.description ) end end elseif word and config.commands[word] then return config.commands[word](args) else log('"' .. tostring(word) .. '" is not a recognised command. Printing help..') log('') CommandLine:new(config) 'help' end end end --- Command line shell quote parser. -- @function CommandLine:parse -- @param {string} command Command line to be parsed. -- @param[opt] {table} config Command line configuration. -- @error[377] {string} "empty command line" -- @error[382] {string} "unterminated quote in command line: -- $command" -- @error[424] {string} "unconfigured argument or flag "$arg" -- in command line" -- @return {table} Map of processed arguments. The array, -- or sequential keys in the map list positional -- arguments in the command line. The flags in -- the command line are converted into named -- arguments. Metadata fields: -- * `0` The CLI module name or context. -- * `"PATH"` The CLI module name or context. -- * `-1` The Lua interpreter Scribunto uses. -- * `-2` The Lua version Scribunto runs. -- * `"OS"` The Lua version Scribunto runs. -- @return {table} Array of processed arguments. Includes -- the metadata fields listed above. -- @return {table} Number of arguments passed by the user, -- counted like the C or C++ `argc` convention. function CommandLine:parse(command, config) util.checkType('parse', 1, command, 'string') util.checkType('parse', 2, config, 'table', true) config = config or { options = {} } local args = {} local argv = {} local argc = 0 -- Initialise stack variables. local state = CLI_STATE_START local stack = '' local quote = '"' local escape_next = true -- Attach version, interpreter & module path to argument value array. argv[-2] = _VERSION argv[-1] = CLI_INTERPRETER argv[0] = mw.title.getCurrentTitle().prefixedText for modulename in (traceback() or ''):gmatch('\n\t([^(%[][^:\n]+:[^:\n]+)') do if not modulename:find(':CLI%f[%z: ]') then argv[0] = modulename:gsub('(input):%d+$', '%1') break end end -- Attach version, interpreter and module path to argument map array. args[-1] = argv[-1] args[0] = argv[0] args.PATH = argv[0] args.OS = argv[-2] -- Replace %PATH% with the module name. command = command:gsub('%%PATH%%', argv[0]) -- Replace %OS% with the Lua version. command = command:gsub('%%OS%%', argv[-2]) -- Parse command chunks. local cmd_length = 0 for chunk in command:gmatch('.') do cmd_length = cmd_length + 1 -- Shell quote parsing. if state == CLI_STATE_QUOTE then -- Reset the stack and state. if chunk == quote then insert(argv, stack) stack = '' state = CLI_STATE_START else stack = stack .. chunk end elseif escape_next then stack = stack .. chunk escape_next = false elseif chunk == '\\' then escape_next = true elseif chunk == '"' or chunk == "'" then state = CLI_STATE_QUOTE quote = chunk elseif state == CLI_STATE_ARG then if chunk == ' ' or chunk == '\t' then insert(argv, stack) stack = '' state = CLI_STATE_START else stack = stack .. chunk if #command == cmd_length then insert(argv, stack) end end elseif chunk ~= ' ' and chunk ~= '\t' then state = CLI_STATE_ARG stack = stack .. chunk end end -- Check for empty command line. local argc = #argv if argc == 0 then error('empty command line') end -- Check for unterminated strings. if state == CLI_STATE_QUOTE then error('unterminated quote in command line: ' .. command) end -- Command line argument parsing. for index, arg in ipairs(argv) do argv[index] = tovalue(arg) end for index, arg in ipairs(argv) do -- Command line flag parsing. if type(arg) == 'string' and arg:find('^%-') then arg = arg:gsub('^%-%-?', '') if type(argv[index + 1]) == 'string' and not argv[index + 1]:find('^%-') then index = index + 1 args[arg] = argv[index] else args[arg] = true end -- Command line variable parsing. elseif type(arg) == 'string' and arg:find('%S=%S') then local key = arg:match('^[^=]+') key = tonumber(key) or key local val = arg:match('[^=]+$') args[key] = val _G[key] = val -- Positional command line arguments. else insert(args, arg) end end -- Check for undefined options when configured. for arg in pairs(args) do if next(config.options) and (args[1] ~= 'help' and arg ~= 'help') and (arg ~= 'PATH' and arg ~= 'OS') and ( type(arg) ~= 'number' or (arg >= 2) ) and not config.options[type(arg) == 'number' and (arg - 1) or arg] then error('unconfigured argument or flag "' .. (type(arg) == 'number' and tostring(arg - 1) or arg) .. '" in command line') end end -- Alias translation for configured argument options. if type(config) == 'table' and type(config.options) == 'table' then for _, optconf in ipairs(config.options) do if optconf.alias and args[optconf.alias] then args[optconf.name] = args[optconf.alias] remove(args, optconf.alias) end end end return args, argv, argc end return CommandLine -- </nowiki>