Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.

## Next Release

### Fixes

#### CPP Rules

- Refine the `-include`/`-include-pch` handling in `compile_commands.json` arg filtering (following up on the 0.4.1 fix): `-include-pch` (the compiled PCH binary) is still always dropped, since it's serialized by whichever real compiler produced it and is generally incompatible with pip's bundled libclang regardless of whether the file exists. A plain `-include <file>` (the textual PCH header CMake also generates alongside the binary one) is now kept — normalized to a bare pair regardless of how it was originally wrapped — whenever that file exists on disk, since real project headers often rely on it being force-included first for standard-library symbols (`<optional>`, `<format>`, ...) they don't include themselves; unconditionally dropping it (the previous fix) produced a cascade of unrelated "no member"/"too many errors" failures when such a header was checked standalone. It's still dropped when missing, to avoid the original "file not found" fatal error

<!-- insertion marker -->
## [0.4.1](https://github.com/repo/owner/releases/tag/0.4.1) - 2026-09-26

Expand Down
87 changes: 66 additions & 21 deletions src/devops/cpp/ast/rule.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import typing
from pathlib import Path

import clang.cindex as clang

Expand All @@ -12,20 +13,15 @@
from devops.rules import ResultType, ResultTypeEnum, Rule, RuleInputType, RuleType

if typing.TYPE_CHECKING:
from pathlib import Path

from devops.rules import FileRuleInput

DEFAULT_COMPILE_ARGS = ["-std=c++23"]


