Bug 1519990 - [mach] Add ability to generate completion scripts via 'mach mach-completion <shell>', r=mhentges

Supported shells are 'bash', 'zsh' and 'fish'. Here are installation
instructions with various shells and contexts:

### bash

    $ mach mach-completion bash -f _mach
    $ sudo mv _mach /etc/bash_completion.d

### bash (homebrew)

    $ mach mach-completion bash -f $(brew --prefix)/etc/bash_completion.d/mach.bash-completion

### zsh

    $ mkdir ~/.zfunc
    $ mach mach-completion zsh -f ~/.zfunc/_mach

then edit ~/.zshrc and add:

    fpath += ~/.zfunc
    autoload -U compinit && compinit

### zsh (oh-my-zsh)

    $ mkdir $ZSH/plugins/mach
    $ mach mach-completion zsh -f $ZSH/plugins/mach/_mach

then edit ~/.zshrc and add 'mach' to your enabled plugins:

    plugins(mach ...)

### zsh (prezto)

    $ mach mach-completion zsh -f ~/.zprezto/modules/completion/external/src/_mach

### fish

    $ ./mach mach-completion fish -f ~/.config/fish/completions/mach.fish

### fish (homebrew)

    $ ./mach mach-completion fish -f (brew --prefix)/share/fish/vendor_completions.d/mach.fish

Differential Revision: https://phabricator.services.mozilla.com/D90416
This commit is contained in:
Andrew Halberstadt 2020-10-09 16:03:30 +00:00
Родитель 1f357936b2
Коммит ef21656e00
7 изменённых файлов: 624 добавлений и 66 удалений

Просмотреть файл

@ -41,46 +41,82 @@ following are valid:
$ ./mach try fuzzy --help
Auto Completion
---------------
Tab Completion
--------------
A `bash completion`_ script is bundled with mach, it can be used with either ``bash`` or ``zsh``.
There are commands built-in to ``mach`` that can generate a fast tab completion
script for various shells. Supported shells are currently ``bash``, ``zsh`` and
``fish``. These generated scripts will slowly become out of date over time, so
you may want to create a cron task to periodically re-generate them.
See below for installation instructions:
Bash
~~~~
Add the following to your ``~/.bashrc``, ``~/.bash_profile`` or equivalent:
.. code-block:: shell
$ mach mach-completion bash -f _mach
$ sudo mv _mach /etc/bash_completion.d
Bash (homebrew)
~~~~~~~~~~~~~~~
.. code-block:: shell
source <srcdir>/python/mach/bash-completion.sh
.. tip::
Windows users using the default shell bundled with mozilla-build should source the completion
script from ``~/.bash_profile`` (it may need to be created first).
$ mach mach-completion bash -f $(brew --prefix)/etc/bash_completion.d/mach.bash-completion
Zsh
~~~
Add this to your ``~/.zshrc`` or equivalent:
.. code-block:: shell
autoload -U bashcompinit && bashcompinit
source <srcdir>/python/mach/bash-completion.sh
The ``compinit`` function also needs to be loaded, but if using a framework (like ``oh-my-zsh``),
this will often be done for you. So if you see ``command not found: compdef``, you'll need to modify
the above instructions to:
.. code-block:: shell
$ mkdir ~/.zfunc
$ mach mach-completion zsh -f ~/.zfunc/_mach
then edit ~/.zshrc and add:
.. code-block:: shell
fpath += ~/.zfunc
autoload -U compinit && compinit
autoload -U bashcompinit && bashcompinit
source <srcdir>/python/mach/bash-completion.sh
Don't forget to substitute ``<srcdir>`` with the path to your checkout.
You can use any directory of your choosing.
Zsh (oh-my-zsh)
~~~~~~~~~~~~~~~
.. code-block:: shell
$ mkdir $ZSH/plugins/mach
$ mach mach-completion zsh -f $ZSH/plugins/mach/_mach
then edit ~/.zshrc and add 'mach' to your enabled plugins:
.. code-block:: shell
plugins(mach ...)
Zsh (prezto)
~~~~~~~~~~~~
.. code-block:: shell
$ mach mach-completion zsh -f ~/.zprezto/modules/completion/external/src/_mach
Fish
~~~~
.. code-block:: shell
$ ./mach mach-completion fish -f ~/.config/fish/completions/mach.fish
Fish (homebrew)
~~~~~~~~~~~~~~~
.. code-block:: shell
$ ./mach mach-completion fish -f (brew --prefix)/share/fish/vendor_completions.d/mach.fish
User Settings

Просмотреть файл

