From 408c16335f0741986cafd7f464e64ab8610fd638 Mon Sep 17 00:00:00 2001 From: Gabriela Araujo Britto Date: Fri, 21 Aug 2026 18:26:54 +0000 Subject: [PATCH 1/3] add createProgram to API --- packages/typescript/src/api/async/api.ts | 80 ++- .../typescript/src/api/proto.generated.ts | 28 +- packages/typescript/src/api/sync/api.ts | 80 ++- packages/typescript/test/async/api.test.ts | 230 +++++++++ packages/typescript/test/sync/api.test.ts | 230 +++++++++ tsc/internal/api/proto.go | 43 +- tsc/internal/api/session.go | 69 +++ .../api/session_createprogram_test.go | 459 ++++++++++++++++++ tsc/internal/ast/diagnostic.go | 24 + .../compiler/projectreferencefilemapper.go | 19 +- .../compiler/projectreferenceparser.go | 2 +- tsc/internal/execute/tsc/emit_test.go | 2 +- tsc/internal/project/api.go | 41 ++ tsc/internal/project/project.go | 22 +- tsc/internal/project/project_test.go | 28 ++ .../project/projectcollectionbuilder.go | 111 +++-- tsc/internal/project/snapshot.go | 266 ++++++++-- tsc/internal/tsoptions/commandlineparser.go | 2 +- tsc/internal/tsoptions/contentmappers_test.go | 1 + tsc/internal/tsoptions/parsedcommandline.go | 6 +- 20 files changed, 1655 insertions(+), 88 deletions(-) create mode 100644 tsc/internal/api/session_createprogram_test.go diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index ad787fb62495d..64f93df52d767 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -49,7 +49,10 @@ import { toPath, } from "../path.ts"; import type { + APIFileChanges, CompilerOptions, + CreateProgramOptions, + CreateProgramResponse, Diagnostic, DocumentIdentifier, DocumentPosition, @@ -135,6 +138,7 @@ import type { export { documentURIToFileName, fileNameToDocumentURI } from "../path.ts"; export { CheckFlags, CompletionItemKind, DiagnosticCategory, ElementFlags, EmitOnly, ModifierFlags, ModuleKind, NodeBuilderFlags, ObjectFlags, SignatureFlags, SignatureKind, SymbolFlags, TypeFlags, TypeFormatFlags, TypePredicateKind }; export type { + APIFileChanges, APIImportAdderAction as ImportAdderAction, APIOptions, AssertsIdentifierTypePredicate, @@ -148,6 +152,7 @@ export type { CompletionInfo, CompletionOptions, ConditionalType, + CreateProgramOptions, Diagnostic, DocumentIdentifier, DocumentPosition, @@ -387,6 +392,46 @@ export class API { resetTimingInfo(): Promise { return this.client.resetTimingInfo(); } + + /** + * Creates a program from current filesystem state, or derives one from oldProgram after applying fileChanges. + */ + async createProgram( + rootFiles: readonly DocumentIdentifier[], + createProgramOptions: CreateProgramOptions, + oldProgram?: Program, + fileChanges?: APIFileChanges, + ): Promise { + await this.ensureInitialized(); + + if (fileChanges && !oldProgram) { + throw new Error("fileChanges requires an oldProgram"); + } + + const data: CreateProgramResponse = await this.client.apiRequest("createProgram", { + rootFiles, + createProgramOptions, + ...(oldProgram ? { oldProgram: { snapshot: oldProgram.snapshotId, project: oldProgram.getProject().id } } : {}), + ...(fileChanges ? { fileChanges } : {}), + }); + if (!data.project) { + throw new Error("createProgram did not return a project"); + } + const snapshot = new Snapshot( + { snapshot: data.snapshot, projects: [data.project] }, + this.client, + this.sourceFileCache, + this.toPath!, + () => { + this.activeSnapshots.delete(snapshot); + this.sourceFileCache.releaseSnapshot(snapshot.id); + }, + ); + const program = snapshot.getProjects()[0].program; + program.setOwnedSnapshot(snapshot); + this.activeSnapshots.add(snapshot); + return program; + } } export class InternalAPI { @@ -947,13 +992,15 @@ export class LanguageService { } export class Program { - private snapshotId: number; - private project: Project; - private client: Client; - private sourceFileCache: SourceFileCache; - private toPath: (fileName: string) => Path; - private decoder = new Wtf8Decoder(); - private sourceFileMetadataCache = new Map>(); + /** @internal */ + readonly snapshotId: number; + private readonly project: Project; + private readonly client: Client; + private readonly sourceFileCache: SourceFileCache; + private readonly toPath: (fileName: string) => Path; + private readonly decoder = new Wtf8Decoder(); + private readonly sourceFileMetadataCache = new Map>(); + private ownedSnapshot: Snapshot | undefined; constructor( snapshotId: number, @@ -969,6 +1016,21 @@ export class Program { this.toPath = toPath; } + /** @internal */ + setOwnedSnapshot(snapshot: Snapshot): void { + this.ownedSnapshot = snapshot; + } + + [globalThis.Symbol.dispose](): void { + this.dispose(); + } + + async dispose(): Promise { + const snapshot = this.ownedSnapshot; + this.ownedSnapshot = undefined; + await snapshot?.dispose(); + } + getCompilerOptions(): CompilerOptions { return this.project.compilerOptions; } @@ -1259,6 +1321,10 @@ export class Program { }); return toEmitOutput(response); } + + getProject(): Project { + return this.project; + } } function toEmitOutput(response: ProtocolEmitOutputResponse): EmitOutput { diff --git a/packages/typescript/src/api/proto.generated.ts b/packages/typescript/src/api/proto.generated.ts index 35c08679d2aa4..1c70d5261e908 100644 --- a/packages/typescript/src/api/proto.generated.ts +++ b/packages/typescript/src/api/proto.generated.ts @@ -14,6 +14,7 @@ export interface APIMethodInfo { initialize: APIMethod; updateSnapshot: APIMethod; updateTemporarySnapshot: APIMethod; + createProgram: APIMethod; parseCommandLine: APIMethod; readConfigFile: APIMethod; parseJsonConfigFileContent: APIMethod; @@ -217,11 +218,23 @@ export interface UpdateSnapshotResponse { */ export interface UpdateTemporarySnapshotParams { /** Snapshot is the current client snapshot on which to layer the temporary update. */ - snapshot: number; + snapshot?: number; /** File identifies the file whose content is temporarily overridden. */ file: DocumentIdentifier; /** NewText is the temporary content for the file. */ - newText: string; + newText?: string; +} + +export interface CreateProgramParams { + rootFiles: readonly DocumentIdentifier[] | null; + createProgramOptions: CreateProgramOptions; + oldProgram?: CreateProgramOldProgramParams; + fileChanges?: APIFileChanges; +} + +export interface CreateProgramResponse { + snapshot: number; + project: ProjectResponse | null; } export interface ParseCommandLineParams { @@ -888,6 +901,17 @@ export interface SnapshotChanges { removedProjects?: string[]; } +export interface CreateProgramOptions { + compilerOptions: CompilerOptions; + projectReferences?: ProjectReference[]; + configFileParsingDiagnostics?: DiagnosticResponse[]; +} + +export interface CreateProgramOldProgramParams { + snapshot?: number; + project?: string; +} + /** CompilerOptions contains the compiler options exposed by the API. */ export interface CompilerOptions { allowJs?: boolean; diff --git a/packages/typescript/src/api/sync/api.ts b/packages/typescript/src/api/sync/api.ts index 074471e86cfbb..bacaca232c32e 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -57,7 +57,10 @@ import { toPath, } from "../path.ts"; import type { + APIFileChanges, CompilerOptions, + CreateProgramOptions, + CreateProgramResponse, Diagnostic, DocumentIdentifier, DocumentPosition, @@ -143,6 +146,7 @@ import type { export { documentURIToFileName, fileNameToDocumentURI } from "../path.ts"; export { CheckFlags, CompletionItemKind, DiagnosticCategory, ElementFlags, EmitOnly, ModifierFlags, ModuleKind, NodeBuilderFlags, ObjectFlags, SignatureFlags, SignatureKind, SymbolFlags, TypeFlags, TypeFormatFlags, TypePredicateKind }; export type { + APIFileChanges, APIImportAdderAction as ImportAdderAction, APIOptions, AssertsIdentifierTypePredicate, @@ -156,6 +160,7 @@ export type { CompletionInfo, CompletionOptions, ConditionalType, + CreateProgramOptions, Diagnostic, DocumentIdentifier, DocumentPosition, @@ -395,6 +400,46 @@ export class API { resetTimingInfo(): void { return this.client.resetTimingInfo(); } + + /** + * Creates a program from current filesystem state, or derives one from oldProgram after applying fileChanges. + */ + createProgram( + rootFiles: readonly DocumentIdentifier[], + createProgramOptions: CreateProgramOptions, + oldProgram?: Program, + fileChanges?: APIFileChanges, + ): Program { + this.ensureInitialized(); + + if (fileChanges && !oldProgram) { + throw new Error("fileChanges requires an oldProgram"); + } + + const data: CreateProgramResponse = this.client.apiRequest("createProgram", { + rootFiles, + createProgramOptions, + ...(oldProgram ? { oldProgram: { snapshot: oldProgram.snapshotId, project: oldProgram.getProject().id } } : {}), + ...(fileChanges ? { fileChanges } : {}), + }); + if (!data.project) { + throw new Error("createProgram did not return a project"); + } + const snapshot = new Snapshot( + { snapshot: data.snapshot, projects: [data.project] }, + this.client, + this.sourceFileCache, + this.toPath!, + () => { + this.activeSnapshots.delete(snapshot); + this.sourceFileCache.releaseSnapshot(snapshot.id); + }, + ); + const program = snapshot.getProjects()[0].program; + program.setOwnedSnapshot(snapshot); + this.activeSnapshots.add(snapshot); + return program; + } } export class InternalAPI { @@ -955,13 +1000,15 @@ export class LanguageService { } export class Program { - private snapshotId: number; - private project: Project; - private client: Client; - private sourceFileCache: SourceFileCache; - private toPath: (fileName: string) => Path; - private decoder = new Wtf8Decoder(); - private sourceFileMetadataCache = new Map(); + /** @internal */ + readonly snapshotId: number; + private readonly project: Project; + private readonly client: Client; + private readonly sourceFileCache: SourceFileCache; + private readonly toPath: (fileName: string) => Path; + private readonly decoder = new Wtf8Decoder(); + private readonly sourceFileMetadataCache = new Map(); + private ownedSnapshot: Snapshot | undefined; constructor( snapshotId: number, @@ -977,6 +1024,21 @@ export class Program { this.toPath = toPath; } + /** @internal */ + setOwnedSnapshot(snapshot: Snapshot): void { + this.ownedSnapshot = snapshot; + } + + [globalThis.Symbol.dispose](): void { + this.dispose(); + } + + dispose(): void { + const snapshot = this.ownedSnapshot; + this.ownedSnapshot = undefined; + snapshot?.dispose(); + } + getCompilerOptions(): CompilerOptions { return this.project.compilerOptions; } @@ -1267,6 +1329,10 @@ export class Program { }); return toEmitOutput(response); } + + getProject(): Project { + return this.project; + } } function toEmitOutput(response: ProtocolEmitOutputResponse): EmitOutput { diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index fe08206bc486b..df7deee6800bb 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -297,6 +297,236 @@ describe("API", () => { } }); + test("createProgram", async () => { + const api = spawnAPI({ + "/src/index.ts": `export const value: string = 1;`, + }); + try { + const program = await api.createProgram(["/src/index.ts"], { compilerOptions: { noLib: true, strict: true } }); + + assert.deepEqual(program.getCompilerOptions(), { noLib: true, strict: true }); + assert.deepEqual(await program.getSourceFileNames(), ["/src/index.ts"]); + assert.equal((await program.getSemanticDiagnostics("/src/index.ts")).length, 1); + + await program.dispose(); + await assert.rejects(program.getSourceFileNames(), /snapshot .* not found/); // @sync: assert.throws(() => program.getSourceFileNames(), /snapshot .* not found/); + } + finally { + await api.close(); + } + }); + + test("createProgram ignores an on-disk tsconfig", async () => { + const api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ + compilerOptions: { noLib: false, strict: false }, + files: ["src/from-config.ts"], + }), + "/src/index.ts": `export const explicitRoot = 1;`, + "/src/from-config.ts": `export const configRoot = 1;`, + }); + try { + const program = await api.createProgram( + ["/src/index.ts"], + { compilerOptions: { noLib: true, strict: true } }, + ); + + assert.deepEqual(program.getCompilerOptions(), { noLib: true, strict: true }); + assert.deepEqual(await program.getSourceFileNames(), ["/src/index.ts"]); + assert.deepEqual(await program.getConfigFileNames(), []); + assert.equal(await program.getSourceFile("/src/from-config.ts"), undefined); + await program.dispose(); + } + finally { + await api.close(); + } + }); + + test("createProgram includes project references", async () => { + const reference = { path: "/lib/tsconfig.json", originalPath: "/lib/tsconfig.json", circular: false }; + const api = spawnAPI({ + "/src/index.ts": `export const value = 1;`, + "/lib/tsconfig.json": JSON.stringify({ compilerOptions: { composite: true, noLib: true }, files: ["index.ts"] }), + "/lib/index.ts": `export const lib = 1;`, + }); + try { + const program = await api.createProgram( + ["/src/index.ts"], + { compilerOptions: { noLib: true }, projectReferences: [reference] }, + ); + assert.deepEqual(program.getProject().parsedCommandLine.projectReferences, [reference]); + await program.dispose(); + } + finally { + await api.close(); + } + }); + + test("createProgram includes config file parsing diagnostics", async () => { + const diagnostic = { + pos: 0, + end: 0, + code: 9001, + category: DiagnosticCategory.Error, + text: "Synthetic config parsing error.", + }; + const api = spawnAPI({ "/src/index.ts": `export const value = 1;` }); + try { + const program = await api.createProgram( + ["/src/index.ts"], + { + compilerOptions: { noLib: true }, + configFileParsingDiagnostics: [diagnostic], + }, + ); + + assert.deepEqual(await program.getConfigFileParsingDiagnostics(), [diagnostic]); + await program.dispose(); + } + finally { + await api.close(); + } + }); + + test("createProgram updates roots when given an old program", async () => { + const options = { compilerOptions: { noLib: true } }; + const api = spawnAPI({ + "/src/a.ts": `export const a = 1;`, + "/src/b.ts": `export const b = 1;`, + "/src/c.ts": `export const c = 1;`, + }); + try { + const oldProgram = await api.createProgram(["/src/a.ts", "/src/b.ts"], options); + const newProgram = await api.createProgram(["/src/a.ts", "/src/c.ts"], options, oldProgram); + assert.deepEqual(await newProgram.getSourceFileNames(), ["/src/a.ts", "/src/c.ts"]); + assert.deepEqual(await oldProgram.getSourceFileNames(), ["/src/a.ts", "/src/b.ts"]); + + await newProgram.dispose(); + await oldProgram.dispose(); + } + finally { + await api.close(); + } + }); + + test("createProgram discovers imported non-root dependencies", async () => { + const api = spawnAPI({ + "/src/main.ts": `import { dependency } from "./dependency"; export const value = dependency;`, + "/src/dependency.ts": `export const dependency = 1;`, + }); + try { + const program = await api.createProgram(["/src/main.ts"], { compilerOptions: { noLib: true } }); + assert.deepEqual([...await program.getSourceFileNames()].sort(), ["/src/dependency.ts", "/src/main.ts"]); + + await program.dispose(); + } + finally { + await api.close(); + } + }); + + test("createProgram updates an old program with file changes", async () => { + const fileName = "/src/index.ts"; + const options = { compilerOptions: { noLib: true, strict: true } }; + const { api, fs } = spawnAPIWithFS({ + [fileName]: `export const value: string = 1;`, + }); + try { + const oldProgram = await api.createProgram([fileName], options); + assert.equal((await oldProgram.getSemanticDiagnostics(fileName)).length, 1); + + fs.writeFile!(fileName, `export const value: string = "valid";`); + const newProgram = await api.createProgram( + [fileName], + options, + oldProgram, + { changed: [fileName] }, + ); + + assert.equal((await newProgram.getSemanticDiagnostics(fileName)).length, 0); + assert.equal((await oldProgram.getSemanticDiagnostics(fileName)).length, 1); + + await newProgram.dispose(); + await oldProgram.dispose(); + } + finally { + await api.close(); + } + }); + + test("createProgram updates an old program with invalidateAll", async () => { + const fileName = "/src/index.ts"; + const options = { compilerOptions: { noLib: true, strict: true } }; + const { api, fs } = spawnAPIWithFS({ + [fileName]: `export const value: string = 1;`, + }); + try { + const oldProgram = await api.createProgram([fileName], options); + assert.equal((await oldProgram.getSemanticDiagnostics(fileName)).length, 1); + + fs.writeFile!(fileName, `export const value: string = "valid";`); + const newProgram = await api.createProgram( + [fileName], + options, + oldProgram, + { invalidateAll: true }, + ); + + assert.equal((await newProgram.getSemanticDiagnostics(fileName)).length, 0); + assert.equal((await oldProgram.getSemanticDiagnostics(fileName)).length, 1); + + await newProgram.dispose(); + await oldProgram.dispose(); + } + finally { + await api.close(); + } + }); + + test("createProgram accepts a regular project program as the old program", async () => { + const fileName = "/src/index.ts"; + const { api, fs } = spawnAPIWithFS({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, strict: true } }), + [fileName]: `export const value: string = 1;`, + }); + try { + const snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json" }); + const project = snapshot.getProject("/tsconfig.json")!; + assert.equal((await project.program.getSemanticDiagnostics(fileName)).length, 1); + + fs.writeFile!(fileName, `export const value: string = "valid";`); + const newProgram = await api.createProgram( + project.parsedCommandLine.fileNames, + { + compilerOptions: project.parsedCommandLine.options, + ...(project.parsedCommandLine.projectReferences + ? { projectReferences: project.parsedCommandLine.projectReferences } + : {}), + }, + project.program, + { changed: [fileName] }, + ); + + assert.equal((await newProgram.getSemanticDiagnostics(fileName)).length, 0); + assert.equal((await project.program.getSemanticDiagnostics(fileName)).length, 1); + await newProgram.dispose(); + } + finally { + await api.close(); + } + }); + + test("createProgram rejects file changes without an old program", async () => { + const api = spawnAPI({ "/src/index.ts": `export const value = 1;` }); + try { + const createWithChanges = () => api.createProgram(["/src/index.ts"], { compilerOptions: { noLib: true } }, undefined, { changed: ["/src/index.ts"] }); + await assert.rejects(createWithChanges, /fileChanges requires an oldProgram/); // @sync: assert.throws(createWithChanges, /fileChanges requires an oldProgram/); + } + finally { + await api.close(); + } + }); + test("parseConfigFile", async () => { const api = spawnAPI(); try { diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index 6974061a4a6fb..f8c6bf54db7ca 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -305,6 +305,236 @@ describe("API", () => { } }); + test("createProgram", () => { + const api = spawnAPI({ + "/src/index.ts": `export const value: string = 1;`, + }); + try { + const program = api.createProgram(["/src/index.ts"], { compilerOptions: { noLib: true, strict: true } }); + + assert.deepEqual(program.getCompilerOptions(), { noLib: true, strict: true }); + assert.deepEqual(program.getSourceFileNames(), ["/src/index.ts"]); + assert.equal((program.getSemanticDiagnostics("/src/index.ts")).length, 1); + + program.dispose(); + assert.throws(() => program.getSourceFileNames(), /snapshot .* not found/); + } + finally { + api.close(); + } + }); + + test("createProgram ignores an on-disk tsconfig", () => { + const api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ + compilerOptions: { noLib: false, strict: false }, + files: ["src/from-config.ts"], + }), + "/src/index.ts": `export const explicitRoot = 1;`, + "/src/from-config.ts": `export const configRoot = 1;`, + }); + try { + const program = api.createProgram( + ["/src/index.ts"], + { compilerOptions: { noLib: true, strict: true } }, + ); + + assert.deepEqual(program.getCompilerOptions(), { noLib: true, strict: true }); + assert.deepEqual(program.getSourceFileNames(), ["/src/index.ts"]); + assert.deepEqual(program.getConfigFileNames(), []); + assert.equal(program.getSourceFile("/src/from-config.ts"), undefined); + program.dispose(); + } + finally { + api.close(); + } + }); + + test("createProgram includes project references", () => { + const reference = { path: "/lib/tsconfig.json", originalPath: "/lib/tsconfig.json", circular: false }; + const api = spawnAPI({ + "/src/index.ts": `export const value = 1;`, + "/lib/tsconfig.json": JSON.stringify({ compilerOptions: { composite: true, noLib: true }, files: ["index.ts"] }), + "/lib/index.ts": `export const lib = 1;`, + }); + try { + const program = api.createProgram( + ["/src/index.ts"], + { compilerOptions: { noLib: true }, projectReferences: [reference] }, + ); + assert.deepEqual(program.getProject().parsedCommandLine.projectReferences, [reference]); + program.dispose(); + } + finally { + api.close(); + } + }); + + test("createProgram includes config file parsing diagnostics", () => { + const diagnostic = { + pos: 0, + end: 0, + code: 9001, + category: DiagnosticCategory.Error, + text: "Synthetic config parsing error.", + }; + const api = spawnAPI({ "/src/index.ts": `export const value = 1;` }); + try { + const program = api.createProgram( + ["/src/index.ts"], + { + compilerOptions: { noLib: true }, + configFileParsingDiagnostics: [diagnostic], + }, + ); + + assert.deepEqual(program.getConfigFileParsingDiagnostics(), [diagnostic]); + program.dispose(); + } + finally { + api.close(); + } + }); + + test("createProgram updates roots when given an old program", () => { + const options = { compilerOptions: { noLib: true } }; + const api = spawnAPI({ + "/src/a.ts": `export const a = 1;`, + "/src/b.ts": `export const b = 1;`, + "/src/c.ts": `export const c = 1;`, + }); + try { + const oldProgram = api.createProgram(["/src/a.ts", "/src/b.ts"], options); + const newProgram = api.createProgram(["/src/a.ts", "/src/c.ts"], options, oldProgram); + assert.deepEqual(newProgram.getSourceFileNames(), ["/src/a.ts", "/src/c.ts"]); + assert.deepEqual(oldProgram.getSourceFileNames(), ["/src/a.ts", "/src/b.ts"]); + + newProgram.dispose(); + oldProgram.dispose(); + } + finally { + api.close(); + } + }); + + test("createProgram discovers imported non-root dependencies", () => { + const api = spawnAPI({ + "/src/main.ts": `import { dependency } from "./dependency"; export const value = dependency;`, + "/src/dependency.ts": `export const dependency = 1;`, + }); + try { + const program = api.createProgram(["/src/main.ts"], { compilerOptions: { noLib: true } }); + assert.deepEqual([...program.getSourceFileNames()].sort(), ["/src/dependency.ts", "/src/main.ts"]); + + program.dispose(); + } + finally { + api.close(); + } + }); + + test("createProgram updates an old program with file changes", () => { + const fileName = "/src/index.ts"; + const options = { compilerOptions: { noLib: true, strict: true } }; + const { api, fs } = spawnAPIWithFS({ + [fileName]: `export const value: string = 1;`, + }); + try { + const oldProgram = api.createProgram([fileName], options); + assert.equal((oldProgram.getSemanticDiagnostics(fileName)).length, 1); + + fs.writeFile!(fileName, `export const value: string = "valid";`); + const newProgram = api.createProgram( + [fileName], + options, + oldProgram, + { changed: [fileName] }, + ); + + assert.equal((newProgram.getSemanticDiagnostics(fileName)).length, 0); + assert.equal((oldProgram.getSemanticDiagnostics(fileName)).length, 1); + + newProgram.dispose(); + oldProgram.dispose(); + } + finally { + api.close(); + } + }); + + test("createProgram updates an old program with invalidateAll", () => { + const fileName = "/src/index.ts"; + const options = { compilerOptions: { noLib: true, strict: true } }; + const { api, fs } = spawnAPIWithFS({ + [fileName]: `export const value: string = 1;`, + }); + try { + const oldProgram = api.createProgram([fileName], options); + assert.equal((oldProgram.getSemanticDiagnostics(fileName)).length, 1); + + fs.writeFile!(fileName, `export const value: string = "valid";`); + const newProgram = api.createProgram( + [fileName], + options, + oldProgram, + { invalidateAll: true }, + ); + + assert.equal((newProgram.getSemanticDiagnostics(fileName)).length, 0); + assert.equal((oldProgram.getSemanticDiagnostics(fileName)).length, 1); + + newProgram.dispose(); + oldProgram.dispose(); + } + finally { + api.close(); + } + }); + + test("createProgram accepts a regular project program as the old program", () => { + const fileName = "/src/index.ts"; + const { api, fs } = spawnAPIWithFS({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, strict: true } }), + [fileName]: `export const value: string = 1;`, + }); + try { + const snapshot = api.updateSnapshot({ openProject: "/tsconfig.json" }); + const project = snapshot.getProject("/tsconfig.json")!; + assert.equal((project.program.getSemanticDiagnostics(fileName)).length, 1); + + fs.writeFile!(fileName, `export const value: string = "valid";`); + const newProgram = api.createProgram( + project.parsedCommandLine.fileNames, + { + compilerOptions: project.parsedCommandLine.options, + ...(project.parsedCommandLine.projectReferences + ? { projectReferences: project.parsedCommandLine.projectReferences } + : {}), + }, + project.program, + { changed: [fileName] }, + ); + + assert.equal((newProgram.getSemanticDiagnostics(fileName)).length, 0); + assert.equal((project.program.getSemanticDiagnostics(fileName)).length, 1); + newProgram.dispose(); + } + finally { + api.close(); + } + }); + + test("createProgram rejects file changes without an old program", () => { + const api = spawnAPI({ "/src/index.ts": `export const value = 1;` }); + try { + const createWithChanges = () => api.createProgram(["/src/index.ts"], { compilerOptions: { noLib: true } }, undefined, { changed: ["/src/index.ts"] }); + assert.throws(createWithChanges, /fileChanges requires an oldProgram/); + } + finally { + api.close(); + } + }); + test("parseConfigFile", () => { const api = spawnAPI(); try { diff --git a/tsc/internal/api/proto.go b/tsc/internal/api/proto.go index 45793d96a61be..f295217958e22 100644 --- a/tsc/internal/api/proto.go +++ b/tsc/internal/api/proto.go @@ -64,6 +64,7 @@ const ( MethodInitialize Method = "initialize" MethodUpdateSnapshot Method = "updateSnapshot" MethodUpdateTemporarySnapshot Method = "updateTemporarySnapshot" + MethodCreateProgram Method = "createProgram" MethodParseCommandLine Method = "parseCommandLine" MethodReadConfigFile Method = "readConfigFile" MethodParseJsonConfigFile Method = "parseJsonConfigFileContent" @@ -362,11 +363,34 @@ type UpdateSnapshotParams struct { // snapshot that overrides a single file's content. type UpdateTemporarySnapshotParams struct { // Snapshot is the current client snapshot on which to layer the temporary update. - Snapshot SnapshotID `json:"snapshot"` + Snapshot SnapshotID `json:"snapshot,omitempty"` // File identifies the file whose content is temporarily overridden. File DocumentIdentifier `json:"file"` // NewText is the temporary content for the file. - NewText string `json:"newText"` + NewText string `json:"newText,omitempty"` +} + +type CreateProgramParams struct { + RootFiles []DocumentIdentifier `json:"rootFiles"` + CreateProgramOptions CreateProgramOptions `json:"createProgramOptions"` + OldProgram *CreateProgramOldProgramParams `json:"oldProgram,omitempty"` + FileChanges *APIFileChanges `json:"fileChanges,omitempty"` +} + +type CreateProgramOptions struct { + CompilerOptions core.CompilerOptions `json:"compilerOptions"` + ProjectReferences []*core.ProjectReference `json:"projectReferences,omitempty"` + ConfigFileParsingDiagnostics []*DiagnosticResponse `json:"configFileParsingDiagnostics,omitempty"` +} + +type CreateProgramOldProgramParams struct { + Snapshot SnapshotID `json:"snapshot,omitempty"` + Project ProjectID `json:"project,omitempty"` +} + +type CreateProgramResponse struct { + Snapshot SnapshotID `json:"snapshot"` + Project *ProjectResponse `json:"project"` } // ProjectFileChanges describes what source files changed within a single project. @@ -405,6 +429,7 @@ var unmarshalers = map[Method]func([]byte) (any, error){ MethodInitialize: noParams, MethodUpdateSnapshot: unmarshallerFor[UpdateSnapshotParams], MethodUpdateTemporarySnapshot: unmarshallerFor[UpdateTemporarySnapshotParams], + MethodCreateProgram: unmarshallerFor[CreateProgramParams], MethodParseCommandLine: unmarshallerFor[ParseCommandLineParams], MethodReadConfigFile: unmarshallerFor[ReadConfigFileParams], MethodParseJsonConfigFile: unmarshallerFor[ParseJsonConfigFileContentParams], @@ -1476,6 +1501,20 @@ func NewDiagnosticResponse(d *ast.Diagnostic) *DiagnosticResponse { return resp } +func (d *DiagnosticResponse) ToDiagnostic() *ast.Diagnostic { + return ast.NewDiagnosticFromText( + nil, + core.NewTextRange(d.Pos, d.End), + d.Code, + d.Category, + d.Text, + core.Map(d.MessageChain, func(d *DiagnosticResponse) *ast.Diagnostic { return d.ToDiagnostic() }), + core.Map(d.RelatedInformation, func(d *DiagnosticResponse) *ast.Diagnostic { return d.ToDiagnostic() }), + d.ReportsUnnecessary, + d.ReportsDeprecated, + ) +} + // NewDiagnosticResponses converts a slice of ast.Diagnostics to DiagnosticResponses. func NewDiagnosticResponses(diags []*ast.Diagnostic) []*DiagnosticResponse { if len(diags) == 0 { diff --git a/tsc/internal/api/session.go b/tsc/internal/api/session.go index d5c668385b849..3ad43d68b9cf0 100644 --- a/tsc/internal/api/session.go +++ b/tsc/internal/api/session.go @@ -613,6 +613,8 @@ func (s *Session) HandleRequest(ctx context.Context, method string, params json. return s.handleReadConfigFile(ctx, parsed.(*ReadConfigFileParams)) case string(MethodParseJsonConfigFile): return s.handleParseJsonConfigFileContent(ctx, parsed.(*ParseJsonConfigFileContentParams)) + case string(MethodCreateProgram): + return s.handleCreateProgram(ctx, parsed.(*CreateProgramParams)) case string(MethodParseConfigFile): return s.handleParseConfigFile(ctx, parsed.(*ParseConfigFileParams)) case string(MethodTranspileModule): @@ -1128,6 +1130,73 @@ func (s *Session) handleUpdateTemporarySnapshot(ctx context.Context, params *Upd }, nil } +func (s *Session) handleCreateProgram(ctx context.Context, params *CreateProgramParams) (*CreateProgramResponse, error) { + if params.FileChanges != nil && params.OldProgram == nil { + return nil, fmt.Errorf("%w: fileChanges requires an oldProgram", ErrClientError) + } + + rootFileNames := make([]string, len(params.RootFiles)) + for i, rootFile := range params.RootFiles { + rootFileNames[i] = rootFile.ToAbsoluteFileName(s.projectSession.GetCurrentDirectory()) + } + + var oldSnapshot *project.Snapshot + var oldProject *project.Project + if params.OldProgram != nil { + oldSnapshotID := params.OldProgram.Snapshot + oldSD, err := s.retainSnapshotData(oldSnapshotID) + if err != nil { + return nil, err + } + defer func() { _ = s.releaseSnapshot(oldSnapshotID) }() + + oldSnapshot = oldSD.snapshot + oldProject, err = oldSD.getProject(params.OldProgram.Project) + if err != nil { + return nil, err + } + } + + snapshot := s.projectSession.APICreateProgram( + ctx, + rootFileNames, + ¶ms.CreateProgramOptions.CompilerOptions, + params.CreateProgramOptions.ProjectReferences, + core.Map(params.CreateProgramOptions.ConfigFileParsingDiagnostics, func(d *DiagnosticResponse) *ast.Diagnostic { return d.ToDiagnostic() }), + oldSnapshot, + oldProject, + s.toFileChangeSummary(params.FileChanges), + ) + project := snapshot.ProjectCollection.InferredProject() + if project == nil { + snapshot.Deref(s.projectSession) + return nil, fmt.Errorf("%w: failed to create synthetic project", ErrClientError) + } + + handle := snapshotHandle(snapshot) + s.snapshotsMu.Lock() + if sd, exists := s.snapshots[handle]; exists { + // Same snapshot already stored: use the existing retained ref and only bump API refcount. + snapshot.Deref(s.projectSession) + sd.refCount++ + } else { + sd = &snapshotData{ + snapshot: snapshot, + refCount: 1, + symbolRegistry: make(map[SymbolID]*ast.Symbol), + symbolCanonicalProjects: make(map[SymbolID]ProjectID), + projectRegistries: make(map[ProjectID]*projectRegistryData), + } + s.snapshots[handle] = sd + } + s.snapshotsMu.Unlock() + + return &CreateProgramResponse{ + Snapshot: handle, + Project: NewProjectResponse(project), + }, nil +} + // handleRelease decrements the ref count for a snapshot. // The snapshot and its registries are only cleaned up when the ref count reaches zero. func (s *Session) handleRelease(ctx context.Context, params *ReleaseParams) (any, error) { diff --git a/tsc/internal/api/session_createprogram_test.go b/tsc/internal/api/session_createprogram_test.go new file mode 100644 index 0000000000000..be053641bef9f --- /dev/null +++ b/tsc/internal/api/session_createprogram_test.go @@ -0,0 +1,459 @@ +package api + +import ( + "context" + "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" + "github.com/microsoft/TypeScript/tsc/internal/tspath" + "gotest.tools/v3/assert" +) + +func TestCreateProgram(t *testing.T) { + t.Parallel() + + const fileName = "/home/projects/p/index.ts" + projectSession, sessionUtils := projecttestutil.Setup(map[string]any{ + fileName: `export const value: string = 1;`, + }) + defer projectSession.Close() + + session := NewSession(projectSession, nil) + defer session.Close() + ctx := context.Background() + + baseResponse, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{}) + assert.NilError(t, err) + projectSession.DidOpenFile( + ctx, + DocumentIdentifier{FileName: fileName}.ToURI(projectSession.GetCurrentDirectory()), + 1, + `export const value: string = "valid overlay";`, + lsproto.LanguageKindTypeScript, + ) + + response, err := session.handleCreateProgram(ctx, &CreateProgramParams{ + RootFiles: []DocumentIdentifier{{FileName: fileName}}, + CreateProgramOptions: CreateProgramOptions{ + CompilerOptions: core.CompilerOptions{ + NoLib: core.TSTrue, + Strict: core.TSTrue, + }, + }, + }) + assert.NilError(t, err) + assert.Assert(t, response.Snapshot != baseResponse.Snapshot) + assert.Equal(t, session.latestSnapshot, baseResponse.Snapshot) + assert.Assert(t, response.Project != nil) + assert.DeepEqual(t, response.Project.RootFiles, []string{fileName}) + assert.Equal(t, response.Project.CompilerOptions.Strict, core.TSTrue) + + snapshot, err := session.getSnapshotData(response.Snapshot) + assert.NilError(t, err) + assert.Equal(t, len(snapshot.snapshot.ProjectCollection.Projects()), 1) + + diagnostics, err := session.handleGetSemanticDiagnostics(ctx, &GetDiagnosticsParams{ + Snapshot: response.Snapshot, + Project: response.Project.Id, + Files: []DocumentIdentifier{{FileName: fileName}}, + }) + assert.NilError(t, err) + assert.Equal(t, len(diagnostics), 0) + + assert.NilError(t, sessionUtils.FS().WriteFile(fileName, `export const value: string = "valid on disk";`)) + updatedResponse, err := session.handleCreateProgram(ctx, &CreateProgramParams{ + RootFiles: []DocumentIdentifier{{FileName: fileName}}, + CreateProgramOptions: CreateProgramOptions{ + CompilerOptions: core.CompilerOptions{ + NoLib: core.TSTrue, + Strict: core.TSTrue, + }, + }, + OldProgram: &CreateProgramOldProgramParams{ + Snapshot: response.Snapshot, + Project: response.Project.Id, + }, + FileChanges: &APIFileChanges{ + Changed: []DocumentIdentifier{{FileName: fileName}}, + }, + }) + assert.NilError(t, err) + updatedSnapshot, err := session.getSnapshotData(updatedResponse.Snapshot) + assert.NilError(t, err) + updatedProject := updatedSnapshot.snapshot.ProjectCollection.InferredProject() + assert.Assert(t, updatedProject != nil) + + updatedDiagnostics, err := session.handleGetSemanticDiagnostics(ctx, &GetDiagnosticsParams{ + Snapshot: updatedResponse.Snapshot, + Project: updatedResponse.Project.Id, + Files: []DocumentIdentifier{{FileName: fileName}}, + }) + assert.NilError(t, err) + assert.Equal(t, len(updatedDiagnostics), 0) + + oldDiagnostics, err := session.handleGetSemanticDiagnostics(ctx, &GetDiagnosticsParams{ + Snapshot: response.Snapshot, + Project: response.Project.Id, + Files: []DocumentIdentifier{{FileName: fileName}}, + }) + assert.NilError(t, err) + assert.Equal(t, len(oldDiagnostics), 0) + + _, err = session.handleRelease(ctx, &ReleaseParams{Snapshot: updatedResponse.Snapshot}) + assert.NilError(t, err) + _, err = session.handleRelease(ctx, &ReleaseParams{Snapshot: response.Snapshot}) + assert.NilError(t, err) + _, err = session.getSnapshotData(response.Snapshot) + assert.ErrorContains(t, err, "not found") + _, err = session.getSnapshotData(baseResponse.Snapshot) + assert.NilError(t, err) +} + +func TestCreateProgramWithNoRootFiles(t *testing.T) { + t.Parallel() + + projectSession, _ := projecttestutil.Setup(map[string]any{}) + defer projectSession.Close() + + session := NewSession(projectSession, nil) + defer session.Close() + + response, err := session.handleCreateProgram(context.Background(), &CreateProgramParams{ + CreateProgramOptions: CreateProgramOptions{ + CompilerOptions: core.CompilerOptions{NoLib: core.TSTrue}, + }, + }) + assert.NilError(t, err) + assert.Assert(t, response.Project != nil) + assert.Equal(t, len(response.Project.RootFiles), 0) + + snapshot, err := session.getSnapshotData(response.Snapshot) + assert.NilError(t, err) + project := snapshot.snapshot.ProjectCollection.InferredProject() + assert.Assert(t, project != nil) + assert.Assert(t, project.Program != nil) + assert.Equal(t, len(project.Program.GetSourceFiles()), 0) +} + +func TestCreateProgramRemovesAllRootFiles(t *testing.T) { + t.Parallel() + + const fileName = "/home/projects/p/index.ts" + projectSession, _ := projecttestutil.Setup(map[string]any{ + fileName: "export {};", + }) + defer projectSession.Close() + + session := NewSession(projectSession, nil) + defer session.Close() + ctx := context.Background() + + oldResponse, err := session.handleCreateProgram(ctx, &CreateProgramParams{ + RootFiles: []DocumentIdentifier{{FileName: fileName}}, + CreateProgramOptions: CreateProgramOptions{ + CompilerOptions: core.CompilerOptions{NoLib: core.TSTrue}, + }, + }) + assert.NilError(t, err) + + response, err := session.handleCreateProgram(ctx, &CreateProgramParams{ + CreateProgramOptions: CreateProgramOptions{ + CompilerOptions: core.CompilerOptions{NoLib: core.TSTrue}, + }, + OldProgram: &CreateProgramOldProgramParams{ + Snapshot: oldResponse.Snapshot, + Project: oldResponse.Project.Id, + }, + FileChanges: &APIFileChanges{ + Changed: []DocumentIdentifier{{FileName: fileName}}, + }, + }) + assert.NilError(t, err) + assert.Assert(t, response.Project != nil) + assert.Equal(t, len(response.Project.RootFiles), 0) + + snapshot, err := session.getSnapshotData(response.Snapshot) + assert.NilError(t, err) + project := snapshot.snapshot.ProjectCollection.InferredProject() + assert.Assert(t, project != nil) + assert.Assert(t, project.Program != nil) + assert.Equal(t, len(project.Program.GetSourceFiles()), 0) +} + +func TestCreateProgramPreservesRootFileOrder(t *testing.T) { + t.Parallel() + + const ( + fileA = "/home/projects/p/a.ts" + fileB = "/home/projects/p/b.ts" + ) + projectSession, _ := projecttestutil.Setup(map[string]any{ + fileA: "export const a = 1;", + fileB: "export const b = 1;", + }) + defer projectSession.Close() + + session := NewSession(projectSession, nil) + defer session.Close() + ctx := context.Background() + + oldResponse, err := session.handleCreateProgram(ctx, &CreateProgramParams{ + RootFiles: []DocumentIdentifier{{FileName: fileB}, {FileName: fileA}}, + CreateProgramOptions: CreateProgramOptions{ + CompilerOptions: core.CompilerOptions{NoLib: core.TSTrue}, + }, + }) + assert.NilError(t, err) + assert.DeepEqual(t, oldResponse.Project.RootFiles, []string{fileB, fileA}) + + response, err := session.handleCreateProgram(ctx, &CreateProgramParams{ + RootFiles: []DocumentIdentifier{{FileName: fileA}, {FileName: fileB}}, + CreateProgramOptions: CreateProgramOptions{ + CompilerOptions: core.CompilerOptions{NoLib: core.TSTrue}, + }, + OldProgram: &CreateProgramOldProgramParams{ + Snapshot: oldResponse.Snapshot, + Project: oldResponse.Project.Id, + }, + }) + assert.NilError(t, err) + assert.DeepEqual(t, response.Project.RootFiles, []string{fileA, fileB}) + + snapshot, err := session.getSnapshotData(response.Snapshot) + assert.NilError(t, err) + assert.Equal(t, snapshot.snapshot.ProjectCollection.InferredProject().ProgramUpdateKind, project.ProgramUpdateKindSameFileNames) +} + +func TestCreateProgramReusesProgram(t *testing.T) { + t.Parallel() + + const fileName = "/home/projects/p/index.ts" + projectSession, sessionUtils := projecttestutil.Setup(map[string]any{ + fileName: `export const value: string = 1;`, + }) + defer projectSession.Close() + + session := NewSession(projectSession, nil) + defer session.Close() + ctx := context.Background() + + oldResponse, err := session.handleCreateProgram(ctx, &CreateProgramParams{ + RootFiles: []DocumentIdentifier{{FileName: fileName}}, + CreateProgramOptions: CreateProgramOptions{ + CompilerOptions: core.CompilerOptions{ + NoLib: core.TSTrue, + Strict: core.TSTrue, + }, + }, + }) + assert.NilError(t, err) + + assert.NilError(t, sessionUtils.FS().WriteFile(fileName, `export const value: string = "valid";`)) + updatedResponse, err := session.handleCreateProgram(ctx, &CreateProgramParams{ + RootFiles: []DocumentIdentifier{{FileName: fileName}}, + CreateProgramOptions: CreateProgramOptions{ + CompilerOptions: core.CompilerOptions{ + NoLib: core.TSTrue, + Strict: core.TSTrue, + }, + }, + OldProgram: &CreateProgramOldProgramParams{ + Snapshot: oldResponse.Snapshot, + Project: oldResponse.Project.Id, + }, + FileChanges: &APIFileChanges{ + Changed: []DocumentIdentifier{{FileName: fileName}}, + }, + }) + assert.NilError(t, err) + + updatedSnapshot, err := session.getSnapshotData(updatedResponse.Snapshot) + assert.NilError(t, err) + updatedProject := updatedSnapshot.snapshot.ProjectCollection.InferredProject() + assert.Assert(t, updatedProject != nil) + assert.Equal(t, updatedProject.ProgramUpdateKind, project.ProgramUpdateKindCloned) + + changedOptionsResponse, err := session.handleCreateProgram(ctx, &CreateProgramParams{ + RootFiles: []DocumentIdentifier{{FileName: fileName}}, + CreateProgramOptions: CreateProgramOptions{ + CompilerOptions: core.CompilerOptions{ + NoLib: core.TSTrue, + Strict: core.TSFalse, + }, + }, + OldProgram: &CreateProgramOldProgramParams{ + Snapshot: oldResponse.Snapshot, + Project: oldResponse.Project.Id, + }, + }) + assert.NilError(t, err) + changedOptionsSnapshot, err := session.getSnapshotData(changedOptionsResponse.Snapshot) + assert.NilError(t, err) + changedOptionsProject := changedOptionsSnapshot.snapshot.ProjectCollection.InferredProject() + assert.Assert(t, changedOptionsProject != nil) + assert.Equal(t, changedOptionsProject.CommandLine.CompilerOptions().Strict, core.TSFalse) + assert.Equal(t, changedOptionsProject.ProgramUpdateKind, project.ProgramUpdateKindSameFileNames) +} + +func TestCreateProgramProjectReferencesAndReuse(t *testing.T) { + t.Parallel() + + const ( + fileName = "/home/projects/app/index.ts" + libConfigName = "/home/projects/lib/tsconfig.json" + otherConfigName = "/home/projects/other/tsconfig.json" + ) + projectSession, sessionUtils := projecttestutil.Setup(map[string]any{ + fileName: `export const value: string = 1;`, + libConfigName: `{ "compilerOptions": { "composite": true, "noLib": true }, "files": ["index.ts"] }`, + "/home/projects/lib/index.ts": `export const lib = 1;`, + otherConfigName: `{ "compilerOptions": { "composite": true, "noLib": true }, "files": ["index.ts"] }`, + "/home/projects/other/index.ts": `export const other = 1;`, + }) + defer projectSession.Close() + + session := NewSession(projectSession, nil) + defer session.Close() + ctx := context.Background() + libReference := &core.ProjectReference{Path: libConfigName, OriginalPath: libConfigName} + + oldResponse, err := session.handleCreateProgram(ctx, &CreateProgramParams{ + RootFiles: []DocumentIdentifier{{FileName: fileName}}, + CreateProgramOptions: CreateProgramOptions{ + CompilerOptions: core.CompilerOptions{NoLib: core.TSTrue, Strict: core.TSTrue}, + ProjectReferences: []*core.ProjectReference{libReference}, + }, + }) + assert.NilError(t, err) + assert.DeepEqual(t, oldResponse.Project.ParsedCommandLine.ProjectReferences, []*core.ProjectReference{libReference}) + oldSnapshot, err := session.getSnapshotData(oldResponse.Snapshot) + assert.NilError(t, err) + resolvedReferences := oldSnapshot.snapshot.ProjectCollection.InferredProject().Program.GetResolvedProjectReferences() + assert.Equal(t, len(resolvedReferences), 1) + assert.Equal(t, resolvedReferences[0].ConfigName(), libConfigName) + + assert.NilError(t, sessionUtils.FS().WriteFile(fileName, `export const value: string = "valid";`)) + equivalentLibReference := &core.ProjectReference{Path: libConfigName, OriginalPath: "../lib"} + reusedResponse, err := session.handleCreateProgram(ctx, &CreateProgramParams{ + RootFiles: []DocumentIdentifier{{FileName: fileName}}, + CreateProgramOptions: CreateProgramOptions{ + CompilerOptions: core.CompilerOptions{NoLib: core.TSTrue, Strict: core.TSTrue}, + ProjectReferences: []*core.ProjectReference{equivalentLibReference}, + }, + OldProgram: &CreateProgramOldProgramParams{ + Snapshot: oldResponse.Snapshot, + Project: oldResponse.Project.Id, + }, + FileChanges: &APIFileChanges{Changed: []DocumentIdentifier{{FileName: fileName}}}, + }) + assert.NilError(t, err) + reusedSnapshot, err := session.getSnapshotData(reusedResponse.Snapshot) + assert.NilError(t, err) + assert.Equal(t, reusedSnapshot.snapshot.ProjectCollection.InferredProject().ProgramUpdateKind, project.ProgramUpdateKindCloned) + + otherReference := &core.ProjectReference{Path: otherConfigName, OriginalPath: otherConfigName} + changedResponse, err := session.handleCreateProgram(ctx, &CreateProgramParams{ + RootFiles: []DocumentIdentifier{{FileName: fileName}}, + CreateProgramOptions: CreateProgramOptions{ + CompilerOptions: core.CompilerOptions{NoLib: core.TSTrue, Strict: core.TSTrue}, + ProjectReferences: []*core.ProjectReference{otherReference}, + }, + OldProgram: &CreateProgramOldProgramParams{ + Snapshot: oldResponse.Snapshot, + Project: oldResponse.Project.Id, + }, + }) + assert.NilError(t, err) + changedSnapshot, err := session.getSnapshotData(changedResponse.Snapshot) + assert.NilError(t, err) + changedProject := changedSnapshot.snapshot.ProjectCollection.InferredProject() + assert.Equal(t, changedProject.ProgramUpdateKind, project.ProgramUpdateKindSameFileNames) + assert.DeepEqual(t, changedProject.CommandLine.ProjectReferences(), []*core.ProjectReference{otherReference}) +} + +func TestCreateProgramFromConfiguredProgramDoesNotRetainOtherProjects(t *testing.T) { + t.Parallel() + + const ( + configFileName = "/home/projects/p/tsconfig.json" + fileName = "/home/projects/p/index.ts" + otherConfigFileName = "/home/projects/other/tsconfig.json" + otherFileName = "/home/projects/other/index.ts" + ) + projectSession, sessionUtils := projecttestutil.Setup(map[string]any{ + configFileName: `{ "compilerOptions": { "noLib": true, "strict": true }, "files": ["index.ts"] }`, + fileName: `export const value: string = 1;`, + otherConfigFileName: `{ "files": ["index.ts"] }`, + otherFileName: `export const other = 1;`, + }) + defer projectSession.Close() + + session := NewSession(projectSession, nil) + defer session.Close() + ctx := context.Background() + + baseResponse, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ + OpenProjects: []DocumentIdentifier{{FileName: configFileName}, {FileName: otherConfigFileName}}, + }) + assert.NilError(t, err) + var baseProject *ProjectResponse + for _, candidate := range baseResponse.Projects { + if candidate.ConfigFileName == configFileName { + baseProject = candidate + break + } + } + assert.Assert(t, baseProject != nil) + rootFiles := make([]DocumentIdentifier, len(baseProject.RootFiles)) + for i, rootFile := range baseProject.RootFiles { + rootFiles[i] = DocumentIdentifier{FileName: rootFile} + } + + assert.NilError(t, sessionUtils.FS().WriteFile(fileName, `export const value: string = "valid";`)) + updatedResponse, err := session.handleCreateProgram(ctx, &CreateProgramParams{ + RootFiles: rootFiles, + CreateProgramOptions: CreateProgramOptions{ + CompilerOptions: core.CompilerOptions{ + NoLib: core.TSTrue, + Strict: core.TSTrue, + }, + }, + OldProgram: &CreateProgramOldProgramParams{ + Snapshot: baseResponse.Snapshot, + Project: baseProject.Id, + }, + FileChanges: &APIFileChanges{ + Changed: []DocumentIdentifier{{FileName: fileName}}, + }, + }) + assert.NilError(t, err) + + updatedSnapshot, err := session.getSnapshotData(updatedResponse.Snapshot) + assert.NilError(t, err) + assert.Equal(t, len(updatedSnapshot.snapshot.ProjectCollection.Projects()), 1) + assert.Equal(t, len(updatedSnapshot.snapshot.ProjectCollection.ConfiguredProjects()), 0) + assert.Assert(t, updatedSnapshot.snapshot.ConfigFileRegistry.GetConfig(tspath.Path(otherConfigFileName)) == nil) + updatedProject := updatedSnapshot.snapshot.ProjectCollection.InferredProject() + assert.Assert(t, updatedProject != nil) + assert.Equal(t, updatedProject.ProgramUpdateKind, project.ProgramUpdateKindSameFileNames) + updatedDiagnostics, err := session.handleGetSemanticDiagnostics(ctx, &GetDiagnosticsParams{ + Snapshot: updatedResponse.Snapshot, + Project: updatedResponse.Project.Id, + Files: []DocumentIdentifier{{FileName: fileName}}, + }) + assert.NilError(t, err) + assert.Equal(t, len(updatedDiagnostics), 0) + + _, err = session.handleRelease(ctx, &ReleaseParams{Snapshot: updatedResponse.Snapshot}) + assert.NilError(t, err) + baseDiagnostics, err := session.handleGetSemanticDiagnostics(ctx, &GetDiagnosticsParams{ + Snapshot: baseResponse.Snapshot, + Project: baseProject.Id, + Files: []DocumentIdentifier{{FileName: fileName}}, + }) + assert.NilError(t, err) + assert.Equal(t, len(baseDiagnostics), 1) +} diff --git a/tsc/internal/ast/diagnostic.go b/tsc/internal/ast/diagnostic.go index d862dc35172d8..1056e22873ca3 100644 --- a/tsc/internal/ast/diagnostic.go +++ b/tsc/internal/ast/diagnostic.go @@ -191,6 +191,30 @@ func NewDiagnosticFromSerialized( } } +func NewDiagnosticFromText( + file *SourceFile, + loc core.TextRange, + code int32, + category diagnostics.Category, + text string, + messageChain []*Diagnostic, + relatedInformation []*Diagnostic, + reportsUnnecessary bool, + reportsDeprecated bool, +) *Diagnostic { + return &Diagnostic{ + file: file, + loc: loc, + code: code, + category: category, + message: diagnostics.NewAdHocMessage(text), + messageChain: messageChain, + relatedInformation: relatedInformation, + reportsUnnecessary: reportsUnnecessary, + reportsDeprecated: reportsDeprecated, + } +} + func NewDiagnostic(file *SourceFile, loc core.TextRange, message *diagnostics.Message, args ...any) *Diagnostic { return &Diagnostic{ file: file, diff --git a/tsc/internal/compiler/projectreferencefilemapper.go b/tsc/internal/compiler/projectreferencefilemapper.go index 9b4bfd2593fc7..7f2ef37141c91 100644 --- a/tsc/internal/compiler/projectreferencefilemapper.go +++ b/tsc/internal/compiler/projectreferencefilemapper.go @@ -25,6 +25,13 @@ type projectReferenceFileMapper struct { realpathDtsToSource collections.SyncMap[tspath.Path, *tsoptions.SourceOutputAndProjectReference] } +func (mapper *projectReferenceFileMapper) rootConfigPath() tspath.Path { + if mapper.opts.Config.ConfigFile == nil { + return "" + } + return mapper.opts.Config.ConfigFile.SourceFile.Path() +} + func (mapper *projectReferenceFileMapper) getParseFileRedirect(file ast.HasFileName) string { if mapper.opts.canUseProjectReferenceSource() { // Map to source file from project reference @@ -46,10 +53,7 @@ func (mapper *projectReferenceFileMapper) getParseFileRedirect(file ast.HasFileN } func (mapper *projectReferenceFileMapper) getResolvedProjectReferences() []*tsoptions.ParsedCommandLine { - if mapper.opts.Config.ConfigFile == nil { - return nil - } - refs, ok := mapper.referencesInConfigFile[mapper.opts.Config.ConfigFile.SourceFile.Path()] + refs, ok := mapper.referencesInConfigFile[mapper.rootConfigPath()] var result []*tsoptions.ParsedCommandLine if ok { result = make([]*tsoptions.ParsedCommandLine, 0, len(refs)) @@ -112,12 +116,13 @@ func (mapper *projectReferenceFileMapper) getResolvedReferenceFor(path tspath.Pa func (mapper *projectReferenceFileMapper) rangeResolvedProjectReference( f func(path tspath.Path, config *tsoptions.ParsedCommandLine, parent *tsoptions.ParsedCommandLine, index int) bool, ) bool { - if mapper.opts.Config.ConfigFile == nil { + if len(mapper.opts.Config.ProjectReferences()) == 0 { return false } seenRef := collections.NewSetWithSizeHint[tspath.Path](len(mapper.referencesInConfigFile)) - seenRef.Add(mapper.opts.Config.ConfigFile.SourceFile.Path()) - refs := mapper.referencesInConfigFile[mapper.opts.Config.ConfigFile.SourceFile.Path()] + rootConfigPath := mapper.rootConfigPath() + seenRef.Add(rootConfigPath) + refs := mapper.referencesInConfigFile[rootConfigPath] return mapper.rangeResolvedReferenceWorker(refs, f, mapper.opts.Config, seenRef) } diff --git a/tsc/internal/compiler/projectreferenceparser.go b/tsc/internal/compiler/projectreferenceparser.go index f4639b4076bce..9bf23abed9d7b 100644 --- a/tsc/internal/compiler/projectreferenceparser.go +++ b/tsc/internal/compiler/projectreferenceparser.go @@ -73,7 +73,7 @@ func (p *projectReferenceParser) initMapper(tasks []*projectReferenceParseTask) p.loader.projectReferenceFileMapper.referencesInConfigFile = make(map[tspath.Path][]tspath.Path, totalReferences) p.loader.projectReferenceFileMapper.sourceToProjectReference = make(map[tspath.Path]*tsoptions.SourceOutputAndProjectReference) p.loader.projectReferenceFileMapper.outputDtsToProjectReference = make(map[tspath.Path]*tsoptions.SourceOutputAndProjectReference) - p.loader.projectReferenceFileMapper.referencesInConfigFile[p.loader.opts.Config.ConfigFile.SourceFile.Path()] = p.initMapperWorker(tasks, &collections.Set[*projectReferenceParseTask]{}) + p.loader.projectReferenceFileMapper.referencesInConfigFile[p.loader.projectReferenceFileMapper.rootConfigPath()] = p.initMapperWorker(tasks, &collections.Set[*projectReferenceParseTask]{}) if p.loader.projectReferenceFileMapper.opts.canUseProjectReferenceSource() && len(p.loader.projectReferenceFileMapper.outputDtsToProjectReference) != 0 { p.loader.projectReferenceFileMapper.host = newProjectReferenceDtsFakingHost(p.loader) } diff --git a/tsc/internal/execute/tsc/emit_test.go b/tsc/internal/execute/tsc/emit_test.go index 685b2817e062f..ce8d12ed94638 100644 --- a/tsc/internal/execute/tsc/emit_test.go +++ b/tsc/internal/execute/tsc/emit_test.go @@ -157,7 +157,7 @@ export const make = (): Box => ({ value: "ok" }); NoEmit: core.TSTrue, TsBuildInfoFile: "/project/tsconfig.tsbuildinfo", } - config := tsoptions.NewParsedCommandLine(options, []string{"/lib/lib.d.ts", "/project/hub.ts", "/project/spoke.ts"}, tspath.ComparePathsOptions{ + config := tsoptions.NewParsedCommandLine(options, []string{"/lib/lib.d.ts", "/project/hub.ts", "/project/spoke.ts"}, nil, tspath.ComparePathsOptions{ UseCaseSensitiveFileNames: true, CurrentDirectory: "/project", }) diff --git a/tsc/internal/project/api.go b/tsc/internal/project/api.go index 284897f29c147..52d7eeb5fcfd3 100644 --- a/tsc/internal/project/api.go +++ b/tsc/internal/project/api.go @@ -5,6 +5,7 @@ import ( "fmt" "maps" + "github.com/microsoft/TypeScript/tsc/internal/ast" "github.com/microsoft/TypeScript/tsc/internal/core" "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" ) @@ -67,3 +68,43 @@ func (s *Session) APIUpdateTemporary(ctx context.Context, baseSnapshot *Snapshot }, overlays, s) return newSnapshot, nil } + +// APICreateProgram creates an isolated snapshot containing one synthetic project. +// Without an old snapshot it starts from the underlying filesystem; otherwise it +// derives from oldSnapshot and applies fileChanges. +func (s *Session) APICreateProgram( + ctx context.Context, + rootFileNames []string, + options *core.CompilerOptions, + projectReferences []*core.ProjectReference, + configFileParsingDiagnostics []*ast.Diagnostic, + oldSnapshot *Snapshot, + oldProject *Project, + fileChanges FileChangeSummary, +) *Snapshot { + if oldSnapshot != nil { + return oldSnapshot.cloneForProgram( + ctx, + rootFileNames, + options, + projectReferences, + configFileParsingDiagnostics, + oldProject, + fileChanges, + s, + ) + } + + snapshot, _ := s.APIUpdate(ctx, fileChanges, nil) + defer snapshot.Deref(s) + return snapshot.cloneForProgram( + ctx, + rootFileNames, + options, + projectReferences, + configFileParsingDiagnostics, + nil, + fileChanges, + s, + ) +} diff --git a/tsc/internal/project/project.go b/tsc/internal/project/project.go index 8f64ee3303274..edc0ee527a0d4 100644 --- a/tsc/internal/project/project.go +++ b/tsc/internal/project/project.go @@ -105,6 +105,7 @@ func NewInferredProject( currentDirectory string, compilerOptions *core.CompilerOptions, rootFileNames []string, + projectReferences []*core.ProjectReference, contentMappers []*contentmapper.Mapper, builder *ProjectCollectionBuilder, logger *logging.LogTree, @@ -128,6 +129,7 @@ func NewInferredProject( p.CommandLine = newInferredProjectCommandLine( compilerOptions, rootFileNames, + projectReferences, contentMappers, tspath.ComparePathsOptions{ UseCaseSensitiveFileNames: builder.fs.fs.UseCaseSensitiveFileNames(), @@ -140,14 +142,32 @@ func NewInferredProject( func newInferredProjectCommandLine( compilerOptions *core.CompilerOptions, rootFileNames []string, + projectReferences []*core.ProjectReference, contentMappers []*contentmapper.Mapper, comparePathsOptions tspath.ComparePathsOptions, ) *tsoptions.ParsedCommandLine { - commandLine := tsoptions.NewParsedCommandLine(compilerOptions, rootFileNames, comparePathsOptions) + commandLine := tsoptions.NewParsedCommandLine(compilerOptions, rootFileNames, projectReferences, comparePathsOptions) commandLine.ParsedConfig.ContentMappers = contentMappers return commandLine } +// newInferredProjectFromProject creates an isolated synthetic project seeded +// from an existing project's compiler state. +func newInferredProjectFromProject( + project *Project, + builder *ProjectCollectionBuilder, + logger *logging.LogTree, +) *Project { + inferred := NewProject(inferredProjectName, KindInferred, project.currentDirectory, builder, logger) + inferred.CommandLine = project.Program.CommandLine() + inferred.Program = project.Program + inferred.ProgramLastUpdate = project.ProgramLastUpdate + inferred.host = project.host + inferred.checkerPool = project.checkerPool + inferred.dirty = false + return inferred +} + func NewProject( configFileName string, kind Kind, diff --git a/tsc/internal/project/project_test.go b/tsc/internal/project/project_test.go index 71a83a522be7b..eb1c8487dac46 100644 --- a/tsc/internal/project/project_test.go +++ b/tsc/internal/project/project_test.go @@ -80,6 +80,34 @@ func TestProjectProgramUpdateKind(t *testing.T) { assert.Equal(t, configured.ProgramUpdateKind, project.ProgramUpdateKindCloned) }) + t.Run("compiler options update inferred project", func(t *testing.T) { + t.Parallel() + const fileName = "/src/index.ts" + session, _ := projecttestutil.Setup(map[string]any{ + fileName: "export const x = 1;", + }) + uri := lsproto.DocumentUri("file://" + fileName) + session.DidOpenFile(context.Background(), uri, 1, "export const x = 1;", lsproto.LanguageKindTypeScript) + oldProject := session.Snapshot().ProjectCollection.InferredProject() + assert.Assert(t, oldProject != nil) + oldProgram := oldProject.Program + assert.Equal(t, oldProgram.Options().Strict, core.TSUnknown) + + session.DidChangeCompilerOptionsForInferredProjects(context.Background(), &core.CompilerOptions{ + NoLib: core.TSTrue, + Strict: core.TSTrue, + }) + _, err := session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + + updatedProject := session.Snapshot().ProjectCollection.InferredProject() + assert.Assert(t, updatedProject != nil) + assert.Equal(t, updatedProject.CommandLine.CompilerOptions().Strict, core.TSTrue) + assert.Assert(t, updatedProject.Program != oldProgram) + assert.Equal(t, updatedProject.Program.Options().Strict, core.TSTrue) + assert.Equal(t, oldProgram.Options().Strict, core.TSUnknown) + }) + t.Run("NewFiles when import resolution mode changes", func(t *testing.T) { t.Parallel() files := map[string]any{ diff --git a/tsc/internal/project/projectcollectionbuilder.go b/tsc/internal/project/projectcollectionbuilder.go index 3285eb16ac93d..07e033e2d8d28 100644 --- a/tsc/internal/project/projectcollectionbuilder.go +++ b/tsc/internal/project/projectcollectionbuilder.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "maps" + "reflect" "slices" "time" @@ -1105,6 +1106,32 @@ func (b *ProjectCollectionBuilder) findOrCreateProject( func (b *ProjectCollectionBuilder) updateInferredProjectRoots(rootFileNames []string, logger *logging.LogTree) bool { rootFileNames = core.Filter(rootFileNames, b.isSupportedInInferredProject) + var projectReferences []*core.ProjectReference + var configFileParsingDiagnostics []*ast.Diagnostic + if project := b.inferredProject.Value(); project != nil { + projectReferences = project.CommandLine.ProjectReferences() + configFileParsingDiagnostics = project.CommandLine.Errors + } + return b.updateInferredProject(rootFileNames, b.compilerOptionsForInferredProjects, projectReferences, configFileParsingDiagnostics, b.inferredContentMappers, logger) +} + +// seedInferredProjectForProgram adapts one selected project into the isolated synthetic-project slot used by createProgram. +func (b *ProjectCollectionBuilder) seedInferredProjectForProgram(project *Project, logger *logging.LogTree) { + if project == nil || project.Program == nil { + return + } + b.inferredProject.Set(newInferredProjectFromProject(project, b, logger)) +} + +// updateInferredProject preserves the current command line when roots/options are unchanged. +func (b *ProjectCollectionBuilder) updateInferredProject( + rootFileNames []string, + compilerOptions *core.CompilerOptions, + projectReferences []*core.ProjectReference, + configFileParsingDiagnostics []*ast.Diagnostic, + contentMappers []*contentmapper.Mapper, + logger *logging.LogTree, +) bool { if len(rootFileNames) == 0 { if b.inferredProject.Value() != nil { if logger != nil { @@ -1115,39 +1142,67 @@ func (b *ProjectCollectionBuilder) updateInferredProjectRoots(rootFileNames []st } return false } - + rootFileNames = slices.Clone(rootFileNames) slices.Sort(rootFileNames) - contentMappers := b.inferredContentMappers - if b.inferredProject.Value() == nil { - b.inferredProject.Set(NewInferredProject(b.sessionOptions.CurrentDirectory, b.compilerOptionsForInferredProjects, rootFileNames, contentMappers, b, logger)) - } else { - newCompilerOptions := b.inferredProject.Value().CommandLine.CompilerOptions() - if b.compilerOptionsForInferredProjects != nil { - newCompilerOptions = b.compilerOptionsForInferredProjects - } - newCommandLine := newInferredProjectCommandLine(newCompilerOptions, rootFileNames, contentMappers, tspath.ComparePathsOptions{ - UseCaseSensitiveFileNames: b.fs.fs.UseCaseSensitiveFileNames(), - CurrentDirectory: b.sessionOptions.CurrentDirectory, - }) - changed := b.inferredProject.ChangeIf( - func(p *Project) bool { - return !maps.Equal(p.CommandLine.FileNamesByPath(), newCommandLine.FileNamesByPath()) || - !slices.Equal(p.CommandLine.ContentMappers(), newCommandLine.ContentMappers()) - }, - func(p *Project) { - if logger != nil { - logger.Log(fmt.Sprintf("Updating inferred project config with %d root files", len(rootFileNames))) - } - p.SetCommandLine(newCommandLine) - }, - ) - if !changed { - return false - } + return b.updateOrCreateInferredProject(rootFileNames, compilerOptions, projectReferences, configFileParsingDiagnostics, contentMappers, logger) +} + +// updateOrCreateInferredProject always retains an inferred project, including when rootFileNames is empty. +// The caller transfers ownership of rootFileNames. +func (b *ProjectCollectionBuilder) updateOrCreateInferredProject( + rootFileNames []string, + compilerOptions *core.CompilerOptions, + projectReferences []*core.ProjectReference, + configFileParsingDiagnostics []*ast.Diagnostic, + contentMappers []*contentmapper.Mapper, + logger *logging.LogTree, +) bool { + project := b.inferredProject.Value() + if project == nil { + project = NewInferredProject(b.sessionOptions.CurrentDirectory, compilerOptions, rootFileNames, projectReferences, contentMappers, b, logger) + project.CommandLine.Errors = configFileParsingDiagnostics + b.inferredProject.Set(project) + return true + } + + if compilerOptions == nil { + compilerOptions = project.CommandLine.CompilerOptions() + } + newCommandLine := newInferredProjectCommandLine(compilerOptions, rootFileNames, projectReferences, contentMappers, tspath.ComparePathsOptions{ + UseCaseSensitiveFileNames: b.fs.fs.UseCaseSensitiveFileNames(), + CurrentDirectory: project.currentDirectory, + }) + newCommandLine.Errors = configFileParsingDiagnostics + changed := b.inferredProject.ChangeIf( + func(p *Project) bool { + return !slices.Equal(p.CommandLine.FileNames(), newCommandLine.FileNames()) || + !reflect.DeepEqual(p.CommandLine.CompilerOptions(), compilerOptions) || + !projectReferencesEqual(p.CommandLine.ProjectReferences(), projectReferences) || + !reflect.DeepEqual(p.CommandLine.Errors, configFileParsingDiagnostics) || + !slices.Equal(p.CommandLine.ContentMappers(), newCommandLine.ContentMappers()) + }, + func(p *Project) { + if logger != nil { + logger.Log(fmt.Sprintf("Updating inferred project config with %d root files", len(rootFileNames))) + } + p.SetCommandLine(newCommandLine) + }, + ) + if !changed { + return false } return true } +func projectReferencesEqual(a []*core.ProjectReference, b []*core.ProjectReference) bool { + return slices.EqualFunc(a, b, func(a *core.ProjectReference, b *core.ProjectReference) bool { + if a == nil || b == nil { + return a == b + } + return a.Path == b.Path && a.Circular == b.Circular + }) +} + func (b *ProjectCollectionBuilder) isSupportedInInferredProject(fileName string) bool { if tspath.IsDynamicFileName(fileName) || core.GetScriptKindFromFileName(fileName) != core.ScriptKindUnknown { return true diff --git a/tsc/internal/project/snapshot.go b/tsc/internal/project/snapshot.go index 23d79f0c55419..63765c8640aa5 100644 --- a/tsc/internal/project/snapshot.go +++ b/tsc/internal/project/snapshot.go @@ -9,7 +9,9 @@ import ( "sync/atomic" "time" + "github.com/microsoft/TypeScript/tsc/internal/ast" "github.com/microsoft/TypeScript/tsc/internal/collections" + "github.com/microsoft/TypeScript/tsc/internal/compiler" "github.com/microsoft/TypeScript/tsc/internal/contentmapper" "github.com/microsoft/TypeScript/tsc/internal/core" "github.com/microsoft/TypeScript/tsc/internal/ls" @@ -21,6 +23,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/project/dirty" "github.com/microsoft/TypeScript/tsc/internal/project/logging" "github.com/microsoft/TypeScript/tsc/internal/sourcemap" + "github.com/microsoft/TypeScript/tsc/internal/tsoptions" "github.com/microsoft/TypeScript/tsc/internal/tspath" "github.com/microsoft/TypeScript/tsc/internal/vfs/vfsmatch" ) @@ -108,6 +111,222 @@ func NewSnapshot( return s } +// cloneForProgram clones a snapshot and creates a single synthetic inferred +// project representing createProgram input. +func (s *Snapshot) cloneForProgram( + ctx context.Context, + rootFileNames []string, + compilerOptions *core.CompilerOptions, + projectReferences []*core.ProjectReference, + configFileParsingDiagnostics []*ast.Diagnostic, + oldProject *Project, + fileChanges FileChangeSummary, + session *Session, +) *Snapshot { + var logger *logging.LogTree + + if session.options.LoggingEnabled { + defer func() { + if r := recover(); r != nil { + session.logger.Log(logger.String()) + panic(r) + } + }() + logger = logging.NewLogTree(fmt.Sprintf("Cloning snapshot %d for program", s.id)) + } + + start := time.Now() + fs := newSnapshotFSBuilder(session.fs.fs, s.fs.overlays, s.fs.overlays, s.fs.diskFiles, s.fs.diskDirectories, s.fs.nodeModulesRealpathAliases, session.options.PositionEncoding, s.toPath) + contentMapperExtensions, contentMapperWatchedFiles := s.contentMapperWatchState() + fileChanges = processFileChanges(fs, s.fs, fileChanges, logger, contentMapperExtensions, contentMapperWatchedFiles) + + configFileRegistry := &ConfigFileRegistry{} + if oldProject != nil && oldProject.Program != nil { + configFileRegistry = configFileRegistryForProgram(oldProject.Program) + } + projectCollection := &ProjectCollection{ + toPath: s.toPath, + configFileRegistry: configFileRegistry, + configuredProjects: make(map[tspath.Path]*Project), + openFiles: openFilePaths(s.fs.overlays), + fileDefaultProjects: make(map[tspath.Path]tspath.Path), + apiState: APIState{ + openProjects: make(map[tspath.Path]int), + openFiles: make(map[tspath.Path]apiOpenedFile), + }, + } + + newSnapshotID := session.snapshotID.Add(1) + projectCollectionBuilder := newProjectCollectionBuilder( + ctx, + newSnapshotID, + fs, + projectCollection, + configFileRegistry, + projectCollection.apiState, + compilerOptions, + s.inferredProjectContentMappers, + s.inferredProjectContentMapperExtensions, + s.sessionOptions, + configFileRegistry.customConfigFileName, + session.parseCache, + session.contentMappedParseCache, + session.extendedConfigCache, + session.contentMapperHost, + session.client, + ) + + projectCollectionBuilder.seedInferredProjectForProgram(oldProject, logger) + if !fileChanges.IsEmpty() { + changeLogger := logger + if changeLogger != nil { + changeLogger = logger.Fork("DidChangeFiles") + } + projectCollectionBuilder.DidChangeFiles(fileChanges, changeLogger) + } + updateLogger := logger + if updateLogger != nil { + updateLogger = logger.Fork("UpdateProgramConfig") + } + projectCollectionBuilder.updateOrCreateInferredProject( + slices.Clone(rootFileNames), + compilerOptions, + projectReferences, + configFileParsingDiagnostics, + s.inferredProjectContentMappers, + updateLogger, + ) + if projectCollectionBuilder.inferredProject.Value().dirty { + createLogger := logger + if createLogger != nil { + createLogger = logger.Fork("CreateProgram") + } + projectCollectionBuilder.updateProgram(projectCollectionBuilder.inferredProject, createLogger) + } + projectCollectionBuilder.configFileRegistryBuilder.Cleanup() + + newProjectCollection, newConfigFileRegistry := projectCollectionBuilder.Finalize(logger) + + cleanFilesStart := time.Now() + removedFiles := 0 + fs.diskFiles.Range(func(entry *dirty.SyncMapEntry[tspath.Path, *diskFile]) bool { + for _, project := range newProjectCollection.Projects() { + if project.host != nil && project.host.sourceFS.SeenFile(entry.Key()) { + return true + } + } + entry.Delete() + removedFiles++ + return true + }) + if session.options.LoggingEnabled { + logger.Logf("Removed %d cached file(s) in %v", removedFiles, time.Since(cleanFilesStart)) + } + + snapshotFS, _ := fs.Finalize() + newSnapshot := NewSnapshot( + newSnapshotID, + snapshotFS, + s.sessionOptions, + newConfigFileRegistry, + compilerOptions, + s.userPreferences, + nil, + nil, + s.toPath, + ) + newSnapshot.parentId = s.id + newSnapshot.ProjectCollection = newProjectCollection + newSnapshot.ConfigFileRegistry = newConfigFileRegistry + newSnapshot.inferredProjectContentMappers = s.inferredProjectContentMappers + newSnapshot.inferredProjectContentMapperExtensions = s.inferredProjectContentMapperExtensions + newSnapshot.builderLogs = logger + + for _, project := range newSnapshot.ProjectCollection.Projects() { + if project.Program != nil { + session.programCounter.Ref(project.Program) + if project.ProgramLastUpdate == newSnapshotID { + project.host.freeze(snapshotFS, newConfigFileRegistry) + } + } + } + + for _, config := range newSnapshot.ConfigFileRegistry.configs { + if config.commandLine != nil && config.commandLine.ConfigFile != nil { + for _, file := range config.commandLine.ConfigFile.ExtendedSourceFiles { + session.extendedConfigCache.AddOwner(newSnapshot.toPath(file), newSnapshot.id) + } + } + } + + if logger != nil { + logger.Logf("Finished cloning snapshot %d into snapshot %d for program in %v", s.id, newSnapshot.id, time.Since(start)) + } + return newSnapshot +} + +// configFileRegistryForProgram retains only project-reference configs reachable from a selected program. +func configFileRegistryForProgram(program *compiler.Program) *ConfigFileRegistry { + registry := &ConfigFileRegistry{} + program.RangeResolvedProjectReference(func(path tspath.Path, config *tsoptions.ParsedCommandLine, _ *tsoptions.ParsedCommandLine, _ int) bool { + if config == nil { + return true + } + if registry.configs == nil { + registry.configs = make(map[tspath.Path]*configFileEntry) + } + fileName := config.ConfigName() + if fileName == "" { + fileName = string(path) + } + registry.configs[path] = &configFileEntry{ + fileName: fileName, + commandLine: config, + retainingProjects: map[tspath.Path]struct{}{inferredProjectName: {}}, + } + return true + }) + return registry +} + +func processFileChanges( + fs *snapshotFSBuilder, + previousFS *SnapshotFS, + fileChanges FileChangeSummary, + logger *logging.LogTree, + contentMapperExtensions []string, + contentMapperWatchedFiles *collections.Set[tspath.Path], +) FileChangeSummary { + if fileChanges.HasExcessiveWatchEvents() { + invalidateStart := time.Now() + if fileChanges.InvalidateAll { + fs.invalidateCache() + if logger != nil { + logger.Logf("InvalidateAll: invalidated file cache in %v", time.Since(invalidateStart)) + } + } else if !fs.watchChangesOverlapCache(fileChanges) { + fileChanges.Changed = collections.Set[lsproto.DocumentUri]{} + fileChanges.Deleted = collections.Set[lsproto.DocumentUri]{} + } else if fileChanges.IncludesWatchChangeOutsideNodeModules { + fs.invalidateCache() + if logger != nil { + logger.Logf("Excessive watch changes detected, invalidated file cache in %v", time.Since(invalidateStart)) + } + } else { + fs.invalidateNodeModulesCache() + if logger != nil { + logger.Logf("npm install detected, invalidated node_modules cache in %v", time.Since(invalidateStart)) + } + } + } else { + fileChanges = fs.expandAndFilterWatchEvents(fileChanges, contentMapperExtensions, contentMapperWatchedFiles) + fileChanges = previousFS.expandRealpathAliases(fileChanges) + fileChanges = fs.markDirtyFiles(fileChanges) + fileChanges = fs.convertOpenAndCloseToChanges(fileChanges) + } + return fileChanges +} + func (s *Snapshot) GetDefaultProject(uri lsproto.DocumentUri) *Project { return s.ProjectCollection.GetDefaultProject(uri.Path(s.UseCaseSensitiveFileNames())) } @@ -333,38 +552,17 @@ func (s *Snapshot) Clone( inferredContentMapperExtensions = change.contentMapperContributions.Extensions } fs := newSnapshotFSBuilder(session.fs.fs, s.fs.overlays, overlays, s.fs.diskFiles, s.fs.diskDirectories, s.fs.nodeModulesRealpathAliases, session.options.PositionEncoding, s.toPath) - if change.fileChanges.HasExcessiveWatchEvents() { - invalidateStart := time.Now() - if change.fileChanges.InvalidateAll { - fs.invalidateCache() - logger.Logf("InvalidateAll: invalidated file cache in %v", time.Since(invalidateStart)) - } else if !fs.watchChangesOverlapCache(change.fileChanges) { - // All watch changes/deletes are files we haven't seen; should be irrelevant to us (probably an external tool's build or something) - change.fileChanges.Changed = collections.Set[lsproto.DocumentUri]{} - change.fileChanges.Deleted = collections.Set[lsproto.DocumentUri]{} - } else if change.fileChanges.IncludesWatchChangeOutsideNodeModules { - fs.invalidateCache() - logger.Logf("Excessive watch changes detected, invalidated file cache in %v", time.Since(invalidateStart)) - } else { - fs.invalidateNodeModulesCache() - logger.Logf("npm install detected, invalidated node_modules cache in %v", time.Since(invalidateStart)) - } + var contentMapperExtensions []string + if change.contentMapperContributions == nil { + contentMapperExtensions, _ = s.contentMapperWatchState() } else { - var contentMapperExtensions []string - if change.contentMapperContributions == nil { - contentMapperExtensions, _ = s.contentMapperWatchState() - } else { - if configuredContentMappers != nil { - contentMapperExtensions = slices.Clone(configuredContentMappers.extensions) - } - contentMapperExtensions = append(contentMapperExtensions, inferredContentMapperExtensions...) + if configuredContentMappers != nil { + contentMapperExtensions = slices.Clone(configuredContentMappers.extensions) } - _, contentMapperWatchedFiles := s.contentMapperWatchState() - change.fileChanges = fs.expandAndFilterWatchEvents(change.fileChanges, contentMapperExtensions, contentMapperWatchedFiles) - change.fileChanges = s.fs.expandRealpathAliases(change.fileChanges) - change.fileChanges = fs.markDirtyFiles(change.fileChanges) - change.fileChanges = fs.convertOpenAndCloseToChanges(change.fileChanges) + contentMapperExtensions = append(contentMapperExtensions, inferredContentMapperExtensions...) } + _, contentMapperWatchedFiles := s.contentMapperWatchState() + change.fileChanges = processFileChanges(fs, s.fs, change.fileChanges, logger, contentMapperExtensions, contentMapperWatchedFiles) compilerOptionsForInferredProjects := s.compilerOptionsForInferredProjects if change.compilerOptionsForInferredProjects != nil { @@ -403,6 +601,16 @@ func (s *Snapshot) Clone( } projectCollectionBuilder.DidChangeCustomConfigFileName(logger.Fork("DidChangeCustomConfigFileName")) + if change.compilerOptionsForInferredProjects != nil && projectCollectionBuilder.inferredProject.Value() != nil { + projectCollectionBuilder.updateInferredProject( + projectCollectionBuilder.inferredProject.Value().CommandLine.FileNames(), + change.compilerOptionsForInferredProjects, + projectCollectionBuilder.inferredProject.Value().CommandLine.ProjectReferences(), + projectCollectionBuilder.inferredProject.Value().CommandLine.Errors, + projectCollectionBuilder.inferredProject.Value().CommandLine.ContentMappers(), + logger.Fork("DidChangeCompilerOptionsForInferredProjects"), + ) + } if change.contentMapperContributions != nil { projectCollectionBuilder.DidChangeContentMapperContributions(logger.Fork("DidChangeContentMapperContributions")) } diff --git a/tsc/internal/tsoptions/commandlineparser.go b/tsc/internal/tsoptions/commandlineparser.go index e267a9773a247..ad9d00dbbdb6c 100644 --- a/tsc/internal/tsoptions/commandlineparser.go +++ b/tsc/internal/tsoptions/commandlineparser.go @@ -51,7 +51,7 @@ func ParseCommandLine( options := convertToOptionsWithAbsolutePaths(parser.options.Clone(), CommandLineCompilerOptionsMap, host.GetCurrentDirectory()) compilerOptions := convertMapToOptions(options, &compilerOptionsParser{&core.CompilerOptions{}}).CompilerOptions watchOptions := convertMapToOptions(options, &watchOptionsParser{&core.WatchOptions{}}).WatchOptions - result := NewParsedCommandLine(compilerOptions, parser.fileNames, tspath.ComparePathsOptions{ + result := NewParsedCommandLine(compilerOptions, parser.fileNames, nil, tspath.ComparePathsOptions{ UseCaseSensitiveFileNames: host.FS().UseCaseSensitiveFileNames(), CurrentDirectory: host.GetCurrentDirectory(), }) diff --git a/tsc/internal/tsoptions/contentmappers_test.go b/tsc/internal/tsoptions/contentmappers_test.go index 6d2bc9875126b..8974163776a39 100644 --- a/tsc/internal/tsoptions/contentmappers_test.go +++ b/tsc/internal/tsoptions/contentmappers_test.go @@ -54,6 +54,7 @@ func TestGetOutputFileNamesExcludesMapperOwnedOutputs(t *testing.T) { SourceMap: core.TSTrue, }, []string{"/src/Component.vue"}, + nil, tspath.ComparePathsOptions{CurrentDirectory: "/", UseCaseSensitiveFileNames: true}, ) commandLine.ParsedConfig.ContentMappers = []*contentmapper.Mapper{mapper} diff --git a/tsc/internal/tsoptions/parsedcommandline.go b/tsc/internal/tsoptions/parsedcommandline.go index 8d9e650b4fbfe..caac1280e1ee0 100644 --- a/tsc/internal/tsoptions/parsedcommandline.go +++ b/tsc/internal/tsoptions/parsedcommandline.go @@ -77,12 +77,14 @@ type ParsedCommandLine struct { func NewParsedCommandLine( compilerOptions *core.CompilerOptions, rootFileNames []string, + projectReferences []*core.ProjectReference, comparePathsOptions tspath.ComparePathsOptions, ) *ParsedCommandLine { return &ParsedCommandLine{ ParsedConfig: &ParsedOptions{ - CompilerOptions: compilerOptions, - FileNames: rootFileNames, + CompilerOptions: compilerOptions, + FileNames: rootFileNames, + ProjectReferences: projectReferences, }, comparePathsOptions: comparePathsOptions, } From bb72421a9eaeb8e647a1625179196288672801d5 Mon Sep 17 00:00:00 2001 From: Gabriela Araujo Britto Date: Fri, 21 Aug 2026 20:14:15 +0000 Subject: [PATCH 2/3] fixes after merge --- .../typescript/src/api/proto.generated.ts | 4 +-- tsc/internal/api/proto.go | 4 +-- tsc/internal/project/snapshot.go | 35 +++++++++---------- 3 files changed, 20 insertions(+), 23 deletions(-) diff --git a/packages/typescript/src/api/proto.generated.ts b/packages/typescript/src/api/proto.generated.ts index 1c70d5261e908..a868fcc646484 100644 --- a/packages/typescript/src/api/proto.generated.ts +++ b/packages/typescript/src/api/proto.generated.ts @@ -218,11 +218,11 @@ export interface UpdateSnapshotResponse { */ export interface UpdateTemporarySnapshotParams { /** Snapshot is the current client snapshot on which to layer the temporary update. */ - snapshot?: number; + snapshot: number; /** File identifies the file whose content is temporarily overridden. */ file: DocumentIdentifier; /** NewText is the temporary content for the file. */ - newText?: string; + newText: string; } export interface CreateProgramParams { diff --git a/tsc/internal/api/proto.go b/tsc/internal/api/proto.go index f295217958e22..3b59a624a4fcd 100644 --- a/tsc/internal/api/proto.go +++ b/tsc/internal/api/proto.go @@ -363,11 +363,11 @@ type UpdateSnapshotParams struct { // snapshot that overrides a single file's content. type UpdateTemporarySnapshotParams struct { // Snapshot is the current client snapshot on which to layer the temporary update. - Snapshot SnapshotID `json:"snapshot,omitempty"` + Snapshot SnapshotID `json:"snapshot"` // File identifies the file whose content is temporarily overridden. File DocumentIdentifier `json:"file"` // NewText is the temporary content for the file. - NewText string `json:"newText,omitempty"` + NewText string `json:"newText"` } type CreateProgramParams struct { diff --git a/tsc/internal/project/snapshot.go b/tsc/internal/project/snapshot.go index 63765c8640aa5..ec964995211c5 100644 --- a/tsc/internal/project/snapshot.go +++ b/tsc/internal/project/snapshot.go @@ -137,8 +137,7 @@ func (s *Snapshot) cloneForProgram( start := time.Now() fs := newSnapshotFSBuilder(session.fs.fs, s.fs.overlays, s.fs.overlays, s.fs.diskFiles, s.fs.diskDirectories, s.fs.nodeModulesRealpathAliases, session.options.PositionEncoding, s.toPath) - contentMapperExtensions, contentMapperWatchedFiles := s.contentMapperWatchState() - fileChanges = processFileChanges(fs, s.fs, fileChanges, logger, contentMapperExtensions, contentMapperWatchedFiles) + fileChanges = s.processFileChanges(fs, fileChanges, logger, nil) configFileRegistry := &ConfigFileRegistry{} if oldProject != nil && oldProject.Program != nil { @@ -289,14 +288,23 @@ func configFileRegistryForProgram(program *compiler.Program) *ConfigFileRegistry return registry } -func processFileChanges( +func (s *Snapshot) processFileChanges( fs *snapshotFSBuilder, - previousFS *SnapshotFS, fileChanges FileChangeSummary, logger *logging.LogTree, - contentMapperExtensions []string, - contentMapperWatchedFiles *collections.Set[tspath.Path], + contentMapperContributions *ContentMapperContributions, ) FileChangeSummary { + var contentMapperExtensions []string + if contentMapperContributions == nil { + contentMapperExtensions, _ = s.contentMapperWatchState() + } else { + if configuredContentMappers := s.ConfigFileRegistry.contentMappers(); configuredContentMappers != nil { + contentMapperExtensions = slices.Clone(configuredContentMappers.extensions) + } + contentMapperExtensions = append(contentMapperExtensions, contentMapperContributions.Extensions...) + } + _, contentMapperWatchedFiles := s.contentMapperWatchState() + if fileChanges.HasExcessiveWatchEvents() { invalidateStart := time.Now() if fileChanges.InvalidateAll { @@ -320,7 +328,7 @@ func processFileChanges( } } else { fileChanges = fs.expandAndFilterWatchEvents(fileChanges, contentMapperExtensions, contentMapperWatchedFiles) - fileChanges = previousFS.expandRealpathAliases(fileChanges) + fileChanges = s.fs.expandRealpathAliases(fileChanges) fileChanges = fs.markDirtyFiles(fileChanges) fileChanges = fs.convertOpenAndCloseToChanges(fileChanges) } @@ -544,7 +552,6 @@ func (s *Snapshot) Clone( } start := time.Now() - configuredContentMappers := s.ConfigFileRegistry.contentMappers() inferredContentMappers := s.inferredProjectContentMappers inferredContentMapperExtensions := s.inferredProjectContentMapperExtensions if change.contentMapperContributions != nil { @@ -552,17 +559,7 @@ func (s *Snapshot) Clone( inferredContentMapperExtensions = change.contentMapperContributions.Extensions } fs := newSnapshotFSBuilder(session.fs.fs, s.fs.overlays, overlays, s.fs.diskFiles, s.fs.diskDirectories, s.fs.nodeModulesRealpathAliases, session.options.PositionEncoding, s.toPath) - var contentMapperExtensions []string - if change.contentMapperContributions == nil { - contentMapperExtensions, _ = s.contentMapperWatchState() - } else { - if configuredContentMappers != nil { - contentMapperExtensions = slices.Clone(configuredContentMappers.extensions) - } - contentMapperExtensions = append(contentMapperExtensions, inferredContentMapperExtensions...) - } - _, contentMapperWatchedFiles := s.contentMapperWatchState() - change.fileChanges = processFileChanges(fs, s.fs, change.fileChanges, logger, contentMapperExtensions, contentMapperWatchedFiles) + change.fileChanges = s.processFileChanges(fs, change.fileChanges, logger, change.contentMapperContributions) compilerOptionsForInferredProjects := s.compilerOptionsForInferredProjects if change.compilerOptionsForInferredProjects != nil { From 315531655fa85083a06b3b37de30602d88fab03e Mon Sep 17 00:00:00 2001 From: Gabriela Araujo Britto Date: Fri, 21 Aug 2026 21:02:55 +0000 Subject: [PATCH 3/3] refactor --- tsc/internal/project/snapshot.go | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tsc/internal/project/snapshot.go b/tsc/internal/project/snapshot.go index ec964995211c5..9f4207ea65715 100644 --- a/tsc/internal/project/snapshot.go +++ b/tsc/internal/project/snapshot.go @@ -294,17 +294,6 @@ func (s *Snapshot) processFileChanges( logger *logging.LogTree, contentMapperContributions *ContentMapperContributions, ) FileChangeSummary { - var contentMapperExtensions []string - if contentMapperContributions == nil { - contentMapperExtensions, _ = s.contentMapperWatchState() - } else { - if configuredContentMappers := s.ConfigFileRegistry.contentMappers(); configuredContentMappers != nil { - contentMapperExtensions = slices.Clone(configuredContentMappers.extensions) - } - contentMapperExtensions = append(contentMapperExtensions, contentMapperContributions.Extensions...) - } - _, contentMapperWatchedFiles := s.contentMapperWatchState() - if fileChanges.HasExcessiveWatchEvents() { invalidateStart := time.Now() if fileChanges.InvalidateAll { @@ -313,6 +302,7 @@ func (s *Snapshot) processFileChanges( logger.Logf("InvalidateAll: invalidated file cache in %v", time.Since(invalidateStart)) } } else if !fs.watchChangesOverlapCache(fileChanges) { + // All watch changes/deletes are files we haven't seen; should be irrelevant to us (probably an external tool's build or something) fileChanges.Changed = collections.Set[lsproto.DocumentUri]{} fileChanges.Deleted = collections.Set[lsproto.DocumentUri]{} } else if fileChanges.IncludesWatchChangeOutsideNodeModules { @@ -327,6 +317,16 @@ func (s *Snapshot) processFileChanges( } } } else { + var contentMapperExtensions []string + if contentMapperContributions == nil { + contentMapperExtensions, _ = s.contentMapperWatchState() + } else { + if configuredContentMappers := s.ConfigFileRegistry.contentMappers(); configuredContentMappers != nil { + contentMapperExtensions = slices.Clone(configuredContentMappers.extensions) + } + contentMapperExtensions = append(contentMapperExtensions, contentMapperContributions.Extensions...) + } + _, contentMapperWatchedFiles := s.contentMapperWatchState() fileChanges = fs.expandAndFilterWatchEvents(fileChanges, contentMapperExtensions, contentMapperWatchedFiles) fileChanges = s.fs.expandRealpathAliases(fileChanges) fileChanges = fs.markDirtyFiles(fileChanges)