From 77b0afc10943e56bda26f4ab3cc900120098f553 Mon Sep 17 00:00:00 2001 From: matt strayer Date: Mon, 24 Aug 2026 13:39:42 -0400 Subject: [PATCH 1/4] feat(graph): full-fidelity Swift extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move Swift (.swift) from the generic breadth tier — whose tags query had no call captures, so Swift repos indexed symbols with zero wiring — to a hand-written tree-sitter extractor, following Kotlin's promotion (#130). tree-sitter-swift folds class/struct/enum/actor/extension into one class_declaration node type, told apart by their own keyword; describeSwift rekinds them (actor → class), protocols become interfaces, typealias a type, and top-level let/var variables. An extension takes the extended type's name so its members mint as that type's methods, but the node itself is kind "module" — a second same-named class would make every initializer call and heritage target ambiguous and drop. init becomes a method named after its type, like a Java constructor. Calls resolve via call_expression with navigation/self/super receivers. A bare call inside a type body may be an implicit-`self` member call, so it may also match a method (R Phase 4's widening; unique matches only). An initializer call — 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, import declarations yield module-path import edges, and only private/fileprivate hide a symbol: Swift's default `internal` is module-wide, which for a one-module repo is the API surface. Dependency: tree-sitter-swift ^0.7.1 — the first release whose install compiles the shipped parser instead of regenerating it via tree-sitter-cli, which broke under npm's hoisting. Its tree-sitter peer wants ^0.22, so a root `overrides` entry pins it to the repo's 0.21 runtime for dev installs; transitive (end-user) installs resolve cleanly without it (verified). Known gap, shared with Kotlin: untyped receivers (`a.run()`) have no bindings collector yet, so those member calls stay unresolved rather than guessed. --- CHANGELOG.md | 35 ++++ README.md | 12 +- package-lock.json | 56 ++++++ package.json | 9 +- src/graph/extract.ts | 228 ++++++++++++++++++++- src/graph/generic.ts | 1 - src/graph/resolve.ts | 10 + test/graph-languages.test.ts | 6 +- test/graph-swift.test.ts | 320 ++++++++++++++++++++++++++++++ test/supported-extensions.test.ts | 4 +- 10 files changed, 665 insertions(+), 16 deletions(-) create mode 100644 test/graph-swift.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a54410bcd..505572f4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,40 @@ # Changelog +## 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 call inside a type body may be an + implicit-`self` member call, so it may also match a method (R Phase 4's + widening — unique matches only, ambiguity still drops); 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. Known gap (shared with Kotlin): an untyped receiver + (`a.run()`) has no bindings collector yet, so those member calls stay + unresolved rather than guessed. + ## 0.13.0 ### Added diff --git a/README.md b/README.md index d75d23582..2e55ccab6 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 a6a21585e..c53546ef9 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.4", "vscode-jsonrpc": "^8.2.1", @@ -731,6 +732,12 @@ "node": ">=0.10.0" } }, + "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/js-yaml": { "version": "3.15.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", @@ -859,6 +866,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", @@ -1005,6 +1025,27 @@ } } }, + "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-typescript": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/tree-sitter-typescript/-/tree-sitter-typescript-0.23.2.tgz", @@ -1124,6 +1165,21 @@ "optional": true } } + }, + "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" + } } } } diff --git a/package.json b/package.json index abf73454e..39558e60c 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.4", "vscode-jsonrpc": "^8.2.1", @@ -92,6 +93,12 @@ "fsevents@2.3.3": true, "tree-sitter-kotlin@0.3.8": true, "tree-sitter-php@0.23.12": true, - "tree-sitter-r@1.3.0": true + "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/extract.ts b/src/graph/extract.ts index e53c5371b..8631394c2 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`. @@ -217,6 +219,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 +264,7 @@ const KINDS_BY_LANG: Record> = { r: R_KINDS, java: JAVA_KINDS, kotlin: KOTLIN_KINDS, + swift: SWIFT_KINDS, php: PHP_KINDS, }; @@ -256,6 +284,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 +312,7 @@ const GRAMMARS: Record = { r: R, java: Java, kotlin: Kotlin, + swift: Swift, php: PHP.php, }; @@ -561,9 +591,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,12 +612,13 @@ 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)); const enclosingClass = - desc.kind === "class" || javaTypeDecl || kotlinTypeDecl + desc.kind === "class" || javaTypeDecl || kotlinTypeDecl || swiftTypeDecl ? desc.name : isGoMethod ? goReceiverType(node) @@ -683,6 +716,14 @@ function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEd // Java only: the call site's argument count, to pick the right overload. const argCount = ctx.lang === "java" ? javaArgCount(node) : undefined; if (argCount !== undefined) callEdge.argCount = argCount; + // Swift: a bare call inside a type body may be an implicit-`self` member + // call (`walk()` for `self.walk()`), syntactically indistinguishable from + // a free-function call. Widen the match to methods — R Phase 4's exact + // move, and with its exact safety: resolve.ts still requires a unique + // match, so an ambiguous function-vs-method name drops, never guesses. + if (ctx.lang === "swift" && !callee.viaMember && !callee.kinds && ctx.enclosingClass) { + callEdge.kinds = ["function", "method"]; + } const recvType = resolveRecvType(callee.receiver, ctx); edges.push(recvType ? { ...callEdge, recvType } : callEdge); } @@ -997,6 +1038,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 @@ -1650,6 +1692,116 @@ 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 }; + } + + 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 }; + } + + 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. */ @@ -1668,6 +1820,17 @@ function kotlinExported(node: Parser.SyntaxNode): boolean { return !vis || vis.text === "public"; } +/** 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 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 { @@ -1755,6 +1918,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 ?? []) { @@ -1917,6 +2099,31 @@ 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", + }; + } + return null; + } + if (lang === "php") return phpCallee(node); const fn = node.childForFieldName("function"); @@ -2131,6 +2338,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"; @@ -2176,6 +2384,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 6ab414a5b..4cef8fa44 100644 --- a/src/graph/resolve.ts +++ b/src/graph/resolve.ts @@ -26,6 +26,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"]; /** A Go module discovered in the repo: its `module` path from `go.mod` and the repo * directory that `go.mod` lives in (posix, `.` for the repo root). A monorepo may hold @@ -243,6 +250,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) } } 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..6f9eeb74b --- /dev/null +++ b/test/graph-swift.test.ts @@ -0,0 +1,320 @@ +/** + * 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); + const bare = calls.find((e) => e.name === "helper"); + assert.equal(bare?.viaMember, false); + 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: 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 From cae05ec27b8c80824ff6c4887a9800c6add7283b Mon Sep 17 00:00:00 2001 From: matt strayer Date: Mon, 24 Aug 2026 14:17:33 -0400 Subject: [PATCH 2/4] feat(graph): Swift receiver-type bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the untyped-receiver gap the Swift promotion shipped with: a collectBindings pass for Swift, from the confident, syntax-local clues the other languages already trust. Typed parameters (`func feed(animal: Animal)`, the last simple_identifier before the `:` is the local name — the first may be an external argument label), typed properties (`var d: Doctor`, one level of optional unwrapped), and initializer-call assignments (`let vet = Vet()` — an UpperCamelCase callee names the constructed type, the same convention trust as Go's `NewX`). A property directly inside a type body is a field, bound at the type's scope both bare and `self.`-prefixed, like Java's fields. calleeName gains the `self.repo.save()` shape (one hop off self binds like TS's `this.x`) and now emits member calls with unknowable receivers instead of dropping them at extract time — recvType stays unset and resolve drops them, the same contract as Java's and TS's unknown receivers. A type-member call (`Animal.census()`) resolves via an uppercase-receiver fallback in resolveRecvType, mirroring PHP's scoped-call rule; a shadowing local binding is tried first. swiftDefName mirrors describeSwift's scope segments (extensions named after the extended type, inits after their enclosing type) so the two scope stacks stay in lockstep, per bindings.ts's existing no-value-import contract. --- CHANGELOG.md | 10 +++- src/graph/bindings.ts | 112 +++++++++++++++++++++++++++++++++++++++ src/graph/extract.ts | 15 ++++++ test/graph-swift.test.ts | 67 +++++++++++++++++++++++ 4 files changed, 202 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 505572f4e..32046e606 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,8 +31,14 @@ 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. Known gap (shared with Kotlin): an untyped receiver - (`a.run()`) has no bindings collector yet, so those member calls stay + 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. ## 0.13.0 diff --git a/src/graph/bindings.ts b/src/graph/bindings.ts index a9bb80c11..b61a95dc0 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(["->", "->>"]); @@ -183,6 +216,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 +230,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 +304,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 +343,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 8631394c2..f6db933cd 100644 --- a/src/graph/extract.ts +++ b/src/graph/extract.ts @@ -2120,6 +2120,21 @@ if (lang === "kotlin") { 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; } diff --git a/test/graph-swift.test.ts b/test/graph-swift.test.ts index 6f9eeb74b..658e6a330 100644 --- a/test/graph-swift.test.ts +++ b/test/graph-swift.test.ts @@ -304,6 +304,73 @@ func make() { ); }); +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: 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. From 7c9f129c6598830c0df1885585b2adb63ad5a9c1 Mon Sep 17 00:00:00 2001 From: matt strayer Date: Mon, 24 Aug 2026 14:27:12 -0400 Subject: [PATCH 3/4] fix(swift): resolve implicit-self calls via the ancestor chain, not global name uniqueness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dogfooding the Swift extractor on Alamofire (108 files) and swift-composable-architecture (796 files) caught two false-positive classes in the bare-call kind widening: - An UpperCamelCase bare call is an initializer, and matching it against methods bound every `Text("hi")` (SwiftUI's own init) to an unrelated in-repo `extension Text { init(template:) }` that happened to be the repo's only method named Text. - A stdlib call inside an extension (`contains(element)` in `extension Set`) bound to an unrelated type's only in-repo `contains`. Replace the widening: a bare lowercase call in a type body now emits a second intent — viaMember, typed to the enclosing class — which resolves only through the owner-qualified method index and the class's in-repo ancestor chain, and drops otherwise. The plain intent still resolves genuine free-function calls, and initializer calls take the class/struct/ enum fallback, which extension nodes (kind "module") can never false-match. On the two corpora after the fix: both false-positive classes are gone, 280 inherited `stored(...)` calls in Alamofire's tests and 144 `clearLogs`/`assertLogs` calls in TCA's resolve to their base-class methods via the chain, both graphs build 100% of files, and `graft check` reports OK (deterministic) on both. --- CHANGELOG.md | 10 +++++--- src/graph/extract.ts | 29 +++++++++++++++++------ test/graph-swift.test.ts | 50 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 77 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32046e606..f948a0e24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,9 +21,13 @@ 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 call inside a type body may be an - implicit-`self` member call, so it may also match a method (R Phase 4's - widening — unique matches only, ambiguity still drops); an initializer call + / `self` / `super` receivers; a bare lowercase call inside a type body may + be an implicit-`self` member call, so it emits a second intent typed to the + enclosing class — resolved only through the owner-qualified index and 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); 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` diff --git a/src/graph/extract.ts b/src/graph/extract.ts index f6db933cd..bef19bd5a 100644 --- a/src/graph/extract.ts +++ b/src/graph/extract.ts @@ -716,13 +716,28 @@ function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEd // Java only: the call site's argument count, to pick the right overload. const argCount = ctx.lang === "java" ? javaArgCount(node) : undefined; if (argCount !== undefined) callEdge.argCount = argCount; - // Swift: a bare call inside a type body may be an implicit-`self` member - // call (`walk()` for `self.walk()`), syntactically indistinguishable from - // a free-function call. Widen the match to methods — R Phase 4's exact - // move, and with its exact safety: resolve.ts still requires a unique - // match, so an ambiguous function-vs-method name drops, never guesses. - if (ctx.lang === "swift" && !callee.viaMember && !callee.kinds && ctx.enclosingClass) { - callEdge.kinds = ["function", "method"]; + // 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. Emit a SECOND intent for the member reading, + // typed to the enclosing class: it resolves only 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) and drops otherwise, while + // the plain intent still resolves genuine free-function calls. 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. + if ( + ctx.lang === "swift" && + !callee.viaMember && + !callee.kinds && + ctx.enclosingClass && + !/^[A-Z]/.test(callee.name) + ) { + edges.push({ ...callEdge, viaMember: true, recvType: ctx.enclosingClass }); } const recvType = resolveRecvType(callee.receiver, ctx); edges.push(recvType ? { ...callEdge, recvType } : callEdge); diff --git a/test/graph-swift.test.ts b/test/graph-swift.test.ts index 658e6a330..c2b870b2d 100644 --- a/test/graph-swift.test.ts +++ b/test/graph-swift.test.ts @@ -127,8 +127,15 @@ func helper() { assert.equal(selfCall?.recvType, "Animal", "`self` resolves to the enclosing type"); const superCall = calls.find((e) => e.name === "describe"); assert.equal(superCall?.viaMember, true); - const bare = calls.find((e) => e.name === "helper"); - assert.equal(bare?.viaMember, false); + // A bare lowercase call in a type body emits BOTH readings: the plain + // free-function intent, and an implicit-self member intent typed to the + // enclosing class (resolve keeps whichever finds its target). + const helperCalls = calls.filter((e) => e.name === "helper"); + assert.ok(helperCalls.some((e) => !e.viaMember), "plain free-function intent"); + assert.ok( + helperCalls.some((e) => e.viaMember && e.recvType === "Animal"), + "implicit-self member intent, typed to the enclosing class", + ); const member = calls.find((e) => e.name === "walk"); assert.equal(member?.viaMember, true); }); @@ -371,6 +378,45 @@ func use() { ); }); +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: 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. From c3e4c2117a18caaa475ade226ef8b92872010bae Mon Sep 17 00:00:00 2001 From: matt strayer Date: Mon, 24 Aug 2026 18:13:37 -0400 Subject: [PATCH 4/4] fix(swift): member-first bare-call precedence, overload arity, super resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address three adversarial-review findings against the Swift promotion: 1. Bare-call double edges. A lowercase bare call in a type body emitted two independent intents (member + free function), and when BOTH targets existed the graph kept both edges — Swift resolves inner-scope-first, to the member alone. Now it is ONE edge: the member reading (viaMember + recvType = enclosing type), with a new RawEdge.implicitSelf flag letting resolve.ts take the free-function reading only when the whole owner chain has no such member. An ambiguous member set still drops without falling through — a member exists, so the function reading is wrong. 2. Overloads resolved to the first declaration. With no arity data, several same-owner candidates hit the same-file tiebreak, which picks whichever overload appears first and stamps it `extracted`. Swift callables now carry arity (required-parameter count; defaults/variadics mark it a minimum) and Swift call sites carry argCount (value_arguments + trailing closure) — Java's exact mechanism — so distinct-arity overloads resolve precisely, and a set arity can't split (`save(Int)` vs `save(String)`) drops rather than guessing by source order. 3. super.method() never resolved. resolveRecvType maps `super` through ctx.rSuperClass, which only R populated. Swift class declarations now set it from their own `:` clause's first entry (the grammar puts a superclass before any protocol), so `super.ping()` in an override resolves to the PARENT's method, climbing past the current class's own. Four new tests pin member-vs-global precedence (inside and outside the type), equal-arity drop, distinct-arity resolution, and super dispatch. Re-dogfooded on Alamofire and swift-composable-architecture: both graphs build 100% of files, `graft check` OK, duplicate-reading and first-overload edges gone. --- CHANGELOG.md | 28 +++++---- src/graph/bindings.ts | 11 ++-- src/graph/extract.ts | 119 ++++++++++++++++++++++++++++++++------- src/graph/resolve.ts | 22 +++++++- test/graph-swift.test.ts | 96 ++++++++++++++++++++++++++++--- 5 files changed, 232 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f948a0e24..7c3894930 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,16 +21,24 @@ 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 may - be an implicit-`self` member call, so it emits a second intent typed to the - enclosing class — resolved only through the owner-qualified index and 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); 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` + / `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 — diff --git a/src/graph/bindings.ts b/src/graph/bindings.ts index b61a95dc0..96d01089b 100644 --- a/src/graph/bindings.ts +++ b/src/graph/bindings.ts @@ -191,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, diff --git a/src/graph/extract.ts b/src/graph/extract.ts index bef19bd5a..58a7da666 100644 --- a/src/graph/extract.ts +++ b/src/graph/extract.ts @@ -109,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 { @@ -641,9 +648,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; @@ -713,16 +729,20 @@ 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; // 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. Emit a SECOND intent for the member reading, - // typed to the enclosing class: it resolves only 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) and drops otherwise, while - // the plain intent still resolves genuine free-function calls. This is + // 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 @@ -730,17 +750,23 @@ function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEd // 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. - if ( + const swiftImplicitSelf = ctx.lang === "swift" && !callee.viaMember && !callee.kinds && ctx.enclosingClass && - !/^[A-Z]/.test(callee.name) - ) { - edges.push({ ...callEdge, viaMember: true, recvType: 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); } - 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;`). @@ -1784,7 +1810,13 @@ function describeSwift(node: Parser.SyntaxNode, ctx: WalkCtx): DefDescriptor | n SWIFT_TYPE_KINDS.has(ctx.enclosingKind ?? "file") ? "method" : "function"; - return { name, kind, headerEnd: headEnd("function_body"), hashNode: node }; + return { + name, + kind, + headerEnd: headEnd("function_body"), + hashNode: node, + ...swiftArity(node), + }; } if (node.type === "init_declaration") { @@ -1792,7 +1824,13 @@ function describeSwift(node: Parser.SyntaxNode, ctx: WalkCtx): DefDescriptor | n // 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 }; + return { + name: ctx.enclosingClass, + kind: "method", + headerEnd: headEnd("function_body"), + hashNode: node, + ...swiftArity(node), + }; } if (node.type === "typealias_declaration") { @@ -1835,6 +1873,34 @@ 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 @@ -1846,6 +1912,21 @@ function swiftExported(node: Parser.SyntaxNode): boolean { 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 { diff --git a/src/graph/resolve.ts b/src/graph/resolve.ts index 4cef8fa44..833c11c03 100644 --- a/src/graph/resolve.ts +++ b/src/graph/resolve.ts @@ -207,12 +207,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" @@ -353,6 +365,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-swift.test.ts b/test/graph-swift.test.ts index c2b870b2d..974330da6 100644 --- a/test/graph-swift.test.ts +++ b/test/graph-swift.test.ts @@ -127,15 +127,15 @@ func helper() { 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 emits BOTH readings: the plain - // free-function intent, and an implicit-self member intent typed to the - // enclosing class (resolve keeps whichever finds its target). + // 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.ok(helperCalls.some((e) => !e.viaMember), "plain free-function intent"); - assert.ok( - helperCalls.some((e) => e.viaMember && e.recvType === "Animal"), - "implicit-self member intent, typed to the enclosing class", - ); + 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); }); @@ -417,6 +417,86 @@ extension Set { ); }); +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.