@ -5,21 +5,47 @@
from __future__ import absolute_import, print_function, unicode_literals
import argparse
import os
import re
import subprocess
import sys
from itertools import chain
import attr
from mach.decorators import (
CommandProvider,
Command,
CommandArgument,
SubCommand,
)
from mozbuild.base import MachCommandBase
from mozbuild.util import memoize, memoized_property
here = os.path.abspath(os.path.dirname(__file__))
COMPLETION_TEMPLATES_DIR = os.path.join(here, "completion_templates")
@attr.s
class CommandInfo(object):
name = attr.ib(type=str)
description = attr.ib(type=str)
subcommands = attr.ib(type=list)
options = attr.ib(type=dict)
subcommand = attr.ib(type=str, default=None)
def render_template(shell, context):
filename = "{}.template".format(shell)
with open(os.path.join(COMPLETION_TEMPLATES_DIR, filename)) as fh:
template = fh.read()
return template % context
@CommandProvider
class BuiltinCommands(MachCommandBase):
@property
@memoized_property
def command_handlers(self):
"""A dictionary of command handlers keyed by command name."""
return self._mach_context.commands.command_handlers
@ -29,23 +55,78 @@ class BuiltinCommands(MachCommandBase):
"""A sorted list of all command names."""
return sorted(self.command_handlers)
def _get_parser_options(self, parser):
options = {}
for action in parser._actions:
# ignore positional args
if not action.option_strings:
continue
# ignore suppressed args
if action.help == argparse.SUPPRESS:
continue
options[tuple(action.option_strings)] = action.help or ""
return options
@memoized_property
def global_options(self):
"""Return a dict of global options.
Of the form `{("-o", "--option"): "description"}`.
"""
for group in self._mach_context.global_parser._action_groups:
if group.title == "Global Arguments":
return self._get_parser_options(group)
@memoize
def get_handler_options(self, handler):
"""Given a command handler, return all available options."""
targets = []
def _get_handler_options(self, handler):
"""Return a dict of options for the given handler.
# The 'option_strings' are of the form [('-f', '--foo'), ('-b', '--bar'), ...].
option_strings = [item[0] for item in handler.arguments]
# Filter out positional arguments (we don't want to complete their metavar).
option_strings = [opt for opt in option_strings if opt[0].startswith('-')]
targets.extend(chain(*option_strings))
Of the form `{("-o", "--option"): "description"}`.
"""
options = {}
for option_strings, val in handler.arguments:
# ignore positional args
if option_strings[0][0] != "-":
continue
# If the command uses its own ArgumentParser, extract options from there as well.
if handler.parser:
targets.extend(chain(*[action.option_strings
for action in handler.parser._actions]))
options[tuple(option_strings)] = val.get("help", "")
return targets
if handler._parser:
options.update(self._get_parser_options(handler.parser))
return options
def _get_handler_info(self, handler):
try:
options = self._get_handler_options(handler)
except (Exception, SystemExit):
# We don't want misbehaving commands to break tab completion,
# ignore any exceptions.
options = {}
subcommands = []
for sub in sorted(handler.subcommand_handlers):
subcommands.append(self._get_handler_info(handler.subcommand_handlers[sub]))
return CommandInfo(
name=handler.name,
description=handler.description or "",
options=options,
subcommands=subcommands,
subcommand=handler.subcommand,
)
@memoized_property
def commands_info(self):
"""Return a list of CommandInfo objects for each command."""
commands_info = []
# Loop over self.commands rather than self.command_handlers.items() for
# alphabetical order.
for c in self.commands:
commands_info.append(self._get_handler_info(self.command_handlers[c]))
return commands_info
@Command('mach-commands', category='misc',
description='List all mach commands.')
@ -109,5 +190,234 @@ class BuiltinCommands(MachCommandBase):
return
targets.append('help')
targets.extend(self.get_handler_options(handler))
targets.extend(chain(*self._get_handler_options(handler).keys()))
print("\n".join(targets))
def _zsh_describe(self, value, description=None):
value = '"' + value.replace(":", "\\:")
if description:
description = re.sub(
r'(["\'#&;`|*?~<>^()\[\]{}$\\\x0A\xFF])', r"\\\1", description
)
value += ":{}".format(subprocess.list2cmdline([description]).strip('"'))
value += '"'
return value
@SubCommand('mach-completion', 'bash',
description="Print mach completion script for bash shell")
@CommandArgument("-f", "--file", dest="outfile", default=None,
help="File path to save completion script.")
def completion_bash(self, outfile):
commands_subcommands = []
case_options = []
case_subcommands = []
for i, cmd in enumerate(self.commands_info):
# Build case statement for options.
options = []
for opt_strs, description in cmd.options.items():
for opt in opt_strs:
options.append(self._zsh_describe(opt, None).strip('"'))
if options:
case_options.append("\n".join([
" ({})".format(cmd.name),
' opts="${{opts}} {}"'.format(" ".join(options)),
" ;;",
"",
]))
# Build case statement for subcommand options.
for sub in cmd.subcommands:
options = []
for opt_strs, description in sub.options.items():
for opt in opt_strs:
options.append(self._zsh_describe(opt, None))
if options:
case_options.append("\n".join([
' ("{} {}")'.format(sub.name, sub.subcommand),
' opts="${{opts}} {}"'.format(" ".join(options)),
" ;;",
""
]))
# Build case statement for subcommands.
subcommands = [
self._zsh_describe(s.subcommand, None) for s in cmd.subcommands
]
if subcommands:
commands_subcommands.append('[{}]=" {} "'.format(
cmd.name, " ".join([h.subcommand for h in cmd.subcommands]))
)
case_subcommands.append("\n".join([
" ({})".format(cmd.name),
' subs="${{subs}} {}"'.format(" ".join(subcommands)),
" ;;",
"",
]))
globalopts = [opt for opt_strs in self.global_options for opt in opt_strs]
context = {
"case_options": "\n".join(case_options),
"case_subcommands": "\n".join(case_subcommands),
"commands": " ".join(self.commands),
"commands_subcommands": " ".join(sorted(commands_subcommands)),
"globalopts": " ".join(sorted(globalopts)),
}
outfile = open(outfile, 'w') if outfile else sys.stdout
print(render_template("bash", context), file=outfile)
@SubCommand('mach-completion', 'zsh',
description="Print mach completion script for zsh shell")
@CommandArgument("-f", "--file", dest="outfile", default=None,
help="File path to save completion script.")
def completion_zsh(self, outfile):
commands_descriptions = []
commands_subcommands = []
case_options = []
case_subcommands = []
for i, cmd in enumerate(self.commands_info):
commands_descriptions.append(
self._zsh_describe(cmd.name, cmd.description)
)
# Build case statement for options.
options = []
for opt_strs, description in cmd.options.items():
for opt in opt_strs:
options.append(self._zsh_describe(opt, description))
if options:
case_options.append("\n".join([
" ({})".format(cmd.name),
" opts+=({})".format(" ".join(options)),
" ;;",
""
]))
# Build case statement for subcommand options.
for sub in cmd.subcommands:
options = []
for opt_strs, description in sub.options.items():
for opt in opt_strs:
options.append(self._zsh_describe(opt, description))
if options:
case_options.append("\n".join([
" ({} {})".format(sub.name, sub.subcommand),
" opts+=({})".format(" ".join(options)),
" ;;",
""
]))
# Build case statement for subcommands.
subcommands = [
self._zsh_describe(s.subcommand, s.description) for s in cmd.subcommands
]
if subcommands:
commands_subcommands.append('[{}]=" {} "'.format(
cmd.name, " ".join([h.subcommand for h in cmd.subcommands]))
)
case_subcommands.append("\n".join([
" ({})".format(cmd.name),
" subs+=({})".format(" ".join(subcommands)),
" ;;",
""
]))
globalopts = []
for opt_strings, description in self.global_options.items():
for opt in opt_strings:
globalopts.append(self._zsh_describe(opt, description))
context = {
"case_options": "\n".join(case_options),
"case_subcommands": "\n".join(case_subcommands),
"commands": " ".join(sorted(commands_descriptions)),
"commands_subcommands": " ".join(sorted(commands_subcommands)),
"globalopts": " ".join(sorted(globalopts)),
}
outfile = open(outfile, 'w') if outfile else sys.stdout
print(render_template("zsh", context), file=outfile)
@SubCommand('mach-completion', 'fish',
description="Print mach completion script for fish shell")
@CommandArgument("-f", "--file", dest="outfile", default=None,
help="File path to save completion script.")
def completion_fish(self, outfile):
def _append_opt_strs(comp, opt_strs):
for opt in opt_strs:
if opt.startswith('--'):
comp += " -l {}".format(opt[2:])
elif opt.startswith('-'):
comp += " -s {}".format(opt[1:])
return comp
globalopts = []
for opt_strs, description in self.global_options.items():
comp = (
"complete -c mach -n '__fish_mach_complete_no_command' "
"-d '{}'".format(description.replace("'", "\\'"))
)
comp = _append_opt_strs(comp, opt_strs)
globalopts.append(comp)
cmds = []
cmds_opts = []
for i, cmd in enumerate(self.commands_info):
cmds.append(
"complete -c mach -f -n '__fish_mach_complete_no_command' "
"-a {} -d '{}'".format(
cmd.name,
cmd.description.replace("'", "\\'"),
)
)
cmds_opts += ["# {}".format(cmd.name)]
subcommands = " ".join([s.subcommand for s in cmd.subcommands])
for opt_strs, description in cmd.options.items():
comp = (
"complete -c mach -A -n '__fish_mach_complete_command {} {}' "
"-d '{}'".format(cmd.name, subcommands, description.replace("'", "\\'"))
)
comp = _append_opt_strs(comp, opt_strs)
cmds_opts.append(comp)
for sub in cmd.subcommands:
for opt_strs, description in sub.options.items():
comp = (
"complete -c mach -A -n '__fish_mach_complete_subcommand {} {}' "
"-d '{}'".format(sub.name, sub.subcommand, description.replace("'", "\\'"))
)
comp = _append_opt_strs(comp, opt_strs)
cmds_opts.append(comp)
description = sub.description or ""
description = description.replace("'", "\\'")
comp = (
"complete -c mach -A -n '__fish_mach_complete_command {} {}' "
"-d '{}' -a {}".format(cmd.name, subcommands, description, sub.subcommand)
)
cmds_opts.append(comp)
if i < len(self.commands) - 1:
cmds_opts.append("")
context = {
"commands": " ".join(self.commands),
"command_completions": "\n".join(cmds),
"command_option_completions": "\n".join(cmds_opts),
"global_option_completions": "\n".join(globalopts),
}
outfile = open(outfile, 'w') if outfile else sys.stdout
print(render_template("fish", context), file=outfile)

