diff --git a/LICENSE b/LICENSE index 87579ac..b59cc73 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ The MIT License (MIT) Copyright (c) 2013 - 2018 Peter Melnichenko + 2019 Paul Ouellette 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/docsrc/completions.rst b/docsrc/completions.rst new file mode 100644 index 0000000..c6c10e7 --- /dev/null +++ b/docsrc/completions.rst @@ -0,0 +1,78 @@ +Shell completions +================= + +Argparse can generate shell completion scripts for +`Bash `_, `Zsh `_, and +`Fish `_. +The completion scripts support completing options, commands, and argument +choices. + +The Parser methods ``:get_bash_complete()``, ``:get_zsh_complete()``, and +``:get_fish_complete()`` return completion scripts as a string. + +Adding a completion option or command +------------------------------------- + +A ``--completion`` option can be added to a parser using the +``:add_complete([value])`` method. The optional ``value`` argument is a string +or table used to configure the option (by calling the option with ``value``). + +.. code-block:: lua + :linenos: + + local parser = argparse() + :add_complete() + +.. code-block:: none + + $ lua script.lua -h + +.. code-block:: none + + Usage: script.lua [-h] [--completion {bash,zsh,fish}] + + Options: + -h, --help Show this help message and exit. + --completion {bash,zsh,fish} + Output a shell completion script for the specified shell. + +A similar ``completion`` command can be added to a parser using the +``:add_complete_command([value])`` method. + +Using completions +----------------- + +Bash +^^^^ + +Save the generated completion script at +``/usr/share/bash-completion/completions/script.lua`` or +``~/.local/share/bash-completion/completions/script.lua``. + +Alternatively, add the following line to the ``~/.bashrc``: + +.. code-block:: bash + + source <(script.lua --completion bash) + +Zsh +^^^ + +Save the completion script in the ``/usr/share/zsh/site-functions/`` directory +or any directory in the ``$fpath``. The file name should be an underscore +followed by the program name. A new directory can be added to to the ``$fpath`` +by adding e.g. ``fpath=(~/.zfunc $fpath)`` in the ``~/.zshrc`` before +``compinit``. + +Fish +^^^^ + +Save the completion script at +``/usr/share/fish/vendor_completions.d/script.lua.fish`` or +``~/.config/fish/completions/script.lua.fish``. + +Alternatively, add the following line to the file ``~/.config/fish/config.fish``: + +.. code-block:: fish + + script.lua --completion fish | source diff --git a/docsrc/index.rst b/docsrc/index.rst index 0e8c6c0..e4d0de1 100644 --- a/docsrc/index.rst +++ b/docsrc/index.rst @@ -13,6 +13,7 @@ Contents: defaults callbacks messages + completions misc This is a tutorial for `argparse `_, a feature-rich command line parser for Lua. diff --git a/spec/completion_spec.lua b/spec/completion_spec.lua new file mode 100644 index 0000000..6812cd2 --- /dev/null +++ b/spec/completion_spec.lua @@ -0,0 +1,222 @@ +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 cur prev cmd opts arg + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + cmd="comptest" + 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 + + for arg in ${COMP_WORDS[@]:1}; do + case "$arg" in + completion) + cmd="completion" + break + ;; + install|i) + cmd="install" + break + ;; + admin) + cmd="admin" + break + ;; + esac + done + + case "$cmd" in + comptest) + COMPREPLY=($(compgen -W "help completion install i admin" -- "$cur")) + ;; + completion) + opts="$opts -h --help" + ;; + install) + case "$prev" in + --deps-mode) + COMPREPLY=($(compgen -W "all one order none" -- "$cur")) + return 0 + ;; + esac + + opts="$opts -h --help --deps-mode --no-doc" + ;; + admin) + opts="$opts -h --help" + ;; + 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 + + _arguments -s -S \ + {-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" \ + ": :_comptest_cmds" \ + "*:: :->args" \ + && return 0 + + case $words[1] in + help) + _arguments -s -S \ + {-h,--help}"[Show this help message and exit]" \ + ": :(help completion install i admin)" \ + && return 0 + ;; + + completion) + _arguments -s -S \ + {-h,--help}"[Show this help message and exit]" \ + ": :(bash zsh fish)" \ + && return 0 + ;; + + install|i) + _arguments -s -S \ + {-h,--help}"[Show this help message and exit]" \ + "--deps-mode: :(all one order none)" \ + "--no-doc[Install without documentation]" \ + && return 0 + ;; + + admin) + _arguments -s -S \ + {-h,--help}"[Show this help message and exit]" \ + ": :_comptest_admin_cmds" \ + "*:: :->args" \ + && return 0 + + case $words[1] in + help) + _arguments -s -S \ + {-h,--help}"[Show this help message and exit]" \ + ": :(help add remove)" \ + && return 0 + ;; + + add) + _arguments -s -S \ + {-h,--help}"[Show this help message and exit]" \ + ": :_files" \ + && return 0 + ;; + + remove) + _arguments -s -S \ + {-h,--help}"[Show this help message and exit]" \ + ": :_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([=[ + +complete -c comptest -n '__fish_use_subcommand' -xa 'help' -d 'Show help for commands' +complete -c comptest -n '__fish_use_subcommand' -xa 'completion' -d 'Output a shell completion script' +complete -c comptest -n '__fish_use_subcommand' -xa 'install' -d 'Install a rock' +complete -c comptest -n '__fish_use_subcommand' -xa 'i' -d 'Install a rock' +complete -c comptest -n '__fish_use_subcommand' -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_seen_subcommand_from help' -xa 'help completion install i admin' +complete -c comptest -n '__fish_seen_subcommand_from help' -s h -l help -d 'Show this help message and exit' + +complete -c comptest -n '__fish_seen_subcommand_from completion' -s h -l help -d 'Show this help message and exit' + +complete -c comptest -n '__fish_seen_subcommand_from install i' -s h -l help -d 'Show this help message and exit' +complete -c comptest -n '__fish_seen_subcommand_from install i' -l deps-mode -xa 'all one order none' +complete -c comptest -n '__fish_seen_subcommand_from install i' -l no-doc -d 'Install without documentation' + +complete -c comptest -n '__fish_use_subcommand' -xa 'help' -d 'Show help for commands' +complete -c comptest -n '__fish_use_subcommand' -xa 'add' -d 'Add a rock to a server' +complete -c comptest -n '__fish_use_subcommand' -xa 'remove' -d 'Remove a rock from a server' +complete -c comptest -n '__fish_seen_subcommand_from admin' -s h -l help -d 'Show this help message and exit' + +complete -c comptest -n '__fish_seen_subcommand_from help' -xa 'help add remove' +complete -c comptest -n '__fish_seen_subcommand_from help' -s h -l help -d 'Show this help message and exit' + +complete -c comptest -n '__fish_seen_subcommand_from add' -s h -l help -d 'Show this help message and exit' + +complete -c comptest -n '__fish_seen_subcommand_from remove' -s h -l help -d 'Show this help message and exit' +]=], get_output("completion fish")) + end) +end) diff --git a/spec/comptest b/spec/comptest new file mode 100755 index 0000000..948bf33 --- /dev/null +++ b/spec/comptest @@ -0,0 +1,39 @@ +#!/usr/bin/env lua + +local argparse = require "argparse" + +local parser = argparse() + :add_help_command() + :add_complete_command() + :add_complete() + +parser:flag "-v --verbose" + :description "Set the verbosity level." + :count "*" + +parser:option "-f --files" + :description "A description with illegal \"' characters." + :args "+" + +local install = parser:command "install i" + :description "Install a rock." + +install:option "--deps-mode" + :choices {"all", "one", "order", "none"} + +install:flag "--no-doc" + :description "Install without documentation." + +local admin = parser:command "admin" + :description "Rock server administration interface." + :add_help_command() + +local admin_add = admin:command "add" + :description "Add a rock to a server." +admin_add:argument "rock" + +local admin_remove = admin:command "remove" + :description "Remove a rock from a server." +admin_remove:argument "rock" + +parser:parse() diff --git a/src/argparse.lua b/src/argparse.lua index 21e0d0b..ddb9e2d 100644 --- a/src/argparse.lua +++ b/src/argparse.lua @@ -1,6 +1,7 @@ -- The MIT License (MIT) -- Copyright (c) 2013 - 2018 Peter Melnichenko +-- 2019 Paul Ouellette -- 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 @@ -1080,9 +1081,420 @@ function Parser:add_help_command(value) help "help" end + 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 + 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) + local cmds = {} + for _, command in ipairs(self._commands) do + if not command._is_help_command then + table.insert(cmds, (" "):rep(12) .. table.concat(command._aliases, "|") .. ")") + table.insert(cmds, (" "):rep(16) .. 'cmd="' .. command._name .. '"') + table.insert(cmds, (" "):rep(16) .. "break") + table.insert(cmds, (" "):rep(16) .. ";;") + end + end + + if #cmds > 0 then + table.insert(buf, (" "):rep(4) .. "for arg in ${COMP_WORDS[@]:1}; do") + table.insert(buf, (" "):rep(8) .. 'case "$arg" in') + table.insert(buf, table.concat(cmds, "\n")) + table.insert(buf, (" "):rep(8) .. "esac") + table.insert(buf, (" "):rep(4) .. "done\n") + end +end + +function Parser:_bash_cmd_completions(buf) + local subcmds = {} + for _, command in ipairs(self._commands) do + if #command._options > 0 and not command._is_help_command then + table.insert(subcmds, (" "):rep(8) .. command._name .. ")") + command:_bash_option_args(subcmds, 12) + table.insert(subcmds, (" "):rep(12) .. 'opts="$opts ' .. command:_get_options() .. '"') + table.insert(subcmds, (" "):rep(12) .. ";;") + end + end + + table.insert(buf, (" "):rep(4) .. 'case "$cmd" in') + table.insert(buf, (" "):rep(8) .. self._basename .. ")") + table.insert(buf, (" "):rep(12) .. 'COMPREPLY=($(compgen -W "' .. self:_get_commands() .. '" -- "$cur"))') + table.insert(buf, (" "):rep(12) .. ";;") + if #subcmds > 0 then + table.insert(buf, table.concat(subcmds, "\n")) + end + table.insert(buf, (" "):rep(4) .. "esac\n") +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 cur prev cmd opts arg + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + cmd="%s" + opts="%s" +]]):format(self._basename, self._basename, self:_get_options())} + + self:_bash_option_args(buf, 4) + self:_bash_get_cmd(buf) + if #self._commands > 0 then + self:_bash_cmd_completions(buf) + 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) + table.insert(buf, (" "):rep(indent) .. "_arguments -s -S \\") + + 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 + + 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_complete_help(buf, prefix) + table.insert(buf, "") + + for _, command in ipairs(self._commands) do + for _, alias in ipairs(command._aliases) do + local line = ("%s -n '__fish_use_subcommand' -xa '%s'"):format(prefix, alias) + if command._description then + line = ("%s -d '%s'"):format(line, fish_escape(get_short_description(command))) + end + table.insert(buf, line) + end + + end + + if self._is_help_command then + local line = ("%s -n '__fish_seen_subcommand_from %s' -xa '%s'") + :format(prefix, table.concat(self._aliases, " "), 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_seen_subcommand_from " .. table.concat(self._aliases, " ") .. "'") + 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, prefix) + end +end + +function Parser:get_fish_complete() + self._basename = base_name(self._name) + assert(self:_is_shell_safe()) + local buf = {} + local prefix = "complete -c " .. self._basename + self:_fish_complete_help(buf, prefix) + return table.concat(buf, "\n") .. "\n" +end + local function get_tip(context, wrong_name) local context_pool = {} local possible_name