chromium/ios/build/tools/update_deps.py

#!/usr/bin/env python3
# Copyright 2022 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

"""
Updates BUILD.gn files to add missing direct dependencies when the
transitive dependency already exists.
The script is intentionally conservative and will not work in many
scenarios (e.g it does not handle target names with variables). These
cases will require manual roll (as today).
"""

import argparse
from collections import defaultdict
import os
import re
import subprocess
import sys

# The output of gn check when some direct dependencies are missing is expected
# to contain these lines.
TRANSITIVE_PATTERNS = [
    (3, "The target:"), (5, "is including a file from the target:"),
    (8, "It's usually best to depend directly on the destination target."),
    (9, "In some cases, the destination target is considered a subcomponent"),
    (10, "of an intermediate target. In this case, the intermediate target"),
    (11, "should depend publicly on the destination to forward the ability"),
    (12, "to include headers."),
    (14, "Dependency chain (there may also be others):")
]
# The line containing the target in the error message.
TRANSITIVE_DEPENDENT_LINE_NUMBER = 4
# The line containing the dependence in the error message.
TRANSITIVE_DEPENDEE_LINE_NUMBER = 6

MISSING_PATTERNS = [
    (3, "It is not in any dependency of"),
    (5, "The include file is in the target(s):"),
]
MISSING_DEPENDENT_LINE_NUMBER = 4
MISSING_DEPENDEE_LINE_NUMBER = 6

# Character to set colors in terminal.
TERMINAL_ERROR_COLOR = "\033[91m"
TERMINAL_WARNING_COLOR = "\033[93m"
TERMINAL_RESET_COLOR = "\033[0m"

# Separator between GN errors.
GN_ERROR_SEPARATOR = "___________________\n"

# The error message to be used when the include file is in multiple GN targets.
MULTIPLE_DEPENDEES_ERROR = "Cannot handle includes in multiple targets:"
# The error message to be used when several "deps" are present for the target.
MULTIPLE_DEPS_ERROR = "Multiple deps variables in:"
# Warning message when automatically removing a target ending with
# "_strings_grit".
GRIT_TARGET_MESSAGE = "Grit target found!"
GRIT_TARGET_MESSAGE_DETAILS = "Automatically replacing:\n  %s\nby:\n  %s\n"

# Array to handle special cases for canonical public target. This avoids having
# dependencies on internal target when there is a canonical target with public
# deps on those internal targets.
CANONICAL_PUBLIC_TARGETS = {
    "//ios/chrome/app/strings:ios_strings_grit":
    "//ios/chrome/app/strings:strings",
    "//ios/chrome/app/strings:ios_branded_strings_grit":
    "//ios/chrome/app/strings:strings",
    "//components/strings:components_strings_grit":
    "//components/strings:strings",
    "//components/sessions:shared":
    "//components/sessions:sessions",
    "//base/numerics:base_numerics":
    "//base:base",
    "//third_party/abseil-cpp/absl/types:optional":
    "//base:base",
}


def gn_format(gn_files):
    """Format the file `gn_files`."""
    subprocess.check_call(["gn", "format"] + gn_files)


def remove_redundant_target_name(dependant, target_name):
    """Canonicalize target_name to be used as a dep of dependant."""
    if target_name in CANONICAL_PUBLIC_TARGETS:
        target_name = CANONICAL_PUBLIC_TARGETS[target_name]
    (dependant_folder, dependant_target) = dependant.split(":")
    (folder, target) = target_name.split(":")
    if dependant_folder == folder:
        return ":%s" % target
    last_folder = os.path.basename(folder)
    if last_folder == target:
        return folder
    if target.endswith("_strings_grit"):
        warning_message = GRIT_TARGET_MESSAGE_DETAILS % (target_name, folder)
        print_warning(GRIT_TARGET_MESSAGE, warning_message)
        return folder
    return target_name


def cleanup_redundant_deps(deps):
    """If the list of `deps` contains only targets redundant with each
    others, this method will return only one of them. Otherwise, return
    all targets.
    """
    if len(deps) != 2:
        """Currently only handle the string grit case where gn finds the
        the string header in <path>:<target>_strings and
        <path>:<target>_string_grit, so don't check if there is not
        exactly two targets.
        """
        return deps
    if (deps[1].endswith("_strings") and
        deps[0] == deps[1] + "_grit"):
        return [deps[0]]
    if (deps[0].endswith("_strings") and
        deps[1] == deps[0] + "_grit"):
        return [deps[1]]
    return deps


def extract_missing_dependency(error, prefix, patterns, dependant_line,
                             dependee_line):
    """Parse gn error message for missing direct dependency."""
    lines = error.splitlines()

    if len(lines) <= patterns[-1][0]:
        return False, None
    for line_number, pattern in patterns:
        if lines[line_number] != pattern:
            return False, None
    dependant = lines[dependant_line].strip()
    if prefix and not dependant.startswith(prefix):
        return False, None

    dependees_with_target = []
    index = dependee_line
    while lines[dependee_line].strip().startswith("//"):
        dependees_with_target.append(lines[dependee_line].strip())
        dependee_line += 1
    dependees_with_target = cleanup_redundant_deps(dependees_with_target)

    dependees = []
    for dependee in dependees_with_target:
        dependees.append(remove_redundant_target_name(dependant, dependee))

    return True, (dependant, dependees)


