fix(swift): an extension is not the type it extends (#2107)

`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<T>.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 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-29 20:31:21 +00:00
committed by GitHub
parent 48b1a19b19
commit 5753de20f5
5 changed files with 584 additions and 2 deletions
+1
View File
@@ -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.
+213
View File
@@ -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<string, string>): Promise<CodeGraph> {
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<Int, LoadError>?
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<Int, LoadError>\` 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<Value> {
struct Isolated { let id: Int }
}
struct Waiter {
var isolated: Future<Int>.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<Int>.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();
}
});
});
+20 -1
View File
@@ -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<string, Node[]>();
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;
+13 -1
View File
@@ -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);
+337
View File
@@ -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<Node['kind']> = new Set<Node['kind']>(['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<string, boolean>;
declaration: Map<string, Node | null>;
conformances: Map<string, string[]>;
clauses: Map<string, string[]>;
inherited: Map<string, ReadonlySet<string>>;
}
const memos = new WeakMap<ResolutionContext, Memo>();
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<string> {
const memo = memoFor(context).inherited;
const hit = memo.get(typeName);
if (hit) return hit;
const found = new Set<string>();
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;
}
/** `<Success>` / `<Key, [Value]>` — 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<Success>.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<string, Node>();
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 };
}