Skip to content
2 changes: 1 addition & 1 deletion graphify/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
_EXTRACTOR_VERSION = "unknown"

# Bump when AST cache-key semantics change independently of the package version.
_AST_CACHE_SCHEMA = 20 # Obsidian `[[wikilink]]` targets that start with the scan root's own folder names now resolve to the real page (#4559), so cached markdown extractions re-run; prior (19): Dart `typedef` declarations are extracted as aliases (not variables of type `typedef`); C++ Metal/CUDA GPU qualifiers no longer mint sourceless god nodes (#4550); a `.h` header containing `extern "C"` routes to the C++ extractor; prior (18): Kotlin `typealias` (#4553), C# `delegate` (#4552), and Swift `macro` declarations (#4551) are now extracted so references stop minting sourceless stubs; Rust `use` rooted at a `mod` declared in the same scope carries `_rust_use_name` (#4564); prior (17): Python pytest-fixture markers (`_py_fixture`/`_python_fixture_param`, #4332) and module-level class-id collision salting (#2810); TS `this.field` typed from any method incl. static (#4473); Solidity callables carry `metadata.arity` + inherited-call/`super`/modifier raw calls (#4379); Go type-decl and Rust file-item case-distinct node ids (#4444); prior (16): Rust `use` edges carry the imported name as written (`_rust_use_name`, #4439); (15): Go generic receiver types normalized (`Box[T]` -> `Box`) so methods attach to the type node; prior (14): C# receiver resolution for nameless/`new X()`/`foreach` receivers (defer + receiver-chain markers) and `_`/case-distinct node ids (underscore-salted); prior (13): Kotlin/Scala/C++/Swift generic & template type params no longer mint sourceless stubs; Objective-C NS_ENUM/NS_OPTIONS extraction and `_callable`/`_callable_class` markers; Go and PHP receiver-typed member calls park cross-repo (`go_type_table`/`php_type_table`, receiver fields); prior (12): Java inherited-supertype (`receiver_unbound`) and chained/new/cast receiver typing (`java_receiver_chain`, `_java_ret`); C/C++ quoted cross-directory include provenance (`include_kind`); JS external imports anchored to the package, not the path leaf; prior (11): Python property-accessor decorators / `Self`, Swift `Self`, Go generic type params, Python forward type-hint file node, PowerShell dot-source, C# verbatim-interpolated string recovery; (10): C# partial types carry `type_path`; (9): Python property-read raw calls and `_py_property` nodes; (8): Rust `use`-group per-binding edges; (7): Python opaque-base/super(args) markers + nested-class enclosing ids.
_AST_CACHE_SCHEMA = 21 # Swift file-scope `typealias` declarations are type nodes; prior (20): Obsidian `[[wikilink]]` targets that start with the scan root's own folder names now resolve to the real page (#4559), so cached markdown extractions re-run; prior (19): Dart `typedef` declarations are extracted as aliases (not variables of type `typedef`); C++ Metal/CUDA GPU qualifiers no longer mint sourceless god nodes (#4550); a `.h` header containing `extern "C"` routes to the C++ extractor; prior (18): Kotlin `typealias` (#4553), C# `delegate` (#4552), and Swift `macro` declarations (#4551) are now extracted so references stop minting sourceless stubs; Rust `use` rooted at a `mod` declared in the same scope carries `_rust_use_name` (#4564); prior (17): Python pytest-fixture markers (`_py_fixture`/`_python_fixture_param`, #4332) and module-level class-id collision salting (#2810); TS `this.field` typed from any method incl. static (#4473); Solidity callables carry `metadata.arity` + inherited-call/`super`/modifier raw calls (#4379); Go type-decl and Rust file-item case-distinct node ids (#4444); prior (16): Rust `use` edges carry the imported name as written (`_rust_use_name`, #4439); (15): Go generic receiver types normalized (`Box[T]` -> `Box`) so methods attach to the type node; prior (14): C# receiver resolution for nameless/`new X()`/`foreach` receivers (defer + receiver-chain markers) and `_`/case-distinct node ids (underscore-salted); prior (13): Kotlin/Scala/C++/Swift generic & template type params no longer mint sourceless stubs; Objective-C NS_ENUM/NS_OPTIONS extraction and `_callable`/`_callable_class` markers; Go and PHP receiver-typed member calls park cross-repo (`go_type_table`/`php_type_table`, receiver fields); prior (12): Java inherited-supertype (`receiver_unbound`) and chained/new/cast receiver typing (`java_receiver_chain`, `_java_ret`); C/C++ quoted cross-directory include provenance (`include_kind`); JS external imports anchored to the package, not the path leaf; prior (11): Python property-accessor decorators / `Self`, Swift `Self`, Go generic type params, Python forward type-hint file node, PowerShell dot-source, C# verbatim-interpolated string recovery; (10): C# partial types carry `type_path`; (9): Python property-read raw calls and `_py_property` nodes; (8): Rust `use`-group per-binding edges; (7): Python opaque-base/super(args) markers + nested-class enclosing ids.