Просмотреть файл

@ -0,0 +1,62 @@
_mach_complete()
{
local com coms comsubs cur opts script sub subs
COMPREPLY=()
declare -A comsubs=( %(commands_subcommands)s )
_get_comp_words_by_ref -n : cur words
# for an alias, get the real script behind it
if [[ $(type -t ${words[0]}) == "alias" ]]; then
script=$(alias ${words[0]} | sed -E "s/alias ${words[0]}='(.*)'/\\1/")
else
script=${words[0]}
fi
# lookup for command and subcommand
for word in ${words[@]:1}; do
if [[ $word == -* ]]; then
continue
fi
if [[ -z $com ]]; then
com=$word
elif [[ "${comsubs[$com]}" == *" $word "* ]]; then
sub=$word
break
fi
done
# completing for an option
if [[ ${cur} == -* ]] ; then
if [[ -n $com ]]; then
if [[ -n $sub ]]; then
optkey="$com $sub"
else
optkey="$com"
fi
case $optkey in
%(case_options)s
esac
else
# no command, complete global options
opts="%(globalopts)s"
fi
COMPREPLY=($(compgen -W "${opts}" -- ${cur}))
__ltrim_colon_completions "$cur"
return 0;
# completing for a command
elif [[ $cur == $com ]]; then
coms="%(commands)s"
COMPREPLY=($(compgen -W "${coms}" -- ${cur}))
__ltrim_colon_completions "$cur"
return 0
else
if [[ -z $sub ]]; then
case "$com" in
%(case_subcommands)s
esac
COMPREPLY=($(compgen -W "${subs}" -- ${cur}))
__ltrim_colon_completions "$cur"
fi
return 0
fi
}
complete -o default -F _mach_complete mach

