diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37350c558..4500368b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,6 +63,9 @@ importers: '@understand-anything/tree-sitter-dart-wasm': specifier: workspace:* version: link:../tree-sitter-dart-wasm + '@understand-anything/tree-sitter-swift-wasm': + specifier: workspace:* + version: link:../tree-sitter-swift-wasm fuse.js: specifier: ^7.1.0 version: 7.1.0 @@ -203,6 +206,8 @@ importers: understand-anything-plugin/packages/tree-sitter-dart-wasm: {} + understand-anything-plugin/packages/tree-sitter-swift-wasm: {} + packages: '@ampproject/remapping@2.3.0': diff --git a/tests/skill/understand/test_extract_import_map.test.mjs b/tests/skill/understand/test_extract_import_map.test.mjs index 5b47d2bd3..8e779daee 100644 --- a/tests/skill/understand/test_extract_import_map.test.mjs +++ b/tests/skill/understand/test_extract_import_map.test.mjs @@ -1200,6 +1200,96 @@ describe('extract-import-map.mjs — C/C++ resolver', () => { }); }); +describe('extract-import-map.mjs — Swift resolver', () => { + let projectRoot; + + afterEach(() => { + if (projectRoot) { + rmSync(projectRoot, { recursive: true, force: true }); + projectRoot = null; + } + }); + + it('resolves SwiftPM target imports to all files in the imported module', () => { + projectRoot = setupTree({ + 'Sources/App/App.swift': `public struct AppRoot {}\n`, + 'Sources/App/Feature.swift': `public struct Feature {}\n`, + 'Tests/AppTests/AppTests.swift': + `import XCTest\n` + + `@testable import App\n` + + `final class AppTests: XCTestCase {}\n`, + }); + + const result = runScript(projectRoot, { + projectRoot, + files: [ + { path: 'Sources/App/App.swift', language: 'swift', fileCategory: 'code' }, + { path: 'Sources/App/Feature.swift', language: 'swift', fileCategory: 'code' }, + { path: 'Tests/AppTests/AppTests.swift', language: 'swift', fileCategory: 'code' }, + ], + }); + + expect(result.status).toBe(0); + expect(result.output.importMap['Tests/AppTests/AppTests.swift']).toEqual([ + 'Sources/App/App.swift', + 'Sources/App/Feature.swift', + ]); + expect(result.output.importMap['Sources/App/App.swift']).toEqual([]); + expect(result.output.importMap['Sources/App/Feature.swift']).toEqual([]); + expect(result.output.stats.filesWithImports).toBe(1); + expect(result.output.stats.totalEdges).toBe(2); + }); + + it('uses Package.swift custom target paths when module name differs from directory name', () => { + projectRoot = setupTree({ + 'Package.swift': + `// swift-tools-version: 5.9\n` + + `import PackageDescription\n` + + `let package = Package(\n` + + ` name: "Workspace",\n` + + ` targets: [\n` + + ` .target(name: "Domain", path: "Core/Model"),\n` + + ` .executableTarget(name: "App", path: "Clients/App")\n` + + ` ]\n` + + `)\n`, + 'Core/Model/User.swift': `public struct User {}\n`, + 'Clients/App/main.swift': `import Foundation\nimport Domain\nprint(User.self)\n`, + }); + + const result = runScript(projectRoot, { + projectRoot, + files: [ + { path: 'Package.swift', language: 'swift', fileCategory: 'code' }, + { path: 'Core/Model/User.swift', language: 'swift', fileCategory: 'code' }, + { path: 'Clients/App/main.swift', language: 'swift', fileCategory: 'code' }, + ], + }); + + expect(result.status).toBe(0); + expect(result.output.importMap['Clients/App/main.swift']).toEqual([ + 'Core/Model/User.swift', + ]); + expect(result.output.importMap['Package.swift']).toEqual([]); + }); + + it('drops Swift SDK imports when no project module matches', () => { + projectRoot = setupTree({ + 'Sources/App/View.swift': `import SwiftUI\nimport Foundation\nstruct RootView {}\n`, + }); + + const result = runScript(projectRoot, { + projectRoot, + files: [ + { path: 'Sources/App/View.swift', language: 'swift', fileCategory: 'code' }, + ], + }); + + expect(result.status).toBe(0); + expect(result.output.importMap['Sources/App/View.swift']).toEqual([]); + expect(result.output.stats.totalEdges).toBe(0); + }); +}); + describe('extract-import-map.mjs — per-file failure resilience', () => { let projectRoot; diff --git a/tests/skill/understand/test_scan_project.test.mjs b/tests/skill/understand/test_scan_project.test.mjs index 65d96c8c1..9ce7357a6 100644 --- a/tests/skill/understand/test_scan_project.test.mjs +++ b/tests/skill/understand/test_scan_project.test.mjs @@ -123,7 +123,7 @@ describe('scan-project.mjs — language detection', () => { expect(byPath(r.output, 'f.cjs').language).toBe('javascript'); }); - it('maps Python, Go, Rust, Java, Kotlin, C# to their language ids', () => { + it('maps Python, Go, Rust, Java, Kotlin, C#, Swift to their language ids', () => { projectRoot = setupTree({ 'a.py': 'x = 1\n', 'b.go': 'package main\n', @@ -131,6 +131,7 @@ describe('scan-project.mjs — language detection', () => { 'd.java': 'class D {}\n', 'e.kt': 'fun main() {}\n', 'f.cs': 'class F {}\n', + 'g.swift': 'struct G {}\n', }); const r = runScript(projectRoot); expect(r.status).toBe(0); @@ -140,6 +141,7 @@ describe('scan-project.mjs — language detection', () => { expect(byPath(r.output, 'd.java').language).toBe('java'); expect(byPath(r.output, 'e.kt').language).toBe('kotlin'); expect(byPath(r.output, 'f.cs').language).toBe('csharp'); + expect(byPath(r.output, 'g.swift').language).toBe('swift'); }); it('maps Ruby, PHP, C, C++ to their language ids', () => { @@ -241,12 +243,13 @@ describe('scan-project.mjs — category assignment (project-scanner.md Step 4)', } }); - it('assigns code to TypeScript, JavaScript, Python, Go, Rust source files', () => { + it('assigns code to TypeScript, JavaScript, Python, Go, Rust, Swift source files', () => { projectRoot = setupTree({ 'src/a.ts': 'export const a = 1;\n', 'src/b.py': 'def b(): pass\n', 'src/c.go': 'package main\n', 'src/d.rs': 'fn main() {}\n', + 'src/e.swift': 'struct E {}\n', }); const r = runScript(projectRoot); expect(r.status).toBe(0); @@ -254,6 +257,7 @@ describe('scan-project.mjs — category assignment (project-scanner.md Step 4)', expect(byPath(r.output, 'src/b.py').fileCategory).toBe('code'); expect(byPath(r.output, 'src/c.go').fileCategory).toBe('code'); expect(byPath(r.output, 'src/d.rs').fileCategory).toBe('code'); + expect(byPath(r.output, 'src/e.swift').fileCategory).toBe('code'); }); it('assigns config to JSON/YAML/TOML/INI/XML', () => { diff --git a/understand-anything-plugin/packages/core/package.json b/understand-anything-plugin/packages/core/package.json index e54ce7285..bc3ac3be9 100644 --- a/understand-anything-plugin/packages/core/package.json +++ b/understand-anything-plugin/packages/core/package.json @@ -39,6 +39,7 @@ "dependencies": { "@tree-sitter-grammars/tree-sitter-kotlin": "1.1.0", "@understand-anything/tree-sitter-dart-wasm": "workspace:*", + "@understand-anything/tree-sitter-swift-wasm": "workspace:*", "fuse.js": "^7.1.0", "ignore": "^7.0.5", "tree-sitter-c-sharp": "^0.23.1", diff --git a/understand-anything-plugin/packages/core/src/__tests__/language-registry.test.ts b/understand-anything-plugin/packages/core/src/__tests__/language-registry.test.ts index 4ecad11ba..7eff12e45 100644 --- a/understand-anything-plugin/packages/core/src/__tests__/language-registry.test.ts +++ b/understand-anything-plugin/packages/core/src/__tests__/language-registry.test.ts @@ -74,6 +74,14 @@ describe("LanguageRegistry", () => { expect(registry.getByExtension(".js")?.id).toBe("javascript"); }); + it("registers Swift with tree-sitter grammar metadata", () => { + const registry = LanguageRegistry.createDefault(); + expect(registry.getById("swift")?.treeSitter).toEqual({ + wasmPackage: "@understand-anything/tree-sitter-swift-wasm", + wasmFile: "tree-sitter-swift.wasm", + }); + }); + it("has no duplicate extension mappings across configs", () => { const registry = LanguageRegistry.createDefault(); const all = registry.getAllLanguages(); diff --git a/understand-anything-plugin/packages/core/src/__tests__/plugin-discovery.test.ts b/understand-anything-plugin/packages/core/src/__tests__/plugin-discovery.test.ts index 31271e74b..12f558157 100644 --- a/understand-anything-plugin/packages/core/src/__tests__/plugin-discovery.test.ts +++ b/understand-anything-plugin/packages/core/src/__tests__/plugin-discovery.test.ts @@ -77,6 +77,10 @@ describe("plugin-discovery", () => { expect(DEFAULT_PLUGIN_CONFIG.plugins[0].name).toBe("tree-sitter"); expect(DEFAULT_PLUGIN_CONFIG.plugins[0].enabled).toBe(true); }); + + it("includes Swift now that a tree-sitter grammar is available", () => { + expect(DEFAULT_PLUGIN_CONFIG.plugins[0].languages).toContain("swift"); + }); }); describe("serializePluginConfig", () => { diff --git a/understand-anything-plugin/packages/core/src/languages/configs/swift.ts b/understand-anything-plugin/packages/core/src/languages/configs/swift.ts index af0977ed8..b8fa3290e 100644 --- a/understand-anything-plugin/packages/core/src/languages/configs/swift.ts +++ b/understand-anything-plugin/packages/core/src/languages/configs/swift.ts @@ -4,6 +4,10 @@ export const swiftConfig = { id: "swift", displayName: "Swift", extensions: [".swift"], + treeSitter: { + wasmPackage: "@understand-anything/tree-sitter-swift-wasm", + wasmFile: "tree-sitter-swift.wasm", + }, concepts: [ "optionals", "protocols", diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/swift-extractor.test.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/swift-extractor.test.ts new file mode 100644 index 000000000..0ed48bd33 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/swift-extractor.test.ts @@ -0,0 +1,464 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { createRequire } from "node:module"; +import { swiftConfig } from "../../../languages/configs/swift.js"; +import { TreeSitterPlugin } from "../../tree-sitter-plugin.js"; +import { SwiftExtractor } from "../swift-extractor.js"; + +const require = createRequire(import.meta.url); + +let Parser: any; +let Language: any; +let swiftLang: any; + +beforeAll(async () => { + const mod = await import("web-tree-sitter"); + Parser = mod.Parser; + Language = mod.Language; + await Parser.init(); + const wasmPath = require.resolve( + "@understand-anything/tree-sitter-swift-wasm/tree-sitter-swift.wasm", + ); + swiftLang = await Language.load(wasmPath); +}); + +function parse(code: string) { + const parser = new Parser(); + parser.setLanguage(swiftLang); + const tree = parser.parse(code); + const root = tree.rootNode; + return { tree, parser, root }; +} + +function withAnalysis( + code: string, + fn: (result: ReturnType) => T, +): T { + const { tree, parser, root } = parse(code); + try { + return fn(extractor.extractStructure(root)); + } finally { + tree.delete(); + parser.delete(); + } +} + +function withCalls( + code: string, + fn: (result: ReturnType) => T, +): T { + const { tree, parser, root } = parse(code); + try { + return fn(extractor.extractCallGraph(root)); + } finally { + tree.delete(); + parser.delete(); + } +} + +const extractor = new SwiftExtractor(); + +describe("SwiftExtractor", () => { + it("has correct languageIds", () => { + expect(extractor.languageIds).toEqual(["swift"]); + }); + + describe("extractStructure - functions", () => { + it("extracts a top-level function with params and return type", () => { + withAnalysis(`func add(_ a: Int, b: Int) -> Int { a + b }\n`, (result) => { + expect(result.functions).toHaveLength(1); + expect(result.functions[0].name).toBe("add"); + expect(result.functions[0].params).toEqual(["a", "b"]); + expect(result.functions[0].returnType).toBe("Int"); + }); + }); + + it("extracts a function with no params and inferred return type", () => { + withAnalysis(`func noop() {}\n`, (result) => { + expect(result.functions[0].name).toBe("noop"); + expect(result.functions[0].params).toEqual([]); + expect(result.functions[0].returnType).toBeUndefined(); + }); + }); + + it("ignores async and throws while preserving declared return type", () => { + withAnalysis( + `func fetch(_ type: T.Type) async throws -> T { fatalError() }\n`, + (result) => { + expect(result.functions[0].name).toBe("fetch"); + expect(result.functions[0].params).toEqual(["type"]); + expect(result.functions[0].returnType).toBe("T"); + }, + ); + }); + + it("uses local parameter names instead of external labels", () => { + withAnalysis(`func update(_ value: Int = 0, forKey key: String) {}\n`, (result) => { + expect(result.functions[0].params).toEqual(["value", "key"]); + }); + }); + }); + + describe("extractStructure - type declarations", () => { + it("extracts class properties and methods", () => { + withAnalysis( + `final class User { + let id: String + var name: String + static let kind = "user" + func save() {} +} +`, + (result) => { + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("User"); + expect(result.classes[0].properties).toEqual( + expect.arrayContaining(["id", "name", "kind"]), + ); + expect(result.classes[0].methods).toContain("save"); + expect(result.functions.map((f) => f.name)).toContain("save"); + }, + ); + }); + + it("extracts initializers and deinitializers as callable members", () => { + withAnalysis( + `class Box { + init(value: Int) {} + deinit { cleanup() } +} +`, + (result) => { + const box = result.classes[0]; + expect(box.methods).toEqual(expect.arrayContaining(["init", "deinit"])); + expect(result.functions.find((f) => f.name === "Box.init")?.params).toEqual(["value"]); + expect(result.functions.map((f) => f.name)).toContain("Box.deinit"); + }, + ); + }); + + it("extracts structs and ignores property wrapper attributes", () => { + withAnalysis(`struct Model { @Published var count = 0 }\n`, (result) => { + expect(result.classes[0].name).toBe("Model"); + expect(result.classes[0].properties).toContain("count"); + }); + }); + + it("extracts enum cases as properties", () => { + withAnalysis(`enum LoadState { case idle; case failed(Error); case loaded(User) }\n`, (result) => { + expect(result.classes[0].name).toBe("LoadState"); + expect(result.classes[0].properties).toEqual(["idle", "failed", "loaded"]); + }); + }); + + it("extracts protocol associated types, properties, functions, and init requirements", () => { + withAnalysis( + `protocol Repository { + associatedtype Item + var id: String { get } + func load(id: String) async throws -> Item + init(seed: String) +} +`, + (result) => { + const repo = result.classes[0]; + expect(repo.name).toBe("Repository"); + expect(repo.properties).toEqual(expect.arrayContaining(["Item", "id"])); + expect(repo.methods).toEqual(expect.arrayContaining(["load", "init"])); + expect(result.functions.find((f) => f.name === "load")?.returnType).toBe("Item"); + expect(result.functions.find((f) => f.name === "Repository.init")?.params).toEqual(["seed"]); + }, + ); + }); + + it("extracts extensions as class-like entries without colliding with the nominal type", () => { + withAnalysis( + `struct User {} +extension User: Codable where ID == String { + func displayName() -> String { "" } +} +`, + (result) => { + expect(result.classes.map((c) => c.name)).toEqual(["User", "extension User"]); + const ext = result.classes.find((c) => c.name === "extension User"); + expect(ext?.methods).toContain("displayName"); + expect(result.functions.find((f) => f.name === "displayName")?.returnType).toBe("String"); + }, + ); + }); + + it("extracts computed extension properties", () => { + withAnalysis(`extension String { var trimmed: String { self.trimmingCharacters(in: .whitespaces) } }\n`, (result) => { + expect(result.classes[0].name).toBe("extension String"); + expect(result.classes[0].properties).toContain("trimmed"); + }); + }); + + it("extracts actor declarations", () => { + withAnalysis( + `actor Cache { + var store: [String: Data] = [:] + func get(_ key: String) -> Data? { store[key] } +} +`, + (result) => { + expect(result.classes[0].name).toBe("Cache"); + expect(result.classes[0].properties).toContain("store"); + expect(result.classes[0].methods).toContain("get"); + expect(result.functions.find((f) => f.name === "get")?.returnType).toBe("Data?"); + }, + ); + }); + + it("extracts nested type declarations", () => { + withAnalysis(`struct Outer { struct Inner {}; func make() {} }\n`, (result) => { + expect(result.classes.map((c) => c.name)).toEqual(["Outer", "Inner"]); + expect(result.classes.find((c) => c.name === "Outer")?.methods).toContain("make"); + }); + }); + + it("extracts subscript declarations as callable members", () => { + withAnalysis(`struct Bag { subscript(index: Int) -> Item { items[index] } }\n`, (result) => { + expect(result.classes[0].methods).toContain("subscript"); + expect(result.functions.find((f) => f.name === "subscript")?.params).toEqual(["index"]); + expect(result.functions.find((f) => f.name === "subscript")?.returnType).toBe("Item"); + }); + }); + + it("extracts comma-list property declarations as separate properties", () => { + withAnalysis(`struct Point { var x, y: Double }\n`, (result) => { + expect(result.classes[0].properties).toEqual(["x", "y"]); + }); + }); + + it("does not treat local declarations in property initializers as properties", () => { + withAnalysis( + `public struct Store { + public let value = { let local = 1; return local }() +} +`, + (result) => { + expect(result.classes[0].properties).toEqual(["value"]); + expect(result.exports.map((e) => e.name)).toEqual( + expect.arrayContaining(["Store", "value"]), + ); + expect(result.exports.map((e) => e.name)).not.toContain("local"); + }, + ); + }); + }); + + describe("extractStructure - imports", () => { + it("extracts a simple module import", () => { + withAnalysis(`import Foundation\n`, (result) => { + expect(result.imports).toEqual([ + { source: "Foundation", specifiers: ["Foundation"], lineNumber: 1 }, + ]); + }); + }); + + it("extracts qualified import specifiers", () => { + withAnalysis(`import struct Foundation.Date\n`, (result) => { + expect(result.imports[0].source).toBe("Foundation"); + expect(result.imports[0].specifiers).toEqual(["Date"]); + }); + }); + + it("preserves import order and ignores import kind tokens", () => { + withAnalysis(`import class UIKit.UIView\nimport protocol Combine.Publisher\n`, (result) => { + expect(result.imports.map((i) => i.source)).toEqual(["UIKit", "Combine"]); + expect(result.imports.map((i) => i.specifiers[0])).toEqual(["UIView", "Publisher"]); + }); + }); + + it("handles @testable imports", () => { + withAnalysis(`@testable import MyApp\n`, (result) => { + expect(result.imports[0].source).toBe("MyApp"); + expect(result.imports[0].specifiers).toEqual(["MyApp"]); + }); + }); + + it("extracts conditional imports with correct line numbers", () => { + withAnalysis(`#if canImport(UIKit)\nimport UIKit\n#endif\n`, (result) => { + expect(result.imports[0]).toEqual({ + source: "UIKit", + specifiers: ["UIKit"], + lineNumber: 2, + }); + }); + }); + }); + + describe("extractStructure - visibility and exports", () => { + it("exports default and internal declarations", () => { + withAnalysis(`func helper() {}\ninternal struct Store {}\n`, (result) => { + expect(result.exports.map((e) => e.name)).toEqual(expect.arrayContaining(["helper", "Store"])); + }); + }); + + it("exports public, open, and package declarations", () => { + withAnalysis(`public func api() {}\nopen class Base {}\npackage struct ModuleOnly {}\n`, (result) => { + expect(result.exports.map((e) => e.name)).toEqual( + expect.arrayContaining(["api", "Base", "ModuleOnly"]), + ); + }); + }); + + it("does not export private or fileprivate declarations", () => { + withAnalysis(`private func hidden() {}\nfileprivate class Local {}\n`, (result) => { + expect(result.functions.map((f) => f.name)).toContain("hidden"); + expect(result.classes.map((c) => c.name)).toContain("Local"); + expect(result.exports.map((e) => e.name)).not.toContain("hidden"); + expect(result.exports.map((e) => e.name)).not.toContain("Local"); + }); + }); + + it("exports visible members and excludes private members", () => { + withAnalysis( + `public class Service { + public func start() {} + private func debug() {} +} +`, + (result) => { + const exports = result.exports.map((e) => e.name); + expect(exports).toContain("Service"); + expect(exports).toContain("start"); + expect(exports).not.toContain("debug"); + }, + ); + }); + + it("exports public private(set) properties", () => { + withAnalysis(`public struct Counter { public private(set) var value = 0 }\n`, (result) => { + expect(result.exports.map((e) => e.name)).toEqual( + expect.arrayContaining(["Counter", "value"]), + ); + }); + }); + + it("does not export members of a private extension", () => { + withAnalysis(`private extension User { func secret() {} }\n`, (result) => { + expect(result.classes[0].methods).toContain("secret"); + expect(result.exports.map((e) => e.name)).not.toContain("secret"); + }); + }); + + it("exports declarations with attributes before access modifiers", () => { + withAnalysis(`@objc public func bridge() {}\n`, (result) => { + expect(result.exports.map((e) => e.name)).toContain("bridge"); + }); + }); + }); + + describe("extractCallGraph", () => { + it("attributes a bare call to its enclosing function", () => { + withCalls(`func helper() {}\nfunc caller() { helper() }\n`, (calls) => { + expect(calls).toContainEqual({ caller: "caller", callee: "helper", lineNumber: 2 }); + }); + }); + + it("attributes member calls to the enclosing function", () => { + withCalls(`func load() { service.fetch() }\n`, (calls) => { + expect(calls).toContainEqual({ caller: "load", callee: "fetch", lineNumber: 1 }); + }); + }); + + it("preserves obvious static qualifiers", () => { + withCalls(`func log() { Logger.info("x") }\n`, (calls) => { + expect(calls).toContainEqual({ caller: "log", callee: "Logger.info", lineNumber: 1 }); + }); + }); + + it("records initializer-shaped calls", () => { + withCalls(`func make() { let user = User(name: "A") }\n`, (calls) => { + expect(calls).toContainEqual({ caller: "make", callee: "User", lineNumber: 1 }); + }); + }); + + it("attributes calls inside an initializer", () => { + withCalls(`class Child: Parent { override init() { super.init(); configure() } }\n`, (calls) => { + expect(calls).toContainEqual({ caller: "Child.init", callee: "super.init", lineNumber: 1 }); + expect(calls).toContainEqual({ caller: "Child.init", callee: "configure", lineNumber: 1 }); + }); + }); + + it("attributes SwiftUI result-builder calls to computed body", () => { + withCalls(`struct AppView { var body: some View { VStack { Text("Hi") } } }\n`, (calls) => { + expect(calls).toContainEqual({ caller: "body", callee: "VStack", lineNumber: 1 }); + expect(calls).toContainEqual({ caller: "body", callee: "Text", lineNumber: 1 }); + }); + }); + + it("attributes calls inside closures to the enclosing function", () => { + withCalls(`func build(_ items: [Item]) { items.map { transform($0) }.filter { isValid($0) } }\n`, (calls) => { + expect(calls.map((c) => c.callee)).toEqual( + expect.arrayContaining(["map", "transform", "filter", "isValid"]), + ); + expect(calls.every((c) => c.caller === "build")).toBe(true); + }); + }); + + it("extracts async/await member calls", () => { + withCalls(`func run() async throws { try await client.load() }\n`, (calls) => { + expect(calls).toContainEqual({ caller: "run", callee: "load", lineNumber: 1 }); + }); + }); + + it("attributes property observer calls to the property", () => { + withCalls(`struct Counter { var value: Int { didSet { notify() } } }\n`, (calls) => { + expect(calls).toContainEqual({ caller: "value", callee: "notify", lineNumber: 1 }); + }); + }); + + it("does not record top-level calls without an enclosing callable", () => { + withCalls(`let booted = bootstrap()\nfunc main() {}\n`, (calls) => { + expect(calls).toEqual([]); + }); + }); + }); + + describe("TreeSitterPlugin integration", () => { + it("analyzes Swift files through registered builtin configs and extractors", async () => { + const plugin = new TreeSitterPlugin([swiftConfig]); + await plugin.init(); + + const result = plugin.analyzeFile("App.swift", `struct App { func run() {} }\n`); + + expect(result.classes[0].name).toBe("App"); + expect(result.functions[0].name).toBe("run"); + }); + + it("extracts Swift call graphs through the plugin", async () => { + const plugin = new TreeSitterPlugin([swiftConfig]); + await plugin.init(); + + const calls = plugin.extractCallGraph("File.swift", `func a() { b() }\nfunc b() {}\n`); + + expect(calls).toContainEqual({ caller: "a", callee: "b", lineNumber: 1 }); + }); + + it("handles a SwiftUI app smoke test through the plugin", async () => { + const plugin = new TreeSitterPlugin([swiftConfig]); + await plugin.init(); + + const code = `import SwiftUI +@main struct MyApp: App { + var body: some Scene { + WindowGroup { ContentView() } + } +} +`; + + const result = plugin.analyzeFile("MyApp.swift", code); + const calls = plugin.extractCallGraph("MyApp.swift", code); + + expect(result.imports[0].source).toBe("SwiftUI"); + expect(result.classes[0].name).toBe("MyApp"); + expect(result.classes[0].properties).toContain("body"); + expect(result.exports.map((e) => e.name)).toContain("MyApp"); + expect(calls.map((c) => c.callee)).toEqual( + expect.arrayContaining(["WindowGroup", "ContentView"]), + ); + }); + }); +}); diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/index.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/index.ts index 8fbe73608..d4327d7fa 100644 --- a/understand-anything-plugin/packages/core/src/plugins/extractors/index.ts +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/index.ts @@ -11,6 +11,7 @@ export { CppExtractor } from "./cpp-extractor.js"; export { CSharpExtractor } from "./csharp-extractor.js"; export { DartExtractor } from "./dart-extractor.js"; export { KotlinExtractor } from "./kotlin-extractor.js"; +export { SwiftExtractor } from "./swift-extractor.js"; import type { LanguageExtractor } from "./types.js"; import { TypeScriptExtractor } from "./typescript-extractor.js"; @@ -24,6 +25,7 @@ import { CppExtractor } from "./cpp-extractor.js"; import { CSharpExtractor } from "./csharp-extractor.js"; import { DartExtractor } from "./dart-extractor.js"; import { KotlinExtractor } from "./kotlin-extractor.js"; +import { SwiftExtractor } from "./swift-extractor.js"; export const builtinExtractors: LanguageExtractor[] = [ new TypeScriptExtractor(), @@ -37,4 +39,5 @@ export const builtinExtractors: LanguageExtractor[] = [ new CSharpExtractor(), new DartExtractor(), new KotlinExtractor(), + new SwiftExtractor(), ]; diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/swift-extractor.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/swift-extractor.ts new file mode 100644 index 000000000..312b4ccd5 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/swift-extractor.ts @@ -0,0 +1,722 @@ +import type { StructuralAnalysis, CallGraphEntry } from "../../types.js"; +import type { LanguageExtractor, TreeSitterNode } from "./types.js"; +import { findChild } from "./base-extractor.js"; + +const TYPE_DECLARATION_KINDS = new Set(["class", "struct", "enum", "actor", "extension"]); +const WRAPPER_NODES = new Set(["ERROR", "if_config_declaration"]); + +function lineRange(node: TreeSitterNode): [number, number] { + return [node.startPosition.row + 1, node.endPosition.row + 1]; +} + +function namedChildren(node: TreeSitterNode): TreeSitterNode[] { + const children: TreeSitterNode[] = []; + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child?.isNamed) children.push(child); + } + return children; +} + +function childrenForFieldName(node: TreeSitterNode, fieldName: string): TreeSitterNode[] { + const children: TreeSitterNode[] = []; + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child && node.fieldNameForChild(i) === fieldName) { + children.push(child); + } + } + return children; +} + +function collectDescendants( + node: TreeSitterNode, + predicate: (node: TreeSitterNode) => boolean, + options: { stopAt?: Set } = {}, +): TreeSitterNode[] { + const result: TreeSitterNode[] = []; + + const walk = (current: TreeSitterNode) => { + if (current !== node && options.stopAt?.has(current.type)) return; + if (predicate(current)) result.push(current); + for (let i = 0; i < current.childCount; i++) { + const child = current.child(i); + if (child) walk(child); + } + }; + + walk(node); + return result; +} + +function normalizeWhitespace(text: string): string { + return text.replace(/\s+/g, " ").trim(); +} + +function declarationKind(node: TreeSitterNode): string | null { + const field = node.childForFieldName("declaration_kind"); + if (field) return field.text; + + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child && TYPE_DECLARATION_KINDS.has(child.text)) return child.text; + } + + return null; +} + +function isPrivateOrFileprivate(node: TreeSitterNode): boolean { + const modifiers = findChild(node, "modifiers"); + if (!modifiers) return false; + + for (const modifier of namedChildren(modifiers)) { + if (modifier.type !== "visibility_modifier") continue; + const normalized = normalizeWhitespace(modifier.text); + if (normalized === "private" || normalized === "fileprivate") return true; + } + + return false; +} + +function isExported(node: TreeSitterNode, parentExported: boolean): boolean { + return parentExported && !isPrivateOrFileprivate(node); +} + +function findFirstIdentifier(node: TreeSitterNode): TreeSitterNode | null { + if ( + node.type === "simple_identifier" || + node.type === "type_identifier" || + node.type === "identifier" + ) { + return node; + } + + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (!child) continue; + const found = findFirstIdentifier(child); + if (found) return found; + } + + return null; +} + +function findLastSimpleIdentifier(node: TreeSitterNode): TreeSitterNode | null { + let found: TreeSitterNode | null = null; + + const walk = (current: TreeSitterNode) => { + if (current.type === "simple_identifier" && current.text !== "_") { + found = current; + } + for (let i = 0; i < current.childCount; i++) { + const child = current.child(i); + if (child) walk(child); + } + }; + + walk(node); + return found; +} + +function extractCallableName(node: TreeSitterNode): string | null { + const nameNode = node.childForFieldName("name") ?? findFirstIdentifier(node); + return nameNode?.text ?? null; +} + +function extractTypeName(node: TreeSitterNode): string | null { + const nameNode = node.childForFieldName("name"); + if (!nameNode) return null; + return normalizeWhitespace(nameNode.text); +} + +function extractReturnType(node: TreeSitterNode): string | undefined { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (!child || child.text !== "->") continue; + + for (let j = i + 1; j < node.childCount; j++) { + const candidate = node.child(j); + if (!candidate?.isNamed) continue; + if (candidate.type === "function_body" || candidate.type === "computed_property") { + return undefined; + } + return normalizeWhitespace(candidate.text); + } + } + + return undefined; +} + +function extractParams(node: TreeSitterNode): string[] { + const params: string[] = []; + + for (const param of namedChildren(node)) { + if (param.type !== "parameter") continue; + + const nameField = param.childForFieldName("name"); + const nameNode = nameField ? findLastSimpleIdentifier(nameField) : findLastSimpleIdentifier(param); + if (nameNode && nameNode.text !== "_") params.push(nameNode.text); + } + + return params; +} + +function extractPatternName(pattern: TreeSitterNode): string | null { + const bound = collectDescendants(pattern, (node) => { + if (node.type !== "simple_identifier") return false; + const parent = node.parent; + if (!parent) return true; + for (let i = 0; i < parent.childCount; i++) { + const child = parent.child(i); + if (child && child.startIndex === node.startIndex && child.endIndex === node.endIndex) { + return parent.fieldNameForChild(i) === "bound_identifier"; + } + } + return false; + }); + + if (bound[0]) return bound[0].text; + + return findLastSimpleIdentifier(pattern)?.text ?? null; +} + +function extractPropertyNames(node: TreeSitterNode): string[] { + const names: string[] = []; + const seen = new Set(); + const directNamePatterns = childrenForFieldName(node, "name") + .flatMap((nameNode) => ( + nameNode.type === "pattern" + ? [nameNode] + : collectDescendants(nameNode, (child) => child.type === "pattern") + )); + const patterns = directNamePatterns.length > 0 + ? directNamePatterns + : namedChildren(node).filter((child) => child.type === "pattern"); + + for (const pattern of patterns) { + const name = extractPatternName(pattern); + if (name && !seen.has(name)) { + seen.add(name); + names.push(name); + } + } + + return names; +} + +function extractAssociatedTypeName(node: TreeSitterNode): string | null { + const nameNode = node.childForFieldName("name") ?? findFirstIdentifier(node); + return nameNode?.text ?? null; +} + +function extractEnumCaseNames(node: TreeSitterNode): string[] { + const names: string[] = []; + + const fieldNames = childrenForFieldName(node, "name"); + if (fieldNames.length > 0) { + for (const field of fieldNames) { + const identifier = findFirstIdentifier(field); + if (identifier) names.push(identifier.text); + } + return names; + } + + for (const child of namedChildren(node)) { + if (child.type === "simple_identifier") { + names.push(child.text); + } + } + + return names; +} + +function containerBaseName(containerName: string): string { + return containerName.startsWith("extension ") + ? containerName.slice("extension ".length) + : containerName; +} + +function pushExport( + exports: StructuralAnalysis["exports"], + name: string, + node: TreeSitterNode, +): void { + exports.push({ name, lineNumber: node.startPosition.row + 1 }); +} + +function pushFunction( + functions: StructuralAnalysis["functions"], + node: TreeSitterNode, + name: string, +): void { + functions.push({ + name, + lineRange: lineRange(node), + params: extractParams(node), + returnType: extractReturnType(node), + }); +} + +/** + * Swift extractor for tree-sitter structural analysis and call graph extraction. + * + * Swift has more type-like containers than the shared StructuralAnalysis schema + * can represent directly. Following the existing Dart/Kotlin/Rust conventions, + * class, struct, enum, actor, protocol, and extension containers are folded into + * `classes[]`, while callable members are also surfaced in `functions[]`. + */ +export class SwiftExtractor implements LanguageExtractor { + readonly languageIds = ["swift"]; + + extractStructure(rootNode: TreeSitterNode): StructuralAnalysis { + const functions: StructuralAnalysis["functions"] = []; + const classes: StructuralAnalysis["classes"] = []; + const imports: StructuralAnalysis["imports"] = []; + const exports: StructuralAnalysis["exports"] = []; + + const processNode = (node: TreeSitterNode, parentExported: boolean) => { + switch (node.type) { + case "import_declaration": + this.extractImport(node, imports); + return; + case "function_declaration": + this.extractTopLevelFunction(node, functions, exports, parentExported); + return; + case "class_declaration": + this.extractClassLike(node, classes, functions, exports, parentExported, processNode); + return; + case "protocol_declaration": + this.extractProtocol(node, classes, functions, exports, parentExported, processNode); + return; + case "function_body": + case "computed_property": + case "class_body": + case "protocol_body": + case "enum_class_body": + return; + } + + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child) processNode(child, parentExported); + } + }; + + processNode(rootNode, true); + + return { functions, classes, imports, exports }; + } + + extractCallGraph(rootNode: TreeSitterNode): CallGraphEntry[] { + const calls: CallGraphEntry[] = []; + const callerStack: string[] = []; + + const walk = (node: TreeSitterNode, containerName?: string) => { + switch (node.type) { + case "class_declaration": { + const name = this.classLikeName(node); + const body = node.childForFieldName("body"); + if (body) walk(body, name ?? containerName); + return; + } + case "protocol_declaration": { + const name = extractTypeName(node); + const body = node.childForFieldName("body"); + if (body) walk(body, name ?? containerName); + return; + } + case "function_declaration": { + const name = extractCallableName(node); + const body = node.childForFieldName("body"); + if (name && body) { + callerStack.push(name); + walk(body, containerName); + callerStack.pop(); + } + return; + } + case "init_declaration": { + const body = node.childForFieldName("body"); + if (containerName && body) { + callerStack.push(`${containerBaseName(containerName)}.init`); + walk(body, containerName); + callerStack.pop(); + } + return; + } + case "deinit_declaration": { + const body = node.childForFieldName("body"); + if (containerName && body) { + callerStack.push(`${containerBaseName(containerName)}.deinit`); + walk(body, containerName); + callerStack.pop(); + } + return; + } + case "property_declaration": { + const computedOrObserved = + findChild(node, "computed_property") ?? + findChild(node, "willset_didset_block"); + if (computedOrObserved) { + const propertyName = extractPropertyNames(node)[0]; + if (propertyName) { + callerStack.push(propertyName); + walk(computedOrObserved, containerName); + callerStack.pop(); + return; + } + } + break; + } + case "call_expression": { + const caller = callerStack[callerStack.length - 1]; + const callee = this.extractCalleeName(node); + if (caller && callee) { + calls.push({ + caller, + callee, + lineNumber: node.startPosition.row + 1, + }); + } + break; + } + case "constructor_expression": { + const caller = callerStack[callerStack.length - 1]; + const callee = this.extractConstructedType(node); + if (caller && callee) { + calls.push({ + caller, + callee, + lineNumber: node.startPosition.row + 1, + }); + } + break; + } + } + + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child) walk(child, containerName); + } + }; + + walk(rootNode); + return calls; + } + + private extractTopLevelFunction( + node: TreeSitterNode, + functions: StructuralAnalysis["functions"], + exports: StructuralAnalysis["exports"], + parentExported: boolean, + ): void { + const name = extractCallableName(node); + if (!name) return; + + pushFunction(functions, node, name); + if (isExported(node, parentExported)) pushExport(exports, name, node); + } + + private extractClassLike( + node: TreeSitterNode, + classes: StructuralAnalysis["classes"], + functions: StructuralAnalysis["functions"], + exports: StructuralAnalysis["exports"], + parentExported: boolean, + processNode: (node: TreeSitterNode, parentExported: boolean) => void, + ): void { + const name = this.classLikeName(node); + if (!name) return; + + const kind = declarationKind(node); + const exported = isExported(node, parentExported); + const methods: string[] = []; + const properties: string[] = []; + const nested: TreeSitterNode[] = []; + + const body = node.childForFieldName("body"); + if (body) { + this.collectBodyMembers( + body, + name, + exported, + methods, + properties, + functions, + exports, + nested, + ); + } + + classes.push({ + name, + lineRange: lineRange(node), + methods, + properties, + }); + + if (kind !== "extension" && exported) pushExport(exports, name, node); + + for (const nestedNode of nested) { + processNode(nestedNode, exported); + } + } + + private extractProtocol( + node: TreeSitterNode, + classes: StructuralAnalysis["classes"], + functions: StructuralAnalysis["functions"], + exports: StructuralAnalysis["exports"], + parentExported: boolean, + processNode: (node: TreeSitterNode, parentExported: boolean) => void, + ): void { + const name = extractTypeName(node); + if (!name) return; + + const exported = isExported(node, parentExported); + const methods: string[] = []; + const properties: string[] = []; + const nested: TreeSitterNode[] = []; + + const body = node.childForFieldName("body"); + if (body) { + this.collectBodyMembers( + body, + name, + exported, + methods, + properties, + functions, + exports, + nested, + ); + } + + classes.push({ + name, + lineRange: lineRange(node), + methods, + properties, + }); + + if (exported) pushExport(exports, name, node); + + for (const nestedNode of nested) { + processNode(nestedNode, exported); + } + } + + private collectBodyMembers( + body: TreeSitterNode, + containerName: string, + parentExported: boolean, + methods: string[], + properties: string[], + functions: StructuralAnalysis["functions"], + exports: StructuralAnalysis["exports"], + nested: TreeSitterNode[], + ): void { + for (let i = 0; i < body.childCount; i++) { + const member = body.child(i); + if (!member?.isNamed) continue; + + if (WRAPPER_NODES.has(member.type)) { + this.collectBodyMembers( + member, + containerName, + parentExported, + methods, + properties, + functions, + exports, + nested, + ); + continue; + } + + switch (member.type) { + case "function_declaration": + case "protocol_function_declaration": + this.collectFunctionMember(member, methods, functions, exports, parentExported); + break; + case "init_declaration": + this.collectInitMember(member, containerName, methods, functions, exports, parentExported); + break; + case "deinit_declaration": + this.collectDeinitMember(member, containerName, methods, functions); + break; + case "subscript_declaration": + this.collectSubscriptMember(member, methods, functions, exports, parentExported); + break; + case "property_declaration": + case "protocol_property_declaration": + this.collectPropertyMember(member, properties, exports, parentExported); + break; + case "associatedtype_declaration": + this.collectAssociatedType(member, properties, exports, parentExported); + break; + case "enum_entry": + properties.push(...extractEnumCaseNames(member)); + break; + case "class_declaration": + case "protocol_declaration": + nested.push(member); + break; + } + } + } + + private collectFunctionMember( + node: TreeSitterNode, + methods: string[], + functions: StructuralAnalysis["functions"], + exports: StructuralAnalysis["exports"], + parentExported: boolean, + ): void { + const name = extractCallableName(node); + if (!name) return; + + methods.push(name); + pushFunction(functions, node, name); + if (isExported(node, parentExported)) pushExport(exports, name, node); + } + + private collectInitMember( + node: TreeSitterNode, + containerName: string, + methods: string[], + functions: StructuralAnalysis["functions"], + exports: StructuralAnalysis["exports"], + parentExported: boolean, + ): void { + methods.push("init"); + const name = `${containerBaseName(containerName)}.init`; + pushFunction(functions, node, name); + if (isExported(node, parentExported)) pushExport(exports, name, node); + } + + private collectDeinitMember( + node: TreeSitterNode, + containerName: string, + methods: string[], + functions: StructuralAnalysis["functions"], + ): void { + methods.push("deinit"); + pushFunction(functions, node, `${containerBaseName(containerName)}.deinit`); + } + + private collectSubscriptMember( + node: TreeSitterNode, + methods: string[], + functions: StructuralAnalysis["functions"], + exports: StructuralAnalysis["exports"], + parentExported: boolean, + ): void { + methods.push("subscript"); + pushFunction(functions, node, "subscript"); + if (isExported(node, parentExported)) pushExport(exports, "subscript", node); + } + + private collectPropertyMember( + node: TreeSitterNode, + properties: string[], + exports: StructuralAnalysis["exports"], + parentExported: boolean, + ): void { + const exported = isExported(node, parentExported); + + for (const property of extractPropertyNames(node)) { + properties.push(property); + if (exported) pushExport(exports, property, node); + } + } + + private collectAssociatedType( + node: TreeSitterNode, + properties: string[], + exports: StructuralAnalysis["exports"], + parentExported: boolean, + ): void { + const name = extractAssociatedTypeName(node); + if (!name) return; + + properties.push(name); + if (isExported(node, parentExported)) pushExport(exports, name, node); + } + + private extractImport( + node: TreeSitterNode, + imports: StructuralAnalysis["imports"], + ): void { + const importPath = namedChildren(node).find((child) => child.type === "identifier"); + if (!importPath) return; + + const parts = importPath.text.split(".").filter(Boolean); + const source = parts[0] ?? importPath.text; + const specifier = parts[parts.length - 1] ?? source; + + imports.push({ + source, + specifiers: [specifier], + lineNumber: node.startPosition.row + 1, + }); + } + + private classLikeName(node: TreeSitterNode): string | null { + const kind = declarationKind(node); + const name = extractTypeName(node); + if (!name) return null; + + return kind === "extension" ? `extension ${name}` : name; + } + + private extractCalleeName(node: TreeSitterNode): string | null { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (!child || child.type === "call_suffix") break; + if (!child.isNamed) continue; + + if (child.type === "simple_identifier" || child.type === "type_identifier") { + return child.text; + } + if (child.type === "navigation_expression") { + return this.extractNavigationName(child); + } + if (child.type === "constructor_expression") { + return this.extractConstructedType(child); + } + if (child.type === "user_type" || child.type === "member_type_identifier") { + return normalizeWhitespace(child.text); + } + } + + return null; + } + + private extractNavigationName(node: TreeSitterNode): string | null { + const cleaned = normalizeWhitespace(node.text) + .replace(/\?\./g, ".") + .replace(/!\./g, ".") + .replace(/\?/g, "") + .replace(/!/g, ""); + const parts = cleaned.split(".").map((part) => part.trim()).filter(Boolean); + if (parts.length === 0) return null; + + const receiver = parts[0]; + const last = parts[parts.length - 1]; + if (!receiver || !last) return null; + + if (receiver === "super") return parts.join("."); + if (receiver === "self") return last; + if (/^[A-Z_]/.test(receiver)) return parts.join("."); + return last; + } + + private extractConstructedType(node: TreeSitterNode): string | null { + const constructedType = findChild(node, "constructed_type") ?? node.childForFieldName("type"); + if (constructedType) return normalizeWhitespace(constructedType.text); + + const identifier = findFirstIdentifier(node); + return identifier?.text ?? null; + } +} diff --git a/understand-anything-plugin/packages/tree-sitter-swift-wasm/.swift-grammar-pin b/understand-anything-plugin/packages/tree-sitter-swift-wasm/.swift-grammar-pin new file mode 100644 index 000000000..63d475ed3 --- /dev/null +++ b/understand-anything-plugin/packages/tree-sitter-swift-wasm/.swift-grammar-pin @@ -0,0 +1 @@ +d42e9bb24646c4dbf1f5ec476a35b96d817da448 diff --git a/understand-anything-plugin/packages/tree-sitter-swift-wasm/BUILD.md b/understand-anything-plugin/packages/tree-sitter-swift-wasm/BUILD.md new file mode 100644 index 000000000..27d4004d8 --- /dev/null +++ b/understand-anything-plugin/packages/tree-sitter-swift-wasm/BUILD.md @@ -0,0 +1,48 @@ +# tree-sitter-swift WASM (vendored) + +This directory ships a pre-built `tree-sitter-swift.wasm` because the published +`tree-sitter-swift@0.7.1` npm package ships native `.node` prebuilds and C +sources, but no WASM artifact. + +## Why vendored + +The core analyzer loads grammars through `web-tree-sitter@0.26.x`, which expects +modern `dylink.0` WASM modules. Vendoring the Swift grammar keeps runtime +loading consistent with the Dart grammar package in this workspace and avoids a +runtime dependency on a third-party bundle of many unrelated grammars. + +## How to rebuild + +```bash +git clone https://github.com/alex-pinkus/tree-sitter-swift.git /tmp/tree-sitter-swift +cd /tmp/tree-sitter-swift +git checkout d42e9bb24646c4dbf1f5ec476a35b96d817da448 +npx -y tree-sitter-cli@0.26.9 build --wasm --output tree-sitter-swift.wasm . +cp tree-sitter-swift.wasm \ + /path/to/understand-anything-plugin/packages/tree-sitter-swift-wasm/ +``` + +Verify the resulting wasm: + +```bash +node -e "const b=require('fs').readFileSync('tree-sitter-swift.wasm'); console.log(b.toString('latin1').includes('dylink.0'))" +# Expect: true +``` + +## Provenance + +- Grammar source: `alex-pinkus/tree-sitter-swift` at commit + `d42e9bb24646c4dbf1f5ec476a35b96d817da448`, recorded in + `.swift-grammar-pin`. +- Current artifact source: extracted from + `@plurnk/plurnk-mimetypes-text-swift@0.2.3`, which vendors a compatible + `tree-sitter-swift.wasm` for this grammar revision. +- The checked-in artifact was verified to load with this repository's + `web-tree-sitter@0.26.x` runtime and to contain the `dylink.0` custom section. +- License: MIT, inherited from `tree-sitter-swift`. + +## When to remove this package + +If `tree-sitter-swift` publishes a refreshed npm package with a compatible +`tree-sitter-swift.wasm`, this workspace package can be deleted and +`@understand-anything/core` can depend directly on the upstream grammar package. diff --git a/understand-anything-plugin/packages/tree-sitter-swift-wasm/LICENSE b/understand-anything-plugin/packages/tree-sitter-swift-wasm/LICENSE new file mode 100644 index 000000000..f158d7005 --- /dev/null +++ b/understand-anything-plugin/packages/tree-sitter-swift-wasm/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 alex-pinkus + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/understand-anything-plugin/packages/tree-sitter-swift-wasm/package.json b/understand-anything-plugin/packages/tree-sitter-swift-wasm/package.json new file mode 100644 index 000000000..b94434be7 --- /dev/null +++ b/understand-anything-plugin/packages/tree-sitter-swift-wasm/package.json @@ -0,0 +1,9 @@ +{ + "name": "@understand-anything/tree-sitter-swift-wasm", + "version": "0.1.0", + "type": "module", + "description": "Vendored tree-sitter-swift WASM grammar built with the modern dylink.0 ABI for use with web-tree-sitter@^0.26.", + "main": "tree-sitter-swift.wasm", + "files": ["tree-sitter-swift.wasm", "BUILD.md", ".swift-grammar-pin", "LICENSE"], + "license": "MIT" +} diff --git a/understand-anything-plugin/packages/tree-sitter-swift-wasm/tree-sitter-swift.wasm b/understand-anything-plugin/packages/tree-sitter-swift-wasm/tree-sitter-swift.wasm new file mode 100644 index 000000000..210e2c886 Binary files /dev/null and b/understand-anything-plugin/packages/tree-sitter-swift-wasm/tree-sitter-swift.wasm differ diff --git a/understand-anything-plugin/pnpm-lock.yaml b/understand-anything-plugin/pnpm-lock.yaml index e885e212a..8cd5f67ca 100644 --- a/understand-anything-plugin/pnpm-lock.yaml +++ b/understand-anything-plugin/pnpm-lock.yaml @@ -36,6 +36,9 @@ importers: '@understand-anything/tree-sitter-dart-wasm': specifier: workspace:* version: link:../tree-sitter-dart-wasm + '@understand-anything/tree-sitter-swift-wasm': + specifier: workspace:* + version: link:../tree-sitter-swift-wasm fuse.js: specifier: ^7.1.0 version: 7.1.0 @@ -176,6 +179,8 @@ importers: packages/tree-sitter-dart-wasm: {} + packages/tree-sitter-swift-wasm: {} + packages: '@ampproject/remapping@2.3.0': diff --git a/understand-anything-plugin/skills/understand/extract-import-map.mjs b/understand-anything-plugin/skills/understand/extract-import-map.mjs index 9a8ab371c..eda3e0f71 100644 --- a/understand-anything-plugin/skills/understand/extract-import-map.mjs +++ b/understand-anything-plugin/skills/understand/extract-import-map.mjs @@ -299,6 +299,101 @@ async function loadGoModules(projectRoot, files) { return { modules: out, warnings }; } +/** + * Parse Swift Package.swift target declarations just enough for import-map + * resolution. Swift imports modules, and SwiftPM target names are module names. + * The common convention is `Sources/`, but packages can override the + * source directory with `path: "..."`; without this light manifest pass those + * custom targets would stay disconnected. + * + * This is intentionally a focused parser, not a Swift evaluator. It handles + * `.target(...)`, `.executableTarget(...)`, and `.testTarget(...)` calls with + * literal `name:` and optional literal `path:` arguments. + */ +function parseSwiftPackageTargets(raw) { + const targets = []; + const callRe = /\.(target|executableTarget|testTarget)\s*\(/g; + let match; + + while ((match = callRe.exec(raw)) !== null) { + const kind = match[1]; + const bodyStart = callRe.lastIndex; + let depth = 1; + let i = bodyStart; + let inString = false; + let quote = ''; + let escaped = false; + + for (; i < raw.length; i++) { + const ch = raw[i]; + if (inString) { + if (escaped) { + escaped = false; + } else if (ch === '\\') { + escaped = true; + } else if (ch === quote) { + inString = false; + } + continue; + } + + if (ch === '"' || ch === "'") { + inString = true; + quote = ch; + continue; + } + if (ch === '(') depth += 1; + if (ch === ')') { + depth -= 1; + if (depth === 0) break; + } + } + + const body = raw.slice(bodyStart, i); + callRe.lastIndex = i + 1; + + const name = body.match(/\bname\s*:\s*"([^"]+)"/)?.[1]; + if (!name) continue; + const explicitPath = body.match(/\bpath\s*:\s*"([^"]+)"/)?.[1]; + const defaultRoot = kind === 'testTarget' ? 'Tests' : 'Sources'; + targets.push({ + name, + path: explicitPath || `${defaultRoot}/${name}`, + }); + } + + return targets; +} + +async function loadSwiftPackageTargets(projectRoot, files) { + const targets = new Map(); + const warnings = []; + const candidates = []; + + for (const f of files) { + const p = toPosix(f.path); + const base = p.includes('/') ? p.slice(p.lastIndexOf('/') + 1) : p; + if (base !== 'Package.swift') continue; + const absPath = join(projectRoot, p); + if (!existsSync(absPath)) continue; + candidates.push({ key: p, absPath }); + } + + const reads = await readFilesParallel(candidates); + for (const { key: p, raw, err } of reads) { + if (err) continue; + const packageDir = dirOf(p); + for (const target of parseSwiftPackageTargets(raw)) { + const targetPath = resolveRelative(packageDir, target.path.replace(/\\/g, '/')); + if (!targetPath) continue; + if (!targets.has(target.name)) targets.set(target.name, new Set()); + targets.get(target.name).add(targetPath); + } + } + + return { targets, warnings }; +} + /** * Walk up from `startDir` (project-relative POSIX, '' for project root) * and return the DEEPEST ancestor directory that exists as a key in @@ -344,26 +439,28 @@ function findNearestConfigDir(startDir, configMap) { async function buildResolutionContext(projectRoot, files) { const fileSet = new Set(files.map(f => toPosix(f.path))); - // The three config-loader passes are independent and each does its own + // These config-loader passes are independent and each does its own // batched parallel I/O; run them concurrently so the wait for a slow - // tsconfig.json read doesn't block go.mod / composer.json scanning. + // tsconfig.json read doesn't block go.mod / composer.json / SwiftPM scanning. // // Each loader BUFFERS warnings into a private array rather than writing // them to stderr inline. If a loader streamed warnings directly during // the concurrent passes, lines from independent loader families could // interleave based on I/O timing — that would break the pre-PR - // deterministic order (ts → go → php) and make stderr-diff verification + // deterministic order (ts → go → php → swift) and make stderr-diff verification // flaky. Drain the buffers in canonical order *after* Promise.all, so // a fixture with `(malformed tsconfig.json, malformed composer.json)` // always emits `tsconfig…\ncomposer…\n`, never the reverse. - const [tsResult, goResult, phpResult] = await Promise.all([ + const [tsResult, goResult, phpResult, swiftResult] = await Promise.all([ loadTsConfigs(projectRoot, files), loadGoModules(projectRoot, files), loadPhpAutoloads(projectRoot, files), + loadSwiftPackageTargets(projectRoot, files), ]); for (const w of tsResult.warnings) process.stderr.write(w); for (const w of goResult.warnings) process.stderr.write(w); for (const w of phpResult.warnings) process.stderr.write(w); + for (const w of swiftResult.warnings) process.stderr.write(w); const tsConfigs = tsResult.configs; const goModules = goResult.modules; const phpAutoloads = phpResult.autoloads; @@ -387,6 +484,7 @@ async function buildResolutionContext(projectRoot, files) { const javaIndex = buildSuffixIndex(files, p => p.endsWith('.java')); const kotlinIndex = buildSuffixIndex(files, p => p.endsWith('.kt')); const csIndex = buildSuffixIndex(files, p => p.endsWith('.cs')); + const swiftModuleIndex = buildSwiftModuleIndex(files, swiftResult.targets); return { projectRoot, @@ -397,6 +495,7 @@ async function buildResolutionContext(projectRoot, files) { javaIndex, kotlinIndex, csIndex, + swiftModuleIndex, phpAutoloads, // Dedupe Sets for one-time-per-file warnings. Keyed by importer file // path. Mutated by resolvers. @@ -923,6 +1022,86 @@ function buildSuffixIndex(files, extPredicate) { return idx; } +const SWIFT_SOURCE_ROOT_DIRS = new Set(['source', 'sources', 'test', 'tests']); +const SWIFT_MODULE_CONTAINER_DIRS = new Set([ + 'framework', + 'frameworks', + 'library', + 'libraries', + 'module', + 'modules', +]); + +function addSwiftModuleFile(index, moduleName, filePath) { + if (!moduleName) return; + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(moduleName)) return; + if (!index.has(moduleName)) index.set(moduleName, new Set()); + index.get(moduleName).add(filePath); +} + +function inferSwiftModulesFromPath(filePath) { + const parts = filePath.split('/'); + const dirs = parts.slice(0, -1); + const modules = new Set(); + + for (let i = 0; i < dirs.length - 1; i++) { + const lower = dirs[i].toLowerCase(); + if ( + SWIFT_SOURCE_ROOT_DIRS.has(lower) || + SWIFT_MODULE_CONTAINER_DIRS.has(lower) + ) { + modules.add(dirs[i + 1]); + } + } + + if (dirs.length > 0 && !SWIFT_SOURCE_ROOT_DIRS.has(dirs[0].toLowerCase())) { + modules.add(dirs[0]); + } + + return modules; +} + +/** + * Build a Swift module-name -> files index. + * + * Swift files in the same module do not import each other by relative path; + * `import Foo` imports a module. We therefore resolve to every project Swift + * file that belongs to module `Foo`, mirroring the Go resolver's package-level + * expansion. The index combines SwiftPM manifest targets with common on-disk + * conventions (`Sources/Foo`, `Tests/FooTests`, and top-level Xcode groups). + */ +function buildSwiftModuleIndex(files, packageTargets) { + const idx = new Map(); + const targetEntries = [...packageTargets.entries()].map(([name, paths]) => [ + name, + [...paths].sort((a, b) => a.localeCompare(b)), + ]); + + for (const f of files) { + const p = toPosix(f.path); + if (!p.endsWith('.swift')) continue; + if (p.endsWith('/Package.swift') || p === 'Package.swift') continue; + + for (const [moduleName, targetPaths] of targetEntries) { + for (const targetPath of targetPaths) { + if (p === targetPath || p.startsWith(`${targetPath}/`)) { + addSwiftModuleFile(idx, moduleName, p); + } + } + } + + for (const moduleName of inferSwiftModulesFromPath(p)) { + addSwiftModuleFile(idx, moduleName, p); + } + } + + const out = new Map(); + for (const [moduleName, paths] of idx.entries()) { + out.set(moduleName, [...paths].sort((a, b) => a.localeCompare(b))); + } + return out; +} + /** * Resolve a dotted-import to a file. `fqn` is the qualified name * (`com.example.Foo`); `ext` is the file extension to probe (`.java`, @@ -977,6 +1156,30 @@ export function resolveCSharpImport(rawImport, _file, ctx) { return resolveDottedFqn(rawImport, '.cs', ctx.csIndex); } +// --------------------------------------------------------------------------- +// Swift resolver +// +// Swift imports modules, not files. `SwiftExtractor` reports the module part +// as `imp.source` for both `import Foo` and qualified forms such as +// `import struct Foo.Bar`. If a project module named Foo exists in the Swift +// module index, map the import to all Swift files in that module. +// --------------------------------------------------------------------------- + +function normalizeSwiftModuleName(rawImport) { + if (!rawImport || typeof rawImport !== 'string') return null; + const moduleName = rawImport.trim().split('.')[0]; + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(moduleName) ? moduleName : null; +} + +export function resolveSwiftImport(rawImport, file, ctx) { + const moduleName = normalizeSwiftModuleName(rawImport); + if (!moduleName) return []; + const matches = ctx.swiftModuleIndex.get(moduleName); + if (!matches) return []; + const importer = toPosix(file.path); + return matches.filter(p => p !== importer); +} + // --------------------------------------------------------------------------- // Ruby resolver // @@ -1439,6 +1642,9 @@ function resolveImport(imp, file, ctx) { if (lang === 'csharp') { return resolveCSharpImport(src, file, ctx); } + if (lang === 'swift') { + return resolveSwiftImport(src, file, ctx); + } if (lang === 'php') { return resolvePhpImport(src, file, ctx); }