diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 8099c85..136085e 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -3101,6 +3101,22 @@ def _extract_generic( prop_name = _swift_property_name(node, source) if prop_name and prop_type: type_table[prop_name] = prop_type + # #2181: a computed property (`var body: some View { … }`) or an + # observed one (`willSet`/`didSet`) carries a body that the branches + # above never emitted — so the property node AND every call inside it + # were dropped. For SwiftUI this erases the whole view layer, since + # `body` is a computed property. Emit a function-like member node and + # defer its body to the call-walk via function_bodies (mirroring how + # methods register their bodies). Stored properties have no such body + # child, so their behaviour is unchanged (no regression). + comp_bodies = [c for c in node.children + if c.type in ("computed_property", "willset_didset_block")] + if comp_bodies and prop_name: + prop_nid = _make_id(parent_class_nid, prop_name) + add_node(prop_nid, f".{prop_name}", line) + add_edge(parent_class_nid, prop_nid, "method", line) + for body_block in comp_bodies: + function_bodies.append((prop_nid, body_block)) return if (config.ts_module == "tree_sitter_scala" diff --git a/tests/test_swift_computed_properties.py b/tests/test_swift_computed_properties.py new file mode 100644 index 0000000..dd78d74 --- /dev/null +++ b/tests/test_swift_computed_properties.py @@ -0,0 +1,86 @@ +"""Regression tests for #2181. + +Swift computed properties (`var body: some View { … }`) and observed +properties (`willSet` / `didSet`) carry a body. Before the fix the Swift +extractor only recognised function/init/deinit/subscript as callables, so +those properties produced no node and their bodies were never walked -- which +for SwiftUI erased the entire view layer (`body` is a computed property). +""" +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from graphify.extract import extract_swift + + +def _labels(result): + return [n["label"] for n in result["nodes"]] + + +def _rel(result, relation): + return [e for e in result["edges"] if e["relation"] == relation] + + +class TestSwiftComputedProperties(unittest.TestCase): + def _extract(self, src: str) -> dict: + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "View.swift" + p.write_text(src, encoding="utf-8") + return extract_swift(p) + + def test_computed_property_emits_node_and_walks_body(self): + r = self._extract( + "struct PlayerScrubber: View {\n" + " var body: some View {\n" + " VStack { doTap() }\n" + " }\n" + " var toggled: Int { 1 }\n" + " func doTap() {}\n" + "}\n" + ) + labels = _labels(r) + # Both computed properties become nodes... + self.assertIn(".body", labels) + self.assertIn(".toggled", labels) + # ...and the call inside `body` is attributed to the body node, not lost. + call_pairs = {(e["source"], e["target"]) for e in _rel(r, "calls")} + body_nid = next(n["id"] for n in r["nodes"] if n["label"] == ".body") + dotap_nid = next(n["id"] for n in r["nodes"] if n["label"] == ".doTap()") + self.assertIn((body_nid, dotap_nid), call_pairs, + "call inside computed `body` was not captured") + + def test_stored_property_not_emitted_as_member_but_keeps_type_ref(self): + # A plain stored property has no body block: it must NOT become a + # function-like node, but its type must still produce a references edge. + r = self._extract( + "struct S {\n" + " var vm: ViewModel\n" + "}\n" + ) + self.assertNotIn(".vm", _labels(r)) + ref_targets = {n["label"] + for e in _rel(r, "references") + for n in r["nodes"] if n["id"] == e["target"]} + self.assertIn("ViewModel", ref_targets) + + def test_observed_property_body_is_walked(self): + # willSet/didSet observers also carry a body whose calls used to vanish. + r = self._extract( + "class M {\n" + " var score: Int = 0 {\n" + " didSet { react() }\n" + " }\n" + " func react() {}\n" + "}\n" + ) + self.assertIn(".score", _labels(r)) + call_pairs = {(e["source"], e["target"]) for e in _rel(r, "calls")} + score_nid = next(n["id"] for n in r["nodes"] if n["label"] == ".score") + react_nid = next(n["id"] for n in r["nodes"] if n["label"] == ".react()") + self.assertIn((score_nid, react_nid), call_pairs) + + +if __name__ == "__main__": + unittest.main()