diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index 2d3d2b571a914..e38f074d1600b 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -61,8 +61,9 @@ import { import type { CompilerOptions, ConfiguredProjectId, - CreateProgramOptions, - CreateSnapshotParams, + CreateProgramOptions as ProtocolCreateProgramOptions, + CreateSnapshotParams as ProtocolCreateSnapshotParams, + CreateSnapshotProgramParams as ProtocolCreateSnapshotProgramParams, CreateSnapshotResponse, CreateSourceFileOptions, Diagnostic, @@ -73,18 +74,24 @@ import type { ImportAdderAction, InferredProjectId, IntrinsicTypeMethod, - LanguageServerSnapshotChanges, + LanguageServerSnapshotChanges as ProtocolLanguageServerSnapshotChanges, + ModuleResolutionEntry, + ModuleResolutionSpec, PackageId, ParsedCommandLine, ProjectId, ProjectReference, ProjectResponse, ReadConfigFileResponse, + ReconfigureSnapshotProgramParams as ProtocolReconfigureSnapshotProgramParams, + ResolutionMode, ResolvedModule, ResolvedTypeReferenceDirective, + ResolveModuleNameResult, SignaturePropertyMethod, SignatureResponse, SourceFileMetadata, + StaticModuleResolution, SymbolPropertyMethod, SymbolResponse, SymbolsPropertyMethod, @@ -174,8 +181,6 @@ export type { CompletionOptions, ConditionalType, ConfiguredProjectId, - CreateProgramOptions, - CreateSnapshotParams, CreateSourceFileOptions, Diagnostic, DocumentIdentifier, @@ -197,10 +202,11 @@ export type { IntersectionType, IntrinsicType, JSDocTagInfo, - LanguageServerSnapshotChanges, LiteralType, LSPConnectionOptions, MappedType, + ModuleResolutionEntry, + ModuleResolutionSpec, NumberLiteralType, ObjectType, PackageId, @@ -209,9 +215,12 @@ export type { ProjectReference, ReadConfigFileResponse, RequestTiming, + ResolutionMode, ResolvedModule, ResolvedTypeReferenceDirective, + ResolveModuleNameResult, SourceFileMetadata, + StaticModuleResolution, StringLiteralType, StringMappingType, StructuredType, @@ -234,6 +243,62 @@ export type { UnionType, }; +export interface ModuleResolverOptions { + moduleResolutions?: ModuleResolutionSpec | undefined; + resolveModuleName?: ResolveModuleNameCallback | undefined; +} + +export interface ResolveModuleNameCallbackOptions { + snapshot: Snapshot | InProgressSnapshot | undefined; +} + +declare const inProgressSnapshotBrand: unique symbol; +export type InProgressSnapshot = number & { readonly [inProgressSnapshotBrand]: never; }; + +export type ResolveModuleNameCallback = (moduleName: string, containingDirectory: string, resolutionMode: ResolutionMode | undefined, options: ResolveModuleNameCallbackOptions) => StaticModuleResolution | undefined | Promise; // @sync: export type ResolveModuleNameCallback = (moduleName: string, containingDirectory: string, resolutionMode: ResolutionMode | undefined, options: ResolveModuleNameCallbackOptions) => StaticModuleResolution | undefined; + +export type CreateProgramOptions = Omit & { + moduleResolver?: ModuleResolver | undefined; +}; +export type CreateSnapshotProgramParams = Omit & { options?: CreateProgramOptions | undefined; }; +export type ReconfigureSnapshotProgramParams = Omit & { options?: CreateProgramOptions | undefined; }; +export type CreateSnapshotParams = Omit & { + createPrograms?: readonly CreateSnapshotProgramParams[] | undefined; + reconfigurePrograms?: readonly ReconfigureSnapshotProgramParams[] | undefined; +}; +export type LanguageServerSnapshotChanges = Omit & { + createPrograms?: readonly CreateSnapshotProgramParams[] | undefined; + reconfigurePrograms?: readonly ReconfigureSnapshotProgramParams[] | undefined; +}; + +let nextModuleResolutionCallbackId = 0; +function registerModuleResolutionCallback(client: Client, callback: ResolveModuleNameCallback, getSnapshot: (id: number) => Snapshot | undefined): { name: string; dispose: () => void; } { + const name = `resolveModuleName/${++nextModuleResolutionCallbackId}`; + const dispose = client.registerCallback(name, params => { + const { moduleName, containingDirectory, resolutionMode, snapshot: snapshotId, inProgressSnapshot } = params as { + moduleName: string; + containingDirectory: string; + resolutionMode?: ResolutionMode; + snapshot?: number; + inProgressSnapshot?: number; + }; + let snapshot: Snapshot | InProgressSnapshot | undefined = snapshotId === undefined ? undefined : getSnapshot(snapshotId); + if (snapshotId !== undefined && snapshot === undefined) { + throw new Error(`Snapshot ${snapshotId} is inactive`); + } + if (inProgressSnapshot !== undefined) { + snapshot = -inProgressSnapshot as InProgressSnapshot; + } + return callback( + moduleName, + containingDirectory, + resolutionMode, + { snapshot }, + ); + }); + return { name, dispose }; +} + export interface TranspileOptions { compilerOptions?: CompilerOptions | undefined; fileName?: string | undefined; @@ -261,7 +326,7 @@ export class API implements FormatDiagnosticsHo private getCanonicalFileNameWorker: ((fileName: string) => string) | undefined; private initialized: boolean = false; private initializing: Promise | undefined; - private activeSnapshots: Set = new Set(); + private activeSnapshots: Map = new Map(); readonly printer: Printer; readonly internal: InternalAPI; @@ -403,7 +468,7 @@ export class API implements FormatDiagnosticsHo async createSnapshot(params?: CreateSnapshotParams): Promise { await this.ensureInitialized(); - const requestParams = toCreateSnapshotRequest(params); + const requestParams = toCreateSnapshotRequest(this.prepareCreateSnapshotParams(params)); const data = await this.client.apiRequest("createSnapshot", requestParams); const snapshot = new Snapshot( @@ -413,26 +478,26 @@ export class API implements FormatDiagnosticsHo this.toPath!, this, () => { - this.activeSnapshots.delete(snapshot); + this.activeSnapshots.delete(snapshot.id); this.sourceFileCache.releaseSnapshot(snapshot.id); }, this.createSnapshotUpdater(() => snapshot), undefined, ); - this.activeSnapshots.add(snapshot); + this.activeSnapshots.set(snapshot.id, snapshot); return snapshot; } private async updateSnapshot(baseSnapshot: Snapshot, params: CreateSnapshotParams): Promise { await this.ensureInitialized(); - if (!this.activeSnapshots.has(baseSnapshot) || baseSnapshot.isDisposed()) { + if (this.activeSnapshots.get(baseSnapshot.id) !== baseSnapshot || baseSnapshot.isDisposed()) { throw new Error("Cannot update an inactive snapshot"); } const data = await this.client.apiRequest("updateSnapshot", { snapshot: baseSnapshot.id, - changes: toCreateSnapshotRequest(params), + changes: toCreateSnapshotRequest(this.prepareCreateSnapshotParams(params)), }); if (data.snapshot === baseSnapshot.id) { await this.client.apiRequest("release", { snapshot: data.snapshot }); @@ -446,16 +511,40 @@ export class API implements FormatDiagnosticsHo this.toPath!, this, () => { - this.activeSnapshots.delete(snapshot); + this.activeSnapshots.delete(snapshot.id); this.sourceFileCache.releaseSnapshot(snapshot.id); }, this.createSnapshotUpdater(() => snapshot), baseSnapshot, ); - this.activeSnapshots.add(snapshot); + this.activeSnapshots.set(snapshot.id, snapshot); return snapshot; } + private prepareCreateSnapshotParams(params: CreateSnapshotParams | undefined): ProtocolCreateSnapshotParams | undefined { + if (!params) return undefined; + const prepareOptions = (options: CreateProgramOptions | undefined): ProtocolCreateProgramOptions | undefined => { + if (!options) return undefined; + const { moduleResolver, ...rest } = options; + moduleResolver?.ensureNotDisposed(); + return { + ...rest, + moduleResolver: moduleResolver?.id, + }; + }; + return { + ...params, + createPrograms: params.createPrograms?.map(program => ({ ...program, options: prepareOptions(program.options) })), + reconfigurePrograms: params.reconfigurePrograms?.map(program => ({ ...program, options: prepareOptions(program.options) })), + }; + } + + private prepareLanguageServerSnapshotChanges(changes: LanguageServerSnapshotChanges | undefined): ProtocolLanguageServerSnapshotChanges | undefined { + if (!changes) return undefined; + const prepared = this.prepareCreateSnapshotParams(changes); + return prepared; + } + private createSnapshotUpdater(getSnapshot: () => Snapshot): SnapshotUpdater { const update: SnapshotUpdater = params => this.updateSnapshot(getSnapshot(), params); // @sync: const update = ((params: CreateSnapshotParams) => this.updateSnapshot(getSnapshot(), params)) as SnapshotUpdater; // @sync-only-start @@ -486,12 +575,12 @@ export class API implements FormatDiagnosticsHo const changes = args[0] as LanguageServerSnapshotChanges | undefined; const baseSnapshot = args[1] as Snapshot | undefined; - if (baseSnapshot && (!this.activeSnapshots.has(baseSnapshot) || baseSnapshot.isDisposed())) { + if (baseSnapshot && (this.activeSnapshots.get(baseSnapshot.id) !== baseSnapshot || baseSnapshot.isDisposed())) { throw new Error("Cannot use an inactive snapshot as a response base"); } const data = await this.client.apiRequest("getCurrentLanguageServerSnapshot", { baseSnapshot: baseSnapshot?.id, - changes, + changes: this.prepareLanguageServerSnapshotChanges(changes), }); if (baseSnapshot) { this.sourceFileCache.retainForSnapshot(data.snapshot, baseSnapshot.id, data.changes); @@ -503,13 +592,13 @@ export class API implements FormatDiagnosticsHo this.toPath!, this, () => { - this.activeSnapshots.delete(snapshot); + this.activeSnapshots.delete(snapshot.id); this.sourceFileCache.releaseSnapshot(snapshot.id); }, this.createSnapshotUpdater(() => snapshot), baseSnapshot, ); - this.activeSnapshots.add(snapshot); + this.activeSnapshots.set(snapshot.id, snapshot); return snapshot; } @@ -521,7 +610,7 @@ export class API implements FormatDiagnosticsHo await this.initializing?.catch(() => {}); // @sync-skip // Dispose all active snapshots try { - for (const snapshot of [...this.activeSnapshots]) { + for (const snapshot of [...this.activeSnapshots.values()]) { await snapshot.dispose(); } this.sourceFileCache.clear(); @@ -531,6 +620,25 @@ export class API implements FormatDiagnosticsHo } } + async createModuleResolver(compilerOptions: CompilerOptions, options?: ModuleResolverOptions): Promise { + await this.ensureInitialized(); + const callback = options?.resolveModuleName + ? registerModuleResolutionCallback(this.client, options.resolveModuleName, id => this.activeSnapshots.get(id)) + : undefined; + try { + const id = await this.client.apiRequest("createModuleResolver", { + compilerOptions, + moduleResolutions: options?.moduleResolutions, + resolveModuleNameCallback: callback?.name, + }); + return new ModuleResolver(id, this.client, callback?.dispose); + } + catch (error) { + callback?.dispose(); + throw error; + } + } + clearSourceFileCache(): void { this.sourceFileCache.clear(); } @@ -538,7 +646,7 @@ export class API implements FormatDiagnosticsHo async runWithTemporaryFileUpdate(baseSnapshot: Snapshot, file: DocumentIdentifier, newText: string, cb: (newSnapshot: Snapshot) => void | Promise): Promise { await this.ensureInitialized(); - if (!this.activeSnapshots.has(baseSnapshot) || baseSnapshot.isDisposed()) { + if (this.activeSnapshots.get(baseSnapshot.id) !== baseSnapshot || baseSnapshot.isDisposed()) { throw new Error("Cannot run a temporary file update on an inactive snapshot"); } const snapshot = await baseSnapshot.update({ @@ -588,7 +696,7 @@ export class API implements FormatDiagnosticsHo const snapshot = await this.createSnapshot({ createPrograms: [{ rootFiles, compilerOptions, options: createProgramOptions }], }); - const program = snapshot.operation.createdPrograms[0]; + const program = snapshot.operation.createdPrograms![0]; if (!program) { await snapshot.dispose(); throw new Error("createProgram did not return a project"); @@ -660,7 +768,7 @@ type ContextualizeTuple< /** Substitutes the operation arrays with contextually typed, tuple-preserving versions. */ type SnapshotOperationParams< - Params extends CreateSnapshotParams, + Params extends { createPrograms?: readonly unknown[] | undefined; openFiles?: readonly unknown[] | undefined; }, CreatePrograms extends Params["createPrograms"], OpenFiles extends Params["openFiles"], > = Omit & { @@ -673,8 +781,8 @@ type SnapshotOperationParams< * operation arrays were supplied, preserving their lengths for indexed access. */ type SnapshotForOperationResults< - CreatePrograms extends CreateSnapshotParams["createPrograms"], - OpenFiles extends CreateSnapshotParams["openFiles"], + CreatePrograms extends readonly unknown[] | undefined, + OpenFiles extends readonly unknown[] | undefined, > = Snapshot & { readonly operation: & SnapshotOperation @@ -828,6 +936,55 @@ export class Snapshot { } } +export class ModuleResolver { + readonly id: number; + private readonly client: Client; + private readonly disposeCallback: (() => void) | undefined; + private disposed = false; + + constructor(id: number, client: Client, disposeCallback: (() => void) | undefined) { + this.id = id; + this.client = client; + this.disposeCallback = disposeCallback; + } + + async resolveModuleName( + moduleName: string, + containingDirectory: DocumentIdentifier, + resolutionMode?: ResolutionMode, + options?: { snapshot?: Snapshot | InProgressSnapshot | undefined; }, + ): Promise { + this.ensureNotDisposed(); + if (options?.snapshot instanceof Snapshot && options.snapshot.isDisposed()) { + throw new Error("Snapshot is disposed"); + } + return this.client.apiRequest("resolveModuleName", { + snapshot: options?.snapshot instanceof Snapshot ? options.snapshot.id : undefined, + inProgressSnapshot: typeof options?.snapshot === "number" ? -options.snapshot : undefined, + resolver: this.id, + moduleName, + containingDirectory, + resolutionMode, + }); + } + + [globalThis.Symbol.asyncDispose](): Promise { // @sync: [globalThis.Symbol.dispose](): void { + return this.dispose(); + } + + async dispose(): Promise { + if (this.disposed) return; + await this.client.apiRequest("releaseModuleResolver", { resolver: this.id }); + this.disposed = true; + this.disposeCallback?.(); + } + + /** @internal */ + ensureNotDisposed(): void { + if (this.disposed) throw new Error("ModuleResolver is disposed"); + } +} + class SnapshotObjectRegistry { private readonly symbols: Map = new Map(); private readonly client: Client; diff --git a/packages/typescript/src/api/async/client.ts b/packages/typescript/src/api/async/client.ts index 03809285c9b4d..ff7b9c2f60f8f 100644 --- a/packages/typescript/src/api/async/client.ts +++ b/packages/typescript/src/api/async/client.ts @@ -199,6 +199,14 @@ export class Client { return result; } + registerCallback(name: string, callback: (params: unknown) => unknown | Promise): () => void { + if (!this.connection) { + throw new Error("Connection not established"); + } + const disposable = this.connection.onRequest(new RequestType(name), callback); + return () => disposable.dispose(); + } + private async doBatch(): Promise { this.nextBatch = undefined; if (!this.batchedRequests.length) return; diff --git a/packages/typescript/src/api/proto.generated.ts b/packages/typescript/src/api/proto.generated.ts index 193b491d4749a..931ce6348c448 100644 --- a/packages/typescript/src/api/proto.generated.ts +++ b/packages/typescript/src/api/proto.generated.ts @@ -26,6 +26,9 @@ export interface APIMethodInfo { createSnapshot: APIMethod; updateSnapshot: APIMethod; getCurrentLanguageServerSnapshot: APIMethod; + createModuleResolver: APIMethod; + releaseModuleResolver: APIMethod; + resolveModuleName: APIMethod; parseCommandLine: APIMethod; readConfigFile: APIMethod; parseJsonConfigFileContent: APIMethod; @@ -185,6 +188,8 @@ export interface APIMethodInfo { export type DocumentIdentifier = string | { uri: string; }; +export type ResolutionMode = ModuleKind.None | ModuleKind.CommonJS | ModuleKind.ESNext; + export type EnsurePrograms = true | readonly ProjectId[]; export type InferredProjectId = string & { __inferredProjectIdBrand: any; }; @@ -253,6 +258,31 @@ export interface GetCurrentLanguageServerSnapshotParams { changes?: LanguageServerSnapshotChanges | undefined; } +export interface CreateModuleResolverParams { + compilerOptions: CompilerOptions; + moduleResolutions?: ModuleResolutionSpec | undefined; + resolveModuleNameCallback?: string | undefined; +} + +export interface ReleaseModuleResolverParams { + resolver: number; +} + +export interface ResolveModuleNameParams { + snapshot?: number | undefined; + inProgressSnapshot?: number | undefined; + resolver: number; + moduleName: string; + containingDirectory: DocumentIdentifier; + resolutionMode?: ResolutionMode | undefined; +} + +export interface ResolveModuleNameResult { + resolvedModule?: ResolvedModule | undefined; + /** Trace is provided when compilerOptions.traceResolution is true. */ + trace?: string[] | undefined; +} + export interface ParseCommandLineParams { commandLine: readonly string[] | null; } @@ -1008,6 +1038,7 @@ export interface ProfileResult { export interface BatchRequest { method: | "batchRequests" + | "createModuleResolver" | "createSnapshot" | "createSourceFile" | "createSourceFileFromFile" @@ -1156,6 +1187,8 @@ export interface BatchRequest { | "printNode" | "readConfigFile" | "release" + | "releaseModuleResolver" + | "resolveModuleName" | "resolveName" | "saveHeapProfile" | "signatureToSignatureDeclaration" @@ -1174,6 +1207,7 @@ export interface BatchRequest { export interface BatchResponse { method: | "batchRequests" + | "createModuleResolver" | "createSnapshot" | "createSourceFile" | "createSourceFileFromFile" @@ -1322,6 +1356,8 @@ export interface BatchResponse { | "printNode" | "readConfigFile" | "release" + | "releaseModuleResolver" + | "resolveModuleName" | "resolveName" | "saveHeapProfile" | "signatureToSignatureDeclaration" @@ -1550,6 +1586,11 @@ export interface CompilerOptions { configFilePath?: string | undefined; } +export interface ModuleResolutionSpec { + fallback: "resolve" | "unresolved"; + entries: ModuleResolutionEntry[]; +} + export interface ProjectReference { /** Path is a normalized path on disk. */ path: string; @@ -1669,6 +1710,13 @@ export interface PluginImport { name: string; } +export interface ModuleResolutionEntry { + moduleName: string; + containingDirectory?: DocumentIdentifier | undefined; + resolutionMode?: ResolutionMode | undefined; + result: StaticModuleResolution; +} + /** CompletionEntryLabelDetailsResponse holds additional label display text for a completion entry. */ export interface CompletionEntryLabelDetailsResponse { detail?: string | undefined; @@ -1678,4 +1726,11 @@ export interface CompletionEntryLabelDetailsResponse { export interface CreateProgramOptions { projectReferences?: ProjectReference[] | undefined; configFileParsingDiagnostics?: DiagnosticResponse[] | undefined; + moduleResolver?: number | undefined; +} + +export interface StaticModuleResolution { + resolvedFileName?: DocumentIdentifier | undefined; + originalPath?: DocumentIdentifier | undefined; + packageId?: PackageId | undefined; } diff --git a/packages/typescript/src/api/sync/api.ts b/packages/typescript/src/api/sync/api.ts index 11d6f0e8b28ba..60a12bd151bde 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -78,8 +78,9 @@ import { import type { CompilerOptions, ConfiguredProjectId, - CreateProgramOptions, - CreateSnapshotParams, + CreateProgramOptions as ProtocolCreateProgramOptions, + CreateSnapshotParams as ProtocolCreateSnapshotParams, + CreateSnapshotProgramParams as ProtocolCreateSnapshotProgramParams, CreateSnapshotResponse, CreateSourceFileOptions, Diagnostic, @@ -90,18 +91,24 @@ import type { ImportAdderAction, InferredProjectId, IntrinsicTypeMethod, - LanguageServerSnapshotChanges, + LanguageServerSnapshotChanges as ProtocolLanguageServerSnapshotChanges, + ModuleResolutionEntry, + ModuleResolutionSpec, PackageId, ParsedCommandLine, ProjectId, ProjectReference, ProjectResponse, ReadConfigFileResponse, + ReconfigureSnapshotProgramParams as ProtocolReconfigureSnapshotProgramParams, + ResolutionMode, ResolvedModule, ResolvedTypeReferenceDirective, + ResolveModuleNameResult, SignaturePropertyMethod, SignatureResponse, SourceFileMetadata, + StaticModuleResolution, SymbolPropertyMethod, SymbolResponse, SymbolsPropertyMethod, @@ -191,8 +198,6 @@ export type { CompletionOptions, ConditionalType, ConfiguredProjectId, - CreateProgramOptions, - CreateSnapshotParams, CreateSourceFileOptions, Diagnostic, DocumentIdentifier, @@ -214,10 +219,11 @@ export type { IntersectionType, IntrinsicType, JSDocTagInfo, - LanguageServerSnapshotChanges, LiteralType, LSPConnectionOptions, MappedType, + ModuleResolutionEntry, + ModuleResolutionSpec, NumberLiteralType, ObjectType, PackageId, @@ -226,9 +232,12 @@ export type { ProjectReference, ReadConfigFileResponse, RequestTiming, + ResolutionMode, ResolvedModule, ResolvedTypeReferenceDirective, + ResolveModuleNameResult, SourceFileMetadata, + StaticModuleResolution, StringLiteralType, StringMappingType, StructuredType, @@ -251,6 +260,62 @@ export type { UnionType, }; +export interface ModuleResolverOptions { + moduleResolutions?: ModuleResolutionSpec | undefined; + resolveModuleName?: ResolveModuleNameCallback | undefined; +} + +export interface ResolveModuleNameCallbackOptions { + snapshot: Snapshot | InProgressSnapshot | undefined; +} + +declare const inProgressSnapshotBrand: unique symbol; +export type InProgressSnapshot = number & { readonly [inProgressSnapshotBrand]: never; }; + +export type ResolveModuleNameCallback = (moduleName: string, containingDirectory: string, resolutionMode: ResolutionMode | undefined, options: ResolveModuleNameCallbackOptions) => StaticModuleResolution | undefined; + +export type CreateProgramOptions = Omit & { + moduleResolver?: ModuleResolver | undefined; +}; +export type CreateSnapshotProgramParams = Omit & { options?: CreateProgramOptions | undefined; }; +export type ReconfigureSnapshotProgramParams = Omit & { options?: CreateProgramOptions | undefined; }; +export type CreateSnapshotParams = Omit & { + createPrograms?: readonly CreateSnapshotProgramParams[] | undefined; + reconfigurePrograms?: readonly ReconfigureSnapshotProgramParams[] | undefined; +}; +export type LanguageServerSnapshotChanges = Omit & { + createPrograms?: readonly CreateSnapshotProgramParams[] | undefined; + reconfigurePrograms?: readonly ReconfigureSnapshotProgramParams[] | undefined; +}; + +let nextModuleResolutionCallbackId = 0; +function registerModuleResolutionCallback(client: Client, callback: ResolveModuleNameCallback, getSnapshot: (id: number) => Snapshot | undefined): { name: string; dispose: () => void; } { + const name = `resolveModuleName/${++nextModuleResolutionCallbackId}`; + const dispose = client.registerCallback(name, params => { + const { moduleName, containingDirectory, resolutionMode, snapshot: snapshotId, inProgressSnapshot } = params as { + moduleName: string; + containingDirectory: string; + resolutionMode?: ResolutionMode; + snapshot?: number; + inProgressSnapshot?: number; + }; + let snapshot: Snapshot | InProgressSnapshot | undefined = snapshotId === undefined ? undefined : getSnapshot(snapshotId); + if (snapshotId !== undefined && snapshot === undefined) { + throw new Error(`Snapshot ${snapshotId} is inactive`); + } + if (inProgressSnapshot !== undefined) { + snapshot = -inProgressSnapshot as InProgressSnapshot; + } + return callback( + moduleName, + containingDirectory, + resolutionMode, + { snapshot }, + ); + }); + return { name, dispose }; +} + export interface TranspileOptions { compilerOptions?: CompilerOptions | undefined; fileName?: string | undefined; @@ -280,7 +345,7 @@ export class API implements FormatDiagnosticsHo private getCanonicalFileNameWorker: ((fileName: string) => string) | undefined; private initialized: boolean = false; private initializing: void | undefined; - private activeSnapshots: Set = new Set(); + private activeSnapshots: Map = new Map(); readonly printer: Printer; readonly internal: InternalAPI; @@ -636,7 +701,7 @@ export class API implements FormatDiagnosticsHo function createSnapshot(params?: CreateSnapshotParams): Snapshot { owner.ensureInitialized(); - const requestParams = toCreateSnapshotRequest(params); + const requestParams = toCreateSnapshotRequest(owner.prepareCreateSnapshotParams(params)); const data = owner.client.apiRequest("createSnapshot", requestParams); const snapshot = new Snapshot( @@ -646,13 +711,13 @@ export class API implements FormatDiagnosticsHo owner.toPath!, owner, () => { - owner.activeSnapshots.delete(snapshot); + owner.activeSnapshots.delete(snapshot.id); owner.sourceFileCache.releaseSnapshot(snapshot.id); }, owner.createSnapshotUpdater(() => snapshot), undefined, ); - owner.activeSnapshots.add(snapshot); + owner.activeSnapshots.set(snapshot.id, snapshot); return snapshot; } @@ -661,7 +726,7 @@ export class API implements FormatDiagnosticsHo function* gen(params?: CreateSnapshotParams): Generator { yield* owner.ensureInitialized.gen(); - const requestParams = toCreateSnapshotRequest(params); + const requestParams = toCreateSnapshotRequest(owner.prepareCreateSnapshotParams(params)); const data = yield* apiRequest("createSnapshot", requestParams); const snapshot = new Snapshot( @@ -671,13 +736,13 @@ export class API implements FormatDiagnosticsHo owner.toPath!, owner, () => { - owner.activeSnapshots.delete(snapshot); + owner.activeSnapshots.delete(snapshot.id); owner.sourceFileCache.releaseSnapshot(snapshot.id); }, owner.createSnapshotUpdater(() => snapshot), undefined, ); - owner.activeSnapshots.add(snapshot); + owner.activeSnapshots.set(snapshot.id, snapshot); return snapshot; } @@ -694,13 +759,13 @@ export class API implements FormatDiagnosticsHo "updateSnapshot", function (baseSnapshot: Snapshot, params: CreateSnapshotParams): Snapshot { owner.ensureInitialized(); - if (!owner.activeSnapshots.has(baseSnapshot) || baseSnapshot.isDisposed()) { + if (owner.activeSnapshots.get(baseSnapshot.id) !== baseSnapshot || baseSnapshot.isDisposed()) { throw new Error("Cannot update an inactive snapshot"); } const data = owner.client.apiRequest("updateSnapshot", { snapshot: baseSnapshot.id, - changes: toCreateSnapshotRequest(params), + changes: toCreateSnapshotRequest(owner.prepareCreateSnapshotParams(params)), }); if (data.snapshot === baseSnapshot.id) { owner.client.apiRequest("release", { snapshot: data.snapshot }); @@ -714,24 +779,24 @@ export class API implements FormatDiagnosticsHo owner.toPath!, owner, () => { - owner.activeSnapshots.delete(snapshot); + owner.activeSnapshots.delete(snapshot.id); owner.sourceFileCache.releaseSnapshot(snapshot.id); }, owner.createSnapshotUpdater(() => snapshot), baseSnapshot, ); - owner.activeSnapshots.add(snapshot); + owner.activeSnapshots.set(snapshot.id, snapshot); return snapshot; }, function* (baseSnapshot: Snapshot, params: CreateSnapshotParams): Generator { yield* owner.ensureInitialized.gen(); - if (!owner.activeSnapshots.has(baseSnapshot) || baseSnapshot.isDisposed()) { + if (owner.activeSnapshots.get(baseSnapshot.id) !== baseSnapshot || baseSnapshot.isDisposed()) { throw new Error("Cannot update an inactive snapshot"); } const data = yield* apiRequest("updateSnapshot", { snapshot: baseSnapshot.id, - changes: toCreateSnapshotRequest(params), + changes: toCreateSnapshotRequest(owner.prepareCreateSnapshotParams(params)), }); if (data.snapshot === baseSnapshot.id) { yield* apiRequest("release", { snapshot: data.snapshot }); @@ -745,18 +810,42 @@ export class API implements FormatDiagnosticsHo owner.toPath!, owner, () => { - owner.activeSnapshots.delete(snapshot); + owner.activeSnapshots.delete(snapshot.id); owner.sourceFileCache.releaseSnapshot(snapshot.id); }, owner.createSnapshotUpdater(() => snapshot), baseSnapshot, ); - owner.activeSnapshots.add(snapshot); + owner.activeSnapshots.set(snapshot.id, snapshot); return snapshot; }, ); } + private prepareCreateSnapshotParams(params: CreateSnapshotParams | undefined): ProtocolCreateSnapshotParams | undefined { + if (!params) return undefined; + const prepareOptions = (options: CreateProgramOptions | undefined): ProtocolCreateProgramOptions | undefined => { + if (!options) return undefined; + const { moduleResolver, ...rest } = options; + moduleResolver?.ensureNotDisposed(); + return { + ...rest, + moduleResolver: moduleResolver?.id, + }; + }; + return { + ...params, + createPrograms: params.createPrograms?.map(program => ({ ...program, options: prepareOptions(program.options) })), + reconfigurePrograms: params.reconfigurePrograms?.map(program => ({ ...program, options: prepareOptions(program.options) })), + }; + } + + private prepareLanguageServerSnapshotChanges(changes: LanguageServerSnapshotChanges | undefined): ProtocolLanguageServerSnapshotChanges | undefined { + if (!changes) return undefined; + const prepared = this.prepareCreateSnapshotParams(changes); + return prepared; + } + private createSnapshotUpdater(getSnapshot: () => Snapshot): SnapshotUpdater { const update = ((params: CreateSnapshotParams) => this.updateSnapshot(getSnapshot(), params)) as SnapshotUpdater; const owner = this; @@ -793,12 +882,12 @@ export class API implements FormatDiagnosticsHo const changes = args[0] as LanguageServerSnapshotChanges | undefined; const baseSnapshot = args[1] as Snapshot | undefined; - if (baseSnapshot && (!owner.activeSnapshots.has(baseSnapshot) || baseSnapshot.isDisposed())) { + if (baseSnapshot && (owner.activeSnapshots.get(baseSnapshot.id) !== baseSnapshot || baseSnapshot.isDisposed())) { throw new Error("Cannot use an inactive snapshot as a response base"); } const data = owner.client.apiRequest("getCurrentLanguageServerSnapshot", { baseSnapshot: baseSnapshot?.id, - changes, + changes: owner.prepareLanguageServerSnapshotChanges(changes), }); if (baseSnapshot) { owner.sourceFileCache.retainForSnapshot(data.snapshot, baseSnapshot.id, data.changes); @@ -810,13 +899,13 @@ export class API implements FormatDiagnosticsHo owner.toPath!, owner, () => { - owner.activeSnapshots.delete(snapshot); + owner.activeSnapshots.delete(snapshot.id); owner.sourceFileCache.releaseSnapshot(snapshot.id); }, owner.createSnapshotUpdater(() => snapshot), baseSnapshot, ); - owner.activeSnapshots.add(snapshot); + owner.activeSnapshots.set(snapshot.id, snapshot); return snapshot; } function gen( @@ -829,12 +918,12 @@ export class API implements FormatDiagnosticsHo const changes = args[0] as LanguageServerSnapshotChanges | undefined; const baseSnapshot = args[1] as Snapshot | undefined; - if (baseSnapshot && (!owner.activeSnapshots.has(baseSnapshot) || baseSnapshot.isDisposed())) { + if (baseSnapshot && (owner.activeSnapshots.get(baseSnapshot.id) !== baseSnapshot || baseSnapshot.isDisposed())) { throw new Error("Cannot use an inactive snapshot as a response base"); } const data = yield* apiRequest("getCurrentLanguageServerSnapshot", { baseSnapshot: baseSnapshot?.id, - changes, + changes: owner.prepareLanguageServerSnapshotChanges(changes), }); if (baseSnapshot) { owner.sourceFileCache.retainForSnapshot(data.snapshot, baseSnapshot.id, data.changes); @@ -846,13 +935,13 @@ export class API implements FormatDiagnosticsHo owner.toPath!, owner, () => { - owner.activeSnapshots.delete(snapshot); + owner.activeSnapshots.delete(snapshot.id); owner.sourceFileCache.releaseSnapshot(snapshot.id); }, owner.createSnapshotUpdater(() => snapshot), baseSnapshot, ); - owner.activeSnapshots.add(snapshot); + owner.activeSnapshots.set(snapshot.id, snapshot); return snapshot; } return cacheGeneratorMethod(owner, "getCurrentLanguageServerSnapshot", getCurrentLanguageServerSnapshot, gen); @@ -873,7 +962,7 @@ export class API implements FormatDiagnosticsHo function (): void { // Dispose all active snapshots try { - for (const snapshot of [...owner.activeSnapshots]) { + for (const snapshot of [...owner.activeSnapshots.values()]) { snapshot.dispose(); } owner.sourceFileCache.clear(); @@ -885,7 +974,7 @@ export class API implements FormatDiagnosticsHo function* (): Generator { // Dispose all active snapshots try { - for (const snapshot of [...owner.activeSnapshots]) { + for (const snapshot of [...owner.activeSnapshots.values()]) { yield* snapshot.dispose.gen(); } owner.sourceFileCache.clear(); @@ -897,6 +986,53 @@ export class API implements FormatDiagnosticsHo ); } + get createModuleResolver(): { + (compilerOptions: CompilerOptions, options?: ModuleResolverOptions): ModuleResolver; + gen(compilerOptions: CompilerOptions, options?: ModuleResolverOptions): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "createModuleResolver", + function (compilerOptions: CompilerOptions, options?: ModuleResolverOptions): ModuleResolver { + owner.ensureInitialized(); + const callback = options?.resolveModuleName + ? registerModuleResolutionCallback(owner.client, options.resolveModuleName, id => owner.activeSnapshots.get(id)) + : undefined; + try { + const id = owner.client.apiRequest("createModuleResolver", { + compilerOptions, + moduleResolutions: options?.moduleResolutions, + resolveModuleNameCallback: callback?.name, + }); + return new ModuleResolver(id, owner.client, callback?.dispose); + } + catch (error) { + callback?.dispose(); + throw error; + } + }, + function* (compilerOptions: CompilerOptions, options?: ModuleResolverOptions): Generator { + yield* owner.ensureInitialized.gen(); + const callback = options?.resolveModuleName + ? registerModuleResolutionCallback(owner.client, options.resolveModuleName, id => owner.activeSnapshots.get(id)) + : undefined; + try { + const id = yield* apiRequest("createModuleResolver", { + compilerOptions, + moduleResolutions: options?.moduleResolutions, + resolveModuleNameCallback: callback?.name, + }); + return new ModuleResolver(id, owner.client, callback?.dispose); + } + catch (error) { + callback?.dispose(); + throw error; + } + }, + ); + } + clearSourceFileCache(): void { this.sourceFileCache.clear(); } @@ -912,7 +1048,7 @@ export class API implements FormatDiagnosticsHo function (baseSnapshot: Snapshot, file: DocumentIdentifier, newText: string, cb: (newSnapshot: Snapshot) => void): void { owner.ensureInitialized(); - if (!owner.activeSnapshots.has(baseSnapshot) || baseSnapshot.isDisposed()) { + if (owner.activeSnapshots.get(baseSnapshot.id) !== baseSnapshot || baseSnapshot.isDisposed()) { throw new Error("Cannot run a temporary file update on an inactive snapshot"); } const snapshot = baseSnapshot.update({ @@ -933,7 +1069,7 @@ export class API implements FormatDiagnosticsHo function* (baseSnapshot: Snapshot, file: DocumentIdentifier, newText: string, cb: (newSnapshot: Snapshot) => void | Generator): Generator { yield* owner.ensureInitialized.gen(); - if (!owner.activeSnapshots.has(baseSnapshot) || baseSnapshot.isDisposed()) { + if (owner.activeSnapshots.get(baseSnapshot.id) !== baseSnapshot || baseSnapshot.isDisposed()) { throw new Error("Cannot run a temporary file update on an inactive snapshot"); } const snapshot = yield* baseSnapshot.update.gen({ @@ -1015,7 +1151,7 @@ export class API implements FormatDiagnosticsHo const snapshot = owner.createSnapshot({ createPrograms: [{ rootFiles, compilerOptions, options: createProgramOptions }], }); - const program = snapshot.operation.createdPrograms[0]; + const program = snapshot.operation.createdPrograms![0]; if (!program) { snapshot.dispose(); throw new Error("createProgram did not return a project"); @@ -1029,7 +1165,7 @@ export class API implements FormatDiagnosticsHo const snapshot = yield* owner.createSnapshot.gen({ createPrograms: [{ rootFiles, compilerOptions, options: createProgramOptions }], }); - const program = snapshot.operation.createdPrograms[0]; + const program = snapshot.operation.createdPrograms![0]; if (!program) { yield* snapshot.dispose.gen(); throw new Error("createProgram did not return a project"); @@ -1147,7 +1283,7 @@ type ContextualizeTuple< /** Substitutes the operation arrays with contextually typed, tuple-preserving versions. */ type SnapshotOperationParams< - Params extends CreateSnapshotParams, + Params extends { createPrograms?: readonly unknown[] | undefined; openFiles?: readonly unknown[] | undefined; }, CreatePrograms extends Params["createPrograms"], OpenFiles extends Params["openFiles"], > = Omit & { @@ -1160,8 +1296,8 @@ type SnapshotOperationParams< * operation arrays were supplied, preserving their lengths for indexed access. */ type SnapshotForOperationResults< - CreatePrograms extends CreateSnapshotParams["createPrograms"], - OpenFiles extends CreateSnapshotParams["openFiles"], + CreatePrograms extends readonly unknown[] | undefined, + OpenFiles extends readonly unknown[] | undefined, > = Snapshot & { readonly operation: & SnapshotOperation @@ -1384,6 +1520,90 @@ export class Snapshot { } } +export class ModuleResolver { + readonly id: number; + private readonly client: Client; + private readonly disposeCallback: (() => void) | undefined; + private disposed = false; + + constructor(id: number, client: Client, disposeCallback: (() => void) | undefined) { + this.id = id; + this.client = client; + this.disposeCallback = disposeCallback; + } + + get resolveModuleName(): { + (moduleName: string, containingDirectory: DocumentIdentifier, resolutionMode?: ResolutionMode, options?: { snapshot?: Snapshot | InProgressSnapshot | undefined; }): ResolveModuleNameResult; + gen(moduleName: string, containingDirectory: DocumentIdentifier, resolutionMode?: ResolutionMode, options?: { snapshot?: Snapshot | InProgressSnapshot | undefined; }): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "resolveModuleName", + function (moduleName: string, containingDirectory: DocumentIdentifier, resolutionMode?: ResolutionMode, options?: { snapshot?: Snapshot | InProgressSnapshot | undefined; }): ResolveModuleNameResult { + owner.ensureNotDisposed(); + if (options?.snapshot instanceof Snapshot && options.snapshot.isDisposed()) { + throw new Error("Snapshot is disposed"); + } + return owner.client.apiRequest("resolveModuleName", { + snapshot: options?.snapshot instanceof Snapshot ? options.snapshot.id : undefined, + inProgressSnapshot: typeof options?.snapshot === "number" ? -options.snapshot : undefined, + resolver: owner.id, + moduleName, + containingDirectory, + resolutionMode, + }); + }, + function* (moduleName: string, containingDirectory: DocumentIdentifier, resolutionMode?: ResolutionMode, options?: { snapshot?: Snapshot | InProgressSnapshot | undefined; }): Generator { + owner.ensureNotDisposed(); + if (options?.snapshot instanceof Snapshot && options.snapshot.isDisposed()) { + throw new Error("Snapshot is disposed"); + } + return yield* apiRequest("resolveModuleName", { + snapshot: options?.snapshot instanceof Snapshot ? options.snapshot.id : undefined, + inProgressSnapshot: typeof options?.snapshot === "number" ? -options.snapshot : undefined, + resolver: owner.id, + moduleName, + containingDirectory, + resolutionMode, + }); + }, + ); + } + + [globalThis.Symbol.dispose](): void { + return this.dispose(); + } + + get dispose(): { + (): void; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "dispose", + function (): void { + if (owner.disposed) return; + owner.client.apiRequest("releaseModuleResolver", { resolver: owner.id }); + owner.disposed = true; + owner.disposeCallback?.(); + }, + function* (): Generator { + if (owner.disposed) return; + yield* apiRequest("releaseModuleResolver", { resolver: owner.id }); + owner.disposed = true; + owner.disposeCallback?.(); + }, + ); + } + + /** @internal */ + ensureNotDisposed(): void { + if (this.disposed) throw new Error("ModuleResolver is disposed"); + } +} + class SnapshotObjectRegistry { private readonly symbols: Map = new Map(); private readonly client: Client; diff --git a/packages/typescript/src/api/sync/client.ts b/packages/typescript/src/api/sync/client.ts index b174784d7006e..d3c3ccc64b9fc 100644 --- a/packages/typescript/src/api/sync/client.ts +++ b/packages/typescript/src/api/sync/client.ts @@ -98,9 +98,15 @@ export class Client { if (result.length) { return JSON.parse(result) as APIMethodInfo[K]["result"]; } + return undefined as APIMethodInfo[K]["result"]; } + registerCallback(name: string, callback: (params: unknown) => unknown): () => void { + this.channel.registerCallback(name, (_, payload) => JSON.stringify(callback(JSON.parse(payload))) ?? ""); + return () => this.channel.unregisterCallback(name); + } + batchRequests(requests: readonly APIRequest[]): BatchRequestsResponse { const params: BatchRequestsParams = { requests }; if (this.maxResponseBytesPerPage !== undefined) { diff --git a/packages/typescript/src/api/syncChannel.ts b/packages/typescript/src/api/syncChannel.ts index f14c42471f303..6672c5aa79971 100644 --- a/packages/typescript/src/api/syncChannel.ts +++ b/packages/typescript/src/api/syncChannel.ts @@ -244,6 +244,10 @@ export class SyncRpcChannel { this.callbacks.set(name, callback); } + unregisterCallback(name: string): void { + this.callbacks.delete(name); + } + /** Kill the child process and release resources. */ close(): void { try { diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index 4a54dc8ea2a19..c9a6f3b2cb736 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -64,6 +64,7 @@ import { IndexKind, type IndexType, type InferredProjectId, + type InProgressSnapshot, type InterfaceType, type IntrinsicType, isErrorType, @@ -73,6 +74,7 @@ import { ModifierFlags, ModuleKind, ModuleResolutionKind, + type ModuleResolver, type NumberLiteralType, ObjectFlags, type Program, @@ -124,6 +126,14 @@ describe("API", () => { const lsp = undefined! as API; void lsp.getCurrentLanguageServerSnapshot({ openProjects: ["/tsconfig.json"] }); + const moduleResolver = undefined! as ModuleResolver; + void lsp.getCurrentLanguageServerSnapshot({ + createPrograms: [{ + rootFiles: ["/index.ts"], + compilerOptions: {}, + options: { moduleResolver }, + }], + }); const baseSnapshot = undefined! as Snapshot; void lsp.getCurrentLanguageServerSnapshot(undefined, baseSnapshot); @@ -514,6 +524,363 @@ describe("API", () => { assert.deepEqual(await originalProgram.getSourceFileNames(), ["/src/a.ts"]); }); + test("snapshot.update reconfigures module resolution providers", async () => { + const root = "/src/index.ts"; + const providedA = "/a.d.ts"; + const providedB = "/b.d.ts"; + const { api: disposableAPI, fs } = spawnAPIWithFS({ + [root]: `import { value } from "pkg"; export { value };`, + [providedA]: `export declare const value: "a";`, + [providedB]: `export declare const value: "b";`, + }); + await using api = disposableAPI; + const compilerOptions = { + noLib: true, + module: ModuleKind.NodeNext, + moduleResolution: ModuleResolutionKind.NodeNext, + }; + const spec = (resolvedFileName: string) => ({ + fallback: "unresolved" as const, + entries: [{ moduleName: "pkg", result: { resolvedFileName } }], + }); + const resolverA = await api.createModuleResolver(compilerOptions, { moduleResolutions: spec(providedA) }); + const resolverB = await api.createModuleResolver(compilerOptions, { moduleResolutions: spec(providedB) }); + const inlineResolverA = await api.createModuleResolver(compilerOptions, { moduleResolutions: spec(providedA) }); + const inlineResolverB = await api.createModuleResolver(compilerOptions, { moduleResolutions: spec(providedA) }); + const createProgram = (moduleResolver?: ModuleResolver) => ({ + rootFiles: [root], + compilerOptions, + ...(moduleResolver ? { options: { moduleResolver } } : {}), + }); + + const initial = await api.createSnapshot({ createPrograms: [createProgram(resolverA)] }); + const programId = initial.operation.createdPrograms![0].id; + assert.deepEqual([...await initial.getProgram(programId)!.getSourceFileNames()].sort(), [providedA, root]); + assert.equal( + (await resolverA.resolveModuleName("pkg", "/src", undefined, { snapshot: initial })).resolvedModule?.resolvedFileName, + providedA, + ); + assert.equal((await resolverA.resolveModuleName("pkg", "/src")).resolvedModule?.resolvedFileName, providedA); + + const sameSet = await initial.update({ + reconfigurePrograms: [{ id: programId, ...createProgram(resolverA) }], + }); + assert.deepEqual([...await sameSet.getProgram(programId)!.getSourceFileNames()].sort(), [providedA, root]); + + fs.writeFile!(root, `import { value } from "pkg"; export const updated = value;`); + const sameSetAfterEdit = await sameSet.update({ + reconfigurePrograms: [{ id: programId, ...createProgram(resolverA) }], + fileNotifications: { changed: [root] }, + }); + assert.deepEqual([...await sameSetAfterEdit.getProgram(programId)!.getSourceFileNames()].sort(), [providedA, root]); + + const changedSet = await sameSetAfterEdit.update({ + reconfigurePrograms: [{ id: programId, ...createProgram(resolverB) }], + }); + assert.deepEqual([...await changedSet.getProgram(programId)!.getSourceFileNames()].sort(), [providedB, root]); + + const removedSet = await sameSetAfterEdit.update({ + reconfigurePrograms: [{ id: programId, ...createProgram(undefined) }], + }); + assert.deepEqual(await removedSet.getProgram(programId)!.getSourceFileNames(), [root]); + + const inline = await sameSetAfterEdit.update({ + reconfigurePrograms: [{ id: programId, ...createProgram(inlineResolverA) }], + }); + const repeatedInline = await inline.update({ + reconfigurePrograms: [{ id: programId, ...createProgram(inlineResolverB) }], + }); + assert.deepEqual([...await repeatedInline.getProgram(programId)!.getSourceFileNames()].sort(), [providedA, root]); + + let callbackCalls = 0; + const callbackResolver = await api.createModuleResolver(compilerOptions, { + resolveModuleName: (moduleName: string) => { + callbackCalls++; + return moduleName === "pkg" ? { resolvedFileName: providedA } : undefined; + }, + }); + const callbackSnapshot = await api.createSnapshot({ + createPrograms: [createProgram(callbackResolver)], + }); + const callbackProgramId = callbackSnapshot.operation.createdPrograms![0].id; + const repeatedCallback = await callbackSnapshot.update({ + reconfigurePrograms: [{ id: callbackProgramId, ...createProgram(callbackResolver) }], + }); + assert.deepEqual([...await repeatedCallback.getProgram(callbackProgramId)!.getSourceFileNames()].sort(), [providedA, root]); + assert.equal(callbackCalls, 1); + }); + + test("module resolver runs against snapshots or the host filesystem", async () => { + const packageJson = "/node_modules/pkg/package.json"; + const { api: disposableAPI, fs } = spawnAPIWithFS({ + [packageJson]: JSON.stringify({ name: "pkg", version: "1.0.0", types: "a.d.ts" }), + "/node_modules/pkg/a.d.ts": `export declare const value: "a";`, + }); + await using api = disposableAPI; + const resolver = await api.createModuleResolver({ + module: ModuleKind.NodeNext, + moduleResolution: ModuleResolutionKind.NodeNext, + }); + const firstSnapshot = await api.createSnapshot(); + assert.equal( + (await resolver.resolveModuleName("pkg", "/src", undefined, { snapshot: firstSnapshot })).resolvedModule?.resolvedFileName, + "/node_modules/pkg/a.d.ts", + ); + + fs.writeFile!(packageJson, JSON.stringify({ name: "pkg", version: "1.0.0", types: "b.d.ts" })); + fs.writeFile!("/node_modules/pkg/b.d.ts", `export declare const value: "b";`); + const secondSnapshot = await firstSnapshot.update({ + fileNotifications: { + changed: [packageJson], + created: ["/node_modules/pkg/b.d.ts"], + }, + }); + + assert.equal( + (await resolver.resolveModuleName("pkg", "/src", undefined, { snapshot: firstSnapshot })).resolvedModule?.resolvedFileName, + "/node_modules/pkg/a.d.ts", + ); + assert.equal( + (await resolver.resolveModuleName("pkg", "/src", undefined, { snapshot: secondSnapshot })).resolvedModule?.resolvedFileName, + "/node_modules/pkg/b.d.ts", + ); + assert.equal( + (await resolver.resolveModuleName("pkg", "/src")).resolvedModule?.resolvedFileName, + "/node_modules/pkg/b.d.ts", + ); + }); + + test("module resolver callbacks can delegate to another resolver", async () => { + await using api = spawnAPI({ + "/src/index.ts": `import "custom"; import "native";`, + "/custom.d.ts": `export {};`, + "/node_modules/native/package.json": JSON.stringify({ name: "native", version: "1.0.0", types: "index.d.ts" }), + "/node_modules/native/index.d.ts": `export {};`, + }); + const compilerOptions = { + noLib: true, + module: ModuleKind.NodeNext, + moduleResolution: ModuleResolutionKind.NodeNext, + }; + const defaultResolver = await api.createModuleResolver(compilerOptions); + const callbackSnapshots: (Snapshot | InProgressSnapshot | undefined)[] = []; + const customResolver = await api.createModuleResolver(compilerOptions, { + resolveModuleName: async (moduleName, containingDirectory, resolutionMode, { snapshot }) => { + callbackSnapshots.push(snapshot); + if (moduleName === "custom") return { resolvedFileName: "/custom.d.ts" }; + assert.ok(snapshot); + return (await defaultResolver.resolveModuleName( + moduleName, + containingDirectory, + resolutionMode, + { snapshot }, + )).resolvedModule; + }, + }); + const snapshot = await api.createSnapshot({ + createPrograms: [{ + rootFiles: ["/src/index.ts"], + compilerOptions, + options: { moduleResolver: customResolver }, + }], + }); + assert.deepEqual( + [...await snapshot.operation.createdPrograms[0].getSourceFileNames()].sort(), + ["/custom.d.ts", "/node_modules/native/index.d.ts", "/src/index.ts"], + ); + assert.equal(callbackSnapshots.length, 2); + assert.equal(callbackSnapshots[0], callbackSnapshots[1]); + assert.ok(typeof callbackSnapshots[0] === "number"); + assert.ok(callbackSnapshots[0] < 0); + }); + + test("module resolver callbacks can resolve against the in-progress snapshot filesystem", async () => { + await using api = spawnAPI({ + "/src/index.ts": `import "layered";`, + }); + const compilerOptions = { + noLib: true, + module: ModuleKind.NodeNext, + moduleResolution: ModuleResolutionKind.NodeNext, + }; + const defaultResolver = await api.createModuleResolver({ + ...compilerOptions, + customConditions: ["delegated"], + }); + const customResolver = await api.createModuleResolver(compilerOptions, { + resolveModuleName: async (moduleName, containingDirectory, resolutionMode, { snapshot }) => { + assert.ok(snapshot); + return (await defaultResolver.resolveModuleName( + moduleName, + containingDirectory, + resolutionMode, + { snapshot }, + )).resolvedModule; + }, + }); + const snapshot = await api.createSnapshot({ + fileSystem: createFileSystemLayer([ + [ + "/node_modules/layered/package.json", + JSON.stringify({ + name: "layered", + version: "1.0.0", + exports: { ".": { delegated: "./index.d.ts" } }, + }), + ], + ["/node_modules/layered/index.d.ts", `export {};`], + ]), + createPrograms: [{ + rootFiles: ["/src/index.ts"], + compilerOptions, + options: { moduleResolver: customResolver }, + }], + }); + assert.deepEqual( + [...await snapshot.operation.createdPrograms[0].getSourceFileNames()].sort(), + ["/node_modules/layered/index.d.ts", "/src/index.ts"], + ); + }); + + test("module resolver callback errors reject lib replacement", async () => { + await using api = spawnAPI({ + "/src/index.ts": `export {};`, + }); + const resolver = await api.createModuleResolver( + { moduleResolution: ModuleResolutionKind.Bundler }, + { + resolveModuleName: () => { + throw new Error("lib replacement callback failed"); + }, + }, + ); + + await assert.rejects( // @sync: assert.throws( + () => + api.createSnapshot({ + createPrograms: [{ + rootFiles: ["/src/index.ts"], + compilerOptions: { libReplacement: true }, + options: { moduleResolver: resolver }, + }], + }), + /lib replacement callback failed/, + ); + }); + + test("program module resolution uses the resolver compiler options", async () => { + await using api = spawnAPI({ + "/src/index.ts": `/// +import "pkg/feature";`, + "/node_modules/pkg/package.json": JSON.stringify({ + name: "pkg", + version: "1.0.0", + exports: { "./feature": { resolver: "./dist/feature.d.ts" } }, + }), + "/node_modules/pkg/dist/feature.d.ts": `export {};`, + "/node_modules/@types/resolver-types/index.d.ts": `export {};`, + }); + const resolver = await api.createModuleResolver({ + module: ModuleKind.ESNext, + moduleResolution: ModuleResolutionKind.Bundler, + customConditions: ["resolver"], + }); + const snapshot = await api.createSnapshot({ + createPrograms: [{ + rootFiles: ["/src/index.ts"], + compilerOptions: { + noLib: true, + module: ModuleKind.Node16, + moduleResolution: ModuleResolutionKind.Node16, + }, + options: { moduleResolver: resolver }, + }], + }); + + assert.deepEqual( + [...await snapshot.operation.createdPrograms![0].getSourceFileNames()].sort(), + ["/node_modules/@types/resolver-types/index.d.ts", "/node_modules/pkg/dist/feature.d.ts", "/src/index.ts"], + ); + }); + + test("module resolver callbacks preserve retained and live filesystem context", async () => { + await using api = spawnAPI({ + "/src/index.ts": `export {};`, + "/node_modules/pkg/package.json": JSON.stringify({ name: "pkg", version: "1.0.0", types: "index.d.ts" }), + "/node_modules/pkg/index.d.ts": `export {};`, + }); + const compilerOptions = { + module: ModuleKind.NodeNext, + moduleResolution: ModuleResolutionKind.NodeNext, + }; + const defaultResolver = await api.createModuleResolver(compilerOptions); + const callbackSnapshots: (Snapshot | InProgressSnapshot | undefined)[] = []; + const passthroughResolver = await api.createModuleResolver(compilerOptions, { + resolveModuleName: async (moduleName, containingDirectory, resolutionMode, { snapshot }) => { + callbackSnapshots.push(snapshot); + return (await defaultResolver.resolveModuleName( + moduleName, + containingDirectory, + resolutionMode, + snapshot === undefined ? undefined : { snapshot }, + )).resolvedModule; + }, + }); + const snapshot = await api.createSnapshot(); + + assert.equal( + (await passthroughResolver.resolveModuleName("pkg", "/src", ModuleKind.ESNext, { snapshot })).resolvedModule?.resolvedFileName, + "/node_modules/pkg/index.d.ts", + ); + assert.equal(callbackSnapshots[0], snapshot); + + assert.equal( + (await passthroughResolver.resolveModuleName("pkg", "/src", ModuleKind.ESNext)).resolvedModule?.resolvedFileName, + "/node_modules/pkg/index.d.ts", + ); + assert.equal(callbackSnapshots[1], undefined); + }); + + test("static resolutions do not report native resolution provenance diagnostics", async () => { + await using api = spawnAPI({ + "/src/index.ts": `import { value } from "./value.ts"; export { value };`, + "/value.ts": `export const value = 1;`, + }); + const resolver = await api.createModuleResolver( + { + noLib: true, + module: ModuleKind.NodeNext, + moduleResolution: ModuleResolutionKind.NodeNext, + }, + { + moduleResolutions: { + fallback: "unresolved", + entries: [{ + moduleName: "./value.ts", + result: { + resolvedFileName: "/value.ts", + }, + }], + }, + }, + ); + const snapshot = await api.createSnapshot({ + createPrograms: [{ + rootFiles: ["/src/index.ts"], + compilerOptions: { + noLib: true, + module: ModuleKind.NodeNext, + moduleResolution: ModuleResolutionKind.NodeNext, + }, + options: { + moduleResolver: resolver, + }, + }], + }); + const program = snapshot.operation.createdPrograms[0]; + assert.deepEqual(await program.getSemanticDiagnostics("/src/index.ts"), []); + }); + test("Program resolved modules and type reference directives", async () => { await using api = spawnAPI({ "/src/index.ts": `/// diff --git a/packages/typescript/test/sync/api-generators.test.ts b/packages/typescript/test/sync/api-generators.test.ts index f9bacf9a96206..17d5f62923d6d 100644 --- a/packages/typescript/test/sync/api-generators.test.ts +++ b/packages/typescript/test/sync/api-generators.test.ts @@ -40,6 +40,7 @@ import { type LiteralType, type MappedType, ModuleKind, + ModuleResolutionKind, type NodeHandle, type Program, type Project, @@ -1522,6 +1523,16 @@ describe("API - generator batching", () => { assert.equal(checker.isArgumentsSymbol(argumentsSymbol), true); assert.equal(checker.isUnknownSignature(unknownSignature), true); + const moduleResolutionSpec = { + fallback: "unresolved" as const, + entries: [{ moduleName: "models", result: { resolvedFileName: "/src/models.ts" } }], + }; + const moduleResolver = api.batch(api.createModuleResolver.gen( + { moduleResolution: ModuleResolutionKind.NodeNext }, + { moduleResolutions: moduleResolutionSpec }, + ))[0]; + exercisedMethods.add("API.createModuleResolver"); + const cases: ParityCase[] = [ parityCase("API", "parseConfigFile", api.parseConfigFile, assertDeepEquivalent, "/tsconfig.json"), parityCase("API", "parseCommandLine", api.parseCommandLine, assertDeepEquivalent, ["--strict", "--noEmit"]), @@ -1540,6 +1551,7 @@ describe("API - generator batching", () => { temporaryProjects.push(temporarySnapshot.getProjects()[0].configFileName); }), parityCase("Snapshot", "getDefaultProjectForFile", snapshot.getDefaultProjectForFile, assertOptionalProjectsEquivalent, "/src/index.ts"), + parityCase("ModuleResolver", "resolveModuleName", moduleResolver.resolveModuleName, assertDeepEquivalent, "models", "/src"), parityCase("Snapshot", "update", snapshot.update, assertSnapshotsEquivalent, {}), parityCase("Project", "getImportAdderEdits", project.getImportAdderEdits, assertDeepEquivalent, "/src/index.ts", [{ kind: "importSymbol", symbol: unimportedSymbol }]), @@ -1759,6 +1771,10 @@ describe("API - generator batching", () => { assert.throws(() => disposableProgram.getSourceFileNames(), /snapshot .* not found/); assert.equal(disposableProgram.dispose(), undefined); exercisedMethods.add("Program.dispose"); + const disposableResolver = destructiveAPI.batch(destructiveAPI.createModuleResolver.gen({}))[0]; + destructiveAPI.batch(disposableResolver.dispose.gen()); + assert.equal(disposableResolver.dispose(), undefined); + exercisedMethods.add("ModuleResolver.dispose"); destructiveAPI.batch(destructiveAPI.close.gen()); assert.equal(destructiveAPI.close(), undefined); exercisedMethods.add("API.close"); @@ -1768,6 +1784,7 @@ describe("API - generator batching", () => { { name: "API", value: api.constructor as object, own: true }, { name: "InternalAPI", value: api.internal }, { name: "Snapshot", value: snapshot }, + { name: "ModuleResolver", value: moduleResolver }, { name: "Project", value: project }, { name: "LanguageService", value: languageService }, { name: "Program", value: program }, diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index 969c2885ceb35..ead2ac76f4227 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -79,6 +79,7 @@ import { IndexKind, type IndexType, type InferredProjectId, + type InProgressSnapshot, type InterfaceType, type IntrinsicType, isErrorType, @@ -88,6 +89,7 @@ import { ModifierFlags, ModuleKind, ModuleResolutionKind, + type ModuleResolver, type NumberLiteralType, ObjectFlags, type Program, @@ -132,6 +134,14 @@ describe("API", () => { const lsp = undefined! as API; void lsp.getCurrentLanguageServerSnapshot({ openProjects: ["/tsconfig.json"] }); + const moduleResolver = undefined! as ModuleResolver; + void lsp.getCurrentLanguageServerSnapshot({ + createPrograms: [{ + rootFiles: ["/index.ts"], + compilerOptions: {}, + options: { moduleResolver }, + }], + }); const baseSnapshot = undefined! as Snapshot; void lsp.getCurrentLanguageServerSnapshot(undefined, baseSnapshot); @@ -503,6 +513,363 @@ describe("API", () => { assert.deepEqual(originalProgram.getSourceFileNames(), ["/src/a.ts"]); }); + test("snapshot.update reconfigures module resolution providers", () => { + const root = "/src/index.ts"; + const providedA = "/a.d.ts"; + const providedB = "/b.d.ts"; + const { api: disposableAPI, fs } = spawnAPIWithFS({ + [root]: `import { value } from "pkg"; export { value };`, + [providedA]: `export declare const value: "a";`, + [providedB]: `export declare const value: "b";`, + }); + using api = disposableAPI; + const compilerOptions = { + noLib: true, + module: ModuleKind.NodeNext, + moduleResolution: ModuleResolutionKind.NodeNext, + }; + const spec = (resolvedFileName: string) => ({ + fallback: "unresolved" as const, + entries: [{ moduleName: "pkg", result: { resolvedFileName } }], + }); + const resolverA = api.createModuleResolver(compilerOptions, { moduleResolutions: spec(providedA) }); + const resolverB = api.createModuleResolver(compilerOptions, { moduleResolutions: spec(providedB) }); + const inlineResolverA = api.createModuleResolver(compilerOptions, { moduleResolutions: spec(providedA) }); + const inlineResolverB = api.createModuleResolver(compilerOptions, { moduleResolutions: spec(providedA) }); + const createProgram = (moduleResolver?: ModuleResolver) => ({ + rootFiles: [root], + compilerOptions, + ...(moduleResolver ? { options: { moduleResolver } } : {}), + }); + + const initial = api.createSnapshot({ createPrograms: [createProgram(resolverA)] }); + const programId = initial.operation.createdPrograms![0].id; + assert.deepEqual([...initial.getProgram(programId)!.getSourceFileNames()].sort(), [providedA, root]); + assert.equal( + (resolverA.resolveModuleName("pkg", "/src", undefined, { snapshot: initial })).resolvedModule?.resolvedFileName, + providedA, + ); + assert.equal((resolverA.resolveModuleName("pkg", "/src")).resolvedModule?.resolvedFileName, providedA); + + const sameSet = initial.update({ + reconfigurePrograms: [{ id: programId, ...createProgram(resolverA) }], + }); + assert.deepEqual([...sameSet.getProgram(programId)!.getSourceFileNames()].sort(), [providedA, root]); + + fs.writeFile!(root, `import { value } from "pkg"; export const updated = value;`); + const sameSetAfterEdit = sameSet.update({ + reconfigurePrograms: [{ id: programId, ...createProgram(resolverA) }], + fileNotifications: { changed: [root] }, + }); + assert.deepEqual([...sameSetAfterEdit.getProgram(programId)!.getSourceFileNames()].sort(), [providedA, root]); + + const changedSet = sameSetAfterEdit.update({ + reconfigurePrograms: [{ id: programId, ...createProgram(resolverB) }], + }); + assert.deepEqual([...changedSet.getProgram(programId)!.getSourceFileNames()].sort(), [providedB, root]); + + const removedSet = sameSetAfterEdit.update({ + reconfigurePrograms: [{ id: programId, ...createProgram(undefined) }], + }); + assert.deepEqual(removedSet.getProgram(programId)!.getSourceFileNames(), [root]); + + const inline = sameSetAfterEdit.update({ + reconfigurePrograms: [{ id: programId, ...createProgram(inlineResolverA) }], + }); + const repeatedInline = inline.update({ + reconfigurePrograms: [{ id: programId, ...createProgram(inlineResolverB) }], + }); + assert.deepEqual([...repeatedInline.getProgram(programId)!.getSourceFileNames()].sort(), [providedA, root]); + + let callbackCalls = 0; + const callbackResolver = api.createModuleResolver(compilerOptions, { + resolveModuleName: (moduleName: string) => { + callbackCalls++; + return moduleName === "pkg" ? { resolvedFileName: providedA } : undefined; + }, + }); + const callbackSnapshot = api.createSnapshot({ + createPrograms: [createProgram(callbackResolver)], + }); + const callbackProgramId = callbackSnapshot.operation.createdPrograms![0].id; + const repeatedCallback = callbackSnapshot.update({ + reconfigurePrograms: [{ id: callbackProgramId, ...createProgram(callbackResolver) }], + }); + assert.deepEqual([...repeatedCallback.getProgram(callbackProgramId)!.getSourceFileNames()].sort(), [providedA, root]); + assert.equal(callbackCalls, 1); + }); + + test("module resolver runs against snapshots or the host filesystem", () => { + const packageJson = "/node_modules/pkg/package.json"; + const { api: disposableAPI, fs } = spawnAPIWithFS({ + [packageJson]: JSON.stringify({ name: "pkg", version: "1.0.0", types: "a.d.ts" }), + "/node_modules/pkg/a.d.ts": `export declare const value: "a";`, + }); + using api = disposableAPI; + const resolver = api.createModuleResolver({ + module: ModuleKind.NodeNext, + moduleResolution: ModuleResolutionKind.NodeNext, + }); + const firstSnapshot = api.createSnapshot(); + assert.equal( + (resolver.resolveModuleName("pkg", "/src", undefined, { snapshot: firstSnapshot })).resolvedModule?.resolvedFileName, + "/node_modules/pkg/a.d.ts", + ); + + fs.writeFile!(packageJson, JSON.stringify({ name: "pkg", version: "1.0.0", types: "b.d.ts" })); + fs.writeFile!("/node_modules/pkg/b.d.ts", `export declare const value: "b";`); + const secondSnapshot = firstSnapshot.update({ + fileNotifications: { + changed: [packageJson], + created: ["/node_modules/pkg/b.d.ts"], + }, + }); + + assert.equal( + (resolver.resolveModuleName("pkg", "/src", undefined, { snapshot: firstSnapshot })).resolvedModule?.resolvedFileName, + "/node_modules/pkg/a.d.ts", + ); + assert.equal( + (resolver.resolveModuleName("pkg", "/src", undefined, { snapshot: secondSnapshot })).resolvedModule?.resolvedFileName, + "/node_modules/pkg/b.d.ts", + ); + assert.equal( + (resolver.resolveModuleName("pkg", "/src")).resolvedModule?.resolvedFileName, + "/node_modules/pkg/b.d.ts", + ); + }); + + test("module resolver callbacks can delegate to another resolver", () => { + using api = spawnAPI({ + "/src/index.ts": `import "custom"; import "native";`, + "/custom.d.ts": `export {};`, + "/node_modules/native/package.json": JSON.stringify({ name: "native", version: "1.0.0", types: "index.d.ts" }), + "/node_modules/native/index.d.ts": `export {};`, + }); + const compilerOptions = { + noLib: true, + module: ModuleKind.NodeNext, + moduleResolution: ModuleResolutionKind.NodeNext, + }; + const defaultResolver = api.createModuleResolver(compilerOptions); + const callbackSnapshots: (Snapshot | InProgressSnapshot | undefined)[] = []; + const customResolver = api.createModuleResolver(compilerOptions, { + resolveModuleName: (moduleName, containingDirectory, resolutionMode, { snapshot }) => { + callbackSnapshots.push(snapshot); + if (moduleName === "custom") return { resolvedFileName: "/custom.d.ts" }; + assert.ok(snapshot); + return (defaultResolver.resolveModuleName( + moduleName, + containingDirectory, + resolutionMode, + { snapshot }, + )).resolvedModule; + }, + }); + const snapshot = api.createSnapshot({ + createPrograms: [{ + rootFiles: ["/src/index.ts"], + compilerOptions, + options: { moduleResolver: customResolver }, + }], + }); + assert.deepEqual( + [...snapshot.operation.createdPrograms[0].getSourceFileNames()].sort(), + ["/custom.d.ts", "/node_modules/native/index.d.ts", "/src/index.ts"], + ); + assert.equal(callbackSnapshots.length, 2); + assert.equal(callbackSnapshots[0], callbackSnapshots[1]); + assert.ok(typeof callbackSnapshots[0] === "number"); + assert.ok(callbackSnapshots[0] < 0); + }); + + test("module resolver callbacks can resolve against the in-progress snapshot filesystem", () => { + using api = spawnAPI({ + "/src/index.ts": `import "layered";`, + }); + const compilerOptions = { + noLib: true, + module: ModuleKind.NodeNext, + moduleResolution: ModuleResolutionKind.NodeNext, + }; + const defaultResolver = api.createModuleResolver({ + ...compilerOptions, + customConditions: ["delegated"], + }); + const customResolver = api.createModuleResolver(compilerOptions, { + resolveModuleName: (moduleName, containingDirectory, resolutionMode, { snapshot }) => { + assert.ok(snapshot); + return (defaultResolver.resolveModuleName( + moduleName, + containingDirectory, + resolutionMode, + { snapshot }, + )).resolvedModule; + }, + }); + const snapshot = api.createSnapshot({ + fileSystem: createFileSystemLayer([ + [ + "/node_modules/layered/package.json", + JSON.stringify({ + name: "layered", + version: "1.0.0", + exports: { ".": { delegated: "./index.d.ts" } }, + }), + ], + ["/node_modules/layered/index.d.ts", `export {};`], + ]), + createPrograms: [{ + rootFiles: ["/src/index.ts"], + compilerOptions, + options: { moduleResolver: customResolver }, + }], + }); + assert.deepEqual( + [...snapshot.operation.createdPrograms[0].getSourceFileNames()].sort(), + ["/node_modules/layered/index.d.ts", "/src/index.ts"], + ); + }); + + test("module resolver callback errors reject lib replacement", () => { + using api = spawnAPI({ + "/src/index.ts": `export {};`, + }); + const resolver = api.createModuleResolver( + { moduleResolution: ModuleResolutionKind.Bundler }, + { + resolveModuleName: () => { + throw new Error("lib replacement callback failed"); + }, + }, + ); + + assert.throws( + () => + api.createSnapshot({ + createPrograms: [{ + rootFiles: ["/src/index.ts"], + compilerOptions: { libReplacement: true }, + options: { moduleResolver: resolver }, + }], + }), + /lib replacement callback failed/, + ); + }); + + test("program module resolution uses the resolver compiler options", () => { + using api = spawnAPI({ + "/src/index.ts": `/// +import "pkg/feature";`, + "/node_modules/pkg/package.json": JSON.stringify({ + name: "pkg", + version: "1.0.0", + exports: { "./feature": { resolver: "./dist/feature.d.ts" } }, + }), + "/node_modules/pkg/dist/feature.d.ts": `export {};`, + "/node_modules/@types/resolver-types/index.d.ts": `export {};`, + }); + const resolver = api.createModuleResolver({ + module: ModuleKind.ESNext, + moduleResolution: ModuleResolutionKind.Bundler, + customConditions: ["resolver"], + }); + const snapshot = api.createSnapshot({ + createPrograms: [{ + rootFiles: ["/src/index.ts"], + compilerOptions: { + noLib: true, + module: ModuleKind.Node16, + moduleResolution: ModuleResolutionKind.Node16, + }, + options: { moduleResolver: resolver }, + }], + }); + + assert.deepEqual( + [...snapshot.operation.createdPrograms![0].getSourceFileNames()].sort(), + ["/node_modules/@types/resolver-types/index.d.ts", "/node_modules/pkg/dist/feature.d.ts", "/src/index.ts"], + ); + }); + + test("module resolver callbacks preserve retained and live filesystem context", () => { + using api = spawnAPI({ + "/src/index.ts": `export {};`, + "/node_modules/pkg/package.json": JSON.stringify({ name: "pkg", version: "1.0.0", types: "index.d.ts" }), + "/node_modules/pkg/index.d.ts": `export {};`, + }); + const compilerOptions = { + module: ModuleKind.NodeNext, + moduleResolution: ModuleResolutionKind.NodeNext, + }; + const defaultResolver = api.createModuleResolver(compilerOptions); + const callbackSnapshots: (Snapshot | InProgressSnapshot | undefined)[] = []; + const passthroughResolver = api.createModuleResolver(compilerOptions, { + resolveModuleName: (moduleName, containingDirectory, resolutionMode, { snapshot }) => { + callbackSnapshots.push(snapshot); + return (defaultResolver.resolveModuleName( + moduleName, + containingDirectory, + resolutionMode, + snapshot === undefined ? undefined : { snapshot }, + )).resolvedModule; + }, + }); + const snapshot = api.createSnapshot(); + + assert.equal( + (passthroughResolver.resolveModuleName("pkg", "/src", ModuleKind.ESNext, { snapshot })).resolvedModule?.resolvedFileName, + "/node_modules/pkg/index.d.ts", + ); + assert.equal(callbackSnapshots[0], snapshot); + + assert.equal( + (passthroughResolver.resolveModuleName("pkg", "/src", ModuleKind.ESNext)).resolvedModule?.resolvedFileName, + "/node_modules/pkg/index.d.ts", + ); + assert.equal(callbackSnapshots[1], undefined); + }); + + test("static resolutions do not report native resolution provenance diagnostics", () => { + using api = spawnAPI({ + "/src/index.ts": `import { value } from "./value.ts"; export { value };`, + "/value.ts": `export const value = 1;`, + }); + const resolver = api.createModuleResolver( + { + noLib: true, + module: ModuleKind.NodeNext, + moduleResolution: ModuleResolutionKind.NodeNext, + }, + { + moduleResolutions: { + fallback: "unresolved", + entries: [{ + moduleName: "./value.ts", + result: { + resolvedFileName: "/value.ts", + }, + }], + }, + }, + ); + const snapshot = api.createSnapshot({ + createPrograms: [{ + rootFiles: ["/src/index.ts"], + compilerOptions: { + noLib: true, + module: ModuleKind.NodeNext, + moduleResolution: ModuleResolutionKind.NodeNext, + }, + options: { + moduleResolver: resolver, + }, + }], + }); + const program = snapshot.operation.createdPrograms[0]; + assert.deepEqual(program.getSemanticDiagnostics("/src/index.ts"), []); + }); + test("Program resolved modules and type reference directives", () => { using api = spawnAPI({ "/src/index.ts": `/// diff --git a/tools/gen-proto/main.go b/tools/gen-proto/main.go index e528a19c02459..c2541415b255f 100644 --- a/tools/gen-proto/main.go +++ b/tools/gen-proto/main.go @@ -494,6 +494,8 @@ func (r *typeRenderer) namedType(named *types.Named) string { case r.apiPackagePath + ".DocumentIdentifier": r.documentIdentifier = obj return "DocumentIdentifier" + case r.apiPackagePath + ".ResolutionMode": + return "ResolutionMode" case r.apiPackagePath + ".EnsurePrograms": return "EnsurePrograms" case "github.com/microsoft/TypeScript/tsc/internal/project.ID": @@ -611,6 +613,7 @@ func (r *typeRenderer) declarations() (string, error) { writeDoc(&out, "", r.docs[r.documentIdentifier]) out.WriteString("export type DocumentIdentifier = string | { uri: string; };\n\n") } + out.WriteString("export type ResolutionMode = ModuleKind.None | ModuleKind.CommonJS | ModuleKind.ESNext;\n\n") out.WriteString("export type EnsurePrograms = true | readonly ProjectId[];\n\n") out.WriteString("export type InferredProjectId = string & { __inferredProjectIdBrand: any; };\n") out.WriteString("export type ConfiguredProjectId = Path & { __configuredProjectIdBrand: any; };\n") diff --git a/tsc/internal/api/module_resolution.go b/tsc/internal/api/module_resolution.go new file mode 100644 index 0000000000000..7df844f5e3875 --- /dev/null +++ b/tsc/internal/api/module_resolution.go @@ -0,0 +1,401 @@ +package api + +import ( + "context" + "fmt" + "strings" + "sync" + + "github.com/microsoft/TypeScript/tsc/internal/core" + "github.com/microsoft/TypeScript/tsc/internal/ipc" + "github.com/microsoft/TypeScript/tsc/internal/json" + "github.com/microsoft/TypeScript/tsc/internal/locale" + "github.com/microsoft/TypeScript/tsc/internal/module" + "github.com/microsoft/TypeScript/tsc/internal/packagejson" + "github.com/microsoft/TypeScript/tsc/internal/project" + "github.com/microsoft/TypeScript/tsc/internal/tspath" +) + +type moduleResolverFactory struct { + registration *moduleResolverRegistration + session *Session + conn ipc.Conn + ctx context.Context + currentDirectory string +} + +type programResolutionContext struct { + options module.ResolverOptions + resolvers map[ModuleResolverID]module.Resolver + mu sync.Mutex +} + +type callbackModuleResolver struct { + registration *moduleResolverRegistration + conn ipc.Conn + ctx context.Context + currentDirectory string + snapshot SnapshotID + programResolutionContextID uint64 + fallbackResolver module.Resolver +} + +func (f *moduleResolverFactory) NewResolver( + options module.ResolverOptions, +) (module.Resolver, func()) { + options.CompilerOptions = f.registration.compilerOptions + var fallback module.Resolver = module.NewResolver(options) + if f.registration.resolveModuleNameCallback == "" { + if f.registration.resolutions != nil { + fallback = module.NewStaticResolver(fallback, f.registration.resolutions) + } + return fallback, func() {} + } + contextID := f.session.registerProgramResolutionContext(fallback, options, f.registration) + var resolver module.Resolver = &callbackModuleResolver{ + registration: f.registration, + conn: f.conn, + ctx: f.ctx, + currentDirectory: f.currentDirectory, + programResolutionContextID: contextID, + fallbackResolver: fallback, + } + if f.registration.resolutions != nil { + resolver = module.NewStaticResolver(resolver, f.registration.resolutions) + } + return resolver, func() { + f.session.releaseProgramResolutionContext(contextID) + } +} + +func (p *callbackModuleResolver) ResolveModuleName( + moduleName string, + containingFile string, + resolutionMode core.ResolutionMode, + redirectedReference module.ResolvedProjectReference, +) (*module.ResolvedModule, []module.DiagAndArgs, error) { + return p.resolveModuleName(moduleName, containingFile, tspath.GetDirectoryPath(containingFile), resolutionMode, redirectedReference) +} + +func (p *callbackModuleResolver) ResolveModuleNameFromDirectory( + moduleName string, + containingDirectory string, + resolutionMode core.ResolutionMode, +) (*module.ResolvedModule, []module.DiagAndArgs, error) { + return p.resolveModuleName(moduleName, containingDirectory, containingDirectory, resolutionMode, nil) +} + +func (p *callbackModuleResolver) resolveModuleName( + moduleName string, + containingFile string, + containingDirectory string, + resolutionMode core.ResolutionMode, + redirectedReference module.ResolvedProjectReference, +) (*module.ResolvedModule, []module.DiagAndArgs, error) { + params := &ResolveModuleNameCallbackParams{ + ModuleName: moduleName, + ContainingDirectory: containingDirectory, + } + if p.snapshot != 0 { + params.Snapshot = &p.snapshot + } + if p.programResolutionContextID != 0 { + params.InProgressSnapshot = &p.programResolutionContextID + } + mode := ResolutionMode(resolutionMode) + params.ResolutionMode = &mode + callbackResult, err := p.conn.Call(p.ctx, p.registration.resolveModuleNameCallback, params) + if err != nil { + return nil, nil, fmt.Errorf("resolveModuleName callback failed: %w", err) + } + + if len(callbackResult) == 0 || string(callbackResult) == "null" { + return nil, nil, nil + } + var staticResolution StaticModuleResolution + if err := json.Unmarshal(callbackResult, &staticResolution); err != nil { + return nil, nil, fmt.Errorf("invalid resolveModuleName callback result: %w", err) + } + return staticModuleResolutionToResolvedModule(&staticResolution, p.currentDirectory), nil, nil +} + +func (p *callbackModuleResolver) ResolveTypeReferenceDirective( + typeReferenceDirectiveName string, + containingFile string, + resolutionMode core.ResolutionMode, + redirectedReference module.ResolvedProjectReference, +) (*module.ResolvedTypeReferenceDirective, []module.DiagAndArgs) { + return p.fallbackResolver.ResolveTypeReferenceDirective(typeReferenceDirectiveName, containingFile, resolutionMode, redirectedReference) +} + +func (p *callbackModuleResolver) GetPackageScopeForPath(directory string) *packagejson.InfoCacheEntry { + return p.fallbackResolver.GetPackageScopeForPath(directory) +} + +func (p *callbackModuleResolver) PackageJsonCacheEntries(f func(key tspath.Path, value *packagejson.InfoCacheEntry) bool) { + p.fallbackResolver.PackageJsonCacheEntries(f) +} + +func (p *callbackModuleResolver) ResolvePackageDirectory( + moduleName string, + containingFile string, + resolutionMode core.ResolutionMode, + redirectedReference module.ResolvedProjectReference, +) *module.ResolvedModule { + return p.fallbackResolver.ResolvePackageDirectory(moduleName, containingFile, resolutionMode, redirectedReference) +} + +func compileModuleResolutionSpec(spec *ModuleResolutionSpec, currentDirectory string, useCaseSensitive bool) (*module.StaticResolutions, error) { + if spec == nil { + return nil, nil + } + fallbackToResolution := false + switch spec.Fallback { + case ModuleResolutionFallbackResolve: + fallbackToResolution = true + case ModuleResolutionFallbackUnresolved: + fallbackToResolution = false + default: + return nil, fmt.Errorf("%w: invalid module resolution fallback %q", ErrClientError, spec.Fallback) + } + + entries := make([]module.StaticResolutionEntry, 0, len(spec.Entries)) + for i, entry := range spec.Entries { + if entry == nil { + return nil, fmt.Errorf("%w: module resolution entry %d is null", ErrClientError, i) + } + if entry.ModuleName == "" { + return nil, fmt.Errorf("%w: module resolution entry %d has an empty moduleName", ErrClientError, i) + } + if entry.Result == nil { + return nil, fmt.Errorf("%w: module resolution entry %d has no result", ErrClientError, i) + } + + staticEntry := module.StaticResolutionEntry{ModuleName: entry.ModuleName} + if entry.ContainingDirectory != nil { + staticEntry.ContainingDirectory = tspath.GetNormalizedAbsolutePath(entry.ContainingDirectory.ToAbsoluteFileName(currentDirectory), currentDirectory) + } + if entry.ResolutionMode != nil { + mode := core.ModuleKind(*entry.ResolutionMode) + if mode != core.ModuleKindNone && mode != core.ModuleKindCommonJS && mode != core.ModuleKindESNext { + return nil, fmt.Errorf("%w: module resolution entry %d has invalid resolutionMode %s", ErrClientError, i, mode.String()) + } + staticEntry.ResolutionMode = &mode + } + staticEntry.Result = staticModuleResolutionToResolvedModule(entry.Result, currentDirectory) + entries = append(entries, staticEntry) + } + + resolutions, err := module.NewStaticResolutions(entries, fallbackToResolution, currentDirectory, useCaseSensitive) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrClientError, err) + } + return resolutions, nil +} + +func staticModuleResolutionToResolvedModule(staticResolution *StaticModuleResolution, currentDirectory string) *module.ResolvedModule { + if staticResolution == nil || staticResolution.ResolvedFileName == nil { + return nil + } + result := &module.ResolvedModule{ + ResolvedFileName: tspath.GetNormalizedAbsolutePath(staticResolution.ResolvedFileName.ToAbsoluteFileName(currentDirectory), currentDirectory), + } + if staticResolution.OriginalPath != nil { + result.OriginalPath = tspath.GetNormalizedAbsolutePath(staticResolution.OriginalPath.ToAbsoluteFileName(currentDirectory), currentDirectory) + } + if staticResolution.PackageID != nil { + result.PackageId = module.PackageId{ + Name: staticResolution.PackageID.Name, + SubModuleName: staticResolution.PackageID.SubModuleName, + Version: staticResolution.PackageID.Version, + PeerDependencies: staticResolution.PackageID.PeerDependencies, + } + } + originalPath := result.ResolvedFileName + if result.OriginalPath != "" { + originalPath = result.OriginalPath + } + result.Extension = tspath.TryGetExtensionFromPath(result.ResolvedFileName) + result.IsExternalLibraryImport = strings.Contains(originalPath, "/node_modules/") + return result +} + +func moduleResolutionTraceToStrings(trace []module.DiagAndArgs) []string { + return core.Map(trace, func(entry module.DiagAndArgs) string { + return entry.Message.Localize(locale.Default, entry.Args...) + }) +} + +func (s *Session) moduleResolverFactory(ctx context.Context, options *CreateProgramOptions) (project.ModuleResolverFactory, error) { + if options.ModuleResolver == 0 { + return nil, nil + } + s.moduleResolversMu.RLock() + data := s.moduleResolvers[options.ModuleResolver] + s.moduleResolversMu.RUnlock() + if data == nil { + return nil, fmt.Errorf("%w: module resolver %d not found", ErrClientError, options.ModuleResolver) + } + if data.resolveModuleNameCallback != "" && s.conn == nil { + return nil, fmt.Errorf("%w: API connection is not initialized", ErrClientError) + } + return &moduleResolverFactory{ + registration: data, + session: s, + conn: s.conn, + ctx: ctx, + currentDirectory: s.GetCurrentDirectory(), + }, nil +} + +func (s *Session) registerProgramResolutionContext( + resolver module.Resolver, + options module.ResolverOptions, + registration *moduleResolverRegistration, +) uint64 { + id := s.nextProgramResolutionContextID.Add(1) + s.programResolutionContextsMu.Lock() + s.programResolutionContexts[id] = &programResolutionContext{ + options: options, + resolvers: map[ModuleResolverID]module.Resolver{registration.id: resolver}, + } + s.programResolutionContextsMu.Unlock() + return id +} + +func (s *Session) releaseProgramResolutionContext(id uint64) { + s.programResolutionContextsMu.Lock() + delete(s.programResolutionContexts, id) + s.programResolutionContextsMu.Unlock() +} + +func (c *programResolutionContext) resolverFor(registration *moduleResolverRegistration) module.Resolver { + c.mu.Lock() + defer c.mu.Unlock() + if resolver := c.resolvers[registration.id]; resolver != nil { + return resolver + } + options := c.options + options.CompilerOptions = registration.compilerOptions + resolver := module.NewResolver(options) + c.resolvers[registration.id] = resolver + return resolver +} + +func moduleResolutionError(snapshot *project.Snapshot) error { + for _, project := range snapshot.ProjectCollection.Projects() { + if project.Program != nil { + if err := project.Program.ModuleResolutionError(); err != nil { + return err + } + } + } + return nil +} + +func (s *Session) handleCreateModuleResolver(params *CreateModuleResolverParams) (ModuleResolverID, error) { + provider, err := compileModuleResolutionSpec( + params.ModuleResolutions, + s.GetCurrentDirectory(), + s.FS().UseCaseSensitiveFileNames(), + ) + if err != nil { + return 0, err + } + id := ModuleResolverID(s.nextModuleResolverID.Add(1)) + data := &moduleResolverRegistration{ + id: id, + compilerOptions: ¶ms.CompilerOptions, + resolutions: provider, + resolveModuleNameCallback: params.ResolveModuleNameCallback, + } + s.moduleResolversMu.Lock() + s.moduleResolvers[id] = data + s.moduleResolversMu.Unlock() + return id, nil +} + +func (s *Session) handleReleaseModuleResolver(params *ReleaseModuleResolverParams) (any, error) { + s.moduleResolversMu.Lock() + _, ok := s.moduleResolvers[params.Resolver] + if ok { + delete(s.moduleResolvers, params.Resolver) + } + s.moduleResolversMu.Unlock() + if !ok { + return nil, fmt.Errorf("%w: module resolver %d not found", ErrClientError, params.Resolver) + } + return nil, nil +} + +func (s *Session) handleResolveModuleName(ctx context.Context, params *ResolveModuleNameParams) (*ResolveModuleNameResult, error) { + if params.ModuleName == "" { + return nil, fmt.Errorf("%w: moduleName is empty", ErrClientError) + } + s.moduleResolversMu.RLock() + data := s.moduleResolvers[params.Resolver] + s.moduleResolversMu.RUnlock() + if data == nil { + return nil, fmt.Errorf("%w: module resolver %d not found", ErrClientError, params.Resolver) + } + + mode := core.ResolutionModeNone + if params.ResolutionMode != nil { + mode = core.ModuleKind(*params.ResolutionMode) + if mode != core.ResolutionModeNone && mode != core.ResolutionModeCommonJS && mode != core.ResolutionModeESM { + return nil, fmt.Errorf("%w: invalid resolutionMode %s", ErrClientError, mode.String()) + } + } + containingDirectory := tspath.GetNormalizedAbsolutePath(params.ContainingDirectory.ToAbsoluteFileName(s.GetCurrentDirectory()), s.GetCurrentDirectory()) + + var resolver module.Resolver + if params.Snapshot != 0 && params.InProgressSnapshot != 0 { + return nil, fmt.Errorf("%w: snapshot and inProgressSnapshot are mutually exclusive", ErrClientError) + } + if params.InProgressSnapshot != 0 { + s.programResolutionContextsMu.RLock() + resolutionContext := s.programResolutionContexts[params.InProgressSnapshot] + s.programResolutionContextsMu.RUnlock() + if resolutionContext == nil { + return nil, fmt.Errorf("%w: in-progress snapshot %d not found", ErrClientError, params.InProgressSnapshot) + } + resolver = resolutionContext.resolverFor(data) + } else if params.Snapshot != 0 { + sd, err := s.getSnapshotData(params.Snapshot) + if err != nil { + return nil, err + } + resolver = module.NewResolver(module.ResolverOptions{ + Host: sd.snapshot, + CompilerOptions: data.compilerOptions, + ExtraExtensions: sd.snapshot.ContentMapperExtensions(), + }) + } else { + resolver = module.NewResolver(module.ResolverOptions{ + Host: s, + CompilerOptions: data.compilerOptions, + }) + } + if data.resolveModuleNameCallback != "" { + resolver = &callbackModuleResolver{ + registration: data, + conn: s.conn, + ctx: ctx, + currentDirectory: s.GetCurrentDirectory(), + snapshot: params.Snapshot, + programResolutionContextID: params.InProgressSnapshot, + fallbackResolver: resolver, + } + } + if data.resolutions != nil { + resolver = module.NewStaticResolver(resolver, data.resolutions) + } + result, trace, err := resolver.ResolveModuleNameFromDirectory(params.ModuleName, containingDirectory, mode) + if err != nil { + return nil, err + } + return &ResolveModuleNameResult{ + ResolvedModule: newResolvedModuleResponse(result), + Trace: moduleResolutionTraceToStrings(trace), + }, nil +} diff --git a/tsc/internal/api/proto.go b/tsc/internal/api/proto.go index df213b998852a..9c563f2049b7e 100644 --- a/tsc/internal/api/proto.go +++ b/tsc/internal/api/proto.go @@ -34,11 +34,12 @@ var ( type Method string type ( - SnapshotID uint64 - SymbolID uint64 - TypeID uint32 - SignatureID uint64 - NodeHandle string + SnapshotID uint64 + ModuleResolverID uint64 + SymbolID uint64 + TypeID uint32 + SignatureID uint64 + NodeHandle string ) func SymbolHandle(symbol *ast.Symbol) SymbolID { @@ -62,6 +63,9 @@ const ( MethodCreateSnapshot Method = "createSnapshot" MethodUpdateSnapshot Method = "updateSnapshot" MethodGetCurrentLanguageServerSnapshot Method = "getCurrentLanguageServerSnapshot" + MethodCreateModuleResolver Method = "createModuleResolver" + MethodReleaseModuleResolver Method = "releaseModuleResolver" + MethodResolveModuleName Method = "resolveModuleName" MethodParseCommandLine Method = "parseCommandLine" MethodReadConfigFile Method = "readConfigFile" MethodParseJsonConfigFile Method = "parseJsonConfigFileContent" @@ -441,6 +445,68 @@ type LanguageServerSnapshotChanges struct { type CreateProgramOptions struct { ProjectReferences []*core.ProjectReference `json:"projectReferences,omitempty"` ConfigFileParsingDiagnostics []*DiagnosticResponse `json:"configFileParsingDiagnostics,omitempty"` + ModuleResolver ModuleResolverID `json:"moduleResolver,omitempty"` +} + +type ( + ModuleResolutionFallback string + ResolutionMode core.ModuleKind +) + +const ( + ModuleResolutionFallbackResolve ModuleResolutionFallback = "resolve" + ModuleResolutionFallbackUnresolved ModuleResolutionFallback = "unresolved" +) + +type ModuleResolutionSpec struct { + Fallback ModuleResolutionFallback `json:"fallback"` + Entries []*ModuleResolutionEntry `json:"entries" nonnil:"true"` +} + +type ModuleResolutionEntry struct { + ModuleName string `json:"moduleName"` + ContainingDirectory *DocumentIdentifier `json:"containingDirectory,omitempty"` + ResolutionMode *ResolutionMode `json:"resolutionMode,omitempty"` + Result *StaticModuleResolution `json:"result" nonnil:"true"` +} + +type StaticModuleResolution struct { + ResolvedFileName *DocumentIdentifier `json:"resolvedFileName,omitempty"` + OriginalPath *DocumentIdentifier `json:"originalPath,omitempty"` + PackageID *PackageId `json:"packageId,omitempty"` +} + +type CreateModuleResolverParams struct { + CompilerOptions core.CompilerOptions `json:"compilerOptions"` + ModuleResolutions *ModuleResolutionSpec `json:"moduleResolutions,omitempty"` + ResolveModuleNameCallback string `json:"resolveModuleNameCallback,omitempty"` +} + +type ReleaseModuleResolverParams struct { + Resolver ModuleResolverID `json:"resolver"` +} + +type ResolveModuleNameParams struct { + Snapshot SnapshotID `json:"snapshot,omitempty"` + InProgressSnapshot uint64 `json:"inProgressSnapshot,omitempty"` + Resolver ModuleResolverID `json:"resolver"` + ModuleName string `json:"moduleName"` + ContainingDirectory DocumentIdentifier `json:"containingDirectory"` + ResolutionMode *ResolutionMode `json:"resolutionMode,omitempty"` +} + +type ResolveModuleNameCallbackParams struct { + ModuleName string `json:"moduleName"` + ContainingDirectory string `json:"containingDirectory"` + ResolutionMode *ResolutionMode `json:"resolutionMode,omitempty"` + Snapshot *SnapshotID `json:"snapshot,omitempty"` + InProgressSnapshot *uint64 `json:"inProgressSnapshot,omitempty"` +} + +type ResolveModuleNameResult struct { + ResolvedModule *ResolvedModule `json:"resolvedModule,omitempty"` + // Trace is provided when compilerOptions.traceResolution is true. + Trace []string `json:"trace,omitempty"` } // ProjectFileChanges describes what source files changed within a single project. @@ -492,6 +558,9 @@ var unmarshalers = map[Method]func([]byte) (any, error){ MethodCreateSnapshot: unmarshallerFor[CreateSnapshotParams], MethodUpdateSnapshot: unmarshallerFor[UpdateSnapshotParams], MethodGetCurrentLanguageServerSnapshot: unmarshallerFor[GetCurrentLanguageServerSnapshotParams], + MethodCreateModuleResolver: unmarshallerFor[CreateModuleResolverParams], + MethodReleaseModuleResolver: unmarshallerFor[ReleaseModuleResolverParams], + MethodResolveModuleName: unmarshallerFor[ResolveModuleNameParams], MethodParseCommandLine: unmarshallerFor[ParseCommandLineParams], MethodReadConfigFile: unmarshallerFor[ReadConfigFileParams], MethodParseJsonConfigFile: unmarshallerFor[ParseJsonConfigFileContentParams], diff --git a/tsc/internal/api/server.go b/tsc/internal/api/server.go index 6f922dcd1cbc7..12fdda3104886 100644 --- a/tsc/internal/api/server.go +++ b/tsc/internal/api/server.go @@ -126,6 +126,7 @@ func (s *StdioServer) Run(ctx context.Context) error { if callbackFS != nil { callbackFS.SetConnection(ctx, conn) } + session.SetConnection(conn) return serverRunError(ctx, conn.Run(ctx)) } diff --git a/tsc/internal/api/session.go b/tsc/internal/api/session.go index 1d74cf71b5c7c..a564686981587 100644 --- a/tsc/internal/api/session.go +++ b/tsc/internal/api/session.go @@ -74,6 +74,13 @@ type snapshotData struct { projectRegistriesMu sync.RWMutex } +type moduleResolverRegistration struct { + id ModuleResolverID + compilerOptions *core.CompilerOptions + resolutions *module.StaticResolutions + resolveModuleNameCallback string +} + // projectRegistryData holds per-project type and signature registries. // Types and signatures use per-checker sequential IDs, so the same local ID // can appear in multiple projects. Separate maps per project prevent collisions @@ -429,6 +436,14 @@ type Session struct { languageServerUpdateMu sync.Mutex + nextModuleResolverID atomic.Uint64 + moduleResolvers map[ModuleResolverID]*moduleResolverRegistration + moduleResolversMu sync.RWMutex + nextProgramResolutionContextID atomic.Uint64 + programResolutionContexts map[uint64]*programResolutionContext + programResolutionContextsMu sync.RWMutex + conn ipc.Conn + cpuProfiler pprof.CPUProfiler } @@ -470,10 +485,12 @@ func newSession(snapshotHost *project.SnapshotHost, withLocale func(context.Cont withLocale = func(ctx context.Context) context.Context { return ctx } } s := &Session{ - id: formatSessionID(id), - snapshotHost: snapshotHost, - withLocale: withLocale, - snapshots: make(map[SnapshotID]*snapshotData), + id: formatSessionID(id), + snapshotHost: snapshotHost, + withLocale: withLocale, + snapshots: make(map[SnapshotID]*snapshotData), + moduleResolvers: make(map[ModuleResolverID]*moduleResolverRegistration), + programResolutionContexts: make(map[uint64]*programResolutionContext), } if options != nil { s.useBinaryResponses = options.UseBinaryResponses @@ -486,11 +503,15 @@ func (s *Session) ID() string { return s.id } -func (s *Session) currentDirectory() string { +func (s *Session) SetConnection(conn ipc.Conn) { + s.conn = conn +} + +func (s *Session) GetCurrentDirectory() string { return s.snapshotHost.GetCurrentDirectory() } -func (s *Session) fileSystem() vfs.FS { +func (s *Session) FS() vfs.FS { if s.projectSession != nil { return s.projectSession.FS() } @@ -686,6 +707,12 @@ func (s *Session) HandleRequest(ctx context.Context, method string, params json. return s.handleUpdateSnapshot(ctx, parsed.(*UpdateSnapshotParams)) case string(MethodGetCurrentLanguageServerSnapshot): return s.handleGetCurrentLanguageServerSnapshot(ctx, parsed.(*GetCurrentLanguageServerSnapshotParams)) + case string(MethodCreateModuleResolver): + return s.handleCreateModuleResolver(parsed.(*CreateModuleResolverParams)) + case string(MethodReleaseModuleResolver): + return s.handleReleaseModuleResolver(parsed.(*ReleaseModuleResolverParams)) + case string(MethodResolveModuleName): + return s.handleResolveModuleName(ctx, parsed.(*ResolveModuleNameParams)) case string(MethodParseCommandLine): return s.handleParseCommandLine(ctx, parsed.(*ParseCommandLineParams)) case string(MethodReadConfigFile): @@ -1161,13 +1188,13 @@ func (s *Session) HandleNotification(ctx context.Context, method string, params func (s *Session) handleInitialize(ctx context.Context) (*InitializeResponse, error) { return &InitializeResponse{ UseCaseSensitiveFileNames: s.useCaseSensitiveFileNames(), - CurrentDirectory: s.currentDirectory(), + CurrentDirectory: s.GetCurrentDirectory(), }, nil } // handleCreateSnapshot creates a new independent snapshot. func (s *Session) handleCreateSnapshot(ctx context.Context, params *CreateSnapshotParams) (*CreateSnapshotResponse, error) { - apiRequest, err := s.toAPISnapshotRequest(¶ms.SnapshotRequestChangesParams) + apiRequest, err := s.toAPISnapshotRequest(ctx, ¶ms.SnapshotRequestChangesParams) if err != nil { return nil, err } @@ -1176,7 +1203,7 @@ func (s *Session) handleCreateSnapshot(ctx context.Context, params *CreateSnapsh fileChanges := s.toFileChangeSummary(params.FileNotifications) var snapshotFileSystem vfs.FS if params.FileSystem != nil { - fileSystem, fileSystemErr := requestfilesystem.NewForUpdate(params.FileSystem, s.fileSystem(), s.currentDirectory(), &fileChanges) + fileSystem, fileSystemErr := requestfilesystem.NewForUpdate(params.FileSystem, s.FS(), s.GetCurrentDirectory(), &fileChanges) if fileSystemErr != nil { return nil, fmt.Errorf("%w: %w", ErrClientError, fileSystemErr) } @@ -1191,6 +1218,10 @@ func (s *Session) handleCreateSnapshot(ctx context.Context, params *CreateSnapsh snapshot.Deref() return nil, fmt.Errorf("%w: failed to create snapshot: %w", ErrClientError, err) } + if err := moduleResolutionError(snapshot); err != nil { + snapshot.Deref() + return nil, err + } response := s.createSnapshotResponse(snapshot, nil, ¶ms.SnapshotRequestChangesParams) s.registerSnapshot(snapshot, openState, snapshotFileSystem) @@ -1208,7 +1239,7 @@ func (s *Session) handleUpdateSnapshot(ctx context.Context, params *UpdateSnapsh if changes == nil { changes = &CreateSnapshotParams{} } - apiRequest, err := s.toAPISnapshotRequest(&changes.SnapshotRequestChangesParams) + apiRequest, err := s.toAPISnapshotRequest(ctx, &changes.SnapshotRequestChangesParams) if err != nil { return nil, err } @@ -1218,9 +1249,9 @@ func (s *Session) handleUpdateSnapshot(ctx context.Context, params *UpdateSnapsh if changes.FileSystem != nil { baseFileSystem := snapshotFileSystem if baseFileSystem == nil { - baseFileSystem = s.fileSystem() + baseFileSystem = s.FS() } - fileSystem, fileSystemErr := requestfilesystem.NewForUpdate(changes.FileSystem, baseFileSystem, s.currentDirectory(), &fileChanges) + fileSystem, fileSystemErr := requestfilesystem.NewForUpdate(changes.FileSystem, baseFileSystem, s.GetCurrentDirectory(), &fileChanges) if fileSystemErr != nil { return nil, fmt.Errorf("%w: %w", ErrClientError, fileSystemErr) } @@ -1235,17 +1266,21 @@ func (s *Session) handleUpdateSnapshot(ctx context.Context, params *UpdateSnapsh snapshot.Deref() return nil, fmt.Errorf("%w: failed to update snapshot: %w", ErrClientError, err) } + if err := moduleResolutionError(snapshot); err != nil { + snapshot.Deref() + return nil, err + } response := s.createSnapshotResponse(snapshot, baseSD.snapshot, &changes.SnapshotRequestChangesParams) s.registerSnapshot(snapshot, openState, snapshotFileSystem) return response, nil } -func (s *Session) toAPISnapshotRequest(changes *SnapshotRequestChangesParams) (*project.APISnapshotRequest, error) { +func (s *Session) toAPISnapshotRequest(ctx context.Context, changes *SnapshotRequestChangesParams) (*project.APISnapshotRequest, error) { apiRequest := &project.APISnapshotRequest{} for _, p := range changes.OpenProjects { - configFileName := p.ToAbsoluteFileName(s.currentDirectory()) + configFileName := p.ToAbsoluteFileName(s.GetCurrentDirectory()) configuredProjectID, ok := project.ParseConfiguredProjectID(s.toPath(configFileName)) if !ok { return nil, fmt.Errorf("%w: invalid configured project ID: %s", ErrClientError, configFileName) @@ -1261,7 +1296,7 @@ func (s *Session) toAPISnapshotRequest(changes *SnapshotRequestChangesParams) (* } for _, p := range changes.CloseProjects { - configPath := s.toPath(p.ToAbsoluteFileName(s.currentDirectory())) + configPath := s.toPath(p.ToAbsoluteFileName(s.GetCurrentDirectory())) if apiRequest.CloseProjects == nil { apiRequest.CloseProjects = collections.NewSetWithSizeHint[tspath.Path](len(changes.CloseProjects)) } @@ -1269,7 +1304,7 @@ func (s *Session) toAPISnapshotRequest(changes *SnapshotRequestChangesParams) (* } for _, f := range changes.OpenFiles { - fileName := f.ToAbsoluteFileName(s.currentDirectory()) + fileName := f.ToAbsoluteFileName(s.GetCurrentDirectory()) path := s.toPath(fileName) if apiRequest.OpenFiles == nil { apiRequest.OpenFiles = make(map[tspath.Path]string, len(changes.OpenFiles)) @@ -1282,7 +1317,7 @@ func (s *Session) toAPISnapshotRequest(changes *SnapshotRequestChangesParams) (* } for _, f := range changes.CloseFiles { - path := s.toPath(f.ToURI(s.currentDirectory()).FileName()) + path := s.toPath(f.ToURI(s.GetCurrentDirectory()).FileName()) if apiRequest.CloseFiles == nil { apiRequest.CloseFiles = collections.NewSetWithSizeHint[tspath.Path](len(changes.CloseFiles)) } @@ -1296,7 +1331,7 @@ func (s *Session) toAPISnapshotRequest(changes *SnapshotRequestChangesParams) (* } rootFileNames := make([]string, len(programParams.RootFiles)) for j, rootFile := range programParams.RootFiles { - rootFileNames[j] = rootFile.ToAbsoluteFileName(s.currentDirectory()) + rootFileNames[j] = rootFile.ToAbsoluteFileName(s.GetCurrentDirectory()) } request := &project.APICreateProgramRequest{ RootFileNames: rootFileNames, @@ -1305,6 +1340,12 @@ func (s *Session) toAPISnapshotRequest(changes *SnapshotRequestChangesParams) (* if programParams.Options != nil { request.ProjectReferences = programParams.Options.ProjectReferences request.ConfigFileParsingDiagnostics = core.Map(programParams.Options.ConfigFileParsingDiagnostics, func(d *DiagnosticResponse) *ast.Diagnostic { return d.ToDiagnostic() }) + factory, err := s.moduleResolverFactory(ctx, programParams.Options) + if err != nil { + return nil, err + } + request.ModuleResolverFactory = factory + request.ModuleResolverID = uint64(programParams.Options.ModuleResolver) } apiRequest.CreatePrograms[i] = request } @@ -1324,7 +1365,7 @@ func (s *Session) toAPISnapshotRequest(changes *SnapshotRequestChangesParams) (* reconfiguredProgramIDs.Add(programID) rootFileNames := make([]string, len(programParams.RootFiles)) for j, rootFile := range programParams.RootFiles { - rootFileNames[j] = rootFile.ToAbsoluteFileName(s.currentDirectory()) + rootFileNames[j] = rootFile.ToAbsoluteFileName(s.GetCurrentDirectory()) } request := &project.APIReconfigureProgramRequest{ ProgramID: programID, @@ -1334,6 +1375,12 @@ func (s *Session) toAPISnapshotRequest(changes *SnapshotRequestChangesParams) (* if programParams.Options != nil { request.ProjectReferences = programParams.Options.ProjectReferences request.ConfigFileParsingDiagnostics = core.Map(programParams.Options.ConfigFileParsingDiagnostics, func(d *DiagnosticResponse) *ast.Diagnostic { return d.ToDiagnostic() }) + factory, err := s.moduleResolverFactory(ctx, programParams.Options) + if err != nil { + return nil, err + } + request.ModuleResolverFactory = factory + request.ModuleResolverID = uint64(programParams.Options.ModuleResolver) } apiRequest.ReconfigurePrograms[i] = request } @@ -1363,8 +1410,8 @@ type languageServerSnapshotUpdate struct { openState snapshotOpenState } -func (s *Session) toLanguageServerSnapshotUpdate(changes *SnapshotRequestChangesParams) (*languageServerSnapshotUpdate, error) { - apiRequest, err := s.toAPISnapshotRequest(changes) +func (s *Session) toLanguageServerSnapshotUpdate(ctx context.Context, changes *SnapshotRequestChangesParams) (*languageServerSnapshotUpdate, error) { + apiRequest, err := s.toAPISnapshotRequest(ctx, changes) if err != nil { return nil, err } @@ -1494,7 +1541,7 @@ func (s *Session) handleGetCurrentLanguageServerSnapshot(ctx context.Context, pa if changes == nil { changes = &LanguageServerSnapshotChanges{} } - update, err := s.toLanguageServerSnapshotUpdate(&changes.SnapshotRequestChangesParams) + update, err := s.toLanguageServerSnapshotUpdate(ctx, &changes.SnapshotRequestChangesParams) if err != nil { return nil, err } @@ -1532,7 +1579,7 @@ func (s *Session) handleGetDefaultProjectForFile(ctx context.Context, params *Ge return nil, err } - uri := params.File.ToURI(s.currentDirectory()) + uri := params.File.ToURI(s.GetCurrentDirectory()) proj := sd.snapshot.GetDefaultProject(uri) if proj == nil { return nil, nil @@ -1548,7 +1595,7 @@ func (s *Session) handleParseCommandLine(ctx context.Context, params *ParseComma // handleReadConfigFile reads and parses a JSON configuration file. func (s *Session) handleReadConfigFile(ctx context.Context, params *ReadConfigFileParams) (*ReadConfigFileResponse, error) { - configFileName := params.File.ToAbsoluteFileName(s.currentDirectory()) + configFileName := params.File.ToAbsoluteFileName(s.GetCurrentDirectory()) configFileContent, ok := s.snapshotHost.FS().ReadFile(configFileName) if !ok { return &ReadConfigFileResponse{ @@ -1578,9 +1625,9 @@ func (s *Session) handleParseJsonConfigFileContent(ctx context.Context, params * var basePath string var configFileName string if params.ConfigDirectory != nil { - basePath = tspath.GetNormalizedAbsolutePath(*params.ConfigDirectory, s.currentDirectory()) + basePath = tspath.GetNormalizedAbsolutePath(*params.ConfigDirectory, s.GetCurrentDirectory()) } else { - configFileName = params.ConfigFileName.ToAbsoluteFileName(s.currentDirectory()) + configFileName = params.ConfigFileName.ToAbsoluteFileName(s.GetCurrentDirectory()) basePath = tspath.GetDirectoryPath(configFileName) } @@ -1598,7 +1645,7 @@ func (s *Session) handleParseJsonConfigFileContent(ctx context.Context, params * // handleParseConfigFile parses a tsconfig.json file and returns its contents. func (s *Session) handleParseConfigFile(ctx context.Context, params *ParseConfigFileParams) (*ConfigFileResponse, error) { - configFileName := params.File.ToAbsoluteFileName(s.currentDirectory()) + configFileName := params.File.ToAbsoluteFileName(s.GetCurrentDirectory()) configFileContent, ok := s.snapshotHost.FS().ReadFile(configFileName) if !ok { return nil, fmt.Errorf("%w: could not read file %q", ErrClientError, configFileName) @@ -1638,7 +1685,7 @@ func (s *Session) handleCreateSourceFile(ctx context.Context, params *CreateSour // @gen-proto-result: SourceFileResponse func (s *Session) handleCreateSourceFileFromFile(ctx context.Context, params *CreateSourceFileFromFileParams) (any, error) { - fileName := tspath.GetNormalizedAbsolutePath(params.FileName, s.currentDirectory()) + fileName := tspath.GetNormalizedAbsolutePath(params.FileName, s.GetCurrentDirectory()) sourceText, ok := s.snapshotHost.FS().ReadFile(fileName) if !ok { return nil, fmt.Errorf("%w: could not read file %q", ErrClientError, fileName) @@ -1658,7 +1705,7 @@ func (s *Session) createSourceFile(fileName string, sourceText string, options C if !isValidCreateSourceFileScriptKind(scriptKind) { return nil, fmt.Errorf("%w: invalid scriptKind %d", ErrClientError, scriptKind) } - fileName = tspath.GetNormalizedAbsolutePath(fileName, s.currentDirectory()) + fileName = tspath.GetNormalizedAbsolutePath(fileName, s.GetCurrentDirectory()) return parser.ParseSourceFile(ast.SourceFileParseOptions{ FileName: fileName, Path: s.toPath(fileName), @@ -1675,7 +1722,7 @@ func isValidCreateSourceFileScriptKind(scriptKind core.ScriptKind) bool { } func (s *Session) handleTranspileFromFile(ctx context.Context, params *TranspileFromFileParams, declaration bool) (*TranspileOutputResponse, error) { - fileName := tspath.GetNormalizedAbsolutePath(params.FileName, s.currentDirectory()) + fileName := tspath.GetNormalizedAbsolutePath(params.FileName, s.GetCurrentDirectory()) input, ok := s.snapshotHost.FS().ReadFile(fileName) if !ok { return nil, fmt.Errorf("%w: could not read file %q", ErrClientError, fileName) @@ -2602,7 +2649,7 @@ func (s *Session) handleGetImportAdderEdits(ctx context.Context, params *GetImpo userPreferences := workingSnapshot.UserPreferences() if registry := workingSnapshot.AutoImportRegistry(); registry == nil || !registry.IsPreparedForImportingFile(sourceFile.FileName(), projectID, userPreferences) { - preparedSnapshot := s.snapshotHost.CloneSnapshotWithAutoImports(ctx, workingSnapshot, params.File.ToURI(s.currentDirectory()), nil) + preparedSnapshot := s.snapshotHost.CloneSnapshotWithAutoImports(ctx, workingSnapshot, params.File.ToURI(s.GetCurrentDirectory()), nil) if s.projectSession != nil { s.projectSession.TryAdoptSnapshotInBackground(workingSnapshot, preparedSnapshot) } @@ -4422,9 +4469,9 @@ func (s *Session) createSnapshotOperationResponse(snapshot *project.Snapshot, re if request.OpenFiles != nil { results := make([]*OpenedFileOperationResult, len(request.OpenFiles)) for i, file := range request.OpenFiles { - project := snapshot.GetDefaultProject(file.ToURI(s.currentDirectory())) + project := snapshot.GetDefaultProject(file.ToURI(s.GetCurrentDirectory())) if project == nil { - panic("no project found for opened file " + file.ToAbsoluteFileName(s.currentDirectory())) + panic("no project found for opened file " + file.ToAbsoluteFileName(s.GetCurrentDirectory())) } results[i] = &OpenedFileOperationResult{Project: project.ID()} } @@ -4497,7 +4544,7 @@ func formatSessionID(id uint64) string { // toPath converts a file name to a normalized path. func (s *Session) toPath(fileName string) tspath.Path { - return tspath.ToPath(fileName, s.currentDirectory(), s.useCaseSensitiveFileNames()) + return tspath.ToPath(fileName, s.GetCurrentDirectory(), s.useCaseSensitiveFileNames()) } // toFileChangeSummary converts API file changes to a project.FileChangeSummary. @@ -4511,7 +4558,7 @@ func (s *Session) toFileChangeSummary(changes *FileNotifications) project.FileCh summary.IncludesWatchChangeOutsideNodeModules = true return summary } - cwd := s.currentDirectory() + cwd := s.GetCurrentDirectory() for _, doc := range changes.Changed { uri := doc.ToURI(cwd) summary.Changed.Add(uri) @@ -4765,7 +4812,7 @@ func (s *Session) handleGetCompletionsAtPosition(ctx context.Context, params *Ge } result, err := run(sd.snapshot, program) if errors.Is(err, ls.ErrNeedsAutoImports) { - preparedSnapshot := s.snapshotHost.CloneSnapshotWithAutoImports(ctx, sd.snapshot, params.File.ToURI(s.currentDirectory()), nil) + preparedSnapshot := s.snapshotHost.CloneSnapshotWithAutoImports(ctx, sd.snapshot, params.File.ToURI(s.GetCurrentDirectory()), nil) if s.projectSession != nil { s.projectSession.TryAdoptSnapshotInBackground(sd.snapshot, preparedSnapshot) } diff --git a/tsc/internal/api/session_apistate_test.go b/tsc/internal/api/session_apistate_test.go index 94d886d57c561..25810ac7de48c 100644 --- a/tsc/internal/api/session_apistate_test.go +++ b/tsc/internal/api/session_apistate_test.go @@ -102,7 +102,7 @@ func TestOpenProjectRejectsReservedProjectID(t *testing.T) { session := NewStandaloneSession(init, nil) defer session.Close() - _, err := session.toAPISnapshotRequest(&SnapshotRequestChangesParams{ + _, err := session.toAPISnapshotRequest(context.Background(), &SnapshotRequestChangesParams{ OpenProjects: []DocumentIdentifier{{FileName: "/dev/null/inferred"}}, }) assert.ErrorContains(t, err, "invalid configured project ID") diff --git a/tsc/internal/api/session_createprogram_test.go b/tsc/internal/api/session_createprogram_test.go index 2420e649dff15..cd6fc3ef38f3b 100644 --- a/tsc/internal/api/session_createprogram_test.go +++ b/tsc/internal/api/session_createprogram_test.go @@ -184,20 +184,20 @@ func TestReconfigureSyntheticProgramValidation(t *testing.T) { program := &ReconfigureSnapshotProgramParams{Id: "/dev/null/synthetic/1"} var nullReconfigure SnapshotRequestChangesParams assert.NilError(t, json.Unmarshal([]byte(`{"reconfigurePrograms":[null]}`), &nullReconfigure)) - _, err := session.toAPISnapshotRequest(&nullReconfigure) + _, err := session.toAPISnapshotRequest(context.Background(), &nullReconfigure) assert.ErrorContains(t, err, "reconfigurePrograms[0] must not be null") - _, err = session.toAPISnapshotRequest(&SnapshotRequestChangesParams{ + _, err = session.toAPISnapshotRequest(context.Background(), &SnapshotRequestChangesParams{ ReconfigurePrograms: []*ReconfigureSnapshotProgramParams{{Id: "/tsconfig.json"}}, }) assert.ErrorContains(t, err, "invalid synthetic project handle") - _, err = session.toAPISnapshotRequest(&SnapshotRequestChangesParams{ + _, err = session.toAPISnapshotRequest(context.Background(), &SnapshotRequestChangesParams{ ReconfigurePrograms: []*ReconfigureSnapshotProgramParams{program, program}, }) assert.ErrorContains(t, err, "reconfigured more than once") - _, err = session.toAPISnapshotRequest(&SnapshotRequestChangesParams{ + _, err = session.toAPISnapshotRequest(context.Background(), &SnapshotRequestChangesParams{ ReconfigurePrograms: []*ReconfigureSnapshotProgramParams{program}, RemovePrograms: []project.SyntheticProjectID{program.Id}, }) @@ -220,7 +220,7 @@ func TestCreateSyntheticProgramValidation(t *testing.T) { var nullCreate SnapshotRequestChangesParams assert.NilError(t, json.Unmarshal([]byte(`{"createPrograms":[null]}`), &nullCreate)) - _, err := session.toAPISnapshotRequest(&nullCreate) + _, err := session.toAPISnapshotRequest(context.Background(), &nullCreate) assert.ErrorContains(t, err, "createPrograms[0] must not be null") assert.ErrorIs(t, err, ErrClientError) } diff --git a/tsc/internal/api/session_module_resolution_test.go b/tsc/internal/api/session_module_resolution_test.go new file mode 100644 index 0000000000000..215d8b926b90e --- /dev/null +++ b/tsc/internal/api/session_module_resolution_test.go @@ -0,0 +1,312 @@ +package api + +import ( + "context" + "errors" + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/core" + "github.com/microsoft/TypeScript/tsc/internal/json" + "github.com/microsoft/TypeScript/tsc/internal/module" + "github.com/microsoft/TypeScript/tsc/internal/testutil/projecttestutil" + "gotest.tools/v3/assert" +) + +type failingModuleResolutionConn struct { + calls int +} + +func (c *failingModuleResolutionConn) Run(context.Context) error { + return nil +} + +func (c *failingModuleResolutionConn) Call(context.Context, string, any) (json.Value, error) { + c.calls++ + return nil, errors.New("callback error") +} + +func (c *failingModuleResolutionConn) Notify(context.Context, string, any) error { + return nil +} + +func TestModuleResolverUsesSnapshotFileSystem(t *testing.T) { + t.Parallel() + + projectSession, _ := projecttestutil.Setup(map[string]any{ + "/home/projects/p/node_modules/pkg/package.json": `{"name":"pkg","version":"1.0.0","exports":{".":{"types":"./index.d.ts","default":"./index.js"}}}`, + "/home/projects/p/node_modules/pkg/index.d.ts": `export declare const value: string;`, + "/home/projects/p/node_modules/pkg/index.js": `exports.value = "value";`, + }) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + + snapshot, err := session.handleCreateSnapshot(context.Background(), &CreateSnapshotParams{}) + assert.NilError(t, err) + resolver, err := session.handleCreateModuleResolver(&CreateModuleResolverParams{ + CompilerOptions: core.CompilerOptions{ + Module: core.ModuleKindNodeNext, + ModuleResolution: core.ModuleResolutionKindNodeNext, + TraceResolution: core.TSTrue, + }, + }) + assert.NilError(t, err) + result, resolutionErr := session.handleResolveModuleName(context.Background(), &ResolveModuleNameParams{ + Snapshot: snapshot.Snapshot, + Resolver: resolver, + ModuleName: "pkg", + ContainingDirectory: DocumentIdentifier{FileName: "/home/projects/p/src"}, + }) + assert.NilError(t, resolutionErr) + assert.Equal(t, result.ResolvedModule.ResolvedFileName, "/home/projects/p/node_modules/pkg/index.d.ts") + assert.Equal(t, result.ResolvedModule.PackageId.Name, "pkg") + assert.Assert(t, len(result.Trace) > 0) +} + +func TestStaticModuleResolutionSpecificityAndLifetime(t *testing.T) { + t.Parallel() + + projectSession, _ := projecttestutil.Setup(map[string]any{ + "/home/projects/p/global.d.ts": `export declare const value: "global";`, + "/home/projects/p/mode.d.ts": `export declare const value: "mode";`, + "/home/projects/p/dir.d.ts": `export declare const value: "dir";`, + "/home/projects/p/exact.d.ts": `export declare const value: "exact";`, + "/home/projects/p/default.d.ts": `export declare const value: "default";`, + }) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + + snapshot, err := session.handleCreateSnapshot(context.Background(), &CreateSnapshotParams{}) + assert.NilError(t, err) + esm := core.ModuleKindESNext + spec := ModuleResolutionSpec{ + Fallback: ModuleResolutionFallbackUnresolved, + Entries: []*ModuleResolutionEntry{ + staticResolutionEntry("pkg", "", nil, "/home/projects/p/global.d.ts"), + staticResolutionEntry("pkg", "", &esm, "/home/projects/p/mode.d.ts"), + staticResolutionEntry("pkg", "/home/projects/p/src", nil, "/home/projects/p/dir.d.ts"), + staticResolutionEntry("pkg", "/home/projects/p/src", &esm, "/home/projects/p/exact.d.ts"), + }, + } + resolverID, err := session.handleCreateModuleResolver(&CreateModuleResolverParams{ + CompilerOptions: core.CompilerOptions{ModuleResolution: core.ModuleResolutionKindNodeNext}, + ModuleResolutions: &spec, + }) + assert.NilError(t, err) + + assertResolution := func(directory string, mode core.ModuleKind, expected string) { + t.Helper() + resolutionMode := ResolutionMode(mode) + result, resolutionErr := session.handleResolveModuleName(context.Background(), &ResolveModuleNameParams{ + Snapshot: snapshot.Snapshot, + Resolver: resolverID, + ModuleName: "pkg", + ContainingDirectory: DocumentIdentifier{FileName: directory}, + ResolutionMode: &resolutionMode, + }) + assert.NilError(t, resolutionErr) + assert.Equal(t, result.ResolvedModule.ResolvedFileName, expected) + assert.Equal(t, len(result.Trace), 0) + } + assertResolution("/home/projects/p/src", core.ModuleKindESNext, "/home/projects/p/exact.d.ts") + assertResolution("/home/projects/p/src", core.ModuleKindCommonJS, "/home/projects/p/dir.d.ts") + assertResolution("/home/projects/p/other", core.ModuleKindESNext, "/home/projects/p/mode.d.ts") + assertResolution("/home/projects/p/other", core.ModuleKindCommonJS, "/home/projects/p/global.d.ts") + + unresolved, err := session.handleResolveModuleName(context.Background(), &ResolveModuleNameParams{ + Snapshot: snapshot.Snapshot, + Resolver: resolverID, + ModuleName: "other", + ContainingDirectory: DocumentIdentifier{FileName: "/home/projects/p/src"}, + }) + assert.NilError(t, err) + assert.Assert(t, unresolved.ResolvedModule == nil) + + assertResolution("/home/projects/p/src", core.ModuleKindESNext, "/home/projects/p/exact.d.ts") +} + +func TestCreateProgramUsesStaticModuleResolutions(t *testing.T) { + t.Parallel() + + const root = "/home/projects/p/src/index.ts" + const provided = "/home/projects/p/provided.d.ts" + projectSession, _ := projecttestutil.Setup(map[string]any{ + root: `import { value } from "pkg"; export { value };`, + provided: `export declare const value: string;`, + }) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + resolver, err := session.handleCreateModuleResolver(&CreateModuleResolverParams{ + CompilerOptions: core.CompilerOptions{ + NoLib: core.TSTrue, + Module: core.ModuleKindNodeNext, + ModuleResolution: core.ModuleResolutionKindNodeNext, + }, + ModuleResolutions: &ModuleResolutionSpec{ + Fallback: ModuleResolutionFallbackUnresolved, + Entries: []*ModuleResolutionEntry{ + staticResolutionEntry("pkg", "", nil, provided), + }, + }, + }) + assert.NilError(t, err) + + response, err := session.handleCreateSnapshot(context.Background(), &CreateSnapshotParams{ + SnapshotRequestChangesParams: SnapshotRequestChangesParams{ //nolint:modernize + CreatePrograms: []*CreateSnapshotProgramParams{{ + RootFiles: []DocumentIdentifier{{FileName: root}}, + CompilerOptions: core.CompilerOptions{ + NoLib: core.TSTrue, + Module: core.ModuleKindNodeNext, + ModuleResolution: core.ModuleResolutionKindNodeNext, + }, + Options: &CreateProgramOptions{ + ModuleResolver: resolver, + }, + }}, + }, + }) + assert.NilError(t, err) + projectID := (*response.Operation.CreatedPrograms)[0].AsID() + fileNames, err := session.handleGetSourceFileNames(context.Background(), &GetSourceFileNamesParams{ + Snapshot: response.Snapshot, + Project: projectID, + }) + assert.NilError(t, err) + assert.DeepEqual(t, fileNames, []string{provided, root}) +} + +func TestStaticModuleResolutionPreservesStaticIdentity(t *testing.T) { + t.Parallel() + + projectSession, _ := projecttestutil.Setup(map[string]any{}) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + + snapshot, err := session.handleCreateSnapshot(context.Background(), &CreateSnapshotParams{}) + assert.NilError(t, err) + resolver, err := session.handleCreateModuleResolver(&CreateModuleResolverParams{ + CompilerOptions: core.CompilerOptions{ModuleResolution: core.ModuleResolutionKindNodeNext}, + ModuleResolutions: &ModuleResolutionSpec{ + Fallback: ModuleResolutionFallbackUnresolved, + Entries: []*ModuleResolutionEntry{ + { + ModuleName: "pkg", + Result: &StaticModuleResolution{ + ResolvedFileName: &DocumentIdentifier{FileName: "/store/pkg/index.d.ts"}, + OriginalPath: &DocumentIdentifier{FileName: "/node_modules/pkg/index.d.ts"}, + PackageID: &PackageId{ + Name: "pkg", + SubModuleName: "", + Version: "1.2.3", + }, + }, + }, + }, + }, + }) + assert.NilError(t, err) + result, err := session.handleResolveModuleName(context.Background(), &ResolveModuleNameParams{ + Snapshot: snapshot.Snapshot, + Resolver: resolver, + ModuleName: "pkg", + ContainingDirectory: DocumentIdentifier{FileName: "/src"}, + }) + assert.NilError(t, err) + assert.Equal(t, result.ResolvedModule.OriginalPath, "/node_modules/pkg/index.d.ts") + assert.Equal(t, result.ResolvedModule.PackageId.Name, "pkg") + assert.Equal(t, result.ResolvedModule.PackageId.Version, "1.2.3") + assert.Equal(t, result.ResolvedModule.IsExternalLibraryImport, true) +} + +func TestModuleResolutionCallbackErrorsAreReturned(t *testing.T) { + t.Parallel() + + projectSession, _ := projecttestutil.Setup(map[string]any{}) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + conn := &failingModuleResolutionConn{} + registration := &moduleResolverRegistration{ + id: 1, + resolveModuleNameCallback: "resolveModuleName/1", + } + factory := &moduleResolverFactory{ + registration: registration, + session: session, + conn: conn, + ctx: context.Background(), + currentDirectory: "/", + } + provider, cleanup := factory.NewResolver(module.ResolverOptions{ + Host: session, + CompilerOptions: core.EmptyCompilerOptions, + }) + for range 2 { + _, _, err := provider.ResolveModuleNameFromDirectory("pkg", "/src", core.ResolutionModeESM) + assert.ErrorContains(t, err, "callback error") + } + assert.Equal(t, conn.calls, 2) + assert.Equal(t, len(session.programResolutionContexts), 1) + cleanup() + assert.Equal(t, len(session.programResolutionContexts), 0) +} + +func TestModuleResolutionCallbackErrorRejectsLanguageServerUpdate(t *testing.T) { + t.Parallel() + + projectSession, _ := projecttestutil.Setup(map[string]any{ + "/src/index.ts": `import "pkg";`, + }) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + session.conn = &failingModuleResolutionConn{} + resolver, err := session.handleCreateModuleResolver(&CreateModuleResolverParams{ + CompilerOptions: core.CompilerOptions{ + NoLib: core.TSTrue, + Module: core.ModuleKindNodeNext, + ModuleResolution: core.ModuleResolutionKindNodeNext, + }, + ResolveModuleNameCallback: "resolveModuleName/1", + }) + assert.NilError(t, err) + baseSnapshot := projectSession.Snapshot() + + _, err = session.handleGetCurrentLanguageServerSnapshot(context.Background(), &GetCurrentLanguageServerSnapshotParams{ + Changes: &LanguageServerSnapshotChanges{SnapshotRequestChangesParams{ + CreatePrograms: []*CreateSnapshotProgramParams{{ + RootFiles: []DocumentIdentifier{{FileName: "/src/index.ts"}}, + CompilerOptions: core.CompilerOptions{ + NoLib: core.TSTrue, + Module: core.ModuleKindNodeNext, + ModuleResolution: core.ModuleResolutionKindNodeNext, + }, + Options: &CreateProgramOptions{ModuleResolver: resolver}, + }}, + }}, + }) + assert.ErrorContains(t, err, "callback error") + assert.Assert(t, projectSession.Snapshot() == baseSnapshot) + assert.Equal(t, len(projectSession.Snapshot().ProjectCollection.SyntheticProjects()), 0) +} + +func staticResolutionEntry(moduleName string, directory string, mode *core.ModuleKind, fileName string) *ModuleResolutionEntry { + entry := &ModuleResolutionEntry{ + ModuleName: moduleName, + Result: &StaticModuleResolution{ + ResolvedFileName: &DocumentIdentifier{FileName: fileName}, + }, + } + if mode != nil { + resolutionMode := ResolutionMode(*mode) + entry.ResolutionMode = &resolutionMode + } + if directory != "" { + entry.ContainingDirectory = &DocumentIdentifier{FileName: directory} + } + return entry +} diff --git a/tsc/internal/compiler/emitHost.go b/tsc/internal/compiler/emitHost.go index e6d4e1ce153d0..de6c2dde62742 100644 --- a/tsc/internal/compiler/emitHost.go +++ b/tsc/internal/compiler/emitHost.go @@ -138,6 +138,6 @@ func (host *emitHost) GetSymlinkCache() *symlinks.KnownSymlinks { } func (host *emitHost) ResolveModuleName(moduleName string, containingFile string, resolutionMode core.ResolutionMode) *module.ResolvedModule { - resolved, _ := host.program.resolver.ResolveModuleName(moduleName, containingFile, resolutionMode, nil) + resolved, _, _ := host.program.resolver.ResolveModuleName(moduleName, containingFile, resolutionMode, nil) return resolved } diff --git a/tsc/internal/compiler/fileloader.go b/tsc/internal/compiler/fileloader.go index 1885c55056045..f8bba578a534a 100644 --- a/tsc/internal/compiler/fileloader.go +++ b/tsc/internal/compiler/fileloader.go @@ -46,7 +46,7 @@ type sourceFileFromReferenceDiagnostic struct { type fileLoader struct { opts ProgramOptions - resolver *module.Resolver + resolver module.Resolver defaultLibraryPath string comparePathsOptions tspath.ComparePathsOptions supportedExtensions [][]string @@ -70,10 +70,12 @@ type fileLoader struct { // contentMapperMu guards the content-mapper bookkeeping below, which is written concurrently as // content-mapped files are parsed across worker goroutines. - contentMapperMu sync.Mutex - contentMapperFailures map[*contentmapper.Mapper]int - contentMapperInitFailed collections.Set[*contentmapper.Mapper] - contentMapperDiagnostics []*ast.Diagnostic + contentMapperMu sync.Mutex + contentMapperFailures map[*contentmapper.Mapper]int + contentMapperInitFailed collections.Set[*contentmapper.Mapper] + contentMapperDiagnostics []*ast.Diagnostic + moduleResolutionErrorOnce sync.Once + moduleResolutionError error } type redirectsFile struct { @@ -109,7 +111,7 @@ func (r *redirectsFile) Path() tspath.Path { } type processedFiles struct { - resolver *module.Resolver + resolver module.Resolver files []*ast.SourceFile // duplicateSourceFiles tracks parsed files loaded during program construction // that were later dropped from the final program, such as losing filename @@ -138,6 +140,7 @@ type processedFiles struct { redirectFilesByPath map[tspath.Path]*redirectsFile // Program-level diagnostics reported when a content mapper fails fatally (reported once per mapper). contentMapperDiagnostics []*ast.Diagnostic + moduleResolutionError error finishedProcessing bool } @@ -175,7 +178,18 @@ func processAllProgramFiles( contentMapperExtensions: opts.Config.ContentMapperExtensions(), } loader.addProjectReferenceTasks(singleThreaded) - loader.resolver = module.NewResolver(loader.projectReferenceFileMapper.host, compilerOptions, opts.TypingsLocation, opts.ProjectName, opts.Config.ContentMapperExtensions()) + resolverOptions := module.ResolverOptions{ + Host: loader.projectReferenceFileMapper.host, + CompilerOptions: compilerOptions, + TypingsLocation: opts.TypingsLocation, + ProjectName: opts.ProjectName, + ExtraExtensions: opts.Config.ContentMapperExtensions(), + } + if opts.CreateModuleResolver != nil { + loader.resolver = opts.CreateModuleResolver(resolverOptions) + } else { + loader.resolver = module.NewResolver(resolverOptions) + } if opts.Tracing != nil { defer opts.Tracing.Push(tracing.PhaseProgram, "processRootFiles", map[string]any{"count": len(rootFiles)}, false)() } @@ -872,7 +886,18 @@ func (p *fileLoader) resolveImportsAndModuleAugmentations(t *parseTask) { } mode := getModeForUsageLocation(file.FileName(), meta, entry, optionsForFile) - resolvedModule, trace := p.resolver.ResolveModuleName(moduleName, fileName, mode, redirect) + var resolvedModule *module.ResolvedModule + var trace []module.DiagAndArgs + var err error + resolvedModule, trace, err = p.resolver.ResolveModuleName(moduleName, fileName, mode, redirect) + if err != nil { + p.moduleResolutionErrorOnce.Do(func() { + p.moduleResolutionError = err + }) + } + if resolvedModule == nil { + resolvedModule = &module.ResolvedModule{} + } resolutionsInFile[module.ModeAwareCacheKey{Name: moduleName, Mode: mode}] = resolvedModule resolutionsTrace = append(resolutionsTrace, trace...) @@ -963,7 +988,13 @@ func (p *fileLoader) resolveLibrary(libraryName, resolveFrom string) (*module.Re if tr := p.opts.Tracing; tr != nil { defer tr.Push(tracing.PhaseProgram, "resolveLibrary", map[string]any{"resolveFrom": resolveFrom}, false)() } - return p.resolver.ResolveModuleName(libraryName, resolveFrom, core.ModuleKindCommonJS, nil) + resolved, trace, err := p.resolver.ResolveModuleName(libraryName, resolveFrom, core.ModuleKindCommonJS, nil) + if err != nil { + p.moduleResolutionErrorOnce.Do(func() { + p.moduleResolutionError = err + }) + } + return resolved, trace } func getLibraryNameFromLibFileName(libFileName string) string { diff --git a/tsc/internal/compiler/filesparser.go b/tsc/internal/compiler/filesparser.go index 8ec9b43dc827a..dc232f819fa7f 100644 --- a/tsc/internal/compiler/filesparser.go +++ b/tsc/internal/compiler/filesparser.go @@ -582,6 +582,7 @@ func (w *filesParser) getProcessedFiles(loader *fileLoader) processedFiles { redirectTargetsMap: redirectTargetsMap, redirectFilesByPath: redirectFilesByPath, contentMapperDiagnostics: loader.contentMapperDiagnostics, + moduleResolutionError: loader.moduleResolutionError, } } diff --git a/tsc/internal/compiler/program.go b/tsc/internal/compiler/program.go index 37ff6abbf4caa..4521c7f318d21 100644 --- a/tsc/internal/compiler/program.go +++ b/tsc/internal/compiler/program.go @@ -43,6 +43,7 @@ type ProgramOptions struct { TypingsLocation string ProjectName string Tracing *tracing.Tracing + CreateModuleResolver func(options module.ResolverOptions) module.Resolver // SkipModuleResolution avoids all module and type reference resolution while // still collecting import metadata needed for emit. SkipModuleResolution bool @@ -301,8 +302,13 @@ func NewProgram(opts ProgramOptions) *Program { // only if the host cannot locate the file (e.g. it was deleted). Callers that manage // host-side parse caches must release this exact pointer when the old program could not be // reused, since it was acquired speculatively before that decision was made. -func (p *Program) UpdateProgram(changedFilePath tspath.Path, newHost CompilerHost, createCheckerPool func(*Program) CheckerPool) (*Program, *ast.SourceFile, bool) { - if result, newFile, reused := p.ReuseProgram(changedFilePath, newHost, createCheckerPool); reused { +func (p *Program) UpdateProgram( + changedFilePath tspath.Path, + newHost CompilerHost, + createCheckerPool func(*Program) CheckerPool, + createModuleResolver func(module.ResolverOptions) module.Resolver, +) (*Program, *ast.SourceFile, bool) { + if result, newFile, reused := p.ReuseProgram(changedFilePath, newHost, createCheckerPool, createModuleResolver); reused { return result, newFile, true } else { newOpts := p.opts @@ -310,6 +316,9 @@ func (p *Program) UpdateProgram(changedFilePath tspath.Path, newHost CompilerHos if createCheckerPool != nil { newOpts.CreateCheckerPool = createCheckerPool } + if createModuleResolver != nil { + newOpts.CreateModuleResolver = createModuleResolver + } return NewProgram(newOpts), newFile, false } } @@ -320,13 +329,20 @@ func (p *Program) UpdateProgram(changedFilePath tspath.Path, newHost CompilerHos // file cannot be replaced in place. Unlike UpdateProgram, it never constructs a // full fallback program, so callers that build their own fallback (e.g. with a // different host) do not pay for a discarded program build. -func (p *Program) ReuseProgram(changedFilePath tspath.Path, newHost CompilerHost, createCheckerPool func(*Program) CheckerPool) (*Program, *ast.SourceFile, bool) { +func (p *Program) ReuseProgram( + changedFilePath tspath.Path, + newHost CompilerHost, + createCheckerPool func(*Program) CheckerPool, + createModuleResolver func(module.ResolverOptions) module.Resolver, +) (*Program, *ast.SourceFile, bool) { newOpts := p.opts newOpts.Host = newHost if createCheckerPool != nil { newOpts.CreateCheckerPool = createCheckerPool } - + if createModuleResolver != nil { + newOpts.CreateModuleResolver = createModuleResolver + } oldFile := p.filesByPath[changedFilePath] var newFile *ast.SourceFile var oldSupplementalFiles []*ast.SourceFile @@ -342,6 +358,7 @@ func (p *Program) ReuseProgram(changedFilePath tspath.Path, newHost CompilerHost if err != nil { return nil, nil, false } + oldSupplementalFiles = oldFile.SupplementalSourceFiles() newSupplementalFiles = files.Supplemental } else { @@ -624,6 +641,10 @@ func (p *Program) GetResolvedModules() map[tspath.Path]module.ModeAwareCache[*mo return p.resolvedModules } +func (p *Program) ModuleResolutionError() error { + return p.moduleResolutionError +} + // GetPackagesMap returns a lazily-cached map of package names to whether they bundle types. // This is used by incremental diagnostic repopulation. func (p *Program) GetPackagesMap() map[string]bool { @@ -2292,11 +2313,6 @@ func (p *Program) GetSymlinkCache() *symlinks.KnownSymlinks { }) } -func (p *Program) ResolveModuleName(moduleName string, containingFile string, resolutionMode core.ResolutionMode) *module.ResolvedModule { - resolved, _ := p.resolver.ResolveModuleName(moduleName, containingFile, resolutionMode, nil) - return resolved -} - func (p *Program) ForEachResolvedModule(callback func(resolution *module.ResolvedModule, moduleName string, mode core.ResolutionMode, filePath tspath.Path), file *ast.SourceFile) { forEachResolution(p.resolvedModules, callback, file) } diff --git a/tsc/internal/execute/watcher.go b/tsc/internal/execute/watcher.go index 3a3b9405340c9..55907e5d8e7ce 100644 --- a/tsc/internal/execute/watcher.go +++ b/tsc/internal/execute/watcher.go @@ -562,7 +562,7 @@ func (w *Watcher) tryUpdateProgram(host *watchCompilerHost) bool { } } - newProgram, _, reused := oldProgram.ReuseProgram(changedPath, host, nil) + newProgram, _, reused := oldProgram.ReuseProgram(changedPath, host, nil, nil) if reused { w.program = incremental.NewProgram(newProgram, w.program, nil, w.sys.Now, w.testing != nil) } diff --git a/tsc/internal/ipc/conn_sync.go b/tsc/internal/ipc/conn_sync.go index cc3e001e7ef51..a0b0cecb3c3d1 100644 --- a/tsc/internal/ipc/conn_sync.go +++ b/tsc/internal/ipc/conn_sync.go @@ -186,21 +186,38 @@ func (c *SyncConn) Call(ctx context.Context, method string, params any) (json.Va return nil, ctx.Err() } - // Read the response inline. - msg, err := c.protocol.ReadMessage() - if err != nil { - return nil, err - } + for { + // Read the response inline. + msg, err := c.protocol.ReadMessage() + if err != nil { + return nil, err + } - if msg.IsResponse() && msg.ID != nil && msg.ID.String() == method { - if msg.Error != nil { - return nil, fmt.Errorf("ipc: remote error [%d]: %s", msg.Error.Code, msg.Error.Message) + if msg.IsResponse() && msg.ID != nil && msg.ID.String() == method { + if msg.Error != nil { + return nil, fmt.Errorf("ipc: remote error [%d]: %s", msg.Error.Code, msg.Error.Message) + } + return msg.Result, nil + } + if msg.IsRequest() { + // A synchronous client callback may make a nested API request. Release + // the protocol lock while handling it so nested callbacks can proceed. + c.mu.Unlock() + err := c.handleRequest(ctx, msg) + c.mu.Lock() + if err != nil { + return nil, err + } + continue } - return msg.Result, nil + if msg.IsNotification() { + c.mu.Unlock() + c.handleNotification(ctx, msg) + c.mu.Lock() + continue + } + return nil, fmt.Errorf("ipc: unexpected message while waiting for %q response", method) } - - // Unexpected message while waiting for response - return nil, fmt.Errorf("ipc: unexpected message while waiting for %q response", method) } // Notify sends a notification to the client (no response expected). diff --git a/tsc/internal/ls/autoimport/aliasresolver.go b/tsc/internal/ls/autoimport/aliasresolver.go index 52955e92c3d09..62882eef2e5f7 100644 --- a/tsc/internal/ls/autoimport/aliasresolver.go +++ b/tsc/internal/ls/autoimport/aliasresolver.go @@ -21,7 +21,7 @@ type pathAndFileName struct { type aliasResolver struct { toPath func(fileName string) tspath.Path host RegistryCloneHost - moduleResolver *module.Resolver + moduleResolver *module.DefaultResolver rootFiles []*ast.SourceFile // symlinks maps from realpath to symlinked path and file name @@ -34,7 +34,7 @@ func newAliasResolver( rootFiles []*ast.SourceFile, symlinks map[tspath.Path]pathAndFileName, host RegistryCloneHost, - moduleResolver *module.Resolver, + moduleResolver *module.DefaultResolver, toPath func(fileName string) tspath.Path, onFailedAmbientModuleLookup func(source ast.HasFileName, moduleName string), ) *aliasResolver { @@ -118,7 +118,7 @@ func (r *aliasResolver) GetResolvedModule(currentSourceFile ast.HasFileName, mod if resolved, ok := cache.Load(module.ModeAwareCacheKey{Name: moduleReference, Mode: mode}); ok { return resolved } - resolved, _ := r.moduleResolver.ResolveModuleName(moduleReference, currentSourceFile.FileName(), mode, nil) + resolved, _, _ := r.moduleResolver.ResolveModuleName(moduleReference, currentSourceFile.FileName(), mode, nil) resolved, _ = cache.LoadOrStore(module.ModeAwareCacheKey{Name: moduleReference, Mode: mode}, resolved) if !resolved.IsResolved() && !tspath.PathIsRelative(moduleReference) { r.onFailedAmbientModuleLookup(currentSourceFile, moduleReference) diff --git a/tsc/internal/ls/autoimport/aliasresolver_crash_test.go b/tsc/internal/ls/autoimport/aliasresolver_crash_test.go index b502c35ffab8c..1043d9a6f8111 100644 --- a/tsc/internal/ls/autoimport/aliasresolver_crash_test.go +++ b/tsc/internal/ls/autoimport/aliasresolver_crash_test.go @@ -58,7 +58,7 @@ func TestAliasResolverGetDiagnosticsDoesNotPanic(t *testing.T) { }, text, core.ScriptKindTS) binder.BindSourceFile(sourceFile) - resolver := module.NewResolver(host, core.EmptyCompilerOptions, "", "", nil) + resolver := module.NewResolver(module.ResolverOptions{Host: host, CompilerOptions: core.EmptyCompilerOptions}) r := newAliasResolver( []*ast.SourceFile{sourceFile}, nil, diff --git a/tsc/internal/ls/autoimport/extract.go b/tsc/internal/ls/autoimport/extract.go index c1a57f1af1749..f21f585ae90a9 100644 --- a/tsc/internal/ls/autoimport/extract.go +++ b/tsc/internal/ls/autoimport/extract.go @@ -28,7 +28,7 @@ type symbolExtractor struct { type exportExtractor struct { *symbolExtractor - moduleResolver *module.Resolver + moduleResolver *module.DefaultResolver } type extractorStats struct { @@ -70,7 +70,7 @@ func newSymbolExtractor(packageName string, checker *checker.Checker, toPath fun } } -func (b *registryBuilder) newExportExtractor(packageName string, checker *checker.Checker, moduleResolver *module.Resolver, realpath func(string) string) *exportExtractor { +func (b *registryBuilder) newExportExtractor(packageName string, checker *checker.Checker, moduleResolver *module.DefaultResolver, realpath func(string) string) *exportExtractor { return &exportExtractor{ symbolExtractor: newSymbolExtractor(packageName, checker, b.base.toPath, realpath), moduleResolver: moduleResolver, @@ -156,7 +156,7 @@ func (e *exportExtractor) extractFromModule(file *ast.SourceFile) []*Export { moduleID := ModuleID(name) var moduleFileName string if tspath.IsExternalModuleNameRelative(name) { - if resolved, _ := e.moduleResolver.ResolveModuleName(name, file.FileName(), core.ModuleKindCommonJS, nil); resolved.IsResolved() { + if resolved, _, _ := e.moduleResolver.ResolveModuleName(name, file.FileName(), core.ModuleKindCommonJS, nil); resolved.IsResolved() { moduleFileName = resolved.ResolvedFileName moduleID = ModuleID(e.toPath(moduleFileName)) } else { diff --git a/tsc/internal/ls/autoimport/registry.go b/tsc/internal/ls/autoimport/registry.go index 1e8cefcd6c59a..a6c2f256afba3 100644 --- a/tsc/internal/ls/autoimport/registry.go +++ b/tsc/internal/ls/autoimport/registry.go @@ -1099,7 +1099,10 @@ func (b *registryBuilder) updateIndexes(ctx context.Context, change RegistryChan } } if len(rootFiles) > 0 { - moduleResolver := module.NewResolverWithOptions(b.host, core.EmptyCompilerOptions, "", "", b.resolverOptions) + resolverOptions := b.resolverOptions + resolverOptions.Host = b.host + resolverOptions.CompilerOptions = core.EmptyCompilerOptions + moduleResolver := module.NewResolver(resolverOptions) aliasResolver := newAliasResolver( slices.Collect(maps.Values(rootFiles)), nil, @@ -1244,7 +1247,10 @@ func (b *registryBuilder) buildProjectBucket( var mu sync.Mutex fileExcludePatterns := b.userPreferences.ParsedAutoImportFileExcludePatterns(b.host.FS().UseCaseSensitiveFileNames()) result.bucket = &RegistryBucket{} - moduleResolver := module.NewResolverWithOptions(b.host, core.EmptyCompilerOptions, "", "", b.resolverOptions) + resolverOptions := b.resolverOptions + resolverOptions.Host = b.host + resolverOptions.CompilerOptions = core.EmptyCompilerOptions + moduleResolver := module.NewResolver(resolverOptions) program := b.host.GetProgramForProject(projectID) projectRootPath := b.base.toPath(program.GetCurrentDirectory()) symlinkCache := program.GetSymlinkCache() diff --git a/tsc/internal/ls/autoimport/util.go b/tsc/internal/ls/autoimport/util.go index b3b1b75cc962b..b8f21b7319003 100644 --- a/tsc/internal/ls/autoimport/util.go +++ b/tsc/internal/ls/autoimport/util.go @@ -314,10 +314,12 @@ func (rh *resolutionHost) FS() vfs.FS { return rh.fs } -func getModuleResolver(host RegistryCloneHost, realpath func(string) string, opts module.ResolverOptions) *module.Resolver { +func getModuleResolver(host RegistryCloneHost, realpath func(string) string, opts module.ResolverOptions) *module.DefaultResolver { rh := &resolutionHost{ fs: wrapvfs.Wrap(host.FS(), wrapvfs.Replacements{Realpath: realpath}), currentDirectory: host.GetCurrentDirectory(), } - return module.NewResolverWithOptions(rh, core.EmptyCompilerOptions, "", "", opts) + opts.Host = rh + opts.CompilerOptions = core.EmptyCompilerOptions + return module.NewResolver(opts) } diff --git a/tsc/internal/ls/sourcedefinition.go b/tsc/internal/ls/sourcedefinition.go index bea42cddfe45a..0a8024aa644f2 100644 --- a/tsc/internal/ls/sourcedefinition.go +++ b/tsc/internal/ls/sourcedefinition.go @@ -135,7 +135,7 @@ type sourceDefResolver struct { options *core.CompilerOptions getSourceFile func(string) *ast.SourceFile resolveFrom string - resolver *module.Resolver + resolver *module.DefaultResolver parsedFiles map[string]*ast.SourceFile } @@ -152,7 +152,12 @@ func (l *LanguageService) newSourceDefResolver( options: options, getSourceFile: program.GetSourceFile, resolveFrom: resolveFrom, - resolver: module.NewResolver(program.Host(), noDtsOptions, program.GetGlobalTypingsCacheLocation(), "", program.CommandLine().ContentMapperExtensions()), + resolver: module.NewResolver(module.ResolverOptions{ + Host: program.Host(), + CompilerOptions: noDtsOptions, + TypingsLocation: program.GetGlobalTypingsCacheLocation(), + ExtraExtensions: program.CommandLine().ContentMapperExtensions(), + }), } } @@ -422,7 +427,7 @@ func (r *sourceDefResolver) resolveImplementationFrom( } for _, mode := range modes { - resolved, _ := r.resolver.ResolveModuleName(moduleName, resolveFromFile, mode, nil) + resolved, _, _ := r.resolver.ResolveModuleName(moduleName, resolveFromFile, mode, nil) if resolved != nil && resolved.IsResolved() && !tspath.IsDeclarationFileName(resolved.ResolvedFileName) { return resolved.ResolvedFileName } diff --git a/tsc/internal/lsp/server.go b/tsc/internal/lsp/server.go index 1d0ab3b6edca8..1d32d1e97b6be 100644 --- a/tsc/internal/lsp/server.go +++ b/tsc/internal/lsp/server.go @@ -2332,6 +2332,7 @@ func (s *Server) handleInitializeAPISession(ctx context.Context, params *lsproto }() conn := ipc.NewAsyncConn(rwc, apiSession) + apiSession.SetConnection(conn) if apiErr := conn.Run(apiCtx); apiErr != nil { s.logger.Errorf("API session %s: %v", apiSession.ID(), apiErr) } diff --git a/tsc/internal/module/resolver.go b/tsc/internal/module/resolver.go index 2363435bb2207..367884c90698a 100644 --- a/tsc/internal/module/resolver.go +++ b/tsc/internal/module/resolver.go @@ -66,7 +66,7 @@ func (t *tracer) getTraces() []DiagAndArgs { } type resolutionState struct { - resolver *Resolver + resolver *DefaultResolver tracer *tracer // request fields @@ -97,8 +97,7 @@ func newResolutionState( resolutionMode core.ResolutionMode, compilerOptions *core.CompilerOptions, redirectedReference ResolvedProjectReference, - resolver *Resolver, - traceBuilder *tracer, + resolver *DefaultResolver, traceBuilder *tracer, ) *resolutionState { state := &resolutionState{ name: name, @@ -146,7 +145,7 @@ func GetCompilerOptionsWithRedirect(compilerOptions *core.CompilerOptions, redir return compilerOptions } -type Resolver struct { +type DefaultResolver struct { caches host ResolutionHost compilerOptions *core.CompilerOptions @@ -157,59 +156,42 @@ type Resolver struct { } type ResolverOptions struct { + Host ResolutionHost + CompilerOptions *core.CompilerOptions + TypingsLocation string + ProjectName string + ExtraExtensions []string PackageJsonCache *packagejson.InfoCache } -func NewResolver( - host ResolutionHost, - options *core.CompilerOptions, - typingsLocation string, - projectName string, - extraExtensions []string, -) *Resolver { - return &Resolver{ - host: host, - caches: newCaches(host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames(), options), - compilerOptions: options, - typingsLocation: typingsLocation, - projectName: projectName, - extraExtensions: extraExtensions, - } -} - -func NewResolverWithOptions( - host ResolutionHost, - compilerOptions *core.CompilerOptions, - typingsLocation string, - projectName string, - opts ResolverOptions, -) *Resolver { - r := &Resolver{ - host: host, - compilerOptions: compilerOptions, - typingsLocation: typingsLocation, - projectName: projectName, +func NewResolver(opts ResolverOptions) *DefaultResolver { + r := &DefaultResolver{ + host: opts.Host, + compilerOptions: opts.CompilerOptions, + typingsLocation: opts.TypingsLocation, + projectName: opts.ProjectName, + extraExtensions: opts.ExtraExtensions, } if opts.PackageJsonCache != nil { r.packageJsonInfoCache = opts.PackageJsonCache } else { - r.caches = newCaches(host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames(), compilerOptions) + r.caches = newCaches(opts.Host.GetCurrentDirectory(), opts.Host.FS().UseCaseSensitiveFileNames(), opts.CompilerOptions) } return r } -func (r *Resolver) newTraceBuilder() *tracer { +func (r *DefaultResolver) newTraceBuilder() *tracer { if r.compilerOptions.TraceResolution == core.TSTrue { return &tracer{} } return nil } -func (r *Resolver) GetPackageScopeForPath(directory string) *packagejson.InfoCacheEntry { +func (r *DefaultResolver) GetPackageScopeForPath(directory string) *packagejson.InfoCacheEntry { return (&resolutionState{compilerOptions: r.compilerOptions, resolver: r}).getPackageScopeForPath(directory) } -func (r *Resolver) PackageJsonCacheEntries(f func(key tspath.Path, value *packagejson.InfoCacheEntry) bool) { +func (r *DefaultResolver) PackageJsonCacheEntries(f func(key tspath.Path, value *packagejson.InfoCacheEntry) bool) { r.caches.packageJsonInfoCache.Range(f) } @@ -219,7 +201,7 @@ func (r *tracer) traceResolutionUsingProjectReference(redirectedReference Resolv } } -func (r *Resolver) ResolveTypeReferenceDirective( +func (r *DefaultResolver) ResolveTypeReferenceDirective( typeReferenceDirectiveName string, containingFile string, resolutionMode core.ResolutionMode, @@ -264,8 +246,17 @@ func (r *Resolver) ResolveTypeReferenceDirective( return result, traceBuilder.getTraces() } -func (r *Resolver) ResolveModuleName(moduleName string, containingFile string, resolutionMode core.ResolutionMode, redirectedReference ResolvedProjectReference) (*ResolvedModule, []DiagAndArgs) { - containingDirectory := tspath.GetDirectoryPath(containingFile) +func (r *DefaultResolver) ResolveModuleName(moduleName string, containingFile string, resolutionMode core.ResolutionMode, redirectedReference ResolvedProjectReference) (*ResolvedModule, []DiagAndArgs, error) { + result, trace := r.resolveModuleName(moduleName, containingFile, tspath.GetDirectoryPath(containingFile), resolutionMode, redirectedReference) + return result, trace, nil +} + +func (r *DefaultResolver) ResolveModuleNameFromDirectory(moduleName string, containingDirectory string, resolutionMode core.ResolutionMode) (*ResolvedModule, []DiagAndArgs, error) { + result, trace := r.resolveModuleName(moduleName, containingDirectory, containingDirectory, resolutionMode, nil) + return result, trace, nil +} + +func (r *DefaultResolver) resolveModuleName(moduleName string, containingFile string, containingDirectory string, resolutionMode core.ResolutionMode, redirectedReference ResolvedProjectReference) (*ResolvedModule, []DiagAndArgs) { traceBuilder := r.newTraceBuilder() cacheKey := moduleResolutionCacheKey{ @@ -325,7 +316,7 @@ func (r *Resolver) ResolveModuleName(moduleName string, containingFile string, r return finalResult, traceBuilder.getTraces() } -func (r *Resolver) ResolvePackageDirectory(moduleName string, containingFile string, resolutionMode core.ResolutionMode, redirectedReference ResolvedProjectReference) *ResolvedModule { +func (r *DefaultResolver) ResolvePackageDirectory(moduleName string, containingFile string, resolutionMode core.ResolutionMode, redirectedReference ResolvedProjectReference) *ResolvedModule { compilerOptions := GetCompilerOptionsWithRedirect(r.compilerOptions, redirectedReference) containingDirectory := tspath.GetDirectoryPath(containingFile) state := newResolutionState(moduleName, containingDirectory, false /*isTypeReferenceDirective*/, resolutionMode, compilerOptions, redirectedReference, r, nil) @@ -336,7 +327,7 @@ func (r *Resolver) ResolvePackageDirectory(moduleName string, containingFile str return nil } -func (r *Resolver) tryResolveFromTypingsLocation(moduleName string, containingDirectory string, originalResult *ResolvedModule, traceBuilder *tracer) *ResolvedModule { +func (r *DefaultResolver) tryResolveFromTypingsLocation(moduleName string, containingDirectory string, originalResult *ResolvedModule, traceBuilder *tracer) *ResolvedModule { if r.typingsLocation == "" || tspath.IsExternalModuleNameRelative(moduleName) || (originalResult.ResolvedFileName != "" && tspath.ExtensionIsOneOf(originalResult.Extension, tspath.SupportedTSExtensionsWithJsonFlat)) { @@ -365,7 +356,7 @@ func (r *Resolver) tryResolveFromTypingsLocation(moduleName string, containingDi return result } -func (r *Resolver) resolveConfig(moduleName string, containingFile string) *ResolvedModule { +func (r *DefaultResolver) resolveConfig(moduleName string, containingFile string) *ResolvedModule { containingDirectory := tspath.GetDirectoryPath(containingFile) state := newResolutionState(moduleName, containingDirectory, false /*isTypeReferenceDirective*/, core.ModuleKindCommonJS, r.compilerOptions, nil, r, nil) state.isConfigLookup = true @@ -1988,7 +1979,7 @@ type ParsedPatterns struct { patterns []core.Pattern } -func (r *Resolver) getParsedPatternsForPaths(compilerOptions *core.CompilerOptions) *ParsedPatterns { +func (r *DefaultResolver) getParsedPatternsForPaths(compilerOptions *core.CompilerOptions) *ParsedPatterns { return r.parsedPatternsForPaths.Get(compilerOptions.Paths) } @@ -2079,7 +2070,10 @@ func extensionIsOk(extensions extensions, extension string) bool { } func ResolveConfig(moduleName string, containingFile string, host ResolutionHost) *ResolvedModule { - resolver := NewResolver(host, &core.CompilerOptions{ModuleResolution: core.ModuleResolutionKindNodeNext}, "", "", nil) + resolver := NewResolver(ResolverOptions{ + Host: host, + CompilerOptions: &core.CompilerOptions{ModuleResolution: core.ModuleResolutionKindNodeNext}, + }) return resolver.resolveConfig(moduleName, containingFile) } @@ -2165,7 +2159,7 @@ func (e *ResolvedEntrypoint) SymlinkOrRealpath() string { return e.ResolvedFileName } -func (r *Resolver) GetEntrypointsFromPackageJsonInfo(packageJson *packagejson.InfoCacheEntry, packageName string, enableDirectorySearch bool) []*ResolvedEntrypoint { +func (r *DefaultResolver) GetEntrypointsFromPackageJsonInfo(packageJson *packagejson.InfoCacheEntry, packageName string, enableDirectorySearch bool) []*ResolvedEntrypoint { extensions := extensionsTypeScript | extensionsDeclaration features := NodeResolutionFeaturesAll state := &resolutionState{resolver: r, extensions: extensions, features: features, compilerOptions: r.compilerOptions} @@ -2224,7 +2218,7 @@ func (r *Resolver) GetEntrypointsFromPackageJsonInfo(packageJson *packagejson.In return nil } -func (r *Resolver) createResolvedEntrypointHandlingSymlink(fileName string, moduleSpecifier string, includeConditions *collections.Set[string], excludeConditions *collections.Set[string], ending Ending) *ResolvedEntrypoint { +func (r *DefaultResolver) createResolvedEntrypointHandlingSymlink(fileName string, moduleSpecifier string, includeConditions *collections.Set[string], excludeConditions *collections.Set[string], ending Ending) *ResolvedEntrypoint { var originalFileName string resolvedFileName := fileName if realPath := r.host.FS().Realpath(fileName); realPath != fileName { diff --git a/tsc/internal/module/resolver_test.go b/tsc/internal/module/resolver_test.go index 1d75c85c4b47a..9de8cf9c8c223 100644 --- a/tsc/internal/module/resolver_test.go +++ b/tsc/internal/module/resolver_test.go @@ -39,10 +39,10 @@ func TestResolveModuleNameTrailingSlash(t *testing.T) { Module: core.ModuleKindESNext, Target: core.ScriptTargetESNext, } - resolver := module.NewResolver(host, opts, "", "", nil) + resolver := module.NewResolver(module.ResolverOptions{Host: host, CompilerOptions: opts}) for _, name := range []string{"pkg", "pkg/"} { - r, _ := resolver.ResolveModuleName(name, "/repo/src/file.ts", core.ModuleKindESNext, nil) + r, _, _ := resolver.ResolveModuleName(name, "/repo/src/file.ts", core.ModuleKindESNext, nil) if !r.IsResolved() { t.Errorf("%q failed to resolve", name) } @@ -168,7 +168,7 @@ func TestResolveModuleNameTrailingSlashRace(t *testing.T) { Module: core.ModuleKindESNext, Target: core.ScriptTargetESNext, } - resolver := module.NewResolver(host, opts, "", "", nil) + resolver := module.NewResolver(module.ResolverOptions{Host: host, CompilerOptions: opts}) type resolutionResult struct { name string @@ -182,7 +182,7 @@ func TestResolveModuleNameTrailingSlashRace(t *testing.T) { containingFile = "/repo/src/b/file.ts" } wg.Go(func() { - r, _ := resolver.ResolveModuleName(name, containingFile, core.ModuleKindESNext, nil) + r, _, _ := resolver.ResolveModuleName(name, containingFile, core.ModuleKindESNext, nil) results <- resolutionResult{name, r.IsResolved()} }) } @@ -240,7 +240,7 @@ func TestResolveSubpathNilContentsRace(t *testing.T) { Module: core.ModuleKindESNext, Target: core.ScriptTargetESNext, } - resolver := module.NewResolver(host, opts, "", "", nil) + resolver := module.NewResolver(module.ResolverOptions{Host: host, CompilerOptions: opts}) var panicked atomic.Bool type resolutionResult struct { @@ -260,7 +260,7 @@ func TestResolveSubpathNilContentsRace(t *testing.T) { } results <- resolutionResult{containingFile: containingFile, resolved: resolved} }() - r, _ := resolver.ResolveModuleName("pkg/sub", containingFile, core.ModuleKindESNext, nil) + r, _, _ := resolver.ResolveModuleName("pkg/sub", containingFile, core.ModuleKindESNext, nil) resolved = r.IsResolved() }) } @@ -363,7 +363,7 @@ func TestResolvePeerDependencyNilContentsRace(t *testing.T) { Module: core.ModuleKindESNext, Target: core.ScriptTargetESNext, } - resolver := module.NewResolver(host, opts, "", "", nil) + resolver := module.NewResolver(module.ResolverOptions{Host: host, CompilerOptions: opts}) var panicked atomic.Bool type resolutionResult struct { @@ -381,7 +381,7 @@ func TestResolvePeerDependencyNilContentsRace(t *testing.T) { } results <- resolutionResult{containingFile: containingFile, resolved: resolved} }() - r, _ := resolver.ResolveModuleName("pkg", containingFile, core.ModuleKindESNext, nil) + r, _, _ := resolver.ResolveModuleName("pkg", containingFile, core.ModuleKindESNext, nil) resolved = r.IsResolved() }) } diff --git a/tsc/internal/module/staticresolver.go b/tsc/internal/module/staticresolver.go new file mode 100644 index 0000000000000..34a55ba2c9f00 --- /dev/null +++ b/tsc/internal/module/staticresolver.go @@ -0,0 +1,155 @@ +package module + +import ( + "errors" + "fmt" + + "github.com/microsoft/TypeScript/tsc/internal/core" + "github.com/microsoft/TypeScript/tsc/internal/packagejson" + "github.com/microsoft/TypeScript/tsc/internal/tspath" +) + +type StaticResolutionEntry struct { + ModuleName string + ContainingDirectory string + ResolutionMode *core.ResolutionMode + Result *ResolvedModule +} + +type staticResolutionKey struct { + moduleName string + directory tspath.Path + mode core.ResolutionMode + hasDirectory bool + hasMode bool +} + +type StaticResolutions struct { + fallbackToResolver bool + entries map[staticResolutionKey]*ResolvedModule + currentDirectory string + useCaseSensitiveFileNames bool +} + +func NewStaticResolutions( + entries []StaticResolutionEntry, + fallbackToResolver bool, + currentDirectory string, + useCaseSensitiveFileNames bool, +) (*StaticResolutions, error) { + resolutions := &StaticResolutions{ + fallbackToResolver: fallbackToResolver, + entries: make(map[staticResolutionKey]*ResolvedModule, len(entries)), + currentDirectory: currentDirectory, + useCaseSensitiveFileNames: useCaseSensitiveFileNames, + } + for _, entry := range entries { + if entry.ModuleName == "" { + return nil, errors.New("module name is empty") + } + key := staticResolutionKey{moduleName: entry.ModuleName} + if entry.ContainingDirectory != "" { + key.directory = tspath.ToPath(entry.ContainingDirectory, currentDirectory, useCaseSensitiveFileNames) + key.hasDirectory = true + } + if entry.ResolutionMode != nil { + key.mode = *entry.ResolutionMode + key.hasMode = true + } + if _, exists := resolutions.entries[key]; exists { + return nil, fmt.Errorf("duplicate static module resolution for %q", entry.ModuleName) + } + resolutions.entries[key] = entry.Result + } + return resolutions, nil +} + +func (r *StaticResolutions) lookup(moduleName string, containingDirectory string, resolutionMode core.ResolutionMode) (*ResolvedModule, bool) { + directory := tspath.ToPath(containingDirectory, r.currentDirectory, r.useCaseSensitiveFileNames) + keys := [...]staticResolutionKey{ + {moduleName: moduleName, directory: directory, mode: resolutionMode, hasDirectory: true, hasMode: true}, + {moduleName: moduleName, directory: directory, hasDirectory: true}, + {moduleName: moduleName, mode: resolutionMode, hasMode: true}, + {moduleName: moduleName}, + } + for _, key := range keys { + if result, ok := r.entries[key]; ok { + return result, true + } + } + return nil, false +} + +type StaticResolver struct { + fallback Resolver + resolutions *StaticResolutions +} + +func NewStaticResolver(fallback Resolver, resolutions *StaticResolutions) *StaticResolver { + return &StaticResolver{fallback: fallback, resolutions: resolutions} +} + +func (r *StaticResolver) ResolveModuleName( + moduleName string, + containingFile string, + resolutionMode core.ResolutionMode, + redirectedReference ResolvedProjectReference, +) (*ResolvedModule, []DiagAndArgs, error) { + return r.resolveModuleName(moduleName, containingFile, tspath.GetDirectoryPath(containingFile), resolutionMode, redirectedReference) +} + +func (r *StaticResolver) ResolveModuleNameFromDirectory( + moduleName string, + containingDirectory string, + resolutionMode core.ResolutionMode, +) (*ResolvedModule, []DiagAndArgs, error) { + if result, found := r.resolutions.lookup(moduleName, containingDirectory, resolutionMode); found { + return result, nil, nil + } + if !r.resolutions.fallbackToResolver { + return nil, nil, nil + } + return r.fallback.ResolveModuleNameFromDirectory(moduleName, containingDirectory, resolutionMode) +} + +func (r *StaticResolver) resolveModuleName( + moduleName string, + containingFile string, + containingDirectory string, + resolutionMode core.ResolutionMode, + redirectedReference ResolvedProjectReference, +) (*ResolvedModule, []DiagAndArgs, error) { + if result, found := r.resolutions.lookup(moduleName, containingDirectory, resolutionMode); found { + return result, nil, nil + } + if !r.resolutions.fallbackToResolver { + return nil, nil, nil + } + return r.fallback.ResolveModuleName(moduleName, containingFile, resolutionMode, redirectedReference) +} + +func (r *StaticResolver) ResolveTypeReferenceDirective( + typeReferenceDirectiveName string, + containingFile string, + resolutionMode core.ResolutionMode, + redirectedReference ResolvedProjectReference, +) (*ResolvedTypeReferenceDirective, []DiagAndArgs) { + return r.fallback.ResolveTypeReferenceDirective(typeReferenceDirectiveName, containingFile, resolutionMode, redirectedReference) +} + +func (r *StaticResolver) GetPackageScopeForPath(directory string) *packagejson.InfoCacheEntry { + return r.fallback.GetPackageScopeForPath(directory) +} + +func (r *StaticResolver) PackageJsonCacheEntries(f func(key tspath.Path, value *packagejson.InfoCacheEntry) bool) { + r.fallback.PackageJsonCacheEntries(f) +} + +func (r *StaticResolver) ResolvePackageDirectory( + moduleName string, + containingFile string, + resolutionMode core.ResolutionMode, + redirectedReference ResolvedProjectReference, +) *ResolvedModule { + return r.fallback.ResolvePackageDirectory(moduleName, containingFile, resolutionMode, redirectedReference) +} diff --git a/tsc/internal/module/staticresolver_test.go b/tsc/internal/module/staticresolver_test.go new file mode 100644 index 0000000000000..a93c711158389 --- /dev/null +++ b/tsc/internal/module/staticresolver_test.go @@ -0,0 +1,64 @@ +package module_test + +import ( + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/core" + "github.com/microsoft/TypeScript/tsc/internal/module" + "github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest" + "gotest.tools/v3/assert" +) + +func TestStaticResolver(t *testing.T) { + t.Parallel() + + fs := vfstest.FromMap(map[string]string{ + "/repo/node_modules/fallback/package.json": `{"name":"fallback","types":"index.d.ts"}`, + "/repo/node_modules/fallback/index.d.ts": "export {};", + }, true) + fallback := module.NewResolver(module.ResolverOptions{ + Host: &resolutionHostStub{fs: fs, cwd: "/repo"}, + CompilerOptions: &core.CompilerOptions{ + Module: core.ModuleKindESNext, + ModuleResolution: core.ModuleResolutionKindBundler, + }, + }) + esm := core.ResolutionModeESM + resolutions, err := module.NewStaticResolutions( + []module.StaticResolutionEntry{ + {ModuleName: "provided", Result: &module.ResolvedModule{ResolvedFileName: "/global.d.ts"}}, + {ModuleName: "provided", ContainingDirectory: "/repo/src", Result: &module.ResolvedModule{ResolvedFileName: "/directory.d.ts"}}, + {ModuleName: "provided", ResolutionMode: &esm, Result: &module.ResolvedModule{ResolvedFileName: "/esm.d.ts"}}, + {ModuleName: "provided", ContainingDirectory: "/repo/src", ResolutionMode: &esm, Result: &module.ResolvedModule{ResolvedFileName: "/directory-esm.d.ts"}}, + {ModuleName: "unresolved"}, + }, + true, + "/repo", + true, + ) + assert.NilError(t, err) + resolver := module.NewStaticResolver(fallback, resolutions) + + tests := []struct { + name string + containingFile string + mode core.ResolutionMode + resolvedFileName string + }{ + {name: "provided", containingFile: "/repo/src/index.ts", mode: core.ResolutionModeESM, resolvedFileName: "/directory-esm.d.ts"}, + {name: "provided", containingFile: "/repo/src/index.ts", mode: core.ResolutionModeCommonJS, resolvedFileName: "/directory.d.ts"}, + {name: "provided", containingFile: "/repo/other/index.ts", mode: core.ResolutionModeESM, resolvedFileName: "/esm.d.ts"}, + {name: "provided", containingFile: "/repo/other/index.ts", mode: core.ResolutionModeCommonJS, resolvedFileName: "/global.d.ts"}, + {name: "fallback", containingFile: "/repo/src/index.ts", mode: core.ResolutionModeESM, resolvedFileName: "/repo/node_modules/fallback/index.d.ts"}, + {name: "unresolved", containingFile: "/repo/src/index.ts", mode: core.ResolutionModeESM}, + } + for _, test := range tests { + result, _, err := resolver.ResolveModuleName(test.name, test.containingFile, test.mode, nil) + assert.NilError(t, err) + if test.resolvedFileName == "" { + assert.Assert(t, result == nil) + } else { + assert.Equal(t, result.ResolvedFileName, test.resolvedFileName) + } + } +} diff --git a/tsc/internal/module/types.go b/tsc/internal/module/types.go index 72e2573adf3fe..00d9f0f3b786b 100644 --- a/tsc/internal/module/types.go +++ b/tsc/internal/module/types.go @@ -7,6 +7,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/ast" "github.com/microsoft/TypeScript/tsc/internal/core" + "github.com/microsoft/TypeScript/tsc/internal/packagejson" "github.com/microsoft/TypeScript/tsc/internal/tspath" "github.com/microsoft/TypeScript/tsc/internal/vfs" ) @@ -16,6 +17,34 @@ type ResolutionHost interface { GetCurrentDirectory() string } +type Resolver interface { + ResolveModuleName( + moduleName string, + containingFile string, + resolutionMode core.ResolutionMode, + redirectedReference ResolvedProjectReference, + ) (*ResolvedModule, []DiagAndArgs, error) + ResolveModuleNameFromDirectory( + moduleName string, + containingDirectory string, + resolutionMode core.ResolutionMode, + ) (*ResolvedModule, []DiagAndArgs, error) + ResolveTypeReferenceDirective( + typeReferenceDirectiveName string, + containingFile string, + resolutionMode core.ResolutionMode, + redirectedReference ResolvedProjectReference, + ) (*ResolvedTypeReferenceDirective, []DiagAndArgs) + GetPackageScopeForPath(directory string) *packagejson.InfoCacheEntry + PackageJsonCacheEntries(f func(key tspath.Path, value *packagejson.InfoCacheEntry) bool) + ResolvePackageDirectory( + moduleName string, + containingFile string, + resolutionMode core.ResolutionMode, + redirectedReference ResolvedProjectReference, + ) *ResolvedModule +} + type ModeAwareCacheKey struct { Name string Mode core.ResolutionMode diff --git a/tsc/internal/project/ata/ata.go b/tsc/internal/project/ata/ata.go index 16b3b4b05bbc2..e6f0b1c7504db 100644 --- a/tsc/internal/project/ata/ata.go +++ b/tsc/internal/project/ata/ata.go @@ -188,7 +188,10 @@ func (ti *TypingsInstaller) installTypings( if packageNames, ok := ti.installWorker(requestID, scopedTypings, logger); ok { logger.Log(fmt.Sprintf("ATA:: Installed typings %v", packageNames)) var installedTypingFiles []string - resolver := module.NewResolver(ti.host, &core.CompilerOptions{ModuleResolution: core.ModuleResolutionKindNodeNext}, "", "", nil) + resolver := module.NewResolver(module.ResolverOptions{ + Host: ti.host, + CompilerOptions: &core.CompilerOptions{ModuleResolution: core.ModuleResolutionKindNodeNext}, + }) for _, packageName := range filteredTypings { typingFile := ti.typingToFileName(resolver, packageName) if typingFile == "" { @@ -416,7 +419,10 @@ func (ti *TypingsInstaller) processCacheLocation(projectID string, fs vfs.FS, lo logger.Log("ATA:: Loaded content of " + packageLockJson + ": " + npmLockContents) // !!! sheetal strada uses Node10 - resolver := module.NewResolver(ti.host, &core.CompilerOptions{ModuleResolution: core.ModuleResolutionKindNodeNext}, "", "", nil) + resolver := module.NewResolver(module.ResolverOptions{ + Host: ti.host, + CompilerOptions: &core.CompilerOptions{ModuleResolution: core.ModuleResolutionKindNodeNext}, + }) if npmConfig.DevDependencies != nil && (npmLock.Packages != nil || npmLock.Dependencies != nil) { for key := range npmConfig.DevDependencies { npmLockValue, npmLockValueExists := npmLock.Packages["node_modules/"+key] @@ -476,8 +482,8 @@ func (ti *TypingsInstaller) ensureTypingsLocationExists(fs vfs.FS, logger loggin } } -func (ti *TypingsInstaller) typingToFileName(resolver *module.Resolver, packageName string) string { - result, _ := resolver.ResolveModuleName(packageName, tspath.CombinePaths(ti.typingsLocation, "index.d.ts"), core.ModuleKindNone, nil) +func (ti *TypingsInstaller) typingToFileName(resolver *module.DefaultResolver, packageName string) string { + result, _, _ := resolver.ResolveModuleName(packageName, tspath.CombinePaths(ti.typingsLocation, "index.d.ts"), core.ModuleKindNone, nil) return result.ResolvedFileName } diff --git a/tsc/internal/project/project.go b/tsc/internal/project/project.go index ad1c5d85a6a4c..79954831210c9 100644 --- a/tsc/internal/project/project.go +++ b/tsc/internal/project/project.go @@ -16,6 +16,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/json" "github.com/microsoft/TypeScript/tsc/internal/ls" "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" + "github.com/microsoft/TypeScript/tsc/internal/module" "github.com/microsoft/TypeScript/tsc/internal/project/ata" "github.com/microsoft/TypeScript/tsc/internal/project/logging" "github.com/microsoft/TypeScript/tsc/internal/tsoptions" @@ -164,6 +165,9 @@ type Project struct { checkerPool *checkerPool + moduleResolverFactory ModuleResolverFactory + moduleResolverID uint64 + // installedTypingsInfo is the value of `project.ComputeTypingsInfo()` that was // used during the most recently completed typings installation. installedTypingsInfo *ata.TypingsInfo @@ -411,6 +415,9 @@ func (p *Project) Clone() *Project { checkerPool: p.checkerPool, + moduleResolverFactory: p.moduleResolverFactory, + moduleResolverID: p.moduleResolverID, + installedTypingsInfo: p.installedTypingsInfo, typingsFiles: p.typingsFiles, } @@ -501,12 +508,26 @@ func (p *Project) CreateProgram() CreateProgramResult { createCheckerPool := func(program *compiler.Program) compiler.CheckerPool { return newCheckerPool(p.host.sessionOptions.CheckerPoolOptions, program, p.log) } + var cleanupModuleResolver func() + createModuleResolver := func(options module.ResolverOptions) module.Resolver { + if p.moduleResolverFactory == nil { + return module.NewResolver(options) + } + resolver, cleanup := p.moduleResolverFactory.NewResolver(options) + cleanupModuleResolver = cleanup + return resolver + } + defer func() { + if cleanupModuleResolver != nil { + cleanupModuleResolver() + } + }() // Create the command line, potentially augmented with typing files commandLine := p.getCommandLineWithTypingsFiles() if p.dirtyFilePath != "" && p.Program != nil && p.Program.CommandLine() == commandLine { var dirtyFile *ast.SourceFile - newProgram, dirtyFile, programCloned = p.Program.UpdateProgram(p.dirtyFilePath, p.host, createCheckerPool) + newProgram, dirtyFile, programCloned = p.Program.UpdateProgram(p.dirtyFilePath, p.host, createCheckerPool, createModuleResolver) if programCloned { updateKind = ProgramUpdateKindCloned for _, file := range newProgram.SourceFiles() { @@ -552,6 +573,7 @@ func (p *Project) CreateProgram() CreateProgramResult { UseSourceOfProjectReference: true, TypingsLocation: typingsLocation, CreateCheckerPool: createCheckerPool, + CreateModuleResolver: createModuleResolver, }, ) } diff --git a/tsc/internal/project/projectcollectionbuilder.go b/tsc/internal/project/projectcollectionbuilder.go index ec2cafd59df08..eb3752678485c 100644 --- a/tsc/internal/project/projectcollectionbuilder.go +++ b/tsc/internal/project/projectcollectionbuilder.go @@ -311,6 +311,8 @@ func (b *ProjectCollectionBuilder) HandleAPIRequest(apiRequest *APISnapshotReque request.CompilerOptions, request.ProjectReferences, request.ConfigFileParsingDiagnostics, + request.ModuleResolverFactory, + request.ModuleResolverID, b.inferredContentMappers, logger, ) @@ -324,6 +326,8 @@ func (b *ProjectCollectionBuilder) HandleAPIRequest(apiRequest *APISnapshotReque request.CompilerOptions, request.ProjectReferences, request.ConfigFileParsingDiagnostics, + request.ModuleResolverFactory, + request.ModuleResolverID, b.inferredContentMappers, logger, ) @@ -361,7 +365,15 @@ func (b *ProjectCollectionBuilder) HandleAPIRequest(apiRequest *APISnapshotReque return true }) } - return nil + var moduleResolutionError error + b.forEachProject(func(entry dirty.Value[*Project]) bool { + project := entry.Value() + if project.Program != nil { + moduleResolutionError = project.Program.ModuleResolutionError() + } + return moduleResolutionError == nil + }) + return moduleResolutionError } func (b *ProjectCollectionBuilder) nextSyntheticProjectID() SyntheticProjectID { @@ -1277,6 +1289,8 @@ func (b *ProjectCollectionBuilder) updateOrCreateSyntheticProject( compilerOptions *core.CompilerOptions, projectReferences []*core.ProjectReference, configFileParsingDiagnostics []*ast.Diagnostic, + moduleResolverFactory ModuleResolverFactory, + moduleResolverID uint64, contentMappers []*contentmapper.Mapper, logger *logging.LogTree, ) *dirty.SyncMapEntry[SyntheticProjectID, *Project] { @@ -1284,6 +1298,8 @@ func (b *ProjectCollectionBuilder) updateOrCreateSyntheticProject( if !loaded { syntheticProject := newSyntheticProject(projectID, b.sessionOptions.CurrentDirectory, compilerOptions, rootFileNames, projectReferences, contentMappers, b, logger) syntheticProject.CommandLine.Errors = configFileParsingDiagnostics + syntheticProject.moduleResolverFactory = moduleResolverFactory + syntheticProject.moduleResolverID = moduleResolverID project, _ = b.syntheticProjects.LoadOrStore(projectID, syntheticProject) return project } @@ -1303,13 +1319,16 @@ func (b *ProjectCollectionBuilder) updateOrCreateSyntheticProject( !reflect.DeepEqual(p.CommandLine.CompilerOptions(), compilerOptions) || !projectReferencesEqual(p.CommandLine.ProjectReferences(), projectReferences) || !reflect.DeepEqual(p.CommandLine.Errors, configFileParsingDiagnostics) || - !slices.Equal(p.CommandLine.ContentMappers(), newCommandLine.ContentMappers()) + !slices.Equal(p.CommandLine.ContentMappers(), newCommandLine.ContentMappers()) || + p.moduleResolverID != moduleResolverID }, func(p *Project) { if logger != nil { logger.Log(fmt.Sprintf("Updating synthetic project config with %d root files", len(rootFileNames))) } p.SetCommandLine(newCommandLine) + p.moduleResolverFactory = moduleResolverFactory + p.moduleResolverID = moduleResolverID }, ) return project diff --git a/tsc/internal/project/snapshot.go b/tsc/internal/project/snapshot.go index 2bd0f616621b4..6040a639a2697 100644 --- a/tsc/internal/project/snapshot.go +++ b/tsc/internal/project/snapshot.go @@ -18,6 +18,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/ls/lsconv" "github.com/microsoft/TypeScript/tsc/internal/ls/lsutil" "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" + "github.com/microsoft/TypeScript/tsc/internal/module" "github.com/microsoft/TypeScript/tsc/internal/project/ata" "github.com/microsoft/TypeScript/tsc/internal/project/dirty" "github.com/microsoft/TypeScript/tsc/internal/project/logging" @@ -305,11 +306,30 @@ func (s *Snapshot) ReadDirectory(currentDir string, path string, extensions []st return vfsmatch.ReadDirectory(s.fs.fs, currentDir, path, extensions, excludes, includes, depth) } +func (s *Snapshot) FS() vfs.FS { + return newSourceFS(false, s.fs, s.host.toPath) +} + +func (s *Snapshot) GetCurrentDirectory() string { + return s.host.GetCurrentDirectory() +} + +func (s *Snapshot) ContentMapperExtensions() []string { + extensions, _ := s.contentMapperWatchState() + return extensions +} + type APICreateProgramRequest struct { RootFileNames []string CompilerOptions *core.CompilerOptions ProjectReferences []*core.ProjectReference ConfigFileParsingDiagnostics []*ast.Diagnostic + ModuleResolverFactory ModuleResolverFactory + ModuleResolverID uint64 +} + +type ModuleResolverFactory interface { + NewResolver(options module.ResolverOptions) (module.Resolver, func()) } type APIReconfigureProgramRequest struct { diff --git a/tsc/internal/tsoptions/contentmappers.go b/tsc/internal/tsoptions/contentmappers.go index 124e34a1d4bb3..10e31596d94bf 100644 --- a/tsc/internal/tsoptions/contentmappers.go +++ b/tsc/internal/tsoptions/contentmappers.go @@ -15,7 +15,10 @@ import ( // and package directory. It never executes the package. On failure it returns a diagnostic describing why // the mapper could not be resolved; on success the diagnostic is nil. func resolveContentMapperManifest(host ParseConfigHost, containingFile string, packageName string) (contentmapper.Manifest, string, *ast.Diagnostic) { - resolver := module.NewResolver(host, &core.CompilerOptions{ModuleResolution: core.ModuleResolutionKindBundler}, "", "", nil) + resolver := module.NewResolver(module.ResolverOptions{ + Host: host, + CompilerOptions: &core.CompilerOptions{ModuleResolution: core.ModuleResolutionKindBundler}, + }) resolved := resolver.ResolvePackageDirectory(packageName, containingFile, core.ResolutionModeNone, nil) if resolved == nil || resolved.ResolvedFileName == "" { return contentmapper.Manifest{}, "", ast.NewCompilerDiagnostic(diagnostics.The_content_mapper_package_0_could_not_be_resolved, packageName)