blob: 8750a586071a36311c87992d87f32477f5d1f445 [file] [log] [blame]
#!/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)