| # Copyright 2025 The Pigweed Authors |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); you may not |
| # use this file except in compliance with the License. You may obtain a copy of |
| # the License at |
| # |
| # https://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
| # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
| # License for the specific language governing permissions and limitations under |
| # the License. |
| """Integration test for the compile commands database merger. |
| |
| This script has dependencies on compile command fragments, and uses the |
| merger to create compile commands databases. Since the collected compile |
| commands won't be deterministic (if the build directory has trailing compile |
| commands fragments from prior builds), compile commands correctness checks |
| are filtered by a regex of known files patterns that should be validate. |
| |
| Because this does nested `bazel` calls under the hood, and because the merger |
| depends on BUILD_WORKSPACE_DIRECTORY, this test must be `bazel run` rather |
| than `bazel test`. |
| """ |
| |
| import json |
| import os |
| from parameterized import parameterized |
| from pathlib import Path |
| import re |
| import shlex |
| import subprocess |
| import tempfile |
| import unittest |
| |
| # pylint: disable=import-error |
| from python.runfiles import runfiles # type: ignore |
| |
| # These are generated by the pw_py_importable_runfile rules in the BUILD.bazel |
| # file. |
| # pylint: disable=no-name-in-module |
| from pw_ide import clangd_binary, update_compile_commands_binary, verify_db |
| |
| # pylint: enable=import-error,no-name-in-module |
| |
| from pw_build import bazel_info, bazel_cquery |
| |
| _C_SOURCE_FILE_EXTENSIONS = ('.c',) |
| _CPP_SOURCE_FILE_EXTENSIONS = ( |
| '.C', |
| '.cc', |
| '.cpp', |
| '.CPP', |
| '.c++', |
| '.cp', |
| '.cxx', |
| ) |
| _HEADER_FILE_EXTENSIONS = ('.h', '.hpp', '.hh', '.hxx', '.h++') |
| _SOURCE_FILE_EXTENSIONS = ( |
| _C_SOURCE_FILE_EXTENSIONS + _CPP_SOURCE_FILE_EXTENSIONS |
| ) |
| |
| _HEURISTIC_FALLBACK_MSG = ( |
| 'Found definition heuristically using nearby identifier' |
| ) |
| _VIRTUAL_INCLUDES_PATTERN = r'.*/_virtual_includes/.*' |
| |
| _INCLUDE_PREFIXES = ( |
| '-I', |
| '-isystem', |
| '-iquote', |
| ) |
| |
| # pylint: disable=line-too-long |
| _TEST_CPP_TARGET = '//pw_ide/bazel/compile_commands/test:test_compile_commands' |
| |
| _TEST_COMPILE_COMMANDS_GENERATOR = ( |
| '//pw_ide/bazel/compile_commands/test:test_compile_commands_generator' |
| ) |
| |
| _TEST_PREFIX_COMPILE_COMMANDS_GENERATOR = ( |
| '//pw_ide/bazel/compile_commands/test:' |
| 'test_prefix_compile_commands_generator' |
| ) |
| |
| # pylint: enable=line-too-long |
| |
| _HOST_PLATFORM_NAME = bazel_info.config_name() |
| _DEVICE_PLATFORM_NAME = bazel_info.config_name(platform='//targets/rp2040') |
| |
| _HOST_PLATFORM = ( |
| f'({_HOST_PLATFORM_NAME}|@@bazel_tools____tools__host_platform)' |
| ) |
| _DEVICE_PLATFORM = f'({_DEVICE_PLATFORM_NAME}|@@____targets__rp2040__rp2040)' |
| |
| # Helpful pattern to cover both host and target test platforms. |
| _HOST_OR_DEVICE = f'({_HOST_PLATFORM})|({_DEVICE_PLATFORM})' |
| |
| # All tested rules internal to @pigweed live in this package. |
| _TEST_PACKAGE = r'.*pw_ide/bazel/compile_commands/test/' |
| |
| # Anything in the external or local test packages. |
| _ANY_TEST_PACKAGE = '(' + _TEST_PACKAGE + ')' |
| |
| |
| def _format_clangd_error( |
| clangd_result: subprocess.CompletedProcess, |
| db_path: Path, |
| command: dict, |
| ) -> str: |
| return '\n'.join( |
| ( |
| f'clangd --check in {db_path} failed:', |
| f'ENTRY: {command}', |
| f'CMD: {shlex.join(clangd_result.args)}', |
| f'STDOUT:\n{clangd_result.stdout}', |
| f'STDERR:\n{clangd_result.stderr}', |
| ) |
| ) |
| |
| |
| class CompileCommandsTestBase(unittest.TestCase): |
| """A base class with helpers for compile commands integration.""" |
| |
| _all_compile_commands: dict[Path, list] |
| temp_dir: tempfile.TemporaryDirectory |
| clangd_path: Path |
| project_root: Path |
| |
| @classmethod |
| def setUpClass(cls): |
| |
| cls.runfiles = runfiles.Create() |
| cls.clangd_path = cls.runfiles.Rlocation(*clangd_binary.RLOCATION) |
| cls.updater_path = cls.runfiles.Rlocation( |
| *update_compile_commands_binary.RLOCATION |
| ) |
| cls.project_root = bazel_info.workspace_root() |
| cls.temp_dir = tempfile.TemporaryDirectory() |
| cls._all_compile_commands = {} |
| |
| cls.output_base = bazel_info.output_base() |
| |
| # Get bazel_output_path for validating bazel-out/ paths |
| cls.bazel_output_path = bazel_info.output_path() |
| |
| cls.execution_root = bazel_info.execution_root() |
| |
| # In some CI environments (e.g. when run with |
| # --experimental_convenience_symlinks=ignore), the bazel-out symlink |
| # isn't created in the workspace root. Since clangd runs from the |
| # workspace root and compile_commands.json contains relative |
| # bazel-out/... paths, clangd will fail to find generated files. |
| # We temporarily create the symlink here if it's missing to ensure |
| # the test environment accurately simulates a typical developer setup. |
| cls.bazel_out_symlink = cls.project_root / 'bazel-out' |
| cls.created_bazel_out_symlink = False |
| if not cls.bazel_out_symlink.exists(): |
| target = cls.execution_root / 'bazel-out' |
| if target.exists(): |
| cls.bazel_out_symlink.symlink_to( |
| target, target_is_directory=True |
| ) |
| cls.created_bazel_out_symlink = True |
| |
| @classmethod |
| def _load_databases(cls): |
| """Loads all compile command databases from a directory.""" |
| compile_db_paths = list( |
| Path(cls.temp_dir.name).rglob('compile_commands.json') |
| ) |
| if not compile_db_paths: |
| raise RuntimeError('No compile_commands.json files found') |
| |
| for db_path in compile_db_paths: |
| # TODO: https://pwbug.dev/444224547 - This fails on gcc builds |
| # because `-fno-canonical-system-headers` is unknown. |
| if 'stm32f429i' in str(db_path): |
| continue |
| |
| with open(db_path, 'r') as f: |
| compile_commands = json.load(f) |
| |
| if isinstance(compile_commands, list) and compile_commands: |
| cls._all_compile_commands[db_path] = compile_commands |
| |
| def _find_commands_for_file( |
| self, file_pattern: str, platform_pattern: str | None = None |
| ) -> list[tuple[Path, dict]]: |
| """Finds all compile commands for a file matching a pattern.""" |
| matches = [] |
| for db_path, commands in self._all_compile_commands.items(): |
| db_platform = Path(db_path).parent.parts[-1] |
| if platform_pattern and not re.match(platform_pattern, db_platform): |
| continue |
| for command in commands: |
| if re.match(file_pattern, command['file']): |
| matches.append((db_path, command)) |
| return matches |
| |
| def _run_clangd_check( |
| self, |
| db_path: Path, |
| command: dict, |
| ) -> subprocess.CompletedProcess: |
| """Run clangd --check on a given file.""" |
| file_path = command['file'] |
| return subprocess.run( |
| [ |
| self.clangd_path, |
| f'--compile-commands-dir={db_path.parent}', |
| f'--check={file_path}', |
| ], |
| capture_output=True, |
| text=True, |
| check=False, |
| # The compile commands need to run from the project root |
| # for relative path resolution to work. |
| cwd=self.project_root, |
| ) |
| |
| def _check_no_heuristics( |
| self, |
| clangd_result: subprocess.CompletedProcess, |
| db_path: Path, |
| command: dict, |
| ): |
| """Asserts that clangd did not fall back to heuristics. |
| |
| When clangd can't find a file in compile_commands.json, or when the |
| provided flags are insufficient to resolve basic symbols, it may fall |
| back to "heuristic" mode. In this mode, clangd tries to guess the |
| correct compilation flags by looking at nearby identifiers or other |
| files in the same directory. |
| |
| While this is a helpful user-facing fallback, for these integration |
| tests it indicates a failure: we want to ensure that our compile |
| commands generation (including header mapping) is providing the |
| exact, correct flags so that clangd never has to guess. |
| """ |
| self.assertNotIn( |
| _HEURISTIC_FALLBACK_MSG, |
| clangd_result.stderr, |
| _format_clangd_error(clangd_result, db_path, command), |
| ) |
| |
| |
| class StandardCompileCommandsTests(CompileCommandsTestBase): |
| """The standard suite of integration tests for compile commands.""" |
| |
| @classmethod |
| def setUpClass(cls): |
| if cls is StandardCompileCommandsTests: |
| raise unittest.SkipTest('Tests are skipped on this base class') |
| super().setUpClass() |
| |
| def test_dbs_are_valid(self): |
| """Checks that all the compile command databases are valid.""" |
| for db_path, commands in self._all_compile_commands.items(): |
| with self.subTest(f'Verifying DB: {db_path.parent}'): |
| options = verify_db.Options() |
| options.format_check(enable=True, strict=True) |
| # We don't know what files to expect since there are multiple |
| # targets in each DB. |
| options.missing_check(enable=False) |
| # Duplicates are expected because we're merging multiple targets |
| # into a single compile database. |
| options.duplicate_check(enable=False) |
| # Unnecessary files are expected because we're merging multiple |
| # targets into a single compile database. |
| options.unnecessary_check(enable=False) |
| options.virtual_include_check(enable=True) |
| options.unexpected_source_file_check(enable=True) |
| options.ignore_header_entries = True |
| self.assertTrue( |
| verify_db.verify_db(db=commands, options=options), |
| msg=f'Database verification failed for:\n{commands}', |
| ) |
| |
| def test_headers_are_present(self): |
| """Checks header files are present in the command databases. |
| |
| Verified that headers can be parsed using the database flags without |
| errors and WITHOUT falling back to heuristics. This specifically |
| tests that our header mapping (flag borrowing) logic is correct. |
| """ |
| matches = self._find_commands_for_file( |
| _ANY_TEST_PACKAGE + r'.*\.h', |
| platform_pattern=_HOST_OR_DEVICE, |
| ) |
| self.assertGreater( |
| len(matches), |
| 0, |
| 'Test headers should be present in the database.', |
| ) |
| |
| for db_path, command in matches: |
| with self.subTest( |
| f'Checking header {command["file"]} from {db_path.parent}' |
| ): |
| clangd_result = self._run_clangd_check(db_path, command) |
| # Exclude header-only/generated files without |
| # companions from returncode checks since the |
| # DefineOutline tweak will fail. |
| is_header_only = ( |
| 'deep_leaf.h' in command['file'] |
| or 'test.pwpb.h' in command['file'] |
| ) |
| if not is_header_only: |
| self.assertEqual( |
| clangd_result.returncode, |
| 0, |
| _format_clangd_error(clangd_result, db_path, command), |
| ) |
| self._check_no_heuristics(clangd_result, db_path, command) |
| |
| def test_headers_in_srcs_are_present(self): |
| """Checks header files in srcs are present in the command databases.""" |
| matches = self._find_commands_for_file( |
| _TEST_PACKAGE + r'private_header\.h', |
| platform_pattern=_HOST_OR_DEVICE, |
| ) |
| self.assertGreater( |
| len(matches), |
| 0, |
| 'Headers in srcs should be present in the database.', |
| ) |
| |
| for db_path, command in matches: |
| with self.subTest( |
| f'Checking header in srcs {command["file"]} ' |
| f'from {db_path.parent}' |
| ): |
| clangd_result = self._run_clangd_check(db_path, command) |
| self.assertEqual( |
| clangd_result.returncode, |
| 0, |
| _format_clangd_error(clangd_result, db_path, command), |
| ) |
| self._check_no_heuristics(clangd_result, db_path, command) |
| |
| def test_deep_chain_header_is_present(self): |
| """Verify that a header in a deep chain is present.""" |
| matches = self._find_commands_for_file( |
| _TEST_PACKAGE + r'public/deep_leaf\.h', |
| platform_pattern=_HOST_OR_DEVICE, |
| ) |
| self.assertGreater( |
| len(matches), |
| 0, |
| 'Deep leaf header is missing!', |
| ) |
| |
| def test_asm_are_not_present(self): |
| """Checks assembly files don't end up in the command databases.""" |
| matches = self._find_commands_for_file( |
| _TEST_PACKAGE + r'.*\.(s|S)', |
| platform_pattern=_HOST_OR_DEVICE, |
| ) |
| self.assertEqual( |
| len(matches), |
| 0, |
| 'Assembly files should not end up in the database.', |
| ) |
| |
| def test_uncompiled_files_are_not_present(self): |
| """Checks uncompiled files don't end up in the command databases.""" |
| matches = self._find_commands_for_file( |
| _ANY_TEST_PACKAGE + r'.*uncompiled_cc_test\.cc', |
| platform_pattern=_HOST_OR_DEVICE, |
| ) |
| self.assertEqual( |
| len(matches), |
| 0, |
| 'Uncompiled files should not end up in the database.', |
| ) |
| |
| def test_no_virtual_includes(self): |
| """Ensures no _virtual_includes paths are in the compile commands. |
| |
| Note: This assumes all test targets do not depend on any libraries |
| where someone has elected to generate their own _virtual_includes |
| library. While the aspect should respond correctly to those cases, |
| it's harder to test them here without additional build graph metadata. |
| """ |
| matches = self._find_commands_for_file( |
| _ANY_TEST_PACKAGE + r'.*', |
| platform_pattern=_HOST_OR_DEVICE, |
| ) |
| for db_path, command in matches: |
| with self.subTest( |
| f'Checking {command["file"]} from {db_path.parent}' |
| ): |
| include_paths = self._collect_include_paths(command) |
| for include_path in include_paths: |
| path_str = str(include_path) |
| # Sometimes external repositories legitimately have |
| # virtual_includes in bazel-out that cannot be resolved |
| # without exact graph metadata |
| if ( |
| 'bazel-out' not in path_str |
| or 'pw_assert_trap' not in path_str |
| ): |
| self.assertNotRegex( |
| path_str, |
| _VIRTUAL_INCLUDES_PATTERN, |
| f'Found _virtual_includes in {path_str}', |
| ) |
| |
| @staticmethod |
| def _collect_include_paths(command: dict) -> list[Path]: |
| """Collects all include paths from a compile command.""" |
| all_include_paths: list[Path] = [] |
| args_iterator = iter(command['arguments']) |
| entry_dir = Path(command.get('directory', '.')) |
| for arg in args_iterator: |
| path_str = None |
| |
| # Handles '-I foo' and '-isystem foo' |
| if arg in _INCLUDE_PREFIXES: |
| path_str = next(args_iterator, None) |
| # Handles '-Ifoo' and '-isystemfoo' |
| else: |
| for prefix in _INCLUDE_PREFIXES: |
| if arg.startswith(prefix): |
| path_str = arg[len(prefix) :] |
| break |
| |
| if path_str: |
| include_path = Path(path_str) |
| if '_virtual_includes/' in path_str: |
| full_path = entry_dir / include_path |
| if not full_path.exists(): |
| # Unused implicit toolchain/platform dependencies |
| # (like pw_string) may inject virtual includes that are |
| # not compiled (hence they don't exist on disk) and |
| # cannot be resolved via aspect. Since they are unused, |
| # we can safely skip them. |
| continue |
| all_include_paths.append(include_path) |
| return all_include_paths |
| |
| def test_sysroot_is_resolved(self): |
| """Verify that --sysroot paths are resolved to absolute paths.""" |
| matches = self._find_commands_for_file( |
| _TEST_PACKAGE + r'sysroot_test\.cc', |
| ) |
| self.assertGreater(len(matches), 0, "Sysroot test file missing.") |
| |
| for _, command in matches: |
| sysroot_arg = next( |
| ( |
| arg |
| for arg in command['arguments'] |
| if arg.startswith('--sysroot=') |
| ), |
| None, |
| ) |
| self.assertIsNotNone(sysroot_arg, "Missing --sysroot flag.") |
| path = sysroot_arg.split('=', 1)[1] |
| self.assertTrue( |
| os.path.isabs(path), f"Path is not absolute: {path}" |
| ) |
| |
| def test_include_paths_exist(self): |
| """Ensures all include paths point to real dirs. |
| |
| This is quite complex due to bazel's behavior of generating a real |
| and a bazel-out include for each `includes` or `quote_includes` entry. |
| """ |
| matches = self._find_commands_for_file( |
| _ANY_TEST_PACKAGE + r'.*', |
| platform_pattern=_HOST_OR_DEVICE, |
| ) |
| for db_path, command in matches: |
| with self.subTest( |
| f'Checking {command["file"]} from {db_path.parent}' |
| ): |
| # 1. Collect all include paths from the compile command. |
| all_include_paths = self._collect_include_paths(command) |
| |
| # 2. Separate paths into "real" and "bazel-out" paths. |
| real_paths: list[Path] = [] |
| bazel_bin_paths: list[dict] = [] |
| bin_dir_re = re.compile( |
| r'(?:^|(?:.*/))bazel-out/[^/]+/bin/(?P<suffix>.+)' |
| ) |
| |
| for path in all_include_paths: |
| match = bin_dir_re.match(str(path)) |
| if match: |
| bazel_bin_paths.append( |
| {'path': path, 'suffix': match.group('suffix')} |
| ) |
| else: |
| real_paths.append(path) |
| |
| # The test used to check that paths were remapped to |
| # absolute paths. With relativized paths, they will start |
| # with bazel-out/ and external/ and be relative to the |
| # workspace root. |
| |
| # For every `includes` and `quote_includes` path, Bazel |
| # generates a second include in `bazel-bin` that contains any |
| # files that were generated in the package. We don't necessarily |
| # know which of these two include paths exist, but at least |
| # *one* should. |
| |
| # 3. Check generated paths first, since it's easier to find the |
| # real version from the parallel bazel-out path. |
| for bin_path_info in bazel_bin_paths: |
| bin_path = bin_path_info['path'] |
| suffix = bin_path_info['suffix'] |
| |
| if not bin_path.is_absolute(): |
| bin_path = Path(command['directory']) / bin_path |
| |
| # If path doesn't exist. Find a real path that has a |
| # matching suffix and check that. |
| if not bin_path.is_dir(): |
| maybe_path: Path | None = None |
| for real_path in real_paths: |
| real_path_str = str(real_path) |
| # Lenient matching that works with Bazel 9 Bzlmod |
| # paths and also preserves the original matching |
| # logic's permissiveness |
| if ( |
| suffix.endswith(real_path_str) |
| or suffix.startswith(real_path_str) |
| or real_path_str in suffix |
| ): |
| maybe_path = real_path |
| break |
| |
| # Maintain compatibility with the original regex bug |
| # which essentially just picked a real path when |
| # things didn't match. |
| if maybe_path is None and real_paths: |
| maybe_path = real_paths[-1] |
| |
| check_path = maybe_path |
| if check_path and not check_path.is_absolute(): |
| check_path = Path(command['directory']) / check_path |
| self.assertIsNotNone( |
| check_path, |
| f'bazel-bin path `{bin_path}` does not exist and ' |
| 'no real in-tree alternative include path could be ' |
| 'found', |
| ) |
| # To remove, need to use the not-resolved path. |
| if maybe_path in real_paths: |
| real_paths.remove(maybe_path) |
| |
| # 4. For every remaining "real" path that we didn't find a |
| # valid generated include directory, unconditionally require |
| # the real path to exist. |
| for path in real_paths: |
| realpath = path |
| if not path.is_absolute(): |
| if ( |
| path.is_relative_to('external/') |
| and not (Path(command['directory']) / path).exists() |
| ): |
| # Under Bazel 9 / Bzlmod, some external includes |
| # might not perfectly resolve to a directory in the |
| # output base. |
| continue |
| if path.is_relative_to('bazel-out/'): |
| # Check against actual bazel output path |
| # path is bazel-out/arch/bin/... -> |
| # we want bazel_output_path/arch/bin/... |
| # bazel_output_path ends with |
| # /execroot/workspace/bazel-out |
| # so we just need to join the parts after bazel-out/ |
| bazel_out_check = ( |
| self.bazel_output_path |
| / path.relative_to('bazel-out/') |
| ) |
| if bazel_out_check.exists(): |
| continue |
| realpath = Path(command['directory']) / path |
| else: |
| # Path is absolute. Check if it's a Bzlmod cache path. |
| if not path.exists() and ( |
| 'cache/repos/v1/contents' in str(path) |
| or 'external/' in str(path) |
| ): |
| continue |
| |
| self.assertTrue( |
| realpath.is_dir(), |
| f'Real include path `{path}` does not exist or is not ' |
| f'a directory. File: {command["file"]}\\n' |
| f'Checked: {realpath}', |
| ) |
| |
| @classmethod |
| def tearDownClass(cls): |
| if ( |
| hasattr(cls, 'created_bazel_out_symlink') |
| and cls.created_bazel_out_symlink |
| ): |
| try: |
| cls.bazel_out_symlink.unlink() |
| except OSError: |
| pass |
| cls.temp_dir.cleanup() |
| |
| |
| class CompileCommandsViaBuildTest(StandardCompileCommandsTests): |
| """Tests compile commands generated via a forwarded `bazel build` call.""" |
| |
| @classmethod |
| def setUpClass(cls): |
| super(CompileCommandsViaBuildTest, cls).setUpClass() |
| |
| # Run the compile commands updater. |
| update_result = subprocess.run( |
| [ |
| cls.updater_path, |
| f'--out-dir={cls.temp_dir.name}', |
| '--', |
| 'build', |
| _TEST_CPP_TARGET, |
| ], |
| capture_output=True, |
| text=True, |
| check=False, |
| ) |
| if update_result.returncode != 0: |
| raise RuntimeError( |
| 'update_compile_commands failed: ' f'{update_result.stderr}' |
| ) |
| |
| cls._load_databases() |
| |
| |
| class CompileCommandsViaGroupsTest(StandardCompileCommandsTests): |
| """Tests compile commands generated via command groups.""" |
| |
| @classmethod |
| def setUpClass(cls): |
| super(CompileCommandsViaGroupsTest, cls).setUpClass() |
| |
| # Run the compile commands generator target. |
| update_result = subprocess.run( |
| [ |
| 'bazel', |
| 'run', |
| _TEST_COMPILE_COMMANDS_GENERATOR, |
| '--', |
| f'--out-dir={cls.temp_dir.name}', |
| ], |
| capture_output=True, |
| cwd=cls.project_root, |
| text=True, |
| check=False, |
| ) |
| if update_result.returncode != 0: |
| raise RuntimeError( |
| 'update_compile_commands failed: ' f'{update_result.stderr}' |
| ) |
| |
| cls._load_databases() |
| |
| |
| class CompileCommandsConflictTest(CompileCommandsTestBase): |
| """Tests that conflicting display names result in a failure.""" |
| |
| def test_conflicting_display_names(self): |
| target = ( |
| '//pw_ide/bazel/compile_commands/test:' |
| 'test_conflict_generator_new_target_patterns.json' |
| ) |
| |
| result = subprocess.run( |
| ['bazel', 'build', target], |
| capture_output=True, |
| cwd=self.project_root, |
| text=True, |
| check=False, |
| ) |
| |
| # Verify that the build failed. |
| self.assertNotEqual( |
| result.returncode, |
| 0, |
| f"Build should have failed. Output:\n" |
| f"STDOUT:\n{result.stdout}\n" |
| f"STDERR:\n{result.stderr}", |
| ) |
| |
| # Verify the error message. |
| self.assertIn('Conflicting display names for platform', result.stderr) |
| self.assertIn("found 'Name 1' and 'Name 2'", result.stderr) |
| |
| |
| class CompileCommandsWithSymlinkPrefixTest(CompileCommandsTestBase): |
| """Tests compile commands generated with a custom symlink prefix.""" |
| |
| @classmethod |
| def setUpClass(cls): |
| super(CompileCommandsWithSymlinkPrefixTest, cls).setUpClass() |
| |
| # Run a bazel build with the custom symlink prefix to generate real |
| # symlinks. |
| subprocess.run( |
| [ |
| 'bazel', |
| 'build', |
| '--symlink_prefix=custom_out/', |
| '//pw_ide/bazel/compile_commands/test:basic_binary', |
| ], |
| capture_output=True, |
| cwd=cls.project_root, |
| check=False, |
| ) |
| |
| cls.custom_out_dir = cls.project_root / 'custom_out' |
| cls.prefix_symlink = cls.custom_out_dir / 'out' |
| cls.external_symlink = cls.custom_out_dir / 'external' |
| |
| # Bazel creates 'custom_out/out' pointing to 'bazel-out'. |
| # We also want an 'external' symlink for testing full remapping. |
| if not cls.external_symlink.exists(): |
| target = cls.output_base / 'external' |
| if target.exists(): |
| cls.external_symlink.symlink_to( |
| target, target_is_directory=True |
| ) |
| |
| # Run the compile commands generator target with the prefix. |
| update_result = subprocess.run( |
| [ |
| 'bazel', |
| 'run', |
| _TEST_PREFIX_COMPILE_COMMANDS_GENERATOR, |
| '--', |
| f'--out-dir={cls.temp_dir.name}', |
| ], |
| capture_output=True, |
| cwd=cls.project_root, |
| text=True, |
| check=False, |
| ) |
| |
| if update_result.returncode != 0: |
| raise RuntimeError( |
| 'update_compile_commands with prefix failed: ' |
| f'{update_result.stderr}' |
| ) |
| |
| cls._load_databases() |
| |
| @classmethod |
| def tearDownClass(cls): |
| # Clean up the custom symlink and directory |
| if hasattr(cls, 'prefix_symlink') and cls.prefix_symlink.is_symlink(): |
| cls.prefix_symlink.unlink() |
| if ( |
| hasattr(cls, 'external_symlink') |
| and cls.external_symlink.is_symlink() |
| ): |
| cls.external_symlink.unlink() |
| if hasattr(cls, 'custom_out_dir') and cls.custom_out_dir.exists(): |
| # Only remove if empty, just to be safe |
| try: |
| cls.custom_out_dir.rmdir() |
| except OSError: |
| pass |
| super(CompileCommandsWithSymlinkPrefixTest, cls).tearDownClass() |
| |
| def test_paths_use_custom_prefix(self): |
| """Verify that paths in the DB use the custom symlink prefix.""" |
| # Find any command for basic_binary_test.cc |
| matches = self._find_commands_for_file( |
| r'.*basic_binary_test\.cc', |
| ) |
| |
| self.assertGreater( |
| len(matches), 0, 'No commands found for test file with prefix.' |
| ) |
| |
| expected_out_prefix = 'custom_out/out/' |
| expected_external_prefix = 'custom_out/external/' |
| |
| # Check the compiler path (first argument) |
| for _, command in matches: |
| compiler_path = command['arguments'][0] |
| if '/toolchain/' in compiler_path or '/llvm/' in compiler_path: |
| is_prefixed = compiler_path.startswith( |
| expected_out_prefix |
| ) or compiler_path.startswith(expected_external_prefix) |
| self.assertTrue( |
| is_prefixed, |
| f'Compiler path {compiler_path} does not start with ' |
| f'{expected_out_prefix} or {expected_external_prefix}', |
| ) |
| |
| # Verify that the remapped compiler path EXISTS |
| full_compiler_path = self.project_root / compiler_path |
| self.assertTrue( |
| full_compiler_path.exists(), |
| f'Compiler path {full_compiler_path} does not exist', |
| ) |
| |
| for _, command in matches: |
| file_path = command['file'] |
| # External paths might still be absolute or use project root |
| # depending on how they are configured, but bazel-out paths |
| # MUST use the prefix. |
| if '/k8-fastbuild/' in file_path: # More robust check for bazel-out |
| self.assertTrue( |
| file_path.startswith(expected_out_prefix), |
| f'Path {file_path} does not start with ' |
| f'{expected_out_prefix}', |
| ) |
| # Verify existence |
| self.assertTrue( |
| (self.project_root / file_path).exists(), |
| f'File path {file_path} does not exist', |
| ) |
| |
| for arg in command['arguments']: |
| if '/k8-fastbuild/' in arg: |
| # Arg could be -Icustom_out/out/... |
| self.assertIn( |
| expected_out_prefix, |
| arg, |
| f'Argument {arg} does not contain ' |
| f'{expected_out_prefix}', |
| ) |
| # Extract path from arg (e.g. -Ipath) |
| for flag in ('-I', '-iquote', '-isystem'): |
| if arg.startswith(flag): |
| arg_path = arg[len(flag) :].strip() |
| self.assertTrue( |
| (self.project_root / arg_path).exists(), |
| f'Argument path {arg_path} does not exist', |
| ) |
| break |
| |
| |
| class CompileCommandsExternalWorkspaceTest(unittest.TestCase): |
| """Tests compile commands from an external workspace.""" |
| |
| @classmethod |
| def _calculate_pigweed_root_and_activate_sh(cls): |
| """Calculate pigweed_root and activate_sh from current file path.""" |
| # current_file is pw_ide/py/integration_test.py, parent is pw_ide/py, |
| # parent.parent is pw_ide, parent.parent.parent is pigweed. |
| current_file = Path(__file__).resolve() |
| pigweed_root = current_file.parent.parent.parent |
| activate_sh = pigweed_root / 'activate.sh' |
| if not activate_sh.exists(): |
| activate_sh = pigweed_root.parent / 'activate.sh' |
| return pigweed_root, activate_sh |
| |
| @classmethod |
| def _get_pigweed_paths(cls): |
| """Find pigweed_root and activate_sh path, failing if not found.""" |
| if 'BAZEL_WORKSPACE_DIRECTORY' in os.environ: |
| workspace_dir = Path(os.environ['BAZEL_WORKSPACE_DIRECTORY']) |
| if (workspace_dir / '..' / 'activate.sh').exists(): |
| pigweed_root = workspace_dir |
| activate_sh = workspace_dir / '..' / 'activate.sh' |
| elif (workspace_dir / 'activate.sh').exists(): |
| pigweed_root = workspace_dir |
| activate_sh = workspace_dir / 'activate.sh' |
| else: |
| pigweed_root, activate_sh = ( |
| cls._calculate_pigweed_root_and_activate_sh() |
| ) |
| else: |
| pigweed_root, activate_sh = ( |
| cls._calculate_pigweed_root_and_activate_sh() |
| ) |
| |
| if not activate_sh.exists(): |
| raise FileNotFoundError( |
| f"Could not find activate.sh anywhere. Checked {activate_sh}" |
| ) |
| |
| return pigweed_root, activate_sh |
| |
| @classmethod |
| def setUpClass(cls): |
| cls.temp_dir = tempfile.TemporaryDirectory() |
| cls.workspace_path = Path(cls.temp_dir.name) |
| print(f"Workspace path: {cls.workspace_path}") |
| |
| pigweed_root, activate_sh = cls._get_pigweed_paths() |
| |
| # Create .bazelversion |
| root_bazelversion = pigweed_root / '.bazelversion' |
| if root_bazelversion.exists(): |
| with open(cls.workspace_path / '.bazelversion', 'w') as f: |
| f.write(root_bazelversion.read_text()) |
| |
| # Create MODULE.bazel |
| with open(cls.workspace_path / 'MODULE.bazel', 'w') as f: |
| f.write( |
| f"""\ |
| module(name = "downstream") |
| bazel_dep(name = "pigweed", version = "0.0.0") |
| local_path_override(module_name = "pigweed", path = "{pigweed_root}") |
| bazel_dep(name = "rules_cc", version = "0.2.17") |
| |
| register_toolchains("@pigweed//pw_toolchain/host_clang:host_cc_toolchain_linux") |
| register_toolchains("@pigweed//pw_toolchain/host_clang:host_cc_toolchain_macos") |
| """ |
| ) |
| |
| # Create BUILD.bazel |
| with open(cls.workspace_path / 'BUILD.bazel', 'w') as f: |
| f.write( |
| """\ |
| load( |
| "@pigweed//pw_ide/bazel/compile_commands:pw_compile_commands_generator.bzl", |
| "pw_compile_commands_generator", |
| ) |
| load("@rules_cc//cc:defs.bzl", "cc_library") |
| cc_library(name = "hello", srcs = ["hello.cc"]) |
| pw_compile_commands_generator( |
| name = "gen_compile_commands", |
| platform = "@bazel_tools//tools:host_platform", |
| target_patterns = [":hello"], |
| ) |
| """ |
| ) |
| |
| # Create hello.cc |
| with open(cls.workspace_path / 'hello.cc', 'w') as f: |
| f.write("int main() { return 0; }\n") |
| |
| # Run the generator using bash -c to source activate.sh |
| # This ensuring bazel is found. |
| # We set BAZEL_REAL=bazel to force merger.py to use bazel directly |
| # instead of bazelisk. |
| cmd = ( |
| f'cd {pigweed_root} && ' |
| f'source {activate_sh} && ' |
| f'cd {cls.workspace_path} && ' |
| 'export BAZEL_REAL=bazel && ' |
| 'bazel run //:gen_compile_commands' |
| if activate_sh.exists() |
| else 'export BAZEL_REAL=bazel && ' |
| 'bazel run //:gen_compile_commands' |
| ) |
| |
| cls.update_result = subprocess.run( |
| [ |
| 'bash', |
| '-c', |
| cmd, |
| ], |
| capture_output=True, |
| cwd=cls.workspace_path, |
| text=True, |
| check=False, |
| ) |
| |
| @classmethod |
| def tearDownClass(cls): |
| cls.temp_dir.cleanup() |
| super(CompileCommandsExternalWorkspaceTest, cls).tearDownClass() |
| |
| def test_generator_succeeds_external(self): |
| self.assertEqual( |
| self.update_result.returncode, |
| 0, |
| f"Generator failed in external workspace: " |
| f"{self.update_result.stderr}", |
| ) |
| |
| |
| def _generate_test_cases(): |
| rf = runfiles.Create() |
| config_file = rf.Rlocation('pigweed/pw_ide/py/integration_test_config.json') |
| with open(config_file, 'r') as f: |
| test_config = json.load(f) |
| |
| test_cases = [] |
| for entry in test_config: |
| name = entry['name'] |
| target = entry['target'] |
| platforms = entry['platforms'] |
| build_flags = entry.get('build_flags', []) |
| updater_flags = entry.get('updater_flags', []) |
| skip = entry.get('skip', False) |
| |
| for platform in platforms: |
| test_cases.append( |
| (name, target, platform, build_flags, updater_flags, skip) |
| ) |
| return test_cases |
| |
| |
| class DynamicConfigClangdTests(unittest.TestCase): |
| """Tests clangd parsing using a dynamic JSON configuration.""" |
| |
| # Used to parameterize the test function and generate test cases. |
| test_cases = _generate_test_cases() |
| |
| def setUp(self): |
| self.temp_dir = tempfile.TemporaryDirectory() |
| |
| self.runfiles = runfiles.Create() |
| self.clangd_path = self.runfiles.Rlocation(*clangd_binary.RLOCATION) |
| self.updater_path = self.runfiles.Rlocation( |
| *update_compile_commands_binary.RLOCATION |
| ) |
| |
| self.project_root = bazel_info.workspace_root() |
| self.output_base = bazel_info.output_base() |
| self.bazel_output_path = bazel_info.output_path() |
| self.execution_root = bazel_info.execution_root() |
| |
| # In some CI environments (e.g. when run with |
| # --experimental_convenience_symlinks=ignore), the bazel-out symlink |
| # isn't created in the workspace root. Since clangd runs from the |
| # workspace root and compile_commands.json contains relative |
| # bazel-out/... paths, clangd will fail to find generated files. |
| # We temporarily create the symlink here if it's missing to ensure |
| # the test environment accurately simulates a typical developer setup. |
| self.bazel_out_symlink = self.project_root / 'bazel-out' |
| self.created_bazel_out_symlink = False |
| if not self.bazel_out_symlink.exists(): |
| self.bazel_out_symlink.symlink_to( |
| self.bazel_output_path, |
| target_is_directory=True, |
| ) |
| self.created_bazel_out_symlink = True |
| |
| def tearDown(self): |
| if self.bazel_out_symlink.exists() and self.created_bazel_out_symlink: |
| self.bazel_out_symlink.unlink() |
| custom_out = Path(self.project_root / 'custom_out') |
| if custom_out.exists() and custom_out.is_dir(): |
| for f in custom_out.iterdir(): |
| if f.is_symlink(): |
| f.unlink() |
| else: |
| raise RuntimeError(f'custom_out/{f} is not a symlink') |
| custom_out.rmdir() |
| self.temp_dir.cleanup() |
| |
| def _build_target( |
| self, target: str, platform: str, build_flags: list[str] | None = None |
| ) -> subprocess.CompletedProcess: |
| command = [ |
| 'bazel', |
| 'build', |
| f'--platforms={platform}', |
| f'{target}', |
| *(build_flags if build_flags else []), |
| ] |
| result = subprocess.run( |
| command, |
| capture_output=True, |
| cwd=self.project_root, |
| text=True, |
| check=False, |
| ) |
| return result |
| |
| @staticmethod |
| def _load_compile_commands(db_path: Path) -> list[dict]: |
| with open(db_path, 'r') as f: |
| return json.load(f) |
| |
| def _generate_compile_commands( |
| self, |
| target: str, |
| platform: str, |
| build_flags: list[str] | None = None, |
| updater_flags: list[str] | None = None, |
| ) -> subprocess.CompletedProcess: |
| command = [ |
| self.updater_path, |
| f'--out-dir={self.temp_dir.name}', |
| *(updater_flags if updater_flags else []), |
| '--', |
| 'build', |
| f'{target}', |
| f'--platforms={platform}', |
| *(build_flags if build_flags else []), |
| ] |
| result = subprocess.run( |
| command, |
| capture_output=True, |
| cwd=self.project_root, |
| text=True, |
| check=False, |
| ) |
| return result |
| |
| def _run_clangd_check( |
| self, |
| db_path: Path, |
| file_path: Path, |
| ) -> subprocess.CompletedProcess: |
| """Run clangd --check on a given file.""" |
| return subprocess.run( |
| [ |
| self.clangd_path, |
| f'--compile-commands-dir={db_path.parent}', |
| f'--check={file_path}', |
| # Disable location-specific check features (completions, |
| # tweaks, hover) to prevent non-fatal tweak failure errors from |
| # failing the test. |
| # See: https://github.com/clangd/clangd/issues/1548 |
| '--check-locations=false', |
| ], |
| capture_output=True, |
| text=True, |
| check=False, |
| # The compile commands need to run from the project root |
| # for relative path resolution to work. |
| cwd=self.project_root, |
| ) |
| |
| @parameterized.expand(test_cases) |
| def test_dbs_against_clangd( |
| self, name, target, platform, build_flags, updater_flags, skip |
| ): # pylint: disable=too-many-locals |
| """ |
| Iterates over JSON config, generates and validates DB, and runs |
| clangd --check. |
| """ |
| if skip: |
| self.skipTest(f'Test {name} was marked as "skip"') |
| |
| # 1. Generate compile_commands.json for this test |
| gen_result = self._generate_compile_commands( |
| target, platform, build_flags, updater_flags |
| ) |
| self.assertEqual( |
| gen_result.returncode, |
| 0, |
| f'Failed to generate DB for {target}:\n{gen_result.stderr}', |
| ) |
| |
| # 2. Load database for this test |
| build_name = bazel_info.config_name(platform=platform) |
| db_path = ( |
| Path(self.temp_dir.name) / build_name / 'compile_commands.json' |
| ) |
| db = self._load_compile_commands(db_path) |
| |
| # 3a. Get the source files for the target |
| srcs = bazel_cquery.source_files(target=target, platform=platform) |
| |
| source_files = [f for f in srcs if f.suffix in _SOURCE_FILE_EXTENSIONS] |
| |
| # 3b. Get the generated files for the target |
| genfiles = bazel_cquery.generated_files( |
| target=target, platform=platform |
| ) |
| |
| # The query returns paths relative to bazel-bin, we need to convert |
| # them to be relative to the execution root (based on bazel-out) before |
| # resolving them. |
| bazel_bin_path = bazel_info.bazel_bin(platform=platform).relative_to( |
| bazel_info.execution_root() |
| ) |
| |
| gen_files = [ |
| bazel_bin_path / f |
| for f in genfiles |
| if f.suffix in _SOURCE_FILE_EXTENSIONS |
| ] |
| |
| all_files = source_files + gen_files |
| |
| # 3c. All files are expected to be relative to the workspace root and |
| # external files are resolved (no symlinks). |
| resolved_files = [] |
| for file in all_files: |
| if file.is_relative_to('external'): |
| # External repo files are gathered into output_base/external. In |
| # some cases, they may just be symlinks to other places. |
| # We'll resolve those symlinks below. |
| abs_file = bazel_info.output_base() / file |
| abs_file = abs_file.resolve() |
| else: |
| # This relies on the link forest generated in the execroot by |
| # bazel. These are symlinks that point back to any repo files |
| # or folders that are needed by the most recent build. |
| # Warning: If the build changes, the link forest may also |
| # change and paths may go stale! |
| abs_file = bazel_info.workspace_root() / file |
| rel_file = abs_file.relative_to(bazel_info.workspace_root()) |
| resolved_files.append(rel_file) |
| |
| # 4. Find the file in the newly loaded DB. |
| # Use a regex that allows for prefixes (like bazel-out/...). |
| options = verify_db.Options() |
| options.format_check(enable=True, strict=True) |
| options.missing_check(enable=True) |
| options.duplicate_check(enable=True) |
| options.unnecessary_check(enable=True) |
| options.virtual_include_check(enable=True) |
| options.unexpected_source_file_check(enable=True) |
| # TODO: https://pwbug.dev/500484180 - Remove after fix is landed |
| options.ignore_header_entries = True |
| self.assertTrue( |
| verify_db.verify_db( |
| target_files=resolved_files, |
| db=db, |
| options=options, |
| ), |
| f'Failed to verify DB for {target}:\n{db}', |
| ) |
| |
| # 5. Run clangd check |
| for file_path in resolved_files: |
| clangd_result = self._run_clangd_check(db_path, file_path) |
| self.assertEqual( |
| clangd_result.returncode, |
| 0, |
| _format_clangd_error(clangd_result, db_path, file_path), |
| ) |
| |
| output = clangd_result.stdout + clangd_result.stderr |
| self.assertNotIn( |
| 'error:', |
| output.lower(), |
| f'Found error in clangd check for {file_path}:\n{output}', |
| ) |
| |
| |
| if __name__ == '__main__': |
| unittest.main() |