Module:CLI

From The Satanic Wiki
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>