# Per-process memo: distributions() scans site-packages metadata, which is
Expand Down
85 changes: 81 additions & 4 deletions graphify/extractors/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -4704,12 +4704,87 @@ def _csharp_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path:
return True
return False

def _swift_alias_name(node, source: bytes) -> str | None:
"""The name a `typealias X = ...` declares, or None for a same-name
re-export (`typealias Field = Core.Field`), which denotes the original type
and declares no new name."""
name_node = node.child_by_field_name("name")
if name_node is None or name_node.type != "type_identifier":
return None
name = _read_text(name_node, source)
# tree-sitter-swift tags the aliased type with the `name` field too.
value = node.children_by_field_name("name")[-1]
if value.type == "user_type":
segments = [c for c in value.children if c.type == "type_identifier"]
if len(segments) > 1 and _read_text(segments[-1], source) == name:
return None
return name or None


def _swift_brace_balance(node) -> int:
"""`{` tokens minus `}` tokens in a subtree, ignoring zero-width MISSING ones."""
balance = 0
stack = [node]
while stack:
n = stack.pop()
if n.child_count:
stack.extend(n.children)
elif n.type == "{" and not n.is_missing:
balance += 1
elif n.type == "}" and not n.is_missing:
balance -= 1
return balance


def _swift_file_scope_aliases(root, source: bytes) -> dict[int, str]:
"""start_byte -> name of each file-scope `typealias`.

A member alias (`typealias Element = Int` in a type, protocol or
extension) is not one: it witnesses an associated type that many types
name, and a node for it would capture references meant for those. Neither
is a function-local one. tree-sitter-swift misparses many real files (an
`#if` inside a type body is enough), and error recovery can lift a type's
members to the top level, so in such a file a top-level alias counts only
when it parsed cleanly itself and the `{`/`}` tokens before it balance."""
children = root.children
top = [i for i, c in enumerate(children) if c.type == "typealias_declaration"]
if not top:
return {}
aliases: dict[int, str] = {}
depth = 0
for child in children[:top[-1] + 1]:
if child.type == "typealias_declaration" and depth == 0 and not child.has_error:
name = _swift_alias_name(child, source)
if name:
aliases[child.start_byte] = name
if root.has_error:
depth += _swift_brace_balance(child)
return aliases


