From 5753de20f5cac6a47d69d7372f3bebee992c6626 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 29 Sep 2026 20:31:21 +0000 Subject: [PATCH] fix(swift): an extension is not the type it extends (#2107) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `extension View { … }` parses as a class_declaration, so the index held a `class View` node for every file extending SwiftUI's View, Text, Color, String, URL. With no declaration of the type in the project, every `struct X: View`, `Text("…")` and `Color.red` bound to whichever extension came first: on IceCubesApp 6 of the top 12 "most depended on" symbols were extension files (View 632 edges, Text 402, Label 209). And a bare `@State` bound to a view model's nested `enum State` (253 edges), `@Test` to a test's nested struct (821 on SwiftPackageIndex), `: Error` to a nested `IOError.Error` (233 on swift-nio). A gate at resolveOne's seam (every strategy, framework patterns and name matching alike) checks a Swift reference's type target against what the site wrote: - an extension moves to the declaration it extends, or the ref stays unresolved when the SDK declares the type; - a bare name of a nested type needs the site inside its parent, the type itself, or a subtype that declares inheriting from the parent; - a qualified name must fit its path. The index keeps `Build.Id` as a ref named `Id`, so the qualifier is read back from the source line (generic arguments stripped: `EventLoopFuture.Isolated`); a member indexed top-level because an `#if` ended its type's body early still fits a parent declared in its file. Redirects pick the unique declaration in scope, then the same file, then the nearest directory; a tie is left unresolved. Two things rode on the wrong edges and are kept: - `getSupertypes` named `View` for `struct X: View` only because that edge resolved to an extension. Swift conformances to a type the project only extends are now read from the declaration head (once per node, memoized; a per-node unresolved_refs query was +75% index time on swift-nio), so `X().withEnvironments()` still resolves to the `extension View` method. - interface-impl dispatch read a protocol's methods off the node the conformance landed on. Swift protocol requirements are not extracted as methods; the default implementations live on `extension P` nodes, which are now included — every extension, not one arbitrary one (+359 dispatch edges on swift-nio, +44 on Alamofire, none lost). Measured edge diffs vs main, every removal classified: IceCubesApp 2473 removed (172 redirects to the declaration, 1989 SDK-extension edges, 280 wrong bare nested-type bindings, ~30 from the class the typealias parse error swallows); SwiftPackageIndex 3810 (1461 redirects); swift-nio 10991 (5085 redirects); Alamofire 1368 (198). Index time at parity (swift-nio 4.1s vs 4.0s, Alamofire 1.33 vs 1.37, IceCubes 1.31 vs 1.31). Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/swift-type-visibility.test.ts | 213 +++++++++++++++ src/resolution/callback-synthesizer.ts | 21 +- src/resolution/index.ts | 14 +- src/resolution/swift-type-visibility.ts | 337 ++++++++++++++++++++++++ 5 files changed, 584 insertions(+), 2 deletions(-) create mode 100644 __tests__/swift-type-visibility.test.ts create mode 100644 src/resolution/swift-type-visibility.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4213efb67..418a59281 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Laravel routes are now named by the path a request takes: a leading `/` is added where the routes file leaves it out, `Route::prefix()` and `Route::group(['prefix' => …])` groups are applied, and routes in `routes/api.php` carry the `/api` prefix Laravel serves them under, read from your `RouteServiceProvider`, `bootstrap/app.php` or any file that mounts a routes file. A front-end `fetch('/api/…')` now connects to the Laravel route that serves it. Re-index Laravel projects after upgrading. - Controllers that Spring or Laravel tests exercise by URL now count as tested. That covers MockMvc's `perform(post("/owners/new"))`, WebTestClient, TestRestTemplate, RestAssured, Laravel's `$this->postJson('api/me')`, Pest's `get('/about')`, and a project's own request helpers built on them. `codegraph_explore` now names the test suite that reaches such an endpoint, where before every one of them looked untested. Re-index Spring and Laravel projects after upgrading. +- A Swift reference to a type now links to the type's own declaration, not to a file that extends it. An `extension View { … }` or `extension Text { … }` used to stand in for SwiftUI's type, so every view, every `Text("…")` and every `Color.red` in an app linked to whichever file happened to extend it. Those files topped the most-depended-on lists and their impact reached the whole app. A bare name like `@State`, `@Test` or `Result<…>` no longer links to some other type's nested `State` or `Result`, and a qualified name like `Build.Id` links to the `Id` it names. Methods declared in an extension of an SDK type still resolve on the types that conform to it, and a protocol's methods declared in any of its extensions now connect to each conforming type's own implementation. Re-index Swift projects after upgrading. - A Vapor route now links to the handler it names. `use: SearchController.show` used to link to whichever controller's `show` came first, so routes with a common handler name, like `show`, `index` or `get`, pointed at another endpoint's code in callers, impact and `codegraph_explore` answers. Nested types like `API.PackageController.get` and handlers declared in an extension of the controller now resolve too, and `use: self.index` resolves to the collection's own `index`. Re-index Vapor projects after upgrading. - A PHP call written without a receiver, such as `redirect($url)`, `view('books.show')`, `auth()` or `basename($path)`, is a function call, and no longer links to a same-named method, field or class elsewhere in the project. These wrong links showed up in callers, impact and `codegraph_explore` answers wherever a Laravel helper or PHP built-in shared its name with a project member. Re-index PHP projects after upgrading. diff --git a/__tests__/swift-type-visibility.test.ts b/__tests__/swift-type-visibility.test.ts new file mode 100644 index 000000000..10ce62960 --- /dev/null +++ b/__tests__/swift-type-visibility.test.ts @@ -0,0 +1,213 @@ +/** + * Where a Swift reference to a type lands. + * + * `extension View { … }` is indexed as a `class View` node, so with no + * declaration of SwiftUI's `View` in the project every `struct X: View`, + * `Text("…")` and `Color.red` bound to whichever file extended it — on + * IceCubesApp half of the "most depended on" list was extension files. And a + * bare `@State` bound to some view model's nested `enum State`. + */ +import { describe, it, expect, afterAll, beforeAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +const projects: string[] = []; +afterAll(() => { + for (const p of projects.splice(0)) fs.rmSync(p, { recursive: true, force: true }); +}); + +async function project(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-swift-types-')); + projects.push(root); + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + return CodeGraph.init(root, { index: true }); +} + +/** Every non-`contains` edge as `kind source -> target (file)`. */ +function edges(cg: CodeGraph): string[] { + const out: string[] = []; + for (const file of cg.getFiles()) { + for (const node of cg.getNodesInFile(file.path)) { + for (const c of cg.getCallees(node.id)) { + out.push(`${c.edge.kind} ${node.qualifiedName} -> ${c.node.qualifiedName} (${path.basename(c.node.filePath)}:${c.node.startLine})`); + } + } + } + return out.sort(); +} + +describe('Swift: an extension is not the type it extends', () => { + let cg: CodeGraph; + let all: string[]; + beforeAll(async () => { + cg = await project({ + 'App/DesignSystem/Card.swift': `import SwiftUI +extension View { + func cardStyle() -> some View { padding() } +} +`, + 'App/DesignSystem/Text+Style.swift': `import SwiftUI +extension Text { + func headline() -> Text { bold() } +} +`, + 'App/Models/Account.swift': `struct Account { + let id: String +} +`, + 'App/Other/Account+Display.swift': `extension Account { + var display: String { id } +} +struct AccountRow { + let account: Account +} +`, + 'App/Home/HomeView.swift': `import SwiftUI +struct HomeView: View { + @State private var count = 0 + var last: Result? + var body: some View { + Text("Hello").headline() + } +} +func makeHome() -> some View { + HomeView().cardStyle() +} +enum LoadError: Error { + case offline +} +`, + 'App/Home/ListViewModel.swift': `public final class ListViewModel { + public enum State { case loading, loaded } + public struct Result { let ok: Bool } + var state: State = .loading + var latest: Result? +} +`, + }); + all = edges(cg); + }); + afterAll(() => cg.close()); + + it('leaves a reference to an SDK type the project only extends unresolved', () => { + // `struct HomeView: View`, `-> some View`, `Text("Hello")`: SwiftUI's, not the extension files'. + expect(all.filter((e) => / -> (View|Text) \(/.test(e))).toEqual([]); + }); + + it("moves a reference from a type's extension to its declaration", () => { + // `AccountRow` sits in the extension's own file — proximity used to pick the extension. + expect(all.filter((e) => e.includes(' -> Account ('))).toEqual(['references AccountRow -> Account (Account.swift:1)']); + }); + + it("keeps a nested type to its parent's scope", () => { + // HomeView's \`Result\` is Swift's, and its \`@State\` SwiftUI's; inside + // ListViewModel, \`State\` and \`Result\` are its own. + expect(all.filter((e) => /ListViewModel::(State|Result)/.test(e))).toEqual([ + 'references ListViewModel -> ListViewModel::Result (ListViewModel.swift:3)', + 'references ListViewModel -> ListViewModel::State (ListViewModel.swift:2)', + ]); + }); + + it("still resolves an SDK type's extension members on a conforming type", () => { + // `HomeView().cardStyle()`: `cardStyle` comes from `extension View`, and HomeView conforms to View. + expect(all).toContain('calls makeHome -> View::cardStyle (Card.swift:3)'); + expect(all).toContain('calls HomeView::body -> Text::headline (Text+Style.swift:3)'); + }); +}); + +describe('Swift: the scopes that can name a nested type bare', () => { + it('reaches a supertype, a generic qualifier and the type itself', async () => { + const cg = await project({ + 'Sources/Core/Channel.swift': `class BaseChannel { + enum ReadResult { case some, none } +} +final class StreamChannel: BaseChannel { + var last: ReadResult? +} +`, + 'Sources/Core/Future.swift': `struct Future { + struct Isolated { let id: Int } +} +struct Waiter { + var isolated: Future.Isolated? +} +`, + 'Sources/Core/Socket.swift': `enum Socket { + struct Option { let raw: Int } +} +extension Socket.Option { + static let none: Option = Option(raw: 0) +} +`, + }); + try { + const all = edges(cg); + // A subclass names its superclass's nested type bare. + expect(all).toContain('references StreamChannel -> BaseChannel::ReadResult (Channel.swift:2)'); + // `Future.Isolated`: the generic argument is not part of the path. + expect(all).toContain('references Waiter -> Future::Isolated (Future.swift:2)'); + // Inside `extension Socket.Option`, `Option` is the type itself. + expect(all.filter((e) => e.includes(' -> Socket::Option '))).toEqual(['instantiates Option -> Socket::Option (Socket.swift:2)']); + } finally { + cg.close(); + } + }); +}); + +describe('Swift: a qualified type name lands on the type on that path', () => { + it('binds Build.Id and Package.Id to their own nested types, and a construction through a namespace', async () => { + const cg = await project({ + 'Sources/App/Models/Build.swift': `import Foundation +final class Build { + typealias Id = UUID +} +`, + 'Sources/App/Models/Package.swift': `import Foundation +final class Package { + typealias Id = UUID +} +`, + 'Sources/App/Controllers/API.swift': `enum API {} +extension API { + enum PackageController { + struct Model { let name: String } + } +} +`, + 'Sources/App/Views/Other.swift': `enum Other { + struct Model { let name: String } +} +`, + 'Sources/App/Commands/Trigger.swift': `struct Trigger { + let build: Build.Id + let package: Package.Id +} +func makeModel() { + let m = API.PackageController.Model(name: "x") + _ = m +} +`, + }); + try { + const all = edges(cg); + // The index keeps both as a ref named \`Id\`; the site says which. + expect(all.filter((e) => e.startsWith('references Trigger -> '))).toEqual([ + 'references Trigger -> Build (Build.swift:2)', + 'references Trigger -> Build::Id (Build.swift:3)', + 'references Trigger -> Package (Package.swift:2)', + 'references Trigger -> Package::Id (Package.swift:3)', + ]); + // A construction's column is the chain's start: `API.PackageController.Model(`. + expect(all.filter((e) => e.startsWith('instantiates makeModel'))).toEqual([ + 'instantiates makeModel -> API::PackageController::Model (API.swift:4)', + ]); + } finally { + cg.close(); + } + }); +}); diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 63e31533b..ae619d01f 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -1029,6 +1029,25 @@ async function interfaceOverrideEdges(queries: QueryBuilder, onYield: MaybeYield methodsMemo.set(classId, methods); return methods; }; + // A Swift protocol's methods live in its extensions: requirements are not + // extracted as methods, and `extension EventMonitor { func request(…) }` is + // where the default implementations a conformer overrides are. A class-kind + // node sharing a protocol's name is one of its extensions. + const protocolMemo = new Map(); + const baseMethodsOf = (base: Node): Node[] => { + if (base.language !== 'swift' || base.kind !== 'interface') return methodsOf(base.id); + const hit = protocolMemo.get(base.id); + if (hit) return hit; + const methods = [ + ...methodsOf(base.id), + ...queries + .getNodesByName(base.name) + .filter((n) => n.language === 'swift' && n.kind === 'class') + .flatMap((n) => methodsOf(n.id)), + ]; + protocolMemo.set(base.id, methods); + return methods; + }; // Concrete-side kinds vary by language: `class` covers Java / Kotlin / // C# / TS / Swift-classes / Scala-classes; `struct` covers Swift value // types that conform to protocols. Iterate both. @@ -1056,7 +1075,7 @@ async function interfaceOverrideEdges(queries: QueryBuilder, onYield: MaybeYield if (arr) arr.push(m); else implByName.set(m.name, [m]); } let added = 0; - for (const bm of methodsOf(base.id)) { + for (const bm of baseMethodsOf(base)) { if (added >= MAX_CALLBACKS_PER_CHANNEL) break; for (const m of implByName.get(bm.name) ?? []) { if (added >= MAX_CALLBACKS_PER_CHANNEL) break; diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 17f1300b9..bfc87eff3 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -24,6 +24,7 @@ import { import { matchJsStoreBindingCall, isUnresolvedJsMemberCall, isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos } from './name-matcher'; import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility'; import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor'; +import { gateSwiftTypeTarget, clearSwiftTypeVisibility, swiftExtendedConformances } from './swift-type-visibility'; import { resolveViaImport, resolvePhpImportedStaticCall, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isBoundToOutOfRepoImport, clearImportResolverMemos, resolveImportPath } from './import-resolver'; import { ResolverPool, minRefsForPool } from './resolver-pool'; import { resolveAliasBinding } from './alias-binding'; @@ -446,6 +447,7 @@ export class ReferenceResolver { clearImportResolverMemos(this.context); clearNameMatcherMemos(this.context); clearCppMacroVisibility(this.context); + clearSwiftTypeVisibility(this.context); } } @@ -724,6 +726,14 @@ export class ReferenceResolver { const target = this.nodeById(edge.target); if (target?.name && target.name !== typeName) supertypes.add(target.name); } + // A Swift conformance to a type the project only extends (SwiftUI's + // `View`) resolves to nothing — the extension is not the type — yet + // the members its extensions add are still the conformer's. + if (language === 'swift') { + for (const name of swiftExtendedConformances(tn, this.context)) { + if (name !== typeName) supertypes.add(name); + } + } } supers = [...supertypes]; } @@ -969,7 +979,9 @@ export class ReferenceResolver { // translation unit is a macro expansion, not a call — it must never bind // to a same-named function in another file (#1838). if (isVisibleCppMacro(ref, this.context)) return null; - const candidate = this.gateTargetKind(this.resolveOneInner(ref), ref); + // A Swift type reference never lands on an `extension X {}` node, nor on a + // nested type it cannot name bare (see ./swift-type-visibility). + const candidate = gateSwiftTypeTarget(this.gateTargetKind(this.resolveOneInner(ref), ref), ref, this.context); const resolved = candidate?.resolvedBy === 'framework' ? this.gateFrameworkLanguage(candidate, ref) : this.gateLanguage(candidate, ref); diff --git a/src/resolution/swift-type-visibility.ts b/src/resolution/swift-type-visibility.ts new file mode 100644 index 000000000..8dfa698fa --- /dev/null +++ b/src/resolution/swift-type-visibility.ts @@ -0,0 +1,337 @@ +/** + * Where a Swift reference to a type may land. + * + * Two shapes bound type references to the wrong node: + * + * 1. `extension View { … }` parses as a class_declaration, so the index holds a + * `class View` node for every file that extends SwiftUI's `View`, `Text`, + * `Color`, `String` or `URL`. With no declaration of the type in the + * project, every `struct X: View`, `Text("…")` and `Color.red` bound to + * whichever extension came first, and one private helper file became the + * most depended-on symbol in the app. An extension is not the type: a + * reference lands on the type's own declaration, or nowhere when the SDK + * declares it. + * + * 2. A nested type is named bare only inside its parent (or an extension or + * subclass of it). `@State` in a view is SwiftUI's property wrapper, never + * `AccountsListViewModel.State` from another file. The index keeps a type + * reference by its last segment, so a site's qualifier (`Build.Id` vs + * `Package.Id`) is read back from the source line. + * + * Applied to every strategy's result at resolveOne's seam, so the framework + * resolvers' name patterns and the name matcher obey it alike. A conformance + * to a type the project only extends now resolves to nothing, so the + * supertype walk reads such conformances from the declaration itself + * (`swiftExtendedConformances`) and the extensions' members still reach the + * conformer. Resolution only: extraction, and the kernel's, are unchanged. + */ +import type { Node } from '../types'; +import type { ResolutionContext, ResolvedRef, UnresolvedRef } from './types'; + +const TYPE_KINDS: ReadonlySet = new Set(['class', 'struct', 'enum', 'interface', 'protocol', 'type_alias']); + +/** The first declaration keyword of a class_declaration, past its attributes and modifiers. */ +const DECLARATION_KEYWORD = /\b(extension|class|struct|enum|actor|protocol)\b/; + +interface Memo { + extension: Map; + declaration: Map; + conformances: Map; + clauses: Map; + inherited: Map>; +} +const memos = new WeakMap(); + +function memoFor(context: ResolutionContext): Memo { + let memo = memos.get(context); + if (!memo) { + memo = { extension: new Map(), declaration: new Map(), conformances: new Map(), clauses: new Map(), inherited: new Map() }; + memos.set(context, memo); + } + return memo; +} + +/** Drop the memos (see ReferenceResolver.clearCaches). */ +export function clearSwiftTypeVisibility(context: ResolutionContext): void { + memos.delete(context); +} + +/** Is this Swift node an `extension` of a type rather than its declaration? */ +export function isSwiftExtension(node: Node, context: ResolutionContext): boolean { + // Extraction classifies an extension as a class (it is neither struct nor enum). + if (node.language !== 'swift' || node.kind !== 'class') return false; + const memo = memoFor(context).extension; + const hit = memo.get(node.id); + if (hit !== undefined) return hit; + const lines = linesOf(node.filePath, context); + let extension = false; + if (lines) { + const head = [ + (lines[node.startLine - 1] ?? '').slice(node.startColumn), + ...lines.slice(node.startLine, Math.min(node.endLine, node.startLine + 4)), + ] + .join(' ') + // An attribute's string argument (`message: "use class Foo"`) is not a keyword. + .replace(/"(?:[^"\\\n]|\\.)*"/g, '""'); + extension = DECLARATION_KEYWORD.exec(head)?.[1] === 'extension'; + } + memo.set(node.id, extension); + return extension; +} + +/** A declaration's inheritance clause: `struct HomeView: View, Sendable {` → `View, Sendable`. */ +const INHERITANCE_CLAUSE = /\b(?:class|struct|enum|actor|protocol|extension)\s+[A-Za-z_][\w.]*\s*(?:<[^{}]*?>)?\s*:\s*([^{]*?)\s*(?:\bwhere\b[^{]*)?\{/; + +/** The supertypes a Swift type node's own declaration names: `View`, `Sendable` for `struct HomeView: View, Sendable {`. */ +function declaredSupertypes(node: Node, context: ResolutionContext): string[] { + const memo = memoFor(context).clauses; + const hit = memo.get(node.id); + if (hit) return hit; + let names: string[] = []; + const lines = linesOf(node.filePath, context); + if (lines) { + const head = [(lines[node.startLine - 1] ?? '').slice(node.startColumn), ...lines.slice(node.startLine, Math.min(node.endLine, node.startLine + 6))].join(' '); + const clause = INHERITANCE_CLAUSE.exec(head.replace(/"(?:[^"\\\n]|\\.)*"/g, '""'))?.[1]; + if (clause) { + names = clause + .split(',') + .map((part) => part.replace(GENERIC_ARGS, '').trim().split('.').pop()!.trim()) + .filter((name) => /^[A-Za-z_]\w*$/.test(name)); + } + } + memo.set(node.id, names); + return names; +} + +/** + * The types a Swift type node's own declaration conforms to that the project + * only extends — `View` for `struct HomeView: View` when `extension View {}` + * is all the index holds of it. Those conformances resolve to nothing (an + * extension is not the type), yet the members the extensions add are the + * conformer's: the supertype walk reads them from here. From the source, once + * per node — the resolved edges it would otherwise come from do not exist. + */ +export function swiftExtendedConformances(node: Node, context: ResolutionContext): string[] { + if (node.language !== 'swift') return []; + const memo = memoFor(context).conformances; + const hit = memo.get(node.id); + if (hit) return hit; + const names = declaredSupertypes(node, context).filter((name) => { + const typed = context.getNodesByName(name).filter((n) => n.language === 'swift' && TYPE_KINDS.has(n.kind)); + return typed.length > 0 && typed.every((n) => isSwiftExtension(n, context)); + }); + memo.set(node.id, names); + return names; +} + +/** Every type a Swift type inherits from or conforms to, as declared in the project — transitively, a few levels. */ +function inheritedTypeNames(typeName: string, context: ResolutionContext): ReadonlySet { + const memo = memoFor(context).inherited; + const hit = memo.get(typeName); + if (hit) return hit; + const found = new Set(); + let frontier = [typeName]; + for (let depth = 0; depth < 4 && frontier.length > 0; depth++) { + const next: string[] = []; + for (const name of frontier) { + for (const node of context.getNodesByName(name)) { + if (node.language !== 'swift' || !TYPE_KINDS.has(node.kind)) continue; + for (const sup of declaredSupertypes(node, context)) { + if (sup === typeName || found.has(sup)) continue; + found.add(sup); + next.push(sup); + } + } + } + frontier = next; + } + memo.set(typeName, found); + return found; +} + +/** The type path a nested type is declared in (`AccountsListViewModel` for `AccountsListViewModel::State`), or null at the top level. */ +function parentPath(node: Node): string | null { + const sep = node.qualifiedName.lastIndexOf('::'); + if (sep <= 0) return null; + const parent = node.qualifiedName.slice(0, sep); + // A framework resolver's node is named after its file (`Sources/App/Home.swift::Home`), not a scope. + if (parent.includes('/') || parent.endsWith('.swift')) return null; + return parent; +} + +/** + * How deep the code at `from` sits in `target`'s scope, when it may name + * `target` bare: 0 for a top-level type, the parent's segment count inside + * the parent or a subtype of it, one more inside the type itself; null when + * out of scope. An `extension API.PackageController` carries only its last + * segment (`PackageController::get`), so a suffix of a path counts as inside + * it too. + */ +function bareScopeDepth(target: Node, from: Node | null, context: ResolutionContext): number | null { + const parent = parentPath(target); + if (!parent) return 0; + if (!from) return null; + const scope = from.qualifiedName; + const inside = (path: string): boolean => { + const segs = path.split('::'); + for (let i = 0; i < segs.length; i++) { + const suffix = segs.slice(i).join('::'); + if (scope === suffix || scope.startsWith(`${suffix}::`)) return true; + } + return false; + }; + const depth = parent.split('::').length; + // Inside the type itself — `extension NIOBSDSocket.Option { static let x: Option }` — + // or inside its parent. + if (inside(target.qualifiedName)) return depth + 1; + if (inside(parent)) return depth; + // A subclass (or conformer) names its supertype's nested types bare: + // `ReadResult` in `BaseStreamSocketChannel: BaseSocketChannel`. + const parentName = parent.split('::').pop()!; + const scopeTypes = scope.split('::'); + for (const name of scopeTypes) { + if (inheritedTypeNames(name, context).has(parentName)) return depth; + } + return null; +} + +/** The source lines of a file, through the resolver's line cache when it has one. */ +function linesOf(filePath: string, context: ResolutionContext): string[] | null { + return context.getFileLines?.(filePath) ?? context.readFile(filePath)?.split(/\r?\n/) ?? null; +} + +/** `` / `` — one level of nesting is all a qualifier carries in practice. */ +const GENERIC_ARGS = /<[^<>]*(?:<[^<>]*>[^<>]*)*>/g; +const QUALIFIER_SEGMENT = String.raw`[A-Za-z_]\w*(?:<[^<>]*(?:<[^<>]*>[^<>]*)*>)?\s*\.\s*`; +const QUALIFIER_BEFORE = new RegExp(String.raw`((?:${QUALIFIER_SEGMENT})+)$`); + +/** + * The reference as its site writes it. The index keeps a Swift type + * reference by its last segment — `Build.Id` is a ref named `Id` — so whether + * the name was qualified is read back from the line: a type annotation's + * column is the name's (`Build.|Id`), a construction's is the chain's + * (`|API.PackageController.GetRoute.Model(`). + */ +function writtenName(ref: UnresolvedRef, context: ResolutionContext): string { + const name = ref.referenceName; + if (name.includes('.')) return name; + const text = linesOf(ref.filePath, context)?.[ref.line - 1]; + if (!text) return name; + // `EventLoopFuture.Isolated`: the qualifier's generic arguments are not part of its path. + const clean = (qualifier: string): string => `${qualifier.replace(GENERIC_ARGS, '').replace(/\s+/g, '')}${name}`; + if (text.startsWith(name, ref.column)) { + const before = QUALIFIER_BEFORE.exec(text.slice(Math.max(0, ref.column - 240), ref.column)); + return before ? clean(before[1]!) : name; + } + const chain = new RegExp(String.raw`^((?:${QUALIFIER_SEGMENT})+)${name.replace(/[$\\]/g, '\\$&')}\b`).exec(text.slice(ref.column, ref.column + 240)); + return chain ? clean(chain[1]!) : name; +} + +/** `API.PackageController.Model` as `API::PackageController::Model`, up to `name`; null when `name` is not a segment. */ +function writtenPath(written: string, name: string): string | null { + const segs = written.split('.'); + const at = segs.lastIndexOf(name); + return at < 0 ? null : segs.slice(0, at + 1).join('::'); +} + +/** + * Does a type's qualified name fit the path a site wrote? A type declared in + * `extension API.PackageController.GetRoute` is `GetRoute::Model`, so its + * name may be a tail of the path; a module-qualified `Foundation.Date` names + * a top-level type. + */ +function fitsPath(node: Node, path: string, context: ResolutionContext): boolean { + const qn = node.qualifiedName; + if (qn === path || qn.endsWith(`::${path}`)) return true; + if (!path.endsWith(`::${qn}`)) return false; + if (qn.includes('::')) return true; + const head = path.slice(0, path.length - qn.length - 2); + const parentName = head.split('::').pop()!; + // A member declared under `#if` in a type's body can be indexed at the top + // level — the grammar ends `enum NIOBSDSocket {` at an `#if` right inside + // it, so `typealias Handle` loses its parent. It still shares a file with a + // declaration or extension of the parent the site names. + if (context.getNodesInFile(node.filePath).some((p) => p.name === parentName && TYPE_KINDS.has(p.kind) && p.id !== node.id)) return true; + // `App.Build`: a top-level type under a qualifier that names no type — a module. + return !head.includes('::') && !context.getNodesByName(head).some((n) => n.language === 'swift' && TYPE_KINDS.has(n.kind)); +} + +/** Segments two paths' directories share. */ +function sharedDirs(a: string, b: string): number { + const da = a.split('/').slice(0, -1); + const db = b.split('/').slice(0, -1); + let n = 0; + while (n < da.length && n < db.length && da[n] === db[n]) n++; + return n; +} + +/** The project's own declaration of the type a site names, when exactly one fits. */ +function declarationFor(name: string, written: string, ref: UnresolvedRef, from: Node | null, context: ResolutionContext): Node | null { + const memo = memoFor(context).declaration; + const key = `${ref.filePath}\0${from?.qualifiedName ?? ''}\0${written}\0${name}`; + if (memo.has(key)) return memo.get(key)!; + + const bare = !written.includes('.'); + const path = bare ? null : writtenPath(written, name); + const scored: Array<{ node: Node; depth: number }> = []; + for (const node of context.getNodesByName(name)) { + if (node.language !== 'swift' || !TYPE_KINDS.has(node.kind) || isSwiftExtension(node, context)) continue; + if (path !== null) { + if (fitsPath(node, path, context)) scored.push({ node, depth: 0 }); + continue; + } + const depth = bare ? bareScopeDepth(node, from, context) : 0; + if (depth !== null) scored.push({ node, depth }); + } + // Swift looks a bare name up innermost scope first. + const deepest = Math.max(-1, ...scored.map((s) => s.depth)); + // One declaration per file: a framework resolver's one-line twin of a + // declaration (`@main struct App`) is the same type. + const byFile = new Map(); + for (const { node, depth } of scored) { + if (depth !== deepest) continue; + const had = byFile.get(node.filePath); + if (!had || node.endLine - node.startLine > had.endLine - had.startLine) byFile.set(node.filePath, node); + } + const declarations = [...byFile.values()]; + let found: Node | null = null; + if (declarations.length === 1) { + found = declarations[0]!; + } else if (declarations.length > 1) { + // The same file's, else the nearest by directory — a package's own type + // over another package's. A tie is left unresolved. + found = declarations.find((n) => n.filePath === ref.filePath) ?? null; + if (!found) { + const near = Math.max(...declarations.map((n) => sharedDirs(n.filePath, ref.filePath))); + const nearest = declarations.filter((n) => sharedDirs(n.filePath, ref.filePath) === near); + found = nearest.length === 1 ? nearest[0]! : null; + } + } + memo.set(key, found); + return found; +} + +/** + * A Swift reference's resolved type target, checked against what the site + * wrote: an extension moves to the declaration it extends, a qualified name + * to the type on that path, and a bare name of a nested type out of its + * scope to one in scope — or the reference is left unresolved. + */ +export function gateSwiftTypeTarget(result: ResolvedRef | null, ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + if (!result || ref.language !== 'swift' || ref.referenceKind === 'imports') return result; + const target = context.getNodeById?.(result.targetNodeId); + if (!target || target.language !== 'swift' || !TYPE_KINDS.has(target.kind)) return result; + const from = context.getNodeById?.(ref.fromNodeId) ?? null; + const written = writtenName(ref, context); + if (!isSwiftExtension(target, context)) { + if (!written.includes('.')) { + if (bareScopeDepth(target, from, context) !== null) return result; + } else { + const path = writtenPath(written, target.name); + if (path === null || fitsPath(target, path, context)) return result; + } + } + const declared = declarationFor(target.name, written, ref, from, context); + if (!declared) return null; + return declared.id === result.targetNodeId ? result : { ...result, targetNodeId: declared.id }; +}