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
|
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 ──────────────────────────────────────────────────────────
|
# ── Language configs ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_PYTHON_CONFIG = LanguageConfig(
|
_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 ─────────────────────────────────────────────────────────
|
# ── Generic extractor ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _extract_generic(path: Path, config: LanguageConfig) -> dict:
|
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)
|
seen_ids.add(base_nid)
|
||||||
add_edge(class_nid, base_nid, "inherits", line)
|
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
|
# Find body and recurse
|
||||||
body = _find_body(node, config)
|
body = _find_body(node, config)
|
||||||
if body:
|
if body:
|
||||||
@@ -643,11 +715,15 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
|
|||||||
|
|
||||||
# Function types
|
# Function types
|
||||||
if t in config.function_types:
|
if t in config.function_types:
|
||||||
# Resolve function name
|
# Swift deinit/subscript have no name field — resolve before generic fallback
|
||||||
if config.resolve_function_name_fn is not None:
|
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
|
# C/C++ style: use declarator
|
||||||
declarator = node.child_by_field_name("declarator")
|
declarator = node.child_by_field_name("declarator")
|
||||||
func_name: str | None = None
|
func_name = None
|
||||||
if declarator:
|
if declarator:
|
||||||
func_name = config.resolve_function_name_fn(declarator, source)
|
func_name = config.resolve_function_name_fn(declarator, source)
|
||||||
else:
|
else:
|
||||||
@@ -690,6 +766,12 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
|
|||||||
parent_class_nid, add_node, add_edge, walk):
|
parent_class_nid, add_node, add_edge, walk):
|
||||||
return
|
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
|
# Default: recurse
|
||||||
for child in node.children:
|
for child in node.children:
|
||||||
walk(child, parent_class_nid=None)
|
walk(child, parent_class_nid=None)
|
||||||
@@ -713,7 +795,19 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
|
|||||||
callee_name: str | None = None
|
callee_name: str | None = None
|
||||||
|
|
||||||
# Special handling per language
|
# 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
|
# Kotlin: first child may be simple_identifier or navigation_expression
|
||||||
first = node.children[0] if node.children else None
|
first = node.children[0] if node.children else None
|
||||||
if first:
|
if first:
|
||||||
@@ -984,6 +1078,11 @@ def extract_php(path: Path) -> dict:
|
|||||||
return _extract_generic(path, _PHP_CONFIG)
|
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) ────────────────────────────────────────────────
|
# ── Go extractor (custom walk) ────────────────────────────────────────────────
|
||||||
|
|
||||||
def extract_go(path: Path) -> dict:
|
def extract_go(path: Path) -> dict:
|
||||||
@@ -1523,6 +1622,7 @@ def extract(paths: list[Path]) -> dict:
|
|||||||
".kts": extract_kotlin,
|
".kts": extract_kotlin,
|
||||||
".scala": extract_scala,
|
".scala": extract_scala,
|
||||||
".php": extract_php,
|
".php": extract_php,
|
||||||
|
".swift": extract_swift,
|
||||||
}
|
}
|
||||||
|
|
||||||
for path in paths:
|
for path in paths:
|
||||||
@@ -1564,7 +1664,7 @@ def collect_files(target: Path) -> list[Path]:
|
|||||||
_EXTENSIONS = (
|
_EXTENSIONS = (
|
||||||
"*.py", "*.js", "*.ts", "*.tsx", "*.go", "*.rs",
|
"*.py", "*.js", "*.ts", "*.tsx", "*.go", "*.rs",
|
||||||
"*.java", "*.c", "*.h", "*.cpp", "*.cc", "*.cxx", "*.hpp",
|
"*.java", "*.c", "*.h", "*.cpp", "*.cc", "*.cxx", "*.hpp",
|
||||||
"*.rb", "*.cs", "*.kt", "*.kts", "*.scala", "*.php",
|
"*.rb", "*.cs", "*.kt", "*.kts", "*.scala", "*.php", "*.swift",
|
||||||
)
|
)
|
||||||
results: list[Path] = []
|
results: list[Path] = []
|
||||||
for pattern in _EXTENSIONS:
|
for pattern in _EXTENSIONS:
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ dependencies = [
|
|||||||
"tree-sitter-kotlin",
|
"tree-sitter-kotlin",
|
||||||
"tree-sitter-scala",
|
"tree-sitter-scala",
|
||||||
"tree-sitter-php",
|
"tree-sitter-php",
|
||||||
|
"tree-sitter-swift",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[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)
|
files = collect_files(FIXTURES)
|
||||||
supported = {".py", ".js", ".ts", ".tsx", ".go", ".rs",
|
supported = {".py", ".js", ".ts", ".tsx", ".go", ".rs",
|
||||||
".java", ".c", ".cpp", ".cc", ".cxx", ".rb",
|
".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 all(f.suffix in supported for f in files)
|
||||||
assert len(files) > 0
|
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 __future__ import annotations
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import pytest
|
import pytest
|
||||||
from graphify.extract import (
|
from graphify.extract import (
|
||||||
extract_java, extract_c, extract_cpp, extract_ruby,
|
extract_java, extract_c, extract_cpp, extract_ruby,
|
||||||
extract_csharp, extract_kotlin, extract_scala, extract_php,
|
extract_csharp, extract_kotlin, extract_scala, extract_php,
|
||||||
|
extract_swift,
|
||||||
)
|
)
|
||||||
|
|
||||||
FIXTURES = Path(__file__).parent / "fixtures"
|
FIXTURES = Path(__file__).parent / "fixtures"
|
||||||
@@ -217,3 +218,122 @@ def test_php_finds_function():
|
|||||||
def test_php_finds_imports():
|
def test_php_finds_imports():
|
||||||
r = extract_php(FIXTURES / "sample.php")
|
r = extract_php(FIXTURES / "sample.php")
|
||||||
assert "imports" in _relations(r)
|
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