diff --git a/graphify/extract.py b/graphify/extract.py index 2fbd748cb..db4fd1bad 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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``). + """ + + 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) diff --git a/tests/test_cpp_nested_and_cli.py b/tests/test_cpp_nested_and_cli.py index 1f5ddcc8c..b9668b24d 100644 --- a/tests/test_cpp_nested_and_cli.py +++ b/tests/test_cpp_nested_and_cli.py @@ -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