fix(swift): extract computed & observed properties (#2181)

function_types only recognised func/init/deinit/subscript, so computed properties (var body: some View { ... }) and willSet/didSet observers produced no node and their bodies were never walked — erasing the whole SwiftUI view layer. Emit a function-like member node for them and defer the body to the call-walk via function_bodies; stored properties are unchanged. Adds tests.
This commit is contained in:
ozdemirsarman
2026-07-27 10:22:48 +01:00
committed by safishamsi
parent 62b1d4370b
commit d1f303e237
2 changed files with 102 additions and 0 deletions
+16
View File
@@ -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"
+86
View File
@@ -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()