From 18db53f8c48988e72c0abb28f69dbf9fde41f3c6 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Wed, 30 Sep 2026 15:14:05 -0700 Subject: [PATCH 1/3] [api] Add getSymbol(decl) --- .github/skills/api-client/SKILL.md | 6 +- packages/typescript/src/api/async/api.ts | 266 ++++++++------ packages/typescript/src/api/node/node.ts | 13 +- .../typescript/src/api/proto.generated.ts | 76 ++-- .../typescript/src/api/sourceFileCache.ts | 48 ++- packages/typescript/src/api/sync/api.ts | 335 +++++++++++------- packages/typescript/test/async/api.test.ts | 176 +++++++++ .../test/sync/api-generators.test.ts | 17 + packages/typescript/test/sync/api.test.ts | 176 +++++++++ tsc/internal/api/proto.go | 15 +- tsc/internal/api/session.go | 25 ++ .../api/session_createsourcefile_test.go | 39 ++ 12 files changed, 909 insertions(+), 283 deletions(-) diff --git a/.github/skills/api-client/SKILL.md b/.github/skills/api-client/SKILL.md index bdca56996094f..cb2a63377ec3d 100644 --- a/.github/skills/api-client/SKILL.md +++ b/.github/skills/api-client/SKILL.md @@ -47,4 +47,8 @@ What matters: What doesn't matter: * `uint64` overflow -* Passing objects between multiple `API` instances in the same JS process. We may eventually add some kind of central provenance validation. Do not add it on individual methods or flag it in reviews for now. It's not a realistic concern. \ No newline at end of file +* Passing objects between multiple `API` instances in the same JS process. We may eventually add some kind of central provenance validation. Do not add it on individual methods or flag it in reviews for now. It's not a realistic concern. + +## Reference equality guarantees are forfeited by disposing owners and clearing the source file cache + +It's not a bug to observe a new object identity for an object whose owner was previously disposed or manually removed by `api.clearSourceFileCache()`. Review comments and tests that assert an ownership or lifetime bug that rely on testing reference equality after disposal or cache clearing will be rejected. diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index acd517b484c25..3c5fa9ecaeff4 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -47,6 +47,7 @@ import { getNodeId, parseNodeHandle, readSourceFileLease, + RemoteNode, RemoteSourceFile, } from "../node/node.ts"; import { Wtf8Decoder } from "../node/wtf8.ts"; @@ -122,6 +123,7 @@ import { import type { RequestTiming, TimingAccumulators, + TimingCollector, TimingInfo, } from "../timing.ts"; import { @@ -330,8 +332,10 @@ export interface TranspileOutput { // @sync-only-end export class API implements FormatDiagnosticsHost { - private client: Client; - private sourceFileCache: SourceFileCache; + /** @internal */ + readonly client: Client; + /** @internal */ + readonly sourceFileCache: SourceFileCache; private toPath: ((fileName: string) => Path) | undefined; private currentDirectory: string | undefined; private readonly decoder = new Wtf8Decoder(); @@ -351,6 +355,60 @@ export class API implements FormatDiagnosticsHo this.internal = new InternalAPI(this.client, () => this.ensureInitialized()); // @sync: this.internal = new InternalAPI(this.client, this.ensureInitialized); } + /** + * Returns the declaration's stored binder symbol, or undefined for synthesized or configuration ASTs. + * Successful lookups, including missing symbols, are cached. An uncached lookup requires + * the exact source file to remain available on the server. + */ + async getSymbol(declaration: Declaration): Promise { + const file = getRemoteSourceFile(declaration); + if (!file) return undefined; + const record = file.symbolCache as CachedSourceFile | undefined; + if (!record) return undefined; + const index = parseNodeHandle(getNodeId(declaration)).index; + if (record.symbolsByDeclarationNodeIndex.has(index)) return record.symbolsByDeclarationNodeIndex.get(index); + // @sync-only-start + // return this.fetchDeclarationSymbol(record, index); + // @sync-only-end + // @sync-skip-block-start + let request = record.declarationSymbolRequests.get(index); + if (!request) { + request = this.fetchDeclarationSymbol(record, index); + record.declarationSymbolRequests.set(index, request); + } + try { + return await request; + } + finally { + record.declarationSymbolRequests.delete(index); + } + // @sync-skip-block-end + } + + private async fetchDeclarationSymbol(record: CachedSourceFile, index: number): Promise { + const data = await this.client.apiRequest("getSymbolOfDeclaration", { + file: record.descriptor, + index, + }); + let symbol: Symbol | undefined; + if (data) { + validateSymbolResponse(data); + const reference = data.reference; + if (reference.kind !== SymbolOwnerKind.File) { + throw new Error(`Symbol ${reference.id} is not file-owned`); + } + const fileOwner: SourceFileOwner = { record, api: this }; + symbol = this.sourceFileCache.getOrCreateSymbol( + record, + reference.file, + reference.id, + () => new Symbol(data, { kind: SymbolOwnerKind.File, owner: fileOwner }), + ); + } + record.symbolsByDeclarationNodeIndex.set(index, symbol); + return symbol; + } + /** * Create an API instance from an existing LSP connection's API session. * Use this when connecting to an API pipe provided by an LSP server via custom/initializeAPISession. @@ -497,7 +555,7 @@ export class API implements FormatDiagnosticsHo const view = new DataView(data.buffer, data.byteOffset, data.byteLength); const lease = readSourceFileLease(view); try { - const decoded = new RemoteSourceFile(data, this.decoder, this.client.getTimingCollector()); + const decoded = new RemoteSourceFile(data, this.decoder, this.client.getTimingCollector(), this); return this.addSourceFileLease(decoded, lease); } catch (error) { @@ -516,7 +574,7 @@ export class API implements FormatDiagnosticsHo private addSourceFileLease(sourceFile: RemoteSourceFile, lease: number): RetainedSourceFile { const cached = this.sourceFileCache.setForLease(sourceFile, lease); - const retained = new RetainedSourceFile(cached as unknown as SourceFile, lease, this.client, () => { + const retained = new RetainedSourceFile(cached as unknown as SourceFile, lease, () => { this.activeSourceFileLeases.delete(lease); this.sourceFileCache.releaseLease(lease); }); @@ -557,8 +615,6 @@ export class API implements FormatDiagnosticsHo const snapshot = new Snapshot( data, - this.client, - this.sourceFileCache, this.toPath!, this, () => { @@ -590,8 +646,6 @@ export class API implements FormatDiagnosticsHo this.sourceFileCache.retainForSnapshot(data.snapshot, baseSnapshot.id, data.changes); const snapshot = new Snapshot( data, - this.client, - this.sourceFileCache, this.toPath!, this, () => { @@ -671,8 +725,6 @@ export class API implements FormatDiagnosticsHo } const snapshot = new Snapshot( data, - this.client, - this.sourceFileCache, this.toPath!, this, () => { @@ -812,19 +864,46 @@ function sourceFileDescriptor(sourceFile: RemoteSourceFile): SourceFileDescripto }; } +function getRemoteSourceFile(node: Node): RemoteSourceFile | undefined { + if (!(node instanceof RemoteNode)) return undefined; + const file = node.getSourceFile(); + return file instanceof RemoteSourceFile && file.api ? file : undefined; +} + +function getNodeAPI(node: Node): API | undefined { + const api = getRemoteSourceFile(node)?.api; + if (!api) return undefined; + if (!(api instanceof API)) throw new Error("Source file does not belong to the async API"); // @sync: if (!(api instanceof API)) throw new Error("Source file does not belong to the sync API"); + return api; +} + +/** Looks up the declaration's symbol through its owning API, or returns undefined for ASTs without symbol state. */ +export async function getSymbol(declaration: Declaration): Promise { + const api = getNodeAPI(declaration); + return api?.getSymbol(declaration); +} + +// @sync-only-start +// export declare namespace getSymbol { +// function gen(declaration: Declaration): Generator; +// } +// getSymbol.gen = function* (declaration: Declaration): Generator { +// const api = getNodeAPI(declaration); +// return api ? yield* api.getSymbol.gen(declaration) : undefined; +// }; +// @sync-only-end + /** An independently retained source file and its disposable remote-lifetime lease. */ export class RetainedSourceFile { readonly sourceFile: SourceFile; private readonly lease: number; - private readonly client: Client; private readonly onDispose: () => void; private disposed = false; private disposePromise: Promise | undefined; - constructor(sourceFile: SourceFile, lease: number, client: Client, onDispose: () => void) { + constructor(sourceFile: SourceFile, lease: number, onDispose: () => void) { this.sourceFile = sourceFile; this.lease = lease; - this.client = client; this.onDispose = onDispose; } @@ -838,9 +917,11 @@ export class RetainedSourceFile { private async disposeWorker(): Promise { if (this.disposed) return; + const api = getNodeAPI(this.sourceFile); + if (!api) throw new Error("Retained source file has no owning API"); this.disposed = true; try { - await this.client.apiRequest("releaseSourceFile", { lease: this.lease }); + await api.client.apiRequest("releaseSourceFile", { lease: this.lease }); } finally { this.onDispose(); @@ -941,7 +1022,7 @@ export class Snapshot { readonly operation: SnapshotOperation; private projectMap: Map; private toPath: (fileName: string) => Path; - private client: Client; + private readonly api: API; private disposed: boolean = false; private disposePromise: Promise | undefined; private onDispose: () => void; @@ -950,18 +1031,13 @@ export class Snapshot { private updateSnapshot: SnapshotUpdater; readonly internal: SnapshotInternalAPI; - constructor( - data: CreateSnapshotResponse, - client: Client, - sourceFileCache: SourceFileCache, - toPath: (fileName: string) => Path, - formatDiagnosticsHost: FormatDiagnosticsHost, - onDispose: () => void, - updateSnapshot: SnapshotUpdater, - baseSnapshot?: Snapshot, - ) { + private get client(): Client { + return this.api.client; + } + + constructor(data: CreateSnapshotResponse, toPath: (fileName: string) => Path, api: API, onDispose: () => void, updateSnapshot: SnapshotUpdater, baseSnapshot?: Snapshot) { this.id = data.snapshot; - this.client = client; + this.api = api; this.toPath = toPath; this.onDispose = onDispose; this.updateSnapshot = updateSnapshot; @@ -977,7 +1053,7 @@ export class Snapshot { this.snapshotRegistry = new SnapshotObjectRegistry(this.id); for (const projData of this.projectDataMap.values()) { - const project = new Project(projData, this.id, client, sourceFileCache, toPath, formatDiagnosticsHost, this.snapshotRegistry); + const project = new Project(projData, this.id, toPath, api, this.snapshotRegistry); this.projectMap.set(projData.id, project); } @@ -986,7 +1062,7 @@ export class Snapshot { openedFiles: data.operation.openedFiles?.map(result => ({ project: this.requireProject(result.project) })), }; - this.internal = new SnapshotInternalAPI(this.id, client); + this.internal = new SnapshotInternalAPI(this.id, api.client); } getProjects(): readonly Project[] { @@ -1164,27 +1240,25 @@ class SnapshotObjectRegistry { } class ProjectObjectRegistry { - private client: Client; private snapshotId: number; readonly project: Project; private snapshotRegistry: SnapshotObjectRegistry; - private sourceFileCache: SourceFileCache; private types: Map = new Map(); private signatures: Map = new Map(); private disposed = false; - constructor( - client: Client, - snapshotId: number, - project: Project, - snapshotRegistry: SnapshotObjectRegistry, - sourceFileCache: SourceFileCache, - ) { - this.client = client; + private get client(): Client { + return this.project.api.client; + } + + private get sourceFileCache(): SourceFileCache { + return this.project.api.sourceFileCache; + } + + constructor(snapshotId: number, project: Project, snapshotRegistry: SnapshotObjectRegistry) { this.snapshotId = snapshotId; this.project = project; this.snapshotRegistry = snapshotRegistry; - this.sourceFileCache = sourceFileCache; snapshotRegistry.addProjectRegistry(this); } @@ -1199,7 +1273,7 @@ class ProjectObjectRegistry { return this.sourceFileCache.getOrCreateSymbol(record, reference.file, reference.id, () => new Symbol(data, { kind: SymbolOwnerKind.File, - owner: { record, cache: this.sourceFileCache, client: this.client }, + owner: { record, api: this.project.api }, })); } @@ -1210,7 +1284,7 @@ class ProjectObjectRegistry { return this.snapshotRegistry.getSymbol(reference.id); } const record = this.sourceFileCache.findRecord(reference.file); - const symbol = record?.symbols.get(reference.id); + const symbol = record?.symbolsById.get(reference.id); if (record && symbol) { this.sourceFileCache.retainRecord(record, this.snapshotId, this.project.id); } @@ -1434,6 +1508,8 @@ class ProjectObjectRegistry { } export class Project { + /** @internal */ + readonly api: API; readonly id: Id; readonly configFileName: string; readonly currentDirectory: string; @@ -1447,19 +1523,11 @@ export class Project { readonly program: Program; readonly checker: Checker; readonly languageService: LanguageService; - private client: Client; private snapshotId: number; - constructor( - data: ProjectResponse, - snapshotId: number, - client: Client, - sourceFileCache: SourceFileCache, - toPath: (fileName: string) => Path, - formatDiagnosticsHost: FormatDiagnosticsHost, - snapshotRegistry: SnapshotObjectRegistry, - ) { + constructor(data: ProjectResponse, snapshotId: number, toPath: (fileName: string) => Path, api: API, snapshotRegistry: SnapshotObjectRegistry) { this.id = data.id as Id; + this.api = api; this.configFileName = data.configFileName; this.currentDirectory = data.currentDirectory; this.dirty = data.dirty; @@ -1469,24 +1537,11 @@ export class Project { this.parsedCommandLine = data.parsedCommandLine; this.compilerOptions = this.parsedCommandLine.options; this.rootFiles = this.parsedCommandLine.fileNames; - this.client = client; this.snapshotId = snapshotId; - this.program = new Program( - snapshotId, - this, - client, - sourceFileCache, - toPath, - formatDiagnosticsHost, - ); - const objectRegistry = new ProjectObjectRegistry(client, snapshotId, this, snapshotRegistry, sourceFileCache); - this.checker = new Checker( - snapshotId, - this, - client, - objectRegistry, - ); - this.languageService = new LanguageService(snapshotId, this, client, objectRegistry); + this.program = new Program(snapshotId, this, toPath); + const objectRegistry = new ProjectObjectRegistry(snapshotId, this, snapshotRegistry); + this.checker = new Checker(snapshotId, this, objectRegistry); + this.languageService = new LanguageService(snapshotId, this, objectRegistry); } /** @deprecated Use `languageService.getImportAdderEdits`. */ @@ -1507,18 +1562,15 @@ export class Project { export class LanguageService { private snapshotId: number; private project: Project; - private client: Client; private objectRegistry: ProjectObjectRegistry; - constructor( - snapshotId: number, - project: Project, - client: Client, - objectRegistry: ProjectObjectRegistry, - ) { + private get client(): Client { + return this.project.api.client; + } + + constructor(snapshotId: number, project: Project, objectRegistry: ProjectObjectRegistry) { this.snapshotId = snapshotId; this.project = project; - this.client = client; this.objectRegistry = objectRegistry; } @@ -1618,30 +1670,25 @@ export class Program implements FormatDiagnost readonly snapshotId: number; readonly id: Id; private readonly project: Project; - private readonly client: Client; - private readonly sourceFileCache: SourceFileCache; private readonly toPath: (fileName: string) => Path; - private readonly formatDiagnosticsHost: FormatDiagnosticsHost; private readonly decoder = new Wtf8Decoder(); private readonly sourceFileMetadataCache = new Map>(); private ownedSnapshot: Snapshot | undefined; private disposePromise: Promise | undefined; - constructor( - snapshotId: number, - project: Project, - client: Client, - sourceFileCache: SourceFileCache, - toPath: (fileName: string) => Path, - formatDiagnosticsHost: FormatDiagnosticsHost, - ) { + private get client(): Client { + return this.project.api.client; + } + + private get sourceFileCache(): SourceFileCache { + return this.project.api.sourceFileCache; + } + + constructor(snapshotId: number, project: Project, toPath: (fileName: string) => Path) { this.snapshotId = snapshotId; this.id = project.id; this.project = project; - this.client = client; - this.sourceFileCache = sourceFileCache; this.toPath = toPath; - this.formatDiagnosticsHost = formatDiagnosticsHost; } getCurrentDirectory(): string { @@ -1649,7 +1696,7 @@ export class Program implements FormatDiagnost } getCanonicalFileName(fileName: string): string { - return this.formatDiagnosticsHost.getCanonicalFileName(fileName); + return this.project.api.getCanonicalFileName(fileName); } getNewLine(): string { @@ -1700,7 +1747,7 @@ export class Program implements FormatDiagnost } // Create a new RemoteSourceFile and cache it (set returns existing if hash matches) - const decoded = new RemoteSourceFile(binaryData, this.decoder, this.client.getTimingCollector()); + const decoded = new RemoteSourceFile(binaryData, this.decoder, this.client.getTimingCollector(), this.project.api); return this.sourceFileCache.set(decoded, this.snapshotId, this.project.id) as unknown as SourceFile; } @@ -1866,7 +1913,7 @@ export class Program implements FormatDiagnost return undefined; } - return new RemoteSourceFile(binaryData, this.decoder) as unknown as SourceFile; + return new RemoteSourceFile(binaryData, this.decoder, undefined, this.project.api) as unknown as SourceFile; } /** @@ -2164,20 +2211,17 @@ function toEmitOutput(response: ProtocolEmitOutputResponse): EmitOutput { export class Checker { private snapshotId: number; private project: Project; - private client: Client; private objectRegistry: ProjectObjectRegistry; private wellKnownSymbols: Promise<{ unknown: number; undefined: number; arguments: number; }> | undefined; private wellKnownSignatures: Promise<{ unknown: number; }> | undefined; - constructor( - snapshotId: number, - project: Project, - client: Client, - objectRegistry: ProjectObjectRegistry, - ) { + private get client(): Client { + return this.project.api.client; + } + + constructor(snapshotId: number, project: Project, objectRegistry: ProjectObjectRegistry) { this.snapshotId = snapshotId; this.project = project; - this.client = client; this.objectRegistry = objectRegistry; } @@ -3114,9 +3158,12 @@ export class NodeHandle { } private async fetchOwnerFile(fileOwner: SourceFileOwner): Promise { - const data = await fileOwner.client.apiRequestBinary("getCachedSourceFile", { file: fileOwner.record.descriptor }); + const data = await fileOwner.api.client.apiRequestBinary("getCachedSourceFile", { file: fileOwner.record.descriptor }); if (!data) throw new Error(`Source file '${fileOwner.record.descriptor.fileName}' is not available`); - return fileOwner.cache.attachFile(fileOwner.record, new RemoteSourceFile(data, new Wtf8Decoder(), fileOwner.client.getTimingCollector())); + return fileOwner.api.sourceFileCache.attachFile( + fileOwner.record, + new RemoteSourceFile(data, new Wtf8Decoder(), fileOwner.api.client.getTimingCollector(), fileOwner.api), + ); } } @@ -3138,11 +3185,10 @@ export interface SignatureUsage { call?: NodeHandle | undefined; } -/** A cached source-file record and the dependencies needed to materialize or extend it. */ +/** A cached source-file record and its owning API. */ interface SourceFileOwner { readonly record: CachedSourceFile; - readonly cache: SourceFileCache; - readonly client: Client; + readonly api: API; } type SymbolStorage = @@ -3240,9 +3286,9 @@ export class Symbol { } const fileOwner = this.storage.owner; // A file-owned symbol's relationships are always owned by the same file. - const cached = reference.file === fileOwner.record.descriptor.nodeId ? fileOwner.record.symbols.get(reference.id) : undefined; + const cached = reference.file === fileOwner.record.descriptor.nodeId ? fileOwner.record.symbolsById.get(reference.id) : undefined; if (cached) return cached; - const data = await fileOwner.client.apiRequest(method, { symbol: this.reference }); + const data = await fileOwner.api.client.apiRequest(method, { symbol: this.reference }); if (!data) throw new Error(`${method} returned null symbol for Symbol ${this.id}`); return this.internFileSymbol(fileOwner, data); } @@ -3252,7 +3298,7 @@ export class Symbol { return this.storage.registry.fetchSymbols(this, method); } const fileOwner = this.storage.owner; - const data = await fileOwner.client.apiRequest(method, { symbol: this.reference }); + const data = await fileOwner.api.client.apiRequest(method, { symbol: this.reference }); return data?.map(symbol => this.internFileSymbol(fileOwner, symbol)) ?? []; } @@ -3260,7 +3306,7 @@ export class Symbol { validateSymbolResponse(data); const reference = data.reference; if (reference.kind !== SymbolOwnerKind.File) throw new Error(`Symbol ${reference.id} is not file-owned`); - return fileOwner.cache.getOrCreateSymbol(fileOwner.record, reference.file, reference.id, () => new Symbol(data, { kind: SymbolOwnerKind.File, owner: fileOwner })); + return fileOwner.api.sourceFileCache.getOrCreateSymbol(fileOwner.record, reference.file, reference.id, () => new Symbol(data, { kind: SymbolOwnerKind.File, owner: fileOwner })); } } diff --git a/packages/typescript/src/api/node/node.ts b/packages/typescript/src/api/node/node.ts index 7126722b97178..c65a9cf907023 100644 --- a/packages/typescript/src/api/node/node.ts +++ b/packages/typescript/src/api/node/node.ts @@ -12,6 +12,9 @@ import { SyntaxKind, TokenFlags, } from "../../ast/index.ts"; +import type { API as AsyncAPI } from "../async/api.ts"; +import type { CachedSourceFile } from "../sourceFileCache.ts"; +import type { API as SyncAPI } from "../sync/api.ts"; import type { TimingCollector } from "../timing.ts"; import { MsgpackReader } from "./msgpack.ts"; import { @@ -78,6 +81,8 @@ for (const [index, offset] of Object.values(sourceFileExtendedDataOffsets).entri const NO_STRUCTURED_DATA = 0xFFFFFFFF; export class RemoteSourceFile extends RemoteNode implements SourceFileInfo { + readonly api: AsyncAPI | SyncAPI | undefined; + symbolCache: CachedSourceFile | undefined; readonly nodes: (RemoteNode | RemoteNodeList)[]; readonly _offsetNodes: number; readonly _offsetStringTableOffsets: number; @@ -100,7 +105,12 @@ export class RemoteSourceFile extends RemoteNode implements SourceFileInfo { private _cachedDiagnosticDirectives: readonly MappedDiagnosticDirective[] | undefined; private _diagnosticDirectivesRead = false; - constructor(data: Uint8Array, decoder: TextDecoder, timing?: TimingCollector) { + constructor( + data: Uint8Array, + decoder: TextDecoder, + timing?: TimingCollector, + api?: AsyncAPI | SyncAPI | undefined, + ) { const view = new DataView(data.buffer, data.byteOffset, data.byteLength); const offsetNodes = view.getUint32(HEADER_OFFSET_NODES, true); super(view, 1, undefined!, undefined!, offsetNodes); @@ -112,6 +122,7 @@ export class RemoteSourceFile extends RemoteNode implements SourceFileInfo { this._offsetStructuredData = view.getUint32(HEADER_OFFSET_STRUCTURED_DATA, true); this._decoder = decoder; this._timing = timing; + this.api = api; this.nodes = Array((view.byteLength - offsetNodes) / NODE_LEN); this.nodes[1] = this; // Every node slot is materializable on demand except the nil sentinel at diff --git a/packages/typescript/src/api/proto.generated.ts b/packages/typescript/src/api/proto.generated.ts index e80e6e9cf4f95..165d4d502a279 100644 --- a/packages/typescript/src/api/proto.generated.ts +++ b/packages/typescript/src/api/proto.generated.ts @@ -26,6 +26,7 @@ export interface APIMethodInfo { releaseSourceFile: APIMethod; retainSourceFile: APIMethod; getCachedSourceFile: APIMethod; + getSymbolOfDeclaration: APIMethod; batchRequests: APIMethod; initialize: APIMethod; createSnapshot: APIMethod; @@ -241,6 +242,22 @@ export interface SourceFileResponse { data: string; } +export interface GetSymbolOfDeclarationParams { + file: SourceFileDescriptor; + index: number; +} + +export interface SymbolResponse { + reference: SymbolReference; + name: string; + flags: number; + checkFlags: number; + declarations?: string[] | undefined; + valueDeclaration?: string | undefined; + parent?: CompactSymbolReference | undefined; + exportSymbol?: CompactSymbolReference | undefined; +} + export interface BatchRequestsParams { requests: readonly BatchRequest[] | null; continuationToken?: string | undefined; @@ -443,17 +460,6 @@ export interface GetSymbolAtPositionParams { position: number; } -export interface SymbolResponse { - reference: SymbolReference; - name: string; - flags: number; - checkFlags: number; - declarations?: string[] | undefined; - valueDeclaration?: string | undefined; - parent?: CompactSymbolReference | undefined; - exportSymbol?: CompactSymbolReference | undefined; -} - export interface GetSymbolsAtPositionsParams { snapshot: number; project: ProjectId; @@ -1104,6 +1110,22 @@ export interface SourceFileDescriptor { nodeId: string; } +/** SymbolReference identifies a symbol and its server-resolvable owner. */ +export interface SymbolReference extends SymbolOwner { + id: number; +} + +/** + * CompactSymbolReference is embedded in other responses. It identifies a cached + * symbol without repeating its owning file's full descriptor: File is the owning source file's + * node ID, or empty for a symbol owned by the response's snapshot. When the client has not cached + * the symbol, it fetches a full SymbolResponse through the corresponding property method. + */ +export interface CompactSymbolReference { + id: number; + file?: string | undefined; +} + export interface BatchRequest { method: | "batchRequests" @@ -1214,6 +1236,7 @@ export interface BatchRequest { | "getSuggestionDiagnostics" | "getSymbolAtLocation" | "getSymbolAtPosition" + | "getSymbolOfDeclaration" | "getSymbolOfSourceFile" | "getSymbolOfType" | "getSymbolsAtLocations" @@ -1391,6 +1414,7 @@ export interface BatchResponse { | "getSuggestionDiagnostics" | "getSymbolAtLocation" | "getSymbolAtPosition" + | "getSymbolOfDeclaration" | "getSymbolOfSourceFile" | "getSymbolOfType" | "getSymbolsAtLocations" @@ -1718,22 +1742,6 @@ export interface TranspileOptions { reportDiagnostics?: boolean | undefined; } -/** SymbolReference identifies a symbol and its server-resolvable owner. */ -export interface SymbolReference extends SymbolOwner { - id: number; -} - -/** - * CompactSymbolReference is embedded in other responses. It identifies a cached - * symbol without repeating its owning file's full descriptor: File is the owning source file's - * node ID, or empty for a symbol owned by the response's snapshot. When the client has not cached - * the symbol, it fetches a full SymbolResponse through the corresponding property method. - */ -export interface CompactSymbolReference { - id: number; - file?: string | undefined; -} - export interface PackageId { name: string; subModuleName: string; @@ -1775,6 +1783,13 @@ export interface EmitOutputFile { sourceFileName?: string | undefined; } +export interface SymbolOwner { + kind: SymbolOwnerKind; + file?: SourceFileDescriptor | undefined; + snapshot?: number | undefined; + project?: ProjectId | undefined; +} + export interface CreateSnapshotProgramParams { rootFiles: readonly DocumentIdentifier[] | null; compilerOptions: CompilerOptions; @@ -1834,13 +1849,6 @@ export interface ModuleResolutionEntry { result: StaticModuleResolution; } -export interface SymbolOwner { - kind: SymbolOwnerKind; - file?: SourceFileDescriptor | undefined; - snapshot?: number | undefined; - project?: ProjectId | undefined; -} - /** CompletionEntryLabelDetailsResponse holds additional label display text for a completion entry. */ export interface CompletionEntryLabelDetailsResponse { detail?: string | undefined; diff --git a/packages/typescript/src/api/sourceFileCache.ts b/packages/typescript/src/api/sourceFileCache.ts index d53035de12134..0a1938e0ef50a 100644 --- a/packages/typescript/src/api/sourceFileCache.ts +++ b/packages/typescript/src/api/sourceFileCache.ts @@ -47,7 +47,11 @@ export interface CachedSourceFile { /** Set of snapshot/project or direct-lease ref keys that reference this entry */ refs: Set; /** Binder symbols owned by this exact source-file incarnation. */ - readonly symbols: Map; + readonly symbolsById: Map; + /** Successfully resolved declaration symbols, including known-absent entries. */ + readonly symbolsByDeclarationNodeIndex: Map; + /** In-flight async declaration symbol lookups. */ + readonly declarationSymbolRequests: Map>; } /** @@ -103,7 +107,13 @@ export class SourceFileCache { } let record = this.findDescriptor(file, entries); if (!record) { - record = this.addRecord(entries, { descriptor: file, refs: new Set(), symbols: new Map() }); + record = this.addRecord(entries, { + descriptor: file, + refs: new Set(), + symbolsById: new Map(), + symbolsByDeclarationNodeIndex: new Map(), + declarationSymbolRequests: new Map(), + }); } this.retainRecordForSnapshot(record, snapshotId, projectId); return record; @@ -127,17 +137,19 @@ export class SourceFileCache { if (!descriptorsEqual(descriptorFromFile(file), record.descriptor)) { throw new Error(`Source file does not match cached record '${record.descriptor.fileName}'`); } - return record.file ??= file; + const result = record.file ??= file; + result.symbolCache = record; + return result; } getOrCreateSymbol(record: CachedSourceFile, file: SourceFileDescriptor, id: number, create: () => TSymbol): TSymbol { if (!descriptorsEqual(file, record.descriptor)) { throw new Error(`Symbol ${id} does not belong to '${record.descriptor.fileName}'`); } - let symbol = record.symbols.get(id); + let symbol = record.symbolsById.get(id); if (!symbol) { symbol = create(); - record.symbols.set(id, symbol); + record.symbolsById.set(id, symbol); } return symbol; } @@ -171,14 +183,19 @@ export class SourceFileCache { entries = []; this.cache.set(file.path, entries); } - const existing = this.find(file, entries); - if (existing) { - existing.refs.add(ref); - existing.file ??= file; - return existing.file; + let record = this.find(file, entries); + if (!record) { + record = (file.symbolCache as CachedSourceFile | undefined) ?? { + descriptor: descriptorFromFile(file), + refs: new Set(), + symbolsById: new Map(), + symbolsByDeclarationNodeIndex: new Map(), + declarationSymbolRequests: new Map(), + }; + this.addRecord(entries, record); } - this.addRecord(entries, { file, descriptor: descriptorFromFile(file), refs: new Set([ref]), symbols: new Map() }); - return file; + record.refs.add(ref); + return this.attachFile(record, file); } private addRecord(entries: CachedSourceFile[], record: CachedSourceFile): CachedSourceFile { @@ -298,9 +315,14 @@ export class SourceFileCache { } /** - * Clear all entries from the cache. + * Drop local cache ownership without releasing server-side snapshots or leases. + * Caller-held ASTs may keep detached records alive; newly cached records need not + * preserve object identity with those detached records. */ clear(): void { + for (const record of this.recordsByNodeId.values()) { + record.refs.clear(); + } this.cache.clear(); this.snapshotProjectPaths.clear(); this.leasePaths.clear(); diff --git a/packages/typescript/src/api/sync/api.ts b/packages/typescript/src/api/sync/api.ts index d0df2729dbb76..d7f9033d446fe 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -64,6 +64,7 @@ import { getNodeId, parseNodeHandle, readSourceFileLease, + RemoteNode, RemoteSourceFile, } from "../node/node.ts"; import { Wtf8Decoder } from "../node/wtf8.ts"; @@ -139,6 +140,7 @@ import { import type { RequestTiming, TimingAccumulators, + TimingCollector, TimingInfo, } from "../timing.ts"; import { @@ -349,8 +351,10 @@ import { } from "./generatorSupport.ts"; export class API implements FormatDiagnosticsHost { - private client: Client; - private sourceFileCache: SourceFileCache; + /** @internal */ + readonly client: Client; + /** @internal */ + readonly sourceFileCache: SourceFileCache; private toPath: ((fileName: string) => Path) | undefined; private currentDirectory: string | undefined; private readonly decoder = new Wtf8Decoder(); @@ -370,6 +374,97 @@ export class API implements FormatDiagnosticsHo this.internal = new InternalAPI(this.client, this.ensureInitialized); } + /** + * Returns the declaration's stored binder symbol, or undefined for synthesized or configuration ASTs. + * Successful lookups, including missing symbols, are cached. An uncached lookup requires + * the exact source file to remain available on the server. + */ + get getSymbol(): { + (declaration: Declaration): Symbol | undefined; + gen(declaration: Declaration): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "getSymbol", + function (declaration: Declaration): Symbol | undefined { + const file = getRemoteSourceFile(declaration); + if (!file) return undefined; + const record = file.symbolCache as CachedSourceFile | undefined; + if (!record) return undefined; + const index = parseNodeHandle(getNodeId(declaration)).index; + if (record.symbolsByDeclarationNodeIndex.has(index)) return record.symbolsByDeclarationNodeIndex.get(index); + return owner.fetchDeclarationSymbol(record, index); + }, + function* (declaration: Declaration): Generator { + const file = getRemoteSourceFile(declaration); + if (!file) return undefined; + const record = file.symbolCache as CachedSourceFile | undefined; + if (!record) return undefined; + const index = parseNodeHandle(getNodeId(declaration)).index; + if (record.symbolsByDeclarationNodeIndex.has(index)) return record.symbolsByDeclarationNodeIndex.get(index); + return yield* owner.fetchDeclarationSymbol.gen(record, index); + }, + ); + } + + private get fetchDeclarationSymbol(): { + (record: CachedSourceFile, index: number): Symbol | undefined; + gen(record: CachedSourceFile, index: number): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "fetchDeclarationSymbol", + function (record: CachedSourceFile, index: number): Symbol | undefined { + const data = owner.client.apiRequest("getSymbolOfDeclaration", { + file: record.descriptor, + index, + }); + let symbol: Symbol | undefined; + if (data) { + validateSymbolResponse(data); + const reference = data.reference; + if (reference.kind !== SymbolOwnerKind.File) { + throw new Error(`Symbol ${reference.id} is not file-owned`); + } + const fileOwner: SourceFileOwner = { record, api: owner }; + symbol = owner.sourceFileCache.getOrCreateSymbol( + record, + reference.file, + reference.id, + () => new Symbol(data, { kind: SymbolOwnerKind.File, owner: fileOwner }), + ); + } + record.symbolsByDeclarationNodeIndex.set(index, symbol); + return symbol; + }, + function* (record: CachedSourceFile, index: number): Generator { + const data = yield* apiRequest("getSymbolOfDeclaration", { + file: record.descriptor, + index, + }); + let symbol: Symbol | undefined; + if (data) { + validateSymbolResponse(data); + const reference = data.reference; + if (reference.kind !== SymbolOwnerKind.File) { + throw new Error(`Symbol ${reference.id} is not file-owned`); + } + const fileOwner: SourceFileOwner = { record, api: owner }; + symbol = owner.sourceFileCache.getOrCreateSymbol( + record, + reference.file, + reference.id, + () => new Symbol(data, { kind: SymbolOwnerKind.File, owner: fileOwner }), + ); + } + record.symbolsByDeclarationNodeIndex.set(index, symbol); + return symbol; + }, + ); + } + /** * Create an API instance from an existing LSP connection's API session. * Use this when connecting to an API pipe provided by an LSP server via custom/initializeAPISession. @@ -716,7 +811,7 @@ export class API implements FormatDiagnosticsHo const view = new DataView(data.buffer, data.byteOffset, data.byteLength); const lease = readSourceFileLease(view); try { - const decoded = new RemoteSourceFile(data, this.decoder, this.client.getTimingCollector()); + const decoded = new RemoteSourceFile(data, this.decoder, this.client.getTimingCollector(), this); return this.addSourceFileLease(decoded, lease); } catch (error) { @@ -730,7 +825,7 @@ export class API implements FormatDiagnosticsHo private addSourceFileLease(sourceFile: RemoteSourceFile, lease: number): RetainedSourceFile { const cached = this.sourceFileCache.setForLease(sourceFile, lease); - const retained = new RetainedSourceFile(cached as unknown as SourceFile, lease, this.client, () => { + const retained = new RetainedSourceFile(cached as unknown as SourceFile, lease, () => { this.activeSourceFileLeases.delete(lease); this.sourceFileCache.releaseLease(lease); }); @@ -831,8 +926,6 @@ export class API implements FormatDiagnosticsHo const snapshot = new Snapshot( data, - owner.client, - owner.sourceFileCache, owner.toPath!, owner, () => { @@ -856,8 +949,6 @@ export class API implements FormatDiagnosticsHo const snapshot = new Snapshot( data, - owner.client, - owner.sourceFileCache, owner.toPath!, owner, () => { @@ -899,8 +990,6 @@ export class API implements FormatDiagnosticsHo owner.sourceFileCache.retainForSnapshot(data.snapshot, baseSnapshot.id, data.changes); const snapshot = new Snapshot( data, - owner.client, - owner.sourceFileCache, owner.toPath!, owner, () => { @@ -930,8 +1019,6 @@ export class API implements FormatDiagnosticsHo owner.sourceFileCache.retainForSnapshot(data.snapshot, baseSnapshot.id, data.changes); const snapshot = new Snapshot( data, - owner.client, - owner.sourceFileCache, owner.toPath!, owner, () => { @@ -1019,8 +1106,6 @@ export class API implements FormatDiagnosticsHo } const snapshot = new Snapshot( data, - owner.client, - owner.sourceFileCache, owner.toPath!, owner, () => { @@ -1055,8 +1140,6 @@ export class API implements FormatDiagnosticsHo } const snapshot = new Snapshot( data, - owner.client, - owner.sourceFileCache, owner.toPath!, owner, () => { @@ -1176,6 +1259,21 @@ export class API implements FormatDiagnosticsHo ); } + /** + * Drops this API's local source-file cache to reduce memory usage, for example after + * a one-time analysis of every file. Snapshots, programs, and explicit source-file + * leases remain active on the server; checker operations that do not take AST nodes + * and other server-side operations can continue. + * + * The cache normally preserves source-file and node identity across repeated fetches + * and program updates that reuse unchanged files, including ASTs fetched by resolving + * symbol node handles. Clearing it relinquishes reference-equality guarantees with + * previously obtained ASTs and file-owned symbols; subsequent fetches may create new objects. + * + * Objects still referenced by the caller are not freed or stripped of locally cached + * data. Access requiring a server request remains subject to server-side availability. + * Release those references as well to allow their memory to be reclaimed. + */ clearSourceFileCache(): void { this.sourceFileCache.clear(); } @@ -1333,19 +1431,44 @@ function sourceFileDescriptor(sourceFile: RemoteSourceFile): SourceFileDescripto }; } +function getRemoteSourceFile(node: Node): RemoteSourceFile | undefined { + if (!(node instanceof RemoteNode)) return undefined; + const file = node.getSourceFile(); + return file instanceof RemoteSourceFile && file.api ? file : undefined; +} + +function getNodeAPI(node: Node): API | undefined { + const api = getRemoteSourceFile(node)?.api; + if (!api) return undefined; + if (!(api instanceof API)) throw new Error("Source file does not belong to the sync API"); + return api; +} + +/** Looks up the declaration's symbol through its owning API, or returns undefined for ASTs without symbol state. */ +export function getSymbol(declaration: Declaration): Symbol | undefined { + const api = getNodeAPI(declaration); + return api?.getSymbol(declaration); +} + +export declare namespace getSymbol { + function gen(declaration: Declaration): Generator; +} +getSymbol.gen = function* (declaration: Declaration): Generator { + const api = getNodeAPI(declaration); + return api ? yield* api.getSymbol.gen(declaration) : undefined; +}; + /** An independently retained source file and its disposable remote-lifetime lease. */ export class RetainedSourceFile { readonly sourceFile: SourceFile; private readonly lease: number; - private readonly client: Client; private readonly onDispose: () => void; private disposed = false; private disposePromise: void | undefined; - constructor(sourceFile: SourceFile, lease: number, client: Client, onDispose: () => void) { + constructor(sourceFile: SourceFile, lease: number, onDispose: () => void) { this.sourceFile = sourceFile; this.lease = lease; - this.client = client; this.onDispose = onDispose; } @@ -1380,9 +1503,11 @@ export class RetainedSourceFile { "disposeWorker", function (): void { if (owner.disposed) return; + const api = getNodeAPI(owner.sourceFile); + if (!api) throw new Error("Retained source file has no owning API"); owner.disposed = true; try { - owner.client.apiRequest("releaseSourceFile", { lease: owner.lease }); + api.client.apiRequest("releaseSourceFile", { lease: owner.lease }); } finally { owner.onDispose(); @@ -1390,6 +1515,8 @@ export class RetainedSourceFile { }, function* (): Generator { if (owner.disposed) return; + const api = getNodeAPI(owner.sourceFile); + if (!api) throw new Error("Retained source file has no owning API"); owner.disposed = true; try { yield* apiRequest("releaseSourceFile", { lease: owner.lease }); @@ -1539,7 +1666,7 @@ export class Snapshot { readonly operation: SnapshotOperation; private projectMap: Map; private toPath: (fileName: string) => Path; - private client: Client; + private readonly api: API; private disposed: boolean = false; private disposePromise: void | undefined; private onDispose: () => void; @@ -1548,18 +1675,13 @@ export class Snapshot { private updateSnapshot: SnapshotUpdater; readonly internal: SnapshotInternalAPI; - constructor( - data: CreateSnapshotResponse, - client: Client, - sourceFileCache: SourceFileCache, - toPath: (fileName: string) => Path, - formatDiagnosticsHost: FormatDiagnosticsHost, - onDispose: () => void, - updateSnapshot: SnapshotUpdater, - baseSnapshot?: Snapshot, - ) { + private get client(): Client { + return this.api.client; + } + + constructor(data: CreateSnapshotResponse, toPath: (fileName: string) => Path, api: API, onDispose: () => void, updateSnapshot: SnapshotUpdater, baseSnapshot?: Snapshot) { this.id = data.snapshot; - this.client = client; + this.api = api; this.toPath = toPath; this.onDispose = onDispose; this.updateSnapshot = updateSnapshot; @@ -1575,7 +1697,7 @@ export class Snapshot { this.snapshotRegistry = new SnapshotObjectRegistry(this.id); for (const projData of this.projectDataMap.values()) { - const project = new Project(projData, this.id, client, sourceFileCache, toPath, formatDiagnosticsHost, this.snapshotRegistry); + const project = new Project(projData, this.id, toPath, api, this.snapshotRegistry); this.projectMap.set(projData.id, project); } @@ -1584,7 +1706,7 @@ export class Snapshot { openedFiles: data.operation.openedFiles?.map(result => ({ project: this.requireProject(result.project) })), }; - this.internal = new SnapshotInternalAPI(this.id, client); + this.internal = new SnapshotInternalAPI(this.id, api.client); } getProjects(): readonly Project[] { @@ -1866,27 +1988,25 @@ class SnapshotObjectRegistry { } class ProjectObjectRegistry { - private client: Client; private snapshotId: number; readonly project: Project; private snapshotRegistry: SnapshotObjectRegistry; - private sourceFileCache: SourceFileCache; private types: Map = new Map(); private signatures: Map = new Map(); private disposed = false; - constructor( - client: Client, - snapshotId: number, - project: Project, - snapshotRegistry: SnapshotObjectRegistry, - sourceFileCache: SourceFileCache, - ) { - this.client = client; + private get client(): Client { + return this.project.api.client; + } + + private get sourceFileCache(): SourceFileCache { + return this.project.api.sourceFileCache; + } + + constructor(snapshotId: number, project: Project, snapshotRegistry: SnapshotObjectRegistry) { this.snapshotId = snapshotId; this.project = project; this.snapshotRegistry = snapshotRegistry; - this.sourceFileCache = sourceFileCache; snapshotRegistry.addProjectRegistry(this); } @@ -1901,7 +2021,7 @@ class ProjectObjectRegistry { return this.sourceFileCache.getOrCreateSymbol(record, reference.file, reference.id, () => new Symbol(data, { kind: SymbolOwnerKind.File, - owner: { record, cache: this.sourceFileCache, client: this.client }, + owner: { record, api: this.project.api }, })); } @@ -1912,7 +2032,7 @@ class ProjectObjectRegistry { return this.snapshotRegistry.getSymbol(reference.id); } const record = this.sourceFileCache.findRecord(reference.file); - const symbol = record?.symbols.get(reference.id); + const symbol = record?.symbolsById.get(reference.id); if (record && symbol) { this.sourceFileCache.retainRecord(record, this.snapshotId, this.project.id); } @@ -2435,6 +2555,8 @@ class ProjectObjectRegistry { } export class Project { + /** @internal */ + readonly api: API; readonly id: Id; readonly configFileName: string; readonly currentDirectory: string; @@ -2448,19 +2570,11 @@ export class Project { readonly program: Program; readonly checker: Checker; readonly languageService: LanguageService; - private client: Client; private snapshotId: number; - constructor( - data: ProjectResponse, - snapshotId: number, - client: Client, - sourceFileCache: SourceFileCache, - toPath: (fileName: string) => Path, - formatDiagnosticsHost: FormatDiagnosticsHost, - snapshotRegistry: SnapshotObjectRegistry, - ) { + constructor(data: ProjectResponse, snapshotId: number, toPath: (fileName: string) => Path, api: API, snapshotRegistry: SnapshotObjectRegistry) { this.id = data.id as Id; + this.api = api; this.configFileName = data.configFileName; this.currentDirectory = data.currentDirectory; this.dirty = data.dirty; @@ -2470,24 +2584,11 @@ export class Project { this.parsedCommandLine = data.parsedCommandLine; this.compilerOptions = this.parsedCommandLine.options; this.rootFiles = this.parsedCommandLine.fileNames; - this.client = client; this.snapshotId = snapshotId; - this.program = new Program( - snapshotId, - this, - client, - sourceFileCache, - toPath, - formatDiagnosticsHost, - ); - const objectRegistry = new ProjectObjectRegistry(client, snapshotId, this, snapshotRegistry, sourceFileCache); - this.checker = new Checker( - snapshotId, - this, - client, - objectRegistry, - ); - this.languageService = new LanguageService(snapshotId, this, client, objectRegistry); + this.program = new Program(snapshotId, this, toPath); + const objectRegistry = new ProjectObjectRegistry(snapshotId, this, snapshotRegistry); + this.checker = new Checker(snapshotId, this, objectRegistry); + this.languageService = new LanguageService(snapshotId, this, objectRegistry); } /** @deprecated Use `languageService.getImportAdderEdits`. */ @@ -2534,18 +2635,15 @@ export class Project { export class LanguageService { private snapshotId: number; private project: Project; - private client: Client; private objectRegistry: ProjectObjectRegistry; - constructor( - snapshotId: number, - project: Project, - client: Client, - objectRegistry: ProjectObjectRegistry, - ) { + private get client(): Client { + return this.project.api.client; + } + + constructor(snapshotId: number, project: Project, objectRegistry: ProjectObjectRegistry) { this.snapshotId = snapshotId; this.project = project; - this.client = client; this.objectRegistry = objectRegistry; } @@ -2780,30 +2878,25 @@ export class Program implements FormatDiagnost readonly snapshotId: number; readonly id: Id; private readonly project: Project; - private readonly client: Client; - private readonly sourceFileCache: SourceFileCache; private readonly toPath: (fileName: string) => Path; - private readonly formatDiagnosticsHost: FormatDiagnosticsHost; private readonly decoder = new Wtf8Decoder(); private readonly sourceFileMetadataCache = new Map(); private ownedSnapshot: Snapshot | undefined; private disposePromise: void | undefined; - constructor( - snapshotId: number, - project: Project, - client: Client, - sourceFileCache: SourceFileCache, - toPath: (fileName: string) => Path, - formatDiagnosticsHost: FormatDiagnosticsHost, - ) { + private get client(): Client { + return this.project.api.client; + } + + private get sourceFileCache(): SourceFileCache { + return this.project.api.sourceFileCache; + } + + constructor(snapshotId: number, project: Project, toPath: (fileName: string) => Path) { this.snapshotId = snapshotId; this.id = project.id; this.project = project; - this.client = client; - this.sourceFileCache = sourceFileCache; this.toPath = toPath; - this.formatDiagnosticsHost = formatDiagnosticsHost; } getCurrentDirectory(): string { @@ -2811,7 +2904,7 @@ export class Program implements FormatDiagnost } getCanonicalFileName(fileName: string): string { - return this.formatDiagnosticsHost.getCanonicalFileName(fileName); + return this.project.api.getCanonicalFileName(fileName); } getNewLine(): string { @@ -2898,7 +2991,7 @@ export class Program implements FormatDiagnost } // Create a new RemoteSourceFile and cache it (set returns existing if hash matches) - const decoded = new RemoteSourceFile(binaryData, owner.decoder, owner.client.getTimingCollector()); + const decoded = new RemoteSourceFile(binaryData, owner.decoder, owner.client.getTimingCollector(), owner.project.api); return owner.sourceFileCache.set(decoded, owner.snapshotId, owner.project.id) as unknown as SourceFile; }, function* (file: DocumentIdentifier): Generator { @@ -2924,7 +3017,7 @@ export class Program implements FormatDiagnost } // Create a new RemoteSourceFile and cache it (set returns existing if hash matches) - const decoded = new RemoteSourceFile(binaryData, owner.decoder, owner.client.getTimingCollector()); + const decoded = new RemoteSourceFile(binaryData, owner.decoder, owner.client.getTimingCollector(), owner.project.api); return owner.sourceFileCache.set(decoded, owner.snapshotId, owner.project.id) as unknown as SourceFile; }, ); @@ -3312,7 +3405,7 @@ export class Program implements FormatDiagnost return undefined; } - return new RemoteSourceFile(binaryData, owner.decoder) as unknown as SourceFile; + return new RemoteSourceFile(binaryData, owner.decoder, undefined, owner.project.api) as unknown as SourceFile; }, function* (file: DocumentIdentifier): Generator { const binaryData = sourceFileResponseToUint8Array( @@ -3326,7 +3419,7 @@ export class Program implements FormatDiagnost return undefined; } - return new RemoteSourceFile(binaryData, owner.decoder) as unknown as SourceFile; + return new RemoteSourceFile(binaryData, owner.decoder, undefined, owner.project.api) as unknown as SourceFile; }, ); } @@ -3972,20 +4065,17 @@ function toEmitOutput(response: ProtocolEmitOutputResponse): EmitOutput { export class Checker { private snapshotId: number; private project: Project; - private client: Client; private objectRegistry: ProjectObjectRegistry; private wellKnownSymbols: { unknown: number; undefined: number; arguments: number; } | undefined; private wellKnownSignatures: { unknown: number; } | undefined; - constructor( - snapshotId: number, - project: Project, - client: Client, - objectRegistry: ProjectObjectRegistry, - ) { + private get client(): Client { + return this.project.api.client; + } + + constructor(snapshotId: number, project: Project, objectRegistry: ProjectObjectRegistry) { this.snapshotId = snapshotId; this.project = project; - this.client = client; this.objectRegistry = objectRegistry; } @@ -6472,14 +6562,20 @@ export class NodeHandle { owner, "fetchOwnerFile", function (fileOwner: SourceFileOwner): RemoteSourceFile { - const data = fileOwner.client.apiRequestBinary("getCachedSourceFile", { file: fileOwner.record.descriptor }); + const data = fileOwner.api.client.apiRequestBinary("getCachedSourceFile", { file: fileOwner.record.descriptor }); if (!data) throw new Error(`Source file '${fileOwner.record.descriptor.fileName}' is not available`); - return fileOwner.cache.attachFile(fileOwner.record, new RemoteSourceFile(data, new Wtf8Decoder(), fileOwner.client.getTimingCollector())); + return fileOwner.api.sourceFileCache.attachFile( + fileOwner.record, + new RemoteSourceFile(data, new Wtf8Decoder(), fileOwner.api.client.getTimingCollector(), fileOwner.api), + ); }, function* (fileOwner: SourceFileOwner): Generator { const data = sourceFileResponseToUint8Array(yield* apiRequest("getCachedSourceFile", { file: fileOwner.record.descriptor })); if (!data) throw new Error(`Source file '${fileOwner.record.descriptor.fileName}' is not available`); - return fileOwner.cache.attachFile(fileOwner.record, new RemoteSourceFile(data, new Wtf8Decoder(), fileOwner.client.getTimingCollector())); + return fileOwner.api.sourceFileCache.attachFile( + fileOwner.record, + new RemoteSourceFile(data, new Wtf8Decoder(), fileOwner.api.client.getTimingCollector(), fileOwner.api), + ); }, ); } @@ -6503,11 +6599,10 @@ export interface SignatureUsage { call?: NodeHandle | undefined; } -/** A cached source-file record and the dependencies needed to materialize or extend it. */ +/** A cached source-file record and its owning API. */ interface SourceFileOwner { readonly record: CachedSourceFile; - readonly cache: SourceFileCache; - readonly client: Client; + readonly api: API; } type SymbolStorage = @@ -6711,9 +6806,9 @@ export class Symbol { } const fileOwner = owner.storage.owner; // A file-owned symbol's relationships are always owned by the same file. - const cached = reference.file === fileOwner.record.descriptor.nodeId ? fileOwner.record.symbols.get(reference.id) : undefined; + const cached = reference.file === fileOwner.record.descriptor.nodeId ? fileOwner.record.symbolsById.get(reference.id) : undefined; if (cached) return cached; - const data = fileOwner.client.apiRequest(method, { symbol: owner.reference }); + const data = fileOwner.api.client.apiRequest(method, { symbol: owner.reference }); if (!data) throw new Error(`${method} returned null symbol for Symbol ${owner.id}`); return owner.internFileSymbol(fileOwner, data); }, @@ -6723,7 +6818,7 @@ export class Symbol { } const fileOwner = owner.storage.owner; // A file-owned symbol's relationships are always owned by the same file. - const cached = reference.file === fileOwner.record.descriptor.nodeId ? fileOwner.record.symbols.get(reference.id) : undefined; + const cached = reference.file === fileOwner.record.descriptor.nodeId ? fileOwner.record.symbolsById.get(reference.id) : undefined; if (cached) return cached; const data = yield* apiRequest(method, { symbol: owner.reference }); if (!data) throw new Error(`${method} returned null symbol for Symbol ${owner.id}`); @@ -6745,7 +6840,7 @@ export class Symbol { return owner.storage.registry.fetchSymbols(owner, method); } const fileOwner = owner.storage.owner; - const data = fileOwner.client.apiRequest(method, { symbol: owner.reference }); + const data = fileOwner.api.client.apiRequest(method, { symbol: owner.reference }); return data?.map(symbol => owner.internFileSymbol(fileOwner, symbol)) ?? []; }, function* (method: SymbolsPropertyMethod): Generator { @@ -6763,7 +6858,7 @@ export class Symbol { validateSymbolResponse(data); const reference = data.reference; if (reference.kind !== SymbolOwnerKind.File) throw new Error(`Symbol ${reference.id} is not file-owned`); - return fileOwner.cache.getOrCreateSymbol(fileOwner.record, reference.file, reference.id, () => new Symbol(data, { kind: SymbolOwnerKind.File, owner: fileOwner })); + return fileOwner.api.sourceFileCache.getOrCreateSymbol(fileOwner.record, reference.file, reference.id, () => new Symbol(data, { kind: SymbolOwnerKind.File, owner: fileOwner })); } } diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index 4abec037a51cd..f086696498b47 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -19,6 +19,8 @@ import { isJSDocParameterTag, isModuleDeclaration, isNamedImports, + isObjectLiteralExpression, + isPropertyAssignment, isReturnStatement, isShorthandPropertyAssignment, isStringLiteral, @@ -33,11 +35,13 @@ import { type NodeArray, NodeFlags, type Path, + type SourceFile, SyntaxKind, tryGetAmbientModuleNameFromSymbolName, unescapeLeadingUnderscores, } from "@typescript/typescript/unstable/ast"; import { + cloneNode, createArrayTypeNode, createFunctionTypeNode, createIdentifier, @@ -64,6 +68,7 @@ import { type DocumentIdentifier, EmitOnly, type FreshableType, + getSymbol, type ImportAdderAction, type IndexedAccessType, IndexKind, @@ -116,6 +121,11 @@ import { test, } from "node:test"; import { fileURLToPath } from "node:url"; +import { + getNodeId, + parseNodeHandle, + RemoteSourceFile, +} from "../../src/api/node/node.ts"; import { isSignatureDeclaration } from "../../src/ast/is.ts"; import { areTestsFiltered, @@ -495,6 +505,163 @@ describe("API", { concurrency }, () => { assert.strictEqual(retainedAfterDispose.sourceFile, retainedAgain.sourceFile); }); + test("remote declarations lazily fetch and cache binder symbols", async () => { + const sourceText = "function present() {}\nimport {} from './missing';"; + await using api = spawnAPI({ + "/symbols.ts": sourceText, + }); + const snapshot = await api.createSnapshot({ openFiles: ["/symbols.ts"] }); + const project = snapshot.getProjects()[0]; + const sourceFile = await project.program.getSourceFile("/symbols.ts"); + assert.ok(sourceFile); + const declaration = sourceFile.statements[0]; + const withoutSymbol = sourceFile.statements[1]; + assert.ok(isFunctionDeclaration(declaration)); + assert.ok(isImportDeclaration(withoutSymbol)); + const checkerSymbol = await project.checker.getSymbolAtPosition("/symbols.ts", sourceText.indexOf("present")); // @sync: const checkerSymbol = project.checker.getSymbolAtPosition("/symbols.ts", sourceText.indexOf("present")); + assert.ok(checkerSymbol); + + const client = (api as unknown as { + client: { apiRequest(method: string, params: unknown): Promise; }; + }).client; + const apiRequest = client.apiRequest.bind(client); + let symbolRequests = 0; + client.apiRequest = async (method, params) => { + if (method === "getSymbolOfDeclaration") symbolRequests++; + return apiRequest(method, params); + }; + + const [first, concurrent] = await Promise.all([getSymbol(declaration), api.getSymbol(declaration)]); // @sync: const first = getSymbol(declaration); const concurrent = api.getSymbol(declaration); + assert.ok(first); + assert.strictEqual(first, checkerSymbol); + assert.strictEqual(concurrent, first); + assert.strictEqual(await getSymbol(declaration), first); // @sync: assert.strictEqual(getSymbol(declaration), first); + assert.equal(symbolRequests, 1); + + symbolRequests = 0; + const [absent, concurrentAbsent] = await Promise.all([getSymbol(withoutSymbol), api.getSymbol(withoutSymbol)]); // @sync: const absent = getSymbol(withoutSymbol); const concurrentAbsent = api.getSymbol(withoutSymbol); + assert.equal(absent, undefined); + assert.equal(concurrentAbsent, undefined); + assert.equal(await getSymbol(withoutSymbol), undefined); // @sync: assert.equal(getSymbol(withoutSymbol), undefined); + assert.equal(symbolRequests, 1); + + await snapshot.dispose(); + }); + + test("getSymbol returns undefined for synthesized declarations", async () => { + await using api = spawnAPI(); + const local = createVariableDeclaration(createIdentifier("local"), undefined, undefined, undefined); + assert.equal(await getSymbol(local), undefined); + assert.equal(await api.getSymbol(local), undefined); + + await using lease = await api.createSourceFile("/symbols.ts", "function present() {}"); + const declaration = cast(lease.sourceFile.statements[0], isFunctionDeclaration); + assert.ok(await getSymbol(declaration)); + const shallowClone = cloneNode(declaration); + const deepClone = getSynthesizedDeepClone(declaration); + assert.equal(await getSymbol(shallowClone), undefined); + assert.equal(await api.getSymbol(deepClone), undefined); + assert.equal("getSymbol" in declaration, false); + }); + + test("source files own separate declaration result and request caches", async () => { + await using api = spawnAPI(); + await using lease = await api.createSourceFile("/symbols.ts", "function present() {}\nimport {} from './missing';"); + const file = lease.sourceFile; + assert.ok(file instanceof RemoteSourceFile); + const cache = file.symbolCache; + assert.ok(cache); + const declaration = cast(file.statements[0], isFunctionDeclaration); + const index = parseNodeHandle(getNodeId(declaration)).index; + const request = getSymbol(declaration); + assert.ok(cache.declarationSymbolRequests.get(index) instanceof Promise); // @sync-skip + assert.equal(cache.symbolsByDeclarationNodeIndex.has(index), false); // @sync-skip + const symbol = await request; + assert.ok(symbol); + assert.strictEqual(cache.symbolsByDeclarationNodeIndex.get(index), symbol); + assert.strictEqual(cache.symbolsById.get(symbol.reference.id), symbol); + assert.equal(cache.declarationSymbolRequests.has(index), false); + const withoutSymbol = cast(file.statements[1], isImportDeclaration); + const absentIndex = parseNodeHandle(getNodeId(withoutSymbol)).index; + assert.equal(await getSymbol(withoutSymbol), undefined); + assert.ok(cache.symbolsByDeclarationNodeIndex.has(absentIndex)); + assert.equal(cache.symbolsByDeclarationNodeIndex.get(absentIndex), undefined); + assert.equal(cache.declarationSymbolRequests.has(absentIndex), false); + api.clearSourceFileCache(); + assert.strictEqual(file.symbolCache, cache); + }); + + test("standalone getSymbol delegates to the source file's API", async context => { + await using api = spawnAPI(); + await using lease = await api.createSourceFile("/symbols.ts", "function present() {}"); + assert.ok(lease.sourceFile instanceof RemoteSourceFile); + assert.strictEqual(lease.sourceFile.api, api); + const declaration = cast(lease.sourceFile.statements[0], isFunctionDeclaration); + // Materialize the sync API's lazy method before mocking it. + void api.getSymbol; + const method = context.mock.method(api, "getSymbol"); + const symbol = await getSymbol(declaration); + assert.ok(symbol); + assert.equal(method.mock.callCount(), 1); + assert.strictEqual(await api.getSymbol(declaration), symbol); + }); + + test("declaration symbol results survive releasing the last lease", async () => { + await using api = spawnAPI(); + const lease = await api.createSourceFile("/symbols.ts", "function present() {}\nimport {} from './missing';"); + const declaration = cast(lease.sourceFile.statements[0], isFunctionDeclaration); + const withoutSymbol = cast(lease.sourceFile.statements[1], isImportDeclaration); + const symbol = await getSymbol(declaration); + assert.ok(symbol); + assert.equal(await getSymbol(withoutSymbol), undefined); + + await lease.dispose(); + assert.strictEqual(await getSymbol(declaration), symbol); + assert.equal(await getSymbol(withoutSymbol), undefined); + }); + + test("declaration symbols can be fetched after clearing the client cache", async () => { + await using api = spawnAPI(); + await using lease = await api.createSourceFile("/symbols.ts", "function first() {} function second() {}"); + const first = cast(lease.sourceFile.statements[0], isFunctionDeclaration); + const second = cast(lease.sourceFile.statements[1], isFunctionDeclaration); + const firstSymbol = await getSymbol(first); + assert.ok(firstSymbol); + + api.clearSourceFileCache(); + const secondSymbol = await getSymbol(second); + assert.ok(secondSymbol); + assert.equal(secondSymbol.name, "second"); + assert.strictEqual(await getSymbol(first), firstSymbol); + + await using retained = await api.retainSourceFile(lease.sourceFile); + assert.strictEqual(retained.sourceFile, lease.sourceFile); + assert.strictEqual(await getSymbol(first), firstSymbol); + assert.strictEqual(await getSymbol(second), secondSymbol); + }); + + test("declaration lookup rejects unavailable and recreated files without caching failures", async () => { + await using api = spawnAPI(); + const text = "function present() {}"; + const lease = await api.createSourceFile("/symbols.ts", text); + const declaration = cast(lease.sourceFile.statements[0], isFunctionDeclaration); + assert.ok(lease.sourceFile instanceof RemoteSourceFile); + const cache = lease.sourceFile.symbolCache; + assert.ok(cache); + const index = parseNodeHandle(getNodeId(declaration)).index; + await lease.dispose(); + + await assert.rejects(getSymbol(declaration), /source file is not available/); // @sync: assert.throws(() => getSymbol(declaration), /source file is not available/); + assert.equal(cache.symbolsByDeclarationNodeIndex.has(index), false); + assert.equal(cache.declarationSymbolRequests.has(index), false); + await using recreated = await api.createSourceFile("/symbols.ts", text); + await assert.rejects(getSymbol(declaration), /source file descriptor no longer identifies/); // @sync: assert.throws(() => getSymbol(declaration), /source file descriptor no longer identifies/); + assert.equal(cache.symbolsByDeclarationNodeIndex.has(index), false); + assert.equal(cache.declarationSymbolRequests.has(index), false); + const recreatedDeclaration = cast(recreated.sourceFile.statements[0], isFunctionDeclaration); + assert.equal((await getSymbol(recreatedDeclaration))?.name, "present"); + }); + test("createSourceFile can be used with a compatible program", async () => { const sourceText = "export const element =
;"; await using api = spawnAPI({ @@ -3632,6 +3799,7 @@ export const value = 1; assert.ok(declaration); assert.equal(declaration.getSourceFile().fileName, "/src/mod.ts"); assert.equal((await api.getTimingInfo()).totals.sourceFilesFetched, 1); + assert.equal((await getSymbol(cast(declaration, isClassDeclaration)))?.name, "Animal"); }); test("file-owned symbol properties do not require a snapshot or project", async () => { @@ -3643,6 +3811,10 @@ export const value = 1; await using retained = await api.retainSourceFile(sourceFile); const animal = await project.checker.getSymbolAtPosition("/src/mod.ts", symbolFiles["/src/mod.ts"].indexOf("Animal")); assert.ok(animal); + const storage = animal["storage"]; + assert.ok("owner" in storage); + assert.strictEqual(storage.owner.api, api); + assert.deepEqual(Object.keys(storage.owner).sort(), ["api", "record"]); await snapshot.dispose(); const members = await animal.getMembers(); @@ -8176,6 +8348,10 @@ describe("Program - diagnostics", { concurrency }, () => { assert.ok(rootConfig); assert.equal(rootConfig.fileName, "/tsconfig.json"); assert.equal(await project.program.getSourceFile("/tsconfig.json"), undefined); + const configObject = cast(cast(rootConfig.statements[0], isExpressionStatement).expression, isObjectLiteralExpression); + const configProperty = cast(configObject.properties[0], isPropertyAssignment); + assert.equal(await getSymbol(configProperty), undefined); + assert.equal(await api.getSymbol(configProperty), undefined); fs.writeFile!("/tsconfig.base.json", `{ "compilerOptions": { "strict": false } }`); const extendedConfig = await project.program.getConfigSourceFile("/tsconfig.base.json"); diff --git a/packages/typescript/test/sync/api-generators.test.ts b/packages/typescript/test/sync/api-generators.test.ts index 0ce4d0aa376f9..c764b630131d1 100644 --- a/packages/typescript/test/sync/api-generators.test.ts +++ b/packages/typescript/test/sync/api-generators.test.ts @@ -21,6 +21,7 @@ import { type SourceFile, SyntaxKind, } from "@typescript/typescript/unstable/ast"; +import { cloneNode } from "@typescript/typescript/unstable/ast/factory"; import type { APIRequest, APIResponse, @@ -34,6 +35,7 @@ import { type ConditionalType, defer, type DeferredAPIRequestGenerator, + getSymbol, type IndexedAccessType, type IndexInfo, IndexKind, @@ -160,6 +162,7 @@ const publicGeneratorExemptions = new Map([ ]); const privateGeneratorGetters = new Set([ "API.ensureInitialized", + "API.fetchDeclarationSymbol", "API.initializeWorker", "API.updateSnapshot", "Checker.getIntrinsicType", @@ -437,6 +440,19 @@ function assertPublicGeneratorCoverage(owners: readonly { readonly name: string; } describe("API - generator batching", { concurrency: areTestsFiltered() }, () => { + test("looks up binder symbols through standalone and API generators", () => { + using api = spawnAPI(); + using lease = api.createSourceFile("/symbols.ts", "function present() {}"); + const declaration = cast(lease.sourceFile.statements[0], isFunctionDeclaration); + const [symbol] = api.batch(getSymbol.gen(declaration)); + assert.ok(symbol); + assert.equal(symbol.name, "present"); + const [cached] = api.batch(api.getSymbol.gen(declaration)); + assert.strictEqual(cached, symbol); + const clone = cloneNode(declaration); + assert.deepEqual(api.batch(getSymbol.gen(clone), api.getSymbol.gen(clone)), [undefined, undefined]); + }); + test("batches source file requests", context => { const api = spawnAPI(parityFiles); context.after(() => api.close()); @@ -1570,6 +1586,7 @@ describe("API - generator batching", { concurrency: areTestsFiltered() }, () => parityCase("API", "createSourceFile", api.createSourceFile, assertRetainedSourceFilesEquivalent, "/generated.ts", "export const generated = true;"), parityCase("API", "createSourceFileFromFile", api.createSourceFileFromFile, assertRetainedSourceFilesEquivalent, "/src/index.ts"), parityCase("API", "retainSourceFile", api.retainSourceFile, assertRetainedSourceFilesEquivalent, indexFile), + parityCase("API", "getSymbol", api.getSymbol, assertOptionalSymbolsEquivalent, combineDeclaration), parityCase("API", "transpileModule", api.transpileModule, assertDeepEquivalent, "export const value: number = 1;", { compilerOptions: { module: 99 } }), parityCase("API", "transpileModuleFromFile", api.transpileModuleFromFile, assertDeepEquivalent, "/src/index.ts"), parityCase("API", "transpileDeclaration", api.transpileDeclaration, assertDeepEquivalent, "export function declared(value: string): number { return value.length; }"), diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index e12633b6d64fd..026b771ff42a5 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -27,6 +27,8 @@ import { isJSDocParameterTag, isModuleDeclaration, isNamedImports, + isObjectLiteralExpression, + isPropertyAssignment, isReturnStatement, isShorthandPropertyAssignment, isStringLiteral, @@ -41,11 +43,13 @@ import { type NodeArray, NodeFlags, type Path, + type SourceFile, SyntaxKind, tryGetAmbientModuleNameFromSymbolName, unescapeLeadingUnderscores, } from "@typescript/typescript/unstable/ast"; import { + cloneNode, createArrayTypeNode, createFunctionTypeNode, createIdentifier, @@ -79,6 +83,7 @@ import { type DocumentIdentifier, EmitOnly, type FreshableType, + getSymbol, type ImportAdderAction, type IndexedAccessType, IndexKind, @@ -124,6 +129,11 @@ import { test, } from "node:test"; import { fileURLToPath } from "node:url"; +import { + getNodeId, + parseNodeHandle, + RemoteSourceFile, +} from "../../src/api/node/node.ts"; import { isSignatureDeclaration } from "../../src/ast/is.ts"; import { areTestsFiltered, @@ -483,6 +493,163 @@ describe("API", { concurrency }, () => { assert.strictEqual(retainedAfterDispose.sourceFile, retainedAgain.sourceFile); }); + test("remote declarations lazily fetch and cache binder symbols", () => { + const sourceText = "function present() {}\nimport {} from './missing';"; + using api = spawnAPI({ + "/symbols.ts": sourceText, + }); + const snapshot = api.createSnapshot({ openFiles: ["/symbols.ts"] }); + const project = snapshot.getProjects()[0]; + const sourceFile = project.program.getSourceFile("/symbols.ts"); + assert.ok(sourceFile); + const declaration = sourceFile.statements[0]; + const withoutSymbol = sourceFile.statements[1]; + assert.ok(isFunctionDeclaration(declaration)); + assert.ok(isImportDeclaration(withoutSymbol)); + const checkerSymbol = project.checker.getSymbolAtPosition("/symbols.ts", sourceText.indexOf("present")); + assert.ok(checkerSymbol); + + const client = (api as unknown as { + client: { apiRequest(method: string, params: unknown): unknown; }; + }).client; + const apiRequest = client.apiRequest.bind(client); + let symbolRequests = 0; + client.apiRequest = (method, params) => { + if (method === "getSymbolOfDeclaration") symbolRequests++; + return apiRequest(method, params); + }; + + const first = getSymbol(declaration); + const concurrent = api.getSymbol(declaration); + assert.ok(first); + assert.strictEqual(first, checkerSymbol); + assert.strictEqual(concurrent, first); + assert.strictEqual(getSymbol(declaration), first); + assert.equal(symbolRequests, 1); + + symbolRequests = 0; + const absent = getSymbol(withoutSymbol); + const concurrentAbsent = api.getSymbol(withoutSymbol); + assert.equal(absent, undefined); + assert.equal(concurrentAbsent, undefined); + assert.equal(getSymbol(withoutSymbol), undefined); + assert.equal(symbolRequests, 1); + + snapshot.dispose(); + }); + + test("getSymbol returns undefined for synthesized declarations", () => { + using api = spawnAPI(); + const local = createVariableDeclaration(createIdentifier("local"), undefined, undefined, undefined); + assert.equal(getSymbol(local), undefined); + assert.equal(api.getSymbol(local), undefined); + + using lease = api.createSourceFile("/symbols.ts", "function present() {}"); + const declaration = cast(lease.sourceFile.statements[0], isFunctionDeclaration); + assert.ok(getSymbol(declaration)); + const shallowClone = cloneNode(declaration); + const deepClone = getSynthesizedDeepClone(declaration); + assert.equal(getSymbol(shallowClone), undefined); + assert.equal(api.getSymbol(deepClone), undefined); + assert.equal("getSymbol" in declaration, false); + }); + + test("source files own separate declaration result and request caches", () => { + using api = spawnAPI(); + using lease = api.createSourceFile("/symbols.ts", "function present() {}\nimport {} from './missing';"); + const file = lease.sourceFile; + assert.ok(file instanceof RemoteSourceFile); + const cache = file.symbolCache; + assert.ok(cache); + const declaration = cast(file.statements[0], isFunctionDeclaration); + const index = parseNodeHandle(getNodeId(declaration)).index; + const request = getSymbol(declaration); + const symbol = request; + assert.ok(symbol); + assert.strictEqual(cache.symbolsByDeclarationNodeIndex.get(index), symbol); + assert.strictEqual(cache.symbolsById.get(symbol.reference.id), symbol); + assert.equal(cache.declarationSymbolRequests.has(index), false); + const withoutSymbol = cast(file.statements[1], isImportDeclaration); + const absentIndex = parseNodeHandle(getNodeId(withoutSymbol)).index; + assert.equal(getSymbol(withoutSymbol), undefined); + assert.ok(cache.symbolsByDeclarationNodeIndex.has(absentIndex)); + assert.equal(cache.symbolsByDeclarationNodeIndex.get(absentIndex), undefined); + assert.equal(cache.declarationSymbolRequests.has(absentIndex), false); + api.clearSourceFileCache(); + assert.strictEqual(file.symbolCache, cache); + }); + + test("standalone getSymbol delegates to the source file's API", context => { + using api = spawnAPI(); + using lease = api.createSourceFile("/symbols.ts", "function present() {}"); + assert.ok(lease.sourceFile instanceof RemoteSourceFile); + assert.strictEqual(lease.sourceFile.api, api); + const declaration = cast(lease.sourceFile.statements[0], isFunctionDeclaration); + // Materialize the sync API's lazy method before mocking it. + void api.getSymbol; + const method = context.mock.method(api, "getSymbol"); + const symbol = getSymbol(declaration); + assert.ok(symbol); + assert.equal(method.mock.callCount(), 1); + assert.strictEqual(api.getSymbol(declaration), symbol); + }); + + test("declaration symbol results survive releasing the last lease", () => { + using api = spawnAPI(); + const lease = api.createSourceFile("/symbols.ts", "function present() {}\nimport {} from './missing';"); + const declaration = cast(lease.sourceFile.statements[0], isFunctionDeclaration); + const withoutSymbol = cast(lease.sourceFile.statements[1], isImportDeclaration); + const symbol = getSymbol(declaration); + assert.ok(symbol); + assert.equal(getSymbol(withoutSymbol), undefined); + + lease.dispose(); + assert.strictEqual(getSymbol(declaration), symbol); + assert.equal(getSymbol(withoutSymbol), undefined); + }); + + test("declaration symbols can be fetched after clearing the client cache", () => { + using api = spawnAPI(); + using lease = api.createSourceFile("/symbols.ts", "function first() {} function second() {}"); + const first = cast(lease.sourceFile.statements[0], isFunctionDeclaration); + const second = cast(lease.sourceFile.statements[1], isFunctionDeclaration); + const firstSymbol = getSymbol(first); + assert.ok(firstSymbol); + + api.clearSourceFileCache(); + const secondSymbol = getSymbol(second); + assert.ok(secondSymbol); + assert.equal(secondSymbol.name, "second"); + assert.strictEqual(getSymbol(first), firstSymbol); + + using retained = api.retainSourceFile(lease.sourceFile); + assert.strictEqual(retained.sourceFile, lease.sourceFile); + assert.strictEqual(getSymbol(first), firstSymbol); + assert.strictEqual(getSymbol(second), secondSymbol); + }); + + test("declaration lookup rejects unavailable and recreated files without caching failures", () => { + using api = spawnAPI(); + const text = "function present() {}"; + const lease = api.createSourceFile("/symbols.ts", text); + const declaration = cast(lease.sourceFile.statements[0], isFunctionDeclaration); + assert.ok(lease.sourceFile instanceof RemoteSourceFile); + const cache = lease.sourceFile.symbolCache; + assert.ok(cache); + const index = parseNodeHandle(getNodeId(declaration)).index; + lease.dispose(); + + assert.throws(() => getSymbol(declaration), /source file is not available/); + assert.equal(cache.symbolsByDeclarationNodeIndex.has(index), false); + assert.equal(cache.declarationSymbolRequests.has(index), false); + using recreated = api.createSourceFile("/symbols.ts", text); + assert.throws(() => getSymbol(declaration), /source file descriptor no longer identifies/); + assert.equal(cache.symbolsByDeclarationNodeIndex.has(index), false); + assert.equal(cache.declarationSymbolRequests.has(index), false); + const recreatedDeclaration = cast(recreated.sourceFile.statements[0], isFunctionDeclaration); + assert.equal((getSymbol(recreatedDeclaration))?.name, "present"); + }); + test("createSourceFile can be used with a compatible program", () => { const sourceText = "export const element =
;"; using api = spawnAPI({ @@ -3466,6 +3633,7 @@ export const value = 1; assert.ok(declaration); assert.equal(declaration.getSourceFile().fileName, "/src/mod.ts"); assert.equal((api.getTimingInfo()).totals.sourceFilesFetched, 1); + assert.equal((getSymbol(cast(declaration, isClassDeclaration)))?.name, "Animal"); }); test("file-owned symbol properties do not require a snapshot or project", () => { @@ -3477,6 +3645,10 @@ export const value = 1; using retained = api.retainSourceFile(sourceFile); const animal = project.checker.getSymbolAtPosition("/src/mod.ts", symbolFiles["/src/mod.ts"].indexOf("Animal")); assert.ok(animal); + const storage = animal["storage"]; + assert.ok("owner" in storage); + assert.strictEqual(storage.owner.api, api); + assert.deepEqual(Object.keys(storage.owner).sort(), ["api", "record"]); snapshot.dispose(); const members = animal.getMembers(); @@ -7986,6 +8158,10 @@ describe("Program - diagnostics", { concurrency }, () => { assert.ok(rootConfig); assert.equal(rootConfig.fileName, "/tsconfig.json"); assert.equal(project.program.getSourceFile("/tsconfig.json"), undefined); + const configObject = cast(cast(rootConfig.statements[0], isExpressionStatement).expression, isObjectLiteralExpression); + const configProperty = cast(configObject.properties[0], isPropertyAssignment); + assert.equal(getSymbol(configProperty), undefined); + assert.equal(api.getSymbol(configProperty), undefined); fs.writeFile!("/tsconfig.base.json", `{ "compilerOptions": { "strict": false } }`); const extendedConfig = project.program.getConfigSourceFile("/tsconfig.base.json"); diff --git a/tsc/internal/api/proto.go b/tsc/internal/api/proto.go index f3c359de84991..9ffb2fbed291c 100644 --- a/tsc/internal/api/proto.go +++ b/tsc/internal/api/proto.go @@ -65,10 +65,11 @@ func SignatureHandle(sig *checker.Signature) SignatureID { } const ( - MethodRelease Method = "release" - MethodReleaseSourceFile Method = "releaseSourceFile" - MethodRetainSourceFile Method = "retainSourceFile" - MethodGetCachedSourceFile Method = "getCachedSourceFile" + MethodRelease Method = "release" + MethodReleaseSourceFile Method = "releaseSourceFile" + MethodRetainSourceFile Method = "retainSourceFile" + MethodGetCachedSourceFile Method = "getCachedSourceFile" + MethodGetSymbolOfDeclaration Method = "getSymbolOfDeclaration" MethodBatchRequests Method = "batchRequests" MethodInitialize Method = "initialize" @@ -574,6 +575,7 @@ var unmarshalers = map[Method]func([]byte) (any, error){ MethodReleaseSourceFile: unmarshallerFor[ReleaseSourceFileParams], MethodRetainSourceFile: unmarshallerFor[RetainSourceFileParams], MethodGetCachedSourceFile: unmarshallerFor[GetCachedSourceFileParams], + MethodGetSymbolOfDeclaration: unmarshallerFor[GetSymbolOfDeclarationParams], MethodInitialize: noParams, MethodCreateSnapshot: unmarshallerFor[CreateSnapshotParams], MethodUpdateSnapshot: unmarshallerFor[UpdateSnapshotParams], @@ -921,6 +923,11 @@ type GetCachedSourceFileParams struct { File SourceFileDescriptor `json:"file"` } +type GetSymbolOfDeclarationParams struct { + File SourceFileDescriptor `json:"file"` + Index uint32 `json:"index"` +} + type ProfileParams struct { Dir string `json:"dir"` } diff --git a/tsc/internal/api/session.go b/tsc/internal/api/session.go index 754450e2f071d..2836a0b8af1da 100644 --- a/tsc/internal/api/session.go +++ b/tsc/internal/api/session.go @@ -894,6 +894,8 @@ func (s *Session) HandleRequest(ctx context.Context, method string, params json. return s.handleRetainSourceFile(parsed.(*RetainSourceFileParams)) case string(MethodGetCachedSourceFile): return s.handleGetCachedSourceFile(parsed.(*GetCachedSourceFileParams)) + case string(MethodGetSymbolOfDeclaration): + return s.handleGetSymbolOfDeclaration(parsed.(*GetSymbolOfDeclarationParams)) case string(MethodInitialize): return s.handleInitialize(ctx) case string(MethodCreateSnapshot): @@ -2085,6 +2087,29 @@ func (s *Session) handleGetCachedSourceFile(params *GetCachedSourceFileParams) ( return s.encodeSourceFileResponse(lease.SourceFile()) } +// @gen-proto-nullable +func (s *Session) handleGetSymbolOfDeclaration(params *GetSymbolOfDeclarationParams) (*SymbolResponse, error) { + lease, err := s.acquireCachedSourceFile(params.File) + if err != nil { + return nil, err + } + defer lease.Release() + + table := encoder.GetNodeIndexTable(lease.SourceFile()) + if params.Index == 0 || int(params.Index) >= len(table.Nodes) { + return nil, fmt.Errorf("%w: declaration node index %d is out of range", ErrClientError, params.Index) + } + node := table.Nodes[params.Index] + if node == nil || !ast.IsDeclaration(node) { + return nil, fmt.Errorf("%w: node index %d is not a declaration", ErrClientError, params.Index) + } + symbol := node.Symbol() + if symbol == nil { + return nil, nil + } + return newFileSymbolResponse(symbol), nil +} + // acquireCachedSourceFile holds a reference to the exact ordinary cached AST identified by a // descriptor. It never parses; the caller must release the returned lease. func (s *Session) acquireCachedSourceFile(descriptor SourceFileDescriptor) (*project.SourceFileLease, error) { diff --git a/tsc/internal/api/session_createsourcefile_test.go b/tsc/internal/api/session_createsourcefile_test.go index 9d911bffa165b..9b78604e8734d 100644 --- a/tsc/internal/api/session_createsourcefile_test.go +++ b/tsc/internal/api/session_createsourcefile_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/microsoft/TypeScript/tsc/internal/api/encoder" "github.com/microsoft/TypeScript/tsc/internal/core" "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" "github.com/microsoft/TypeScript/tsc/internal/testutil/projecttestutil" @@ -165,6 +166,44 @@ func TestCreateSourceFile(t *testing.T) { assert.ErrorContains(t, err, "source file is not available") }) + t.Run("declaration symbol lookup", func(t *testing.T) { + t.Parallel() + + created, err := session.createSourceFile( + "/src/symbols.ts", + "function present() {}\nimport {} from './missing';", + CreateSourceFileOptions{}, + ) + assert.NilError(t, err) + defer created.Release() + + sourceFile := created.SourceFile() + table := encoder.GetNodeIndexTable(sourceFile) + descriptor := newSourceFileDescriptor(sourceFile) + + present, err := session.handleGetSymbolOfDeclaration(&GetSymbolOfDeclarationParams{ + File: descriptor, + Index: table.GetIndex(sourceFile.Statements.Nodes[0]), + }) + assert.NilError(t, err) + assert.Assert(t, present != nil) + assert.Equal(t, present.Name, "present") + assert.Equal(t, present.Reference.Kind, SymbolOwnerKindFile) + + absent, err := session.handleGetSymbolOfDeclaration(&GetSymbolOfDeclarationParams{ + File: descriptor, + Index: table.GetIndex(sourceFile.Statements.Nodes[1]), + }) + assert.NilError(t, err) + assert.Assert(t, absent == nil) + + _, err = session.handleGetSymbolOfDeclaration(&GetSymbolOfDeclarationParams{ + File: descriptor, + Index: 0, + }) + assert.ErrorContains(t, err, "out of range") + }) + t.Run("rejects stale node ID", func(t *testing.T) { t.Parallel() From f9654f182bc6d13a99e64382081956d758771296 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Wed, 30 Sep 2026 15:27:46 -0700 Subject: [PATCH 2/3] Minimize comments --- packages/typescript/src/api/async/api.ts | 6 ++---- packages/typescript/src/api/sync/api.ts | 21 ++------------------- 2 files changed, 4 insertions(+), 23 deletions(-) diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index 3c5fa9ecaeff4..c175bcaefc67d 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -356,9 +356,7 @@ export class API implements FormatDiagnosticsHo } /** - * Returns the declaration's stored binder symbol, or undefined for synthesized or configuration ASTs. - * Successful lookups, including missing symbols, are cached. An uncached lookup requires - * the exact source file to remain available on the server. + * Looks up the declaration's symbol. */ async getSymbol(declaration: Declaration): Promise { const file = getRemoteSourceFile(declaration); @@ -877,7 +875,7 @@ function getNodeAPI(node: Node): API | undefined { return api; } -/** Looks up the declaration's symbol through its owning API, or returns undefined for ASTs without symbol state. */ +/** Looks up the declaration's symbol. */ export async function getSymbol(declaration: Declaration): Promise { const api = getNodeAPI(declaration); return api?.getSymbol(declaration); diff --git a/packages/typescript/src/api/sync/api.ts b/packages/typescript/src/api/sync/api.ts index d7f9033d446fe..adf9c7cc2bead 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -375,9 +375,7 @@ export class API implements FormatDiagnosticsHo } /** - * Returns the declaration's stored binder symbol, or undefined for synthesized or configuration ASTs. - * Successful lookups, including missing symbols, are cached. An uncached lookup requires - * the exact source file to remain available on the server. + * Looks up the declaration's symbol. */ get getSymbol(): { (declaration: Declaration): Symbol | undefined; @@ -1259,21 +1257,6 @@ export class API implements FormatDiagnosticsHo ); } - /** - * Drops this API's local source-file cache to reduce memory usage, for example after - * a one-time analysis of every file. Snapshots, programs, and explicit source-file - * leases remain active on the server; checker operations that do not take AST nodes - * and other server-side operations can continue. - * - * The cache normally preserves source-file and node identity across repeated fetches - * and program updates that reuse unchanged files, including ASTs fetched by resolving - * symbol node handles. Clearing it relinquishes reference-equality guarantees with - * previously obtained ASTs and file-owned symbols; subsequent fetches may create new objects. - * - * Objects still referenced by the caller are not freed or stripped of locally cached - * data. Access requiring a server request remains subject to server-side availability. - * Release those references as well to allow their memory to be reclaimed. - */ clearSourceFileCache(): void { this.sourceFileCache.clear(); } @@ -1444,7 +1427,7 @@ function getNodeAPI(node: Node): API | undefined { return api; } -/** Looks up the declaration's symbol through its owning API, or returns undefined for ASTs without symbol state. */ +/** Looks up the declaration's symbol. */ export function getSymbol(declaration: Declaration): Symbol | undefined { const api = getNodeAPI(declaration); return api?.getSymbol(declaration); From be621e6917f4ca26c6f646607eccd8750866435f Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Thu, 1 Oct 2026 09:08:00 -0700 Subject: [PATCH 3/3] Make getSymbol non-optional --- packages/typescript/src/api/async/api.ts | 54 +++++------ .../typescript/src/api/proto.generated.ts | 2 +- .../typescript/src/api/sourceFileCache.ts | 6 +- packages/typescript/src/api/sync/api.ts | 96 +++++++++---------- packages/typescript/test/async/api.test.ts | 32 +++---- .../test/sync/api-generators.test.ts | 3 +- packages/typescript/test/sync/api.test.ts | 33 +++---- tsc/internal/api/session.go | 3 +- .../api/session_createsourcefile_test.go | 2 +- 9 files changed, 108 insertions(+), 123 deletions(-) diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index c175bcaefc67d..dd88a2be9b156 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -356,15 +356,16 @@ export class API implements FormatDiagnosticsHo } /** - * Looks up the declaration's symbol. + * Looks up a remote declaration's binder symbol. Throws for synthesized or configuration ASTs. */ - async getSymbol(declaration: Declaration): Promise { + async getSymbol(declaration: Declaration): Promise { const file = getRemoteSourceFile(declaration); - if (!file) return undefined; + if (!file) throw new Error(`Source file not found for declaration`); const record = file.symbolCache as CachedSourceFile | undefined; - if (!record) return undefined; + if (!record) throw new Error(`Cached source file not found for declaration`); const index = parseNodeHandle(getNodeId(declaration)).index; - if (record.symbolsByDeclarationNodeIndex.has(index)) return record.symbolsByDeclarationNodeIndex.get(index); + const cached = record.symbolsByDeclarationNodeIndex.get(index); + if (cached) return cached; // @sync-only-start // return this.fetchDeclarationSymbol(record, index); // @sync-only-end @@ -383,26 +384,23 @@ export class API implements FormatDiagnosticsHo // @sync-skip-block-end } - private async fetchDeclarationSymbol(record: CachedSourceFile, index: number): Promise { + private async fetchDeclarationSymbol(record: CachedSourceFile, index: number): Promise { const data = await this.client.apiRequest("getSymbolOfDeclaration", { file: record.descriptor, index, }); - let symbol: Symbol | undefined; - if (data) { - validateSymbolResponse(data); - const reference = data.reference; - if (reference.kind !== SymbolOwnerKind.File) { - throw new Error(`Symbol ${reference.id} is not file-owned`); - } - const fileOwner: SourceFileOwner = { record, api: this }; - symbol = this.sourceFileCache.getOrCreateSymbol( - record, - reference.file, - reference.id, - () => new Symbol(data, { kind: SymbolOwnerKind.File, owner: fileOwner }), - ); - } + validateSymbolResponse(data); + const reference = data.reference; + if (reference.kind !== SymbolOwnerKind.File) { + throw new Error(`Symbol ${reference.id} is not file-owned`); + } + const fileOwner: SourceFileOwner = { record, api: this }; + const symbol = this.sourceFileCache.getOrCreateSymbol( + record, + reference.file, + reference.id, + () => new Symbol(data, { kind: SymbolOwnerKind.File, owner: fileOwner }), + ); record.symbolsByDeclarationNodeIndex.set(index, symbol); return symbol; } @@ -875,19 +873,21 @@ function getNodeAPI(node: Node): API | undefined { return api; } -/** Looks up the declaration's symbol. */ -export async function getSymbol(declaration: Declaration): Promise { +/** Looks up a remote declaration's binder symbol through its API. Throws for non-remote declarations. */ +export async function getSymbol(declaration: Declaration): Promise { const api = getNodeAPI(declaration); - return api?.getSymbol(declaration); + if (!api) throw new Error("Source file not found for declaration"); + return api.getSymbol(declaration); } // @sync-only-start // export declare namespace getSymbol { -// function gen(declaration: Declaration): Generator; +// function gen(declaration: Declaration): Generator; // } -// getSymbol.gen = function* (declaration: Declaration): Generator { +// getSymbol.gen = function* (declaration: Declaration): Generator { // const api = getNodeAPI(declaration); -// return api ? yield* api.getSymbol.gen(declaration) : undefined; +// if (!api) throw new Error("Source file not found for declaration"); +// return yield* api.getSymbol.gen(declaration); // }; // @sync-only-end diff --git a/packages/typescript/src/api/proto.generated.ts b/packages/typescript/src/api/proto.generated.ts index 165d4d502a279..8ac50007ba978 100644 --- a/packages/typescript/src/api/proto.generated.ts +++ b/packages/typescript/src/api/proto.generated.ts @@ -26,7 +26,7 @@ export interface APIMethodInfo { releaseSourceFile: APIMethod; retainSourceFile: APIMethod; getCachedSourceFile: APIMethod; - getSymbolOfDeclaration: APIMethod; + getSymbolOfDeclaration: APIMethod; batchRequests: APIMethod; initialize: APIMethod; createSnapshot: APIMethod; diff --git a/packages/typescript/src/api/sourceFileCache.ts b/packages/typescript/src/api/sourceFileCache.ts index 0a1938e0ef50a..517112fac703f 100644 --- a/packages/typescript/src/api/sourceFileCache.ts +++ b/packages/typescript/src/api/sourceFileCache.ts @@ -48,10 +48,10 @@ export interface CachedSourceFile { refs: Set; /** Binder symbols owned by this exact source-file incarnation. */ readonly symbolsById: Map; - /** Successfully resolved declaration symbols, including known-absent entries. */ - readonly symbolsByDeclarationNodeIndex: Map; + /** Successfully resolved declaration symbols. */ + readonly symbolsByDeclarationNodeIndex: Map; /** In-flight async declaration symbol lookups. */ - readonly declarationSymbolRequests: Map>; + readonly declarationSymbolRequests: Map>; } /** diff --git a/packages/typescript/src/api/sync/api.ts b/packages/typescript/src/api/sync/api.ts index adf9c7cc2bead..cdd421a1b7ecd 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -375,88 +375,84 @@ export class API implements FormatDiagnosticsHo } /** - * Looks up the declaration's symbol. + * Looks up a remote declaration's binder symbol. Throws for synthesized or configuration ASTs. */ get getSymbol(): { - (declaration: Declaration): Symbol | undefined; - gen(declaration: Declaration): Generator; + (declaration: Declaration): Symbol; + gen(declaration: Declaration): Generator; } { const owner = this; return cacheGeneratorMethod( owner, "getSymbol", - function (declaration: Declaration): Symbol | undefined { + function (declaration: Declaration): Symbol { const file = getRemoteSourceFile(declaration); - if (!file) return undefined; + if (!file) throw new Error(`Source file not found for declaration`); const record = file.symbolCache as CachedSourceFile | undefined; - if (!record) return undefined; + if (!record) throw new Error(`Cached source file not found for declaration`); const index = parseNodeHandle(getNodeId(declaration)).index; - if (record.symbolsByDeclarationNodeIndex.has(index)) return record.symbolsByDeclarationNodeIndex.get(index); + const cached = record.symbolsByDeclarationNodeIndex.get(index); + if (cached) return cached; return owner.fetchDeclarationSymbol(record, index); }, - function* (declaration: Declaration): Generator { + function* (declaration: Declaration): Generator { const file = getRemoteSourceFile(declaration); - if (!file) return undefined; + if (!file) throw new Error(`Source file not found for declaration`); const record = file.symbolCache as CachedSourceFile | undefined; - if (!record) return undefined; + if (!record) throw new Error(`Cached source file not found for declaration`); const index = parseNodeHandle(getNodeId(declaration)).index; - if (record.symbolsByDeclarationNodeIndex.has(index)) return record.symbolsByDeclarationNodeIndex.get(index); + const cached = record.symbolsByDeclarationNodeIndex.get(index); + if (cached) return cached; return yield* owner.fetchDeclarationSymbol.gen(record, index); }, ); } private get fetchDeclarationSymbol(): { - (record: CachedSourceFile, index: number): Symbol | undefined; - gen(record: CachedSourceFile, index: number): Generator; + (record: CachedSourceFile, index: number): Symbol; + gen(record: CachedSourceFile, index: number): Generator; } { const owner = this; return cacheGeneratorMethod( owner, "fetchDeclarationSymbol", - function (record: CachedSourceFile, index: number): Symbol | undefined { + function (record: CachedSourceFile, index: number): Symbol { const data = owner.client.apiRequest("getSymbolOfDeclaration", { file: record.descriptor, index, }); - let symbol: Symbol | undefined; - if (data) { - validateSymbolResponse(data); - const reference = data.reference; - if (reference.kind !== SymbolOwnerKind.File) { - throw new Error(`Symbol ${reference.id} is not file-owned`); - } - const fileOwner: SourceFileOwner = { record, api: owner }; - symbol = owner.sourceFileCache.getOrCreateSymbol( - record, - reference.file, - reference.id, - () => new Symbol(data, { kind: SymbolOwnerKind.File, owner: fileOwner }), - ); + validateSymbolResponse(data); + const reference = data.reference; + if (reference.kind !== SymbolOwnerKind.File) { + throw new Error(`Symbol ${reference.id} is not file-owned`); } + const fileOwner: SourceFileOwner = { record, api: owner }; + const symbol = owner.sourceFileCache.getOrCreateSymbol( + record, + reference.file, + reference.id, + () => new Symbol(data, { kind: SymbolOwnerKind.File, owner: fileOwner }), + ); record.symbolsByDeclarationNodeIndex.set(index, symbol); return symbol; }, - function* (record: CachedSourceFile, index: number): Generator { + function* (record: CachedSourceFile, index: number): Generator { const data = yield* apiRequest("getSymbolOfDeclaration", { file: record.descriptor, index, }); - let symbol: Symbol | undefined; - if (data) { - validateSymbolResponse(data); - const reference = data.reference; - if (reference.kind !== SymbolOwnerKind.File) { - throw new Error(`Symbol ${reference.id} is not file-owned`); - } - const fileOwner: SourceFileOwner = { record, api: owner }; - symbol = owner.sourceFileCache.getOrCreateSymbol( - record, - reference.file, - reference.id, - () => new Symbol(data, { kind: SymbolOwnerKind.File, owner: fileOwner }), - ); + validateSymbolResponse(data); + const reference = data.reference; + if (reference.kind !== SymbolOwnerKind.File) { + throw new Error(`Symbol ${reference.id} is not file-owned`); } + const fileOwner: SourceFileOwner = { record, api: owner }; + const symbol = owner.sourceFileCache.getOrCreateSymbol( + record, + reference.file, + reference.id, + () => new Symbol(data, { kind: SymbolOwnerKind.File, owner: fileOwner }), + ); record.symbolsByDeclarationNodeIndex.set(index, symbol); return symbol; }, @@ -1427,18 +1423,20 @@ function getNodeAPI(node: Node): API | undefined { return api; } -/** Looks up the declaration's symbol. */ -export function getSymbol(declaration: Declaration): Symbol | undefined { +/** Looks up a remote declaration's binder symbol through its API. Throws for non-remote declarations. */ +export function getSymbol(declaration: Declaration): Symbol { const api = getNodeAPI(declaration); - return api?.getSymbol(declaration); + if (!api) throw new Error("Source file not found for declaration"); + return api.getSymbol(declaration); } export declare namespace getSymbol { - function gen(declaration: Declaration): Generator; + function gen(declaration: Declaration): Generator; } -getSymbol.gen = function* (declaration: Declaration): Generator { +getSymbol.gen = function* (declaration: Declaration): Generator { const api = getNodeAPI(declaration); - return api ? yield* api.getSymbol.gen(declaration) : undefined; + if (!api) throw new Error("Source file not found for declaration"); + return yield* api.getSymbol.gen(declaration); }; /** An independently retained source file and its disposable remote-lifetime lease. */ diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index f086696498b47..e36bd1382d331 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -539,28 +539,26 @@ describe("API", { concurrency }, () => { assert.equal(symbolRequests, 1); symbolRequests = 0; - const [absent, concurrentAbsent] = await Promise.all([getSymbol(withoutSymbol), api.getSymbol(withoutSymbol)]); // @sync: const absent = getSymbol(withoutSymbol); const concurrentAbsent = api.getSymbol(withoutSymbol); - assert.equal(absent, undefined); - assert.equal(concurrentAbsent, undefined); - assert.equal(await getSymbol(withoutSymbol), undefined); // @sync: assert.equal(getSymbol(withoutSymbol), undefined); - assert.equal(symbolRequests, 1); + await assert.rejects(getSymbol(withoutSymbol), /has no binder symbol/); // @sync: assert.throws(() => getSymbol(withoutSymbol), /has no binder symbol/); + await assert.rejects(api.getSymbol(withoutSymbol), /has no binder symbol/); // @sync: assert.throws(() => api.getSymbol(withoutSymbol), /has no binder symbol/); + assert.equal(symbolRequests, 2); await snapshot.dispose(); }); - test("getSymbol returns undefined for synthesized declarations", async () => { + test("getSymbol rejects synthesized declarations", async () => { await using api = spawnAPI(); const local = createVariableDeclaration(createIdentifier("local"), undefined, undefined, undefined); - assert.equal(await getSymbol(local), undefined); - assert.equal(await api.getSymbol(local), undefined); + await assert.rejects(getSymbol(local), /Source file not found/); // @sync: assert.throws(() => getSymbol(local), /Source file not found/); + await assert.rejects(api.getSymbol(local), /Source file not found/); // @sync: assert.throws(() => api.getSymbol(local), /Source file not found/); await using lease = await api.createSourceFile("/symbols.ts", "function present() {}"); const declaration = cast(lease.sourceFile.statements[0], isFunctionDeclaration); assert.ok(await getSymbol(declaration)); const shallowClone = cloneNode(declaration); const deepClone = getSynthesizedDeepClone(declaration); - assert.equal(await getSymbol(shallowClone), undefined); - assert.equal(await api.getSymbol(deepClone), undefined); + await assert.rejects(getSymbol(shallowClone), /Source file not found/); // @sync: assert.throws(() => getSymbol(shallowClone), /Source file not found/); + await assert.rejects(api.getSymbol(deepClone), /Source file not found/); // @sync: assert.throws(() => api.getSymbol(deepClone), /Source file not found/); assert.equal("getSymbol" in declaration, false); }); @@ -583,9 +581,8 @@ describe("API", { concurrency }, () => { assert.equal(cache.declarationSymbolRequests.has(index), false); const withoutSymbol = cast(file.statements[1], isImportDeclaration); const absentIndex = parseNodeHandle(getNodeId(withoutSymbol)).index; - assert.equal(await getSymbol(withoutSymbol), undefined); - assert.ok(cache.symbolsByDeclarationNodeIndex.has(absentIndex)); - assert.equal(cache.symbolsByDeclarationNodeIndex.get(absentIndex), undefined); + await assert.rejects(getSymbol(withoutSymbol), /has no binder symbol/); // @sync: assert.throws(() => getSymbol(withoutSymbol), /has no binder symbol/); + assert.equal(cache.symbolsByDeclarationNodeIndex.has(absentIndex), false); assert.equal(cache.declarationSymbolRequests.has(absentIndex), false); api.clearSourceFileCache(); assert.strictEqual(file.symbolCache, cache); @@ -608,16 +605,13 @@ describe("API", { concurrency }, () => { test("declaration symbol results survive releasing the last lease", async () => { await using api = spawnAPI(); - const lease = await api.createSourceFile("/symbols.ts", "function present() {}\nimport {} from './missing';"); + const lease = await api.createSourceFile("/symbols.ts", "function present() {}"); const declaration = cast(lease.sourceFile.statements[0], isFunctionDeclaration); - const withoutSymbol = cast(lease.sourceFile.statements[1], isImportDeclaration); const symbol = await getSymbol(declaration); assert.ok(symbol); - assert.equal(await getSymbol(withoutSymbol), undefined); await lease.dispose(); assert.strictEqual(await getSymbol(declaration), symbol); - assert.equal(await getSymbol(withoutSymbol), undefined); }); test("declaration symbols can be fetched after clearing the client cache", async () => { @@ -8350,8 +8344,8 @@ describe("Program - diagnostics", { concurrency }, () => { assert.equal(await project.program.getSourceFile("/tsconfig.json"), undefined); const configObject = cast(cast(rootConfig.statements[0], isExpressionStatement).expression, isObjectLiteralExpression); const configProperty = cast(configObject.properties[0], isPropertyAssignment); - assert.equal(await getSymbol(configProperty), undefined); - assert.equal(await api.getSymbol(configProperty), undefined); + await assert.rejects(getSymbol(configProperty), /Cached source file not found/); // @sync: assert.throws(() => getSymbol(configProperty), /Cached source file not found/); + await assert.rejects(api.getSymbol(configProperty), /Cached source file not found/); // @sync: assert.throws(() => api.getSymbol(configProperty), /Cached source file not found/); fs.writeFile!("/tsconfig.base.json", `{ "compilerOptions": { "strict": false } }`); const extendedConfig = await project.program.getConfigSourceFile("/tsconfig.base.json"); diff --git a/packages/typescript/test/sync/api-generators.test.ts b/packages/typescript/test/sync/api-generators.test.ts index c764b630131d1..971e926caf559 100644 --- a/packages/typescript/test/sync/api-generators.test.ts +++ b/packages/typescript/test/sync/api-generators.test.ts @@ -450,7 +450,8 @@ describe("API - generator batching", { concurrency: areTestsFiltered() }, () => const [cached] = api.batch(api.getSymbol.gen(declaration)); assert.strictEqual(cached, symbol); const clone = cloneNode(declaration); - assert.deepEqual(api.batch(getSymbol.gen(clone), api.getSymbol.gen(clone)), [undefined, undefined]); + assert.throws(() => api.batch(getSymbol.gen(clone)), /Source file not found/); + assert.throws(() => api.batch(api.getSymbol.gen(clone)), /Source file not found/); }); test("batches source file requests", context => { diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index 026b771ff42a5..31550905ffa43 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -528,29 +528,26 @@ describe("API", { concurrency }, () => { assert.equal(symbolRequests, 1); symbolRequests = 0; - const absent = getSymbol(withoutSymbol); - const concurrentAbsent = api.getSymbol(withoutSymbol); - assert.equal(absent, undefined); - assert.equal(concurrentAbsent, undefined); - assert.equal(getSymbol(withoutSymbol), undefined); - assert.equal(symbolRequests, 1); + assert.throws(() => getSymbol(withoutSymbol), /has no binder symbol/); + assert.throws(() => api.getSymbol(withoutSymbol), /has no binder symbol/); + assert.equal(symbolRequests, 2); snapshot.dispose(); }); - test("getSymbol returns undefined for synthesized declarations", () => { + test("getSymbol rejects synthesized declarations", () => { using api = spawnAPI(); const local = createVariableDeclaration(createIdentifier("local"), undefined, undefined, undefined); - assert.equal(getSymbol(local), undefined); - assert.equal(api.getSymbol(local), undefined); + assert.throws(() => getSymbol(local), /Source file not found/); + assert.throws(() => api.getSymbol(local), /Source file not found/); using lease = api.createSourceFile("/symbols.ts", "function present() {}"); const declaration = cast(lease.sourceFile.statements[0], isFunctionDeclaration); assert.ok(getSymbol(declaration)); const shallowClone = cloneNode(declaration); const deepClone = getSynthesizedDeepClone(declaration); - assert.equal(getSymbol(shallowClone), undefined); - assert.equal(api.getSymbol(deepClone), undefined); + assert.throws(() => getSymbol(shallowClone), /Source file not found/); + assert.throws(() => api.getSymbol(deepClone), /Source file not found/); assert.equal("getSymbol" in declaration, false); }); @@ -571,9 +568,8 @@ describe("API", { concurrency }, () => { assert.equal(cache.declarationSymbolRequests.has(index), false); const withoutSymbol = cast(file.statements[1], isImportDeclaration); const absentIndex = parseNodeHandle(getNodeId(withoutSymbol)).index; - assert.equal(getSymbol(withoutSymbol), undefined); - assert.ok(cache.symbolsByDeclarationNodeIndex.has(absentIndex)); - assert.equal(cache.symbolsByDeclarationNodeIndex.get(absentIndex), undefined); + assert.throws(() => getSymbol(withoutSymbol), /has no binder symbol/); + assert.equal(cache.symbolsByDeclarationNodeIndex.has(absentIndex), false); assert.equal(cache.declarationSymbolRequests.has(absentIndex), false); api.clearSourceFileCache(); assert.strictEqual(file.symbolCache, cache); @@ -596,16 +592,13 @@ describe("API", { concurrency }, () => { test("declaration symbol results survive releasing the last lease", () => { using api = spawnAPI(); - const lease = api.createSourceFile("/symbols.ts", "function present() {}\nimport {} from './missing';"); + const lease = api.createSourceFile("/symbols.ts", "function present() {}"); const declaration = cast(lease.sourceFile.statements[0], isFunctionDeclaration); - const withoutSymbol = cast(lease.sourceFile.statements[1], isImportDeclaration); const symbol = getSymbol(declaration); assert.ok(symbol); - assert.equal(getSymbol(withoutSymbol), undefined); lease.dispose(); assert.strictEqual(getSymbol(declaration), symbol); - assert.equal(getSymbol(withoutSymbol), undefined); }); test("declaration symbols can be fetched after clearing the client cache", () => { @@ -8160,8 +8153,8 @@ describe("Program - diagnostics", { concurrency }, () => { assert.equal(project.program.getSourceFile("/tsconfig.json"), undefined); const configObject = cast(cast(rootConfig.statements[0], isExpressionStatement).expression, isObjectLiteralExpression); const configProperty = cast(configObject.properties[0], isPropertyAssignment); - assert.equal(getSymbol(configProperty), undefined); - assert.equal(api.getSymbol(configProperty), undefined); + assert.throws(() => getSymbol(configProperty), /Cached source file not found/); + assert.throws(() => api.getSymbol(configProperty), /Cached source file not found/); fs.writeFile!("/tsconfig.base.json", `{ "compilerOptions": { "strict": false } }`); const extendedConfig = project.program.getConfigSourceFile("/tsconfig.base.json"); diff --git a/tsc/internal/api/session.go b/tsc/internal/api/session.go index 2836a0b8af1da..bb52e6360836f 100644 --- a/tsc/internal/api/session.go +++ b/tsc/internal/api/session.go @@ -2087,7 +2087,6 @@ func (s *Session) handleGetCachedSourceFile(params *GetCachedSourceFileParams) ( return s.encodeSourceFileResponse(lease.SourceFile()) } -// @gen-proto-nullable func (s *Session) handleGetSymbolOfDeclaration(params *GetSymbolOfDeclarationParams) (*SymbolResponse, error) { lease, err := s.acquireCachedSourceFile(params.File) if err != nil { @@ -2105,7 +2104,7 @@ func (s *Session) handleGetSymbolOfDeclaration(params *GetSymbolOfDeclarationPar } symbol := node.Symbol() if symbol == nil { - return nil, nil + return nil, fmt.Errorf("%w: declaration node index %d has no binder symbol", ErrClientError, params.Index) } return newFileSymbolResponse(symbol), nil } diff --git a/tsc/internal/api/session_createsourcefile_test.go b/tsc/internal/api/session_createsourcefile_test.go index 9b78604e8734d..8422f7195e06b 100644 --- a/tsc/internal/api/session_createsourcefile_test.go +++ b/tsc/internal/api/session_createsourcefile_test.go @@ -194,7 +194,7 @@ func TestCreateSourceFile(t *testing.T) { File: descriptor, Index: table.GetIndex(sourceFile.Statements.Nodes[1]), }) - assert.NilError(t, err) + assert.ErrorContains(t, err, "has no binder symbol") assert.Assert(t, absent == nil) _, err = session.handleGetSymbolOfDeclaration(&GetSymbolOfDeclarationParams{