Просмотреть файл

@ -0,0 +1,64 @@
function __fish_mach_complete_no_command
for i in (commandline -opc)
if contains -- $i %(commands)s
return 1
end
end
return 0
end
function __fish_mach_complete_command_matches
for i in (commandline -opc)
if contains -- $i %(commands)s
set com $i
break
end
end
if not set -q com
return 1
end
if test "$com" != "$argv"
return 1
end
return 0
end
function __fish_mach_complete_command
__fish_mach_complete_command_matches $argv[1]
if test $status -ne 0
return 1
end
# If a subcommand is already entered, don't complete, we should defer to
# '__fish_mach_complete_subcommand'.
for i in (commandline -opc)
if contains -- $i $argv[2..-1]
return 1
end
end
return 0
end
function __fish_mach_complete_subcommand
__fish_mach_complete_command_matches $argv[1]
if test $status -ne 0
return 1
end
# Command matches, now check for subcommand
for i in (commandline -opc)
if contains -- $i $argv[2]
return 0
end
end
return 1
end
# global options
%(global_option_completions)s
# commands
%(command_completions)s
# command options
%(command_option_completions)s

Просмотреть файл

@ -0,0 +1,62 @@
#compdef mach
_mach_complete()
{
local com coms comsubs cur optkey opts state sub subs
cur=${words[${#words[@]}]}
typeset -A comsubs
comsubs=( %(commands_subcommands)s )
# lookup for command and subcommand
for word in ${words[@]:1}; do
if [[ $word == -* ]]; then
continue
fi
if [[ -z $com ]]; then
com=$word
elif [[ ${comsubs[$com]} == *" $word "* ]]; then
sub=$word
break
fi
done
# check for a subcommand
if [[ $cur == $com ]]; then
state="command"
coms=(%(commands)s)
elif [[ ${cur} == -* ]]; then
state="option"
if [[ -z $com ]]; then
# no command, use global options
opts=(%(globalopts)s)
fi
fi
case $state in
(command)
_describe 'command' coms
;;
(option)
if [[ -n $sub ]]; then
optkey="$com $sub"
else
optkey="$com"
fi
case $optkey in
%(case_options)s
esac
_describe 'option' opts
;;
*)
if [[ -z $sub ]]; then
# if we're completing a command with subcommands, add them here
case "$com" in
%(case_subcommands)s
esac
_describe 'subcommand' subs
fi
# also fallback to file completion
_arguments '*:file:_files'
esac
}
_mach_complete "$@"
compdef _mach_complete mach

Просмотреть файл

@ -400,6 +400,7 @@ To see more help for a specific command, run:
context = ContextWrapper(context, self.populate_context_handler)
parser = self.get_argument_parser(context)
context.global_parser = parser
if not len(argv):
# We don't register the usage until here because if it is globally

Просмотреть файл

@ -2,28 +2,34 @@
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
from __future__ import absolute_import, unicode_literals
from __future__ import absolute_import, print_function, unicode_literals
import os
import sys
import pytest
from mozunit import main
from buildconfig import topsrcdir
import mach
from mach.test.common import TestBase
ALL_COMMANDS = [
'cmd_bar',
'cmd_foo',
'cmd_foobar',
'mach-commands',
'mach-completion',
'mach-debug-commands',
]
class TestCommands(TestBase):
all_commands = [
'cmd_bar',
'cmd_foo',
'cmd_foobar',
'mach-commands',
'mach-completion',
'mach-debug-commands',
]
def _run_mach(self, args):
@pytest.fixture
def run_mach():
tester = TestBase()
def inner(args):
mach_dir = os.path.dirname(mach.__file__)
providers = [
'commands.py',
@ -34,27 +40,44 @@ class TestCommands(TestBase):
if key == 'topdir':
return topsrcdir
return TestBase._run_mach(self, args, providers,
context_handler=context_handler)
return tester._run_mach(args, providers, context_handler=context_handler)
def format(self, targets):
return "\n".join(targets) + "\n"
return inner
def test_mach_completion(self):
result, stdout, stderr = self._run_mach(['mach-completion'])
assert result == 0
assert stdout == self.format(self.all_commands)
result, stdout, stderr = self._run_mach(['mach-completion', 'cmd_f'])
assert result == 0
# While it seems like this should return only commands that have
# 'cmd_f' as a prefix, the completion script will handle this case
# properly.
assert stdout == self.format(self.all_commands)
def format(targets):
return "\n".join(targets) + "\n"
result, stdout, stderr = self._run_mach(['mach-completion', 'cmd_foo'])
assert result == 0
assert stdout == self.format(['help', '--arg'])
def test_mach_completion(run_mach):
result, stdout, stderr = run_mach(['mach-completion'])
assert result == 0
assert stdout == format(ALL_COMMANDS)
result, stdout, stderr = run_mach(['mach-completion', 'cmd_f'])
assert result == 0
# While it seems like this should return only commands that have
# 'cmd_f' as a prefix, the completion script will handle this case
# properly.
assert stdout == format(ALL_COMMANDS)
result, stdout, stderr = run_mach(['mach-completion', 'cmd_foo'])
assert result == 0
assert stdout == format(['help', '--arg'])
@pytest.mark.parametrize("shell", ("bash", "fish", "zsh"))
def test_generate_mach_completion_script(run_mach, shell):
rv, out, err = run_mach(["mach-completion", shell])
print(out)
print(err, file=sys.stderr)
assert rv == 0
assert err == ""
assert "cmd_foo" in out
assert "arg" in out
assert "cmd_foobar" in out
assert "cmd_bar" in out
if __name__ == '__main__':