fix(extract): preserve C++ classes behind export macros (#3661, #3648)

C++ projects using module export macros (class MYLIB_EXPORT Foo {}) lost the
class and its inherits edge because tree-sitter-cpp fails to parse the macro and
error-recovers over the declaration. Blank the ALL-CAPS macro token before
parsing, preserving byte length and newlines so offsets and ids stay stable;
guarded so legitimate all-caps class names, 'final', range-for, and brace-init
are never touched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nikhil2004andClaude Opus 4.8 authored and safishamsi committed 2026-10-06 20:54:55 +01:00
1 parent be528e110e
commit 3ba28369fb
2 files changed
+141 -1

No files matched your search

+57 -1
View File
@@ -2992,6 +2992,60 @@ def _normalize_cpp_cli(source: bytes) -> bytes | None:
return _CPP_CLI_ATTR_RE.sub(_blank_keeping_newlines, out)
_CPP_EXPORT_MACRO_RE = re.compile(
# Match `class` or `struct`
rb"(\b(?:class|struct)[ \t\r\n]+)"
# Match one or more ALL-CAPS macros (stacked or single)
rb"((?:[A-Z][A-Z0-9_]*[ \t\r\n]+)+)"
# Negative lookahead: the matched class name cannot be exactly "final"
rb"(?!final[ \t\r\n]*[:{])"
# Match the actual class name (and optional final), ending with `:` or `{`
rb"([A-Za-z_][A-Za-z0-9_]*(?:[ \t\r\n]+final)?[ \t\r\n]*)([:{])"
)
def _normalize_cpp_export_macros(source: bytes) -> bytes:
"""Blank an export macro between ``class``/``struct`` and the type name.
Single ALL-CAPS identifier macros (and stacked ones) are handled.
Note: paren-attribute macros like ``__declspec(dllexport)`` and
``__attribute__((visibility("default")))`` are currently unsupported,
as well as template specializations (e.g. ``template<> class MODULE_API Widget<int>``).
"""
def repl(m: re.Match[bytes]) -> bytes:
start = m.start()
# Check if preceded by `(` (e.g. range-based for loop `for(class MACRO var: items)`)
idx = start - 1
while idx >= 0 and source[idx] in b" \t\r\n":
idx -= 1
if idx >= 0 and source[idx] == ord(b"("):
return m.group(0)
# Check for brace initialization like `var{1}`
if m.group(4) == b"{":
end = m.end()
idx_after = end
while idx_after < len(source) and source[idx_after] in b" \t\r\n":
idx_after += 1
if idx_after < len(source):
next_char = source[idx_after : idx_after + 1]
if next_char in b"0123456789\"'-":
return m.group(0)
# Blank the macro with spaces to preserve offsets, but KEEP NEWLINES!
macro_text = m.group(2)
blanked_macro = bytearray(macro_text)
for i in range(len(blanked_macro)):
if blanked_macro[i] not in b"\r\n":
blanked_macro[i] = ord(b" ")
return m.group(1) + bytes(blanked_macro) + m.group(3) + m.group(4)
return _CPP_EXPORT_MACRO_RE.sub(repl, source)
def extract_cpp(path: Path) -> dict:
"""Extract functions, classes, and includes from a .cpp/.cc/.cxx/.hpp file.
@@ -3006,8 +3060,10 @@ def extract_cpp(path: Path) -> dict:
except OSError:
# Let _extract_generic report the read failure in its usual shape.
return _augment_cpp_string_tests(path, _extract_generic(path, _CPP_CONFIG))
source_override = _normalize_cpp_cli(source) or source
source_override = _normalize_cpp_export_macros(source_override)
result = _extract_generic(
path, _CPP_CONFIG, source_override=_normalize_cpp_cli(source) or source
path, _CPP_CONFIG, source_override=source_override
)
return _augment_cpp_string_tests(path, result)
+84
View File
@@ -375,3 +375,87 @@ def test_cli_type_suffixes_are_still_rewritten(decl, rewritten):
assert out is not None
assert len(out) == len(src)
assert rewritten in out
def test_cpp_export_macros_survive(tmp_path):
"""Verify that classes with export macros extract correctly, including inherits edges."""
p = tmp_path / "export.h"
p.write_text(
"class MODULE_API Widget : public BaseWidget {\n"
"public:\n"
" void DoThing() {}\n"
"};\n"
"class SOME_OTHER_MACRO Widget2 {};\n"
"class MODULE_API Widget3 final : public BaseWidget {};\n"
"class API FINAL Foo {};\n"
"struct EXPORT S {};\n"
)
from graphify.extract import extract_cpp
result = extract_cpp(p)
labels = [n["label"] for n in result["nodes"]]
assert "Widget" in labels
assert ".DoThing()" in labels
assert "Widget2" in labels
assert "Widget3" in labels
assert "Foo" in labels
assert "S" in labels
# assertion that the inherits edge actually reappears
edges = result.get("edges", [])
nodes = {n["id"]: n["label"] for n in result["nodes"]}
inherits_edges = [e for e in edges if e["relation"] == "inherits"]
# We should have Widget -> BaseWidget, Widget3 -> BaseWidget
widget_inherits = [e for e in inherits_edges if nodes.get(e["source"]) == "Widget" and nodes.get(e["target"]) == "BaseWidget"]
assert widget_inherits, "Widget -> BaseWidget inherits edge should reappear after macro blanking"
widget3_inherits = [e for e in inherits_edges if nodes.get(e["source"]) == "Widget3" and nodes.get(e["target"]) == "BaseWidget"]
assert widget3_inherits, "Widget3 -> BaseWidget inherits edge should reappear after macro blanking"
def test_cpp_export_macro_does_not_break_variables(tmp_path):
"""Ensure elaborated type variable declarations don't trigger macro stripping."""
p = tmp_path / "vars.h"
p.write_text(
"void F() {\n"
" for (class MODULE_API var: container) {}\n"
" class MODULE_API var2{1};\n"
" class MODULE_API v {1};\n"
"}\n"
)
from graphify.extract import extract_cpp
result = extract_cpp(p)
# The extraction should just parse normally. We don't extract local vars, but
# we ensure there are no parse_errors.
assert result.get("parse_errors") is None
def test_cpp_export_macro_normalization_preserves_byte_offsets(tmp_path):
src = (
'#define API\n'
"class API \n"
"Widget : public Base {\n"
" int x;\n"
"};\n"
).encode()
from graphify.extract import _normalize_cpp_export_macros
out = _normalize_cpp_export_macros(src)
assert out is not None
assert len(out) == len(src)
assert b"class \nWidget : public Base" in out
# Check that newlines are preserved exactly
assert out.count(b"\n") == src.count(b"\n")
def test_cpp_export_macro_all_caps_class_final(tmp_path):
"""Ensure an ALL-CAPS class name marked as final is not mistaken for a macro."""
p = tmp_path / "final.h"
p.write_text(
"class MY_WIDGET final : public Base {};\n"
"class MACRO Foo final : public Base {};\n"
)
from graphify.extract import extract_cpp
result = extract_cpp(p)
labels = [n["label"] for n in result["nodes"]]
assert "MY_WIDGET" in labels, "MY_WIDGET was stripped as a macro!"
assert "Foo" in labels