diff --git a/graphify/extract.py b/graphify/extract.py index 70c468e3..e3181f5c 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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: diff --git a/pyproject.toml b/pyproject.toml index 5a4a1e3c..c4feb1f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "tree-sitter-kotlin", "tree-sitter-scala", "tree-sitter-php", + "tree-sitter-swift", ] [project.urls] diff --git a/tests/fixtures/sample.swift b/tests/fixtures/sample.swift new file mode 100644 index 00000000..648bb6d4 --- /dev/null +++ b/tests/fixtures/sample.swift @@ -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() +} diff --git a/tests/test_extract.py b/tests/test_extract.py index 2ae63eae..e0afd6de 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -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 diff --git a/tests/test_languages.py b/tests/test_languages.py index 3bd2d56a..7f645b85 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -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)