From 20febacc0c02545684d3429fc21f28cf7ed8f461 Mon Sep 17 00:00:00 2001 From: Akira Baruah Date: Fri, 16 Oct 2020 02:10:37 -0700 Subject: [PATCH] Generate fish shell completion at build time Fish shell completion for bazel previously relied on parsing bazel help text at run time, leading to latency of multiple seconds for first-time completion. This change adds a script that instead parses bazel help text and generates an appropriate fish completion script at build time, greatly reducing completion latency for the user. Fixes #12206 Fixes #12207 Fixes #12208 Fixes #12209 Fixes #12210 Closes #12249. PiperOrigin-RevId: 337468387 --- scripts/BUILD | 22 +- scripts/fish/BUILD | 9 - scripts/fish/README.md | 5 +- scripts/fish/completions/bazel.fish | 141 ------------ scripts/generate_fish_completion.py | 322 ++++++++++++++++++++++++++++ 5 files changed, 346 insertions(+), 153 deletions(-) delete mode 100644 scripts/fish/BUILD delete mode 100644 scripts/fish/completions/bazel.fish create mode 100644 scripts/generate_fish_completion.py diff --git a/scripts/BUILD b/scripts/BUILD index 83e778c230e02b..c477439950b45c 100644 --- a/scripts/BUILD +++ b/scripts/BUILD @@ -40,10 +40,30 @@ filegroup( name = "srcs", srcs = glob(["**"]) + [ "//scripts/docs:srcs", - "//scripts/fish:srcs", "//scripts/packages:srcs", "//scripts/release:srcs", "//scripts/zsh_completion:srcs", ], visibility = ["//:__pkg__"], ) + +py_binary( + name = "generate_fish_completion", + srcs = ["generate_fish_completion.py"], + deps = ["//third_party/py/abseil"], +) + +genrule( + name = "fish_completion", + outs = ["bazel.fish"], + cmd = " ".join([ + "$(location :generate_fish_completion)", + "--bazel=$(location //src:bazel)", + "--output=$@", + ]), + tools = [ + ":generate_fish_completion", + "//src:bazel", + ], + visibility = ["//scripts/packages:__subpackages__"], +) diff --git a/scripts/fish/BUILD b/scripts/fish/BUILD deleted file mode 100644 index d8f608fbbf7361..00000000000000 --- a/scripts/fish/BUILD +++ /dev/null @@ -1,9 +0,0 @@ -exports_files([ - "completions/bazel.fish", -]) - -filegroup( - name = "srcs", - srcs = glob(["**"]), - visibility = ["//scripts:__pkg__"], -) diff --git a/scripts/fish/README.md b/scripts/fish/README.md index 69024f94724015..d88261a5ad885b 100644 --- a/scripts/fish/README.md +++ b/scripts/fish/README.md @@ -1,4 +1,5 @@ # fish completions -To enable bazel completions, copy //scripts/fish/completions/bazel.fish to -~/.config/fish/completions/bazel.fish. +To enable bazel completions, run `bazel build //scripts:fish_completion` and +copy the resulting script from `bazel-bin/scripts/bazel.fish` to +`~/.config/fish/completions/bazel.fish`. diff --git a/scripts/fish/completions/bazel.fish b/scripts/fish/completions/bazel.fish deleted file mode 100644 index db1d0170b2fab8..00000000000000 --- a/scripts/fish/completions/bazel.fish +++ /dev/null @@ -1,141 +0,0 @@ -# Copyright 2020 The Bazel Authors. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# fish completion for bazel - -set __bazel_command "bazel" -set __bazel_completion_text ($__bazel_command help completion 2>/dev/null) -set __bazel_help_text ($__bazel_command help 2>/dev/null) -set __bazel_startup_options_text ($__bazel_command help startup_options 2>/dev/null) - -function __bazel_get_completion_variable \ - -d 'Print contents of a completion helper variable from `bazel help completion`' - set -l var $argv[1] - set -l regex (string join '' "$var=\"\\([^\"]*\\)\"") - echo $__bazel_completion_text | grep -o $regex | sed "s/$regex/\\1/" | string trim -end - -set __bazel_subcommands (__bazel_get_completion_variable BAZEL_COMMAND_LIST | string split ' ') - -function __bazel_seen_subcommand \ - -d 'Check whether the current command line contains a bazel subcommand' - set -l subcommand $argv[1] - if test -n "$subcommand" - __fish_seen_subcommand_from $subcommand - else - __fish_seen_subcommand_from $__bazel_subcommands - end -end - -function __bazel_get_options \ - -d 'Parse bazel help text for options and print each option and its description' - set -l help_text_lines $argv - set -l regex '^[[:space:]]*--\(\[no\]\)\?\([_[:alnum:]]\+\)[[:space:]]\+(\(.*\))$' - printf '%s\n' $help_text_lines | grep $regex | sed "s/$regex/\\2 \\3/" -end - -function __bazel_complete_option \ - -d 'Set up completion for a bazel option with a given condition' - set -l condition $argv[1] - set -l option $argv[2] - set -l desc $argv[3..-1] - - set -l complete_opts -c $__bazel_command -n $condition - if string match -qr boolean $desc - complete $complete_opts -l "$option" - complete $complete_opts -l "no$option" - else if test -n "$desc" - complete $complete_opts -rl "$option" -d "$desc" - else - complete $complete_opts -rl "$option" - end -end - -function __bazel_complete_startup_options \ - -d 'Set up completion for all bazel startup options' - for line in (__bazel_get_options (printf '%s\n' $__bazel_startup_options_text)) - __bazel_complete_option "not __bazel_seen_subcommand" (string split ' ' $line) - end -end - -function __bazel_describe_subcommand \ - -d 'Print description text for a bazel subcommand' - set -l subcommand $argv[1] - set -l regex (string join '' '^[[:space:]]*' $subcommand '[[:space:]]\+\(.*\)$') - printf '%s\n' $__bazel_help_text | grep -m1 $regex | sed "s/$regex/\\1/" -end - -function __bazel_get_subcommand_arg_type \ - -d 'Print the expected argument type of a bazel subcommand' - set -l subcommand $argv[1] - set -l formatted_subcommand (string upper $subcommand | tr '-' '_') - set -l var (string join '' 'BAZEL_COMMAND_' $formatted_subcommand '_ARGUMENT') - __bazel_get_completion_variable $var -end - -function __bazel_get_subcommand_args \ - -d 'Print an argument string for subcommand completion' - set -l subcommand $argv[1] - set -l arg_type (__bazel_get_subcommand_arg_type $subcommand) - - switch $arg_type - case "label" - echo "($__bazel_command query -k '//...' 2>/dev/null)" - case "label-bin" - echo "($__bazel_command query -k 'kind(\".*_binary\", //...)' 2>/dev/null)" - case "label-test" - echo "($__bazel_command query -k 'tests(//...)' 2>/dev/null)" - case "command*" - printf '%s\n' $__bazel_subcommands - echo $arg_type | sed 's/command|{\(.*\)}/\1/' | string split ',' - case "info-key" - __bazel_get_completion_variable BAZEL_INFO_KEYS | string split ' ' - end -end - -function __bazel_complete_subcommand \ - -d 'Set up completion for a given bazel subcommand' - set -l subcommand $argv[1] - - set -l desc (__bazel_describe_subcommand $subcommand) - if test -n "$desc" - complete -c $__bazel_command -n "not __bazel_seen_subcommand" -xa $subcommand -d $desc - else - complete -c $__bazel_command -n "not __bazel_seen_subcommand" -xa $subcommand - end - - set -l opts (__bazel_get_options (bazel help $subcommand 2>/dev/null)) - if test -n "$opts" - for line in $opts - __bazel_complete_option "__bazel_seen_subcommand $subcommand" (string split ' ' $line) - end - end - - set -l args (__bazel_get_subcommand_args $subcommand) - if test -n "$args" - complete -c $__bazel_command -n "__bazel_seen_subcommand $subcommand" -fa "$args" - else - complete -c $__bazel_command -n "__bazel_seen_subcommand $subcommand" - end -end - -function __bazel_complete_subcommands \ - -d 'Set up completion for all bazel subcommands' - for subcommand in $__bazel_subcommands - __bazel_complete_subcommand $subcommand - end -end - -__bazel_complete_startup_options -__bazel_complete_subcommands diff --git a/scripts/generate_fish_completion.py b/scripts/generate_fish_completion.py new file mode 100644 index 00000000000000..40018ade7c3645 --- /dev/null +++ b/scripts/generate_fish_completion.py @@ -0,0 +1,322 @@ +# Copyright 2020 The Bazel Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http:#www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Generates a fish completion script for Bazel.""" + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +import re +import subprocess +import tempfile + +from absl import app +from absl import flags + +flags.DEFINE_string('bazel', None, 'Path to the bazel binary') +flags.DEFINE_string('output', None, 'Where to put the generated fish script') + +flags.mark_flag_as_required('bazel') +flags.mark_flag_as_required('output') + +FLAGS = flags.FLAGS +_BAZEL = 'bazel' +_FISH_SEEN_SUBCOMMAND_FROM = '__fish_seen_subcommand_from' +_FISH_BAZEL_HEADER = """#!/usr/bin/env fish +# Copyright 2020 The Bazel Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Fish completion for Bazel commands and options. +# +# This script was generated from a specific Bazel build distribution. See +# https://github.com/bazelbuild/bazel/blob/master/scripts/generate_fish_completion.py +# for details and implementation. +""" +_FISH_BAZEL_COMMAND_LIST_VAR = 'BAZEL_COMMAND_LIST' +_FISH_BAZEL_SEEN_SUBCOMMAND = '__bazel_seen_subcommand' +_FISH_BAZEL_SEEN_SUBCOMMAND_DEF = """ +function {} -d "Checks whether the current command line contains a bazel subcommand." + {} ${} +end +""".format(_FISH_BAZEL_SEEN_SUBCOMMAND, _FISH_SEEN_SUBCOMMAND_FROM, + _FISH_BAZEL_COMMAND_LIST_VAR) + + +class BazelCompletionWriter(object): + """Constructs a Fish completion script for Bazel.""" + + def __init__(self, bazel, output_user_root): + """Initializes writer state. + + Args: + bazel: String containing a path the a bazel binary to run. + output_user_root: String path to user root directory used for + running bazel commands. + """ + self._bazel = bazel + self._output_user_root = output_user_root + self._startup_options = self._get_options_from_bazel( + ('help', 'startup_options')) + self._bazel_help_completion_text = self._get_bazel_output( + ('help', 'completion')) + self._param_types_by_subcommand = self._get_param_types() + self._subcommands = self._get_subcommands() + + def write_completion(self, output_file): + """Writes a Fish completion script for Bazel to an output file. + + Args: + output_file: File object opened in a writable mode. + """ + output_file.write(_FISH_BAZEL_HEADER) + output_file.write('set {} {}\n'.format( + _FISH_BAZEL_COMMAND_LIST_VAR, + ' '.join(c.name for c in self._subcommands))) + output_file.write(_FISH_BAZEL_SEEN_SUBCOMMAND_DEF) + for opt in self._startup_options: + opt.write_completion(output_file) + for sub in self._subcommands: + sub.write_completion(output_file) + + def _get_bazel_output(self, args): + return subprocess.check_output( + (self._bazel, '--output_user_root={}'.format(self._output_user_root)) + + tuple(args), + universal_newlines=True) + + def _get_options_from_bazel(self, bazel_args, **kwargs): + output = self._get_bazel_output(bazel_args) + return list( + Arg.generate_from_help( + r'^\s*--(\[no\])?(?P\w+)\s+\((?P.*)\)$', output, + **kwargs)) + + def _get_param_types(self): + param_types = {} + for match in re.finditer( + r'^BAZEL_COMMAND_(?P.*)_ARGUMENT="(?P.*)"$', + self._bazel_help_completion_text, re.MULTILINE): + sub = self._normalize_subcommand_name(match.group('subcommand')) + param_types[sub] = match.group('type') + return param_types + + def _get_subcommands(self): + """Runs `bazel help` and parses its output to derive Bazel commands. + + Returns: + (:obj:`list` of :obj:`Arg`): List of Bazel commands. + """ + subs = [] + output = self._get_bazel_output(('help',)) + block = re.search(r'Available commands:(.*\n\n)', output, re.DOTALL) + for sub in Arg.generate_from_help( + r'^\s*(?P\S+)\s*(?P\S+.*\.)\s*$', + block.group(1), + is_subcommand=True): + sub.sub_opts = self._get_options_from_bazel(('help', sub.name), + expected_subcommand=sub.name) + sub.sub_params = self._get_params(sub.name) + subs.append(sub) + return subs + + _BAZEL_QUERY_BY_LABEL = { + 'label': r'//...', + 'label-bin': r'kind(".*_binary", //...)', + 'label-test': r'tests(//...)', + } + + def _get_params(self, subcommand): + """Produces a list of param completions for a given Bazel command. + + Uses a previously generated mapping of Bazel commands to parameter types + to determine how to complete params following a given command. For + example, `bazel build` expects `label` type params, whereas `bazel info` + expects an `info-key` type. The param type is finally translated into a + list of completion strings. + + Args: + subcommand: Bazel command string. + + Returns: + (:obj:`list` of :obj:`str`): List of completions based on the param + type for the given Bazel command. + """ + name = self._normalize_subcommand_name(subcommand) + if name not in self._param_types_by_subcommand: + return [] + params = [] + param_type = self._param_types_by_subcommand[name] + if param_type.startswith('label'): + query = self._BAZEL_QUERY_BY_LABEL[param_type] + params.append("({} query -k '{}' 2>/dev/null)".format(_BAZEL, query)) + elif param_type.startswith('command'): + match = re.match(r'command\|\{(?P.*)\}', param_type) + params.extend(match.group('commands').split(',')) + elif param_type == 'info-key': + match = re.search(r'BAZEL_INFO_KEYS="(?P[^"]*)"', + self._bazel_help_completion_text) + params.extend(match.group('keys').split()) + return params + + @staticmethod + def _normalize_subcommand_name(subcommand): + return subcommand.strip().lower().replace('_', '-') + + +class Arg(object): + """Represents a Bazel argument and its metadata. + + Attributes: + name: String containing the name of the argument. + desc: String describing the argument usage. + is_subcommand: True if this arg represents a Bazel subcommand. Defaults + to False, indicating that this arg is an option flag. + expected_subcommand: Nullable string containing a subcommand that this + option must follow. Defaults to None, indicating that this option or + subcommand must not follow another subcommand. + sub_opts: List of Args representing options of a subcommand. Used only + if is_subcommand is True. + sub_params: List of Args representing parameters of a subcommand. Used + only if is_subcommand is True. + """ + + def __init__(self, + name, + desc=None, + is_subcommand=False, + expected_subcommand=None): + self.name = name + self.desc = desc + self.is_subcommand = is_subcommand + self.expected_subcommand = expected_subcommand + self.sub_opts = [] + self.sub_params = [] + self._is_boolean = (self.desc and self.desc.startswith('a boolean')) + + @classmethod + def generate_from_help(cls, line_regex, text, **kwargs): + """Generates Arg objects using a line regex on a block of help text. + + Args: + line_regex: Regular expression string to match a line of text. + text: String of help text to parse. + **kwargs: Extra keywords to pass into the Arg constructor. + + Yields: + Arg objects parsed from the help text. + """ + for match in re.finditer(line_regex, text, re.MULTILINE): + kwargs.update(match.groupdict()) + yield cls(**kwargs) + + def write_completion(self, output_file, command=_BAZEL): + """Writes Fish completion commands to a file. + + Uses the metadata stored in this class to write Fish shell commands + that enable completion for this Bazel argument. + + Args: + output_file: File object to write completions into. Must be open in + a writable mode. + command: String containg the command name (i.e. "bazel"). + """ + args = self._get_complete_args_base( + command=command, subcommand=self.expected_subcommand) + + # Argument can be subcommand or option flag. + if self.is_subcommand: + args.append('-xa') # Exclusive subcommand argument. + else: + args.append('-l') # Long option. + args.append('"{}"'.format(self.name)) + name_index = len(args) - 1 + + if self.desc: + args.extend(('-d', '"{}"'.format(self._escape(self.desc)))) + + if not self._is_boolean: + args.append('-r') # Require a subsequent parameter. + + # Write completion commands to the file. + output_file.write(self._complete(args)) + if self._is_boolean: + # Include the "false" version of a boolean option. + args[name_index] = '"no{}"'.format(self.name) + output_file.write(self._complete(args)) + if self.is_subcommand: + for opt in self.sub_opts: + opt.write_completion(output_file, command=command) + self._write_params_completion(output_file, command=command) + output_file.write('\n') + + def _write_params_completion(self, output_file, command=_BAZEL): + args = self._get_complete_args_base(command, subcommand=self.name) + if self.sub_params: + args.extend( + ('-fa', '"{}"'.format(self._escape(' '.join(self.sub_params))))) + output_file.write(self._complete(args)) + + @staticmethod + def _get_complete_args_base(command, subcommand=None): + """Provides basic arguments for all fish `complete` invocations. + + Args: + command: Name of the Bazel executable (i.e. "bazel"). + subcommand: Optional Bazel command like "build". + + Returns: + (:obj:`list` of :obj:`str`): List of args for `complete`. + """ + args = ['-c', command] + + # Completion pre-condition. + args.append('-n') + if subcommand: + args.append('"{} {}"'.format(_FISH_SEEN_SUBCOMMAND_FROM, subcommand)) + else: + args.append('"not {}"'.format(_FISH_BAZEL_SEEN_SUBCOMMAND)) + + return args + + @staticmethod + def _complete(args): + return 'complete {}\n'.format(' '.join(args)) + + @staticmethod + def _escape(text): + return text.replace('"', r'\"') + + +def main(argv): + """Generates fish completion using provided flags.""" + del argv # Unused. + with tempfile.TemporaryDirectory() as output_user_root: + writer = BazelCompletionWriter(FLAGS.bazel, output_user_root) + with open(FLAGS.output, mode='w') as output: + writer.write_completion(output) + + +if __name__ == '__main__': + app.run(main)