Module:CLI: Difference between revisions
Jump to navigation
Jump to search
Mediawiki>8nml mNo edit summary |
m 1 revision imported |
||
(No difference)
|
Latest revision as of 02:23, 30 April 2021
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>