#!/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):
    # These C compiler flags are required for on-device targets.
    REQUIRED_DEVICE_CC_FLAGS = set([
        "-Wall",
        "-Werror",
        "-Wextra",
        "-Wgnu",
        "-Wimplicit-fallthrough",
        "-Winvalid-pch",
        "-Wno-covered-switch-default",
        "-Wno-error=unused-function",
        "-Wno-gnu-zero-variadic-macro-arguments",
        "-Wno-missing-field-initializers",
        "-Wno-sign-compare",
        "-Wno-unused-parameter",
        "-Wstrict-prototypes",
        "-Wswitch-default",
        "-Wconversion",
    ])

    paths = PathBuilder(os.path.realpath(__file__))

    device_commands = []
    device_unittest_commands = []
    host_commands = []
    host_unittest_commands = []

    if not args.audit_device_cc_flags:
        device_commands, device_unittest_commands = build_compile_commands(
            paths, device_build=True)
        host_commands, host_unittest_commands = build_compile_commands(
            paths, device_build=False)
    else:
        all_device_commands, _ = build_compile_commands(paths,
                                                        device_build=True)

        troubled_sources = []

        for command in all_device_commands:
            arguments = command["arguments"]
            source_file = command["file"]
            assert "-march=rv32imc" in arguments

            missing_flags = REQUIRED_DEVICE_CC_FLAGS - set(arguments)
            if not missing_flags:
                continue

            troubled_sources.append(source_file)

            command["missing_cc_flags"] = sorted(list(missing_flags))
            device_commands.append(command)

        logger.warning(
            "Audit found %d sources that are missing required flags",
            len(set(troubled_sources)))

    # 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(
        '--audit_device_cc_flags',
        action='store_true',
        help=
        'When specified, only non-test device sources missing required flags will be emitted.'
    )
    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)