# Flags that are not useful to libclang and whose following argument (if any)
# should also be dropped. -include is dropped entirely (not just filtered by
# the source-extension check below) because a force-included PCH header may
# not exist for every cmake target and would otherwise cause a hard parse
# failure; see the -Xclang handling below for the Clang-specific spelling of
# the same flag.
_SKIP_WITH_ARG = frozenset(("-o", "-MF", "-MT", "-MQ", "-include"))
# should also be dropped. -include / -include-pch are handled separately (see
# _consume_include) since whether to keep them depends on the file they name.
_SKIP_WITH_ARG = frozenset(("-o", "-MF", "-MT", "-MQ"))
# Flags that are not useful but take no following argument.
_SKIP_ALONE = frozenset(
(
Expand All @@ -38,20 +34,68 @@
)
# Source / header file extensions to skip when they appear as positional args.
_SOURCE_EXTENSIONS = (".cpp", ".cxx", ".cc", ".c", ".hpp", ".hxx", ".hh", ".h")
# -include / -include-pch: force-include another file before the translation
# unit proper. CMake's target_precompile_headers() emits both for the same
# PCH, spelled differently per compiler/wrapping (see _consume_include).
_INCLUDE_FLAGS = frozenset(("-include", "-include-pch"))


def _include_file_exists(file_arg: str | None) -> bool:
"""Check whether an ``-include``'s file argument names a real file."""
if not file_arg:
return False
try:
return Path(file_arg).is_file()
except OSError:
return False


def _consume_include(it: typing.Iterator[str], result: list[str], flag: str) -> None:
"""Handle the file argument of an already-consumed ``-include``/``-include-pch``.

``-include-pch`` (the compiled PCH binary) is always dropped together
with its file argument: it's serialized by whichever real compiler
produced it and is generally incompatible with pip's bundled libclang,
triggering a hard parse failure regardless of whether the file exists.

A plain ``-include <file>`` (the textual PCH header CMake also
generates alongside the binary one) is kept — normalized to a bare
pair regardless of how it was originally wrapped — when that file
exists on disk: project headers often rely on it being force-included
first for standard-library symbols (``<optional>``, ``<format>``, ...)
they don't include themselves, so dropping it unconditionally produces
a cascade of unrelated "no member"/"too many errors" failures. It's
dropped, like ``-include-pch``, when the file is missing (e.g. a
fresh/partial checkout), to avoid a "file not found" fatal error.

The file argument may itself be wrapped in another ``-Xclang``
(``-Xclang -include(-pch) -Xclang <file>``) or bare
(``-Xclang -include(-pch) <file>`` / plain ``-include(-pch) <file>``)
depending on the CMake/Clang version, so this peeks one token rather
than assuming a fixed shape.

Parameters
----------
it: typing.Iterator[str]
The shared argument iterator, positioned right after the flag.
result: list[str]
The filtered-args list being built; appended to in place.
flag: str
Either ``"-include"`` or ``"-include-pch"``.

"""
following = next(it, None) # the file, bare or another -Xclang
file_arg = next(it, None) if following == "-Xclang" else following
if flag == "-include" and file_arg is not None and _include_file_exists(file_arg):
result.append("-include")
result.append(file_arg)


def _consume_xclang(it: typing.Iterator[str], result: list[str]) -> None:
"""Handle the token(s) after an ``-Xclang`` already consumed from `it`.

Drops ``-Xclang -include-pch`` / ``-Xclang -include`` and their file
argument entirely, same as the bare ``-include`` case in
`_args_from_compile_commands`: a force-included PCH header may not exist
for every cmake target and would otherwise cause a hard parse failure.
That file argument may itself be wrapped in another ``-Xclang``
(``-Xclang -include-pch -Xclang <file>``) or bare
(``-Xclang -include-pch <file>``) depending on the CMake/Clang version,
so this peeks one token rather than assuming a fixed shape. Every other
``-Xclang <frontend-arg>`` pair is appended to `result` as-is.
Delegates ``-Xclang -include(-pch) ...`` to `_consume_include`. Every
other ``-Xclang <frontend-arg>`` pair is appended to `result` as-is.

Parameters
----------
Expand All @@ -62,10 +106,8 @@ def _consume_xclang(it: typing.Iterator[str], result: list[str]) -> None:

"""
xclang_arg = next(it, None)
if xclang_arg in ("-include-pch", "-include"):
following = next(it, None) # the file, bare or another -Xclang
if following == "-Xclang":
next(it, None) # the file itself, wrapped
if xclang_arg in _INCLUDE_FLAGS:
_consume_include(it, result, xclang_arg)
return
if xclang_arg is not None:
result.append("-Xclang")
Expand Down Expand Up @@ -95,6 +137,9 @@ def _args_from_compile_commands(
# Skip source / header files passed as positional arguments.
if not arg.startswith("-") and arg.endswith(_SOURCE_EXTENSIONS):
continue
if arg in _INCLUDE_FLAGS:
_consume_include(it, result, arg)
continue
if arg == "-Xclang":
_consume_xclang(it, result)
continue
Expand Down
93 changes: 76 additions & 17 deletions tests/cpp/ast/test_rule.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,19 +84,34 @@ def _write_compile_commands(
class TestArgsFromCompileCommands:
"""`-include`/PCH handling: bare (GCC) and -Xclang-wrapped (Clang) forms.

Regression coverage for a real bug: a bare `-include <pch>.hxx` (as GCC
emits for CMake's target_precompile_headers, unlike Clang's
`-Xclang -include -Xclang <pch>.hxx`) used to have its file argument
silently dropped by the positional-source-file heuristic (it ends in
`.hxx`, one of `_SOURCE_EXTENSIONS`) while the `-include` flag itself
was kept — leaving a dangling `-include` that then swallowed the next
*unrelated* flag in the args list as its filename, producing a fatal
"file not found" libclang parse error for a file that was never
referenced by the project at all.
Two bugs are covered here, both discovered from real project failures:

1. A bare `-include <pch>.hxx` (as GCC emits for CMake's
`target_precompile_headers`, unlike Clang's
`-Xclang -include -Xclang <pch>.hxx`) used to have its file argument
silently dropped by the positional-source-file heuristic (it ends in
`.hxx`, one of `_SOURCE_EXTENSIONS`) while the `-include` flag itself
was kept — leaving a dangling `-include` that then swallowed the next
*unrelated* flag in the args list as its filename, producing a fatal
"file not found" libclang parse error for a file that was never
referenced by the project at all.

2. The fix for (1) then dropped every `-include`/`-include-pch`
unconditionally. That broke real headers that rely on the textual
PCH header (the plain `-include`, not the compiled `-include-pch`
binary) always being force-included first for standard-library
symbols (`<optional>`, `<format>`, ...) they don't include
themselves — producing a cascade of unrelated "no member"/"too many
errors" failures when checking such a header standalone. So a plain
`-include <file>` is now kept (normalized to a bare pair) whenever
that file exists on disk, and only dropped when it's missing (e.g. a
fresh/partial checkout) — while `-include-pch` (the compiled binary,
generally incompatible with pip's bundled libclang regardless of
whether it exists) is still always dropped.
"""

def test_bare_include_and_its_file_are_both_dropped(self, tmp_path: Path) -> None:
"""Test bare include and its file are both dropped."""
def test_bare_include_dropped_when_file_missing(self, tmp_path: Path) -> None:
"""Test bare include dropped when file missing."""
db_dir = tmp_path
cpp_file = tmp_path / "foo.cpp"
cpp_file.write_text("void f() {}\n")
Expand All @@ -116,14 +131,36 @@ def test_bare_include_and_its_file_are_both_dropped(self, tmp_path: Path) -> Non
idx = args.index("-Wno-unknown-warning-option")
assert args[idx - 1] != "-include"

def test_xclang_wrapped_include_pch_is_dropped(self, tmp_path: Path) -> None:
"""Test xclang wrapped include pch is dropped."""
def test_bare_include_kept_when_file_exists(self, tmp_path: Path) -> None:
"""Test bare include kept when file exists."""
db_dir = tmp_path
cpp_file = tmp_path / "foo.cpp"
cpp_file.write_text("void f() {}\n")
pch_header = tmp_path / "cmake_pch.hxx"
pch_header.write_text("#include <optional>\n")
command = (
"/usr/bin/clang++-20 -Xclang -include-pch -Xclang "
f"{tmp_path}/pch.pch -std=c++23 -o foo.o -c {cpp_file}"
f"/usr/bin/g++-13 -Winvalid-pch -include {pch_header} "
f"-std=c++23 -o foo.o -c {cpp_file}"
)
_write_compile_commands(tmp_path, db_dir, cpp_file, command)

db = clang.CompilationDatabase.fromDirectory(str(db_dir))
args = _args_from_compile_commands(db, cpp_file)

assert args is not None
idx = args.index("-include")
assert args[idx + 1] == str(pch_header)

def test_xclang_wrapped_include_pch_is_always_dropped(self, tmp_path: Path) -> None:
"""The compiled PCH binary is dropped even when the file exists."""
db_dir = tmp_path
cpp_file = tmp_path / "foo.cpp"
cpp_file.write_text("void f() {}\n")
pch_binary = tmp_path / "pch.pch"
pch_binary.write_bytes(b"not a real pch")
command = (
f"/usr/bin/clang++-20 -Xclang -include-pch -Xclang {pch_binary} "
f"-std=c++23 -o foo.o -c {cpp_file}"
)
_write_compile_commands(tmp_path, db_dir, cpp_file, command)

Expand All @@ -134,8 +171,10 @@ def test_xclang_wrapped_include_pch_is_dropped(self, tmp_path: Path) -> None:
assert "-include-pch" not in args
assert not any(a.endswith("pch.pch") for a in args)

def test_xclang_wrapped_include_is_dropped(self, tmp_path: Path) -> None:
"""Test xclang wrapped include is dropped (Clang's own -include form)."""
def test_xclang_wrapped_include_dropped_when_file_missing(
self, tmp_path: Path
) -> None:
"""Test xclang wrapped include dropped when file missing."""
db_dir = tmp_path
cpp_file = tmp_path / "foo.cpp"
cpp_file.write_text("void f() {}\n")
Expand All @@ -152,6 +191,26 @@ def test_xclang_wrapped_include_is_dropped(self, tmp_path: Path) -> None:
assert "-include" not in args
assert not any(a.endswith("cmake_pch.hxx") for a in args)

def test_xclang_wrapped_include_kept_when_file_exists(self, tmp_path: Path) -> None:
"""Kept (and normalized to a bare pair) when the file exists."""
db_dir = tmp_path
cpp_file = tmp_path / "foo.cpp"
cpp_file.write_text("void f() {}\n")
pch_header = tmp_path / "cmake_pch.hxx"
pch_header.write_text("#include <optional>\n")
command = (
f"/usr/bin/clang++-20 -Xclang -include -Xclang {pch_header} "
f"-std=c++23 -o foo.o -c {cpp_file}"
)
_write_compile_commands(tmp_path, db_dir, cpp_file, command)

db = clang.CompilationDatabase.fromDirectory(str(db_dir))
args = _args_from_compile_commands(db, cpp_file)

assert args is not None
idx = args.index("-include")
assert args[idx + 1] == str(pch_header)

def test_other_xclang_pairs_are_preserved(self, tmp_path: Path) -> None:
"""Test other xclang pairs are preserved."""
db_dir = tmp_path
Expand Down
Loading