From f893a6e2daa33acbf12dbb0055acdfcd698e0344 Mon Sep 17 00:00:00 2001 From: rajatnagda45 Date: Mon, 19 Oct 2026 00:22:49 +0530 Subject: [PATCH] fix(swift): extract macro declarations and resolve freestanding macro calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swift 5.9 macros were dropped entirely. `macro stringify(...) = #externalMacro(...)` parses as a `macro_declaration` (freestanding or attached), which was in neither `function_types` nor `class_types`, so the symbol never entered the graph. A freestanding call `#stringify(...)` parses as a `macro_invocation`, not a `call_expression`, so its call edge was lost too. - Add `macro_declaration` to the Swift `function_types`. It has no `name` field, but the name is a bare `simple_identifier` child already covered by `name_fallback_child_types`, so the existing name resolution handles it; the generic `` type parameter is excluded by the existing type-param scope pass (no `t` god node). - Add `macro_invocation` to `call_types` and give it a callee branch in walk_calls: a freestanding macro is a free top-level symbol (resolved by bare name, no `self` receiver). Calls inside the macro's arguments (`#stringify(helper())`) still resolve because walk_calls recurses into the invocation's children. Attached-macro *usage* (`@AddCompletion` on a type) is an attribute, not a `macro_invocation`, so it is not linked to the macro — left unresolved (fail-closed); the declaration itself is still extracted. --- graphify/extract.py | 10 +++- graphify/extractors/engine.py | 11 +++- tests/test_swift_macro_declarations.py | 80 ++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 tests/test_swift_macro_declarations.py diff --git a/graphify/extract.py b/graphify/extract.py index 76ae2428d..c55209145 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -1715,9 +1715,15 @@ _SWIFT_CONFIG = LanguageConfig( # `protocol { ... }`; tree-sitter-swift gives it its own node type rather than # reusing `function_declaration`, so without it a protocol's method contract # is dropped and the protocol becomes an empty node. - function_types=frozenset({"function_declaration", "protocol_function_declaration", "init_declaration", "deinit_declaration", "subscript_declaration"}), + # `macro_declaration` is the Swift 5.9 `macro stringify(...) = #externalMacro(...)` + # form (freestanding or attached). It has no `name` field — the name is a bare + # `simple_identifier` child, already covered by `name_fallback_child_types` — so + # without it the whole declaration (and its `#name` call sites) was dropped. + function_types=frozenset({"function_declaration", "protocol_function_declaration", "init_declaration", "deinit_declaration", "subscript_declaration", "macro_declaration"}), import_types=frozenset({"import_declaration"}), - call_types=frozenset({"call_expression"}), + # `macro_invocation` is a freestanding macro call `#stringify(...)`; it is not a + # `call_expression`, so it needs its own callee branch in walk_calls. + call_types=frozenset({"call_expression", "macro_invocation"}), call_function_field="", call_accessor_node_types=frozenset({"navigation_expression"}), call_accessor_field="", diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index d8c2a7ef6..9a22f0c6f 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -8765,9 +8765,18 @@ def _extract_generic( callee_name = _read_text(node, source) self_receiver = "self" elif config.ts_module == "tree_sitter_swift": + # Freestanding macro call `#stringify(...)`: `macro_invocation` is + # `# simple_identifier call_suffix`. The macro is a free top-level + # symbol, not a method on `self`, so resolve it by bare name without + # a receiver (an unresolved `#name` stays unresolved — fail-closed). + if node.type == "macro_invocation": + for child in node.children: + if child.type == "simple_identifier": + callee_name = _read_text(child, source) + break # Swift: first child may be simple_identifier or navigation_expression first = node.children[0] if node.children else None - if first: + if node.type != "macro_invocation" and first: if first.type == "simple_identifier": callee_name = _read_text(first, source) # Inside a method a bare `save()` is `self.save()`. diff --git a/tests/test_swift_macro_declarations.py b/tests/test_swift_macro_declarations.py new file mode 100644 index 000000000..1548938a5 --- /dev/null +++ b/tests/test_swift_macro_declarations.py @@ -0,0 +1,80 @@ +"""Swift 5.9 macro declarations must be extracted, not silently dropped. + +`macro stringify(...) = #externalMacro(...)` parses as a `macro_declaration`, +and a freestanding call `#stringify(...)` parses as a `macro_invocation` — +neither of which is a `function_declaration`/`call_expression`. Before the fix +the whole macro declaration (and every `#name` call site) was dropped: the +symbol never appeared in the graph. A `macro_declaration` has no `name` field +(the name is a bare `simple_identifier`, covered by Swift's +`name_fallback_child_types`). +""" +from pathlib import Path + +from graphify.extract import extract + + +def _labels(r): + return [n["label"] for n in r["nodes"]] + + +def _calls(r): + id_to_label = {n["id"]: n["label"] for n in r["nodes"]} + return { + (id_to_label.get(e["source"]), id_to_label.get(e["target"])) + for e in r["edges"] + if e["relation"] == "calls" + } + + +def test_freestanding_macro_declaration_is_extracted(tmp_path): + (tmp_path / "m.swift").write_text( + '@freestanding(expression)\n' + 'macro stringify(_ value: T) -> (T, String) = ' + '#externalMacro(module: "M", type: "S")\n' + ) + r = extract([tmp_path / "m.swift"], cache_root=tmp_path, parallel=False) + assert any("stringify" in l for l in _labels(r)), ( + "macro declaration was dropped" + ) + # The generic type parameter `T` must not leak as a sourceless god node. + assert not any(e.get("target") == "t" for e in r["edges"]) + + +def test_attached_macro_declaration_is_extracted(tmp_path): + (tmp_path / "m.swift").write_text( + '@attached(member)\n' + 'macro AddCompletion() = #externalMacro(module: "M", type: "AddCompletionMacro")\n' + ) + r = extract([tmp_path / "m.swift"], cache_root=tmp_path, parallel=False) + assert any("AddCompletion" in l for l in _labels(r)) + + +def test_freestanding_macro_invocation_resolves_to_declaration(tmp_path): + (tmp_path / "m.swift").write_text( + 'macro stringify(_ value: T) -> (T, String) = ' + '#externalMacro(module: "M", type: "S")\n' + '\n' + 'func use() {\n' + ' let x = #stringify(1 + 1)\n' + '}\n' + ) + r = extract([tmp_path / "m.swift"], cache_root=tmp_path, parallel=False) + assert ("use()", "stringify()") in _calls(r), ( + "freestanding macro call #stringify did not resolve to the macro" + ) + + +def test_call_inside_macro_argument_still_resolves(tmp_path): + # Recursing into a macro_invocation's arguments must still find ordinary + # calls: `#stringify(helper())` keeps `use() -> helper()`. + (tmp_path / "m.swift").write_text( + 'macro stringify(_ value: T) -> (T, String) = ' + '#externalMacro(module: "M", type: "S")\n' + '\n' + 'func helper() -> Int { return 1 }\n' + 'func use() { let x = #stringify(helper()) }\n' + ) + r = extract([tmp_path / "m.swift"], cache_root=tmp_path, parallel=False) + calls = _calls(r) + assert ("use()", "stringify()") in calls + assert ("use()", "helper()") in calls