| #!/usr/bin/env python3 | |
| # Copyright lowRISC contributors. | |
| # Licensed under the Apache License, Version 2.0, see LICENSE for details. | |
| # SPDX-License-Identifier: Apache-2.0 | |
| """generate_compilation_db.py builds compilation_commands.json from BUILD files. | |
| This tool runs a Bazel Action Graph query (Bazel's "aquery" command) and | |
| transforms the results to produce a compilation database (aka | |
| compile_commands.json). The goal is to enable semantic features like | |
| jump-to-definition and cross-references in IDEs that support | |
| compile_commands.json. | |
| The analysis.ActionGraphContainer protobuf [0] defines aquery's results format. | |
| Clang informally defines the schema of compile_commands.json [1]. | |
| Caveat: this tool only emits the commands for building C/C++ code. | |
| Example: | |
| util/generate_compilation_db.py --target //sw/... --out compile_commands.json | |
| Tip: If your IDE complains that it cannot find headers, e.g. "gmock/gmock.h", it | |
| might be telling the truth. Try building the relevant target with Bazel | |
| (specifying "--config=riscv32" as necessary) and then restart clangd. | |
| [0]: https://github.com/bazelbuild/bazel/blob/master/src/main/protobuf/analysis_v2.proto | |
| [1]: https://clang.llvm.org/docs/JSONCompilationDatabase.html | |
| """ | |
| import argparse | |
| import json | |
| import logging | |
| import os | |
| import subprocess | |
| import sys | |
| from typing import Dict, List, Tuple | |
| logger = logging.getLogger('generate_compilation_db') | |
| def build_id_lookup_dict(dicts: List[Dict]): | |
| """Create a dict from `dicts` indexed by the "id" key.""" | |
| lookup = {} | |
| for d in dicts: | |
| lookup[d['id']] = d | |
| return lookup | |
| class BazelAqueryResults: | |
| """Corresponds to Bazel's analysis.ActionGraphContainer protobuf.""" | |
| def __init__(self, output: str): | |
| parsed = json.loads(output) | |
| self.actions = [ | |
| BazelAqueryAction(action) for action in parsed['actions'] | |
| ] | |
| self.dep_sets_ = build_id_lookup_dict(parsed['depSetOfFiles']) | |
| self.artifacts_ = build_id_lookup_dict(parsed['artifacts']) | |
| self.path_fragments_ = build_id_lookup_dict(parsed['pathFragments']) | |
| def reconstruct_path(self, id: int): | |
| """Reconstruct a file path from Bazel aquery fragments.""" | |
| labels = [] | |
| while True: | |
| path_fragment = self.path_fragments_[id] | |
| labels.append(path_fragment['label']) | |
| if 'parentId' not in path_fragment: | |
| break | |
| id = path_fragment['parentId'] | |
| # For our purposes, `os.sep.join()` should be equivalent to | |
| # `os.path.join()`, but without the additional overhead. | |
| return os.sep.join(reversed(labels)) | |
| def iter_artifacts_for_dep_sets(self, dep_set_ids: List[int]): | |
| """Iterate the reconstructed paths of all artifacts related to `dep_set_ids`.""" | |
| SOURCE_EXTENSIONS = [".h", ".c", ".cc"] | |
| dep_set_id_stack = dep_set_ids | |
| while len(dep_set_id_stack) > 0: | |
| dep_set_id = dep_set_id_stack.pop() | |
| dep_set = self.dep_sets_[dep_set_id] | |
| for direct_artifact_id in dep_set.get('directArtifactIds', []): | |
| artifact = self.artifacts_[direct_artifact_id] | |
| path_fragment_id = artifact['pathFragmentId'] | |
| path = self.reconstruct_path(path_fragment_id) | |
| if path.startswith("external/"): | |
| continue | |
| if not any(path.endswith(ext) for ext in SOURCE_EXTENSIONS): | |
| continue | |
| yield path | |
| for transitive_dep_set_id in dep_set.get('transitiveDepSetIds', | |
| []): | |
| dep_set_id_stack.append(transitive_dep_set_id) | |
| class BazelAqueryAction: | |
| """Corresponds to Bazel's analysis.Action protobuf.""" | |
| def __init__(self, action: Dict): | |
| self.mnemonic = action.get('mnemonic', None) | |
| self.arguments = action.get('arguments', None) | |
| self.input_dep_set_ids = action.get('inputDepSetIds', []) | |
| def transform_arguments_for_clangd(self) -> List[str]: | |
| """Return modified arguments for compatibility with Clangd. | |
| It appears that Clangd fails to infer the desired target from the | |
| compiler name. For instance, this is the path to our cross-compiler: | |
| `external/crt/toolchains/lowrisc_rv32imcb/wrappers/clang`. Specifically, | |
| Clangd fails to launch a compiler instance if it sees `--march=rv32imc` | |
| or `--mabi=ilp32`. | |
| This function explicitly tells Clangd which target we want by inserting | |
| a `--target=riscv32` flag as needed. | |
| """ | |
| args = self.arguments | |
| if not args: | |
| return args | |
| compiler_path = args[0] | |
| if 'lowrisc_rv32imcb' in compiler_path: | |
| return [compiler_path, '--target=riscv32'] + args[1:] | |
| return args | |
| class PathBuilder: | |
| """Helper class that builds useful paths relative to this source file.""" | |
| def __init__(self, script_path): | |
| util_dir = os.path.dirname(script_path) | |
| self.top_dir = os.path.dirname(util_dir) | |
| if self.top_dir == '': | |
| raise Exception('Could not find parent of the util directory.') | |
| self.bazelisk_script = os.path.join(self.top_dir, 'bazelisk.sh') | |
| # Bazel creates a symlink to execRoot based on the workspace name. | |
| # https://bazel.build/remote/output-directories | |
| self.bazel_exec_root = os.path.join( | |
| self.top_dir, f"bazel-{os.path.basename(self.top_dir)}") | |
| def build_compile_commands( | |
| paths: PathBuilder, | |
| device_build: bool) -> Tuple[List[Dict], List[Dict]]: | |
| bazel_aquery_command = [ | |
| paths.bazelisk_script, | |
| 'aquery', | |
| '--output=jsonproto', | |
| ] | |
| if device_build: | |
| bazel_aquery_command.append('--config=riscv32') | |
| bazel_aquery_command.append(f'mnemonic("CppCompile", {args.target})') | |
| logger.info("Running bazel command: %s", bazel_aquery_command) | |
| try: | |
| completed_process = subprocess.run(bazel_aquery_command, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| check=True) | |
| except subprocess.CalledProcessError as e: | |
| print(e.stderr.decode('utf-8'), file=sys.stderr) | |
| raise | |
| except BaseException: | |
| raise | |
| logger.info("Processing output from bazel aquery") | |
| aquery_results = BazelAqueryResults( | |
| completed_process.stdout.decode('utf-8')) | |
| compile_commands = [] | |
| unittest_compile_commands = [] | |
| for action in aquery_results.actions: | |
| assert action.mnemonic == 'CppCompile' | |
| assert action.arguments != [] | |
| arguments = action.transform_arguments_for_clangd() | |
| for artifact in aquery_results.iter_artifacts_for_dep_sets( | |
| action.input_dep_set_ids): | |
| command = { | |
| 'directory': paths.bazel_exec_root, | |
| 'arguments': arguments, | |
| 'file': artifact, | |
| } | |
| if artifact.endswith("_unittest.cc"): | |
| unittest_compile_commands.append(command) | |
| else: | |
| compile_commands.append(command) | |
| return (compile_commands, unittest_compile_commands) | |
| def main(args): | |
| paths = PathBuilder(os.path.realpath(__file__)) | |
| device_commands, device_unittest_commands = build_compile_commands( | |
| paths, device_build=True) | |
| host_commands, host_unittest_commands = build_compile_commands( | |
| paths, device_build=False) | |
| # In case there are conflicting host and device commands for "*_unittest.cc" | |
| # sources, we strategically place the host commands first. Conversely, we | |
| # favor the device commands for non-test sources. | |
| all_compile_commands = device_commands + host_commands + \ | |
| host_unittest_commands + device_unittest_commands | |
| logger.info("Writing compile commands to %s", args.out) | |
| compile_commands_json = json.dumps(all_compile_commands, indent=4) | |
| if not args.out: | |
| print(compile_commands_json) | |
| return | |
| with open(args.out, 'w') as output_file: | |
| output_file.write(compile_commands_json) | |
| if __name__ == '__main__': | |
| parser = argparse.ArgumentParser( | |
| description=__doc__, formatter_class=argparse.RawTextHelpFormatter) | |
| parser.add_argument('--target', | |
| default='//...', | |
| help='Bazel target. Default is "//...".') | |
| parser.add_argument( | |
| '--out', | |
| help='Path of output file for compilation DB. Defaults to stdout.') | |
| if len(sys.argv) == 1: | |
| parser.print_help() | |
| sys.exit(1) | |
| args = parser.parse_args() | |
| logging.basicConfig(format='%(asctime)s %(message)s') | |
| logger.setLevel(logging.DEBUG) | |
| main(args) |