From c446bd0287804ba7e4c8b2074374bca5d212d311 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Thu, 17 Sep 2026 17:32:32 -0700 Subject: [PATCH 01/11] [api] Add createIncrementalProgram Restore persistent diagnostic and emit state from tsbuildinfo while keeping checker and language-service operations backed by the underlying compiler program. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/typescript/src/api/async/api.ts | 23 +++- .../typescript/src/api/proto.generated.ts | 2 + packages/typescript/src/api/sync/api.ts | 65 +++++++++-- packages/typescript/test/async/api.test.ts | 37 ++++++ .../test/sync/api-generators.test.ts | 1 + packages/typescript/test/sync/api.test.ts | 37 ++++++ tsc/internal/api/proto.go | 2 + tsc/internal/api/session.go | 107 ++++++++++++++---- .../api/session_createprogram_test.go | 82 ++++++++++++++ tsc/internal/project/project.go | 5 + 10 files changed, 328 insertions(+), 33 deletions(-) diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index 912710b5cb4a5..dec4f5881123d 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -62,6 +62,7 @@ import type { ConfiguredProjectId, CreateProgramOptions, CreateSnapshotParams, + CreateSnapshotProgramParams, CreateSnapshotResponse, Diagnostic, DocumentIdentifier, @@ -556,10 +557,30 @@ export class API implements FormatDiagnosticsHo const snapshot = await this.createSnapshot({ createPrograms: [{ rootFiles, options: createProgramOptions }], }); + return this.getOwnedCreatedProgram(snapshot, "createProgram"); + } + + /** + * Creates a program that restores persistent diagnostic and emit state from + * the build info file configured by `tsBuildInfoFile`. + */ + async createIncrementalProgram( + rootFiles: readonly DocumentIdentifier[], + createProgramOptions: CreateProgramOptions, + ): Promise { + await this.ensureInitialized(); + + const snapshot = await this.createSnapshot({ + createPrograms: [{ rootFiles, options: createProgramOptions, incremental: true }], + }); + return this.getOwnedCreatedProgram(snapshot, "createIncrementalProgram"); + } + + private async getOwnedCreatedProgram(snapshot: SnapshotForOperationResults, method: "createProgram" | "createIncrementalProgram"): Promise { const program = snapshot.operation.createdPrograms[0]; if (!program) { await snapshot.dispose(); - throw new Error("createProgram did not return a project"); + throw new Error(`${method} did not return a project`); } program.setOwnedSnapshot(snapshot); return program; diff --git a/packages/typescript/src/api/proto.generated.ts b/packages/typescript/src/api/proto.generated.ts index 624fdf4003911..6ad4464aff7c2 100644 --- a/packages/typescript/src/api/proto.generated.ts +++ b/packages/typescript/src/api/proto.generated.ts @@ -1574,6 +1574,8 @@ export interface EmitOutputFile { export interface CreateSnapshotProgramParams { rootFiles: readonly DocumentIdentifier[] | null; options: CreateProgramOptions; + /** Incremental restores persistent diagnostic and emit state from the configured build info file. */ + incremental?: boolean | undefined; } export interface ReconfigureSnapshotProgramParams { diff --git a/packages/typescript/src/api/sync/api.ts b/packages/typescript/src/api/sync/api.ts index 8f942c3feb83f..22a2156cc4647 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -79,6 +79,7 @@ import type { ConfiguredProjectId, CreateProgramOptions, CreateSnapshotParams, + CreateSnapshotProgramParams, CreateSnapshotResponse, Diagnostic, DocumentIdentifier, @@ -944,13 +945,7 @@ export class API implements FormatDiagnosticsHo const snapshot = owner.createSnapshot({ createPrograms: [{ rootFiles, options: createProgramOptions }], }); - const program = snapshot.operation.createdPrograms[0]; - if (!program) { - snapshot.dispose(); - throw new Error("createProgram did not return a project"); - } - program.setOwnedSnapshot(snapshot); - return program; + return owner.getOwnedCreatedProgram(snapshot, "createProgram"); }, function* (rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions): Generator { yield* owner.ensureInitialized.gen(); @@ -958,10 +953,64 @@ export class API implements FormatDiagnosticsHo const snapshot = yield* owner.createSnapshot.gen({ createPrograms: [{ rootFiles, options: createProgramOptions }], }); + return yield* owner.getOwnedCreatedProgram.gen(snapshot, "createProgram"); + }, + ); + } + + /** + * Creates a program that restores persistent diagnostic and emit state from + * the build info file configured by `tsBuildInfoFile`. + */ + get createIncrementalProgram(): { + (rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions): Program; + gen(rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "createIncrementalProgram", + function (rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions): Program { + owner.ensureInitialized(); + + const snapshot = owner.createSnapshot({ + createPrograms: [{ rootFiles, options: createProgramOptions, incremental: true }], + }); + return owner.getOwnedCreatedProgram(snapshot, "createIncrementalProgram"); + }, + function* (rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions): Generator { + yield* owner.ensureInitialized.gen(); + + const snapshot = yield* owner.createSnapshot.gen({ + createPrograms: [{ rootFiles, options: createProgramOptions, incremental: true }], + }); + return yield* owner.getOwnedCreatedProgram.gen(snapshot, "createIncrementalProgram"); + }, + ); + } + + private get getOwnedCreatedProgram(): { + (snapshot: SnapshotForOperationResults, method: "createProgram" | "createIncrementalProgram"): Program; + gen(snapshot: SnapshotForOperationResults, method: "createProgram" | "createIncrementalProgram"): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "getOwnedCreatedProgram", + function (snapshot: SnapshotForOperationResults, method: "createProgram" | "createIncrementalProgram"): Program { + const program = snapshot.operation.createdPrograms[0]; + if (!program) { + snapshot.dispose(); + throw new Error(`${method} did not return a project`); + } + program.setOwnedSnapshot(snapshot); + return program; + }, + function* (snapshot: SnapshotForOperationResults, method: "createProgram" | "createIncrementalProgram"): Generator { const program = snapshot.operation.createdPrograms[0]; if (!program) { yield* snapshot.dispose.gen(); - throw new Error("createProgram did not return a project"); + throw new Error(`${method} did not return a project`); } program.setOwnedSnapshot(snapshot); return program; diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index 0a15cdff436bc..d2a22e23eef98 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -507,6 +507,43 @@ declare module "augmentation" {}`, await program.dispose(); }); + test("createIncrementalProgram restores build info and emits only affected files", async () => { + const fs = createVirtualFileSystem({ + "/src/main.ts": `import { value } from "./dependency"; export const result = value();`, + "/src/dependency.ts": `export function value() { return 1; }`, + }); + await using api = new API({ + cwd: "/", + fs, + }); + const options = { + compilerOptions: { + declaration: true, + incremental: true, + noLib: true, + outDir: "/out", + rootDir: "/src", + tsBuildInfoFile: "/out/build.tsbuildinfo", + }, + }; + + const firstProgram = await api.createIncrementalProgram(["/src/main.ts"], options); + const firstEmit = await firstProgram.emit(); + assert.ok(firstEmit.emittedFiles.includes("/out/main.js")); + assert.ok(firstEmit.emittedFiles.includes("/out/dependency.js")); + assert.ok(firstEmit.emittedFiles.includes("/out/build.tsbuildinfo")); + assert.ok(fs.readFile!("/out/build.tsbuildinfo")); + await firstProgram.dispose(); + + fs.writeFile!("/src/dependency.ts", `export function value() { return 2; }`); + const secondProgram = await api.createIncrementalProgram(["/src/main.ts"], options); + const secondEmit = await secondProgram.emit(); + assert.ok(!secondEmit.emittedFiles.includes("/out/main.js"), JSON.stringify(secondEmit.emittedFiles)); + assert.ok(secondEmit.emittedFiles.includes("/out/dependency.js")); + assert.ok(secondEmit.emittedFiles.includes("/out/build.tsbuildinfo")); + await secondProgram.dispose(); + }); + test("createProgram includes project references", async () => { const reference = { path: "/lib/tsconfig.json", originalPath: "/lib/tsconfig.json", circular: false }; await using api = spawnAPI({ diff --git a/packages/typescript/test/sync/api-generators.test.ts b/packages/typescript/test/sync/api-generators.test.ts index 7979cf39112e5..e9bb2360ffbfb 100644 --- a/packages/typescript/test/sync/api-generators.test.ts +++ b/packages/typescript/test/sync/api-generators.test.ts @@ -1513,6 +1513,7 @@ describe("API - generator batching", () => { parityCase("API", "transpileDeclarationFromFile", api.transpileDeclarationFromFile, assertDeepEquivalent, "/src/index.ts"), parityCase("API", "createSnapshot", api.createSnapshot as GeneratorMethod<[params: { openProject: string; }], Snapshot>, assertSnapshotsEquivalent, { openProject: "/tsconfig.json" }), parityCase("API", "createProgram", api.createProgram, assertProgramsEquivalent, ["/src/index.ts"], { compilerOptions: { noLib: true } }), + parityCase("API", "createIncrementalProgram", api.createIncrementalProgram, assertProgramsEquivalent, [], { compilerOptions: { incremental: true, noLib: true, tsBuildInfoFile: "/build.tsbuildinfo" } }), parityCase("API", "runWithTemporaryFileUpdate", api.runWithTemporaryFileUpdate, assertDeepEquivalent, snapshot, "/src/index.ts", parityFiles["/src/index.ts"].replace("123", '"fixed"'), (temporarySnapshot: Snapshot) => { temporaryProjects.push(temporarySnapshot.getProjects()[0].configFileName); }), diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index f20c229bbfce3..f52aa89f5a561 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -496,6 +496,43 @@ declare module "augmentation" {}`, program.dispose(); }); + test("createIncrementalProgram restores build info and emits only affected files", () => { + const fs = createVirtualFileSystem({ + "/src/main.ts": `import { value } from "./dependency"; export const result = value();`, + "/src/dependency.ts": `export function value() { return 1; }`, + }); + using api = new API({ + cwd: "/", + fs, + }); + const options = { + compilerOptions: { + declaration: true, + incremental: true, + noLib: true, + outDir: "/out", + rootDir: "/src", + tsBuildInfoFile: "/out/build.tsbuildinfo", + }, + }; + + const firstProgram = api.createIncrementalProgram(["/src/main.ts"], options); + const firstEmit = firstProgram.emit(); + assert.ok(firstEmit.emittedFiles.includes("/out/main.js")); + assert.ok(firstEmit.emittedFiles.includes("/out/dependency.js")); + assert.ok(firstEmit.emittedFiles.includes("/out/build.tsbuildinfo")); + assert.ok(fs.readFile!("/out/build.tsbuildinfo")); + firstProgram.dispose(); + + fs.writeFile!("/src/dependency.ts", `export function value() { return 2; }`); + const secondProgram = api.createIncrementalProgram(["/src/main.ts"], options); + const secondEmit = secondProgram.emit(); + assert.ok(!secondEmit.emittedFiles.includes("/out/main.js"), JSON.stringify(secondEmit.emittedFiles)); + assert.ok(secondEmit.emittedFiles.includes("/out/dependency.js")); + assert.ok(secondEmit.emittedFiles.includes("/out/build.tsbuildinfo")); + secondProgram.dispose(); + }); + test("createProgram includes project references", () => { const reference = { path: "/lib/tsconfig.json", originalPath: "/lib/tsconfig.json", circular: false }; using api = spawnAPI({ diff --git a/tsc/internal/api/proto.go b/tsc/internal/api/proto.go index 8b330c808923b..62130cda9c096 100644 --- a/tsc/internal/api/proto.go +++ b/tsc/internal/api/proto.go @@ -419,6 +419,8 @@ type CreateSnapshotParams struct { type CreateSnapshotProgramParams struct { RootFiles []DocumentIdentifier `json:"rootFiles"` Options CreateProgramOptions `json:"options"` + // Incremental restores persistent diagnostic and emit state from the configured build info file. + Incremental bool `json:"incremental,omitempty"` } type ReconfigureSnapshotProgramParams struct { diff --git a/tsc/internal/api/session.go b/tsc/internal/api/session.go index e47519ca076a8..f46dcb521bc04 100644 --- a/tsc/internal/api/session.go +++ b/tsc/internal/api/session.go @@ -21,6 +21,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/compiler" "github.com/microsoft/TypeScript/tsc/internal/core" "github.com/microsoft/TypeScript/tsc/internal/diagnostics" + "github.com/microsoft/TypeScript/tsc/internal/execute/incremental" "github.com/microsoft/TypeScript/tsc/internal/format" "github.com/microsoft/TypeScript/tsc/internal/ipc" "github.com/microsoft/TypeScript/tsc/internal/jsnum" @@ -47,9 +48,10 @@ var sessionIDCounter atomic.Uint64 // Multiple clients may hold references to the same snapshot via ref counting; // the registries are cleaned up when refCount reaches zero. type snapshotData struct { - snapshot *project.Snapshot - fileSystem vfs.FS - refCount int + snapshot *project.Snapshot + fileSystem vfs.FS + incrementalPrograms map[ProjectID]*incremental.Program + refCount int openProjects collections.Set[tspath.Path] openFiles collections.Set[tspath.Path] @@ -100,6 +102,13 @@ func (sd *snapshotData) getProgram(projectHandle ProjectID) (*compiler.Program, return program, nil } +func (sd *snapshotData) getProgramLike(projectHandle ProjectID) (compiler.ProgramLike, error) { + if program := sd.incrementalPrograms[projectHandle]; program != nil { + return program, nil + } + return sd.getProgram(projectHandle) +} + // getProject looks up a project from a project handle within this snapshot. func (sd *snapshotData) getProject(projectHandle ProjectID) (*project.Project, error) { projectName := tspath.Path(projectHandle) @@ -1157,7 +1166,7 @@ func (s *Session) handleCreateSnapshot(ctx context.Context, params *CreateSnapsh } response := s.createSnapshotResponse(snapshot, nil, ¶ms.SnapshotRequestChangesParams) - s.registerSnapshot(snapshot, openState, snapshotFileSystem) + s.registerSnapshot(snapshot, openState, snapshotFileSystem, nil, ¶ms.SnapshotRequestChangesParams) return response, nil } @@ -1201,7 +1210,7 @@ func (s *Session) handleUpdateSnapshot(ctx context.Context, params *UpdateSnapsh } response := s.createSnapshotResponse(snapshot, baseSD.snapshot, &changes.SnapshotRequestChangesParams) - s.registerSnapshot(snapshot, openState, snapshotFileSystem) + s.registerSnapshot(snapshot, openState, snapshotFileSystem, baseSD, &changes.SnapshotRequestChangesParams) return response, nil } @@ -1404,7 +1413,13 @@ func (s *Session) reconcileSnapshotOpens(apiRequest *project.APISnapshotRequest, return state } -func (s *Session) registerSnapshot(snapshot *project.Snapshot, openState snapshotOpenState, fileSystem vfs.FS) { +func (s *Session) registerSnapshot( + snapshot *project.Snapshot, + openState snapshotOpenState, + fileSystem vfs.FS, + baseSD *snapshotData, + request *SnapshotRequestChangesParams, +) { // If the same snapshot ID is returned (no changes), we increment the ref count // so each client-side Snapshot can be disposed independently. handle := snapshotHandle(snapshot) @@ -1419,6 +1434,7 @@ func (s *Session) registerSnapshot(snapshot *project.Snapshot, openState snapsho sd = &snapshotData{ snapshot: snapshot, fileSystem: fileSystem, + incrementalPrograms: s.createIncrementalPrograms(snapshot, baseSD, request), refCount: 1, openProjects: *openState.openProjects.Clone(), openFiles: *openState.openFiles.Clone(), @@ -1431,13 +1447,45 @@ func (s *Session) registerSnapshot(snapshot *project.Snapshot, openState snapsho s.snapshotsMu.Unlock() } +func (s *Session) createIncrementalPrograms( + snapshot *project.Snapshot, + baseSD *snapshotData, + request *SnapshotRequestChangesParams, +) map[ProjectID]*incremental.Program { + programs := make(map[ProjectID]*incremental.Program) + if baseSD != nil { + for id, oldProgram := range baseSD.incrementalPrograms { + proj := snapshot.ProjectCollection.GetProjectByPath(parseProjectHandle(id)) + if proj != nil && proj.Program != nil { + host := proj.CompilerHost() + programs[id] = incremental.NewProgram(proj.Program, oldProgram, incremental.CreateHost(host), nil, false) + } + } + } + if request != nil { + createdPrograms := snapshot.CreatedPrograms() + for i, create := range request.CreatePrograms { + if !create.Incremental { + continue + } + proj := createdPrograms[i] + host := proj.CompilerHost() + oldProgram := incremental.ReadBuildInfoProgram(proj.CommandLine, incremental.NewBuildInfoReader(host), host) + programs[ProjectHandle(proj)] = incremental.NewProgram(proj.Program, oldProgram, incremental.CreateHost(host), nil, false) + } + } + return programs +} + func (s *Session) handleGetCurrentLanguageServerSnapshot(ctx context.Context, params *GetCurrentLanguageServerSnapshotParams) (*CreateSnapshotResponse, error) { if s.projectSession == nil { return nil, fmt.Errorf("%w: getCurrentLanguageServerSnapshot requires an LSP-connected API session", ErrClientError) } var baseSnapshot *project.Snapshot + var baseSD *snapshotData if params.BaseSnapshot != 0 { - baseSD, err := s.retainSnapshotData(params.BaseSnapshot) + var err error + baseSD, err = s.retainSnapshotData(params.BaseSnapshot) if err != nil { return nil, err } @@ -1464,7 +1512,7 @@ func (s *Session) handleGetCurrentLanguageServerSnapshot(ctx context.Context, pa update.commit(s, snapshot) response := s.createSnapshotResponse(snapshot, baseSnapshot, &changes.SnapshotRequestChangesParams) - s.registerSnapshot(snapshot, snapshotOpenState{openProjects: s.openProjects, openFiles: s.openFiles}, nil) + s.registerSnapshot(snapshot, snapshotOpenState{openProjects: s.openProjects, openFiles: s.openFiles}, nil, baseSD, &changes.SnapshotRequestChangesParams) return response, nil } @@ -3260,7 +3308,7 @@ func (s *Session) handleSelectedFilesEmit(ctx context.Context, params *SelectedF }) } -func emitToOutput(ctx context.Context, program *compiler.Program, options compiler.EmitOptions) (*EmitOutputResponse, error) { +func emitToOutput(ctx context.Context, program compiler.ProgramLike, options compiler.EmitOptions) (*EmitOutputResponse, error) { var mu sync.Mutex outputFiles := make([]*EmitOutputFile, 0) options.WriteFile = func(fileName string, text string, data *compiler.WriteFileData) error { @@ -3289,8 +3337,8 @@ func emitToOutput(ctx context.Context, program *compiler.Program, options compil }, nil } -func (s *Session) getEmitOptions(params *EmitParams) (*compiler.Program, compiler.EmitOptions, error) { - program, err := s.getEmitProgram(params.Snapshot, params.Project) +func (s *Session) getEmitOptions(params *EmitParams) (compiler.ProgramLike, compiler.EmitOptions, error) { + program, err := s.getEmitProgramLike(params.Snapshot, params.Project) if err != nil { return nil, compiler.EmitOptions{}, err } @@ -3311,6 +3359,14 @@ func (s *Session) getEmitProgram(snapshot SnapshotID, projectID ProjectID) (*com return sd.getProgram(projectID) } +func (s *Session) getEmitProgramLike(snapshot SnapshotID, projectID ProjectID) (compiler.ProgramLike, error) { + sd, err := s.getSnapshotData(snapshot) + if err != nil { + return nil, err + } + return sd.getProgramLike(projectID) +} + func getEmitOnly(value *uint32) (compiler.EmitOnly, error) { if value == nil { return compiler.EmitAll, nil @@ -3322,7 +3378,7 @@ func getEmitOnly(value *uint32) (compiler.EmitOnly, error) { return emitOnly, nil } -func emitProgram(ctx context.Context, program *compiler.Program, options compiler.EmitOptions) (*compiler.EmitResult, error) { +func emitProgram(ctx context.Context, program compiler.ProgramLike, options compiler.EmitOptions) (*compiler.EmitResult, error) { result := program.Emit(ctx, options) if result != nil { return result, nil @@ -4399,13 +4455,13 @@ func (s *Session) toFileChangeSummary(changes *FileNotifications) project.FileCh return summary } -func (s *Session) getDiagnostics(ctx context.Context, params *GetDiagnosticsParams, getter func(*compiler.Program, context.Context, *ast.SourceFile) []*ast.Diagnostic) ([]*DiagnosticResponse, error) { +func (s *Session) getDiagnostics(ctx context.Context, params *GetDiagnosticsParams, getter func(compiler.ProgramLike, context.Context, *ast.SourceFile) []*ast.Diagnostic) ([]*DiagnosticResponse, error) { sd, err := s.getSnapshotData(params.Snapshot) if err != nil { return nil, err } - program, err := sd.getProgram(params.Project) + program, err := sd.getProgramLike(params.Project) if err != nil { return nil, err } @@ -4413,7 +4469,7 @@ func (s *Session) getDiagnostics(ctx context.Context, params *GetDiagnosticsPara if params.Files != nil { var allDiags []*ast.Diagnostic for _, file := range params.Files { - sourceFile, err := s.resolveOptionalSourceFile(program, &file) + sourceFile, err := s.resolveOptionalSourceFile(program.Program(), &file) if err != nil { return nil, err } @@ -4428,31 +4484,31 @@ func (s *Session) getDiagnostics(ctx context.Context, params *GetDiagnosticsPara // @gen-proto-nullable func (s *Session) handleGetSyntacticDiagnostics(ctx context.Context, params *GetDiagnosticsParams) ([]*DiagnosticResponse, error) { ctx = core.WithCheckerLifetime(ctx, core.CheckerLifetimeDiagnostics) - return s.getDiagnostics(ctx, params, (*compiler.Program).GetSyntacticDiagnostics) + return s.getDiagnostics(ctx, params, compiler.ProgramLike.GetSyntacticDiagnostics) } // @gen-proto-nullable func (s *Session) handleGetBindDiagnostics(ctx context.Context, params *GetDiagnosticsParams) ([]*DiagnosticResponse, error) { ctx = core.WithCheckerLifetime(ctx, core.CheckerLifetimeDiagnostics) - return s.getDiagnostics(ctx, params, (*compiler.Program).GetBindDiagnostics) + return s.getDiagnostics(ctx, params, compiler.ProgramLike.GetBindDiagnostics) } // @gen-proto-nullable func (s *Session) handleGetSemanticDiagnostics(ctx context.Context, params *GetDiagnosticsParams) ([]*DiagnosticResponse, error) { ctx = core.WithCheckerLifetime(ctx, core.CheckerLifetimeDiagnostics) - return s.getDiagnostics(ctx, params, (*compiler.Program).GetSemanticDiagnostics) + return s.getDiagnostics(ctx, params, compiler.ProgramLike.GetSemanticDiagnostics) } // @gen-proto-nullable func (s *Session) handleGetSuggestionDiagnostics(ctx context.Context, params *GetDiagnosticsParams) ([]*DiagnosticResponse, error) { ctx = core.WithCheckerLifetime(ctx, core.CheckerLifetimeDiagnostics) - return s.getDiagnostics(ctx, params, (*compiler.Program).GetSuggestionDiagnostics) + return s.getDiagnostics(ctx, params, compiler.ProgramLike.GetSuggestionDiagnostics) } // @gen-proto-nullable func (s *Session) handleGetDeclarationDiagnostics(ctx context.Context, params *GetDiagnosticsParams) ([]*DiagnosticResponse, error) { ctx = core.WithCheckerLifetime(ctx, core.CheckerLifetimeDiagnostics) - return s.getDiagnostics(ctx, params, (*compiler.Program).GetDeclarationDiagnostics) + return s.getDiagnostics(ctx, params, compiler.ProgramLike.GetDeclarationDiagnostics) } // handleGetConfigFileParsingDiagnostics returns config file parsing diagnostics. @@ -4463,7 +4519,7 @@ func (s *Session) handleGetConfigFileParsingDiagnostics(ctx context.Context, par return nil, err } - program, err := sd.getProgram(params.Project) + program, err := sd.getProgramLike(params.Project) if err != nil { return nil, err } @@ -4480,7 +4536,7 @@ func (s *Session) handleGetProgramDiagnostics(ctx context.Context, params *GetPr return nil, err } - program, err := sd.getProgram(params.Project) + program, err := sd.getProgramLike(params.Project) if err != nil { return nil, err } @@ -4503,8 +4559,11 @@ func (s *Session) handleGetGlobalDiagnostics(ctx context.Context, params *GetPro return nil, err } - program := proj.GetProgram() - if program == nil { + program, err := sd.getProgramLike(params.Project) + if err != nil { + return nil, err + } + if program.Program() == nil { return nil, fmt.Errorf("%w: project has no program", ErrClientError) } diff --git a/tsc/internal/api/session_createprogram_test.go b/tsc/internal/api/session_createprogram_test.go index c0605e7a889c4..2ed02019ab757 100644 --- a/tsc/internal/api/session_createprogram_test.go +++ b/tsc/internal/api/session_createprogram_test.go @@ -2,6 +2,7 @@ package api import ( "context" + "slices" "testing" "github.com/microsoft/TypeScript/tsc/internal/bundled" @@ -151,6 +152,87 @@ func TestUpdateSnapshotReconfiguresSyntheticProgram(t *testing.T) { assert.Equal(t, reconfigured.Projects[0].CompilerOptions.Strict, core.TSTrue) } +func TestIncrementalProgramComposesWithSnapshotUpdates(t *testing.T) { + t.Parallel() + + const ( + mainFile = "/home/projects/p/main.ts" + dependencyFile = "/home/projects/p/dependency.ts" + buildInfoFile = "/home/projects/p/out/build.tsbuildinfo" + ) + projectSession, sessionUtils := projecttestutil.Setup(map[string]any{ + mainFile: `import { value } from "./dependency"; export const result = value();`, + dependencyFile: `export function value() { return 1; }`, + }) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + ctx := context.Background() + options := CreateProgramOptions{CompilerOptions: core.CompilerOptions{ + Declaration: core.TSTrue, + Incremental: core.TSTrue, + NoLib: core.TSTrue, + OutDir: "/home/projects/p/out", + RootDir: "/home/projects/p", + TsBuildInfoFile: buildInfoFile, + }} + + created, err := session.handleCreateSnapshot(ctx, &CreateSnapshotParams{ + CreatePrograms: []*CreateSnapshotProgramParams{{ + RootFiles: []DocumentIdentifier{{FileName: mainFile}}, + Options: options, + Incremental: true, + }}, + }) + assert.NilError(t, err) + programID := (*created.Operation.CreatedPrograms)[0] + firstEmit, err := session.handleEmit(ctx, &EmitParams{Snapshot: created.Snapshot, Project: ProjectID(programID)}) + assert.NilError(t, err) + assert.Assert(t, slices.Contains(firstEmit.EmittedFiles, buildInfoFile)) + + assert.NilError(t, sessionUtils.FS().WriteFile(dependencyFile, `export function value() { return 2; }`)) + dirty, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ + Snapshot: created.Snapshot, + Changes: &CreateSnapshotParams{ + FileNotifications: &FileNotifications{Changed: []DocumentIdentifier{{FileName: dependencyFile}}}, + }, + }) + assert.NilError(t, err) + dirtySnapshot, err := session.getSnapshotData(dirty.Snapshot) + assert.NilError(t, err) + assert.Assert(t, dirtySnapshot.incrementalPrograms[ProjectID(programID)] != nil) + dirtyProject, err := dirtySnapshot.getProject(ProjectID(programID)) + assert.NilError(t, err) + assert.Assert(t, dirtyProject.IsDirty()) + + ensured, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ + Snapshot: dirty.Snapshot, + Changes: &CreateSnapshotParams{ + EnsurePrograms: &EnsurePrograms{Projects: []ProjectID{ProjectID(programID)}}, + }, + }) + assert.NilError(t, err) + emitted, err := session.handleEmit(ctx, &EmitParams{Snapshot: ensured.Snapshot, Project: ProjectID(programID)}) + assert.NilError(t, err) + assert.Assert(t, !slices.Contains(emitted.EmittedFiles, "/home/projects/p/out/main.js"), "unexpected emitted files: %v", emitted.EmittedFiles) + assert.Assert(t, slices.Contains(emitted.EmittedFiles, "/home/projects/p/out/dependency.js")) + + reconfigured, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ + Snapshot: ensured.Snapshot, + Changes: &CreateSnapshotParams{ + ReconfigurePrograms: []*ReconfigureSnapshotProgramParams{{ + Id: programID, + RootFiles: []DocumentIdentifier{{FileName: mainFile}}, + Options: options, + }}, + }, + }) + assert.NilError(t, err) + reconfiguredSnapshot, err := session.getSnapshotData(reconfigured.Snapshot) + assert.NilError(t, err) + assert.Assert(t, reconfiguredSnapshot.incrementalPrograms[ProjectID(programID)] != nil) +} + func TestReconfigureSyntheticProgramValidation(t *testing.T) { t.Parallel() diff --git a/tsc/internal/project/project.go b/tsc/internal/project/project.go index 354e778da94be..0e2b501e4a5de 100644 --- a/tsc/internal/project/project.go +++ b/tsc/internal/project/project.go @@ -283,6 +283,11 @@ func (p *Project) GetProgram() *compiler.Program { return p.Program } +// CompilerHost returns the frozen host associated with the current program. +func (p *Project) CompilerHost() compiler.CompilerHost { + return p.host +} + func (p *Project) IsDirty() bool { return p.dirty } From ab561e16f233c6ca8a0f4bc178aed330c5584831 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Thu, 17 Sep 2026 17:44:03 -0700 Subject: [PATCH 02/11] Fix incremental API validation Avoid exposing the shared program ownership helper as a generated API method and construct fresh compiler options in tests to satisfy no-copy checks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/typescript/src/api/async/api.ts | 4 +- packages/typescript/src/api/sync/api.ts | 39 +++++-------------- .../api/session_createprogram_test.go | 22 ++++++----- 3 files changed, 24 insertions(+), 41 deletions(-) diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index dec4f5881123d..e7f677ab2f47e 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -576,10 +576,10 @@ export class API implements FormatDiagnosticsHo return this.getOwnedCreatedProgram(snapshot, "createIncrementalProgram"); } - private async getOwnedCreatedProgram(snapshot: SnapshotForOperationResults, method: "createProgram" | "createIncrementalProgram"): Promise { + private getOwnedCreatedProgram(snapshot: SnapshotForOperationResults, method: "createProgram" | "createIncrementalProgram"): Program { const program = snapshot.operation.createdPrograms[0]; if (!program) { - await snapshot.dispose(); + void snapshot.dispose(); throw new Error(`${method} did not return a project`); } program.setOwnedSnapshot(snapshot); diff --git a/packages/typescript/src/api/sync/api.ts b/packages/typescript/src/api/sync/api.ts index 22a2156cc4647..7ff59eccdd909 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -953,7 +953,7 @@ export class API implements FormatDiagnosticsHo const snapshot = yield* owner.createSnapshot.gen({ createPrograms: [{ rootFiles, options: createProgramOptions }], }); - return yield* owner.getOwnedCreatedProgram.gen(snapshot, "createProgram"); + return owner.getOwnedCreatedProgram(snapshot, "createProgram"); }, ); } @@ -984,38 +984,19 @@ export class API implements FormatDiagnosticsHo const snapshot = yield* owner.createSnapshot.gen({ createPrograms: [{ rootFiles, options: createProgramOptions, incremental: true }], }); - return yield* owner.getOwnedCreatedProgram.gen(snapshot, "createIncrementalProgram"); + return owner.getOwnedCreatedProgram(snapshot, "createIncrementalProgram"); }, ); } - private get getOwnedCreatedProgram(): { - (snapshot: SnapshotForOperationResults, method: "createProgram" | "createIncrementalProgram"): Program; - gen(snapshot: SnapshotForOperationResults, method: "createProgram" | "createIncrementalProgram"): Generator; - } { - const owner = this; - return cacheGeneratorMethod( - owner, - "getOwnedCreatedProgram", - function (snapshot: SnapshotForOperationResults, method: "createProgram" | "createIncrementalProgram"): Program { - const program = snapshot.operation.createdPrograms[0]; - if (!program) { - snapshot.dispose(); - throw new Error(`${method} did not return a project`); - } - program.setOwnedSnapshot(snapshot); - return program; - }, - function* (snapshot: SnapshotForOperationResults, method: "createProgram" | "createIncrementalProgram"): Generator { - const program = snapshot.operation.createdPrograms[0]; - if (!program) { - yield* snapshot.dispose.gen(); - throw new Error(`${method} did not return a project`); - } - program.setOwnedSnapshot(snapshot); - return program; - }, - ); + private getOwnedCreatedProgram(snapshot: SnapshotForOperationResults, method: "createProgram" | "createIncrementalProgram"): Program { + const program = snapshot.operation.createdPrograms[0]; + if (!program) { + void snapshot.dispose(); + throw new Error(`${method} did not return a project`); + } + program.setOwnedSnapshot(snapshot); + return program; } } diff --git a/tsc/internal/api/session_createprogram_test.go b/tsc/internal/api/session_createprogram_test.go index 2ed02019ab757..add6cf603fd23 100644 --- a/tsc/internal/api/session_createprogram_test.go +++ b/tsc/internal/api/session_createprogram_test.go @@ -168,19 +168,21 @@ func TestIncrementalProgramComposesWithSnapshotUpdates(t *testing.T) { session := NewLSPSession(projectSession, nil) defer session.Close() ctx := context.Background() - options := CreateProgramOptions{CompilerOptions: core.CompilerOptions{ - Declaration: core.TSTrue, - Incremental: core.TSTrue, - NoLib: core.TSTrue, - OutDir: "/home/projects/p/out", - RootDir: "/home/projects/p", - TsBuildInfoFile: buildInfoFile, - }} + options := func() CreateProgramOptions { + return CreateProgramOptions{CompilerOptions: core.CompilerOptions{ + Declaration: core.TSTrue, + Incremental: core.TSTrue, + NoLib: core.TSTrue, + OutDir: "/home/projects/p/out", + RootDir: "/home/projects/p", + TsBuildInfoFile: buildInfoFile, + }} + } created, err := session.handleCreateSnapshot(ctx, &CreateSnapshotParams{ CreatePrograms: []*CreateSnapshotProgramParams{{ RootFiles: []DocumentIdentifier{{FileName: mainFile}}, - Options: options, + Options: options(), Incremental: true, }}, }) @@ -223,7 +225,7 @@ func TestIncrementalProgramComposesWithSnapshotUpdates(t *testing.T) { ReconfigurePrograms: []*ReconfigureSnapshotProgramParams{{ Id: programID, RootFiles: []DocumentIdentifier{{FileName: mainFile}}, - Options: options, + Options: options(), }}, }, }) From 1dc803348d5d32ca6a866f04e084447dead2412b Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Tue, 22 Sep 2026 16:36:52 -0700 Subject: [PATCH 03/11] Prototype incremental programs --- packages/typescript/src/api/async/api.ts | 146 ++++++++-- .../typescript/src/api/proto.generated.ts | 18 ++ packages/typescript/src/api/sync/api.ts | 275 ++++++++++++++---- packages/typescript/test/async/api.test.ts | 58 ++++ .../test/sync/api-generators.test.ts | 19 ++ packages/typescript/test/sync/api.test.ts | 58 ++++ tsc/internal/api/proto.go | 29 +- tsc/internal/api/session.go | 192 ++++++++---- .../api/session_createprogram_test.go | 61 +++- tsc/internal/execute/incremental/program.go | 84 +++++- .../execute/incremental/programtosnapshot.go | 4 + tsc/internal/project/project.go | 31 +- .../project/projectcollectionbuilder.go | 56 ++++ tsc/internal/project/snapshot.go | 41 ++- 14 files changed, 889 insertions(+), 183 deletions(-) diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index e7f677ab2f47e..50871ff71251f 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -68,8 +68,10 @@ import type { DocumentIdentifier, DocumentPosition, EmitOutputResponse as ProtocolEmitOutputResponse, + EmitResponse, FileNotifications, ImportAdderAction, + IncrementalOperationParams, InferredProjectId, IntrinsicTypeMethod, LanguageServerSnapshotChanges, @@ -372,7 +374,8 @@ export class API implements FormatDiagnosticsHo createSnapshot< const CreatePrograms extends CreateSnapshotParams["createPrograms"] = undefined, const OpenFiles extends CreateSnapshotParams["openFiles"] = undefined, - >(params: SnapshotOperationParams): Promise>; + const IncrementalOperations extends CreateSnapshotParams["incrementalOperations"] = undefined, + >(params: SnapshotOperationParams): Promise>; createSnapshot(): Promise; async createSnapshot(params?: CreateSnapshotParams): Promise { await this.ensureInitialized(); @@ -442,10 +445,11 @@ export class API implements FormatDiagnosticsHo getCurrentLanguageServerSnapshot< const CreatePrograms extends LanguageServerSnapshotChanges["createPrograms"] = undefined, const OpenFiles extends LanguageServerSnapshotChanges["openFiles"] = undefined, + const IncrementalOperations extends LanguageServerSnapshotChanges["incrementalOperations"] = undefined, >( - ...args: FromLSP extends true ? [changes: SnapshotOperationParams, baseSnapshot?: Snapshot] + ...args: FromLSP extends true ? [changes: SnapshotOperationParams, baseSnapshot?: Snapshot] : [changes: never, baseSnapshot?: never] - ): Promise>; + ): Promise>; getCurrentLanguageServerSnapshot( ...args: FromLSP extends true ? [changes?: LanguageServerSnapshotChanges, baseSnapshot?: Snapshot] : [changes: never, baseSnapshot?: never] ): Promise; @@ -567,16 +571,21 @@ export class API implements FormatDiagnosticsHo async createIncrementalProgram( rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions, - ): Promise { + ): Promise { await this.ensureInitialized(); const snapshot = await this.createSnapshot({ createPrograms: [{ rootFiles, options: createProgramOptions, incremental: true }], }); - return this.getOwnedCreatedProgram(snapshot, "createIncrementalProgram"); + const program = this.getOwnedCreatedProgram(snapshot, "createIncrementalProgram"); + if (!(program instanceof IncrementalProgram)) { + await snapshot.dispose(); + throw new Error("createIncrementalProgram did not return an incremental program"); + } + return program; } - private getOwnedCreatedProgram(snapshot: SnapshotForOperationResults, method: "createProgram" | "createIncrementalProgram"): Program { + private getOwnedCreatedProgram(snapshot: SnapshotForOperationResults, method: "createProgram" | "createIncrementalProgram"): Program { const program = snapshot.operation.createdPrograms[0]; if (!program) { void snapshot.dispose(); @@ -622,6 +631,12 @@ type SnapshotUpdater = (params: CreateSnapshotParams) => Promise; // @ export interface SnapshotOperation { readonly createdPrograms?: readonly Program[]; readonly openedFiles?: readonly SnapshotOpenedFileOperation[]; + readonly incrementalOperations?: readonly IncrementalOperationResult[]; +} + +export interface IncrementalOperationResult { + readonly program: IncrementalProgram; + readonly result: EmitResult; } export interface SnapshotOpenedFileOperation { @@ -636,24 +651,29 @@ type SnapshotOperationParams< Params extends CreateSnapshotParams, CreatePrograms extends Params["createPrograms"], OpenFiles extends Params["openFiles"], -> = Omit & { + IncrementalOperations extends Params["incrementalOperations"], +> = Omit & { createPrograms?: CreatePrograms; openFiles?: OpenFiles; + incrementalOperations?: IncrementalOperations; }; type SnapshotForOperationResults< CreatePrograms extends CreateSnapshotParams["createPrograms"], OpenFiles extends CreateSnapshotParams["openFiles"], + IncrementalOperations extends CreateSnapshotParams["incrementalOperations"], > = Snapshot & { readonly operation: & SnapshotOperation & (CreatePrograms extends readonly unknown[] ? { readonly createdPrograms: MapTupleTo>; } : unknown) - & (OpenFiles extends readonly unknown[] ? { readonly openedFiles: MapTupleTo; } : unknown); + & (OpenFiles extends readonly unknown[] ? { readonly openedFiles: MapTupleTo; } : unknown) + & (IncrementalOperations extends readonly unknown[] ? { readonly incrementalOperations: MapTupleTo; } : unknown); }; export type SnapshotForOperation = SnapshotForOperationResults< Params extends { createPrograms: infer CreatePrograms extends readonly unknown[]; } ? CreatePrograms : undefined, - Params extends { openFiles: infer OpenFiles extends readonly unknown[]; } ? OpenFiles : undefined + Params extends { openFiles: infer OpenFiles extends readonly unknown[]; } ? OpenFiles : undefined, + Params extends { incrementalOperations: infer IncrementalOperations extends readonly unknown[]; } ? IncrementalOperations : undefined >; export class Snapshot { @@ -697,13 +717,24 @@ export class Snapshot { this.snapshotRegistry = new SnapshotObjectRegistry(client, this.id, projectId => this.projectMap.get(projectId)); for (const projData of this.projectDataMap.values()) { - const project = new Project(projData, this.id, client, sourceFileCache, toPath, formatDiagnosticsHost, this.snapshotRegistry); + const project = new Project(projData, this.id, client, sourceFileCache, toPath, formatDiagnosticsHost, this.snapshotRegistry, this.updateSnapshot); this.projectMap.set(projData.id, project); } this.operation = { ...(data.operation.createdPrograms ? { createdPrograms: data.operation.createdPrograms.map(projectId => this.requireProject(projectId).program) } : {}), ...(data.operation.openedFiles ? { openedFiles: data.operation.openedFiles.map(result => ({ project: this.requireProject(result.project) })) } : {}), + ...(data.operation.incrementalOperations + ? { + incrementalOperations: data.operation.incrementalOperations.map(operation => { + const program = this.requireProject(operation.program).program; + if (!(program instanceof IncrementalProgram)) { + throw new Error(`Snapshot operation returned non-incremental program '${operation.program}'`); + } + return { program, result: toEmitResult(operation.result) }; + }), + } + : {}), }; this.internal = new SnapshotInternalAPI(this.id, client); @@ -731,7 +762,8 @@ export class Snapshot { update< const CreatePrograms extends CreateSnapshotParams["createPrograms"] = undefined, const OpenFiles extends CreateSnapshotParams["openFiles"] = undefined, - >(params: SnapshotOperationParams): Promise>; + const IncrementalOperations extends CreateSnapshotParams["incrementalOperations"] = undefined, + >(params: SnapshotOperationParams): Promise>; update(params: CreateSnapshotParams): Promise; update(params: CreateSnapshotParams): Promise { this.ensureNotDisposed(); @@ -1100,6 +1132,7 @@ export class Project { toPath: (fileName: string) => Path, formatDiagnosticsHost: FormatDiagnosticsHost, snapshotRegistry: SnapshotObjectRegistry, + updateSnapshot: SnapshotUpdater, ) { this.id = data.id as Id; this.configFileName = data.configFileName; @@ -1113,13 +1146,14 @@ export class Project { this.rootFiles = this.parsedCommandLine.fileNames; this.client = client; this.snapshotId = snapshotId; - this.program = new Program( + this.program = new (data.incremental ? IncrementalProgram : Program)( snapshotId, this, client, sourceFileCache, toPath, formatDiagnosticsHost, + updateSnapshot, ); const objectRegistry = new ProjectObjectRegistry(client, snapshotId, this, snapshotRegistry); this.checker = new Checker( @@ -1261,10 +1295,11 @@ export class Program implements FormatDiagnost readonly snapshotId: number; readonly id: Id; private readonly project: Project; - private readonly client: Client; + protected readonly client: Client; private readonly sourceFileCache: SourceFileCache; private readonly toPath: (fileName: string) => Path; private readonly formatDiagnosticsHost: FormatDiagnosticsHost; + protected readonly updateSnapshot: SnapshotUpdater; private readonly decoder = new Wtf8Decoder(); private readonly sourceFileMetadataCache = new Map>(); private ownedSnapshot: Snapshot | undefined; @@ -1277,6 +1312,7 @@ export class Program implements FormatDiagnost sourceFileCache: SourceFileCache, toPath: (fileName: string) => Path, formatDiagnosticsHost: FormatDiagnosticsHost, + updateSnapshot: SnapshotUpdater, ) { this.snapshotId = snapshotId; this.id = project.id; @@ -1285,6 +1321,7 @@ export class Program implements FormatDiagnost this.sourceFileCache = sourceFileCache; this.toPath = toPath; this.formatDiagnosticsHost = formatDiagnosticsHost; + this.updateSnapshot = updateSnapshot; } getCurrentDirectory(): string { @@ -1640,18 +1677,7 @@ export class Program implements FormatDiagnost project: this.project.id, emitOnly, }); - const fileSystem = response.emittedFilesContents.length - ? { - kind: "layer" as const, - files: Object.fromEntries(response.emittedFiles.map((fileName, index) => [fileName, response.emittedFilesContents[index]])), - } - : undefined; - return { - emitSkipped: response.emitSkipped, - diagnostics: response.diagnostics, - emittedFiles: response.emittedFiles, - ...(fileSystem ? { fileSystem } : {}), - }; + return toEmitResult(response); } /** @@ -1695,6 +1721,76 @@ export class Program implements FormatDiagnost } } +export class IncrementalProgram extends Program { + /** + * Emits pending files and returns the new snapshot containing the advanced incremental state. + */ + override async emit(emitOnly?: EmitOnly): Promise { + return this.runIncrementalOperation({ + program: this.id as SyntheticProjectId, + kind: "emit", + emitOnly, + }); + } + + /** + * Writes the current incremental build information and returns the new snapshot + * containing the advanced incremental state. + */ + async emitBuildInfo(): Promise { + return this.runIncrementalOperation({ + program: this.id as SyntheticProjectId, + kind: "emitBuildInfo", + }); + } + + /** + * Returns the current serialized incremental build information without writing it. + */ + async getBuildInfoEmit(): Promise { + return this.client.apiRequest("getBuildInfoEmit", { + snapshot: this.snapshotId, + project: this.id, + }); + } + + private async runIncrementalOperation(operation: IncrementalOperationParams): Promise { + const snapshot = await this.updateSnapshot({ + incrementalOperations: [operation], + }); + const result = snapshot.operation.incrementalOperations?.[0]; + if (!result) { + await snapshot.dispose(); + throw new Error("Snapshot update did not return an incremental operation result"); + } + return { + ...result.result, + snapshot, + program: result.program, + }; + } +} + +export interface IncrementalEmitResult extends EmitResult { + readonly snapshot: Snapshot; + readonly program: IncrementalProgram; +} + +function toEmitResult(response: EmitResponse): EmitResult { + const fileSystem = response.emittedFilesContents.length + ? { + kind: "layer" as const, + files: Object.fromEntries(response.emittedFiles.map((fileName, index) => [fileName, response.emittedFilesContents[index]])), + } + : undefined; + return { + emitSkipped: response.emitSkipped, + diagnostics: response.diagnostics, + emittedFiles: response.emittedFiles, + ...(fileSystem ? { fileSystem } : {}), + }; +} + function toEmitOutput(response: ProtocolEmitOutputResponse): EmitOutput { const outputFiles = new Map(); for (const { fileName, ...outputFile } of response.outputFiles) { diff --git a/packages/typescript/src/api/proto.generated.ts b/packages/typescript/src/api/proto.generated.ts index 6ad4464aff7c2..b41662fb5eb83 100644 --- a/packages/typescript/src/api/proto.generated.ts +++ b/packages/typescript/src/api/proto.generated.ts @@ -153,6 +153,7 @@ export interface APIMethodInfo { printNode: APIMethod; formatNodeForInsertion: APIMethod; emit: APIMethod; + getBuildInfoEmit: APIMethod; emitToString: APIMethod; getJavaScriptEmit: APIMethod; getDeclarationEmit: APIMethod; @@ -304,6 +305,7 @@ export interface ProjectResponse { configFileName: string; currentDirectory: string; dirty: boolean; + incremental: boolean; parsedCommandLine: ConfigFileResponse; /** @deprecated Use parsedCommandLine.fileNames. */ rootFiles: string[]; @@ -1002,6 +1004,7 @@ export interface BatchRequest { | "getBigIntType" | "getBindDiagnostics" | "getBooleanType" + | "getBuildInfoEmit" | "getCheckTypeOfType" | "getCompletionsAtPosition" | "getConfigFileNames" @@ -1162,6 +1165,7 @@ export interface BatchResponse { | "getBigIntType" | "getBindDiagnostics" | "getBooleanType" + | "getBuildInfoEmit" | "getCheckTypeOfType" | "getCompletionsAtPosition" | "getConfigFileNames" @@ -1338,6 +1342,8 @@ export interface SnapshotRequestChangesParams { * or all contained projects when true. */ ensurePrograms?: EnsurePrograms | undefined; + /** IncrementalOperations advances incremental program state while constructing the snapshot. */ + incrementalOperations?: readonly IncrementalOperationParams[] | undefined; } /** @@ -1393,6 +1399,7 @@ export interface SnapshotChanges { export interface SnapshotOperationResponse { createdPrograms?: SyntheticProjectId[] | undefined; openedFiles?: OpenedFileOperationResult[] | undefined; + incrementalOperations?: IncrementalOperationResultResponse[] | undefined; } /** @@ -1584,6 +1591,12 @@ export interface ReconfigureSnapshotProgramParams { options: CreateProgramOptions; } +export interface IncrementalOperationParams { + program: SyntheticProjectId; + kind: "emit" | "emitBuildInfo"; + emitOnly?: number | undefined; +} + /** * RequestDirectoryEntries is a cached directory listing. Entry names are * relative to the directory, matching vfs.GetAccessibleEntries. @@ -1619,6 +1632,11 @@ export interface OpenedFileOperationResult { project: ProjectId; } +export interface IncrementalOperationResultResponse { + program: ProjectId; + result: EmitResponse; +} + /** CompletionEntryLabelDetailsResponse holds additional label display text for a completion entry. */ export interface CompletionEntryLabelDetailsResponse { detail?: string | undefined; diff --git a/packages/typescript/src/api/sync/api.ts b/packages/typescript/src/api/sync/api.ts index 7ff59eccdd909..ffaf7d526fccb 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -85,8 +85,10 @@ import type { DocumentIdentifier, DocumentPosition, EmitOutputResponse as ProtocolEmitOutputResponse, + EmitResponse, FileNotifications, ImportAdderAction, + IncrementalOperationParams, InferredProjectId, IntrinsicTypeMethod, LanguageServerSnapshotChanges, @@ -563,13 +565,13 @@ export class API implements FormatDiagnosticsHo } get createSnapshot(): { - (params: SnapshotOperationParams): SnapshotForOperationResults; + (params: SnapshotOperationParams): SnapshotForOperationResults; (): Snapshot; - gen(params: SnapshotOperationParams): Generator, ProtocolResponse["result"]>; + gen(params: SnapshotOperationParams): Generator, ProtocolResponse["result"]>; gen(): Generator; } { const owner = this; - function createSnapshot(params: SnapshotOperationParams): SnapshotForOperationResults; + function createSnapshot(params: SnapshotOperationParams): SnapshotForOperationResults; function createSnapshot(): Snapshot; function createSnapshot(params?: CreateSnapshotParams): Snapshot { owner.ensureInitialized(); @@ -594,7 +596,7 @@ export class API implements FormatDiagnosticsHo return snapshot; } - function gen(params: SnapshotOperationParams): Generator, ProtocolResponse["result"]>; + function gen(params: SnapshotOperationParams): Generator, ProtocolResponse["result"]>; function gen(): Generator; function* gen(params?: CreateSnapshotParams): Generator { yield* owner.ensureInitialized.gen(); @@ -701,22 +703,22 @@ export class API implements FormatDiagnosticsHo * adopting any supplied API-driven changes. Only available on LSP-connected APIs. */ get getCurrentLanguageServerSnapshot(): { - ( - ...args: FromLSP extends true ? [changes: SnapshotOperationParams, baseSnapshot?: Snapshot] + ( + ...args: FromLSP extends true ? [changes: SnapshotOperationParams, baseSnapshot?: Snapshot] : [changes: never, baseSnapshot?: never] - ): SnapshotForOperationResults; + ): SnapshotForOperationResults; (...args: FromLSP extends true ? [changes?: LanguageServerSnapshotChanges, baseSnapshot?: Snapshot] : [changes: never, baseSnapshot?: never]): Snapshot; - gen( - ...args: FromLSP extends true ? [changes: SnapshotOperationParams, baseSnapshot?: Snapshot] + gen( + ...args: FromLSP extends true ? [changes: SnapshotOperationParams, baseSnapshot?: Snapshot] : [changes: never, baseSnapshot?: never] - ): Generator, ProtocolResponse["result"]>; + ): Generator, ProtocolResponse["result"]>; gen(...args: FromLSP extends true ? [changes?: LanguageServerSnapshotChanges, baseSnapshot?: Snapshot] : [changes: never, baseSnapshot?: never]): Generator; } { const owner = this; - function getCurrentLanguageServerSnapshot( - ...args: FromLSP extends true ? [changes: SnapshotOperationParams, baseSnapshot?: Snapshot] + function getCurrentLanguageServerSnapshot( + ...args: FromLSP extends true ? [changes: SnapshotOperationParams, baseSnapshot?: Snapshot] : [changes: never, baseSnapshot?: never] - ): SnapshotForOperationResults; + ): SnapshotForOperationResults; function getCurrentLanguageServerSnapshot(...args: FromLSP extends true ? [changes?: LanguageServerSnapshotChanges, baseSnapshot?: Snapshot] : [changes: never, baseSnapshot?: never]): Snapshot; function getCurrentLanguageServerSnapshot(...args: FromLSP extends true ? [changes?: LanguageServerSnapshotChanges, baseSnapshot?: Snapshot] : [changes: never, baseSnapshot?: never]): Snapshot { owner.ensureInitialized(); @@ -749,10 +751,10 @@ export class API implements FormatDiagnosticsHo owner.activeSnapshots.add(snapshot); return snapshot; } - function gen( - ...args: FromLSP extends true ? [changes: SnapshotOperationParams, baseSnapshot?: Snapshot] + function gen( + ...args: FromLSP extends true ? [changes: SnapshotOperationParams, baseSnapshot?: Snapshot] : [changes: never, baseSnapshot?: never] - ): Generator, ProtocolResponse["result"]>; + ): Generator, ProtocolResponse["result"]>; function gen(...args: FromLSP extends true ? [changes?: LanguageServerSnapshotChanges, baseSnapshot?: Snapshot] : [changes: never, baseSnapshot?: never]): Generator; function* gen(...args: FromLSP extends true ? [changes?: LanguageServerSnapshotChanges, baseSnapshot?: Snapshot] : [changes: never, baseSnapshot?: never]): Generator { yield* owner.ensureInitialized.gen(); @@ -963,33 +965,43 @@ export class API implements FormatDiagnosticsHo * the build info file configured by `tsBuildInfoFile`. */ get createIncrementalProgram(): { - (rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions): Program; - gen(rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions): Generator; + (rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions): IncrementalProgram; + gen(rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions): Generator; } { const owner = this; return cacheGeneratorMethod( owner, "createIncrementalProgram", - function (rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions): Program { + function (rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions): IncrementalProgram { owner.ensureInitialized(); const snapshot = owner.createSnapshot({ createPrograms: [{ rootFiles, options: createProgramOptions, incremental: true }], }); - return owner.getOwnedCreatedProgram(snapshot, "createIncrementalProgram"); + const program = owner.getOwnedCreatedProgram(snapshot, "createIncrementalProgram"); + if (!(program instanceof IncrementalProgram)) { + snapshot.dispose(); + throw new Error("createIncrementalProgram did not return an incremental program"); + } + return program; }, - function* (rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions): Generator { + function* (rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions): Generator { yield* owner.ensureInitialized.gen(); const snapshot = yield* owner.createSnapshot.gen({ createPrograms: [{ rootFiles, options: createProgramOptions, incremental: true }], }); - return owner.getOwnedCreatedProgram(snapshot, "createIncrementalProgram"); + const program = owner.getOwnedCreatedProgram(snapshot, "createIncrementalProgram"); + if (!(program instanceof IncrementalProgram)) { + yield* snapshot.dispose.gen(); + throw new Error("createIncrementalProgram did not return an incremental program"); + } + return program; }, ); } - private getOwnedCreatedProgram(snapshot: SnapshotForOperationResults, method: "createProgram" | "createIncrementalProgram"): Program { + private getOwnedCreatedProgram(snapshot: SnapshotForOperationResults, method: "createProgram" | "createIncrementalProgram"): Program { const program = snapshot.operation.createdPrograms[0]; if (!program) { void snapshot.dispose(); @@ -1079,6 +1091,12 @@ type SnapshotUpdater = ((params: CreateSnapshotParams) => Snapshot) & { gen(para export interface SnapshotOperation { readonly createdPrograms?: readonly Program[]; readonly openedFiles?: readonly SnapshotOpenedFileOperation[]; + readonly incrementalOperations?: readonly IncrementalOperationResult[]; +} + +export interface IncrementalOperationResult { + readonly program: IncrementalProgram; + readonly result: EmitResult; } export interface SnapshotOpenedFileOperation { @@ -1093,24 +1111,29 @@ type SnapshotOperationParams< Params extends CreateSnapshotParams, CreatePrograms extends Params["createPrograms"], OpenFiles extends Params["openFiles"], -> = Omit & { + IncrementalOperations extends Params["incrementalOperations"], +> = Omit & { createPrograms?: CreatePrograms; openFiles?: OpenFiles; + incrementalOperations?: IncrementalOperations; }; type SnapshotForOperationResults< CreatePrograms extends CreateSnapshotParams["createPrograms"], OpenFiles extends CreateSnapshotParams["openFiles"], + IncrementalOperations extends CreateSnapshotParams["incrementalOperations"], > = Snapshot & { readonly operation: & SnapshotOperation & (CreatePrograms extends readonly unknown[] ? { readonly createdPrograms: MapTupleTo>; } : unknown) - & (OpenFiles extends readonly unknown[] ? { readonly openedFiles: MapTupleTo; } : unknown); + & (OpenFiles extends readonly unknown[] ? { readonly openedFiles: MapTupleTo; } : unknown) + & (IncrementalOperations extends readonly unknown[] ? { readonly incrementalOperations: MapTupleTo; } : unknown); }; export type SnapshotForOperation = SnapshotForOperationResults< Params extends { createPrograms: infer CreatePrograms extends readonly unknown[]; } ? CreatePrograms : undefined, - Params extends { openFiles: infer OpenFiles extends readonly unknown[]; } ? OpenFiles : undefined + Params extends { openFiles: infer OpenFiles extends readonly unknown[]; } ? OpenFiles : undefined, + Params extends { incrementalOperations: infer IncrementalOperations extends readonly unknown[]; } ? IncrementalOperations : undefined >; export class Snapshot { @@ -1154,13 +1177,24 @@ export class Snapshot { this.snapshotRegistry = new SnapshotObjectRegistry(client, this.id, projectId => this.projectMap.get(projectId)); for (const projData of this.projectDataMap.values()) { - const project = new Project(projData, this.id, client, sourceFileCache, toPath, formatDiagnosticsHost, this.snapshotRegistry); + const project = new Project(projData, this.id, client, sourceFileCache, toPath, formatDiagnosticsHost, this.snapshotRegistry, this.updateSnapshot); this.projectMap.set(projData.id, project); } this.operation = { ...(data.operation.createdPrograms ? { createdPrograms: data.operation.createdPrograms.map(projectId => this.requireProject(projectId).program) } : {}), ...(data.operation.openedFiles ? { openedFiles: data.operation.openedFiles.map(result => ({ project: this.requireProject(result.project) })) } : {}), + ...(data.operation.incrementalOperations + ? { + incrementalOperations: data.operation.incrementalOperations.map(operation => { + const program = this.requireProject(operation.program).program; + if (!(program instanceof IncrementalProgram)) { + throw new Error(`Snapshot operation returned non-incremental program '${operation.program}'`); + } + return { program, result: toEmitResult(operation.result) }; + }), + } + : {}), }; this.internal = new SnapshotInternalAPI(this.id, client); @@ -1186,19 +1220,19 @@ export class Snapshot { } get update(): { - (params: SnapshotOperationParams): SnapshotForOperationResults; + (params: SnapshotOperationParams): SnapshotForOperationResults; (params: CreateSnapshotParams): Snapshot; - gen(params: SnapshotOperationParams): Generator, ProtocolResponse["result"]>; + gen(params: SnapshotOperationParams): Generator, ProtocolResponse["result"]>; gen(params: CreateSnapshotParams): Generator; } { const owner = this; - function update(params: SnapshotOperationParams): SnapshotForOperationResults; + function update(params: SnapshotOperationParams): SnapshotForOperationResults; function update(params: CreateSnapshotParams): Snapshot; function update(params: CreateSnapshotParams): Snapshot { owner.ensureNotDisposed(); return owner.updateSnapshot(params); } - function gen(params: SnapshotOperationParams): Generator, ProtocolResponse["result"]>; + function gen(params: SnapshotOperationParams): Generator, ProtocolResponse["result"]>; function gen(params: CreateSnapshotParams): Generator; function* gen(params: CreateSnapshotParams): Generator { owner.ensureNotDisposed(); @@ -1938,6 +1972,7 @@ export class Project { toPath: (fileName: string) => Path, formatDiagnosticsHost: FormatDiagnosticsHost, snapshotRegistry: SnapshotObjectRegistry, + updateSnapshot: SnapshotUpdater, ) { this.id = data.id as Id; this.configFileName = data.configFileName; @@ -1951,13 +1986,14 @@ export class Project { this.rootFiles = this.parsedCommandLine.fileNames; this.client = client; this.snapshotId = snapshotId; - this.program = new Program( + this.program = new (data.incremental ? IncrementalProgram : Program)( snapshotId, this, client, sourceFileCache, toPath, formatDiagnosticsHost, + updateSnapshot, ); const objectRegistry = new ProjectObjectRegistry(client, snapshotId, this, snapshotRegistry); this.checker = new Checker( @@ -2260,10 +2296,11 @@ export class Program implements FormatDiagnost readonly snapshotId: number; readonly id: Id; private readonly project: Project; - private readonly client: Client; + protected readonly client: Client; private readonly sourceFileCache: SourceFileCache; private readonly toPath: (fileName: string) => Path; private readonly formatDiagnosticsHost: FormatDiagnosticsHost; + protected readonly updateSnapshot: SnapshotUpdater; private readonly decoder = new Wtf8Decoder(); private readonly sourceFileMetadataCache = new Map(); private ownedSnapshot: Snapshot | undefined; @@ -2276,6 +2313,7 @@ export class Program implements FormatDiagnost sourceFileCache: SourceFileCache, toPath: (fileName: string) => Path, formatDiagnosticsHost: FormatDiagnosticsHost, + updateSnapshot: SnapshotUpdater, ) { this.snapshotId = snapshotId; this.id = project.id; @@ -2284,6 +2322,7 @@ export class Program implements FormatDiagnost this.sourceFileCache = sourceFileCache; this.toPath = toPath; this.formatDiagnosticsHost = formatDiagnosticsHost; + this.updateSnapshot = updateSnapshot; } getCurrentDirectory(): string { @@ -3103,18 +3142,7 @@ export class Program implements FormatDiagnost project: owner.project.id, emitOnly, }); - const fileSystem = response.emittedFilesContents.length - ? { - kind: "layer" as const, - files: Object.fromEntries(response.emittedFiles.map((fileName, index) => [fileName, response.emittedFilesContents[index]])), - } - : undefined; - return { - emitSkipped: response.emitSkipped, - diagnostics: response.diagnostics, - emittedFiles: response.emittedFiles, - ...(fileSystem ? { fileSystem } : {}), - }; + return toEmitResult(response); }, function* (emitOnly?: EmitOnly): Generator { const response = yield* apiRequest("emit", { @@ -3122,18 +3150,7 @@ export class Program implements FormatDiagnost project: owner.project.id, emitOnly, }); - const fileSystem = response.emittedFilesContents.length - ? { - kind: "layer" as const, - files: Object.fromEntries(response.emittedFiles.map((fileName, index) => [fileName, response.emittedFilesContents[index]])), - } - : undefined; - return { - emitSkipped: response.emitSkipped, - diagnostics: response.diagnostics, - emittedFiles: response.emittedFiles, - ...(fileSystem ? { fileSystem } : {}), - }; + return toEmitResult(response); }, ); } @@ -3233,6 +3250,150 @@ export class Program implements FormatDiagnost } } +export class IncrementalProgram extends Program { + /** + * Emits pending files and returns the new snapshot containing the advanced incremental state. + */ + override get emit(): { + (emitOnly?: EmitOnly): IncrementalEmitResult; + gen(emitOnly?: EmitOnly): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "emit", + function (emitOnly?: EmitOnly): IncrementalEmitResult { + return owner.runIncrementalOperation({ + program: owner.id as SyntheticProjectId, + kind: "emit", + emitOnly, + }); + }, + function* (emitOnly?: EmitOnly): Generator { + return yield* owner.runIncrementalOperation.gen({ + program: owner.id as SyntheticProjectId, + kind: "emit", + emitOnly, + }); + }, + ); + } + + /** + * Writes the current incremental build information and returns the new snapshot + * containing the advanced incremental state. + */ + get emitBuildInfo(): { + (): IncrementalEmitResult; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "emitBuildInfo", + function (): IncrementalEmitResult { + return owner.runIncrementalOperation({ + program: owner.id as SyntheticProjectId, + kind: "emitBuildInfo", + }); + }, + function* (): Generator { + return yield* owner.runIncrementalOperation.gen({ + program: owner.id as SyntheticProjectId, + kind: "emitBuildInfo", + }); + }, + ); + } + + /** + * Returns the current serialized incremental build information without writing it. + */ + get getBuildInfoEmit(): { + (): string; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "getBuildInfoEmit", + function (): string { + return owner.client.apiRequest("getBuildInfoEmit", { + snapshot: owner.snapshotId, + project: owner.id, + }); + }, + function* (): Generator { + return yield* apiRequest("getBuildInfoEmit", { + snapshot: owner.snapshotId, + project: owner.id, + }); + }, + ); + } + + private get runIncrementalOperation(): { + (operation: IncrementalOperationParams): IncrementalEmitResult; + gen(operation: IncrementalOperationParams): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "runIncrementalOperation", + function (operation: IncrementalOperationParams): IncrementalEmitResult { + const snapshot = owner.updateSnapshot({ + incrementalOperations: [operation], + }); + const result = snapshot.operation.incrementalOperations?.[0]; + if (!result) { + snapshot.dispose(); + throw new Error("Snapshot update did not return an incremental operation result"); + } + return { + ...result.result, + snapshot, + program: result.program, + }; + }, + function* (operation: IncrementalOperationParams): Generator { + const snapshot = yield* owner.updateSnapshot.gen({ + incrementalOperations: [operation], + }); + const result = snapshot.operation.incrementalOperations?.[0]; + if (!result) { + yield* snapshot.dispose.gen(); + throw new Error("Snapshot update did not return an incremental operation result"); + } + return { + ...result.result, + snapshot, + program: result.program, + }; + }, + ); + } +} + +export interface IncrementalEmitResult extends EmitResult { + readonly snapshot: Snapshot; + readonly program: IncrementalProgram; +} + +function toEmitResult(response: EmitResponse): EmitResult { + const fileSystem = response.emittedFilesContents.length + ? { + kind: "layer" as const, + files: Object.fromEntries(response.emittedFiles.map((fileName, index) => [fileName, response.emittedFilesContents[index]])), + } + : undefined; + return { + emitSkipped: response.emitSkipped, + diagnostics: response.diagnostics, + emittedFiles: response.emittedFiles, + ...(fileSystem ? { fileSystem } : {}), + }; +} + function toEmitOutput(response: ProtocolEmitOutputResponse): EmitOutput { const outputFiles = new Map(); for (const { fileName, ...outputFile } of response.outputFiles) { diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index d2a22e23eef98..7afb77e70bee2 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -60,6 +60,7 @@ import { EmitOnly, type FreshableType, type ImportAdderAction, + IncrementalProgram, type IndexedAccessType, IndexKind, type IndexType, @@ -528,22 +529,79 @@ declare module "augmentation" {}`, }; const firstProgram = await api.createIncrementalProgram(["/src/main.ts"], options); + const buildInfoText = await firstProgram.getBuildInfoEmit(); + assert.equal(typeof JSON.parse(buildInfoText).version, "string"); + assert.equal(fs.readFile!("/out/build.tsbuildinfo"), undefined); + const buildInfoEmit = await firstProgram.emitBuildInfo(); + using buildInfoSnapshot = buildInfoEmit.snapshot; + assert.deepEqual(buildInfoEmit.emittedFiles, ["/out/build.tsbuildinfo"]); + assert.equal(buildInfoEmit.emitSkipped, false); + assert.deepEqual(buildInfoEmit.diagnostics, []); + assert.equal(fs.readFile!("/out/build.tsbuildinfo"), buildInfoText); + const firstEmit = await firstProgram.emit(); + using firstEmitSnapshot = firstEmit.snapshot; assert.ok(firstEmit.emittedFiles.includes("/out/main.js")); assert.ok(firstEmit.emittedFiles.includes("/out/dependency.js")); assert.ok(firstEmit.emittedFiles.includes("/out/build.tsbuildinfo")); assert.ok(fs.readFile!("/out/build.tsbuildinfo")); + const repeatedEmit = await firstEmit.program.emit(); + using repeatedEmitSnapshot = repeatedEmit.snapshot; + assert.deepEqual(repeatedEmit.emittedFiles, []); await firstProgram.dispose(); fs.writeFile!("/src/dependency.ts", `export function value() { return 2; }`); const secondProgram = await api.createIncrementalProgram(["/src/main.ts"], options); const secondEmit = await secondProgram.emit(); + using secondEmitSnapshot = secondEmit.snapshot; assert.ok(!secondEmit.emittedFiles.includes("/out/main.js"), JSON.stringify(secondEmit.emittedFiles)); assert.ok(secondEmit.emittedFiles.includes("/out/dependency.js")); assert.ok(secondEmit.emittedFiles.includes("/out/build.tsbuildinfo")); await secondProgram.dispose(); }); + test("emitBuildInfo returns a filesystem layer for a full filesystem", async () => { + const hostWrites: string[] = []; + await using api = new API({ + cwd: "/", + fs: { + writeFile: path => { + hostWrites.push(path); + }, + }, + }); + using snapshot = await api.createSnapshot({ + fileSystem: createFileSystem(Object.entries({ + "/src/index.ts": `export const value = 1;`, + })), + createPrograms: [{ + rootFiles: ["/src/index.ts"], + options: { + compilerOptions: { + incremental: true, + noLib: true, + tsBuildInfoFile: "/out/build.tsbuildinfo", + }, + }, + incremental: true, + }], + }); + const program = snapshot.operation.createdPrograms[0]; + assert.ok(program instanceof IncrementalProgram); + + const buildInfoText = await program.getBuildInfoEmit(); + const result = await program.emitBuildInfo(); + using emittedSnapshot = result.snapshot; + assert.deepEqual(result.emittedFiles, ["/out/build.tsbuildinfo"]); + assert.deepEqual(result.fileSystem, { + kind: "layer", + files: { + "/out/build.tsbuildinfo": buildInfoText, + }, + }); + assert.deepEqual(hostWrites, []); + }); + test("createProgram includes project references", async () => { const reference = { path: "/lib/tsconfig.json", originalPath: "/lib/tsconfig.json", circular: false }; await using api = spawnAPI({ diff --git a/packages/typescript/test/sync/api-generators.test.ts b/packages/typescript/test/sync/api-generators.test.ts index e9bb2360ffbfb..2f00a165abbb0 100644 --- a/packages/typescript/test/sync/api-generators.test.ts +++ b/packages/typescript/test/sync/api-generators.test.ts @@ -1368,6 +1368,7 @@ describe("API - generator batching", () => { using snapshot = api.batch(api.createSnapshot.gen({ openProject: "/tsconfig.json" }))[0]; const project = snapshot.getConfiguredProject("/tsconfig.json")!; const { checker, emitter, languageService, program } = project; + const incrementalProgram = api.createIncrementalProgram([], { compilerOptions: { incremental: true, noLib: true, tsBuildInfoFile: "/build.tsbuildinfo" } }); const indexFile = program.getSourceFile("/src/index.ts")!; const modelsFile = program.getSourceFile("/src/models.ts")!; @@ -1559,6 +1560,7 @@ describe("API - generator batching", () => { parityCase("Program", "emitToString", program.emitToString, assertDeepEquivalent), parityCase("Program", "getJavaScriptEmit", program.getJavaScriptEmit, assertDeepEquivalent, ["/src/index.ts"]), parityCase("Program", "getDeclarationEmit", program.getDeclarationEmit, assertDeepEquivalent, ["/src/index.ts"]), + parityCase("IncrementalProgram", "getBuildInfoEmit", incrementalProgram.getBuildInfoEmit, assertDeepEquivalent), parityCase("Checker", "getSymbolAtLocation", selectGeneratorMethod<[node: Node], Symbol | undefined>(checker.getSymbolAtLocation), assertOptionalSymbolsEquivalent, importedDerived), parityCase("Checker", "getSymbolAtLocation", checker.getSymbolAtLocation, assertOptionalSymbolArraysEquivalent, [importedDerived, combineDeclaration.name!]), @@ -1706,6 +1708,23 @@ describe("API - generator batching", () => { runParityBatch(api, cases); assert.deepEqual(temporaryProjects, ["/tsconfig.json", "/tsconfig.json"]); + for ( + const [methodName, method] of [ + ["emit", incrementalProgram.emit], + ["emitBuildInfo", incrementalProgram.emitBuildInfo], + ] as const + ) { + const generated = api.batch(method.gen())[0]; + const direct = method(); + assert.deepEqual(generated.emittedFiles, direct.emittedFiles); + assert.deepEqual(generated.diagnostics, direct.diagnostics); + assert.deepEqual(generated.fileSystem, direct.fileSystem); + assertProgramsEquivalent(generated.program, direct.program); + generated.snapshot.dispose(); + direct.snapshot.dispose(); + exercisedMethods.add(`IncrementalProgram.${methodName}`); + } + const snapshotGeneratorAPI = spawnAPI(parityFiles); const snapshotSyncAPI = spawnAPI(parityFiles); try { diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index f52aa89f5a561..5348e4b8cce9f 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -75,6 +75,7 @@ import { EmitOnly, type FreshableType, type ImportAdderAction, + IncrementalProgram, type IndexedAccessType, IndexKind, type IndexType, @@ -517,22 +518,79 @@ declare module "augmentation" {}`, }; const firstProgram = api.createIncrementalProgram(["/src/main.ts"], options); + const buildInfoText = firstProgram.getBuildInfoEmit(); + assert.equal(typeof JSON.parse(buildInfoText).version, "string"); + assert.equal(fs.readFile!("/out/build.tsbuildinfo"), undefined); + const buildInfoEmit = firstProgram.emitBuildInfo(); + using buildInfoSnapshot = buildInfoEmit.snapshot; + assert.deepEqual(buildInfoEmit.emittedFiles, ["/out/build.tsbuildinfo"]); + assert.equal(buildInfoEmit.emitSkipped, false); + assert.deepEqual(buildInfoEmit.diagnostics, []); + assert.equal(fs.readFile!("/out/build.tsbuildinfo"), buildInfoText); + const firstEmit = firstProgram.emit(); + using firstEmitSnapshot = firstEmit.snapshot; assert.ok(firstEmit.emittedFiles.includes("/out/main.js")); assert.ok(firstEmit.emittedFiles.includes("/out/dependency.js")); assert.ok(firstEmit.emittedFiles.includes("/out/build.tsbuildinfo")); assert.ok(fs.readFile!("/out/build.tsbuildinfo")); + const repeatedEmit = firstEmit.program.emit(); + using repeatedEmitSnapshot = repeatedEmit.snapshot; + assert.deepEqual(repeatedEmit.emittedFiles, []); firstProgram.dispose(); fs.writeFile!("/src/dependency.ts", `export function value() { return 2; }`); const secondProgram = api.createIncrementalProgram(["/src/main.ts"], options); const secondEmit = secondProgram.emit(); + using secondEmitSnapshot = secondEmit.snapshot; assert.ok(!secondEmit.emittedFiles.includes("/out/main.js"), JSON.stringify(secondEmit.emittedFiles)); assert.ok(secondEmit.emittedFiles.includes("/out/dependency.js")); assert.ok(secondEmit.emittedFiles.includes("/out/build.tsbuildinfo")); secondProgram.dispose(); }); + test("emitBuildInfo returns a filesystem layer for a full filesystem", () => { + const hostWrites: string[] = []; + using api = new API({ + cwd: "/", + fs: { + writeFile: path => { + hostWrites.push(path); + }, + }, + }); + using snapshot = api.createSnapshot({ + fileSystem: createFileSystem(Object.entries({ + "/src/index.ts": `export const value = 1;`, + })), + createPrograms: [{ + rootFiles: ["/src/index.ts"], + options: { + compilerOptions: { + incremental: true, + noLib: true, + tsBuildInfoFile: "/out/build.tsbuildinfo", + }, + }, + incremental: true, + }], + }); + const program = snapshot.operation.createdPrograms[0]; + assert.ok(program instanceof IncrementalProgram); + + const buildInfoText = program.getBuildInfoEmit(); + const result = program.emitBuildInfo(); + using emittedSnapshot = result.snapshot; + assert.deepEqual(result.emittedFiles, ["/out/build.tsbuildinfo"]); + assert.deepEqual(result.fileSystem, { + kind: "layer", + files: { + "/out/build.tsbuildinfo": buildInfoText, + }, + }); + assert.deepEqual(hostWrites, []); + }); + test("createProgram includes project references", () => { const reference = { path: "/lib/tsconfig.json", originalPath: "/lib/tsconfig.json", circular: false }; using api = spawnAPI({ diff --git a/tsc/internal/api/proto.go b/tsc/internal/api/proto.go index 62130cda9c096..758d801442276 100644 --- a/tsc/internal/api/proto.go +++ b/tsc/internal/api/proto.go @@ -220,6 +220,7 @@ const ( MethodPrintNode Method = "printNode" MethodFormatNodeForInsertion Method = "formatNodeForInsertion" MethodEmit Method = "emit" + MethodGetBuildInfoEmit Method = "getBuildInfoEmit" MethodEmitToString Method = "emitToString" MethodGetJavaScriptEmit Method = "getJavaScriptEmit" MethodGetDeclarationEmit Method = "getDeclarationEmit" @@ -381,6 +382,21 @@ type SnapshotRequestChangesParams struct { // EnsurePrograms identifies projects whose programs should be updated if dirty, // or all contained projects when true. EnsurePrograms *EnsurePrograms `json:"ensurePrograms,omitempty"` + // IncrementalOperations advances incremental program state while constructing the snapshot. + IncrementalOperations []*IncrementalOperationParams `json:"incrementalOperations,omitempty"` +} + +type IncrementalOperationKind string + +const ( + IncrementalOperationKindEmit IncrementalOperationKind = "emit" + IncrementalOperationKindEmitBuildInfo IncrementalOperationKind = "emitBuildInfo" +) + +type IncrementalOperationParams struct { + Program SyntheticProjectID `json:"program"` + Kind IncrementalOperationKind `json:"kind"` + EmitOnly *uint32 `json:"emitOnly,omitempty"` } type EnsurePrograms struct { @@ -485,8 +501,14 @@ type CreateSnapshotResponse struct { } type SnapshotOperationResponse struct { - CreatedPrograms *[]SyntheticProjectID `json:"createdPrograms,omitzero"` - OpenedFiles *[]*OpenedFileOperationResult `json:"openedFiles,omitzero"` + CreatedPrograms *[]SyntheticProjectID `json:"createdPrograms,omitzero"` + OpenedFiles *[]*OpenedFileOperationResult `json:"openedFiles,omitzero"` + IncrementalOperations *[]*IncrementalOperationResultResponse `json:"incrementalOperations,omitzero"` +} + +type IncrementalOperationResultResponse struct { + Program ProjectID `json:"program"` + Result *EmitResponse `json:"result" nonnil:"true"` } type OpenedFileOperationResult struct { @@ -625,6 +647,7 @@ var unmarshalers = map[Method]func([]byte) (any, error){ MethodPrintNode: unmarshallerFor[PrintNodeParams], MethodFormatNodeForInsertion: unmarshallerFor[FormatNodeForInsertionParams], MethodEmit: unmarshallerFor[EmitParams], + MethodGetBuildInfoEmit: unmarshallerFor[GetProjectDiagnosticsParams], MethodEmitToString: unmarshallerFor[EmitParams], MethodGetJavaScriptEmit: unmarshallerFor[SelectedFilesEmitParams], MethodGetDeclarationEmit: unmarshallerFor[SelectedFilesEmitParams], @@ -820,6 +843,7 @@ type ProjectResponse struct { ConfigFileName string `json:"configFileName"` CurrentDirectory string `json:"currentDirectory"` Dirty bool `json:"dirty"` + Incremental bool `json:"incremental"` ParsedCommandLine *ConfigFileResponse `json:"parsedCommandLine" nonnil:"true"` // Deprecated: Use parsedCommandLine.fileNames. RootFiles []string `json:"rootFiles" nonnil:"true"` @@ -889,6 +913,7 @@ func NewProjectResponse(p *project.Project) *ProjectResponse { ConfigFileName: p.Name(), CurrentDirectory: p.CurrentDirectory(), Dirty: p.IsDirty(), + Incremental: p.IsIncremental(), ParsedCommandLine: NewConfigFileResponse(p.CommandLine), RootFiles: p.CommandLine.FileNames(), CompilerOptions: p.CommandLine.CompilerOptions(), diff --git a/tsc/internal/api/session.go b/tsc/internal/api/session.go index f46dcb521bc04..88e4ae4ce132d 100644 --- a/tsc/internal/api/session.go +++ b/tsc/internal/api/session.go @@ -48,10 +48,9 @@ var sessionIDCounter atomic.Uint64 // Multiple clients may hold references to the same snapshot via ref counting; // the registries are cleaned up when refCount reaches zero. type snapshotData struct { - snapshot *project.Snapshot - fileSystem vfs.FS - incrementalPrograms map[ProjectID]*incremental.Program - refCount int + snapshot *project.Snapshot + fileSystem vfs.FS + refCount int openProjects collections.Set[tspath.Path] openFiles collections.Set[tspath.Path] @@ -103,10 +102,23 @@ func (sd *snapshotData) getProgram(projectHandle ProjectID) (*compiler.Program, } func (sd *snapshotData) getProgramLike(projectHandle ProjectID) (compiler.ProgramLike, error) { - if program := sd.incrementalPrograms[projectHandle]; program != nil { - return program, nil + proj, err := sd.getProject(projectHandle) + if err != nil { + return nil, err + } + return proj.GetProgramLike(), nil +} + +func (sd *snapshotData) getIncrementalProgram(projectHandle ProjectID) (*incremental.Program, error) { + proj, err := sd.getProject(projectHandle) + if err != nil { + return nil, err } - return sd.getProgram(projectHandle) + program := proj.GetIncrementalProgram() + if program == nil { + return nil, fmt.Errorf("%w: project %s is not incremental", ErrClientError, projectHandle) + } + return program, nil } // getProject looks up a project from a project handle within this snapshot. @@ -860,6 +872,8 @@ func (s *Session) HandleRequest(ctx context.Context, method string, params json. return s.handleFormatNodeForInsertion(ctx, parsed.(*FormatNodeForInsertionParams)) case string(MethodEmit): return s.handleEmit(ctx, parsed.(*EmitParams)) + case string(MethodGetBuildInfoEmit): + return s.handleGetBuildInfoEmit(ctx, parsed.(*GetProjectDiagnosticsParams)) case string(MethodEmitToString): return s.handleEmitToString(ctx, parsed.(*EmitParams)) case string(MethodGetJavaScriptEmit): @@ -1157,6 +1171,7 @@ func (s *Session) handleCreateSnapshot(ctx context.Context, params *CreateSnapsh apiRequest.FileSystem = fileSystem apiRequest.ReplaceFileSystem = params.FileSystem.Kind == requestfilesystem.KindFull } + s.configureIncrementalOperations(apiRequest, snapshotFileSystem) root := s.snapshotHost.NewRootSnapshot() snapshot, err := s.snapshotHost.CloneSnapshot(ctx, root, fileChanges, apiRequest) root.Deref() @@ -1165,8 +1180,8 @@ func (s *Session) handleCreateSnapshot(ctx context.Context, params *CreateSnapsh return nil, fmt.Errorf("%w: failed to create snapshot: %w", ErrClientError, err) } - response := s.createSnapshotResponse(snapshot, nil, ¶ms.SnapshotRequestChangesParams) - s.registerSnapshot(snapshot, openState, snapshotFileSystem, nil, ¶ms.SnapshotRequestChangesParams) + response := s.createSnapshotResponse(snapshot, nil, ¶ms.SnapshotRequestChangesParams, apiRequest) + s.registerSnapshot(snapshot, openState, snapshotFileSystem) return response, nil } @@ -1203,14 +1218,15 @@ func (s *Session) handleUpdateSnapshot(ctx context.Context, params *UpdateSnapsh apiRequest.FileSystem = snapshotFileSystem apiRequest.ReplaceFileSystem = changes.FileSystem != nil && changes.FileSystem.Kind == requestfilesystem.KindFull } + s.configureIncrementalOperations(apiRequest, snapshotFileSystem) snapshot, err := s.snapshotHost.CloneSnapshot(ctx, baseSD.snapshot, fileChanges, apiRequest) if err != nil { snapshot.Deref() return nil, fmt.Errorf("%w: failed to update snapshot: %w", ErrClientError, err) } - response := s.createSnapshotResponse(snapshot, baseSD.snapshot, &changes.SnapshotRequestChangesParams) - s.registerSnapshot(snapshot, openState, snapshotFileSystem, baseSD, &changes.SnapshotRequestChangesParams) + response := s.createSnapshotResponse(snapshot, baseSD.snapshot, &changes.SnapshotRequestChangesParams, apiRequest) + s.registerSnapshot(snapshot, openState, snapshotFileSystem) return response, nil } @@ -1271,6 +1287,7 @@ func (s *Session) toAPISnapshotRequest(changes *SnapshotRequestChangesParams) (* CompilerOptions: &programParams.Options.CompilerOptions, ProjectReferences: programParams.Options.ProjectReferences, ConfigFileParsingDiagnostics: core.Map(programParams.Options.ConfigFileParsingDiagnostics, func(d *DiagnosticResponse) *ast.Diagnostic { return d.ToDiagnostic() }), + Incremental: programParams.Incremental, } } apiRequest.ReconfigurePrograms = make([]*project.APIReconfigureProgramRequest, len(changes.ReconfigurePrograms)) @@ -1321,9 +1338,47 @@ func (s *Session) toAPISnapshotRequest(changes *SnapshotRequestChangesParams) (* apiRequest.EnsurePrograms.Add(parseProjectHandle(program)) } } + apiRequest.IncrementalOperations = make([]*project.APIIncrementalOperationRequest, len(changes.IncrementalOperations)) + for i, operation := range changes.IncrementalOperations { + if operation == nil { + return nil, fmt.Errorf("%w: incrementalOperations[%d] must not be null", ErrClientError, i) + } + programID, ok := project.SyntheticProgramID(tspath.Path(operation.Program)) + if !ok { + return nil, fmt.Errorf("%w: invalid synthetic project handle: %s", ErrClientError, operation.Program) + } + emitOnly, err := getEmitOnly(operation.EmitOnly) + if err != nil { + return nil, err + } + apiRequest.IncrementalOperations[i] = &project.APIIncrementalOperationRequest{ + ProgramID: programID, + Kind: project.APIIncrementalOperationKind(operation.Kind), + EmitOnly: emitOnly, + } + } return apiRequest, nil } +func (s *Session) configureIncrementalOperations(apiRequest *project.APISnapshotRequest, fileSystem vfs.FS) { + for _, operation := range apiRequest.IncrementalOperations { + if requestfilesystem.HasFullFileSystem(fileSystem) { + var outputMu sync.Mutex + operation.EmittedFilesContents = make(map[string]string) + operation.WriteFile = func(fileName string, text string, _ *compiler.WriteFileData) error { + outputMu.Lock() + operation.EmittedFilesContents[fileName] = text + outputMu.Unlock() + return nil + } + } else { + operation.WriteFile = func(fileName string, text string, _ *compiler.WriteFileData) error { + return s.snapshotHost.FS().WriteFile(fileName, text) + } + } + } +} + type languageServerSnapshotUpdate struct { request *project.APISnapshotRequest openState snapshotOpenState @@ -1352,6 +1407,11 @@ func (s *Session) toLanguageServerSnapshotUpdate(changes *SnapshotRequestChanges return nil, fmt.Errorf("%w: synthetic program is not owned by this API session: %d", ErrClientError, reconfigure.ProgramID) } } + for _, operation := range apiRequest.IncrementalOperations { + if !s.createdPrograms.Has(operation.ProgramID) { + return nil, fmt.Errorf("%w: incremental program is not owned by this API session: %d", ErrClientError, operation.ProgramID) + } + } return update, nil } @@ -1413,13 +1473,7 @@ func (s *Session) reconcileSnapshotOpens(apiRequest *project.APISnapshotRequest, return state } -func (s *Session) registerSnapshot( - snapshot *project.Snapshot, - openState snapshotOpenState, - fileSystem vfs.FS, - baseSD *snapshotData, - request *SnapshotRequestChangesParams, -) { +func (s *Session) registerSnapshot(snapshot *project.Snapshot, openState snapshotOpenState, fileSystem vfs.FS) { // If the same snapshot ID is returned (no changes), we increment the ref count // so each client-side Snapshot can be disposed independently. handle := snapshotHandle(snapshot) @@ -1434,7 +1488,6 @@ func (s *Session) registerSnapshot( sd = &snapshotData{ snapshot: snapshot, fileSystem: fileSystem, - incrementalPrograms: s.createIncrementalPrograms(snapshot, baseSD, request), refCount: 1, openProjects: *openState.openProjects.Clone(), openFiles: *openState.openFiles.Clone(), @@ -1447,45 +1500,13 @@ func (s *Session) registerSnapshot( s.snapshotsMu.Unlock() } -func (s *Session) createIncrementalPrograms( - snapshot *project.Snapshot, - baseSD *snapshotData, - request *SnapshotRequestChangesParams, -) map[ProjectID]*incremental.Program { - programs := make(map[ProjectID]*incremental.Program) - if baseSD != nil { - for id, oldProgram := range baseSD.incrementalPrograms { - proj := snapshot.ProjectCollection.GetProjectByPath(parseProjectHandle(id)) - if proj != nil && proj.Program != nil { - host := proj.CompilerHost() - programs[id] = incremental.NewProgram(proj.Program, oldProgram, incremental.CreateHost(host), nil, false) - } - } - } - if request != nil { - createdPrograms := snapshot.CreatedPrograms() - for i, create := range request.CreatePrograms { - if !create.Incremental { - continue - } - proj := createdPrograms[i] - host := proj.CompilerHost() - oldProgram := incremental.ReadBuildInfoProgram(proj.CommandLine, incremental.NewBuildInfoReader(host), host) - programs[ProjectHandle(proj)] = incremental.NewProgram(proj.Program, oldProgram, incremental.CreateHost(host), nil, false) - } - } - return programs -} - func (s *Session) handleGetCurrentLanguageServerSnapshot(ctx context.Context, params *GetCurrentLanguageServerSnapshotParams) (*CreateSnapshotResponse, error) { if s.projectSession == nil { return nil, fmt.Errorf("%w: getCurrentLanguageServerSnapshot requires an LSP-connected API session", ErrClientError) } var baseSnapshot *project.Snapshot - var baseSD *snapshotData if params.BaseSnapshot != 0 { - var err error - baseSD, err = s.retainSnapshotData(params.BaseSnapshot) + baseSD, err := s.retainSnapshotData(params.BaseSnapshot) if err != nil { return nil, err } @@ -1504,6 +1525,7 @@ func (s *Session) handleGetCurrentLanguageServerSnapshot(ctx context.Context, pa if err != nil { return nil, err } + s.configureIncrementalOperations(update.request, nil) snapshot, err := s.projectSession.APIUpdate(ctx, project.FileChangeSummary{}, update.request) if err != nil { @@ -1511,8 +1533,8 @@ func (s *Session) handleGetCurrentLanguageServerSnapshot(ctx context.Context, pa } update.commit(s, snapshot) - response := s.createSnapshotResponse(snapshot, baseSnapshot, &changes.SnapshotRequestChangesParams) - s.registerSnapshot(snapshot, snapshotOpenState{openProjects: s.openProjects, openFiles: s.openFiles}, nil, baseSD, &changes.SnapshotRequestChangesParams) + response := s.createSnapshotResponse(snapshot, baseSnapshot, &changes.SnapshotRequestChangesParams, update.request) + s.registerSnapshot(snapshot, snapshotOpenState{openProjects: s.openProjects, openFiles: s.openFiles}, nil) return response, nil } @@ -1657,8 +1679,8 @@ func transpileOutput(ctx context.Context, input string, options TranspileOptions output = transpile.TranspileModule(ctx, input, transpileOptions) } if output == nil { - if err := ctx.Err(); err != nil { - return nil, err + if contextErr := ctx.Err(); contextErr != nil { + return nil, contextErr } return nil, errors.New("transpilation produced no output") } @@ -3235,8 +3257,31 @@ func (s *Session) handleEmit(ctx context.Context, params *EmitParams) (*EmitResp if err != nil { return nil, err } - var outputFiles map[string]string + return s.handleEmitWorker(ctx, params.Snapshot, options, func(options compiler.EmitOptions) (*compiler.EmitResult, error) { + return emitProgram(ctx, program, options) + }) +} + +func (s *Session) handleGetBuildInfoEmit(ctx context.Context, params *GetProjectDiagnosticsParams) (string, error) { sd, err := s.getSnapshotData(params.Snapshot) + if err != nil { + return "", err + } + program, err := sd.getIncrementalProgram(params.Project) + if err != nil { + return "", err + } + return program.GetBuildInfoEmit(ctx) +} + +func (s *Session) handleEmitWorker( + ctx context.Context, + snapshot SnapshotID, + options compiler.EmitOptions, + emit func(options compiler.EmitOptions) (*compiler.EmitResult, error), +) (*EmitResponse, error) { + var outputFiles map[string]string + sd, err := s.getSnapshotData(snapshot) if err != nil { return nil, err } @@ -3254,10 +3299,14 @@ func (s *Session) handleEmit(ctx context.Context, params *EmitParams) (*EmitResp return s.snapshotHost.FS().WriteFile(fileName, text) } } - result, err := emitProgram(ctx, program, options) + result, err := emit(options) if err != nil { return nil, err } + return newEmitResponse(result, outputFiles), nil +} + +func newEmitResponse(result *compiler.EmitResult, outputFiles map[string]string) *EmitResponse { emittedFiles := slices.Clone(result.EmittedFiles) if emittedFiles == nil { emittedFiles = []string{} @@ -3274,7 +3323,7 @@ func (s *Session) handleEmit(ctx context.Context, params *EmitParams) (*EmitResp Diagnostics: nonNilDiagnostics(result.Diagnostics), EmittedFiles: emittedFiles, EmittedFilesContents: emittedFilesContents, - }, nil + } } func (s *Session) handleEmitToString(ctx context.Context, params *EmitParams) (*EmitOutputResponse, error) { @@ -4293,8 +4342,13 @@ func computeSnapshotChanges(prev *project.Snapshot, next *project.Snapshot) *Sna return &changes } -func (s *Session) createSnapshotResponse(snapshot *project.Snapshot, base *project.Snapshot, request *SnapshotRequestChangesParams) *CreateSnapshotResponse { - operation := s.createSnapshotOperationResponse(snapshot, request) +func (s *Session) createSnapshotResponse( + snapshot *project.Snapshot, + base *project.Snapshot, + request *SnapshotRequestChangesParams, + apiRequest *project.APISnapshotRequest, +) *CreateSnapshotResponse { + operation := s.createSnapshotOperationResponse(snapshot, request, apiRequest) if base == nil { projects := snapshot.ProjectCollection.Projects() projectResponses := make([]*ProjectResponse, 0, len(projects)) @@ -4329,7 +4383,11 @@ func (s *Session) createSnapshotResponse(snapshot *project.Snapshot, base *proje } } -func (s *Session) createSnapshotOperationResponse(snapshot *project.Snapshot, request *SnapshotRequestChangesParams) *SnapshotOperationResponse { +func (s *Session) createSnapshotOperationResponse( + snapshot *project.Snapshot, + request *SnapshotRequestChangesParams, + apiRequest *project.APISnapshotRequest, +) *SnapshotOperationResponse { operation := &SnapshotOperationResponse{} if request == nil { return operation @@ -4355,6 +4413,16 @@ func (s *Session) createSnapshotOperationResponse(snapshot *project.Snapshot, re } operation.OpenedFiles = &results } + if request.IncrementalOperations != nil { + results := make([]*IncrementalOperationResultResponse, len(apiRequest.IncrementalOperations)) + for i, result := range apiRequest.IncrementalOperations { + results[i] = &IncrementalOperationResultResponse{ + Program: ProjectID(request.IncrementalOperations[i].Program), + Result: newEmitResponse(result.Result, result.EmittedFilesContents), + } + } + operation.IncrementalOperations = &results + } return operation } diff --git a/tsc/internal/api/session_createprogram_test.go b/tsc/internal/api/session_createprogram_test.go index add6cf603fd23..308f7a407f256 100644 --- a/tsc/internal/api/session_createprogram_test.go +++ b/tsc/internal/api/session_createprogram_test.go @@ -7,6 +7,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/bundled" "github.com/microsoft/TypeScript/tsc/internal/core" + "github.com/microsoft/TypeScript/tsc/internal/execute/incremental" "github.com/microsoft/TypeScript/tsc/internal/json" "github.com/microsoft/TypeScript/tsc/internal/testutil/projecttestutil" "github.com/microsoft/TypeScript/tsc/internal/tspath" @@ -188,13 +189,47 @@ func TestIncrementalProgramComposesWithSnapshotUpdates(t *testing.T) { }) assert.NilError(t, err) programID := (*created.Operation.CreatedPrograms)[0] + buildInfoText, err := session.handleGetBuildInfoEmit(ctx, &GetProjectDiagnosticsParams{Snapshot: created.Snapshot, Project: ProjectID(programID)}) + assert.NilError(t, err) + assert.Assert(t, len(buildInfoText) > 0) + _, buildInfoExists := sessionUtils.FS().ReadFile(buildInfoFile) + assert.Assert(t, !buildInfoExists) + emittedBuildInfoSnapshot, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ + Snapshot: created.Snapshot, + Changes: &CreateSnapshotParams{ + IncrementalOperations: []*IncrementalOperationParams{{ + Program: programID, + Kind: IncrementalOperationKindEmitBuildInfo, + }}, + }, + }) + assert.NilError(t, err) + buildInfoEmit := (*emittedBuildInfoSnapshot.Operation.IncrementalOperations)[0].Result + assert.DeepEqual(t, buildInfoEmit.EmittedFiles, []string{buildInfoFile}) + writtenBuildInfo, ok := sessionUtils.FS().ReadFile(buildInfoFile) + assert.Assert(t, ok) + assert.Equal(t, writtenBuildInfo, buildInfoText) + firstEmit, err := session.handleEmit(ctx, &EmitParams{Snapshot: created.Snapshot, Project: ProjectID(programID)}) assert.NilError(t, err) assert.Assert(t, slices.Contains(firstEmit.EmittedFiles, buildInfoFile)) + buildInfoAfterEmit, err := session.handleGetBuildInfoEmit(ctx, &GetProjectDiagnosticsParams{Snapshot: created.Snapshot, Project: ProjectID(programID)}) + assert.NilError(t, err) + assert.Equal(t, buildInfoAfterEmit, buildInfoText) + + restored, err := session.handleCreateSnapshot(ctx, &CreateSnapshotParams{ + CreatePrograms: []*CreateSnapshotProgramParams{{ + RootFiles: []DocumentIdentifier{{FileName: mainFile}}, + Options: options(), + Incremental: true, + }}, + }) + assert.NilError(t, err) + restoredProgramID := (*restored.Operation.CreatedPrograms)[0] assert.NilError(t, sessionUtils.FS().WriteFile(dependencyFile, `export function value() { return 2; }`)) dirty, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ - Snapshot: created.Snapshot, + Snapshot: restored.Snapshot, Changes: &CreateSnapshotParams{ FileNotifications: &FileNotifications{Changed: []DocumentIdentifier{{FileName: dependencyFile}}}, }, @@ -202,28 +237,31 @@ func TestIncrementalProgramComposesWithSnapshotUpdates(t *testing.T) { assert.NilError(t, err) dirtySnapshot, err := session.getSnapshotData(dirty.Snapshot) assert.NilError(t, err) - assert.Assert(t, dirtySnapshot.incrementalPrograms[ProjectID(programID)] != nil) - dirtyProject, err := dirtySnapshot.getProject(ProjectID(programID)) + dirtyProject, err := dirtySnapshot.getProject(ProjectID(restoredProgramID)) assert.NilError(t, err) + _, isIncremental := dirtyProject.GetProgramLike().(*incremental.Program) + assert.Assert(t, isIncremental) assert.Assert(t, dirtyProject.IsDirty()) - ensured, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ + emittedSnapshot, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ Snapshot: dirty.Snapshot, Changes: &CreateSnapshotParams{ - EnsurePrograms: &EnsurePrograms{Projects: []ProjectID{ProjectID(programID)}}, + IncrementalOperations: []*IncrementalOperationParams{{ + Program: restoredProgramID, + Kind: IncrementalOperationKindEmit, + }}, }, }) assert.NilError(t, err) - emitted, err := session.handleEmit(ctx, &EmitParams{Snapshot: ensured.Snapshot, Project: ProjectID(programID)}) - assert.NilError(t, err) + emitted := (*emittedSnapshot.Operation.IncrementalOperations)[0].Result assert.Assert(t, !slices.Contains(emitted.EmittedFiles, "/home/projects/p/out/main.js"), "unexpected emitted files: %v", emitted.EmittedFiles) assert.Assert(t, slices.Contains(emitted.EmittedFiles, "/home/projects/p/out/dependency.js")) reconfigured, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ - Snapshot: ensured.Snapshot, + Snapshot: emittedSnapshot.Snapshot, Changes: &CreateSnapshotParams{ ReconfigurePrograms: []*ReconfigureSnapshotProgramParams{{ - Id: programID, + Id: restoredProgramID, RootFiles: []DocumentIdentifier{{FileName: mainFile}}, Options: options(), }}, @@ -232,7 +270,10 @@ func TestIncrementalProgramComposesWithSnapshotUpdates(t *testing.T) { assert.NilError(t, err) reconfiguredSnapshot, err := session.getSnapshotData(reconfigured.Snapshot) assert.NilError(t, err) - assert.Assert(t, reconfiguredSnapshot.incrementalPrograms[ProjectID(programID)] != nil) + reconfiguredProject, err := reconfiguredSnapshot.getProject(ProjectID(restoredProgramID)) + assert.NilError(t, err) + _, isIncremental = reconfiguredProject.GetProgramLike().(*incremental.Program) + assert.Assert(t, isIncremental) } func TestReconfigureSyntheticProgramValidation(t *testing.T) { diff --git a/tsc/internal/execute/incremental/program.go b/tsc/internal/execute/incremental/program.go index d8b6a5dbb1c2c..ae0cbe7fda042 100644 --- a/tsc/internal/execute/incremental/program.go +++ b/tsc/internal/execute/incremental/program.go @@ -2,6 +2,7 @@ package incremental import ( "context" + "errors" "fmt" "slices" "strings" @@ -66,6 +67,15 @@ func NewProgram(program *compiler.Program, oldProgram *Program, host Host, neste return incrementalProgram } +func (p *Program) Fork() *Program { + return &Program{ + snapshot: createProgramSnapshot(p.program, p, p.snapshot.hashWithText), + program: p.program, + host: p.host, + nestedEmitNow: p.nestedEmitNow, + } +} + type TestingData struct { SemanticDiagnosticsPerFile *collections.SyncMap[tspath.Path, *DiagnosticsOrBuildInfoDiagnosticsWithFileName] OldProgramSemanticDiagnosticsPerFile *collections.SyncMap[tspath.Path, *DiagnosticsOrBuildInfoDiagnosticsWithFileName] @@ -330,6 +340,57 @@ func (p *Program) emitBuildInfo(ctx context.Context, options compiler.EmitOption if buildInfoFileName == "" || p.program.IsEmitBlocked(buildInfoFileName) { return nil } + text, buildInfo, err := p.getBuildInfoEmit(ctx, buildInfoFileName) + if err != nil { + return &compiler.EmitResult{ + EmitSkipped: true, + Diagnostics: []*ast.Diagnostic{ + compiler.ContentMapperProjectDiagnostic(err), + }, + } + } + if !p.snapshot.buildInfoEmitPending.Load() { + return nil + } + return p.writeBuildInfo(buildInfoFileName, text, buildInfo, options) +} + +func (p *Program) GetBuildInfoEmit(ctx context.Context) (string, error) { + buildInfoFileName := outputpaths.GetBuildInfoFileName(p.snapshot.options, tspath.ComparePathsOptions{ + CurrentDirectory: p.program.GetCurrentDirectory(), + UseCaseSensitiveFileNames: p.program.UseCaseSensitiveFileNames(), + }) + if buildInfoFileName == "" { + return "", errors.New("build info emit is not configured") + } + if p.program.IsEmitBlocked(buildInfoFileName) { + return "", fmt.Errorf("build info emit is blocked for %s", buildInfoFileName) + } + text, _, err := p.getBuildInfoEmit(ctx, buildInfoFileName) + return text, err +} + +func (p *Program) EmitBuildInfo(ctx context.Context, options compiler.EmitOptions) *compiler.EmitResult { + buildInfoFileName := outputpaths.GetBuildInfoFileName(p.snapshot.options, tspath.ComparePathsOptions{ + CurrentDirectory: p.program.GetCurrentDirectory(), + UseCaseSensitiveFileNames: p.program.UseCaseSensitiveFileNames(), + }) + if buildInfoFileName == "" || p.program.IsEmitBlocked(buildInfoFileName) { + return &compiler.EmitResult{EmitSkipped: true} + } + text, buildInfo, err := p.getBuildInfoEmit(ctx, buildInfoFileName) + if err != nil { + return &compiler.EmitResult{ + EmitSkipped: true, + Diagnostics: []*ast.Diagnostic{ + compiler.ContentMapperProjectDiagnostic(err), + }, + } + } + return p.writeBuildInfo(buildInfoFileName, text, buildInfo, options) +} + +func (p *Program) getBuildInfoEmit(ctx context.Context, buildInfoFileName string) (string, *BuildInfo, error) { if p.snapshot.hasErrors == core.TSUnknown { p.ensureHasErrorsForState(ctx, p.program) if p.snapshot.hasErrors != p.snapshot.hasErrorsFromOldState || p.snapshot.hasSemanticErrors != p.snapshot.hasSemanticErrorsFromOldState { @@ -343,31 +404,28 @@ func (p *Program) emitBuildInfo(ctx context.Context, options compiler.EmitOption p.snapshot.buildInfoEmitPending.Store(true) } } - if !p.snapshot.buildInfoEmitPending.Load() { - return nil - } - if ctx.Err() != nil { - return nil + if err := ctx.Err(); err != nil { + return "", nil, err } buildInfo, err := snapshotToBuildInfo(p.snapshot, p.program, buildInfoFileName) if err != nil { - return &compiler.EmitResult{ - EmitSkipped: true, - Diagnostics: []*ast.Diagnostic{ - compiler.ContentMapperProjectDiagnostic(err), - }, - } + return "", nil, err } text, err := json.Marshal(buildInfo) if err != nil { panic(fmt.Sprintf("Failed to marshal build info: %v", err)) } + return string(text), buildInfo, nil +} + +func (p *Program) writeBuildInfo(buildInfoFileName string, text string, buildInfo *BuildInfo, options compiler.EmitOptions) *compiler.EmitResult { + var err error if options.WriteFile != nil { - err = options.WriteFile(buildInfoFileName, string(text), &compiler.WriteFileData{ + err = options.WriteFile(buildInfoFileName, text, &compiler.WriteFileData{ BuildInfo: buildInfo, }) } else { - err = p.program.Host().FS().WriteFile(buildInfoFileName, string(text)) + err = p.program.Host().FS().WriteFile(buildInfoFileName, text) } if err != nil { return &compiler.EmitResult{ diff --git a/tsc/internal/execute/incremental/programtosnapshot.go b/tsc/internal/execute/incremental/programtosnapshot.go index 27bfffb552b1c..bb19bcd99d9bd 100644 --- a/tsc/internal/execute/incremental/programtosnapshot.go +++ b/tsc/internal/execute/incremental/programtosnapshot.go @@ -17,6 +17,10 @@ func programToSnapshot(program *compiler.Program, oldProgram *Program, hashWithT if oldProgram != nil && oldProgram.program == program { return oldProgram.snapshot } + return createProgramSnapshot(program, oldProgram, hashWithText) +} + +func createProgramSnapshot(program *compiler.Program, oldProgram *Program, hashWithText bool) *snapshot { snapshot := &snapshot{ options: program.Options(), hashWithText: hashWithText, diff --git a/tsc/internal/project/project.go b/tsc/internal/project/project.go index 0e2b501e4a5de..15465e41a4a45 100644 --- a/tsc/internal/project/project.go +++ b/tsc/internal/project/project.go @@ -13,6 +13,7 @@ import ( "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/execute/incremental" "github.com/microsoft/TypeScript/tsc/internal/ls" "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" "github.com/microsoft/TypeScript/tsc/internal/project/ata" @@ -84,6 +85,8 @@ type Project struct { commandLineWithTypingsFiles *tsoptions.ParsedCommandLine commandLineWithTypingsFilesOnce sync.Once Program *compiler.Program + incrementalProgram *incremental.Program + incremental bool // The kind of update that was performed on the program last time it was updated. ProgramUpdateKind ProgramUpdateKind // The ID of the snapshot that created the program stored in this project. @@ -283,9 +286,22 @@ func (p *Project) GetProgram() *compiler.Program { return p.Program } -// CompilerHost returns the frozen host associated with the current program. -func (p *Project) CompilerHost() compiler.CompilerHost { - return p.host +func (p *Project) GetProgramLike() compiler.ProgramLike { + if p.incrementalProgram != nil { + return p.incrementalProgram.Fork() + } + return p.Program +} + +func (p *Project) GetIncrementalProgram() *incremental.Program { + if p.incrementalProgram == nil { + return nil + } + return p.incrementalProgram.Fork() +} + +func (p *Project) IsIncremental() bool { + return p.incremental } func (p *Project) IsDirty() bool { @@ -333,6 +349,8 @@ func (p *Project) Clone() *Project { CommandLine: p.CommandLine, commandLineWithTypingsFiles: p.commandLineWithTypingsFiles, Program: p.Program, + incrementalProgram: p.incrementalProgram, + incremental: p.incremental, ProgramUpdateKind: ProgramUpdateKindNone, ProgramLastUpdate: p.ProgramLastUpdate, potentialProjectReferences: p.potentialProjectReferences, @@ -437,6 +455,10 @@ func (p *Project) CreateProgram() CreateProgramResult { // Create the command line, potentially augmented with typing files commandLine := p.getCommandLineWithTypingsFiles() + oldIncrementalProgram := p.incrementalProgram + if p.incremental && oldIncrementalProgram == nil { + oldIncrementalProgram = incremental.ReadBuildInfoProgram(commandLine, incremental.NewBuildInfoReader(p.host), p.host) + } if p.dirtyFilePath != "" && p.Program != nil && p.Program.CommandLine() == commandLine { var dirtyFile *ast.SourceFile newProgram, dirtyFile, programCloned = p.Program.UpdateProgram(p.dirtyFilePath, p.host, createCheckerPool) @@ -494,6 +516,9 @@ func (p *Project) CreateProgram() CreateProgramResult { } newProgram.BindSourceFiles() + if p.incremental { + p.incrementalProgram = incremental.NewProgram(newProgram, oldIncrementalProgram, incremental.CreateHost(p.host), nil, false) + } return CreateProgramResult{ Program: newProgram, diff --git a/tsc/internal/project/projectcollectionbuilder.go b/tsc/internal/project/projectcollectionbuilder.go index d77440bd69dd4..56a3f943db0c3 100644 --- a/tsc/internal/project/projectcollectionbuilder.go +++ b/tsc/internal/project/projectcollectionbuilder.go @@ -307,6 +307,19 @@ func (b *ProjectCollectionBuilder) HandleAPIRequest(apiRequest *APISnapshotReque } b.deleteProject(project, logger) } + seenIncrementalOperations := collections.Set[int]{} + for _, request := range apiRequest.IncrementalOperations { + if seenIncrementalOperations.Has(request.ProgramID) { + return fmt.Errorf("incremental program operated on more than once: %d", request.ProgramID) + } + seenIncrementalOperations.Add(request.ProgramID) + if apiRequest.RemovePrograms.Has(request.ProgramID) { + return fmt.Errorf("incremental program cannot be operated on and removed: %d", request.ProgramID) + } + if request.Kind != APIIncrementalOperationKindEmit && request.Kind != APIIncrementalOperationKindEmitBuildInfo { + return fmt.Errorf("unsupported incremental operation: %s", request.Kind) + } + } createdPrograms := make([]*Project, len(apiRequest.CreatePrograms)) createdEntries := make([]*dirty.SyncMapEntry[tspath.Path, *Project], len(apiRequest.CreatePrograms)) for i, request := range apiRequest.CreatePrograms { @@ -317,6 +330,7 @@ func (b *ProjectCollectionBuilder) HandleAPIRequest(apiRequest *APISnapshotReque request.ProjectReferences, request.ConfigFileParsingDiagnostics, b.inferredContentMappers, + &request.Incremental, logger, ) createdEntries[i] = entry @@ -331,6 +345,7 @@ func (b *ProjectCollectionBuilder) HandleAPIRequest(apiRequest *APISnapshotReque request.ProjectReferences, request.ConfigFileParsingDiagnostics, b.inferredContentMappers, + nil, logger, ) } @@ -364,6 +379,45 @@ func (b *ProjectCollectionBuilder) HandleAPIRequest(apiRequest *APISnapshotReque return true }) } + for _, request := range apiRequest.IncrementalOperations { + projectPath := b.toPath(syntheticProjectName(request.ProgramID)) + entry, ok := b.syntheticProjects.Load(projectPath) + if !ok { + return fmt.Errorf("incremental program not found: %d", request.ProgramID) + } + if entry.Value().dirty { + b.updateProgram(entry, logger) + } + var operationError error + entry.Change(func(project *Project) { + if project.incrementalProgram == nil { + operationError = fmt.Errorf("project is not incremental: %d", request.ProgramID) + return + } + program := project.incrementalProgram.Fork() + options := compiler.EmitOptions{ + EmitOnly: request.EmitOnly, + WriteFile: request.WriteFile, + } + switch request.Kind { + case APIIncrementalOperationKindEmit: + request.Result = program.Emit(b.ctx, options) + case APIIncrementalOperationKindEmitBuildInfo: + request.Result = program.EmitBuildInfo(b.ctx, options) + } + if request.Result == nil { + operationError = b.ctx.Err() + if operationError == nil { + operationError = fmt.Errorf("incremental operation returned no result: %s", request.Kind) + } + return + } + project.incrementalProgram = program + }) + if operationError != nil { + return operationError + } + } return nil } @@ -1257,12 +1311,14 @@ func (b *ProjectCollectionBuilder) updateOrCreateSyntheticProject( projectReferences []*core.ProjectReference, configFileParsingDiagnostics []*ast.Diagnostic, contentMappers []*contentmapper.Mapper, + incremental *bool, logger *logging.LogTree, ) *dirty.SyncMapEntry[tspath.Path, *Project] { projectPath := b.toPath(name) project, loaded := b.syntheticProjects.Load(projectPath) if !loaded { syntheticProject := newSyntheticProject(name, b.sessionOptions.CurrentDirectory, compilerOptions, rootFileNames, projectReferences, contentMappers, b, logger) + syntheticProject.incremental = incremental != nil && *incremental syntheticProject.CommandLine.Errors = configFileParsingDiagnostics project, _ = b.syntheticProjects.LoadOrStore(projectPath, syntheticProject) return project diff --git a/tsc/internal/project/snapshot.go b/tsc/internal/project/snapshot.go index d38f338e28433..a7d63161446a9 100644 --- a/tsc/internal/project/snapshot.go +++ b/tsc/internal/project/snapshot.go @@ -11,6 +11,7 @@ import ( "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" @@ -310,6 +311,7 @@ type APICreateProgramRequest struct { CompilerOptions *core.CompilerOptions ProjectReferences []*core.ProjectReference ConfigFileParsingDiagnostics []*ast.Diagnostic + Incremental bool } type APIReconfigureProgramRequest struct { @@ -317,18 +319,35 @@ type APIReconfigureProgramRequest struct { APICreateProgramRequest } +type APIIncrementalOperationKind string + +const ( + APIIncrementalOperationKindEmit APIIncrementalOperationKind = "emit" + APIIncrementalOperationKindEmitBuildInfo APIIncrementalOperationKind = "emitBuildInfo" +) + +type APIIncrementalOperationRequest struct { + ProgramID int + Kind APIIncrementalOperationKind + EmitOnly compiler.EmitOnly + WriteFile func(fileName string, text string, data *compiler.WriteFileData) error + Result *compiler.EmitResult + EmittedFilesContents map[string]string +} + type APISnapshotRequest struct { - OpenProjects *collections.Set[string] - CloseProjects *collections.Set[tspath.Path] - OpenFiles *collections.Set[lsproto.DocumentUri] - CloseFiles *collections.Set[tspath.Path] - CreatePrograms []*APICreateProgramRequest - ReconfigurePrograms []*APIReconfigureProgramRequest - RemovePrograms *collections.Set[int] - EnsurePrograms *collections.Set[tspath.Path] - EnsureAllPrograms bool - EnsureFiles *collections.Set[lsproto.DocumentUri] - FileSystem vfs.FS + OpenProjects *collections.Set[string] + CloseProjects *collections.Set[tspath.Path] + OpenFiles *collections.Set[lsproto.DocumentUri] + CloseFiles *collections.Set[tspath.Path] + CreatePrograms []*APICreateProgramRequest + ReconfigurePrograms []*APIReconfigureProgramRequest + RemovePrograms *collections.Set[int] + EnsurePrograms *collections.Set[tspath.Path] + EnsureAllPrograms bool + EnsureFiles *collections.Set[lsproto.DocumentUri] + IncrementalOperations []*APIIncrementalOperationRequest + FileSystem vfs.FS // ReplaceFileSystem indicates a total filesystem replacement. Layers use // per-path file changes instead of invalidating all inherited state. ReplaceFileSystem bool From 6799123c6deaa93ea5b378101c9f0f3b80a2e038 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Tue, 22 Sep 2026 17:13:42 -0700 Subject: [PATCH 04/11] Return incremental status with program --- packages/typescript/src/api/async/api.ts | 85 ++++++++++++++++--- .../typescript/src/api/proto.generated.ts | 16 ++++ packages/typescript/src/api/sync/api.ts | 85 ++++++++++++++++--- .../typescript/src/enums/fileEmitKind.enum.ts | 11 +++ packages/typescript/src/enums/fileEmitKind.ts | 11 +++ packages/typescript/test/async/api.test.ts | 29 +++++++ packages/typescript/test/sync/api.test.ts | 29 +++++++ tools/gen-proto/main.go | 2 + tools/scripts/tsc/generate-enums.ts | 1 + tsc/internal/api/enum_values_generated.go | 10 +++ tsc/internal/api/proto.go | 40 +++++++-- .../api/session_createprogram_test.go | 7 ++ tsc/internal/execute/incremental/program.go | 42 +++++++++ tsc/internal/project/project.go | 7 ++ 14 files changed, 347 insertions(+), 28 deletions(-) create mode 100644 packages/typescript/src/enums/fileEmitKind.enum.ts create mode 100644 packages/typescript/src/enums/fileEmitKind.ts diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index bd5ddda1248b7..56a06ebc7879d 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -3,6 +3,7 @@ import { CompletionItemKind } from "#enums/completionItemKind"; import { DiagnosticCategory } from "#enums/diagnosticCategory"; import { ElementFlags } from "#enums/elementFlags"; import { EmitOnly } from "#enums/emitOnly"; +import { FileEmitKind } from "#enums/fileEmitKind"; import { IndexKind } from "#enums/indexKind"; import { JsxEmit } from "#enums/jsxEmit"; import { ModuleKind } from "#enums/moduleKind"; @@ -74,6 +75,7 @@ import type { FileNotifications, ImportAdderAction, IncrementalOperationParams, + IncrementalStatusResponse, InferredProjectId, IntrinsicTypeMethod, LanguageServerSnapshotChanges, @@ -160,7 +162,7 @@ import type { export { formatDiagnostics, formatDiagnosticsWithColorAndContext } from "../diagnosticFormatter.ts"; export { documentURIToFileName, fileNameToDocumentURI } from "../path.ts"; -export { CheckFlags, CompletionItemKind, DiagnosticCategory, ElementFlags, EmitOnly, IndexKind, JsxEmit, ModifierFlags, ModuleKind, ModuleResolutionKind, NodeBuilderFlags, ObjectFlags, ScriptKind, SignatureFlags, SignatureKind, SymbolFlags, TypeFlags, TypeFormatFlags, TypePredicateKind }; +export { CheckFlags, CompletionItemKind, DiagnosticCategory, ElementFlags, EmitOnly, FileEmitKind, IndexKind, JsxEmit, ModifierFlags, ModuleKind, ModuleResolutionKind, NodeBuilderFlags, ObjectFlags, ScriptKind, SignatureFlags, SignatureKind, SymbolFlags, TypeFlags, TypeFormatFlags, TypePredicateKind }; export type { APIImportAdderAction as ImportAdderAction, APIOptions, @@ -1193,15 +1195,32 @@ export class Project { this.rootFiles = this.parsedCommandLine.fileNames; this.client = client; this.snapshotId = snapshotId; - this.program = new (data.incremental ? IncrementalProgram : Program)( - snapshotId, - this, - client, - sourceFileCache, - toPath, - formatDiagnosticsHost, - updateSnapshot, - ); + if (data.incremental) { + if (!data.incrementalStatus) { + throw new Error(`Incremental project '${data.id}' has no incremental status`); + } + this.program = new IncrementalProgram( + snapshotId, + this, + client, + sourceFileCache, + toPath, + formatDiagnosticsHost, + updateSnapshot, + toIncrementalStatus(data.dirty, data.incrementalStatus), + ); + } + else { + this.program = new Program( + snapshotId, + this, + client, + sourceFileCache, + toPath, + formatDiagnosticsHost, + updateSnapshot, + ); + } const objectRegistry = new ProjectObjectRegistry(client, snapshotId, this, snapshotRegistry); this.checker = new Checker( snapshotId, @@ -1768,6 +1787,22 @@ export class Program implements FormatDiagnost } export class IncrementalProgram extends Program { + readonly status: IncrementalStatus; + + constructor( + snapshotId: number, + project: Project, + client: Client, + sourceFileCache: SourceFileCache, + toPath: (fileName: string) => Path, + formatDiagnosticsHost: FormatDiagnosticsHost, + updateSnapshot: SnapshotUpdater, + status: IncrementalStatus, + ) { + super(snapshotId, project, client, sourceFileCache, toPath, formatDiagnosticsHost, updateSnapshot); + this.status = status; + } + /** * Emits pending files and returns the new snapshot containing the advanced incremental state. */ @@ -1822,6 +1857,34 @@ export interface IncrementalEmitResult extends EmitResult { readonly program: IncrementalProgram; } +export interface IncrementalPendingEmit { + readonly sourceFileName: string; + readonly kind: FileEmitKind; +} + +export interface IncrementalStatus { + readonly dirty: boolean; + readonly changedFiles: readonly string[]; + readonly pendingEmit: readonly IncrementalPendingEmit[]; + readonly pendingSemanticDiagnostics: readonly string[]; + readonly buildInfoEmitPending: boolean; + readonly latestChangedDtsFile?: string | undefined; +} + +function toIncrementalStatus(dirty: boolean, status: IncrementalStatusResponse): IncrementalStatus { + return { + dirty, + changedFiles: status.changedFiles, + pendingEmit: status.pendingEmit.map(emit => ({ + sourceFileName: emit.sourceFileName, + kind: emit.kind, + })), + pendingSemanticDiagnostics: status.pendingSemanticDiagnostics, + buildInfoEmitPending: status.buildInfoEmitPending, + latestChangedDtsFile: status.latestChangedDtsFile, + }; +} + function toEmitResult(response: EmitResponse): EmitResult { const fileSystem = response.emittedFilesContents.length ? { @@ -1833,7 +1896,7 @@ function toEmitResult(response: EmitResponse): EmitResult { emitSkipped: response.emitSkipped, diagnostics: response.diagnostics, emittedFiles: response.emittedFiles, - ...(fileSystem ? { fileSystem } : {}), + fileSystem, }; } diff --git a/packages/typescript/src/api/proto.generated.ts b/packages/typescript/src/api/proto.generated.ts index daac86a9a719a..2c5bf34cf3c3d 100644 --- a/packages/typescript/src/api/proto.generated.ts +++ b/packages/typescript/src/api/proto.generated.ts @@ -1,5 +1,6 @@ // Code generated by gen-proto; DO NOT EDIT. +import { FileEmitKind } from "#enums/fileEmitKind"; import { JsxEmit } from "#enums/jsxEmit"; import { ModuleDetectionKind } from "#enums/moduleDetectionKind"; import { ModuleKind } from "#enums/moduleKind"; @@ -9,6 +10,7 @@ import { ScriptKind } from "#enums/scriptKind"; import { ScriptTarget } from "#enums/scriptTarget"; import type { Path } from "../ast/index.ts"; +export { FileEmitKind } from "#enums/fileEmitKind"; export { JsxEmit } from "#enums/jsxEmit"; export { ModuleDetectionKind } from "#enums/moduleDetectionKind"; export { ModuleKind } from "#enums/moduleKind"; @@ -330,6 +332,7 @@ export interface ProjectResponse { currentDirectory: string; dirty: boolean; incremental: boolean; + incrementalStatus?: IncrementalStatusResponse | undefined; parsedCommandLine: ConfigFileResponse; /** @deprecated Use parsedCommandLine.fileNames. */ rootFiles: string[]; @@ -1564,6 +1567,14 @@ export interface TranspileOptions { reportDiagnostics?: boolean | undefined; } +export interface IncrementalStatusResponse { + changedFiles: string[]; + pendingEmit: IncrementalPendingEmitResponse[]; + pendingSemanticDiagnostics: string[]; + buildInfoEmitPending: boolean; + latestChangedDtsFile?: string | undefined; +} + export interface PackageId { name: string; subModuleName: string; @@ -1666,6 +1677,11 @@ export interface IncrementalOperationResultResponse { result: EmitResponse; } +export interface IncrementalPendingEmitResponse { + sourceFileName: string; + kind: FileEmitKind; +} + /** CompletionEntryLabelDetailsResponse holds additional label display text for a completion entry. */ export interface CompletionEntryLabelDetailsResponse { detail?: string | undefined; diff --git a/packages/typescript/src/api/sync/api.ts b/packages/typescript/src/api/sync/api.ts index 6bc8fccb30c0c..73894a3699be7 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -20,6 +20,7 @@ import { CompletionItemKind } from "#enums/completionItemKind"; import { DiagnosticCategory } from "#enums/diagnosticCategory"; import { ElementFlags } from "#enums/elementFlags"; import { EmitOnly } from "#enums/emitOnly"; +import { FileEmitKind } from "#enums/fileEmitKind"; import { IndexKind } from "#enums/indexKind"; import { JsxEmit } from "#enums/jsxEmit"; import { ModuleKind } from "#enums/moduleKind"; @@ -91,6 +92,7 @@ import type { FileNotifications, ImportAdderAction, IncrementalOperationParams, + IncrementalStatusResponse, InferredProjectId, IntrinsicTypeMethod, LanguageServerSnapshotChanges, @@ -177,7 +179,7 @@ import type { export { formatDiagnostics, formatDiagnosticsWithColorAndContext } from "../diagnosticFormatter.ts"; export { documentURIToFileName, fileNameToDocumentURI } from "../path.ts"; -export { CheckFlags, CompletionItemKind, DiagnosticCategory, ElementFlags, EmitOnly, IndexKind, JsxEmit, ModifierFlags, ModuleKind, ModuleResolutionKind, NodeBuilderFlags, ObjectFlags, ScriptKind, SignatureFlags, SignatureKind, SymbolFlags, TypeFlags, TypeFormatFlags, TypePredicateKind }; +export { CheckFlags, CompletionItemKind, DiagnosticCategory, ElementFlags, EmitOnly, FileEmitKind, IndexKind, JsxEmit, ModifierFlags, ModuleKind, ModuleResolutionKind, NodeBuilderFlags, ObjectFlags, ScriptKind, SignatureFlags, SignatureKind, SymbolFlags, TypeFlags, TypeFormatFlags, TypePredicateKind }; export type { APIImportAdderAction as ImportAdderAction, APIOptions, @@ -2071,15 +2073,32 @@ export class Project { this.rootFiles = this.parsedCommandLine.fileNames; this.client = client; this.snapshotId = snapshotId; - this.program = new (data.incremental ? IncrementalProgram : Program)( - snapshotId, - this, - client, - sourceFileCache, - toPath, - formatDiagnosticsHost, - updateSnapshot, - ); + if (data.incremental) { + if (!data.incrementalStatus) { + throw new Error(`Incremental project '${data.id}' has no incremental status`); + } + this.program = new IncrementalProgram( + snapshotId, + this, + client, + sourceFileCache, + toPath, + formatDiagnosticsHost, + updateSnapshot, + toIncrementalStatus(data.dirty, data.incrementalStatus), + ); + } + else { + this.program = new Program( + snapshotId, + this, + client, + sourceFileCache, + toPath, + formatDiagnosticsHost, + updateSnapshot, + ); + } const objectRegistry = new ProjectObjectRegistry(client, snapshotId, this, snapshotRegistry); this.checker = new Checker( snapshotId, @@ -3339,6 +3358,22 @@ export class Program implements FormatDiagnost } export class IncrementalProgram extends Program { + readonly status: IncrementalStatus; + + constructor( + snapshotId: number, + project: Project, + client: Client, + sourceFileCache: SourceFileCache, + toPath: (fileName: string) => Path, + formatDiagnosticsHost: FormatDiagnosticsHost, + updateSnapshot: SnapshotUpdater, + status: IncrementalStatus, + ) { + super(snapshotId, project, client, sourceFileCache, toPath, formatDiagnosticsHost, updateSnapshot); + this.status = status; + } + /** * Emits pending files and returns the new snapshot containing the advanced incremental state. */ @@ -3467,6 +3502,34 @@ export interface IncrementalEmitResult extends EmitResult { readonly program: IncrementalProgram; } +export interface IncrementalPendingEmit { + readonly sourceFileName: string; + readonly kind: FileEmitKind; +} + +export interface IncrementalStatus { + readonly dirty: boolean; + readonly changedFiles: readonly string[]; + readonly pendingEmit: readonly IncrementalPendingEmit[]; + readonly pendingSemanticDiagnostics: readonly string[]; + readonly buildInfoEmitPending: boolean; + readonly latestChangedDtsFile?: string | undefined; +} + +function toIncrementalStatus(dirty: boolean, status: IncrementalStatusResponse): IncrementalStatus { + return { + dirty, + changedFiles: status.changedFiles, + pendingEmit: status.pendingEmit.map(emit => ({ + sourceFileName: emit.sourceFileName, + kind: emit.kind, + })), + pendingSemanticDiagnostics: status.pendingSemanticDiagnostics, + buildInfoEmitPending: status.buildInfoEmitPending, + latestChangedDtsFile: status.latestChangedDtsFile, + }; +} + function toEmitResult(response: EmitResponse): EmitResult { const fileSystem = response.emittedFilesContents.length ? { @@ -3478,7 +3541,7 @@ function toEmitResult(response: EmitResponse): EmitResult { emitSkipped: response.emitSkipped, diagnostics: response.diagnostics, emittedFiles: response.emittedFiles, - ...(fileSystem ? { fileSystem } : {}), + fileSystem, }; } diff --git a/packages/typescript/src/enums/fileEmitKind.enum.ts b/packages/typescript/src/enums/fileEmitKind.enum.ts new file mode 100644 index 0000000000000..6c57c04b83a50 --- /dev/null +++ b/packages/typescript/src/enums/fileEmitKind.enum.ts @@ -0,0 +1,11 @@ +// Code generated by tools/scripts/tsc/generate-enums.ts from tsc/internal/execute/incremental/snapshot.go. DO NOT EDIT. + +export enum FileEmitKind { + None = 0, + Js = 1 << 0, + JsMap = 1 << 1, + JsInlineMap = 1 << 2, + DtsErrors = 1 << 3, + DtsEmit = 1 << 4, + DtsMap = 1 << 5, +} diff --git a/packages/typescript/src/enums/fileEmitKind.ts b/packages/typescript/src/enums/fileEmitKind.ts new file mode 100644 index 0000000000000..25f819bb61cf7 --- /dev/null +++ b/packages/typescript/src/enums/fileEmitKind.ts @@ -0,0 +1,11 @@ +// Code generated by tools/scripts/tsc/generate-enums.ts from tsc/internal/execute/incremental/snapshot.go. DO NOT EDIT. +export var FileEmitKind: any; +(function (FileEmitKind) { + FileEmitKind[FileEmitKind["None"] = 0] = "None"; + FileEmitKind[FileEmitKind["Js"] = 1] = "Js"; + FileEmitKind[FileEmitKind["JsMap"] = 2] = "JsMap"; + FileEmitKind[FileEmitKind["JsInlineMap"] = 4] = "JsInlineMap"; + FileEmitKind[FileEmitKind["DtsErrors"] = 8] = "DtsErrors"; + FileEmitKind[FileEmitKind["DtsEmit"] = 16] = "DtsEmit"; + FileEmitKind[FileEmitKind["DtsMap"] = 32] = "DtsMap"; +})(FileEmitKind || (FileEmitKind = {})); diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index 37e3dac91724a..9eae5fb0d2a01 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -612,6 +612,18 @@ declare module "augmentation" {}`, }; const firstProgram = await api.createIncrementalProgram(["/src/main.ts"], options); + const initialStatus = firstProgram.status; + assert.equal(initialStatus.dirty, false); + assert.equal(initialStatus.buildInfoEmitPending, true); + assert.deepEqual(initialStatus.changedFiles, []); + assert.deepEqual( + initialStatus.pendingEmit.map(emit => emit.sourceFileName), + ["/src/dependency.ts", "/src/main.ts"], + ); + assert.deepEqual( + initialStatus.pendingSemanticDiagnostics, + ["/src/dependency.ts", "/src/main.ts"], + ); const buildInfoText = await firstProgram.getBuildInfoEmit(); assert.equal(typeof JSON.parse(buildInfoText).version, "string"); assert.equal(fs.readFile!("/out/build.tsbuildinfo"), undefined); @@ -621,6 +633,8 @@ declare module "augmentation" {}`, assert.equal(buildInfoEmit.emitSkipped, false); assert.deepEqual(buildInfoEmit.diagnostics, []); assert.equal(fs.readFile!("/out/build.tsbuildinfo"), buildInfoText); + assert.equal(firstProgram.status.buildInfoEmitPending, true); + assert.equal(buildInfoEmit.program.status.buildInfoEmitPending, false); const firstEmit = await firstProgram.emit(); using firstEmitSnapshot = firstEmit.snapshot; @@ -628,6 +642,11 @@ declare module "augmentation" {}`, assert.ok(firstEmit.emittedFiles.includes("/out/dependency.js")); assert.ok(firstEmit.emittedFiles.includes("/out/build.tsbuildinfo")); assert.ok(fs.readFile!("/out/build.tsbuildinfo")); + assert.deepEqual(firstProgram.status, initialStatus); + assert.equal(firstEmit.program.status.dirty, false); + assert.equal(firstEmit.program.status.buildInfoEmitPending, false); + assert.deepEqual(firstEmit.program.status.changedFiles, []); + assert.deepEqual(firstEmit.program.status.pendingEmit, []); const repeatedEmit = await firstEmit.program.emit(); using repeatedEmitSnapshot = repeatedEmit.snapshot; assert.deepEqual(repeatedEmit.emittedFiles, []); @@ -669,6 +688,16 @@ declare module "augmentation" {}`, }); const program = snapshot.operation.createdPrograms[0]; assert.ok(program instanceof IncrementalProgram); + using dirtySnapshot = await snapshot.update({ + fileSystem: createFileSystemLayer([["/src/index.ts", `export const value = 2;`]]), + }); + const dirtyProgram = dirtySnapshot.getProgram(program.id); + assert.ok(dirtyProgram instanceof IncrementalProgram); + assert.equal(dirtyProgram.status.dirty, true); + assert.deepEqual( + { ...dirtyProgram.status, dirty: false }, + program.status, + ); const buildInfoText = await program.getBuildInfoEmit(); const result = await program.emitBuildInfo(); diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index 2d35b0cfceff5..6599834ab5ff3 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -601,6 +601,18 @@ declare module "augmentation" {}`, }; const firstProgram = api.createIncrementalProgram(["/src/main.ts"], options); + const initialStatus = firstProgram.status; + assert.equal(initialStatus.dirty, false); + assert.equal(initialStatus.buildInfoEmitPending, true); + assert.deepEqual(initialStatus.changedFiles, []); + assert.deepEqual( + initialStatus.pendingEmit.map(emit => emit.sourceFileName), + ["/src/dependency.ts", "/src/main.ts"], + ); + assert.deepEqual( + initialStatus.pendingSemanticDiagnostics, + ["/src/dependency.ts", "/src/main.ts"], + ); const buildInfoText = firstProgram.getBuildInfoEmit(); assert.equal(typeof JSON.parse(buildInfoText).version, "string"); assert.equal(fs.readFile!("/out/build.tsbuildinfo"), undefined); @@ -610,6 +622,8 @@ declare module "augmentation" {}`, assert.equal(buildInfoEmit.emitSkipped, false); assert.deepEqual(buildInfoEmit.diagnostics, []); assert.equal(fs.readFile!("/out/build.tsbuildinfo"), buildInfoText); + assert.equal(firstProgram.status.buildInfoEmitPending, true); + assert.equal(buildInfoEmit.program.status.buildInfoEmitPending, false); const firstEmit = firstProgram.emit(); using firstEmitSnapshot = firstEmit.snapshot; @@ -617,6 +631,11 @@ declare module "augmentation" {}`, assert.ok(firstEmit.emittedFiles.includes("/out/dependency.js")); assert.ok(firstEmit.emittedFiles.includes("/out/build.tsbuildinfo")); assert.ok(fs.readFile!("/out/build.tsbuildinfo")); + assert.deepEqual(firstProgram.status, initialStatus); + assert.equal(firstEmit.program.status.dirty, false); + assert.equal(firstEmit.program.status.buildInfoEmitPending, false); + assert.deepEqual(firstEmit.program.status.changedFiles, []); + assert.deepEqual(firstEmit.program.status.pendingEmit, []); const repeatedEmit = firstEmit.program.emit(); using repeatedEmitSnapshot = repeatedEmit.snapshot; assert.deepEqual(repeatedEmit.emittedFiles, []); @@ -658,6 +677,16 @@ declare module "augmentation" {}`, }); const program = snapshot.operation.createdPrograms[0]; assert.ok(program instanceof IncrementalProgram); + using dirtySnapshot = snapshot.update({ + fileSystem: createFileSystemLayer([["/src/index.ts", `export const value = 2;`]]), + }); + const dirtyProgram = dirtySnapshot.getProgram(program.id); + assert.ok(dirtyProgram instanceof IncrementalProgram); + assert.equal(dirtyProgram.status.dirty, true); + assert.deepEqual( + { ...dirtyProgram.status, dirty: false }, + program.status, + ); const buildInfoText = program.getBuildInfoEmit(); const result = program.emitBuildInfo(); diff --git a/tools/gen-proto/main.go b/tools/gen-proto/main.go index e528a19c02459..248d70f6119cb 100644 --- a/tools/gen-proto/main.go +++ b/tools/gen-proto/main.go @@ -525,6 +525,8 @@ func (r *typeRenderer) namedType(named *types.Named) string { return r.importType("ScriptTarget", "#enums/scriptTarget") case "github.com/microsoft/TypeScript/tsc/internal/core.ScriptKind": return r.importType("ScriptKind", "#enums/scriptKind") + case "github.com/microsoft/TypeScript/tsc/internal/execute/incremental.FileEmitKind": + return r.importType("FileEmitKind", "#enums/fileEmitKind") case "github.com/microsoft/TypeScript/tsc/internal/collections.OrderedMap": if named.TypeArgs().Len() != 2 { return "Record" diff --git a/tools/scripts/tsc/generate-enums.ts b/tools/scripts/tsc/generate-enums.ts index f0c67100f5239..601d9087e0213 100644 --- a/tools/scripts/tsc/generate-enums.ts +++ b/tools/scripts/tsc/generate-enums.ts @@ -55,6 +55,7 @@ const enumDefs = [ { name: "NodeBuilderFlags", goPrefix: "Flags", goFile: "tsc/internal/nodebuilder/types.go", outDir: "packages/typescript/src/enums" }, { name: "CompletionItemKind", goPrefix: "CompletionItemKind", goFile: "tsc/internal/lsp/lsproto/lsp_generated.go", outDir: "packages/typescript/src/enums" }, { name: "EmitOnly", goPrefix: "Emit", goFile: "tsc/internal/compiler/emitter.go", outDir: "packages/typescript/src/enums", excludeMembers: ["OnlyBuilderSignature"] }, + { name: "FileEmitKind", goPrefix: "FileEmitKind", goFile: "tsc/internal/execute/incremental/snapshot.go", outDir: "packages/typescript/src/enums", excludeMembers: ["Dts", "AllJs", "AllDtsEmit", "AllDts", "All"] }, // String enum: Go stores internal names with a "\xFE" sentinel prefix, but the escaped // form sent over the wire uses "__" (see EscapeSymbolName), so map the sentinel accordingly. { name: "InternalSymbolName", goPrefix: "InternalSymbolName", goFile: "tsc/internal/ast/symbol.go", outDir: "packages/typescript/src/enums", stringEnum: true, valueReplacements: { InternalSymbolNamePrefix: "__" } }, diff --git a/tsc/internal/api/enum_values_generated.go b/tsc/internal/api/enum_values_generated.go index d1f15a1830af3..ecc1a480a0d52 100644 --- a/tsc/internal/api/enum_values_generated.go +++ b/tsc/internal/api/enum_values_generated.go @@ -17,6 +17,7 @@ import ( compiler "github.com/microsoft/TypeScript/tsc/internal/compiler" core "github.com/microsoft/TypeScript/tsc/internal/core" diagnostics "github.com/microsoft/TypeScript/tsc/internal/diagnostics" + incremental "github.com/microsoft/TypeScript/tsc/internal/execute/incremental" lsproto "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" nodebuilder "github.com/microsoft/TypeScript/tsc/internal/nodebuilder" spanmap "github.com/microsoft/TypeScript/tsc/internal/spanmap" @@ -998,6 +999,15 @@ func main() { "OnlyJs": toInt32(compiler.EmitOnlyJs), "OnlyDts": toInt32(compiler.EmitOnlyDts), }, + "FileEmitKind": { + "None": toInt32(incremental.FileEmitKindNone), + "Js": toInt32(incremental.FileEmitKindJs), + "JsMap": toInt32(incremental.FileEmitKindJsMap), + "JsInlineMap": toInt32(incremental.FileEmitKindJsInlineMap), + "DtsErrors": toInt32(incremental.FileEmitKindDtsErrors), + "DtsEmit": toInt32(incremental.FileEmitKindDtsEmit), + "DtsMap": toInt32(incremental.FileEmitKindDtsMap), + }, } if err := json.NewEncoder(os.Stdout).Encode(values); err != nil { panic(err) diff --git a/tsc/internal/api/proto.go b/tsc/internal/api/proto.go index ea56c71ee746a..cc525ab766134 100644 --- a/tsc/internal/api/proto.go +++ b/tsc/internal/api/proto.go @@ -13,6 +13,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/core" "github.com/microsoft/TypeScript/tsc/internal/diagnostics" "github.com/microsoft/TypeScript/tsc/internal/diagnosticwriter" + "github.com/microsoft/TypeScript/tsc/internal/execute/incremental" "github.com/microsoft/TypeScript/tsc/internal/jsnum" "github.com/microsoft/TypeScript/tsc/internal/json" "github.com/microsoft/TypeScript/tsc/internal/locale" @@ -846,18 +847,32 @@ type GetDefaultProjectForFileParams struct { } type ProjectResponse struct { - Id project.ID `json:"id"` - ConfigFileName string `json:"configFileName"` - CurrentDirectory string `json:"currentDirectory"` - Dirty bool `json:"dirty"` - Incremental bool `json:"incremental"` - ParsedCommandLine *ConfigFileResponse `json:"parsedCommandLine" nonnil:"true"` + Id project.ID `json:"id"` + ConfigFileName string `json:"configFileName"` + CurrentDirectory string `json:"currentDirectory"` + Dirty bool `json:"dirty"` + Incremental bool `json:"incremental"` + IncrementalStatus *IncrementalStatusResponse `json:"incrementalStatus,omitempty"` + ParsedCommandLine *ConfigFileResponse `json:"parsedCommandLine" nonnil:"true"` // Deprecated: Use parsedCommandLine.fileNames. RootFiles []string `json:"rootFiles" nonnil:"true"` // Deprecated: Use parsedCommandLine.options. CompilerOptions *core.CompilerOptions `json:"compilerOptions" nonnil:"true"` } +type IncrementalStatusResponse struct { + ChangedFiles []string `json:"changedFiles" nonnil:"true"` + PendingEmit []*IncrementalPendingEmitResponse `json:"pendingEmit" nonnil:"true"` + PendingSemanticDiagnostics []string `json:"pendingSemanticDiagnostics" nonnil:"true"` + BuildInfoEmitPending bool `json:"buildInfoEmitPending"` + LatestChangedDtsFile string `json:"latestChangedDtsFile,omitempty"` +} + +type IncrementalPendingEmitResponse struct { + SourceFileName string `json:"sourceFileName"` + Kind incremental.FileEmitKind `json:"kind"` +} + func NewConfigFileResponse(parsedCommandLine *tsoptions.ParsedCommandLine) *ConfigFileResponse { if parsedCommandLine == nil { return nil @@ -919,12 +934,25 @@ func NewProjectResponse(p *project.Project) *ProjectResponse { if p.Kind == project.KindConfigured { configFileName = p.ConfigFileName() } + var incrementalStatus *IncrementalStatusResponse + if status := p.IncrementalStatus(); status != nil { + incrementalStatus = &IncrementalStatusResponse{ + ChangedFiles: status.ChangedFiles, + PendingEmit: core.Map(status.PendingEmit, func(emit *incremental.PendingEmit) *IncrementalPendingEmitResponse { + return &IncrementalPendingEmitResponse{SourceFileName: emit.SourceFileName, Kind: emit.Kind} + }), + PendingSemanticDiagnostics: status.PendingSemanticDiagnostics, + BuildInfoEmitPending: status.BuildInfoEmitPending, + LatestChangedDtsFile: status.LatestChangedDtsFile, + } + } return &ProjectResponse{ Id: p.ID(), ConfigFileName: configFileName, CurrentDirectory: p.CurrentDirectory(), Dirty: p.IsDirty(), Incremental: p.IsIncremental(), + IncrementalStatus: incrementalStatus, ParsedCommandLine: NewConfigFileResponse(p.CommandLine), RootFiles: p.CommandLine.FileNames(), CompilerOptions: p.CommandLine.CompilerOptions(), diff --git a/tsc/internal/api/session_createprogram_test.go b/tsc/internal/api/session_createprogram_test.go index 5dfcee7ebac7a..1942227973514 100644 --- a/tsc/internal/api/session_createprogram_test.go +++ b/tsc/internal/api/session_createprogram_test.go @@ -264,6 +264,7 @@ func TestIncrementalProgramComposesWithSnapshotUpdates(t *testing.T) { _, isIncremental := dirtyProject.GetProgramLike().(*incremental.Program) assert.Assert(t, isIncremental) assert.Assert(t, dirtyProject.IsDirty()) + assert.Assert(t, dirtyProject.IncrementalStatus() != nil) emittedSnapshot, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ Snapshot: dirty.Snapshot, @@ -278,6 +279,12 @@ func TestIncrementalProgramComposesWithSnapshotUpdates(t *testing.T) { emitted := (*emittedSnapshot.Operation.IncrementalOperations)[0].Result assert.Assert(t, !slices.Contains(emitted.EmittedFiles, "/home/projects/p/out/main.js"), "unexpected emitted files: %v", emitted.EmittedFiles) assert.Assert(t, slices.Contains(emitted.EmittedFiles, "/home/projects/p/out/dependency.js")) + emittedSnapshotData, err := session.getSnapshotData(emittedSnapshot.Snapshot) + assert.NilError(t, err) + emittedProject, err := emittedSnapshotData.getProject(restoredProgramID.AsID()) + assert.NilError(t, err) + assert.Equal(t, emittedProject.IncrementalStatus().BuildInfoEmitPending, false) + assert.Equal(t, len(emittedProject.IncrementalStatus().PendingEmit), 0) reconfigured, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ Snapshot: emittedSnapshot.Snapshot, diff --git a/tsc/internal/execute/incremental/program.go b/tsc/internal/execute/incremental/program.go index ae0cbe7fda042..52384cefc8344 100644 --- a/tsc/internal/execute/incremental/program.go +++ b/tsc/internal/execute/incremental/program.go @@ -132,6 +132,48 @@ func (p *Program) HasChangedDtsFile() bool { return p.snapshot.hasChangedDtsFile } +type PendingEmit struct { + SourceFileName string + Kind FileEmitKind +} + +type Status struct { + ChangedFiles []string + PendingEmit []*PendingEmit + PendingSemanticDiagnostics []string + BuildInfoEmitPending bool + LatestChangedDtsFile string +} + +func (p *Program) Status() *Status { + status := &Status{ + BuildInfoEmitPending: p.snapshot.buildInfoEmitPending.Load(), + LatestChangedDtsFile: p.snapshot.latestChangedDtsFile, + } + p.snapshot.changedFilesSet.Range(func(path tspath.Path) bool { + status.ChangedFiles = append(status.ChangedFiles, string(path)) + return true + }) + p.snapshot.affectedFilesPendingEmit.Range(func(path tspath.Path, kind FileEmitKind) bool { + status.PendingEmit = append(status.PendingEmit, &PendingEmit{ + SourceFileName: string(path), + Kind: kind, + }) + return true + }) + for _, file := range p.program.GetSourceFiles() { + if _, ok := p.snapshot.semanticDiagnosticsPerFile.Load(file.Path()); !ok { + status.PendingSemanticDiagnostics = append(status.PendingSemanticDiagnostics, file.FileName()) + } + } + slices.Sort(status.ChangedFiles) + slices.SortFunc(status.PendingEmit, func(a, b *PendingEmit) int { + return strings.Compare(a.SourceFileName, b.SourceFileName) + }) + slices.Sort(status.PendingSemanticDiagnostics) + return status +} + // Options implements compiler.AnyProgram interface. func (p *Program) Options() *core.CompilerOptions { return p.snapshot.options diff --git a/tsc/internal/project/project.go b/tsc/internal/project/project.go index 3d292842459f0..f8c31c2f4d2ca 100644 --- a/tsc/internal/project/project.go +++ b/tsc/internal/project/project.go @@ -375,6 +375,13 @@ func (p *Project) IsIncremental() bool { return p.incremental } +func (p *Project) IncrementalStatus() *incremental.Status { + if p.incrementalProgram == nil { + return nil + } + return p.incrementalProgram.Status() +} + func (p *Project) IsDirty() bool { return p.dirty } From 51d31a69ebf6e4412f9b99380c3e48e70d0aa626 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Wed, 23 Sep 2026 11:03:43 -0700 Subject: [PATCH 05/11] PR feedback --- tsc/internal/api/session.go | 3 +++ tsc/internal/api/session_createprogram_test.go | 16 ++++++++++++++++ tsc/internal/execute/incremental/program.go | 6 ++++++ 3 files changed, 25 insertions(+) diff --git a/tsc/internal/api/session.go b/tsc/internal/api/session.go index 71db2331b94d2..09cf4ff176de9 100644 --- a/tsc/internal/api/session.go +++ b/tsc/internal/api/session.go @@ -107,6 +107,9 @@ func (sd *snapshotData) getProgramLike(projectHandle project.ID) (compiler.Progr if err != nil { return nil, err } + if proj.GetProgram() == nil { + return nil, fmt.Errorf("%w: project has no program", ErrClientError) + } return proj.GetProgramLike(), nil } diff --git a/tsc/internal/api/session_createprogram_test.go b/tsc/internal/api/session_createprogram_test.go index 1942227973514..d125e95224423 100644 --- a/tsc/internal/api/session_createprogram_test.go +++ b/tsc/internal/api/session_createprogram_test.go @@ -216,6 +216,22 @@ func TestIncrementalProgramComposesWithSnapshotUpdates(t *testing.T) { assert.Assert(t, len(buildInfoText) > 0) _, buildInfoExists := sessionUtils.FS().ReadFile(buildInfoFile) assert.Assert(t, !buildInfoExists) + canceledContext, cancel := context.WithCancel(ctx) + cancel() + _, err = session.handleUpdateSnapshot(canceledContext, &UpdateSnapshotParams{ + Snapshot: created.Snapshot, + Changes: &CreateSnapshotParams{ + IncrementalOperations: []*IncrementalOperationParams{{ + Program: programID, + Kind: IncrementalOperationKindEmitBuildInfo, + }}, + }, + }) + assert.ErrorIs(t, err, context.Canceled) + buildInfoAfterCancellation, err := session.handleGetBuildInfoEmit(ctx, &GetProjectDiagnosticsParams{Snapshot: created.Snapshot, Project: programID.AsID()}) + assert.NilError(t, err) + assert.Equal(t, buildInfoAfterCancellation, buildInfoText) + emittedBuildInfoSnapshot, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ Snapshot: created.Snapshot, Changes: &CreateSnapshotParams{ diff --git a/tsc/internal/execute/incremental/program.go b/tsc/internal/execute/incremental/program.go index 52384cefc8344..95b2ab9d53318 100644 --- a/tsc/internal/execute/incremental/program.go +++ b/tsc/internal/execute/incremental/program.go @@ -384,6 +384,9 @@ func (p *Program) emitBuildInfo(ctx context.Context, options compiler.EmitOption } text, buildInfo, err := p.getBuildInfoEmit(ctx, buildInfoFileName) if err != nil { + if ctx.Err() != nil { + return nil + } return &compiler.EmitResult{ EmitSkipped: true, Diagnostics: []*ast.Diagnostic{ @@ -422,6 +425,9 @@ func (p *Program) EmitBuildInfo(ctx context.Context, options compiler.EmitOption } text, buildInfo, err := p.getBuildInfoEmit(ctx, buildInfoFileName) if err != nil { + if ctx.Err() != nil { + return nil + } return &compiler.EmitResult{ EmitSkipped: true, Diagnostics: []*ast.Diagnostic{ From 0a27621097f601a78ec3cb29da318afaed842343 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Thu, 24 Sep 2026 08:56:32 -0700 Subject: [PATCH 06/11] PR feedback --- packages/typescript/test/async/api.test.ts | 7 +++ packages/typescript/test/sync/api.test.ts | 7 +++ tsc/internal/api/session.go | 18 +++++- tsc/internal/execute/incremental/program.go | 9 ++- .../execute/incremental/referencemap.go | 7 +++ tsc/internal/execute/incremental/snapshot.go | 59 +++++++++++++++++++ 6 files changed, 102 insertions(+), 5 deletions(-) diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index 387eadb41f5f3..e676a860e61c1 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -1018,6 +1018,13 @@ declare module "augmentation" {}`, const repeatedEmit = await firstEmit.program.emit(); using repeatedEmitSnapshot = repeatedEmit.snapshot; assert.deepEqual(repeatedEmit.emittedFiles, []); + const emitToString = await firstEmit.program.emitToString(); + assert.deepEqual([...emitToString.outputFiles.keys()].sort(), [ + "/out/dependency.d.ts", + "/out/dependency.js", + "/out/main.d.ts", + "/out/main.js", + ]); await firstProgram.dispose(); fs.writeFile!("/src/dependency.ts", `export function value() { return 2; }`); diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index d04708562e9b7..e1f7034a6ecfa 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -1007,6 +1007,13 @@ declare module "augmentation" {}`, const repeatedEmit = firstEmit.program.emit(); using repeatedEmitSnapshot = repeatedEmit.snapshot; assert.deepEqual(repeatedEmit.emittedFiles, []); + const emitToString = firstEmit.program.emitToString(); + assert.deepEqual([...emitToString.outputFiles.keys()].sort(), [ + "/out/dependency.d.ts", + "/out/dependency.js", + "/out/main.d.ts", + "/out/main.js", + ]); firstProgram.dispose(); fs.writeFile!("/src/dependency.ts", `export function value() { return 2; }`); diff --git a/tsc/internal/api/session.go b/tsc/internal/api/session.go index b6890e70cfee2..30da049c2d43e 100644 --- a/tsc/internal/api/session.go +++ b/tsc/internal/api/session.go @@ -3425,7 +3425,7 @@ func newPrinter(params *PrintNodeParams) *printer.Printer { } func (s *Session) handleEmit(ctx context.Context, params *EmitParams) (*EmitResponse, error) { - program, options, err := s.getEmitOptions(params) + program, options, err := s.getIncrementalEmitOptions(params) if err != nil { return nil, err } @@ -3558,7 +3558,21 @@ func emitToOutput(ctx context.Context, program compiler.ProgramLike, options com }, nil } -func (s *Session) getEmitOptions(params *EmitParams) (compiler.ProgramLike, compiler.EmitOptions, error) { +func (s *Session) getEmitOptions(params *EmitParams) (*compiler.Program, compiler.EmitOptions, error) { + program, err := s.getEmitProgram(params.Snapshot, params.Project) + if err != nil { + return nil, compiler.EmitOptions{}, err + } + emitOnly, err := getEmitOnly(params.EmitOnly) + if err != nil { + return nil, compiler.EmitOptions{}, err + } + return program, compiler.EmitOptions{ + EmitOnly: emitOnly, + }, nil +} + +func (s *Session) getIncrementalEmitOptions(params *EmitParams) (compiler.ProgramLike, compiler.EmitOptions, error) { program, err := s.getEmitProgramLike(params.Snapshot, params.Project) if err != nil { return nil, compiler.EmitOptions{}, err diff --git a/tsc/internal/execute/incremental/program.go b/tsc/internal/execute/incremental/program.go index 95b2ab9d53318..f143db6cadafc 100644 --- a/tsc/internal/execute/incremental/program.go +++ b/tsc/internal/execute/incremental/program.go @@ -69,7 +69,7 @@ func NewProgram(program *compiler.Program, oldProgram *Program, host Host, neste func (p *Program) Fork() *Program { return &Program{ - snapshot: createProgramSnapshot(p.program, p, p.snapshot.hashWithText), + snapshot: p.snapshot.clone(), program: p.program, host: p.host, nestedEmitNow: p.nestedEmitNow, @@ -147,8 +147,11 @@ type Status struct { func (p *Program) Status() *Status { status := &Status{ - BuildInfoEmitPending: p.snapshot.buildInfoEmitPending.Load(), - LatestChangedDtsFile: p.snapshot.latestChangedDtsFile, + ChangedFiles: []string{}, + PendingEmit: []*PendingEmit{}, + PendingSemanticDiagnostics: []string{}, + BuildInfoEmitPending: p.snapshot.buildInfoEmitPending.Load(), + LatestChangedDtsFile: p.snapshot.latestChangedDtsFile, } p.snapshot.changedFilesSet.Range(func(path tspath.Path) bool { status.ChangedFiles = append(status.ChangedFiles, string(path)) diff --git a/tsc/internal/execute/incremental/referencemap.go b/tsc/internal/execute/incremental/referencemap.go index 70e67977a033a..0c03f423976d4 100644 --- a/tsc/internal/execute/incremental/referencemap.go +++ b/tsc/internal/execute/incremental/referencemap.go @@ -16,6 +16,13 @@ type referenceMap struct { referenceBy sync.Once } +func (r *referenceMap) cloneInto(target *referenceMap) { + r.references.Range(func(path tspath.Path, refs *collections.Set[tspath.Path]) bool { + target.references.Store(path, refs) + return true + }) +} + func (r *referenceMap) storeReferences(path tspath.Path, refs *collections.Set[tspath.Path]) { r.references.Store(path, refs) } diff --git a/tsc/internal/execute/incremental/snapshot.go b/tsc/internal/execute/incremental/snapshot.go index 61aa15a4230d6..5217e44f27335 100644 --- a/tsc/internal/execute/incremental/snapshot.go +++ b/tsc/internal/execute/incremental/snapshot.go @@ -3,6 +3,7 @@ package incremental import ( "encoding/hex" "fmt" + "slices" "strings" "sync" "sync/atomic" @@ -351,6 +352,64 @@ type snapshot struct { hashWithText bool } +func (s *snapshot) clone() *snapshot { + clone := &snapshot{ + options: s.options, + latestChangedDtsFile: s.latestChangedDtsFile, + hasErrors: s.hasErrors, + hasSemanticErrors: s.hasSemanticErrors, + checkPending: s.checkPending, + packageJsons: slices.Clone(s.packageJsons), + missingPackageJsons: slices.Clone(s.missingPackageJsons), + hasErrorsFromOldState: s.hasErrorsFromOldState, + hasSemanticErrorsFromOldState: s.hasSemanticErrorsFromOldState, + packageJsonsFromOldState: slices.Clone(s.packageJsonsFromOldState), + missingPackageJsonsFromOldState: slices.Clone(s.missingPackageJsonsFromOldState), + hasChangedDtsFile: s.hasChangedDtsFile, + hasEmitDiagnostics: s.hasEmitDiagnostics, + hashWithText: s.hashWithText, + } + s.referencedMap.cloneInto(&clone.referencedMap) + clone.buildInfoEmitPending.Store(s.buildInfoEmitPending.Load()) + s.fileInfos.Range(func(path tspath.Path, info *FileInfo) bool { + infoClone := *info + clone.fileInfos.Store(path, &infoClone) + return true + }) + cloneDiagnosticsMap(&s.semanticDiagnosticsPerFile, &clone.semanticDiagnosticsPerFile) + cloneDiagnosticsMap(&s.emitDiagnosticsPerFile, &clone.emitDiagnosticsPerFile) + s.changedFilesSet.Range(func(path tspath.Path) bool { + clone.changedFilesSet.Add(path) + return true + }) + s.affectedFilesPendingEmit.Range(func(path tspath.Path, kind FileEmitKind) bool { + clone.affectedFilesPendingEmit.Store(path, kind) + return true + }) + s.emitSignatures.Range(func(path tspath.Path, signature *emitSignature) bool { + signatureClone := &emitSignature{ + signature: signature.signature, + signatureWithDifferentOptions: slices.Clone(signature.signatureWithDifferentOptions), + } + clone.emitSignatures.Store(path, signatureClone) + return true + }) + return clone +} + +func cloneDiagnosticsMap( + source *collections.SyncMap[tspath.Path, *DiagnosticsOrBuildInfoDiagnosticsWithFileName], + target *collections.SyncMap[tspath.Path, *DiagnosticsOrBuildInfoDiagnosticsWithFileName], +) { + source.Range(func(path tspath.Path, value *DiagnosticsOrBuildInfoDiagnosticsWithFileName) bool { + target.Store(path, &DiagnosticsOrBuildInfoDiagnosticsWithFileName{ + diagnostics: value.diagnostics, + buildInfoDiagnostics: value.buildInfoDiagnostics, + }) + return true + }) +} + func (s *snapshot) addFileToChangeSet(filePath tspath.Path) { s.changedFilesSet.Add(filePath) s.buildInfoEmitPending.Store(true) From 153607834cb810620c5d8f49fa72df584aeba1ce Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Thu, 24 Sep 2026 09:44:51 -0700 Subject: [PATCH 07/11] PR feedback --- packages/typescript/src/api/async/api.ts | 1 - packages/typescript/src/api/sync/api.ts | 2 -- packages/typescript/test/async/api.test.ts | 2 ++ packages/typescript/test/sync/api.test.ts | 2 ++ tsc/internal/api/session.go | 5 +-- tsc/internal/execute/incremental/program.go | 36 ++++++++++++--------- 6 files changed, 25 insertions(+), 23 deletions(-) diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index e7b91de8a52ac..4fa884bb055b5 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -702,7 +702,6 @@ export class API implements FormatDiagnosticsHo const snapshot = await this.createSnapshot({ createPrograms: [{ rootFiles, compilerOptions, options: createProgramOptions }], }); - const program = snapshot.operation.createdPrograms![0]; return this.getOwnedCreatedProgram(snapshot, "createProgram"); } diff --git a/packages/typescript/src/api/sync/api.ts b/packages/typescript/src/api/sync/api.ts index 3d655fa74cf2a..e174c70b03b3b 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -1155,7 +1155,6 @@ export class API implements FormatDiagnosticsHo const snapshot = owner.createSnapshot({ createPrograms: [{ rootFiles, compilerOptions, options: createProgramOptions }], }); - const program = snapshot.operation.createdPrograms![0]; return owner.getOwnedCreatedProgram(snapshot, "createProgram"); }, function* (rootFiles: readonly DocumentIdentifier[], compilerOptions: CompilerOptions, createProgramOptions?: CreateProgramOptions): Generator { @@ -1164,7 +1163,6 @@ export class API implements FormatDiagnosticsHo const snapshot = yield* owner.createSnapshot.gen({ createPrograms: [{ rootFiles, compilerOptions, options: createProgramOptions }], }); - const program = snapshot.operation.createdPrograms![0]; return owner.getOwnedCreatedProgram(snapshot, "createProgram"); }, ); diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index e676a860e61c1..ea1f4d2e74728 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -1029,6 +1029,8 @@ declare module "augmentation" {}`, fs.writeFile!("/src/dependency.ts", `export function value() { return 2; }`); const secondProgram = await api.createIncrementalProgram(["/src/main.ts"], options); + const globalDiagnostics = await secondProgram.getGlobalDiagnostics(); + assert.ok(globalDiagnostics.some(diagnostic => diagnostic.code === 2318)); const secondEmit = await secondProgram.emit(); using secondEmitSnapshot = secondEmit.snapshot; assert.ok(!secondEmit.emittedFiles.includes("/out/main.js"), JSON.stringify(secondEmit.emittedFiles)); diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index e1f7034a6ecfa..8f221f15d851b 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -1018,6 +1018,8 @@ declare module "augmentation" {}`, fs.writeFile!("/src/dependency.ts", `export function value() { return 2; }`); const secondProgram = api.createIncrementalProgram(["/src/main.ts"], options); + const globalDiagnostics = secondProgram.getGlobalDiagnostics(); + assert.ok(globalDiagnostics.some(diagnostic => diagnostic.code === 2318)); const secondEmit = secondProgram.emit(); using secondEmitSnapshot = secondEmit.snapshot; assert.ok(!secondEmit.emittedFiles.includes("/out/main.js"), JSON.stringify(secondEmit.emittedFiles)); diff --git a/tsc/internal/api/session.go b/tsc/internal/api/session.go index 30da049c2d43e..0b8d69ea6990a 100644 --- a/tsc/internal/api/session.go +++ b/tsc/internal/api/session.go @@ -4810,13 +4810,10 @@ func (s *Session) handleGetGlobalDiagnostics(ctx context.Context, params *GetPro return nil, err } - program, err := sd.getProgramLike(params.Project) + program, err := sd.getProgram(params.Project) if err != nil { return nil, err } - if program.Program() == nil { - return nil, fmt.Errorf("%w: project has no program", ErrClientError) - } // Global diagnostics are accumulated lazily by the project's checker pool as // files are checked. Force a full semantic pass so any global (non-file-specific) diff --git a/tsc/internal/execute/incremental/program.go b/tsc/internal/execute/incremental/program.go index f143db6cadafc..0cf92bf4e678c 100644 --- a/tsc/internal/execute/incremental/program.go +++ b/tsc/internal/execute/incremental/program.go @@ -385,11 +385,14 @@ func (p *Program) emitBuildInfo(ctx context.Context, options compiler.EmitOption if buildInfoFileName == "" || p.program.IsEmitBlocked(buildInfoFileName) { return nil } - text, buildInfo, err := p.getBuildInfoEmit(ctx, buildInfoFileName) + if err := p.prepareBuildInfoState(ctx); err != nil { + return nil + } + if !p.snapshot.buildInfoEmitPending.Load() { + return nil + } + text, buildInfo, err := p.serializeBuildInfo(buildInfoFileName) if err != nil { - if ctx.Err() != nil { - return nil - } return &compiler.EmitResult{ EmitSkipped: true, Diagnostics: []*ast.Diagnostic{ @@ -397,9 +400,6 @@ func (p *Program) emitBuildInfo(ctx context.Context, options compiler.EmitOption }, } } - if !p.snapshot.buildInfoEmitPending.Load() { - return nil - } return p.writeBuildInfo(buildInfoFileName, text, buildInfo, options) } @@ -414,7 +414,10 @@ func (p *Program) GetBuildInfoEmit(ctx context.Context) (string, error) { if p.program.IsEmitBlocked(buildInfoFileName) { return "", fmt.Errorf("build info emit is blocked for %s", buildInfoFileName) } - text, _, err := p.getBuildInfoEmit(ctx, buildInfoFileName) + if err := p.prepareBuildInfoState(ctx); err != nil { + return "", err + } + text, _, err := p.serializeBuildInfo(buildInfoFileName) return text, err } @@ -426,11 +429,11 @@ func (p *Program) EmitBuildInfo(ctx context.Context, options compiler.EmitOption if buildInfoFileName == "" || p.program.IsEmitBlocked(buildInfoFileName) { return &compiler.EmitResult{EmitSkipped: true} } - text, buildInfo, err := p.getBuildInfoEmit(ctx, buildInfoFileName) + if err := p.prepareBuildInfoState(ctx); err != nil { + return nil + } + text, buildInfo, err := p.serializeBuildInfo(buildInfoFileName) if err != nil { - if ctx.Err() != nil { - return nil - } return &compiler.EmitResult{ EmitSkipped: true, Diagnostics: []*ast.Diagnostic{ @@ -441,7 +444,7 @@ func (p *Program) EmitBuildInfo(ctx context.Context, options compiler.EmitOption return p.writeBuildInfo(buildInfoFileName, text, buildInfo, options) } -func (p *Program) getBuildInfoEmit(ctx context.Context, buildInfoFileName string) (string, *BuildInfo, error) { +func (p *Program) prepareBuildInfoState(ctx context.Context) error { if p.snapshot.hasErrors == core.TSUnknown { p.ensureHasErrorsForState(ctx, p.program) if p.snapshot.hasErrors != p.snapshot.hasErrorsFromOldState || p.snapshot.hasSemanticErrors != p.snapshot.hasSemanticErrorsFromOldState { @@ -455,9 +458,10 @@ func (p *Program) getBuildInfoEmit(ctx context.Context, buildInfoFileName string p.snapshot.buildInfoEmitPending.Store(true) } } - if err := ctx.Err(); err != nil { - return "", nil, err - } + return ctx.Err() +} + +func (p *Program) serializeBuildInfo(buildInfoFileName string) (string, *BuildInfo, error) { buildInfo, err := snapshotToBuildInfo(p.snapshot, p.program, buildInfoFileName) if err != nil { return "", nil, err From 1dedff5d7bfd382062bdd7d80bbda7612110cded Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Thu, 24 Sep 2026 10:36:05 -0700 Subject: [PATCH 08/11] Fix EmitOnly handling --- packages/typescript/test/async/api.test.ts | 33 +++++++++++++++++++ packages/typescript/test/sync/api.test.ts | 33 +++++++++++++++++++ .../execute/incremental/emitfileshandler.go | 3 ++ 3 files changed, 69 insertions(+) diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index ea1f4d2e74728..16e32ef60de1e 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -58,6 +58,7 @@ import { DiagnosticCategory, type DocumentIdentifier, EmitOnly, + FileEmitKind, type FreshableType, type ImportAdderAction, IncrementalProgram, @@ -1089,6 +1090,38 @@ declare module "augmentation" {}`, assert.deepEqual(hostWrites, []); }); + test("incremental emitOnly preserves other pending output kinds", async () => { + const fs = createVirtualFileSystem({ + "/src/index.ts": `export const value = 1;`, + }); + await using api = new API({ cwd: "/", fs }); + const program = await api.createIncrementalProgram( + ["/src/index.ts"], + { + declaration: true, + incremental: true, + noLib: true, + outDir: "/out", + rootDir: "/src", + tsBuildInfoFile: "/out/build.tsbuildinfo", + }, + ); + + const jsEmit = await program.emit(EmitOnly.OnlyJs); + using jsSnapshot = jsEmit.snapshot; + assert.ok(jsEmit.emittedFiles.includes("/out/index.js")); + assert.ok(!jsEmit.emittedFiles.includes("/out/index.d.ts")); + assert.ok(jsEmit.program.status.pendingEmit.some(emit => emit.sourceFileName === "/src/index.ts" && (emit.kind & FileEmitKind.DtsEmit) !== 0)); + assert.ok(jsEmit.program.status.pendingEmit.every(emit => (emit.kind & FileEmitKind.Js) === 0)); + + const dtsEmit = await jsEmit.program.emit(EmitOnly.OnlyDts); + using dtsSnapshot = dtsEmit.snapshot; + assert.ok(!dtsEmit.emittedFiles.includes("/out/index.js")); + assert.ok(dtsEmit.emittedFiles.includes("/out/index.d.ts")); + assert.deepEqual(dtsEmit.program.status.pendingEmit, []); + await program.dispose(); + }); + test("createProgram includes project references", async () => { const reference = { path: "/lib/tsconfig.json", originalPath: "/lib/tsconfig.json", circular: false }; await using api = spawnAPI({ diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index 8f221f15d851b..f6157c6e22142 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -73,6 +73,7 @@ import { DiagnosticCategory, type DocumentIdentifier, EmitOnly, + FileEmitKind, type FreshableType, type ImportAdderAction, IncrementalProgram, @@ -1078,6 +1079,38 @@ declare module "augmentation" {}`, assert.deepEqual(hostWrites, []); }); + test("incremental emitOnly preserves other pending output kinds", () => { + const fs = createVirtualFileSystem({ + "/src/index.ts": `export const value = 1;`, + }); + using api = new API({ cwd: "/", fs }); + const program = api.createIncrementalProgram( + ["/src/index.ts"], + { + declaration: true, + incremental: true, + noLib: true, + outDir: "/out", + rootDir: "/src", + tsBuildInfoFile: "/out/build.tsbuildinfo", + }, + ); + + const jsEmit = program.emit(EmitOnly.OnlyJs); + using jsSnapshot = jsEmit.snapshot; + assert.ok(jsEmit.emittedFiles.includes("/out/index.js")); + assert.ok(!jsEmit.emittedFiles.includes("/out/index.d.ts")); + assert.ok(jsEmit.program.status.pendingEmit.some(emit => emit.sourceFileName === "/src/index.ts" && (emit.kind & FileEmitKind.DtsEmit) !== 0)); + assert.ok(jsEmit.program.status.pendingEmit.every(emit => (emit.kind & FileEmitKind.Js) === 0)); + + const dtsEmit = jsEmit.program.emit(EmitOnly.OnlyDts); + using dtsSnapshot = dtsEmit.snapshot; + assert.ok(!dtsEmit.emittedFiles.includes("/out/index.js")); + assert.ok(dtsEmit.emittedFiles.includes("/out/index.d.ts")); + assert.deepEqual(dtsEmit.program.status.pendingEmit, []); + program.dispose(); + }); + test("createProgram includes project references", () => { const reference = { path: "/lib/tsconfig.json", originalPath: "/lib/tsconfig.json", circular: false }; using api = spawnAPI({ diff --git a/tsc/internal/execute/incremental/emitfileshandler.go b/tsc/internal/execute/incremental/emitfileshandler.go index ba064548666c3..64a24f296786b 100644 --- a/tsc/internal/execute/incremental/emitfileshandler.go +++ b/tsc/internal/execute/incremental/emitfileshandler.go @@ -36,6 +36,9 @@ func (h *emitFilesHandler) getPendingEmitKindForEmitOptions(emitKind FileEmitKin if options.EmitOnly == compiler.EmitOnlyDts { pendingKind &= FileEmitKindAllDts } + if options.EmitOnly == compiler.EmitOnlyJs { + pendingKind &= FileEmitKindAllJs + } if h.isForDtsErrors { pendingKind &= FileEmitKindDtsErrors } From f2561abce2549bdcbf1bbc7987ebaf608e034be6 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Thu, 24 Sep 2026 13:55:07 -0700 Subject: [PATCH 09/11] PR feedback --- packages/typescript/src/api/async/api.ts | 23 +++++++------- packages/typescript/src/api/sync/api.ts | 40 +++++++++++++++--------- 2 files changed, 38 insertions(+), 25 deletions(-) diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index 4fa884bb055b5..f3a1d97757818 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -702,7 +702,13 @@ export class API implements FormatDiagnosticsHo const snapshot = await this.createSnapshot({ createPrograms: [{ rootFiles, compilerOptions, options: createProgramOptions }], }); - return this.getOwnedCreatedProgram(snapshot, "createProgram"); + const program = snapshot.operation.createdPrograms![0]; + if (!program) { + await snapshot.dispose(); + throw new Error("createProgram did not return a project"); + } + program.setOwnedSnapshot(snapshot); + return program; } /** @@ -719,20 +725,15 @@ export class API implements FormatDiagnosticsHo const snapshot = await this.createSnapshot({ createPrograms: [{ rootFiles, compilerOptions, options: createProgramOptions, incremental: true }], }); - const program = this.getOwnedCreatedProgram(snapshot, "createIncrementalProgram"); + const program = snapshot.operation.createdPrograms![0]; + if (!program) { + await snapshot.dispose(); + throw new Error("createIncrementalProgram did not return a project"); + } if (!(program instanceof IncrementalProgram)) { await snapshot.dispose(); throw new Error("createIncrementalProgram did not return an incremental program"); } - return program; - } - - private getOwnedCreatedProgram(snapshot: SnapshotForOperationResults, method: "createProgram" | "createIncrementalProgram"): Program { - const program = snapshot.operation.createdPrograms![0]; - if (!program) { - void snapshot.dispose(); - throw new Error(`${method} did not return a project`); - } program.setOwnedSnapshot(snapshot); return program; } diff --git a/packages/typescript/src/api/sync/api.ts b/packages/typescript/src/api/sync/api.ts index e174c70b03b3b..3dabbdf1ac29f 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -1155,7 +1155,13 @@ export class API implements FormatDiagnosticsHo const snapshot = owner.createSnapshot({ createPrograms: [{ rootFiles, compilerOptions, options: createProgramOptions }], }); - return owner.getOwnedCreatedProgram(snapshot, "createProgram"); + const program = snapshot.operation.createdPrograms![0]; + if (!program) { + snapshot.dispose(); + throw new Error("createProgram did not return a project"); + } + program.setOwnedSnapshot(snapshot); + return program; }, function* (rootFiles: readonly DocumentIdentifier[], compilerOptions: CompilerOptions, createProgramOptions?: CreateProgramOptions): Generator { yield* owner.ensureInitialized.gen(); @@ -1163,7 +1169,13 @@ export class API implements FormatDiagnosticsHo const snapshot = yield* owner.createSnapshot.gen({ createPrograms: [{ rootFiles, compilerOptions, options: createProgramOptions }], }); - return owner.getOwnedCreatedProgram(snapshot, "createProgram"); + const program = snapshot.operation.createdPrograms![0]; + if (!program) { + yield* snapshot.dispose.gen(); + throw new Error("createProgram did not return a project"); + } + program.setOwnedSnapshot(snapshot); + return program; }, ); } @@ -1186,11 +1198,16 @@ export class API implements FormatDiagnosticsHo const snapshot = owner.createSnapshot({ createPrograms: [{ rootFiles, compilerOptions, options: createProgramOptions, incremental: true }], }); - const program = owner.getOwnedCreatedProgram(snapshot, "createIncrementalProgram"); + const program = snapshot.operation.createdPrograms![0]; + if (!program) { + snapshot.dispose(); + throw new Error("createIncrementalProgram did not return a project"); + } if (!(program instanceof IncrementalProgram)) { snapshot.dispose(); throw new Error("createIncrementalProgram did not return an incremental program"); } + program.setOwnedSnapshot(snapshot); return program; }, function* (rootFiles: readonly DocumentIdentifier[], compilerOptions: CompilerOptions, createProgramOptions?: CreateProgramOptions): Generator { @@ -1199,25 +1216,20 @@ export class API implements FormatDiagnosticsHo const snapshot = yield* owner.createSnapshot.gen({ createPrograms: [{ rootFiles, compilerOptions, options: createProgramOptions, incremental: true }], }); - const program = owner.getOwnedCreatedProgram(snapshot, "createIncrementalProgram"); + const program = snapshot.operation.createdPrograms![0]; + if (!program) { + yield* snapshot.dispose.gen(); + throw new Error("createIncrementalProgram did not return a project"); + } if (!(program instanceof IncrementalProgram)) { yield* snapshot.dispose.gen(); throw new Error("createIncrementalProgram did not return an incremental program"); } + program.setOwnedSnapshot(snapshot); return program; }, ); } - - private getOwnedCreatedProgram(snapshot: SnapshotForOperationResults, method: "createProgram" | "createIncrementalProgram"): Program { - const program = snapshot.operation.createdPrograms![0]; - if (!program) { - void snapshot.dispose(); - throw new Error(`${method} did not return a project`); - } - program.setOwnedSnapshot(snapshot); - return program; - } } type EnsureInitialized = (() => void) & { gen(): Generator; }; From 20a75d9bfed7b090bec076cc9d7d3bb5f15e386f Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Thu, 24 Sep 2026 15:27:12 -0700 Subject: [PATCH 10/11] PR feedback --- .../api/session_createprogram_test.go | 111 ++++++++++++++++++ tsc/internal/execute/incremental/program.go | 12 +- .../execute/incremental/programtosnapshot.go | 2 + .../project/projectcollectionbuilder.go | 16 +-- 4 files changed, 131 insertions(+), 10 deletions(-) diff --git a/tsc/internal/api/session_createprogram_test.go b/tsc/internal/api/session_createprogram_test.go index f211f15d978c9..eb067e0bd9c2d 100644 --- a/tsc/internal/api/session_createprogram_test.go +++ b/tsc/internal/api/session_createprogram_test.go @@ -358,6 +358,117 @@ func TestReconfigureSyntheticProgramValidation(t *testing.T) { assert.ErrorContains(t, err, "not found for reconfiguration") } +func TestIncrementalOperationsValidateBeforeWriting(t *testing.T) { + t.Parallel() + + const fileName = "/home/projects/p/index.ts" + projectSession, sessionUtils := projecttestutil.Setup(map[string]any{ + fileName: `export const value = 1;`, + }) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + ctx := context.Background() + + created, err := session.handleCreateSnapshot(ctx, &CreateSnapshotParams{ + CreatePrograms: []*CreateSnapshotProgramParams{ + { + RootFiles: []DocumentIdentifier{{FileName: fileName}}, + CompilerOptions: core.CompilerOptions{ + Incremental: core.TSTrue, + NoLib: core.TSTrue, + OutDir: "/out/incremental", + RootDir: "/home/projects/p", + TsBuildInfoFile: "/out/incremental.tsbuildinfo", + }, + Incremental: true, + }, + { + RootFiles: []DocumentIdentifier{{FileName: fileName}}, + CompilerOptions: core.CompilerOptions{ + NoLib: core.TSTrue, + OutDir: "/out/regular", + RootDir: "/home/projects/p", + }, + }, + }, + }) + assert.NilError(t, err) + programs := *created.Operation.CreatedPrograms + + _, err = session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ + Snapshot: created.Snapshot, + Changes: &CreateSnapshotParams{ + IncrementalOperations: []*IncrementalOperationParams{ + {Program: programs[0], Kind: IncrementalOperationKindEmit}, + {Program: programs[1], Kind: IncrementalOperationKindEmit}, + }, + }, + }) + assert.ErrorContains(t, err, "project is not incremental") + _, written := sessionUtils.FS().ReadFile("/out/incremental/index.js") + assert.Assert(t, !written) +} + +func TestIncrementalStatusPreservesSourceFileNameCasing(t *testing.T) { + t.Parallel() + + const fileName = "/Src/Foo.ts" + projectSession, sessionUtils := projecttestutil.Setup(map[string]any{ + fileName: `export const value = 1;`, + }) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + ctx := context.Background() + + created, err := session.handleCreateSnapshot(ctx, &CreateSnapshotParams{ + CreatePrograms: []*CreateSnapshotProgramParams{{ + RootFiles: []DocumentIdentifier{{FileName: fileName}}, + CompilerOptions: core.CompilerOptions{NoLib: core.TSTrue}, + Incremental: true, + }}, + }) + assert.NilError(t, err) + programID := (*created.Operation.CreatedPrograms)[0] + createdSnapshot, err := session.getSnapshotData(created.Snapshot) + assert.NilError(t, err) + createdProject, err := createdSnapshot.getProject(programID.AsID()) + assert.NilError(t, err) + assert.Equal(t, createdProject.IncrementalStatus().PendingEmit[0].SourceFileName, fileName) + + assert.NilError(t, sessionUtils.FS().WriteFile(fileName, `export const value = 2;`)) + updated, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ + Snapshot: created.Snapshot, + Changes: &CreateSnapshotParams{ + FileNotifications: &FileNotifications{Changed: []DocumentIdentifier{{FileName: fileName}}}, + EnsurePrograms: &EnsurePrograms{Projects: []project.ID{programID.AsID()}}, + }, + }) + assert.NilError(t, err) + updatedSnapshot, err := session.getSnapshotData(updated.Snapshot) + assert.NilError(t, err) + updatedProject, err := updatedSnapshot.getProject(programID.AsID()) + assert.NilError(t, err) + assert.DeepEqual(t, updatedProject.IncrementalStatus().ChangedFiles, []string{fileName}) + + assert.NilError(t, sessionUtils.FS().Remove(fileName)) + deleted, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ + Snapshot: updated.Snapshot, + Changes: &CreateSnapshotParams{ + FileNotifications: &FileNotifications{Deleted: []DocumentIdentifier{{FileName: fileName}}}, + EnsurePrograms: &EnsurePrograms{Projects: []project.ID{programID.AsID()}}, + }, + }) + assert.NilError(t, err) + deletedSnapshot, err := session.getSnapshotData(deleted.Snapshot) + assert.NilError(t, err) + deletedProject, err := deletedSnapshot.getProject(programID.AsID()) + assert.NilError(t, err) + assert.DeepEqual(t, deletedProject.IncrementalStatus().ChangedFiles, []string{}) + assert.DeepEqual(t, deletedProject.IncrementalStatus().PendingEmit, []*incremental.PendingEmit{}) +} + func TestCreateSyntheticProgramValidation(t *testing.T) { t.Parallel() diff --git a/tsc/internal/execute/incremental/program.go b/tsc/internal/execute/incremental/program.go index 0cf92bf4e678c..c9027a9eac97e 100644 --- a/tsc/internal/execute/incremental/program.go +++ b/tsc/internal/execute/incremental/program.go @@ -154,12 +154,12 @@ func (p *Program) Status() *Status { LatestChangedDtsFile: p.snapshot.latestChangedDtsFile, } p.snapshot.changedFilesSet.Range(func(path tspath.Path) bool { - status.ChangedFiles = append(status.ChangedFiles, string(path)) + status.ChangedFiles = append(status.ChangedFiles, p.sourceFileName(path)) return true }) p.snapshot.affectedFilesPendingEmit.Range(func(path tspath.Path, kind FileEmitKind) bool { status.PendingEmit = append(status.PendingEmit, &PendingEmit{ - SourceFileName: string(path), + SourceFileName: p.sourceFileName(path), Kind: kind, }) return true @@ -177,6 +177,14 @@ func (p *Program) Status() *Status { return status } +func (p *Program) sourceFileName(path tspath.Path) string { + file := p.program.GetSourceFileByPath(path) + if file == nil { + panic(fmt.Sprintf("incremental state contains source file path not present in program: %s", path)) + } + return file.FileName() +} + // Options implements compiler.AnyProgram interface. func (p *Program) Options() *core.CompilerOptions { return p.snapshot.options diff --git a/tsc/internal/execute/incremental/programtosnapshot.go b/tsc/internal/execute/incremental/programtosnapshot.go index bb19bcd99d9bd..3ad7c3b8c0b23 100644 --- a/tsc/internal/execute/incremental/programtosnapshot.go +++ b/tsc/internal/execute/incremental/programtosnapshot.go @@ -168,6 +168,8 @@ func (t *toProgramSnapshot) handleFileDelete() { // If the global file is removed, add all files as changed t.oldProgram.snapshot.fileInfos.Range(func(filePath tspath.Path, oldInfo *FileInfo) bool { if _, ok := t.snapshot.fileInfos.Load(filePath); !ok { + t.snapshot.changedFilesSet.Delete(filePath) + t.snapshot.affectedFilesPendingEmit.Delete(filePath) if oldInfo.affectsGlobalScope { for _, file := range t.snapshot.getAllFilesExcludingDefaultLibraryFile(t.program, nil) { t.snapshot.addFileToChangeSet(file.Path()) diff --git a/tsc/internal/project/projectcollectionbuilder.go b/tsc/internal/project/projectcollectionbuilder.go index 87c48a5ea5244..c2b9cfab383bf 100644 --- a/tsc/internal/project/projectcollectionbuilder.go +++ b/tsc/internal/project/projectcollectionbuilder.go @@ -314,6 +314,13 @@ func (b *ProjectCollectionBuilder) HandleAPIRequest(apiRequest *APISnapshotReque if request.Kind != APIIncrementalOperationKindEmit && request.Kind != APIIncrementalOperationKindEmitBuildInfo { return fmt.Errorf("unsupported incremental operation: %s", request.Kind) } + entry, ok := b.syntheticProjects.Load(request.ProgramID) + if !ok { + return fmt.Errorf("incremental program not found: %s", request.ProgramID) + } + if entry.Value().incrementalProgram == nil { + return fmt.Errorf("project is not incremental: %s", request.ProgramID) + } } createdPrograms := make([]*Project, len(apiRequest.CreatePrograms)) createdEntries := make([]dirty.Value[*Project], len(apiRequest.CreatePrograms)) @@ -381,19 +388,12 @@ func (b *ProjectCollectionBuilder) HandleAPIRequest(apiRequest *APISnapshotReque }) } for _, request := range apiRequest.IncrementalOperations { - entry, ok := b.syntheticProjects.Load(request.ProgramID) - if !ok { - return fmt.Errorf("incremental program not found: %s", request.ProgramID) - } + entry, _ := b.syntheticProjects.Load(request.ProgramID) if entry.Value().dirty { b.updateProgram(entry, logger) } var operationError error entry.Change(func(project *Project) { - if project.incrementalProgram == nil { - operationError = fmt.Errorf("project is not incremental: %s", request.ProgramID) - return - } program := project.incrementalProgram.Fork() options := compiler.EmitOptions{ EmitOnly: request.EmitOnly, From 987486e33b63e6f10e7ace13f645271bb6fa8107 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Fri, 25 Sep 2026 13:57:10 -0700 Subject: [PATCH 11/11] Only clone incremental state on semantic and declaration diagnostics --- tsc/internal/api/session.go | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/tsc/internal/api/session.go b/tsc/internal/api/session.go index 0b8d69ea6990a..6b0bf32b0f360 100644 --- a/tsc/internal/api/session.go +++ b/tsc/internal/api/session.go @@ -4706,21 +4706,33 @@ func (s *Session) toFileChangeSummary(changes *FileNotifications) project.FileCh return summary } -func (s *Session) getDiagnostics(ctx context.Context, params *GetDiagnosticsParams, getter func(compiler.ProgramLike, context.Context, *ast.SourceFile) []*ast.Diagnostic) ([]*DiagnosticResponse, error) { +func (s *Session) getDiagnostics( + ctx context.Context, + params *GetDiagnosticsParams, + incremental bool, + getter func(compiler.ProgramLike, context.Context, *ast.SourceFile) []*ast.Diagnostic, +) ([]*DiagnosticResponse, error) { sd, err := s.getSnapshotData(params.Snapshot) if err != nil { return nil, err } - program, err := sd.getProgramLike(params.Project) + compilerProgram, err := sd.getProgram(params.Project) if err != nil { return nil, err } + var program compiler.ProgramLike = compilerProgram + if incremental { + program, err = sd.getProgramLike(params.Project) + if err != nil { + return nil, err + } + } if params.Files != nil { var allDiags []*ast.Diagnostic for _, file := range params.Files { - sourceFile, err := s.resolveOptionalSourceFile(program.Program(), &file) + sourceFile, err := s.resolveOptionalSourceFile(compilerProgram, &file) if err != nil { return nil, err } @@ -4735,31 +4747,31 @@ func (s *Session) getDiagnostics(ctx context.Context, params *GetDiagnosticsPara // @gen-proto-nullable func (s *Session) handleGetSyntacticDiagnostics(ctx context.Context, params *GetDiagnosticsParams) ([]*DiagnosticResponse, error) { ctx = core.WithCheckerLifetime(ctx, core.CheckerLifetimeDiagnostics) - return s.getDiagnostics(ctx, params, compiler.ProgramLike.GetSyntacticDiagnostics) + return s.getDiagnostics(ctx, params, false, compiler.ProgramLike.GetSyntacticDiagnostics) } // @gen-proto-nullable func (s *Session) handleGetBindDiagnostics(ctx context.Context, params *GetDiagnosticsParams) ([]*DiagnosticResponse, error) { ctx = core.WithCheckerLifetime(ctx, core.CheckerLifetimeDiagnostics) - return s.getDiagnostics(ctx, params, compiler.ProgramLike.GetBindDiagnostics) + return s.getDiagnostics(ctx, params, false, compiler.ProgramLike.GetBindDiagnostics) } // @gen-proto-nullable func (s *Session) handleGetSemanticDiagnostics(ctx context.Context, params *GetDiagnosticsParams) ([]*DiagnosticResponse, error) { ctx = core.WithCheckerLifetime(ctx, core.CheckerLifetimeDiagnostics) - return s.getDiagnostics(ctx, params, compiler.ProgramLike.GetSemanticDiagnostics) + return s.getDiagnostics(ctx, params, true, compiler.ProgramLike.GetSemanticDiagnostics) } // @gen-proto-nullable func (s *Session) handleGetSuggestionDiagnostics(ctx context.Context, params *GetDiagnosticsParams) ([]*DiagnosticResponse, error) { ctx = core.WithCheckerLifetime(ctx, core.CheckerLifetimeDiagnostics) - return s.getDiagnostics(ctx, params, compiler.ProgramLike.GetSuggestionDiagnostics) + return s.getDiagnostics(ctx, params, false, compiler.ProgramLike.GetSuggestionDiagnostics) } // @gen-proto-nullable func (s *Session) handleGetDeclarationDiagnostics(ctx context.Context, params *GetDiagnosticsParams) ([]*DiagnosticResponse, error) { ctx = core.WithCheckerLifetime(ctx, core.CheckerLifetimeDiagnostics) - return s.getDiagnostics(ctx, params, compiler.ProgramLike.GetDeclarationDiagnostics) + return s.getDiagnostics(ctx, params, true, compiler.ProgramLike.GetDeclarationDiagnostics) } // handleGetConfigFileParsingDiagnostics returns config file parsing diagnostics. @@ -4770,7 +4782,7 @@ func (s *Session) handleGetConfigFileParsingDiagnostics(ctx context.Context, par return nil, err } - program, err := sd.getProgramLike(params.Project) + program, err := sd.getProgram(params.Project) if err != nil { return nil, err } @@ -4787,7 +4799,7 @@ func (s *Session) handleGetProgramDiagnostics(ctx context.Context, params *GetPr return nil, err } - program, err := sd.getProgramLike(params.Project) + program, err := sd.getProgram(params.Project) if err != nil { return nil, err }