def _swift_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str,
nodes: list, edges: list, seen_ids: set, function_bodies: list,
parent_class_nid: str | None, add_node_fn, add_edge_fn,
ensure_named_node_fn) -> bool:
"""Handle enum_entry for Swift. Returns True if handled."""
if node.type == "enum_entry" and parent_class_nid:
ensure_named_node_fn, file_aliases: dict[int, str]) -> bool:
"""Handle enum_entry for Swift. Returns True if handled.

A file-scope `typealias` also gets a type node contained by the file, so a
type reference to it lands on a sourced declaration instead of a sourceless
stub. Returns False for it: the walk goes on as before. Swift never
populates namespace_stack, so the id is the one ensure_named_node looks up
for a name declared in this file.
"""
node_type = node.type
if node_type == "typealias_declaration":
alias_name = file_aliases.get(node.start_byte)
if alias_name:
line = node.start_point[0] + 1
alias_nid = _make_id(stem, alias_name)
if alias_nid not in seen_ids:
add_node_fn(alias_nid, alias_name, line)
add_edge_fn(file_nid, alias_nid, "contains", line)
return False
if node_type == "enum_entry" and parent_class_nid:
line = node.start_point[0] + 1
for child in node.children:
if child.type == "simple_identifier":
Expand Down Expand Up @@ -5938,8 +6013,10 @@ def _extract_generic(

swift_protocol_names: set[str] = set()
swift_class_names: set[str] = set()
swift_file_aliases: dict[int, str] = {}
if config.ts_module == "tree_sitter_swift":
swift_protocol_names, swift_class_names = _swift_pre_scan(root, source)
swift_file_aliases = _swift_file_scope_aliases(root, source)

# JS/TS class body -> member collision groups, filled lazily (#4475).
js_member_groups: dict = {}
Expand Down Expand Up @@ -8229,7 +8306,7 @@ def collect(node, source: bytes, generic: bool,
if _swift_extra_walk(node, source, file_nid, stem, str_path,
nodes, edges, seen_ids, function_bodies,
parent_class_nid, add_node, add_edge,
ensure_named_node):
ensure_named_node, swift_file_aliases):
return

# Groovy shares Java's `enum_constant` node shape (name field + optional
Expand Down
145 changes: 145 additions & 0 deletions tests/test_swift_typealias.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""A file-scope Swift `typealias` is a type node in its declaring file.

The Swift extractor emitted no node for `typealias Handler = (Int) -> Void`, so
every field, parameter or return typed with the alias, even in the declaring
file, resolved to a sourceless stub. A file-scope alias now gets a node with a
`contains` edge from its file, the same id a top-level type would get.

Not declarations here, on purpose: a member alias (`typealias Element = Int`
in a type, protocol or extension), which witnesses an associated type in many
types, and a function-local one. A same-name re-export
(`typealias Field = Core.Field`) denotes the original type, so it gets no
node either.
"""
from __future__ import annotations

from pathlib import Path

import pytest

pytest.importorskip("tree_sitter_swift")

from graphify.extract import extract


def _extract(tmp_path: Path, files: dict[str, str]) -> dict:
for name, src in files.items():
(tmp_path / name).write_text(src, encoding="utf-8")
paths = sorted(tmp_path / name for name in files)
return extract(paths, root=tmp_path, cache_root=tmp_path / "graphify-out", parallel=False)


def _sourced(result: dict, label: str) -> list[dict]:
return [n for n in result["nodes"] if n["label"] == label and n.get("source_file")]


def _ref_targets(result: dict, source_label: str) -> dict[str, str]:
nodes = {n["id"]: n for n in result["nodes"]}
out = {}
for e in result["edges"]:
if e["relation"] == "references" and nodes[e["source"]]["label"] == source_label:
target = nodes[e["target"]]
out[target["label"]] = Path(target.get("source_file") or "").name or "SOURCELESS"
return out


ALIASES = """\
typealias Handler = (Int) -> Void
public typealias JSON = [String: Any]
typealias Pair<T> = (T, T)
@available(iOS 13, *)
typealias Token = String

struct Box {
var h: Handler
func run(j: JSON) -> Pair<Int> { return (1, 1) }
}
"""


def test_file_scope_aliases_are_sourced_nodes_on_their_lines(tmp_path: Path):
result = _extract(tmp_path, {"a.swift": ALIASES})
# An attribute is part of the declaration, as for an attributed struct.
for label, line in (("Handler", "L1"), ("JSON", "L2"), ("Pair", "L3"), ("Token", "L4")):
nodes = _sourced(result, label)
assert len(nodes) == 1, (label, nodes)
assert nodes[0]["source_location"] == line
assert Path(nodes[0]["source_file"]).name == "a.swift"


def test_the_file_contains_each_alias(tmp_path: Path):
result = _extract(tmp_path, {"a.swift": ALIASES})
labels = {n["id"]: n["label"] for n in result["nodes"]}
contained = {labels[e["target"]] for e in result["edges"]
if e["relation"] == "contains" and labels[e["source"]] == "a.swift"}
assert {"Handler", "JSON", "Pair", "Token"} <= contained


def test_references_in_the_declaring_file_land_on_the_alias(tmp_path: Path):
result = _extract(tmp_path, {"a.swift": ALIASES})
assert _ref_targets(result, "Box")["Handler"] == "a.swift"
run = _ref_targets(result, ".run()")
assert run["JSON"] == "a.swift"
assert run["Pair"] == "a.swift"


def test_a_reference_from_another_file_is_rewired_onto_the_alias(tmp_path: Path):
result = _extract(tmp_path, {"a.swift": ALIASES, "b.swift": "func use(h: Handler) {}\n"})
assert _ref_targets(result, "use()")["Handler"] == "a.swift"


def test_a_conditional_alias_pair_is_one_node(tmp_path: Path):
src = ("#if os(iOS)\npublic typealias Color = UIColor\n#else\n"
"public typealias Color = NSColor\n#endif\n")
result = _extract(tmp_path, {"a.swift": src})
nodes = _sourced(result, "Color")
assert len(nodes) == 1 and nodes[0]["source_location"] == "L2"


def test_member_and_local_aliases_are_not_nodes(tmp_path: Path):
src = """\
struct Box { typealias Element = Int }
protocol P { associatedtype Item; typealias Alias = Item }
extension Box { typealias Index = Int }
func f() { typealias Local = Int }
"""
result = _extract(tmp_path, {"a.swift": src})
for label in ("Element", "Alias", "Index", "Local"):
assert not _sourced(result, label), label


def test_a_same_name_re_export_is_not_a_node(tmp_path: Path):
src = """\
typealias Field = Core.Field
typealias Entry = Core.Field<Int>
typealias Other = Core.Entry
"""
result = _extract(tmp_path, {"a.swift": src})
assert not _sourced(result, "Field")
assert len(_sourced(result, "Entry")) == 1
assert len(_sourced(result, "Other")) == 1


# tree-sitter-swift misparses an `#if` inside a type body; error recovery then
# lifts members to the top level, or wraps the type's header in a bogus alias.
MISPARSED = {
"lifted-member": "class DS {\n #if os(iOS)\n typealias Titles = (Int) -> [String]?\n"
" #endif\n func titles() -> Int { 1 }\n}\n",
"wrapped-header": "class DS<S: P>:\n NSObject,\n Q\n{\n typealias Item = S.Item\n\n"
" #if os(iOS)\n typealias Titles = (Int) -> [String]?\n #endif\n\n"
" func titles(for _: Int) -> [String]? { nil }\n}\n",
}


@pytest.mark.parametrize("case", sorted(MISPARSED))
def test_a_member_alias_in_a_misparsed_type_is_not_a_node(tmp_path: Path, case: str):
result = _extract(tmp_path, {"a.swift": MISPARSED[case]})
assert not _sourced(result, "Titles")
assert not _sourced(result, "Item")


def test_a_file_scope_alias_after_a_misparsed_type_is_still_a_node(tmp_path: Path):
src = MISPARSED["lifted-member"] + "typealias Top = Int\n"
result = _extract(tmp_path, {"a.swift": src})
nodes = _sourced(result, "Top")
assert len(nodes) == 1 and nodes[0]["source_location"] == "L7"
Loading