diff --git a/PRESUBMIT.py b/PRESUBMIT.py index 2eea4af3128..ebd92b4c22d 100644 --- a/PRESUBMIT.py +++ b/PRESUBMIT.py @@ -473,6 +473,30 @@ def CheckPlasterFiles(input_api, output_api): ]) +def CheckJson5ParseErrors(input_api, output_api): + """Check that .json5 files parse without syntax errors. + + Upstream `CheckParseErrors` covers `.idl` and `.json` but not `.json5`. + `json5.loads` natively accepts `//` and `/* */` comments, trailing + commas, and unquoted keys, so no comment-stripping pre-pass is needed. + """ + import json5 + + def _is_json5(affected_file): + return affected_file.LocalPath().endswith('.json5') + + results = [] + for affected_file in input_api.AffectedFiles(file_filter=_is_json5, + include_deletes=False): + try: + json5.loads(input_api.ReadFile(affected_file)) + except ValueError as e: + results.append( + output_api.PresubmitError( + f'{affected_file.LocalPath()} could not be parsed: {e}')) + return results + + # DON'T ADD NEW BRAVE CHECKS AFTER THIS LINE. # # This call inlines Chromium checks into current scope from src/PRESUBMIT.py. We diff --git a/PRESUBMIT_test.py b/PRESUBMIT_test.py index ac5f1bd0710..966f35c2f95 100755 --- a/PRESUBMIT_test.py +++ b/PRESUBMIT_test.py @@ -92,5 +92,65 @@ class CheckTypeScriptSuppressionsHaveReasonsTest(unittest.TestCase): self.assertEqual(0, len(errors)) +class CheckJson5ParseErrorsTest(unittest.TestCase): + + def testAcceptsJson5WithCommentsAndTrailingCommas(self): + input_api = MockInputApi() + input_api.files = [ + MockAffectedFile('brave/build/mappings.json5', [ + '// libc++ private headers -> public facades', + '{', + ' "include": [', + ' [ "<__algorithm/sort.h>", "private",', + ' "", "public" ], /* trailing comma OK */', + ' ],', + '}', + ]), + ] + + errors = PRESUBMIT.CheckJson5ParseErrors(input_api, MockOutputApi()) + + self.assertEqual([], errors) + + def testReportsInvalidJson5(self): + input_api = MockInputApi() + input_api.files = [ + MockAffectedFile('brave/build/broken.json5', [ + '{', + ' "key": "value"', + ' "missing_comma": true', + '}', + ]), + ] + + errors = PRESUBMIT.CheckJson5ParseErrors(input_api, MockOutputApi()) + + self.assertEqual(1, len(errors)) + self.assertIn('brave/build/broken.json5 could not be parsed', + errors[0].message) + + def testIgnoresNonJson5Files(self): + input_api = MockInputApi() + input_api.files = [ + MockAffectedFile('brave/build/mappings.json', + ['{ "not-json5": true,, }']), + MockAffectedFile('brave/foo.ts', ['const x = 1;']), + ] + + errors = PRESUBMIT.CheckJson5ParseErrors(input_api, MockOutputApi()) + + self.assertEqual([], errors) + + def testIgnoresDeletedFiles(self): + input_api = MockInputApi() + input_api.files = [ + MockAffectedFile('brave/build/deleted.json5', [], action='D'), + ] + + errors = PRESUBMIT.CheckJson5ParseErrors(input_api, MockOutputApi()) + + self.assertEqual([], errors) + + if __name__ == '__main__': unittest.main() diff --git a/build/include_what_you_use_mappings.json5 b/build/include_what_you_use_mappings.json5 new file mode 100644 index 00000000000..63abef69264 --- /dev/null +++ b/build/include_what_you_use_mappings.json5 @@ -0,0 +1,354 @@ +// 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/. + +// IWYU mapping file consumed by brave/tools/cr/iwyu/run_iwyu.py. +// +// Chromium ships libc++, whose standard headers (``, ``, +// ...) are split into many private detail headers under +// `third_party/libc++/src/include/__/`. Without these mappings, IWYU +// would suggest: +// +// #include "__algorithm/ranges_sort.h" +// #include "__atomic/atomic.h" +// #include "__fwd/sstream.h" +// +// instead of the public-facing: +// +// #include +// #include +// #include +// +// Each rule uses the `@` wildcard prefix so a single regex pattern remaps +// every private header under a `__/` subtree to its public facade. +// `__fwd/.h` is the libc++ forward-decl shim for `` and must be +// enumerated per-header. +// +// IMPORTANT: the `@` regex matches the include string *including* its +// delimiters. Brave's compile commands produce both angle-bracket +// (`<__algorithm/...>`) and quoted (`"__algorithm/..."`) forms depending +// on platform / build config, so every rule has both variants. +// +// See `tools/cr/iwyu/README.md` and IWYU's IWYUMappings.md for the file format. + +[ + // === Private detail subdirectories -- angle-bracket form === + + { 'include': ['@<__algorithm/.*>', 'private', '', 'public'] }, + { 'include': ['@<__atomic/.*>', 'private', '', 'public'] }, + { 'include': ['@<__bit/.*>', 'private', '', 'public'] }, + { 'include': ['@<__charconv/.*>', 'private', '', 'public'] }, + { 'include': ['@<__chrono/.*>', 'private', '', 'public'] }, + { 'include': ['@<__compare/.*>', 'private', '', 'public'] }, + { 'include': ['@<__concepts/.*>', 'private', '', 'public'] }, + { + 'include': [ + '@<__condition_variable/.*>', + 'private', + '', + 'public', + ], + }, + { 'include': ['@<__coroutine/.*>', 'private', '', 'public'] }, + { 'include': ['@<__exception/.*>', 'private', '', 'public'] }, + { 'include': ['@<__expected/.*>', 'private', '', 'public'] }, + { 'include': ['@<__filesystem/.*>', 'private', '', 'public'] }, + { 'include': ['@<__format/.*>', 'private', '', 'public'] }, + { 'include': ['@<__functional/.*>', 'private', '', 'public'] }, + { 'include': ['@<__ios/.*>', 'private', '', 'public'] }, + { 'include': ['@<__iterator/.*>', 'private', '', 'public'] }, + { 'include': ['@<__locale/.*>', 'private', '', 'public'] }, + { 'include': ['@<__math/.*>', 'private', '', 'public'] }, + { 'include': ['@<__memory/.*>', 'private', '', 'public'] }, + { + 'include': [ + '@<__memory_resource/.*>', + 'private', + '', + 'public', + ], + }, + { 'include': ['@<__mutex/.*>', 'private', '', 'public'] }, + { 'include': ['@<__new/.*>', 'private', '', 'public'] }, + { 'include': ['@<__numeric/.*>', 'private', '', 'public'] }, + { 'include': ['@<__ostream/.*>', 'private', '', 'public'] }, + { 'include': ['@<__random/.*>', 'private', '', 'public'] }, + { 'include': ['@<__ranges/.*>', 'private', '', 'public'] }, + { 'include': ['@<__stop_token/.*>', 'private', '', 'public'] }, + { 'include': ['@<__string/.*>', 'private', '', 'public'] }, + { + 'include': ['@<__system_error/.*>', 'private', '', 'public'], + }, + { 'include': ['@<__thread/.*>', 'private', '', 'public'] }, + { 'include': ['@<__tuple/.*>', 'private', '', 'public'] }, + { 'include': ['@<__type_traits/.*>', 'private', '', 'public'] }, + { 'include': ['@<__utility/.*>', 'private', '', 'public'] }, + { 'include': ['@<__variant/.*>', 'private', '', 'public'] }, + { 'include': ['@<__vector/.*>', 'private', '', 'public'] }, + + // === Private detail subdirectories -- quoted form === + + { 'include': ['@"__algorithm/.*"', 'private', '', 'public'] }, + { 'include': ['@"__atomic/.*"', 'private', '', 'public'] }, + { 'include': ['@"__bit/.*"', 'private', '', 'public'] }, + { 'include': ['@"__charconv/.*"', 'private', '', 'public'] }, + { 'include': ['@"__chrono/.*"', 'private', '', 'public'] }, + { 'include': ['@"__compare/.*"', 'private', '', 'public'] }, + { 'include': ['@"__concepts/.*"', 'private', '', 'public'] }, + { + 'include': [ + '@"__condition_variable/.*"', + 'private', + '', + 'public', + ], + }, + { 'include': ['@"__coroutine/.*"', 'private', '', 'public'] }, + { 'include': ['@"__exception/.*"', 'private', '', 'public'] }, + { 'include': ['@"__expected/.*"', 'private', '', 'public'] }, + { 'include': ['@"__filesystem/.*"', 'private', '', 'public'] }, + { 'include': ['@"__format/.*"', 'private', '', 'public'] }, + { 'include': ['@"__functional/.*"', 'private', '', 'public'] }, + { 'include': ['@"__ios/.*"', 'private', '', 'public'] }, + { 'include': ['@"__iterator/.*"', 'private', '', 'public'] }, + { 'include': ['@"__locale/.*"', 'private', '', 'public'] }, + { 'include': ['@"__math/.*"', 'private', '', 'public'] }, + { 'include': ['@"__memory/.*"', 'private', '', 'public'] }, + { + 'include': [ + '@"__memory_resource/.*"', + 'private', + '', + 'public', + ], + }, + { 'include': ['@"__mutex/.*"', 'private', '', 'public'] }, + { 'include': ['@"__new/.*"', 'private', '', 'public'] }, + { 'include': ['@"__numeric/.*"', 'private', '', 'public'] }, + { 'include': ['@"__ostream/.*"', 'private', '', 'public'] }, + { 'include': ['@"__random/.*"', 'private', '', 'public'] }, + { 'include': ['@"__ranges/.*"', 'private', '', 'public'] }, + { 'include': ['@"__stop_token/.*"', 'private', '', 'public'] }, + { 'include': ['@"__string/.*"', 'private', '', 'public'] }, + { + 'include': ['@"__system_error/.*"', 'private', '', 'public'], + }, + { 'include': ['@"__thread/.*"', 'private', '', 'public'] }, + { 'include': ['@"__tuple/.*"', 'private', '', 'public'] }, + { + 'include': ['@"__type_traits/.*"', 'private', '', 'public'], + }, + { 'include': ['@"__utility/.*"', 'private', '', 'public'] }, + { 'include': ['@"__variant/.*"', 'private', '', 'public'] }, + { 'include': ['@"__vector/.*"', 'private', '', 'public'] }, + + // === Forward-decl shims -- angle-bracket form === + // `__fwd/.h` is libc++'s forward-decl-only header for ``. + + { 'include': ['<__fwd/array.h>', 'private', '', 'public'] }, + { 'include': ['<__fwd/byte.h>', 'private', '', 'public'] }, + { 'include': ['<__fwd/complex.h>', 'private', '', 'public'] }, + { 'include': ['<__fwd/deque.h>', 'private', '', 'public'] }, + { 'include': ['<__fwd/format.h>', 'private', '', 'public'] }, + { 'include': ['<__fwd/fstream.h>', 'private', '', 'public'] }, + { 'include': ['<__fwd/functional.h>', 'private', '', 'public'] }, + { 'include': ['<__fwd/ios.h>', 'private', '', 'public'] }, + { 'include': ['<__fwd/istream.h>', 'private', '', 'public'] }, + { 'include': ['<__fwd/mdspan.h>', 'private', '', 'public'] }, + { 'include': ['<__fwd/memory.h>', 'private', '', 'public'] }, + { + 'include': [ + '<__fwd/memory_resource.h>', + 'private', + '', + 'public', + ], + }, + { 'include': ['<__fwd/ostream.h>', 'private', '', 'public'] }, + { 'include': ['<__fwd/pair.h>', 'private', '', 'public'] }, + { 'include': ['<__fwd/queue.h>', 'private', '', 'public'] }, + { 'include': ['<__fwd/span.h>', 'private', '', 'public'] }, + { 'include': ['<__fwd/sstream.h>', 'private', '', 'public'] }, + { 'include': ['<__fwd/stack.h>', 'private', '', 'public'] }, + { 'include': ['<__fwd/streambuf.h>', 'private', '', 'public'] }, + { 'include': ['<__fwd/string.h>', 'private', '', 'public'] }, + { + 'include': ['<__fwd/string_view.h>', 'private', '', 'public'], + }, + { 'include': ['<__fwd/subrange.h>', 'private', '', 'public'] }, + { 'include': ['<__fwd/tuple.h>', 'private', '', 'public'] }, + { 'include': ['<__fwd/variant.h>', 'private', '', 'public'] }, + { 'include': ['<__fwd/vector.h>', 'private', '', 'public'] }, + + // === Forward-decl shims -- quoted form === + + { 'include': ['"__fwd/array.h"', 'private', '', 'public'] }, + { 'include': ['"__fwd/byte.h"', 'private', '', 'public'] }, + { 'include': ['"__fwd/complex.h"', 'private', '', 'public'] }, + { 'include': ['"__fwd/deque.h"', 'private', '', 'public'] }, + { 'include': ['"__fwd/format.h"', 'private', '', 'public'] }, + { 'include': ['"__fwd/fstream.h"', 'private', '', 'public'] }, + { + 'include': ['"__fwd/functional.h"', 'private', '', 'public'], + }, + { 'include': ['"__fwd/ios.h"', 'private', '', 'public'] }, + { 'include': ['"__fwd/istream.h"', 'private', '', 'public'] }, + { 'include': ['"__fwd/mdspan.h"', 'private', '', 'public'] }, + { 'include': ['"__fwd/memory.h"', 'private', '', 'public'] }, + { + 'include': [ + '"__fwd/memory_resource.h"', + 'private', + '', + 'public', + ], + }, + { 'include': ['"__fwd/ostream.h"', 'private', '', 'public'] }, + { 'include': ['"__fwd/pair.h"', 'private', '', 'public'] }, + { 'include': ['"__fwd/queue.h"', 'private', '', 'public'] }, + { 'include': ['"__fwd/span.h"', 'private', '', 'public'] }, + { 'include': ['"__fwd/sstream.h"', 'private', '', 'public'] }, + { 'include': ['"__fwd/stack.h"', 'private', '', 'public'] }, + { 'include': ['"__fwd/streambuf.h"', 'private', '', 'public'] }, + { 'include': ['"__fwd/string.h"', 'private', '', 'public'] }, + { + 'include': ['"__fwd/string_view.h"', 'private', '', 'public'], + }, + { 'include': ['"__fwd/subrange.h"', 'private', '', 'public'] }, + { 'include': ['"__fwd/tuple.h"', 'private', '', 'public'] }, + { 'include': ['"__fwd/variant.h"', 'private', '', 'public'] }, + { 'include': ['"__fwd/vector.h"', 'private', '', 'public'] }, + + // `third_party/jni_zero/jni_zero.h` is Chromium's wrapper over `` + // and pulls it in transitively. Anywhere IWYU would suggest the raw + // ``, route through jni_zero so files that already include the + // wrapper don't pick up a redundant ``. + { + 'include': [ + '', + 'private', + '"third_party/jni_zero/jni_zero.h"', + 'public', + ], + }, + + // We use a short version for rust's cxx headers + { + 'include': [ + '"third_party/rust/chromium_crates_io/vendor/cxx-v1/include/cxx.h"', + 'private', + '"third_party/rust/cxx/v1/cxx.h"', + 'public', + ], + }, + + // This one seems to be IWYU doing something weird, as I can get both the + // forwarding header and span.h pulled in some cases. + { + 'include': [ + '"base/containers/span_forward_internal.h"', + 'private', + '"base/containers/span.h"', + 'public', + ], + }, + + // BoringSSL: the project convention is to spell these as + // `"openssl/.h"` since `third_party/boringssl:external_config` adds + // `src/include` to every consumer's `-I` path (see boringssl/BUILD.gn). + // Map the full physical path back to that short form. + { + 'include': [ + '"third_party/boringssl/src/include/openssl/base.h"', + 'private', + '"openssl/base.h"', + 'public', + ], + }, + { + 'include': [ + '"third_party/boringssl/src/include/openssl/digest.h"', + 'private', + '"openssl/digest.h"', + 'public', + ], + }, + + // Pin the basic integer and size typedefs to their canonical C headers. + // `openssl/base.h` re-exports `` and `` via + // `IWYU pragma: export`, which would otherwise make it a valid provider + // for these types and cause IWYU to keep a vestigial `openssl/base.h` + // include just to satisfy a `size_t` / `uint8_t` use. + { 'symbol': ['size_t', 'private', '', 'public'] }, + { 'symbol': ['ptrdiff_t', 'private', '', 'public'] }, + { 'symbol': ['int8_t', 'private', '', 'public'] }, + { 'symbol': ['int16_t', 'private', '', 'public'] }, + { 'symbol': ['int32_t', 'private', '', 'public'] }, + { 'symbol': ['int64_t', 'private', '', 'public'] }, + { 'symbol': ['uint8_t', 'private', '', 'public'] }, + { 'symbol': ['uint16_t', 'private', '', 'public'] }, + { 'symbol': ['uint32_t', 'private', '', 'public'] }, + { 'symbol': ['uint64_t', 'private', '', 'public'] }, + { + 'include': [ + '"ethash/hash_types.h"', + 'private', + '"brave/third_party/ethash/src/include/ethash/hash_types.h"', + 'public', + ], + }, + + // mappings that require further investigation. + { + 'include': [ + '"gtest/gtest.h"', + 'private', + '"testing/gtest/include/gtest/gtest.h"', + 'public', + ], + }, + { + 'include': [ + '"gmock/gmock.h"', + 'private', + '"testing/gmock/include/gmock/gmock.h"', + 'public', + ], + }, + { + 'include': ['"span.h"', 'private', '"base/containers/span.h"', 'public'], + }, + + // this seems to occur only on windows build + { 'include': ['"map"', 'private', '', 'public'] }, + { 'include': ['"tuple"', 'private', '', 'public'] }, + { 'include': ['"string_view"', 'private', '', 'public'] }, + + // Using as an include blackhole + // + // These headers are being blackholed because they are not desired to ever be + // shown in our code. They are usually cases where we could add an export + // pragma in upstream code, or where include-what-you-use is confused about + // things. + { 'include': ['', 'private', '', 'public'] }, + { 'include': ['', 'private', '', 'public'] }, + { + 'include': [ + '"mojo/public/cpp/bindings/struct_ptr.h"', + 'private', + '', + 'public', + ], + }, + { + 'include': [ + '"base/containers/checked_iterators.h"', + 'private', + '', + 'public', + ], + }, + { 'include': ['"base/macros/if.h"', 'private', '', 'public'] }, + { 'include': ['"base/macros/is_empty.h"', 'private', '', 'public'] }, +] diff --git a/build/include_what_you_use_paths.cfg b/build/include_what_you_use_paths.cfg new file mode 100644 index 00000000000..980a4155cb5 --- /dev/null +++ b/build/include_what_you_use_paths.cfg @@ -0,0 +1,11 @@ +# 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/. + +# All paths listes are relative to `src/`. Use + prefix for the paths you wnat +# to include, and - prefix for paths you want to exclude in the inclusions. +# For how this file is used, see `tools/cr/iwyu/README.md`. + ++brave/components/brave_ads/browser ++brave/components/brave_wallet/common diff --git a/tools/cr/iwyu/README.md b/tools/cr/iwyu/README.md new file mode 100644 index 00000000000..159389a814a --- /dev/null +++ b/tools/cr/iwyu/README.md @@ -0,0 +1,102 @@ +# Brave IWYU tooling + +Two scripts that drive [include-what-you-use](https://include-what-you-use.org/) +over Brave sources: + +* `build_iwyu.py` — builds the IWYU binary against Chromium's pinned Clang. +* `run_iwyu.py` — runs IWYU against an existing Brave build dir and applies + the suggested include fixes in place. + +IWYU is **not** part of the normal Brave build; run these scripts on demand. + +## One-time setup: build IWYU + +```sh +vpython3 tools/cr/iwyu/build_iwyu.py +``` + +Clones LLVM at Chromium's pinned `CLANG_REVISION` and IWYU at the pinned +`IWYU_REVISION` into `out/iwyu/`, then builds: + +``` +out/iwyu/tools/clang/third_party/llvm/build/bin/include-what-you-use +``` + +Re-run after Chromium's Clang pin moves, or bump `IWYU_REVISION` in +`build_iwyu.py` to pick up an upstream IWYU fix. + +## Enable the paths you want IWYU to scan + +Edit `brave/build/include_what_you_use_paths.cfg`. Each non-comment line is: + +``` ++path/to/enable +-path/to/disable +``` + +Paths are relative to `src/`. Longest-matching prefix wins, so you can +enable a directory and carve exceptions out of it: + +``` ++brave/components/brave_ads/browser +-brave/components/brave_ads/browser/third_party +``` + +## Run IWYU + +IWYU needs an existing Brave build in some `out/` to pick up its +compile flags and generated headers, **and** several GN args have to be +flipped — without them IWYU crashes during analysis: + +```sh +npm run build -- Static \ + --target_os=android \ + --ignore_compile_failure \ + --target=brave:all \ + --gn=clang_use_chrome_plugins:false \ + --gn=force_enable_raw_ptr_exclusion:true \ + --gn=enable_precompiled_headers:false \ + --gn=treat_warnings_as_errors:false \ + --gn=clang_warning_suppression_file:"" \ + --gn=symbol_level:0 +``` + +Required (IWYU crashes without these): + +* `clang_use_chrome_plugins:false`. +* `force_enable_raw_ptr_exclusion:true` +* `enable_precompiled_headers:false` +* `treat_warnings_as_errors:false` + +Then finally run `include-what-you-use` with `run_iwyu.py`, bearing in mind that `--out` is relative to `src/`, rather than the current directory. + +```sh +vpython3 tools/cr/iwyu/run_iwyu.py --out out/Static --verbose +``` + +This: + +1. Generates a compile database from `out/` and filters it to + sources enabled by `include_what_you_use_paths.cfg`. +2. Runs `iwyu_tool.py` with our libc++ mapping file + (`brave/build/include_what_you_use_mappings.json5`). +3. Applies suggestions in place via `fix_includes.py`. +4. Strips blackholed includes (e.g. ``) from modified files. +5. Runs `npm run format` so the resulting diff matches Brave's style. + +Artifacts written next to the build: + +* `out//iwyu_compile_commands.json` — filtered compile DB. +* `out//iwyu_suggestions.txt` — raw IWYU output, handy when a + rewrite looks wrong. + +## Defending an include from IWYU + +If IWYU keeps removing an include you need, mark it: + +```c++ +#include "foo/bar.h" // IWYU pragma: keep +``` + +`run_iwyu.py` leaves any line containing `IWYU pragma:` untouched, including +during the blackhole-includes pass. diff --git a/tools/cr/iwyu/_boot.py b/tools/cr/iwyu/_boot.py new file mode 100644 index 00000000000..d5e33ea4b24 --- /dev/null +++ b/tools/cr/iwyu/_boot.py @@ -0,0 +1,11 @@ +# 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/. +"""Import this to add the parent tools/cr to sys.path.""" +import sys +from pathlib import Path + +_cr = str(Path(__file__).parent.parent) +if _cr not in sys.path: + sys.path.insert(0, _cr) diff --git a/tools/cr/iwyu/build_iwyu.py b/tools/cr/iwyu/build_iwyu.py new file mode 100755 index 00000000000..938718273da --- /dev/null +++ b/tools/cr/iwyu/build_iwyu.py @@ -0,0 +1,216 @@ +#!/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/. +"""Build `include-what-you-use` against Chromium's pinned LLVM revision. + +Keep this script standalone, with no dependencies on other local python sources +in tools/cr, as we may want to run the build in CI, in the future, using only +a vanilla Chromium checkout. + +What this script does: + + 1. Clones the LLVM monorepo into `out/iwyu/tools/clang/third_party/llvm`, + pinned to `CLANG_REVISION` from `tools/clang/scripts/update.py`. This + matches the revision Chromium's bundled Clang is built from, so the + resulting IWYU binary speaks the same Clang AST as the rest of the + toolchain. + 2. Clones include-what-you-use into + `out/iwyu/tools/clang/third_party/iwyu`, alongside the LLVM checkout + (pinned to the `IWYU_REVISION` constant below). + 3. Configures a CMake build that pulls IWYU in as an LLVM external project + (`LLVM_EXTERNAL_PROJECTS=iwyu` + + `LLVM_EXTERNAL_IWYU_SOURCE_DIR=…/iwyu`). Building IWYU as part of the + LLVM build avoids the standalone-build resource-dir gymnastics + (see IWYU README's "How to install" section). + 4. Invokes `ninja include-what-you-use` inside the LLVM build tree. + +The resulting binary lands at: + + out/iwyu/tools/clang/third_party/llvm/build/bin/include-what-you-use + +""" + +from __future__ import annotations + +import argparse +import logging +import platform +import shutil +import subprocess +import sys +from pathlib import Path, PurePath + +CHROMIUM_SRC: Path = Path(__file__).resolve().parents[4] + +# Importing clang toolchain helper from Chromium. +sys.path.append(str(CHROMIUM_SRC / 'tools' / 'clang' / 'scripts')) + +# Static analysers can't see through the sys.path mutation above. +# pylint: disable=wrong-import-position +from build import AddCMakeToPath, CheckoutGitRepo, LLVM_GIT_URL # type: ignore # noqa: E402 +from update import CLANG_REVISION # type: ignore # noqa: E402 +# pylint: enable=wrong-import-position + +# The repo to clone from for the build. +IWYU_GIT_URL = ('https://github.com/include-what-you-use/' + 'include-what-you-use.git') + +# Pinned IWYU revision. IWYU master tracks LLVM main; we pin to a specific +# commit that compiles against the Clang revision Chromium has currently +# pinned (`update.CLANG_REVISION`). Bump when bumping Chromium's Clang. +# +# `ece9edb` is the commit immediately before +# c36eacb "[clang compat] Handle new HLSL builtin trait", which references +# `clang::TypeTrait::UTT_IsConstantBufferElementCompatible` -- a symbol that +# doesn't exist in `llvmorg-23-init-5669-g8a0be0bc` (our current pin). +IWYU_REVISION = 'ece9edb' + +# Constants for the the directory layout of where these tools live under. +THIRD_PARTY_REL = PurePath('tools') / 'clang' / 'third_party' +LLVM_REL = THIRD_PARTY_REL / 'llvm' +IWYU_REL = THIRD_PARTY_REL / 'iwyu' +BUILD_REL = LLVM_REL / 'build' + +# We always build it at `out/iwyu` in Chromium. +OUT_DIR: Path = CHROMIUM_SRC / 'out' / 'iwyu' + + +def _check_call(*command, cwd=None): + """Run *command* as a subprocess, logging the invocation. + + Stdout and stderr are inherited from this process so the user sees ninja + progress and compiler diagnostics live -- important for long-running + cmake/ninja invocations where errors otherwise scroll past or get lost. + """ + logging.info(' >>>> %s', ' '.join(str(a) for a in command)) + + if platform.system() == 'Windows': + # Resolve to an absolute path so .bat shims (e.g. `cmake.bat`) are + # found without `shell=True`. + resolved = shutil.which(command[0]) + if resolved is None: + raise RuntimeError(f'Command not found: {command[0]}') + if resolved != command[0]: + command = [resolved] + list(command[1:]) + + subprocess.run(command, cwd=cwd, check=True) + + +class IwyuBuilder: + """Clone LLVM + IWYU and build the IWYU binary against pinned Clang. + + Three phases: + + 1. **Checkout** (`_checkout_llvm`, `_checkout_iwyu`): clones (or updates) + LLVM and IWYU at the requested revisions into the layout described + in the module docstring. Uses `build.CheckoutGitRepo` from the + Chromium clang scripts, which idempotently fetches/updates an + existing clone or re-clones from scratch if the working tree is + broken. + + 2. **Configure** (`_configure`): runs `cmake -GNinja` against + `/llvm/CMakeLists.txt` with IWYU wired in as an LLVM + external project. Build dir lives at `/build/`, matching + `build_clang_tools_extra.py`. + + 3. **Build** (`_build`): `ninja ` (default: + `include-what-you-use`). + """ + + def __init__(self): + self.llvm_dir: Path = OUT_DIR / LLVM_REL + self.iwyu_dir: Path = OUT_DIR / IWYU_REL + self.build_dir: Path = OUT_DIR / BUILD_REL + + def _checkout_llvm(self): + """Clone or update LLVM at the pinned Chromium Clang revision.""" + self.llvm_dir.parent.mkdir(parents=True, exist_ok=True) + CheckoutGitRepo('LLVM monorepo', LLVM_GIT_URL, CLANG_REVISION, + str(self.llvm_dir)) + + def _checkout_iwyu(self): + """Clone or update IWYU at the pinned revision.""" + self.iwyu_dir.parent.mkdir(parents=True, exist_ok=True) + CheckoutGitRepo('include-what-you-use', IWYU_GIT_URL, IWYU_REVISION, + str(self.iwyu_dir)) + + def _configure(self): + """Configure the LLVM build with IWYU added as an external project. + + The LLVM monorepo's CMake root is `/llvm`, not the + repo root. IWYU is pulled in via the standard LLVM external-project + knobs, so the build picks up the in-tree Clang's resource directory + without any of the `IWYU_RESOURCE_RELATIVE_TO` gymnastics needed for + a standalone IWYU build. + """ + # Download Chromium's pinned cmake into third_party/llvm-build-tools/ + # and prepend it to PATH. Sidesteps "wrong cmake on PATH" failures -- + # most often on macOS where Homebrew cmake versions skew vs. the + # llvm-project requirements. + AddCMakeToPath() + + self.build_dir.mkdir(parents=True, exist_ok=True) + llvm_cmake_root = self.llvm_dir / 'llvm' + cmake_args = [ + 'cmake', + '-GNinja', + '-DLLVM_ENABLE_PROJECTS=clang', + '-DLLVM_EXTERNAL_PROJECTS=iwyu', + f'-DLLVM_EXTERNAL_IWYU_SOURCE_DIR={self.iwyu_dir}', + '-DCMAKE_BUILD_TYPE=Release', + '-DLLVM_ENABLE_ASSERTIONS=On', + # IWYU is a Clang frontend tool and needs no codegen backends. + # Restricting to the host backend skips AMDGPU/MIPS/PowerPC/etc. + # which would otherwise take the bulk of the build time (and have + # caused AMDGPU compile failures on some hosts). + '-DLLVM_TARGETS_TO_BUILD=host', + # Trim other optional bits that are irrelevant for a tool build. + '-DLLVM_INCLUDE_TESTS=OFF', + '-DLLVM_INCLUDE_EXAMPLES=OFF', + '-DLLVM_INCLUDE_BENCHMARKS=OFF', + '-DLLVM_INCLUDE_DOCS=OFF', + str(llvm_cmake_root), + ] + _check_call(*cmake_args, cwd=self.build_dir) + + def _build(self): + """Build the IWYU binary.""" + _check_call('ninja', 'include-what-you-use', cwd=self.build_dir) + + def run(self): + """Execute the full checkout / configure / build pipeline.""" + OUT_DIR.mkdir(parents=True, exist_ok=True) + + logging.info('LLVM revision: %s', CLANG_REVISION) + logging.info('IWYU revision: %s', IWYU_REVISION) + logging.info('LLVM checkout: %s', self.llvm_dir) + logging.info('IWYU checkout: %s', self.iwyu_dir) + logging.info('Build dir: %s', self.build_dir) + + self._checkout_llvm() + self._checkout_iwyu() + self._configure() + self._build() + + produced = self.build_dir / 'bin' / 'include-what-you-use' + logging.info('Build complete. Expected binary at: %s', produced) + + +def main(): + parser = argparse.ArgumentParser( + description='Build include-what-you-use against Chromium-pinned LLVM.') + parser.add_argument('--verbose', + action='store_true', + help='Enable verbose (debug) logging.') + args = parser.parse_args() + + logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO) + + IwyuBuilder().run() + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/cr/iwyu/run_iwyu.py b/tools/cr/iwyu/run_iwyu.py new file mode 100755 index 00000000000..a763bef5f8e --- /dev/null +++ b/tools/cr/iwyu/run_iwyu.py @@ -0,0 +1,304 @@ +#!/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 `/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 `/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 (``). +# Passed to IWYU via `-Xiwyu --mapping_file=` 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([ + '', +]) + +# Matches `#include ` 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 `+/` (enable) or + `-/` (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/`). + # Run fix_includes.py with cwd= 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()) diff --git a/tools/cr/iwyu/run_iwyu_test.py b/tools/cr/iwyu/run_iwyu_test.py new file mode 100755 index 00000000000..88136efca08 --- /dev/null +++ b/tools/cr/iwyu/run_iwyu_test.py @@ -0,0 +1,158 @@ +#!/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/. + +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from run_iwyu import is_path_enabled, parse_paths_file + + +class ParsePathsFileTest(unittest.TestCase): + + def setUp(self): + self._tmp_dir = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp_dir.cleanup) + self.cfg = Path(self._tmp_dir.name) / 'paths.cfg' + + def _write(self, content: str) -> Path: + self.cfg.write_text(content, encoding='utf-8', newline='') + return self.cfg + + def test_empty_file(self): + self.assertEqual(parse_paths_file(self._write('')), []) + + def test_blank_and_comment_lines_only(self): + self._write('\n \n# a comment\n\t# indented comment\n') + self.assertEqual(parse_paths_file(self.cfg), []) + + def test_enable_and_disable_rules(self): + self._write('+brave/browser/\n-brave/browser/internal/\n') + self.assertEqual(parse_paths_file(self.cfg), [ + ('+', 'brave/browser/'), + ('-', 'brave/browser/internal/'), + ]) + + def test_trailing_slash_is_added_when_missing(self): + self._write('+brave/browser\n-brave/browser/internal\n') + self.assertEqual(parse_paths_file(self.cfg), [ + ('+', 'brave/browser/'), + ('-', 'brave/browser/internal/'), + ]) + + def test_trailing_comment_is_stripped(self): + self._write('+brave/browser/ # enable browser\n' + '-brave/browser/internal/# nested disable\n') + self.assertEqual(parse_paths_file(self.cfg), [ + ('+', 'brave/browser/'), + ('-', 'brave/browser/internal/'), + ]) + + def test_line_that_is_only_a_comment_is_skipped(self): + self._write('+brave/browser/\n' + '# -brave/browser/internal/\n' + '-brave/components/\n') + self.assertEqual(parse_paths_file(self.cfg), [ + ('+', 'brave/browser/'), + ('-', 'brave/components/'), + ]) + + def test_surrounding_whitespace_is_tolerated(self): + self._write(' +brave/browser/ \n\t-brave/components/\t\n') + self.assertEqual(parse_paths_file(self.cfg), [ + ('+', 'brave/browser/'), + ('-', 'brave/components/'), + ]) + + def test_rules_preserve_file_order(self): + self._write('+a/\n-b/\n+c/\n-d/\n') + self.assertEqual(parse_paths_file(self.cfg), [ + ('+', 'a/'), + ('-', 'b/'), + ('+', 'c/'), + ('-', 'd/'), + ]) + + def test_invalid_prefix_raises(self): + self._write('brave/browser/\n') + with self.assertRaisesRegex(ValueError, + 'must start with `\\+` or `-`'): + parse_paths_file(self.cfg) + + def test_empty_path_after_sign_raises(self): + self._write('+\n') + with self.assertRaisesRegex(ValueError, 'empty path after `\\+`'): + parse_paths_file(self.cfg) + + def test_error_line_number_matches_offending_line(self): + self._write('+brave/browser/\n' + '\n' + '# a comment\n' + 'oops\n') + with self.assertRaisesRegex(ValueError, ':4:'): + parse_paths_file(self.cfg) + + +class IsPathEnabledTest(unittest.TestCase): + + def test_no_rules_means_disabled(self): + self.assertFalse(is_path_enabled('brave/browser/foo.cc', [])) + + def test_unmatched_path_is_disabled(self): + rules = [('+', 'brave/browser/')] + self.assertFalse(is_path_enabled('brave/components/foo.cc', rules)) + + def test_single_enable_rule(self): + rules = [('+', 'brave/browser/')] + self.assertTrue(is_path_enabled('brave/browser/foo.cc', rules)) + + def test_single_disable_rule(self): + rules = [('-', 'brave/browser/')] + self.assertFalse(is_path_enabled('brave/browser/foo.cc', rules)) + + def test_longest_prefix_wins_disable_overrides_enable(self): + rules = [ + ('+', 'brave/browser/'), + ('-', 'brave/browser/internal/'), + ] + self.assertTrue(is_path_enabled('brave/browser/foo.cc', rules)) + self.assertFalse( + is_path_enabled('brave/browser/internal/foo.cc', rules)) + + def test_longest_prefix_wins_enable_overrides_disable(self): + rules = [ + ('-', 'brave/browser/'), + ('+', 'brave/browser/public/'), + ] + self.assertFalse(is_path_enabled('brave/browser/foo.cc', rules)) + self.assertTrue(is_path_enabled('brave/browser/public/foo.cc', rules)) + + def test_rule_order_does_not_matter(self): + forward = [ + ('+', 'brave/browser/'), + ('-', 'brave/browser/internal/'), + ] + reverse = list(reversed(forward)) + path = 'brave/browser/internal/foo.cc' + self.assertEqual(is_path_enabled(path, forward), + is_path_enabled(path, reverse)) + + def test_prefix_must_align_to_directory_boundary(self): + # The trailing `/` is what prevents `brave/browser/` matching + # `brave/browser_other/...`. + rules = [('+', 'brave/browser/')] + self.assertFalse(is_path_enabled('brave/browser_other/foo.cc', rules)) + + def test_exact_directory_match_is_enabled(self): + # Source exactly under the enabled prefix. + rules = [('+', 'brave/browser/')] + self.assertTrue(is_path_enabled('brave/browser/a/b/c.cc', rules)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/cr/terminal.py b/tools/cr/terminal.py index 300d0e3e0dd..1262b43ebeb 100644 --- a/tools/cr/terminal.py +++ b/tools/cr/terminal.py @@ -3,6 +3,8 @@ # 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/. +from __future__ import annotations + from contextlib import contextmanager import logging import platform @@ -188,7 +190,8 @@ class Terminal: *, env: dict[str, str] | None = None, cwd=None, - interactive: bool = False): + interactive: bool = False, + stdin: str | None = None): """Runs a command on the terminal. When `interactive=True`, the subprocess inherits the parent's @@ -201,6 +204,11 @@ class Terminal: the parent's environment, a dict fully replaces it. If you want to add a few keys on top of `os.environ`, copy it first (`env={**os.environ, ...}`). + + Pass `stdin=` to feed data into the subprocess's stdin. Captured + (non-interactive) mode encodes it with utf-8 to match the + captured streams; interactive mode rejects `stdin=` because the + subprocess owns the tty. """ # Convert all arguments to strings, to avoid issues with `PurePath` # being passed arguments @@ -245,11 +253,17 @@ class Terminal: text=True, encoding='utf-8') + if interactive and stdin is not None: + raise ValueError( + 'terminal.run(): `stdin=` is not supported with ' + '`interactive=True` (the subprocess owns the tty).') + try: result = subprocess.run(cmd, check=True, env=env, cwd=cwd, + input=stdin, **capture_kwargs) except subprocess.CalledProcessError as e: if e.stderr: