Command-line applications#

nupp.cli parses raw option tokens or derives complete applications from typed records. Types control argument conversion, while doc comments supply help text.

local cli = require("nupp.cli")

@derive(cli.Arguments)
local record Options
    --- Print more detail.
    @cli(short = "v")
    verbose: boolean = false

    --- Output path.
    @cli(short = "o", value = "PATH")
    output: string?

    --- Input files.
    @cli(positional = "FILE")
    files: {string} = {}
end

local options, problem = Options.fromCLI(arg)
assert(options ~= nil, problem and problem.message)

Parse option tokens#

optParser is a mutable token cursor that does not allocate a token object on each advance. Configure which options take values, then advance it until it ends. next returns a token kind; the token's data remains on the parser until the following next call.

local cli = require("nupp.cli")

local parser = cli.optParser(arg, {
    arity = {output = cli.required, verbose = cli.none},
})
while true do
    local kind, problem = parser:next()
    assert(problem == nil, problem and problem.message)
    if kind == nil then break end
    if kind == cli.shortOption or kind == cli.longOption then
        print(kind, parser:key(), parser:value() or "")
    else
        print(kind, parser:raw())
    end
end

raw and index are valid for every current token. key, value, attached, and pattern are option-only and raise when the current token is another kind. Every accessor raises before the first token and after the end. cli.getopt exposes the token kind and current parser as an iterator, raising on syntax errors. -- makes every remaining word an argument.

Derive typed arguments#

@derive(cli.Arguments) adds fromCLI. Optional choice positionals are skipped until an argument matches, so a required positional can follow them.

local cli = require("nupp.cli")

@derive(cli.Arguments)
local record Input
    --- Execution mode.
    @cli(positional = "MODE", choices = {"fast", "thorough"})
    mode: string?

    --- Input file.
    @cli(positional = "FILE")
    file: string
end

local input = assert(Input.fromCLI({"main.nupp"}))
assert(input.mode == nil and input.file == "main.nupp")

Add commands#

@derive(cli.Command) adds typed parsing, metadata, help, and completion. A parent returns its children from subcommands, so the tree reads from the top down.

local cli = require("nupp.cli")

--- Search files.
@cli(name = "gmatch")
@derive(cli.Command)
local record Gmatch
    --- Lua pattern to find.
    @cli(positional = "PATTERN")
    pattern: string

    --- Files to search.
    @cli(positional = "FILE")
    files: {string} = {}

    function run(self): integer
        for _, path in ipairs(self.files) do
            local file = assert(io.open(path))
            local text = file:read("*a")
            file:close()
            for match in text:gmatch(self.pattern) do
                print(path .. ": " .. match)
            end
        end
        return 0
    end
end

--- Project tools.
@cli(name = "tool", group = true)
@derive(cli.Command)
local record Tool
end

function Tool.subcommands(): {cli.CommandType}
    return {Gmatch}
end

return cli.application(Tool):main(arg)

Any command can be an application root. cli.application(Gmatch):main(arg) runs the same command without the tool gmatch prefix.

Complete dynamic values#

Literal choices complete automatically, and a value named FILE, PATH, HEADER, INPUT or ROCKSPEC (or DIR, DIRECTORY or ROOT) is handed to the shell's own file (or directory) completion. Use @cli(complete = Provider) when candidates depend on the machine or current project.

local record Files
    function complete(
        self,
        request: cli.CompletionRequest
    ): {cli.CompletionCandidate}
        local out: {cli.CompletionCandidate} = {}
        local entries = require("nupp.io.files").list(request.cwd) or {}
        for _, entry in ipairs(entries) do
            if entry.name:sub(1, #request.prefix) == request.prefix then
                out[#out + 1] = new cli.CompletionCandidate(
                    value = entry.name,
                    kind = "file"
                )
            end
        end
        return out
    end
end

@derive(cli.Arguments)
local record FileOptions
    --- File to open.
    @cli(positional = "FILE", complete = Files)
    file: string
end

Generate shell adapters with application:completion("bash" | "zsh" | "fish"). Each adapter asks the program for the candidates at the cursor through its hidden __complete command, so the answer is always the current grammar's.

Use terminal colors#

cli.style(stream) returns styles that become identity functions when color is disabled.

local styles = cli.style(io.stderr)
io.stderr:write(styles.bold("error:") .. " bad input\n")

Automatic color respects the output stream, NO_COLOR, CLICOLOR_FORCE, and TERM=dumb; override it with cli.setColorMode("always" | "never" | "auto").