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
diff --git a/scripts/BUILD b/scripts/BUILD
index 83e778c..c477439 100644
--- a/scripts/BUILD
+++ b/scripts/BUILD
@@ -40,10 +40,30 @@
     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 d8f608f..0000000
--- 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 69024f9..d88261a 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 db1d017..0000000
--- 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 0000000..40018ad
--- /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<name>\w+)\s+\((?P<desc>.*)\)$', output,
+            **kwargs))
+
+  def _get_param_types(self):
+    param_types = {}
+    for match in re.finditer(
+        r'^BAZEL_COMMAND_(?P<subcommand>.*)_ARGUMENT="(?P<type>.*)"$',
+        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<name>\S+)\s*(?P<desc>\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<commands>.*)\}', param_type)
+      params.extend(match.group('commands').split(','))
+    elif param_type == 'info-key':
+      match = re.search(r'BAZEL_INFO_KEYS="(?P<keys>[^"]*)"',
+                        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)