From f49fdad9b7cd1b6e1036b3671a60e7e559e9d37d Mon Sep 17 00:00:00 2001 From: Wesley Wigham Date: Wed, 7 Oct 2026 13:48:01 -0700 Subject: [PATCH] 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) - }) + } } }