mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-12 02:17:04 +00:00
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:
+105
-5
@@ -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:
|
||||
|
||||
@@ -26,6 +26,7 @@ dependencies = [
|
||||
"tree-sitter-kotlin",
|
||||
"tree-sitter-scala",
|
||||
"tree-sitter-php",
|
||||
"tree-sitter-swift",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
Vendored
+73
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user