From 77aee43889319d1e51e7f79501be0e715e0ae9bf Mon Sep 17 00:00:00 2001 From: isabel duan Date: Tue, 27 Oct 2026 13:11:53 -0700 Subject: [PATCH 1/3] program init --- packages/typescript/src/api/async/api.ts | 96 +++++- .../typescript/src/api/proto.generated.ts | 133 +++++--- packages/typescript/src/api/sync/api.ts | 306 +++++++++++++++++- packages/typescript/test/async/api.test.ts | 127 ++++++++ .../test/sync/api-generators.test.ts | 18 ++ packages/typescript/test/sync/api.test.ts | 127 ++++++++ tsc/internal/api/proto.go | 29 +- tsc/internal/api/session.go | 99 ++++++ tsc/internal/api/session_program_test.go | 52 +++ tsc/internal/checker/exports.go | 9 + tsc/internal/checker/types.go | 7 + tsc/internal/compiler/program.go | 21 ++ 12 files changed, 973 insertions(+), 51 deletions(-) create mode 100644 tsc/internal/api/session_program_test.go diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index 64dbd13a61a75..820915ccd6f78 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -42,6 +42,7 @@ import { import { assertNever } from "../../internal/utils.ts"; import { encodeNode, + sourceFileResponseToUint8Array, uint8ArrayToBase64, } from "../node/encoder.ts"; import { @@ -95,6 +96,7 @@ import type { ModuleResolutionSpec, PackageId, ParsedCommandLine, + ProgramStatisticsResponse, ProjectId, ProjectReference, ProjectResponse, @@ -102,6 +104,7 @@ import type { RawCompilerOptions, ReadConfigFileResponse, ReconfigureSnapshotProgramParams as ProtocolReconfigureSnapshotProgramParams, + RelationCacheSizes, ResolutionMode, ResolvedModule, ResolvedTypeReferenceDirective, @@ -241,6 +244,7 @@ export type { ProjectReference, RawCompilerOptions, ReadConfigFileResponse, + RelationCacheSizes, RequestTiming, ResolutionMode, ResolvedModule, @@ -343,7 +347,6 @@ export interface TranspileOutput { // @sync-only-start // export { all, defer, type APIRequestGenerator, type AnyAPIRequestGenerator, type AllAPIRequestGenerator, type DeferredAPIRequestGenerator, type ExecutedGeneratorsResults } from "./generatorSupport.ts"; // import {executeRequestGenerators, type ExecutedGeneratorsResults, type AnyAPIRequestGenerator} from "./generatorSupport.ts"; -// import { sourceFileResponseToUint8Array } from "../node/encoder.ts"; // @sync-only-end export class API implements FormatDiagnosticsHost { @@ -1712,6 +1715,12 @@ export class LanguageService { } } +export interface ResolvedProjectReference { + commandLine: ParsedCommandLine; + sourceFile: SourceFile; + references?: readonly (ResolvedProjectReference | undefined)[] | undefined; +} + export class Program implements FormatDiagnosticsHost { /** @internal */ readonly snapshotId: number; @@ -1722,6 +1731,7 @@ export class Program implements FormatDiagnost private readonly sourceFileMetadataCache = new Map>(); private ownedSnapshot: Snapshot | undefined; private disposePromise: Promise | undefined; + private resolvedProjectReferences: Promise | undefined; private get client(): Client { return this.project.api.client; @@ -1773,6 +1783,82 @@ export class Program implements FormatDiagnost return this.project.parsedCommandLine.options; } + async getRootFileNames(): Promise { + return this.project.parsedCommandLine.fileNames; + } + + async getProjectReferences(): Promise { + return this.project.parsedCommandLine.projectReferences; + } + + async getTypeChecker(): Promise { + await this.getStatistics(); + return this.project.checker; + } + + async getSourceFiles(): Promise { + const files: SourceFile[] = []; + for (const fileName of await this.getSourceFileNames()) { + const file = await this.getSourceFile(fileName); + if (!file) { + throw new Error(`Program source file '${fileName}' could not be loaded`); + } + files.push(file); + } + return files; + } + + private async getStatistics(): Promise { + return this.client.apiRequest("getProgramStatistics", { + snapshot: this.snapshotId, + project: this.project.handle, + }); + } + + async getIdentifierCount(): Promise { + return (await this.getStatistics()).identifierCount; + } + + async getInstantiationCount(): Promise { + return (await this.getStatistics()).instantiationCount; + } + + async getNodeCount(): Promise { + return (await this.getStatistics()).nodeCount; + } + + async getSymbolCount(): Promise { + return (await this.getStatistics()).symbolCount; + } + + async getTypeCount(): Promise { + return (await this.getStatistics()).typeCount; + } + + async getRelationCacheSizes(): Promise { + return (await this.getStatistics()).relationCacheSizes; + } + + getResolvedProjectReferences(): Promise { + return this.resolvedProjectReferences ??= this.fetchResolvedProjectReferences(); + } + + private async fetchResolvedProjectReferences(): Promise { + const data = await this.client.apiRequest("getResolvedProjectReferences", { + snapshot: this.snapshotId, + project: this.project.handle, + }); + const projects: ResolvedProjectReference[] = data.projects.map(project => ({ + commandLine: project.commandLine, + sourceFile: new RemoteSourceFile(sourceFileResponseToUint8Array(project.sourceFile)!, this.decoder, undefined, this.project.api) as unknown as SourceFile, + })); + const resolve = (indices: readonly number[] | null | undefined): (ResolvedProjectReference | undefined)[] | undefined => indices?.map(index => index === 0 ? undefined : projects[index - 1]); + for (let i = 0; i < projects.length; i++) { + projects[i].references = resolve(data.projects[i].references); + } + return resolve(data.references); + } + async getSourceFile(file: DocumentIdentifier): Promise { const fileName = resolveFileName(file); const path = this.pathKeyForFileName(fileName); @@ -2072,6 +2158,14 @@ export class Program implements FormatDiagnost return data ?? []; } + async getOptionsDiagnostics(): Promise { + const data = await this.client.apiRequest("getOptionsDiagnostics", { + snapshot: this.snapshotId, + project: this.project.handle, + }); + return data ?? []; + } + /** * Get global (non-file-specific) semantic diagnostics for the project. */ diff --git a/packages/typescript/src/api/proto.generated.ts b/packages/typescript/src/api/proto.generated.ts index 6f49fab3ca6ab..5b753e044042d 100644 --- a/packages/typescript/src/api/proto.generated.ts +++ b/packages/typescript/src/api/proto.generated.ts @@ -80,6 +80,9 @@ export interface APIMethodInfo { getNonMissingTypeOfSymbol: APIMethod; getSourceFile: APIMethod; getSourceFileNames: APIMethod; + getProgramStatistics: APIMethod; + getResolvedProjectReferences: APIMethod; + getOptionsDiagnostics: APIMethod; getSourceFileMetadata: APIMethod; getModeForUsageLocation: APIMethod; getModeForResolutionAtIndex: APIMethod; @@ -589,6 +592,59 @@ export interface GetSourceFileNamesParams { project: number; } +/** GetProjectDiagnosticsParams are parameters for project-wide diagnostic methods. */ +export interface GetProjectDiagnosticsParams { + snapshot: number; + project: number; +} + +export interface ProgramStatisticsResponse { + nodeCount: number; + identifierCount: number; + symbolCount: number; + typeCount: number; + instantiationCount: number; + relationCacheSizes: RelationCacheSizes; +} + +/** References use one-based indices into Projects; zero denotes an unresolved reference. */ +export interface ResolvedProjectReferencesResponse { + references?: number[] | undefined; + projects: ResolvedProjectReferenceResponse[]; +} + +/** DiagnosticResponse is the API response for a single diagnostic. */ +export interface DiagnosticResponse { + /** The file name of the file this diagnostic belongs to, if any. */ + fileName?: RootedFilePath | undefined; + /** Pos is the start position of the diagnostic in the source file. */ + pos: number; + /** End is the end position of the diagnostic in the source file. */ + end: number; + /** StartPosition is the zero-based line and UTF-16 character position of Pos. */ + startPosition?: DiagnosticPositionResponse | undefined; + /** EndPosition is the zero-based line and UTF-16 character position of End. */ + endPosition?: DiagnosticPositionResponse | undefined; + /** SourceLines contains the source lines needed to render this diagnostic with context. */ + sourceLines?: DiagnosticSourceLineResponse[] | undefined; + /** Code is the diagnostic error code. */ + code: number; + /** Category is the diagnostic category (error, warning, suggestion, message). */ + category: number; + /** Source is a custom diagnostic-code prefix. An empty value uses the default "TS". */ + source?: string | undefined; + /** Text is the localized diagnostic message text. */ + text: string; + /** ReportsUnnecessary indicates this diagnostic highlights unnecessary code. */ + reportsUnnecessary?: boolean | undefined; + /** ReportsDeprecated indicates this diagnostic highlights deprecated code. */ + reportsDeprecated?: boolean | undefined; + /** MessageChain contains chained diagnostic messages, if any. */ + messageChain?: DiagnosticResponse[] | undefined; + /** RelatedInformation contains related diagnostic information, if any. */ + relatedInformation?: DiagnosticResponse[] | undefined; +} + /** SourceFileMetadata carries program-stored metadata about a single source file. */ export interface SourceFileMetadata { isDefaultLibrary: boolean; @@ -662,12 +718,6 @@ export interface GetResolvedTypeReferenceDirectiveFromReferenceParams { resolutionMode: ModuleKind; } -/** GetProjectDiagnosticsParams are parameters for project-wide diagnostic methods. */ -export interface GetProjectDiagnosticsParams { - snapshot: number; - project: number; -} - export interface ResolveNameParams { snapshot: number; project: number; @@ -1019,38 +1069,6 @@ export interface GetDiagnosticsParams { files?: readonly DocumentIdentifier[] | undefined; } -/** DiagnosticResponse is the API response for a single diagnostic. */ -export interface DiagnosticResponse { - /** The file name of the file this diagnostic belongs to, if any. */ - fileName?: RootedFilePath | undefined; - /** Pos is the start position of the diagnostic in the source file. */ - pos: number; - /** End is the end position of the diagnostic in the source file. */ - end: number; - /** StartPosition is the zero-based line and UTF-16 character position of Pos. */ - startPosition?: DiagnosticPositionResponse | undefined; - /** EndPosition is the zero-based line and UTF-16 character position of End. */ - endPosition?: DiagnosticPositionResponse | undefined; - /** SourceLines contains the source lines needed to render this diagnostic with context. */ - sourceLines?: DiagnosticSourceLineResponse[] | undefined; - /** Code is the diagnostic error code. */ - code: number; - /** Category is the diagnostic category (error, warning, suggestion, message). */ - category: number; - /** Source is a custom diagnostic-code prefix. An empty value uses the default "TS". */ - source?: string | undefined; - /** Text is the localized diagnostic message text. */ - text: string; - /** ReportsUnnecessary indicates this diagnostic highlights unnecessary code. */ - reportsUnnecessary?: boolean | undefined; - /** ReportsDeprecated indicates this diagnostic highlights deprecated code. */ - reportsDeprecated?: boolean | undefined; - /** MessageChain contains chained diagnostic messages, if any. */ - messageChain?: DiagnosticResponse[] | undefined; - /** RelatedInformation contains related diagnostic information, if any. */ - relatedInformation?: DiagnosticResponse[] | undefined; -} - /** PrintNodeParams are the parameters for the printNode method. */ export interface PrintNodeParams { /** base64-encoded binary AST data */ @@ -1229,12 +1247,14 @@ export interface BatchRequest { | "getNullType" | "getNumberType" | "getObjectTypeOfType" + | "getOptionsDiagnostics" | "getOuterTypeParametersOfType" | "getParameterType" | "getParametersOfSignature" | "getParentOfSymbol" | "getParentOfSymbolForChecker" | "getProgramDiagnostics" + | "getProgramStatistics" | "getPropertiesOfType" | "getPropertyOfType" | "getReducedType" @@ -1243,6 +1263,7 @@ export interface BatchRequest { | "getRegularTypeOfType" | "getResolvedModule" | "getResolvedModuleFromModuleSpecifier" + | "getResolvedProjectReferences" | "getResolvedSignature" | "getResolvedTypeReferenceDirective" | "getResolvedTypeReferenceDirectiveFromTypeReferenceDirective" @@ -1411,12 +1432,14 @@ export interface BatchResponse { | "getNullType" | "getNumberType" | "getObjectTypeOfType" + | "getOptionsDiagnostics" | "getOuterTypeParametersOfType" | "getParameterType" | "getParametersOfSignature" | "getParentOfSymbol" | "getParentOfSymbolForChecker" | "getProgramDiagnostics" + | "getProgramStatistics" | "getPropertiesOfType" | "getPropertyOfType" | "getReducedType" @@ -1425,6 +1448,7 @@ export interface BatchResponse { | "getRegularTypeOfType" | "getResolvedModule" | "getResolvedModuleFromModuleSpecifier" + | "getResolvedProjectReferences" | "getResolvedSignature" | "getResolvedTypeReferenceDirective" | "getResolvedTypeReferenceDirectiveFromTypeReferenceDirective" @@ -1663,6 +1687,29 @@ export interface TranspileOptions { reportDiagnostics?: boolean | undefined; } +export interface RelationCacheSizes { + assignable: number; + identity: number; + subtype: number; + strictSubtype: number; +} + +export interface ResolvedProjectReferenceResponse { + commandLine: ConfigFileResponse; + sourceFile: SourceFileResponse; + references?: number[] | undefined; +} + +export interface DiagnosticPositionResponse { + line: number; + character: number; +} + +export interface DiagnosticSourceLineResponse { + line: number; + text: string; +} + export interface PackageId { name: string; subModuleName: string; @@ -1688,16 +1735,6 @@ export interface CompletionEntryResponse { symbol?: SymbolResponse | undefined; } -export interface DiagnosticPositionResponse { - line: number; - character: number; -} - -export interface DiagnosticSourceLineResponse { - line: number; - text: string; -} - export interface EmitOutputFile { fileName: RootedFilePath; text: string; diff --git a/packages/typescript/src/api/sync/api.ts b/packages/typescript/src/api/sync/api.ts index f08ef255288b9..50ba3adfbe182 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -59,6 +59,7 @@ import { import { assertNever } from "../../internal/utils.ts"; import { encodeNode, + sourceFileResponseToUint8Array, uint8ArrayToBase64, } from "../node/encoder.ts"; import { @@ -112,6 +113,7 @@ import type { ModuleResolutionSpec, PackageId, ParsedCommandLine, + ProgramStatisticsResponse, ProjectId, ProjectReference, ProjectResponse, @@ -119,6 +121,7 @@ import type { RawCompilerOptions, ReadConfigFileResponse, ReconfigureSnapshotProgramParams as ProtocolReconfigureSnapshotProgramParams, + RelationCacheSizes, ResolutionMode, ResolvedModule, ResolvedTypeReferenceDirective, @@ -258,6 +261,7 @@ export type { ProjectReference, RawCompilerOptions, ReadConfigFileResponse, + RelationCacheSizes, RequestTiming, ResolutionMode, ResolvedModule, @@ -358,7 +362,6 @@ export interface TranspileOutput { } export { all, type AllAPIRequestGenerator, type AnyAPIRequestGenerator, type APIRequestGenerator, defer, type DeferredAPIRequestGenerator, type ExecutedGeneratorsResults } from "./generatorSupport.ts"; -import { sourceFileResponseToUint8Array } from "../node/encoder.ts"; import { type AnyAPIRequestGenerator, type ExecutedGeneratorsResults, @@ -2899,6 +2902,12 @@ export class LanguageService { } } +export interface ResolvedProjectReference { + commandLine: ParsedCommandLine; + sourceFile: SourceFile; + references?: readonly (ResolvedProjectReference | undefined)[] | undefined; +} + export class Program implements FormatDiagnosticsHost { /** @internal */ readonly snapshotId: number; @@ -2909,6 +2918,7 @@ export class Program implements FormatDiagnost private readonly sourceFileMetadataCache = new Map(); private ownedSnapshot: Snapshot | undefined; private disposePromise: void | undefined; + private resolvedProjectReferences: readonly (ResolvedProjectReference | undefined)[] | undefined | undefined; private get client(): Client { return this.project.api.client; @@ -2988,6 +2998,275 @@ export class Program implements FormatDiagnost return this.project.parsedCommandLine.options; } + get getRootFileNames(): { + (): readonly RootedFilePath[]; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "getRootFileNames", + function (): readonly RootedFilePath[] { + return owner.project.parsedCommandLine.fileNames; + }, + function* (): Generator { + return owner.project.parsedCommandLine.fileNames; + }, + ); + } + + get getProjectReferences(): { + (): readonly ProjectReference[] | undefined; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "getProjectReferences", + function (): readonly ProjectReference[] | undefined { + return owner.project.parsedCommandLine.projectReferences; + }, + function* (): Generator { + return owner.project.parsedCommandLine.projectReferences; + }, + ); + } + + get getTypeChecker(): { + (): Checker; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "getTypeChecker", + function (): Checker { + owner.getStatistics(); + return owner.project.checker; + }, + function* (): Generator { + yield* owner.getStatistics.gen(); + return owner.project.checker; + }, + ); + } + + get getSourceFiles(): { + (): readonly SourceFile[]; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "getSourceFiles", + function (): readonly SourceFile[] { + const files: SourceFile[] = []; + for (const fileName of owner.getSourceFileNames()) { + const file = owner.getSourceFile(fileName); + if (!file) { + throw new Error(`Program source file '${fileName}' could not be loaded`); + } + files.push(file); + } + return files; + }, + function* (): Generator { + const files: SourceFile[] = []; + for (const fileName of (yield* owner.getSourceFileNames.gen())) { + const file = yield* owner.getSourceFile.gen(fileName); + if (!file) { + throw new Error(`Program source file '${fileName}' could not be loaded`); + } + files.push(file); + } + return files; + }, + ); + } + + private get getStatistics(): { + (): ProgramStatisticsResponse; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "getStatistics", + function (): ProgramStatisticsResponse { + return owner.client.apiRequest("getProgramStatistics", { + snapshot: owner.snapshotId, + project: owner.project.handle, + }); + }, + function* (): Generator { + return yield* apiRequest("getProgramStatistics", { + snapshot: owner.snapshotId, + project: owner.project.handle, + }); + }, + ); + } + + get getIdentifierCount(): { + (): number; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "getIdentifierCount", + function (): number { + return (owner.getStatistics()).identifierCount; + }, + function* (): Generator { + return (yield* owner.getStatistics.gen()).identifierCount; + }, + ); + } + + get getInstantiationCount(): { + (): number; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "getInstantiationCount", + function (): number { + return (owner.getStatistics()).instantiationCount; + }, + function* (): Generator { + return (yield* owner.getStatistics.gen()).instantiationCount; + }, + ); + } + + get getNodeCount(): { + (): number; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "getNodeCount", + function (): number { + return (owner.getStatistics()).nodeCount; + }, + function* (): Generator { + return (yield* owner.getStatistics.gen()).nodeCount; + }, + ); + } + + get getSymbolCount(): { + (): number; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "getSymbolCount", + function (): number { + return (owner.getStatistics()).symbolCount; + }, + function* (): Generator { + return (yield* owner.getStatistics.gen()).symbolCount; + }, + ); + } + + get getTypeCount(): { + (): number; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "getTypeCount", + function (): number { + return (owner.getStatistics()).typeCount; + }, + function* (): Generator { + return (yield* owner.getStatistics.gen()).typeCount; + }, + ); + } + + get getRelationCacheSizes(): { + (): RelationCacheSizes; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "getRelationCacheSizes", + function (): RelationCacheSizes { + return (owner.getStatistics()).relationCacheSizes; + }, + function* (): Generator { + return (yield* owner.getStatistics.gen()).relationCacheSizes; + }, + ); + } + + get getResolvedProjectReferences(): { + (): readonly (ResolvedProjectReference | undefined)[] | undefined; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "getResolvedProjectReferences", + function (): readonly (ResolvedProjectReference | undefined)[] | undefined { + return owner.resolvedProjectReferences ??= owner.fetchResolvedProjectReferences(); + }, + function* (): Generator { + return owner.resolvedProjectReferences ??= yield* owner.fetchResolvedProjectReferences.gen(); + }, + ); + } + + private get fetchResolvedProjectReferences(): { + (): readonly (ResolvedProjectReference | undefined)[] | undefined; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "fetchResolvedProjectReferences", + function (): readonly (ResolvedProjectReference | undefined)[] | undefined { + const data = owner.client.apiRequest("getResolvedProjectReferences", { + snapshot: owner.snapshotId, + project: owner.project.handle, + }); + const projects: ResolvedProjectReference[] = data.projects.map(project => ({ + commandLine: project.commandLine, + sourceFile: new RemoteSourceFile(sourceFileResponseToUint8Array(project.sourceFile)!, owner.decoder, undefined, owner.project.api) as unknown as SourceFile, + })); + const resolve = (indices: readonly number[] | null | undefined): (ResolvedProjectReference | undefined)[] | undefined => indices?.map(index => index === 0 ? undefined : projects[index - 1]); + for (let i = 0; i < projects.length; i++) { + projects[i].references = resolve(data.projects[i].references); + } + return resolve(data.references); + }, + function* (): Generator { + const data = yield* apiRequest("getResolvedProjectReferences", { + snapshot: owner.snapshotId, + project: owner.project.handle, + }); + const projects: ResolvedProjectReference[] = data.projects.map(project => ({ + commandLine: project.commandLine, + sourceFile: new RemoteSourceFile(sourceFileResponseToUint8Array(project.sourceFile)!, owner.decoder, undefined, owner.project.api) as unknown as SourceFile, + })); + const resolve = (indices: readonly number[] | null | undefined): (ResolvedProjectReference | undefined)[] | undefined => indices?.map(index => index === 0 ? undefined : projects[index - 1]); + for (let i = 0; i < projects.length; i++) { + projects[i].references = resolve(data.projects[i].references); + } + return resolve(data.references); + }, + ); + } + get getSourceFile(): { (file: DocumentIdentifier): SourceFile | undefined; gen(file: DocumentIdentifier): Generator; @@ -3710,6 +3989,31 @@ export class Program implements FormatDiagnost ); } + get getOptionsDiagnostics(): { + (): readonly Diagnostic[]; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "getOptionsDiagnostics", + function (): readonly Diagnostic[] { + const data = owner.client.apiRequest("getOptionsDiagnostics", { + snapshot: owner.snapshotId, + project: owner.project.handle, + }); + return data ?? []; + }, + function* (): Generator { + const data = yield* apiRequest("getOptionsDiagnostics", { + snapshot: owner.snapshotId, + project: owner.project.handle, + }); + return data ?? []; + }, + ); + } + /** * Get global (non-file-specific) semantic diagnostics for the project. */ diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index 379f07b6b68f7..a29b6388dfb17 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -2650,6 +2650,133 @@ describe("SourceFile", { concurrency }, () => { ); }); + test("Program accessors distinguish root files from source files and reuse the checker", async () => { + await using api = spawnAPI({ + "/src/main.ts": `import { value } from "./dependency"; export const result = value;`, + "/src/dependency.ts": `export const value = 1;`, + }); + await using program = await api.createProgram(["/src/main.ts"], { noLib: true }); + assert.deepEqual(await program.getRootFileNames(), ["/src/main.ts"]); + const files = await program.getSourceFiles(); + assert.deepEqual(files.map(file => file.fileName), ["/src/dependency.ts", "/src/main.ts"]); + assert.strictEqual(files[1], await program.getSourceFile("/src/main.ts")); + const checker = await program.getTypeChecker(); + assert.strictEqual(await program.getTypeChecker(), checker); + assert.ok(await checker.getSymbolAtLocation(cast(files[1].statements[1], isVariableStatement).declarationList.declarations[0].name)); + assert.equal(await program.getProjectReferences(), undefined); + assert.equal(await program.getResolvedProjectReferences(), undefined); + }); + + test("Program counters reflect work on its type checker without forcing diagnostics", async () => { + await using api = spawnAPI({ + "/src/main.ts": `type Box = { value: T }; +export const box: Box = { value: "hello" }; +export const other: { value: string } = box;`, + }); + await using program = await api.createProgram(["/src/main.ts"], { noLib: true }); + const identifiers = await program.getIdentifierCount(); + const nodes = await program.getNodeCount(); + const symbols = await program.getSymbolCount(); + const types = await program.getTypeCount(); + const instantiations = await program.getInstantiationCount(); + const relations = await program.getRelationCacheSizes(); + assert.ok(identifiers > 0); + assert.ok(nodes > identifiers); + assert.ok(symbols > 0); + assert.ok(types > 0); + assert.equal(instantiations, 0); + assert.deepEqual(relations, { assignable: 0, identity: 0, subtype: 0, strictSubtype: 0 }); + + const file = (await program.getSourceFile("/src/main.ts"))!; + const checker = await program.getTypeChecker(); + const declaration = cast(file.statements[1], isVariableStatement).declarationList.declarations[0]; + const type = await checker.getTypeAtLocation(declaration.name); + await type.getProperties(); + assert.ok(await program.getInstantiationCount() > instantiations); + assert.ok(await program.getTypeCount() > types); + assert.ok(await program.getSymbolCount() >= symbols); + const other = cast(file.statements[2], isVariableStatement).declarationList.declarations[0]; + await checker.isTypeAssignableTo(type, await checker.getTypeAtLocation(other.name)); + assert.ok((await program.getRelationCacheSizes()).assignable > relations.assignable); + assert.equal(await program.getIdentifierCount(), identifiers); + assert.equal(await program.getNodeCount(), nodes); + }); + + test("Program accessors handle empty programs and missing root files", async () => { + await using api = spawnAPI({}); + await using empty = await api.createProgram([], { noLib: true }); + assert.deepEqual(await empty.getRootFileNames(), []); + assert.deepEqual(await empty.getSourceFiles(), []); + assert.equal(await empty.getNodeCount(), 0); + assert.equal(await empty.getIdentifierCount(), 0); + assert.equal(await empty.getProjectReferences(), undefined); + assert.equal(await empty.getResolvedProjectReferences(), undefined); + assert.deepEqual(await empty.getOptionsDiagnostics(), []); + + await using noReferences = await api.createProgram([], { noLib: true }, { projectReferences: [] }); + assert.deepEqual(await noReferences.getProjectReferences(), []); + assert.deepEqual(await noReferences.getResolvedProjectReferences(), []); + + await using missing = await api.createProgram(["/missing.ts"], { noLib: true }); + assert.deepEqual(await missing.getRootFileNames(), ["/missing.ts"]); + assert.deepEqual(await missing.getSourceFiles(), []); + assert.ok((await missing.getOptionsDiagnostics()).some(diagnostic => diagnostic.code === 6053)); + }); + + test("Program options diagnostics exclude source-file and config parsing diagnostics", async () => { + await using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ + compilerOptions: { noLib: true, strictNullChecks: false, exactOptionalPropertyTypes: true, unknownOption: true }, + files: ["src/main.ts"], + }), + "/src/main.ts": `/// +const value: string = 1;`, + }); + const snapshot = await api.createSnapshot({ openProjects: ["/tsconfig.json"] }); + const program = snapshot.getConfiguredProject("/tsconfig.json")!.program; + const diagnostics = await program.getOptionsDiagnostics(); + assert.ok(diagnostics.some(diagnostic => diagnostic.code === 5052)); + assert.ok(diagnostics.every(diagnostic => !diagnostic.fileName || diagnostic.fileName === "/tsconfig.json")); + assert.ok(!(diagnostics.some(diagnostic => diagnostic.code === 5023 || diagnostic.code === 6053 || diagnostic.code === 2322))); + assert.ok((await program.getConfigFileParsingDiagnostics()).some(diagnostic => diagnostic.code === 5023)); + }); + + test("Program resolved project references preserve missing, nested, shared, and circular references", async () => { + await using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ files: [], references: [{ path: "./a" }, { path: "./missing" }, { path: "./b" }, { path: "./a" }] }), + "/a/tsconfig.json": JSON.stringify({ compilerOptions: { composite: true }, files: ["index.ts"], references: [{ path: "../b" }] }), + "/a/index.ts": "export const a = 1;", + "/b/tsconfig.json": JSON.stringify({ compilerOptions: { composite: true }, files: ["index.ts"], references: [{ path: "../a", circular: true }, { path: "../missing" }] }), + "/b/index.ts": "export const b = 1;", + }); + const snapshot = await api.createSnapshot({ openProjects: ["/tsconfig.json"] }); + const program = snapshot.getConfiguredProject("/tsconfig.json")!.program; + const references = await program.getProjectReferences(); + assert.equal(references?.length, 4); + const resolved = await program.getResolvedProjectReferences(); + assert.equal(resolved?.length, 4); + assert.equal(resolved![1], undefined); + const a = resolved![0]!; + const b = resolved![2]!; + assert.strictEqual(resolved![3], a); + assert.strictEqual(a.references![0], b); + assert.strictEqual(b.references![0], a); + assert.equal(b.references![1], undefined); + assert.equal(a.sourceFile.fileName, "/a/tsconfig.json"); + assert.equal(a.commandLine.options.composite, true); + assert.deepEqual(a.commandLine.fileNames, ["/a/index.ts"]); + assert.strictEqual((await program.getResolvedProjectReferences())![0], a); + + await using synthetic = await api.createProgram([], { noLib: true }, { + projectReferences: [{ path: "/a/tsconfig.json" }, { path: "/missing/tsconfig.json" }], + }); + assert.equal((await synthetic.getProjectReferences())!.length, 2); + const syntheticResolved = await synthetic.getResolvedProjectReferences(); + assert.equal(syntheticResolved!.length, 2); + assert.equal(syntheticResolved![0]!.sourceFile.fileName, "/a/tsconfig.json"); + assert.equal(syntheticResolved![1], undefined); + }); + test("getSourceFileNames returns all program files, not just root files", async () => { await using api = spawnAPI({ "/tsconfig.json": JSON.stringify({ diff --git a/packages/typescript/test/sync/api-generators.test.ts b/packages/typescript/test/sync/api-generators.test.ts index 314052b63f789..4c33e43ab3797 100644 --- a/packages/typescript/test/sync/api-generators.test.ts +++ b/packages/typescript/test/sync/api-generators.test.ts @@ -172,6 +172,8 @@ const privateGeneratorGetters = new Set([ "NodeHandle.fetchOwnerFile", "NodeHandle.resolveInFile", "Program.disposeWorker", + "Program.getStatistics", + "Program.fetchResolvedProjectReferences", "Program.fetchSourceFileMetadata", "Program.getSourceFileWorker", "Snapshot.disposeWorker", @@ -1615,6 +1617,13 @@ describe("API - generator batching", { concurrency: areTestsFiltered() }, () => parityCase("Program", "getResolvedTypeReferenceDirectiveFromTypeReferenceDirective", program.getResolvedTypeReferenceDirectiveFromTypeReferenceDirective, assertDeepEquivalent, typeReferenceDirective, "/src/index.ts"), parityCase("Program", "getSourceFileByPath", program.getSourceFileByPath, assertOptionalSourceFilesEquivalent, indexFile.path), parityCase("Program", "getSourceFileNames", program.getSourceFileNames, assertDeepEquivalent), + parityCase("Program", "getRootFileNames", program.getRootFileNames, assertDeepEquivalent), + parityCase("Program", "getSourceFiles", program.getSourceFiles, (actual, expected, message) => { + assertArrayElementsEquivalent(actual, expected, assertSourceFilesEquivalent, message); + }), + parityCase("Program", "getProjectReferences", program.getProjectReferences, assertDeepEquivalent), + parityCase("Program", "getResolvedProjectReferences", program.getResolvedProjectReferences, assertDeepEquivalent), + parityCase("Program", "getTypeChecker", program.getTypeChecker, assert.strictEqual), parityCase("Program", "getSourceFileMetadata", program.getSourceFileMetadata, assertDeepEquivalent, "/src/index.ts"), parityCase("Program", "getSourceFileMetadataByPath", program.getSourceFileMetadataByPath, assertDeepEquivalent, indexFile.path), parityCase("Program", "isSourceFileFromExternalLibrary", program.isSourceFileFromExternalLibrary, assertDeepEquivalent, indexFile), @@ -1631,6 +1640,7 @@ describe("API - generator batching", { concurrency: areTestsFiltered() }, () => parityCase("Program", "getSuggestionDiagnostics", program.getSuggestionDiagnostics, assertDeepEquivalent, "/src/suggestions.ts"), parityCase("Program", "getDeclarationDiagnostics", program.getDeclarationDiagnostics, assertDeepEquivalent, "/src/index.ts"), parityCase("Program", "getProgramDiagnostics", program.getProgramDiagnostics, assertDeepEquivalent), + parityCase("Program", "getOptionsDiagnostics", program.getOptionsDiagnostics, assertDeepEquivalent), parityCase("Program", "getGlobalDiagnostics", program.getGlobalDiagnostics, assertDeepEquivalent), parityCase("Program", "getConfigFileParsingDiagnostics", program.getConfigFileParsingDiagnostics, assertDeepEquivalent), parityCase("Program", "emit", program.emit, assertDeepEquivalent), @@ -1788,6 +1798,14 @@ describe("API - generator batching", { concurrency: areTestsFiltered() }, () => ]; runParityBatch(api, cases); + runParityBatch(api, [ + parityCase("Program", "getIdentifierCount", program.getIdentifierCount, assertDeepEquivalent), + parityCase("Program", "getNodeCount", program.getNodeCount, assertDeepEquivalent), + parityCase("Program", "getSymbolCount", program.getSymbolCount, assertDeepEquivalent), + parityCase("Program", "getTypeCount", program.getTypeCount, assertDeepEquivalent), + parityCase("Program", "getInstantiationCount", program.getInstantiationCount, assertDeepEquivalent), + parityCase("Program", "getRelationCacheSizes", program.getRelationCacheSizes, assertDeepEquivalent), + ]); assert.deepEqual(temporaryProjects, ["/tsconfig.json", "/tsconfig.json"]); const snapshotGeneratorAPI = spawnAPI(parityFiles); diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index f048e8ede87e6..2cccd99abedce 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -2508,6 +2508,133 @@ describe("SourceFile", { concurrency }, () => { ); }); + test("Program accessors distinguish root files from source files and reuse the checker", () => { + using api = spawnAPI({ + "/src/main.ts": `import { value } from "./dependency"; export const result = value;`, + "/src/dependency.ts": `export const value = 1;`, + }); + using program = api.createProgram(["/src/main.ts"], { noLib: true }); + assert.deepEqual(program.getRootFileNames(), ["/src/main.ts"]); + const files = program.getSourceFiles(); + assert.deepEqual(files.map(file => file.fileName), ["/src/dependency.ts", "/src/main.ts"]); + assert.strictEqual(files[1], program.getSourceFile("/src/main.ts")); + const checker = program.getTypeChecker(); + assert.strictEqual(program.getTypeChecker(), checker); + assert.ok(checker.getSymbolAtLocation(cast(files[1].statements[1], isVariableStatement).declarationList.declarations[0].name)); + assert.equal(program.getProjectReferences(), undefined); + assert.equal(program.getResolvedProjectReferences(), undefined); + }); + + test("Program counters reflect work on its type checker without forcing diagnostics", () => { + using api = spawnAPI({ + "/src/main.ts": `type Box = { value: T }; +export const box: Box = { value: "hello" }; +export const other: { value: string } = box;`, + }); + using program = api.createProgram(["/src/main.ts"], { noLib: true }); + const identifiers = program.getIdentifierCount(); + const nodes = program.getNodeCount(); + const symbols = program.getSymbolCount(); + const types = program.getTypeCount(); + const instantiations = program.getInstantiationCount(); + const relations = program.getRelationCacheSizes(); + assert.ok(identifiers > 0); + assert.ok(nodes > identifiers); + assert.ok(symbols > 0); + assert.ok(types > 0); + assert.equal(instantiations, 0); + assert.deepEqual(relations, { assignable: 0, identity: 0, subtype: 0, strictSubtype: 0 }); + + const file = (program.getSourceFile("/src/main.ts"))!; + const checker = program.getTypeChecker(); + const declaration = cast(file.statements[1], isVariableStatement).declarationList.declarations[0]; + const type = checker.getTypeAtLocation(declaration.name); + type.getProperties(); + assert.ok(program.getInstantiationCount() > instantiations); + assert.ok(program.getTypeCount() > types); + assert.ok(program.getSymbolCount() >= symbols); + const other = cast(file.statements[2], isVariableStatement).declarationList.declarations[0]; + checker.isTypeAssignableTo(type, checker.getTypeAtLocation(other.name)); + assert.ok((program.getRelationCacheSizes()).assignable > relations.assignable); + assert.equal(program.getIdentifierCount(), identifiers); + assert.equal(program.getNodeCount(), nodes); + }); + + test("Program accessors handle empty programs and missing root files", () => { + using api = spawnAPI({}); + using empty = api.createProgram([], { noLib: true }); + assert.deepEqual(empty.getRootFileNames(), []); + assert.deepEqual(empty.getSourceFiles(), []); + assert.equal(empty.getNodeCount(), 0); + assert.equal(empty.getIdentifierCount(), 0); + assert.equal(empty.getProjectReferences(), undefined); + assert.equal(empty.getResolvedProjectReferences(), undefined); + assert.deepEqual(empty.getOptionsDiagnostics(), []); + + using noReferences = api.createProgram([], { noLib: true }, { projectReferences: [] }); + assert.deepEqual(noReferences.getProjectReferences(), []); + assert.deepEqual(noReferences.getResolvedProjectReferences(), []); + + using missing = api.createProgram(["/missing.ts"], { noLib: true }); + assert.deepEqual(missing.getRootFileNames(), ["/missing.ts"]); + assert.deepEqual(missing.getSourceFiles(), []); + assert.ok((missing.getOptionsDiagnostics()).some(diagnostic => diagnostic.code === 6053)); + }); + + test("Program options diagnostics exclude source-file and config parsing diagnostics", () => { + using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ + compilerOptions: { noLib: true, strictNullChecks: false, exactOptionalPropertyTypes: true, unknownOption: true }, + files: ["src/main.ts"], + }), + "/src/main.ts": `/// +const value: string = 1;`, + }); + const snapshot = api.createSnapshot({ openProjects: ["/tsconfig.json"] }); + const program = snapshot.getConfiguredProject("/tsconfig.json")!.program; + const diagnostics = program.getOptionsDiagnostics(); + assert.ok(diagnostics.some(diagnostic => diagnostic.code === 5052)); + assert.ok(diagnostics.every(diagnostic => !diagnostic.fileName || diagnostic.fileName === "/tsconfig.json")); + assert.ok(!(diagnostics.some(diagnostic => diagnostic.code === 5023 || diagnostic.code === 6053 || diagnostic.code === 2322))); + assert.ok((program.getConfigFileParsingDiagnostics()).some(diagnostic => diagnostic.code === 5023)); + }); + + test("Program resolved project references preserve missing, nested, shared, and circular references", () => { + using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ files: [], references: [{ path: "./a" }, { path: "./missing" }, { path: "./b" }, { path: "./a" }] }), + "/a/tsconfig.json": JSON.stringify({ compilerOptions: { composite: true }, files: ["index.ts"], references: [{ path: "../b" }] }), + "/a/index.ts": "export const a = 1;", + "/b/tsconfig.json": JSON.stringify({ compilerOptions: { composite: true }, files: ["index.ts"], references: [{ path: "../a", circular: true }, { path: "../missing" }] }), + "/b/index.ts": "export const b = 1;", + }); + const snapshot = api.createSnapshot({ openProjects: ["/tsconfig.json"] }); + const program = snapshot.getConfiguredProject("/tsconfig.json")!.program; + const references = program.getProjectReferences(); + assert.equal(references?.length, 4); + const resolved = program.getResolvedProjectReferences(); + assert.equal(resolved?.length, 4); + assert.equal(resolved![1], undefined); + const a = resolved![0]!; + const b = resolved![2]!; + assert.strictEqual(resolved![3], a); + assert.strictEqual(a.references![0], b); + assert.strictEqual(b.references![0], a); + assert.equal(b.references![1], undefined); + assert.equal(a.sourceFile.fileName, "/a/tsconfig.json"); + assert.equal(a.commandLine.options.composite, true); + assert.deepEqual(a.commandLine.fileNames, ["/a/index.ts"]); + assert.strictEqual((program.getResolvedProjectReferences())![0], a); + + using synthetic = api.createProgram([], { noLib: true }, { + projectReferences: [{ path: "/a/tsconfig.json" }, { path: "/missing/tsconfig.json" }], + }); + assert.equal((synthetic.getProjectReferences())!.length, 2); + const syntheticResolved = synthetic.getResolvedProjectReferences(); + assert.equal(syntheticResolved!.length, 2); + assert.equal(syntheticResolved![0]!.sourceFile.fileName, "/a/tsconfig.json"); + assert.equal(syntheticResolved![1], undefined); + }); + test("getSourceFileNames returns all program files, not just root files", () => { using api = spawnAPI({ "/tsconfig.json": JSON.stringify({ diff --git a/tsc/internal/api/proto.go b/tsc/internal/api/proto.go index c03e7ff85ed46..40cc134f6463f 100644 --- a/tsc/internal/api/proto.go +++ b/tsc/internal/api/proto.go @@ -111,6 +111,9 @@ const ( MethodGetNonMissingTypeOfSymbol Method = "getNonMissingTypeOfSymbol" MethodGetSourceFile Method = "getSourceFile" MethodGetSourceFileNames Method = "getSourceFileNames" + MethodGetProgramStatistics Method = "getProgramStatistics" + MethodGetResolvedProjectReferences Method = "getResolvedProjectReferences" + MethodGetOptionsDiagnostics Method = "getOptionsDiagnostics" MethodGetSourceFileMetadata Method = "getSourceFileMetadata" MethodGetModeForUsageLocation Method = "getModeForUsageLocation" MethodGetModeForResolutionAtIndex Method = "getModeForResolutionAtIndex" @@ -770,6 +773,9 @@ var unmarshalers = map[Method]func([]byte) (any, error){ MethodGetSuggestionDiagnostics: unmarshallerFor[GetDiagnosticsParams], MethodGetDeclarationDiagnostics: unmarshallerFor[GetDiagnosticsParams], MethodGetProgramDiagnostics: unmarshallerFor[GetProjectDiagnosticsParams], + MethodGetProgramStatistics: unmarshallerFor[GetProjectDiagnosticsParams], + MethodGetResolvedProjectReferences: unmarshallerFor[GetProjectDiagnosticsParams], + MethodGetOptionsDiagnostics: unmarshallerFor[GetProjectDiagnosticsParams], MethodGetGlobalDiagnostics: unmarshallerFor[GetProjectDiagnosticsParams], MethodGetConfigFileParsingDiagnostics: unmarshallerFor[GetProjectDiagnosticsParams], MethodStartCPUProfile: unmarshallerFor[ProfileParams], @@ -1014,13 +1020,34 @@ type ConfigFileResponse struct { FileNames []tspath.RootedFilePath `json:"fileNames" nonnil:"true"` Options *core.CompilerOptions `json:"options" nonnil:"true"` BuildOptions *core.BuildOptions `json:"buildOptions,omitempty"` - ProjectReferences []*core.ProjectReference `json:"projectReferences,omitempty"` + ProjectReferences []*core.ProjectReference `json:"projectReferences,omitzero"` TypeAcquisition *core.TypeAcquisition `json:"typeAcquisition,omitempty"` CompileOnSave *bool `json:"compileOnSave,omitempty"` Raw any `json:"raw,omitempty"` Errors []*DiagnosticResponse `json:"errors" nonnil:"true"` } +type ProgramStatisticsResponse struct { + NodeCount int `json:"nodeCount"` + IdentifierCount int `json:"identifierCount"` + SymbolCount int `json:"symbolCount"` + TypeCount uint32 `json:"typeCount"` + InstantiationCount uint32 `json:"instantiationCount"` + RelationCacheSizes checker.RelationCacheSizes `json:"relationCacheSizes"` +} + +// References use one-based indices into Projects; zero denotes an unresolved reference. +type ResolvedProjectReferencesResponse struct { + References []int `json:"references,omitzero"` + Projects []*ResolvedProjectReferenceResponse `json:"projects" nonnil:"true"` +} + +type ResolvedProjectReferenceResponse struct { + CommandLine *ConfigFileResponse `json:"commandLine" nonnil:"true"` + SourceFile *SourceFileResponse `json:"sourceFile" nonnil:"true"` + References []int `json:"references,omitzero"` +} + type ReadConfigFileResponse struct { Config any `json:"config"` Error *DiagnosticResponse `json:"error,omitempty"` diff --git a/tsc/internal/api/session.go b/tsc/internal/api/session.go index c88ef9a1b3bee..6f001e22a5f88 100644 --- a/tsc/internal/api/session.go +++ b/tsc/internal/api/session.go @@ -951,6 +951,12 @@ func (s *Session) HandleRequest(ctx context.Context, method string, params json. return s.handleGetSourceFile(ctx, parsed.(*GetSourceFileParams)) case string(MethodGetSourceFileNames): return s.handleGetSourceFileNames(ctx, parsed.(*GetSourceFileNamesParams)) + case string(MethodGetProgramStatistics): + return s.handleGetProgramStatistics(ctx, parsed.(*GetProjectDiagnosticsParams)) + case string(MethodGetResolvedProjectReferences): + return s.handleGetResolvedProjectReferences(ctx, parsed.(*GetProjectDiagnosticsParams)) + case string(MethodGetOptionsDiagnostics): + return s.handleGetOptionsDiagnostics(ctx, parsed.(*GetProjectDiagnosticsParams)) case string(MethodGetSourceFileMetadata): return s.handleGetSourceFileMetadata(ctx, parsed.(*GetSourceFileParams)) case string(MethodGetModeForUsageLocation): @@ -2348,6 +2354,99 @@ func transpileOutput(ctx context.Context, input string, options TranspileOptions }, nil } +func (s *Session) handleGetProgramStatistics(ctx context.Context, params *GetProjectDiagnosticsParams) (*ProgramStatisticsResponse, error) { + setup, err := s.setupChecker(ctx, params.Snapshot, params.Project) + if err != nil { + return nil, err + } + defer setup.done() + result := &ProgramStatisticsResponse{ + SymbolCount: int(setup.checker.SymbolCount), + TypeCount: setup.checker.TypeCount, + InstantiationCount: setup.checker.TotalInstantiationCount, + RelationCacheSizes: setup.checker.GetRelationCacheSizes(), + } + for _, file := range setup.program.GetSourceFiles() { + result.NodeCount += file.NodeCount + result.IdentifierCount += file.IdentifierCount + result.SymbolCount += file.SymbolCount + } + return result, nil +} + +func (s *Session) handleGetResolvedProjectReferences(ctx context.Context, params *GetProjectDiagnosticsParams) (*ResolvedProjectReferencesResponse, error) { + sd, err := s.getSnapshotData(params.Snapshot) + if err != nil { + return nil, err + } + program, err := sd.getProgram(params.Project) + if err != nil { + return nil, err + } + result := &ResolvedProjectReferencesResponse{Projects: []*ResolvedProjectReferenceResponse{}} + indices := map[*tsoptions.ParsedCommandLine]int{} + var visit func(config *tsoptions.ParsedCommandLine) (int, error) + visit = func(config *tsoptions.ParsedCommandLine) (int, error) { + if config == nil { + return 0, nil + } + if index, ok := indices[config]; ok { + return index, nil + } + if config.ConfigFile == nil || config.ConfigFile.SourceFile == nil { + return 0, errors.New("resolved project reference has no config source file") + } + file := config.ConfigFile.SourceFile + data, _, encodeErr := encoder.EncodeSourceFile(file) + if encodeErr != nil { + return 0, fmt.Errorf("failed to encode project reference config: %w", encodeErr) + } + encoder.SetSourceFileID(data, sourceFileNodeID(file)) + response := &ResolvedProjectReferenceResponse{ + CommandLine: NewConfigFileResponse(config), + SourceFile: &SourceFileResponse{Data: base64.StdEncoding.EncodeToString(data)}, + } + result.Projects = append(result.Projects, response) + index := len(result.Projects) + indices[config] = index + if config.ProjectReferences() != nil { + response.References = make([]int, len(config.ProjectReferences())) + for i, path := range config.ResolvedProjectReferencePaths() { + child, _ := program.GetResolvedProjectReferenceFor(program.PathKeyForFileName(path)) + response.References[i], err = visit(child) + if err != nil { + return 0, err + } + } + } + return index, nil + } + if program.CommandLine().ProjectReferences() != nil { + references := program.GetResolvedProjectReferences() + result.References = make([]int, len(references)) + for i, config := range references { + result.References[i], err = visit(config) + if err != nil { + return nil, err + } + } + } + return result, nil +} + +// @gen-proto-nullable +func (s *Session) handleGetOptionsDiagnostics(ctx context.Context, params *GetProjectDiagnosticsParams) ([]*DiagnosticResponse, error) { + sd, err := s.getSnapshotData(params.Snapshot) + if err != nil { + return nil, err + } + program, err := sd.getProgram(params.Project) + if err != nil { + return nil, err + } + return NewDiagnosticResponses(program.GetOptionsDiagnostics()), nil +} + // handleGetSourceFile returns a source file from a project within a snapshot. // @gen-proto-result: SourceFileResponse // @gen-proto-nullable diff --git a/tsc/internal/api/session_program_test.go b/tsc/internal/api/session_program_test.go new file mode 100644 index 0000000000000..5cbd6758e3e4a --- /dev/null +++ b/tsc/internal/api/session_program_test.go @@ -0,0 +1,52 @@ +package api + +import ( + "context" + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/core" + "github.com/microsoft/TypeScript/tsc/internal/project" + "github.com/microsoft/TypeScript/tsc/internal/testutil/projecttestutil" + "gotest.tools/v3/assert" +) + +func TestProgramStatisticsUseAPIChecker(t *testing.T) { + t.Parallel() + + const fileName = "/home/projects/p/index.ts" + projectSession, _ := projecttestutil.Setup(map[string]any{ + fileName: "export const value = 1;", + }) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + response, err := session.handleCreateSnapshot(context.Background(), &CreateSnapshotParams{ + CreatePrograms: []*CreateSnapshotProgramParams{{ + RootFiles: []DocumentIdentifier{{FileName: fileName}}, + CompilerOptions: core.CompilerOptions{NoLib: core.TSTrue}, + }}, + }) + assert.NilError(t, err) + params := &GetProjectDiagnosticsParams{Snapshot: response.Snapshot, Project: response.Projects[0].Handle} + setup, err := session.setupChecker(context.Background(), params.Snapshot, params.Project) + assert.NilError(t, err) + file := setup.program.GetSourceFile(fileName) + setup.checker.GetTypeAtLocation(file.Statements.Nodes[0]) + expected := ProgramStatisticsResponse{ + NodeCount: file.NodeCount, + IdentifierCount: file.IdentifierCount, + SymbolCount: file.SymbolCount + int(setup.checker.SymbolCount), + TypeCount: setup.checker.TypeCount, + InstantiationCount: setup.checker.TotalInstantiationCount, + RelationCacheSizes: setup.checker.GetRelationCacheSizes(), + } + setup.done() + + actual, err := session.handleGetProgramStatistics(context.Background(), params) + assert.NilError(t, err) + assert.DeepEqual(t, *actual, expected) + _, err = session.handleGetProgramStatistics(context.Background(), &GetProjectDiagnosticsParams{ + Snapshot: response.Snapshot, Project: project.Handle(999999), + }) + assert.ErrorContains(t, err, "not found") +} diff --git a/tsc/internal/checker/exports.go b/tsc/internal/checker/exports.go index bce0ba48cfa20..497b2c49850ec 100644 --- a/tsc/internal/checker/exports.go +++ b/tsc/internal/checker/exports.go @@ -8,6 +8,15 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/diagnostics" ) +func (c *Checker) GetRelationCacheSizes() RelationCacheSizes { + return RelationCacheSizes{ + Assignable: c.assignableRelation.size(), + Identity: c.identityRelation.size(), + Subtype: c.subtypeRelation.size(), + StrictSubtype: c.strictSubtypeRelation.size(), + } +} + func (c *Checker) GetStringType() *Type { return c.stringType } diff --git a/tsc/internal/checker/types.go b/tsc/internal/checker/types.go index f119288a21744..83ac6bd0408de 100644 --- a/tsc/internal/checker/types.go +++ b/tsc/internal/checker/types.go @@ -14,6 +14,13 @@ import ( //go:generate npx hereby generate:checker +type RelationCacheSizes struct { + Assignable int `json:"assignable"` + Identity int `json:"identity"` + Subtype int `json:"subtype"` + StrictSubtype int `json:"strictSubtype"` +} + // ParseFlags type ParseFlags uint32 diff --git a/tsc/internal/compiler/program.go b/tsc/internal/compiler/program.go index 9946362def3ad..96c94196b8065 100644 --- a/tsc/internal/compiler/program.go +++ b/tsc/internal/compiler/program.go @@ -857,6 +857,27 @@ func (p *Program) GetProgramDiagnostics() []*ast.Diagnostic { )) } +func (p *Program) GetOptionsDiagnostics() []*ast.Diagnostic { + configFiles := map[tspath.PathKey]*ast.SourceFile{} + addConfig := func(config *tsoptions.ParsedCommandLine) { + if config != nil && config.ConfigFile != nil { + configFiles[config.ConfigFile.SourceFile.PathKey()] = config.ConfigFile.SourceFile + } + } + addConfig(p.CommandLine()) + p.RangeResolvedProjectReference(func(_ tspath.PathKey, config *tsoptions.ParsedCommandLine, _ *tsoptions.ParsedCommandLine, _ int) bool { + addConfig(config) + return true + }) + result := core.Filter(p.GetProgramDiagnostics(), func(d *ast.Diagnostic) bool { + return d.File() == nil || configFiles[d.File().PathKey()] != nil + }) + for _, file := range configFiles { + result = append(result, p.includeProcessor.getDiagnostics(p).GetDiagnosticsForFile(file)...) + } + return SortAndDeduplicateDiagnostics(result) +} + func (p *Program) collectContentMapperOptionDiagnostics() { project := p.ContentMapperProject() if project == nil { From c2817fedf1f27a7396cdef27374e92a87447492c Mon Sep 17 00:00:00 2001 From: isabel duan Date: Tue, 27 Oct 2026 23:27:16 -0700 Subject: [PATCH 2/3] project --- packages/typescript/src/api/async/api.ts | 61 +++++- .../typescript/src/api/proto.generated.ts | 16 ++ packages/typescript/src/api/sync/api.ts | 182 +++++++++++++++++- packages/typescript/test/async/api.test.ts | 88 +++++++++ .../test/sync/api-generators.test.ts | 9 + packages/typescript/test/sync/api.test.ts | 88 +++++++++ tsc/internal/api/proto.go | 13 ++ tsc/internal/api/session.go | 60 ++++++ tsc/internal/api/session_program_test.go | 74 +++++++ tsc/internal/project/projectcollection.go | 8 + 10 files changed, 596 insertions(+), 3 deletions(-) diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index 820915ccd6f78..13785866a72b6 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -1565,11 +1565,13 @@ export class Project { readonly program: Program; readonly checker: Checker; readonly languageService: LanguageService; + private readonly toPath: (fileName: string, basePath?: string) => PathKey; constructor(data: ProjectResponse, snapshotId: number, toPath: (fileName: string, basePath?: string) => PathKey, api: API, snapshotRegistry: SnapshotObjectRegistry) { this.id = data.id as Id; this.handle = data.handle; this.api = api; + this.toPath = toPath; this.configFileName = data.configFileName; this.currentDirectory = data.currentDirectory; this.dirty = data.dirty; @@ -1583,6 +1585,60 @@ export class Project { this.languageService = new LanguageService(snapshotId, this, objectRegistry); } + async containsFile(file: DocumentIdentifier, requireOpen?: boolean): Promise { + return this.api.client.apiRequest("containsProjectFile", { + snapshot: this.program.snapshotId, + project: this.handle, + file, + requireOpen, + }); + } + + async getFileNames(excludeFilesFromExternalLibraries?: boolean, excludeConfigFiles?: boolean): Promise { + const files = await this.program.getSourceFileNames(); + const result: RootedFilePath[] = []; + for (const file of files) { + if (excludeFilesFromExternalLibraries) { + const metadata = await this.program.getSourceFileMetadata(file); + if (!metadata) { + throw new Error(`Program source file '${file}' has no metadata`); + } + if (metadata.isFromExternalLibrary) continue; + } + result.push(file); + } + if (!excludeConfigFiles) { + result.push(...await this.program.getConfigFileNames()); + } + return result; + } + + /** Returns existing root files, excluding missing root declarations. */ + async getRootFiles(): Promise { + return this.api.client.apiRequest("getProjectRootFiles", { + snapshot: this.program.snapshotId, + project: this.handle, + }); + } + + async hasRoots(): Promise { + return this.parsedCommandLine.fileNames.length > 0; + } + + async isRoot(file: DocumentIdentifier): Promise { + const path = this.toPath(resolveFileName(file), this.currentDirectory); + return (await this.getRootFiles()).some(root => this.toPath(root, this.currentDirectory) === path); + } + + async hasConfigFile(file: DocumentIdentifier): Promise { + const path = this.toPath(resolveFileName(file), this.currentDirectory); + return (await this.program.getConfigFileNames()).some(config => this.toPath(config, this.currentDirectory) === path); + } + + async getSourceFile(file: DocumentIdentifier): Promise { + return this.program.getSourceFile(file); + } + dispose(): void { this.checker.dispose(); } @@ -1792,7 +1848,10 @@ export class Program implements FormatDiagnost } async getTypeChecker(): Promise { - await this.getStatistics(); + await this.client.apiRequest("ensureTypeChecker", { + snapshot: this.snapshotId, + project: this.project.handle, + }); return this.project.checker; } diff --git a/packages/typescript/src/api/proto.generated.ts b/packages/typescript/src/api/proto.generated.ts index 5b753e044042d..ba62934bb65c3 100644 --- a/packages/typescript/src/api/proto.generated.ts +++ b/packages/typescript/src/api/proto.generated.ts @@ -80,6 +80,9 @@ export interface APIMethodInfo { getNonMissingTypeOfSymbol: APIMethod; getSourceFile: APIMethod; getSourceFileNames: APIMethod; + containsProjectFile: APIMethod; + getProjectRootFiles: APIMethod; + ensureTypeChecker: APIMethod; getProgramStatistics: APIMethod; getResolvedProjectReferences: APIMethod; getOptionsDiagnostics: APIMethod; @@ -592,6 +595,13 @@ export interface GetSourceFileNamesParams { project: number; } +export interface ContainsProjectFileParams { + snapshot: number; + project: number; + file: DocumentIdentifier; + requireOpen?: boolean | undefined; +} + /** GetProjectDiagnosticsParams are parameters for project-wide diagnostic methods. */ export interface GetProjectDiagnosticsParams { snapshot: number; @@ -1173,6 +1183,7 @@ export interface BatchRequest { | "buildReferences" | "cleanBuild" | "cleanReferences" + | "containsProjectFile" | "createBuildOrchestrator" | "createModuleResolver" | "createSnapshot" @@ -1181,6 +1192,7 @@ export interface BatchRequest { | "disposeBuildOrchestrator" | "emit" | "emitToString" + | "ensureTypeChecker" | "formatNodeForInsertion" | "getAliasSymbolOfType" | "getAliasTypeArgumentsOfType" @@ -1255,6 +1267,7 @@ export interface BatchRequest { | "getParentOfSymbolForChecker" | "getProgramDiagnostics" | "getProgramStatistics" + | "getProjectRootFiles" | "getPropertiesOfType" | "getPropertyOfType" | "getReducedType" @@ -1358,6 +1371,7 @@ export interface BatchResponse { | "buildReferences" | "cleanBuild" | "cleanReferences" + | "containsProjectFile" | "createBuildOrchestrator" | "createModuleResolver" | "createSnapshot" @@ -1366,6 +1380,7 @@ export interface BatchResponse { | "disposeBuildOrchestrator" | "emit" | "emitToString" + | "ensureTypeChecker" | "formatNodeForInsertion" | "getAliasSymbolOfType" | "getAliasTypeArgumentsOfType" @@ -1440,6 +1455,7 @@ export interface BatchResponse { | "getParentOfSymbolForChecker" | "getProgramDiagnostics" | "getProgramStatistics" + | "getProjectRootFiles" | "getPropertiesOfType" | "getPropertyOfType" | "getReducedType" diff --git a/packages/typescript/src/api/sync/api.ts b/packages/typescript/src/api/sync/api.ts index 50ba3adfbe182..46555d8c61136 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -2596,11 +2596,13 @@ export class Project { readonly program: Program; readonly checker: Checker; readonly languageService: LanguageService; + private readonly toPath: (fileName: string, basePath?: string) => PathKey; constructor(data: ProjectResponse, snapshotId: number, toPath: (fileName: string, basePath?: string) => PathKey, api: API, snapshotRegistry: SnapshotObjectRegistry) { this.id = data.id as Id; this.handle = data.handle; this.api = api; + this.toPath = toPath; this.configFileName = data.configFileName; this.currentDirectory = data.currentDirectory; this.dirty = data.dirty; @@ -2614,6 +2616,176 @@ export class Project { this.languageService = new LanguageService(snapshotId, this, objectRegistry); } + get containsFile(): { + (file: DocumentIdentifier, requireOpen?: boolean): boolean; + gen(file: DocumentIdentifier, requireOpen?: boolean): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "containsFile", + function (file: DocumentIdentifier, requireOpen?: boolean): boolean { + return owner.api.client.apiRequest("containsProjectFile", { + snapshot: owner.program.snapshotId, + project: owner.handle, + file, + requireOpen, + }); + }, + function* (file: DocumentIdentifier, requireOpen?: boolean): Generator { + return yield* apiRequest("containsProjectFile", { + snapshot: owner.program.snapshotId, + project: owner.handle, + file, + requireOpen, + }); + }, + ); + } + + get getFileNames(): { + (excludeFilesFromExternalLibraries?: boolean, excludeConfigFiles?: boolean): readonly RootedFilePath[]; + gen(excludeFilesFromExternalLibraries?: boolean, excludeConfigFiles?: boolean): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "getFileNames", + function (excludeFilesFromExternalLibraries?: boolean, excludeConfigFiles?: boolean): readonly RootedFilePath[] { + const files = owner.program.getSourceFileNames(); + const result: RootedFilePath[] = []; + for (const file of files) { + if (excludeFilesFromExternalLibraries) { + const metadata = owner.program.getSourceFileMetadata(file); + if (!metadata) { + throw new Error(`Program source file '${file}' has no metadata`); + } + if (metadata.isFromExternalLibrary) continue; + } + result.push(file); + } + if (!excludeConfigFiles) { + result.push(...owner.program.getConfigFileNames()); + } + return result; + }, + function* (excludeFilesFromExternalLibraries?: boolean, excludeConfigFiles?: boolean): Generator { + const files = yield* owner.program.getSourceFileNames.gen(); + const result: RootedFilePath[] = []; + for (const file of files) { + if (excludeFilesFromExternalLibraries) { + const metadata = yield* owner.program.getSourceFileMetadata.gen(file); + if (!metadata) { + throw new Error(`Program source file '${file}' has no metadata`); + } + if (metadata.isFromExternalLibrary) continue; + } + result.push(file); + } + if (!excludeConfigFiles) { + result.push(...(yield* owner.program.getConfigFileNames.gen())); + } + return result; + }, + ); + } + + /** Returns existing root files, excluding missing root declarations. */ + get getRootFiles(): { + (): readonly RootedFilePath[]; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "getRootFiles", + function (): readonly RootedFilePath[] { + return owner.api.client.apiRequest("getProjectRootFiles", { + snapshot: owner.program.snapshotId, + project: owner.handle, + }); + }, + function* (): Generator { + return yield* apiRequest("getProjectRootFiles", { + snapshot: owner.program.snapshotId, + project: owner.handle, + }); + }, + ); + } + + get hasRoots(): { + (): boolean; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "hasRoots", + function (): boolean { + return owner.parsedCommandLine.fileNames.length > 0; + }, + function* (): Generator { + return owner.parsedCommandLine.fileNames.length > 0; + }, + ); + } + + get isRoot(): { + (file: DocumentIdentifier): boolean; + gen(file: DocumentIdentifier): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "isRoot", + function (file: DocumentIdentifier): boolean { + const path = owner.toPath(resolveFileName(file), owner.currentDirectory); + return (owner.getRootFiles()).some(root => owner.toPath(root, owner.currentDirectory) === path); + }, + function* (file: DocumentIdentifier): Generator { + const path = owner.toPath(resolveFileName(file), owner.currentDirectory); + return (yield* owner.getRootFiles.gen()).some(root => owner.toPath(root, owner.currentDirectory) === path); + }, + ); + } + + get hasConfigFile(): { + (file: DocumentIdentifier): boolean; + gen(file: DocumentIdentifier): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "hasConfigFile", + function (file: DocumentIdentifier): boolean { + const path = owner.toPath(resolveFileName(file), owner.currentDirectory); + return (owner.program.getConfigFileNames()).some(config => owner.toPath(config, owner.currentDirectory) === path); + }, + function* (file: DocumentIdentifier): Generator { + const path = owner.toPath(resolveFileName(file), owner.currentDirectory); + return (yield* owner.program.getConfigFileNames.gen()).some(config => owner.toPath(config, owner.currentDirectory) === path); + }, + ); + } + + get getSourceFile(): { + (file: DocumentIdentifier): SourceFile | undefined; + gen(file: DocumentIdentifier): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "getSourceFile", + function (file: DocumentIdentifier): SourceFile | undefined { + return owner.program.getSourceFile(file); + }, + function* (file: DocumentIdentifier): Generator { + return yield* owner.program.getSourceFile.gen(file); + }, + ); + } + dispose(): void { this.checker.dispose(); } @@ -3041,11 +3213,17 @@ export class Program implements FormatDiagnost owner, "getTypeChecker", function (): Checker { - owner.getStatistics(); + owner.client.apiRequest("ensureTypeChecker", { + snapshot: owner.snapshotId, + project: owner.project.handle, + }); return owner.project.checker; }, function* (): Generator { - yield* owner.getStatistics.gen(); + yield* apiRequest("ensureTypeChecker", { + snapshot: owner.snapshotId, + project: owner.project.handle, + }); return owner.project.checker; }, ); diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index a29b6388dfb17..642723ec6d190 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -2777,6 +2777,94 @@ const value: string = 1;`, assert.equal(syntheticResolved![1], undefined); }); + test("Project membership distinguishes roots, dependencies, and open files", async () => { + await using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: ["src/main.ts", "missing.ts"] }), + "/src/main.ts": `import { value } from "./dependency"; export const result = value;`, + "/src/dependency.ts": `export const value = 1;`, + "/outside.ts": "export {};", + }); + const snapshot = await api.createSnapshot({ openProjects: ["/tsconfig.json"], openFiles: ["/src/main.ts", "/outside.ts"] }); + const project = snapshot.getConfiguredProject("/tsconfig.json")!; + assert.equal(await project.hasRoots(), true); + assert.deepEqual(await project.getRootFiles(), ["/src/main.ts"]); + assert.equal(await project.isRoot("src/main.ts"), true); + assert.equal(await project.isRoot({ uri: "file:///src/main.ts" }), true); + assert.equal(await project.isRoot("/missing.ts"), false); + assert.equal(await project.isRoot("/src/dependency.ts"), false); + assert.equal(await project.containsFile("src/main.ts"), true); + assert.equal(await project.containsFile({ uri: "file:///src/main.ts" }, true), true); + assert.equal(await project.containsFile("/src/dependency.ts"), true); + assert.equal(await project.containsFile("/src/dependency.ts", true), false); + assert.equal(await project.containsFile("/outside.ts"), false); + assert.equal(await project.containsFile("/outside.ts", true), false); + assert.equal(await project.containsFile("/missing.ts"), false); + assert.strictEqual(await project.getSourceFile("src/main.ts"), await project.program.getSourceFile("/src/main.ts")); + assert.equal(await project.getSourceFile("/outside.ts"), undefined); + assert.equal(await project.hasConfigFile("tsconfig.json"), true); + assert.equal(await project.hasConfigFile("/src/main.ts"), false); + + const updated = await snapshot.update({ closeFiles: ["/src/main.ts"] }); + assert.equal(await updated.getConfiguredProject("/tsconfig.json")!.containsFile("/src/main.ts", true), false); + assert.equal(await project.containsFile("/src/main.ts", true), true); + }); + + test("Project file lists include extended configs and optionally exclude external libraries", async () => { + await using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ extends: "./base.json", files: ["src/main.ts"] }), + "/base.json": JSON.stringify({ compilerOptions: { noLib: true, moduleResolution: "node10" } }), + "/src/main.ts": `import { value } from "my-lib"; export const result = value;`, + "/node_modules/my-lib/package.json": JSON.stringify({ types: "./index.d.ts" }), + "/node_modules/my-lib/index.d.ts": "export declare const value: number;", + }); + const snapshot = await api.createSnapshot({ openProjects: ["/tsconfig.json"] }); + const project = snapshot.getConfiguredProject("/tsconfig.json")!; + assert.deepEqual(await project.getFileNames(), ["/node_modules/my-lib/index.d.ts", "/src/main.ts", "/tsconfig.json", "/base.json"]); + assert.deepEqual(await project.getFileNames(true), ["/src/main.ts", "/tsconfig.json", "/base.json"]); + assert.deepEqual(await project.getFileNames(false, true), ["/node_modules/my-lib/index.d.ts", "/src/main.ts"]); + assert.deepEqual(await project.getFileNames(true, true), ["/src/main.ts"]); + assert.equal(await project.hasConfigFile("./base.json"), true); + assert.equal(await project.hasConfigFile({ uri: "file:///base.json" }), true); + assert.equal(await project.hasConfigFile("/other.json"), false); + assert.equal(await project.containsFile("/tsconfig.json"), false); + }); + + test("Project roots preserve missing declarations and empty projects", async () => { + await using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: [] }), + }); + const snapshot = await api.createSnapshot({ + openProjects: ["/tsconfig.json"], + createPrograms: [{ rootFiles: ["/missing.ts"], compilerOptions: { noLib: true } }], + }); + const empty = snapshot.getConfiguredProject("/tsconfig.json")!; + assert.equal(await empty.hasRoots(), false); + assert.deepEqual(await empty.getRootFiles(), []); + assert.deepEqual(await empty.getFileNames(), ["/tsconfig.json"]); + const missing = snapshot.getProject(snapshot.operation.createdPrograms![0].id)!; + assert.equal(await missing.hasRoots(), true); + assert.deepEqual(await missing.getRootFiles(), []); + assert.equal(await missing.isRoot("/missing.ts"), false); + assert.equal(await missing.containsFile("/missing.ts"), false); + assert.deepEqual(await missing.getFileNames(), []); + assert.equal(await missing.hasConfigFile("/tsconfig.json"), false); + }); + + test("Project membership uses configured path case sensitivity", async () => { + const files = { + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: ["src/main.ts"] }), + "/src/main.ts": "export {};", + }; + for (const useCaseSensitiveFileNames of [true, false]) { + await using api = new API({ cwd: "/", fs: createVirtualFileSystem(files), useCaseSensitiveFileNames }); + const snapshot = await api.createSnapshot({ openProjects: ["/tsconfig.json"], openFiles: ["/src/main.ts"] }); + const project = snapshot.getConfiguredProject("/tsconfig.json")!; + assert.equal(await project.isRoot("/SRC/MAIN.TS"), !useCaseSensitiveFileNames); + assert.equal(await project.hasConfigFile("/TSCONFIG.JSON"), !useCaseSensitiveFileNames); + assert.equal(await project.containsFile("/SRC/MAIN.TS", true), !useCaseSensitiveFileNames); + } + }); + test("getSourceFileNames returns all program files, not just root files", async () => { await using api = spawnAPI({ "/tsconfig.json": JSON.stringify({ diff --git a/packages/typescript/test/sync/api-generators.test.ts b/packages/typescript/test/sync/api-generators.test.ts index 4c33e43ab3797..7b46d05fd464f 100644 --- a/packages/typescript/test/sync/api-generators.test.ts +++ b/packages/typescript/test/sync/api-generators.test.ts @@ -1600,6 +1600,15 @@ describe("API - generator batching", { concurrency: areTestsFiltered() }, () => 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", "containsFile", project.containsFile, assertDeepEquivalent, "/src/index.ts"), + parityCase("Project", "containsFile", project.containsFile, assertDeepEquivalent, "/src/index.ts", true), + parityCase("Project", "getFileNames", project.getFileNames, assertDeepEquivalent), + parityCase("Project", "getFileNames", project.getFileNames, assertDeepEquivalent, true, true), + parityCase("Project", "getRootFiles", project.getRootFiles, assertDeepEquivalent), + parityCase("Project", "hasRoots", project.hasRoots, assertDeepEquivalent), + parityCase("Project", "isRoot", project.isRoot, assertDeepEquivalent, "/src/index.ts"), + parityCase("Project", "hasConfigFile", project.hasConfigFile, assertDeepEquivalent, "/tsconfig.json"), + parityCase("Project", "getSourceFile", project.getSourceFile, assertOptionalSourceFilesEquivalent, "/src/index.ts"), parityCase("LanguageService", "getImportAdderEdits", languageService.getImportAdderEdits, assertDeepEquivalent, "/src/index.ts", [{ kind: "importSymbol", symbol: unimportedSymbol }]), parityCase("LanguageService", "getImportEditsForSymbols", languageService.getImportEditsForSymbols, assertDeepEquivalent, "/src/index.ts", [unimportedSymbol], { isValidTypeOnlyUseSite: true }), diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index 2cccd99abedce..dda5185add414 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -2635,6 +2635,94 @@ const value: string = 1;`, assert.equal(syntheticResolved![1], undefined); }); + test("Project membership distinguishes roots, dependencies, and open files", () => { + using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: ["src/main.ts", "missing.ts"] }), + "/src/main.ts": `import { value } from "./dependency"; export const result = value;`, + "/src/dependency.ts": `export const value = 1;`, + "/outside.ts": "export {};", + }); + const snapshot = api.createSnapshot({ openProjects: ["/tsconfig.json"], openFiles: ["/src/main.ts", "/outside.ts"] }); + const project = snapshot.getConfiguredProject("/tsconfig.json")!; + assert.equal(project.hasRoots(), true); + assert.deepEqual(project.getRootFiles(), ["/src/main.ts"]); + assert.equal(project.isRoot("src/main.ts"), true); + assert.equal(project.isRoot({ uri: "file:///src/main.ts" }), true); + assert.equal(project.isRoot("/missing.ts"), false); + assert.equal(project.isRoot("/src/dependency.ts"), false); + assert.equal(project.containsFile("src/main.ts"), true); + assert.equal(project.containsFile({ uri: "file:///src/main.ts" }, true), true); + assert.equal(project.containsFile("/src/dependency.ts"), true); + assert.equal(project.containsFile("/src/dependency.ts", true), false); + assert.equal(project.containsFile("/outside.ts"), false); + assert.equal(project.containsFile("/outside.ts", true), false); + assert.equal(project.containsFile("/missing.ts"), false); + assert.strictEqual(project.getSourceFile("src/main.ts"), project.program.getSourceFile("/src/main.ts")); + assert.equal(project.getSourceFile("/outside.ts"), undefined); + assert.equal(project.hasConfigFile("tsconfig.json"), true); + assert.equal(project.hasConfigFile("/src/main.ts"), false); + + const updated = snapshot.update({ closeFiles: ["/src/main.ts"] }); + assert.equal(updated.getConfiguredProject("/tsconfig.json")!.containsFile("/src/main.ts", true), false); + assert.equal(project.containsFile("/src/main.ts", true), true); + }); + + test("Project file lists include extended configs and optionally exclude external libraries", () => { + using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ extends: "./base.json", files: ["src/main.ts"] }), + "/base.json": JSON.stringify({ compilerOptions: { noLib: true, moduleResolution: "node10" } }), + "/src/main.ts": `import { value } from "my-lib"; export const result = value;`, + "/node_modules/my-lib/package.json": JSON.stringify({ types: "./index.d.ts" }), + "/node_modules/my-lib/index.d.ts": "export declare const value: number;", + }); + const snapshot = api.createSnapshot({ openProjects: ["/tsconfig.json"] }); + const project = snapshot.getConfiguredProject("/tsconfig.json")!; + assert.deepEqual(project.getFileNames(), ["/node_modules/my-lib/index.d.ts", "/src/main.ts", "/tsconfig.json", "/base.json"]); + assert.deepEqual(project.getFileNames(true), ["/src/main.ts", "/tsconfig.json", "/base.json"]); + assert.deepEqual(project.getFileNames(false, true), ["/node_modules/my-lib/index.d.ts", "/src/main.ts"]); + assert.deepEqual(project.getFileNames(true, true), ["/src/main.ts"]); + assert.equal(project.hasConfigFile("./base.json"), true); + assert.equal(project.hasConfigFile({ uri: "file:///base.json" }), true); + assert.equal(project.hasConfigFile("/other.json"), false); + assert.equal(project.containsFile("/tsconfig.json"), false); + }); + + test("Project roots preserve missing declarations and empty projects", () => { + using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: [] }), + }); + const snapshot = api.createSnapshot({ + openProjects: ["/tsconfig.json"], + createPrograms: [{ rootFiles: ["/missing.ts"], compilerOptions: { noLib: true } }], + }); + const empty = snapshot.getConfiguredProject("/tsconfig.json")!; + assert.equal(empty.hasRoots(), false); + assert.deepEqual(empty.getRootFiles(), []); + assert.deepEqual(empty.getFileNames(), ["/tsconfig.json"]); + const missing = snapshot.getProject(snapshot.operation.createdPrograms![0].id)!; + assert.equal(missing.hasRoots(), true); + assert.deepEqual(missing.getRootFiles(), []); + assert.equal(missing.isRoot("/missing.ts"), false); + assert.equal(missing.containsFile("/missing.ts"), false); + assert.deepEqual(missing.getFileNames(), []); + assert.equal(missing.hasConfigFile("/tsconfig.json"), false); + }); + + test("Project membership uses configured path case sensitivity", () => { + const files = { + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: ["src/main.ts"] }), + "/src/main.ts": "export {};", + }; + for (const useCaseSensitiveFileNames of [true, false]) { + using api = new API({ cwd: "/", fs: createVirtualFileSystem(files), useCaseSensitiveFileNames }); + const snapshot = api.createSnapshot({ openProjects: ["/tsconfig.json"], openFiles: ["/src/main.ts"] }); + const project = snapshot.getConfiguredProject("/tsconfig.json")!; + assert.equal(project.isRoot("/SRC/MAIN.TS"), !useCaseSensitiveFileNames); + assert.equal(project.hasConfigFile("/TSCONFIG.JSON"), !useCaseSensitiveFileNames); + assert.equal(project.containsFile("/SRC/MAIN.TS", true), !useCaseSensitiveFileNames); + } + }); + test("getSourceFileNames returns all program files, not just root files", () => { using api = spawnAPI({ "/tsconfig.json": JSON.stringify({ diff --git a/tsc/internal/api/proto.go b/tsc/internal/api/proto.go index 40cc134f6463f..d586398bc19b4 100644 --- a/tsc/internal/api/proto.go +++ b/tsc/internal/api/proto.go @@ -111,6 +111,9 @@ const ( MethodGetNonMissingTypeOfSymbol Method = "getNonMissingTypeOfSymbol" MethodGetSourceFile Method = "getSourceFile" MethodGetSourceFileNames Method = "getSourceFileNames" + MethodContainsProjectFile Method = "containsProjectFile" + MethodGetProjectRootFiles Method = "getProjectRootFiles" + MethodEnsureTypeChecker Method = "ensureTypeChecker" MethodGetProgramStatistics Method = "getProgramStatistics" MethodGetResolvedProjectReferences Method = "getResolvedProjectReferences" MethodGetOptionsDiagnostics Method = "getOptionsDiagnostics" @@ -629,6 +632,8 @@ var unmarshalers = map[Method]func([]byte) (any, error){ MethodGetDefaultProjectForFile: unmarshallerFor[GetDefaultProjectForFileParams], MethodGetSourceFile: unmarshallerFor[GetSourceFileParams], MethodGetSourceFileNames: unmarshallerFor[GetSourceFileNamesParams], + MethodContainsProjectFile: unmarshallerFor[ContainsProjectFileParams], + MethodGetProjectRootFiles: unmarshallerFor[GetSourceFileNamesParams], MethodGetSourceFileMetadata: unmarshallerFor[GetSourceFileParams], MethodGetModeForUsageLocation: unmarshallerFor[GetModeForUsageLocationParams], MethodGetModeForResolutionAtIndex: unmarshallerFor[GetModeForResolutionAtIndexParams], @@ -773,6 +778,7 @@ var unmarshalers = map[Method]func([]byte) (any, error){ MethodGetSuggestionDiagnostics: unmarshallerFor[GetDiagnosticsParams], MethodGetDeclarationDiagnostics: unmarshallerFor[GetDiagnosticsParams], MethodGetProgramDiagnostics: unmarshallerFor[GetProjectDiagnosticsParams], + MethodEnsureTypeChecker: unmarshallerFor[GetProjectDiagnosticsParams], MethodGetProgramStatistics: unmarshallerFor[GetProjectDiagnosticsParams], MethodGetResolvedProjectReferences: unmarshallerFor[GetProjectDiagnosticsParams], MethodGetOptionsDiagnostics: unmarshallerFor[GetProjectDiagnosticsParams], @@ -1414,6 +1420,13 @@ type GetSourceFileNamesParams struct { Project project.Handle `json:"project"` } +type ContainsProjectFileParams struct { + Snapshot SnapshotID `json:"snapshot"` + Project project.Handle `json:"project"` + File DocumentIdentifier `json:"file"` + RequireOpen bool `json:"requireOpen,omitempty"` +} + type GetModeForUsageLocationParams struct { Snapshot SnapshotID `json:"snapshot"` Project project.Handle `json:"project"` diff --git a/tsc/internal/api/session.go b/tsc/internal/api/session.go index 6f001e22a5f88..9f5028519b469 100644 --- a/tsc/internal/api/session.go +++ b/tsc/internal/api/session.go @@ -951,6 +951,12 @@ func (s *Session) HandleRequest(ctx context.Context, method string, params json. return s.handleGetSourceFile(ctx, parsed.(*GetSourceFileParams)) case string(MethodGetSourceFileNames): return s.handleGetSourceFileNames(ctx, parsed.(*GetSourceFileNamesParams)) + case string(MethodContainsProjectFile): + return s.handleContainsProjectFile(ctx, parsed.(*ContainsProjectFileParams)) + case string(MethodGetProjectRootFiles): + return s.handleGetProjectRootFiles(ctx, parsed.(*GetSourceFileNamesParams)) + case string(MethodEnsureTypeChecker): + return s.handleEnsureTypeChecker(ctx, parsed.(*GetProjectDiagnosticsParams)) case string(MethodGetProgramStatistics): return s.handleGetProgramStatistics(ctx, parsed.(*GetProjectDiagnosticsParams)) case string(MethodGetResolvedProjectReferences): @@ -2354,6 +2360,15 @@ func transpileOutput(ctx context.Context, input string, options TranspileOptions }, nil } +func (s *Session) handleEnsureTypeChecker(ctx context.Context, params *GetProjectDiagnosticsParams) (bool, error) { + setup, err := s.setupChecker(ctx, params.Snapshot, params.Project) + if err != nil { + return false, err + } + defer setup.done() + return true, nil +} + func (s *Session) handleGetProgramStatistics(ctx context.Context, params *GetProjectDiagnosticsParams) (*ProgramStatisticsResponse, error) { setup, err := s.setupChecker(ctx, params.Snapshot, params.Project) if err != nil { @@ -2554,6 +2569,51 @@ func (s *Session) encodeSourceFileResponse(sourceFile *ast.SourceFile) (any, err }, nil } +func (s *Session) handleContainsProjectFile(ctx context.Context, params *ContainsProjectFileParams) (bool, error) { + sd, err := s.getSnapshotData(params.Snapshot) + if err != nil { + return false, err + } + proj, err := sd.getProject(params.Project) + if err != nil { + return false, err + } + fileName := params.File.ToFileName(proj.CurrentDirectory()) + file := sd.snapshot.GetFile(fileName) + if file == nil { + return false, nil + } + path := sd.snapshot.CaseSensitivity().PathKey(fileName.AsPath()) + if params.RequireOpen && !sd.snapshot.ProjectCollection.IsFileOpen(path) { + return false, nil + } + for _, root := range proj.CommandLine.FileNames() { + if sd.snapshot.CaseSensitivity().PathKey(root.AsPath()) == path { + return true, nil + } + } + program := proj.GetProgram() + return program != nil && program.GetSourceFileByPath(path) != nil, nil +} + +func (s *Session) handleGetProjectRootFiles(ctx context.Context, params *GetSourceFileNamesParams) ([]tspath.RootedFilePath, error) { + sd, err := s.getSnapshotData(params.Snapshot) + if err != nil { + return nil, err + } + proj, err := sd.getProject(params.Project) + if err != nil { + return nil, err + } + result := make([]tspath.RootedFilePath, 0, len(proj.CommandLine.FileNames())) + for _, root := range proj.CommandLine.FileNames() { + if sd.snapshot.GetFile(root) != nil { + result = append(result, root) + } + } + return result, nil +} + // handleGetSourceFileNames returns file names of all source files in a project. func (s *Session) handleGetSourceFileNames(ctx context.Context, params *GetSourceFileNamesParams) ([]tspath.RootedFilePath, error) { sd, err := s.getSnapshotData(params.Snapshot) diff --git a/tsc/internal/api/session_program_test.go b/tsc/internal/api/session_program_test.go index 5cbd6758e3e4a..9dceffd8353c2 100644 --- a/tsc/internal/api/session_program_test.go +++ b/tsc/internal/api/session_program_test.go @@ -5,11 +5,45 @@ import ( "testing" "github.com/microsoft/TypeScript/tsc/internal/core" + "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" "github.com/microsoft/TypeScript/tsc/internal/project" "github.com/microsoft/TypeScript/tsc/internal/testutil/projecttestutil" "gotest.tools/v3/assert" ) +func TestProjectContainsEditorOpenFile(t *testing.T) { + t.Parallel() + + const fileName = "/home/projects/p/index.ts" + projectSession, _ := projecttestutil.Setup(map[string]any{ + "/home/projects/p/tsconfig.json": `{"compilerOptions":{"noLib":true},"files":["index.ts"]}`, + fileName: "export {};", + }) + defer projectSession.Close() + uri := DocumentIdentifier{FileName: fileName}.ToURI(projectSession.GetCurrentDirectory()) + projectSession.DidOpenFile(context.Background(), uri, 1, "export {};", lsproto.LanguageKindTypeScript) + session := NewLSPSession(projectSession, nil) + defer session.Close() + response, err := session.handleGetCurrentLanguageServerSnapshot(context.Background(), &GetCurrentLanguageServerSnapshotParams{}) + assert.NilError(t, err) + params := &ContainsProjectFileParams{ + Snapshot: response.Snapshot, + Project: response.Projects[0].Handle, + File: DocumentIdentifier{FileName: fileName}, + RequireOpen: true, + } + contains, err := session.handleContainsProjectFile(context.Background(), params) + assert.NilError(t, err) + assert.Assert(t, contains) + params.Project = project.Handle(999999) + _, err = session.handleContainsProjectFile(context.Background(), params) + assert.ErrorContains(t, err, "not found") + _, err = session.handleGetProjectRootFiles(context.Background(), &GetSourceFileNamesParams{ + Snapshot: response.Snapshot, Project: params.Project, + }) + assert.ErrorContains(t, err, "not found") +} + func TestProgramStatisticsUseAPIChecker(t *testing.T) { t.Parallel() @@ -50,3 +84,43 @@ func TestProgramStatisticsUseAPIChecker(t *testing.T) { }) assert.ErrorContains(t, err, "not found") } + +func TestEnsureTypeChecker(t *testing.T) { + t.Parallel() + + const fileName = "/home/projects/p/index.ts" + projectSession, _ := projecttestutil.Setup(map[string]any{ + fileName: "export const value = 1;", + }) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + response, err := session.handleCreateSnapshot(context.Background(), &CreateSnapshotParams{ + CreatePrograms: []*CreateSnapshotProgramParams{{ + RootFiles: []DocumentIdentifier{{FileName: fileName}}, + CompilerOptions: core.CompilerOptions{NoLib: core.TSTrue}, + }}, + }) + assert.NilError(t, err) + params := &GetProjectDiagnosticsParams{Snapshot: response.Snapshot, Project: response.Projects[0].Handle} + initialized, err := session.handleEnsureTypeChecker(context.Background(), params) + assert.NilError(t, err) + assert.Assert(t, initialized) + setup, err := session.setupChecker(context.Background(), params.Snapshot, params.Project) + assert.NilError(t, err) + first := setup.checker + setup.done() + + initialized, err = session.handleEnsureTypeChecker(context.Background(), params) + assert.NilError(t, err) + assert.Assert(t, initialized) + setup, err = session.setupChecker(context.Background(), params.Snapshot, params.Project) + assert.NilError(t, err) + assert.Assert(t, setup.checker == first) + setup.done() + + _, err = session.handleEnsureTypeChecker(context.Background(), &GetProjectDiagnosticsParams{ + Snapshot: response.Snapshot, Project: project.Handle(999999), + }) + assert.ErrorContains(t, err, "not found") +} diff --git a/tsc/internal/project/projectcollection.go b/tsc/internal/project/projectcollection.go index 609985e2c8bd5..222701557ece7 100644 --- a/tsc/internal/project/projectcollection.go +++ b/tsc/internal/project/projectcollection.go @@ -224,6 +224,14 @@ func (c *ProjectCollection) GetOpenConfiguredProjects() *collections.Set[Configu return c.openConfiguredProjects } +func (c *ProjectCollection) IsFileOpen(path tspath.PathKey) bool { + if c.openFiles.Has(path) { + return true + } + _, ok := c.apiState.openFiles[path] + return ok +} + func openFilePaths(overlays map[tspath.PathKey]*Overlay) collections.Set[tspath.PathKey] { openFiles := collections.Set[tspath.PathKey]{M: make(map[tspath.PathKey]struct{}, len(overlays))} for path := range overlays { From 6a6a1d23a09970d2b58d979f1c7e7d31e02f0ec7 Mon Sep 17 00:00:00 2001 From: isabel duan Date: Wed, 28 Oct 2026 19:05:25 -0700 Subject: [PATCH 3/3] address comments --- packages/typescript/test/async/api.test.ts | 63 +++++++++++++++++++++- packages/typescript/test/sync/api.test.ts | 63 +++++++++++++++++++++- tsc/internal/api/session.go | 12 ++--- tsc/internal/api/session_program_test.go | 50 +++++++++++++++++ 4 files changed, 177 insertions(+), 11 deletions(-) diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index 642723ec6d190..a7d8fc62ece4e 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -126,8 +126,19 @@ import { toRootedFilePath, } from "@typescript/typescript/path"; import assert from "node:assert"; -import { globSync } from "node:fs"; -import { resolve } from "node:path"; +import { + existsSync, + globSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { + join, + resolve, +} from "node:path"; import { describe, test, @@ -2809,6 +2820,54 @@ const value: string = 1;`, assert.equal(await project.containsFile("/src/main.ts", true), true); }); + test("Project membership includes supplemental files without physical files", async t => { + const directory = mkdtempSync(join(tmpdir(), "ts-project-membership-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const mapperDirectory = join(directory, "node_modules", "mapper"); + mkdirSync(mapperDirectory, { recursive: true }); + const mapperScript = ` + import { createMessageConnection } from ${JSON.stringify(new URL("../../vendor/vscode-jsonrpc/lib/node/main.js", import.meta.url).href)}; + const connection = createMessageConnection(process.stdin, process.stdout); + connection.onRequest("initialize", () => ({ positionEncoding: "utf-8", diagnosticSource: "mapper" })); + connection.onRequest("openProject", () => ({})); + connection.onRequest("closeProject", () => null); + connection.onRequest("transform", ({ content }) => ({ + text: "export {};", + extension: ".ts", + supplemental: [{ text: content, extension: ".ts" }], + })); + connection.listen(); + `; + writeFileSync( + join(mapperDirectory, "package.json"), + JSON.stringify({ + name: "mapper", + version: "1.0.0", + typescript: { contentMapper: { exec: [process.execPath, "--input-type=module", "--eval", mapperScript] } }, + }), + ); + const configFileName = toRootedFilePath(join(directory, "tsconfig.json"), undefined); + writeFileSync( + configFileName, + JSON.stringify({ + compilerOptions: { noLib: true }, + files: ["app.box", "main.ts", "missing.ts"], + contentMappers: [{ package: "mapper", extensions: [".box"] }], + }), + ); + writeFileSync(join(directory, "app.box"), "declare const supplementalValue: number;"); + writeFileSync(join(directory, "main.ts"), "const value: number = supplementalValue;"); + await using api = new API({ cwd: directory, runExternalCode: true }); + const snapshot = await api.createSnapshot({ openProjects: [configFileName] }); + const project = snapshot.getConfiguredProject(configFileName)!; + const supplementalFileName = toRootedFilePath(join(directory, "app.box.0.ts"), undefined); + assert.equal(existsSync(supplementalFileName), false); + assert.ok(await project.getSourceFile(supplementalFileName)); + assert.equal(await project.containsFile(supplementalFileName), true); + assert.equal(await project.containsFile(supplementalFileName, true), false); + assert.equal(await project.containsFile(join(directory, "missing.ts")), false); + }); + test("Project file lists include extended configs and optionally exclude external libraries", async () => { await using api = spawnAPI({ "/tsconfig.json": JSON.stringify({ extends: "./base.json", files: ["src/main.ts"] }), diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index dda5185add414..300361def50e4 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -134,8 +134,19 @@ import { type UserPreferences, } from "@typescript/typescript/sync"; import assert from "node:assert"; -import { globSync } from "node:fs"; -import { resolve } from "node:path"; +import { + existsSync, + globSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { + join, + resolve, +} from "node:path"; import { describe, test, @@ -2667,6 +2678,54 @@ const value: string = 1;`, assert.equal(project.containsFile("/src/main.ts", true), true); }); + test("Project membership includes supplemental files without physical files", t => { + const directory = mkdtempSync(join(tmpdir(), "ts-project-membership-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const mapperDirectory = join(directory, "node_modules", "mapper"); + mkdirSync(mapperDirectory, { recursive: true }); + const mapperScript = ` + import { createMessageConnection } from ${JSON.stringify(new URL("../../vendor/vscode-jsonrpc/lib/node/main.js", import.meta.url).href)}; + const connection = createMessageConnection(process.stdin, process.stdout); + connection.onRequest("initialize", () => ({ positionEncoding: "utf-8", diagnosticSource: "mapper" })); + connection.onRequest("openProject", () => ({})); + connection.onRequest("closeProject", () => null); + connection.onRequest("transform", ({ content }) => ({ + text: "export {};", + extension: ".ts", + supplemental: [{ text: content, extension: ".ts" }], + })); + connection.listen(); + `; + writeFileSync( + join(mapperDirectory, "package.json"), + JSON.stringify({ + name: "mapper", + version: "1.0.0", + typescript: { contentMapper: { exec: [process.execPath, "--input-type=module", "--eval", mapperScript] } }, + }), + ); + const configFileName = toRootedFilePath(join(directory, "tsconfig.json"), undefined); + writeFileSync( + configFileName, + JSON.stringify({ + compilerOptions: { noLib: true }, + files: ["app.box", "main.ts", "missing.ts"], + contentMappers: [{ package: "mapper", extensions: [".box"] }], + }), + ); + writeFileSync(join(directory, "app.box"), "declare const supplementalValue: number;"); + writeFileSync(join(directory, "main.ts"), "const value: number = supplementalValue;"); + using api = new API({ cwd: directory, runExternalCode: true }); + const snapshot = api.createSnapshot({ openProjects: [configFileName] }); + const project = snapshot.getConfiguredProject(configFileName)!; + const supplementalFileName = toRootedFilePath(join(directory, "app.box.0.ts"), undefined); + assert.equal(existsSync(supplementalFileName), false); + assert.ok(project.getSourceFile(supplementalFileName)); + assert.equal(project.containsFile(supplementalFileName), true); + assert.equal(project.containsFile(supplementalFileName, true), false); + assert.equal(project.containsFile(join(directory, "missing.ts")), false); + }); + test("Project file lists include extended configs and optionally exclude external libraries", () => { using api = spawnAPI({ "/tsconfig.json": JSON.stringify({ extends: "./base.json", files: ["src/main.ts"] }), diff --git a/tsc/internal/api/session.go b/tsc/internal/api/session.go index 9f5028519b469..2fbbd11179cfe 100644 --- a/tsc/internal/api/session.go +++ b/tsc/internal/api/session.go @@ -2579,21 +2579,19 @@ func (s *Session) handleContainsProjectFile(ctx context.Context, params *Contain return false, err } fileName := params.File.ToFileName(proj.CurrentDirectory()) - file := sd.snapshot.GetFile(fileName) - if file == nil { - return false, nil - } path := sd.snapshot.CaseSensitivity().PathKey(fileName.AsPath()) if params.RequireOpen && !sd.snapshot.ProjectCollection.IsFileOpen(path) { return false, nil } + if program := proj.GetProgram(); program != nil && program.GetSourceFileByPath(path) != nil { + return true, nil + } for _, root := range proj.CommandLine.FileNames() { if sd.snapshot.CaseSensitivity().PathKey(root.AsPath()) == path { - return true, nil + return sd.snapshot.GetFile(fileName) != nil, nil } } - program := proj.GetProgram() - return program != nil && program.GetSourceFileByPath(path) != nil, nil + return false, nil } func (s *Session) handleGetProjectRootFiles(ctx context.Context, params *GetSourceFileNamesParams) ([]tspath.RootedFilePath, error) { diff --git a/tsc/internal/api/session_program_test.go b/tsc/internal/api/session_program_test.go index 9dceffd8353c2..e31cd1dad8a44 100644 --- a/tsc/internal/api/session_program_test.go +++ b/tsc/internal/api/session_program_test.go @@ -7,10 +7,60 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/core" "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" "github.com/microsoft/TypeScript/tsc/internal/project" + "github.com/microsoft/TypeScript/tsc/internal/testutil/contentmappertest" "github.com/microsoft/TypeScript/tsc/internal/testutil/projecttestutil" "gotest.tools/v3/assert" ) +func TestProjectContainsSupplementalFile(t *testing.T) { + t.Parallel() + + const supplementalFileName = "/home/project/app.box.0.ts" + files := map[string]any{ + "/home/project/tsconfig.json": `{"compilerOptions":{"noLib":true},"files":["app.box","main.ts","missing.ts"],"contentMappers":[{"package":"mapper","extensions":[".box"]}]}`, + "/home/project/node_modules/mapper/package.json": contentmappertest.PackageJSON(contentmappertest.SupplementalMapper), + "/home/project/app.box": "declare const supplementalValue: number;", + "/home/project/main.ts": "const value: number = supplementalValue;", + } + init, _ := projecttestutil.GetSessionInitOptions(files, &project.SessionOptions{ + CurrentDirectory: "/home/project", + RunExternalCode: true, + }, nil) + init.Spawner = contentmappertest.NewSpawner() + projectSession := project.NewSession(init) + defer projectSession.Close() + projectSession.DidOpenFile(context.Background(), "file:///home/project/main.ts", 1, files["/home/project/main.ts"].(string), lsproto.LanguageKindTypeScript) + session := NewLSPSession(projectSession, nil) + defer session.Close() + response, err := session.handleGetCurrentLanguageServerSnapshot(context.Background(), &GetCurrentLanguageServerSnapshotParams{}) + assert.NilError(t, err) + params := &ContainsProjectFileParams{ + Snapshot: response.Snapshot, + Project: response.Projects[0].Handle, + File: DocumentIdentifier{FileName: supplementalFileName}, + } + sd, err := session.getSnapshotData(response.Snapshot) + assert.NilError(t, err) + assert.Assert(t, sd.snapshot.GetFile(supplementalFileName) == nil) + sourceFile, err := session.handleGetSourceFile(context.Background(), &GetSourceFileParams{ + Snapshot: params.Snapshot, Project: params.Project, File: params.File, + }) + assert.NilError(t, err) + assert.Assert(t, sourceFile != nil) + contains, err := session.handleContainsProjectFile(context.Background(), params) + assert.NilError(t, err) + assert.Assert(t, contains) + params.RequireOpen = true + contains, err = session.handleContainsProjectFile(context.Background(), params) + assert.NilError(t, err) + assert.Assert(t, !contains) + params.RequireOpen = false + params.File = DocumentIdentifier{FileName: "/home/project/missing.ts"} + contains, err = session.handleContainsProjectFile(context.Background(), params) + assert.NilError(t, err) + assert.Assert(t, !contains) +} + func TestProjectContainsEditorOpenFile(t *testing.T) { t.Parallel()