feat: Swift language support

* feat: add Swift language support

Add tree-sitter-swift extractor for classes, structs, protocols,
functions, imports, and call graph edges. Includes 8 passing tests.

* feat: full Swift AST support — enums, extensions, actors, conformance

- Enums: extract enum types, methods, and cases (case_of edges)
- Extensions: methods attach to the original type (no duplicate nodes)
- Actors: recognized via unified class_declaration node type
- Conformance/inheritance: inherits edges from : Protocol syntax
- deinit/subscript: name resolution for nameless declarations
- 12 new tests (110 total, all passing)
This commit is contained in:
Safi
2026-04-06 21:59:38 +01:00
parent 404674a24a
commit 477465ae0d
5 changed files with 302 additions and 7 deletions
+105 -5
View File
@@ -371,6 +371,24 @@ def _csharp_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path:
return False
# ── Swift extra walk for enum cases ──────────────────────────────────────────
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) -> bool:
"""Handle enum_entry for Swift. Returns True if handled."""
if node.type == "enum_entry" and parent_class_nid:
for child in node.children:
if child.type == "simple_identifier":
case_name = _read_text(child, source)
case_nid = _make_id(parent_class_nid, case_name)
line = node.start_point[0] + 1
add_node_fn(case_nid, case_name, line)
add_edge_fn(parent_class_nid, case_nid, "case_of", line)
return True
return False
# ── Language configs ──────────────────────────────────────────────────────────
_PYTHON_CONFIG = LanguageConfig(
@@ -527,6 +545,39 @@ _PHP_CONFIG = LanguageConfig(
)
def _import_swift(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str) -> None:
for child in node.children:
if child.type == "identifier":
raw = _read_text(child, source)
tgt_nid = _make_id(raw)
edges.append({
"source": file_nid,
"target": tgt_nid,
"relation": "imports",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{node.start_point[0] + 1}",
"weight": 1.0,
})
break
_SWIFT_CONFIG = LanguageConfig(
ts_module="tree_sitter_swift",
class_types=frozenset({"class_declaration", "protocol_declaration"}),
function_types=frozenset({"function_declaration", "init_declaration", "deinit_declaration", "subscript_declaration"}),
import_types=frozenset({"import_declaration"}),
call_types=frozenset({"call_expression"}),
call_function_field="",
call_accessor_node_types=frozenset({"navigation_expression"}),
call_accessor_field="",
name_fallback_child_types=("simple_identifier", "type_identifier", "user_type"),
body_fallback_child_types=("class_body", "protocol_body", "function_body", "enum_class_body"),
function_boundary_types=frozenset({"function_declaration", "init_declaration", "deinit_declaration", "subscript_declaration"}),
import_handler=_import_swift,
)
# ── Generic extractor ─────────────────────────────────────────────────────────
def _extract_generic(path: Path, config: LanguageConfig) -> dict:
@@ -634,6 +685,27 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
seen_ids.add(base_nid)
add_edge(class_nid, base_nid, "inherits", line)
# Swift-specific: conformance / inheritance
if config.ts_module == "tree_sitter_swift":
for child in node.children:
if child.type == "inheritance_specifier":
for sub in child.children:
if sub.type in ("user_type", "type_identifier"):
base = _read_text(sub, source)
base_nid = _make_id(stem, base)
if base_nid not in seen_ids:
base_nid = _make_id(base)
if base_nid not in seen_ids:
nodes.append({
"id": base_nid,
"label": base,
"file_type": "code",
"source_file": "",
"source_location": "",
})
seen_ids.add(base_nid)
add_edge(class_nid, base_nid, "inherits", line)
# Find body and recurse
body = _find_body(node, config)
if body:
@@ -643,11 +715,15 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
# Function types
if t in config.function_types:
# Resolve function name
if config.resolve_function_name_fn is not None:
# Swift deinit/subscript have no name field — resolve before generic fallback
if t == "deinit_declaration":
func_name: str | None = "deinit"
elif t == "subscript_declaration":
func_name = "subscript"
elif config.resolve_function_name_fn is not None:
# C/C++ style: use declarator
declarator = node.child_by_field_name("declarator")
func_name: str | None = None
func_name = None
if declarator:
func_name = config.resolve_function_name_fn(declarator, source)
else:
@@ -690,6 +766,12 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
parent_class_nid, add_node, add_edge, walk):
return
if config.ts_module == "tree_sitter_swift":
if _swift_extra_walk(node, source, file_nid, stem, str_path,
nodes, edges, seen_ids, function_bodies,
parent_class_nid, add_node, add_edge):
return
# Default: recurse
for child in node.children:
walk(child, parent_class_nid=None)
@@ -713,7 +795,19 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
callee_name: str | None = None
# Special handling per language
if config.ts_module == "tree_sitter_kotlin":
if config.ts_module == "tree_sitter_swift":
# Swift: first child may be simple_identifier or navigation_expression
first = node.children[0] if node.children else None
if first:
if first.type == "simple_identifier":
callee_name = _read_text(first, source)
elif first.type == "navigation_expression":
for child in first.children:
if child.type == "navigation_suffix":
for sc in child.children:
if sc.type == "simple_identifier":
callee_name = _read_text(sc, source)
elif config.ts_module == "tree_sitter_kotlin":
# Kotlin: first child may be simple_identifier or navigation_expression
first = node.children[0] if node.children else None
if first:
@@ -984,6 +1078,11 @@ def extract_php(path: Path) -> dict:
return _extract_generic(path, _PHP_CONFIG)
def extract_swift(path: Path) -> dict:
"""Extract classes, structs, protocols, functions, imports, and calls from a .swift file."""
return _extract_generic(path, _SWIFT_CONFIG)
# ── Go extractor (custom walk) ────────────────────────────────────────────────
def extract_go(path: Path) -> dict:
@@ -1523,6 +1622,7 @@ def extract(paths: list[Path]) -> dict:
".kts": extract_kotlin,
".scala": extract_scala,
".php": extract_php,
".swift": extract_swift,
}
for path in paths:
@@ -1564,7 +1664,7 @@ def collect_files(target: Path) -> list[Path]:
_EXTENSIONS = (
"*.py", "*.js", "*.ts", "*.tsx", "*.go", "*.rs",
"*.java", "*.c", "*.h", "*.cpp", "*.cc", "*.cxx", "*.hpp",
"*.rb", "*.cs", "*.kt", "*.kts", "*.scala", "*.php",
"*.rb", "*.cs", "*.kt", "*.kts", "*.scala", "*.php", "*.swift",
)
results: list[Path] = []
for pattern in _EXTENSIONS:
+1
View File
@@ -26,6 +26,7 @@ dependencies = [
"tree-sitter-kotlin",
"tree-sitter-scala",
"tree-sitter-php",
"tree-sitter-swift",
]
[project.urls]
+73
View File
@@ -0,0 +1,73 @@
import Foundation
import UIKit
protocol Processor {
func process() -> [String]
}
protocol Loggable {
func log()
}
class DataProcessor: Processor {
private var items: [String] = []
init() {}
deinit {}
func addItem(_ item: String) {
items.append(item)
}
func process() -> [String] {
return validate(items)
}
private func validate(_ data: [String]) -> [String] {
return data.filter { !$0.isEmpty }
}
}
struct Config {
let baseUrl: String
let timeout: Int
subscript(key: String) -> String? {
return nil
}
}
enum NetworkError {
case timeout
case connectionFailed
case unauthorized
func describe() -> String {
return "error"
}
}
actor CacheManager {
private var store: [String: String] = [:]
func get(_ key: String) -> String? {
return store[key]
}
}
extension DataProcessor: Loggable {
func log() {
print("logging")
}
}
extension Config {
func isValid() -> Bool {
return !baseUrl.isEmpty
}
}
func createProcessor() -> DataProcessor {
return DataProcessor()
}
+2 -1
View File
@@ -60,7 +60,8 @@ def test_collect_files_from_dir():
files = collect_files(FIXTURES)
supported = {".py", ".js", ".ts", ".tsx", ".go", ".rs",
".java", ".c", ".cpp", ".cc", ".cxx", ".rb",
".cs", ".kt", ".kts", ".scala", ".php", ".h", ".hpp"}
".cs", ".kt", ".kts", ".scala", ".php", ".h", ".hpp",
".swift"}
assert all(f.suffix in supported for f in files)
assert len(files) > 0
+121 -1
View File
@@ -1,10 +1,11 @@
"""Tests for the 8 new language extractors: Java, C, C++, Ruby, C#, Kotlin, Scala, PHP."""
"""Tests for language extractors: Java, C, C++, Ruby, C#, Kotlin, Scala, PHP, Swift."""
from __future__ import annotations
from pathlib import Path
import pytest
from graphify.extract import (
extract_java, extract_c, extract_cpp, extract_ruby,
extract_csharp, extract_kotlin, extract_scala, extract_php,
extract_swift,
)
FIXTURES = Path(__file__).parent / "fixtures"
@@ -217,3 +218,122 @@ def test_php_finds_function():
def test_php_finds_imports():
r = extract_php(FIXTURES / "sample.php")
assert "imports" in _relations(r)
# ── Swift ────────────────────────────────────────────────────────────────────
def test_swift_no_error():
r = extract_swift(FIXTURES / "sample.swift")
assert "error" not in r
def test_swift_finds_class():
r = extract_swift(FIXTURES / "sample.swift")
assert any("DataProcessor" in l for l in _labels(r))
def test_swift_finds_protocol():
r = extract_swift(FIXTURES / "sample.swift")
assert any("Processor" in l for l in _labels(r))
def test_swift_finds_struct():
r = extract_swift(FIXTURES / "sample.swift")
assert any("Config" in l for l in _labels(r))
def test_swift_finds_methods():
r = extract_swift(FIXTURES / "sample.swift")
labels = _labels(r)
assert any("addItem" in l for l in labels)
assert any("process" in l for l in labels)
def test_swift_finds_function():
r = extract_swift(FIXTURES / "sample.swift")
assert any("createProcessor" in l for l in _labels(r))
def test_swift_finds_imports():
r = extract_swift(FIXTURES / "sample.swift")
assert "imports" in _relations(r)
def test_swift_no_dangling_edges():
r = extract_swift(FIXTURES / "sample.swift")
node_ids = {n["id"] for n in r["nodes"]}
for e in r["edges"]:
assert e["source"] in node_ids
def test_swift_finds_actor():
r = extract_swift(FIXTURES / "sample.swift")
assert any("CacheManager" in l for l in _labels(r))
def test_swift_finds_enum():
r = extract_swift(FIXTURES / "sample.swift")
assert any("NetworkError" in l for l in _labels(r))
def test_swift_finds_enum_methods():
r = extract_swift(FIXTURES / "sample.swift")
assert any("describe" in l for l in _labels(r))
def test_swift_finds_enum_cases():
r = extract_swift(FIXTURES / "sample.swift")
labels = _labels(r)
assert any("timeout" in l for l in labels)
assert any("connectionFailed" in l for l in labels)
def test_swift_enum_cases_have_case_of_edge():
r = extract_swift(FIXTURES / "sample.swift")
case_edges = [e for e in r["edges"] if e["relation"] == "case_of"]
assert len(case_edges) >= 2
def test_swift_finds_deinit():
r = extract_swift(FIXTURES / "sample.swift")
assert any("deinit" in l for l in _labels(r))
def test_swift_finds_subscript():
r = extract_swift(FIXTURES / "sample.swift")
assert any("subscript" in l for l in _labels(r))
def test_swift_extension_methods_attach_to_type():
r = extract_swift(FIXTURES / "sample.swift")
node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
method_edges = [e for e in r["edges"] if e["relation"] == "method"]
found = False
for e in method_edges:
src_label = node_by_id.get(e["source"], "")
tgt_label = node_by_id.get(e["target"], "")
if "Config" in src_label and "isValid" in tgt_label:
found = True
break
assert found, "extension method isValid should attach to Config"
def test_swift_extension_does_not_duplicate_type_node():
r = extract_swift(FIXTURES / "sample.swift")
config_nodes = [n for n in r["nodes"] if n["label"] == "Config"]
assert len(config_nodes) == 1, f"Config should appear once, got {len(config_nodes)}"
def test_swift_conformance_edge():
r = extract_swift(FIXTURES / "sample.swift")
inherits_edges = [e for e in r["edges"] if e["relation"] == "inherits"]
node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
found = False
for e in inherits_edges:
src_label = node_by_id.get(e["source"], "")
tgt_label = node_by_id.get(e["target"], "")
if "DataProcessor" in src_label and "Processor" in tgt_label:
found = True
break
assert found, "DataProcessor should have inherits edge to Processor"
def test_swift_extension_conformance_edge():
r = extract_swift(FIXTURES / "sample.swift")
inherits_edges = [e for e in r["edges"] if e["relation"] == "inherits"]
node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
found = False
for e in inherits_edges:
src_label = node_by_id.get(e["source"], "")
tgt_label = node_by_id.get(e["target"], "")
if "DataProcessor" in src_label and "Loggable" in tgt_label:
found = True
break
assert found, "extension should add conformance edge DataProcessor -> Loggable"
def test_swift_emits_calls():
r = extract_swift(FIXTURES / "sample.swift")
calls = _calls(r)
assert any("process" in src and "validate" in tgt for src, tgt in calls)