From f49fdad9b7cd1b6e1036b3671a60e7e559e9d37d Mon Sep 17 00:00:00 2001 From: Wesley Wigham Date: Wed, 7 Oct 2026 13:48:01 -0700 Subject: [PATCH 1/4] Add snapshot.rebase(newBaseSnapshot, changes) --- packages/typescript/src/api/async/api.ts | 64 +++++- .../typescript/src/api/proto.generated.ts | 9 + packages/typescript/src/api/sync/api.ts | 118 +++++++++- packages/typescript/test/async/api.test.ts | 207 ++++++++++++++---- .../test/sync/api-generators.test.ts | 20 +- packages/typescript/test/sync/api.test.ts | 206 +++++++++++++---- tsc/internal/api/proto.go | 8 + .../api/requestfilesystem/filechanges.go | 96 ++++++++ .../api/requestfilesystem/filechanges_test.go | 55 +++++ .../api/requestfilesystem/pathtree.go | 10 +- .../api/requestfilesystem/rebase_test.go | 73 ++++++ .../requestfilesystem/requestfilesystem.go | 34 ++- tsc/internal/api/session.go | 65 ++++++ tsc/internal/api/session_completion_test.go | 43 +++- .../api/session_rebase_changes_test.go | 173 +++++++++++++++ tsc/internal/api/session_rebase_test.go | 190 ++++++++++++++++ .../api/session_requestfilesystem_test.go | 202 +++++++++-------- 17 files changed, 1364 insertions(+), 209 deletions(-) create mode 100644 tsc/internal/api/requestfilesystem/rebase_test.go create mode 100644 tsc/internal/api/session_rebase_changes_test.go create mode 100644 tsc/internal/api/session_rebase_test.go diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index e1eec8d4d3b51..2bb515f524df5 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -638,6 +638,7 @@ export class API implements FormatDiagnosticsHo this.sourceFileCache.releaseSnapshot(snapshot.id); }, this.createSnapshotUpdater(() => snapshot), + this.createSnapshotRebaser(() => snapshot), undefined, ); this.activeSnapshots.set(snapshot.id, snapshot); @@ -669,12 +670,43 @@ export class API implements FormatDiagnosticsHo this.sourceFileCache.releaseSnapshot(snapshot.id); }, this.createSnapshotUpdater(() => snapshot), + this.createSnapshotRebaser(() => snapshot), baseSnapshot, ); this.activeSnapshots.set(snapshot.id, snapshot); return snapshot; } + private async rebaseSnapshot(sourceSnapshot: Snapshot, newSnapshot: Snapshot, params?: CreateSnapshotParams): Promise { + await this.ensureInitialized(); + if (this.activeSnapshots.get(sourceSnapshot.id) !== sourceSnapshot || sourceSnapshot.isDisposed()) { + throw new Error("Cannot rebase an inactive snapshot"); + } + if (this.activeSnapshots.get(newSnapshot.id) !== newSnapshot || newSnapshot.isDisposed()) { + throw new Error("Cannot rebase onto an inactive snapshot"); + } + const data = await this.client.apiRequest("rebaseSnapshot", { + snapshot: sourceSnapshot.id, + newSnapshot: newSnapshot.id, + changes: toCreateSnapshotRequest(this.prepareCreateSnapshotParams(params)), + }); + this.sourceFileCache.retainForSnapshot(data.snapshot, newSnapshot.id, data.changes); + const snapshot = new Snapshot( + data, + this.toPath!, + this, + () => { + this.activeSnapshots.delete(snapshot.id); + this.sourceFileCache.releaseSnapshot(snapshot.id); + }, + this.createSnapshotUpdater(() => snapshot), + this.createSnapshotRebaser(() => snapshot), + newSnapshot, + ); + this.activeSnapshots.set(snapshot.id, snapshot); + return snapshot; + } + private prepareCreateSnapshotParams(params: CreateSnapshotParams | undefined): ProtocolCreateSnapshotParams | undefined { if (!params) return undefined; const prepareOptions = (options: CreateProgramOptions | undefined): ProtocolCreateProgramOptions | undefined => { @@ -712,6 +744,15 @@ export class API implements FormatDiagnosticsHo return update; } + private createSnapshotRebaser(getSnapshot: () => Snapshot): SnapshotRebaser { + const rebase: SnapshotRebaser = (newSnapshot, params) => this.rebaseSnapshot(getSnapshot(), newSnapshot, params); // @sync: const rebase = ((newSnapshot: Snapshot, params?: CreateSnapshotParams) => this.rebaseSnapshot(getSnapshot(), newSnapshot, params)) as SnapshotRebaser; + // @sync-only-start + // const owner = this; + // rebase.gen = function* (newSnapshot: Snapshot, params?: CreateSnapshotParams) { return yield* owner.rebaseSnapshot.gen(getSnapshot(), newSnapshot, params); }; + // @sync-only-end + return rebase; + } + /** * Returns the language server's current canonical snapshot after atomically * adopting any supplied API-driven changes. Only available on LSP-connected APIs. @@ -752,6 +793,7 @@ export class API implements FormatDiagnosticsHo this.sourceFileCache.releaseSnapshot(snapshot.id); }, this.createSnapshotUpdater(() => snapshot), + this.createSnapshotRebaser(() => snapshot), baseSnapshot, ); this.activeSnapshots.set(snapshot.id, snapshot); @@ -980,6 +1022,7 @@ export class InternalAPI { } type SnapshotUpdater = (params: CreateSnapshotParams) => Promise; // @sync: type SnapshotUpdater = ((params: CreateSnapshotParams) => Snapshot) & { gen(params: CreateSnapshotParams): Generator; }; +type SnapshotRebaser = (newSnapshot: Snapshot, params?: CreateSnapshotParams) => Promise; // @sync: type SnapshotRebaser = ((newSnapshot: Snapshot, params?: CreateSnapshotParams) => Snapshot) & { gen(newSnapshot: Snapshot, params?: CreateSnapshotParams): Generator; }; export interface SnapshotOperation { readonly createdPrograms?: readonly Program[] | undefined; @@ -1051,18 +1094,20 @@ export class Snapshot { private snapshotRegistry: SnapshotObjectRegistry; private projectDataMap: Map; private updateSnapshot: SnapshotUpdater; + private rebaseSnapshot: SnapshotRebaser; readonly internal: SnapshotInternalAPI; private get client(): Client { return this.api.client; } - constructor(data: CreateSnapshotResponse, toPath: (fileName: string, basePath?: string) => PathKey, api: API, onDispose: () => void, updateSnapshot: SnapshotUpdater, baseSnapshot?: Snapshot) { + constructor(data: CreateSnapshotResponse, toPath: (fileName: string, basePath?: string) => PathKey, api: API, onDispose: () => void, updateSnapshot: SnapshotUpdater, rebaseSnapshot: SnapshotRebaser, baseSnapshot?: Snapshot) { this.id = data.snapshot; this.api = api; this.toPath = toPath; this.onDispose = onDispose; this.updateSnapshot = updateSnapshot; + this.rebaseSnapshot = rebaseSnapshot; this.projectMap = new Map(); const projectDataMap = new Map(baseSnapshot?.projectDataMap); for (const projectId of data.changes?.removedProjects ?? []) { @@ -1117,6 +1162,23 @@ export class Snapshot { return this.updateSnapshot(params); } + /** + * Creates a new snapshot with this snapshot's memory filesystem overlaid on + * `newSnapshot`, retaining the target's projects and language server state. + * Source entries take precedence; a full source filesystem remains total. + * Optional changes are applied after rebasing, including auto-import preparation. + * Neither input snapshot is modified, and retained file contents are not resent. + */ + rebase< + const CreatePrograms extends CreateSnapshotParams["createPrograms"] = undefined, + const OpenFiles extends CreateSnapshotParams["openFiles"] = undefined, + >(newSnapshot: Snapshot, params: SnapshotOperationParams): Promise>; + rebase(newSnapshot: Snapshot, params?: CreateSnapshotParams): Promise; + rebase(newSnapshot: Snapshot, params?: CreateSnapshotParams): Promise { + this.ensureNotDisposed(); + return this.rebaseSnapshot(newSnapshot, params); + } + /** * Gets the default project for a given file from the configured projects and * inferred project already loaded in the snapshot. Synthetic projects are not diff --git a/packages/typescript/src/api/proto.generated.ts b/packages/typescript/src/api/proto.generated.ts index 8d8489615ec6a..31c36177f1182 100644 --- a/packages/typescript/src/api/proto.generated.ts +++ b/packages/typescript/src/api/proto.generated.ts @@ -47,6 +47,7 @@ export interface APIMethodInfo { initialize: APIMethod; createSnapshot: APIMethod; updateSnapshot: APIMethod; + rebaseSnapshot: APIMethod; getCurrentLanguageServerSnapshot: APIMethod; createBuildOrchestrator: APIMethod; disposeBuildOrchestrator: APIMethod; @@ -333,6 +334,12 @@ export interface UpdateSnapshotParams { changes?: CreateSnapshotParams | undefined; } +export interface RebaseSnapshotParams { + snapshot: number; + newSnapshot: number; + changes?: CreateSnapshotParams | undefined; +} + export interface GetCurrentLanguageServerSnapshotParams { baseSnapshot?: number | undefined; changes?: LanguageServerSnapshotChanges | undefined; @@ -1312,6 +1319,7 @@ export interface BatchRequest { | "parseJsonConfigFileContent" | "printNode" | "readConfigFile" + | "rebaseSnapshot" | "release" | "releaseModuleResolver" | "releaseSourceFile" @@ -1494,6 +1502,7 @@ export interface BatchResponse { | "parseJsonConfigFileContent" | "printNode" | "readConfigFile" + | "rebaseSnapshot" | "release" | "releaseModuleResolver" | "releaseSourceFile" diff --git a/packages/typescript/src/api/sync/api.ts b/packages/typescript/src/api/sync/api.ts index f8976dcbb6793..8be59ee45051e 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -948,6 +948,7 @@ export class API implements FormatDiagnosticsHo owner.sourceFileCache.releaseSnapshot(snapshot.id); }, owner.createSnapshotUpdater(() => snapshot), + owner.createSnapshotRebaser(() => snapshot), undefined, ); owner.activeSnapshots.set(snapshot.id, snapshot); @@ -971,6 +972,7 @@ export class API implements FormatDiagnosticsHo owner.sourceFileCache.releaseSnapshot(snapshot.id); }, owner.createSnapshotUpdater(() => snapshot), + owner.createSnapshotRebaser(() => snapshot), undefined, ); owner.activeSnapshots.set(snapshot.id, snapshot); @@ -1012,6 +1014,7 @@ export class API implements FormatDiagnosticsHo owner.sourceFileCache.releaseSnapshot(snapshot.id); }, owner.createSnapshotUpdater(() => snapshot), + owner.createSnapshotRebaser(() => snapshot), baseSnapshot, ); owner.activeSnapshots.set(snapshot.id, snapshot); @@ -1041,6 +1044,7 @@ export class API implements FormatDiagnosticsHo owner.sourceFileCache.releaseSnapshot(snapshot.id); }, owner.createSnapshotUpdater(() => snapshot), + owner.createSnapshotRebaser(() => snapshot), baseSnapshot, ); owner.activeSnapshots.set(snapshot.id, snapshot); @@ -1049,6 +1053,75 @@ export class API implements FormatDiagnosticsHo ); } + private get rebaseSnapshot(): { + (sourceSnapshot: Snapshot, newSnapshot: Snapshot, params?: CreateSnapshotParams): Snapshot; + gen(sourceSnapshot: Snapshot, newSnapshot: Snapshot, params?: CreateSnapshotParams): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "rebaseSnapshot", + function (sourceSnapshot: Snapshot, newSnapshot: Snapshot, params?: CreateSnapshotParams): Snapshot { + owner.ensureInitialized(); + if (owner.activeSnapshots.get(sourceSnapshot.id) !== sourceSnapshot || sourceSnapshot.isDisposed()) { + throw new Error("Cannot rebase an inactive snapshot"); + } + if (owner.activeSnapshots.get(newSnapshot.id) !== newSnapshot || newSnapshot.isDisposed()) { + throw new Error("Cannot rebase onto an inactive snapshot"); + } + const data = owner.client.apiRequest("rebaseSnapshot", { + snapshot: sourceSnapshot.id, + newSnapshot: newSnapshot.id, + changes: toCreateSnapshotRequest(owner.prepareCreateSnapshotParams(params)), + }); + owner.sourceFileCache.retainForSnapshot(data.snapshot, newSnapshot.id, data.changes); + const snapshot = new Snapshot( + data, + owner.toPath!, + owner, + () => { + owner.activeSnapshots.delete(snapshot.id); + owner.sourceFileCache.releaseSnapshot(snapshot.id); + }, + owner.createSnapshotUpdater(() => snapshot), + owner.createSnapshotRebaser(() => snapshot), + newSnapshot, + ); + owner.activeSnapshots.set(snapshot.id, snapshot); + return snapshot; + }, + function* (sourceSnapshot: Snapshot, newSnapshot: Snapshot, params?: CreateSnapshotParams): Generator { + yield* owner.ensureInitialized.gen(); + if (owner.activeSnapshots.get(sourceSnapshot.id) !== sourceSnapshot || sourceSnapshot.isDisposed()) { + throw new Error("Cannot rebase an inactive snapshot"); + } + if (owner.activeSnapshots.get(newSnapshot.id) !== newSnapshot || newSnapshot.isDisposed()) { + throw new Error("Cannot rebase onto an inactive snapshot"); + } + const data = yield* apiRequest("rebaseSnapshot", { + snapshot: sourceSnapshot.id, + newSnapshot: newSnapshot.id, + changes: toCreateSnapshotRequest(owner.prepareCreateSnapshotParams(params)), + }); + owner.sourceFileCache.retainForSnapshot(data.snapshot, newSnapshot.id, data.changes); + const snapshot = new Snapshot( + data, + owner.toPath!, + owner, + () => { + owner.activeSnapshots.delete(snapshot.id); + owner.sourceFileCache.releaseSnapshot(snapshot.id); + }, + owner.createSnapshotUpdater(() => snapshot), + owner.createSnapshotRebaser(() => snapshot), + newSnapshot, + ); + owner.activeSnapshots.set(snapshot.id, snapshot); + return snapshot; + }, + ); + } + private prepareCreateSnapshotParams(params: CreateSnapshotParams | undefined): ProtocolCreateSnapshotParams | undefined { if (!params) return undefined; const prepareOptions = (options: CreateProgramOptions | undefined): ProtocolCreateProgramOptions | undefined => { @@ -1086,6 +1159,15 @@ export class API implements FormatDiagnosticsHo return update; } + private createSnapshotRebaser(getSnapshot: () => Snapshot): SnapshotRebaser { + const rebase = ((newSnapshot: Snapshot, params?: CreateSnapshotParams) => this.rebaseSnapshot(getSnapshot(), newSnapshot, params)) as SnapshotRebaser; + const owner = this; + rebase.gen = function* (newSnapshot: Snapshot, params?: CreateSnapshotParams) { + return yield* owner.rebaseSnapshot.gen(getSnapshot(), newSnapshot, params); + }; + return rebase; + } + /** * Returns the language server's current canonical snapshot after atomically * adopting any supplied API-driven changes. Only available on LSP-connected APIs. @@ -1132,6 +1214,7 @@ export class API implements FormatDiagnosticsHo owner.sourceFileCache.releaseSnapshot(snapshot.id); }, owner.createSnapshotUpdater(() => snapshot), + owner.createSnapshotRebaser(() => snapshot), baseSnapshot, ); owner.activeSnapshots.set(snapshot.id, snapshot); @@ -1166,6 +1249,7 @@ export class API implements FormatDiagnosticsHo owner.sourceFileCache.releaseSnapshot(snapshot.id); }, owner.createSnapshotUpdater(() => snapshot), + owner.createSnapshotRebaser(() => snapshot), baseSnapshot, ); owner.activeSnapshots.set(snapshot.id, snapshot); @@ -1608,6 +1692,7 @@ export class InternalAPI { } type SnapshotUpdater = ((params: CreateSnapshotParams) => Snapshot) & { gen(params: CreateSnapshotParams): Generator; }; +type SnapshotRebaser = ((newSnapshot: Snapshot, params?: CreateSnapshotParams) => Snapshot) & { gen(newSnapshot: Snapshot, params?: CreateSnapshotParams): Generator; }; export interface SnapshotOperation { readonly createdPrograms?: readonly Program[] | undefined; @@ -1679,18 +1764,20 @@ export class Snapshot { private snapshotRegistry: SnapshotObjectRegistry; private projectDataMap: Map; private updateSnapshot: SnapshotUpdater; + private rebaseSnapshot: SnapshotRebaser; readonly internal: SnapshotInternalAPI; private get client(): Client { return this.api.client; } - constructor(data: CreateSnapshotResponse, toPath: (fileName: string, basePath?: string) => PathKey, api: API, onDispose: () => void, updateSnapshot: SnapshotUpdater, baseSnapshot?: Snapshot) { + constructor(data: CreateSnapshotResponse, toPath: (fileName: string, basePath?: string) => PathKey, api: API, onDispose: () => void, updateSnapshot: SnapshotUpdater, rebaseSnapshot: SnapshotRebaser, baseSnapshot?: Snapshot) { this.id = data.snapshot; this.api = api; this.toPath = toPath; this.onDispose = onDispose; this.updateSnapshot = updateSnapshot; + this.rebaseSnapshot = rebaseSnapshot; this.projectMap = new Map(); const projectDataMap = new Map(baseSnapshot?.projectDataMap); for (const projectId of data.changes?.removedProjects ?? []) { @@ -1757,6 +1844,35 @@ export class Snapshot { return cacheGeneratorMethod(owner, "update", update, gen); } + /** + * Creates a new snapshot with this snapshot's memory filesystem overlaid on + * `newSnapshot`, retaining the target's projects and language server state. + * Source entries take precedence; a full source filesystem remains total. + * Optional changes are applied after rebasing, including auto-import preparation. + * Neither input snapshot is modified, and retained file contents are not resent. + */ + get rebase(): { + (newSnapshot: Snapshot, params: SnapshotOperationParams): SnapshotForOperationResults; + (newSnapshot: Snapshot, params?: CreateSnapshotParams): Snapshot; + gen(newSnapshot: Snapshot, params: SnapshotOperationParams): Generator, ProtocolResponse["result"]>; + gen(newSnapshot: Snapshot, params?: CreateSnapshotParams): Generator; + } { + const owner = this; + function rebase(newSnapshot: Snapshot, params: SnapshotOperationParams): SnapshotForOperationResults; + function rebase(newSnapshot: Snapshot, params?: CreateSnapshotParams): Snapshot; + function rebase(newSnapshot: Snapshot, params?: CreateSnapshotParams): Snapshot { + owner.ensureNotDisposed(); + return owner.rebaseSnapshot(newSnapshot, params); + } + function gen(newSnapshot: Snapshot, params: SnapshotOperationParams): Generator, ProtocolResponse["result"]>; + function gen(newSnapshot: Snapshot, params?: CreateSnapshotParams): Generator; + function* gen(newSnapshot: Snapshot, params?: CreateSnapshotParams): Generator { + owner.ensureNotDisposed(); + return yield* owner.rebaseSnapshot.gen(newSnapshot, params); + } + return cacheGeneratorMethod(owner, "rebase", rebase, gen); + } + /** * Gets the default project for a given file from the configured projects and * inferred project already loaded in the snapshot. Synthetic projects are not diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index f9245bdb4d216..c43b7f4f45dc4 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -67,6 +67,7 @@ import { type CompilerOptions, type ConditionalType, type ConfiguredProjectId, + type CreateSnapshotParams, DiagnosticCategory, type DocumentIdentifier, EmitOnly, @@ -5440,60 +5441,170 @@ describe("updateSnapshot file systems", { concurrency }, () => { assert.deepEqual(callbackCalls, []); }); - test("Snapshot.update layers filesystem edits and removals", async () => { - await using api = new API({ - cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), - }); - await using snapshot = await api.createSnapshot({ - openProject: "/tsconfig.json", - fileSystem: createFileSystem(Object.entries({ - "/tsconfig.json": JSON.stringify({ - compilerOptions: { noLib: true }, - include: ["src/**/*.ts"], - }), - "/src/keep.ts": `export const keep = true;`, - "/src/change.ts": `export const version = "old";`, - "/src/remove.ts": `export const remove = true;`, - "/src/removed/gone.ts": `export const gone = true;`, - })), + for (const selfRebase of [false, true]) { + test(`Snapshot.${selfRebase ? "rebase" : "update"} layers filesystem edits and removals`, async () => { + const update = (base: Snapshot, changes: CreateSnapshotParams) => selfRebase ? base.rebase(base, changes) : base.update(changes); + await using api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + }); + await using snapshot = await api.createSnapshot({ + openProject: "/tsconfig.json", + fileSystem: createFileSystem(Object.entries({ + "/tsconfig.json": JSON.stringify({ + compilerOptions: { noLib: true }, + include: ["src/**/*.ts"], + }), + "/src/keep.ts": `export const keep = true;`, + "/src/change.ts": `export const version = "old";`, + "/src/remove.ts": `export const remove = true;`, + "/src/removed/gone.ts": `export const gone = true;`, + })), + }); + + await using updated = await update(snapshot, { + ensurePrograms: true, + fileSystem: createFileSystemLayer( + Object.entries({ + "/src/change.ts": `export const version = "new";`, + "/src/added.ts": `export const added = true;`, + }), + { + removedPaths: ["/src/remove.ts", "/src/removed"], + }, + ), + }); + const project = updated.getConfiguredProject("/tsconfig.json")!; + assert.equal((await project.program.getSourceFile("/src/keep.ts"))?.text, `export const keep = true;`); + assert.equal((await project.program.getSourceFile("/src/change.ts"))?.text, `export const version = "new";`); + assert.equal((await project.program.getSourceFile("/src/added.ts"))?.text, `export const added = true;`); + assert.equal(await project.program.getSourceFile("/src/remove.ts"), undefined); + assert.equal(await project.program.getSourceFile("/src/removed/gone.ts"), undefined); + await using fork = await update(snapshot, { ensurePrograms: true }); + assert.equal((await fork.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/src/change.ts"))?.text, `export const version = "old";`); + + await using updatedAgain = await update(updated, { + ensurePrograms: true, + fileSystem: createFileSystemLayer( + Object.entries({ + "/src/added.ts": `export const added = "updated again";`, + }), + { + removedPaths: ["/src/change.ts"], + }, + ), + }); + const updatedAgainProject = updatedAgain.getConfiguredProject("/tsconfig.json")!; + assert.equal((await updatedAgainProject.program.getSourceFile("/src/keep.ts"))?.text, `export const keep = true;`); + assert.equal((await updatedAgainProject.program.getSourceFile("/src/added.ts"))?.text, `export const added = "updated again";`); + assert.equal(await updatedAgainProject.program.getSourceFile("/src/change.ts"), undefined); }); + } - await using updated = await snapshot.update({ + test("Snapshot.rebase preserves explicit full invalidation", async () => { + const { api: disposableAPI, fs } = spawnAPIWithFS({ "/index.ts": "export const value = 1;" }); + await using api = disposableAPI; + await using original = await api.createSnapshot({ + createPrograms: [{ rootFiles: ["/index.ts"], compilerOptions: { noLib: true } }], + }); + const programId = original.operation.createdPrograms[0].id; + await using target = await original.update({}); + fs.writeFile!(toRootedFilePath("/index.ts", undefined), "export const value = 2;"); + const changes = { fileNotifications: { invalidateAll: true }, ensurePrograms: true } as const; + await using updated = await original.update(changes); + assert.equal((await updated.getProgram(programId)!.getSourceFile("/index.ts"))!.text, "export const value = 2;"); + for (const base of [original, target]) { + await using rebased = await original.rebase(base, changes); + assert.equal((await rebased.getProgram(programId)!.getSourceFile("/index.ts"))!.text, "export const value = 2;"); + } + assert.equal((await original.getProgram(programId)!.getSourceFile("/index.ts"))!.text, "export const value = 1;"); + }); + + test("Snapshot.rebase refreshes dependency auto-imports", async () => { + const fileName = "/home/project/index.ts"; + const dependency = "/home/project/node_modules/my-pkg/index.d.ts"; + await using api = spawnAPI({ + "/home/project/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true } }), + [fileName]: "package", + "/home/project/package.json": JSON.stringify({ dependencies: { "my-pkg": "1.0.0" } }), + "/home/project/node_modules/my-pkg/package.json": JSON.stringify({ name: "my-pkg", version: "1.0.0", types: "index.d.ts" }), + [dependency]: "export declare const packageOld: number;", + }); + await using target = await api.createSnapshot({ + openProjects: ["/home/project/tsconfig.json"], + prepareAutoImports: fileName, + }); + const completionNames = async (snapshot: Snapshot) => (await snapshot.getConfiguredProject("/home/project/tsconfig.json")!.languageService.getCompletionsAtPosition(fileName, 7, { includeSymbol: true }))!.entries.map(entry => entry.name); + assert.ok((await completionNames(target)).includes("packageOld")); + await using source = await target.update({ + prepareAutoImports: fileName, + fileSystem: createFileSystemLayer([[dependency, "export declare const packageNew: number;"]]), + }); + assert.ok((await completionNames(source)).includes("packageNew")); + for (const base of [target, source]) { + await using rebased = await source.rebase(base, { prepareAutoImports: fileName }); + const names = await completionNames(rebased); + assert.ok(names.includes("packageNew")); + assert.ok(!names.includes("packageOld")); + } + }); + + test("Snapshot.rebase applies memory filesystems over the target snapshot", async () => { + const { api: disposableAPI, fs } = spawnAPIWithFS({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, include: ["src/**/*.ts"] }), + "/src/index.ts": "export const value = 1;", + "/other/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: ["index.ts"] }), + "/other/index.ts": "export const other = true;", + }); + await using api = disposableAPI; + await using original = await api.createSnapshot({ openProjects: ["/tsconfig.json"] }); + await using extras = await original.update({ ensurePrograms: true, - fileSystem: createFileSystemLayer( - Object.entries({ - "/src/change.ts": `export const version = "new";`, - "/src/added.ts": `export const added = true;`, - }), - { - removedPaths: ["/src/remove.ts", "/src/removed"], - }, - ), + fileSystem: createFileSystemLayer([ + ["/src/extra.ts", "export const extra = true;"], + ], { removedPaths: ["/src/removed"] }), }); - const project = updated.getConfiguredProject("/tsconfig.json")!; - assert.equal((await project.program.getSourceFile("/src/keep.ts"))?.text, `export const keep = true;`); - assert.equal((await project.program.getSourceFile("/src/change.ts"))?.text, `export const version = "new";`); - assert.equal((await project.program.getSourceFile("/src/added.ts"))?.text, `export const added = true;`); - assert.equal(await project.program.getSourceFile("/src/remove.ts"), undefined); - assert.equal(await project.program.getSourceFile("/src/removed/gone.ts"), undefined); - await using fork = await snapshot.update({ ensurePrograms: true }); - assert.equal((await fork.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/src/change.ts"))?.text, `export const version = "old";`); - - await using updatedAgain = await updated.update({ + await using moreExtras = await extras.update({ + fileSystem: createFileSystemLayer([["/src/second.ts", "export const second = true;"]]), + }); + fs.writeFile!(toRootedFilePath("/src/index.ts", undefined), "export const value = 2;"); + await using newOriginal = await original.update({ + openProjects: ["/other/tsconfig.json"], ensurePrograms: true, - fileSystem: createFileSystemLayer( - Object.entries({ - "/src/added.ts": `export const added = "updated again";`, - }), - { - removedPaths: ["/src/change.ts"], - }, - ), + fileNotifications: { changed: ["/src/index.ts"] }, + fileSystem: createFileSystemLayer([ + ["/src/extra.ts", "export const extra = false;"], + ["/src/target.ts", "export const target = true;"], + ["/src/removed/nested.ts", "export const removed = true;"], + ]), }); - const updatedAgainProject = updatedAgain.getConfiguredProject("/tsconfig.json")!; - assert.equal((await updatedAgainProject.program.getSourceFile("/src/keep.ts"))?.text, `export const keep = true;`); - assert.equal((await updatedAgainProject.program.getSourceFile("/src/added.ts"))?.text, `export const added = "updated again";`); - assert.equal(await updatedAgainProject.program.getSourceFile("/src/change.ts"), undefined); + await using rebased = await moreExtras.rebase(newOriginal, { + prepareAutoImports: "/src/index.ts", + openFiles: ["/src/index.ts"], + createPrograms: [{ rootFiles: ["/src/extra.ts"], compilerOptions: { noLib: true } }], + fileSystem: createFileSystemLayer([["/src/second.ts", "export const second = false;"]]), + }); + assert.notEqual(rebased.id, moreExtras.id); + assert.notEqual(rebased.id, newOriginal.id); + assert.ok(rebased.getConfiguredProject("/other/tsconfig.json")); + assert.strictEqual(rebased.operation.openedFiles[0].project, rebased.getConfiguredProject("/tsconfig.json")); + assert.equal((await rebased.operation.createdPrograms[0].getSourceFile("/src/extra.ts"))!.text, "export const extra = true;"); + const program = rebased.getConfiguredProject("/tsconfig.json")!.program; + assert.equal((await program.getSourceFile("/src/index.ts"))!.text, "export const value = 2;"); + assert.equal((await program.getSourceFile("/src/extra.ts"))!.text, "export const extra = true;"); + assert.equal((await program.getSourceFile("/src/second.ts"))!.text, "export const second = false;"); + assert.equal((await program.getSourceFile("/src/target.ts"))!.text, "export const target = true;"); + assert.equal(await program.getSourceFile("/src/removed/nested.ts"), undefined); + assert.equal((await extras.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/src/index.ts"))!.text, "export const value = 1;"); + assert.equal((await newOriginal.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/src/extra.ts"))!.text, "export const extra = false;"); + await extras.dispose(); + await moreExtras.dispose(); + await newOriginal.dispose(); + assert.equal((await program.getSourceFile("/src/extra.ts"))!.text, "export const extra = true;"); + await using rebasedAgain = await rebased.rebase(original, { ensurePrograms: true }); + assert.equal((await rebasedAgain.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/src/target.ts"))!.text, "export const target = true;"); + assert.throws(() => moreExtras.rebase(original), /disposed/); + // @sync: assert.throws(() => rebased.rebase(newOriginal), /inactive snapshot/); + await assert.rejects(rebased.rebase(newOriginal), /inactive snapshot/); // @sync-skip }); test("eager snapshot disposal does not retain filesystem history", async () => { diff --git a/packages/typescript/test/sync/api-generators.test.ts b/packages/typescript/test/sync/api-generators.test.ts index 4a167fee90eb6..08b41eff7cb05 100644 --- a/packages/typescript/test/sync/api-generators.test.ts +++ b/packages/typescript/test/sync/api-generators.test.ts @@ -165,6 +165,7 @@ const privateGeneratorGetters = new Set([ "API.ensureInitialized", "API.fetchDeclarationSymbol", "API.initializeWorker", + "API.rebaseSnapshot", "API.updateSnapshot", "Checker.getIntrinsicType", "Checker.getWellKnownSignatures", @@ -1801,8 +1802,12 @@ describe("API - generator batching", { concurrency: areTestsFiltered() }, () => runParityBatch(api, cases); assert.deepEqual(temporaryProjects, ["/tsconfig.json", "/tsconfig.json"]); - const snapshotGeneratorAPI = spawnAPI(parityFiles); - const snapshotSyncAPI = spawnAPI(parityFiles); + const snapshotFiles = { + ...parityFiles, + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: ["src/syntax.ts"] }), + }; + const snapshotGeneratorAPI = spawnAPI(snapshotFiles); + const snapshotSyncAPI = spawnAPI(snapshotFiles); try { const generatorBase = snapshotGeneratorAPI.batch(snapshotGeneratorAPI.createSnapshot.gen({ openProject: "/tsconfig.json" }))[0]; const syncBase = snapshotSyncAPI.createSnapshot({ openProject: "/tsconfig.json" }); @@ -1810,6 +1815,17 @@ describe("API - generator batching", { concurrency: areTestsFiltered() }, () => const syncUpdated = syncBase.update({}); assertSnapshotsEquivalent(generatorUpdated, syncUpdated, "Snapshot.update"); exercisedMethods.add("Snapshot.update"); + const changes = { + fileSystem: { kind: "layer", files: { "/src/syntax.ts": "export const fixed = true;" } }, + } as const; + const generatorExtras = snapshotGeneratorAPI.batch(generatorUpdated.update.gen(changes))[0]; + const syncExtras = syncUpdated.update(changes); + const rebaseChanges = { prepareAutoImports: "/src/syntax.ts", openFiles: ["/src/syntax.ts"] } as const; + const generatorRebased = snapshotGeneratorAPI.batch(generatorExtras.rebase.gen(generatorBase, rebaseChanges))[0]; + const syncRebased = syncExtras.rebase(syncBase, rebaseChanges); + assertSnapshotsEquivalent(generatorRebased, syncRebased, "Snapshot.rebase"); + assert.equal(generatorRebased.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/src/syntax.ts")!.text, "export const fixed = true;"); + exercisedMethods.add("Snapshot.rebase"); } finally { snapshotGeneratorAPI.close(); diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index b9f69d2e14aba..549aa653421a4 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -88,6 +88,7 @@ import { type CompilerOptions, type ConditionalType, type ConfiguredProjectId, + type CreateSnapshotParams, DiagnosticCategory, type DocumentIdentifier, EmitOnly, @@ -5241,60 +5242,169 @@ describe("updateSnapshot file systems", { concurrency }, () => { assert.deepEqual(callbackCalls, []); }); - test("Snapshot.update layers filesystem edits and removals", () => { - using api = new API({ - cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), - }); - using snapshot = api.createSnapshot({ - openProject: "/tsconfig.json", - fileSystem: createFileSystem(Object.entries({ - "/tsconfig.json": JSON.stringify({ - compilerOptions: { noLib: true }, - include: ["src/**/*.ts"], - }), - "/src/keep.ts": `export const keep = true;`, - "/src/change.ts": `export const version = "old";`, - "/src/remove.ts": `export const remove = true;`, - "/src/removed/gone.ts": `export const gone = true;`, - })), + for (const selfRebase of [false, true]) { + test(`Snapshot.${selfRebase ? "rebase" : "update"} layers filesystem edits and removals`, () => { + const update = (base: Snapshot, changes: CreateSnapshotParams) => selfRebase ? base.rebase(base, changes) : base.update(changes); + using api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + }); + using snapshot = api.createSnapshot({ + openProject: "/tsconfig.json", + fileSystem: createFileSystem(Object.entries({ + "/tsconfig.json": JSON.stringify({ + compilerOptions: { noLib: true }, + include: ["src/**/*.ts"], + }), + "/src/keep.ts": `export const keep = true;`, + "/src/change.ts": `export const version = "old";`, + "/src/remove.ts": `export const remove = true;`, + "/src/removed/gone.ts": `export const gone = true;`, + })), + }); + + using updated = update(snapshot, { + ensurePrograms: true, + fileSystem: createFileSystemLayer( + Object.entries({ + "/src/change.ts": `export const version = "new";`, + "/src/added.ts": `export const added = true;`, + }), + { + removedPaths: ["/src/remove.ts", "/src/removed"], + }, + ), + }); + const project = updated.getConfiguredProject("/tsconfig.json")!; + assert.equal((project.program.getSourceFile("/src/keep.ts"))?.text, `export const keep = true;`); + assert.equal((project.program.getSourceFile("/src/change.ts"))?.text, `export const version = "new";`); + assert.equal((project.program.getSourceFile("/src/added.ts"))?.text, `export const added = true;`); + assert.equal(project.program.getSourceFile("/src/remove.ts"), undefined); + assert.equal(project.program.getSourceFile("/src/removed/gone.ts"), undefined); + using fork = update(snapshot, { ensurePrograms: true }); + assert.equal((fork.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/src/change.ts"))?.text, `export const version = "old";`); + + using updatedAgain = update(updated, { + ensurePrograms: true, + fileSystem: createFileSystemLayer( + Object.entries({ + "/src/added.ts": `export const added = "updated again";`, + }), + { + removedPaths: ["/src/change.ts"], + }, + ), + }); + const updatedAgainProject = updatedAgain.getConfiguredProject("/tsconfig.json")!; + assert.equal((updatedAgainProject.program.getSourceFile("/src/keep.ts"))?.text, `export const keep = true;`); + assert.equal((updatedAgainProject.program.getSourceFile("/src/added.ts"))?.text, `export const added = "updated again";`); + assert.equal(updatedAgainProject.program.getSourceFile("/src/change.ts"), undefined); }); + } - using updated = snapshot.update({ + test("Snapshot.rebase preserves explicit full invalidation", () => { + const { api: disposableAPI, fs } = spawnAPIWithFS({ "/index.ts": "export const value = 1;" }); + using api = disposableAPI; + using original = api.createSnapshot({ + createPrograms: [{ rootFiles: ["/index.ts"], compilerOptions: { noLib: true } }], + }); + const programId = original.operation.createdPrograms[0].id; + using target = original.update({}); + fs.writeFile!(toRootedFilePath("/index.ts", undefined), "export const value = 2;"); + const changes = { fileNotifications: { invalidateAll: true }, ensurePrograms: true } as const; + using updated = original.update(changes); + assert.equal((updated.getProgram(programId)!.getSourceFile("/index.ts"))!.text, "export const value = 2;"); + for (const base of [original, target]) { + using rebased = original.rebase(base, changes); + assert.equal((rebased.getProgram(programId)!.getSourceFile("/index.ts"))!.text, "export const value = 2;"); + } + assert.equal((original.getProgram(programId)!.getSourceFile("/index.ts"))!.text, "export const value = 1;"); + }); + + test("Snapshot.rebase refreshes dependency auto-imports", () => { + const fileName = "/home/project/index.ts"; + const dependency = "/home/project/node_modules/my-pkg/index.d.ts"; + using api = spawnAPI({ + "/home/project/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true } }), + [fileName]: "package", + "/home/project/package.json": JSON.stringify({ dependencies: { "my-pkg": "1.0.0" } }), + "/home/project/node_modules/my-pkg/package.json": JSON.stringify({ name: "my-pkg", version: "1.0.0", types: "index.d.ts" }), + [dependency]: "export declare const packageOld: number;", + }); + using target = api.createSnapshot({ + openProjects: ["/home/project/tsconfig.json"], + prepareAutoImports: fileName, + }); + const completionNames = (snapshot: Snapshot) => (snapshot.getConfiguredProject("/home/project/tsconfig.json")!.languageService.getCompletionsAtPosition(fileName, 7, { includeSymbol: true }))!.entries.map(entry => entry.name); + assert.ok((completionNames(target)).includes("packageOld")); + using source = target.update({ + prepareAutoImports: fileName, + fileSystem: createFileSystemLayer([[dependency, "export declare const packageNew: number;"]]), + }); + assert.ok((completionNames(source)).includes("packageNew")); + for (const base of [target, source]) { + using rebased = source.rebase(base, { prepareAutoImports: fileName }); + const names = completionNames(rebased); + assert.ok(names.includes("packageNew")); + assert.ok(!names.includes("packageOld")); + } + }); + + test("Snapshot.rebase applies memory filesystems over the target snapshot", () => { + const { api: disposableAPI, fs } = spawnAPIWithFS({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, include: ["src/**/*.ts"] }), + "/src/index.ts": "export const value = 1;", + "/other/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: ["index.ts"] }), + "/other/index.ts": "export const other = true;", + }); + using api = disposableAPI; + using original = api.createSnapshot({ openProjects: ["/tsconfig.json"] }); + using extras = original.update({ ensurePrograms: true, - fileSystem: createFileSystemLayer( - Object.entries({ - "/src/change.ts": `export const version = "new";`, - "/src/added.ts": `export const added = true;`, - }), - { - removedPaths: ["/src/remove.ts", "/src/removed"], - }, - ), + fileSystem: createFileSystemLayer([ + ["/src/extra.ts", "export const extra = true;"], + ], { removedPaths: ["/src/removed"] }), }); - const project = updated.getConfiguredProject("/tsconfig.json")!; - assert.equal((project.program.getSourceFile("/src/keep.ts"))?.text, `export const keep = true;`); - assert.equal((project.program.getSourceFile("/src/change.ts"))?.text, `export const version = "new";`); - assert.equal((project.program.getSourceFile("/src/added.ts"))?.text, `export const added = true;`); - assert.equal(project.program.getSourceFile("/src/remove.ts"), undefined); - assert.equal(project.program.getSourceFile("/src/removed/gone.ts"), undefined); - using fork = snapshot.update({ ensurePrograms: true }); - assert.equal((fork.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/src/change.ts"))?.text, `export const version = "old";`); - - using updatedAgain = updated.update({ + using moreExtras = extras.update({ + fileSystem: createFileSystemLayer([["/src/second.ts", "export const second = true;"]]), + }); + fs.writeFile!(toRootedFilePath("/src/index.ts", undefined), "export const value = 2;"); + using newOriginal = original.update({ + openProjects: ["/other/tsconfig.json"], ensurePrograms: true, - fileSystem: createFileSystemLayer( - Object.entries({ - "/src/added.ts": `export const added = "updated again";`, - }), - { - removedPaths: ["/src/change.ts"], - }, - ), + fileNotifications: { changed: ["/src/index.ts"] }, + fileSystem: createFileSystemLayer([ + ["/src/extra.ts", "export const extra = false;"], + ["/src/target.ts", "export const target = true;"], + ["/src/removed/nested.ts", "export const removed = true;"], + ]), }); - const updatedAgainProject = updatedAgain.getConfiguredProject("/tsconfig.json")!; - assert.equal((updatedAgainProject.program.getSourceFile("/src/keep.ts"))?.text, `export const keep = true;`); - assert.equal((updatedAgainProject.program.getSourceFile("/src/added.ts"))?.text, `export const added = "updated again";`); - assert.equal(updatedAgainProject.program.getSourceFile("/src/change.ts"), undefined); + using rebased = moreExtras.rebase(newOriginal, { + prepareAutoImports: "/src/index.ts", + openFiles: ["/src/index.ts"], + createPrograms: [{ rootFiles: ["/src/extra.ts"], compilerOptions: { noLib: true } }], + fileSystem: createFileSystemLayer([["/src/second.ts", "export const second = false;"]]), + }); + assert.notEqual(rebased.id, moreExtras.id); + assert.notEqual(rebased.id, newOriginal.id); + assert.ok(rebased.getConfiguredProject("/other/tsconfig.json")); + assert.strictEqual(rebased.operation.openedFiles[0].project, rebased.getConfiguredProject("/tsconfig.json")); + assert.equal((rebased.operation.createdPrograms[0].getSourceFile("/src/extra.ts"))!.text, "export const extra = true;"); + const program = rebased.getConfiguredProject("/tsconfig.json")!.program; + assert.equal((program.getSourceFile("/src/index.ts"))!.text, "export const value = 2;"); + assert.equal((program.getSourceFile("/src/extra.ts"))!.text, "export const extra = true;"); + assert.equal((program.getSourceFile("/src/second.ts"))!.text, "export const second = false;"); + assert.equal((program.getSourceFile("/src/target.ts"))!.text, "export const target = true;"); + assert.equal(program.getSourceFile("/src/removed/nested.ts"), undefined); + assert.equal((extras.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/src/index.ts"))!.text, "export const value = 1;"); + assert.equal((newOriginal.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/src/extra.ts"))!.text, "export const extra = false;"); + extras.dispose(); + moreExtras.dispose(); + newOriginal.dispose(); + assert.equal((program.getSourceFile("/src/extra.ts"))!.text, "export const extra = true;"); + using rebasedAgain = rebased.rebase(original, { ensurePrograms: true }); + assert.equal((rebasedAgain.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/src/target.ts"))!.text, "export const target = true;"); + assert.throws(() => moreExtras.rebase(original), /disposed/); + assert.throws(() => rebased.rebase(newOriginal), /inactive snapshot/); }); test("eager snapshot disposal does not retain filesystem history", () => { diff --git a/tsc/internal/api/proto.go b/tsc/internal/api/proto.go index 55bede513df9e..da823eae72162 100644 --- a/tsc/internal/api/proto.go +++ b/tsc/internal/api/proto.go @@ -76,6 +76,7 @@ const ( MethodInitialize Method = "initialize" MethodCreateSnapshot Method = "createSnapshot" MethodUpdateSnapshot Method = "updateSnapshot" + MethodRebaseSnapshot Method = "rebaseSnapshot" MethodGetCurrentLanguageServerSnapshot Method = "getCurrentLanguageServerSnapshot" MethodCreateBuildOrchestrator Method = "createBuildOrchestrator" MethodDisposeBuildOrchestrator Method = "disposeBuildOrchestrator" @@ -471,6 +472,12 @@ type UpdateSnapshotParams struct { Changes *CreateSnapshotParams `json:"changes,omitempty"` } +type RebaseSnapshotParams struct { + Snapshot SnapshotID `json:"snapshot"` + NewSnapshot SnapshotID `json:"newSnapshot"` + Changes *CreateSnapshotParams `json:"changes,omitempty"` +} + type GetCurrentLanguageServerSnapshotParams struct { BaseSnapshot SnapshotID `json:"baseSnapshot,omitempty"` Changes *LanguageServerSnapshotChanges `json:"changes,omitempty"` @@ -601,6 +608,7 @@ var unmarshalers = map[Method]func([]byte) (any, error){ MethodInitialize: noParams, MethodCreateSnapshot: unmarshallerFor[CreateSnapshotParams], MethodUpdateSnapshot: unmarshallerFor[UpdateSnapshotParams], + MethodRebaseSnapshot: unmarshallerFor[RebaseSnapshotParams], MethodGetCurrentLanguageServerSnapshot: unmarshallerFor[GetCurrentLanguageServerSnapshotParams], MethodCreateBuildOrchestrator: unmarshallerFor[CreateBuildOrchestratorParams], MethodDisposeBuildOrchestrator: unmarshallerFor[DisposeBuildOrchestratorParams], diff --git a/tsc/internal/api/requestfilesystem/filechanges.go b/tsc/internal/api/requestfilesystem/filechanges.go index 94f9f74a6c07a..05adda82d7c44 100644 --- a/tsc/internal/api/requestfilesystem/filechanges.go +++ b/tsc/internal/api/requestfilesystem/filechanges.go @@ -1,6 +1,9 @@ package requestfilesystem import ( + "maps" + "slices" + "github.com/microsoft/TypeScript/tsc/internal/collections" "github.com/microsoft/TypeScript/tsc/internal/ls/lsconv" "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" @@ -27,6 +30,99 @@ func (s *requestFileSystem) ExpandFileChanges(summary project.FileChangeSummary) return summary } +func addRebaseFileChanges(summary *project.FileChangeSummary, source *requestFileSystem, baseFS vfs.FS, rebased *requestFileSystem) bool { + baseRequestFS := getRequestFileSystem(baseFS) + changed := false + addChange := func(path tspath.RootedPath, kind lsproto.FileChangeType) { + uri := lsconv.FileNameToDocumentURI(tspath.RootedFilePathFromPath(path)) + if kind == lsproto.FileChangeTypeDeleted { + if baseFS.FileExists(tspath.RootedFilePathFromPath(path)) || baseFS.DirectoryExists(tspath.RootedDirectoryPathFromPath(path)) { + summary.Deleted.Add(uri) + changed = true + } + } else if kind == lsproto.FileChangeTypeCreated { + summary.Created.Add(uri) + changed = true + } else if baseFS.FileExists(tspath.RootedFilePathFromPath(path)) { + summary.Changed.Add(uri) + changed = true + } else { + summary.Created.Add(uri) + changed = true + } + } + addChangeAndAliases := func(path tspath.RootedPath, kind lsproto.FileChangeType) { + addChange(path, kind) + if baseRequestFS != nil { + for _, alias := range baseRequestFS.aliasesForPath(path) { + addChange(alias, kind) + } + } + } + var visit func(*requestPathNode) + visit = func(node *requestPathNode) { + for path, child := range node.children { + _, isFile := child.entry.(*requestFile) + if child.fallback == requestFallbackMissing && child.fallbackPath != "" && !isFile { + previousFallback := requestFallbackAllowed + if baseRequestFS != nil { + _, previousFallback = baseRequestFS.paths.lookup(path) + } + if previousFallback != requestFallbackMissing { + addChangeAndAliases(child.fallbackPath, lsproto.FileChangeTypeDeleted) + } + } + switch entry := child.entry.(type) { + case *requestFile: + if previous, ok := baseFS.ReadFile(entry.fileName); !ok || previous != entry.content { + addChangeAndAliases(entry.fileName.AsPath(), lsproto.FileChangeTypeChanged) + } + case *requestDirectory: + if entry.listing != nil { + previous := baseFS.GetAccessibleEntries(entry.directoryName) + current := rebased.GetAccessibleEntries(entry.directoryName) + if !slices.Equal(previous.Files, current.Files) || !slices.Equal(previous.Directories, current.Directories) || !maps.Equal(previous.Symlinks, current.Symlinks) { + addChangeAndAliases(entry.directoryName.AsPath(), lsproto.FileChangeTypeDeleted) + addChangeAndAliases(entry.directoryName.AsPath(), lsproto.FileChangeTypeCreated) + } + } + if source.kind == KindFull { + previous := baseFS.GetAccessibleEntries(entry.directoryName) + for _, name := range previous.Files { + fileName := entry.directoryName.ResolveFile(name) + if !rebased.FileExists(fileName) { + addChangeAndAliases(fileName.AsPath(), lsproto.FileChangeTypeDeleted) + } + } + for _, name := range previous.Directories { + directoryName := entry.directoryName.ResolveDirectory(name) + if !rebased.DirectoryExists(directoryName) { + addChangeAndAliases(directoryName.AsPath(), lsproto.FileChangeTypeDeleted) + } + } + } + case *requestSymlink: + if baseRequestFS != nil { + previous, _ := baseRequestFS.paths.lookup(path) + if previous != nil { + if previousLink, ok := previous.entry.(*requestSymlink); ok && *previousLink == *entry { + break + } + } + } + addChangeAndAliases(entry.linkName, lsproto.FileChangeTypeDeleted) + addChangeAndAliases(entry.linkName, lsproto.FileChangeTypeCreated) + } + visit(child) + } + } + visit(source.paths) + if summary.Changed.Len()+summary.Created.Len()+summary.Deleted.Len() > 0 { + summary.IncludesWatchChangeOutsideNodeModules = true + } + return changed +} + func addFileChanges(summary *project.FileChangeSummary, request *RequestFileSystem, baseFS vfs.FS, fileSystem *requestFileSystem, currentDirectory tspath.RootedDirectoryPath) { caseSensitivity := baseFS.CaseSensitivity() baseRequestFS := getRequestFileSystem(baseFS) diff --git a/tsc/internal/api/requestfilesystem/filechanges_test.go b/tsc/internal/api/requestfilesystem/filechanges_test.go index f2b7500345131..a882574610b1a 100644 --- a/tsc/internal/api/requestfilesystem/filechanges_test.go +++ b/tsc/internal/api/requestfilesystem/filechanges_test.go @@ -9,6 +9,61 @@ import ( "gotest.tools/v3/assert" ) +func TestRebaseFileChangesMatchLayerUpdate(t *testing.T) { + t.Parallel() + base, err := newRequestFileSystem(&RequestFileSystem{ + Kind: KindFull, + Files: map[string]string{ + "/removed/nested/file.ts": "removed", + "/replaced.ts": "old", + "/dir/old.ts": "old listing", + "/target/old.ts": "old target", + }, + Symlinks: map[string]RequestSymlink{ + "/alias": {Target: "/removed"}, + "/link": {Target: "/target/old.ts"}, + }, + }, vfstest.FromMap(map[string]string{}, tspath.CaseSensitive), "/") + assert.NilError(t, err) + for name, params := range map[string]*RequestFileSystem{ + "files and tombstones": { + Kind: KindLayer, + Files: map[string]string{"/replaced.ts": "new", "/created.ts": "created"}, + RemovedPaths: []string{"/removed", "/missing", "/replaced.ts"}, + }, + "listings and symlinks": { + Kind: KindLayer, + Directories: map[string]RequestDirectoryEntries{"/dir": {}}, + Symlinks: map[string]RequestSymlink{"/link": {Target: "/elsewhere"}, "/new": {Target: "/host", Host: true}}, + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + source, sourceErr := newRequestFileSystem(params, vfstest.FromMap(map[string]string{}, tspath.CaseSensitive), "/") + assert.NilError(t, sourceErr) + var expected, actual project.FileChangeSummary + _, updateErr := NewForUpdate(params, base, "/", &expected) + assert.NilError(t, updateErr) + Rebase(source, base, &actual) + assert.DeepEqual(t, actual, expected) + }) + } +} + +func TestRebaseFileChangesPreserveTombstoneCasing(t *testing.T) { + t.Parallel() + host := vfstest.FromMap(map[string]string{"C:/Repo/Removed/index.ts": "old"}, tspath.CaseInsensitive) + source, err := newRequestFileSystem(&RequestFileSystem{ + Kind: KindLayer, + RemovedPaths: []string{"C:/Repo/Removed"}, + }, host, "C:/Repo") + assert.NilError(t, err) + var summary project.FileChangeSummary + Rebase(source, host, &summary) + assert.Assert(t, summary.Deleted.Has("file:///c%3A/Repo/Removed")) + assert.Equal(t, summary.Deleted.Len(), 1) +} + func TestFileChangesIncludeDirectoryTombstones(t *testing.T) { t.Parallel() diff --git a/tsc/internal/api/requestfilesystem/pathtree.go b/tsc/internal/api/requestfilesystem/pathtree.go index 9fdc43185cf66..8b5cf492b4303 100644 --- a/tsc/internal/api/requestfilesystem/pathtree.go +++ b/tsc/internal/api/requestfilesystem/pathtree.go @@ -70,10 +70,11 @@ var ( ) type requestPathNode struct { - entry requestEntry - fallback requestFallback - children map[tspath.PathKey]*requestPathNode - hasSymlinks bool + entry requestEntry + fallback requestFallback + fallbackPath tspath.RootedPath + children map[tspath.PathKey]*requestPathNode + hasSymlinks bool } func (node *requestPathNode) replacesSubtree() bool { @@ -185,6 +186,7 @@ func composeRequestPaths(base *requestPathNode, overlay *requestPathNode, fallba result.children = maps.Clone(result.children) if overlay.fallback != requestFallbackInherit || overlay.replacesSubtree() { result.fallback = fallback + result.fallbackPath = overlay.fallbackPath } previousDirectory, _ := result.entry.(*requestDirectory) overlayDirectory, _ := overlay.entry.(*requestDirectory) diff --git a/tsc/internal/api/requestfilesystem/rebase_test.go b/tsc/internal/api/requestfilesystem/rebase_test.go new file mode 100644 index 0000000000000..0760d2794dd5c --- /dev/null +++ b/tsc/internal/api/requestfilesystem/rebase_test.go @@ -0,0 +1,73 @@ +package requestfilesystem + +import ( + "reflect" + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/project" + "github.com/microsoft/TypeScript/tsc/internal/tspath" + "github.com/microsoft/TypeScript/tsc/internal/vfs" + "github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest" + "gotest.tools/v3/assert" +) + +func TestRebaseDoesNotMutateInputs(t *testing.T) { + t.Parallel() + for _, sourceKind := range []Kind{KindLayer, KindFull} { + for _, targetKind := range []Kind{"host", KindLayer, KindFull} { + t.Run(string(sourceKind)+"/"+string(targetKind), func(t *testing.T) { + t.Parallel() + sourceHost := vfstest.FromMap(map[string]string{"/host.ts": "old host"}, tspath.CaseSensitive) + targetHost := vfstest.FromMap(map[string]string{"/host.ts": "new host"}, tspath.CaseSensitive) + sourceParams := &RequestFileSystem{ + Kind: sourceKind, + Files: map[string]string{ + "/dir/source.ts": "source", + "/dir/shared.ts": "source shared", + }, + Symlinks: map[string]RequestSymlink{"/alias.ts": {Target: "/dir/shared.ts"}}, + RemovedPaths: []string{"/dir/removed"}, + } + source, err := newRequestFileSystem(sourceParams, sourceHost, "/") + assert.NilError(t, err) + expectedSource, err := newRequestFileSystem(sourceParams, sourceHost, "/") + assert.NilError(t, err) + var target vfs.FS = targetHost + var expectedTarget vfs.FS = targetHost + if targetKind != "host" { + targetParams := &RequestFileSystem{ + Kind: targetKind, + Files: map[string]string{ + "/dir/target.ts": "target", + "/dir/shared.ts": "target shared", + "/dir/removed/nested.ts": "removed", + }, + Directories: map[string]RequestDirectoryEntries{ + "/dir": {Files: []string{"target.ts", "shared.ts"}, Directories: []string{"removed"}}, + }, + } + target, err = newRequestFileSystem(targetParams, targetHost, "/") + assert.NilError(t, err) + expectedTarget, err = newRequestFileSystem(targetParams, targetHost, "/") + assert.NilError(t, err) + } + var fileChanges project.FileChangeSummary + rebased := Rebase(source, target, &fileChanges) + assert.Assert(t, rebased != source && rebased != target) + content, ok := rebased.ReadFile("/alias.ts") + assert.Assert(t, ok) + assert.Equal(t, content, "source shared") + _, ok = rebased.ReadFile("/dir/removed/nested.ts") + assert.Assert(t, !ok) + assert.Assert(t, rebased.FileExists("/dir/source.ts")) + assert.Equal(t, rebased.FileExists("/dir/target.ts"), sourceKind == KindLayer && targetKind != "host") + _ = rebased.GetAccessibleEntries("/dir") + second := Rebase(source, source, &fileChanges) + assert.Assert(t, second != source) + _ = second.GetAccessibleEntries("/dir") + assert.Assert(t, reflect.DeepEqual(source, expectedSource), "source filesystem was mutated") + assert.Assert(t, reflect.DeepEqual(target, expectedTarget), "target filesystem was mutated") + }) + } + } +} diff --git a/tsc/internal/api/requestfilesystem/requestfilesystem.go b/tsc/internal/api/requestfilesystem/requestfilesystem.go index 6f52442f7b65b..7dc96a0b3f613 100644 --- a/tsc/internal/api/requestfilesystem/requestfilesystem.go +++ b/tsc/internal/api/requestfilesystem/requestfilesystem.go @@ -119,6 +119,35 @@ func HasFullFileSystem(fileSystem vfs.FS) bool { return requestFileSystem != nil && requestFileSystem.kind == KindFull } +func Rebase(fileSystem vfs.FS, base vfs.FS, fileChanges *project.FileChangeSummary) vfs.FS { + source := getRequestFileSystem(fileSystem) + if source == nil { + return base + } + paths := source.paths + kind := source.kind + baseFileSystem := base + if target := getRequestFileSystem(base); target != nil { + base = target.base + if source.kind == KindLayer { + paths = composeRequestPaths(target.paths, source.paths, requestFallbackAllowed, source.caseSensitivity) + kind = target.kind + } + } + result := &requestFileSystem{ + kind: kind, + base: base, + currentDirectory: source.currentDirectory, + caseSensitivity: source.caseSensitivity, + paths: paths, + } + changed := addRebaseFileChanges(fileChanges, source, baseFileSystem, result) + if source.kind == KindFull && (!HasFullFileSystem(baseFileSystem) || changed) { + fileChanges.InvalidateAll = true + } + return result +} + func newRequestFileSystemWorker(params *RequestFileSystem, base vfs.FS, currentDirectory tspath.RootedDirectoryPath) (*requestFileSystem, error) { if params.Kind != KindFull && params.Kind != KindLayer { return nil, fmt.Errorf("unknown request filesystem kind %q", params.Kind) @@ -198,7 +227,10 @@ func newRequestFileSystemWorker(params *RequestFileSystem, base vfs.FS, currentD result.registerDirectory(directoryName) } for _, path := range params.RemovedPaths { - result.paths.ensure(result.caseSensitivity.PathKey(result.toAbsolutePath(path))).fallback = requestFallbackMissing + absolutePath := result.toAbsolutePath(path) + node := result.paths.ensure(result.caseSensitivity.PathKey(absolutePath)) + node.fallback = requestFallbackMissing + node.fallbackPath = absolutePath } result.paths = composeRequestPaths(nil, result.paths, requestFallbackAllowed, result.caseSensitivity) return &result, nil diff --git a/tsc/internal/api/session.go b/tsc/internal/api/session.go index e44b35895353b..2dee5c1e32768 100644 --- a/tsc/internal/api/session.go +++ b/tsc/internal/api/session.go @@ -905,6 +905,8 @@ func (s *Session) HandleRequest(ctx context.Context, method string, params json. return s.handleCreateSnapshot(ctx, parsed.(*CreateSnapshotParams)) case string(MethodUpdateSnapshot): return s.handleUpdateSnapshot(ctx, parsed.(*UpdateSnapshotParams)) + case string(MethodRebaseSnapshot): + return s.handleRebaseSnapshot(ctx, parsed.(*RebaseSnapshotParams)) case string(MethodGetCurrentLanguageServerSnapshot): return s.handleGetCurrentLanguageServerSnapshot(ctx, parsed.(*GetCurrentLanguageServerSnapshotParams)) case string(MethodCreateModuleResolver): @@ -1513,6 +1515,69 @@ func (s *Session) handleUpdateSnapshot(ctx context.Context, params *UpdateSnapsh return response, nil } +func (s *Session) handleRebaseSnapshot(ctx context.Context, params *RebaseSnapshotParams) (*CreateSnapshotResponse, error) { + source, err := s.retainSnapshotData(params.Snapshot) + if err != nil { + return nil, err + } + defer func() { _ = s.releaseSnapshot(params.Snapshot) }() + target, err := s.retainSnapshotData(params.NewSnapshot) + if err != nil { + return nil, err + } + defer func() { _ = s.releaseSnapshot(params.NewSnapshot) }() + + changes := params.Changes + if changes == nil { + changes = &CreateSnapshotParams{} + } + apiRequest, err := s.toAPISnapshotRequest(ctx, &changes.SnapshotRequestChangesParams) + if err != nil { + return nil, err + } + apiRequest.UserPreferences = changes.UserPreferences + if changes.PrepareAutoImports != nil { + apiRequest.PrepareAutoImports = changes.PrepareAutoImports.ToURI(s.GetCurrentDirectory()) + } + openState := s.reconcileSnapshotOpens(apiRequest, snapshotOpenState{openProjects: target.openProjects, openFiles: target.openFiles}) + fileChanges := s.toFileChangeSummary(changes.FileNotifications) + fileSystem := target.fileSystem + if source.fileSystem != nil { + if fileSystem == nil { + fileSystem = s.FS() + } + fileSystem = requestfilesystem.Rebase(source.fileSystem, fileSystem, &fileChanges) + } + if changes.FileSystem != nil { + baseFileSystem := fileSystem + if baseFileSystem == nil { + baseFileSystem = s.FS() + } + fileSystem, err = requestfilesystem.NewForUpdate(changes.FileSystem, baseFileSystem, s.GetCurrentDirectory(), &fileChanges) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrClientError, err) + } + } + apiRequest.FileSystem = fileSystem + apiRequest.ReplaceFileSystem = changes.FileSystem != nil && changes.FileSystem.Kind == requestfilesystem.KindFull + snapshot, err := s.snapshotHost.CloneSnapshot(ctx, target.snapshot, fileChanges, apiRequest) + if err != nil { + snapshot.Deref() + return nil, fmt.Errorf("%w: failed to rebase snapshot: %w", ErrClientError, err) + } + if err := s.validatePreparedAutoImports(ctx, snapshot, changes.PrepareAutoImports); err != nil { + snapshot.Deref() + return nil, err + } + if err := moduleResolutionError(snapshot); err != nil { + snapshot.Deref() + return nil, err + } + response := s.createSnapshotResponse(snapshot, target.snapshot, &changes.SnapshotRequestChangesParams) + s.registerSnapshot(snapshot, openState, fileSystem) + return response, nil +} + func (s *Session) toAPISnapshotRequest(ctx context.Context, changes *SnapshotRequestChangesParams) (*project.APISnapshotRequest, error) { apiRequest := &project.APISnapshotRequest{} diff --git a/tsc/internal/api/session_completion_test.go b/tsc/internal/api/session_completion_test.go index 91c44ece0de93..77ba53dfde04f 100644 --- a/tsc/internal/api/session_completion_test.go +++ b/tsc/internal/api/session_completion_test.go @@ -326,8 +326,8 @@ func TestCompletionUsesSnapshotPreferences(t *testing.T) { func TestSnapshotCreatesProgramsAndPreparesAutoImportsInOneClone(t *testing.T) { t.Parallel() - for _, update := range []bool{false, true} { - t.Run(map[bool]string{false: "create", true: "update"}[update], func(t *testing.T) { + for _, operation := range []string{"create", "update", "rebase"} { + t.Run(operation, func(t *testing.T) { t.Parallel() defer testutil.RecoverAndFail(t, "snapshot creation panicked") const fileName = "/home/projects/p/index.ts" @@ -351,14 +351,22 @@ func TestSnapshotCreatesProgramsAndPreparesAutoImportsInOneClone(t *testing.T) { var response *CreateSnapshotResponse var err error expectedID := SnapshotID(1) - if update { + if operation != "create" { base, e := session.handleCreateSnapshot(t.Context(), &CreateSnapshotParams{}) assert.NilError(t, e) expectedID = base.Snapshot + 1 - response, err = session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{ - Snapshot: base.Snapshot, - Changes: params, - }) + if operation == "update" { + response, err = session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{ + Snapshot: base.Snapshot, + Changes: params, + }) + } else { + response, err = session.handleRebaseSnapshot(t.Context(), &RebaseSnapshotParams{ + Snapshot: base.Snapshot, + NewSnapshot: base.Snapshot, + Changes: params, + }) + } } else { response, err = session.handleCreateSnapshot(t.Context(), params) } @@ -372,6 +380,27 @@ func TestSnapshotCreatesProgramsAndPreparesAutoImportsInOneClone(t *testing.T) { } } +func TestRebaseRejectsUnpreparedAutoImports(t *testing.T) { + t.Parallel() + projectSession, _ := projecttestutil.Setup(map[string]any{}) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + base, err := session.handleCreateSnapshot(t.Context(), &CreateSnapshotParams{}) + assert.NilError(t, err) + _, err = session.handleRebaseSnapshot(t.Context(), &RebaseSnapshotParams{ + Snapshot: base.Snapshot, + NewSnapshot: base.Snapshot, + Changes: &CreateSnapshotParams{ + PrepareAutoImports: &DocumentIdentifier{FileName: "/missing.ts"}, + }, + }) + assert.ErrorIs(t, err, ErrClientError) + assert.ErrorContains(t, err, "could not prepare auto-imports") + assert.Equal(t, len(session.snapshots), 1) + assert.Equal(t, session.snapshots[base.Snapshot].refCount, 1) +} + func TestPreparedIndependentSnapshotPreservesLSPOverlays(t *testing.T) { t.Parallel() const fileName = "/home/projects/p/index.ts" diff --git a/tsc/internal/api/session_rebase_changes_test.go b/tsc/internal/api/session_rebase_changes_test.go new file mode 100644 index 0000000000000..9fa41fdc101f8 --- /dev/null +++ b/tsc/internal/api/session_rebase_changes_test.go @@ -0,0 +1,173 @@ +package api + +import ( + "slices" + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/api/requestfilesystem" + "github.com/microsoft/TypeScript/tsc/internal/core" + "github.com/microsoft/TypeScript/tsc/internal/project" + "github.com/microsoft/TypeScript/tsc/internal/testutil/projecttestutil" + "gotest.tools/v3/assert" +) + +func TestRebasePreservesFullInvalidation(t *testing.T) { + t.Parallel() + projectSession, _ := projecttestutil.Setup(map[string]any{"/index.ts": "export const value = 1;"}) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + base, err := session.handleCreateSnapshot(t.Context(), &CreateSnapshotParams{ + CreatePrograms: []*CreateSnapshotProgramParams{{ + RootFiles: []DocumentIdentifier{{FileName: "/index.ts"}}, + CompilerOptions: core.CompilerOptions{NoLib: core.TSTrue}, + }}, + }) + assert.NilError(t, err) + programID := project.ID((*base.Operation.CreatedPrograms)[0]) + target, err := session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{Snapshot: base.Snapshot}) + assert.NilError(t, err) + assert.NilError(t, session.FS().WriteFile("/index.ts", "export const value = 2;")) + changes := &CreateSnapshotParams{FileNotifications: &FileNotifications{InvalidateAll: true}, EnsurePrograms: &EnsurePrograms{All: true}} + updated, err := session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{Snapshot: base.Snapshot, Changes: changes}) + assert.NilError(t, err) + updatedProgram, err := session.snapshots[updated.Snapshot].getProgram(programID) + assert.NilError(t, err) + assert.Equal(t, updatedProgram.GetSourceFile("/index.ts").Text(), "export const value = 2;") + for _, targetID := range []SnapshotID{base.Snapshot, target.Snapshot} { + rebased, err := session.handleRebaseSnapshot(t.Context(), &RebaseSnapshotParams{Snapshot: base.Snapshot, NewSnapshot: targetID, Changes: changes}) + assert.NilError(t, err) + program, err := session.snapshots[rebased.Snapshot].getProgram(programID) + assert.NilError(t, err) + assert.Equal(t, program.GetSourceFile("/index.ts").Text(), "export const value = 2;") + } +} + +func TestRebaseRefreshesDependencyAutoImports(t *testing.T) { + t.Parallel() + const fileName = "/home/project/index.ts" + const dependency = "/home/project/node_modules/my-pkg/index.d.ts" + projectSession, _ := projecttestutil.Setup(map[string]any{ + "/home/project/tsconfig.json": `{ "compilerOptions": { "noLib": true } }`, + fileName: "package", + "/home/project/package.json": `{"dependencies":{"my-pkg":"1.0.0"}}`, + "/home/project/node_modules/my-pkg/package.json": `{"name":"my-pkg","version":"1.0.0","types":"index.d.ts"}`, + dependency: "export declare const packageOld: number;", + }) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + target, err := session.handleCreateSnapshot(t.Context(), &CreateSnapshotParams{ + OpenProjects: []DocumentIdentifier{{FileName: "/home/project/tsconfig.json"}}, + PrepareAutoImports: &DocumentIdentifier{FileName: fileName}, + }) + assert.NilError(t, err) + completionNames := func(snapshotID SnapshotID) []string { + t.Helper() + proj := session.snapshots[snapshotID].snapshot.GetDefaultProject("file:///home/project/index.ts") + completions, completionErr := session.handleGetCompletionsAtPosition(t.Context(), &GetCompletionsAtPositionParams{ + Snapshot: snapshotID, Project: proj.ID(), File: DocumentIdentifier{FileName: fileName}, Position: 7, IncludeSymbol: true, + }) + assert.NilError(t, completionErr) + assert.Assert(t, completions != nil) + var names []string + for _, entry := range completions.Entries { + names = append(names, entry.Name) + } + return names + } + assert.Assert(t, slices.Contains(completionNames(target.Snapshot), "packageOld")) + source, err := session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{ + Snapshot: target.Snapshot, + Changes: &CreateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindLayer, Files: map[string]string{ + dependency: "export declare const packageNew: number;", + }}, + PrepareAutoImports: &DocumentIdentifier{FileName: fileName}, + }, + }) + assert.NilError(t, err) + assert.Assert(t, slices.Contains(completionNames(source.Snapshot), "packageNew")) + rebased, err := session.handleRebaseSnapshot(t.Context(), &RebaseSnapshotParams{ + Snapshot: source.Snapshot, NewSnapshot: target.Snapshot, + Changes: &CreateSnapshotParams{PrepareAutoImports: &DocumentIdentifier{FileName: fileName}}, + }) + assert.NilError(t, err) + names := completionNames(rebased.Snapshot) + assert.Assert(t, slices.Contains(names, "packageNew")) + assert.Assert(t, !slices.Contains(names, "packageOld")) +} + +func TestSelfRebaseMatchesUpdateProgramState(t *testing.T) { + t.Parallel() + for _, baseKind := range []requestfilesystem.Kind{"host", requestfilesystem.KindLayer, requestfilesystem.KindFull} { + t.Run(string(baseKind), func(t *testing.T) { + t.Parallel() + projectSession, _ := projecttestutil.Setup(map[string]any{ + "/index.ts": "export const value = 1;", + "/other.ts": "export const other = true;", + }) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + params := &CreateSnapshotParams{CreatePrograms: []*CreateSnapshotProgramParams{{ + RootFiles: []DocumentIdentifier{{FileName: "/index.ts"}}, CompilerOptions: core.CompilerOptions{NoLib: core.TSTrue}, + }}} + if baseKind != "host" { + params.FileSystem = &requestfilesystem.RequestFileSystem{ + Kind: baseKind, + Files: map[string]string{ + "/index.ts": "export const value = 1;", + "/other.ts": "export const other = true;", + "/masked/keep.ts": "export const keep = true;", + }, + Directories: map[string]requestfilesystem.RequestDirectoryEntries{"/": {Files: []string{"index.ts", "other.ts"}, Directories: []string{"masked"}}}, + Symlinks: map[string]requestfilesystem.RequestSymlink{"/alias.ts": {Target: "/index.ts"}}, + RemovedPaths: []string{"/masked"}, + } + } + base, err := session.handleCreateSnapshot(t.Context(), params) + assert.NilError(t, err) + programID := (*base.Operation.CreatedPrograms)[0] + for name, changes := range map[string]*CreateSnapshotParams{ + "no changes": {}, + "dirty program": { + FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindLayer, Files: map[string]string{"/index.ts": "export const value = 2;"}}, + }, + "ensured program": { + FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindLayer, Files: map[string]string{"/index.ts": "export const value = 2;"}}, + EnsurePrograms: &EnsurePrograms{All: true}, + }, + "total replacement": { + FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindFull, Files: map[string]string{"/index.ts": "export const value = 3;"}}, + EnsurePrograms: &EnsurePrograms{All: true}, + }, + "listing and symlink": { + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindLayer, + Directories: map[string]requestfilesystem.RequestDirectoryEntries{"/": {Files: []string{"index.ts"}}}, + Symlinks: map[string]requestfilesystem.RequestSymlink{"/alias.ts": {Target: "/other.ts"}}, + }, + }, + "create and open": { + CreatePrograms: []*CreateSnapshotProgramParams{{RootFiles: []DocumentIdentifier{{FileName: "/other.ts"}}, CompilerOptions: core.CompilerOptions{NoLib: core.TSTrue}}}, + OpenFiles: []DocumentIdentifier{{FileName: "/index.ts"}}, + }, + "reconfigure": { + ReconfigurePrograms: []*ReconfigureSnapshotProgramParams{{Id: programID, RootFiles: []DocumentIdentifier{{FileName: "/other.ts"}}, CompilerOptions: core.CompilerOptions{NoLib: core.TSTrue, Strict: core.TSTrue}}}, + }, + "remove": {RemovePrograms: []project.SyntheticProjectID{programID}}, + } { + t.Log(name) + updated, err := session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{Snapshot: base.Snapshot, Changes: changes}) + assert.NilError(t, err, name) + rebased, err := session.handleRebaseSnapshot(t.Context(), &RebaseSnapshotParams{Snapshot: base.Snapshot, NewSnapshot: base.Snapshot, Changes: changes}) + assert.NilError(t, err, name) + assert.DeepEqual(t, rebased.Projects, updated.Projects) + assert.DeepEqual(t, rebased.Changes, updated.Changes) + assert.DeepEqual(t, rebased.Operation, updated.Operation) + assert.Equal(t, session.snapshots[base.Snapshot].refCount, 1) + } + }) + } +} diff --git a/tsc/internal/api/session_rebase_test.go b/tsc/internal/api/session_rebase_test.go new file mode 100644 index 0000000000000..a439126157c92 --- /dev/null +++ b/tsc/internal/api/session_rebase_test.go @@ -0,0 +1,190 @@ +package api + +import ( + "context" + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/api/requestfilesystem" + "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" + "github.com/microsoft/TypeScript/tsc/internal/project" + "github.com/microsoft/TypeScript/tsc/internal/testutil/projecttestutil" + "github.com/microsoft/TypeScript/tsc/internal/tspath" + "gotest.tools/v3/assert" +) + +func TestRebaseSnapshot(t *testing.T) { + t.Parallel() + ctx := context.Background() + projectSession, _ := projecttestutil.Setup(map[string]any{ + "/tsconfig.json": `{ "compilerOptions": { "noLib": true }, "files": ["index.ts", "extra.ts"] }`, + "/index.ts": "export const value = 1;", + }) + defer projectSession.Close() + projectSession.DidOpenFile(ctx, "file:///index.ts", 1, "export const value = 1;", lsproto.LanguageKindTypeScript) + session := NewLSPSession(projectSession, nil) + defer session.Close() + original, err := session.handleGetCurrentLanguageServerSnapshot(ctx, &GetCurrentLanguageServerSnapshotParams{ + Changes: &LanguageServerSnapshotChanges{ + OpenProjects: []DocumentIdentifier{{FileName: "/tsconfig.json"}}, + }, + }) + assert.NilError(t, err) + extras, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ + Snapshot: original.Snapshot, + Changes: &CreateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindLayer, + Files: map[string]string{ + "/extra.ts": "export const extra = true;", + }, + RemovedPaths: []string{"/removed"}, + Symlinks: map[string]requestfilesystem.RequestSymlink{ + "/alias.ts": {Target: "/index.ts"}, + }, + }, + }, + }) + assert.NilError(t, err) + extras, err = session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ + Snapshot: extras.Snapshot, + Changes: &CreateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindLayer, + Files: map[string]string{"/second.ts": "second"}, + }, + }, + }) + assert.NilError(t, err) + projectSession.DidChangeFile(ctx, "file:///index.ts", 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{{ + WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: "export const value = 2;"}, + }}) + newOriginal, err := session.handleGetCurrentLanguageServerSnapshot(ctx, &GetCurrentLanguageServerSnapshotParams{}) + assert.NilError(t, err) + target, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ + Snapshot: newOriginal.Snapshot, + Changes: &CreateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindLayer, + Files: map[string]string{ + "/extra.ts": "target extra", + "/target.ts": "target", + "/removed/nested.ts": "removed", + }, + }, + }, + }) + assert.NilError(t, err) + rebased, err := session.handleRebaseSnapshot(ctx, &RebaseSnapshotParams{ + Snapshot: extras.Snapshot, NewSnapshot: target.Snapshot, + Changes: &CreateSnapshotParams{EnsurePrograms: &EnsurePrograms{All: true}}, + }) + assert.NilError(t, err) + assert.Assert(t, rebased.Snapshot != extras.Snapshot && rebased.Snapshot != target.Snapshot) + snapshot := session.snapshots[rebased.Snapshot].snapshot + for fileName, expected := range map[string]string{ + "/extra.ts": "export const extra = true;", + "/second.ts": "second", + "/target.ts": "target", + "/index.ts": "export const value = 2;", + "/alias.ts": "export const value = 2;", + } { + content, ok := snapshot.ReadFile(tspath.RootedFilePathFromAbsolute(fileName)) + assert.Assert(t, ok, fileName) + assert.Equal(t, content, expected, fileName) + } + _, ok := snapshot.ReadFile("/removed/nested.ts") + assert.Assert(t, !ok) + program := snapshot.ProjectCollection.GetProject(project.ID("/tsconfig.json")).GetProgram() + assert.Equal(t, program.GetSourceFile("/extra.ts").Text(), "export const extra = true;") + assert.Equal(t, program.GetSourceFile("/index.ts").Text(), "export const value = 2;") + content, ok := session.snapshots[extras.Snapshot].snapshot.ReadFile("/index.ts") + assert.Assert(t, ok) + assert.Equal(t, content, "export const value = 1;") + content, ok = session.snapshots[target.Snapshot].snapshot.ReadFile("/extra.ts") + assert.Assert(t, ok) + assert.Equal(t, content, "target extra") +} + +func TestRebaseSnapshotFileSystemKinds(t *testing.T) { + t.Parallel() + for _, sourceKind := range []requestfilesystem.Kind{"host", requestfilesystem.KindLayer, requestfilesystem.KindFull} { + for _, targetKind := range []requestfilesystem.Kind{"host", requestfilesystem.KindLayer, requestfilesystem.KindFull} { + t.Run(string(sourceKind)+"/"+string(targetKind), func(t *testing.T) { + t.Parallel() + ctx := context.Background() + projectSession, _ := projecttestutil.Setup(map[string]any{"/host.ts": "host"}) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + sourceParams := &CreateSnapshotParams{} + if sourceKind != "host" { + sourceParams.FileSystem = &requestfilesystem.RequestFileSystem{ + Kind: sourceKind, + Files: map[string]string{"/source.ts": "source", "/shared.ts": "source"}, + } + } + source, err := session.handleCreateSnapshot(ctx, sourceParams) + assert.NilError(t, err) + targetParams := &CreateSnapshotParams{} + if targetKind != "host" { + targetParams.FileSystem = &requestfilesystem.RequestFileSystem{ + Kind: targetKind, + Files: map[string]string{"/target.ts": "target", "/shared.ts": "target"}, + } + } + target, err := session.handleCreateSnapshot(ctx, targetParams) + assert.NilError(t, err) + rebased, err := session.handleRebaseSnapshot(ctx, &RebaseSnapshotParams{Snapshot: source.Snapshot, NewSnapshot: target.Snapshot}) + assert.NilError(t, err) + assert.Assert(t, rebased.Snapshot != source.Snapshot && rebased.Snapshot != target.Snapshot) + assert.NilError(t, session.releaseSnapshot(source.Snapshot)) + assert.NilError(t, session.releaseSnapshot(target.Snapshot)) + snapshot := session.snapshots[rebased.Snapshot].snapshot + content, ok := snapshot.ReadFile("/source.ts") + assert.Equal(t, ok, sourceKind != "host") + if ok { + assert.Equal(t, content, "source") + } + content, ok = snapshot.ReadFile("/target.ts") + assert.Equal(t, ok, sourceKind != requestfilesystem.KindFull && targetKind != "host") + if ok { + assert.Equal(t, content, "target") + } + _, ok = snapshot.ReadFile("/host.ts") + assert.Equal(t, ok, sourceKind != requestfilesystem.KindFull && targetKind != requestfilesystem.KindFull) + content, ok = snapshot.ReadFile("/shared.ts") + assert.Equal(t, ok, sourceKind != "host" || targetKind != "host") + if sourceKind != "host" { + assert.Equal(t, content, "source") + } else if targetKind != "host" { + assert.Equal(t, content, "target") + } + }) + } + } +} + +func TestRebaseSnapshotRejectsInactiveSnapshots(t *testing.T) { + t.Parallel() + projectSession, _ := projecttestutil.Setup(map[string]any{}) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + ctx := context.Background() + snapshot, err := session.handleCreateSnapshot(ctx, &CreateSnapshotParams{}) + assert.NilError(t, err) + _, err = session.handleRebaseSnapshot(ctx, &RebaseSnapshotParams{Snapshot: 999, NewSnapshot: snapshot.Snapshot}) + assert.ErrorIs(t, err, ErrClientError) + _, err = session.handleRebaseSnapshot(ctx, &RebaseSnapshotParams{Snapshot: snapshot.Snapshot, NewSnapshot: 999}) + assert.ErrorIs(t, err, ErrClientError) + assert.Equal(t, session.snapshots[snapshot.Snapshot].refCount, 1) + rebased, err := session.handleRebaseSnapshot(ctx, &RebaseSnapshotParams{Snapshot: snapshot.Snapshot, NewSnapshot: snapshot.Snapshot}) + assert.NilError(t, err) + assert.Assert(t, rebased.Snapshot != snapshot.Snapshot) + assert.Equal(t, session.snapshots[snapshot.Snapshot].refCount, 1) + assert.NilError(t, session.releaseSnapshot(snapshot.Snapshot)) + _, err = session.handleRebaseSnapshot(ctx, &RebaseSnapshotParams{Snapshot: snapshot.Snapshot, NewSnapshot: rebased.Snapshot}) + assert.ErrorIs(t, err, ErrClientError) + _, err = session.handleRebaseSnapshot(ctx, &RebaseSnapshotParams{Snapshot: rebased.Snapshot, NewSnapshot: snapshot.Snapshot}) + assert.ErrorIs(t, err, ErrClientError) +} diff --git a/tsc/internal/api/session_requestfilesystem_test.go b/tsc/internal/api/session_requestfilesystem_test.go index dbd9f4d984953..3ef709e241221 100644 --- a/tsc/internal/api/session_requestfilesystem_test.go +++ b/tsc/internal/api/session_requestfilesystem_test.go @@ -586,107 +586,115 @@ func TestSnapshotUpdateCarriesHostFileSystemWithoutOverride(t *testing.T) { func TestSnapshotFileSystemLayersPreserveIncrementalState(t *testing.T) { t.Parallel() - for _, baseKind := range []requestfilesystem.Kind{"host", requestfilesystem.KindFull, requestfilesystem.KindLayer} { - t.Run(string(baseKind), func(t *testing.T) { - t.Parallel() - files := map[string]string{ - "/a/tsconfig.json": `{ "compilerOptions": { "noLib": true }, "include": ["**/*.ts"] }`, - "/a/index.ts": `export const value = 1;`, - "/a/removed/nested.ts": `export const nested = true;`, - "/a/removed/deep/file.ts": `export const deep = true;`, - "/b/tsconfig.json": `{ "compilerOptions": { "noLib": true }, "files": ["index.ts"] }`, - "/b/index.ts": `export const unrelated = true;`, - } - hostFiles := make(map[string]any, len(files)) - for path, content := range files { - hostFiles[path] = content - } - projectSession, _ := projecttestutil.Setup(hostFiles) - defer projectSession.Close() - session := NewLSPSession(projectSession, nil) - defer session.Close() - ctx := context.Background() - params := &CreateSnapshotParams{ - OpenProjects: []DocumentIdentifier{{FileName: "/a/tsconfig.json"}, {FileName: "/b/tsconfig.json"}}, - } - if baseKind != "host" { - params.FileSystem = &requestfilesystem.RequestFileSystem{Kind: baseKind, Files: files} - } - base, err := session.handleCreateSnapshot(ctx, params) - assert.NilError(t, err) - baseSnapshot := session.snapshots[base.Snapshot].snapshot - baseProgram := baseSnapshot.ProjectCollection.GetProject(project.ID("/a/tsconfig.json")).GetProgram() - unrelatedProgram := baseSnapshot.ProjectCollection.GetProject(project.ID("/b/tsconfig.json")).GetProgram() - unrelatedFile := baseSnapshot.GetFile("/b/index.ts") - - unchanged, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ - Snapshot: base.Snapshot, - Changes: &CreateSnapshotParams{ - EnsurePrograms: &EnsurePrograms{All: true}, - FileSystem: &requestfilesystem.RequestFileSystem{ - Kind: requestfilesystem.KindLayer, - Files: map[string]string{"/a/index.ts": files["/a/index.ts"]}, + for _, operation := range []string{"update", "self-rebase"} { + for _, baseKind := range []requestfilesystem.Kind{"host", requestfilesystem.KindFull, requestfilesystem.KindLayer} { + t.Run(operation+"/"+string(baseKind), func(t *testing.T) { + t.Parallel() + files := map[string]string{ + "/a/tsconfig.json": `{ "compilerOptions": { "noLib": true }, "include": ["**/*.ts"] }`, + "/a/index.ts": `export const value = 1;`, + "/a/removed/nested.ts": `export const nested = true;`, + "/a/removed/deep/file.ts": `export const deep = true;`, + "/b/tsconfig.json": `{ "compilerOptions": { "noLib": true }, "files": ["index.ts"] }`, + "/b/index.ts": `export const unrelated = true;`, + } + hostFiles := make(map[string]any, len(files)) + for path, content := range files { + hostFiles[path] = content + } + projectSession, _ := projecttestutil.Setup(hostFiles) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + ctx := context.Background() + updateSnapshot := func(params *UpdateSnapshotParams) (*CreateSnapshotResponse, error) { + if operation == "self-rebase" { + return session.handleRebaseSnapshot(ctx, &RebaseSnapshotParams{Snapshot: params.Snapshot, NewSnapshot: params.Snapshot, Changes: params.Changes}) + } + return session.handleUpdateSnapshot(ctx, params) + } + params := &CreateSnapshotParams{ + OpenProjects: []DocumentIdentifier{{FileName: "/a/tsconfig.json"}, {FileName: "/b/tsconfig.json"}}, + } + if baseKind != "host" { + params.FileSystem = &requestfilesystem.RequestFileSystem{Kind: baseKind, Files: files} + } + base, err := session.handleCreateSnapshot(ctx, params) + assert.NilError(t, err) + baseSnapshot := session.snapshots[base.Snapshot].snapshot + baseProgram := baseSnapshot.ProjectCollection.GetProject(project.ID("/a/tsconfig.json")).GetProgram() + unrelatedProgram := baseSnapshot.ProjectCollection.GetProject(project.ID("/b/tsconfig.json")).GetProgram() + unrelatedFile := baseSnapshot.GetFile("/b/index.ts") + + unchanged, err := updateSnapshot(&UpdateSnapshotParams{ + Snapshot: base.Snapshot, + Changes: &CreateSnapshotParams{ + EnsurePrograms: &EnsurePrograms{All: true}, + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindLayer, + Files: map[string]string{"/a/index.ts": files["/a/index.ts"]}, + }, }, - }, - }) - assert.NilError(t, err) - unchangedSnapshot := session.snapshots[unchanged.Snapshot].snapshot - assert.Assert(t, unchangedSnapshot.ProjectCollection.GetProject(project.ID("/a/tsconfig.json")).GetProgram() == baseProgram) - assert.Assert(t, unchangedSnapshot.ProjectCollection.GetProject(project.ID("/b/tsconfig.json")).GetProgram() == unrelatedProgram) - assert.Assert(t, unchangedSnapshot.GetFile("/b/index.ts") == unrelatedFile) - - const updatedText = `export const value = 2;` - updated, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ - Snapshot: unchanged.Snapshot, - Changes: &CreateSnapshotParams{ - EnsurePrograms: &EnsurePrograms{All: true}, - FileSystem: &requestfilesystem.RequestFileSystem{ - Kind: requestfilesystem.KindLayer, - Files: map[string]string{"/a/index.ts": updatedText}, + }) + assert.NilError(t, err) + unchangedSnapshot := session.snapshots[unchanged.Snapshot].snapshot + assert.Assert(t, unchangedSnapshot.ProjectCollection.GetProject(project.ID("/a/tsconfig.json")).GetProgram() == baseProgram) + assert.Assert(t, unchangedSnapshot.ProjectCollection.GetProject(project.ID("/b/tsconfig.json")).GetProgram() == unrelatedProgram) + assert.Assert(t, unchangedSnapshot.GetFile("/b/index.ts") == unrelatedFile) + + const updatedText = `export const value = 2;` + updated, err := updateSnapshot(&UpdateSnapshotParams{ + Snapshot: unchanged.Snapshot, + Changes: &CreateSnapshotParams{ + EnsurePrograms: &EnsurePrograms{All: true}, + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindLayer, + Files: map[string]string{"/a/index.ts": updatedText}, + }, }, - }, - }) - assert.NilError(t, err) - updatedSnapshot := session.snapshots[updated.Snapshot].snapshot - updatedProject := updatedSnapshot.ProjectCollection.GetProject(project.ID("/a/tsconfig.json")) - assert.Assert(t, updatedProject.GetProgram() != baseProgram) - assert.Equal(t, updatedProject.ProgramUpdateKind, project.ProgramUpdateKindCloned) - assert.Equal(t, updatedProject.GetProgram().GetSourceFile("/a/index.ts").Text(), updatedText) - assert.Assert(t, updatedSnapshot.ProjectCollection.GetProject(project.ID("/b/tsconfig.json")).GetProgram() == unrelatedProgram) - assert.Assert(t, updatedSnapshot.GetFile("/b/index.ts") == unrelatedFile) - - removed, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ - Snapshot: updated.Snapshot, - Changes: &CreateSnapshotParams{ - EnsurePrograms: &EnsurePrograms{All: true}, - FileSystem: &requestfilesystem.RequestFileSystem{ - Kind: requestfilesystem.KindLayer, - RemovedPaths: []string{"/a/removed"}, + }) + assert.NilError(t, err) + updatedSnapshot := session.snapshots[updated.Snapshot].snapshot + updatedProject := updatedSnapshot.ProjectCollection.GetProject(project.ID("/a/tsconfig.json")) + assert.Assert(t, updatedProject.GetProgram() != baseProgram) + assert.Equal(t, updatedProject.ProgramUpdateKind, project.ProgramUpdateKindCloned) + assert.Equal(t, updatedProject.GetProgram().GetSourceFile("/a/index.ts").Text(), updatedText) + assert.Assert(t, updatedSnapshot.ProjectCollection.GetProject(project.ID("/b/tsconfig.json")).GetProgram() == unrelatedProgram) + assert.Assert(t, updatedSnapshot.GetFile("/b/index.ts") == unrelatedFile) + + removed, err := updateSnapshot(&UpdateSnapshotParams{ + Snapshot: updated.Snapshot, + Changes: &CreateSnapshotParams{ + EnsurePrograms: &EnsurePrograms{All: true}, + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindLayer, + RemovedPaths: []string{"/a/removed"}, + }, }, - }, + }) + assert.NilError(t, err) + removedSnapshot := session.snapshots[removed.Snapshot].snapshot + removedProgram := removedSnapshot.ProjectCollection.GetProject(project.ID("/a/tsconfig.json")).GetProgram() + for _, path := range []string{"/a/removed/nested.ts", "/a/removed/deep/file.ts"} { + fileName := tspath.RootedFilePathFromAbsolute(path) + assert.Assert(t, removedProgram.GetSourceFile(fileName) == nil, path) + assert.Assert(t, removedSnapshot.GetFile(fileName) == nil, path) + assert.Assert(t, baseProgram.GetSourceFile(fileName) != nil, path) + } + assert.Assert(t, removedSnapshot.ProjectCollection.GetProject(project.ID("/b/tsconfig.json")).GetProgram() == unrelatedProgram) + assert.Assert(t, removedSnapshot.GetFile("/b/index.ts") == unrelatedFile) + + // A request without a base snapshot returns to the host, so the old + // layer's changed contents and directory tombstones must not survive. + restored, err := session.handleCreateSnapshot(ctx, &CreateSnapshotParams{SnapshotRequestChangesParams: params.SnapshotRequestChangesParams}) + assert.NilError(t, err) + restoredSnapshot := session.snapshots[restored.Snapshot].snapshot + assert.Assert(t, !restoredSnapshot.HasFileSystemOverride()) + restoredProgram := restoredSnapshot.ProjectCollection.GetProject(project.ID("/a/tsconfig.json")).GetProgram() + assert.Equal(t, restoredProgram.GetSourceFile("/a/index.ts").Text(), files["/a/index.ts"]) + assert.Assert(t, restoredProgram.GetSourceFile("/a/removed/deep/file.ts") != nil) }) - assert.NilError(t, err) - removedSnapshot := session.snapshots[removed.Snapshot].snapshot - removedProgram := removedSnapshot.ProjectCollection.GetProject(project.ID("/a/tsconfig.json")).GetProgram() - for _, path := range []string{"/a/removed/nested.ts", "/a/removed/deep/file.ts"} { - fileName := tspath.RootedFilePathFromAbsolute(path) - assert.Assert(t, removedProgram.GetSourceFile(fileName) == nil, path) - assert.Assert(t, removedSnapshot.GetFile(fileName) == nil, path) - assert.Assert(t, baseProgram.GetSourceFile(fileName) != nil, path) - } - assert.Assert(t, removedSnapshot.ProjectCollection.GetProject(project.ID("/b/tsconfig.json")).GetProgram() == unrelatedProgram) - assert.Assert(t, removedSnapshot.GetFile("/b/index.ts") == unrelatedFile) - - // A request without a base snapshot returns to the host, so the old - // layer's changed contents and directory tombstones must not survive. - restored, err := session.handleCreateSnapshot(ctx, &CreateSnapshotParams{SnapshotRequestChangesParams: params.SnapshotRequestChangesParams}) - assert.NilError(t, err) - restoredSnapshot := session.snapshots[restored.Snapshot].snapshot - assert.Assert(t, !restoredSnapshot.HasFileSystemOverride()) - restoredProgram := restoredSnapshot.ProjectCollection.GetProject(project.ID("/a/tsconfig.json")).GetProgram() - assert.Equal(t, restoredProgram.GetSourceFile("/a/index.ts").Text(), files["/a/index.ts"]) - assert.Assert(t, restoredProgram.GetSourceFile("/a/removed/deep/file.ts") != nil) - }) + } } } From 58d03ad18a201e07ebfbd5e39470b2845e322559 Mon Sep 17 00:00:00 2001 From: Wesley Wigham Date: Mon, 19 Oct 2026 08:45:29 -0700 Subject: [PATCH 2/4] fileChanges generation improvements & tests --- packages/typescript/src/api/async/api.ts | 2 + packages/typescript/src/api/sync/api.ts | 2 + packages/typescript/test/async/api.test.ts | 49 ++++ packages/typescript/test/sync/api.test.ts | 49 ++++ .../api/requestfilesystem/filechanges.go | 49 +++- .../api/requestfilesystem/filechanges_test.go | 49 +++- .../api/requestfilesystem/rebase_test.go | 4 +- .../requestfilesystem/requestfilesystem.go | 15 +- tsc/internal/api/session.go | 2 +- .../api/session_rebase_changes_test.go | 274 ++++++++++++++++++ 10 files changed, 475 insertions(+), 20 deletions(-) diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index d676d0c76e0be..fbfeb4ffb41f8 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -1168,6 +1168,8 @@ export class Snapshot { * `newSnapshot`, retaining the target's projects and language server state. * Source entries take precedence; a full source filesystem remains total. * Optional changes are applied after rebasing, including auto-import preparation. + * `newSnapshot` is the sole parent for program reuse and reported changes; + * files absent from it are new files even if already present in this snapshot. * Neither input snapshot is modified, and retained file contents are not resent. */ rebase< diff --git a/packages/typescript/src/api/sync/api.ts b/packages/typescript/src/api/sync/api.ts index 83713d04e34ff..c8861036158fe 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -1850,6 +1850,8 @@ export class Snapshot { * `newSnapshot`, retaining the target's projects and language server state. * Source entries take precedence; a full source filesystem remains total. * Optional changes are applied after rebasing, including auto-import preparation. + * `newSnapshot` is the sole parent for program reuse and reported changes; + * files absent from it are new files even if already present in this snapshot. * Neither input snapshot is modified, and retained file contents are not resent. */ get rebase(): { diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index 297fd099911be..7957705ac9541 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -5513,6 +5513,55 @@ describe("updateSnapshot file systems", { concurrency }, () => { }); } + test("Snapshot.rebase compares against the target's cached contents", async () => { + const { api: disposableAPI, fs } = spawnAPIWithFS({ "/index.ts": "export const value = 1;" }); + await using api = disposableAPI; + await using target = await api.createSnapshot({ + createPrograms: [{ rootFiles: ["/index.ts"], compilerOptions: { noLib: true } }], + }); + const programId = target.operation.createdPrograms[0].id; + const originalFile = await target.getProgram(programId)!.getSourceFile("/index.ts"); + assert.equal(originalFile!.text, "export const value = 1;"); + fs.writeFile!(toRootedFilePath("/index.ts", undefined), "export const value = 2;"); + await using source = await api.createSnapshot({ + fileSystem: createFileSystemLayer([["/index.ts", "export const value = 2;"]]), + }); + await using rebased = await source.rebase(target, { ensurePrograms: true }); + assert.equal((await rebased.getProgram(programId)!.getSourceFile("/index.ts"))!.text, "export const value = 2;"); + assert.strictEqual(await target.getProgram(programId)!.getSourceFile("/index.ts"), originalFile); + }); + + for (const fullReplacement of [false, true]) { + test(`Snapshot.rebase removes cached files with ${fullReplacement ? "a full replacement" : "matching tombstones"}`, async () => { + await using api = spawnAPI({}); + const files = { + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: ["index.ts", "removed/keep.ts"] }), + "/index.ts": "export {};", + }; + await using target = await api.createSnapshot({ + openProjects: ["/tsconfig.json"], + fileSystem: { + kind: "full", + files: { ...files, "/removed/keep.ts": "export const keep = true;" }, + directories: { "/": { files: ["index.ts", "tsconfig.json"], directories: [] } }, + removedPaths: ["/removed"], + }, + }); + const originalFile = await target.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/removed/keep.ts"); + assert.ok(originalFile); + await using self = await target.rebase(target, { ensurePrograms: true }); + assert.strictEqual(await self.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/removed/keep.ts"), originalFile); + await using source = await api.createSnapshot({ + fileSystem: fullReplacement + ? createFileSystem(Object.entries(files)) + : createFileSystemLayer([], { removedPaths: ["/removed"] }), + }); + await using rebased = await source.rebase(target, { ensurePrograms: true }); + assert.equal(await rebased.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/removed/keep.ts"), undefined); + assert.strictEqual(await target.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/removed/keep.ts"), originalFile); + }); + } + test("Snapshot.rebase preserves explicit full invalidation", async () => { const { api: disposableAPI, fs } = spawnAPIWithFS({ "/index.ts": "export const value = 1;" }); await using api = disposableAPI; diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index 922bb569a3025..667dd0365f741 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -5314,6 +5314,55 @@ describe("updateSnapshot file systems", { concurrency }, () => { }); } + test("Snapshot.rebase compares against the target's cached contents", () => { + const { api: disposableAPI, fs } = spawnAPIWithFS({ "/index.ts": "export const value = 1;" }); + using api = disposableAPI; + using target = api.createSnapshot({ + createPrograms: [{ rootFiles: ["/index.ts"], compilerOptions: { noLib: true } }], + }); + const programId = target.operation.createdPrograms[0].id; + const originalFile = target.getProgram(programId)!.getSourceFile("/index.ts"); + assert.equal(originalFile!.text, "export const value = 1;"); + fs.writeFile!(toRootedFilePath("/index.ts", undefined), "export const value = 2;"); + using source = api.createSnapshot({ + fileSystem: createFileSystemLayer([["/index.ts", "export const value = 2;"]]), + }); + using rebased = source.rebase(target, { ensurePrograms: true }); + assert.equal((rebased.getProgram(programId)!.getSourceFile("/index.ts"))!.text, "export const value = 2;"); + assert.strictEqual(target.getProgram(programId)!.getSourceFile("/index.ts"), originalFile); + }); + + for (const fullReplacement of [false, true]) { + test(`Snapshot.rebase removes cached files with ${fullReplacement ? "a full replacement" : "matching tombstones"}`, () => { + using api = spawnAPI({}); + const files = { + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: ["index.ts", "removed/keep.ts"] }), + "/index.ts": "export {};", + }; + using target = api.createSnapshot({ + openProjects: ["/tsconfig.json"], + fileSystem: { + kind: "full", + files: { ...files, "/removed/keep.ts": "export const keep = true;" }, + directories: { "/": { files: ["index.ts", "tsconfig.json"], directories: [] } }, + removedPaths: ["/removed"], + }, + }); + const originalFile = target.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/removed/keep.ts"); + assert.ok(originalFile); + using self = target.rebase(target, { ensurePrograms: true }); + assert.strictEqual(self.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/removed/keep.ts"), originalFile); + using source = api.createSnapshot({ + fileSystem: fullReplacement + ? createFileSystem(Object.entries(files)) + : createFileSystemLayer([], { removedPaths: ["/removed"] }), + }); + using rebased = source.rebase(target, { ensurePrograms: true }); + assert.equal(rebased.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/removed/keep.ts"), undefined); + assert.strictEqual(target.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/removed/keep.ts"), originalFile); + }); + } + test("Snapshot.rebase preserves explicit full invalidation", () => { const { api: disposableAPI, fs } = spawnAPIWithFS({ "/index.ts": "export const value = 1;" }); using api = disposableAPI; diff --git a/tsc/internal/api/requestfilesystem/filechanges.go b/tsc/internal/api/requestfilesystem/filechanges.go index 05adda82d7c44..28568887648ba 100644 --- a/tsc/internal/api/requestfilesystem/filechanges.go +++ b/tsc/internal/api/requestfilesystem/filechanges.go @@ -30,25 +30,20 @@ func (s *requestFileSystem) ExpandFileChanges(summary project.FileChangeSummary) return summary } -func addRebaseFileChanges(summary *project.FileChangeSummary, source *requestFileSystem, baseFS vfs.FS, rebased *requestFileSystem) bool { +func addRebaseFileChanges(summary *project.FileChangeSummary, source *requestFileSystem, baseFS vfs.FS, baseContents vfs.FS, rebased *requestFileSystem) { baseRequestFS := getRequestFileSystem(baseFS) - changed := false addChange := func(path tspath.RootedPath, kind lsproto.FileChangeType) { uri := lsconv.FileNameToDocumentURI(tspath.RootedFilePathFromPath(path)) if kind == lsproto.FileChangeTypeDeleted { - if baseFS.FileExists(tspath.RootedFilePathFromPath(path)) || baseFS.DirectoryExists(tspath.RootedDirectoryPathFromPath(path)) { + if baseContents.FileExists(tspath.RootedFilePathFromPath(path)) || baseContents.DirectoryExists(tspath.RootedDirectoryPathFromPath(path)) { summary.Deleted.Add(uri) - changed = true } } else if kind == lsproto.FileChangeTypeCreated { summary.Created.Add(uri) - changed = true - } else if baseFS.FileExists(tspath.RootedFilePathFromPath(path)) { + } else if baseContents.FileExists(tspath.RootedFilePathFromPath(path)) { summary.Changed.Add(uri) - changed = true } else { summary.Created.Add(uri) - changed = true } } addChangeAndAliases := func(path tspath.RootedPath, kind lsproto.FileChangeType) { @@ -59,6 +54,32 @@ func addRebaseFileChanges(summary *project.FileChangeSummary, source *requestFil } } } + var addRemovedEntries func(*requestPathNode) + addRemovedEntries = func(node *requestPathNode) { + if node == nil { + return + } + switch entry := node.entry.(type) { + case *requestFile: + if !rebased.FileExists(entry.fileName) { + addChangeAndAliases(entry.fileName.AsPath(), lsproto.FileChangeTypeDeleted) + return + } + case *requestDirectory: + if !rebased.DirectoryExists(entry.directoryName) { + addChangeAndAliases(entry.directoryName.AsPath(), lsproto.FileChangeTypeDeleted) + return + } + case *requestSymlink: + if !rebased.FileExists(tspath.RootedFilePathFromPath(entry.linkName)) && !rebased.DirectoryExists(tspath.RootedDirectoryPathFromPath(entry.linkName)) { + addChangeAndAliases(entry.linkName, lsproto.FileChangeTypeDeleted) + return + } + } + for _, child := range node.children { + addRemovedEntries(child) + } + } var visit func(*requestPathNode) visit = func(node *requestPathNode) { for path, child := range node.children { @@ -70,15 +91,22 @@ func addRebaseFileChanges(summary *project.FileChangeSummary, source *requestFil } if previousFallback != requestFallbackMissing { addChangeAndAliases(child.fallbackPath, lsproto.FileChangeTypeDeleted) + } else if baseRequestFS != nil { + // A tombstone can still have explicit descendants. Reapplying + // it drops target descendants not supplied by the source. + previous, _ := baseRequestFS.paths.lookup(path) + addRemovedEntries(previous) } } switch entry := child.entry.(type) { case *requestFile: - if previous, ok := baseFS.ReadFile(entry.fileName); !ok || previous != entry.content { + if previous, ok := baseContents.ReadFile(entry.fileName); !ok || previous != entry.content { addChangeAndAliases(entry.fileName.AsPath(), lsproto.FileChangeTypeChanged) } case *requestDirectory: if entry.listing != nil { + // The snapshot view merges cached files into listings, even + // when omitted explicitly. Compare the supplied listings. previous := baseFS.GetAccessibleEntries(entry.directoryName) current := rebased.GetAccessibleEntries(entry.directoryName) if !slices.Equal(previous.Files, current.Files) || !slices.Equal(previous.Directories, current.Directories) || !maps.Equal(previous.Symlinks, current.Symlinks) { @@ -87,7 +115,7 @@ func addRebaseFileChanges(summary *project.FileChangeSummary, source *requestFil } } if source.kind == KindFull { - previous := baseFS.GetAccessibleEntries(entry.directoryName) + previous := baseContents.GetAccessibleEntries(entry.directoryName) for _, name := range previous.Files { fileName := entry.directoryName.ResolveFile(name) if !rebased.FileExists(fileName) { @@ -120,7 +148,6 @@ func addRebaseFileChanges(summary *project.FileChangeSummary, source *requestFil if summary.Changed.Len()+summary.Created.Len()+summary.Deleted.Len() > 0 { summary.IncludesWatchChangeOutsideNodeModules = true } - return changed } func addFileChanges(summary *project.FileChangeSummary, request *RequestFileSystem, baseFS vfs.FS, fileSystem *requestFileSystem, currentDirectory tspath.RootedDirectoryPath) { diff --git a/tsc/internal/api/requestfilesystem/filechanges_test.go b/tsc/internal/api/requestfilesystem/filechanges_test.go index a882574610b1a..892b04011c127 100644 --- a/tsc/internal/api/requestfilesystem/filechanges_test.go +++ b/tsc/internal/api/requestfilesystem/filechanges_test.go @@ -1,6 +1,7 @@ package requestfilesystem import ( + "strconv" "testing" "github.com/microsoft/TypeScript/tsc/internal/project" @@ -44,7 +45,7 @@ func TestRebaseFileChangesMatchLayerUpdate(t *testing.T) { var expected, actual project.FileChangeSummary _, updateErr := NewForUpdate(params, base, "/", &expected) assert.NilError(t, updateErr) - Rebase(source, base, &actual) + Rebase(source, base, base, &actual) assert.DeepEqual(t, actual, expected) }) } @@ -59,11 +60,55 @@ func TestRebaseFileChangesPreserveTombstoneCasing(t *testing.T) { }, host, "C:/Repo") assert.NilError(t, err) var summary project.FileChangeSummary - Rebase(source, host, &summary) + Rebase(source, host, host, &summary) assert.Assert(t, summary.Deleted.Has("file:///c%3A/Repo/Removed")) assert.Equal(t, summary.Deleted.Len(), 1) } +func TestRebaseFileChangesUnderMatchingTombstones(t *testing.T) { + t.Parallel() + for _, preserved := range []int{0, 1, 2} { + t.Run(strconv.Itoa(preserved), func(t *testing.T) { + t.Parallel() + host := vfstest.FromMap(map[string]string{}, tspath.CaseSensitive) + base, err := newRequestFileSystem(&RequestFileSystem{ + Kind: KindLayer, + Files: map[string]string{ + "/removed/keep.ts": "keep", + "/removed/extra/file.ts": "extra", + }, + RemovedPaths: []string{"/removed"}, + Symlinks: map[string]RequestSymlink{"/alias": {Target: "/removed"}}, + }, host, "/") + assert.NilError(t, err) + files := map[string]string{} + if preserved >= 1 { + files["/removed/keep.ts"] = "keep" + } + if preserved == 2 { + files["/removed/extra/file.ts"] = "extra" + } + source, err := newRequestFileSystem(&RequestFileSystem{ + Kind: KindLayer, Files: files, RemovedPaths: []string{"/removed"}, + }, host, "/") + assert.NilError(t, err) + var summary project.FileChangeSummary + Rebase(source, base, base, &summary) + var expected project.FileChangeSummary + switch preserved { + case 0: + expected.Deleted.Add("file:///removed") + expected.Deleted.Add("file:///alias") + case 1: + expected.Deleted.Add("file:///removed/extra") + expected.Deleted.Add("file:///alias/extra") + } + expected.IncludesWatchChangeOutsideNodeModules = preserved != 2 + assert.DeepEqual(t, summary, expected) + }) + } +} + func TestFileChangesIncludeDirectoryTombstones(t *testing.T) { t.Parallel() diff --git a/tsc/internal/api/requestfilesystem/rebase_test.go b/tsc/internal/api/requestfilesystem/rebase_test.go index 0760d2794dd5c..1e724e3cf4821 100644 --- a/tsc/internal/api/requestfilesystem/rebase_test.go +++ b/tsc/internal/api/requestfilesystem/rebase_test.go @@ -52,7 +52,7 @@ func TestRebaseDoesNotMutateInputs(t *testing.T) { assert.NilError(t, err) } var fileChanges project.FileChangeSummary - rebased := Rebase(source, target, &fileChanges) + rebased := Rebase(source, target, target, &fileChanges) assert.Assert(t, rebased != source && rebased != target) content, ok := rebased.ReadFile("/alias.ts") assert.Assert(t, ok) @@ -62,7 +62,7 @@ func TestRebaseDoesNotMutateInputs(t *testing.T) { assert.Assert(t, rebased.FileExists("/dir/source.ts")) assert.Equal(t, rebased.FileExists("/dir/target.ts"), sourceKind == KindLayer && targetKind != "host") _ = rebased.GetAccessibleEntries("/dir") - second := Rebase(source, source, &fileChanges) + second := Rebase(source, source, source, &fileChanges) assert.Assert(t, second != source) _ = second.GetAccessibleEntries("/dir") assert.Assert(t, reflect.DeepEqual(source, expectedSource), "source filesystem was mutated") diff --git a/tsc/internal/api/requestfilesystem/requestfilesystem.go b/tsc/internal/api/requestfilesystem/requestfilesystem.go index 7dc96a0b3f613..871c282d77568 100644 --- a/tsc/internal/api/requestfilesystem/requestfilesystem.go +++ b/tsc/internal/api/requestfilesystem/requestfilesystem.go @@ -119,7 +119,10 @@ func HasFullFileSystem(fileSystem vfs.FS) bool { return requestFileSystem != nil && requestFileSystem.kind == KindFull } -func Rebase(fileSystem vfs.FS, base vfs.FS, fileChanges *project.FileChangeSummary) vfs.FS { +// Rebase reapplies fileSystem's request entries over base. baseContents is the +// target snapshot's cached/editor view, used only to report changes relative to +// that snapshot; it must not become part of the composed filesystem. +func Rebase(fileSystem vfs.FS, base vfs.FS, baseContents vfs.FS, fileChanges *project.FileChangeSummary) vfs.FS { source := getRequestFileSystem(fileSystem) if source == nil { return base @@ -141,9 +144,13 @@ func Rebase(fileSystem vfs.FS, base vfs.FS, fileChanges *project.FileChangeSumma caseSensitivity: source.caseSensitivity, paths: paths, } - changed := addRebaseFileChanges(fileChanges, source, baseFileSystem, result) - if source.kind == KindFull && (!HasFullFileSystem(baseFileSystem) || changed) { - fileChanges.InvalidateAll = true + if source != getRequestFileSystem(baseFileSystem) { + addRebaseFileChanges(fileChanges, source, baseFileSystem, baseContents, result) + // Listings need not expose every readable entry, so only a self-rebase can + // establish that a full replacement preserves all of the target's paths. + if source.kind == KindFull { + fileChanges.InvalidateAll = true + } } return result } diff --git a/tsc/internal/api/session.go b/tsc/internal/api/session.go index aca9fb9137971..f2c6c5fb1f815 100644 --- a/tsc/internal/api/session.go +++ b/tsc/internal/api/session.go @@ -1546,7 +1546,7 @@ func (s *Session) handleRebaseSnapshot(ctx context.Context, params *RebaseSnapsh if fileSystem == nil { fileSystem = s.FS() } - fileSystem = requestfilesystem.Rebase(source.fileSystem, fileSystem, &fileChanges) + fileSystem = requestfilesystem.Rebase(source.fileSystem, fileSystem, target.snapshot.FS(), &fileChanges) } if changes.FileSystem != nil { baseFileSystem := fileSystem diff --git a/tsc/internal/api/session_rebase_changes_test.go b/tsc/internal/api/session_rebase_changes_test.go index 9fa41fdc101f8..de27a1f111254 100644 --- a/tsc/internal/api/session_rebase_changes_test.go +++ b/tsc/internal/api/session_rebase_changes_test.go @@ -1,16 +1,290 @@ package api import ( + "fmt" "slices" "testing" "github.com/microsoft/TypeScript/tsc/internal/api/requestfilesystem" "github.com/microsoft/TypeScript/tsc/internal/core" + "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" "github.com/microsoft/TypeScript/tsc/internal/project" "github.com/microsoft/TypeScript/tsc/internal/testutil/projecttestutil" + "github.com/microsoft/TypeScript/tsc/internal/tspath" "gotest.tools/v3/assert" ) +func TestRebaseRemovesExplicitTombstoneDescendants(t *testing.T) { + t.Parallel() + for _, kind := range []requestfilesystem.Kind{requestfilesystem.KindLayer, requestfilesystem.KindFull} { + t.Run(string(kind), func(t *testing.T) { + t.Parallel() + projectSession, _ := projecttestutil.Setup(map[string]any{}) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + source, err := session.handleCreateSnapshot(t.Context(), &CreateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindLayer, RemovedPaths: []string{"/removed"}}, + }) + assert.NilError(t, err) + target, err := session.handleCreateSnapshot(t.Context(), &CreateSnapshotParams{ + OpenProjects: []DocumentIdentifier{{FileName: "/tsconfig.json"}}, + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: kind, + Files: map[string]string{ + "/tsconfig.json": `{"compilerOptions":{"noLib":true},"include":["**/*.ts"]}`, + "/index.ts": "export {};", + "/removed/keep.ts": "export const keep = true;", + "/removed/nested/keep.ts": "export const nested = true;", + }, + RemovedPaths: []string{"/removed"}, + }, + }) + assert.NilError(t, err) + targetSnapshot := session.snapshots[target.Snapshot].snapshot + targetProgram := targetSnapshot.ProjectCollection.GetProject(project.ID("/tsconfig.json")).GetProgram() + assert.Assert(t, targetProgram.GetSourceFile("/removed/keep.ts") != nil) + self, err := session.handleRebaseSnapshot(t.Context(), &RebaseSnapshotParams{ + Snapshot: target.Snapshot, NewSnapshot: target.Snapshot, + Changes: &CreateSnapshotParams{EnsurePrograms: &EnsurePrograms{All: true}}, + }) + assert.NilError(t, err) + assert.Assert(t, session.snapshots[self.Snapshot].snapshot.ProjectCollection.GetProject(project.ID("/tsconfig.json")).GetProgram() == targetProgram) + + rebased, err := session.handleRebaseSnapshot(t.Context(), &RebaseSnapshotParams{ + Snapshot: source.Snapshot, NewSnapshot: target.Snapshot, + Changes: &CreateSnapshotParams{EnsurePrograms: &EnsurePrograms{All: true}}, + }) + assert.NilError(t, err) + snapshot := session.snapshots[rebased.Snapshot].snapshot + program := snapshot.ProjectCollection.GetProject(project.ID("/tsconfig.json")).GetProgram() + for _, fileName := range []tspath.RootedFilePath{"/removed/keep.ts", "/removed/nested/keep.ts"} { + assert.Assert(t, !snapshot.FileSystem().FileExists(fileName)) + assert.Assert(t, snapshot.GetFile(fileName) == nil) + assert.Assert(t, program.GetSourceFile(fileName) == nil) + assert.Assert(t, targetProgram.GetSourceFile(fileName) != nil) + } + }) + } +} + +func TestRebaseFullFileSystemRemovesUnlistedFiles(t *testing.T) { + t.Parallel() + projectSession, _ := projecttestutil.Setup(map[string]any{}) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + files := map[string]string{ + "/tsconfig.json": `{"compilerOptions":{"noLib":true},"files":["index.ts","hidden.ts"]}`, + "/index.ts": "export {};", + } + source, err := session.handleCreateSnapshot(t.Context(), &CreateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindFull, Files: files}, + }) + assert.NilError(t, err) + target, err := session.handleCreateSnapshot(t.Context(), &CreateSnapshotParams{ + OpenProjects: []DocumentIdentifier{{FileName: "/tsconfig.json"}}, + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindFull, + Files: map[string]string{ + "/tsconfig.json": files["/tsconfig.json"], + "/index.ts": files["/index.ts"], + "/hidden.ts": "export const hidden = true;", + }, + Directories: map[string]requestfilesystem.RequestDirectoryEntries{ + "/": {Files: []string{"index.ts", "tsconfig.json"}}, + }, + }, + }) + assert.NilError(t, err) + assert.Assert(t, session.snapshots[target.Snapshot].snapshot.GetFile("/hidden.ts") != nil) + rebased, err := session.handleRebaseSnapshot(t.Context(), &RebaseSnapshotParams{ + Snapshot: source.Snapshot, NewSnapshot: target.Snapshot, + Changes: &CreateSnapshotParams{EnsurePrograms: &EnsurePrograms{All: true}}, + }) + assert.NilError(t, err) + snapshot := session.snapshots[rebased.Snapshot].snapshot + assert.Assert(t, !snapshot.FileSystem().FileExists("/hidden.ts")) + assert.Assert(t, snapshot.GetFile("/hidden.ts") == nil) + assert.Assert(t, snapshot.ProjectCollection.GetProject(project.ID("/tsconfig.json")).GetProgram().GetSourceFile("/hidden.ts") == nil) +} + +func TestRebaseComparesTargetSnapshotContents(t *testing.T) { + t.Parallel() + for _, editor := range []bool{false, true} { + for _, layeredTarget := range []bool{false, true} { + t.Run(fmt.Sprintf("editor=%v/layer=%v", editor, layeredTarget), func(t *testing.T) { + t.Parallel() + const oldText = "export const value = 1;" + const newText = "export const value = 2;" + projectSession, _ := projecttestutil.Setup(map[string]any{ + "/tsconfig.json": `{"compilerOptions":{"noLib":true},"files":["index.ts"]}`, + "/index.ts": oldText, + }) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + expected := newText + if editor { + expected = oldText + projectSession.DidOpenFile(t.Context(), "file:///index.ts", 1, newText, lsproto.LanguageKindTypeScript) + } + target, err := session.handleGetCurrentLanguageServerSnapshot(t.Context(), &GetCurrentLanguageServerSnapshotParams{ + Changes: &LanguageServerSnapshotChanges{OpenProjects: []DocumentIdentifier{{FileName: "/tsconfig.json"}}}, + }) + assert.NilError(t, err) + if layeredTarget { + target, err = session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{ + Snapshot: target.Snapshot, + Changes: &CreateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindLayer}, + EnsurePrograms: &EnsurePrograms{All: true}, + }, + }) + assert.NilError(t, err) + } + targetProgram := session.snapshots[target.Snapshot].snapshot.ProjectCollection.GetProject(project.ID("/tsconfig.json")).GetProgram() + assert.Assert(t, targetProgram.GetSourceFile("/index.ts").Text() != expected) + if !editor { + assert.NilError(t, session.FS().WriteFile("/index.ts", newText)) + } + source, err := session.handleCreateSnapshot(t.Context(), &CreateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindLayer, Files: map[string]string{"/index.ts": expected}}, + }) + assert.NilError(t, err) + rebased, err := session.handleRebaseSnapshot(t.Context(), &RebaseSnapshotParams{ + Snapshot: source.Snapshot, NewSnapshot: target.Snapshot, + Changes: &CreateSnapshotParams{EnsurePrograms: &EnsurePrograms{All: true}}, + }) + assert.NilError(t, err) + snapshot := session.snapshots[rebased.Snapshot].snapshot + content, ok := snapshot.ReadFile("/index.ts") + assert.Assert(t, ok) + assert.Equal(t, content, expected) + proj := snapshot.ProjectCollection.GetProject(project.ID("/tsconfig.json")) + assert.Equal(t, proj.GetProgram().GetSourceFile("/index.ts").Text(), expected) + assert.Equal(t, proj.ProgramUpdateKind, project.ProgramUpdateKindCloned) + }) + } + } +} + +func TestRebaseUnchangedListingPreservesIncrementalState(t *testing.T) { + t.Parallel() + projectSession, _ := projecttestutil.Setup(map[string]any{}) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + directories := map[string]requestfilesystem.RequestDirectoryEntries{ + "/": {Files: []string{"index.ts", "tsconfig.json"}}, + } + target, err := session.handleCreateSnapshot(t.Context(), &CreateSnapshotParams{ + OpenProjects: []DocumentIdentifier{{FileName: "/tsconfig.json"}}, + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindFull, + Files: map[string]string{ + "/tsconfig.json": `{"compilerOptions":{"noLib":true},"files":["index.ts","hidden.ts"]}`, + "/index.ts": "export const value = 1;", + "/hidden.ts": "export const hidden = true;", + }, + Directories: directories, + }, + }) + assert.NilError(t, err) + targetProgram := session.snapshots[target.Snapshot].snapshot.ProjectCollection.GetProject(project.ID("/tsconfig.json")).GetProgram() + source, err := session.handleCreateSnapshot(t.Context(), &CreateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindLayer, + Files: map[string]string{"/index.ts": "export const value = 2;"}, + Directories: directories, + }, + }) + assert.NilError(t, err) + rebased, err := session.handleRebaseSnapshot(t.Context(), &RebaseSnapshotParams{ + Snapshot: source.Snapshot, NewSnapshot: target.Snapshot, + Changes: &CreateSnapshotParams{EnsurePrograms: &EnsurePrograms{All: true}}, + }) + assert.NilError(t, err) + proj := session.snapshots[rebased.Snapshot].snapshot.ProjectCollection.GetProject(project.ID("/tsconfig.json")) + assert.Assert(t, proj != nil) + assert.Equal(t, proj.ProgramUpdateKind, project.ProgramUpdateKindCloned) + assert.Equal(t, proj.GetProgram().GetSourceFile("/index.ts").Text(), "export const value = 2;") + assert.Assert(t, proj.GetProgram().GetSourceFile("/hidden.ts") == targetProgram.GetSourceFile("/hidden.ts")) +} + +func TestRebaseGeneratedFilesAreRelativeToTarget(t *testing.T) { + t.Parallel() + for _, targetHasGeneratedFiles := range []bool{false, true} { + t.Run(fmt.Sprintf("targetHasGeneratedFiles=%v", targetHasGeneratedFiles), func(t *testing.T) { + t.Parallel() + projectSession, _ := projecttestutil.Setup(map[string]any{ + "/tsconfig.json": `{"compilerOptions":{"noLib":true},"include":["*.ts"]}`, + "/index.ts": "export const value = 1;", + "/other/tsconfig.json": `{"compilerOptions":{"noLib":true},"files":["index.ts"]}`, + "/other/index.ts": "export {};", + }) + defer projectSession.Close() + projectSession.DidOpenFile(t.Context(), "file:///index.ts", 1, "export const value = 1;", lsproto.LanguageKindTypeScript) + session := NewLSPSession(projectSession, nil) + defer session.Close() + base, err := session.handleGetCurrentLanguageServerSnapshot(t.Context(), &GetCurrentLanguageServerSnapshotParams{ + Changes: &LanguageServerSnapshotChanges{OpenProjects: []DocumentIdentifier{{FileName: "/tsconfig.json"}, {FileName: "/other/tsconfig.json"}}}, + }) + assert.NilError(t, err) + const count = 200 + files := make(map[string]string, count) + for i := range count { + files[fmt.Sprintf("/file%d.ngtypecheck.ts", i)] = "export {};" + } + layer := &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindLayer, Files: files} + source, err := session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{ + Snapshot: base.Snapshot, + Changes: &CreateSnapshotParams{FileSystem: layer, EnsurePrograms: &EnsurePrograms{All: true}}, + }) + assert.NilError(t, err) + projectSession.DidChangeFile(t.Context(), "file:///index.ts", 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{{ + WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: "export const value = 2;"}, + }}) + target, err := session.handleGetCurrentLanguageServerSnapshot(t.Context(), &GetCurrentLanguageServerSnapshotParams{}) + assert.NilError(t, err) + if targetHasGeneratedFiles { + target, err = session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{ + Snapshot: target.Snapshot, + Changes: &CreateSnapshotParams{FileSystem: layer, EnsurePrograms: &EnsurePrograms{All: true}}, + }) + assert.NilError(t, err) + } + targetSnapshot := session.snapshots[target.Snapshot].snapshot + unrelated := targetSnapshot.ProjectCollection.GetProject(project.ID("/other/tsconfig.json")).GetProgram() + rebased, err := session.handleRebaseSnapshot(t.Context(), &RebaseSnapshotParams{ + Snapshot: source.Snapshot, NewSnapshot: target.Snapshot, + Changes: &CreateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindLayer, Files: map[string]string{"/index.ts": "export const value = 3;"}, + }, + EnsurePrograms: &EnsurePrograms{All: true}, + }, + }) + assert.NilError(t, err) + snapshot := session.snapshots[rebased.Snapshot].snapshot + proj := snapshot.ProjectCollection.GetProject(project.ID("/tsconfig.json")) + expectedKind := project.ProgramUpdateKindNewFiles + if targetHasGeneratedFiles { + expectedKind = project.ProgramUpdateKindCloned + } + assert.Equal(t, proj.ProgramUpdateKind, expectedKind) + assert.Equal(t, len(proj.GetProgram().SourceFiles()), count+1) + assert.Equal(t, proj.GetProgram().GetSourceFile("/index.ts").Text(), "export const value = 3;") + assert.Assert(t, snapshot.ProjectCollection.GetProject(project.ID("/other/tsconfig.json")).GetProgram() == unrelated) + assert.DeepEqual(t, rebased.Changes, &SnapshotChanges{ + ChangedProjects: map[project.ID]*ProjectFileChanges{ + "/tsconfig.json": {ChangedFiles: []tspath.PathKey{"/index.ts"}}, + }, + }) + }) + } +} + func TestRebasePreservesFullInvalidation(t *testing.T) { t.Parallel() projectSession, _ := projecttestutil.Setup(map[string]any{"/index.ts": "export const value = 1;"}) From 254d07cd46e9803b8904fca1284ae9c1486ab6aa Mon Sep 17 00:00:00 2001 From: Wesley Wigham Date: Thu, 22 Oct 2026 10:49:59 -0700 Subject: [PATCH 3/4] Fix tests post-merge --- tsc/internal/api/session_rebase_changes_test.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tsc/internal/api/session_rebase_changes_test.go b/tsc/internal/api/session_rebase_changes_test.go index de27a1f111254..dac6d5de754e2 100644 --- a/tsc/internal/api/session_rebase_changes_test.go +++ b/tsc/internal/api/session_rebase_changes_test.go @@ -5,6 +5,7 @@ import ( "slices" "testing" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/microsoft/TypeScript/tsc/internal/api/requestfilesystem" "github.com/microsoft/TypeScript/tsc/internal/core" "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" @@ -298,20 +299,19 @@ func TestRebasePreservesFullInvalidation(t *testing.T) { }}, }) assert.NilError(t, err) - programID := project.ID((*base.Operation.CreatedPrograms)[0]) target, err := session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{Snapshot: base.Snapshot}) assert.NilError(t, err) assert.NilError(t, session.FS().WriteFile("/index.ts", "export const value = 2;")) changes := &CreateSnapshotParams{FileNotifications: &FileNotifications{InvalidateAll: true}, EnsurePrograms: &EnsurePrograms{All: true}} updated, err := session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{Snapshot: base.Snapshot, Changes: changes}) assert.NilError(t, err) - updatedProgram, err := session.snapshots[updated.Snapshot].getProgram(programID) + updatedProgram, err := session.snapshots[updated.Snapshot].getProgram(updated.Projects[0].Handle) assert.NilError(t, err) assert.Equal(t, updatedProgram.GetSourceFile("/index.ts").Text(), "export const value = 2;") for _, targetID := range []SnapshotID{base.Snapshot, target.Snapshot} { rebased, err := session.handleRebaseSnapshot(t.Context(), &RebaseSnapshotParams{Snapshot: base.Snapshot, NewSnapshot: targetID, Changes: changes}) assert.NilError(t, err) - program, err := session.snapshots[rebased.Snapshot].getProgram(programID) + program, err := session.snapshots[rebased.Snapshot].getProgram(rebased.Projects[0].Handle) assert.NilError(t, err) assert.Equal(t, program.GetSourceFile("/index.ts").Text(), "export const value = 2;") } @@ -340,7 +340,7 @@ func TestRebaseRefreshesDependencyAutoImports(t *testing.T) { t.Helper() proj := session.snapshots[snapshotID].snapshot.GetDefaultProject("file:///home/project/index.ts") completions, completionErr := session.handleGetCompletionsAtPosition(t.Context(), &GetCompletionsAtPositionParams{ - Snapshot: snapshotID, Project: proj.ID(), File: DocumentIdentifier{FileName: fileName}, Position: 7, IncludeSymbol: true, + Snapshot: snapshotID, Project: proj.Handle(), File: DocumentIdentifier{FileName: fileName}, Position: 7, IncludeSymbol: true, }) assert.NilError(t, completionErr) assert.Assert(t, completions != nil) @@ -437,7 +437,8 @@ func TestSelfRebaseMatchesUpdateProgramState(t *testing.T) { assert.NilError(t, err, name) rebased, err := session.handleRebaseSnapshot(t.Context(), &RebaseSnapshotParams{Snapshot: base.Snapshot, NewSnapshot: base.Snapshot, Changes: changes}) assert.NilError(t, err, name) - assert.DeepEqual(t, rebased.Projects, updated.Projects) + // Projects created independently have distinct lifetime handles. + assert.DeepEqual(t, rebased.Projects, updated.Projects, cmpopts.IgnoreFields(ProjectResponse{}, "Handle")) assert.DeepEqual(t, rebased.Changes, updated.Changes) assert.DeepEqual(t, rebased.Operation, updated.Operation) assert.Equal(t, session.snapshots[base.Snapshot].refCount, 1) From ccee60a414af257df3ba6f6fa94057e3e5d962d7 Mon Sep 17 00:00:00 2001 From: Wesley Wigham Date: Thu, 22 Oct 2026 11:22:54 -0700 Subject: [PATCH 4/4] rebase -> update variant --- packages/typescript/src/api/async/api.ts | 79 ++------- packages/typescript/src/api/fs.ts | 21 +++ .../typescript/src/api/proto.generated.ts | 19 +-- packages/typescript/src/api/sync/api.ts | 133 ++------------- packages/typescript/test/async/api.test.ts | 86 +++++++--- .../test/sync/api-generators.test.ts | 13 +- packages/typescript/test/sync/api.test.ts | 84 +++++++--- tsc/internal/api/proto.go | 12 +- .../requestfilesystem/requestfilesystem.go | 11 ++ tsc/internal/api/session.go | 83 +++------- tsc/internal/api/session_completion_test.go | 28 ++-- .../api/session_requestfilesystem_test.go | 12 +- ...ssion_snapshot_filesystem_changes_test.go} | 90 +++++++---- ...go => session_snapshot_filesystem_test.go} | 153 ++++++++++++++++-- 14 files changed, 453 insertions(+), 371 deletions(-) rename tsc/internal/api/{session_rebase_changes_test.go => session_snapshot_filesystem_changes_test.go} (84%) rename tsc/internal/api/{session_rebase_test.go => session_snapshot_filesystem_test.go} (50%) diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index 54dfc758a34b3..29369a7392809 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -639,7 +639,6 @@ export class API implements FormatDiagnosticsHo this.sourceFileCache.releaseSnapshot(snapshot.id); }, this.createSnapshotUpdater(() => snapshot), - this.createSnapshotRebaser(() => snapshot), undefined, ); this.activeSnapshots.set(snapshot.id, snapshot); @@ -671,45 +670,21 @@ export class API implements FormatDiagnosticsHo this.sourceFileCache.releaseSnapshot(snapshot.id); }, this.createSnapshotUpdater(() => snapshot), - this.createSnapshotRebaser(() => snapshot), baseSnapshot, ); this.activeSnapshots.set(snapshot.id, snapshot); return snapshot; } - private async rebaseSnapshot(sourceSnapshot: Snapshot, newSnapshot: Snapshot, params?: CreateSnapshotParams): Promise { - await this.ensureInitialized(); - if (this.activeSnapshots.get(sourceSnapshot.id) !== sourceSnapshot || sourceSnapshot.isDisposed()) { - throw new Error("Cannot rebase an inactive snapshot"); - } - if (this.activeSnapshots.get(newSnapshot.id) !== newSnapshot || newSnapshot.isDisposed()) { - throw new Error("Cannot rebase onto an inactive snapshot"); - } - const data = await this.client.apiRequest("rebaseSnapshot", { - snapshot: sourceSnapshot.id, - newSnapshot: newSnapshot.id, - changes: this.prepareCreateSnapshotParams(params), - }); - this.sourceFileCache.retainForSnapshot(data.snapshot, newSnapshot.id, data.changes); - const snapshot = new Snapshot( - data, - this.toPath!, - this, - () => { - this.activeSnapshots.delete(snapshot.id); - this.sourceFileCache.releaseSnapshot(snapshot.id); - }, - this.createSnapshotUpdater(() => snapshot), - this.createSnapshotRebaser(() => snapshot), - newSnapshot, - ); - this.activeSnapshots.set(snapshot.id, snapshot); - return snapshot; - } - private prepareCreateSnapshotParams(params: CreateSnapshotParams | undefined): ProtocolCreateSnapshotParams { params ??= {}; + if (params.fileSystem?.kind === "snapshot") { + const sourceId = params.fileSystem.snapshot; + const source = sourceId === undefined ? undefined : this.activeSnapshots.get(sourceId); + if (!source || source.isDisposed()) { + throw new Error("Cannot use an inactive snapshot filesystem"); + } + } const prepareOptions = (options: CreateProgramOptions | undefined): ProtocolCreateProgramOptions | undefined => { if (!options) return undefined; const { moduleResolver, projectReferences, ...rest } = options; @@ -745,15 +720,6 @@ export class API implements FormatDiagnosticsHo return update; } - private createSnapshotRebaser(getSnapshot: () => Snapshot): SnapshotRebaser { - const rebase: SnapshotRebaser = (newSnapshot, params) => this.rebaseSnapshot(getSnapshot(), newSnapshot, params); // @sync: const rebase = ((newSnapshot: Snapshot, params?: CreateSnapshotParams) => this.rebaseSnapshot(getSnapshot(), newSnapshot, params)) as SnapshotRebaser; - // @sync-only-start - // const owner = this; - // rebase.gen = function* (newSnapshot: Snapshot, params?: CreateSnapshotParams) { return yield* owner.rebaseSnapshot.gen(getSnapshot(), newSnapshot, params); }; - // @sync-only-end - return rebase; - } - /** * Returns the language server's current canonical snapshot after atomically * adopting any supplied API-driven changes. Only available on LSP-connected APIs. @@ -794,7 +760,6 @@ export class API implements FormatDiagnosticsHo this.sourceFileCache.releaseSnapshot(snapshot.id); }, this.createSnapshotUpdater(() => snapshot), - this.createSnapshotRebaser(() => snapshot), baseSnapshot, ); this.activeSnapshots.set(snapshot.id, snapshot); @@ -1027,7 +992,6 @@ export class DebugHandlers { } type SnapshotUpdater = (params: CreateSnapshotParams) => Promise; // @sync: type SnapshotUpdater = ((params: CreateSnapshotParams) => Snapshot) & { gen(params: CreateSnapshotParams): Generator; }; -type SnapshotRebaser = (newSnapshot: Snapshot, params?: CreateSnapshotParams) => Promise; // @sync: type SnapshotRebaser = ((newSnapshot: Snapshot, params?: CreateSnapshotParams) => Snapshot) & { gen(newSnapshot: Snapshot, params?: CreateSnapshotParams): Generator; }; export interface SnapshotOperation { readonly createdPrograms?: readonly Program[] | undefined; @@ -1099,19 +1063,17 @@ export class Snapshot { private snapshotRegistry: SnapshotObjectRegistry; private projectDataMap: Map; private updateSnapshot: SnapshotUpdater; - private rebaseSnapshot: SnapshotRebaser; private get client(): Client { return this.api.client; } - constructor(data: CreateSnapshotResponse, toPath: (fileName: string, basePath?: string) => PathKey, api: API, onDispose: () => void, updateSnapshot: SnapshotUpdater, rebaseSnapshot: SnapshotRebaser, baseSnapshot?: Snapshot) { + constructor(data: CreateSnapshotResponse, toPath: (fileName: string, basePath?: string) => PathKey, api: API, onDispose: () => void, updateSnapshot: SnapshotUpdater, baseSnapshot?: Snapshot) { this.id = data.snapshot; this.api = api; this.toPath = toPath; this.onDispose = onDispose; this.updateSnapshot = updateSnapshot; - this.rebaseSnapshot = rebaseSnapshot; this.projectMap = new Map(); const projectDataMap = new Map(baseSnapshot?.projectDataMap); for (const projectId of data.changes?.removedProjects ?? []) { @@ -1154,6 +1116,12 @@ export class Snapshot { return this.getProject(projectId)?.program; } + /** + * Creates a snapshot using this snapshot's projects and incremental state. + * Use `createFileSystemFromSnapshot(source)` from the filesystem API as `fileSystem` + * to overlay another snapshot's retained request entries without replaying + * intermediate updates. Changes and program reuse are relative to this snapshot. + */ update< const CreatePrograms extends CreateSnapshotParams["createPrograms"] = undefined, const OpenFiles extends CreateSnapshotParams["openFiles"] = undefined, @@ -1164,25 +1132,6 @@ export class Snapshot { return this.updateSnapshot(params); } - /** - * Creates a new snapshot with this snapshot's memory filesystem overlaid on - * `newSnapshot`, retaining the target's projects and language server state. - * Source entries take precedence; a full source filesystem remains total. - * Optional changes are applied after rebasing, including auto-import preparation. - * `newSnapshot` is the sole parent for program reuse and reported changes; - * files absent from it are new files even if already present in this snapshot. - * Neither input snapshot is modified, and retained file contents are not resent. - */ - rebase< - const CreatePrograms extends CreateSnapshotParams["createPrograms"] = undefined, - const OpenFiles extends CreateSnapshotParams["openFiles"] = undefined, - >(newSnapshot: Snapshot, params: SnapshotOperationParams): Promise>; - rebase(newSnapshot: Snapshot, params?: CreateSnapshotParams): Promise; - rebase(newSnapshot: Snapshot, params?: CreateSnapshotParams): Promise { - this.ensureNotDisposed(); - return this.rebaseSnapshot(newSnapshot, params); - } - /** * Gets the default project for a given file from the configured projects and * inferred project already loaded in the snapshot. Synthetic projects are not diff --git a/packages/typescript/src/api/fs.ts b/packages/typescript/src/api/fs.ts index 25bfe9f044309..762711f4352ac 100644 --- a/packages/typescript/src/api/fs.ts +++ b/packages/typescript/src/api/fs.ts @@ -5,6 +5,7 @@ import type { RootedFilePath, RootedPath, } from "../ast/index.ts"; +import type { Snapshot } from "./async/api.ts"; import { normalizePath } from "./path.ts"; import type { RequestDirectoryEntries, @@ -169,6 +170,26 @@ export function createFileSystemLayer( return createRequestFileSystem("layer", files, options); } +/** + * Reuses a snapshot's retained request filesystem without resending its contents. + * Entries take precedence over the receiving snapshot's filesystem; a full source + * filesystem remains total. Host and editor contents are not copied. + * An optional layer or full filesystem is applied over the reused filesystem. + * The source must remain active until the snapshot operation completes. + */ +export function createFileSystemFromSnapshot( + snapshot: Pick, + fileSystem?: RequestFileSystem | undefined, +): RequestFileSystem { + if (snapshot.isDisposed()) { + throw new Error("Cannot create a filesystem from a disposed snapshot"); + } + if (fileSystem?.kind === "snapshot") { + throw new Error("Only a layer or full filesystem can be applied on top of a snapshot filesystem"); + } + return { kind: "snapshot", snapshot: snapshot.id, files: {}, fileSystem }; +} + function createRequestFileSystem( kind: RequestFileSystem["kind"], files: RequestFileEntries, diff --git a/packages/typescript/src/api/proto.generated.ts b/packages/typescript/src/api/proto.generated.ts index 68963d7830b55..51b1123b15634 100644 --- a/packages/typescript/src/api/proto.generated.ts +++ b/packages/typescript/src/api/proto.generated.ts @@ -47,7 +47,6 @@ export interface APIMethodInfo { initialize: APIMethod; createSnapshot: APIMethod; updateSnapshot: APIMethod; - rebaseSnapshot: APIMethod; getCurrentLanguageServerSnapshot: APIMethod; createBuildOrchestrator: APIMethod; disposeBuildOrchestrator: APIMethod; @@ -309,7 +308,9 @@ export interface CreateSnapshotParams extends SnapshotRequestChangesParams { /** * FileSystem supplies file contents and directory listings for the new snapshot. * A full filesystem is canonical and total. A filesystem layer is checked - * before falling back to the base snapshot or host filesystem. + * before falling back to the base snapshot or host filesystem. A snapshot + * filesystem overlays retained request entries from another active snapshot, + * then applies an optional layer or full filesystem. */ fileSystem?: RequestFileSystem | undefined; } @@ -334,12 +335,6 @@ export interface UpdateSnapshotParams { changes?: CreateSnapshotParams | undefined; } -export interface RebaseSnapshotParams { - snapshot: number; - newSnapshot: number; - changes?: CreateSnapshotParams | undefined; -} - export interface GetCurrentLanguageServerSnapshotParams { baseSnapshot?: number | undefined; changes?: LanguageServerSnapshotChanges | undefined; @@ -1317,7 +1312,6 @@ export interface BatchRequest { | "parseJsonConfigFileContent" | "printNode" | "readConfigFile" - | "rebaseSnapshot" | "release" | "releaseModuleResolver" | "releaseSourceFile" @@ -1500,7 +1494,6 @@ export interface BatchResponse { | "parseJsonConfigFileContent" | "printNode" | "readConfigFile" - | "rebaseSnapshot" | "release" | "releaseModuleResolver" | "releaseSourceFile" @@ -1579,7 +1572,11 @@ export interface FileNotifications { * for a request that creates a snapshot. */ export interface RequestFileSystem { - kind: "full" | "layer"; + kind: "full" | "layer" | "snapshot"; + /** Snapshot identifies the retained snapshot supplying request entries for KindSnapshot. */ + snapshot?: number | undefined; + /** FileSystem is an optional layer or full filesystem applied on top of KindSnapshot. */ + fileSystem?: RequestFileSystem | undefined; /** Files maps file names to their complete contents. */ files: Record; /** diff --git a/packages/typescript/src/api/sync/api.ts b/packages/typescript/src/api/sync/api.ts index 99a2bbdb0b3d0..e271dafabaf7b 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -949,7 +949,6 @@ export class API implements FormatDiagnosticsHo owner.sourceFileCache.releaseSnapshot(snapshot.id); }, owner.createSnapshotUpdater(() => snapshot), - owner.createSnapshotRebaser(() => snapshot), undefined, ); owner.activeSnapshots.set(snapshot.id, snapshot); @@ -973,7 +972,6 @@ export class API implements FormatDiagnosticsHo owner.sourceFileCache.releaseSnapshot(snapshot.id); }, owner.createSnapshotUpdater(() => snapshot), - owner.createSnapshotRebaser(() => snapshot), undefined, ); owner.activeSnapshots.set(snapshot.id, snapshot); @@ -1015,7 +1013,6 @@ export class API implements FormatDiagnosticsHo owner.sourceFileCache.releaseSnapshot(snapshot.id); }, owner.createSnapshotUpdater(() => snapshot), - owner.createSnapshotRebaser(() => snapshot), baseSnapshot, ); owner.activeSnapshots.set(snapshot.id, snapshot); @@ -1045,7 +1042,6 @@ export class API implements FormatDiagnosticsHo owner.sourceFileCache.releaseSnapshot(snapshot.id); }, owner.createSnapshotUpdater(() => snapshot), - owner.createSnapshotRebaser(() => snapshot), baseSnapshot, ); owner.activeSnapshots.set(snapshot.id, snapshot); @@ -1054,77 +1050,15 @@ export class API implements FormatDiagnosticsHo ); } - private get rebaseSnapshot(): { - (sourceSnapshot: Snapshot, newSnapshot: Snapshot, params?: CreateSnapshotParams): Snapshot; - gen(sourceSnapshot: Snapshot, newSnapshot: Snapshot, params?: CreateSnapshotParams): Generator; - } { - const owner = this; - return cacheGeneratorMethod( - owner, - "rebaseSnapshot", - function (sourceSnapshot: Snapshot, newSnapshot: Snapshot, params?: CreateSnapshotParams): Snapshot { - owner.ensureInitialized(); - if (owner.activeSnapshots.get(sourceSnapshot.id) !== sourceSnapshot || sourceSnapshot.isDisposed()) { - throw new Error("Cannot rebase an inactive snapshot"); - } - if (owner.activeSnapshots.get(newSnapshot.id) !== newSnapshot || newSnapshot.isDisposed()) { - throw new Error("Cannot rebase onto an inactive snapshot"); - } - const data = owner.client.apiRequest("rebaseSnapshot", { - snapshot: sourceSnapshot.id, - newSnapshot: newSnapshot.id, - changes: owner.prepareCreateSnapshotParams(params), - }); - owner.sourceFileCache.retainForSnapshot(data.snapshot, newSnapshot.id, data.changes); - const snapshot = new Snapshot( - data, - owner.toPath!, - owner, - () => { - owner.activeSnapshots.delete(snapshot.id); - owner.sourceFileCache.releaseSnapshot(snapshot.id); - }, - owner.createSnapshotUpdater(() => snapshot), - owner.createSnapshotRebaser(() => snapshot), - newSnapshot, - ); - owner.activeSnapshots.set(snapshot.id, snapshot); - return snapshot; - }, - function* (sourceSnapshot: Snapshot, newSnapshot: Snapshot, params?: CreateSnapshotParams): Generator { - yield* owner.ensureInitialized.gen(); - if (owner.activeSnapshots.get(sourceSnapshot.id) !== sourceSnapshot || sourceSnapshot.isDisposed()) { - throw new Error("Cannot rebase an inactive snapshot"); - } - if (owner.activeSnapshots.get(newSnapshot.id) !== newSnapshot || newSnapshot.isDisposed()) { - throw new Error("Cannot rebase onto an inactive snapshot"); - } - const data = yield* apiRequest("rebaseSnapshot", { - snapshot: sourceSnapshot.id, - newSnapshot: newSnapshot.id, - changes: owner.prepareCreateSnapshotParams(params), - }); - owner.sourceFileCache.retainForSnapshot(data.snapshot, newSnapshot.id, data.changes); - const snapshot = new Snapshot( - data, - owner.toPath!, - owner, - () => { - owner.activeSnapshots.delete(snapshot.id); - owner.sourceFileCache.releaseSnapshot(snapshot.id); - }, - owner.createSnapshotUpdater(() => snapshot), - owner.createSnapshotRebaser(() => snapshot), - newSnapshot, - ); - owner.activeSnapshots.set(snapshot.id, snapshot); - return snapshot; - }, - ); - } - private prepareCreateSnapshotParams(params: CreateSnapshotParams | undefined): ProtocolCreateSnapshotParams { params ??= {}; + if (params.fileSystem?.kind === "snapshot") { + const sourceId = params.fileSystem.snapshot; + const source = sourceId === undefined ? undefined : this.activeSnapshots.get(sourceId); + if (!source || source.isDisposed()) { + throw new Error("Cannot use an inactive snapshot filesystem"); + } + } const prepareOptions = (options: CreateProgramOptions | undefined): ProtocolCreateProgramOptions | undefined => { if (!options) return undefined; const { moduleResolver, projectReferences, ...rest } = options; @@ -1160,15 +1094,6 @@ export class API implements FormatDiagnosticsHo return update; } - private createSnapshotRebaser(getSnapshot: () => Snapshot): SnapshotRebaser { - const rebase = ((newSnapshot: Snapshot, params?: CreateSnapshotParams) => this.rebaseSnapshot(getSnapshot(), newSnapshot, params)) as SnapshotRebaser; - const owner = this; - rebase.gen = function* (newSnapshot: Snapshot, params?: CreateSnapshotParams) { - return yield* owner.rebaseSnapshot.gen(getSnapshot(), newSnapshot, params); - }; - return rebase; - } - /** * Returns the language server's current canonical snapshot after atomically * adopting any supplied API-driven changes. Only available on LSP-connected APIs. @@ -1215,7 +1140,6 @@ export class API implements FormatDiagnosticsHo owner.sourceFileCache.releaseSnapshot(snapshot.id); }, owner.createSnapshotUpdater(() => snapshot), - owner.createSnapshotRebaser(() => snapshot), baseSnapshot, ); owner.activeSnapshots.set(snapshot.id, snapshot); @@ -1250,7 +1174,6 @@ export class API implements FormatDiagnosticsHo owner.sourceFileCache.releaseSnapshot(snapshot.id); }, owner.createSnapshotUpdater(() => snapshot), - owner.createSnapshotRebaser(() => snapshot), baseSnapshot, ); owner.activeSnapshots.set(snapshot.id, snapshot); @@ -1697,7 +1620,6 @@ export class DebugHandlers { } type SnapshotUpdater = ((params: CreateSnapshotParams) => Snapshot) & { gen(params: CreateSnapshotParams): Generator; }; -type SnapshotRebaser = ((newSnapshot: Snapshot, params?: CreateSnapshotParams) => Snapshot) & { gen(newSnapshot: Snapshot, params?: CreateSnapshotParams): Generator; }; export interface SnapshotOperation { readonly createdPrograms?: readonly Program[] | undefined; @@ -1769,19 +1691,17 @@ export class Snapshot { private snapshotRegistry: SnapshotObjectRegistry; private projectDataMap: Map; private updateSnapshot: SnapshotUpdater; - private rebaseSnapshot: SnapshotRebaser; private get client(): Client { return this.api.client; } - constructor(data: CreateSnapshotResponse, toPath: (fileName: string, basePath?: string) => PathKey, api: API, onDispose: () => void, updateSnapshot: SnapshotUpdater, rebaseSnapshot: SnapshotRebaser, baseSnapshot?: Snapshot) { + constructor(data: CreateSnapshotResponse, toPath: (fileName: string, basePath?: string) => PathKey, api: API, onDispose: () => void, updateSnapshot: SnapshotUpdater, baseSnapshot?: Snapshot) { this.id = data.snapshot; this.api = api; this.toPath = toPath; this.onDispose = onDispose; this.updateSnapshot = updateSnapshot; - this.rebaseSnapshot = rebaseSnapshot; this.projectMap = new Map(); const projectDataMap = new Map(baseSnapshot?.projectDataMap); for (const projectId of data.changes?.removedProjects ?? []) { @@ -1824,6 +1744,12 @@ export class Snapshot { return this.getProject(projectId)?.program; } + /** + * Creates a snapshot using this snapshot's projects and incremental state. + * Use `createFileSystemFromSnapshot(source)` from the filesystem API as `fileSystem` + * to overlay another snapshot's retained request entries without replaying + * intermediate updates. Changes and program reuse are relative to this snapshot. + */ get update(): { (params: SnapshotOperationParams): SnapshotForOperationResults; (params: CreateSnapshotParams): Snapshot; @@ -1846,37 +1772,6 @@ export class Snapshot { return cacheGeneratorMethod(owner, "update", update, gen); } - /** - * Creates a new snapshot with this snapshot's memory filesystem overlaid on - * `newSnapshot`, retaining the target's projects and language server state. - * Source entries take precedence; a full source filesystem remains total. - * Optional changes are applied after rebasing, including auto-import preparation. - * `newSnapshot` is the sole parent for program reuse and reported changes; - * files absent from it are new files even if already present in this snapshot. - * Neither input snapshot is modified, and retained file contents are not resent. - */ - get rebase(): { - (newSnapshot: Snapshot, params: SnapshotOperationParams): SnapshotForOperationResults; - (newSnapshot: Snapshot, params?: CreateSnapshotParams): Snapshot; - gen(newSnapshot: Snapshot, params: SnapshotOperationParams): Generator, ProtocolResponse["result"]>; - gen(newSnapshot: Snapshot, params?: CreateSnapshotParams): Generator; - } { - const owner = this; - function rebase(newSnapshot: Snapshot, params: SnapshotOperationParams): SnapshotForOperationResults; - function rebase(newSnapshot: Snapshot, params?: CreateSnapshotParams): Snapshot; - function rebase(newSnapshot: Snapshot, params?: CreateSnapshotParams): Snapshot { - owner.ensureNotDisposed(); - return owner.rebaseSnapshot(newSnapshot, params); - } - function gen(newSnapshot: Snapshot, params: SnapshotOperationParams): Generator, ProtocolResponse["result"]>; - function gen(newSnapshot: Snapshot, params?: CreateSnapshotParams): Generator; - function* gen(newSnapshot: Snapshot, params?: CreateSnapshotParams): Generator { - owner.ensureNotDisposed(); - return yield* owner.rebaseSnapshot.gen(newSnapshot, params); - } - return cacheGeneratorMethod(owner, "rebase", rebase, gen); - } - /** * Gets the default project for a given file from the configured projects and * inferred project already loaded in the snapshot. Synthetic projects are not diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index 00300e2cac92d..aa069991f5661 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -115,6 +115,7 @@ import { } from "@typescript/typescript/async"; // @sync: } from "@typescript/typescript/sync"; import { createFileSystem, + createFileSystemFromSnapshot, createFileSystemLayer, createFileSystemWithLib, type FileSystemCallbacks, @@ -5551,9 +5552,55 @@ describe("updateSnapshot file systems", { concurrency }, () => { assert.deepEqual(callbackCalls, []); }); - for (const selfRebase of [false, true]) { - test(`Snapshot.${selfRebase ? "rebase" : "update"} layers filesystem edits and removals`, async () => { - const update = (base: Snapshot, changes: CreateSnapshotParams) => selfRebase ? base.rebase(base, changes) : base.update(changes); + test("createFileSystemFromSnapshot supports creating snapshots with additional layer and full filesystems", async () => { + await using api = spawnAPI({}); + await using source = await api.createSnapshot({ + fileSystem: createFileSystemLayer([ + ["/source.ts", "source"], + ["/removed/nested.ts", "removed"], + ]), + }); + const descriptor = createFileSystemFromSnapshot(source); + assert.equal(descriptor.kind, "snapshot"); + assert.equal(descriptor.snapshot, source.id); + assert.deepEqual(descriptor.files, {}); + await using cloned = await api.createSnapshot({ + fileSystem: descriptor, + createPrograms: [{ rootFiles: ["/source.ts"], compilerOptions: { noLib: true } }], + }); + assert.notEqual(cloned.id, source.id); + assert.equal((await cloned.operation.createdPrograms[0].getSourceFile("/source.ts"))?.text, "source"); + assert.throws(() => createFileSystemFromSnapshot(source, descriptor), /layer or full/); + for (const full of [false, true]) { + const extra = (full ? createFileSystem : createFileSystemLayer)([ + ["/extra.ts", "extra"], + ], { + directories: { "/listed": { files: ["entry.ts"], directories: [] } }, + symlinks: { "/alias.ts": { target: "/extra.ts" } }, + removedPaths: ["/removed"], + }); + const fileSystem = createFileSystemFromSnapshot(source, extra); + assert.strictEqual(fileSystem.fileSystem, extra); + await using snapshot = await api.createSnapshot({ + fileSystem, + createPrograms: [{ rootFiles: ["/extra.ts", "/alias.ts", "/source.ts", "/removed/nested.ts"], compilerOptions: { noLib: true } }], + }); + const program = snapshot.operation.createdPrograms[0]; + assert.equal((await program.getSourceFile("/extra.ts"))?.text, "extra"); + assert.equal((await program.getSourceFile("/alias.ts"))?.text, "extra"); + assert.equal((await program.getSourceFile("/source.ts"))?.text, full ? undefined : "source"); + assert.equal(await program.getSourceFile("/removed/nested.ts"), undefined); + } + }); + + for (const snapshotFileSystem of [false, true]) { + test(`Snapshot.update layers filesystem edits and removals with ${snapshotFileSystem ? "a snapshot" : "a layer"} filesystem`, async () => { + const update = (base: Snapshot, changes: CreateSnapshotParams) => + base.update( + snapshotFileSystem + ? { ...changes, fileSystem: createFileSystemFromSnapshot(base, changes.fileSystem) } + : changes, + ); await using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), }); @@ -5610,7 +5657,7 @@ describe("updateSnapshot file systems", { concurrency }, () => { }); } - test("Snapshot.rebase compares against the target's cached contents", async () => { + test("Snapshot.update with a snapshot filesystem compares against the target's cached contents", async () => { const { api: disposableAPI, fs } = spawnAPIWithFS({ "/index.ts": "export const value = 1;" }); await using api = disposableAPI; await using target = await api.createSnapshot({ @@ -5623,13 +5670,13 @@ describe("updateSnapshot file systems", { concurrency }, () => { await using source = await api.createSnapshot({ fileSystem: createFileSystemLayer([["/index.ts", "export const value = 2;"]]), }); - await using rebased = await source.rebase(target, { ensurePrograms: true }); + await using rebased = await target.update({ fileSystem: createFileSystemFromSnapshot(source), ensurePrograms: true }); assert.equal((await rebased.getProgram(programId)!.getSourceFile("/index.ts"))!.text, "export const value = 2;"); assert.strictEqual(await target.getProgram(programId)!.getSourceFile("/index.ts"), originalFile); }); for (const fullReplacement of [false, true]) { - test(`Snapshot.rebase removes cached files with ${fullReplacement ? "a full replacement" : "matching tombstones"}`, async () => { + test(`Snapshot.update with a snapshot filesystem removes cached files with ${fullReplacement ? "a full replacement" : "matching tombstones"}`, async () => { await using api = spawnAPI({}); const files = { "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: ["index.ts", "removed/keep.ts"] }), @@ -5646,20 +5693,20 @@ describe("updateSnapshot file systems", { concurrency }, () => { }); const originalFile = await target.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/removed/keep.ts"); assert.ok(originalFile); - await using self = await target.rebase(target, { ensurePrograms: true }); + await using self = await target.update({ fileSystem: createFileSystemFromSnapshot(target), ensurePrograms: true }); assert.strictEqual(await self.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/removed/keep.ts"), originalFile); await using source = await api.createSnapshot({ fileSystem: fullReplacement ? createFileSystem(Object.entries(files)) : createFileSystemLayer([], { removedPaths: ["/removed"] }), }); - await using rebased = await source.rebase(target, { ensurePrograms: true }); + await using rebased = await target.update({ fileSystem: createFileSystemFromSnapshot(source), ensurePrograms: true }); assert.equal(await rebased.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/removed/keep.ts"), undefined); assert.strictEqual(await target.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/removed/keep.ts"), originalFile); }); } - test("Snapshot.rebase preserves explicit full invalidation", async () => { + test("Snapshot.update with a snapshot filesystem preserves explicit full invalidation", async () => { const { api: disposableAPI, fs } = spawnAPIWithFS({ "/index.ts": "export const value = 1;" }); await using api = disposableAPI; await using original = await api.createSnapshot({ @@ -5672,13 +5719,13 @@ describe("updateSnapshot file systems", { concurrency }, () => { await using updated = await original.update(changes); assert.equal((await updated.getProgram(programId)!.getSourceFile("/index.ts"))!.text, "export const value = 2;"); for (const base of [original, target]) { - await using rebased = await original.rebase(base, changes); + await using rebased = await base.update({ ...changes, fileSystem: createFileSystemFromSnapshot(original) }); assert.equal((await rebased.getProgram(programId)!.getSourceFile("/index.ts"))!.text, "export const value = 2;"); } assert.equal((await original.getProgram(programId)!.getSourceFile("/index.ts"))!.text, "export const value = 1;"); }); - test("Snapshot.rebase refreshes dependency auto-imports", async () => { + test("Snapshot.update with a snapshot filesystem refreshes dependency auto-imports", async () => { const fileName = "/home/project/index.ts"; const dependency = "/home/project/node_modules/my-pkg/index.d.ts"; await using api = spawnAPI({ @@ -5700,14 +5747,14 @@ describe("updateSnapshot file systems", { concurrency }, () => { }); assert.ok((await completionNames(source)).includes("packageNew")); for (const base of [target, source]) { - await using rebased = await source.rebase(base, { prepareAutoImports: fileName }); + await using rebased = await base.update({ fileSystem: createFileSystemFromSnapshot(source), prepareAutoImports: fileName }); const names = await completionNames(rebased); assert.ok(names.includes("packageNew")); assert.ok(!names.includes("packageOld")); } }); - test("Snapshot.rebase applies memory filesystems over the target snapshot", async () => { + test("Snapshot.update applies snapshot filesystems over the target snapshot", async () => { const { api: disposableAPI, fs } = spawnAPIWithFS({ "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, include: ["src/**/*.ts"] }), "/src/index.ts": "export const value = 1;", @@ -5736,11 +5783,11 @@ describe("updateSnapshot file systems", { concurrency }, () => { ["/src/removed/nested.ts", "export const removed = true;"], ]), }); - await using rebased = await moreExtras.rebase(newOriginal, { + await using rebased = await newOriginal.update({ prepareAutoImports: "/src/index.ts", openFiles: ["/src/index.ts"], createPrograms: [{ rootFiles: ["/src/extra.ts"], compilerOptions: { noLib: true } }], - fileSystem: createFileSystemLayer([["/src/second.ts", "export const second = false;"]]), + fileSystem: createFileSystemFromSnapshot(moreExtras, createFileSystemLayer([["/src/second.ts", "export const second = false;"]])), }); assert.notEqual(rebased.id, moreExtras.id); assert.notEqual(rebased.id, newOriginal.id); @@ -5759,11 +5806,12 @@ describe("updateSnapshot file systems", { concurrency }, () => { await moreExtras.dispose(); await newOriginal.dispose(); assert.equal((await program.getSourceFile("/src/extra.ts"))!.text, "export const extra = true;"); - await using rebasedAgain = await rebased.rebase(original, { ensurePrograms: true }); + await using rebasedAgain = await original.update({ fileSystem: createFileSystemFromSnapshot(rebased), ensurePrograms: true }); assert.equal((await rebasedAgain.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/src/target.ts"))!.text, "export const target = true;"); - assert.throws(() => moreExtras.rebase(original), /disposed/); - // @sync: assert.throws(() => rebased.rebase(newOriginal), /inactive snapshot/); - await assert.rejects(rebased.rebase(newOriginal), /inactive snapshot/); // @sync-skip + assert.throws(() => createFileSystemFromSnapshot(moreExtras), /disposed/); + assert.throws(() => newOriginal.update({ fileSystem: createFileSystemFromSnapshot(rebased) }), /disposed/); + // @sync: assert.throws(() => original.update({ fileSystem: { kind: "snapshot", snapshot: moreExtras.id, files: {} } }), /inactive snapshot/); + await assert.rejects(original.update({ fileSystem: { kind: "snapshot", snapshot: moreExtras.id, files: {} } }), /inactive snapshot/); // @sync-skip }); test("eager snapshot disposal does not retain filesystem history", async () => { diff --git a/packages/typescript/test/sync/api-generators.test.ts b/packages/typescript/test/sync/api-generators.test.ts index 45d8dbf8225f7..bae80333e1ba3 100644 --- a/packages/typescript/test/sync/api-generators.test.ts +++ b/packages/typescript/test/sync/api-generators.test.ts @@ -22,6 +22,7 @@ import { SyntaxKind, } from "@typescript/typescript/ast"; import { cloneNode } from "@typescript/typescript/ast/factory"; +import { createFileSystemFromSnapshot } from "@typescript/typescript/fs"; import { toRootedFilePath } from "@typescript/typescript/path"; import type { APIRequest, @@ -165,7 +166,6 @@ const privateGeneratorGetters = new Set([ "API.ensureInitialized", "API.fetchDeclarationSymbol", "API.initializeWorker", - "API.rebaseSnapshot", "API.updateSnapshot", "Checker.getIntrinsicType", "Checker.getWellKnownSignatures", @@ -1809,12 +1809,11 @@ describe("API - generator batching", { concurrency: areTestsFiltered() }, () => } as const; const generatorExtras = snapshotGeneratorAPI.batch(generatorUpdated.update.gen(changes))[0]; const syncExtras = syncUpdated.update(changes); - const rebaseChanges = { prepareAutoImports: "/src/syntax.ts", openFiles: ["/src/syntax.ts"] } as const; - const generatorRebased = snapshotGeneratorAPI.batch(generatorExtras.rebase.gen(generatorBase, rebaseChanges))[0]; - const syncRebased = syncExtras.rebase(syncBase, rebaseChanges); - assertSnapshotsEquivalent(generatorRebased, syncRebased, "Snapshot.rebase"); - assert.equal(generatorRebased.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/src/syntax.ts")!.text, "export const fixed = true;"); - exercisedMethods.add("Snapshot.rebase"); + const snapshotChanges = { prepareAutoImports: "/src/syntax.ts", openFiles: ["/src/syntax.ts"] } as const; + const generatorOverlay = snapshotGeneratorAPI.batch(generatorBase.update.gen({ ...snapshotChanges, fileSystem: createFileSystemFromSnapshot(generatorExtras) }))[0]; + const syncOverlay = syncBase.update({ ...snapshotChanges, fileSystem: createFileSystemFromSnapshot(syncExtras) }); + assertSnapshotsEquivalent(generatorOverlay, syncOverlay, "Snapshot.update with a snapshot filesystem"); + assert.equal(generatorOverlay.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/src/syntax.ts")!.text, "export const fixed = true;"); } finally { snapshotGeneratorAPI.close(); diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index a93e8fe5d21a5..6a474c2febd76 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -70,6 +70,7 @@ import { import { visitEachChild } from "@typescript/typescript/ast/visitor"; import { createFileSystem, + createFileSystemFromSnapshot, createFileSystemLayer, createFileSystemWithLib, type FileSystemCallbacks, @@ -5352,9 +5353,55 @@ describe("updateSnapshot file systems", { concurrency }, () => { assert.deepEqual(callbackCalls, []); }); - for (const selfRebase of [false, true]) { - test(`Snapshot.${selfRebase ? "rebase" : "update"} layers filesystem edits and removals`, () => { - const update = (base: Snapshot, changes: CreateSnapshotParams) => selfRebase ? base.rebase(base, changes) : base.update(changes); + test("createFileSystemFromSnapshot supports creating snapshots with additional layer and full filesystems", () => { + using api = spawnAPI({}); + using source = api.createSnapshot({ + fileSystem: createFileSystemLayer([ + ["/source.ts", "source"], + ["/removed/nested.ts", "removed"], + ]), + }); + const descriptor = createFileSystemFromSnapshot(source); + assert.equal(descriptor.kind, "snapshot"); + assert.equal(descriptor.snapshot, source.id); + assert.deepEqual(descriptor.files, {}); + using cloned = api.createSnapshot({ + fileSystem: descriptor, + createPrograms: [{ rootFiles: ["/source.ts"], compilerOptions: { noLib: true } }], + }); + assert.notEqual(cloned.id, source.id); + assert.equal((cloned.operation.createdPrograms[0].getSourceFile("/source.ts"))?.text, "source"); + assert.throws(() => createFileSystemFromSnapshot(source, descriptor), /layer or full/); + for (const full of [false, true]) { + const extra = (full ? createFileSystem : createFileSystemLayer)([ + ["/extra.ts", "extra"], + ], { + directories: { "/listed": { files: ["entry.ts"], directories: [] } }, + symlinks: { "/alias.ts": { target: "/extra.ts" } }, + removedPaths: ["/removed"], + }); + const fileSystem = createFileSystemFromSnapshot(source, extra); + assert.strictEqual(fileSystem.fileSystem, extra); + using snapshot = api.createSnapshot({ + fileSystem, + createPrograms: [{ rootFiles: ["/extra.ts", "/alias.ts", "/source.ts", "/removed/nested.ts"], compilerOptions: { noLib: true } }], + }); + const program = snapshot.operation.createdPrograms[0]; + assert.equal((program.getSourceFile("/extra.ts"))?.text, "extra"); + assert.equal((program.getSourceFile("/alias.ts"))?.text, "extra"); + assert.equal((program.getSourceFile("/source.ts"))?.text, full ? undefined : "source"); + assert.equal(program.getSourceFile("/removed/nested.ts"), undefined); + } + }); + + for (const snapshotFileSystem of [false, true]) { + test(`Snapshot.update layers filesystem edits and removals with ${snapshotFileSystem ? "a snapshot" : "a layer"} filesystem`, () => { + const update = (base: Snapshot, changes: CreateSnapshotParams) => + base.update( + snapshotFileSystem + ? { ...changes, fileSystem: createFileSystemFromSnapshot(base, changes.fileSystem) } + : changes, + ); using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), }); @@ -5411,7 +5458,7 @@ describe("updateSnapshot file systems", { concurrency }, () => { }); } - test("Snapshot.rebase compares against the target's cached contents", () => { + test("Snapshot.update with a snapshot filesystem compares against the target's cached contents", () => { const { api: disposableAPI, fs } = spawnAPIWithFS({ "/index.ts": "export const value = 1;" }); using api = disposableAPI; using target = api.createSnapshot({ @@ -5424,13 +5471,13 @@ describe("updateSnapshot file systems", { concurrency }, () => { using source = api.createSnapshot({ fileSystem: createFileSystemLayer([["/index.ts", "export const value = 2;"]]), }); - using rebased = source.rebase(target, { ensurePrograms: true }); + using rebased = target.update({ fileSystem: createFileSystemFromSnapshot(source), ensurePrograms: true }); assert.equal((rebased.getProgram(programId)!.getSourceFile("/index.ts"))!.text, "export const value = 2;"); assert.strictEqual(target.getProgram(programId)!.getSourceFile("/index.ts"), originalFile); }); for (const fullReplacement of [false, true]) { - test(`Snapshot.rebase removes cached files with ${fullReplacement ? "a full replacement" : "matching tombstones"}`, () => { + test(`Snapshot.update with a snapshot filesystem removes cached files with ${fullReplacement ? "a full replacement" : "matching tombstones"}`, () => { using api = spawnAPI({}); const files = { "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: ["index.ts", "removed/keep.ts"] }), @@ -5447,20 +5494,20 @@ describe("updateSnapshot file systems", { concurrency }, () => { }); const originalFile = target.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/removed/keep.ts"); assert.ok(originalFile); - using self = target.rebase(target, { ensurePrograms: true }); + using self = target.update({ fileSystem: createFileSystemFromSnapshot(target), ensurePrograms: true }); assert.strictEqual(self.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/removed/keep.ts"), originalFile); using source = api.createSnapshot({ fileSystem: fullReplacement ? createFileSystem(Object.entries(files)) : createFileSystemLayer([], { removedPaths: ["/removed"] }), }); - using rebased = source.rebase(target, { ensurePrograms: true }); + using rebased = target.update({ fileSystem: createFileSystemFromSnapshot(source), ensurePrograms: true }); assert.equal(rebased.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/removed/keep.ts"), undefined); assert.strictEqual(target.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/removed/keep.ts"), originalFile); }); } - test("Snapshot.rebase preserves explicit full invalidation", () => { + test("Snapshot.update with a snapshot filesystem preserves explicit full invalidation", () => { const { api: disposableAPI, fs } = spawnAPIWithFS({ "/index.ts": "export const value = 1;" }); using api = disposableAPI; using original = api.createSnapshot({ @@ -5473,13 +5520,13 @@ describe("updateSnapshot file systems", { concurrency }, () => { using updated = original.update(changes); assert.equal((updated.getProgram(programId)!.getSourceFile("/index.ts"))!.text, "export const value = 2;"); for (const base of [original, target]) { - using rebased = original.rebase(base, changes); + using rebased = base.update({ ...changes, fileSystem: createFileSystemFromSnapshot(original) }); assert.equal((rebased.getProgram(programId)!.getSourceFile("/index.ts"))!.text, "export const value = 2;"); } assert.equal((original.getProgram(programId)!.getSourceFile("/index.ts"))!.text, "export const value = 1;"); }); - test("Snapshot.rebase refreshes dependency auto-imports", () => { + test("Snapshot.update with a snapshot filesystem refreshes dependency auto-imports", () => { const fileName = "/home/project/index.ts"; const dependency = "/home/project/node_modules/my-pkg/index.d.ts"; using api = spawnAPI({ @@ -5501,14 +5548,14 @@ describe("updateSnapshot file systems", { concurrency }, () => { }); assert.ok((completionNames(source)).includes("packageNew")); for (const base of [target, source]) { - using rebased = source.rebase(base, { prepareAutoImports: fileName }); + using rebased = base.update({ fileSystem: createFileSystemFromSnapshot(source), prepareAutoImports: fileName }); const names = completionNames(rebased); assert.ok(names.includes("packageNew")); assert.ok(!names.includes("packageOld")); } }); - test("Snapshot.rebase applies memory filesystems over the target snapshot", () => { + test("Snapshot.update applies snapshot filesystems over the target snapshot", () => { const { api: disposableAPI, fs } = spawnAPIWithFS({ "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, include: ["src/**/*.ts"] }), "/src/index.ts": "export const value = 1;", @@ -5537,11 +5584,11 @@ describe("updateSnapshot file systems", { concurrency }, () => { ["/src/removed/nested.ts", "export const removed = true;"], ]), }); - using rebased = moreExtras.rebase(newOriginal, { + using rebased = newOriginal.update({ prepareAutoImports: "/src/index.ts", openFiles: ["/src/index.ts"], createPrograms: [{ rootFiles: ["/src/extra.ts"], compilerOptions: { noLib: true } }], - fileSystem: createFileSystemLayer([["/src/second.ts", "export const second = false;"]]), + fileSystem: createFileSystemFromSnapshot(moreExtras, createFileSystemLayer([["/src/second.ts", "export const second = false;"]])), }); assert.notEqual(rebased.id, moreExtras.id); assert.notEqual(rebased.id, newOriginal.id); @@ -5560,10 +5607,11 @@ describe("updateSnapshot file systems", { concurrency }, () => { moreExtras.dispose(); newOriginal.dispose(); assert.equal((program.getSourceFile("/src/extra.ts"))!.text, "export const extra = true;"); - using rebasedAgain = rebased.rebase(original, { ensurePrograms: true }); + using rebasedAgain = original.update({ fileSystem: createFileSystemFromSnapshot(rebased), ensurePrograms: true }); assert.equal((rebasedAgain.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/src/target.ts"))!.text, "export const target = true;"); - assert.throws(() => moreExtras.rebase(original), /disposed/); - assert.throws(() => rebased.rebase(newOriginal), /inactive snapshot/); + assert.throws(() => createFileSystemFromSnapshot(moreExtras), /disposed/); + assert.throws(() => newOriginal.update({ fileSystem: createFileSystemFromSnapshot(rebased) }), /disposed/); + assert.throws(() => original.update({ fileSystem: { kind: "snapshot", snapshot: moreExtras.id, files: {} } }), /inactive snapshot/); }); test("eager snapshot disposal does not retain filesystem history", () => { diff --git a/tsc/internal/api/proto.go b/tsc/internal/api/proto.go index 45bda05381433..47b04cb15faec 100644 --- a/tsc/internal/api/proto.go +++ b/tsc/internal/api/proto.go @@ -78,7 +78,6 @@ const ( MethodInitialize Method = "initialize" MethodCreateSnapshot Method = "createSnapshot" MethodUpdateSnapshot Method = "updateSnapshot" - MethodRebaseSnapshot Method = "rebaseSnapshot" MethodGetCurrentLanguageServerSnapshot Method = "getCurrentLanguageServerSnapshot" MethodCreateBuildOrchestrator Method = "createBuildOrchestrator" MethodDisposeBuildOrchestrator Method = "disposeBuildOrchestrator" @@ -450,7 +449,9 @@ type CreateSnapshotParams struct { FileNotifications *FileNotifications `json:"fileNotifications,omitempty"` // FileSystem supplies file contents and directory listings for the new snapshot. // A full filesystem is canonical and total. A filesystem layer is checked - // before falling back to the base snapshot or host filesystem. + // before falling back to the base snapshot or host filesystem. A snapshot + // filesystem overlays retained request entries from another active snapshot, + // then applies an optional layer or full filesystem. FileSystem *requestfilesystem.RequestFileSystem `json:"fileSystem,omitempty"` } @@ -474,12 +475,6 @@ type UpdateSnapshotParams struct { Changes *CreateSnapshotParams `json:"changes,omitempty"` } -type RebaseSnapshotParams struct { - Snapshot SnapshotID `json:"snapshot"` - NewSnapshot SnapshotID `json:"newSnapshot"` - Changes *CreateSnapshotParams `json:"changes,omitempty"` -} - type GetCurrentLanguageServerSnapshotParams struct { BaseSnapshot SnapshotID `json:"baseSnapshot,omitempty"` Changes *LanguageServerSnapshotChanges `json:"changes,omitempty"` @@ -610,7 +605,6 @@ var unmarshalers = map[Method]func([]byte) (any, error){ MethodInitialize: noParams, MethodCreateSnapshot: unmarshallerFor[CreateSnapshotParams], MethodUpdateSnapshot: unmarshallerFor[UpdateSnapshotParams], - MethodRebaseSnapshot: unmarshallerFor[RebaseSnapshotParams], MethodGetCurrentLanguageServerSnapshot: unmarshallerFor[GetCurrentLanguageServerSnapshotParams], MethodCreateBuildOrchestrator: unmarshallerFor[CreateBuildOrchestratorParams], MethodDisposeBuildOrchestrator: unmarshallerFor[DisposeBuildOrchestratorParams], diff --git a/tsc/internal/api/requestfilesystem/requestfilesystem.go b/tsc/internal/api/requestfilesystem/requestfilesystem.go index 871c282d77568..6990c96e5ff76 100644 --- a/tsc/internal/api/requestfilesystem/requestfilesystem.go +++ b/tsc/internal/api/requestfilesystem/requestfilesystem.go @@ -19,6 +19,8 @@ const ( KindFull Kind = "full" // KindLayer checks the supplied filesystem before falling back to the host. KindLayer Kind = "layer" + // KindSnapshot overlays a retained snapshot's request entries before applying an optional filesystem. + KindSnapshot Kind = "snapshot" ) // RequestDirectoryEntries is a cached directory listing. Entry names are @@ -42,6 +44,10 @@ type RequestSymlink struct { // for a request that creates a snapshot. type RequestFileSystem struct { Kind Kind `json:"kind"` + // Snapshot identifies the retained snapshot supplying request entries for KindSnapshot. + Snapshot uint64 `json:"snapshot,omitempty"` + // FileSystem is an optional layer or full filesystem applied on top of KindSnapshot. + FileSystem *RequestFileSystem `json:"fileSystem,omitempty"` // Files maps file names to their complete contents. Files map[string]string `json:"files" nonnil:"true"` // Directories maps directory names to complete listing results. Directory @@ -54,6 +60,11 @@ type RequestFileSystem struct { RemovedPaths []string `json:"removedPaths,omitempty"` } +// ReplacesFileSystem reports whether the request includes an explicit full replacement. +func (r *RequestFileSystem) ReplacesFileSystem() bool { + return r != nil && (r.Kind == KindFull || r.Kind == KindSnapshot && r.FileSystem != nil && r.FileSystem.Kind == KindFull) +} + // requestFileSystem is either a full filesystem or a layer over the session // host filesystem. Its base is always the host, which may be a callback filesystem; // inherited request entries are compacted into paths. diff --git a/tsc/internal/api/session.go b/tsc/internal/api/session.go index b0b9373ca14f8..f90ce9a02e0cb 100644 --- a/tsc/internal/api/session.go +++ b/tsc/internal/api/session.go @@ -905,8 +905,6 @@ func (s *Session) HandleRequest(ctx context.Context, method string, params json. return s.handleCreateSnapshot(ctx, parsed.(*CreateSnapshotParams)) case string(MethodUpdateSnapshot): return s.handleUpdateSnapshot(ctx, parsed.(*UpdateSnapshotParams)) - case string(MethodRebaseSnapshot): - return s.handleRebaseSnapshot(ctx, parsed.(*RebaseSnapshotParams)) case string(MethodGetCurrentLanguageServerSnapshot): return s.handleGetCurrentLanguageServerSnapshot(ctx, parsed.(*GetCurrentLanguageServerSnapshotParams)) case string(MethodCreateModuleResolver): @@ -1430,13 +1428,13 @@ func (s *Session) handleCreateSnapshot(ctx context.Context, params *CreateSnapsh fileChanges := s.toFileChangeSummary(params.FileNotifications) var snapshotFileSystem vfs.FS if params.FileSystem != nil { - fileSystem, fileSystemErr := requestfilesystem.NewForUpdate(params.FileSystem, s.FS(), s.GetCurrentDirectory(), &fileChanges) + fileSystem, fileSystemErr := s.createRequestFileSystem(params.FileSystem, s.FS(), s.FS(), &fileChanges) if fileSystemErr != nil { return nil, fmt.Errorf("%w: %w", ErrClientError, fileSystemErr) } snapshotFileSystem = fileSystem apiRequest.FileSystem = fileSystem - apiRequest.ReplaceFileSystem = params.FileSystem.Kind == requestfilesystem.KindFull + apiRequest.ReplaceFileSystem = params.FileSystem.ReplacesFileSystem() } root := s.snapshotHost.NewRootSnapshot() snapshot, err := s.snapshotHost.CloneSnapshot(ctx, root, fileChanges, apiRequest) @@ -1486,7 +1484,7 @@ func (s *Session) handleUpdateSnapshot(ctx context.Context, params *UpdateSnapsh if baseFileSystem == nil { baseFileSystem = s.FS() } - fileSystem, fileSystemErr := requestfilesystem.NewForUpdate(changes.FileSystem, baseFileSystem, s.GetCurrentDirectory(), &fileChanges) + fileSystem, fileSystemErr := s.createRequestFileSystem(changes.FileSystem, baseFileSystem, baseSD.snapshot.FS(), &fileChanges) if fileSystemErr != nil { return nil, fmt.Errorf("%w: %w", ErrClientError, fileSystemErr) } @@ -1494,7 +1492,7 @@ func (s *Session) handleUpdateSnapshot(ctx context.Context, params *UpdateSnapsh } if snapshotFileSystem != nil { apiRequest.FileSystem = snapshotFileSystem - apiRequest.ReplaceFileSystem = changes.FileSystem != nil && changes.FileSystem.Kind == requestfilesystem.KindFull + apiRequest.ReplaceFileSystem = changes.FileSystem.ReplacesFileSystem() } snapshot, err := s.snapshotHost.CloneSnapshot(ctx, baseSD.snapshot, fileChanges, apiRequest) if err != nil { @@ -1515,67 +1513,30 @@ func (s *Session) handleUpdateSnapshot(ctx context.Context, params *UpdateSnapsh return response, nil } -func (s *Session) handleRebaseSnapshot(ctx context.Context, params *RebaseSnapshotParams) (*CreateSnapshotResponse, error) { - source, err := s.retainSnapshotData(params.Snapshot) - if err != nil { - return nil, err - } - defer func() { _ = s.releaseSnapshot(params.Snapshot) }() - target, err := s.retainSnapshotData(params.NewSnapshot) - if err != nil { - return nil, err - } - defer func() { _ = s.releaseSnapshot(params.NewSnapshot) }() - - changes := params.Changes - if changes == nil { - changes = &CreateSnapshotParams{} - } - apiRequest, err := s.toAPISnapshotRequest(ctx, &changes.SnapshotRequestChangesParams) - if err != nil { - return nil, err - } - apiRequest.UserPreferences = changes.UserPreferences - if changes.PrepareAutoImports != nil { - apiRequest.PrepareAutoImports = changes.PrepareAutoImports.ToURI(s.GetCurrentDirectory()) - } - openState := s.reconcileSnapshotOpens(apiRequest, snapshotOpenState{openProjects: target.openProjects, openFiles: target.openFiles}) - fileChanges := s.toFileChangeSummary(changes.FileNotifications) - fileSystem := target.fileSystem - if source.fileSystem != nil { - if fileSystem == nil { - fileSystem = s.FS() +func (s *Session) createRequestFileSystem(params *requestfilesystem.RequestFileSystem, base vfs.FS, baseContents vfs.FS, fileChanges *project.FileChangeSummary) (vfs.FS, error) { + if params.Kind == requestfilesystem.KindSnapshot { + if len(params.Files) != 0 || len(params.Directories) != 0 || len(params.Symlinks) != 0 || len(params.RemovedPaths) != 0 { + return nil, fmt.Errorf("%w: snapshot filesystem entries must be supplied in fileSystem", ErrClientError) } - fileSystem = requestfilesystem.Rebase(source.fileSystem, fileSystem, target.snapshot.FS(), &fileChanges) - } - if changes.FileSystem != nil { - baseFileSystem := fileSystem - if baseFileSystem == nil { - baseFileSystem = s.FS() + if params.FileSystem != nil && params.FileSystem.Kind != requestfilesystem.KindLayer && params.FileSystem.Kind != requestfilesystem.KindFull { + return nil, fmt.Errorf("%w: only a layer or full filesystem can be applied on top of a snapshot filesystem", ErrClientError) } - fileSystem, err = requestfilesystem.NewForUpdate(changes.FileSystem, baseFileSystem, s.GetCurrentDirectory(), &fileChanges) + snapshotID := SnapshotID(params.Snapshot) + source, err := s.retainSnapshotData(snapshotID) if err != nil { - return nil, fmt.Errorf("%w: %w", ErrClientError, err) + return nil, err + } + defer func() { _ = s.releaseSnapshot(snapshotID) }() + base = requestfilesystem.Rebase(source.fileSystem, base, baseContents, fileChanges) + params = params.FileSystem + if params == nil { + return base, nil } } - apiRequest.FileSystem = fileSystem - apiRequest.ReplaceFileSystem = changes.FileSystem != nil && changes.FileSystem.Kind == requestfilesystem.KindFull - snapshot, err := s.snapshotHost.CloneSnapshot(ctx, target.snapshot, fileChanges, apiRequest) - if err != nil { - snapshot.Deref() - return nil, fmt.Errorf("%w: failed to rebase snapshot: %w", ErrClientError, err) - } - if err := s.validatePreparedAutoImports(ctx, snapshot, changes.PrepareAutoImports); err != nil { - snapshot.Deref() - return nil, err + if params.Snapshot != 0 || params.FileSystem != nil { + return nil, fmt.Errorf("%w: snapshot and fileSystem are only valid for a snapshot filesystem", ErrClientError) } - if err := moduleResolutionError(snapshot); err != nil { - snapshot.Deref() - return nil, err - } - response := s.createSnapshotResponse(snapshot, target.snapshot, &changes.SnapshotRequestChangesParams) - s.registerSnapshot(snapshot, openState, fileSystem) - return response, nil + return requestfilesystem.NewForUpdate(params, base, s.GetCurrentDirectory(), fileChanges) } func (s *Session) toAPISnapshotRequest(ctx context.Context, changes *SnapshotRequestChangesParams) (*project.APISnapshotRequest, error) { diff --git a/tsc/internal/api/session_completion_test.go b/tsc/internal/api/session_completion_test.go index 2fa38d4cd4002..4e1a217030e99 100644 --- a/tsc/internal/api/session_completion_test.go +++ b/tsc/internal/api/session_completion_test.go @@ -4,6 +4,7 @@ import ( "testing" "time" + "github.com/microsoft/TypeScript/tsc/internal/api/requestfilesystem" "github.com/microsoft/TypeScript/tsc/internal/bundled" "github.com/microsoft/TypeScript/tsc/internal/core" "github.com/microsoft/TypeScript/tsc/internal/ls/lsutil" @@ -326,7 +327,7 @@ func TestCompletionUsesSnapshotPreferences(t *testing.T) { func TestSnapshotCreatesProgramsAndPreparesAutoImportsInOneClone(t *testing.T) { t.Parallel() - for _, operation := range []string{"create", "update", "rebase"} { + for _, operation := range []string{"create", "update", "snapshot filesystem"} { t.Run(operation, func(t *testing.T) { t.Parallel() defer testutil.RecoverAndFail(t, "snapshot creation panicked") @@ -355,18 +356,13 @@ func TestSnapshotCreatesProgramsAndPreparesAutoImportsInOneClone(t *testing.T) { base, e := session.handleCreateSnapshot(t.Context(), &CreateSnapshotParams{}) assert.NilError(t, e) expectedID = base.Snapshot + 1 - if operation == "update" { - response, err = session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{ - Snapshot: base.Snapshot, - Changes: params, - }) - } else { - response, err = session.handleRebaseSnapshot(t.Context(), &RebaseSnapshotParams{ - Snapshot: base.Snapshot, - NewSnapshot: base.Snapshot, - Changes: params, - }) + if operation == "snapshot filesystem" { + params.FileSystem = &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(base.Snapshot)} } + response, err = session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{ + Snapshot: base.Snapshot, + Changes: params, + }) } else { response, err = session.handleCreateSnapshot(t.Context(), params) } @@ -380,7 +376,7 @@ func TestSnapshotCreatesProgramsAndPreparesAutoImportsInOneClone(t *testing.T) { } } -func TestRebaseRejectsUnpreparedAutoImports(t *testing.T) { +func TestSnapshotFileSystemRejectsUnpreparedAutoImports(t *testing.T) { t.Parallel() projectSession, _ := projecttestutil.Setup(map[string]any{}) defer projectSession.Close() @@ -388,11 +384,11 @@ func TestRebaseRejectsUnpreparedAutoImports(t *testing.T) { defer session.Close() base, err := session.handleCreateSnapshot(t.Context(), &CreateSnapshotParams{}) assert.NilError(t, err) - _, err = session.handleRebaseSnapshot(t.Context(), &RebaseSnapshotParams{ - Snapshot: base.Snapshot, - NewSnapshot: base.Snapshot, + _, err = session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{ + Snapshot: base.Snapshot, Changes: &CreateSnapshotParams{ PrepareAutoImports: &DocumentIdentifier{FileName: "/missing.ts"}, + FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(base.Snapshot)}, }, }) assert.ErrorIs(t, err, ErrClientError) diff --git a/tsc/internal/api/session_requestfilesystem_test.go b/tsc/internal/api/session_requestfilesystem_test.go index 6e5e8696a57c9..09d68c9c7c31f 100644 --- a/tsc/internal/api/session_requestfilesystem_test.go +++ b/tsc/internal/api/session_requestfilesystem_test.go @@ -586,7 +586,7 @@ func TestSnapshotUpdateCarriesHostFileSystemWithoutOverride(t *testing.T) { func TestSnapshotFileSystemLayersPreserveIncrementalState(t *testing.T) { t.Parallel() - for _, operation := range []string{"update", "self-rebase"} { + for _, operation := range []string{"update", "snapshot filesystem"} { for _, baseKind := range []requestfilesystem.Kind{"host", requestfilesystem.KindFull, requestfilesystem.KindLayer} { t.Run(operation+"/"+string(baseKind), func(t *testing.T) { t.Parallel() @@ -608,8 +608,14 @@ func TestSnapshotFileSystemLayersPreserveIncrementalState(t *testing.T) { defer session.Close() ctx := context.Background() updateSnapshot := func(params *UpdateSnapshotParams) (*CreateSnapshotResponse, error) { - if operation == "self-rebase" { - return session.handleRebaseSnapshot(ctx, &RebaseSnapshotParams{Snapshot: params.Snapshot, NewSnapshot: params.Snapshot, Changes: params.Changes}) + if operation == "snapshot filesystem" { + changes := *params.Changes + changes.FileSystem = &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindSnapshot, + Snapshot: uint64(params.Snapshot), + FileSystem: changes.FileSystem, + } + return session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{Snapshot: params.Snapshot, Changes: &changes}) } return session.handleUpdateSnapshot(ctx, params) } diff --git a/tsc/internal/api/session_rebase_changes_test.go b/tsc/internal/api/session_snapshot_filesystem_changes_test.go similarity index 84% rename from tsc/internal/api/session_rebase_changes_test.go rename to tsc/internal/api/session_snapshot_filesystem_changes_test.go index dac6d5de754e2..66796fe730391 100644 --- a/tsc/internal/api/session_rebase_changes_test.go +++ b/tsc/internal/api/session_snapshot_filesystem_changes_test.go @@ -15,7 +15,7 @@ import ( "gotest.tools/v3/assert" ) -func TestRebaseRemovesExplicitTombstoneDescendants(t *testing.T) { +func TestSnapshotFileSystemRemovesExplicitTombstoneDescendants(t *testing.T) { t.Parallel() for _, kind := range []requestfilesystem.Kind{requestfilesystem.KindLayer, requestfilesystem.KindFull} { t.Run(string(kind), func(t *testing.T) { @@ -45,16 +45,22 @@ func TestRebaseRemovesExplicitTombstoneDescendants(t *testing.T) { targetSnapshot := session.snapshots[target.Snapshot].snapshot targetProgram := targetSnapshot.ProjectCollection.GetProject(project.ID("/tsconfig.json")).GetProgram() assert.Assert(t, targetProgram.GetSourceFile("/removed/keep.ts") != nil) - self, err := session.handleRebaseSnapshot(t.Context(), &RebaseSnapshotParams{ - Snapshot: target.Snapshot, NewSnapshot: target.Snapshot, - Changes: &CreateSnapshotParams{EnsurePrograms: &EnsurePrograms{All: true}}, + self, err := session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{ + Snapshot: target.Snapshot, + Changes: &CreateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(target.Snapshot)}, + EnsurePrograms: &EnsurePrograms{All: true}, + }, }) assert.NilError(t, err) assert.Assert(t, session.snapshots[self.Snapshot].snapshot.ProjectCollection.GetProject(project.ID("/tsconfig.json")).GetProgram() == targetProgram) - rebased, err := session.handleRebaseSnapshot(t.Context(), &RebaseSnapshotParams{ - Snapshot: source.Snapshot, NewSnapshot: target.Snapshot, - Changes: &CreateSnapshotParams{EnsurePrograms: &EnsurePrograms{All: true}}, + rebased, err := session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{ + Snapshot: target.Snapshot, + Changes: &CreateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(source.Snapshot)}, + EnsurePrograms: &EnsurePrograms{All: true}, + }, }) assert.NilError(t, err) snapshot := session.snapshots[rebased.Snapshot].snapshot @@ -69,7 +75,7 @@ func TestRebaseRemovesExplicitTombstoneDescendants(t *testing.T) { } } -func TestRebaseFullFileSystemRemovesUnlistedFiles(t *testing.T) { +func TestSnapshotFullFileSystemRemovesUnlistedFiles(t *testing.T) { t.Parallel() projectSession, _ := projecttestutil.Setup(map[string]any{}) defer projectSession.Close() @@ -99,9 +105,12 @@ func TestRebaseFullFileSystemRemovesUnlistedFiles(t *testing.T) { }) assert.NilError(t, err) assert.Assert(t, session.snapshots[target.Snapshot].snapshot.GetFile("/hidden.ts") != nil) - rebased, err := session.handleRebaseSnapshot(t.Context(), &RebaseSnapshotParams{ - Snapshot: source.Snapshot, NewSnapshot: target.Snapshot, - Changes: &CreateSnapshotParams{EnsurePrograms: &EnsurePrograms{All: true}}, + rebased, err := session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{ + Snapshot: target.Snapshot, + Changes: &CreateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(source.Snapshot)}, + EnsurePrograms: &EnsurePrograms{All: true}, + }, }) assert.NilError(t, err) snapshot := session.snapshots[rebased.Snapshot].snapshot @@ -110,7 +119,7 @@ func TestRebaseFullFileSystemRemovesUnlistedFiles(t *testing.T) { assert.Assert(t, snapshot.ProjectCollection.GetProject(project.ID("/tsconfig.json")).GetProgram().GetSourceFile("/hidden.ts") == nil) } -func TestRebaseComparesTargetSnapshotContents(t *testing.T) { +func TestSnapshotFileSystemComparesTargetSnapshotContents(t *testing.T) { t.Parallel() for _, editor := range []bool{false, true} { for _, layeredTarget := range []bool{false, true} { @@ -153,9 +162,12 @@ func TestRebaseComparesTargetSnapshotContents(t *testing.T) { FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindLayer, Files: map[string]string{"/index.ts": expected}}, }) assert.NilError(t, err) - rebased, err := session.handleRebaseSnapshot(t.Context(), &RebaseSnapshotParams{ - Snapshot: source.Snapshot, NewSnapshot: target.Snapshot, - Changes: &CreateSnapshotParams{EnsurePrograms: &EnsurePrograms{All: true}}, + rebased, err := session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{ + Snapshot: target.Snapshot, + Changes: &CreateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(source.Snapshot)}, + EnsurePrograms: &EnsurePrograms{All: true}, + }, }) assert.NilError(t, err) snapshot := session.snapshots[rebased.Snapshot].snapshot @@ -170,7 +182,7 @@ func TestRebaseComparesTargetSnapshotContents(t *testing.T) { } } -func TestRebaseUnchangedListingPreservesIncrementalState(t *testing.T) { +func TestSnapshotFileSystemUnchangedListingPreservesIncrementalState(t *testing.T) { t.Parallel() projectSession, _ := projecttestutil.Setup(map[string]any{}) defer projectSession.Close() @@ -201,9 +213,12 @@ func TestRebaseUnchangedListingPreservesIncrementalState(t *testing.T) { }, }) assert.NilError(t, err) - rebased, err := session.handleRebaseSnapshot(t.Context(), &RebaseSnapshotParams{ - Snapshot: source.Snapshot, NewSnapshot: target.Snapshot, - Changes: &CreateSnapshotParams{EnsurePrograms: &EnsurePrograms{All: true}}, + rebased, err := session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{ + Snapshot: target.Snapshot, + Changes: &CreateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(source.Snapshot)}, + EnsurePrograms: &EnsurePrograms{All: true}, + }, }) assert.NilError(t, err) proj := session.snapshots[rebased.Snapshot].snapshot.ProjectCollection.GetProject(project.ID("/tsconfig.json")) @@ -213,7 +228,7 @@ func TestRebaseUnchangedListingPreservesIncrementalState(t *testing.T) { assert.Assert(t, proj.GetProgram().GetSourceFile("/hidden.ts") == targetProgram.GetSourceFile("/hidden.ts")) } -func TestRebaseGeneratedFilesAreRelativeToTarget(t *testing.T) { +func TestSnapshotFileSystemGeneratedFilesAreRelativeToTarget(t *testing.T) { t.Parallel() for _, targetHasGeneratedFiles := range []bool{false, true} { t.Run(fmt.Sprintf("targetHasGeneratedFiles=%v", targetHasGeneratedFiles), func(t *testing.T) { @@ -257,11 +272,14 @@ func TestRebaseGeneratedFilesAreRelativeToTarget(t *testing.T) { } targetSnapshot := session.snapshots[target.Snapshot].snapshot unrelated := targetSnapshot.ProjectCollection.GetProject(project.ID("/other/tsconfig.json")).GetProgram() - rebased, err := session.handleRebaseSnapshot(t.Context(), &RebaseSnapshotParams{ - Snapshot: source.Snapshot, NewSnapshot: target.Snapshot, + rebased, err := session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{ + Snapshot: target.Snapshot, Changes: &CreateSnapshotParams{ FileSystem: &requestfilesystem.RequestFileSystem{ - Kind: requestfilesystem.KindLayer, Files: map[string]string{"/index.ts": "export const value = 3;"}, + Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(source.Snapshot), + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindLayer, Files: map[string]string{"/index.ts": "export const value = 3;"}, + }, }, EnsurePrograms: &EnsurePrograms{All: true}, }, @@ -286,7 +304,7 @@ func TestRebaseGeneratedFilesAreRelativeToTarget(t *testing.T) { } } -func TestRebasePreservesFullInvalidation(t *testing.T) { +func TestSnapshotFileSystemPreservesFullInvalidation(t *testing.T) { t.Parallel() projectSession, _ := projecttestutil.Setup(map[string]any{"/index.ts": "export const value = 1;"}) defer projectSession.Close() @@ -309,7 +327,9 @@ func TestRebasePreservesFullInvalidation(t *testing.T) { assert.NilError(t, err) assert.Equal(t, updatedProgram.GetSourceFile("/index.ts").Text(), "export const value = 2;") for _, targetID := range []SnapshotID{base.Snapshot, target.Snapshot} { - rebased, err := session.handleRebaseSnapshot(t.Context(), &RebaseSnapshotParams{Snapshot: base.Snapshot, NewSnapshot: targetID, Changes: changes}) + snapshotChanges := *changes + snapshotChanges.FileSystem = &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(base.Snapshot)} + rebased, err := session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{Snapshot: targetID, Changes: &snapshotChanges}) assert.NilError(t, err) program, err := session.snapshots[rebased.Snapshot].getProgram(rebased.Projects[0].Handle) assert.NilError(t, err) @@ -317,7 +337,7 @@ func TestRebasePreservesFullInvalidation(t *testing.T) { } } -func TestRebaseRefreshesDependencyAutoImports(t *testing.T) { +func TestSnapshotFileSystemRefreshesDependencyAutoImports(t *testing.T) { t.Parallel() const fileName = "/home/project/index.ts" const dependency = "/home/project/node_modules/my-pkg/index.d.ts" @@ -362,9 +382,12 @@ func TestRebaseRefreshesDependencyAutoImports(t *testing.T) { }) assert.NilError(t, err) assert.Assert(t, slices.Contains(completionNames(source.Snapshot), "packageNew")) - rebased, err := session.handleRebaseSnapshot(t.Context(), &RebaseSnapshotParams{ - Snapshot: source.Snapshot, NewSnapshot: target.Snapshot, - Changes: &CreateSnapshotParams{PrepareAutoImports: &DocumentIdentifier{FileName: fileName}}, + rebased, err := session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{ + Snapshot: target.Snapshot, + Changes: &CreateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(source.Snapshot)}, + PrepareAutoImports: &DocumentIdentifier{FileName: fileName}, + }, }) assert.NilError(t, err) names := completionNames(rebased.Snapshot) @@ -372,7 +395,7 @@ func TestRebaseRefreshesDependencyAutoImports(t *testing.T) { assert.Assert(t, !slices.Contains(names, "packageOld")) } -func TestSelfRebaseMatchesUpdateProgramState(t *testing.T) { +func TestSelfSnapshotFileSystemMatchesUpdateProgramState(t *testing.T) { t.Parallel() for _, baseKind := range []requestfilesystem.Kind{"host", requestfilesystem.KindLayer, requestfilesystem.KindFull} { t.Run(string(baseKind), func(t *testing.T) { @@ -435,7 +458,12 @@ func TestSelfRebaseMatchesUpdateProgramState(t *testing.T) { t.Log(name) updated, err := session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{Snapshot: base.Snapshot, Changes: changes}) assert.NilError(t, err, name) - rebased, err := session.handleRebaseSnapshot(t.Context(), &RebaseSnapshotParams{Snapshot: base.Snapshot, NewSnapshot: base.Snapshot, Changes: changes}) + snapshotChanges := *changes + snapshotChanges.FileSystem = &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(base.Snapshot), + FileSystem: changes.FileSystem, + } + rebased, err := session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{Snapshot: base.Snapshot, Changes: &snapshotChanges}) assert.NilError(t, err, name) // Projects created independently have distinct lifetime handles. assert.DeepEqual(t, rebased.Projects, updated.Projects, cmpopts.IgnoreFields(ProjectResponse{}, "Handle")) diff --git a/tsc/internal/api/session_rebase_test.go b/tsc/internal/api/session_snapshot_filesystem_test.go similarity index 50% rename from tsc/internal/api/session_rebase_test.go rename to tsc/internal/api/session_snapshot_filesystem_test.go index a439126157c92..8d311b312243d 100644 --- a/tsc/internal/api/session_rebase_test.go +++ b/tsc/internal/api/session_snapshot_filesystem_test.go @@ -12,7 +12,7 @@ import ( "gotest.tools/v3/assert" ) -func TestRebaseSnapshot(t *testing.T) { +func TestSnapshotFileSystem(t *testing.T) { t.Parallel() ctx := context.Background() projectSession, _ := projecttestutil.Setup(map[string]any{ @@ -74,9 +74,12 @@ func TestRebaseSnapshot(t *testing.T) { }, }) assert.NilError(t, err) - rebased, err := session.handleRebaseSnapshot(ctx, &RebaseSnapshotParams{ - Snapshot: extras.Snapshot, NewSnapshot: target.Snapshot, - Changes: &CreateSnapshotParams{EnsurePrograms: &EnsurePrograms{All: true}}, + rebased, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ + Snapshot: target.Snapshot, + Changes: &CreateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(extras.Snapshot)}, + EnsurePrograms: &EnsurePrograms{All: true}, + }, }) assert.NilError(t, err) assert.Assert(t, rebased.Snapshot != extras.Snapshot && rebased.Snapshot != target.Snapshot) @@ -105,7 +108,7 @@ func TestRebaseSnapshot(t *testing.T) { assert.Equal(t, content, "target extra") } -func TestRebaseSnapshotFileSystemKinds(t *testing.T) { +func TestSnapshotFileSystemKinds(t *testing.T) { t.Parallel() for _, sourceKind := range []requestfilesystem.Kind{"host", requestfilesystem.KindLayer, requestfilesystem.KindFull} { for _, targetKind := range []requestfilesystem.Kind{"host", requestfilesystem.KindLayer, requestfilesystem.KindFull} { @@ -134,7 +137,10 @@ func TestRebaseSnapshotFileSystemKinds(t *testing.T) { } target, err := session.handleCreateSnapshot(ctx, targetParams) assert.NilError(t, err) - rebased, err := session.handleRebaseSnapshot(ctx, &RebaseSnapshotParams{Snapshot: source.Snapshot, NewSnapshot: target.Snapshot}) + rebased, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ + Snapshot: target.Snapshot, + Changes: &CreateSnapshotParams{FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(source.Snapshot)}}, + }) assert.NilError(t, err) assert.Assert(t, rebased.Snapshot != source.Snapshot && rebased.Snapshot != target.Snapshot) assert.NilError(t, session.releaseSnapshot(source.Snapshot)) @@ -164,7 +170,7 @@ func TestRebaseSnapshotFileSystemKinds(t *testing.T) { } } -func TestRebaseSnapshotRejectsInactiveSnapshots(t *testing.T) { +func TestSnapshotFileSystemRejectsInactiveSnapshots(t *testing.T) { t.Parallel() projectSession, _ := projecttestutil.Setup(map[string]any{}) defer projectSession.Close() @@ -173,18 +179,141 @@ func TestRebaseSnapshotRejectsInactiveSnapshots(t *testing.T) { ctx := context.Background() snapshot, err := session.handleCreateSnapshot(ctx, &CreateSnapshotParams{}) assert.NilError(t, err) - _, err = session.handleRebaseSnapshot(ctx, &RebaseSnapshotParams{Snapshot: 999, NewSnapshot: snapshot.Snapshot}) + _, err = session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ + Snapshot: snapshot.Snapshot, + Changes: &CreateSnapshotParams{FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindSnapshot, Snapshot: 999}}, + }) assert.ErrorIs(t, err, ErrClientError) - _, err = session.handleRebaseSnapshot(ctx, &RebaseSnapshotParams{Snapshot: snapshot.Snapshot, NewSnapshot: 999}) + _, err = session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ + Snapshot: 999, + Changes: &CreateSnapshotParams{FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(snapshot.Snapshot)}}, + }) assert.ErrorIs(t, err, ErrClientError) assert.Equal(t, session.snapshots[snapshot.Snapshot].refCount, 1) - rebased, err := session.handleRebaseSnapshot(ctx, &RebaseSnapshotParams{Snapshot: snapshot.Snapshot, NewSnapshot: snapshot.Snapshot}) + rebased, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ + Snapshot: snapshot.Snapshot, + Changes: &CreateSnapshotParams{FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(snapshot.Snapshot)}}, + }) assert.NilError(t, err) assert.Assert(t, rebased.Snapshot != snapshot.Snapshot) assert.Equal(t, session.snapshots[snapshot.Snapshot].refCount, 1) assert.NilError(t, session.releaseSnapshot(snapshot.Snapshot)) - _, err = session.handleRebaseSnapshot(ctx, &RebaseSnapshotParams{Snapshot: snapshot.Snapshot, NewSnapshot: rebased.Snapshot}) + _, err = session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ + Snapshot: rebased.Snapshot, + Changes: &CreateSnapshotParams{FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(snapshot.Snapshot)}}, + }) assert.ErrorIs(t, err, ErrClientError) - _, err = session.handleRebaseSnapshot(ctx, &RebaseSnapshotParams{Snapshot: rebased.Snapshot, NewSnapshot: snapshot.Snapshot}) + _, err = session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ + Snapshot: snapshot.Snapshot, + Changes: &CreateSnapshotParams{FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(rebased.Snapshot)}}, + }) assert.ErrorIs(t, err, ErrClientError) } + +func TestCreateSnapshotWithSnapshotFileSystem(t *testing.T) { + t.Parallel() + for _, kind := range []requestfilesystem.Kind{requestfilesystem.KindLayer, requestfilesystem.KindFull} { + t.Run(string(kind), func(t *testing.T) { + t.Parallel() + projectSession, _ := projecttestutil.Setup(map[string]any{"/host.ts": "host"}) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + source, err := session.handleCreateSnapshot(t.Context(), &CreateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindLayer, + Files: map[string]string{"/source.ts": "source", "/shared.ts": "source", "/removed/nested.ts": "removed"}, + }, + }) + assert.NilError(t, err) + cloned, err := session.handleCreateSnapshot(t.Context(), &CreateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(source.Snapshot)}, + }) + assert.NilError(t, err) + assert.Assert(t, cloned.Snapshot != source.Snapshot) + content, ok := session.snapshots[cloned.Snapshot].snapshot.ReadFile("/source.ts") + assert.Assert(t, ok) + assert.Equal(t, content, "source") + created, err := session.handleCreateSnapshot(t.Context(), &CreateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(source.Snapshot), + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: kind, + Files: map[string]string{"/extra.ts": "extra", "/shared.ts": "extra"}, + Directories: map[string]requestfilesystem.RequestDirectoryEntries{ + "/listed": {Files: []string{"entry.ts"}}, + }, + Symlinks: map[string]requestfilesystem.RequestSymlink{ + "/alias.ts": {Target: "/shared.ts"}, + }, + RemovedPaths: []string{"/removed"}, + }, + }, + }) + assert.NilError(t, err) + assert.Assert(t, created.Snapshot != source.Snapshot) + assert.NilError(t, session.releaseSnapshot(source.Snapshot)) + snapshot := session.snapshots[created.Snapshot].snapshot + for fileName, expected := range map[tspath.RootedFilePath]string{ + "/extra.ts": "extra", "/shared.ts": "extra", "/alias.ts": "extra", + } { + content, ok = snapshot.ReadFile(fileName) + assert.Assert(t, ok) + assert.Equal(t, content, expected) + } + _, ok = snapshot.ReadFile("/source.ts") + assert.Equal(t, ok, kind == requestfilesystem.KindLayer) + _, ok = snapshot.ReadFile("/host.ts") + assert.Equal(t, ok, kind == requestfilesystem.KindLayer) + _, ok = snapshot.ReadFile("/removed/nested.ts") + assert.Assert(t, !ok) + assert.Assert(t, snapshot.FileSystem().DirectoryExists("/listed")) + }) + } +} + +func TestSnapshotFileSystemRejectsInvalidDescriptors(t *testing.T) { + t.Parallel() + projectSession, _ := projecttestutil.Setup(map[string]any{}) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + source, err := session.handleCreateSnapshot(t.Context(), &CreateSnapshotParams{}) + assert.NilError(t, err) + released, err := session.handleCreateSnapshot(t.Context(), &CreateSnapshotParams{}) + assert.NilError(t, err) + assert.NilError(t, session.releaseSnapshot(released.Snapshot)) + for name, fileSystem := range map[string]*requestfilesystem.RequestFileSystem{ + "invalid kind": {Kind: "invalid"}, + "zero snapshot": {Kind: requestfilesystem.KindSnapshot}, + "unknown snapshot": {Kind: requestfilesystem.KindSnapshot, Snapshot: 999}, + "released snapshot": {Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(released.Snapshot)}, + "snapshot on layer": {Kind: requestfilesystem.KindLayer, Snapshot: uint64(source.Snapshot)}, + "nested filesystem on full": { + Kind: requestfilesystem.KindFull, + FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindLayer}, + }, + "nested snapshot": { + Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(source.Snapshot), + FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(source.Snapshot)}, + }, + "invalid nested kind": { + Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(source.Snapshot), + FileSystem: &requestfilesystem.RequestFileSystem{Kind: "invalid"}, + }, + "unnested entries": { + Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(source.Snapshot), + Files: map[string]string{"/extra.ts": "extra"}, + }, + } { + t.Log(name) + _, err = session.handleCreateSnapshot(t.Context(), &CreateSnapshotParams{FileSystem: fileSystem}) + assert.ErrorIs(t, err, ErrClientError, name) + _, err = session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{ + Snapshot: source.Snapshot, + Changes: &CreateSnapshotParams{FileSystem: fileSystem}, + }) + assert.ErrorIs(t, err, ErrClientError, name) + assert.Equal(t, session.snapshots[source.Snapshot].refCount, 1) + } +}