def get_missing_deps(builddir, prefix):
    """Extracts missing direct dependencies from gn."""
    missing_deps = defaultdict(set)
    process = subprocess.Popen(["gn", "check", builddir],
                               stdout=subprocess.PIPE)
    lines, errs = process.communicate()
    errors = lines.decode("ascii").split(GN_ERROR_SEPARATOR)
    for error in errors:
        has_missing_dep, info = extract_missing_dependency(
            error, prefix, TRANSITIVE_PATTERNS,
            TRANSITIVE_DEPENDENT_LINE_NUMBER, TRANSITIVE_DEPENDEE_LINE_NUMBER)
        if not has_missing_dep:
            has_missing_dep, info = extract_missing_dependency(
                error, prefix, MISSING_PATTERNS, MISSING_DEPENDENT_LINE_NUMBER,
                MISSING_DEPENDEE_LINE_NUMBER)
        if has_missing_dep:
            dependant, dependees = info
            if len(dependees) == 1:
                missing_deps[dependant].add(dependees[0])
            else:
                print_error(MULTIPLE_DEPENDEES_ERROR, error)
    return missing_deps


def add_missing_deps(srcdir, target, deps):
    """Adds the missing deps to the BUILD.gn file and run gn format on it."""
    (dir, target_name) = target.split(":")
    build_gn_file = os.path.join(srcdir, dir[2:], "BUILD.gn")
    content = []
    in_target = False
    changed = False
    first_deps_variable_line_index = -1
    target_name = target_name.replace("+", "\\+")

    # Handles the internal targets from ios_app_bundle and
    # ios_framework_bundle templates.
    for suffix in ('_executable', '_shared_library'):
        if target_name.endswith(suffix):
            target_name = target_name[:-len(suffix)]
            break

    target_rule = re.compile(r"\s*[a-z_]*\(\"%s\"\) {" % target_name)
    with open(build_gn_file, "r") as build_gn:
        all_lines = build_gn.readlines()
        for line_index, line in enumerate(all_lines):
            content += [line]
            if target_rule.search(line):
                indent = len(line) - len(line.lstrip(" "))
                in_target = True
            if in_target and line == (" " * indent + "}\n"):
                in_target = False
            if (in_target and first_deps_variable_line_index != -1 and
                     line.strip().startswith("deps ")):
                error_detail = (f"At lines:\n"
                  f"* {build_gn_file}:{first_deps_variable_line_index}:\n"
                  f"  {all_lines[first_deps_variable_line_index]}\n"
                  f"* {build_gn_file}:{line_index}:\n"
                  f"  {line}\n")
                error_detail = f"{target}\n{error_detail}"
                print_error(MULTIPLE_DEPS_ERROR, error_detail)
                # Multiple deps, abort
                return False, None
            if (in_target and first_deps_variable_line_index == -1 and
                    line.strip().startswith("deps ")):
                first_deps_variable_line_index = line_index
                onelinedeps = False
                if "[" in line and line[-2] == "]":
                    onelinedeps = True
                    content[-1] = line.replace("]", ",")
                for dep in deps:
                    content += ["\"%s\",\n" % dep]
                    changed = True
                if onelinedeps:
                    content += ["]\n"]
    if changed:
        with open(build_gn_file, "w") as build_gn:
            build_gn.write("".join(content))
        return True, build_gn_file
    return False, None


def main(args):
    parser = argparse.ArgumentParser(
        description=__doc__, formatter_class=argparse.RawTextHelpFormatter)
    parser.add_argument("--prefix",
                        help="Only fix subtargets of prefix",
                        default="")
    parser.add_argument("builddir", help="The build dir")
    parser.add_argument("srcdir", help="The src dir")
    args = parser.parse_args()
    deps = get_missing_deps(args.builddir, args.prefix)
    changed_build_gn_files = []
    for target in deps:
        changed, build_gn_file = add_missing_deps(args.srcdir, target,
                                                deps[target])
        if changed:
            changed_build_gn_files.append(build_gn_file)
    if changed_build_gn_files:
        gn_format(changed_build_gn_files)


def print_error(error_message, error_info):
    """ Print the `error_message` with additional `error_info` """
    color_start, color_end = adapted_color_for_output(TERMINAL_ERROR_COLOR,
                                                   TERMINAL_RESET_COLOR)

    error_message = color_start + "ERROR: " + error_message + color_end
    if len(error_info) > 0:
        error_message = error_message + "\n" + error_info
    print(error_message + "\n" + GN_ERROR_SEPARATOR)


def print_warning(warning_message, warning_info):
    """ Print the `warning_message` with additional `warning_info` """
    color_start, color_end = adapted_color_for_output(TERMINAL_WARNING_COLOR,
                                                   TERMINAL_RESET_COLOR)

    warning_message = color_start + "WARNING: " + warning_message + color_end
    if len(warning_info) > 0:
        warning_message = warning_message + "\n" + warning_info
    print(warning_message + "\n" + GN_ERROR_SEPARATOR)


def adapted_color_for_output(color_start, color_end):
    """ Returns a the `color_start`, `color_end` tuple if the output is a
    terminal, or empty strings otherwise """
    if not sys.stdout.isatty():
        return "", ""
    return color_start, color_end


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))