diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index 64dbd13a61a75..29369a7392809 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -678,6 +678,13 @@ export class API implements FormatDiagnosticsHo private prepareCreateSnapshotParams(params: CreateSnapshotParams | undefined): ProtocolCreateSnapshotParams { params ??= {}; + if (params.fileSystem?.kind === "snapshot") { + const sourceId = params.fileSystem.snapshot; + const source = sourceId === undefined ? undefined : this.activeSnapshots.get(sourceId); + if (!source || source.isDisposed()) { + throw new Error("Cannot use an inactive snapshot filesystem"); + } + } const prepareOptions = (options: CreateProgramOptions | undefined): ProtocolCreateProgramOptions | undefined => { if (!options) return undefined; const { moduleResolver, projectReferences, ...rest } = options; @@ -1109,6 +1116,12 @@ export class Snapshot { return this.getProject(projectId)?.program; } + /** + * Creates a snapshot using this snapshot's projects and incremental state. + * Use `createFileSystemFromSnapshot(source)` from the filesystem API as `fileSystem` + * to overlay another snapshot's retained request entries without replaying + * intermediate updates. Changes and program reuse are relative to this snapshot. + */ update< const CreatePrograms extends CreateSnapshotParams["createPrograms"] = undefined, const OpenFiles extends CreateSnapshotParams["openFiles"] = undefined, diff --git a/packages/typescript/src/api/fs.ts b/packages/typescript/src/api/fs.ts index 25bfe9f044309..762711f4352ac 100644 --- a/packages/typescript/src/api/fs.ts +++ b/packages/typescript/src/api/fs.ts @@ -5,6 +5,7 @@ import type { RootedFilePath, RootedPath, } from "../ast/index.ts"; +import type { Snapshot } from "./async/api.ts"; import { normalizePath } from "./path.ts"; import type { RequestDirectoryEntries, @@ -169,6 +170,26 @@ export function createFileSystemLayer( return createRequestFileSystem("layer", files, options); } +/** + * Reuses a snapshot's retained request filesystem without resending its contents. + * Entries take precedence over the receiving snapshot's filesystem; a full source + * filesystem remains total. Host and editor contents are not copied. + * An optional layer or full filesystem is applied over the reused filesystem. + * The source must remain active until the snapshot operation completes. + */ +export function createFileSystemFromSnapshot( + snapshot: Pick, + fileSystem?: RequestFileSystem | undefined, +): RequestFileSystem { + if (snapshot.isDisposed()) { + throw new Error("Cannot create a filesystem from a disposed snapshot"); + } + if (fileSystem?.kind === "snapshot") { + throw new Error("Only a layer or full filesystem can be applied on top of a snapshot filesystem"); + } + return { kind: "snapshot", snapshot: snapshot.id, files: {}, fileSystem }; +} + function createRequestFileSystem( kind: RequestFileSystem["kind"], files: RequestFileEntries, diff --git a/packages/typescript/src/api/proto.generated.ts b/packages/typescript/src/api/proto.generated.ts index 6f49fab3ca6ab..51b1123b15634 100644 --- a/packages/typescript/src/api/proto.generated.ts +++ b/packages/typescript/src/api/proto.generated.ts @@ -308,7 +308,9 @@ export interface CreateSnapshotParams extends SnapshotRequestChangesParams { /** * FileSystem supplies file contents and directory listings for the new snapshot. * A full filesystem is canonical and total. A filesystem layer is checked - * before falling back to the base snapshot or host filesystem. + * before falling back to the base snapshot or host filesystem. A snapshot + * filesystem overlays retained request entries from another active snapshot, + * then applies an optional layer or full filesystem. */ fileSystem?: RequestFileSystem | undefined; } @@ -1570,7 +1572,11 @@ export interface FileNotifications { * for a request that creates a snapshot. */ export interface RequestFileSystem { - kind: "full" | "layer"; + kind: "full" | "layer" | "snapshot"; + /** Snapshot identifies the retained snapshot supplying request entries for KindSnapshot. */ + snapshot?: number | undefined; + /** FileSystem is an optional layer or full filesystem applied on top of KindSnapshot. */ + fileSystem?: RequestFileSystem | undefined; /** Files maps file names to their complete contents. */ files: Record; /** diff --git a/packages/typescript/src/api/sync/api.ts b/packages/typescript/src/api/sync/api.ts index f08ef255288b9..e271dafabaf7b 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -1052,6 +1052,13 @@ export class API implements FormatDiagnosticsHo private prepareCreateSnapshotParams(params: CreateSnapshotParams | undefined): ProtocolCreateSnapshotParams { params ??= {}; + if (params.fileSystem?.kind === "snapshot") { + const sourceId = params.fileSystem.snapshot; + const source = sourceId === undefined ? undefined : this.activeSnapshots.get(sourceId); + if (!source || source.isDisposed()) { + throw new Error("Cannot use an inactive snapshot filesystem"); + } + } const prepareOptions = (options: CreateProgramOptions | undefined): ProtocolCreateProgramOptions | undefined => { if (!options) return undefined; const { moduleResolver, projectReferences, ...rest } = options; @@ -1737,6 +1744,12 @@ export class Snapshot { return this.getProject(projectId)?.program; } + /** + * Creates a snapshot using this snapshot's projects and incremental state. + * Use `createFileSystemFromSnapshot(source)` from the filesystem API as `fileSystem` + * to overlay another snapshot's retained request entries without replaying + * intermediate updates. Changes and program reuse are relative to this snapshot. + */ get update(): { (params: SnapshotOperationParams): SnapshotForOperationResults; (params: CreateSnapshotParams): Snapshot; diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index 379f07b6b68f7..aa069991f5661 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, @@ -114,6 +115,7 @@ import { } from "@typescript/typescript/async"; // @sync: } from "@typescript/typescript/sync"; import { createFileSystem, + createFileSystemFromSnapshot, createFileSystemLayer, createFileSystemWithLib, type FileSystemCallbacks, @@ -5550,60 +5552,266 @@ 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()), + test("createFileSystemFromSnapshot supports creating snapshots with additional layer and full filesystems", async () => { + await using api = spawnAPI({}); + await using source = await api.createSnapshot({ + fileSystem: createFileSystemLayer([ + ["/source.ts", "source"], + ["/removed/nested.ts", "removed"], + ]), }); - await using snapshot = await api.createSnapshot({ - openProjects: ["/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;`, - })), + const descriptor = createFileSystemFromSnapshot(source); + assert.equal(descriptor.kind, "snapshot"); + assert.equal(descriptor.snapshot, source.id); + assert.deepEqual(descriptor.files, {}); + await using cloned = await api.createSnapshot({ + fileSystem: descriptor, + createPrograms: [{ rootFiles: ["/source.ts"], compilerOptions: { noLib: true } }], + }); + assert.notEqual(cloned.id, source.id); + assert.equal((await cloned.operation.createdPrograms[0].getSourceFile("/source.ts"))?.text, "source"); + assert.throws(() => createFileSystemFromSnapshot(source, descriptor), /layer or full/); + for (const full of [false, true]) { + const extra = (full ? createFileSystem : createFileSystemLayer)([ + ["/extra.ts", "extra"], + ], { + directories: { "/listed": { files: ["entry.ts"], directories: [] } }, + symlinks: { "/alias.ts": { target: "/extra.ts" } }, + removedPaths: ["/removed"], + }); + const fileSystem = createFileSystemFromSnapshot(source, extra); + assert.strictEqual(fileSystem.fileSystem, extra); + await using snapshot = await api.createSnapshot({ + fileSystem, + createPrograms: [{ rootFiles: ["/extra.ts", "/alias.ts", "/source.ts", "/removed/nested.ts"], compilerOptions: { noLib: true } }], + }); + const program = snapshot.operation.createdPrograms[0]; + assert.equal((await program.getSourceFile("/extra.ts"))?.text, "extra"); + assert.equal((await program.getSourceFile("/alias.ts"))?.text, "extra"); + assert.equal((await program.getSourceFile("/source.ts"))?.text, full ? undefined : "source"); + assert.equal(await program.getSourceFile("/removed/nested.ts"), undefined); + } + }); + + for (const snapshotFileSystem of [false, true]) { + test(`Snapshot.update layers filesystem edits and removals with ${snapshotFileSystem ? "a snapshot" : "a layer"} filesystem`, async () => { + const update = (base: Snapshot, changes: CreateSnapshotParams) => + base.update( + snapshotFileSystem + ? { ...changes, fileSystem: createFileSystemFromSnapshot(base, changes.fileSystem) } + : changes, + ); + await using api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + }); + await using snapshot = await api.createSnapshot({ + openProjects: ["/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({ - 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"], + test("Snapshot.update with a snapshot filesystem compares against the target's cached contents", async () => { + const { api: disposableAPI, fs } = spawnAPIWithFS({ "/index.ts": "export const value = 1;" }); + await using api = disposableAPI; + await using target = await api.createSnapshot({ + createPrograms: [{ rootFiles: ["/index.ts"], compilerOptions: { noLib: true } }], + }); + const programId = target.operation.createdPrograms[0].id; + const originalFile = await target.getProgram(programId)!.getSourceFile("/index.ts"); + assert.equal(originalFile!.text, "export const value = 1;"); + fs.writeFile!(toRootedFilePath("/index.ts", undefined), "export const value = 2;"); + await using source = await api.createSnapshot({ + fileSystem: createFileSystemLayer([["/index.ts", "export const value = 2;"]]), + }); + await using rebased = await target.update({ fileSystem: createFileSystemFromSnapshot(source), ensurePrograms: true }); + assert.equal((await rebased.getProgram(programId)!.getSourceFile("/index.ts"))!.text, "export const value = 2;"); + assert.strictEqual(await target.getProgram(programId)!.getSourceFile("/index.ts"), originalFile); + }); + + for (const fullReplacement of [false, true]) { + test(`Snapshot.update with a snapshot filesystem removes cached files with ${fullReplacement ? "a full replacement" : "matching tombstones"}`, async () => { + await using api = spawnAPI({}); + const files = { + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: ["index.ts", "removed/keep.ts"] }), + "/index.ts": "export {};", + }; + await using target = await api.createSnapshot({ + openProjects: ["/tsconfig.json"], + fileSystem: { + kind: "full", + files: { ...files, "/removed/keep.ts": "export const keep = true;" }, + directories: { "/": { files: ["index.ts", "tsconfig.json"], directories: [] } }, + removedPaths: ["/removed"], }, - ), + }); + const originalFile = await target.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/removed/keep.ts"); + assert.ok(originalFile); + await using self = await target.update({ fileSystem: createFileSystemFromSnapshot(target), ensurePrograms: true }); + assert.strictEqual(await self.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/removed/keep.ts"), originalFile); + await using source = await api.createSnapshot({ + fileSystem: fullReplacement + ? createFileSystem(Object.entries(files)) + : createFileSystemLayer([], { removedPaths: ["/removed"] }), + }); + await using rebased = await target.update({ fileSystem: createFileSystemFromSnapshot(source), ensurePrograms: true }); + assert.equal(await rebased.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/removed/keep.ts"), undefined); + assert.strictEqual(await target.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/removed/keep.ts"), originalFile); }); - 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";`); + } + + test("Snapshot.update with a snapshot filesystem preserves explicit full invalidation", async () => { + const { api: disposableAPI, fs } = spawnAPIWithFS({ "/index.ts": "export const value = 1;" }); + await using api = disposableAPI; + await using original = await api.createSnapshot({ + 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 base.update({ ...changes, fileSystem: createFileSystemFromSnapshot(original) }); + assert.equal((await rebased.getProgram(programId)!.getSourceFile("/index.ts"))!.text, "export const value = 2;"); + } + assert.equal((await original.getProgram(programId)!.getSourceFile("/index.ts"))!.text, "export const value = 1;"); + }); + + test("Snapshot.update with a snapshot filesystem refreshes dependency auto-imports", async () => { + const fileName = "/home/project/index.ts"; + const dependency = "/home/project/node_modules/my-pkg/index.d.ts"; + await using api = spawnAPI({ + "/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 base.update({ fileSystem: createFileSystemFromSnapshot(source), prepareAutoImports: fileName }); + const names = await completionNames(rebased); + assert.ok(names.includes("packageNew")); + assert.ok(!names.includes("packageOld")); + } + }); - await using updatedAgain = await updated.update({ + test("Snapshot.update applies snapshot filesystems over the target snapshot", async () => { + const { api: disposableAPI, fs } = spawnAPIWithFS({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, include: ["src/**/*.ts"] }), + "/src/index.ts": "export const value = 1;", + "/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/added.ts": `export const added = "updated again";`, - }), - { - removedPaths: ["/src/change.ts"], - }, - ), + fileSystem: createFileSystemLayer([ + ["/src/extra.ts", "export const extra = true;"], + ], { removedPaths: ["/src/removed"] }), + }); + 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, + 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 newOriginal.update({ + prepareAutoImports: "/src/index.ts", + openFiles: ["/src/index.ts"], + createPrograms: [{ rootFiles: ["/src/extra.ts"], compilerOptions: { noLib: true } }], + fileSystem: createFileSystemFromSnapshot(moreExtras, 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 original.update({ fileSystem: createFileSystemFromSnapshot(rebased), ensurePrograms: true }); + assert.equal((await rebasedAgain.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/src/target.ts"))!.text, "export const target = true;"); + assert.throws(() => createFileSystemFromSnapshot(moreExtras), /disposed/); + assert.throws(() => newOriginal.update({ fileSystem: createFileSystemFromSnapshot(rebased) }), /disposed/); + // @sync: assert.throws(() => original.update({ fileSystem: { kind: "snapshot", snapshot: moreExtras.id, files: {} } }), /inactive snapshot/); + await assert.rejects(original.update({ fileSystem: { kind: "snapshot", snapshot: moreExtras.id, files: {} } }), /inactive snapshot/); // @sync-skip }); test("eager snapshot disposal does not retain filesystem history", async () => { diff --git a/packages/typescript/test/sync/api-generators.test.ts b/packages/typescript/test/sync/api-generators.test.ts index 314052b63f789..bae80333e1ba3 100644 --- a/packages/typescript/test/sync/api-generators.test.ts +++ b/packages/typescript/test/sync/api-generators.test.ts @@ -22,6 +22,7 @@ import { SyntaxKind, } from "@typescript/typescript/ast"; import { cloneNode } from "@typescript/typescript/ast/factory"; +import { createFileSystemFromSnapshot } from "@typescript/typescript/fs"; import { toRootedFilePath } from "@typescript/typescript/path"; import type { APIRequest, @@ -1790,8 +1791,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({ openProjects: ["/tsconfig.json"] }))[0]; const syncBase = snapshotSyncAPI.createSnapshot({ openProjects: ["/tsconfig.json"] }); @@ -1799,6 +1804,16 @@ 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 snapshotChanges = { prepareAutoImports: "/src/syntax.ts", openFiles: ["/src/syntax.ts"] } as const; + const generatorOverlay = snapshotGeneratorAPI.batch(generatorBase.update.gen({ ...snapshotChanges, fileSystem: createFileSystemFromSnapshot(generatorExtras) }))[0]; + const syncOverlay = syncBase.update({ ...snapshotChanges, fileSystem: createFileSystemFromSnapshot(syncExtras) }); + assertSnapshotsEquivalent(generatorOverlay, syncOverlay, "Snapshot.update with a snapshot filesystem"); + assert.equal(generatorOverlay.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/src/syntax.ts")!.text, "export const fixed = true;"); } finally { snapshotGeneratorAPI.close(); diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index f048e8ede87e6..6a474c2febd76 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -70,6 +70,7 @@ import { import { visitEachChild } from "@typescript/typescript/ast/visitor"; import { createFileSystem, + createFileSystemFromSnapshot, createFileSystemLayer, createFileSystemWithLib, type FileSystemCallbacks, @@ -88,6 +89,7 @@ import { type CompilerOptions, type ConditionalType, type ConfiguredProjectId, + type CreateSnapshotParams, DiagnosticCategory, type DocumentIdentifier, EmitOnly, @@ -5351,60 +5353,265 @@ 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()), + test("createFileSystemFromSnapshot supports creating snapshots with additional layer and full filesystems", () => { + using api = spawnAPI({}); + using source = api.createSnapshot({ + fileSystem: createFileSystemLayer([ + ["/source.ts", "source"], + ["/removed/nested.ts", "removed"], + ]), }); - using snapshot = api.createSnapshot({ - openProjects: ["/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;`, - })), + const descriptor = createFileSystemFromSnapshot(source); + assert.equal(descriptor.kind, "snapshot"); + assert.equal(descriptor.snapshot, source.id); + assert.deepEqual(descriptor.files, {}); + using cloned = api.createSnapshot({ + fileSystem: descriptor, + createPrograms: [{ rootFiles: ["/source.ts"], compilerOptions: { noLib: true } }], + }); + assert.notEqual(cloned.id, source.id); + assert.equal((cloned.operation.createdPrograms[0].getSourceFile("/source.ts"))?.text, "source"); + assert.throws(() => createFileSystemFromSnapshot(source, descriptor), /layer or full/); + for (const full of [false, true]) { + const extra = (full ? createFileSystem : createFileSystemLayer)([ + ["/extra.ts", "extra"], + ], { + directories: { "/listed": { files: ["entry.ts"], directories: [] } }, + symlinks: { "/alias.ts": { target: "/extra.ts" } }, + removedPaths: ["/removed"], + }); + const fileSystem = createFileSystemFromSnapshot(source, extra); + assert.strictEqual(fileSystem.fileSystem, extra); + using snapshot = api.createSnapshot({ + fileSystem, + createPrograms: [{ rootFiles: ["/extra.ts", "/alias.ts", "/source.ts", "/removed/nested.ts"], compilerOptions: { noLib: true } }], + }); + const program = snapshot.operation.createdPrograms[0]; + assert.equal((program.getSourceFile("/extra.ts"))?.text, "extra"); + assert.equal((program.getSourceFile("/alias.ts"))?.text, "extra"); + assert.equal((program.getSourceFile("/source.ts"))?.text, full ? undefined : "source"); + assert.equal(program.getSourceFile("/removed/nested.ts"), undefined); + } + }); + + for (const snapshotFileSystem of [false, true]) { + test(`Snapshot.update layers filesystem edits and removals with ${snapshotFileSystem ? "a snapshot" : "a layer"} filesystem`, () => { + const update = (base: Snapshot, changes: CreateSnapshotParams) => + base.update( + snapshotFileSystem + ? { ...changes, fileSystem: createFileSystemFromSnapshot(base, changes.fileSystem) } + : changes, + ); + using api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + }); + using snapshot = api.createSnapshot({ + openProjects: ["/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({ - 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"], + test("Snapshot.update with a snapshot filesystem compares against the target's cached contents", () => { + const { api: disposableAPI, fs } = spawnAPIWithFS({ "/index.ts": "export const value = 1;" }); + using api = disposableAPI; + using target = api.createSnapshot({ + createPrograms: [{ rootFiles: ["/index.ts"], compilerOptions: { noLib: true } }], + }); + const programId = target.operation.createdPrograms[0].id; + const originalFile = target.getProgram(programId)!.getSourceFile("/index.ts"); + assert.equal(originalFile!.text, "export const value = 1;"); + fs.writeFile!(toRootedFilePath("/index.ts", undefined), "export const value = 2;"); + using source = api.createSnapshot({ + fileSystem: createFileSystemLayer([["/index.ts", "export const value = 2;"]]), + }); + using rebased = target.update({ fileSystem: createFileSystemFromSnapshot(source), ensurePrograms: true }); + assert.equal((rebased.getProgram(programId)!.getSourceFile("/index.ts"))!.text, "export const value = 2;"); + assert.strictEqual(target.getProgram(programId)!.getSourceFile("/index.ts"), originalFile); + }); + + for (const fullReplacement of [false, true]) { + test(`Snapshot.update with a snapshot filesystem removes cached files with ${fullReplacement ? "a full replacement" : "matching tombstones"}`, () => { + using api = spawnAPI({}); + const files = { + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: ["index.ts", "removed/keep.ts"] }), + "/index.ts": "export {};", + }; + using target = api.createSnapshot({ + openProjects: ["/tsconfig.json"], + fileSystem: { + kind: "full", + files: { ...files, "/removed/keep.ts": "export const keep = true;" }, + directories: { "/": { files: ["index.ts", "tsconfig.json"], directories: [] } }, + removedPaths: ["/removed"], }, - ), + }); + const originalFile = target.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/removed/keep.ts"); + assert.ok(originalFile); + using self = target.update({ fileSystem: createFileSystemFromSnapshot(target), ensurePrograms: true }); + assert.strictEqual(self.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/removed/keep.ts"), originalFile); + using source = api.createSnapshot({ + fileSystem: fullReplacement + ? createFileSystem(Object.entries(files)) + : createFileSystemLayer([], { removedPaths: ["/removed"] }), + }); + using rebased = target.update({ fileSystem: createFileSystemFromSnapshot(source), ensurePrograms: true }); + assert.equal(rebased.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/removed/keep.ts"), undefined); + assert.strictEqual(target.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/removed/keep.ts"), originalFile); }); - 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";`); + } + + test("Snapshot.update with a snapshot filesystem preserves explicit full invalidation", () => { + const { api: disposableAPI, fs } = spawnAPIWithFS({ "/index.ts": "export const value = 1;" }); + using api = disposableAPI; + using original = api.createSnapshot({ + 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 = base.update({ ...changes, fileSystem: createFileSystemFromSnapshot(original) }); + assert.equal((rebased.getProgram(programId)!.getSourceFile("/index.ts"))!.text, "export const value = 2;"); + } + assert.equal((original.getProgram(programId)!.getSourceFile("/index.ts"))!.text, "export const value = 1;"); + }); + + test("Snapshot.update with a snapshot filesystem refreshes dependency auto-imports", () => { + const fileName = "/home/project/index.ts"; + const dependency = "/home/project/node_modules/my-pkg/index.d.ts"; + using api = spawnAPI({ + "/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 = base.update({ fileSystem: createFileSystemFromSnapshot(source), prepareAutoImports: fileName }); + const names = completionNames(rebased); + assert.ok(names.includes("packageNew")); + assert.ok(!names.includes("packageOld")); + } + }); - using updatedAgain = updated.update({ + test("Snapshot.update applies snapshot filesystems over the target snapshot", () => { + const { api: disposableAPI, fs } = spawnAPIWithFS({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, include: ["src/**/*.ts"] }), + "/src/index.ts": "export const value = 1;", + "/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/added.ts": `export const added = "updated again";`, - }), - { - removedPaths: ["/src/change.ts"], - }, - ), + fileSystem: createFileSystemLayer([ + ["/src/extra.ts", "export const extra = true;"], + ], { removedPaths: ["/src/removed"] }), + }); + 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, + 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 = newOriginal.update({ + prepareAutoImports: "/src/index.ts", + openFiles: ["/src/index.ts"], + createPrograms: [{ rootFiles: ["/src/extra.ts"], compilerOptions: { noLib: true } }], + fileSystem: createFileSystemFromSnapshot(moreExtras, 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 = original.update({ fileSystem: createFileSystemFromSnapshot(rebased), ensurePrograms: true }); + assert.equal((rebasedAgain.getConfiguredProject("/tsconfig.json")!.program.getSourceFile("/src/target.ts"))!.text, "export const target = true;"); + assert.throws(() => createFileSystemFromSnapshot(moreExtras), /disposed/); + assert.throws(() => newOriginal.update({ fileSystem: createFileSystemFromSnapshot(rebased) }), /disposed/); + assert.throws(() => original.update({ fileSystem: { kind: "snapshot", snapshot: moreExtras.id, files: {} } }), /inactive snapshot/); }); test("eager snapshot disposal does not retain filesystem history", () => { diff --git a/tsc/internal/api/proto.go b/tsc/internal/api/proto.go index c03e7ff85ed46..47b04cb15faec 100644 --- a/tsc/internal/api/proto.go +++ b/tsc/internal/api/proto.go @@ -449,7 +449,9 @@ type CreateSnapshotParams struct { FileNotifications *FileNotifications `json:"fileNotifications,omitempty"` // FileSystem supplies file contents and directory listings for the new snapshot. // A full filesystem is canonical and total. A filesystem layer is checked - // before falling back to the base snapshot or host filesystem. + // before falling back to the base snapshot or host filesystem. A snapshot + // filesystem overlays retained request entries from another active snapshot, + // then applies an optional layer or full filesystem. FileSystem *requestfilesystem.RequestFileSystem `json:"fileSystem,omitempty"` } diff --git a/tsc/internal/api/requestfilesystem/filechanges.go b/tsc/internal/api/requestfilesystem/filechanges.go index 94f9f74a6c07a..28568887648ba 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,126 @@ func (s *requestFileSystem) ExpandFileChanges(summary project.FileChangeSummary) return summary } +func addRebaseFileChanges(summary *project.FileChangeSummary, source *requestFileSystem, baseFS vfs.FS, baseContents vfs.FS, rebased *requestFileSystem) { + baseRequestFS := getRequestFileSystem(baseFS) + addChange := func(path tspath.RootedPath, kind lsproto.FileChangeType) { + uri := lsconv.FileNameToDocumentURI(tspath.RootedFilePathFromPath(path)) + if kind == lsproto.FileChangeTypeDeleted { + if baseContents.FileExists(tspath.RootedFilePathFromPath(path)) || baseContents.DirectoryExists(tspath.RootedDirectoryPathFromPath(path)) { + summary.Deleted.Add(uri) + } + } else if kind == lsproto.FileChangeTypeCreated { + summary.Created.Add(uri) + } else if baseContents.FileExists(tspath.RootedFilePathFromPath(path)) { + summary.Changed.Add(uri) + } else { + summary.Created.Add(uri) + } + } + addChangeAndAliases := func(path tspath.RootedPath, kind lsproto.FileChangeType) { + addChange(path, kind) + if baseRequestFS != nil { + for _, alias := range baseRequestFS.aliasesForPath(path) { + addChange(alias, kind) + } + } + } + var addRemovedEntries func(*requestPathNode) + addRemovedEntries = func(node *requestPathNode) { + if node == nil { + return + } + switch entry := node.entry.(type) { + case *requestFile: + if !rebased.FileExists(entry.fileName) { + addChangeAndAliases(entry.fileName.AsPath(), lsproto.FileChangeTypeDeleted) + return + } + case *requestDirectory: + if !rebased.DirectoryExists(entry.directoryName) { + addChangeAndAliases(entry.directoryName.AsPath(), lsproto.FileChangeTypeDeleted) + return + } + case *requestSymlink: + if !rebased.FileExists(tspath.RootedFilePathFromPath(entry.linkName)) && !rebased.DirectoryExists(tspath.RootedDirectoryPathFromPath(entry.linkName)) { + addChangeAndAliases(entry.linkName, lsproto.FileChangeTypeDeleted) + return + } + } + for _, child := range node.children { + addRemovedEntries(child) + } + } + var visit func(*requestPathNode) + visit = func(node *requestPathNode) { + for path, child := range node.children { + _, 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) + } else if baseRequestFS != nil { + // A tombstone can still have explicit descendants. Reapplying + // it drops target descendants not supplied by the source. + previous, _ := baseRequestFS.paths.lookup(path) + addRemovedEntries(previous) + } + } + switch entry := child.entry.(type) { + case *requestFile: + if previous, ok := baseContents.ReadFile(entry.fileName); !ok || previous != entry.content { + addChangeAndAliases(entry.fileName.AsPath(), lsproto.FileChangeTypeChanged) + } + case *requestDirectory: + if entry.listing != nil { + // The snapshot view merges cached files into listings, even + // when omitted explicitly. Compare the supplied listings. + previous := baseFS.GetAccessibleEntries(entry.directoryName) + current := rebased.GetAccessibleEntries(entry.directoryName) + if !slices.Equal(previous.Files, current.Files) || !slices.Equal(previous.Directories, current.Directories) || !maps.Equal(previous.Symlinks, current.Symlinks) { + addChangeAndAliases(entry.directoryName.AsPath(), lsproto.FileChangeTypeDeleted) + addChangeAndAliases(entry.directoryName.AsPath(), lsproto.FileChangeTypeCreated) + } + } + if source.kind == KindFull { + previous := baseContents.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 + } +} + 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..892b04011c127 100644 --- a/tsc/internal/api/requestfilesystem/filechanges_test.go +++ b/tsc/internal/api/requestfilesystem/filechanges_test.go @@ -1,6 +1,7 @@ package requestfilesystem import ( + "strconv" "testing" "github.com/microsoft/TypeScript/tsc/internal/project" @@ -9,6 +10,105 @@ 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, 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, host, &summary) + assert.Assert(t, summary.Deleted.Has("file:///c%3A/Repo/Removed")) + assert.Equal(t, summary.Deleted.Len(), 1) +} + +func TestRebaseFileChangesUnderMatchingTombstones(t *testing.T) { + t.Parallel() + for _, preserved := range []int{0, 1, 2} { + t.Run(strconv.Itoa(preserved), func(t *testing.T) { + t.Parallel() + host := vfstest.FromMap(map[string]string{}, tspath.CaseSensitive) + base, err := newRequestFileSystem(&RequestFileSystem{ + Kind: KindLayer, + Files: map[string]string{ + "/removed/keep.ts": "keep", + "/removed/extra/file.ts": "extra", + }, + RemovedPaths: []string{"/removed"}, + Symlinks: map[string]RequestSymlink{"/alias": {Target: "/removed"}}, + }, host, "/") + assert.NilError(t, err) + files := map[string]string{} + if preserved >= 1 { + files["/removed/keep.ts"] = "keep" + } + if preserved == 2 { + files["/removed/extra/file.ts"] = "extra" + } + source, err := newRequestFileSystem(&RequestFileSystem{ + Kind: KindLayer, Files: files, RemovedPaths: []string{"/removed"}, + }, host, "/") + assert.NilError(t, err) + var summary project.FileChangeSummary + Rebase(source, base, base, &summary) + var expected project.FileChangeSummary + switch preserved { + case 0: + expected.Deleted.Add("file:///removed") + expected.Deleted.Add("file:///alias") + case 1: + expected.Deleted.Add("file:///removed/extra") + expected.Deleted.Add("file:///alias/extra") + } + expected.IncludesWatchChangeOutsideNodeModules = preserved != 2 + assert.DeepEqual(t, summary, expected) + }) + } +} + func TestFileChangesIncludeDirectoryTombstones(t *testing.T) { t.Parallel() diff --git a/tsc/internal/api/requestfilesystem/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..1e724e3cf4821 --- /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, 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, 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..6990c96e5ff76 100644 --- a/tsc/internal/api/requestfilesystem/requestfilesystem.go +++ b/tsc/internal/api/requestfilesystem/requestfilesystem.go @@ -19,6 +19,8 @@ const ( KindFull Kind = "full" // KindLayer checks the supplied filesystem before falling back to the host. KindLayer Kind = "layer" + // KindSnapshot overlays a retained snapshot's request entries before applying an optional filesystem. + KindSnapshot Kind = "snapshot" ) // RequestDirectoryEntries is a cached directory listing. Entry names are @@ -42,6 +44,10 @@ type RequestSymlink struct { // for a request that creates a snapshot. type RequestFileSystem struct { Kind Kind `json:"kind"` + // Snapshot identifies the retained snapshot supplying request entries for KindSnapshot. + Snapshot uint64 `json:"snapshot,omitempty"` + // FileSystem is an optional layer or full filesystem applied on top of KindSnapshot. + FileSystem *RequestFileSystem `json:"fileSystem,omitempty"` // Files maps file names to their complete contents. Files map[string]string `json:"files" nonnil:"true"` // Directories maps directory names to complete listing results. Directory @@ -54,6 +60,11 @@ type RequestFileSystem struct { RemovedPaths []string `json:"removedPaths,omitempty"` } +// ReplacesFileSystem reports whether the request includes an explicit full replacement. +func (r *RequestFileSystem) ReplacesFileSystem() bool { + return r != nil && (r.Kind == KindFull || r.Kind == KindSnapshot && r.FileSystem != nil && r.FileSystem.Kind == KindFull) +} + // requestFileSystem is either a full filesystem or a layer over the session // host filesystem. Its base is always the host, which may be a callback filesystem; // inherited request entries are compacted into paths. @@ -119,6 +130,42 @@ func HasFullFileSystem(fileSystem vfs.FS) bool { return requestFileSystem != nil && requestFileSystem.kind == KindFull } +// Rebase reapplies fileSystem's request entries over base. baseContents is the +// target snapshot's cached/editor view, used only to report changes relative to +// that snapshot; it must not become part of the composed filesystem. +func Rebase(fileSystem vfs.FS, base vfs.FS, baseContents vfs.FS, fileChanges *project.FileChangeSummary) vfs.FS { + source := getRequestFileSystem(fileSystem) + if source == nil { + return base + } + 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, + } + if source != getRequestFileSystem(baseFileSystem) { + addRebaseFileChanges(fileChanges, source, baseFileSystem, baseContents, result) + // Listings need not expose every readable entry, so only a self-rebase can + // establish that a full replacement preserves all of the target's paths. + if source.kind == KindFull { + fileChanges.InvalidateAll = true + } + } + return result +} + 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 +245,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 c88ef9a1b3bee..f90ce9a02e0cb 100644 --- a/tsc/internal/api/session.go +++ b/tsc/internal/api/session.go @@ -1428,13 +1428,13 @@ func (s *Session) handleCreateSnapshot(ctx context.Context, params *CreateSnapsh fileChanges := s.toFileChangeSummary(params.FileNotifications) var snapshotFileSystem vfs.FS if params.FileSystem != nil { - fileSystem, fileSystemErr := requestfilesystem.NewForUpdate(params.FileSystem, s.FS(), s.GetCurrentDirectory(), &fileChanges) + fileSystem, fileSystemErr := s.createRequestFileSystem(params.FileSystem, s.FS(), s.FS(), &fileChanges) if fileSystemErr != nil { return nil, fmt.Errorf("%w: %w", ErrClientError, fileSystemErr) } snapshotFileSystem = fileSystem apiRequest.FileSystem = fileSystem - apiRequest.ReplaceFileSystem = params.FileSystem.Kind == requestfilesystem.KindFull + apiRequest.ReplaceFileSystem = params.FileSystem.ReplacesFileSystem() } root := s.snapshotHost.NewRootSnapshot() snapshot, err := s.snapshotHost.CloneSnapshot(ctx, root, fileChanges, apiRequest) @@ -1484,7 +1484,7 @@ func (s *Session) handleUpdateSnapshot(ctx context.Context, params *UpdateSnapsh if baseFileSystem == nil { baseFileSystem = s.FS() } - fileSystem, fileSystemErr := requestfilesystem.NewForUpdate(changes.FileSystem, baseFileSystem, s.GetCurrentDirectory(), &fileChanges) + fileSystem, fileSystemErr := s.createRequestFileSystem(changes.FileSystem, baseFileSystem, baseSD.snapshot.FS(), &fileChanges) if fileSystemErr != nil { return nil, fmt.Errorf("%w: %w", ErrClientError, fileSystemErr) } @@ -1492,7 +1492,7 @@ func (s *Session) handleUpdateSnapshot(ctx context.Context, params *UpdateSnapsh } if snapshotFileSystem != nil { apiRequest.FileSystem = snapshotFileSystem - apiRequest.ReplaceFileSystem = changes.FileSystem != nil && changes.FileSystem.Kind == requestfilesystem.KindFull + apiRequest.ReplaceFileSystem = changes.FileSystem.ReplacesFileSystem() } snapshot, err := s.snapshotHost.CloneSnapshot(ctx, baseSD.snapshot, fileChanges, apiRequest) if err != nil { @@ -1513,6 +1513,32 @@ func (s *Session) handleUpdateSnapshot(ctx context.Context, params *UpdateSnapsh return response, nil } +func (s *Session) createRequestFileSystem(params *requestfilesystem.RequestFileSystem, base vfs.FS, baseContents vfs.FS, fileChanges *project.FileChangeSummary) (vfs.FS, error) { + if params.Kind == requestfilesystem.KindSnapshot { + if len(params.Files) != 0 || len(params.Directories) != 0 || len(params.Symlinks) != 0 || len(params.RemovedPaths) != 0 { + return nil, fmt.Errorf("%w: snapshot filesystem entries must be supplied in fileSystem", ErrClientError) + } + if params.FileSystem != nil && params.FileSystem.Kind != requestfilesystem.KindLayer && params.FileSystem.Kind != requestfilesystem.KindFull { + return nil, fmt.Errorf("%w: only a layer or full filesystem can be applied on top of a snapshot filesystem", ErrClientError) + } + snapshotID := SnapshotID(params.Snapshot) + source, err := s.retainSnapshotData(snapshotID) + if err != nil { + return nil, err + } + defer func() { _ = s.releaseSnapshot(snapshotID) }() + base = requestfilesystem.Rebase(source.fileSystem, base, baseContents, fileChanges) + params = params.FileSystem + if params == nil { + return base, nil + } + } + if params.Snapshot != 0 || params.FileSystem != nil { + return nil, fmt.Errorf("%w: snapshot and fileSystem are only valid for a snapshot filesystem", ErrClientError) + } + return requestfilesystem.NewForUpdate(params, base, s.GetCurrentDirectory(), fileChanges) +} + 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 ca415027f491b..4e1a217030e99 100644 --- a/tsc/internal/api/session_completion_test.go +++ b/tsc/internal/api/session_completion_test.go @@ -4,6 +4,7 @@ import ( "testing" "time" + "github.com/microsoft/TypeScript/tsc/internal/api/requestfilesystem" "github.com/microsoft/TypeScript/tsc/internal/bundled" "github.com/microsoft/TypeScript/tsc/internal/core" "github.com/microsoft/TypeScript/tsc/internal/ls/lsutil" @@ -326,8 +327,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", "snapshot filesystem"} { + t.Run(operation, func(t *testing.T) { t.Parallel() defer testutil.RecoverAndFail(t, "snapshot creation panicked") const fileName = "/home/projects/p/index.ts" @@ -351,10 +352,13 @@ 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 + if operation == "snapshot filesystem" { + params.FileSystem = &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(base.Snapshot)} + } response, err = session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{ Snapshot: base.Snapshot, Changes: params, @@ -372,6 +376,27 @@ func TestSnapshotCreatesProgramsAndPreparesAutoImportsInOneClone(t *testing.T) { } } +func TestSnapshotFileSystemRejectsUnpreparedAutoImports(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.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{ + Snapshot: base.Snapshot, + Changes: &CreateSnapshotParams{ + PrepareAutoImports: &DocumentIdentifier{FileName: "/missing.ts"}, + FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(base.Snapshot)}, + }, + }) + assert.ErrorIs(t, err, ErrClientError) + 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_requestfilesystem_test.go b/tsc/internal/api/session_requestfilesystem_test.go index bf0311e577215..09d68c9c7c31f 100644 --- a/tsc/internal/api/session_requestfilesystem_test.go +++ b/tsc/internal/api/session_requestfilesystem_test.go @@ -586,107 +586,121 @@ 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", "snapshot filesystem"} { + 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 == "snapshot filesystem" { + changes := *params.Changes + changes.FileSystem = &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindSnapshot, + Snapshot: uint64(params.Snapshot), + FileSystem: changes.FileSystem, + } + return session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{Snapshot: params.Snapshot, Changes: &changes}) + } + return session.handleUpdateSnapshot(ctx, params) + } + 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) - }) + } } } diff --git a/tsc/internal/api/session_snapshot_filesystem_changes_test.go b/tsc/internal/api/session_snapshot_filesystem_changes_test.go new file mode 100644 index 0000000000000..66796fe730391 --- /dev/null +++ b/tsc/internal/api/session_snapshot_filesystem_changes_test.go @@ -0,0 +1,476 @@ +package api + +import ( + "fmt" + "slices" + "testing" + + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/microsoft/TypeScript/tsc/internal/api/requestfilesystem" + "github.com/microsoft/TypeScript/tsc/internal/core" + "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" + "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 TestSnapshotFileSystemRemovesExplicitTombstoneDescendants(t *testing.T) { + t.Parallel() + for _, kind := range []requestfilesystem.Kind{requestfilesystem.KindLayer, requestfilesystem.KindFull} { + t.Run(string(kind), func(t *testing.T) { + t.Parallel() + projectSession, _ := projecttestutil.Setup(map[string]any{}) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + source, err := session.handleCreateSnapshot(t.Context(), &CreateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindLayer, RemovedPaths: []string{"/removed"}}, + }) + assert.NilError(t, err) + target, err := session.handleCreateSnapshot(t.Context(), &CreateSnapshotParams{ + OpenProjects: []DocumentIdentifier{{FileName: "/tsconfig.json"}}, + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: kind, + Files: map[string]string{ + "/tsconfig.json": `{"compilerOptions":{"noLib":true},"include":["**/*.ts"]}`, + "/index.ts": "export {};", + "/removed/keep.ts": "export const keep = true;", + "/removed/nested/keep.ts": "export const nested = true;", + }, + RemovedPaths: []string{"/removed"}, + }, + }) + assert.NilError(t, err) + targetSnapshot := session.snapshots[target.Snapshot].snapshot + targetProgram := targetSnapshot.ProjectCollection.GetProject(project.ID("/tsconfig.json")).GetProgram() + assert.Assert(t, targetProgram.GetSourceFile("/removed/keep.ts") != nil) + self, err := session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{ + Snapshot: target.Snapshot, + Changes: &CreateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(target.Snapshot)}, + EnsurePrograms: &EnsurePrograms{All: true}, + }, + }) + assert.NilError(t, err) + assert.Assert(t, session.snapshots[self.Snapshot].snapshot.ProjectCollection.GetProject(project.ID("/tsconfig.json")).GetProgram() == targetProgram) + + rebased, err := session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{ + Snapshot: target.Snapshot, + Changes: &CreateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(source.Snapshot)}, + EnsurePrograms: &EnsurePrograms{All: true}, + }, + }) + assert.NilError(t, err) + snapshot := session.snapshots[rebased.Snapshot].snapshot + program := snapshot.ProjectCollection.GetProject(project.ID("/tsconfig.json")).GetProgram() + for _, fileName := range []tspath.RootedFilePath{"/removed/keep.ts", "/removed/nested/keep.ts"} { + assert.Assert(t, !snapshot.FileSystem().FileExists(fileName)) + assert.Assert(t, snapshot.GetFile(fileName) == nil) + assert.Assert(t, program.GetSourceFile(fileName) == nil) + assert.Assert(t, targetProgram.GetSourceFile(fileName) != nil) + } + }) + } +} + +func TestSnapshotFullFileSystemRemovesUnlistedFiles(t *testing.T) { + t.Parallel() + projectSession, _ := projecttestutil.Setup(map[string]any{}) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + files := map[string]string{ + "/tsconfig.json": `{"compilerOptions":{"noLib":true},"files":["index.ts","hidden.ts"]}`, + "/index.ts": "export {};", + } + source, err := session.handleCreateSnapshot(t.Context(), &CreateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindFull, Files: files}, + }) + assert.NilError(t, err) + target, err := session.handleCreateSnapshot(t.Context(), &CreateSnapshotParams{ + OpenProjects: []DocumentIdentifier{{FileName: "/tsconfig.json"}}, + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindFull, + Files: map[string]string{ + "/tsconfig.json": files["/tsconfig.json"], + "/index.ts": files["/index.ts"], + "/hidden.ts": "export const hidden = true;", + }, + Directories: map[string]requestfilesystem.RequestDirectoryEntries{ + "/": {Files: []string{"index.ts", "tsconfig.json"}}, + }, + }, + }) + assert.NilError(t, err) + assert.Assert(t, session.snapshots[target.Snapshot].snapshot.GetFile("/hidden.ts") != nil) + rebased, err := session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{ + Snapshot: target.Snapshot, + Changes: &CreateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(source.Snapshot)}, + EnsurePrograms: &EnsurePrograms{All: true}, + }, + }) + assert.NilError(t, err) + snapshot := session.snapshots[rebased.Snapshot].snapshot + assert.Assert(t, !snapshot.FileSystem().FileExists("/hidden.ts")) + assert.Assert(t, snapshot.GetFile("/hidden.ts") == nil) + assert.Assert(t, snapshot.ProjectCollection.GetProject(project.ID("/tsconfig.json")).GetProgram().GetSourceFile("/hidden.ts") == nil) +} + +func TestSnapshotFileSystemComparesTargetSnapshotContents(t *testing.T) { + t.Parallel() + for _, editor := range []bool{false, true} { + for _, layeredTarget := range []bool{false, true} { + t.Run(fmt.Sprintf("editor=%v/layer=%v", editor, layeredTarget), func(t *testing.T) { + t.Parallel() + const oldText = "export const value = 1;" + const newText = "export const value = 2;" + projectSession, _ := projecttestutil.Setup(map[string]any{ + "/tsconfig.json": `{"compilerOptions":{"noLib":true},"files":["index.ts"]}`, + "/index.ts": oldText, + }) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + expected := newText + if editor { + expected = oldText + projectSession.DidOpenFile(t.Context(), "file:///index.ts", 1, newText, lsproto.LanguageKindTypeScript) + } + target, err := session.handleGetCurrentLanguageServerSnapshot(t.Context(), &GetCurrentLanguageServerSnapshotParams{ + Changes: &LanguageServerSnapshotChanges{OpenProjects: []DocumentIdentifier{{FileName: "/tsconfig.json"}}}, + }) + assert.NilError(t, err) + if layeredTarget { + target, err = session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{ + Snapshot: target.Snapshot, + Changes: &CreateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindLayer}, + EnsurePrograms: &EnsurePrograms{All: true}, + }, + }) + assert.NilError(t, err) + } + targetProgram := session.snapshots[target.Snapshot].snapshot.ProjectCollection.GetProject(project.ID("/tsconfig.json")).GetProgram() + assert.Assert(t, targetProgram.GetSourceFile("/index.ts").Text() != expected) + if !editor { + assert.NilError(t, session.FS().WriteFile("/index.ts", newText)) + } + source, err := session.handleCreateSnapshot(t.Context(), &CreateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindLayer, Files: map[string]string{"/index.ts": expected}}, + }) + assert.NilError(t, err) + rebased, err := session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{ + Snapshot: target.Snapshot, + Changes: &CreateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(source.Snapshot)}, + EnsurePrograms: &EnsurePrograms{All: true}, + }, + }) + assert.NilError(t, err) + snapshot := session.snapshots[rebased.Snapshot].snapshot + content, ok := snapshot.ReadFile("/index.ts") + assert.Assert(t, ok) + assert.Equal(t, content, expected) + proj := snapshot.ProjectCollection.GetProject(project.ID("/tsconfig.json")) + assert.Equal(t, proj.GetProgram().GetSourceFile("/index.ts").Text(), expected) + assert.Equal(t, proj.ProgramUpdateKind, project.ProgramUpdateKindCloned) + }) + } + } +} + +func TestSnapshotFileSystemUnchangedListingPreservesIncrementalState(t *testing.T) { + t.Parallel() + projectSession, _ := projecttestutil.Setup(map[string]any{}) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + directories := map[string]requestfilesystem.RequestDirectoryEntries{ + "/": {Files: []string{"index.ts", "tsconfig.json"}}, + } + target, err := session.handleCreateSnapshot(t.Context(), &CreateSnapshotParams{ + OpenProjects: []DocumentIdentifier{{FileName: "/tsconfig.json"}}, + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindFull, + Files: map[string]string{ + "/tsconfig.json": `{"compilerOptions":{"noLib":true},"files":["index.ts","hidden.ts"]}`, + "/index.ts": "export const value = 1;", + "/hidden.ts": "export const hidden = true;", + }, + Directories: directories, + }, + }) + assert.NilError(t, err) + targetProgram := session.snapshots[target.Snapshot].snapshot.ProjectCollection.GetProject(project.ID("/tsconfig.json")).GetProgram() + source, err := session.handleCreateSnapshot(t.Context(), &CreateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindLayer, + Files: map[string]string{"/index.ts": "export const value = 2;"}, + Directories: directories, + }, + }) + assert.NilError(t, err) + rebased, err := session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{ + Snapshot: target.Snapshot, + Changes: &CreateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(source.Snapshot)}, + EnsurePrograms: &EnsurePrograms{All: true}, + }, + }) + assert.NilError(t, err) + proj := session.snapshots[rebased.Snapshot].snapshot.ProjectCollection.GetProject(project.ID("/tsconfig.json")) + assert.Assert(t, proj != nil) + assert.Equal(t, proj.ProgramUpdateKind, project.ProgramUpdateKindCloned) + assert.Equal(t, proj.GetProgram().GetSourceFile("/index.ts").Text(), "export const value = 2;") + assert.Assert(t, proj.GetProgram().GetSourceFile("/hidden.ts") == targetProgram.GetSourceFile("/hidden.ts")) +} + +func TestSnapshotFileSystemGeneratedFilesAreRelativeToTarget(t *testing.T) { + t.Parallel() + for _, targetHasGeneratedFiles := range []bool{false, true} { + t.Run(fmt.Sprintf("targetHasGeneratedFiles=%v", targetHasGeneratedFiles), func(t *testing.T) { + t.Parallel() + projectSession, _ := projecttestutil.Setup(map[string]any{ + "/tsconfig.json": `{"compilerOptions":{"noLib":true},"include":["*.ts"]}`, + "/index.ts": "export const value = 1;", + "/other/tsconfig.json": `{"compilerOptions":{"noLib":true},"files":["index.ts"]}`, + "/other/index.ts": "export {};", + }) + defer projectSession.Close() + projectSession.DidOpenFile(t.Context(), "file:///index.ts", 1, "export const value = 1;", lsproto.LanguageKindTypeScript) + session := NewLSPSession(projectSession, nil) + defer session.Close() + base, err := session.handleGetCurrentLanguageServerSnapshot(t.Context(), &GetCurrentLanguageServerSnapshotParams{ + Changes: &LanguageServerSnapshotChanges{OpenProjects: []DocumentIdentifier{{FileName: "/tsconfig.json"}, {FileName: "/other/tsconfig.json"}}}, + }) + assert.NilError(t, err) + const count = 200 + files := make(map[string]string, count) + for i := range count { + files[fmt.Sprintf("/file%d.ngtypecheck.ts", i)] = "export {};" + } + layer := &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindLayer, Files: files} + source, err := session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{ + Snapshot: base.Snapshot, + Changes: &CreateSnapshotParams{FileSystem: layer, EnsurePrograms: &EnsurePrograms{All: true}}, + }) + assert.NilError(t, err) + projectSession.DidChangeFile(t.Context(), "file:///index.ts", 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{{ + WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{Text: "export const value = 2;"}, + }}) + target, err := session.handleGetCurrentLanguageServerSnapshot(t.Context(), &GetCurrentLanguageServerSnapshotParams{}) + assert.NilError(t, err) + if targetHasGeneratedFiles { + target, err = session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{ + Snapshot: target.Snapshot, + Changes: &CreateSnapshotParams{FileSystem: layer, EnsurePrograms: &EnsurePrograms{All: true}}, + }) + assert.NilError(t, err) + } + targetSnapshot := session.snapshots[target.Snapshot].snapshot + unrelated := targetSnapshot.ProjectCollection.GetProject(project.ID("/other/tsconfig.json")).GetProgram() + rebased, err := session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{ + Snapshot: target.Snapshot, + Changes: &CreateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(source.Snapshot), + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindLayer, Files: map[string]string{"/index.ts": "export const value = 3;"}, + }, + }, + EnsurePrograms: &EnsurePrograms{All: true}, + }, + }) + assert.NilError(t, err) + snapshot := session.snapshots[rebased.Snapshot].snapshot + proj := snapshot.ProjectCollection.GetProject(project.ID("/tsconfig.json")) + expectedKind := project.ProgramUpdateKindNewFiles + if targetHasGeneratedFiles { + expectedKind = project.ProgramUpdateKindCloned + } + assert.Equal(t, proj.ProgramUpdateKind, expectedKind) + assert.Equal(t, len(proj.GetProgram().SourceFiles()), count+1) + assert.Equal(t, proj.GetProgram().GetSourceFile("/index.ts").Text(), "export const value = 3;") + assert.Assert(t, snapshot.ProjectCollection.GetProject(project.ID("/other/tsconfig.json")).GetProgram() == unrelated) + assert.DeepEqual(t, rebased.Changes, &SnapshotChanges{ + ChangedProjects: map[project.ID]*ProjectFileChanges{ + "/tsconfig.json": {ChangedFiles: []tspath.PathKey{"/index.ts"}}, + }, + }) + }) + } +} + +func TestSnapshotFileSystemPreservesFullInvalidation(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) + 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(updated.Projects[0].Handle) + assert.NilError(t, err) + assert.Equal(t, updatedProgram.GetSourceFile("/index.ts").Text(), "export const value = 2;") + for _, targetID := range []SnapshotID{base.Snapshot, target.Snapshot} { + snapshotChanges := *changes + snapshotChanges.FileSystem = &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(base.Snapshot)} + rebased, err := session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{Snapshot: targetID, Changes: &snapshotChanges}) + assert.NilError(t, err) + program, err := session.snapshots[rebased.Snapshot].getProgram(rebased.Projects[0].Handle) + assert.NilError(t, err) + assert.Equal(t, program.GetSourceFile("/index.ts").Text(), "export const value = 2;") + } +} + +func TestSnapshotFileSystemRefreshesDependencyAutoImports(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.Handle(), 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.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{ + Snapshot: target.Snapshot, + Changes: &CreateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(source.Snapshot)}, + PrepareAutoImports: &DocumentIdentifier{FileName: fileName}, + }, + }) + assert.NilError(t, err) + names := completionNames(rebased.Snapshot) + assert.Assert(t, slices.Contains(names, "packageNew")) + assert.Assert(t, !slices.Contains(names, "packageOld")) +} + +func TestSelfSnapshotFileSystemMatchesUpdateProgramState(t *testing.T) { + t.Parallel() + for _, baseKind := range []requestfilesystem.Kind{"host", requestfilesystem.KindLayer, requestfilesystem.KindFull} { + t.Run(string(baseKind), func(t *testing.T) { + 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) + snapshotChanges := *changes + snapshotChanges.FileSystem = &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(base.Snapshot), + FileSystem: changes.FileSystem, + } + rebased, err := session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{Snapshot: base.Snapshot, Changes: &snapshotChanges}) + assert.NilError(t, err, name) + // Projects created independently have distinct lifetime handles. + assert.DeepEqual(t, rebased.Projects, updated.Projects, cmpopts.IgnoreFields(ProjectResponse{}, "Handle")) + 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_snapshot_filesystem_test.go b/tsc/internal/api/session_snapshot_filesystem_test.go new file mode 100644 index 0000000000000..8d311b312243d --- /dev/null +++ b/tsc/internal/api/session_snapshot_filesystem_test.go @@ -0,0 +1,319 @@ +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 TestSnapshotFileSystem(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.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ + Snapshot: target.Snapshot, + Changes: &CreateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(extras.Snapshot)}, + EnsurePrograms: &EnsurePrograms{All: true}, + }, + }) + assert.NilError(t, err) + assert.Assert(t, rebased.Snapshot != extras.Snapshot && rebased.Snapshot != target.Snapshot) + 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 TestSnapshotFileSystemKinds(t *testing.T) { + t.Parallel() + for _, sourceKind := range []requestfilesystem.Kind{"host", requestfilesystem.KindLayer, requestfilesystem.KindFull} { + for _, targetKind := range []requestfilesystem.Kind{"host", requestfilesystem.KindLayer, requestfilesystem.KindFull} { + 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.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ + Snapshot: target.Snapshot, + Changes: &CreateSnapshotParams{FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(source.Snapshot)}}, + }) + assert.NilError(t, err) + assert.Assert(t, rebased.Snapshot != source.Snapshot && rebased.Snapshot != target.Snapshot) + assert.NilError(t, session.releaseSnapshot(source.Snapshot)) + 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 TestSnapshotFileSystemRejectsInactiveSnapshots(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.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ + Snapshot: snapshot.Snapshot, + Changes: &CreateSnapshotParams{FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindSnapshot, Snapshot: 999}}, + }) + assert.ErrorIs(t, err, ErrClientError) + _, err = session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ + Snapshot: 999, + Changes: &CreateSnapshotParams{FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(snapshot.Snapshot)}}, + }) + assert.ErrorIs(t, err, ErrClientError) + assert.Equal(t, session.snapshots[snapshot.Snapshot].refCount, 1) + rebased, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ + Snapshot: snapshot.Snapshot, + Changes: &CreateSnapshotParams{FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(snapshot.Snapshot)}}, + }) + assert.NilError(t, err) + assert.Assert(t, rebased.Snapshot != snapshot.Snapshot) + assert.Equal(t, session.snapshots[snapshot.Snapshot].refCount, 1) + assert.NilError(t, session.releaseSnapshot(snapshot.Snapshot)) + _, err = session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ + Snapshot: rebased.Snapshot, + Changes: &CreateSnapshotParams{FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(snapshot.Snapshot)}}, + }) + assert.ErrorIs(t, err, ErrClientError) + _, err = session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ + Snapshot: snapshot.Snapshot, + Changes: &CreateSnapshotParams{FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(rebased.Snapshot)}}, + }) + assert.ErrorIs(t, err, ErrClientError) +} + +func TestCreateSnapshotWithSnapshotFileSystem(t *testing.T) { + t.Parallel() + for _, kind := range []requestfilesystem.Kind{requestfilesystem.KindLayer, requestfilesystem.KindFull} { + t.Run(string(kind), func(t *testing.T) { + t.Parallel() + projectSession, _ := projecttestutil.Setup(map[string]any{"/host.ts": "host"}) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + source, err := session.handleCreateSnapshot(t.Context(), &CreateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindLayer, + Files: map[string]string{"/source.ts": "source", "/shared.ts": "source", "/removed/nested.ts": "removed"}, + }, + }) + assert.NilError(t, err) + cloned, err := session.handleCreateSnapshot(t.Context(), &CreateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(source.Snapshot)}, + }) + assert.NilError(t, err) + assert.Assert(t, cloned.Snapshot != source.Snapshot) + content, ok := session.snapshots[cloned.Snapshot].snapshot.ReadFile("/source.ts") + assert.Assert(t, ok) + assert.Equal(t, content, "source") + created, err := session.handleCreateSnapshot(t.Context(), &CreateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(source.Snapshot), + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: kind, + Files: map[string]string{"/extra.ts": "extra", "/shared.ts": "extra"}, + Directories: map[string]requestfilesystem.RequestDirectoryEntries{ + "/listed": {Files: []string{"entry.ts"}}, + }, + Symlinks: map[string]requestfilesystem.RequestSymlink{ + "/alias.ts": {Target: "/shared.ts"}, + }, + RemovedPaths: []string{"/removed"}, + }, + }, + }) + assert.NilError(t, err) + assert.Assert(t, created.Snapshot != source.Snapshot) + assert.NilError(t, session.releaseSnapshot(source.Snapshot)) + snapshot := session.snapshots[created.Snapshot].snapshot + for fileName, expected := range map[tspath.RootedFilePath]string{ + "/extra.ts": "extra", "/shared.ts": "extra", "/alias.ts": "extra", + } { + content, ok = snapshot.ReadFile(fileName) + assert.Assert(t, ok) + assert.Equal(t, content, expected) + } + _, ok = snapshot.ReadFile("/source.ts") + assert.Equal(t, ok, kind == requestfilesystem.KindLayer) + _, ok = snapshot.ReadFile("/host.ts") + assert.Equal(t, ok, kind == requestfilesystem.KindLayer) + _, ok = snapshot.ReadFile("/removed/nested.ts") + assert.Assert(t, !ok) + assert.Assert(t, snapshot.FileSystem().DirectoryExists("/listed")) + }) + } +} + +func TestSnapshotFileSystemRejectsInvalidDescriptors(t *testing.T) { + t.Parallel() + projectSession, _ := projecttestutil.Setup(map[string]any{}) + defer projectSession.Close() + session := NewLSPSession(projectSession, nil) + defer session.Close() + source, err := session.handleCreateSnapshot(t.Context(), &CreateSnapshotParams{}) + assert.NilError(t, err) + released, err := session.handleCreateSnapshot(t.Context(), &CreateSnapshotParams{}) + assert.NilError(t, err) + assert.NilError(t, session.releaseSnapshot(released.Snapshot)) + for name, fileSystem := range map[string]*requestfilesystem.RequestFileSystem{ + "invalid kind": {Kind: "invalid"}, + "zero snapshot": {Kind: requestfilesystem.KindSnapshot}, + "unknown snapshot": {Kind: requestfilesystem.KindSnapshot, Snapshot: 999}, + "released snapshot": {Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(released.Snapshot)}, + "snapshot on layer": {Kind: requestfilesystem.KindLayer, Snapshot: uint64(source.Snapshot)}, + "nested filesystem on full": { + Kind: requestfilesystem.KindFull, + FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindLayer}, + }, + "nested snapshot": { + Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(source.Snapshot), + FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(source.Snapshot)}, + }, + "invalid nested kind": { + Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(source.Snapshot), + FileSystem: &requestfilesystem.RequestFileSystem{Kind: "invalid"}, + }, + "unnested entries": { + Kind: requestfilesystem.KindSnapshot, Snapshot: uint64(source.Snapshot), + Files: map[string]string{"/extra.ts": "extra"}, + }, + } { + t.Log(name) + _, err = session.handleCreateSnapshot(t.Context(), &CreateSnapshotParams{FileSystem: fileSystem}) + assert.ErrorIs(t, err, ErrClientError, name) + _, err = session.handleUpdateSnapshot(t.Context(), &UpdateSnapshotParams{ + Snapshot: source.Snapshot, + Changes: &CreateSnapshotParams{FileSystem: fileSystem}, + }) + assert.ErrorIs(t, err, ErrClientError, name) + assert.Equal(t, session.snapshots[source.Snapshot].refCount, 1) + } +}