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