Files
brave-core/tools/cr/iwyu/run_iwyu.py
T
cdesouza-chromium c31f01cfc2 [include-what-you-use] Initial tooling (#36508)
This PR introduces the basic tooling to have `include-what-you-use` run
against the brave codebase in maintainance mode. This manual regular
runs that cover more and more of the codebase.

Bug: https://github.com/brave/brave-browser/issues/42212
2026-05-19 18:59:55 +01:00

305 lines
13 KiB
Python
Executable File

#!/usr/bin/env vpython3
# Copyright (c) 2026 The Brave Authors. All rights reserved.
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
# You can obtain one at https://mozilla.org/MPL/2.0/.
"""Run `include-what-you-use` across the Brave paths enabled in
`brave/build/include_what_you_use_paths.cfg`.
What this script does:
1. Generates a compile database for the build dir given via `--out`, using
the helpers behind Chromium's `tools/clang/scripts/generate_compdb.py`
(`tools/clang/pylib/clang/compile_db.py`). Building must already be
done in that directory.
2. Reads `brave/build/include_what_you_use_paths.cfg` and filters the
compile DB down to entries whose source files fall under enabled paths.
3. Writes the filtered DB to `<out>/iwyu_compile_commands.json` and invokes
`iwyu_tool.py` against it with `IWYU_BINARY` pointed at the IWYU binary
produced by `build_iwyu.py`.
Prerequisites:
* `brave/tools/cr/iwyu/build_iwyu.py` has been run, producing the IWYU
binary at `<src>/out/iwyu/tools/clang/third_party/llvm/build/bin/`.
* The Brave build directory passed via `--out` has been built (so that
`build.ninja` and any generated headers exist).
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import re
import sys
from pathlib import Path
import _boot # noqa: F401 -- adds parent tools/cr to sys.path
import repository # noqa: E402 -- after _boot
# Make Chromium helpers importable. Same trick
# `tools/clang/scripts/generate_compdb.py` itself uses. Derived from
# __file__ rather than `repository.chromium.root` so the sys.path entries are
# stable regardless of the cwd `repository` was initialised against.
_SRC_ROOT: Path = Path(__file__).resolve().parents[4]
sys.path.append(str(_SRC_ROOT / 'tools' / 'clang' / 'pylib'))
sys.path.append(str(_SRC_ROOT / 'tools' / 'json_comment_eater'))
# pylint: disable=wrong-import-position
import json_comment_eater # type: ignore # noqa: E402
from clang import compile_db # type: ignore # noqa: E402
from terminal import terminal # type: ignore # noqa: E402
# pylint: enable=wrong-import-position
# IWYU artefacts produced by `build_iwyu.py`. Keeping the paths in sync with
# that script is intentional -- run_iwyu.py is a no-op without it.
_IWYU_OUT_DIR: Path = (repository.chromium.root / 'out' / 'iwyu' / 'tools' /
'clang' / 'third_party')
IWYU_BINARY: Path = (_IWYU_OUT_DIR / 'llvm' / 'build' / 'bin' /
'include-what-you-use')
IWYU_TOOL: Path = _IWYU_OUT_DIR / 'iwyu' / 'iwyu_tool.py'
# Applies the textual suggestions produced by iwyu_tool to source files in
# place. Lives alongside iwyu_tool.py in the same IWYU clone.
FIX_INCLUDES: Path = _IWYU_OUT_DIR / 'iwyu' / 'fix_includes.py'
# Brave-managed path filter file. See module docstring.
PATHS_FILE: Path = (repository.brave.root / 'build' /
'include_what_you_use_paths.cfg')
# IWYU mapping file: remaps libc++ private detail headers (e.g.
# `__algorithm/ranges_sort.h`) to their public facades (`<algorithm>`).
# Passed to IWYU via `-Xiwyu --mapping_file=<absolute path>` so the lookup
# is independent of each compile DB entry's cwd. A comment-stripped copy
# is written under `--out` at run time and fed to iwyu_tool (see main).
MAPPINGS_FILE: Path = (repository.brave.root / 'build' /
'include_what_you_use_mappings.json5')
# Headers we never want to see in Brave's source after IWYU.
BLACKHOLE_INCLUDES: frozenset[str] = frozenset([
'<new>',
])
# Matches `#include <hdr>` or `#include "hdr"`, capturing the delimited
# token (with its brackets/quotes intact) for comparison against
# BLACKHOLE_INCLUDES.
_INCLUDE_RE = re.compile(r'^\s*#\s*include\s+([<"][^<>"]+[>"])')
def parse_paths_file(path: Path) -> list[tuple[str, str]]:
"""Parse the path-rule file into an ordered list of (sign, path) rules.
Each non-blank, non-comment line is either `+<path>/` (enable) or
`-<path>/` (disable). Trailing `#` comments are stripped. Paths are
normalised to end with `/` so prefix matching is unambiguous (e.g.
`brave/browser/` does not match `brave/browser_other/foo.cc`).
"""
rules: list[tuple[str, str]] = []
text = path.read_bytes().decode('utf-8')
for line_no, raw_line in enumerate(text.splitlines(), 1):
# Strip trailing comments.
comment_idx = raw_line.find('#')
if comment_idx != -1:
raw_line = raw_line[:comment_idx]
line = raw_line.strip()
if not line:
continue
if line[0] not in ('+', '-'):
raise ValueError(
f'{path}:{line_no}: rule lines must start with `+` or `-`, '
f'got: {line!r}')
sign = line[0]
rule_path = line[1:].strip()
if not rule_path:
raise ValueError(f'{path}:{line_no}: empty path after `{sign}`')
if not rule_path.endswith('/'):
rule_path += '/'
rules.append((sign, rule_path))
return rules
def is_path_enabled(source_rel: str, rules: list[tuple[str, str]]) -> bool:
"""Return whether `source_rel` (relative to src/) is enabled by `rules`.
Longest-matching prefix wins. If no rule matches, the path is disabled.
"""
best_len = -1
best_sign = '-'
for sign, rule_path in rules:
if source_rel.startswith(rule_path) and len(rule_path) > best_len:
best_len = len(rule_path)
best_sign = sign
return best_sign == '+'
def blackhole_unwanted_includes() -> None:
"""Remove BLACKHOLE_INCLUDES from any file modified in the working tree.
Runs after `npm run format` to clean up includes IWYU/fix_includes
re-added that we never want. Files to scan are taken from
`git diff --name-only` against brave-core's HEAD, so only the files
the pipeline actually touched get rewritten.
Lines containing `IWYU pragma:` are preserved verbatim so callers can
defend a specific include with `// IWYU pragma: keep`.
"""
diff = terminal.run_git('-C', str(repository.brave.root), 'diff',
'--name-only')
files = diff.splitlines() if diff else []
for rel in files:
target = repository.brave.root / rel
if not target.is_file():
continue
text = target.read_bytes().decode('utf-8')
kept: list[str] = []
removed_any = False
for line in text.splitlines(keepends=True):
if 'IWYU pragma:' in line:
kept.append(line)
continue
match = _INCLUDE_RE.match(line)
if match and match.group(1) in BLACKHOLE_INCLUDES:
removed_any = True
continue
kept.append(line)
if removed_any:
target.write_text(''.join(kept), encoding='utf-8', newline='')
logging.info('Blackholed unwanted includes from %s', rel)
def main():
parser = argparse.ArgumentParser(
description='Run IWYU on enabled Brave paths against an existing '
'Brave build directory.')
parser.add_argument(
'--out',
required=True,
help='Brave build directory, relative to Chromium\'s src/ '
'(e.g. `out/Component`).')
parser.add_argument('--verbose',
action='store_true',
help='Enable verbose (debug) logging.')
args = parser.parse_args()
out_dir = repository.chromium.root / args.out
if not (out_dir / 'build.ninja').exists():
raise RuntimeError(
f'--out does not look like a build dir (no build.ninja): '
f'{out_dir}')
if not IWYU_BINARY.exists():
raise RuntimeError(f'IWYU binary not found at {IWYU_BINARY}. '
f'Run brave/tools/cr/iwyu/build_iwyu.py first.')
if not IWYU_TOOL.exists():
raise RuntimeError(f'iwyu_tool.py not found at {IWYU_TOOL}. '
f'Run brave/tools/cr/iwyu/build_iwyu.py first.')
if not FIX_INCLUDES.exists():
raise RuntimeError(f'fix_includes.py not found at {FIX_INCLUDES}. '
f'Run brave/tools/cr/iwyu/build_iwyu.py first.')
if not MAPPINGS_FILE.exists():
raise RuntimeError(f'IWYU mapping file not found at {MAPPINGS_FILE}.')
rules = parse_paths_file(PATHS_FILE)
logging.info('Loaded %d path rule(s) from %s', len(rules), PATHS_FILE)
logging.info('Generating compile database for %s', out_dir)
raw_db = compile_db.GenerateWithNinja(str(out_dir))
full_db = compile_db.ProcessCompileDatabase(raw_db, filtered_args=None)
logging.info('Compile DB has %d entries', len(full_db))
filtered_db = []
for entry in full_db:
# `entry['file']` may be relative to `entry['directory']` (the build
# dir) or already absolute; Path / handles both.
source_path = Path(entry['directory']) / entry['file']
try:
source_rel = repository.chromium.to_repo_relative(source_path)
except ValueError:
# Source outside the Chromium tree -- not something we own.
continue
if is_path_enabled(source_rel.as_posix(), rules):
filtered_db.append(entry)
logging.info('Running IWYU on %d source file(s)', len(filtered_db))
if not filtered_db:
logging.warning('No source files enabled. Edit %s to enable paths.',
PATHS_FILE)
return 0
# Write the filtered DB next to the build outputs, distinct from the
# build's own compile_commands.json (if any).
filtered_db_path = out_dir / 'iwyu_compile_commands.json'
filtered_db_path.write_text(json.dumps(filtered_db, indent=2),
encoding='utf-8',
newline='')
logging.info('Wrote filtered compile DB to %s', filtered_db_path)
env = os.environ.copy()
env['IWYU_BINARY'] = str(IWYU_BINARY)
# Strip `//` and `/* */` comments from the mapping file into a copy
# under `--out` so strict JSON parsers (presubmit, json.load) can
# consume it; IWYU itself is permissive but downstream tooling isn't.
# Living under `--out` keeps this artefact out of the source tree
# without needing a .gitignore entry.
normalised_mappings_path = out_dir / 'iwyu_normalised_mappings.json5'
normalised_mappings_path.write_text(json_comment_eater.Nom(
MAPPINGS_FILE.read_bytes().decode('utf-8')),
encoding='utf-8',
newline='')
logging.info('Wrote normalised mapping file to %s',
normalised_mappings_path)
cpu_count = os.cpu_count() or 1
# Step 1: run iwyu_tool to produce textual fix suggestions on stdout.
# We capture stdout (rather than `interactive=True`) so we can pipe it
# into fix_includes.py in step 2.
#
# Args after `--` are forwarded to each IWYU subprocess by iwyu_tool.
# `-Xiwyu --mapping_file=...` tells IWYU to use our libc++ mapping
# file; the path must be absolute since IWYU runs each unit cd'd to
# the compile DB entry's `directory`.
logging.info('Running iwyu_tool.py')
iwyu_result = terminal.run([
sys.executable, IWYU_TOOL, '-p', filtered_db_path, '-j', cpu_count,
'--', '-Xiwyu', f'--mapping_file={normalised_mappings_path.resolve()}'
],
env=env)
# Persist the raw suggestions next to the filtered DB for inspection /
# post-mortem diffing if a fix goes wrong.
suggestions_path = out_dir / 'iwyu_suggestions.txt'
suggestions_path.write_text(iwyu_result.stdout,
encoding='utf-8',
newline='')
logging.info('Wrote IWYU suggestions to %s', suggestions_path)
# Step 2: apply the suggestions in place. fix_includes.py reads the
# iwyu_tool report on stdin and rewrites the affected source files.
#
# The filenames in the report are relative to each compile DB entry's
# `directory` field, which for a Chromium-style build is the build dir
# (e.g. `../../brave/common/importer/foo.cc` rooted at `out/<config>`).
# Run fix_includes.py with cwd=<build dir> so those paths resolve.
logging.info('Applying suggestions via fix_includes.py')
# FIX_INCLUDES is cwd-relative (via repository.chromium.root); absolutise
# it before changing cwd or the lookup will fail.
terminal.run([sys.executable, FIX_INCLUDES.resolve()],
stdin=iwyu_result.stdout,
cwd=out_dir)
# Step 3: strip BLACKHOLE_INCLUDES from any file the pipeline modified.
logging.info('Stripping blackholed includes')
blackhole_unwanted_includes()
# Step 4: re-format the rewritten files so the resulting diff matches
logging.info('Running npm run format')
terminal.run_npm_command('format')
return 0
if __name__ == '__main__':
sys.exit(main())