mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-21 08:47:04 +08:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user