Akira Baruah | 20febac | 2020-10-16 02:10:37 -0700 | [diff] [blame] | 1 | # Copyright 2020 The Bazel Authors. All rights reserved. |
| 2 | # |
| 3 | # Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | # you may not use this file except in compliance with the License. |
| 5 | # You may obtain a copy of the License at |
| 6 | # |
Googler | bd7a6b9 | 2022-02-24 07:38:58 -0800 | [diff] [blame] | 7 | # http://www.apache.org/licenses/LICENSE-2.0 |
Akira Baruah | 20febac | 2020-10-16 02:10:37 -0700 | [diff] [blame] | 8 | # |
| 9 | # Unless required by applicable law or agreed to in writing, software |
| 10 | # distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | # See the License for the specific language governing permissions and |
| 13 | # limitations under the License. |
| 14 | """Generates a fish completion script for Bazel.""" |
| 15 | |
| 16 | from __future__ import absolute_import |
| 17 | from __future__ import division |
| 18 | from __future__ import print_function |
| 19 | |
| 20 | import re |
| 21 | import subprocess |
| 22 | import tempfile |
| 23 | |
| 24 | from absl import app |
| 25 | from absl import flags |
| 26 | |
| 27 | flags.DEFINE_string('bazel', None, 'Path to the bazel binary') |
| 28 | flags.DEFINE_string('output', None, 'Where to put the generated fish script') |
| 29 | |
| 30 | flags.mark_flag_as_required('bazel') |
| 31 | flags.mark_flag_as_required('output') |
| 32 | |
| 33 | FLAGS = flags.FLAGS |
| 34 | _BAZEL = 'bazel' |
| 35 | _FISH_SEEN_SUBCOMMAND_FROM = '__fish_seen_subcommand_from' |
| 36 | _FISH_BAZEL_HEADER = """#!/usr/bin/env fish |
| 37 | # Copyright 2020 The Bazel Authors. All rights reserved. |
| 38 | # |
| 39 | # Licensed under the Apache License, Version 2.0 (the "License"); |
| 40 | # you may not use this file except in compliance with the License. |
| 41 | # You may obtain a copy of the License at |
| 42 | # |
| 43 | # http://www.apache.org/licenses/LICENSE-2.0 |
| 44 | # |
| 45 | # Unless required by applicable law or agreed to in writing, software |
| 46 | # distributed under the License is distributed on an "AS IS" BASIS, |
| 47 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 48 | # See the License for the specific language governing permissions and |
| 49 | # limitations under the License. |
| 50 | |
| 51 | # Fish completion for Bazel commands and options. |
| 52 | # |
| 53 | # This script was generated from a specific Bazel build distribution. See |
| 54 | # https://github.com/bazelbuild/bazel/blob/master/scripts/generate_fish_completion.py |
| 55 | # for details and implementation. |
| 56 | """ |
| 57 | _FISH_BAZEL_COMMAND_LIST_VAR = 'BAZEL_COMMAND_LIST' |
| 58 | _FISH_BAZEL_SEEN_SUBCOMMAND = '__bazel_seen_subcommand' |
| 59 | _FISH_BAZEL_SEEN_SUBCOMMAND_DEF = """ |
| 60 | function {} -d "Checks whether the current command line contains a bazel subcommand." |
| 61 | {} ${} |
| 62 | end |
| 63 | """.format(_FISH_BAZEL_SEEN_SUBCOMMAND, _FISH_SEEN_SUBCOMMAND_FROM, |
| 64 | _FISH_BAZEL_COMMAND_LIST_VAR) |
| 65 | |
| 66 | |
| 67 | class BazelCompletionWriter(object): |
| 68 | """Constructs a Fish completion script for Bazel.""" |
| 69 | |
| 70 | def __init__(self, bazel, output_user_root): |
| 71 | """Initializes writer state. |
| 72 | |
| 73 | Args: |
| 74 | bazel: String containing a path the a bazel binary to run. |
| 75 | output_user_root: String path to user root directory used for |
| 76 | running bazel commands. |
| 77 | """ |
| 78 | self._bazel = bazel |
| 79 | self._output_user_root = output_user_root |
| 80 | self._startup_options = self._get_options_from_bazel( |
| 81 | ('help', 'startup_options')) |
| 82 | self._bazel_help_completion_text = self._get_bazel_output( |
| 83 | ('help', 'completion')) |
| 84 | self._param_types_by_subcommand = self._get_param_types() |
| 85 | self._subcommands = self._get_subcommands() |
| 86 | |
| 87 | def write_completion(self, output_file): |
| 88 | """Writes a Fish completion script for Bazel to an output file. |
| 89 | |
| 90 | Args: |
| 91 | output_file: File object opened in a writable mode. |
| 92 | """ |
| 93 | output_file.write(_FISH_BAZEL_HEADER) |
| 94 | output_file.write('set {} {}\n'.format( |
| 95 | _FISH_BAZEL_COMMAND_LIST_VAR, |
| 96 | ' '.join(c.name for c in self._subcommands))) |
| 97 | output_file.write(_FISH_BAZEL_SEEN_SUBCOMMAND_DEF) |
| 98 | for opt in self._startup_options: |
| 99 | opt.write_completion(output_file) |
| 100 | for sub in self._subcommands: |
| 101 | sub.write_completion(output_file) |
| 102 | |
| 103 | def _get_bazel_output(self, args): |
| 104 | return subprocess.check_output( |
| 105 | (self._bazel, '--output_user_root={}'.format(self._output_user_root)) + |
| 106 | tuple(args), |
| 107 | universal_newlines=True) |
| 108 | |
| 109 | def _get_options_from_bazel(self, bazel_args, **kwargs): |
| 110 | output = self._get_bazel_output(bazel_args) |
| 111 | return list( |
| 112 | Arg.generate_from_help( |
| 113 | r'^\s*--(\[no\])?(?P<name>\w+)\s+\((?P<desc>.*)\)$', output, |
| 114 | **kwargs)) |
| 115 | |
| 116 | def _get_param_types(self): |
| 117 | param_types = {} |
| 118 | for match in re.finditer( |
| 119 | r'^BAZEL_COMMAND_(?P<subcommand>.*)_ARGUMENT="(?P<type>.*)"$', |
| 120 | self._bazel_help_completion_text, re.MULTILINE): |
| 121 | sub = self._normalize_subcommand_name(match.group('subcommand')) |
| 122 | param_types[sub] = match.group('type') |
| 123 | return param_types |
| 124 | |
| 125 | def _get_subcommands(self): |
| 126 | """Runs `bazel help` and parses its output to derive Bazel commands. |
| 127 | |
| 128 | Returns: |
| 129 | (:obj:`list` of :obj:`Arg`): List of Bazel commands. |
| 130 | """ |
| 131 | subs = [] |
| 132 | output = self._get_bazel_output(('help',)) |
| 133 | block = re.search(r'Available commands:(.*\n\n)', output, re.DOTALL) |
| 134 | for sub in Arg.generate_from_help( |
| 135 | r'^\s*(?P<name>\S+)\s*(?P<desc>\S+.*\.)\s*$', |
| 136 | block.group(1), |
| 137 | is_subcommand=True): |
| 138 | sub.sub_opts = self._get_options_from_bazel(('help', sub.name), |
| 139 | expected_subcommand=sub.name) |
| 140 | sub.sub_params = self._get_params(sub.name) |
| 141 | subs.append(sub) |
| 142 | return subs |
| 143 | |
| 144 | _BAZEL_QUERY_BY_LABEL = { |
| 145 | 'label': r'//...', |
| 146 | 'label-bin': r'kind(".*_binary", //...)', |
| 147 | 'label-test': r'tests(//...)', |
| 148 | } |
| 149 | |
| 150 | def _get_params(self, subcommand): |
| 151 | """Produces a list of param completions for a given Bazel command. |
| 152 | |
| 153 | Uses a previously generated mapping of Bazel commands to parameter types |
| 154 | to determine how to complete params following a given command. For |
| 155 | example, `bazel build` expects `label` type params, whereas `bazel info` |
| 156 | expects an `info-key` type. The param type is finally translated into a |
| 157 | list of completion strings. |
| 158 | |
| 159 | Args: |
| 160 | subcommand: Bazel command string. |
| 161 | |
| 162 | Returns: |
| 163 | (:obj:`list` of :obj:`str`): List of completions based on the param |
| 164 | type for the given Bazel command. |
| 165 | """ |
| 166 | name = self._normalize_subcommand_name(subcommand) |
| 167 | if name not in self._param_types_by_subcommand: |
| 168 | return [] |
| 169 | params = [] |
| 170 | param_type = self._param_types_by_subcommand[name] |
| 171 | if param_type.startswith('label'): |
| 172 | query = self._BAZEL_QUERY_BY_LABEL[param_type] |
| 173 | params.append("({} query -k '{}' 2>/dev/null)".format(_BAZEL, query)) |
| 174 | elif param_type.startswith('command'): |
| 175 | match = re.match(r'command\|\{(?P<commands>.*)\}', param_type) |
| 176 | params.extend(match.group('commands').split(',')) |
| 177 | elif param_type == 'info-key': |
| 178 | match = re.search(r'BAZEL_INFO_KEYS="(?P<keys>[^"]*)"', |
| 179 | self._bazel_help_completion_text) |
| 180 | params.extend(match.group('keys').split()) |
| 181 | return params |
| 182 | |
| 183 | @staticmethod |
| 184 | def _normalize_subcommand_name(subcommand): |
| 185 | return subcommand.strip().lower().replace('_', '-') |
| 186 | |
| 187 | |
| 188 | class Arg(object): |
| 189 | """Represents a Bazel argument and its metadata. |
| 190 | |
| 191 | Attributes: |
| 192 | name: String containing the name of the argument. |
| 193 | desc: String describing the argument usage. |
| 194 | is_subcommand: True if this arg represents a Bazel subcommand. Defaults |
| 195 | to False, indicating that this arg is an option flag. |
| 196 | expected_subcommand: Nullable string containing a subcommand that this |
| 197 | option must follow. Defaults to None, indicating that this option or |
| 198 | subcommand must not follow another subcommand. |
| 199 | sub_opts: List of Args representing options of a subcommand. Used only |
| 200 | if is_subcommand is True. |
| 201 | sub_params: List of Args representing parameters of a subcommand. Used |
| 202 | only if is_subcommand is True. |
| 203 | """ |
| 204 | |
| 205 | def __init__(self, |
| 206 | name, |
| 207 | desc=None, |
| 208 | is_subcommand=False, |
| 209 | expected_subcommand=None): |
| 210 | self.name = name |
| 211 | self.desc = desc |
| 212 | self.is_subcommand = is_subcommand |
| 213 | self.expected_subcommand = expected_subcommand |
| 214 | self.sub_opts = [] |
| 215 | self.sub_params = [] |
| 216 | self._is_boolean = (self.desc and self.desc.startswith('a boolean')) |
| 217 | |
| 218 | @classmethod |
| 219 | def generate_from_help(cls, line_regex, text, **kwargs): |
| 220 | """Generates Arg objects using a line regex on a block of help text. |
| 221 | |
| 222 | Args: |
| 223 | line_regex: Regular expression string to match a line of text. |
| 224 | text: String of help text to parse. |
| 225 | **kwargs: Extra keywords to pass into the Arg constructor. |
| 226 | |
| 227 | Yields: |
| 228 | Arg objects parsed from the help text. |
| 229 | """ |
| 230 | for match in re.finditer(line_regex, text, re.MULTILINE): |
| 231 | kwargs.update(match.groupdict()) |
| 232 | yield cls(**kwargs) |
| 233 | |
| 234 | def write_completion(self, output_file, command=_BAZEL): |
| 235 | """Writes Fish completion commands to a file. |
| 236 | |
| 237 | Uses the metadata stored in this class to write Fish shell commands |
| 238 | that enable completion for this Bazel argument. |
| 239 | |
| 240 | Args: |
| 241 | output_file: File object to write completions into. Must be open in |
| 242 | a writable mode. |
| 243 | command: String containg the command name (i.e. "bazel"). |
| 244 | """ |
| 245 | args = self._get_complete_args_base( |
| 246 | command=command, subcommand=self.expected_subcommand) |
| 247 | |
| 248 | # Argument can be subcommand or option flag. |
| 249 | if self.is_subcommand: |
| 250 | args.append('-xa') # Exclusive subcommand argument. |
| 251 | else: |
| 252 | args.append('-l') # Long option. |
| 253 | args.append('"{}"'.format(self.name)) |
| 254 | name_index = len(args) - 1 |
| 255 | |
| 256 | if self.desc: |
| 257 | args.extend(('-d', '"{}"'.format(self._escape(self.desc)))) |
| 258 | |
| 259 | if not self._is_boolean: |
| 260 | args.append('-r') # Require a subsequent parameter. |
| 261 | |
| 262 | # Write completion commands to the file. |
| 263 | output_file.write(self._complete(args)) |
| 264 | if self._is_boolean: |
| 265 | # Include the "false" version of a boolean option. |
| 266 | args[name_index] = '"no{}"'.format(self.name) |
| 267 | output_file.write(self._complete(args)) |
| 268 | if self.is_subcommand: |
| 269 | for opt in self.sub_opts: |
| 270 | opt.write_completion(output_file, command=command) |
| 271 | self._write_params_completion(output_file, command=command) |
| 272 | output_file.write('\n') |
| 273 | |
| 274 | def _write_params_completion(self, output_file, command=_BAZEL): |
| 275 | args = self._get_complete_args_base(command, subcommand=self.name) |
| 276 | if self.sub_params: |
| 277 | args.extend( |
| 278 | ('-fa', '"{}"'.format(self._escape(' '.join(self.sub_params))))) |
| 279 | output_file.write(self._complete(args)) |
| 280 | |
| 281 | @staticmethod |
| 282 | def _get_complete_args_base(command, subcommand=None): |
| 283 | """Provides basic arguments for all fish `complete` invocations. |
| 284 | |
| 285 | Args: |
| 286 | command: Name of the Bazel executable (i.e. "bazel"). |
| 287 | subcommand: Optional Bazel command like "build". |
| 288 | |
| 289 | Returns: |
| 290 | (:obj:`list` of :obj:`str`): List of args for `complete`. |
| 291 | """ |
| 292 | args = ['-c', command] |
| 293 | |
| 294 | # Completion pre-condition. |
| 295 | args.append('-n') |
| 296 | if subcommand: |
| 297 | args.append('"{} {}"'.format(_FISH_SEEN_SUBCOMMAND_FROM, subcommand)) |
| 298 | else: |
| 299 | args.append('"not {}"'.format(_FISH_BAZEL_SEEN_SUBCOMMAND)) |
| 300 | |
| 301 | return args |
| 302 | |
| 303 | @staticmethod |
| 304 | def _complete(args): |
| 305 | return 'complete {}\n'.format(' '.join(args)) |
| 306 | |
| 307 | @staticmethod |
| 308 | def _escape(text): |
| 309 | return text.replace('"', r'\"') |
| 310 | |
| 311 | |
| 312 | def main(argv): |
| 313 | """Generates fish completion using provided flags.""" |
| 314 | del argv # Unused. |
| 315 | with tempfile.TemporaryDirectory() as output_user_root: |
| 316 | writer = BazelCompletionWriter(FLAGS.bazel, output_user_root) |
| 317 | with open(FLAGS.output, mode='w') as output: |
| 318 | writer.write_completion(output) |
| 319 | |
| 320 | |
| 321 | if __name__ == '__main__': |
| 322 | app.run(main) |