diff --git a/CHANGELOG.md b/CHANGELOG.md index 644db9e66..26da57141 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,56 @@ ## Unreleased +### Added + +- **Swift gets full-fidelity (depth-tier) extraction**, promoted from the + breadth tier the same way Kotlin was (#130) — whose swift tags query had no + call captures at all, so Swift repos indexed symbols with zero wiring. + `tree-sitter-swift` (native, `^0.7.1` — the first release whose install + compiles the shipped parser instead of regenerating it, which broke under + npm's hoisting; a root `overrides` entry pins its `tree-sitter` peer for dev + installs) parses `.swift`. One `class_declaration` node covers `class` / + `struct` / `enum` / `actor` / `extension`, told apart by their own keyword: + class and actor → `class`, struct → `struct`, enum → `enum`, `protocol` → + `interface`, `typealias` → `type`, top-level `let`/`var` → `variable`. An + `extension Point` node takes the extended type's own name, so its members + mint as Point methods and member calls on a Point receiver resolve to them. + The extension node itself is kind `module`, not a second same-named class — + otherwise every `Point()` call and `: Point` heritage target would go + ambiguous and drop. `init` becomes a method named after its type (like a + Java constructor); `func` is a method inside any type and a function + elsewhere. Calls resolve via `call_expression` with `navigation_expression` + / `self` / `super` receivers. A bare lowercase call inside a type body is + ONE edge carrying both of its readings in Swift's own inner-scope-first + order: the member reading first (owner-qualified index + the in-repo + ancestor chain — a stdlib call like `contains` inside `extension Set` + drops instead of binding to an unrelated type's only same-named method, a + false positive dogfooding on swift-composable-architecture caught), then + the free-function reading only when no member exists on the chain — so a + name defined as both yields the member edge alone, as Swift dispatches it. + `super.method()` resolves against the declaration's own superclass (the + first `:` entry, which Swift's grammar puts before any protocol), climbing + past the current class's override. Overloads disambiguate by declared + arity vs call-site argument count (Java's exact mechanism — defaults and + variadics make the arity a minimum); an overload set arity can't split + (`save(Int)` vs `save(String)`) DROPS rather than taking the same-file + tiebreak, which would stamp whichever overload appears first `extracted`. + An initializer call (`Animal(legs: 4)` — an ordinary call node, no `new`) + falls back to class/struct/enum targets once functions find nothing, + Python's constructor-fallback shape. The `:` inheritance clause yields `extends` + edges (bare names — Swift can't say syntactically which specifier is the + superclass), `import` declarations yield module-path import edges, and + visibility maps to `exported` as only `private`/`fileprivate` hidden — + Swift's default `internal` is module-wide, which for a one-module repo is + the API surface. A Swift bindings collector types receivers from the + confident, syntax-local clues: typed parameters (`func feed(animal: + Animal)`, argument labels handled), typed properties, initializer-call + assignments (`let vet = Vet()` — UpperCamelCase callee, the same convention + trust as Go's `NewX`), and fields bound both bare and `self.`-prefixed — + so `vet.check()`, `keeper.wave()`, `self.repo.save()`, and type-member + calls (`Animal.census()`) all resolve through the owner-qualified method + index. A receiver with no local clue (a chained call's result) stays + unresolved rather than guessed. ### Fixed - **`allowScripts` now names R by the identity npm actually matches on.** The diff --git a/README.md b/README.md index 2242cf792..06d7a11b9 100644 --- a/README.md +++ b/README.md @@ -193,13 +193,15 @@ compiler-grade layer — all `$0` and deterministic (no model, no key): - **Full-fidelity** — hand-written extractors with scope-aware, cross-file call and import resolution: **TypeScript / JavaScript** (incl. JSX & TSX), **Python**, **Go**, **Java**, - **R** (`.R`/`.r` — plain functions, S3/S4/R6 classes and methods, roxygen - `@export`, `library()`/`source()` imports). + **Kotlin**, **PHP**, **Swift** (classes, structs, enums, actors, protocols; + extension members attach to the extended type), **R** (`.R`/`.r` — plain + functions, S3/S4/R6 classes and methods, roxygen `@export`, + `library()`/`source()` imports). - **Broad** — symbols (functions, classes, methods, types, …) plus name-resolved call edges via a generic tree-sitter extractor, one grammar per language: - **Rust, C, C++, C#, Ruby, PHP, Kotlin, Scala, Swift, Elixir, Solidity, - OCaml, Zig, Dart, Clojure, Lua**. + **Rust, C, C++, C#, Ruby, Scala, Elixir, Solidity, + OCaml, Zig, Dart, Clojure, Nix, Lua**. - **Compiler-grade edges (opt-in)** — `graft build --lsp` adds precise `lsp_resolved` call edges (member calls the static pass can't type) when a @@ -207,7 +209,7 @@ compiler-grade layer — all `$0` and deterministic (no model, no key): **gopls** (Go), **pyright** (Python), **typescript-language-server** (TS/JS). It's best-effort — with no server installed the graph is unchanged. -Twenty-two languages in total. A file whose language isn't listed is skipped, not +Twenty-three languages in total. A file whose language isn't listed is skipped, not indexed. Adding a broad-tier language is a small contribution — see [CREDITS.md](CREDITS.md) for the folks who added the current set. diff --git a/package-lock.json b/package-lock.json index 96f95811c..847e5b967 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "tree-sitter-php": "^0.23.4", "tree-sitter-python": "^0.21.0", "tree-sitter-r": "npm:@davisvaughan/tree-sitter-r@^1.3.0", + "tree-sitter-swift": "^0.7.1", "tree-sitter-typescript": "^0.23.2", "tree-sitter-wasm": "^1.1.6", "vscode-jsonrpc": "^8.2.1", @@ -1079,6 +1080,19 @@ "node-gyp-build": "^4.8.0" } }, + "node_modules/tree-sitter-cli": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/tree-sitter-cli/-/tree-sitter-cli-0.23.2.tgz", + "integrity": "sha512-kPPXprOqREX+C/FgUp2Qpt9jd0vSwn+hOgjzVv/7hapdoWpa+VeWId53rf4oNNd29ikheF12BYtGD/W90feMbA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "tree-sitter": "cli.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/tree-sitter-go": { "version": "0.23.4", "resolved": "https://registry.npmjs.org/tree-sitter-go/-/tree-sitter-go-0.23.4.tgz", @@ -1225,6 +1239,48 @@ } } }, + "node_modules/tree-sitter-swift": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/tree-sitter-swift/-/tree-sitter-swift-0.7.1.tgz", + "integrity": "sha512-pneKVTuGamaBsqqqfB9BvNQjktzh/0IVPR54jLB5Fq/JTDQwYHd0Wo6pVyZ5jAYpbztzq+rJ/rpL9ruxTmSoKw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.0.0", + "node-gyp-build": "^4.8.0", + "tree-sitter-cli": "^0.23", + "which": "2.0.2" + }, + "peerDependencies": { + "tree-sitter": "^0.22.1" + }, + "peerDependenciesMeta": { + "tree_sitter": { + "optional": true + } + } + }, + "node_modules/tree-sitter-swift/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/tree-sitter-swift/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/tree-sitter-typescript": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/tree-sitter-typescript/-/tree-sitter-typescript-0.23.2.tgz", diff --git a/package.json b/package.json index b652a54ac..b5d0e8ef2 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "tree-sitter-php": "^0.23.4", "tree-sitter-python": "^0.21.0", "tree-sitter-r": "npm:@davisvaughan/tree-sitter-r@^1.3.0", + "tree-sitter-swift": "^0.7.1", "tree-sitter-typescript": "^0.23.2", "tree-sitter-wasm": "^1.1.6", "vscode-jsonrpc": "^8.2.1", @@ -93,6 +94,12 @@ "fsevents@2.3.3": true, "tree-sitter-kotlin@0.3.8": true, "tree-sitter-php@0.23.12": true, - "@davisvaughan/tree-sitter-r@1.3.0": true + "@davisvaughan/tree-sitter-r@1.3.0": true, + "tree-sitter-swift@0.7.1": true + }, + "overrides": { + "tree-sitter-swift": { + "tree-sitter": "$tree-sitter" + } } } diff --git a/src/graph/bindings.ts b/src/graph/bindings.ts index a9bb80c11..96d01089b 100644 --- a/src/graph/bindings.ts +++ b/src/graph/bindings.ts @@ -59,6 +59,7 @@ export function defName(node: Parser.SyntaxNode, lang: Language): string | null return null; } if (lang === "r") return rDefName(node); + if (lang === "swift") return swiftDefName(node); if (lang === "php") { const phpDefTypes = new Set([ "class_declaration", @@ -97,6 +98,38 @@ export function defName(node: Parser.SyntaxNode, lang: Language): string | null return null; } +/** The scope segment a Swift definition pushes, mirroring extract.ts's + * `describeSwift` (duplicated, not imported, per this file's + * no-value-import-of-extract rule): types and extensions push the type's name + * (an extension is named after the type it extends), functions their own name, + * and an `init` the enclosing type's name — extract.ts names initializers after + * their type, so the two scope stacks stay in lockstep inside an init body. + * Typealias and top-level properties DO mint nodes in extract.ts but are + * deliberately absent here: neither has a body a binding could be set in (a + * property initializer's closure is the one vanishing exception, mis-keying + * only bindings set inside itself). */ +function swiftDefName(node: Parser.SyntaxNode): string | null { + if (node.type === "class_declaration") { + if (node.children.some((c) => c.type === "extension")) { + const ut = node.namedChildren.find((c) => c.type === "user_type"); + const ids = ut?.namedChildren.filter((c) => c.type === "type_identifier") ?? []; + return ids.at(-1)?.text ?? null; + } + return node.namedChildren.find((c) => c.type === "type_identifier")?.text ?? null; + } + if (node.type === "protocol_declaration") { + return node.namedChildren.find((c) => c.type === "type_identifier")?.text ?? null; + } + if (node.type === "function_declaration" || node.type === "protocol_function_declaration") { + return node.namedChildren.find((c) => c.type === "simple_identifier")?.text ?? null; + } + if (node.type === "init_declaration") { + const owner = node.parent?.parent; // class_body / protocol_body → the declaration + return owner ? swiftDefName(owner) : null; + } + return null; +} + const R_ASSIGN_OPS = new Set(["<-", "<<-", "="]); const R_RIGHT_ASSIGN_OPS = new Set(["->", "->>"]); @@ -158,11 +191,12 @@ function goReceiverTypeOf(node: Parser.SyntaxNode): string | null { /** Resolves a call site's receiver text (from `calleeName`) to a bound type * name, given the enclosing walk state. `self`/`cls`/`this`/the Go receiver - * var resolve directly to the enclosing class; R6's `super` (Phase 3) resolves - * to the PARENT class instead (`ctx.rSuperClass`, not `ctx.enclosingClass` — - * a `super$method()` call must climb past the current class's own same-named - * override, not find it); anything else is a bindings-map lookup, normalizing - * `this.` to `self.` since both are stored the same way. */ + * var resolve directly to the enclosing class; `super` — R6's `super$` and + * Swift's `super.` alike — resolves to the PARENT class instead + * (`ctx.rSuperClass`, not `ctx.enclosingClass` — a super call must climb past + * the current class's own same-named override, not find it); anything else is + * a bindings-map lookup, normalizing `this.` to `self.` since both are stored + * the same way. */ export function resolveRecvType( receiver: string | undefined, ctx: Pick, @@ -183,6 +217,10 @@ export function resolveRecvType( return ( (ctx.lang === "go" && receiver === ctx.goReceiverVar ? ctx.enclosingClass : undefined) ?? ctx.bindings.lookup(ctx.scope, receiver) ?? + // Swift type-member call `Animal.staticThing()`: an uppercase receiver with no + // local binding is the type itself (Swift naming: types are UpperCamelCase, + // values lowerCamelCase — and a shadowing binding was already tried above). + (ctx.lang === "swift" && /^[A-Z]/.test(receiver) ? receiver : undefined) ?? undefined ); } @@ -193,6 +231,11 @@ function isClassNode(node: Parser.SyntaxNode, lang: Language): boolean { if (lang === "typescript" || lang === "tsx") { return node.type === "class_declaration" || node.type === "abstract_class_declaration"; } + // Swift: class_declaration covers class/struct/enum/actor/extension — all can + // hold members whose `self.field` bindings live at the type's scope. + if (lang === "swift") { + return node.type === "class_declaration" || node.type === "protocol_declaration"; + } return false; } @@ -262,6 +305,7 @@ function visit( // collect yet (see extract.ts's calleeName R branch) — no handleR needed. else if (lang === "r") void 0; else if (lang === "java") handleJava(node, scope, classScope, bindings); + else if (lang === "swift") handleSwift(node, scope, classScope, bindings); else if (lang === "php") handlePhp(node, scope, bindings); else handleTs(node, scope, classScope, bindings, aliases); @@ -300,6 +344,75 @@ function callTypeName(node: Parser.SyntaxNode | null | undefined, aliases: Maptype bindings from the confident, syntax-local clues: + * a typed parameter (`func feed(animal: Animal)`, `init(keeper k: Keeper)` — + * the LAST simple_identifier before the `:` is the local name, the first may be + * an external argument label), a typed property (`var d: Doctor`), and an + * initializer-call assignment (`let vet = Vet()` — an initializer call is an + * ordinary call whose callee is the type's own UpperCamelCase name; Swift's + * naming convention makes the case split reliable, the same way Go's `NewX` + * convention is trusted in handleGo). A property directly inside a type body is + * a field: bound at the type's scope, both bare (`repo.save()`) and + * `self.`-prefixed (`self.repo.save()`), like Java's fields. */ +function handleSwift( + node: Parser.SyntaxNode, + scope: string[], + classScope: string | null, + bindings: FileBindings, +): void { + const scopePath = scope.join("."); + if (node.type === "parameter") { + const ids = node.namedChildren.filter((c) => c.type === "simple_identifier"); + const name = ids.at(-1)?.text; + const type = swiftTypeName( + node.namedChildren.find((c) => c.type === "user_type" || c.type === "optional_type"), + ); + if (name && type) bindings.set(scopePath, name, type); + return; + } + if (node.type !== "property_declaration") return; + const name = node.namedChildren + .find((c) => c.type === "pattern") + ?.namedChildren.find((c) => c.type === "simple_identifier")?.text; + if (!name) return; + const annotated = node.namedChildren + .find((c) => c.type === "type_annotation") + ?.namedChildren.find((c) => c.type === "user_type" || c.type === "optional_type"); + const type = + swiftTypeName(annotated) ?? + swiftCtorTypeName(node.namedChildren.find((c) => c.type === "call_expression")); + if (!type) return; + const isField = node.parent?.type === "class_body" || node.parent?.type === "protocol_body"; + const target = isField ? (classScope ?? scopePath) : scopePath; + bindings.set(target, name, type); + if (isField) bindings.set(target, `self.${name}`, type); +} + +/** A Swift type node's bare name: a `user_type`'s LAST type_identifier (so a + * module-qualified `Foundation.Date` binds as `Date`, generic arguments live in + * nested nodes and never leak in), unwrapping one level of optional (`Animal?`). + * Collections, tuples, and function types bind nothing — no single confident + * receiver type to name. */ +function swiftTypeName(node: Parser.SyntaxNode | null | undefined): string | null { + if (!node) return null; + if (node.type === "optional_type") { + return swiftTypeName(node.namedChildren.find((c) => c.type === "user_type")); + } + if (node.type !== "user_type") return null; + const ids = node.namedChildren.filter((c) => c.type === "type_identifier"); + return ids.at(-1)?.text ?? null; +} + +/** The type constructed by an initializer call (`Vet()` → `Vet`), or null when + * the callee isn't a bare UpperCamelCase name — a lowercase callee is an + * ordinary function whose return type a single-file pass cannot know. */ +function swiftCtorTypeName(call: Parser.SyntaxNode | null | undefined): string | null { + if (call?.type !== "call_expression") return null; + const fn = call.namedChildren[0]; + if (fn?.type !== "simple_identifier") return null; + return /^[A-Z]/.test(fn.text) ? fn.text : null; +} + /** PHP variable->type bindings from the two confident, syntax-local clues: * a type-hinted parameter (`function f(Foo $x)`) and a `new` assignment * (`$x = new Foo()`). Keyed by the `$var` text (with the `$`) so a diff --git a/src/graph/extract.ts b/src/graph/extract.ts index caa84665a..d0f9dff19 100644 --- a/src/graph/extract.ts +++ b/src/graph/extract.ts @@ -13,13 +13,14 @@ import Go from "tree-sitter-go"; import R from "tree-sitter-r"; import Java from "tree-sitter-java"; import Kotlin from "tree-sitter-kotlin"; +import Swift from "tree-sitter-swift"; import PHP from "tree-sitter-php"; import { basename } from "node:path"; import { contentHash } from "../util/id.js"; import { collectBindings, goReceiverVarOf, resolveRecvType, type FileBindings } from "./bindings.js"; import type { Kind, NodeV1, Relation } from "./types.js"; -export type Language = "typescript" | "tsx" | "python" | "go" | "java" | "kotlin" | "php" | "r"; +export type Language = "typescript" | "tsx" | "python" | "go" | "java" | "kotlin" | "swift" | "php" | "r"; /** * Extension → the tree-sitter grammar that parses it, and the label a human expects @@ -51,6 +52,7 @@ const EXTENSIONS: ReadonlyArray<{ ext: string; grammar: Language; label: string { ext: ".java", grammar: "java", label: "java" }, { ext: ".kt", grammar: "kotlin", label: "kotlin" }, { ext: ".kts", grammar: "kotlin", label: "kotlin" }, + { ext: ".swift", grammar: "swift", label: "swift" }, { ext: ".php", grammar: "php", label: "php" }, // `entryFor` lower-cases the path before matching, so this one entry covers // both `.R` (the conventional case in real R codebases) and `.r`. @@ -107,10 +109,17 @@ export interface RawEdge { * unconditionally unresolvable rather than just occasionally ambiguous. */ kinds?: Kind[]; /** calls: the number of arguments at the CALL SITE. Only emitted for languages - * with overloading (Java), where a same-named sibling on the same class is + * with overloading (Java, Swift), where a same-named sibling on the same class is * otherwise indistinguishable — and picking wrong turns a delegating overload * into a self-loop. */ argCount?: number; + /** Swift only: a bare lowercase call inside a type body, which the language + * resolves member-first (inner scope wins). The edge carries the member + * reading (viaMember + recvType = the enclosing type); this flag lets + * resolve.ts fall back to the free-function reading when the owner chain has + * no such member — and ONLY then, so a name defined as both a member and a + * free function yields the member edge alone, exactly as Swift dispatches it. */ + implicitSelf?: boolean; } export interface ExtractResult { @@ -217,6 +226,31 @@ const KOTLIN_KINDS: Record = { * same class_declaration node rekinded in describeKotlin, so all three land in the set. */ const KOTLIN_TYPE_KINDS: ReadonlySet = new Set(["class", "interface", "enum"]); +const SWIFT_KINDS: Record = { + class_declaration: "class", // → "struct" / "enum" in describeSwift (one node type covers all five keywords) + protocol_declaration: "interface", + function_declaration: "function", // → "method" inside a type (resolved in the walk) + protocol_function_declaration: "method", // a protocol requirement is always a member + init_declaration: "method", // the type's own initializer (named after it, like a Java constructor) + typealias_declaration: "type", + property_declaration: "variable", // top-level `let`/`var` only (fields resolved in the walk) +}; + +/** Swift type declarations: they set `enclosingClass` for the members nested in them. + * class/struct/enum are the same class_declaration node rekinded in describeSwift + * (an actor takes "class"); protocols are "interface". "module" is an extension + * body — a member-contributing scope named after the extended type, deliberately + * NOT a type kind in the graph so it can never make the real declaration's name + * ambiguous (see describeSwift's extension branch) — but its members still + * promote to methods owned by that type, which is why it belongs in this set. */ +const SWIFT_TYPE_KINDS: ReadonlySet = new Set([ + "class", + "struct", + "enum", + "interface", + "module", +]); + // PHP: definition node types are all distinct (no py-style function→method // promotion needed — a class body uses `method_declaration`, not // `function_definition`). `trait_declaration` maps to the PHP-only `trait` kind. @@ -237,6 +271,7 @@ const KINDS_BY_LANG: Record> = { r: R_KINDS, java: JAVA_KINDS, kotlin: KOTLIN_KINDS, + swift: SWIFT_KINDS, php: PHP_KINDS, }; @@ -256,6 +291,7 @@ const CALL_TYPES: Record> = { go: new Set(["call_expression"]), java: new Set(["method_invocation", "object_creation_expression"]), kotlin: new Set(["call_expression"]), + swift: new Set(["call_expression"]), php: new Set([ "function_call_expression", "member_call_expression", @@ -283,6 +319,7 @@ const GRAMMARS: Record = { r: R, java: Java, kotlin: Kotlin, + swift: Swift, php: PHP.php, }; @@ -561,9 +598,11 @@ function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEd ? javaExported(node) : ctx.lang === "kotlin" ? kotlinExported(node) - : ctx.lang === "php" - ? phpExported(node) - : tsExported(node), + : ctx.lang === "swift" + ? swiftExported(node) + : ctx.lang === "php" + ? phpExported(node) + : tsExported(node), origin: "ast", body_hash: contentHash(desc.hashNode.text), body_text: searchBody(desc.hashNode.text), @@ -580,13 +619,14 @@ function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEd // may `implements`, so every type declaration is a heritage site, not just a class. const javaTypeDecl = ctx.lang === "java" && JAVA_TYPE_KINDS.has(desc.kind); const kotlinTypeDecl = ctx.lang === "kotlin" && KOTLIN_TYPE_KINDS.has(desc.kind); - if (desc.kind === "class" || javaTypeDecl || kotlinTypeDecl) + const swiftTypeDecl = ctx.lang === "swift" && SWIFT_TYPE_KINDS.has(desc.kind); + if (desc.kind === "class" || javaTypeDecl || kotlinTypeDecl || swiftTypeDecl) edges.push(...heritageEdges(node, id, ctx)); if (ctx.lang === "php") edges.push(...phpAttributeReferenceEdges(node, id, ctx)); if (ctx.lang === "java") edges.push(...javaAnnotationReferenceEdges(node, id, ctx)); const enclosingClass = - desc.kind === "class" || javaTypeDecl || kotlinTypeDecl + desc.kind === "class" || javaTypeDecl || kotlinTypeDecl || swiftTypeDecl ? desc.name : isGoMethod ? goReceiverType(node) @@ -609,9 +649,18 @@ function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEd // defined inside one). rR6Access: null, // Unlike rR6Access, only reset when entering a genuinely new class (so it - // stays live through a method's whole body, where super$ calls actually - // happen) — inherited unchanged for every other definition kind. - rSuperClass: desc.kind === "class" ? (ctx.lang === "r" ? rR6ParentClass(node) : null) : ctx.rSuperClass, + // stays live through a method's whole body, where super$ / super. calls + // actually happen) — inherited unchanged for every other definition kind. + // Swift reads it off the declaration's own `:` clause, so `super.ping()` + // resolves against the PARENT type, not the overriding current one. + rSuperClass: + desc.kind === "class" + ? ctx.lang === "r" + ? rR6ParentClass(node) + : ctx.lang === "swift" + ? swiftSuperClassName(node) + : null + : ctx.rSuperClass, }; walkNamedChildren(node.namedChildren, childCtx, out, edges, minted); return; @@ -681,11 +730,44 @@ function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEd file: ctx.rel, ...(callee.kinds ? { kinds: callee.kinds } : {}), }; - // Java only: the call site's argument count, to pick the right overload. - const argCount = ctx.lang === "java" ? javaArgCount(node) : undefined; + // Overloading languages: the call site's argument count, to pick the right + // overload (see RawEdge.argCount). + const argCount = + ctx.lang === "java" ? javaArgCount(node) : ctx.lang === "swift" ? swiftArgCount(node) : undefined; if (argCount !== undefined) callEdge.argCount = argCount; - const recvType = resolveRecvType(callee.receiver, ctx); - edges.push(recvType ? { ...callEdge, recvType } : callEdge); + // Swift: a bare lowercase call inside a type body may be an implicit-`self` + // member call (`walk()` for `self.walk()`), syntactically indistinguishable + // from a free-function call — and Swift's own lookup is member-FIRST (inner + // scope wins). So the edge is emitted as the member reading, typed to the + // enclosing class — resolved through the owner-qualified method index and + // the class's in-repo ancestor chain (`clearLogs()` in a test subclass + // finds the base class's method) — with `implicitSelf` letting resolve.ts + // fall back to the free-function reading only when no member exists on the + // chain. One edge, both readings, language-order precedence. This is + // deliberately NOT a bare-name kind widening: dogfooding on + // swift-composable-architecture, a global unique-name match bound + // `contains(element)` inside `extension Set` — a stdlib call — to an + // unrelated type's only in-repo `contains`. And not for an UpperCamelCase + // callee: that is an initializer call (`Text("hi")`), which takes + // resolve.ts's class/struct/enum fallback instead — extension nodes (kind + // "module") can never false-match it. + const swiftImplicitSelf = + ctx.lang === "swift" && + !callee.viaMember && + !callee.kinds && + ctx.enclosingClass && + !/^[A-Z]/.test(callee.name); + if (swiftImplicitSelf) { + edges.push({ + ...callEdge, + viaMember: true, + recvType: ctx.enclosingClass!, + implicitSelf: true, + }); + } else { + const recvType = resolveRecvType(callee.receiver, ctx); + edges.push(recvType ? { ...callEdge, recvType } : callEdge); + } } } else if (ctx.lang === "php" && node.type === "use_declaration") { // Trait composition inside a class body (`use HasFactory, Notifiable;`). @@ -1028,6 +1110,7 @@ function describe(node: Parser.SyntaxNode, ctx: WalkCtx): DefDescriptor | null { if (ctx.lang === "r") return describeR(node, ctx); if (ctx.lang === "java") return describeJava(node, ctx); if (ctx.lang === "kotlin") return describeKotlin(node, ctx); + if (ctx.lang === "swift") return describeSwift(node, ctx); // PHP closures: `$h = function () {…}` / `fn() => …`, and bare callbacks // (`$routes->get('/x', function () {…})`). Captured as function nodes so a @@ -1681,6 +1764,128 @@ function describeKotlin(node: Parser.SyntaxNode, ctx: WalkCtx): DefDescriptor | return null; } +/** Swift definition shapes. Like Kotlin's, tree-sitter-swift exposes no `name` or + * `body` fields: a definition's name is a direct `type_identifier` (types) or + * `simple_identifier` (functions) child, and its body is a `class_body` / + * `enum_class_body` / `protocol_body` / `function_body` child. One + * `class_declaration` node type covers `class`, `struct`, `enum`, `actor` AND + * `extension` — the declaration's own keyword token tells them apart. */ +function describeSwift(node: Parser.SyntaxNode, ctx: WalkCtx): DefDescriptor | null { + // The first direct `type_identifier` is the declared name (generic parameters and + // inheritance specifiers are all nested beneath other child nodes). + const typeName = (): string | null => + node.namedChildren.find((c) => c.type === "type_identifier")?.text ?? null; + // The first direct `simple_identifier` is the function name (parameters and + // generic parameters are all nested beneath other child nodes). + const funcName = (): string | null => + node.namedChildren.find((c) => c.type === "simple_identifier")?.text ?? null; + const headEnd = (...types: string[]): number => { + const body = node.namedChildren.find((c) => types.includes(c.type)); + return body ? body.startIndex : node.endIndex; + }; + + if (node.type === "class_declaration") { + const kw = node.children.find( + (c) => + c.type === "class" || + c.type === "struct" || + c.type === "enum" || + c.type === "actor" || + c.type === "extension", + )?.type; + let name: string | null; + if (kw === "extension") { + // `extension Point { … }` has no name of its own — the extended type's IS its + // identity, so the node takes that name: members mint as `Point.method`, + // `enclosingClass` becomes `Point`, and a member call on a Point receiver + // resolves to them exactly as if they were declared on the type. A qualified + // target (`extension Swift.Array`) reduces to its last component, matching + // how the extended type is itself named in the graph. + // + // Its KIND is "module", not "class": the type usually already has a real + // declaration, and a second same-named "class" node would make the name + // AMBIGUOUS to resolveName — every `Point(...)` initializer call and every + // `: Point` heritage target would then drop instead of resolving (resolve + // never guesses between same-named candidates). "module" keeps the node out + // of type-name resolution entirely while SWIFT_TYPE_KINDS still makes it + // own its members; typed member calls are untouched either way, since they + // go through the owner-qualified method index, not the type's own node. + const ut = node.namedChildren.find((c) => c.type === "user_type"); + const ids = ut?.namedChildren.filter((c) => c.type === "type_identifier") ?? []; + name = ids.length ? ids[ids.length - 1]!.text : null; + } else { + name = typeName(); + } + if (!name) return null; + // An actor is class-like (reference semantics, methods) and there is no + // dedicated actor kind, so it takes "class". + const kind: Kind = + kw === "extension" ? "module" : kw === "struct" ? "struct" : kw === "enum" ? "enum" : "class"; + return { name, kind, headerEnd: headEnd("class_body", "enum_class_body"), hashNode: node }; + } + + if (node.type === "protocol_declaration") { + const name = typeName(); + if (!name) return null; + return { name, kind: "interface", headerEnd: headEnd("protocol_body"), hashNode: node }; + } + + // A protocol requirement (`protocol_function_declaration`) has no body and is + // always a member; an ordinary `function_declaration` is a method exactly when + // it is nested in a type (or an extension of one). + if (node.type === "function_declaration" || node.type === "protocol_function_declaration") { + const name = funcName(); + if (!name) return null; + const kind: Kind = + node.type === "protocol_function_declaration" || + SWIFT_TYPE_KINDS.has(ctx.enclosingKind ?? "file") + ? "method" + : "function"; + return { + name, + kind, + headerEnd: headEnd("function_body"), + hashNode: node, + ...swiftArity(node), + }; + } + + if (node.type === "init_declaration") { + // Initializers carry no name of their own — they are the type's own, so scope + // the node under the enclosing type the same way Java's constructor_declaration + // does. (A protocol's `init` requirement lands here too, owned by the protocol.) + if (!ctx.enclosingClass) return null; + return { + name: ctx.enclosingClass, + kind: "method", + headerEnd: headEnd("function_body"), + hashNode: node, + ...swiftArity(node), + }; + } + + if (node.type === "typealias_declaration") { + const name = typeName(); + if (!name) return null; + return { name, kind: "type", headerEnd: node.endIndex, hashNode: node }; + } + + if (node.type === "property_declaration") { + // Top-level `let`/`var` only — a stored/computed property inside a type is a + // field, not a definition node (no depth tier emits fields), so it must not + // become one. `deinit` and `subscript` are likewise skipped: neither is ever + // the target of a resolvable call edge, and neither carries a usable name. + if (ctx.enclosingKind !== null) return null; + const name = node.namedChildren + .find((c) => c.type === "pattern") + ?.namedChildren.find((c) => c.type === "simple_identifier")?.text; + if (!name) return null; + return { name, kind: "variable", headerEnd: node.endIndex, hashNode: node }; + } + + return null; +} + /** Java visibility: `public` (or `protected`) on the declaration's own modifier list. * A package-private or private member is not part of the API surface. Read off the * `modifiers` child's tokens, ignoring annotations, which live in the same node. */ @@ -1699,6 +1904,60 @@ function kotlinExported(node: Parser.SyntaxNode): boolean { return !vis || vis.text === "public"; } +/** Declared parameter count for a Swift callable, for overload disambiguation + * (the same role Java's `arity`/`argCount` pair plays). `parameter` nodes are + * direct children of the declaration; a default value's `=` sits as a SIBLING + * token after its parameter, and a variadic `...` sits inside its parameter. + * `arity` is the REQUIRED minimum (parameters minus defaults) and `variadic` + * marks any default or variadic parameter, so `narrowByArity`'s at-least + * semantics keeps every overload a call of that shape could reach. */ +function swiftArity(node: Parser.SyntaxNode): { arity: number; variadic?: boolean } { + const params = node.children.filter((c) => c.type === "parameter"); + const defaults = node.children.filter((c) => c.type === "=").length; + const hasVariadic = params.some((p) => p.children.some((c) => c.type === "...")); + const arity = Math.max(0, params.length - defaults); + return hasVariadic || defaults > 0 ? { arity, variadic: true } : { arity }; +} + +/** Argument count at a Swift call site: the `value_argument`s plus one for a + * trailing closure (`run(x) { … }` calls a two-parameter function). */ +function swiftArgCount(node: Parser.SyntaxNode): number | undefined { + const suffix = node.namedChildren.find((c) => c.type === "call_suffix"); + if (!suffix) return undefined; + const args = + suffix.namedChildren + .find((c) => c.type === "value_arguments") + ?.namedChildren.filter((c) => c.type === "value_argument").length ?? 0; + const trailing = suffix.namedChildren.some((c) => c.type === "lambda_literal") ? 1 : 0; + return args + trailing; +} + +/** Swift visibility: the default (`internal`) is module-wide, and a repo is + * typically one module — so `public` / `open` / `package` / `internal` all count + * as API surface, and only an explicit `private` / `fileprivate` hides a + * definition. A setter-only restriction (`private(set)`) leaves the getter + * visible, so it does not hide the symbol either. */ +function swiftExported(node: Parser.SyntaxNode): boolean { + const mods = node.namedChildren.find((c) => c.type === "modifiers"); + const vis = mods?.namedChildren.find((c) => c.type === "visibility_modifier"); + return !vis || (vis.text !== "private" && vis.text !== "fileprivate"); +} + +/** The superclass a Swift class declaration names: its FIRST inheritance + * specifier — Swift's grammar requires the superclass to precede any protocol + * in the `:` list, so when a superclass exists it is always this entry. A + * class conforming only to protocols yields that protocol's name instead, but + * `super` is illegal in such a class, so no call site ever consults it. Null + * for a bare `class Foo` (and for an extension, whose declaration carries no + * heritage for the original type — `super` inside one stays unresolved). */ +function swiftSuperClassName(node: Parser.SyntaxNode): string | null { + const spec = node.namedChildren.find((c) => c.type === "inheritance_specifier"); + const ids = spec?.namedChildren + .find((c) => c.type === "user_type") + ?.namedChildren.filter((c) => c.type === "type_identifier"); + return ids?.length ? ids[ids.length - 1]!.text : null; +} + /** The receiver's base type name for a Go method, unwrapping a pointer receiver * (`func (u *User) …` → `User`). Null if it can't be read. */ function goReceiverType(node: Parser.SyntaxNode): string | null { @@ -1786,6 +2045,25 @@ function heritageEdges(node: Parser.SyntaxNode, classId: string, ctx: WalkCtx): } return edges; } + if (ctx.lang === "swift") { + // `class A: B, C` — each `inheritance_specifier` (a direct child of the + // declaration; protocols and extensions carry them too) wraps a `user_type` + // whose LAST direct `type_identifier` is the bare supertype name: a + // module-qualified `Foundation.NSObject` reduces to `NSObject`, and generic + // arguments live in nested nodes so they never leak in. Swift cannot say + // syntactically whether a specifier is the superclass or a protocol + // conformance (that needs the target's kind), so every edge is `extends` — + // the same collapse Kotlin's delegation specifiers make. + for (const child of node.namedChildren) { + if (child.type !== "inheritance_specifier") continue; + const ids = child.namedChildren + .find((c) => c.type === "user_type") + ?.namedChildren.filter((c) => c.type === "type_identifier"); + const t = ids?.length ? ids[ids.length - 1] : undefined; + if (t) edges.push({ source: classId, relation: "extends", name: t.text, file: ctx.rel }); + } + return edges; + } if (ctx.lang === "python") { const supers = node.childForFieldName("superclasses"); // argument_list for (const c of supers?.namedChildren ?? []) { @@ -1948,6 +2226,46 @@ if (lang === "kotlin") { return null; } + if (lang === "swift") { + // Same shape as Kotlin's: `call_expression` = callee expression + `call_suffix`. + // A bare `foo()` names a plain call (this also covers `Animal()` initializer + // calls, which have no distinguishing syntax); `obj.foo()` is a + // `navigation_expression` whose trailing `navigation_suffix` holds the member + // name and whose head is the receiver. + const target = node.namedChildren[0]; + if (target?.type === "simple_identifier") return { name: target.text, viaMember: false }; + if (target?.type === "navigation_expression") { + const suffix = target.namedChildren.find((c) => c.type === "navigation_suffix"); + const name = suffix?.namedChildren.find((c) => c.type === "simple_identifier"); + const receiver = target.namedChildren[0]; + if (!name) return null; + if (receiver?.type === "simple_identifier") + return { name: name.text, viaMember: true, receiver: receiver.text }; + if (receiver?.type === "self_expression" || receiver?.type === "super_expression") + return { + name: name.text, + viaMember: true, + receiver: receiver.type === "self_expression" ? "self" : "super", + }; + if (receiver?.type === "navigation_expression") { + // `self.repo.save()` — one hop off self is a field access and binds like + // TS's `this.x`. Deeper chains and call-result receivers carry no + // confident local clue, so those fall through with no receiver. + const head = receiver.namedChildren[0]; + const field = receiver.namedChildren + .find((c) => c.type === "navigation_suffix") + ?.namedChildren.find((c) => c.type === "simple_identifier"); + if (head?.type === "self_expression" && field) + return { name: name.text, viaMember: true, receiver: `self.${field.text}` }; + } + // Still a member call even with an unknowable receiver (a chained call, a + // literal, a subscript): recvType stays unset and resolve drops it rather + // than guessing — same contract as Java's and TS's unknown receivers. + return { name: name.text, viaMember: true }; + } + return null; + } + if (lang === "php") return phpCallee(node); const fn = node.childForFieldName("function"); @@ -2162,6 +2480,7 @@ function isImport(node: Parser.SyntaxNode, lang: Language): boolean { } if (lang === "java") return node.type === "import_declaration"; if (lang === "kotlin") return node.type === "import_header"; + if (lang === "swift") return node.type === "import_declaration"; // PHP: one edge per imported symbol — the clause leaf inside a (possibly // grouped) `use A\B, C\D;` / `use A\{B, C};` declaration. if (lang === "php") return node.type === "namespace_use_clause"; @@ -2207,6 +2526,14 @@ function importSpecifier(node: Parser.SyntaxNode, lang: Language): string | null // identifier text is already the module path (wildcards dropped, like Java). return node.namedChildren.find((c) => c.type === "identifier")?.text ?? null; } + if (lang === "swift") { + // `import UIKit` / `import struct Foundation.Date` — the dotted path is the + // `identifier` child (an import-kind keyword like `struct` is a separate + // token). Swift imports name MODULES, not files, so the specifier resolves + // to a repo file only when a same-named module target exists; external + // frameworks stay as unresolved (but truthful) import intents. + return node.namedChildren.find((c) => c.type === "identifier")?.text ?? null; + } const str = node.namedChildren.find((c) => c.type === "string"); if (!str) return null; const frag = str.namedChildren.find((c) => c.type === "string_fragment"); diff --git a/src/graph/generic.ts b/src/graph/generic.ts index 26fec6d9b..85583974a 100644 --- a/src/graph/generic.ts +++ b/src/graph/generic.ts @@ -50,7 +50,6 @@ export const GENERIC_LANGS: readonly GenericLang[] = [ // These ship a tags.scm (calls + symbols); ocaml/zig have none and use the // node-kind walker fallback (symbols only) — still one row, zero query. { name: "scala", exts: [".scala", ".sc"], wasm: "scala" }, - { name: "swift", exts: [".swift"], wasm: "swift" }, { name: "elixir", exts: [".ex", ".exs"], wasm: "elixir" }, { name: "solidity", exts: [".sol"], wasm: "solidity" }, { name: "ocaml", exts: [".ml", ".mli"], wasm: "ocaml" }, diff --git a/src/graph/resolve.ts b/src/graph/resolve.ts index 00b70136d..c78a61e6c 100644 --- a/src/graph/resolve.ts +++ b/src/graph/resolve.ts @@ -27,6 +27,13 @@ const PY_EXT = /\.pyi?$/i; * construction. Only `class` — Python enums, dataclasses and NamedTuples are all * classes, so no other kind is reachable this way. */ const PY_CTOR_KINDS: Kind[] = ["class"]; +/** Swift is Python's case with more nominal kinds: `Animal(legs: 4)` is an ordinary + * call node with no `new` to mark construction, and struct/enum initializers are as + * routine as class ones (a struct gets a memberwise init for free). Same fallback + * shape — types are tried only once functions (and methods, see extract.ts's + * implicit-self widening) have found nothing. */ +const SWIFT_EXT = /\.swift$/i; +const SWIFT_CTOR_KINDS: Kind[] = ["class", "struct", "enum"]; /** * Languages whose symbols can genuinely reach each other. A call edge may not @@ -265,12 +272,24 @@ export function resolveEdges( if (!e.recvType) continue; const hit = resolveTypedMember(e.recvType, e.name!, e.file, ownerMethod, classParents, classTraits, e.argCount); if (hit === "ambiguous") continue; // drop — never guess past an ambiguous owner - if (hit) add(e.source, hit.id, "calls", hit.confidence); + if (hit) { + add(e.source, hit.id, "calls", hit.confidence); + continue; + } // No owner-qualified match means the call is unresolved. A unique bare // method name is not evidence that this receiver has that method — a // name-fallback here was measured to HALVE call-edge precision (73%→37% // vs a compiler-grade oracle) for a 3x count inflation, i.e. noise. See #35. - continue; + // + // One carve-out, which is NOT that fallback: a Swift `implicitSelf` edge + // carries two readings of one bare call — member (tried above, in + // Swift's own inner-scope-first order) and free function. Zero members + // on the whole owner chain means the call was a free-function call after + // all, so it falls through to bare-name resolution; an ambiguous member + // set has already dropped it above, and a resolved member never reaches + // here — a name defined as both member and free function yields the + // member edge alone, exactly as Swift dispatches it. + if (!e.implicitSelf) continue; } // Every language's bare-name call is a free function, except R (Phase 4): // an untyped `obj$method()` there sets e.kinds to also allow a "method" @@ -308,6 +327,9 @@ export function resolveEdges( if (!hit && PY_EXT.test(e.file)) { hit = resolveName(e.name!, e.file, PY_CTOR_KINDS, perFileName, globalName); } + if (!hit && SWIFT_EXT.test(e.file)) { + hit = resolveName(e.name!, e.file, SWIFT_CTOR_KINDS, perFileName, globalName); + } if (hit) add(e.source, hit.id, "calls", hit.confidence); // drop unresolved calls (too noisy) } } @@ -413,6 +435,12 @@ function resolveTypedMember( const c = candidates[0]; return { id: c.id, confidence: c.path === file ? "extracted" : "inferred" }; } + // Swift: several candidates surviving arity narrowing are a genuine + // overload set distinguished only by parameter TYPES (`save(Int)` vs + // `save(String)`), which this pass cannot read — the same-file tiebreak + // below would pick whichever overload appears first in the file and + // stamp it `extracted`, a confidently wrong edge. Drop instead. + if (SWIFT_EXT.test(file)) return "ambiguous"; const sameFile = candidates.find((c) => c.path === file); if (sameFile) return { id: sameFile.id, confidence: "extracted" }; return "ambiguous"; // several, none same-file — drop and stop diff --git a/test/graph-languages.test.ts b/test/graph-languages.test.ts index 4fc1e242a..6b28daea6 100644 --- a/test/graph-languages.test.ts +++ b/test/graph-languages.test.ts @@ -30,6 +30,7 @@ const INDEXED = [ "a.R", "a.r", "a.java", "a.kt", "a.kts", + "a.swift", "a.php", ]; @@ -38,6 +39,7 @@ test("a file is labelled exactly when it is indexed", () => { // adding an extension cannot teach the extractor about it and leave the banner // silent (or vice versa — claiming coverage of a file nothing can parse). for (const f of [...INDEXED, "a.txt", "a.rs", "README.md", "noextension", ""]) { + // (".rs" stays a null case here: Rust is breadth-tier, which this table doesn't cover.) assert.equal( languageOf(f) === null, languageLabelOf(f) === null, @@ -62,6 +64,7 @@ test("labels name the language, not the grammar that parses it", () => { assert.equal(languageLabelOf("src/main/java/com/acme/App.java"), "java"); assert.equal(languageLabelOf("src/main/kotlin/com/acme/App.kt"), "kotlin"); assert.equal(languageLabelOf("scripts/main.kts"), "kotlin"); + assert.equal(languageLabelOf("Sources/App/main.swift"), "swift"); assert.equal(languageLabelOf("app/Models/User.php"), "php"); // The grammar is unchanged — extraction, the extract cache and every `Language` @@ -86,9 +89,10 @@ test("the build banner and repo map report every language they indexed", async ( writeFileSync(join(d, "scripts", "web.jsx"), "export function JsxOnly() {\n return null;\n}\n"); writeFileSync(join(d, "src", "c.py"), "def py_only():\n return 1\n"); writeFileSync(join(d, "src", "a.kt"), "fun ktOnly(): Int {\n return 1\n}\n"); + writeFileSync(join(d, "src", "a.swift"), "func swiftOnly() -> Int {\n return 1\n}\n"); const r = await buildGraph(d); - assert.deepEqual(r.languages, ["javascript", "jsx", "kotlin", "python", "tsx", "typescript"]); + assert.deepEqual(r.languages, ["javascript", "jsx", "kotlin", "python", "swift", "tsx", "typescript"]); // The reported symbol was queryable all along — that mismatch is what the issue was // about, so pin both halves together. diff --git a/test/graph-swift.test.ts b/test/graph-swift.test.ts new file mode 100644 index 000000000..974330da6 --- /dev/null +++ b/test/graph-swift.test.ts @@ -0,0 +1,513 @@ +/** + * Full-fidelity Swift extraction (the depth tier), promoted from the generic + * breadth tier the same way Kotlin was (#130). + * + * What these pin, and why each matters: + * - the kind split the breadth tier could not make: `class_declaration` is ONE + * node type for `class` / `struct` / `enum` / `actor` / `extension`, told + * apart only by their own keyword token — the tags query labelled all of + * them "class"; + * - extension member attribution, Swift's biggest structural quirk: a method + * declared in `extension Point` must belong to Point (owner, enclosingClass, + * member-call resolution), not to a node named "extension"; + * - call edges at all — the breadth tier's swift query had NO @reference.call + * captures, so every Swift repo had symbols and zero wiring. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildGraph } from "../src/graph/build.js"; +import { readGraph, wiringPath } from "../src/graph/write.js"; +import { extractFile, type RawEdge } from "../src/graph/extract.js"; +import type { GraphV1, NodeV1 } from "../src/graph/types.js"; + +function extract(source: string): { nodes: NodeV1[]; rawEdges: RawEdge[] } { + return extractFile("app/Model.swift", source, "swift"); +} + +function byName(nodes: NodeV1[], name: string): NodeV1[] { + return nodes.filter((n) => n.name === name); +} + +test("swift: the five class_declaration keywords map to their real kinds", () => { + const { nodes } = extract(` +class Animal {} +struct Point {} +enum Direction {} +actor Counter {} +protocol Greeter {} +typealias Distance = Double +`); + assert.equal(byName(nodes, "Animal")[0]?.kind, "class"); + assert.equal(byName(nodes, "Point")[0]?.kind, "struct"); + assert.equal(byName(nodes, "Direction")[0]?.kind, "enum"); + assert.equal(byName(nodes, "Counter")[0]?.kind, "class"); // actor: class-like, no dedicated kind + assert.equal(byName(nodes, "Greeter")[0]?.kind, "interface"); + assert.equal(byName(nodes, "Distance")[0]?.kind, "type"); +}); + +test("swift: functions are methods inside a type, functions at top level", () => { + const { nodes } = extract(` +func helper() {} + +struct Point { + func norm() -> Double { return 0 } +} + +protocol Greeter { + func greet(name: String) -> String +} +`); + assert.equal(byName(nodes, "helper")[0]?.kind, "function"); + const norm = byName(nodes, "norm")[0]; + assert.equal(norm?.kind, "method"); + assert.equal(norm?.owner, "Point"); + const greet = byName(nodes, "greet")[0]; + assert.equal(greet?.kind, "method"); + assert.equal(greet?.owner, "Greeter"); +}); + +test("swift: an initializer is a method named after its type", () => { + const { nodes } = extract(` +class Animal { + init(legs: Int) {} +} +`); + const inits = byName(nodes, "Animal").filter((n) => n.kind === "method"); + assert.equal(inits.length, 1); + assert.equal(inits[0]?.owner, "Animal"); +}); + +test("swift: extension members belong to the extended type", () => { + const { nodes, rawEdges } = extract(` +struct Point { + var x = 0.0 +} + +extension Point { + func scaled(by f: Double) -> Point { return helper() } +} +`); + const scaled = byName(nodes, "scaled")[0]; + assert.equal(scaled?.kind, "method"); + assert.equal(scaled?.owner, "Point", "an extension method's owner is the extended type"); + // The extension node itself is a member-contributing scope, not a second type + // declaration — kind "module", so the real `struct Point` stays the unique + // type named Point (see the resolution test below for what that protects). + assert.deepEqual( + byName(nodes, "Point").map((n) => n.kind).sort(), + ["module", "struct"], + ); + // The call inside the extension method attributes to the method, not the file. + const call = rawEdges.find((e) => e.relation === "calls" && e.name === "helper"); + assert.ok(call?.source.includes("scaled"), "call attributed to the extension method"); +}); + +test("swift: call edges — bare, member with receiver, self and super", () => { + const { rawEdges } = extract(` +class Animal { + func greet() {} + func walk() { + self.greet() + super.describe() + helper() + } +} + +func helper() { + let a = Animal() + a.walk() +} +`); + const calls = rawEdges.filter((e) => e.relation === "calls"); + const selfCall = calls.find((e) => e.name === "greet"); + assert.equal(selfCall?.viaMember, true); + assert.equal(selfCall?.recvType, "Animal", "`self` resolves to the enclosing type"); + const superCall = calls.find((e) => e.name === "describe"); + assert.equal(superCall?.viaMember, true); + // A bare lowercase call in a type body is ONE edge carrying both readings: + // the member reading (typed to the enclosing class, tried first — Swift's + // inner-scope-first lookup) with `implicitSelf` marking the free-function + // fallback resolve.ts may take when no member exists on the owner chain. + const helperCalls = calls.filter((e) => e.name === "helper"); + assert.equal(helperCalls.length, 1, "one edge, not one per reading"); + assert.equal(helperCalls[0]?.viaMember, true); + assert.equal(helperCalls[0]?.recvType, "Animal"); + assert.equal(helperCalls[0]?.implicitSelf, true); + const member = calls.find((e) => e.name === "walk"); + assert.equal(member?.viaMember, true); +}); + +test("swift: trailing-closure bodies still yield the calls inside them", () => { + const { rawEdges } = extract(` +func helper() { + run { done() } +} +`); + const calls = rawEdges.filter((e) => e.relation === "calls").map((e) => e.name); + assert.ok(calls.includes("done"), "call inside a trailing closure is captured"); +}); + +test("swift: inheritance clause yields extends edges with bare names", () => { + const { rawEdges } = extract(` +import Foundation + +class Animal: Foundation.NSObject, Greeter {} +protocol Greeter: AnyObject {} +`); + const ext = rawEdges.filter((e) => e.relation === "extends").map((e) => e.name); + assert.deepEqual(ext.sort(), ["AnyObject", "Greeter", "NSObject"]); +}); + +test("swift: imports become module-path import edges", () => { + const { rawEdges } = extract(` +import UIKit +import struct Foundation.Date +`); + const imports = rawEdges.filter((e) => e.relation === "imports").map((e) => e.specifier); + assert.deepEqual(imports.sort(), ["Foundation.Date", "UIKit"]); +}); + +test("swift: visibility — only private/fileprivate hide a symbol", () => { + const { nodes } = extract(` +public func a() {} +open class B {} +internal func c() {} +func d() {} +private func e() {} +fileprivate func f() {} + +public class G { + private(set) var count = 0 + private func hidden() {} +} +`); + for (const name of ["a", "B", "c", "d", "G"]) { + assert.equal(byName(nodes, name)[0]?.exported, true, `${name} exported`); + } + for (const name of ["e", "f", "hidden"]) { + assert.equal(byName(nodes, name)[0]?.exported, false, `${name} hidden`); + } +}); + +test("swift: top-level let/var are variables; type properties are not nodes", () => { + const { nodes } = extract(` +let topLevel = 42 +private var hidden = 1 + +class Animal { + var legs = 4 +} +`); + assert.equal(byName(nodes, "topLevel")[0]?.kind, "variable"); + assert.equal(byName(nodes, "topLevel")[0]?.exported, true); + assert.equal(byName(nodes, "hidden")[0]?.exported, false); + assert.equal(byName(nodes, "legs").length, 0, "a stored property is a field, not a node"); +}); + +/** Resolution-level pins (extract → resolve, through buildGraph): the three call + * shapes whose per-language handling is easy to silently lose — implicit-self + * member calls, free-function calls FROM a method, and initializer calls, which + * in Swift are ordinary call nodes with no `new` to mark them (Python's case, + * with struct/enum initializers as routine as class ones). */ +async function buildSwift(files: Record): Promise { + const d = mkdtempSync(join(tmpdir(), "graft-swift-")); + mkdirSync(join(d, "Sources"), { recursive: true }); + for (const [name, src] of Object.entries(files)) writeFileSync(join(d, "Sources", name), src); + const r = await buildGraph(d); + const graph = readGraph(wiringPath(r.contextDir)); + assert.ok(graph, "graph built"); + return graph!; +} + +function callEdges(graph: GraphV1): Array<{ from: string; to: string }> { + const name = (id: string): string => graph.nodes.find((n) => n.id === id)?.name ?? id; + return graph.edges + .filter((e) => e.relation === "calls") + .map((e) => ({ from: name(e.source), to: name(e.target) })); +} + +test("swift: implicit-self member calls resolve, and so do free-function calls from a method", async () => { + const graph = await buildSwift({ + "Animal.swift": ` +class Animal { + func greet() {} + func walk() { + greet() + helper() + } +} + +func helper() {} +`, + }); + const calls = callEdges(graph); + assert.ok( + calls.some((c) => c.from === "walk" && c.to === "greet"), + "implicit-self call resolves to the sibling method", + ); + assert.ok( + calls.some((c) => c.from === "walk" && c.to === "helper"), + "a real free-function call from the same method still resolves", + ); +}); + +test("swift: initializer calls resolve to the constructed type (class and struct)", async () => { + const graph = await buildSwift({ + "Model.swift": ` +class Animal {} +struct Point {} +`, + "Main.swift": ` +func make() { + let a = Animal() + let p = Point() +} +`, + }); + const calls = callEdges(graph); + assert.ok(calls.some((c) => c.from === "make" && c.to === "Animal"), "class initializer edge"); + assert.ok(calls.some((c) => c.from === "make" && c.to === "Point"), "struct initializer edge"); +}); + +test("swift: an extension does not make its type's name ambiguous", async () => { + // The regression this pins: an `extension Animal` minted as a second CLASS named + // Animal made every `Animal()` initializer call and `: Animal` heritage target + // ambiguous, so they dropped. As kind "module" the extension stays out of + // type-name resolution — and its methods still resolve as Animal's. + const graph = await buildSwift({ + "Animal.swift": ` +class Animal { + init(legs: Int) {} + func walk() {} +} + +extension Animal { + func run() { walk() } +} +`, + "Main.swift": ` +class Dog: Animal {} + +func make() { + let a = Animal(legs: 4) +} +`, + }); + const calls = callEdges(graph); + assert.ok(calls.some((c) => c.from === "make" && c.to === "Animal"), "initializer resolves"); + assert.ok(calls.some((c) => c.from === "run" && c.to === "walk"), "extension method wired in"); + const dog = graph.nodes.find((n) => n.name === "Dog"); + assert.ok( + graph.edges.some( + (e) => + e.relation === "extends" && + e.source === dog?.id && + graph.nodes.find((n) => n.id === e.target)?.kind === "class", + ), + "heritage resolves to the class declaration, not the extension", + ); +}); + +test("swift: receiver-typed member calls resolve through bindings", async () => { + // The four binding clues, each feeding resolveRecvType a receiver type: + // an initializer-call local, a typed parameter, a field (bare and via self.), + // and a type-member (static) call on the type's own uppercase name. + const graph = await buildSwift({ + "Zoo.swift": ` +class Repo { + func save() {} +} + +class Keeper { + func wave() {} +} + +class Vet { + func check() {} +} + +class Animal { + func eat() {} + static func census() {} +} + +class Zoo { + var keeper: Keeper + let repo = Repo() + init(keeper k: Keeper) { self.keeper = k } + func feed(animal: Animal) { + let vet = Vet() + vet.check() + animal.eat() + keeper.wave() + self.repo.save() + Animal.census() + } +} +`, + }); + const calls = callEdges(graph); + const from = calls.filter((c) => c.from === "feed").map((c) => c.to).sort(); + // "Vet" is the `Vet()` initializer call itself, resolved by the ctor fallback. + assert.deepEqual(from, ["Vet", "census", "check", "eat", "save", "wave"]); +}); + +test("swift: a typed receiver reaches methods declared in an extension", async () => { + const graph = await buildSwift({ + "Point.swift": ` +struct Point {} + +extension Point { + func scaled() {} +} +`, + "Main.swift": ` +func use() { + let p = Point() + p.scaled() +} +`, + }); + const calls = callEdges(graph); + assert.ok( + calls.some((c) => c.from === "use" && c.to === "scaled"), + "extension method resolves via the receiver's bound type", + ); +}); + +test("swift: implicit-self resolves through the ancestor chain, never to unrelated types", async () => { + // Two halves of one rule, pinned from real code (swift-composable-architecture): + // a bare call in a subclass resolves to the method it inherits, cross-file — + // but a stdlib call inside an extension (`contains` in `extension Set`) must + // NOT bind to an unrelated type's same-named method just because that name is + // unique in the repo. + const graph = await buildSwift({ + "Base.swift": ` +class BaseTests { + func clearLogs() {} +} +`, + "Child.swift": ` +class NavTests: BaseTests { + func testDeep() { clearLogs() } +} +`, + "Unrelated.swift": ` +class Syntax { + func contains(_ s: String) -> Bool { return false } +} +`, + "SetExt.swift": ` +extension Set { + func hasAny() -> Bool { return contains(where: { _ in true }) } +} +`, + }); + const calls = callEdges(graph); + assert.ok( + calls.some((c) => c.from === "testDeep" && c.to === "clearLogs"), + "inherited method resolves via the in-repo ancestor chain", + ); + assert.ok( + !calls.some((c) => c.from === "hasAny"), + "a stdlib call in an extension resolves to nothing, not to Syntax.contains", + ); +}); + +test("swift: a name that is both a member and a free function resolves member-first", async () => { + // Swift's lookup is inner-scope-first: inside A, `helper()` is A's method, not + // the global function — the graph must carry exactly that one edge, and a + // caller OUTSIDE the type must get the free function. + const graph = await buildSwift({ + "A.swift": ` +func helper() {} + +class A { + func helper() {} + func go() { helper() } +} + +func outside() { helper() } +`, + }); + const byId = new Map(graph.nodes.map((n) => [n.id, n])); + const goCalls = graph.edges.filter( + (e) => e.relation === "calls" && byId.get(e.source)?.name === "go", + ); + assert.equal(goCalls.length, 1, "one edge from go, not one per reading"); + assert.equal(byId.get(goCalls[0]!.target)?.kind, "method", "go calls the MEMBER helper"); + const outsideCalls = graph.edges.filter( + (e) => e.relation === "calls" && byId.get(e.source)?.name === "outside", + ); + assert.equal(byId.get(outsideCalls[0]?.target ?? "")?.kind, "function", "outside calls the free function"); +}); + +test("swift: overloads — distinct arity resolves, equal arity drops", async () => { + const graph = await buildSwift({ + "Store.swift": ` +class Store { + func save(_ n: Int) {} + func save(_ s: String) {} + func load() {} + func load(_ n: Int) {} +} +`, + "Main.swift": ` +func use(store: Store) { + store.save("x") + store.load(1) +} +`, + }); + const byId = new Map(graph.nodes.map((n) => [n.id, n])); + const calls = graph.edges.filter( + (e) => e.relation === "calls" && byId.get(e.source)?.name === "use", + ); + const saveTargets = calls.filter((e) => byId.get(e.target)?.name === "save"); + assert.equal(saveTargets.length, 0, "equal-arity overloads drop instead of picking the first"); + const loadTargets = calls.filter((e) => byId.get(e.target)?.name === "load"); + assert.equal(loadTargets.length, 1, "distinct-arity overload resolves"); + assert.equal(byId.get(loadTargets[0]!.target)?.arity, 1, "…to the one-parameter overload"); +}); + +test("swift: super.method() resolves to the parent class's method", async () => { + const graph = await buildSwift({ + "Base.swift": ` +class Base { + func ping() {} +} +`, + "Child.swift": ` +class Child: Base { + override func ping() { super.ping() } +} +`, + }); + const byId = new Map(graph.nodes.map((n) => [n.id, n])); + const superCall = graph.edges.find( + (e) => + e.relation === "calls" && + byId.get(e.source)?.id.includes("Child") && + byId.get(e.target)?.name === "ping", + ); + assert.ok(superCall, "super call resolves"); + assert.equal(byId.get(superCall!.target)?.owner, "Base", "…to the PARENT's ping, not the override"); +}); + +test("swift: an ambiguous implicit-self widening drops rather than guesses", async () => { + // `save` is a method on TWO classes in different files; a bare `save()` inside a + // third class matches neither uniquely, so no edge may be minted. + const graph = await buildSwift({ + "A.swift": `class A { func save() {} }`, + "B.swift": `class B { func save() {} }`, + "C.swift": `class C { func work() { save() } }`, + }); + const calls = callEdges(graph); + assert.ok( + !calls.some((c) => c.from === "work"), + "ambiguous bare name resolves to nothing, never a guess", + ); +}); diff --git a/test/supported-extensions.test.ts b/test/supported-extensions.test.ts index 8ce9a8ddc..68862a92a 100644 --- a/test/supported-extensions.test.ts +++ b/test/supported-extensions.test.ts @@ -16,9 +16,9 @@ import { supportedExtensions, unsupportedExtensions } from "../src/graph/source- test("supportedExtensions covers both tiers, sorted and de-duped", () => { const exts = supportedExtensions(); // depth tier - for (const e of [".ts", ".tsx", ".py", ".go", ".java", ".js", ".php", ".kt", ".kts"]) assert.ok(exts.includes(e), `depth ${e}`); + for (const e of [".ts", ".tsx", ".py", ".go", ".java", ".js", ".php", ".kt", ".kts", ".swift"]) assert.ok(exts.includes(e), `depth ${e}`); // breadth tier - for (const e of [".rs", ".rb", ".c", ".cpp", ".swift"]) assert.ok(exts.includes(e), `breadth ${e}`); + for (const e of [".rs", ".rb", ".c", ".cpp"]) assert.ok(exts.includes(e), `breadth ${e}`); // container tier assert.ok(exts.includes(".vue"), "container .vue"); // de-duped (.java is in BOTH tiers but must appear once) and sorted