From ad71d17f7bb2c3a9e096603120c611265c148b17 Mon Sep 17 00:00:00 2001 From: Wesley Wigham Date: Mon, 31 Aug 2026 15:48:27 -0700 Subject: [PATCH 01/12] Add optional VFS parameters to updateSnapshot --- packages/typescript/src/api/async/api.ts | 139 ++- packages/typescript/src/api/async/client.ts | 10 +- packages/typescript/src/api/async/types.ts | 7 +- packages/typescript/src/api/fs.ts | 148 ++- packages/typescript/src/api/path.ts | 5 +- .../typescript/src/api/proto.generated.ts | 63 ++ packages/typescript/src/api/proto.ts | 7 +- packages/typescript/src/api/sync/api.ts | 274 ++++- packages/typescript/src/api/sync/types.ts | 7 +- packages/typescript/test/async/api.test.ts | 645 +++++++++++- .../test/sync/api-generators.test.ts | 28 + packages/typescript/test/sync/api.test.ts | 621 +++++++++++- tsc/internal/api/proto.go | 63 +- tsc/internal/api/session.go | 144 ++- .../api/session_createprogram_test.go | 36 + tsc/internal/api/snapshotfilesystem.go | 958 ++++++++++++++++++ tsc/internal/api/snapshotfilesystem_test.go | 675 ++++++++++++ tsc/internal/project/api.go | 20 +- tsc/internal/project/refcountcache_test.go | 7 +- tsc/internal/project/snapshot.go | 34 +- tsc/internal/vfs/cachedvfs/cachedvfs.go | 5 + 21 files changed, 3831 insertions(+), 65 deletions(-) create mode 100644 tsc/internal/api/snapshotfilesystem.go create mode 100644 tsc/internal/api/snapshotfilesystem_test.go diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index dc265882bfbca..ba200c2b5fe87 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -232,6 +232,7 @@ export class API implements FormatDiagnosticsHo private currentDirectory: string | undefined; private getCanonicalFileNameWorker: ((fileName: string) => string) | undefined; private initialized: boolean = false; + private initializing: Promise | undefined; private activeSnapshots: Set = new Set(); private latestSnapshot: Snapshot | undefined; readonly internal: InternalAPI; @@ -269,7 +270,12 @@ export class API implements FormatDiagnosticsHo // @sync-only-end private async ensureInitialized(): Promise { - if (!this.initialized) { + if (this.initialized) return; + return this.initializing ??= this.initializeWorker(); + } + + private async initializeWorker(): Promise { + try { const response = await this.client.apiRequest("initialize", null); const getCanonicalFileName = createGetCanonicalFileName(response.useCaseSensitiveFileNames); const currentDirectory = response.currentDirectory; @@ -278,6 +284,10 @@ export class API implements FormatDiagnosticsHo this.toPath = (fileName: string) => toPath(fileName, currentDirectory, getCanonicalFileName) as Path; this.initialized = true; } + catch (error) { + this.initializing = undefined; + throw error; + } } getCurrentDirectory(): string { @@ -344,9 +354,29 @@ export class API implements FormatDiagnosticsHo } async updateSnapshot(params?: FromLSP extends true ? LSPUpdateSnapshotParams : UpdateSnapshotParams): Promise { + return this.updateSnapshotWorker(params); + } + + /** @internal */ + async updateSnapshotFrom(baseSnapshot: Snapshot, params?: UpdateSnapshotParams): Promise { + if (!this.activeSnapshots.has(baseSnapshot) || baseSnapshot.isDisposed()) { + throw new Error("Cannot update an inactive snapshot"); + } + if (baseSnapshot !== this.latestSnapshot) { + // TODO: Support forking active memory/cache snapshots once the server-side + // ownership, project state, and cache semantics have been worked out. + throw new Error("Snapshot.update can only update the latest snapshot"); + } + return this.updateSnapshotWorker(params, baseSnapshot); + } + + private async updateSnapshotWorker( + params?: LSPUpdateSnapshotParams | UpdateSnapshotParams, + baseSnapshot?: Snapshot, + ): Promise { await this.ensureInitialized(); - const requestParams = toUpdateSnapshotRequest(params); + const requestParams = toUpdateSnapshotRequest(params, baseSnapshot?.id); const data = await this.client.apiRequest("updateSnapshot", requestParams); // Retain cached source files from previous snapshot for unchanged files @@ -466,6 +496,30 @@ export class API implements FormatDiagnosticsHo createProgramOptions: CreateProgramOptions, oldProgram?: Program, fileChanges?: APIFileChanges, + ): Promise { + return this.createProgramWorker(rootFiles, createProgramOptions, oldProgram, fileChanges); + } + + /** @internal */ + async createProgramFromSnapshot( + baseSnapshot: Snapshot, + rootFiles: readonly DocumentIdentifier[], + createProgramOptions: CreateProgramOptions, + oldProgram?: Program, + fileChanges?: APIFileChanges, + ): Promise { + if (!this.activeSnapshots.has(baseSnapshot) || baseSnapshot.isDisposed()) { + throw new Error("Cannot create a program from an inactive snapshot"); + } + return this.createProgramWorker(rootFiles, createProgramOptions, oldProgram, fileChanges, baseSnapshot); + } + + private async createProgramWorker( + rootFiles: readonly DocumentIdentifier[], + createProgramOptions: CreateProgramOptions, + oldProgram?: Program, + fileChanges?: APIFileChanges, + baseSnapshot?: Snapshot, ): Promise { await this.ensureInitialized(); @@ -479,6 +533,7 @@ export class API implements FormatDiagnosticsHo const data: CreateProgramResponse = await this.client.apiRequest("createProgram", { rootFiles, createProgramOptions, + ...(baseSnapshot ? { baseSnapshot: baseSnapshot.id } : {}), ...(oldProgram ? { oldProgram: { snapshot: oldProgram.snapshotId, project: oldProgram.getProject().id } } : {}), ...(fileChanges ? { fileChanges } : {}), }); @@ -505,6 +560,17 @@ export class API implements FormatDiagnosticsHo type EnsureInitialized = () => Promise; // @sync: type EnsureInitialized = (() => void) & { gen(): Generator; }; +interface SnapshotOwner extends FormatDiagnosticsHost { + updateSnapshotFrom(baseSnapshot: Snapshot, params?: UpdateSnapshotParams): Promise; + createProgramFromSnapshot( + baseSnapshot: Snapshot, + rootFiles: readonly DocumentIdentifier[], + createProgramOptions: CreateProgramOptions, + oldProgram?: Program, + fileChanges?: APIFileChanges, + ): Promise; +} + export class InternalAPI { private client: Client; private ensureInitialized: EnsureInitialized; @@ -539,7 +605,9 @@ export class Snapshot { private toPath: (fileName: string) => Path; private client: Client; private disposed: boolean = false; + private disposePromise: Promise | undefined; private onDispose: () => void; + private api: SnapshotOwner; private snapshotRegistry: SnapshotObjectRegistry; readonly internal: SnapshotInternalAPI; @@ -548,18 +616,19 @@ export class Snapshot { client: Client, sourceFileCache: SourceFileCache, toPath: (fileName: string) => Path, - formatDiagnosticsHost: FormatDiagnosticsHost, + api: SnapshotOwner, onDispose: () => void, ) { this.id = data.snapshot; this.client = client; this.toPath = toPath; + this.api = api; this.onDispose = onDispose; this.projectMap = new Map(); this.snapshotRegistry = new SnapshotObjectRegistry(client, this.id, projectId => this.projectMap.get(projectId)); for (const projData of data.projects) { - const project = new Project(projData, this.id, client, sourceFileCache, toPath, formatDiagnosticsHost, this.snapshotRegistry); + const project = new Project(projData, this.id, client, sourceFileCache, toPath, api, this.snapshotRegistry); this.projectMap.set(toPath(projData.configFileName), project); } @@ -586,11 +655,38 @@ export class Snapshot { return this.projectMap.get(this.toPath(data.configFileName)); } + /** + * Creates the next snapshot, layering its filesystem over this snapshot's + * filesystem. This snapshot must still be active and be the latest snapshot. + */ + async update(params?: UpdateSnapshotParams): Promise { + this.ensureNotDisposed(); + return this.api.updateSnapshotFrom(this, params); + } + + /** + * Creates an isolated program using this snapshot and its effective filesystem + * as the base. Usage is otherwise identical to {@link API.createProgram}. + */ + async createProgram( + rootFiles: readonly DocumentIdentifier[], + createProgramOptions: CreateProgramOptions, + oldProgram?: Program, + fileChanges?: APIFileChanges, + ): Promise { + this.ensureNotDisposed(); + return this.api.createProgramFromSnapshot(this, rootFiles, createProgramOptions, oldProgram, fileChanges); + } + [globalThis.Symbol.dispose](): void { - this.dispose(); + void this.dispose(); + } + + dispose(): Promise { + return this.disposePromise ??= this.disposeWorker(); } - async dispose(): Promise { + private async disposeWorker(): Promise { if (this.disposed) return; this.disposed = true; for (const project of this.projectMap.values()) { @@ -598,8 +694,12 @@ export class Snapshot { } this.projectMap.clear(); this.snapshotRegistry.clear(); - this.onDispose(); - await this.client.apiRequest("release", { snapshot: this.id }); + try { + await this.client.apiRequest("release", { snapshot: this.id }); + } + finally { + this.onDispose(); + } } isDisposed(): boolean { @@ -1082,6 +1182,7 @@ export class Program implements FormatDiagnosticsHost { private readonly decoder = new Wtf8Decoder(); private readonly sourceFileMetadataCache = new Map>(); private ownedSnapshot: Snapshot | undefined; + private disposePromise: Promise | undefined; constructor( snapshotId: number, @@ -1117,10 +1218,14 @@ export class Program implements FormatDiagnosticsHost { } [globalThis.Symbol.dispose](): void { - this.dispose(); + void this.dispose(); } - async dispose(): Promise { + dispose(): Promise { + return this.disposePromise ??= this.disposeWorker(); + } + + private async disposeWorker(): Promise { const snapshot = this.ownedSnapshot; this.ownedSnapshot = undefined; if (snapshot) await snapshot.dispose(); @@ -1363,10 +1468,9 @@ export class Program implements FormatDiagnosticsHost { } /** - * Emits files to the configured filesystem. - * - * When the API has a virtual filesystem with a `writeFile` callback, output - * is written there. Otherwise, the server writes directly to the host filesystem. + * Emits files to the configured filesystem. Cache and host filesystems are + * written through; memory filesystems remain immutable and return emitted + * files in {@link EmitResult.fileSystem}. */ async emit(emitOnly?: EmitOnly): Promise { const response = await this.client.apiRequest("emit", { @@ -1374,10 +1478,17 @@ export class Program implements FormatDiagnosticsHost { project: this.project.id, ...(emitOnly !== undefined ? { emitOnly } : {}), }); + const fileSystem = response.emittedFilesContents.length + ? { + kind: "cache" as const, + files: Object.fromEntries(response.emittedFiles.map((fileName, index) => [fileName, response.emittedFilesContents[index]])), + } + : undefined; return { emitSkipped: response.emitSkipped, diagnostics: response.diagnostics, emittedFiles: response.emittedFiles, + ...(fileSystem ? { fileSystem } : {}), }; } diff --git a/packages/typescript/src/api/async/client.ts b/packages/typescript/src/api/async/client.ts index 5ed5397cf55cd..bfeb0d5defd31 100644 --- a/packages/typescript/src/api/async/client.ts +++ b/packages/typescript/src/api/async/client.ts @@ -49,6 +49,7 @@ export class Client { private connection: MessageConnection | undefined; private options: ClientOptions; private connected = false; + private connecting: Promise | undefined; private timing: TimingCollector | undefined; private batchedRequests: { method: APIRequest["method"]; params: APIRequest["params"]; resolve: (value: unknown) => void; reject: (reason?: any) => void; }[] = []; private nextBatch: NodeJS.Immediate | "manual" | undefined; @@ -60,9 +61,14 @@ export class Client { } } - async connect(): Promise { - if (this.connected) return; + connect(): Promise { + if (this.connected) return Promise.resolve(); + return this.connecting ??= this.connectWorker().finally(() => { + this.connecting = undefined; + }); + } + private async connectWorker(): Promise { if (isSpawnOptions(this.options)) { await this.connectViaSpawn(this.options); } diff --git a/packages/typescript/src/api/async/types.ts b/packages/typescript/src/api/async/types.ts index 8df18f408c5b7..e06014f4ee9fe 100644 --- a/packages/typescript/src/api/async/types.ts +++ b/packages/typescript/src/api/async/types.ts @@ -8,7 +8,10 @@ import type { NamedTupleMember, ParameterDeclaration, } from "../../ast/ast.ts"; -import type { Diagnostic } from "../proto.ts"; +import type { + Diagnostic, + SnapshotFileSystem, +} from "../proto.ts"; import type { NodeHandle, Signature, @@ -401,6 +404,8 @@ export interface EmitResult { readonly emitSkipped: boolean; readonly diagnostics: readonly Diagnostic[]; readonly emittedFiles: readonly string[]; + /** Emitted files captured as a cache layer suitable for {@link Snapshot.update}. */ + readonly fileSystem?: SnapshotFileSystem | undefined; } export interface EmitOutput { diff --git a/packages/typescript/src/api/fs.ts b/packages/typescript/src/api/fs.ts index 0deb7b6a668e2..50c3be7cf69e2 100644 --- a/packages/typescript/src/api/fs.ts +++ b/packages/typescript/src/api/fs.ts @@ -1,4 +1,18 @@ -import { getPathComponents } from "./path.ts"; +import getExePath from "#getExePath"; +import { dirname } from "node:path"; +import { + getPathComponents, + normalizePath, +} from "./path.ts"; +import type { + SnapshotDirectoryEntries, + SnapshotFileSystem, + SnapshotSymlink, +} from "./proto.generated.ts"; +import { + type DocumentIdentifier, + resolveFileName, +} from "./proto.ts"; export interface FileSystemEntries { files: string[]; @@ -24,6 +38,138 @@ export interface FileSystem { /** The callback names supported by the Go server for virtual FS delegation. */ export const fsCallbackNames = ["readFile", "fileExists", "directoryExists", "getAccessibleEntries", "realpath", "writeFile"] as const; +export interface CreateSnapshotFileSystemOptions { + /** Complete directory listings. Derived from `files` when omitted. */ + directories?: Record; + symlinks?: Record; + /** Files or directory trees hidden from an underlying snapshot or host filesystem. */ + removedPaths?: readonly string[]; +} + +export interface CreateMemoryFileSystemWithLibOptions extends CreateSnapshotFileSystemOptions { + /** Default library directory used by a custom or non-embedded compiler executable. */ + defaultLibraryPath?: string; +} + +/** + * Files supplied to a snapshot filesystem. String identifiers are file names; + * use `{ uri }` when supplying a document URI so it can be decoded correctly. + */ +export type SnapshotFileEntries = readonly (readonly [id: DocumentIdentifier, content: string])[]; + +/** Creates a total memory snapshot filesystem, deriving directory listings when omitted. */ +export function createMemoryFileSystem( + files: SnapshotFileEntries, + options: CreateSnapshotFileSystemOptions = {}, +): SnapshotFileSystem { + return createSnapshotFileSystem("memory", files, options); +} + +/** + * Creates a total memory snapshot filesystem with the compiler's default library + * directory mounted read-only through the host filesystem. + */ +export function createMemoryFileSystemWithLib( + files: SnapshotFileEntries, + options: CreateMemoryFileSystemWithLibOptions = {}, +): SnapshotFileSystem { + const defaultLibraryPaths = options.defaultLibraryPath + ? [normalizePath(options.defaultLibraryPath)] + : [normalizePath("bundled:///libs")]; + if (!options.defaultLibraryPath) { + try { + defaultLibraryPaths.push(normalizePath(dirname(getExePath()))); + } + catch { + // A socket-connected embedded server can provide bundled libs without + // a locally installed compiler executable. + } + } + const symlinks = { ...options.symlinks }; + for (const defaultLibraryPath of defaultLibraryPaths) { + symlinks[defaultLibraryPath] ??= { target: defaultLibraryPath, host: true }; + } + return createSnapshotFileSystem("memory", files, { + symlinks, + ...(options.directories ? { directories: options.directories } : {}), + ...(options.removedPaths?.length ? { removedPaths: options.removedPaths } : {}), + }); +} + +/** Creates a read-through cache snapshot filesystem, deriving directory listings when omitted. */ +export function createCacheFileSystem( + files: SnapshotFileEntries, + options: CreateSnapshotFileSystemOptions = {}, +): SnapshotFileSystem { + return createSnapshotFileSystem("cache", files, options); +} + +function createSnapshotFileSystem( + kind: SnapshotFileSystem["kind"], + files: SnapshotFileEntries, + options: CreateSnapshotFileSystemOptions, +): SnapshotFileSystem { + const normalizedFiles: Record = {}; + for (const [id, content] of files) { + const fileName = resolveFileName(id); + if (Object.hasOwn(normalizedFiles, fileName)) { + throw new Error(`Duplicate snapshot filesystem path: ${fileName}`); + } + normalizedFiles[fileName] = content; + } + return { + kind, + files: normalizedFiles, + directories: options.directories ?? deriveDirectoryListings(normalizedFiles), + ...(options.symlinks ? { symlinks: options.symlinks } : {}), + ...(options.removedPaths?.length ? { removedPaths: [...options.removedPaths] } : {}), + }; +} + +function deriveDirectoryListings(files: Record): Record { + const listings = new Map; directories: Set; }>(); + const getListing = (directory: string) => { + let listing = listings.get(directory); + if (!listing) { + listing = { files: new Set(), directories: new Set() }; + listings.set(directory, listing); + } + return listing; + }; + + for (const inputPath of Object.keys(files)) { + const filePath = normalizePath(inputPath); + const fileName = getBaseName(filePath); + let directory = getDirectory(filePath); + getListing(directory).files.add(fileName); + + let parent = getDirectory(directory); + while (parent !== directory) { + getListing(parent).directories.add(getBaseName(directory)); + directory = parent; + parent = getDirectory(directory); + } + } + + return Object.fromEntries([...listings].map(([directory, listing]) => [directory, { + files: [...listing.files], + directories: [...listing.directories], + }])); +} + +function getDirectory(path: string): string { + const components = getPathComponents(path); + if (components.length <= 1) return components[0] ?? ""; + components.pop(); + const root = components.shift()!; + return root + components.join("/"); +} + +function getBaseName(path: string): string { + const components = getPathComponents(path); + return components.at(-1) ?? ""; +} + interface VDirectory { type: "directory"; children: Record; diff --git a/packages/typescript/src/api/path.ts b/packages/typescript/src/api/path.ts index 2d30b2a4a49e5..ef140b96c0dee 100644 --- a/packages/typescript/src/api/path.ts +++ b/packages/typescript/src/api/path.ts @@ -548,13 +548,14 @@ export function documentURIToFileName(uri: string): string { throw new Error("invalid file URI: " + uri); } + const path = decodeURIComponent(parsed.pathname); + // UNC path: file://server/share/... if (parsed.host !== "") { - return "//" + parsed.host + parsed.pathname; + return "//" + parsed.host + path; } // Local file - fix Windows path by removing leading slash before volume - const path = decodeURIComponent(parsed.pathname); if (path.length >= 3 && path.charCodeAt(0) === CharacterCodesSlash) { const [volume, rest, ok] = splitVolumePath(path.substring(1)); if (ok) { diff --git a/packages/typescript/src/api/proto.generated.ts b/packages/typescript/src/api/proto.generated.ts index b920cda9a7ef3..213d88785ef7c 100644 --- a/packages/typescript/src/api/proto.generated.ts +++ b/packages/typescript/src/api/proto.generated.ts @@ -189,6 +189,11 @@ export interface InitializeResponse { * All fields are optional. With no fields set, the server adopts the latest LSP state. */ export interface UpdateSnapshotParams { + /** + * Snapshot, when set, requires this to be the latest active snapshot and layers + * FileSystem over that snapshot's filesystem. Used by Snapshot.update. + */ + snapshot?: number; /** * OpenProjects lists tsconfig.json files to open/load in the new snapshot. * Opens are ref-counted and persist across snapshots until closed. @@ -201,6 +206,12 @@ export interface UpdateSnapshotParams { closeProjects?: readonly DocumentIdentifier[]; /** FileChanges describes file system changes since the last snapshot. */ fileChanges?: APIFileChanges; + /** + * FileSystem supplies file contents and directory listings for the new snapshot. + * A memory filesystem is canonical and total. A cache filesystem is checked + * before falling back to the host filesystem. + */ + fileSystem?: SnapshotFileSystem; /** * OpenFiles lists files to keep open for the API client, mirroring LSP's * textDocument/didOpen. For each file, ancestor directories are searched for a @@ -246,6 +257,11 @@ export interface UpdateTemporarySnapshotParams { export interface CreateProgramParams { rootFiles: readonly DocumentIdentifier[] | null; createProgramOptions: CreateProgramOptions; + /** + * BaseSnapshot supplies the filesystem and project state from which the + * synthetic program snapshot is cloned. + */ + baseSnapshot?: number; oldProgram?: CreateProgramOldProgramParams; fileChanges?: APIFileChanges; } @@ -852,6 +868,11 @@ export interface EmitResponse { emitSkipped: boolean; diagnostics: DiagnosticResponse[]; emittedFiles: string[]; + /** + * EmittedFilesContents contains contents parallel to EmittedFiles when the + * source snapshot uses a memory filesystem. It is empty for write-through emits. + */ + emittedFilesContents: string[]; } export interface EmitOutputResponse { @@ -1207,6 +1228,25 @@ export interface APIFileChanges { deleted?: DocumentIdentifier[]; } +/** + * SnapshotFileSystem supplies file contents and, optionally, directory listings + * for a snapshot update. + */ +export interface SnapshotFileSystem { + kind: "cache" | "memory"; + /** Files maps file names to their complete contents. */ + files: Record; + /** Directories maps directory names to complete listing results. */ + directories?: Record; + /** Symlinks maps link paths to targets in this filesystem or the host filesystem. */ + symlinks?: Record; + /** + * RemovedPaths lists files or directory trees that must be treated as missing + * even when present in an underlying snapshot or host filesystem. + */ + removedPaths?: string[]; +} + /** * SnapshotChanges describes what changed between the previous latest snapshot * and the newly created snapshot. Changes are reported per-project so clients @@ -1398,6 +1438,29 @@ export interface EmitOutputFile { sourceFileName?: string; } +/** + * SnapshotDirectoryEntries is a cached directory listing. Entry names are + * relative to the directory, matching vfs.GetAccessibleEntries. + */ +export interface SnapshotDirectoryEntries { + files: string[]; + directories: string[]; +} + +/** SnapshotSymlink describes a symbolic link in a snapshot filesystem. */ +export interface SnapshotSymlink { + /** + * Target is resolved relative to the directory containing the link, matching + * native symbolic-link semantics. + */ + target: string; + /** + * Host routes the target through the host filesystem. This is the only way a + * memory filesystem can access paths not supplied in the snapshot filesystem. + */ + host?: boolean; +} + /** ProjectFileChanges describes what source files changed within a single project. */ export interface ProjectFileChanges { /** ChangedFiles lists source file paths whose content differs. */ diff --git a/packages/typescript/src/api/proto.ts b/packages/typescript/src/api/proto.ts index 0be9ac250a756..3f090ebf548de 100644 --- a/packages/typescript/src/api/proto.ts +++ b/packages/typescript/src/api/proto.ts @@ -80,7 +80,7 @@ export function resolveDocumentURI(identifier: DocumentIdentifier): string { return identifier.uri; } -export interface LSPUpdateSnapshotParams extends CoreUpdateSnapshotParams { +export interface LSPUpdateSnapshotParams extends Omit { /** * @deprecated Use {@link openProjects} instead. * Path to a tsconfig.json file to open in the new snapshot. @@ -94,7 +94,7 @@ export interface LSPUpdateSnapshotParams extends CoreUpdateSnapshotParams { /** * Parameters for updateSnapshot, including deprecated members handled by `toUpdateSnapshotRequest` */ -export interface UpdateSnapshotParams extends CoreUpdateSnapshotParams { +export interface UpdateSnapshotParams extends Omit { /** * @deprecated Use {@link openProjects} instead. * Path to a tsconfig.json file to open in the new snapshot. @@ -107,13 +107,14 @@ export interface UpdateSnapshotParams extends CoreUpdateSnapshotParams { * compatibility shim: a single `openProject` is folded into `openProjects` and is * never sent on the wire. */ -export function toUpdateSnapshotRequest(params?: UpdateSnapshotParams): UpdateSnapshotParams { +export function toUpdateSnapshotRequest(params?: UpdateSnapshotParams, snapshot?: number): CoreUpdateSnapshotParams { const { openProject, openProjects, ...rest } = params ?? {}; const mergedOpenProjects = openProject !== undefined ? [resolveFileName(openProject), ...(openProjects ?? [])] : openProjects; return { ...rest, + ...(snapshot !== undefined ? { snapshot } : {}), ...(mergedOpenProjects !== undefined ? { openProjects: mergedOpenProjects } : {}), }; } diff --git a/packages/typescript/src/api/sync/api.ts b/packages/typescript/src/api/sync/api.ts index 5da50411058c3..e678d4e00b312 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -251,6 +251,7 @@ export class API implements FormatDiagnosticsHo private currentDirectory: string | undefined; private getCanonicalFileNameWorker: ((fileName: string) => string) | undefined; private initialized: boolean = false; + private initializing: void | undefined; private activeSnapshots: Set = new Set(); private latestSnapshot: Snapshot | undefined; readonly internal: InternalAPI; @@ -304,7 +305,26 @@ export class API implements FormatDiagnosticsHo owner, "ensureInitialized", function (): void { - if (!owner.initialized) { + if (owner.initialized) return; + return owner.initializing ??= owner.initializeWorker(); + }, + function* (): Generator { + if (owner.initialized) return; + return owner.initializing ??= yield* owner.initializeWorker.gen(); + }, + ); + } + + private get initializeWorker(): { + (): void; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "initializeWorker", + function (): void { + try { const response = owner.client.apiRequest("initialize", null); const getCanonicalFileName = createGetCanonicalFileName(response.useCaseSensitiveFileNames); const currentDirectory = response.currentDirectory; @@ -313,9 +333,13 @@ export class API implements FormatDiagnosticsHo owner.toPath = (fileName: string) => toPath(fileName, currentDirectory, getCanonicalFileName) as Path; owner.initialized = true; } + catch (error) { + owner.initializing = undefined; + throw error; + } }, function* (): Generator { - if (!owner.initialized) { + try { const response = yield* apiRequest("initialize", null); const getCanonicalFileName = createGetCanonicalFileName(response.useCaseSensitiveFileNames); const currentDirectory = response.currentDirectory; @@ -324,6 +348,10 @@ export class API implements FormatDiagnosticsHo owner.toPath = (fileName: string) => toPath(fileName, currentDirectory, getCanonicalFileName) as Path; owner.initialized = true; } + catch (error) { + owner.initializing = undefined; + throw error; + } }, ); } @@ -527,9 +555,60 @@ export class API implements FormatDiagnosticsHo owner, "updateSnapshot", function (params?: FromLSP extends true ? LSPUpdateSnapshotParams : UpdateSnapshotParams): Snapshot { + return owner.updateSnapshotWorker(params); + }, + function* (params?: FromLSP extends true ? LSPUpdateSnapshotParams : UpdateSnapshotParams): Generator { + return yield* owner.updateSnapshotWorker.gen(params); + }, + ); + } + + /** @internal */ + get updateSnapshotFrom(): { + (baseSnapshot: Snapshot, params?: UpdateSnapshotParams): Snapshot; + gen(baseSnapshot: Snapshot, params?: UpdateSnapshotParams): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "updateSnapshotFrom", + function (baseSnapshot: Snapshot, params?: UpdateSnapshotParams): Snapshot { + if (!owner.activeSnapshots.has(baseSnapshot) || baseSnapshot.isDisposed()) { + throw new Error("Cannot update an inactive snapshot"); + } + if (baseSnapshot !== owner.latestSnapshot) { + // TODO: Support forking active memory/cache snapshots once the server-side + // ownership, project state, and cache semantics have been worked out. + throw new Error("Snapshot.update can only update the latest snapshot"); + } + return owner.updateSnapshotWorker(params, baseSnapshot); + }, + function* (baseSnapshot: Snapshot, params?: UpdateSnapshotParams): Generator { + if (!owner.activeSnapshots.has(baseSnapshot) || baseSnapshot.isDisposed()) { + throw new Error("Cannot update an inactive snapshot"); + } + if (baseSnapshot !== owner.latestSnapshot) { + // TODO: Support forking active memory/cache snapshots once the server-side + // ownership, project state, and cache semantics have been worked out. + throw new Error("Snapshot.update can only update the latest snapshot"); + } + return yield* owner.updateSnapshotWorker.gen(params, baseSnapshot); + }, + ); + } + + private get updateSnapshotWorker(): { + (params?: LSPUpdateSnapshotParams | UpdateSnapshotParams, baseSnapshot?: Snapshot): Snapshot; + gen(params?: LSPUpdateSnapshotParams | UpdateSnapshotParams, baseSnapshot?: Snapshot): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "updateSnapshotWorker", + function (params?: LSPUpdateSnapshotParams | UpdateSnapshotParams, baseSnapshot?: Snapshot): Snapshot { owner.ensureInitialized(); - const requestParams = toUpdateSnapshotRequest(params); + const requestParams = toUpdateSnapshotRequest(params, baseSnapshot?.id); const data = owner.client.apiRequest("updateSnapshot", requestParams); // Retain cached source files from previous snapshot for unchanged files @@ -558,10 +637,10 @@ export class API implements FormatDiagnosticsHo return snapshot; }, - function* (params?: FromLSP extends true ? LSPUpdateSnapshotParams : UpdateSnapshotParams): Generator { + function* (params?: LSPUpdateSnapshotParams | UpdateSnapshotParams, baseSnapshot?: Snapshot): Generator { yield* owner.ensureInitialized.gen(); - const requestParams = toUpdateSnapshotRequest(params); + const requestParams = toUpdateSnapshotRequest(params, baseSnapshot?.id); const data = yield* apiRequest("updateSnapshot", requestParams); // Retain cached source files from previous snapshot for unchanged files @@ -779,6 +858,47 @@ export class API implements FormatDiagnosticsHo owner, "createProgram", function (rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions, oldProgram?: Program, fileChanges?: APIFileChanges): Program { + return owner.createProgramWorker(rootFiles, createProgramOptions, oldProgram, fileChanges); + }, + function* (rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions, oldProgram?: Program, fileChanges?: APIFileChanges): Generator { + return yield* owner.createProgramWorker.gen(rootFiles, createProgramOptions, oldProgram, fileChanges); + }, + ); + } + + /** @internal */ + get createProgramFromSnapshot(): { + (baseSnapshot: Snapshot, rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions, oldProgram?: Program, fileChanges?: APIFileChanges): Program; + gen(baseSnapshot: Snapshot, rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions, oldProgram?: Program, fileChanges?: APIFileChanges): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "createProgramFromSnapshot", + function (baseSnapshot: Snapshot, rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions, oldProgram?: Program, fileChanges?: APIFileChanges): Program { + if (!owner.activeSnapshots.has(baseSnapshot) || baseSnapshot.isDisposed()) { + throw new Error("Cannot create a program from an inactive snapshot"); + } + return owner.createProgramWorker(rootFiles, createProgramOptions, oldProgram, fileChanges, baseSnapshot); + }, + function* (baseSnapshot: Snapshot, rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions, oldProgram?: Program, fileChanges?: APIFileChanges): Generator { + if (!owner.activeSnapshots.has(baseSnapshot) || baseSnapshot.isDisposed()) { + throw new Error("Cannot create a program from an inactive snapshot"); + } + return yield* owner.createProgramWorker.gen(rootFiles, createProgramOptions, oldProgram, fileChanges, baseSnapshot); + }, + ); + } + + private get createProgramWorker(): { + (rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions, oldProgram?: Program, fileChanges?: APIFileChanges, baseSnapshot?: Snapshot): Program; + gen(rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions, oldProgram?: Program, fileChanges?: APIFileChanges, baseSnapshot?: Snapshot): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "createProgramWorker", + function (rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions, oldProgram?: Program, fileChanges?: APIFileChanges, baseSnapshot?: Snapshot): Program { owner.ensureInitialized(); if (fileChanges && !oldProgram) { @@ -791,6 +911,7 @@ export class API implements FormatDiagnosticsHo const data: CreateProgramResponse = owner.client.apiRequest("createProgram", { rootFiles, createProgramOptions, + ...(baseSnapshot ? { baseSnapshot: baseSnapshot.id } : {}), ...(oldProgram ? { oldProgram: { snapshot: oldProgram.snapshotId, project: oldProgram.getProject().id } } : {}), ...(fileChanges ? { fileChanges } : {}), }); @@ -813,7 +934,7 @@ export class API implements FormatDiagnosticsHo owner.activeSnapshots.add(snapshot); return program; }, - function* (rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions, oldProgram?: Program, fileChanges?: APIFileChanges): Generator { + function* (rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions, oldProgram?: Program, fileChanges?: APIFileChanges, baseSnapshot?: Snapshot): Generator { yield* owner.ensureInitialized.gen(); if (fileChanges && !oldProgram) { @@ -826,6 +947,7 @@ export class API implements FormatDiagnosticsHo const data: CreateProgramResponse = yield* apiRequest("createProgram", { rootFiles, createProgramOptions, + ...(baseSnapshot ? { baseSnapshot: baseSnapshot.id } : {}), ...(oldProgram ? { oldProgram: { snapshot: oldProgram.snapshotId, project: oldProgram.getProject().id } } : {}), ...(fileChanges ? { fileChanges } : {}), }); @@ -854,6 +976,17 @@ export class API implements FormatDiagnosticsHo type EnsureInitialized = (() => void) & { gen(): Generator; }; +interface SnapshotOwner extends FormatDiagnosticsHost { + updateSnapshotFrom: { + (baseSnapshot: Snapshot, params?: UpdateSnapshotParams): Snapshot; + gen(baseSnapshot: Snapshot, params?: UpdateSnapshotParams): Generator; + }; + createProgramFromSnapshot: { + (baseSnapshot: Snapshot, rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions, oldProgram?: Program, fileChanges?: APIFileChanges): Program; + gen(baseSnapshot: Snapshot, rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions, oldProgram?: Program, fileChanges?: APIFileChanges): Generator; + }; +} + export class InternalAPI { private client: Client; private ensureInitialized: EnsureInitialized; @@ -932,7 +1065,9 @@ export class Snapshot { private toPath: (fileName: string) => Path; private client: Client; private disposed: boolean = false; + private disposePromise: void | undefined; private onDispose: () => void; + private api: SnapshotOwner; private snapshotRegistry: SnapshotObjectRegistry; readonly internal: SnapshotInternalAPI; @@ -941,18 +1076,19 @@ export class Snapshot { client: Client, sourceFileCache: SourceFileCache, toPath: (fileName: string) => Path, - formatDiagnosticsHost: FormatDiagnosticsHost, + api: SnapshotOwner, onDispose: () => void, ) { this.id = data.snapshot; this.client = client; this.toPath = toPath; + this.api = api; this.onDispose = onDispose; this.projectMap = new Map(); this.snapshotRegistry = new SnapshotObjectRegistry(client, this.id, projectId => this.projectMap.get(projectId)); for (const projData of data.projects) { - const project = new Project(projData, this.id, client, sourceFileCache, toPath, formatDiagnosticsHost, this.snapshotRegistry); + const project = new Project(projData, this.id, client, sourceFileCache, toPath, api, this.snapshotRegistry); this.projectMap.set(toPath(projData.configFileName), project); } @@ -998,8 +1134,54 @@ export class Snapshot { ); } + /** + * Creates the next snapshot, layering its filesystem over this snapshot's + * filesystem. This snapshot must still be active and be the latest snapshot. + */ + get update(): { + (params?: UpdateSnapshotParams): Snapshot; + gen(params?: UpdateSnapshotParams): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "update", + function (params?: UpdateSnapshotParams): Snapshot { + owner.ensureNotDisposed(); + return owner.api.updateSnapshotFrom(owner, params); + }, + function* (params?: UpdateSnapshotParams): Generator { + owner.ensureNotDisposed(); + return yield* owner.api.updateSnapshotFrom.gen(owner, params); + }, + ); + } + + /** + * Creates an isolated program using this snapshot and its effective filesystem + * as the base. Usage is otherwise identical to {@link API.createProgram}. + */ + get createProgram(): { + (rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions, oldProgram?: Program, fileChanges?: APIFileChanges): Program; + gen(rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions, oldProgram?: Program, fileChanges?: APIFileChanges): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "createProgram", + function (rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions, oldProgram?: Program, fileChanges?: APIFileChanges): Program { + owner.ensureNotDisposed(); + return owner.api.createProgramFromSnapshot(owner, rootFiles, createProgramOptions, oldProgram, fileChanges); + }, + function* (rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions, oldProgram?: Program, fileChanges?: APIFileChanges): Generator { + owner.ensureNotDisposed(); + return yield* owner.api.createProgramFromSnapshot.gen(owner, rootFiles, createProgramOptions, oldProgram, fileChanges); + }, + ); + } + [globalThis.Symbol.dispose](): void { - this.dispose(); + void this.dispose(); } get dispose(): { @@ -1010,6 +1192,23 @@ export class Snapshot { return cacheGeneratorMethod( owner, "dispose", + function (): void { + return owner.disposePromise ??= owner.disposeWorker(); + }, + function* (): Generator { + return owner.disposePromise ??= yield* owner.disposeWorker.gen(); + }, + ); + } + + private get disposeWorker(): { + (): void; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "disposeWorker", function (): void { if (owner.disposed) return; owner.disposed = true; @@ -1018,8 +1217,12 @@ export class Snapshot { } owner.projectMap.clear(); owner.snapshotRegistry.clear(); - owner.onDispose(); - owner.client.apiRequest("release", { snapshot: owner.id }); + try { + owner.client.apiRequest("release", { snapshot: owner.id }); + } + finally { + owner.onDispose(); + } }, function* (): Generator { if (owner.disposed) return; @@ -1029,8 +1232,12 @@ export class Snapshot { } owner.projectMap.clear(); owner.snapshotRegistry.clear(); - owner.onDispose(); - yield* apiRequest("release", { snapshot: owner.id }); + try { + yield* apiRequest("release", { snapshot: owner.id }); + } + finally { + owner.onDispose(); + } }, ); } @@ -1988,6 +2195,7 @@ export class Program implements FormatDiagnosticsHost { private readonly decoder = new Wtf8Decoder(); private readonly sourceFileMetadataCache = new Map(); private ownedSnapshot: Snapshot | undefined; + private disposePromise: void | undefined; constructor( snapshotId: number, @@ -2023,7 +2231,7 @@ export class Program implements FormatDiagnosticsHost { } [globalThis.Symbol.dispose](): void { - this.dispose(); + void this.dispose(); } get dispose(): { @@ -2034,6 +2242,23 @@ export class Program implements FormatDiagnosticsHost { return cacheGeneratorMethod( owner, "dispose", + function (): void { + return owner.disposePromise ??= owner.disposeWorker(); + }, + function* (): Generator { + return owner.disposePromise ??= yield* owner.disposeWorker.gen(); + }, + ); + } + + private get disposeWorker(): { + (): void; + gen(): Generator; + } { + const owner = this; + return cacheGeneratorMethod( + owner, + "disposeWorker", function (): void { const snapshot = owner.ownedSnapshot; owner.ownedSnapshot = undefined; @@ -2611,10 +2836,9 @@ export class Program implements FormatDiagnosticsHost { } /** - * Emits files to the configured filesystem. - * - * When the API has a virtual filesystem with a `writeFile` callback, output - * is written there. Otherwise, the server writes directly to the host filesystem. + * Emits files to the configured filesystem. Cache and host filesystems are + * written through; memory filesystems remain immutable and return emitted + * files in {@link EmitResult.fileSystem}. */ get emit(): { (emitOnly?: EmitOnly): EmitResult; @@ -2630,10 +2854,17 @@ export class Program implements FormatDiagnosticsHost { project: owner.project.id, ...(emitOnly !== undefined ? { emitOnly } : {}), }); + const fileSystem = response.emittedFilesContents.length + ? { + kind: "cache" as const, + files: Object.fromEntries(response.emittedFiles.map((fileName, index) => [fileName, response.emittedFilesContents[index]])), + } + : undefined; return { emitSkipped: response.emitSkipped, diagnostics: response.diagnostics, emittedFiles: response.emittedFiles, + ...(fileSystem ? { fileSystem } : {}), }; }, function* (emitOnly?: EmitOnly): Generator { @@ -2642,10 +2873,17 @@ export class Program implements FormatDiagnosticsHost { project: owner.project.id, ...(emitOnly !== undefined ? { emitOnly } : {}), }); + const fileSystem = response.emittedFilesContents.length + ? { + kind: "cache" as const, + files: Object.fromEntries(response.emittedFiles.map((fileName, index) => [fileName, response.emittedFilesContents[index]])), + } + : undefined; return { emitSkipped: response.emitSkipped, diagnostics: response.diagnostics, emittedFiles: response.emittedFiles, + ...(fileSystem ? { fileSystem } : {}), }; }, ); diff --git a/packages/typescript/src/api/sync/types.ts b/packages/typescript/src/api/sync/types.ts index 8a6987adf024b..bba22a65d3748 100644 --- a/packages/typescript/src/api/sync/types.ts +++ b/packages/typescript/src/api/sync/types.ts @@ -21,7 +21,10 @@ import type { NamedTupleMember, ParameterDeclaration, } from "../../ast/ast.ts"; -import type { Diagnostic } from "../proto.ts"; +import type { + Diagnostic, + SnapshotFileSystem, +} from "../proto.ts"; import type { NodeHandle, Signature, @@ -525,6 +528,8 @@ export interface EmitResult { readonly emitSkipped: boolean; readonly diagnostics: readonly Diagnostic[]; readonly emittedFiles: readonly string[]; + /** Emitted files captured as a cache layer suitable for {@link Snapshot.update}. */ + readonly fileSystem?: SnapshotFileSystem | undefined; } export interface EmitOutput { diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index 67666073b1734..c199f78733ae3 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -69,6 +69,7 @@ import { ObjectFlags, type Signature, SignatureKind, + type Snapshot, type StringMappingType, SymbolFlags, type TemplateLiteralType, @@ -80,7 +81,12 @@ import { type TypeReference, type UnionOrIntersectionType, } from "@typescript/typescript/unstable/async"; // @sync: } from "@typescript/typescript/unstable/sync"; -import { createVirtualFileSystem } from "@typescript/typescript/unstable/fs"; +import { + createCacheFileSystem, + createMemoryFileSystem, + createMemoryFileSystemWithLib, + createVirtualFileSystem, +} from "@typescript/typescript/unstable/fs"; import type { FileSystem } from "@typescript/typescript/unstable/fs"; import assert from "node:assert"; import { globSync } from "node:fs"; @@ -649,6 +655,30 @@ describe("API", () => { // @sync-skip-block-start describe("API - automatic batching", () => { + test("initializes only once for concurrent first requests", async () => { + const api = spawnAPI(); + const client = (api as unknown as { + client: { apiRequest(method: string, params: unknown): Promise; }; + }).client; + const apiRequest = client.apiRequest.bind(client); + let initializeCalls = 0; + client.apiRequest = (method, params) => { + if (method === "initialize") initializeCalls++; + return apiRequest(method, params); + }; + + try { + await Promise.all([ + api.parseCommandLine(["--strict"]), + api.readConfigFile("/tsconfig.json"), + ]); + assert.equal(initializeCalls, 1); + } + finally { + await api.close(); + } + }); + test("batches multiple concurrent requests into one automatically", async () => { const api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), @@ -2008,7 +2038,10 @@ describe("Snapshot disposal", () => { const api = spawnAPI(); try { const snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json" }); - await snapshot.dispose(); + const firstDispose = snapshot.dispose(); + const secondDispose = snapshot.dispose(); + assert.strictEqual(firstDispose, secondDispose); + await firstDispose; assert.ok(snapshot.isDisposed()); // Second dispose should not throw await snapshot.dispose(); @@ -2019,6 +2052,18 @@ describe("Snapshot disposal", () => { } }); + test("api.close waits for disposal started by using", async () => { + const api = spawnAPI(); + let snapshot: Snapshot; + { + using disposableSnapshot = await api.updateSnapshot({ openProject: "/tsconfig.json" }); + snapshot = disposableSnapshot; + } + assert.ok(snapshot.isDisposed()); + await api.close(); + await snapshot.dispose(); + }); + test("api.close disposes all active snapshots", async () => { const api = spawnAPI(); const snap1 = await api.updateSnapshot({ openProject: "/tsconfig.json" }); @@ -3633,6 +3678,602 @@ describe("readFile callback semantics", () => { }); }); +describe("updateSnapshot file systems", () => { + test("snapshot filesystem factories derive directory listings", () => { + const memory = createMemoryFileSystem([ + ["/src/index.ts", "posix"], + ["C:\\repo\\src\\index.ts", "windows"], + ["file:///literal%20path.ts", "literal file-name string"], + [{ uri: "file:///encoded/path%20with%20spaces.ts" }, "file URI"], + [{ uri: "file:///C%3A/repo/encoded%23name.ts" }, "Windows file URI"], + [{ uri: "file://server/share/encoded%20name.ts" }, "UNC file URI"], + [{ uri: "file:///encoded/unicode%E2%80%93name.ts" }, "Unicode file URI"], + [{ uri: "file:///encoded/literal+plus.ts" }, "plus file URI"], + [{ uri: "file:///encoded/once%2520encoded.ts" }, "double-encoded file URI"], + ["vscode-remote://ssh-remote+host/workspace/src/index.ts", "remote"], + ["vscode-notebook-cell://authority/workspace/notebook.ipynb/cell.ts", "notebook"], + ]); + assert.deepEqual(memory, { + kind: "memory", + files: { + "/src/index.ts": "posix", + "C:\\repo\\src\\index.ts": "windows", + "file:///literal%20path.ts": "literal file-name string", + "/encoded/path with spaces.ts": "file URI", + "c:/repo/encoded#name.ts": "Windows file URI", + "//server/share/encoded name.ts": "UNC file URI", + "/encoded/unicode–name.ts": "Unicode file URI", + "/encoded/literal+plus.ts": "plus file URI", + "/encoded/once%20encoded.ts": "double-encoded file URI", + "vscode-remote://ssh-remote+host/workspace/src/index.ts": "remote", + "vscode-notebook-cell://authority/workspace/notebook.ipynb/cell.ts": "notebook", + }, + directories: { + "/src": { files: ["index.ts"], directories: [] }, + "/": { files: [], directories: ["src", "encoded"] }, + "C:/repo/src": { files: ["index.ts"], directories: [] }, + "C:/repo": { files: [], directories: ["src"] }, + "C:/": { files: [], directories: ["repo"] }, + "c:/repo": { files: ["encoded#name.ts"], directories: [] }, + "c:/": { files: [], directories: ["repo"] }, + "/encoded": { + files: ["path with spaces.ts", "unicode–name.ts", "literal+plus.ts", "once%20encoded.ts"], + directories: [], + }, + "//server/share": { files: ["encoded name.ts"], directories: [] }, + "//server/": { files: [], directories: ["share"] }, + "file:///": { files: ["literal%20path.ts"], directories: [] }, + "vscode-remote://ssh-remote+host/workspace/src": { files: ["index.ts"], directories: [] }, + "vscode-remote://ssh-remote+host/workspace": { files: [], directories: ["src"] }, + "vscode-remote://ssh-remote+host/": { files: [], directories: ["workspace"] }, + "vscode-notebook-cell://authority/workspace/notebook.ipynb": { files: ["cell.ts"], directories: [] }, + "vscode-notebook-cell://authority/workspace": { files: [], directories: ["notebook.ipynb"] }, + "vscode-notebook-cell://authority/": { files: [], directories: ["workspace"] }, + }, + }); + + const directories = { "/explicit": { files: ["provided.ts"], directories: [] } }; + const cache = createCacheFileSystem([["/ignored/derived.ts", "cache"]], { + directories, + removedPaths: ["/removed.ts", "/removed"], + }); + assert.deepEqual(cache.directories, directories); + assert.deepEqual(cache.removedPaths, ["/removed.ts", "/removed"]); + + assert.throws( + () => + createMemoryFileSystem([ + ["/duplicate.ts", "path"], + [{ uri: "file:///duplicate.ts" }, "URI"], + ]), + /Duplicate snapshot filesystem path: \/duplicate\.ts/, + ); + }); + + test("memory file system is total and does not invoke host callbacks", async () => { + const callbackCalls: string[] = []; + const host = createVirtualFileSystem({ + "/host.ts": `export const source = "host";`, + }); + const fs: FileSystem = { + readFile: path => { + callbackCalls.push(`readFile:${path}`); + return host.readFile!(path); + }, + fileExists: path => { + callbackCalls.push(`fileExists:${path}`); + return host.fileExists!(path); + }, + directoryExists: path => { + callbackCalls.push(`directoryExists:${path}`); + return host.directoryExists!(path); + }, + getAccessibleEntries: path => { + callbackCalls.push(`getAccessibleEntries:${path}`); + return host.getAccessibleEntries!(path); + }, + realpath: path => { + callbackCalls.push(`realpath:${path}`); + return path; + }, + writeFile: (path, content) => { + callbackCalls.push(`writeFile:${path}`); + host.writeFile!(path, content); + }, + }; + const api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + fs, + }); + + try { + using snapshot = await api.updateSnapshot({ + openProject: "/tsconfig.json", + fileSystem: { + kind: "memory", + files: { + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, include: ["src/**/*.ts"] }), + "/src/index.ts": `export const source = "memory";`, + }, + directories: { + "/": { files: ["tsconfig.json"], directories: ["src"] }, + "/src": { files: ["index.ts"], directories: [] }, + }, + }, + }); + const project = snapshot.getProject("/tsconfig.json")!; + const sourceFile = await project.program.getSourceFile("/src/index.ts"); + assert.equal(sourceFile?.text, `export const source = "memory";`); + assert.equal(await project.program.getSourceFile("/host.ts"), undefined); + assert.deepEqual(callbackCalls, []); + } + finally { + await api.close(); + } + }); + + test("memory file system with lib resolves the default library", async () => { + const api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + }); + try { + using snapshot = await api.updateSnapshot({ + fileSystem: createMemoryFileSystemWithLib(Object.entries({ + "/src/main.ts": `export const values: Array = [];`, + })), + }); + using program = await snapshot.createProgram( + ["/src/main.ts"], + { compilerOptions: { strict: true } }, + ); + assert.deepEqual(await program.getGlobalDiagnostics(), []); + const sourceFileNames = await program.getSourceFileNames(); + const defaultLibraryName = sourceFileNames.find(fileName => fileName.includes("/lib.") && fileName.endsWith(".d.ts")); + assert.ok(defaultLibraryName, JSON.stringify(sourceFileNames)); + const defaultLibrary = await program.getSourceFile(defaultLibraryName); + assert.ok(defaultLibrary); + assert.equal(await program.isSourceFileDefaultLibrary(defaultLibrary), true); + } + finally { + await api.close(); + } + }); + + test("memory file system accepts paths decoded from VS Code document URIs", async () => { + const fileDocument = { uri: "file:///workspace/file%20name.ts" }; + const remoteDocument = { uri: "vscode-remote://ssh-remote+host/workspace/src/remote%20name.ts" }; + const notebookDocument = { uri: "vscode-notebook-cell:/workspace/notebook.ipynb/cell%20name.ts" }; + const api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + }); + try { + using snapshot = await api.updateSnapshot({ + fileSystem: createMemoryFileSystem([ + [fileDocument, `export const file = true;`], + [remoteDocument, `export const remote = true;`], + [notebookDocument, `export const cell = true;`], + ]), + }); + using program = await snapshot.createProgram( + [fileDocument, remoteDocument, notebookDocument], + { compilerOptions: { noLib: true } }, + ); + assert.equal((await program.getSourceFile(fileDocument))?.text, `export const file = true;`); + assert.equal((await program.getSourceFile(remoteDocument))?.text, `export const remote = true;`); + assert.equal((await program.getSourceFile(notebookDocument))?.text, `export const cell = true;`); + } + finally { + await api.close(); + } + }); + + test("cache file system bypasses callbacks on hits and falls back on misses", async () => { + const readFileCalls: string[] = []; + const directoryCalls: string[] = []; + const host = createVirtualFileSystem({ + "/src/fallback.ts": `export const fallback = true;`, + }); + const fs: FileSystem = { + ...host, + readFile: path => { + readFileCalls.push(path); + return host.readFile!(path); + }, + getAccessibleEntries: path => { + directoryCalls.push(path); + return host.getAccessibleEntries!(path); + }, + }; + const api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + fs, + }); + + try { + using snapshot = await api.updateSnapshot({ + openProject: "/tsconfig.json", + fileSystem: { + kind: "cache", + files: { + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, include: ["src/**/*.ts"] }), + "/src/index.ts": `export const cached = true;`, + }, + directories: { + "/": { files: ["tsconfig.json"], directories: ["src"] }, + "/src": { files: ["fallback.ts", "index.ts"], directories: [] }, + }, + }, + }); + const project = snapshot.getProject("/tsconfig.json")!; + assert.equal((await project.program.getSourceFile("/src/index.ts"))?.text, `export const cached = true;`); + assert.equal((await project.program.getSourceFile("/src/fallback.ts"))?.text, `export const fallback = true;`); + + assert.ok(!readFileCalls.includes("/tsconfig.json")); + assert.ok(!readFileCalls.includes("/src/index.ts")); + assert.ok(readFileCalls.includes("/src/fallback.ts")); + assert.ok(!directoryCalls.includes("/")); + assert.ok(!directoryCalls.includes("/src")); + } + finally { + await api.close(); + } + }); + + test("memory file system resolves packages through internal monorepo symlinks", async () => { + const callbackCalls: string[] = []; + const api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + fs: { + readFile: path => { + callbackCalls.push(path); + return undefined; + }, + }, + }); + + try { + using snapshot = await api.updateSnapshot({ + openProject: "/project/tsconfig.json", + fileSystem: { + kind: "memory", + files: { + "/project/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, moduleResolution: "node" }, files: ["index.ts"] }), + "/project/index.ts": `import { value } from "pkg"; export { value };`, + "/packages/pkg/index.d.ts": `export declare const value: number;`, + }, + symlinks: { + "/project/node_modules/pkg": { target: "/packages/pkg" }, + }, + }, + }); + const project = snapshot.getProject("/project/tsconfig.json")!; + assert.equal( + (await project.program.getSourceFile("/packages/pkg/index.d.ts"))?.text, + `export declare const value: number;`, + ); + assert.deepEqual(callbackCalls, []); + } + finally { + await api.close(); + } + }); + + test("memory file system resolves relative symlink targets", async () => { + const callbackCalls: string[] = []; + const api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + fs: { + readFile: path => { + callbackCalls.push(path); + return undefined; + }, + }, + }); + + try { + using snapshot = await api.updateSnapshot({ + openProject: "/project/tsconfig.json", + fileSystem: { + kind: "memory", + files: { + "/project/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: ["index.ts"] }), + "/project/index.ts": `export { value } from "./pkg";`, + "/packages/pkg/index.d.ts": `export declare const value: number;`, + }, + symlinks: { + "/project/pkg": { target: "../packages/pkg" }, + }, + }, + }); + const project = snapshot.getProject("/project/tsconfig.json")!; + assert.equal( + (await project.program.getSourceFile("/project/pkg/index.d.ts"))?.text, + `export declare const value: number;`, + ); + assert.deepEqual(callbackCalls, []); + } + finally { + await api.close(); + } + }); + + test("Snapshot.update layers filesystem edits and removals", async () => { + const api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + }); + try { + using snapshot = await api.updateSnapshot({ + openProject: "/tsconfig.json", + fileSystem: createMemoryFileSystem(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 = await snapshot.update({ + fileSystem: createCacheFileSystem( + 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.getProject("/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 assert.rejects(() => snapshot.update(), /can only update the latest snapshot/); // @sync: assert.throws(() => snapshot.update(), /can only update the latest snapshot/); + + using updatedAgain = await updated.update({ + fileSystem: createCacheFileSystem( + Object.entries({ + "/src/added.ts": `export const added = "updated again";`, + }), + { + removedPaths: ["/src/change.ts"], + }, + ), + }); + const updatedAgainProject = updatedAgain.getProject("/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); + } + finally { + await api.close(); + } + }); + + test("Snapshot.update applies target changes through inherited symlinks", async () => { + const api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + }); + try { + using snapshot = await api.updateSnapshot({ + fileSystem: createMemoryFileSystem( + Object.entries({ + "/src/main.ts": `import "./link/change"; import "./link/added"; import "./link/remove";`, + "/target/change.ts": `export const version = "old";`, + "/target/remove.ts": `export const removed = true;`, + }), + { + symlinks: { + "/src/link": { target: "/target" }, + }, + }, + ), + }); + + using updated = await snapshot.update({ + fileSystem: createCacheFileSystem( + Object.entries({ + "/target/change.ts": `export const version = "new";`, + "/target/added.ts": `export const added = true;`, + }), + { + removedPaths: ["/target/remove.ts"], + }, + ), + }); + using program = await updated.createProgram( + ["/src/main.ts"], + { compilerOptions: { noLib: true } }, + ); + assert.equal((await program.getSourceFile("/src/link/change.ts"))?.text, `export const version = "new";`); + assert.equal((await program.getSourceFile("/src/link/added.ts"))?.text, `export const added = true;`); + assert.equal(await program.getSourceFile("/src/link/remove.ts"), undefined); + } + finally { + await api.close(); + } + }); + + test("Snapshot.createProgram uses the snapshot filesystem as its base", async () => { + const callbackCalls: string[] = []; + const api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + fs: { + readFile: path => { + callbackCalls.push(path); + return undefined; + }, + }, + }); + const options = { compilerOptions: { noLib: true, strict: true } }; + try { + using snapshot = await api.updateSnapshot({ + fileSystem: createMemoryFileSystem(Object.entries({ + "/src/main.ts": `import { value } from "./dependency"; export const result = value;`, + "/src/dependency.ts": `export const value = "memory";`, + })), + }); + using program = await snapshot.createProgram(["/src/main.ts"], options); + assert.equal((await program.getSourceFile("/src/dependency.ts"))?.text, `export const value = "memory";`); + + using updated = await snapshot.update({ + fileSystem: createCacheFileSystem(Object.entries({ + "/src/dependency.ts": `export const value = "updated";`, + })), + }); + using updatedProgram = await updated.createProgram( + ["/src/main.ts"], + options, + program, + { changed: ["/src/dependency.ts"] }, + ); + assert.equal((await updatedProgram.getSourceFile("/src/dependency.ts"))?.text, `export const value = "updated";`); + assert.deepEqual(callbackCalls, []); + } + finally { + await api.close(); + } + }); + + test("memory filesystem emit returns outputs without mutating the host", async () => { + const hostWrites: string[] = []; + const api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + fs: { + writeFile: path => { + hostWrites.push(path); + }, + }, + }); + try { + using snapshot = await api.updateSnapshot({ + fileSystem: createMemoryFileSystem(Object.entries({ + "/src/main.ts": `export const value: number = 1;`, + })), + }); + using program = await snapshot.createProgram( + ["/src/main.ts"], + { compilerOptions: { noLib: true, outDir: "/out" } }, + ); + const result = await program.emit(); + assert.deepEqual(result.emittedFiles, ["/out/main.js"]); + assert.deepEqual(result.fileSystem, { + kind: "cache", + files: { + "/out/main.js": `export const value = 1;\n`, + }, + }); + assert.deepEqual(hostWrites, []); + + using updated = await snapshot.update({ fileSystem: result.fileSystem! }); + using updatedProgram = await updated.createProgram( + ["/src/main.ts", "/out/main.js"], + { compilerOptions: { allowJs: true, noLib: true } }, + ); + assert.equal((await updatedProgram.getSourceFile("/src/main.ts"))?.text, `export const value: number = 1;`); + assert.equal((await updatedProgram.getSourceFile("/out/main.js"))?.text, `export const value = 1;\n`); + } + finally { + await api.close(); + } + }); + + test("cache filesystem emit writes through to the host", async () => { + const host = createVirtualFileSystem({}); + const api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + fs: host, + }); + try { + using snapshot = await api.updateSnapshot({ + fileSystem: createCacheFileSystem(Object.entries({ + "/src/main.ts": `export const value: number = 1;`, + })), + }); + using program = await snapshot.createProgram( + ["/src/main.ts"], + { compilerOptions: { noLib: true, outDir: "/out" } }, + ); + const result = await program.emit(); + assert.equal(result.fileSystem, undefined); + assert.equal(host.readFile!("/out/main.js"), `export const value = 1;\n`); + } + finally { + await api.close(); + } + }); + + test("memory file system can link node_modules from the host", async () => { + const readFileCalls: string[] = []; + const directoryExistsCalls: string[] = []; + const fileExistsCalls: string[] = []; + const host = createVirtualFileSystem({ + "/host/node_modules/pkg/index.d.ts": `export declare const value: string;`, + }); + const api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + fs: { + ...host, + directoryExists: path => { + directoryExistsCalls.push(path); + return host.directoryExists!(path); + }, + fileExists: path => { + const exists = host.fileExists!(path); + fileExistsCalls.push(`${path}:${exists}`); + return exists; + }, + readFile: path => { + readFileCalls.push(path); + return host.readFile!(path); + }, + }, + }); + + try { + using snapshot = await api.updateSnapshot({ + openProject: "/project/tsconfig.json", + fileSystem: { + kind: "memory", + files: { + "/project/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, moduleResolution: "node" }, files: ["index.ts"] }), + "/project/index.ts": `import { value } from "pkg"; export { value };`, + }, + symlinks: { + "/project/node_modules": { target: "/host/node_modules", host: true }, + }, + }, + }); + const project = snapshot.getProject("/project/tsconfig.json")!; + const sourceFileNames = await project.program.getSourceFileNames(); + assert.ok( + sourceFileNames.includes("/host/node_modules/pkg/index.d.ts"), + JSON.stringify({ sourceFileNames, readFileCalls, directoryExistsCalls, fileExistsCalls }), + ); + assert.equal( + (await project.program.getSourceFile("/host/node_modules/pkg/index.d.ts"))?.text, + `export declare const value: string;`, + ); + assert.ok(readFileCalls.includes("/host/node_modules/pkg/index.d.ts")); + assert.ok(!readFileCalls.some(path => path.startsWith("/project/node_modules"))); + } + finally { + await api.close(); + } + }); + + // TODO: Add snapshot filesystem coverage for `tsc -b` and `tsc -b --clean` + // once build and clean are exposed through the client API. In particular, + // verify that clean removes synthetic outputs and that build-mode re-timestamping + // of emitted-but-unchanged files works for memory filesystems, which currently + // do not model modification times. +}); + describe("Checker - isArrayType / isTupleType", () => { test("number[] is array, not tuple", async () => { const api = spawnAPI({ diff --git a/packages/typescript/test/sync/api-generators.test.ts b/packages/typescript/test/sync/api-generators.test.ts index 7c5befcefdc7e..4017c08dcdef4 100644 --- a/packages/typescript/test/sync/api-generators.test.ts +++ b/packages/typescript/test/sync/api-generators.test.ts @@ -139,10 +139,17 @@ const publicGeneratorExemptions = new Map([ ]); const privateGeneratorGetters = new Set([ "API.ensureInitialized", + "API.initializeWorker", + "API.updateSnapshotFrom", + "API.updateSnapshotWorker", + "API.createProgramFromSnapshot", + "API.createProgramWorker", "Checker.getIntrinsicType", "Checker.getWellKnownSignatures", "Checker.getWellKnownSymbols", + "Program.disposeWorker", "Program.fetchSourceFileMetadata", + "Snapshot.disposeWorker", "Symbol.fetchSymbolTable", "Type.getNumberIndexTypeWorker", "Type.getStringIndexTypeWorker", @@ -907,6 +914,27 @@ describe("API - generator batching", () => { runParityBatch(api, cases); assert.deepEqual(temporaryProjects, ["/tsconfig.json", "/tsconfig.json"]); + const snapshotGeneratorAPI = spawnAPI(parityFiles); + const snapshotSyncAPI = spawnAPI(parityFiles); + try { + const generatorBase = snapshotGeneratorAPI.batch(snapshotGeneratorAPI.updateSnapshot.gen({ openProject: "/tsconfig.json" }))[0]; + const syncBase = snapshotSyncAPI.updateSnapshot({ openProject: "/tsconfig.json" }); + const generatorUpdated = snapshotGeneratorAPI.batch(generatorBase.update.gen())[0]; + const syncUpdated = syncBase.update(); + assertSnapshotsEquivalent(generatorUpdated, syncUpdated, "Snapshot.update"); + exercisedMethods.add("Snapshot.update"); + + const createProgramOptions = { compilerOptions: { noLib: true } }; + const generatorProgram = snapshotGeneratorAPI.batch(generatorUpdated.createProgram.gen(["/src/index.ts"], createProgramOptions))[0]; + const syncProgram = syncUpdated.createProgram(["/src/index.ts"], createProgramOptions); + assertProgramsEquivalent(generatorProgram, syncProgram, "Snapshot.createProgram"); + exercisedMethods.add("Snapshot.createProgram"); + } + finally { + snapshotGeneratorAPI.close(); + snapshotSyncAPI.close(); + } + const destructiveAPI = spawnAPI(parityFiles); const disposableSnapshot = destructiveAPI.batch(destructiveAPI.updateSnapshot.gen({ openProject: "/tsconfig.json" }))[0]; destructiveAPI.batch(disposableSnapshot.dispose.gen()); diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index 494bc27b35fe1..b53d4bd6bf75c 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -56,7 +56,12 @@ import { createVariableStatement, } from "@typescript/typescript/unstable/ast/factory"; import { visitEachChild } from "@typescript/typescript/unstable/ast/visitor"; -import { createVirtualFileSystem } from "@typescript/typescript/unstable/fs"; +import { + createCacheFileSystem, + createMemoryFileSystem, + createMemoryFileSystemWithLib, + createVirtualFileSystem, +} from "@typescript/typescript/unstable/fs"; import type { FileSystem } from "@typescript/typescript/unstable/fs"; import { API, @@ -79,6 +84,7 @@ import { ObjectFlags, type Signature, SignatureKind, + type Snapshot, type StringMappingType, SymbolFlags, type TemplateLiteralType, @@ -1924,7 +1930,10 @@ describe("Snapshot disposal", () => { const api = spawnAPI(); try { const snapshot = api.updateSnapshot({ openProject: "/tsconfig.json" }); - snapshot.dispose(); + const firstDispose = snapshot.dispose(); + const secondDispose = snapshot.dispose(); + assert.strictEqual(firstDispose, secondDispose); + firstDispose; assert.ok(snapshot.isDisposed()); // Second dispose should not throw snapshot.dispose(); @@ -1935,6 +1944,18 @@ describe("Snapshot disposal", () => { } }); + test("api.close waits for disposal started by using", () => { + const api = spawnAPI(); + let snapshot: Snapshot; + { + using disposableSnapshot = api.updateSnapshot({ openProject: "/tsconfig.json" }); + snapshot = disposableSnapshot; + } + assert.ok(snapshot.isDisposed()); + api.close(); + snapshot.dispose(); + }); + test("api.close disposes all active snapshots", () => { const api = spawnAPI(); const snap1 = api.updateSnapshot({ openProject: "/tsconfig.json" }); @@ -3549,6 +3570,602 @@ describe("readFile callback semantics", () => { }); }); +describe("updateSnapshot file systems", () => { + test("snapshot filesystem factories derive directory listings", () => { + const memory = createMemoryFileSystem([ + ["/src/index.ts", "posix"], + ["C:\\repo\\src\\index.ts", "windows"], + ["file:///literal%20path.ts", "literal file-name string"], + [{ uri: "file:///encoded/path%20with%20spaces.ts" }, "file URI"], + [{ uri: "file:///C%3A/repo/encoded%23name.ts" }, "Windows file URI"], + [{ uri: "file://server/share/encoded%20name.ts" }, "UNC file URI"], + [{ uri: "file:///encoded/unicode%E2%80%93name.ts" }, "Unicode file URI"], + [{ uri: "file:///encoded/literal+plus.ts" }, "plus file URI"], + [{ uri: "file:///encoded/once%2520encoded.ts" }, "double-encoded file URI"], + ["vscode-remote://ssh-remote+host/workspace/src/index.ts", "remote"], + ["vscode-notebook-cell://authority/workspace/notebook.ipynb/cell.ts", "notebook"], + ]); + assert.deepEqual(memory, { + kind: "memory", + files: { + "/src/index.ts": "posix", + "C:\\repo\\src\\index.ts": "windows", + "file:///literal%20path.ts": "literal file-name string", + "/encoded/path with spaces.ts": "file URI", + "c:/repo/encoded#name.ts": "Windows file URI", + "//server/share/encoded name.ts": "UNC file URI", + "/encoded/unicode–name.ts": "Unicode file URI", + "/encoded/literal+plus.ts": "plus file URI", + "/encoded/once%20encoded.ts": "double-encoded file URI", + "vscode-remote://ssh-remote+host/workspace/src/index.ts": "remote", + "vscode-notebook-cell://authority/workspace/notebook.ipynb/cell.ts": "notebook", + }, + directories: { + "/src": { files: ["index.ts"], directories: [] }, + "/": { files: [], directories: ["src", "encoded"] }, + "C:/repo/src": { files: ["index.ts"], directories: [] }, + "C:/repo": { files: [], directories: ["src"] }, + "C:/": { files: [], directories: ["repo"] }, + "c:/repo": { files: ["encoded#name.ts"], directories: [] }, + "c:/": { files: [], directories: ["repo"] }, + "/encoded": { + files: ["path with spaces.ts", "unicode–name.ts", "literal+plus.ts", "once%20encoded.ts"], + directories: [], + }, + "//server/share": { files: ["encoded name.ts"], directories: [] }, + "//server/": { files: [], directories: ["share"] }, + "file:///": { files: ["literal%20path.ts"], directories: [] }, + "vscode-remote://ssh-remote+host/workspace/src": { files: ["index.ts"], directories: [] }, + "vscode-remote://ssh-remote+host/workspace": { files: [], directories: ["src"] }, + "vscode-remote://ssh-remote+host/": { files: [], directories: ["workspace"] }, + "vscode-notebook-cell://authority/workspace/notebook.ipynb": { files: ["cell.ts"], directories: [] }, + "vscode-notebook-cell://authority/workspace": { files: [], directories: ["notebook.ipynb"] }, + "vscode-notebook-cell://authority/": { files: [], directories: ["workspace"] }, + }, + }); + + const directories = { "/explicit": { files: ["provided.ts"], directories: [] } }; + const cache = createCacheFileSystem([["/ignored/derived.ts", "cache"]], { + directories, + removedPaths: ["/removed.ts", "/removed"], + }); + assert.deepEqual(cache.directories, directories); + assert.deepEqual(cache.removedPaths, ["/removed.ts", "/removed"]); + + assert.throws( + () => + createMemoryFileSystem([ + ["/duplicate.ts", "path"], + [{ uri: "file:///duplicate.ts" }, "URI"], + ]), + /Duplicate snapshot filesystem path: \/duplicate\.ts/, + ); + }); + + test("memory file system is total and does not invoke host callbacks", () => { + const callbackCalls: string[] = []; + const host = createVirtualFileSystem({ + "/host.ts": `export const source = "host";`, + }); + const fs: FileSystem = { + readFile: path => { + callbackCalls.push(`readFile:${path}`); + return host.readFile!(path); + }, + fileExists: path => { + callbackCalls.push(`fileExists:${path}`); + return host.fileExists!(path); + }, + directoryExists: path => { + callbackCalls.push(`directoryExists:${path}`); + return host.directoryExists!(path); + }, + getAccessibleEntries: path => { + callbackCalls.push(`getAccessibleEntries:${path}`); + return host.getAccessibleEntries!(path); + }, + realpath: path => { + callbackCalls.push(`realpath:${path}`); + return path; + }, + writeFile: (path, content) => { + callbackCalls.push(`writeFile:${path}`); + host.writeFile!(path, content); + }, + }; + const api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + fs, + }); + + try { + using snapshot = api.updateSnapshot({ + openProject: "/tsconfig.json", + fileSystem: { + kind: "memory", + files: { + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, include: ["src/**/*.ts"] }), + "/src/index.ts": `export const source = "memory";`, + }, + directories: { + "/": { files: ["tsconfig.json"], directories: ["src"] }, + "/src": { files: ["index.ts"], directories: [] }, + }, + }, + }); + const project = snapshot.getProject("/tsconfig.json")!; + const sourceFile = project.program.getSourceFile("/src/index.ts"); + assert.equal(sourceFile?.text, `export const source = "memory";`); + assert.equal(project.program.getSourceFile("/host.ts"), undefined); + assert.deepEqual(callbackCalls, []); + } + finally { + api.close(); + } + }); + + test("memory file system with lib resolves the default library", () => { + const api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + }); + try { + using snapshot = api.updateSnapshot({ + fileSystem: createMemoryFileSystemWithLib(Object.entries({ + "/src/main.ts": `export const values: Array = [];`, + })), + }); + using program = snapshot.createProgram( + ["/src/main.ts"], + { compilerOptions: { strict: true } }, + ); + assert.deepEqual(program.getGlobalDiagnostics(), []); + const sourceFileNames = program.getSourceFileNames(); + const defaultLibraryName = sourceFileNames.find(fileName => fileName.includes("/lib.") && fileName.endsWith(".d.ts")); + assert.ok(defaultLibraryName, JSON.stringify(sourceFileNames)); + const defaultLibrary = program.getSourceFile(defaultLibraryName); + assert.ok(defaultLibrary); + assert.equal(program.isSourceFileDefaultLibrary(defaultLibrary), true); + } + finally { + api.close(); + } + }); + + test("memory file system accepts paths decoded from VS Code document URIs", () => { + const fileDocument = { uri: "file:///workspace/file%20name.ts" }; + const remoteDocument = { uri: "vscode-remote://ssh-remote+host/workspace/src/remote%20name.ts" }; + const notebookDocument = { uri: "vscode-notebook-cell:/workspace/notebook.ipynb/cell%20name.ts" }; + const api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + }); + try { + using snapshot = api.updateSnapshot({ + fileSystem: createMemoryFileSystem([ + [fileDocument, `export const file = true;`], + [remoteDocument, `export const remote = true;`], + [notebookDocument, `export const cell = true;`], + ]), + }); + using program = snapshot.createProgram( + [fileDocument, remoteDocument, notebookDocument], + { compilerOptions: { noLib: true } }, + ); + assert.equal((program.getSourceFile(fileDocument))?.text, `export const file = true;`); + assert.equal((program.getSourceFile(remoteDocument))?.text, `export const remote = true;`); + assert.equal((program.getSourceFile(notebookDocument))?.text, `export const cell = true;`); + } + finally { + api.close(); + } + }); + + test("cache file system bypasses callbacks on hits and falls back on misses", () => { + const readFileCalls: string[] = []; + const directoryCalls: string[] = []; + const host = createVirtualFileSystem({ + "/src/fallback.ts": `export const fallback = true;`, + }); + const fs: FileSystem = { + ...host, + readFile: path => { + readFileCalls.push(path); + return host.readFile!(path); + }, + getAccessibleEntries: path => { + directoryCalls.push(path); + return host.getAccessibleEntries!(path); + }, + }; + const api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + fs, + }); + + try { + using snapshot = api.updateSnapshot({ + openProject: "/tsconfig.json", + fileSystem: { + kind: "cache", + files: { + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, include: ["src/**/*.ts"] }), + "/src/index.ts": `export const cached = true;`, + }, + directories: { + "/": { files: ["tsconfig.json"], directories: ["src"] }, + "/src": { files: ["fallback.ts", "index.ts"], directories: [] }, + }, + }, + }); + const project = snapshot.getProject("/tsconfig.json")!; + assert.equal((project.program.getSourceFile("/src/index.ts"))?.text, `export const cached = true;`); + assert.equal((project.program.getSourceFile("/src/fallback.ts"))?.text, `export const fallback = true;`); + + assert.ok(!readFileCalls.includes("/tsconfig.json")); + assert.ok(!readFileCalls.includes("/src/index.ts")); + assert.ok(readFileCalls.includes("/src/fallback.ts")); + assert.ok(!directoryCalls.includes("/")); + assert.ok(!directoryCalls.includes("/src")); + } + finally { + api.close(); + } + }); + + test("memory file system resolves packages through internal monorepo symlinks", () => { + const callbackCalls: string[] = []; + const api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + fs: { + readFile: path => { + callbackCalls.push(path); + return undefined; + }, + }, + }); + + try { + using snapshot = api.updateSnapshot({ + openProject: "/project/tsconfig.json", + fileSystem: { + kind: "memory", + files: { + "/project/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, moduleResolution: "node" }, files: ["index.ts"] }), + "/project/index.ts": `import { value } from "pkg"; export { value };`, + "/packages/pkg/index.d.ts": `export declare const value: number;`, + }, + symlinks: { + "/project/node_modules/pkg": { target: "/packages/pkg" }, + }, + }, + }); + const project = snapshot.getProject("/project/tsconfig.json")!; + assert.equal( + (project.program.getSourceFile("/packages/pkg/index.d.ts"))?.text, + `export declare const value: number;`, + ); + assert.deepEqual(callbackCalls, []); + } + finally { + api.close(); + } + }); + + test("memory file system resolves relative symlink targets", () => { + const callbackCalls: string[] = []; + const api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + fs: { + readFile: path => { + callbackCalls.push(path); + return undefined; + }, + }, + }); + + try { + using snapshot = api.updateSnapshot({ + openProject: "/project/tsconfig.json", + fileSystem: { + kind: "memory", + files: { + "/project/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: ["index.ts"] }), + "/project/index.ts": `export { value } from "./pkg";`, + "/packages/pkg/index.d.ts": `export declare const value: number;`, + }, + symlinks: { + "/project/pkg": { target: "../packages/pkg" }, + }, + }, + }); + const project = snapshot.getProject("/project/tsconfig.json")!; + assert.equal( + (project.program.getSourceFile("/project/pkg/index.d.ts"))?.text, + `export declare const value: number;`, + ); + assert.deepEqual(callbackCalls, []); + } + finally { + api.close(); + } + }); + + test("Snapshot.update layers filesystem edits and removals", () => { + const api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + }); + try { + using snapshot = api.updateSnapshot({ + openProject: "/tsconfig.json", + fileSystem: createMemoryFileSystem(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 = snapshot.update({ + fileSystem: createCacheFileSystem( + 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.getProject("/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); + assert.throws(() => snapshot.update(), /can only update the latest snapshot/); + + using updatedAgain = updated.update({ + fileSystem: createCacheFileSystem( + Object.entries({ + "/src/added.ts": `export const added = "updated again";`, + }), + { + removedPaths: ["/src/change.ts"], + }, + ), + }); + const updatedAgainProject = updatedAgain.getProject("/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); + } + finally { + api.close(); + } + }); + + test("Snapshot.update applies target changes through inherited symlinks", () => { + const api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + }); + try { + using snapshot = api.updateSnapshot({ + fileSystem: createMemoryFileSystem( + Object.entries({ + "/src/main.ts": `import "./link/change"; import "./link/added"; import "./link/remove";`, + "/target/change.ts": `export const version = "old";`, + "/target/remove.ts": `export const removed = true;`, + }), + { + symlinks: { + "/src/link": { target: "/target" }, + }, + }, + ), + }); + + using updated = snapshot.update({ + fileSystem: createCacheFileSystem( + Object.entries({ + "/target/change.ts": `export const version = "new";`, + "/target/added.ts": `export const added = true;`, + }), + { + removedPaths: ["/target/remove.ts"], + }, + ), + }); + using program = updated.createProgram( + ["/src/main.ts"], + { compilerOptions: { noLib: true } }, + ); + assert.equal((program.getSourceFile("/src/link/change.ts"))?.text, `export const version = "new";`); + assert.equal((program.getSourceFile("/src/link/added.ts"))?.text, `export const added = true;`); + assert.equal(program.getSourceFile("/src/link/remove.ts"), undefined); + } + finally { + api.close(); + } + }); + + test("Snapshot.createProgram uses the snapshot filesystem as its base", () => { + const callbackCalls: string[] = []; + const api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + fs: { + readFile: path => { + callbackCalls.push(path); + return undefined; + }, + }, + }); + const options = { compilerOptions: { noLib: true, strict: true } }; + try { + using snapshot = api.updateSnapshot({ + fileSystem: createMemoryFileSystem(Object.entries({ + "/src/main.ts": `import { value } from "./dependency"; export const result = value;`, + "/src/dependency.ts": `export const value = "memory";`, + })), + }); + using program = snapshot.createProgram(["/src/main.ts"], options); + assert.equal((program.getSourceFile("/src/dependency.ts"))?.text, `export const value = "memory";`); + + using updated = snapshot.update({ + fileSystem: createCacheFileSystem(Object.entries({ + "/src/dependency.ts": `export const value = "updated";`, + })), + }); + using updatedProgram = updated.createProgram( + ["/src/main.ts"], + options, + program, + { changed: ["/src/dependency.ts"] }, + ); + assert.equal((updatedProgram.getSourceFile("/src/dependency.ts"))?.text, `export const value = "updated";`); + assert.deepEqual(callbackCalls, []); + } + finally { + api.close(); + } + }); + + test("memory filesystem emit returns outputs without mutating the host", () => { + const hostWrites: string[] = []; + const api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + fs: { + writeFile: path => { + hostWrites.push(path); + }, + }, + }); + try { + using snapshot = api.updateSnapshot({ + fileSystem: createMemoryFileSystem(Object.entries({ + "/src/main.ts": `export const value: number = 1;`, + })), + }); + using program = snapshot.createProgram( + ["/src/main.ts"], + { compilerOptions: { noLib: true, outDir: "/out" } }, + ); + const result = program.emit(); + assert.deepEqual(result.emittedFiles, ["/out/main.js"]); + assert.deepEqual(result.fileSystem, { + kind: "cache", + files: { + "/out/main.js": `export const value = 1;\n`, + }, + }); + assert.deepEqual(hostWrites, []); + + using updated = snapshot.update({ fileSystem: result.fileSystem! }); + using updatedProgram = updated.createProgram( + ["/src/main.ts", "/out/main.js"], + { compilerOptions: { allowJs: true, noLib: true } }, + ); + assert.equal((updatedProgram.getSourceFile("/src/main.ts"))?.text, `export const value: number = 1;`); + assert.equal((updatedProgram.getSourceFile("/out/main.js"))?.text, `export const value = 1;\n`); + } + finally { + api.close(); + } + }); + + test("cache filesystem emit writes through to the host", () => { + const host = createVirtualFileSystem({}); + const api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + fs: host, + }); + try { + using snapshot = api.updateSnapshot({ + fileSystem: createCacheFileSystem(Object.entries({ + "/src/main.ts": `export const value: number = 1;`, + })), + }); + using program = snapshot.createProgram( + ["/src/main.ts"], + { compilerOptions: { noLib: true, outDir: "/out" } }, + ); + const result = program.emit(); + assert.equal(result.fileSystem, undefined); + assert.equal(host.readFile!("/out/main.js"), `export const value = 1;\n`); + } + finally { + api.close(); + } + }); + + test("memory file system can link node_modules from the host", () => { + const readFileCalls: string[] = []; + const directoryExistsCalls: string[] = []; + const fileExistsCalls: string[] = []; + const host = createVirtualFileSystem({ + "/host/node_modules/pkg/index.d.ts": `export declare const value: string;`, + }); + const api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + fs: { + ...host, + directoryExists: path => { + directoryExistsCalls.push(path); + return host.directoryExists!(path); + }, + fileExists: path => { + const exists = host.fileExists!(path); + fileExistsCalls.push(`${path}:${exists}`); + return exists; + }, + readFile: path => { + readFileCalls.push(path); + return host.readFile!(path); + }, + }, + }); + + try { + using snapshot = api.updateSnapshot({ + openProject: "/project/tsconfig.json", + fileSystem: { + kind: "memory", + files: { + "/project/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, moduleResolution: "node" }, files: ["index.ts"] }), + "/project/index.ts": `import { value } from "pkg"; export { value };`, + }, + symlinks: { + "/project/node_modules": { target: "/host/node_modules", host: true }, + }, + }, + }); + const project = snapshot.getProject("/project/tsconfig.json")!; + const sourceFileNames = project.program.getSourceFileNames(); + assert.ok( + sourceFileNames.includes("/host/node_modules/pkg/index.d.ts"), + JSON.stringify({ sourceFileNames, readFileCalls, directoryExistsCalls, fileExistsCalls }), + ); + assert.equal( + (project.program.getSourceFile("/host/node_modules/pkg/index.d.ts"))?.text, + `export declare const value: string;`, + ); + assert.ok(readFileCalls.includes("/host/node_modules/pkg/index.d.ts")); + assert.ok(!readFileCalls.some(path => path.startsWith("/project/node_modules"))); + } + finally { + api.close(); + } + }); + + // TODO: Add snapshot filesystem coverage for `tsc -b` and `tsc -b --clean` + // once build and clean are exposed through the client API. In particular, + // verify that clean removes synthetic outputs and that build-mode re-timestamping + // of emitted-but-unchanged files works for memory filesystems, which currently + // do not model modification times. +}); + describe("Checker - isArrayType / isTupleType", () => { test("number[] is array, not tuple", () => { const api = spawnAPI({ diff --git a/tsc/internal/api/proto.go b/tsc/internal/api/proto.go index c93a0a5b1ac9f..bf343ca674472 100644 --- a/tsc/internal/api/proto.go +++ b/tsc/internal/api/proto.go @@ -341,9 +341,54 @@ type APIFileChanges struct { Deleted []DocumentIdentifier `json:"deleted,omitempty"` } +// SnapshotFileSystemKind controls how an update snapshot filesystem is used. +type SnapshotFileSystemKind string + +const ( + // SnapshotFileSystemKindMemory makes the supplied filesystem canonical and total. + SnapshotFileSystemKindMemory SnapshotFileSystemKind = "memory" + // SnapshotFileSystemKindCache checks the supplied filesystem before falling back to the host. + SnapshotFileSystemKindCache SnapshotFileSystemKind = "cache" +) + +// SnapshotDirectoryEntries is a cached directory listing. Entry names are +// relative to the directory, matching vfs.GetAccessibleEntries. +type SnapshotDirectoryEntries struct { + Files []string `json:"files" nonnil:"true"` + Directories []string `json:"directories" nonnil:"true"` +} + +// SnapshotSymlink describes a symbolic link in a snapshot filesystem. +type SnapshotSymlink struct { + // Target is resolved relative to the directory containing the link, matching + // native symbolic-link semantics. + Target string `json:"target"` + // Host routes the target through the host filesystem. This is the only way a + // memory filesystem can access paths not supplied in the snapshot filesystem. + Host bool `json:"host,omitempty"` +} + +// SnapshotFileSystem supplies file contents and, optionally, directory listings +// for a snapshot update. +type SnapshotFileSystem struct { + Kind SnapshotFileSystemKind `json:"kind"` + // Files maps file names to their complete contents. + Files map[string]string `json:"files" nonnil:"true"` + // Directories maps directory names to complete listing results. + Directories map[string]SnapshotDirectoryEntries `json:"directories,omitempty"` + // Symlinks maps link paths to targets in this filesystem or the host filesystem. + Symlinks map[string]SnapshotSymlink `json:"symlinks,omitempty"` + // RemovedPaths lists files or directory trees that must be treated as missing + // even when present in an underlying snapshot or host filesystem. + RemovedPaths []string `json:"removedPaths,omitempty"` +} + // UpdateSnapshotParams are the parameters for creating a new snapshot. // All fields are optional. With no fields set, the server adopts the latest LSP state. type UpdateSnapshotParams struct { + // Snapshot, when set, requires this to be the latest active snapshot and layers + // FileSystem over that snapshot's filesystem. Used by Snapshot.update. + Snapshot SnapshotID `json:"snapshot,omitempty"` // OpenProjects lists tsconfig.json files to open/load in the new snapshot. // Opens are ref-counted and persist across snapshots until closed. OpenProjects []DocumentIdentifier `json:"openProjects,omitempty"` @@ -352,6 +397,10 @@ type UpdateSnapshotParams struct { CloseProjects []DocumentIdentifier `json:"closeProjects,omitempty"` // FileChanges describes file system changes since the last snapshot. FileChanges *APIFileChanges `json:"fileChanges,omitempty"` + // FileSystem supplies file contents and directory listings for the new snapshot. + // A memory filesystem is canonical and total. A cache filesystem is checked + // before falling back to the host filesystem. + FileSystem *SnapshotFileSystem `json:"fileSystem,omitempty"` // OpenFiles lists files to keep open for the API client, mirroring LSP's // textDocument/didOpen. For each file, ancestor directories are searched for a // tsconfig that contains it; if found, that configured project is loaded and @@ -376,10 +425,13 @@ type UpdateTemporarySnapshotParams struct { } type CreateProgramParams struct { - RootFiles []DocumentIdentifier `json:"rootFiles"` - CreateProgramOptions CreateProgramOptions `json:"createProgramOptions"` - OldProgram *CreateProgramOldProgramParams `json:"oldProgram,omitempty"` - FileChanges *APIFileChanges `json:"fileChanges,omitempty"` + RootFiles []DocumentIdentifier `json:"rootFiles"` + CreateProgramOptions CreateProgramOptions `json:"createProgramOptions"` + // BaseSnapshot supplies the filesystem and project state from which the + // synthetic program snapshot is cloned. + BaseSnapshot SnapshotID `json:"baseSnapshot,omitempty"` + OldProgram *CreateProgramOldProgramParams `json:"oldProgram,omitempty"` + FileChanges *APIFileChanges `json:"fileChanges,omitempty"` } type CreateProgramOptions struct { @@ -1355,6 +1407,9 @@ type EmitResponse struct { EmitSkipped bool `json:"emitSkipped"` Diagnostics []*DiagnosticResponse `json:"diagnostics" nonnil:"true"` EmittedFiles []string `json:"emittedFiles" nonnil:"true"` + // EmittedFilesContents contains contents parallel to EmittedFiles when the + // source snapshot uses a memory filesystem. It is empty for write-through emits. + EmittedFilesContents []string `json:"emittedFilesContents" nonnil:"true"` } type EmitOutputFile struct { diff --git a/tsc/internal/api/session.go b/tsc/internal/api/session.go index 3cc6a23a0babd..af82b04f97e1a 100644 --- a/tsc/internal/api/session.go +++ b/tsc/internal/api/session.go @@ -34,6 +34,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/transpile" "github.com/microsoft/TypeScript/tsc/internal/tsoptions" "github.com/microsoft/TypeScript/tsc/internal/tspath" + "github.com/microsoft/TypeScript/tsc/internal/vfs" ) var sessionIDCounter atomic.Uint64 @@ -484,6 +485,23 @@ func (s *Session) retainSnapshotData(handle SnapshotID) (*snapshotData, error) { return sd, nil } +// retainLatestSnapshotData atomically verifies that handle identifies the latest +// active snapshot and takes a temporary reference that pins it for an update. +// The caller must pair a successful call with releaseSnapshot, including on errors. +func (s *Session) retainLatestSnapshotData(handle SnapshotID) (*snapshotData, error) { + s.snapshotsMu.Lock() + defer s.snapshotsMu.Unlock() + if handle != s.latestSnapshot { + return nil, fmt.Errorf("%w: snapshot %d is not the latest snapshot", ErrClientError, handle) + } + sd := s.snapshots[handle] + if sd == nil { + return nil, fmt.Errorf("%w: snapshot %d not found", ErrClientError, handle) + } + sd.refCount++ + return sd, nil +} + func (s *Session) releaseSnapshot(handle SnapshotID) error { s.snapshotsMu.Lock() sd := s.snapshots[handle] @@ -980,9 +998,41 @@ func (s *Session) handleUpdateSnapshot(ctx context.Context, params *UpdateSnapsh s.updateMu.Lock() defer s.updateMu.Unlock() + var baseSD *snapshotData + if params.Snapshot != 0 { + var err error + baseSD, err = s.retainLatestSnapshotData(params.Snapshot) + if err != nil { + return nil, err + } + // Release only the temporary pin acquired above; the client's Snapshot + // continues to own its existing reference even if this update fails. + defer func() { _ = s.releaseSnapshot(params.Snapshot) }() + } + fileChanges := s.toFileChangeSummary(params.FileChanges) apiRequest := &project.APISnapshotRequest{} + baseFS := s.projectSession.FS() + if baseSD != nil { + baseFS = baseSD.snapshot.FileSystem() + apiRequest.FileSystem = baseFS + } + if params.FileSystem != nil { + var fs vfs.FS + var err error + if baseSD == nil { + fs, err = newSnapshotFileSystem(params.FileSystem, baseFS, s.projectSession.GetCurrentDirectory()) + } else { + s.addLayeredFileSystemChanges(&fileChanges, params.FileSystem, baseFS) + fs, err = newLayeredSnapshotFileSystem(params.FileSystem, baseFS, s.projectSession.GetCurrentDirectory()) + } + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrClientError, err) + } + apiRequest.FileSystem = fs + apiRequest.ReplaceFileSystem = true + } // Open projects: only take a new ref for projects we aren't already holding open. var openedProjects []tspath.Path @@ -1183,7 +1233,16 @@ func (s *Session) handleCreateProgram(ctx context.Context, params *CreateProgram rootFileNames[i] = rootFile.ToAbsoluteFileName(s.projectSession.GetCurrentDirectory()) } - var oldSnapshot *project.Snapshot + var baseSnapshot *project.Snapshot + if params.BaseSnapshot != 0 { + baseSD, err := s.retainSnapshotData(params.BaseSnapshot) + if err != nil { + return nil, err + } + defer func() { _ = s.releaseSnapshot(params.BaseSnapshot) }() + baseSnapshot = baseSD.snapshot + } + var oldProject *project.Project if params.OldProgram != nil { oldSnapshotID := params.OldProgram.Snapshot @@ -1193,7 +1252,9 @@ func (s *Session) handleCreateProgram(ctx context.Context, params *CreateProgram } defer func() { _ = s.releaseSnapshot(oldSnapshotID) }() - oldSnapshot = oldSD.snapshot + if baseSnapshot == nil { + baseSnapshot = oldSD.snapshot + } oldProject, err = oldSD.getProject(params.OldProgram.Project) if err != nil { return nil, err @@ -1206,7 +1267,7 @@ func (s *Session) handleCreateProgram(ctx context.Context, params *CreateProgram ¶ms.CreateProgramOptions.CompilerOptions, params.CreateProgramOptions.ProjectReferences, core.Map(params.CreateProgramOptions.ConfigFileParsingDiagnostics, func(d *DiagnosticResponse) *ast.Diagnostic { return d.ToDiagnostic() }), - oldSnapshot, + baseSnapshot, oldProject, s.toFileChangeSummary(params.FileChanges), ) @@ -2757,8 +2818,24 @@ func (s *Session) handleEmit(ctx context.Context, params *EmitParams) (*EmitResp if err != nil { return nil, err } - options.WriteFile = func(fileName string, text string, _ *compiler.WriteFileData) error { - return s.projectSession.FS().WriteFile(fileName, text) + var outputFiles map[string]string + sd, err := s.getSnapshotData(params.Snapshot) + if err != nil { + return nil, err + } + if snapshotFileSystem := getSnapshotFileSystem(sd.snapshot.FileSystem()); snapshotFileSystem != nil && snapshotFileSystem.kind == SnapshotFileSystemKindMemory { + outputFiles = make(map[string]string) + var outputMu sync.Mutex + options.WriteFile = func(fileName string, text string, _ *compiler.WriteFileData) error { + outputMu.Lock() + outputFiles[fileName] = text + outputMu.Unlock() + return nil + } + } else { + options.WriteFile = func(fileName string, text string, _ *compiler.WriteFileData) error { + return s.projectSession.FS().WriteFile(fileName, text) + } } result, err := emitProgram(ctx, program, options) if err != nil { @@ -2768,10 +2845,18 @@ func (s *Session) handleEmit(ctx context.Context, params *EmitParams) (*EmitResp if emittedFiles == nil { emittedFiles = []string{} } + emittedFilesContents := []string{} + if outputFiles != nil { + emittedFilesContents = make([]string, len(emittedFiles)) + for i, fileName := range emittedFiles { + emittedFilesContents[i] = outputFiles[fileName] + } + } return &EmitResponse{ - EmitSkipped: result.EmitSkipped, - Diagnostics: nonNilDiagnostics(result.Diagnostics), - EmittedFiles: emittedFiles, + EmitSkipped: result.EmitSkipped, + Diagnostics: nonNilDiagnostics(result.Diagnostics), + EmittedFiles: emittedFiles, + EmittedFilesContents: emittedFilesContents, }, nil } @@ -3794,6 +3879,49 @@ func (s *Session) toFileChangeSummary(changes *APIFileChanges) project.FileChang return summary } +func (s *Session) addLayeredFileSystemChanges(summary *project.FileChangeSummary, fileSystem *SnapshotFileSystem, baseFS vfs.FS) { + cwd := s.projectSession.GetCurrentDirectory() + baseSnapshotFS := getSnapshotFileSystem(baseFS) + addChange := func(fileName string, deleted bool) { + uri := lsconv.FileNameToDocumentURI(fileName) + if deleted { + if baseFS.FileExists(fileName) { + summary.Deleted.Add(uri) + } + return + } + if baseFS.FileExists(fileName) { + summary.Changed.Add(uri) + } else { + summary.Created.Add(uri) + } + } + addChangeAndAliases := func(fileName string, deleted bool) { + addChange(fileName, deleted) + if baseSnapshotFS != nil { + for _, alias := range baseSnapshotFS.aliasesForPath(fileName) { + addChange(alias, deleted) + } + } + } + overlayFiles := make(map[tspath.Path]struct{}, len(fileSystem.Files)) + for fileName := range fileSystem.Files { + absoluteFileName := tspath.GetNormalizedAbsolutePath(fileName, cwd) + overlayFiles[s.toPath(absoluteFileName)] = struct{}{} + addChangeAndAliases(absoluteFileName, false) + } + for _, removedPath := range fileSystem.RemovedPaths { + absoluteFileName := tspath.GetNormalizedAbsolutePath(removedPath, cwd) + if _, replaced := overlayFiles[s.toPath(absoluteFileName)]; replaced { + continue + } + addChangeAndAliases(absoluteFileName, true) + } + if summary.Changed.Len()+summary.Created.Len()+summary.Deleted.Len() > 0 { + summary.IncludesWatchChangeOutsideNodeModules = true + } +} + func (s *Session) getDiagnostics(ctx context.Context, params *GetDiagnosticsParams, getter func(*compiler.Program, context.Context, *ast.SourceFile) []*ast.Diagnostic) ([]*DiagnosticResponse, error) { sd, err := s.getSnapshotData(params.Snapshot) if err != nil { diff --git a/tsc/internal/api/session_createprogram_test.go b/tsc/internal/api/session_createprogram_test.go index be053641bef9f..f4b1b322b9b9c 100644 --- a/tsc/internal/api/session_createprogram_test.go +++ b/tsc/internal/api/session_createprogram_test.go @@ -138,6 +138,42 @@ func TestCreateProgramWithNoRootFiles(t *testing.T) { assert.Equal(t, len(project.Program.GetSourceFiles()), 0) } +func TestCreateProgramFromSnapshotFileSystem(t *testing.T) { + t.Parallel() + + const fileName = "/src/index.ts" + projectSession, _ := projecttestutil.Setup(map[string]any{ + fileName: `export const source = "host";`, + }) + defer projectSession.Close() + session := NewSession(projectSession, nil) + defer session.Close() + ctx := context.Background() + + base, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ + FileSystem: &SnapshotFileSystem{ + Kind: SnapshotFileSystemKindMemory, + Files: map[string]string{ + fileName: `export const source = "memory";`, + }, + }, + }) + assert.NilError(t, err) + + response, err := session.handleCreateProgram(ctx, &CreateProgramParams{ + RootFiles: []DocumentIdentifier{{FileName: fileName}}, + BaseSnapshot: base.Snapshot, + CreateProgramOptions: CreateProgramOptions{ + CompilerOptions: core.CompilerOptions{NoLib: core.TSTrue}, + }, + }) + assert.NilError(t, err) + created, err := session.getSnapshotData(response.Snapshot) + assert.NilError(t, err) + program := created.snapshot.ProjectCollection.InferredProject().Program + assert.Equal(t, program.GetSourceFile(fileName).Text(), `export const source = "memory";`) +} + func TestCreateProgramRemovesAllRootFiles(t *testing.T) { t.Parallel() diff --git a/tsc/internal/api/snapshotfilesystem.go b/tsc/internal/api/snapshotfilesystem.go new file mode 100644 index 0000000000000..b1092f9b39d9d --- /dev/null +++ b/tsc/internal/api/snapshotfilesystem.go @@ -0,0 +1,958 @@ +package api + +import ( + "errors" + "fmt" + "io/fs" + "slices" + "strings" + "sync" + "time" + + "github.com/microsoft/TypeScript/tsc/internal/tspath" + "github.com/microsoft/TypeScript/tsc/internal/vfs" +) + +// snapshotFileSystem is either a total in-memory filesystem or a read-through +// cache layered over the session host filesystem. Cache misses deliberately go +// through base, which may itself be a callback filesystem. +type snapshotFileSystem struct { + mu sync.RWMutex + kind SnapshotFileSystemKind + base vfs.FS + layered bool + currentDirectory string + useCaseSensitiveNames bool + files map[tspath.Path]snapshotFile + directoryListings map[tspath.Path]vfs.Entries + symlinks map[tspath.Path]snapshotSymlink + removedPaths map[tspath.Path]struct{} + directories map[tspath.Path]string + derivedListings map[tspath.Path]*snapshotDirectoryBuilder +} + +type snapshotFile struct { + fileName string + content string +} + +type snapshotSymlink struct { + linkName string + target string + host bool +} + +type resolvedSnapshotPath struct { + path string + followedSymlink bool + host bool + ok bool +} + +type snapshotDirectoryBuilder struct { + files map[tspath.Path]string + directories map[tspath.Path]string +} + +type fileSystemUnwrapper interface { + Unwrap() vfs.FS +} + +func getSnapshotFileSystem(fileSystem vfs.FS) *snapshotFileSystem { + seen := make(map[vfs.FS]struct{}) + for fileSystem != nil { + if _, ok := seen[fileSystem]; ok { + return nil + } + seen[fileSystem] = struct{}{} + if snapshotFileSystem, ok := fileSystem.(*snapshotFileSystem); ok { + return snapshotFileSystem + } + unwrapper, ok := fileSystem.(fileSystemUnwrapper) + if !ok { + return nil + } + fileSystem = unwrapper.Unwrap() + } + return nil +} + +func getHostFileSystem(fileSystem vfs.FS) vfs.FS { + seen := make(map[vfs.FS]struct{}) + for fileSystem != nil { + if _, ok := seen[fileSystem]; ok { + return nil + } + seen[fileSystem] = struct{}{} + if snapshotFileSystem, ok := fileSystem.(*snapshotFileSystem); ok { + fileSystem = snapshotFileSystem.base + continue + } + if unwrapper, ok := fileSystem.(fileSystemUnwrapper); ok { + fileSystem = unwrapper.Unwrap() + continue + } + return fileSystem + } + return nil +} + +func newSnapshotFileSystem(params *SnapshotFileSystem, base vfs.FS, currentDirectory string) (vfs.FS, error) { + return newSnapshotFileSystemWorker(params, base, currentDirectory, false) +} + +func newLayeredSnapshotFileSystem(params *SnapshotFileSystem, base vfs.FS, currentDirectory string) (vfs.FS, error) { + return newSnapshotFileSystemWorker(params, base, currentDirectory, true) +} + +func newSnapshotFileSystemWorker(params *SnapshotFileSystem, base vfs.FS, currentDirectory string, layered bool) (vfs.FS, error) { + if params.Kind != SnapshotFileSystemKindMemory && params.Kind != SnapshotFileSystemKindCache { + return nil, fmt.Errorf("unknown snapshot filesystem kind %q", params.Kind) + } + + result := &snapshotFileSystem{ + kind: params.Kind, + base: base, + layered: layered, + currentDirectory: currentDirectory, + useCaseSensitiveNames: base.UseCaseSensitiveFileNames(), + files: make(map[tspath.Path]snapshotFile, len(params.Files)), + directoryListings: make(map[tspath.Path]vfs.Entries, len(params.Directories)), + symlinks: make(map[tspath.Path]snapshotSymlink, len(params.Symlinks)), + removedPaths: make(map[tspath.Path]struct{}, len(params.RemovedPaths)), + } + for fileName, content := range params.Files { + absoluteFileName := result.toAbsolutePath(fileName) + result.files[result.toPath(absoluteFileName)] = snapshotFile{fileName: absoluteFileName, content: content} + } + for directoryName, entries := range params.Directories { + absoluteDirectoryName := result.toAbsolutePath(directoryName) + result.directoryListings[result.toPath(absoluteDirectoryName)] = vfs.Entries{ + Files: slices.Clone(entries.Files), + Directories: slices.Clone(entries.Directories), + } + } + for linkName, symlink := range params.Symlinks { + absoluteLinkName := result.toAbsolutePath(linkName) + targetDirectory := tspath.GetDirectoryPath(absoluteLinkName) + absoluteTarget := result.toAbsolutePathFrom(symlink.Target, targetDirectory) + result.symlinks[result.toPath(absoluteLinkName)] = snapshotSymlink{ + linkName: absoluteLinkName, + target: absoluteTarget, + host: symlink.Host, + } + } + for _, path := range params.RemovedPaths { + result.removedPaths[result.toPath(result.toAbsolutePath(path))] = struct{}{} + } + result.rebuildDirectoriesLocked() + return result, nil +} + +func (s *snapshotFileSystem) fallsBack() bool { + return s.layered || s.kind == SnapshotFileSystemKindCache +} + +func (s *snapshotFileSystem) isRemoved(path string) bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.isRemovedLocked(path) +} + +func (s *snapshotFileSystem) isRemovedLocked(path string) bool { + canonicalPath := s.toPath(path) + for removedPath := range s.removedPaths { + if canonicalPath == removedPath || strings.HasPrefix(string(canonicalPath), tspath.EnsureTrailingDirectorySeparator(string(removedPath))) { + return true + } + } + return false +} + +func (s *snapshotFileSystem) toAbsolutePath(path string) string { + return s.toAbsolutePathFrom(path, s.currentDirectory) +} + +func (s *snapshotFileSystem) toAbsolutePathFrom(path string, currentDirectory string) string { + absolutePath := tspath.GetNormalizedAbsolutePath(path, currentDirectory) + if tspath.IsDiskPathRoot(absolutePath) { + return absolutePath + } + return tspath.RemoveTrailingDirectorySeparator(absolutePath) +} + +func (s *snapshotFileSystem) toPath(path string) tspath.Path { + return tspath.ToPath(path, s.currentDirectory, s.useCaseSensitiveNames) +} + +func (s *snapshotFileSystem) registerDirectoryLocked(directoryName string) { + directoryName = s.toAbsolutePath(directoryName) + directoryPath := s.toPath(directoryName) + if _, ok := s.directories[directoryPath]; ok { + return + } + s.directories[directoryPath] = directoryName + if s.derivedListings[directoryPath] == nil { + s.derivedListings[directoryPath] = &snapshotDirectoryBuilder{} + } + + parentName := tspath.GetDirectoryPath(directoryName) + parentPath := s.toPath(parentName) + if parentPath == directoryPath { + return + } + s.registerDirectoryLocked(parentName) + parent := s.derivedListings[parentPath] + if parent.directories == nil { + parent.directories = make(map[tspath.Path]string) + } + parent.directories[directoryPath] = tspath.GetBaseFileName(directoryName) +} + +func (s *snapshotFileSystem) rebuildDirectoriesLocked() { + s.directories = make(map[tspath.Path]string) + s.derivedListings = make(map[tspath.Path]*snapshotDirectoryBuilder) + s.registerDirectoryLocked(s.currentDirectory) + for path, file := range s.files { + parentName := tspath.GetDirectoryPath(file.fileName) + parentPath := s.toPath(parentName) + s.registerDirectoryLocked(parentName) + listing := s.derivedListings[parentPath] + if listing.files == nil { + listing.files = make(map[tspath.Path]string) + } + listing.files[path] = tspath.GetBaseFileName(file.fileName) + } + for path, entries := range s.directoryListings { + directoryName := string(path) + s.registerDirectoryLocked(directoryName) + for _, child := range entries.Directories { + s.registerDirectoryLocked(tspath.CombinePaths(directoryName, child)) + } + } + for _, symlink := range s.symlinks { + s.registerDirectoryLocked(tspath.GetDirectoryPath(symlink.linkName)) + } +} + +func (s *snapshotFileSystem) resolvePath(path string) resolvedSnapshotPath { + s.mu.RLock() + defer s.mu.RUnlock() + return s.resolvePathLocked(path) +} + +func (s *snapshotFileSystem) resolvePathLocked(path string) resolvedSnapshotPath { + path = s.toAbsolutePath(path) + result := resolvedSnapshotPath{path: path, ok: true} + seen := make(map[tspath.Path]struct{}, len(s.symlinks)) + for { + canonicalPath := string(s.toPath(result.path)) + var matchPath tspath.Path + var match snapshotSymlink + for linkPath, symlink := range s.symlinks { + canonicalLink := string(linkPath) + if canonicalPath != canonicalLink && !strings.HasPrefix(canonicalPath, tspath.EnsureTrailingDirectorySeparator(canonicalLink)) { + continue + } + // Resolve the first link encountered while walking from the root. This + // matches native path traversal when links happen to overlap. + if matchPath == "" || len(linkPath) < len(matchPath) { + matchPath = linkPath + match = symlink + } + } + if matchPath == "" { + result.host = s.isHostPathLocked(result.path) + return result + } + if _, ok := seen[matchPath]; ok { + result.ok = false + return result + } + seen[matchPath] = struct{}{} + result.followedSymlink = true + suffix, ok := tspath.TrimFilePathPrefix(result.path, match.linkName, s.useCaseSensitiveNames) + if !ok { + result.ok = false + return result + } + result.path = s.toAbsolutePath(match.target + suffix) + if match.host { + result.host = true + return result + } + } +} + +// resolvePathForOverlay resolves the effective path through this snapshot layer +// and any underlying snapshot layers, stopping when this layer supplies or removes +// the resolved path. Callers in a newer layer use this to apply their own entries +// to targets of inherited symlinks before delegating the operation to the base. +func (s *snapshotFileSystem) resolvePathForOverlay(path string) resolvedSnapshotPath { + resolved := s.resolvePath(path) + if !resolved.ok || resolved.host { + return resolved + } + if _, ok := s.fileAt(resolved.path); ok { + return resolved + } + if _, ok := s.directoryAt(resolved.path); ok { + return resolved + } + if !resolved.followedSymlink && s.isRemoved(path) || s.isRemoved(resolved.path) || !s.fallsBack() { + return resolved + } + baseResolved := s.resolveBasePath(resolved.path) + baseResolved.followedSymlink = baseResolved.followedSymlink || resolved.followedSymlink + return baseResolved +} + +func (s *snapshotFileSystem) resolveBasePath(path string) resolvedSnapshotPath { + if base := getSnapshotFileSystem(s.base); base != nil { + return base.resolvePathForOverlay(path) + } + return resolvedSnapshotPath{path: path, ok: true} +} + +func (s *snapshotFileSystem) isHostPathLocked(path string) bool { + canonicalPath := string(s.toPath(path)) + for _, symlink := range s.symlinks { + if !symlink.host { + continue + } + canonicalTarget := string(s.toPath(symlink.target)) + if canonicalPath == canonicalTarget || strings.HasPrefix(canonicalPath, tspath.EnsureTrailingDirectorySeparator(canonicalTarget)) { + return true + } + } + return false +} + +func (s *snapshotFileSystem) aliasesForPath(path string) []string { + symlinks := make([]snapshotSymlink, 0, len(s.symlinks)) + for current := s; current != nil; { + current.mu.RLock() + for _, symlink := range current.symlinks { + symlinks = append(symlinks, symlink) + } + current.mu.RUnlock() + base := getSnapshotFileSystem(current.base) + if base == nil { + break + } + current = base + } + + seen := map[tspath.Path]struct{}{s.toPath(path): {}} + queue := []string{s.toAbsolutePath(path)} + var aliases []string + for len(queue) > 0 { + candidate := queue[0] + queue = queue[1:] + for _, symlink := range symlinks { + suffix, ok := tspath.TrimFilePathPrefix(candidate, symlink.target, s.useCaseSensitiveNames) + if !ok || suffix != "" && !tspath.HasTrailingDirectorySeparator(symlink.target) && !strings.HasPrefix(suffix, "/") { + continue + } + alias := s.toAbsolutePath(symlink.linkName + suffix) + aliasPath := s.toPath(alias) + if _, ok := seen[aliasPath]; ok { + continue + } + seen[aliasPath] = struct{}{} + aliases = append(aliases, alias) + queue = append(queue, alias) + } + } + return aliases +} + +func (s *snapshotFileSystem) fileAt(path string) (snapshotFile, bool) { + s.mu.RLock() + file, ok := s.files[s.toPath(path)] + s.mu.RUnlock() + return file, ok +} + +func (s *snapshotFileSystem) directoryAt(path string) (string, bool) { + s.mu.RLock() + directory, ok := s.directories[s.toPath(path)] + s.mu.RUnlock() + return directory, ok +} + +func cloneEntries(entries vfs.Entries) vfs.Entries { + result := vfs.Entries{ + Files: slices.Clone(entries.Files), + Directories: slices.Clone(entries.Directories), + } + if entries.Symlinks != nil { + result.Symlinks = make(map[string]struct{}, len(entries.Symlinks)) + for name := range entries.Symlinks { + result.Symlinks[name] = struct{}{} + } + } + return result +} + +func (s *snapshotFileSystem) UseCaseSensitiveFileNames() bool { + return s.useCaseSensitiveNames +} + +func (s *snapshotFileSystem) ReadFile(fileName string) (string, bool) { + resolved := s.resolvePath(fileName) + if !resolved.ok { + return "", false + } + if resolved.host { + if s.isRemoved(resolved.path) { + return "", false + } + return s.base.ReadFile(resolved.path) + } + file, ok := s.fileAt(resolved.path) + if ok { + return file.content, true + } + if _, ok := s.directoryAt(resolved.path); ok { + return "", false + } + if !resolved.followedSymlink && s.isRemoved(fileName) { + return "", false + } + fallbackPath := resolved.path + if s.fallsBack() { + fallback := s.resolveBasePath(resolved.path) + if !fallback.ok { + return "", false + } + fallbackPath = fallback.path + if file, ok := s.fileAt(fallbackPath); ok { + return file.content, true + } + if _, ok := s.directoryAt(fallbackPath); ok { + return "", false + } + } + if s.isRemoved(resolved.path) || s.isRemoved(fallbackPath) { + return "", false + } + if s.fallsBack() { + return s.base.ReadFile(resolved.path) + } + return "", false +} + +func (s *snapshotFileSystem) FileExists(fileName string) bool { + resolved := s.resolvePath(fileName) + if !resolved.ok { + return false + } + if resolved.host { + if s.isRemoved(resolved.path) { + return false + } + return s.base.FileExists(resolved.path) + } + _, ok := s.fileAt(resolved.path) + if ok { + return true + } + if _, ok := s.directoryAt(resolved.path); ok { + return false + } + if !resolved.followedSymlink && s.isRemoved(fileName) { + return false + } + fallbackPath := resolved.path + if s.fallsBack() { + fallback := s.resolveBasePath(resolved.path) + if !fallback.ok { + return false + } + fallbackPath = fallback.path + if _, ok := s.fileAt(fallbackPath); ok { + return true + } + if _, ok := s.directoryAt(fallbackPath); ok { + return false + } + } + if s.isRemoved(resolved.path) || s.isRemoved(fallbackPath) || !s.fallsBack() { + return false + } + return s.base.FileExists(resolved.path) +} + +func (s *snapshotFileSystem) DirectoryExists(directoryName string) bool { + resolved := s.resolvePath(directoryName) + if !resolved.ok { + return false + } + if resolved.host { + if s.isRemoved(resolved.path) { + return false + } + return s.base.DirectoryExists(resolved.path) + } + _, ok := s.directoryAt(resolved.path) + if ok { + return true + } + if _, ok := s.fileAt(resolved.path); ok { + return false + } + if !resolved.followedSymlink && s.isRemoved(directoryName) { + return false + } + fallbackPath := resolved.path + if s.fallsBack() { + fallback := s.resolveBasePath(resolved.path) + if !fallback.ok { + return false + } + fallbackPath = fallback.path + if _, ok := s.directoryAt(fallbackPath); ok { + return true + } + if _, ok := s.fileAt(fallbackPath); ok { + return false + } + } + if s.isRemoved(resolved.path) || s.isRemoved(fallbackPath) || !s.fallsBack() { + return false + } + return s.base.DirectoryExists(resolved.path) +} + +func (s *snapshotFileSystem) GetAccessibleEntries(directoryName string) vfs.Entries { + resolved := s.resolvePath(directoryName) + if !resolved.ok { + return vfs.Entries{Symlinks: map[string]struct{}{}} + } + if _, ok := s.fileAt(resolved.path); ok { + return vfs.Entries{Symlinks: map[string]struct{}{}} + } + + localEntries, hasExplicitListing, hasLocalEntries := s.getLocalEntries(resolved.path) + if !resolved.followedSymlink && s.isRemoved(directoryName) && !hasLocalEntries { + return vfs.Entries{Symlinks: map[string]struct{}{}} + } + fallbackPath := resolved.path + if !resolved.host && s.fallsBack() { + fallback := s.resolveBasePath(resolved.path) + if !fallback.ok { + return vfs.Entries{Symlinks: map[string]struct{}{}} + } + fallbackPath = fallback.path + if _, ok := s.fileAt(fallbackPath); ok { + return vfs.Entries{Symlinks: map[string]struct{}{}} + } + if s.toPath(fallbackPath) != s.toPath(resolved.path) { + targetEntries, targetExplicit, targetLocal := s.getLocalEntries(fallbackPath) + if targetLocal { + localEntries = mergeEntries(localEntries, targetEntries, s.equalEntryNames) + hasLocalEntries = true + } + hasExplicitListing = hasExplicitListing || targetExplicit + } + } + var result vfs.Entries + if resolved.host { + if !s.isRemoved(resolved.path) { + result = s.removeEntries(resolved.path, s.base.GetAccessibleEntries(resolved.path)) + } + } else if !s.fallsBack() || hasExplicitListing && !s.layered { + result = localEntries + } else { + if !s.isRemoved(directoryName) && !s.isRemoved(resolved.path) && !s.isRemoved(fallbackPath) { + result = s.removeEntries(directoryName, s.base.GetAccessibleEntries(resolved.path)) + if s.toPath(directoryName) != s.toPath(resolved.path) { + result = s.removeEntries(resolved.path, result) + } + if s.toPath(fallbackPath) != s.toPath(resolved.path) { + result = s.removeEntries(fallbackPath, result) + } + } + if hasLocalEntries { + result = mergeEntries(result, localEntries, s.equalEntryNames) + } + } + result = s.addSymlinkEntries(resolved.path, result) + if s.toPath(fallbackPath) != s.toPath(resolved.path) { + result = s.addSymlinkEntries(fallbackPath, result) + } + return result +} + +func (s *snapshotFileSystem) getLocalEntries(directoryName string) (entries vfs.Entries, explicit bool, ok bool) { + s.mu.RLock() + defer s.mu.RUnlock() + path := s.toPath(directoryName) + if listing, ok := s.directoryListings[path]; ok { + return cloneEntries(listing), true, true + } + builder := s.derivedListings[path] + if builder == nil { + return vfs.Entries{}, false, false + } + for _, name := range builder.files { + entries.Files = append(entries.Files, name) + } + for _, name := range builder.directories { + entries.Directories = append(entries.Directories, name) + } + slices.Sort(entries.Files) + slices.Sort(entries.Directories) + return entries, false, true +} + +func mergeEntries(base vfs.Entries, overlay vfs.Entries, equal func(string, string) bool) vfs.Entries { + result := cloneEntries(base) + if result.Symlinks == nil { + result.Symlinks = map[string]struct{}{} + } + deleteSymlink := func(name string) { + for existingName := range result.Symlinks { + if equal(existingName, name) { + delete(result.Symlinks, existingName) + } + } + } + addFile := func(name string) { + result.Directories = slices.DeleteFunc(result.Directories, func(value string) bool { return equal(value, name) }) + if !slices.ContainsFunc(result.Files, func(value string) bool { return equal(value, name) }) { + result.Files = append(result.Files, name) + } + deleteSymlink(name) + } + addDirectory := func(name string) { + result.Files = slices.DeleteFunc(result.Files, func(value string) bool { return equal(value, name) }) + if !slices.ContainsFunc(result.Directories, func(value string) bool { return equal(value, name) }) { + result.Directories = append(result.Directories, name) + } + deleteSymlink(name) + } + for _, name := range overlay.Files { + addFile(name) + } + for _, name := range overlay.Directories { + addDirectory(name) + } + for name := range overlay.Symlinks { + result.Symlinks[name] = struct{}{} + } + slices.Sort(result.Files) + slices.Sort(result.Directories) + return result +} + +func (s *snapshotFileSystem) removeEntries(directoryName string, entries vfs.Entries) vfs.Entries { + s.mu.RLock() + defer s.mu.RUnlock() + return s.removeEntriesLocked(directoryName, entries) +} + +func (s *snapshotFileSystem) removeEntriesLocked(directoryName string, entries vfs.Entries) vfs.Entries { + result := cloneEntries(entries) + filter := func(values []string) []string { + return slices.DeleteFunc(values, func(name string) bool { + return s.isRemovedLocked(tspath.CombinePaths(directoryName, name)) + }) + } + result.Files = filter(result.Files) + result.Directories = filter(result.Directories) + for name := range result.Symlinks { + if s.isRemovedLocked(tspath.CombinePaths(directoryName, name)) { + delete(result.Symlinks, name) + } + } + return result +} + +func (s *snapshotFileSystem) addSymlinkEntries(directoryName string, entries vfs.Entries) vfs.Entries { + result := cloneEntries(entries) + if result.Symlinks == nil { + result.Symlinks = map[string]struct{}{} + } + + s.mu.RLock() + directoryPath := s.toPath(directoryName) + var links []snapshotSymlink + for _, symlink := range s.symlinks { + if s.toPath(tspath.GetDirectoryPath(symlink.linkName)) == directoryPath { + links = append(links, symlink) + } + } + s.mu.RUnlock() + + for _, symlink := range links { + name := tspath.GetBaseFileName(symlink.linkName) + result.Files = s.deleteEntryName(result.Files, name) + result.Directories = s.deleteEntryName(result.Directories, name) + for existingName := range result.Symlinks { + if s.equalEntryNames(existingName, name) { + delete(result.Symlinks, existingName) + } + } + if s.DirectoryExists(symlink.linkName) { + result.Directories = append(result.Directories, name) + result.Symlinks[name] = struct{}{} + } else if s.FileExists(symlink.linkName) { + result.Files = append(result.Files, name) + result.Symlinks[name] = struct{}{} + } + } + slices.Sort(result.Files) + slices.Sort(result.Directories) + return result +} + +func (s *snapshotFileSystem) deleteEntryName(values []string, value string) []string { + return slices.DeleteFunc(values, func(candidate string) bool { return s.equalEntryNames(candidate, value) }) +} + +func (s *snapshotFileSystem) equalEntryNames(left string, right string) bool { + return tspath.GetCanonicalFileName(left, s.useCaseSensitiveNames) == tspath.GetCanonicalFileName(right, s.useCaseSensitiveNames) +} + +func (s *snapshotFileSystem) Realpath(path string) string { + resolved := s.resolvePath(path) + if !resolved.ok { + return path + } + if _, ok := s.fileAt(resolved.path); ok { + return resolved.path + } + if _, ok := s.directoryAt(resolved.path); ok { + return resolved.path + } + if !resolved.followedSymlink && s.isRemoved(path) { + return path + } + fallbackPath := resolved.path + if !resolved.host && s.fallsBack() { + fallback := s.resolveBasePath(resolved.path) + if !fallback.ok { + return path + } + fallbackPath = fallback.path + if _, ok := s.fileAt(fallbackPath); ok { + return fallbackPath + } + if _, ok := s.directoryAt(fallbackPath); ok { + return fallbackPath + } + } + if s.isRemoved(resolved.path) || s.isRemoved(fallbackPath) { + return path + } + if resolved.host { + return s.base.Realpath(resolved.path) + } + if resolved.followedSymlink && !s.fallsBack() { + return path + } + if s.fallsBack() { + return s.base.Realpath(resolved.path) + } + return resolved.path +} + +func (s *snapshotFileSystem) WriteFile(fileName string, data string) error { + if s.kind != SnapshotFileSystemKindCache { + return vfs.ErrInvalid + } + host := getHostFileSystem(s.base) + if host == nil { + return vfs.ErrInvalid + } + return host.WriteFile(s.toAbsolutePath(fileName), data) +} + +func (s *snapshotFileSystem) AppendFile(fileName string, data string) error { + if s.kind != SnapshotFileSystemKindCache { + return vfs.ErrInvalid + } + host := getHostFileSystem(s.base) + if host == nil { + return vfs.ErrInvalid + } + return host.AppendFile(s.toAbsolutePath(fileName), data) +} + +func (s *snapshotFileSystem) Remove(path string) error { + if s.kind != SnapshotFileSystemKindCache { + return vfs.ErrInvalid + } + host := getHostFileSystem(s.base) + if host == nil { + return vfs.ErrInvalid + } + return host.Remove(s.toAbsolutePath(path)) +} + +func (s *snapshotFileSystem) Chtimes(path string, aTime time.Time, mTime time.Time) error { + resolved := s.resolvePath(path) + if !resolved.ok { + return vfs.ErrInvalid + } + if s.kind != SnapshotFileSystemKindCache { + return vfs.ErrInvalid + } + host := getHostFileSystem(s.base) + if host == nil { + return vfs.ErrInvalid + } + return host.Chtimes(s.toAbsolutePath(path), aTime, mTime) +} + +func (s *snapshotFileSystem) Stat(path string) vfs.FileInfo { + resolved := s.resolvePath(path) + if !resolved.ok { + return nil + } + s.mu.RLock() + canonicalPath := s.toPath(resolved.path) + if file, ok := s.files[canonicalPath]; ok { + info := snapshotFileInfo{name: tspath.GetBaseFileName(file.fileName), size: int64(len(file.content))} + s.mu.RUnlock() + return info + } + if directoryName, ok := s.directories[canonicalPath]; ok { + info := snapshotFileInfo{name: tspath.GetBaseFileName(directoryName), directory: true} + s.mu.RUnlock() + return info + } + s.mu.RUnlock() + if !resolved.followedSymlink && s.isRemoved(path) { + return nil + } + fallbackPath := resolved.path + if !resolved.host && s.fallsBack() { + fallback := s.resolveBasePath(resolved.path) + if !fallback.ok { + return nil + } + fallbackPath = fallback.path + s.mu.RLock() + canonicalFallbackPath := s.toPath(fallbackPath) + if file, ok := s.files[canonicalFallbackPath]; ok { + info := snapshotFileInfo{name: tspath.GetBaseFileName(file.fileName), size: int64(len(file.content))} + s.mu.RUnlock() + return info + } + if directoryName, ok := s.directories[canonicalFallbackPath]; ok { + info := snapshotFileInfo{name: tspath.GetBaseFileName(directoryName), directory: true} + s.mu.RUnlock() + return info + } + s.mu.RUnlock() + } + if s.isRemoved(resolved.path) || s.isRemoved(fallbackPath) { + return nil + } + if resolved.host { + return s.statHost(resolved.path) + } + if s.fallsBack() { + return s.statHost(resolved.path) + } + return nil +} + +func (s *snapshotFileSystem) statHost(path string) vfs.FileInfo { + if info := s.base.Stat(path); info != nil { + return info + } + name := tspath.GetBaseFileName(path) + if s.base.DirectoryExists(path) { + return snapshotFileInfo{name: name, directory: true} + } + if s.base.FileExists(path) { + return snapshotFileInfo{name: name} + } + return nil +} + +func (s *snapshotFileSystem) WalkDir(root string, walkFn vfs.WalkDirFunc) error { + originalRoot := s.toAbsolutePath(root) + resolved := s.resolvePath(originalRoot) + if !resolved.ok { + return walkFn(originalRoot, nil, vfs.ErrNotExist) + } + info := s.Stat(originalRoot) + if info == nil { + return walkFn(originalRoot, nil, vfs.ErrNotExist) + } + visited := map[string]struct{}{} + if err := s.walkDir(originalRoot, snapshotDirEntry{info: info}, walkFn, visited); errors.Is(err, fs.SkipAll) { + return nil + } else { + return err + } +} + +func (s *snapshotFileSystem) walkDir(path string, entry snapshotDirEntry, walkFn vfs.WalkDirFunc, visited map[string]struct{}) error { + realpath := s.Realpath(path) + if _, ok := visited[realpath]; ok { + return nil + } + visited[realpath] = struct{}{} + err := walkFn(path, entry, nil) + if err != nil { + if errors.Is(err, fs.SkipDir) && entry.IsDir() { + return nil + } + return err + } + if !entry.IsDir() { + return nil + } + entries := s.GetAccessibleEntries(path) + names := append(slices.Clone(entries.Directories), entries.Files...) + slices.Sort(names) + for _, name := range names { + childPath := tspath.CombinePaths(path, name) + childInfo := s.Stat(childPath) + if childInfo == nil { + continue + } + if err := s.walkDir(childPath, snapshotDirEntry{info: childInfo}, walkFn, visited); err != nil { + if errors.Is(err, fs.SkipDir) { + return nil + } + return err + } + } + return nil +} + +type snapshotFileInfo struct { + name string + size int64 + directory bool +} + +func (i snapshotFileInfo) Name() string { return i.name } +func (i snapshotFileInfo) Size() int64 { return i.size } +func (i snapshotFileInfo) ModTime() time.Time { return time.Time{} } +func (i snapshotFileInfo) IsDir() bool { return i.directory } +func (i snapshotFileInfo) Sys() any { return nil } +func (i snapshotFileInfo) Mode() fs.FileMode { + if i.directory { + return fs.ModeDir | 0o555 + } + return 0o444 +} + +type snapshotDirEntry struct { + info vfs.FileInfo +} + +func (e snapshotDirEntry) Name() string { return e.info.Name() } +func (e snapshotDirEntry) IsDir() bool { return e.info.IsDir() } +func (e snapshotDirEntry) Type() fs.FileMode { return e.info.Mode().Type() } +func (e snapshotDirEntry) Info() (fs.FileInfo, error) { return e.info, nil } + +var _ vfs.FS = (*snapshotFileSystem)(nil) diff --git a/tsc/internal/api/snapshotfilesystem_test.go b/tsc/internal/api/snapshotfilesystem_test.go new file mode 100644 index 0000000000000..1298b4cb24dab --- /dev/null +++ b/tsc/internal/api/snapshotfilesystem_test.go @@ -0,0 +1,675 @@ +package api + +import ( + "context" + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/testutil/projecttestutil" + "github.com/microsoft/TypeScript/tsc/internal/tspath" + "github.com/microsoft/TypeScript/tsc/internal/vfs" + "github.com/microsoft/TypeScript/tsc/internal/vfs/trackingvfs" + "github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest" + "gotest.tools/v3/assert" +) + +func TestSnapshotFileSystem(t *testing.T) { + t.Parallel() + + t.Run("memory is total and never falls back", func(t *testing.T) { + t.Parallel() + base := &trackingvfs.FS{Inner: vfstest.FromMap(map[string]string{ + "/host.ts": "host", + }, true)} + fileSystem, err := newSnapshotFileSystem(&SnapshotFileSystem{ + Kind: SnapshotFileSystemKindMemory, + Files: map[string]string{ + "/src/index.ts": "memory", + }, + }, base, "/") + assert.NilError(t, err) + + contents, ok := fileSystem.ReadFile("/src/index.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, "memory") + assert.Assert(t, fileSystem.FileExists("/src/index.ts")) + assert.Assert(t, fileSystem.DirectoryExists("/src")) + assert.DeepEqual(t, fileSystem.GetAccessibleEntries("/src").Files, []string{"index.ts"}) + + _, ok = fileSystem.ReadFile("/host.ts") + assert.Assert(t, !ok) + assert.Assert(t, !fileSystem.FileExists("/host.ts")) + assert.Assert(t, !base.SeenFiles.Has("/host.ts")) + }) + + t.Run("cache hits bypass the host and misses fall back", func(t *testing.T) { + t.Parallel() + base := &trackingvfs.FS{Inner: vfstest.FromMap(map[string]string{ + "/fallback.ts": "fallback", + }, true)} + fileSystem, err := newSnapshotFileSystem(&SnapshotFileSystem{ + Kind: SnapshotFileSystemKindCache, + Files: map[string]string{ + "/cached/index.ts": "cached", + }, + Directories: map[string]SnapshotDirectoryEntries{ + "/cached": {Files: []string{"index.ts"}, Directories: []string{}}, + }, + }, base, "/") + assert.NilError(t, err) + + contents, ok := fileSystem.ReadFile("/cached/index.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, "cached") + assert.Assert(t, fileSystem.FileExists("/cached/index.ts")) + assert.Assert(t, fileSystem.DirectoryExists("/cached")) + assert.DeepEqual(t, fileSystem.GetAccessibleEntries("/cached").Files, []string{"index.ts"}) + assert.Assert(t, !base.SeenFiles.Has("/cached/index.ts")) + assert.Assert(t, !base.SeenFiles.Has("/cached")) + + contents, ok = fileSystem.ReadFile("/fallback.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, "fallback") + assert.Assert(t, base.SeenFiles.Has("/fallback.ts")) + }) + + t.Run("memory resolves internal file and directory symlinks", func(t *testing.T) { + t.Parallel() + base := &trackingvfs.FS{Inner: vfstest.FromMap(map[string]string{ + "/host.ts": "host", + }, true)} + fileSystem, err := newSnapshotFileSystem(&SnapshotFileSystem{ + Kind: SnapshotFileSystemKindMemory, + Files: map[string]string{ + "/packages/pkg/index.d.ts": "export declare const value: number;", + }, + Symlinks: map[string]SnapshotSymlink{ + "/project/node_modules/pkg": {Target: "../../../packages/pkg"}, + "/project/pkg.d.ts": {Target: "../packages/pkg/index.d.ts"}, + }, + }, base, "/") + assert.NilError(t, err) + + contents, ok := fileSystem.ReadFile("/project/node_modules/pkg/index.d.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, "export declare const value: number;") + contents, ok = fileSystem.ReadFile("/project/pkg.d.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, "export declare const value: number;") + assert.Equal(t, fileSystem.Realpath("/project/node_modules/pkg/index.d.ts"), "/packages/pkg/index.d.ts") + + entries := fileSystem.GetAccessibleEntries("/project/node_modules") + assert.DeepEqual(t, entries.Directories, []string{"pkg"}) + _, isSymlink := entries.Symlinks["pkg"] + assert.Assert(t, isSymlink) + entries = fileSystem.GetAccessibleEntries("/project") + assert.DeepEqual(t, entries.Files, []string{"pkg.d.ts"}) + _, isSymlink = entries.Symlinks["pkg.d.ts"] + assert.Assert(t, isSymlink) + assert.Assert(t, base.SeenFiles.IsEmpty()) + }) + + t.Run("cache resolves internal symlinks before the host", func(t *testing.T) { + t.Parallel() + base := &trackingvfs.FS{Inner: vfstest.FromMap(map[string]string{ + "/packages/pkg/index.d.ts": "host content", + }, true)} + fileSystem, err := newSnapshotFileSystem(&SnapshotFileSystem{ + Kind: SnapshotFileSystemKindCache, + Files: map[string]string{ + "/packages/pkg/index.d.ts": "cached content", + }, + Directories: map[string]SnapshotDirectoryEntries{ + "/project/node_modules": {Files: []string{}, Directories: []string{}}, + }, + Symlinks: map[string]SnapshotSymlink{ + "/project/node_modules/pkg": {Target: "/packages/pkg"}, + }, + }, base, "/") + assert.NilError(t, err) + + contents, ok := fileSystem.ReadFile("/project/node_modules/pkg/index.d.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, "cached content") + assert.Equal(t, fileSystem.Realpath("/project/node_modules/pkg/index.d.ts"), "/packages/pkg/index.d.ts") + entries := fileSystem.GetAccessibleEntries("/project/node_modules") + assert.DeepEqual(t, entries.Directories, []string{"pkg"}) + _, isSymlink := entries.Symlinks["pkg"] + assert.Assert(t, isSymlink) + assert.Assert(t, base.SeenFiles.IsEmpty()) + }) + + t.Run("cache file shadows underlying symlink realpath", func(t *testing.T) { + t.Parallel() + base := vfstest.FromMap(map[string]any{ + "/project/node_modules/pkg": vfstest.Symlink("/host/pkg"), + "/host/pkg/index.d.ts": "host content", + }, true) + fileSystem, err := newSnapshotFileSystem(&SnapshotFileSystem{ + Kind: SnapshotFileSystemKindCache, + Files: map[string]string{ + "/project/node_modules/pkg/index.d.ts": "cached content", + }, + }, base, "/") + assert.NilError(t, err) + + contents, ok := fileSystem.ReadFile("/project/node_modules/pkg/index.d.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, "cached content") + assert.Equal( + t, + fileSystem.Realpath("/project/node_modules/pkg/index.d.ts"), + "/project/node_modules/pkg/index.d.ts", + ) + }) + + t.Run("layered cache adds changes and blocks removed entries", func(t *testing.T) { + t.Parallel() + host := vfstest.FromMap(map[string]string{}, true) + base, err := newSnapshotFileSystem(&SnapshotFileSystem{ + Kind: SnapshotFileSystemKindMemory, + Files: map[string]string{ + "/keep.ts": "keep", + "/change.ts": "old", + "/remove.ts": "remove", + "/removed-dir/gone.ts": "gone", + "/becomes-file/child.ts": "child", + "/becomes-directory.ts": "file", + }, + }, host, "/") + assert.NilError(t, err) + + layered, err := newLayeredSnapshotFileSystem(&SnapshotFileSystem{ + Kind: SnapshotFileSystemKindCache, + Files: map[string]string{ + "/change.ts": "new", + "/added.ts": "added", + "/remove.ts": "replacement", + "/removed-dir/replacement.ts": "replacement", + "/becomes-file": "file", + "/becomes-directory.ts/child.ts": "child", + }, + Directories: map[string]SnapshotDirectoryEntries{ + "/": {Files: []string{"added.ts", "becomes-file", "change.ts", "remove.ts"}, Directories: []string{"becomes-directory.ts", "removed-dir"}}, + }, + RemovedPaths: []string{"/remove.ts", "/removed-dir"}, + }, base, "/") + assert.NilError(t, err) + + for path, expected := range map[string]string{ + "/keep.ts": "keep", + "/change.ts": "new", + "/added.ts": "added", + "/remove.ts": "replacement", + "/removed-dir/replacement.ts": "replacement", + "/becomes-file": "file", + "/becomes-directory.ts/child.ts": "child", + } { + contents, ok := layered.ReadFile(path) + assert.Assert(t, ok, path) + assert.Equal(t, contents, expected) + } + assert.Assert(t, layered.FileExists("/remove.ts")) + assert.Assert(t, layered.DirectoryExists("/removed-dir")) + assert.Assert(t, !layered.FileExists("/removed-dir/gone.ts")) + assert.Assert(t, layered.Stat("/remove.ts") != nil) + assert.Assert(t, layered.Stat("/removed-dir/replacement.ts") != nil) + assert.Equal(t, layered.Realpath("/removed-dir/replacement.ts"), "/removed-dir/replacement.ts") + assert.Assert(t, layered.FileExists("/becomes-file")) + assert.Assert(t, !layered.DirectoryExists("/becomes-file")) + assert.Assert(t, !layered.FileExists("/becomes-directory.ts")) + assert.Assert(t, layered.DirectoryExists("/becomes-directory.ts")) + assert.DeepEqual(t, layered.GetAccessibleEntries("/").Files, []string{"added.ts", "becomes-file", "change.ts", "keep.ts", "remove.ts"}) + assert.DeepEqual(t, layered.GetAccessibleEntries("/").Directories, []string{"becomes-directory.ts", "removed-dir"}) + }) + + t.Run("new layers override targets of inherited symlinks", func(t *testing.T) { + t.Parallel() + host := vfstest.FromMap(map[string]string{}, true) + base, err := newSnapshotFileSystem(&SnapshotFileSystem{ + Kind: SnapshotFileSystemKindMemory, + Files: map[string]string{ + "/target/change.ts": "old", + "/target/remove.ts": "remove", + }, + Symlinks: map[string]SnapshotSymlink{ + "/link": {Target: "/target"}, + }, + }, host, "/") + assert.NilError(t, err) + + layered, err := newLayeredSnapshotFileSystem(&SnapshotFileSystem{ + Kind: SnapshotFileSystemKindCache, + Files: map[string]string{ + "/target/change.ts": "new", + "/target/added.ts": "added", + }, + RemovedPaths: []string{"/target/remove.ts"}, + }, base, "/") + assert.NilError(t, err) + + contents, ok := layered.ReadFile("/link/change.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, "new") + contents, ok = layered.ReadFile("/link/added.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, "added") + _, ok = layered.ReadFile("/link/remove.ts") + assert.Assert(t, !ok) + assert.DeepEqual(t, layered.GetAccessibleEntries("/link").Files, []string{"added.ts", "change.ts"}) + }) + + t.Run("alias tombstones take precedence over inherited symlink targets", func(t *testing.T) { + t.Parallel() + host := vfstest.FromMap(map[string]string{}, true) + base, err := newSnapshotFileSystem(&SnapshotFileSystem{ + Kind: SnapshotFileSystemKindMemory, + Files: map[string]string{ + "/target/file.ts": "old", + }, + Symlinks: map[string]SnapshotSymlink{ + "/link": {Target: "/target"}, + }, + }, host, "/") + assert.NilError(t, err) + + layered, err := newLayeredSnapshotFileSystem(&SnapshotFileSystem{ + Kind: SnapshotFileSystemKindCache, + Files: map[string]string{ + "/target/file.ts": "new", + }, + RemovedPaths: []string{"/link"}, + }, base, "/") + assert.NilError(t, err) + + _, ok := layered.ReadFile("/link/file.ts") + assert.Assert(t, !ok) + assert.Assert(t, !layered.FileExists("/link/file.ts")) + assert.Assert(t, !layered.DirectoryExists("/link")) + assert.Assert(t, layered.Stat("/link/file.ts") == nil) + assert.Equal(t, len(layered.GetAccessibleEntries("/link").Files), 0) + }) + + t.Run("files replacing inherited symlink target directories have empty listings", func(t *testing.T) { + t.Parallel() + host := vfstest.FromMap(map[string]string{}, true) + base, err := newSnapshotFileSystem(&SnapshotFileSystem{ + Kind: SnapshotFileSystemKindMemory, + Files: map[string]string{ + "/target/item/child.ts": "child", + }, + Symlinks: map[string]SnapshotSymlink{ + "/link": {Target: "/target"}, + }, + }, host, "/") + assert.NilError(t, err) + + layered, err := newLayeredSnapshotFileSystem(&SnapshotFileSystem{ + Kind: SnapshotFileSystemKindCache, + Files: map[string]string{ + "/target/item": "file", + }, + }, base, "/") + assert.NilError(t, err) + + assert.Assert(t, layered.FileExists("/link/item")) + assert.Assert(t, !layered.DirectoryExists("/link/item")) + assert.Equal(t, len(layered.GetAccessibleEntries("/link/item").Files), 0) + assert.Equal(t, len(layered.GetAccessibleEntries("/link/item").Directories), 0) + }) + + t.Run("cache tombstones block host hits", func(t *testing.T) { + t.Parallel() + base := &trackingvfs.FS{Inner: vfstest.FromMap(map[string]string{ + "/remove.ts": "host", + "/removed-dir/gone.ts": "host", + }, true)} + fileSystem, err := newSnapshotFileSystem(&SnapshotFileSystem{ + Kind: SnapshotFileSystemKindCache, + Files: map[string]string{}, + RemovedPaths: []string{"/remove.ts", "/removed-dir"}, + }, base, "/") + assert.NilError(t, err) + + assert.Assert(t, !fileSystem.FileExists("/remove.ts")) + assert.Assert(t, !fileSystem.DirectoryExists("/removed-dir")) + assert.Assert(t, !fileSystem.FileExists("/removed-dir/gone.ts")) + assert.Assert(t, base.SeenFiles.IsEmpty()) + }) + + t.Run("memory routes explicit host symlinks to the host only through the link", func(t *testing.T) { + t.Parallel() + base := &trackingvfs.FS{Inner: vfstest.FromMap(map[string]string{ + "/host/node_modules/pkg/index.d.ts": "export declare const hostValue: string;", + "/host/outside.ts": "outside", + }, true)} + fileSystem, err := newSnapshotFileSystem(&SnapshotFileSystem{ + Kind: SnapshotFileSystemKindMemory, + Files: map[string]string{ + "/project/index.ts": `import { hostValue } from "pkg";`, + }, + Symlinks: map[string]SnapshotSymlink{ + "/project/node_modules": {Target: "/host/node_modules", Host: true}, + }, + }, base, "/") + assert.NilError(t, err) + + _, ok := fileSystem.ReadFile("/host/outside.ts") + assert.Assert(t, !ok) + assert.Assert(t, !base.SeenFiles.Has("/host/outside.ts")) + + contents, ok := fileSystem.ReadFile("/project/node_modules/pkg/index.d.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, "export declare const hostValue: string;") + assert.Assert(t, base.SeenFiles.Has("/host/node_modules/pkg/index.d.ts")) + assert.Equal(t, fileSystem.Realpath("/project/node_modules/pkg/index.d.ts"), "/host/node_modules/pkg/index.d.ts") + + entries := fileSystem.GetAccessibleEntries("/project") + assert.DeepEqual(t, entries.Directories, []string{"node_modules"}) + _, isSymlink := entries.Symlinks["node_modules"] + assert.Assert(t, isSymlink) + }) + + t.Run("symlink cycles are treated as missing", func(t *testing.T) { + t.Parallel() + base := &trackingvfs.FS{Inner: vfstest.FromMap(map[string]string{ + "/host.ts": "host", + }, true)} + fileSystem, err := newSnapshotFileSystem(&SnapshotFileSystem{ + Kind: SnapshotFileSystemKindMemory, + Files: map[string]string{}, + Symlinks: map[string]SnapshotSymlink{ + "/a": {Target: "/b"}, + "/b": {Target: "/a"}, + }, + }, base, "/") + assert.NilError(t, err) + + _, ok := fileSystem.ReadFile("/a/file.ts") + assert.Assert(t, !ok) + assert.Assert(t, !fileSystem.DirectoryExists("/a")) + assert.Equal(t, fileSystem.Realpath("/a"), "/a") + assert.Assert(t, base.SeenFiles.IsEmpty()) + }) + + t.Run("posix relative symlink targets resolve from the link directory", func(t *testing.T) { + t.Parallel() + base := &trackingvfs.FS{Inner: vfstest.FromMap(map[string]string{}, true)} + fileSystem, err := newSnapshotFileSystem(&SnapshotFileSystem{ + Kind: SnapshotFileSystemKindMemory, + Files: map[string]string{ + "/packages/pkg/index.d.ts": "export declare const value: number;", + }, + Symlinks: map[string]SnapshotSymlink{ + "/project/pkg": {Target: "../packages/pkg"}, + }, + }, base, `C:\Workspace`) + assert.NilError(t, err) + + contents, ok := fileSystem.ReadFile("/project/pkg/index.d.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, "export declare const value: number;") + assert.Equal(t, fileSystem.Realpath("/project/pkg/index.d.ts"), "/packages/pkg/index.d.ts") + assert.Assert(t, base.SeenFiles.IsEmpty()) + }) + + t.Run("vscode document URI paths support listings symlinks and tombstones", func(t *testing.T) { + t.Parallel() + base := &trackingvfs.FS{Inner: vfstest.FromMap(map[string]string{}, true)} + fileSystem, err := newSnapshotFileSystem(&SnapshotFileSystem{ + Kind: SnapshotFileSystemKindMemory, + Files: map[string]string{ + "vscode-remote://ssh-remote+host/workspace/src/index.ts": "index", + "vscode-remote://ssh-remote+host/workspace/packages/pkg/a.ts": "package", + }, + Symlinks: map[string]SnapshotSymlink{ + "vscode-remote://ssh-remote+host/workspace/src/pkg": {Target: "../packages/pkg"}, + }, + RemovedPaths: []string{ + "vscode-remote://ssh-remote+host/workspace/packages/pkg/removed.ts", + }, + }, base, "/") + assert.NilError(t, err) + + contents, ok := fileSystem.ReadFile("vscode-remote://ssh-remote+host/workspace/src/index.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, "index") + contents, ok = fileSystem.ReadFile("vscode-remote://ssh-remote+host/workspace/src/pkg/a.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, "package") + assert.Equal( + t, + fileSystem.Realpath("vscode-remote://ssh-remote+host/workspace/src/pkg/a.ts"), + "vscode-remote://ssh-remote+host/workspace/packages/pkg/a.ts", + ) + assert.DeepEqual( + t, + fileSystem.GetAccessibleEntries("vscode-remote://ssh-remote+host/workspace/src").Files, + []string{"index.ts"}, + ) + assert.DeepEqual( + t, + fileSystem.GetAccessibleEntries("vscode-remote://ssh-remote+host/workspace/src").Directories, + []string{"pkg"}, + ) + assert.Assert(t, !fileSystem.FileExists("vscode-remote://ssh-remote+host/workspace/src/pkg/removed.ts")) + assert.Assert(t, base.SeenFiles.IsEmpty()) + }) + + t.Run("windows paths resolve symlinks case insensitively", func(t *testing.T) { + t.Parallel() + base := &trackingvfs.FS{Inner: vfstest.FromMap(map[string]string{ + "C:/Host/outside.ts": "outside", + }, false)} + fileSystem, err := newSnapshotFileSystem(&SnapshotFileSystem{ + Kind: SnapshotFileSystemKindMemory, + Files: map[string]string{ + `C:\Repo\Packages\Pkg\Index.d.ts`: "export declare const windowsValue: number;", + }, + Directories: map[string]SnapshotDirectoryEntries{ + `C:\Repo\Project\node_modules`: {Files: []string{}, Directories: []string{"pkg"}}, + }, + Symlinks: map[string]SnapshotSymlink{ + `C:\Repo\Project\node_modules\PKG`: {Target: `..\..\Packages\Pkg`}, + `C:\Repo\Project\Current.d.ts`: {Target: `..\Packages\Pkg\Index.d.ts`}, + }, + }, base, `C:\Workspace`) + assert.NilError(t, err) + + contents, ok := fileSystem.ReadFile(`c:\repo\project\NODE_MODULES\pkg\INDEX.D.TS`) + assert.Assert(t, ok) + assert.Equal(t, contents, "export declare const windowsValue: number;") + contents, ok = fileSystem.ReadFile(`C:\REPO\PROJECT\current.d.ts`) + assert.Assert(t, ok) + assert.Equal(t, contents, "export declare const windowsValue: number;") + assert.Equal( + t, + fileSystem.Realpath(`c:\repo\project\node_modules\pkg\index.d.ts`), + "C:/Repo/Packages/Pkg/index.d.ts", + ) + + entries := fileSystem.GetAccessibleEntries(`c:\REPO\project\NODE_MODULES`) + assert.DeepEqual(t, entries.Directories, []string{"PKG"}) + _, isSymlink := entries.Symlinks["PKG"] + assert.Assert(t, isSymlink) + assert.Assert(t, base.SeenFiles.IsEmpty()) + }) + + t.Run("case insensitive symlink matching handles unicode byte length changes", func(t *testing.T) { + t.Parallel() + base := &trackingvfs.FS{Inner: vfstest.FromMap(map[string]string{}, false)} + fileSystem, err := newSnapshotFileSystem(&SnapshotFileSystem{ + Kind: SnapshotFileSystemKindMemory, + Files: map[string]string{ + "C:/Repo/target.ts": "target", + }, + Symlinks: map[string]SnapshotSymlink{ + "C:/Repo/K": {Target: "C:/Repo/target.ts"}, + }, + }, base, "C:/Repo") + assert.NilError(t, err) + + contents, ok := fileSystem.ReadFile("c:/repo/k") + assert.Assert(t, ok) + assert.Equal(t, contents, "target") + }) + + t.Run("snapshot filesystems are immutable and cache mutations write through to the host", func(t *testing.T) { + t.Parallel() + host := vfstest.FromMap(map[string]string{ + "/host.ts": "host", + }, true) + memory, err := newSnapshotFileSystem(&SnapshotFileSystem{ + Kind: SnapshotFileSystemKindMemory, + Files: map[string]string{ + "/src/a.ts": "a", + }, + }, host, "/") + assert.NilError(t, err) + assert.ErrorIs(t, memory.WriteFile("/src/b.ts", "b"), vfs.ErrInvalid) + assert.ErrorIs(t, memory.AppendFile("/src/a.ts", "b"), vfs.ErrInvalid) + assert.ErrorIs(t, memory.Remove("/src"), vfs.ErrInvalid) + contents, ok := memory.ReadFile("/src/a.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, "a") + + cache, err := newLayeredSnapshotFileSystem(&SnapshotFileSystem{ + Kind: SnapshotFileSystemKindCache, + Files: map[string]string{}, + }, memory, "/") + assert.NilError(t, err) + assert.NilError(t, cache.WriteFile("/written.ts", "written")) + assert.NilError(t, cache.AppendFile("/written.ts", " appended")) + contents, ok = host.ReadFile("/written.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, "written appended") + assert.NilError(t, cache.Remove("/written.ts")) + assert.Assert(t, !host.FileExists("/written.ts")) + }) + + t.Run("mixed windows and posix roots support cross-root and relative symlinks", func(t *testing.T) { + t.Parallel() + base := &trackingvfs.FS{Inner: vfstest.FromMap(map[string]string{ + "C:/Host/node_modules/host-pkg/index.d.ts": "export declare const hostValue: boolean;", + }, false)} + fileSystem, err := newSnapshotFileSystem(&SnapshotFileSystem{ + Kind: SnapshotFileSystemKindMemory, + Files: map[string]string{ + `C:\Repo\Packages\windows-pkg\index.d.ts`: "export declare const windowsValue: number;", + "/repo/packages/posix-pkg/index.d.ts": "export declare const posixValue: string;", + }, + Symlinks: map[string]SnapshotSymlink{ + // Cross between drive-letter and POSIX roots in both directions. + `C:\Repo\Project\node_modules\posix-pkg`: {Target: "/repo/packages/posix-pkg"}, + "/repo/project/node_modules/windows-pkg": {Target: `C:\Repo\Packages\windows-pkg`}, + // Windows symlink targets read from disk may be relative to the link's directory. + `C:\Repo\Project\windows-pkg.d.ts`: {Target: `..\Packages\windows-pkg\index.d.ts`}, + `C:\Repo\Project\node_modules\host-pkg`: { + Target: `..\..\..\Host\node_modules\host-pkg`, + Host: true, + }, + }, + }, base, `C:\Workspace`) + assert.NilError(t, err) + + contents, ok := fileSystem.ReadFile(`c:\REPO\project\NODE_MODULES\POSIX-PKG\INDEX.D.TS`) + assert.Assert(t, ok) + assert.Equal(t, contents, "export declare const posixValue: string;") + contents, ok = fileSystem.ReadFile("/REPO/PROJECT/NODE_MODULES/WINDOWS-PKG/INDEX.D.TS") + assert.Assert(t, ok) + assert.Equal(t, contents, "export declare const windowsValue: number;") + contents, ok = fileSystem.ReadFile(`c:\repo\project\WINDOWS-PKG.D.TS`) + assert.Assert(t, ok) + assert.Equal(t, contents, "export declare const windowsValue: number;") + contents, ok = fileSystem.ReadFile(`C:\Repo\Project\node_modules\HOST-PKG\index.d.ts`) + assert.Assert(t, ok) + assert.Equal(t, contents, "export declare const hostValue: boolean;") + + assert.Equal( + t, + fileSystem.Realpath(`c:\repo\project\node_modules\posix-pkg\index.d.ts`), + "/repo/packages/posix-pkg/index.d.ts", + ) + assert.Equal( + t, + fileSystem.Realpath("/repo/project/node_modules/windows-pkg/index.d.ts"), + "C:/Repo/Packages/windows-pkg/index.d.ts", + ) + assert.Assert(t, base.SeenFiles.Has("C:/Host/node_modules/host-pkg/index.d.ts")) + }) +} + +func TestUpdateSnapshotUsesMemoryFileSystem(t *testing.T) { + t.Parallel() + + projectSession, _ := projecttestutil.Setup(map[string]any{ + "/host.ts": "host", + }) + defer projectSession.Close() + session := NewSession(projectSession, nil) + defer session.Close() + + response, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ + OpenProjects: []DocumentIdentifier{{FileName: "/tsconfig.json"}}, + FileSystem: &SnapshotFileSystem{ + Kind: SnapshotFileSystemKindMemory, + Files: map[string]string{ + "/tsconfig.json": `{ "compilerOptions": { "noLib": true }, "files": ["src/index.ts"] }`, + "/src/index.ts": `export const value = "memory";`, + "/src/other.ts": `export const other = true;`, + }, + }, + }) + assert.NilError(t, err) + assert.Equal(t, len(response.Projects), 1) + assert.Equal(t, response.Projects[0].ConfigFileName, "/tsconfig.json") + + snapshot := session.snapshots[response.Snapshot].snapshot + contents, ok := snapshot.ReadFile("/src/index.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, `export const value = "memory";`) + _, ok = snapshot.ReadFile("/host.ts") + assert.Assert(t, !ok) + + // Carrying the same filesystem forward without a delta must preserve + // incremental state instead of forcing a full program rebuild. + program := snapshot.ProjectCollection.GetProjectByPath(tspath.Path("/tsconfig.json")).GetProgram() + unchanged, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{Snapshot: response.Snapshot}) + assert.NilError(t, err) + unchangedSnapshot := session.snapshots[unchanged.Snapshot].snapshot + assert.Assert(t, unchangedSnapshot.ProjectCollection.GetProjectByPath(tspath.Path("/tsconfig.json")).GetProgram() == program) + response = unchanged + + // Supplying a new filesystem replaces inherited snapshot disk caches even + // when the caller does not redundantly list every file in FileChanges. + response, err = session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ + FileSystem: &SnapshotFileSystem{ + Kind: SnapshotFileSystemKindMemory, + Files: map[string]string{ + "/tsconfig.json": `{ "compilerOptions": { "noLib": true }, "files": ["src/index.ts", "src/other.ts"] }`, + "/src/index.ts": `export const value = "updated";`, + "/src/other.ts": `export const other = true;`, + }, + }, + }) + assert.NilError(t, err) + snapshot = session.snapshots[response.Snapshot].snapshot + contents, ok = snapshot.ReadFile("/src/index.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, `export const value = "updated";`) + + // Temporary snapshots retain the base snapshot's supplied filesystem for + // every file other than the temporary overlay. + temporary, err := session.handleUpdateTemporarySnapshot(context.Background(), &UpdateTemporarySnapshotParams{ + Snapshot: response.Snapshot, + File: DocumentIdentifier{FileName: "/src/index.ts"}, + NewText: `export const value = "temporary";`, + }) + assert.NilError(t, err) + temporarySnapshot := session.snapshots[temporary.Snapshot].snapshot + contents, ok = temporarySnapshot.ReadFile("/src/index.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, `export const value = "temporary";`) + contents, ok = temporarySnapshot.ReadFile("/src/other.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, `export const other = true;`) +} diff --git a/tsc/internal/project/api.go b/tsc/internal/project/api.go index 52d7eeb5fcfd3..356bca5488cdd 100644 --- a/tsc/internal/project/api.go +++ b/tsc/internal/project/api.go @@ -8,6 +8,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/ast" "github.com/microsoft/TypeScript/tsc/internal/core" "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" + "github.com/microsoft/TypeScript/tsc/internal/vfs" ) // APIUpdate creates a new snapshot incorporating the given file changes and the @@ -24,11 +25,20 @@ func (s *Session) APIUpdate(ctx context.Context, apiFileChanges FileChangeSummar fileChanges, overlays, ataChanges, _ := s.flushChanges(ctx) mergeFileChangeSummary(&fileChanges, apiFileChanges) + var fs vfs.FS + var replaceFileSystem bool + if apiRequest != nil { + fs = apiRequest.FileSystem + replaceFileSystem = apiRequest.ReplaceFileSystem + } newSnapshot := s.updateSnapshotRef(ctx, overlays, SnapshotChange{ - apiRequest: apiRequest, - fileChanges: fileChanges, - ataChanges: ataChanges, + apiRequest: apiRequest, + fs: fs, + fileSystemOverride: fs != nil, + replaceFileSystem: replaceFileSystem, + fileChanges: fileChanges, + ataChanges: ataChanges, }) return newSnapshot, newSnapshot.apiError } @@ -61,7 +71,9 @@ func (s *Session) APIUpdateTemporary(ctx context.Context, baseSnapshot *Snapshot overlays[path] = newOverlay(uri.FileName(), newText, version, scriptKind) newSnapshot := baseSnapshot.Clone(ctx, SnapshotChange{ - fileChanges: fileChanges, + fs: baseSnapshot.fs.fs, + fileSystemOverride: baseSnapshot.fileSystemOverride, + fileChanges: fileChanges, ResourceRequest: ResourceRequest{ Documents: []lsproto.DocumentUri{uri}, }, diff --git a/tsc/internal/project/refcountcache_test.go b/tsc/internal/project/refcountcache_test.go index 0b43a78014ce4..d844a345cc8a1 100644 --- a/tsc/internal/project/refcountcache_test.go +++ b/tsc/internal/project/refcountcache_test.go @@ -514,10 +514,13 @@ func TestRefCountingCaches(t *testing.T) { ctx := context.Background() baseSnapshot, err := session.APIUpdate(ctx, FileChangeSummary{}, &APISnapshotRequest{ - OpenProjects: collections.NewSetFromItems(appConfigPath), + OpenProjects: collections.NewSetFromItems(appConfigPath), + FileSystem: session.fs.fs, + ReplaceFileSystem: true, }) assert.NilError(t, err) defer baseSnapshot.Deref(session) + assert.Assert(t, baseSnapshot.fileSystemOverride) appProject := baseSnapshot.ProjectCollection.GetProjectByPath(baseSnapshot.toPath(appConfigPath)) assert.Assert(t, appProject != nil) @@ -532,6 +535,7 @@ func TestRefCountingCaches(t *testing.T) { FileChangeSummary{}, ) defer programSnapshot.Deref(session) + assert.Assert(t, programSnapshot.fileSystemOverride) programProject := programSnapshot.ProjectCollection.InferredProject() assert.Assert(t, programProject != nil) assert.Assert(t, programProject.Program == appProject.Program) @@ -561,6 +565,7 @@ func TestRefCountingCaches(t *testing.T) { fileChanges, ) defer updatedProgramSnapshot.Deref(session) + assert.Assert(t, updatedProgramSnapshot.fileSystemOverride) updatedProgramProject := updatedProgramSnapshot.ProjectCollection.InferredProject() assert.Assert(t, updatedProgramProject != nil) assert.Assert(t, updatedProgramProject.Program != programProject.Program) diff --git a/tsc/internal/project/snapshot.go b/tsc/internal/project/snapshot.go index 97b2961d3f5c8..7523b14d76d43 100644 --- a/tsc/internal/project/snapshot.go +++ b/tsc/internal/project/snapshot.go @@ -23,6 +23,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/project/logging" "github.com/microsoft/TypeScript/tsc/internal/sourcemap" "github.com/microsoft/TypeScript/tsc/internal/tspath" + "github.com/microsoft/TypeScript/tsc/internal/vfs" "github.com/microsoft/TypeScript/tsc/internal/vfs/vfsmatch" ) @@ -53,6 +54,9 @@ type Snapshot struct { builderLogs *logging.LogTree apiError error + // fileSystemOverride indicates that this snapshot was built from a filesystem + // supplied by an API update rather than the session host filesystem. + fileSystemOverride bool } func (s *Snapshot) contentMapperWatchState() ([]string, *collections.Set[tspath.Path]) { @@ -134,7 +138,7 @@ func (s *Snapshot) cloneForProgram( } start := time.Now() - fs := newSnapshotFSBuilder(session.fs.fs, s.fs.overlays, s.fs.overlays, s.fs.diskFiles, s.fs.diskDirectories, s.fs.nodeModulesRealpathAliases, session.options.PositionEncoding, s.toPath) + fs := newSnapshotFSBuilder(s.fs.fs, s.fs.overlays, s.fs.overlays, s.fs.diskFiles, s.fs.diskDirectories, s.fs.nodeModulesRealpathAliases, session.options.PositionEncoding, s.toPath) fileChanges = s.processFileChanges(fs, fileChanges, logger, nil) newSnapshotID := session.snapshotID.Add(1) @@ -221,6 +225,7 @@ func (s *Snapshot) cloneForProgram( newSnapshot.inferredProjectContentMappers = s.inferredProjectContentMappers newSnapshot.inferredProjectContentMapperExtensions = s.inferredProjectContentMapperExtensions newSnapshot.builderLogs = logger + newSnapshot.fileSystemOverride = s.fileSystemOverride for _, project := range newSnapshot.ProjectCollection.Projects() { if project.Program != nil { @@ -345,6 +350,11 @@ func (s *Snapshot) UseCaseSensitiveFileNames() bool { return s.fs.fs.UseCaseSensitiveFileNames() } +// FileSystem returns the filesystem backing this snapshot. +func (s *Snapshot) FileSystem() vfs.FS { + return s.fs.fs +} + func (s *Snapshot) ReadFile(fileName string) (string, bool) { handle := s.GetFile(fileName) if handle == nil { @@ -374,6 +384,10 @@ type APISnapshotRequest struct { CloseProjects *collections.Set[tspath.Path] OpenFiles *collections.Set[lsproto.DocumentUri] CloseFiles *collections.Set[tspath.Path] + FileSystem vfs.FS + // ReplaceFileSystem indicates that FileSystem is a new source rather than the + // unchanged filesystem carried forward from the base snapshot. + ReplaceFileSystem bool } type ProjectTreeRequest struct { @@ -419,6 +433,11 @@ type ResourceRequest struct { type SnapshotChange struct { ResourceRequest reason UpdateReason + // fs overrides the session filesystem for this snapshot. It is used by API + // snapshots that supply their own memory or cache filesystem. + fs vfs.FS + fileSystemOverride bool + replaceFileSystem bool // fileChanges are the changes that have occurred since the last snapshot. fileChanges FileChangeSummary // compilerOptionsForInferredProjects is the compiler options to use for inferred projects. @@ -515,7 +534,17 @@ func (s *Snapshot) Clone( inferredContentMappers = change.contentMapperContributions.Mappers inferredContentMapperExtensions = change.contentMapperContributions.Extensions } - fs := newSnapshotFSBuilder(session.fs.fs, s.fs.overlays, overlays, s.fs.diskFiles, s.fs.diskDirectories, s.fs.nodeModulesRealpathAliases, session.options.PositionEncoding, s.toPath) + baseFS := session.fs.fs + if change.fs != nil { + baseFS = change.fs + } + // A supplied filesystem must take precedence over disk files inherited from + // the previous snapshot. Likewise, returning to the session host must not retain + // files from a previous total memory filesystem. + if change.replaceFileSystem || s.fileSystemOverride != change.fileSystemOverride { + change.fileChanges.InvalidateAll = true + } + fs := newSnapshotFSBuilder(baseFS, s.fs.overlays, overlays, s.fs.diskFiles, s.fs.diskDirectories, s.fs.nodeModulesRealpathAliases, session.options.PositionEncoding, s.toPath) change.fileChanges = s.processFileChanges(fs, change.fileChanges, logger, change.contentMapperContributions) compilerOptionsForInferredProjects := s.compilerOptionsForInferredProjects @@ -691,6 +720,7 @@ func (s *Snapshot) Clone( newSnapshot.inferredProjectContentMapperExtensions = inferredContentMapperExtensions newSnapshot.builderLogs = logger newSnapshot.apiError = apiError + newSnapshot.fileSystemOverride = change.fileSystemOverride for _, project := range newSnapshot.ProjectCollection.Projects() { if project.Program != nil { diff --git a/tsc/internal/vfs/cachedvfs/cachedvfs.go b/tsc/internal/vfs/cachedvfs/cachedvfs.go index 7128d24d6bda7..356c6eb2b410f 100644 --- a/tsc/internal/vfs/cachedvfs/cachedvfs.go +++ b/tsc/internal/vfs/cachedvfs/cachedvfs.go @@ -27,6 +27,11 @@ func From(fs vfs.FS) *FS { return fsys } +// Unwrap returns the filesystem wrapped by this cache. +func (fsys *FS) Unwrap() vfs.FS { + return fsys.fs +} + func (fsys *FS) DisableAndClearCache() { if fsys.enabled.CompareAndSwap(true, false) { fsys.ClearCache() From a002d9fc7626a1cdbde6636ebe9db54261a99b2e Mon Sep 17 00:00:00 2001 From: Wesley Wigham Date: Mon, 31 Aug 2026 20:07:42 -0700 Subject: [PATCH 02/12] Code review feedback and some extra --- packages/typescript/src/api/fs.ts | 13 +- packages/typescript/test/async/api.test.ts | 97 +++++++++++- packages/typescript/test/sync/api.test.ts | 97 +++++++++++- tsc/internal/api/session.go | 16 +- .../api/session_createprogram_test.go | 52 +++++++ tsc/internal/api/snapshotfilesystem.go | 56 +++++-- tsc/internal/api/snapshotfilesystem_test.go | 140 ++++++++++++++++++ tsc/internal/project/snapshot.go | 6 + 8 files changed, 451 insertions(+), 26 deletions(-) diff --git a/packages/typescript/src/api/fs.ts b/packages/typescript/src/api/fs.ts index 50c3be7cf69e2..53a5981684e5e 100644 --- a/packages/typescript/src/api/fs.ts +++ b/packages/typescript/src/api/fs.ts @@ -109,18 +109,19 @@ function createSnapshotFileSystem( files: SnapshotFileEntries, options: CreateSnapshotFileSystemOptions, ): SnapshotFileSystem { - const normalizedFiles: Record = {}; + const normalizedFiles = new Map(); for (const [id, content] of files) { - const fileName = resolveFileName(id); - if (Object.hasOwn(normalizedFiles, fileName)) { + const fileName = normalizePath(resolveFileName(id)); + if (normalizedFiles.has(fileName)) { throw new Error(`Duplicate snapshot filesystem path: ${fileName}`); } - normalizedFiles[fileName] = content; + normalizedFiles.set(fileName, content); } + const fileRecord = Object.fromEntries(normalizedFiles); return { kind, - files: normalizedFiles, - directories: options.directories ?? deriveDirectoryListings(normalizedFiles), + files: fileRecord, + directories: options.directories ?? deriveDirectoryListings(fileRecord), ...(options.symlinks ? { symlinks: options.symlinks } : {}), ...(options.removedPaths?.length ? { removedPaths: [...options.removedPaths] } : {}), }; diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index c199f78733ae3..1d927f7196c90 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -3697,7 +3697,7 @@ describe("updateSnapshot file systems", () => { kind: "memory", files: { "/src/index.ts": "posix", - "C:\\repo\\src\\index.ts": "windows", + "C:/repo/src/index.ts": "windows", "file:///literal%20path.ts": "literal file-name string", "/encoded/path with spaces.ts": "file URI", "c:/repo/encoded#name.ts": "Windows file URI", @@ -3748,6 +3748,19 @@ describe("updateSnapshot file systems", () => { ]), /Duplicate snapshot filesystem path: \/duplicate\.ts/, ); + + const prototypeFileSystem = createMemoryFileSystem([["__proto__", "prototype"]]); + assert.equal(prototypeFileSystem.files["__proto__"], "prototype"); + assert.ok(Object.hasOwn(prototypeFileSystem.files, "__proto__")); + + assert.throws( + () => + createMemoryFileSystem([ + ["/normalized/duplicate.ts", "forward slash"], + ["\\normalized\\duplicate.ts", "backslash"], + ]), + /Duplicate snapshot filesystem path: \/normalized\/duplicate\.ts/, + ); }); test("memory file system is total and does not invoke host callbacks", async () => { @@ -4055,6 +4068,33 @@ describe("updateSnapshot file systems", () => { } }); + test("Snapshot.update treats a memory filesystem as a total replacement", async () => { + const host = createVirtualFileSystem({ + "/host.ts": `export const source = "host";`, + }); + const api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + fs: host, + }); + try { + using snapshot = await api.updateSnapshot(); + using replaced = await snapshot.update({ + fileSystem: createMemoryFileSystem([ + ["/memory.ts", `export const source = "memory";`], + ]), + }); + using program = await replaced.createProgram( + ["/memory.ts", "/host.ts"], + { compilerOptions: { noLib: true } }, + ); + assert.equal((await program.getSourceFile("/memory.ts"))?.text, `export const source = "memory";`); + assert.equal(await program.getSourceFile("/host.ts"), undefined); + } + finally { + await api.close(); + } + }); + test("Snapshot.update applies target changes through inherited symlinks", async () => { const api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), @@ -4140,6 +4180,27 @@ describe("updateSnapshot file systems", () => { } }); + test("Snapshot.createProgram rebuilds an old program from a different snapshot when changes are omitted", async () => { + const api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + }); + const options = { compilerOptions: { noLib: true } }; + try { + using base = await api.updateSnapshot({ + fileSystem: createMemoryFileSystem([["/src/main.ts", `export const source = "base";`]]), + }); + using newer = await base.update({ + fileSystem: createMemoryFileSystem([["/src/main.ts", `export const source = "newer";`]]), + }); + using oldProgram = await newer.createProgram(["/src/main.ts"], options); + using rebuilt = await base.createProgram(["/src/main.ts"], options, oldProgram); + assert.equal((await rebuilt.getSourceFile("/src/main.ts"))?.text, `export const source = "base";`); + } + finally { + await api.close(); + } + }); + test("memory filesystem emit returns outputs without mutating the host", async () => { const hostWrites: string[] = []; const api = new API({ @@ -4267,6 +4328,40 @@ describe("updateSnapshot file systems", () => { } }); + test("Snapshot.update host symlinks bypass an inherited memory filesystem", async () => { + const host = createVirtualFileSystem({ + "/host/node_modules/pkg/index.d.ts": `export declare const value: string;`, + }); + const api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + fs: host, + }); + try { + using snapshot = await api.updateSnapshot({ + openProject: "/project/tsconfig.json", + fileSystem: createMemoryFileSystem([ + ["/project/tsconfig.json", JSON.stringify({ compilerOptions: { noLib: true, moduleResolution: "node" }, files: ["index.ts"] })], + ["/project/index.ts", `import { value } from "pkg"; export { value };`], + ]), + }); + using updated = await snapshot.update({ + fileSystem: createCacheFileSystem([], { + symlinks: { + "/project/node_modules": { target: "/host/node_modules", host: true }, + }, + }), + }); + const project = updated.getProject("/project/tsconfig.json")!; + assert.equal( + (await project.program.getSourceFile("/host/node_modules/pkg/index.d.ts"))?.text, + `export declare const value: string;`, + ); + } + finally { + await api.close(); + } + }); + // TODO: Add snapshot filesystem coverage for `tsc -b` and `tsc -b --clean` // once build and clean are exposed through the client API. In particular, // verify that clean removes synthetic outputs and that build-mode re-timestamping diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index b53d4bd6bf75c..a302afc2de608 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -3589,7 +3589,7 @@ describe("updateSnapshot file systems", () => { kind: "memory", files: { "/src/index.ts": "posix", - "C:\\repo\\src\\index.ts": "windows", + "C:/repo/src/index.ts": "windows", "file:///literal%20path.ts": "literal file-name string", "/encoded/path with spaces.ts": "file URI", "c:/repo/encoded#name.ts": "Windows file URI", @@ -3640,6 +3640,19 @@ describe("updateSnapshot file systems", () => { ]), /Duplicate snapshot filesystem path: \/duplicate\.ts/, ); + + const prototypeFileSystem = createMemoryFileSystem([["__proto__", "prototype"]]); + assert.equal(prototypeFileSystem.files["__proto__"], "prototype"); + assert.ok(Object.hasOwn(prototypeFileSystem.files, "__proto__")); + + assert.throws( + () => + createMemoryFileSystem([ + ["/normalized/duplicate.ts", "forward slash"], + ["\\normalized\\duplicate.ts", "backslash"], + ]), + /Duplicate snapshot filesystem path: \/normalized\/duplicate\.ts/, + ); }); test("memory file system is total and does not invoke host callbacks", () => { @@ -3947,6 +3960,33 @@ describe("updateSnapshot file systems", () => { } }); + test("Snapshot.update treats a memory filesystem as a total replacement", () => { + const host = createVirtualFileSystem({ + "/host.ts": `export const source = "host";`, + }); + const api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + fs: host, + }); + try { + using snapshot = api.updateSnapshot(); + using replaced = snapshot.update({ + fileSystem: createMemoryFileSystem([ + ["/memory.ts", `export const source = "memory";`], + ]), + }); + using program = replaced.createProgram( + ["/memory.ts", "/host.ts"], + { compilerOptions: { noLib: true } }, + ); + assert.equal((program.getSourceFile("/memory.ts"))?.text, `export const source = "memory";`); + assert.equal(program.getSourceFile("/host.ts"), undefined); + } + finally { + api.close(); + } + }); + test("Snapshot.update applies target changes through inherited symlinks", () => { const api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), @@ -4032,6 +4072,27 @@ describe("updateSnapshot file systems", () => { } }); + test("Snapshot.createProgram rebuilds an old program from a different snapshot when changes are omitted", () => { + const api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + }); + const options = { compilerOptions: { noLib: true } }; + try { + using base = api.updateSnapshot({ + fileSystem: createMemoryFileSystem([["/src/main.ts", `export const source = "base";`]]), + }); + using newer = base.update({ + fileSystem: createMemoryFileSystem([["/src/main.ts", `export const source = "newer";`]]), + }); + using oldProgram = newer.createProgram(["/src/main.ts"], options); + using rebuilt = base.createProgram(["/src/main.ts"], options, oldProgram); + assert.equal((rebuilt.getSourceFile("/src/main.ts"))?.text, `export const source = "base";`); + } + finally { + api.close(); + } + }); + test("memory filesystem emit returns outputs without mutating the host", () => { const hostWrites: string[] = []; const api = new API({ @@ -4159,6 +4220,40 @@ describe("updateSnapshot file systems", () => { } }); + test("Snapshot.update host symlinks bypass an inherited memory filesystem", () => { + const host = createVirtualFileSystem({ + "/host/node_modules/pkg/index.d.ts": `export declare const value: string;`, + }); + const api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + fs: host, + }); + try { + using snapshot = api.updateSnapshot({ + openProject: "/project/tsconfig.json", + fileSystem: createMemoryFileSystem([ + ["/project/tsconfig.json", JSON.stringify({ compilerOptions: { noLib: true, moduleResolution: "node" }, files: ["index.ts"] })], + ["/project/index.ts", `import { value } from "pkg"; export { value };`], + ]), + }); + using updated = snapshot.update({ + fileSystem: createCacheFileSystem([], { + symlinks: { + "/project/node_modules": { target: "/host/node_modules", host: true }, + }, + }), + }); + const project = updated.getProject("/project/tsconfig.json")!; + assert.equal( + (project.program.getSourceFile("/host/node_modules/pkg/index.d.ts"))?.text, + `export declare const value: string;`, + ); + } + finally { + api.close(); + } + }); + // TODO: Add snapshot filesystem coverage for `tsc -b` and `tsc -b --clean` // once build and clean are exposed through the client API. In particular, // verify that clean removes synthetic outputs and that build-mode re-timestamping diff --git a/tsc/internal/api/session.go b/tsc/internal/api/session.go index af82b04f97e1a..970edc8551b34 100644 --- a/tsc/internal/api/session.go +++ b/tsc/internal/api/session.go @@ -1016,7 +1016,9 @@ func (s *Session) handleUpdateSnapshot(ctx context.Context, params *UpdateSnapsh baseFS := s.projectSession.FS() if baseSD != nil { baseFS = baseSD.snapshot.FileSystem() - apiRequest.FileSystem = baseFS + if baseSD.snapshot.HasFileSystemOverride() { + apiRequest.FileSystem = baseFS + } } if params.FileSystem != nil { var fs vfs.FS @@ -1024,7 +1026,9 @@ func (s *Session) handleUpdateSnapshot(ctx context.Context, params *UpdateSnapsh if baseSD == nil { fs, err = newSnapshotFileSystem(params.FileSystem, baseFS, s.projectSession.GetCurrentDirectory()) } else { - s.addLayeredFileSystemChanges(&fileChanges, params.FileSystem, baseFS) + if params.FileSystem.Kind == SnapshotFileSystemKindCache { + s.addLayeredFileSystemChanges(&fileChanges, params.FileSystem, baseFS) + } fs, err = newLayeredSnapshotFileSystem(params.FileSystem, baseFS, s.projectSession.GetCurrentDirectory()) } if err != nil { @@ -1261,6 +1265,12 @@ func (s *Session) handleCreateProgram(ctx context.Context, params *CreateProgram } } + fileChanges := s.toFileChangeSummary(params.FileChanges) + if params.BaseSnapshot != 0 && params.OldProgram != nil && params.OldProgram.Snapshot != params.BaseSnapshot && fileChanges.IsEmpty() { + fileChanges.InvalidateAll = true + fileChanges.IncludesWatchChangeOutsideNodeModules = true + } + snapshot := s.projectSession.APICreateProgram( ctx, rootFileNames, @@ -1269,7 +1279,7 @@ func (s *Session) handleCreateProgram(ctx context.Context, params *CreateProgram core.Map(params.CreateProgramOptions.ConfigFileParsingDiagnostics, func(d *DiagnosticResponse) *ast.Diagnostic { return d.ToDiagnostic() }), baseSnapshot, oldProject, - s.toFileChangeSummary(params.FileChanges), + fileChanges, ) project := snapshot.ProjectCollection.InferredProject() if project == nil { diff --git a/tsc/internal/api/session_createprogram_test.go b/tsc/internal/api/session_createprogram_test.go index f4b1b322b9b9c..bf674317ee35a 100644 --- a/tsc/internal/api/session_createprogram_test.go +++ b/tsc/internal/api/session_createprogram_test.go @@ -174,6 +174,58 @@ func TestCreateProgramFromSnapshotFileSystem(t *testing.T) { assert.Equal(t, program.GetSourceFile(fileName).Text(), `export const source = "memory";`) } +func TestCreateProgramRebuildsOldProgramFromDifferentBaseSnapshot(t *testing.T) { + t.Parallel() + + const fileName = "/src/index.ts" + projectSession, _ := projecttestutil.Setup(map[string]any{}) + defer projectSession.Close() + session := NewSession(projectSession, nil) + defer session.Close() + ctx := context.Background() + + base, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ + FileSystem: &SnapshotFileSystem{ + Kind: SnapshotFileSystemKindMemory, + Files: map[string]string{fileName: `export const source = "base";`}, + }, + }) + assert.NilError(t, err) + newer, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ + Snapshot: base.Snapshot, + FileSystem: &SnapshotFileSystem{ + Kind: SnapshotFileSystemKindMemory, + Files: map[string]string{fileName: `export const source = "newer";`}, + }, + }) + assert.NilError(t, err) + oldProgram, err := session.handleCreateProgram(ctx, &CreateProgramParams{ + RootFiles: []DocumentIdentifier{{FileName: fileName}}, + BaseSnapshot: newer.Snapshot, + CreateProgramOptions: CreateProgramOptions{ + CompilerOptions: core.CompilerOptions{NoLib: core.TSTrue}, + }, + }) + assert.NilError(t, err) + + response, err := session.handleCreateProgram(ctx, &CreateProgramParams{ + RootFiles: []DocumentIdentifier{{FileName: fileName}}, + BaseSnapshot: base.Snapshot, + CreateProgramOptions: CreateProgramOptions{ + CompilerOptions: core.CompilerOptions{NoLib: core.TSTrue}, + }, + OldProgram: &CreateProgramOldProgramParams{ + Snapshot: oldProgram.Snapshot, + Project: oldProgram.Project.Id, + }, + }) + assert.NilError(t, err) + created, err := session.getSnapshotData(response.Snapshot) + assert.NilError(t, err) + program := created.snapshot.ProjectCollection.InferredProject().Program + assert.Equal(t, program.GetSourceFile(fileName).Text(), `export const source = "base";`) +} + func TestCreateProgramRemovesAllRootFiles(t *testing.T) { t.Parallel() diff --git a/tsc/internal/api/snapshotfilesystem.go b/tsc/internal/api/snapshotfilesystem.go index b1092f9b39d9d..216d39085239b 100644 --- a/tsc/internal/api/snapshotfilesystem.go +++ b/tsc/internal/api/snapshotfilesystem.go @@ -102,7 +102,7 @@ func newSnapshotFileSystem(params *SnapshotFileSystem, base vfs.FS, currentDirec } func newLayeredSnapshotFileSystem(params *SnapshotFileSystem, base vfs.FS, currentDirectory string) (vfs.FS, error) { - return newSnapshotFileSystemWorker(params, base, currentDirectory, true) + return newSnapshotFileSystemWorker(params, base, currentDirectory, params.Kind == SnapshotFileSystemKindCache) } func newSnapshotFileSystemWorker(params *SnapshotFileSystem, base vfs.FS, currentDirectory string, layered bool) (vfs.FS, error) { @@ -123,20 +123,32 @@ func newSnapshotFileSystemWorker(params *SnapshotFileSystem, base vfs.FS, curren } for fileName, content := range params.Files { absoluteFileName := result.toAbsolutePath(fileName) - result.files[result.toPath(absoluteFileName)] = snapshotFile{fileName: absoluteFileName, content: content} + path := result.toPath(absoluteFileName) + if existing, ok := result.files[path]; ok { + return nil, fmt.Errorf("duplicate snapshot filesystem file path %q and %q", existing.fileName, absoluteFileName) + } + result.files[path] = snapshotFile{fileName: absoluteFileName, content: content} } for directoryName, entries := range params.Directories { absoluteDirectoryName := result.toAbsolutePath(directoryName) - result.directoryListings[result.toPath(absoluteDirectoryName)] = vfs.Entries{ + path := result.toPath(absoluteDirectoryName) + if _, ok := result.directoryListings[path]; ok { + return nil, fmt.Errorf("duplicate snapshot filesystem directory path %q", absoluteDirectoryName) + } + result.directoryListings[path] = vfs.Entries{ Files: slices.Clone(entries.Files), Directories: slices.Clone(entries.Directories), } } for linkName, symlink := range params.Symlinks { absoluteLinkName := result.toAbsolutePath(linkName) + path := result.toPath(absoluteLinkName) + if existing, ok := result.symlinks[path]; ok { + return nil, fmt.Errorf("duplicate snapshot filesystem symlink path %q and %q", existing.linkName, absoluteLinkName) + } targetDirectory := tspath.GetDirectoryPath(absoluteLinkName) absoluteTarget := result.toAbsolutePathFrom(symlink.Target, targetDirectory) - result.symlinks[result.toPath(absoluteLinkName)] = snapshotSymlink{ + result.symlinks[path] = snapshotSymlink{ linkName: absoluteLinkName, target: absoluteTarget, host: symlink.Host, @@ -408,7 +420,11 @@ func (s *snapshotFileSystem) ReadFile(fileName string) (string, bool) { if s.isRemoved(resolved.path) { return "", false } - return s.base.ReadFile(resolved.path) + host := getHostFileSystem(s.base) + if host == nil { + return "", false + } + return host.ReadFile(resolved.path) } file, ok := s.fileAt(resolved.path) if ok { @@ -452,7 +468,8 @@ func (s *snapshotFileSystem) FileExists(fileName string) bool { if s.isRemoved(resolved.path) { return false } - return s.base.FileExists(resolved.path) + host := getHostFileSystem(s.base) + return host != nil && host.FileExists(resolved.path) } _, ok := s.fileAt(resolved.path) if ok { @@ -493,7 +510,8 @@ func (s *snapshotFileSystem) DirectoryExists(directoryName string) bool { if s.isRemoved(resolved.path) { return false } - return s.base.DirectoryExists(resolved.path) + host := getHostFileSystem(s.base) + return host != nil && host.DirectoryExists(resolved.path) } _, ok := s.directoryAt(resolved.path) if ok { @@ -560,7 +578,9 @@ func (s *snapshotFileSystem) GetAccessibleEntries(directoryName string) vfs.Entr var result vfs.Entries if resolved.host { if !s.isRemoved(resolved.path) { - result = s.removeEntries(resolved.path, s.base.GetAccessibleEntries(resolved.path)) + if host := getHostFileSystem(s.base); host != nil { + result = s.removeEntries(resolved.path, host.GetAccessibleEntries(resolved.path)) + } } } else if !s.fallsBack() || hasExplicitListing && !s.layered { result = localEntries @@ -748,7 +768,10 @@ func (s *snapshotFileSystem) Realpath(path string) string { return path } if resolved.host { - return s.base.Realpath(resolved.path) + if host := getHostFileSystem(s.base); host != nil { + return host.Realpath(resolved.path) + } + return path } if resolved.followedSymlink && !s.fallsBack() { return path @@ -853,23 +876,26 @@ func (s *snapshotFileSystem) Stat(path string) vfs.FileInfo { return nil } if resolved.host { - return s.statHost(resolved.path) + return statFileSystem(getHostFileSystem(s.base), resolved.path) } if s.fallsBack() { - return s.statHost(resolved.path) + return statFileSystem(s.base, resolved.path) } return nil } -func (s *snapshotFileSystem) statHost(path string) vfs.FileInfo { - if info := s.base.Stat(path); info != nil { +func statFileSystem(fileSystem vfs.FS, path string) vfs.FileInfo { + if fileSystem == nil { + return nil + } + if info := fileSystem.Stat(path); info != nil { return info } name := tspath.GetBaseFileName(path) - if s.base.DirectoryExists(path) { + if fileSystem.DirectoryExists(path) { return snapshotFileInfo{name: name, directory: true} } - if s.base.FileExists(path) { + if fileSystem.FileExists(path) { return snapshotFileInfo{name: name} } return nil diff --git a/tsc/internal/api/snapshotfilesystem_test.go b/tsc/internal/api/snapshotfilesystem_test.go index 1298b4cb24dab..6e52e44f71166 100644 --- a/tsc/internal/api/snapshotfilesystem_test.go +++ b/tsc/internal/api/snapshotfilesystem_test.go @@ -72,6 +72,22 @@ func TestSnapshotFileSystem(t *testing.T) { assert.Assert(t, base.SeenFiles.Has("/fallback.ts")) }) + t.Run("layered memory is a total replacement", func(t *testing.T) { + t.Parallel() + fileSystem, err := newLayeredSnapshotFileSystem(&SnapshotFileSystem{ + Kind: SnapshotFileSystemKindMemory, + Files: map[string]string{ + "/memory.ts": "memory", + }, + }, vfstest.FromMap(map[string]string{"/host.ts": "host"}, true), "/") + assert.NilError(t, err) + contents, ok := fileSystem.ReadFile("/memory.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, "memory") + _, ok = fileSystem.ReadFile("/host.ts") + assert.Assert(t, !ok) + }) + t.Run("memory resolves internal file and directory symlinks", func(t *testing.T) { t.Parallel() base := &trackingvfs.FS{Inner: vfstest.FromMap(map[string]string{ @@ -369,6 +385,75 @@ func TestSnapshotFileSystem(t *testing.T) { assert.Assert(t, isSymlink) }) + t.Run("layered host symlinks bypass snapshot bases", func(t *testing.T) { + t.Parallel() + host := &trackingvfs.FS{Inner: vfstest.FromMap(map[string]string{ + "/host/pkg/index.d.ts": "host", + }, true)} + base, err := newSnapshotFileSystem(&SnapshotFileSystem{ + Kind: SnapshotFileSystemKindMemory, + Files: map[string]string{ + "/memory.ts": "memory", + }, + }, host, "/") + assert.NilError(t, err) + + layered, err := newLayeredSnapshotFileSystem(&SnapshotFileSystem{ + Kind: SnapshotFileSystemKindCache, + Files: map[string]string{}, + Symlinks: map[string]SnapshotSymlink{ + "/project/pkg": {Target: "/host/pkg", Host: true}, + }, + }, base, "/") + assert.NilError(t, err) + + contents, ok := layered.ReadFile("/project/pkg/index.d.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, "host") + assert.Assert(t, layered.FileExists("/project/pkg/index.d.ts")) + assert.Assert(t, layered.DirectoryExists("/project/pkg")) + assert.DeepEqual(t, layered.GetAccessibleEntries("/project/pkg").Files, []string{"index.d.ts"}) + assert.Equal(t, layered.Realpath("/project/pkg/index.d.ts"), "/host/pkg/index.d.ts") + info := layered.Stat("/project/pkg/index.d.ts") + assert.Assert(t, info != nil) + assert.Equal(t, info.Name(), "index.d.ts") + assert.Assert(t, host.SeenFiles.Has("/host/pkg/index.d.ts")) + }) + + t.Run("canonical path collisions are rejected", func(t *testing.T) { + t.Parallel() + base := vfstest.FromMap(map[string]string{}, false) + + _, err := newSnapshotFileSystem(&SnapshotFileSystem{ + Kind: SnapshotFileSystemKindMemory, + Files: map[string]string{ + `C:\Repo\file.ts`: "first", + `c:/repo/file.ts`: "second", + }, + }, base, `C:\Workspace`) + assert.ErrorContains(t, err, "duplicate snapshot filesystem file path") + + _, err = newSnapshotFileSystem(&SnapshotFileSystem{ + Kind: SnapshotFileSystemKindMemory, + Files: map[string]string{}, + Directories: map[string]SnapshotDirectoryEntries{ + `C:\Repo`: {}, + `c:/repo/.`: {}, + }, + }, base, `C:\Workspace`) + assert.ErrorContains(t, err, "duplicate snapshot filesystem directory path") + + _, err = newSnapshotFileSystem(&SnapshotFileSystem{ + Kind: SnapshotFileSystemKindMemory, + Files: map[string]string{}, + Symlinks: map[string]SnapshotSymlink{ + `C:\Repo\link`: {Target: `C:\Target`}, + `c:/repo/link`: {Target: `C:\Other`}, + }, + }, base, `C:\Workspace`) + assert.ErrorContains(t, err, "duplicate snapshot filesystem symlink path") + }) + t.Run("symlink cycles are treated as missing", func(t *testing.T) { t.Parallel() base := &trackingvfs.FS{Inner: vfstest.FromMap(map[string]string{ @@ -673,3 +758,58 @@ func TestUpdateSnapshotUsesMemoryFileSystem(t *testing.T) { assert.Assert(t, ok) assert.Equal(t, contents, `export const other = true;`) } + +func TestSnapshotUpdateMemoryFileSystemIsTotal(t *testing.T) { + t.Parallel() + + projectSession, _ := projecttestutil.Setup(map[string]any{ + "/host.ts": "host", + }) + defer projectSession.Close() + session := NewSession(projectSession, nil) + defer session.Close() + + base, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{}) + assert.NilError(t, err) + replaced, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ + Snapshot: base.Snapshot, + FileSystem: &SnapshotFileSystem{ + Kind: SnapshotFileSystemKindMemory, + Files: map[string]string{ + "/memory.ts": "memory", + }, + }, + }) + assert.NilError(t, err) + + snapshot := session.snapshots[replaced.Snapshot].snapshot + contents, ok := snapshot.ReadFile("/memory.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, "memory") + _, ok = snapshot.ReadFile("/host.ts") + assert.Assert(t, !ok) +} + +func TestSnapshotUpdateCarriesHostFileSystemWithoutOverride(t *testing.T) { + t.Parallel() + + projectSession, _ := projecttestutil.Setup(map[string]any{ + "/tsconfig.json": `{ "compilerOptions": { "noLib": true }, "files": ["index.ts"] }`, + "/index.ts": `export const value = true;`, + }) + defer projectSession.Close() + session := NewSession(projectSession, nil) + defer session.Close() + + base, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ + OpenProjects: []DocumentIdentifier{{FileName: "/tsconfig.json"}}, + }) + assert.NilError(t, err) + baseSnapshot := session.snapshots[base.Snapshot].snapshot + program := baseSnapshot.ProjectCollection.GetProjectByPath(tspath.Path("/tsconfig.json")).GetProgram() + + updated, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{Snapshot: base.Snapshot}) + assert.NilError(t, err) + updatedSnapshot := session.snapshots[updated.Snapshot].snapshot + assert.Assert(t, updatedSnapshot.ProjectCollection.GetProjectByPath(tspath.Path("/tsconfig.json")).GetProgram() == program) +} diff --git a/tsc/internal/project/snapshot.go b/tsc/internal/project/snapshot.go index 7523b14d76d43..3f83ca20cfc9c 100644 --- a/tsc/internal/project/snapshot.go +++ b/tsc/internal/project/snapshot.go @@ -355,6 +355,12 @@ func (s *Snapshot) FileSystem() vfs.FS { return s.fs.fs } +// HasFileSystemOverride reports whether this snapshot uses an API-supplied +// filesystem instead of the session host filesystem. +func (s *Snapshot) HasFileSystemOverride() bool { + return s.fileSystemOverride +} + func (s *Snapshot) ReadFile(fileName string) (string, bool) { handle := s.GetFile(fileName) if handle == nil { From c8ca98f094418350a9d69c79e2268bfb12f7c377 Mon Sep 17 00:00:00 2001 From: Wesley Wigham Date: Tue, 1 Sep 2026 19:26:29 -0700 Subject: [PATCH 03/12] Big moves and renames, immutable core request fs, auotmatic request fs compaction on snapshot release, general cleanup --- packages/typescript/src/api/async/types.ts | 4 +- packages/typescript/src/api/fs.ts | 60 +- .../typescript/src/api/proto.generated.ts | 22 +- packages/typescript/src/api/sync/types.ts | 4 +- packages/typescript/test/async/api.test.ts | 45 +- packages/typescript/test/sync/api.test.ts | 45 +- tsc/internal/api/proto.go | 45 +- .../api/requestfilesystem/filechanges.go | 53 ++ .../requestfilesystem.go} | 640 +++++++++++------- .../requestfilesystem_test.go} | 561 +++++++++------ .../requestfilesystemhandle.go | 233 +++++++ tsc/internal/api/session.go | 211 +++--- .../api/session_createprogram_test.go | 18 +- .../api/session_requestfilesystem_test.go | 452 +++++++++++++ tsc/internal/project/api.go | 16 +- tsc/internal/project/refcountcache_test.go | 2 + tsc/internal/project/snapshot.go | 3 +- 17 files changed, 1718 insertions(+), 696 deletions(-) create mode 100644 tsc/internal/api/requestfilesystem/filechanges.go rename tsc/internal/api/{snapshotfilesystem.go => requestfilesystem/requestfilesystem.go} (52%) rename tsc/internal/api/{snapshotfilesystem_test.go => requestfilesystem/requestfilesystem_test.go} (64%) create mode 100644 tsc/internal/api/requestfilesystem/requestfilesystemhandle.go create mode 100644 tsc/internal/api/session_requestfilesystem_test.go diff --git a/packages/typescript/src/api/async/types.ts b/packages/typescript/src/api/async/types.ts index e06014f4ee9fe..6f266a2264988 100644 --- a/packages/typescript/src/api/async/types.ts +++ b/packages/typescript/src/api/async/types.ts @@ -10,7 +10,7 @@ import type { } from "../../ast/ast.ts"; import type { Diagnostic, - SnapshotFileSystem, + RequestFileSystem, } from "../proto.ts"; import type { NodeHandle, @@ -405,7 +405,7 @@ export interface EmitResult { readonly diagnostics: readonly Diagnostic[]; readonly emittedFiles: readonly string[]; /** Emitted files captured as a cache layer suitable for {@link Snapshot.update}. */ - readonly fileSystem?: SnapshotFileSystem | undefined; + readonly fileSystem?: RequestFileSystem | undefined; } export interface EmitOutput { diff --git a/packages/typescript/src/api/fs.ts b/packages/typescript/src/api/fs.ts index 53a5981684e5e..53a26ad639211 100644 --- a/packages/typescript/src/api/fs.ts +++ b/packages/typescript/src/api/fs.ts @@ -5,9 +5,9 @@ import { normalizePath, } from "./path.ts"; import type { - SnapshotDirectoryEntries, - SnapshotFileSystem, - SnapshotSymlink, + RequestDirectoryEntries, + RequestFileSystem, + RequestSymlink, } from "./proto.generated.ts"; import { type DocumentIdentifier, @@ -38,41 +38,41 @@ export interface FileSystem { /** The callback names supported by the Go server for virtual FS delegation. */ export const fsCallbackNames = ["readFile", "fileExists", "directoryExists", "getAccessibleEntries", "realpath", "writeFile"] as const; -export interface CreateSnapshotFileSystemOptions { +export interface CreateRequestFileSystemOptions { /** Complete directory listings. Derived from `files` when omitted. */ - directories?: Record; - symlinks?: Record; + directories?: Record; + symlinks?: Record; /** Files or directory trees hidden from an underlying snapshot or host filesystem. */ removedPaths?: readonly string[]; } -export interface CreateMemoryFileSystemWithLibOptions extends CreateSnapshotFileSystemOptions { +export interface CreateMemoryFileSystemWithLibOptions extends CreateRequestFileSystemOptions { /** Default library directory used by a custom or non-embedded compiler executable. */ defaultLibraryPath?: string; } /** - * Files supplied to a snapshot filesystem. String identifiers are file names; + * Files supplied to a request filesystem. String identifiers are file names; * use `{ uri }` when supplying a document URI so it can be decoded correctly. */ -export type SnapshotFileEntries = readonly (readonly [id: DocumentIdentifier, content: string])[]; +export type RequestFileEntries = readonly (readonly [id: DocumentIdentifier, content: string])[]; -/** Creates a total memory snapshot filesystem, deriving directory listings when omitted. */ +/** Creates a total memory request filesystem, deriving directory listings when omitted. */ export function createMemoryFileSystem( - files: SnapshotFileEntries, - options: CreateSnapshotFileSystemOptions = {}, -): SnapshotFileSystem { - return createSnapshotFileSystem("memory", files, options); + files: RequestFileEntries, + options: CreateRequestFileSystemOptions = {}, +): RequestFileSystem { + return createRequestFileSystem("memory", files, options); } /** - * Creates a total memory snapshot filesystem with the compiler's default library + * Creates a total memory request filesystem with the compiler's default library * directory mounted read-only through the host filesystem. */ export function createMemoryFileSystemWithLib( - files: SnapshotFileEntries, + files: RequestFileEntries, options: CreateMemoryFileSystemWithLibOptions = {}, -): SnapshotFileSystem { +): RequestFileSystem { const defaultLibraryPaths = options.defaultLibraryPath ? [normalizePath(options.defaultLibraryPath)] : [normalizePath("bundled:///libs")]; @@ -89,31 +89,31 @@ export function createMemoryFileSystemWithLib( for (const defaultLibraryPath of defaultLibraryPaths) { symlinks[defaultLibraryPath] ??= { target: defaultLibraryPath, host: true }; } - return createSnapshotFileSystem("memory", files, { + return createRequestFileSystem("memory", files, { symlinks, ...(options.directories ? { directories: options.directories } : {}), ...(options.removedPaths?.length ? { removedPaths: options.removedPaths } : {}), }); } -/** Creates a read-through cache snapshot filesystem, deriving directory listings when omitted. */ +/** Creates a read-through cache request filesystem, deriving directory listings when omitted. */ export function createCacheFileSystem( - files: SnapshotFileEntries, - options: CreateSnapshotFileSystemOptions = {}, -): SnapshotFileSystem { - return createSnapshotFileSystem("cache", files, options); + files: RequestFileEntries, + options: CreateRequestFileSystemOptions = {}, +): RequestFileSystem { + return createRequestFileSystem("cache", files, options); } -function createSnapshotFileSystem( - kind: SnapshotFileSystem["kind"], - files: SnapshotFileEntries, - options: CreateSnapshotFileSystemOptions, -): SnapshotFileSystem { +function createRequestFileSystem( + kind: RequestFileSystem["kind"], + files: RequestFileEntries, + options: CreateRequestFileSystemOptions, +): RequestFileSystem { const normalizedFiles = new Map(); for (const [id, content] of files) { const fileName = normalizePath(resolveFileName(id)); if (normalizedFiles.has(fileName)) { - throw new Error(`Duplicate snapshot filesystem path: ${fileName}`); + throw new Error(`Duplicate request filesystem path: ${fileName}`); } normalizedFiles.set(fileName, content); } @@ -127,7 +127,7 @@ function createSnapshotFileSystem( }; } -function deriveDirectoryListings(files: Record): Record { +function deriveDirectoryListings(files: Record): Record { const listings = new Map; directories: Set; }>(); const getListing = (directory: string) => { let listing = listings.get(directory); diff --git a/packages/typescript/src/api/proto.generated.ts b/packages/typescript/src/api/proto.generated.ts index 213d88785ef7c..72fe3fc1f4cae 100644 --- a/packages/typescript/src/api/proto.generated.ts +++ b/packages/typescript/src/api/proto.generated.ts @@ -211,7 +211,7 @@ export interface UpdateSnapshotParams { * A memory filesystem is canonical and total. A cache filesystem is checked * before falling back to the host filesystem. */ - fileSystem?: SnapshotFileSystem; + fileSystem?: RequestFileSystem; /** * OpenFiles lists files to keep open for the API client, mirroring LSP's * textDocument/didOpen. For each file, ancestor directories are searched for a @@ -1229,17 +1229,17 @@ export interface APIFileChanges { } /** - * SnapshotFileSystem supplies file contents and, optionally, directory listings - * for a snapshot update. + * RequestFileSystem supplies file contents and, optionally, directory listings + * for a request that creates a snapshot. */ -export interface SnapshotFileSystem { +export interface RequestFileSystem { kind: "cache" | "memory"; /** Files maps file names to their complete contents. */ files: Record; /** Directories maps directory names to complete listing results. */ - directories?: Record; + directories?: Record; /** Symlinks maps link paths to targets in this filesystem or the host filesystem. */ - symlinks?: Record; + symlinks?: Record; /** * RemovedPaths lists files or directory trees that must be treated as missing * even when present in an underlying snapshot or host filesystem. @@ -1439,16 +1439,16 @@ export interface EmitOutputFile { } /** - * SnapshotDirectoryEntries is a cached directory listing. Entry names are + * RequestDirectoryEntries is a cached directory listing. Entry names are * relative to the directory, matching vfs.GetAccessibleEntries. */ -export interface SnapshotDirectoryEntries { +export interface RequestDirectoryEntries { files: string[]; directories: string[]; } -/** SnapshotSymlink describes a symbolic link in a snapshot filesystem. */ -export interface SnapshotSymlink { +/** RequestSymlink describes a symbolic link in a request filesystem. */ +export interface RequestSymlink { /** * Target is resolved relative to the directory containing the link, matching * native symbolic-link semantics. @@ -1456,7 +1456,7 @@ export interface SnapshotSymlink { target: string; /** * Host routes the target through the host filesystem. This is the only way a - * memory filesystem can access paths not supplied in the snapshot filesystem. + * memory filesystem can access paths not supplied in the request filesystem. */ host?: boolean; } diff --git a/packages/typescript/src/api/sync/types.ts b/packages/typescript/src/api/sync/types.ts index bba22a65d3748..b608f5c4cd12b 100644 --- a/packages/typescript/src/api/sync/types.ts +++ b/packages/typescript/src/api/sync/types.ts @@ -23,7 +23,7 @@ import type { } from "../../ast/ast.ts"; import type { Diagnostic, - SnapshotFileSystem, + RequestFileSystem, } from "../proto.ts"; import type { NodeHandle, @@ -529,7 +529,7 @@ export interface EmitResult { readonly diagnostics: readonly Diagnostic[]; readonly emittedFiles: readonly string[]; /** Emitted files captured as a cache layer suitable for {@link Snapshot.update}. */ - readonly fileSystem?: SnapshotFileSystem | undefined; + readonly fileSystem?: RequestFileSystem | undefined; } export interface EmitOutput { diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index 1d927f7196c90..3fda8f5eb6f3d 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -3679,7 +3679,7 @@ describe("readFile callback semantics", () => { }); describe("updateSnapshot file systems", () => { - test("snapshot filesystem factories derive directory listings", () => { + test("request filesystem factories derive directory listings", () => { const memory = createMemoryFileSystem([ ["/src/index.ts", "posix"], ["C:\\repo\\src\\index.ts", "windows"], @@ -3746,7 +3746,7 @@ describe("updateSnapshot file systems", () => { ["/duplicate.ts", "path"], [{ uri: "file:///duplicate.ts" }, "URI"], ]), - /Duplicate snapshot filesystem path: \/duplicate\.ts/, + /Duplicate request filesystem path: \/duplicate\.ts/, ); const prototypeFileSystem = createMemoryFileSystem([["__proto__", "prototype"]]); @@ -3759,7 +3759,7 @@ describe("updateSnapshot file systems", () => { ["/normalized/duplicate.ts", "forward slash"], ["\\normalized\\duplicate.ts", "backslash"], ]), - /Duplicate snapshot filesystem path: \/normalized\/duplicate\.ts/, + /Duplicate request filesystem path: \/normalized\/duplicate\.ts/, ); }); @@ -4068,6 +4068,41 @@ describe("updateSnapshot file systems", () => { } }); + test("eager snapshot disposal does not retain filesystem history", async () => { + const api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + }); + try { + let snapshot: Snapshot = await api.updateSnapshot({ + fileSystem: createMemoryFileSystem([["/pkg/index.ts", ""]]), + }); + try { + let content = ""; + for (const character of "export const x = 1") { + const oldSnapshot: Snapshot = snapshot; + content += character; + snapshot = await oldSnapshot.update({ + fileSystem: createCacheFileSystem([["/pkg/index.ts", content]]), + }); + await oldSnapshot.dispose(); + assert.equal(oldSnapshot.isDisposed(), true); + } + + using program = await snapshot.createProgram( + ["/pkg/index.ts"], + { compilerOptions: { noLib: true } }, + ); + assert.equal((await program.getSourceFile("/pkg/index.ts"))?.text, "export const x = 1"); + } + finally { + await snapshot.dispose(); + } + } + finally { + await api.close(); + } + }); + test("Snapshot.update treats a memory filesystem as a total replacement", async () => { const host = createVirtualFileSystem({ "/host.ts": `export const source = "host";`, @@ -4139,7 +4174,7 @@ describe("updateSnapshot file systems", () => { } }); - test("Snapshot.createProgram uses the snapshot filesystem as its base", async () => { + test("Snapshot.createProgram uses the request filesystem as its base", async () => { const callbackCalls: string[] = []; const api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), @@ -4362,7 +4397,7 @@ describe("updateSnapshot file systems", () => { } }); - // TODO: Add snapshot filesystem coverage for `tsc -b` and `tsc -b --clean` + // TODO: Add request filesystem coverage for `tsc -b` and `tsc -b --clean` // once build and clean are exposed through the client API. In particular, // verify that clean removes synthetic outputs and that build-mode re-timestamping // of emitted-but-unchanged files works for memory filesystems, which currently diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index a302afc2de608..876c80d1de174 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -3571,7 +3571,7 @@ describe("readFile callback semantics", () => { }); describe("updateSnapshot file systems", () => { - test("snapshot filesystem factories derive directory listings", () => { + test("request filesystem factories derive directory listings", () => { const memory = createMemoryFileSystem([ ["/src/index.ts", "posix"], ["C:\\repo\\src\\index.ts", "windows"], @@ -3638,7 +3638,7 @@ describe("updateSnapshot file systems", () => { ["/duplicate.ts", "path"], [{ uri: "file:///duplicate.ts" }, "URI"], ]), - /Duplicate snapshot filesystem path: \/duplicate\.ts/, + /Duplicate request filesystem path: \/duplicate\.ts/, ); const prototypeFileSystem = createMemoryFileSystem([["__proto__", "prototype"]]); @@ -3651,7 +3651,7 @@ describe("updateSnapshot file systems", () => { ["/normalized/duplicate.ts", "forward slash"], ["\\normalized\\duplicate.ts", "backslash"], ]), - /Duplicate snapshot filesystem path: \/normalized\/duplicate\.ts/, + /Duplicate request filesystem path: \/normalized\/duplicate\.ts/, ); }); @@ -3960,6 +3960,41 @@ describe("updateSnapshot file systems", () => { } }); + test("eager snapshot disposal does not retain filesystem history", () => { + const api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + }); + try { + let snapshot: Snapshot = api.updateSnapshot({ + fileSystem: createMemoryFileSystem([["/pkg/index.ts", ""]]), + }); + try { + let content = ""; + for (const character of "export const x = 1") { + const oldSnapshot: Snapshot = snapshot; + content += character; + snapshot = oldSnapshot.update({ + fileSystem: createCacheFileSystem([["/pkg/index.ts", content]]), + }); + oldSnapshot.dispose(); + assert.equal(oldSnapshot.isDisposed(), true); + } + + using program = snapshot.createProgram( + ["/pkg/index.ts"], + { compilerOptions: { noLib: true } }, + ); + assert.equal((program.getSourceFile("/pkg/index.ts"))?.text, "export const x = 1"); + } + finally { + snapshot.dispose(); + } + } + finally { + api.close(); + } + }); + test("Snapshot.update treats a memory filesystem as a total replacement", () => { const host = createVirtualFileSystem({ "/host.ts": `export const source = "host";`, @@ -4031,7 +4066,7 @@ describe("updateSnapshot file systems", () => { } }); - test("Snapshot.createProgram uses the snapshot filesystem as its base", () => { + test("Snapshot.createProgram uses the request filesystem as its base", () => { const callbackCalls: string[] = []; const api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), @@ -4254,7 +4289,7 @@ describe("updateSnapshot file systems", () => { } }); - // TODO: Add snapshot filesystem coverage for `tsc -b` and `tsc -b --clean` + // TODO: Add request filesystem coverage for `tsc -b` and `tsc -b --clean` // once build and clean are exposed through the client API. In particular, // verify that clean removes synthetic outputs and that build-mode re-timestamping // of emitted-but-unchanged files works for memory filesystems, which currently diff --git a/tsc/internal/api/proto.go b/tsc/internal/api/proto.go index bf343ca674472..9a83806191956 100644 --- a/tsc/internal/api/proto.go +++ b/tsc/internal/api/proto.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" + "github.com/microsoft/TypeScript/tsc/internal/api/requestfilesystem" "github.com/microsoft/TypeScript/tsc/internal/ast" "github.com/microsoft/TypeScript/tsc/internal/checker" "github.com/microsoft/TypeScript/tsc/internal/collections" @@ -341,48 +342,6 @@ type APIFileChanges struct { Deleted []DocumentIdentifier `json:"deleted,omitempty"` } -// SnapshotFileSystemKind controls how an update snapshot filesystem is used. -type SnapshotFileSystemKind string - -const ( - // SnapshotFileSystemKindMemory makes the supplied filesystem canonical and total. - SnapshotFileSystemKindMemory SnapshotFileSystemKind = "memory" - // SnapshotFileSystemKindCache checks the supplied filesystem before falling back to the host. - SnapshotFileSystemKindCache SnapshotFileSystemKind = "cache" -) - -// SnapshotDirectoryEntries is a cached directory listing. Entry names are -// relative to the directory, matching vfs.GetAccessibleEntries. -type SnapshotDirectoryEntries struct { - Files []string `json:"files" nonnil:"true"` - Directories []string `json:"directories" nonnil:"true"` -} - -// SnapshotSymlink describes a symbolic link in a snapshot filesystem. -type SnapshotSymlink struct { - // Target is resolved relative to the directory containing the link, matching - // native symbolic-link semantics. - Target string `json:"target"` - // Host routes the target through the host filesystem. This is the only way a - // memory filesystem can access paths not supplied in the snapshot filesystem. - Host bool `json:"host,omitempty"` -} - -// SnapshotFileSystem supplies file contents and, optionally, directory listings -// for a snapshot update. -type SnapshotFileSystem struct { - Kind SnapshotFileSystemKind `json:"kind"` - // Files maps file names to their complete contents. - Files map[string]string `json:"files" nonnil:"true"` - // Directories maps directory names to complete listing results. - Directories map[string]SnapshotDirectoryEntries `json:"directories,omitempty"` - // Symlinks maps link paths to targets in this filesystem or the host filesystem. - Symlinks map[string]SnapshotSymlink `json:"symlinks,omitempty"` - // RemovedPaths lists files or directory trees that must be treated as missing - // even when present in an underlying snapshot or host filesystem. - RemovedPaths []string `json:"removedPaths,omitempty"` -} - // UpdateSnapshotParams are the parameters for creating a new snapshot. // All fields are optional. With no fields set, the server adopts the latest LSP state. type UpdateSnapshotParams struct { @@ -400,7 +359,7 @@ type UpdateSnapshotParams struct { // FileSystem supplies file contents and directory listings for the new snapshot. // A memory filesystem is canonical and total. A cache filesystem is checked // before falling back to the host filesystem. - FileSystem *SnapshotFileSystem `json:"fileSystem,omitempty"` + FileSystem *requestfilesystem.RequestFileSystem `json:"fileSystem,omitempty"` // OpenFiles lists files to keep open for the API client, mirroring LSP's // textDocument/didOpen. For each file, ancestor directories are searched for a // tsconfig that contains it; if found, that configured project is loaded and diff --git a/tsc/internal/api/requestfilesystem/filechanges.go b/tsc/internal/api/requestfilesystem/filechanges.go new file mode 100644 index 0000000000000..dcba8e8a183ad --- /dev/null +++ b/tsc/internal/api/requestfilesystem/filechanges.go @@ -0,0 +1,53 @@ +package requestfilesystem + +import ( + "github.com/microsoft/TypeScript/tsc/internal/ls/lsconv" + "github.com/microsoft/TypeScript/tsc/internal/project" + "github.com/microsoft/TypeScript/tsc/internal/tspath" + "github.com/microsoft/TypeScript/tsc/internal/vfs" +) + +func addFileChanges(summary *project.FileChangeSummary, request *RequestFileSystem, baseFS vfs.FS, currentDirectory string) { + toPath := func(fileName string) tspath.Path { + return tspath.ToPath(fileName, currentDirectory, baseFS.UseCaseSensitiveFileNames()) + } + baseRequestFS := getRequestFileSystem(baseFS) + addChange := func(fileName string, deleted bool) { + uri := lsconv.FileNameToDocumentURI(fileName) + if deleted { + if baseFS.FileExists(fileName) { + summary.Deleted.Add(uri) + } + return + } + if baseFS.FileExists(fileName) { + summary.Changed.Add(uri) + } else { + summary.Created.Add(uri) + } + } + addChangeAndAliases := func(fileName string, deleted bool) { + addChange(fileName, deleted) + if baseRequestFS != nil { + for _, alias := range baseRequestFS.load().aliasesForPath(fileName) { + addChange(alias, deleted) + } + } + } + overlayFiles := make(map[tspath.Path]struct{}, len(request.Files)) + for fileName := range request.Files { + absoluteFileName := tspath.GetNormalizedAbsolutePath(fileName, currentDirectory) + overlayFiles[toPath(absoluteFileName)] = struct{}{} + addChangeAndAliases(absoluteFileName, false) + } + for _, removedPath := range request.RemovedPaths { + absoluteFileName := tspath.GetNormalizedAbsolutePath(removedPath, currentDirectory) + if _, replaced := overlayFiles[toPath(absoluteFileName)]; replaced { + continue + } + addChangeAndAliases(absoluteFileName, true) + } + if summary.Changed.Len()+summary.Created.Len()+summary.Deleted.Len() > 0 { + summary.IncludesWatchChangeOutsideNodeModules = true + } +} diff --git a/tsc/internal/api/snapshotfilesystem.go b/tsc/internal/api/requestfilesystem/requestfilesystem.go similarity index 52% rename from tsc/internal/api/snapshotfilesystem.go rename to tsc/internal/api/requestfilesystem/requestfilesystem.go index 216d39085239b..fe9814e56d2bc 100644 --- a/tsc/internal/api/snapshotfilesystem.go +++ b/tsc/internal/api/requestfilesystem/requestfilesystem.go @@ -1,154 +1,166 @@ -package api +package requestfilesystem import ( "errors" "fmt" "io/fs" + "maps" "slices" "strings" - "sync" "time" "github.com/microsoft/TypeScript/tsc/internal/tspath" "github.com/microsoft/TypeScript/tsc/internal/vfs" ) -// snapshotFileSystem is either a total in-memory filesystem or a read-through +// Kind controls how a request filesystem is used. +type Kind string + +const ( + // KindMemory makes the supplied filesystem canonical and total. + KindMemory Kind = "memory" + // KindCache checks the supplied filesystem before falling back to the host. + KindCache Kind = "cache" +) + +// RequestDirectoryEntries is a cached directory listing. Entry names are +// relative to the directory, matching vfs.GetAccessibleEntries. +type RequestDirectoryEntries struct { + Files []string `json:"files" nonnil:"true"` + Directories []string `json:"directories" nonnil:"true"` +} + +// RequestSymlink describes a symbolic link in a request filesystem. +type RequestSymlink struct { + // Target is resolved relative to the directory containing the link, matching + // native symbolic-link semantics. + Target string `json:"target"` + // Host routes the target through the host filesystem. This is the only way a + // memory filesystem can access paths not supplied in the request filesystem. + Host bool `json:"host,omitempty"` +} + +// RequestFileSystem supplies file contents and, optionally, directory listings +// for a request that creates a snapshot. +type RequestFileSystem struct { + Kind Kind `json:"kind"` + // Files maps file names to their complete contents. + Files map[string]string `json:"files" nonnil:"true"` + // Directories maps directory names to complete listing results. + Directories map[string]RequestDirectoryEntries `json:"directories,omitempty"` + // Symlinks maps link paths to targets in this filesystem or the host filesystem. + Symlinks map[string]RequestSymlink `json:"symlinks,omitempty"` + // RemovedPaths lists files or directory trees that must be treated as missing + // even when present in an underlying snapshot or host filesystem. + RemovedPaths []string `json:"removedPaths,omitempty"` +} + +// requestFileSystem is either a total in-memory filesystem or a read-through // cache layered over the session host filesystem. Cache misses deliberately go // through base, which may itself be a callback filesystem. -type snapshotFileSystem struct { - mu sync.RWMutex - kind SnapshotFileSystemKind - base vfs.FS - layered bool - currentDirectory string - useCaseSensitiveNames bool - files map[tspath.Path]snapshotFile - directoryListings map[tspath.Path]vfs.Entries - symlinks map[tspath.Path]snapshotSymlink - removedPaths map[tspath.Path]struct{} - directories map[tspath.Path]string - derivedListings map[tspath.Path]*snapshotDirectoryBuilder -} - -type snapshotFile struct { +type requestFileSystem struct { + kind Kind + base vfs.FS + layered bool + currentDirectory string + useCaseSensitiveNames bool + files map[tspath.Path]requestFile + directoryListings map[tspath.Path]vfs.Entries + symlinks map[tspath.Path]requestSymlink + removedPaths map[tspath.Path]struct{} + preSymlinkRemovedPaths map[tspath.Path]struct{} + sealedListings map[tspath.Path]struct{} + directories map[tspath.Path]string + derivedListings map[tspath.Path]*requestDirectoryBuilder +} + +type requestFile struct { fileName string content string } -type snapshotSymlink struct { +type requestSymlink struct { linkName string target string host bool } -type resolvedSnapshotPath struct { +type resolvedRequestPath struct { path string followedSymlink bool host bool ok bool } -type snapshotDirectoryBuilder struct { +type requestDirectoryBuilder struct { files map[tspath.Path]string directories map[tspath.Path]string } -type fileSystemUnwrapper interface { - Unwrap() vfs.FS -} - -func getSnapshotFileSystem(fileSystem vfs.FS) *snapshotFileSystem { - seen := make(map[vfs.FS]struct{}) - for fileSystem != nil { - if _, ok := seen[fileSystem]; ok { - return nil - } - seen[fileSystem] = struct{}{} - if snapshotFileSystem, ok := fileSystem.(*snapshotFileSystem); ok { - return snapshotFileSystem - } - unwrapper, ok := fileSystem.(fileSystemUnwrapper) - if !ok { - return nil - } - fileSystem = unwrapper.Unwrap() - } - return nil +func getRequestFileSystem(fileSystem vfs.FS) *Handle { + requestFileSystem, _ := fileSystem.(*Handle) + return requestFileSystem } func getHostFileSystem(fileSystem vfs.FS) vfs.FS { - seen := make(map[vfs.FS]struct{}) - for fileSystem != nil { - if _, ok := seen[fileSystem]; ok { - return nil - } - seen[fileSystem] = struct{}{} - if snapshotFileSystem, ok := fileSystem.(*snapshotFileSystem); ok { - fileSystem = snapshotFileSystem.base - continue - } - if unwrapper, ok := fileSystem.(fileSystemUnwrapper); ok { - fileSystem = unwrapper.Unwrap() - continue + for { + requestFileSystem := getRequestFileSystem(fileSystem) + if requestFileSystem == nil { + return fileSystem } - return fileSystem + fileSystem = requestFileSystem.baseFileSystem() } - return nil -} - -func newSnapshotFileSystem(params *SnapshotFileSystem, base vfs.FS, currentDirectory string) (vfs.FS, error) { - return newSnapshotFileSystemWorker(params, base, currentDirectory, false) -} - -func newLayeredSnapshotFileSystem(params *SnapshotFileSystem, base vfs.FS, currentDirectory string) (vfs.FS, error) { - return newSnapshotFileSystemWorker(params, base, currentDirectory, params.Kind == SnapshotFileSystemKindCache) } -func newSnapshotFileSystemWorker(params *SnapshotFileSystem, base vfs.FS, currentDirectory string, layered bool) (vfs.FS, error) { - if params.Kind != SnapshotFileSystemKindMemory && params.Kind != SnapshotFileSystemKindCache { - return nil, fmt.Errorf("unknown snapshot filesystem kind %q", params.Kind) +func newRequestFileSystemWorker(params *RequestFileSystem, base vfs.FS, currentDirectory string, layered bool) (*requestFileSystem, error) { + if params.Kind != KindMemory && params.Kind != KindCache { + return nil, fmt.Errorf("unknown request filesystem kind %q", params.Kind) } - result := &snapshotFileSystem{ - kind: params.Kind, - base: base, - layered: layered, - currentDirectory: currentDirectory, - useCaseSensitiveNames: base.UseCaseSensitiveFileNames(), - files: make(map[tspath.Path]snapshotFile, len(params.Files)), - directoryListings: make(map[tspath.Path]vfs.Entries, len(params.Directories)), - symlinks: make(map[tspath.Path]snapshotSymlink, len(params.Symlinks)), - removedPaths: make(map[tspath.Path]struct{}, len(params.RemovedPaths)), + result := requestFileSystem{ + kind: params.Kind, + base: base, + layered: layered, + currentDirectory: currentDirectory, + useCaseSensitiveNames: base.UseCaseSensitiveFileNames(), + files: make(map[tspath.Path]requestFile, len(params.Files)), + directoryListings: make(map[tspath.Path]vfs.Entries, len(params.Directories)), + symlinks: make(map[tspath.Path]requestSymlink, len(params.Symlinks)), + removedPaths: make(map[tspath.Path]struct{}, len(params.RemovedPaths)), + preSymlinkRemovedPaths: make(map[tspath.Path]struct{}), + sealedListings: make(map[tspath.Path]struct{}, len(params.Directories)), } for fileName, content := range params.Files { absoluteFileName := result.toAbsolutePath(fileName) path := result.toPath(absoluteFileName) if existing, ok := result.files[path]; ok { - return nil, fmt.Errorf("duplicate snapshot filesystem file path %q and %q", existing.fileName, absoluteFileName) + return nil, fmt.Errorf("duplicate request filesystem file path %q and %q", existing.fileName, absoluteFileName) } - result.files[path] = snapshotFile{fileName: absoluteFileName, content: content} + result.files[path] = requestFile{fileName: absoluteFileName, content: content} } for directoryName, entries := range params.Directories { absoluteDirectoryName := result.toAbsolutePath(directoryName) path := result.toPath(absoluteDirectoryName) if _, ok := result.directoryListings[path]; ok { - return nil, fmt.Errorf("duplicate snapshot filesystem directory path %q", absoluteDirectoryName) + return nil, fmt.Errorf("duplicate request filesystem directory path %q", absoluteDirectoryName) } result.directoryListings[path] = vfs.Entries{ Files: slices.Clone(entries.Files), Directories: slices.Clone(entries.Directories), } + if !layered { + result.sealedListings[path] = struct{}{} + } } for linkName, symlink := range params.Symlinks { absoluteLinkName := result.toAbsolutePath(linkName) path := result.toPath(absoluteLinkName) if existing, ok := result.symlinks[path]; ok { - return nil, fmt.Errorf("duplicate snapshot filesystem symlink path %q and %q", existing.linkName, absoluteLinkName) + return nil, fmt.Errorf("duplicate request filesystem symlink path %q and %q", existing.linkName, absoluteLinkName) } targetDirectory := tspath.GetDirectoryPath(absoluteLinkName) absoluteTarget := result.toAbsolutePathFrom(symlink.Target, targetDirectory) - result.symlinks[path] = snapshotSymlink{ + result.symlinks[path] = requestSymlink{ linkName: absoluteLinkName, target: absoluteTarget, host: symlink.Host, @@ -157,21 +169,156 @@ func newSnapshotFileSystemWorker(params *SnapshotFileSystem, base vfs.FS, curren for _, path := range params.RemovedPaths { result.removedPaths[result.toPath(result.toAbsolutePath(path))] = struct{}{} } - result.rebuildDirectoriesLocked() - return result, nil + result = result.rebuildDirectories() + return &result, nil +} + +func (s requestFileSystem) fallsBack() bool { + return s.layered || s.kind == KindCache +} + +func (s requestFileSystem) baseFileSystem() vfs.FS { + return s.base +} + +func (s requestFileSystem) applyTo(base requestFileSystem) requestFileSystem { + files := maps.Clone(base.files) + directoryListings := make(map[tspath.Path]vfs.Entries, len(base.directoryListings)+len(s.directoryListings)) + for path, entries := range base.directoryListings { + directoryListings[path] = cloneEntries(entries) + } + symlinks := maps.Clone(base.symlinks) + removedPaths := maps.Clone(base.removedPaths) + preSymlinkRemovedPaths := maps.Clone(base.preSymlinkRemovedPaths) + sealedListings := maps.Clone(base.sealedListings) + + removeListingEntry := func(path tspath.Path) { + parentPath := s.toPath(tspath.GetDirectoryPath(string(path))) + entries, ok := directoryListings[parentPath] + if !ok { + return + } + name := tspath.GetBaseFileName(string(path)) + entries.Files = s.deleteEntryName(entries.Files, name) + entries.Directories = s.deleteEntryName(entries.Directories, name) + for existingName := range entries.Symlinks { + if s.equalEntryNames(existingName, name) { + delete(entries.Symlinks, existingName) + } + } + directoryListings[parentPath] = entries + } + removePath := func(path tspath.Path) { + removeListingEntry(path) + prefix := tspath.EnsureTrailingDirectorySeparator(string(path)) + for candidate := range files { + if candidate == path || strings.HasPrefix(string(candidate), prefix) { + delete(files, candidate) + } + } + for candidate := range symlinks { + if candidate == path || strings.HasPrefix(string(candidate), prefix) { + delete(symlinks, candidate) + } + } + for candidate := range directoryListings { + if candidate == path || strings.HasPrefix(string(candidate), prefix) { + delete(directoryListings, candidate) + delete(sealedListings, candidate) + } + } + } + clearPreSymlinkRemovedPath := func(path tspath.Path) { + for removedPath := range preSymlinkRemovedPaths { + if path == removedPath || strings.HasPrefix(string(removedPath), tspath.EnsureTrailingDirectorySeparator(string(path))) { + delete(preSymlinkRemovedPaths, removedPath) + } + } + } + for path := range s.removedPaths { + if !s.pathUsesSymlink(path) && base.pathUsesSymlink(path) { + preSymlinkRemovedPaths[path] = struct{}{} + } + removePath(path) + removedPaths[path] = struct{}{} + } + for path := range s.directories { + delete(files, path) + delete(symlinks, path) + } + for path, file := range s.files { + clearPreSymlinkRemovedPath(path) + removePath(path) + files[path] = file + } + for path, symlink := range s.symlinks { + clearPreSymlinkRemovedPath(path) + removePath(path) + symlinks[path] = symlink + } + for path, entries := range s.directoryListings { + if baseEntries, ok := directoryListings[path]; ok { + directoryListings[path] = mergeEntries(baseEntries, entries, s.equalEntryNames) + } else { + directoryListings[path] = cloneEntries(entries) + } + } + for path, builder := range s.derivedListings { + entries, ok := directoryListings[path] + if !ok { + continue + } + if _, explicit := s.directoryListings[path]; explicit { + continue + } + var overlay vfs.Entries + for _, name := range builder.files { + overlay.Files = append(overlay.Files, name) + } + for _, name := range builder.directories { + overlay.Directories = append(overlay.Directories, name) + } + directoryListings[path] = mergeEntries(entries, overlay, s.equalEntryNames) + } + + compacted := requestFileSystem{ + kind: base.kind, + base: base.base, + layered: base.layered, + currentDirectory: s.currentDirectory, + useCaseSensitiveNames: s.useCaseSensitiveNames, + files: files, + directoryListings: directoryListings, + symlinks: symlinks, + removedPaths: removedPaths, + preSymlinkRemovedPaths: preSymlinkRemovedPaths, + sealedListings: sealedListings, + } + return compacted.rebuildDirectories() } -func (s *snapshotFileSystem) fallsBack() bool { - return s.layered || s.kind == SnapshotFileSystemKindCache +func (s requestFileSystem) pathUsesSymlink(path tspath.Path) bool { + canonicalPath := string(path) + for linkPath := range s.symlinks { + canonicalLink := string(linkPath) + if canonicalPath == canonicalLink || strings.HasPrefix(canonicalPath, tspath.EnsureTrailingDirectorySeparator(canonicalLink)) { + return true + } + } + return false } -func (s *snapshotFileSystem) isRemoved(path string) bool { - s.mu.RLock() - defer s.mu.RUnlock() - return s.isRemovedLocked(path) +func (s requestFileSystem) isPreSymlinkRemoved(path string) bool { + canonicalPath := s.toPath(path) + for removedPath := range s.preSymlinkRemovedPaths { + if canonicalPath == removedPath || strings.HasPrefix(string(canonicalPath), tspath.EnsureTrailingDirectorySeparator(string(removedPath))) { + return true + } + } + return false } -func (s *snapshotFileSystem) isRemovedLocked(path string) bool { +func (s requestFileSystem) isRemoved(path string) bool { canonicalPath := s.toPath(path) for removedPath := range s.removedPaths { if canonicalPath == removedPath || strings.HasPrefix(string(canonicalPath), tspath.EnsureTrailingDirectorySeparator(string(removedPath))) { @@ -181,11 +328,11 @@ func (s *snapshotFileSystem) isRemovedLocked(path string) bool { return false } -func (s *snapshotFileSystem) toAbsolutePath(path string) string { +func (s requestFileSystem) toAbsolutePath(path string) string { return s.toAbsolutePathFrom(path, s.currentDirectory) } -func (s *snapshotFileSystem) toAbsolutePathFrom(path string, currentDirectory string) string { +func (s requestFileSystem) toAbsolutePathFrom(path string, currentDirectory string) string { absolutePath := tspath.GetNormalizedAbsolutePath(path, currentDirectory) if tspath.IsDiskPathRoot(absolutePath) { return absolutePath @@ -193,42 +340,42 @@ func (s *snapshotFileSystem) toAbsolutePathFrom(path string, currentDirectory st return tspath.RemoveTrailingDirectorySeparator(absolutePath) } -func (s *snapshotFileSystem) toPath(path string) tspath.Path { +func (s requestFileSystem) toPath(path string) tspath.Path { return tspath.ToPath(path, s.currentDirectory, s.useCaseSensitiveNames) } -func (s *snapshotFileSystem) registerDirectoryLocked(directoryName string) { - directoryName = s.toAbsolutePath(directoryName) - directoryPath := s.toPath(directoryName) - if _, ok := s.directories[directoryPath]; ok { - return - } - s.directories[directoryPath] = directoryName - if s.derivedListings[directoryPath] == nil { - s.derivedListings[directoryPath] = &snapshotDirectoryBuilder{} - } +func (s requestFileSystem) rebuildDirectories() requestFileSystem { + s.directories = make(map[tspath.Path]string) + s.derivedListings = make(map[tspath.Path]*requestDirectoryBuilder) + var registerDirectory func(string) + registerDirectory = func(directoryName string) { + directoryName = s.toAbsolutePath(directoryName) + directoryPath := s.toPath(directoryName) + if _, ok := s.directories[directoryPath]; ok { + return + } + s.directories[directoryPath] = directoryName + if s.derivedListings[directoryPath] == nil { + s.derivedListings[directoryPath] = &requestDirectoryBuilder{} + } - parentName := tspath.GetDirectoryPath(directoryName) - parentPath := s.toPath(parentName) - if parentPath == directoryPath { - return - } - s.registerDirectoryLocked(parentName) - parent := s.derivedListings[parentPath] - if parent.directories == nil { - parent.directories = make(map[tspath.Path]string) + parentName := tspath.GetDirectoryPath(directoryName) + parentPath := s.toPath(parentName) + if parentPath == directoryPath { + return + } + registerDirectory(parentName) + parent := s.derivedListings[parentPath] + if parent.directories == nil { + parent.directories = make(map[tspath.Path]string) + } + parent.directories[directoryPath] = tspath.GetBaseFileName(directoryName) } - parent.directories[directoryPath] = tspath.GetBaseFileName(directoryName) -} - -func (s *snapshotFileSystem) rebuildDirectoriesLocked() { - s.directories = make(map[tspath.Path]string) - s.derivedListings = make(map[tspath.Path]*snapshotDirectoryBuilder) - s.registerDirectoryLocked(s.currentDirectory) + registerDirectory(s.currentDirectory) for path, file := range s.files { parentName := tspath.GetDirectoryPath(file.fileName) parentPath := s.toPath(parentName) - s.registerDirectoryLocked(parentName) + registerDirectory(parentName) listing := s.derivedListings[parentPath] if listing.files == nil { listing.files = make(map[tspath.Path]string) @@ -237,30 +384,25 @@ func (s *snapshotFileSystem) rebuildDirectoriesLocked() { } for path, entries := range s.directoryListings { directoryName := string(path) - s.registerDirectoryLocked(directoryName) + registerDirectory(directoryName) for _, child := range entries.Directories { - s.registerDirectoryLocked(tspath.CombinePaths(directoryName, child)) + registerDirectory(tspath.CombinePaths(directoryName, child)) } } for _, symlink := range s.symlinks { - s.registerDirectoryLocked(tspath.GetDirectoryPath(symlink.linkName)) + registerDirectory(tspath.GetDirectoryPath(symlink.linkName)) } + return s } -func (s *snapshotFileSystem) resolvePath(path string) resolvedSnapshotPath { - s.mu.RLock() - defer s.mu.RUnlock() - return s.resolvePathLocked(path) -} - -func (s *snapshotFileSystem) resolvePathLocked(path string) resolvedSnapshotPath { +func (s requestFileSystem) resolvePath(path string) resolvedRequestPath { path = s.toAbsolutePath(path) - result := resolvedSnapshotPath{path: path, ok: true} + result := resolvedRequestPath{path: path, ok: true} seen := make(map[tspath.Path]struct{}, len(s.symlinks)) for { canonicalPath := string(s.toPath(result.path)) var matchPath tspath.Path - var match snapshotSymlink + var match requestSymlink for linkPath, symlink := range s.symlinks { canonicalLink := string(linkPath) if canonicalPath != canonicalLink && !strings.HasPrefix(canonicalPath, tspath.EnsureTrailingDirectorySeparator(canonicalLink)) { @@ -274,7 +416,7 @@ func (s *snapshotFileSystem) resolvePathLocked(path string) resolvedSnapshotPath } } if matchPath == "" { - result.host = s.isHostPathLocked(result.path) + result.host = s.isHostPath(result.path) return result } if _, ok := seen[matchPath]; ok { @@ -300,7 +442,7 @@ func (s *snapshotFileSystem) resolvePathLocked(path string) resolvedSnapshotPath // and any underlying snapshot layers, stopping when this layer supplies or removes // the resolved path. Callers in a newer layer use this to apply their own entries // to targets of inherited symlinks before delegating the operation to the base. -func (s *snapshotFileSystem) resolvePathForOverlay(path string) resolvedSnapshotPath { +func (s requestFileSystem) resolvePathForOverlay(path string) resolvedRequestPath { resolved := s.resolvePath(path) if !resolved.ok || resolved.host { return resolved @@ -319,14 +461,14 @@ func (s *snapshotFileSystem) resolvePathForOverlay(path string) resolvedSnapshot return baseResolved } -func (s *snapshotFileSystem) resolveBasePath(path string) resolvedSnapshotPath { - if base := getSnapshotFileSystem(s.base); base != nil { - return base.resolvePathForOverlay(path) +func (s requestFileSystem) resolveBasePath(path string) resolvedRequestPath { + if base := getRequestFileSystem(s.baseFileSystem()); base != nil { + return base.load().resolvePathForOverlay(path) } - return resolvedSnapshotPath{path: path, ok: true} + return resolvedRequestPath{path: path, ok: true} } -func (s *snapshotFileSystem) isHostPathLocked(path string) bool { +func (s requestFileSystem) isHostPath(path string) bool { canonicalPath := string(s.toPath(path)) for _, symlink := range s.symlinks { if !symlink.host { @@ -340,19 +482,17 @@ func (s *snapshotFileSystem) isHostPathLocked(path string) bool { return false } -func (s *snapshotFileSystem) aliasesForPath(path string) []string { - symlinks := make([]snapshotSymlink, 0, len(s.symlinks)) - for current := s; current != nil; { - current.mu.RLock() +func (s requestFileSystem) aliasesForPath(path string) []string { + symlinks := make([]requestSymlink, 0, len(s.symlinks)) + for current := s; ; { for _, symlink := range current.symlinks { symlinks = append(symlinks, symlink) } - current.mu.RUnlock() - base := getSnapshotFileSystem(current.base) + base := getRequestFileSystem(current.baseFileSystem()) if base == nil { break } - current = base + current = *base.load() } seen := map[tspath.Path]struct{}{s.toPath(path): {}} @@ -379,17 +519,13 @@ func (s *snapshotFileSystem) aliasesForPath(path string) []string { return aliases } -func (s *snapshotFileSystem) fileAt(path string) (snapshotFile, bool) { - s.mu.RLock() +func (s requestFileSystem) fileAt(path string) (requestFile, bool) { file, ok := s.files[s.toPath(path)] - s.mu.RUnlock() return file, ok } -func (s *snapshotFileSystem) directoryAt(path string) (string, bool) { - s.mu.RLock() +func (s requestFileSystem) directoryAt(path string) (string, bool) { directory, ok := s.directories[s.toPath(path)] - s.mu.RUnlock() return directory, ok } @@ -407,11 +543,14 @@ func cloneEntries(entries vfs.Entries) vfs.Entries { return result } -func (s *snapshotFileSystem) UseCaseSensitiveFileNames() bool { +func (s requestFileSystem) UseCaseSensitiveFileNames() bool { return s.useCaseSensitiveNames } -func (s *snapshotFileSystem) ReadFile(fileName string) (string, bool) { +func (s requestFileSystem) ReadFile(fileName string) (string, bool) { + if s.isPreSymlinkRemoved(fileName) { + return "", false + } resolved := s.resolvePath(fileName) if !resolved.ok { return "", false @@ -420,7 +559,7 @@ func (s *snapshotFileSystem) ReadFile(fileName string) (string, bool) { if s.isRemoved(resolved.path) { return "", false } - host := getHostFileSystem(s.base) + host := getHostFileSystem(s.baseFileSystem()) if host == nil { return "", false } @@ -454,12 +593,15 @@ func (s *snapshotFileSystem) ReadFile(fileName string) (string, bool) { return "", false } if s.fallsBack() { - return s.base.ReadFile(resolved.path) + return s.baseFileSystem().ReadFile(resolved.path) } return "", false } -func (s *snapshotFileSystem) FileExists(fileName string) bool { +func (s requestFileSystem) FileExists(fileName string) bool { + if s.isPreSymlinkRemoved(fileName) { + return false + } resolved := s.resolvePath(fileName) if !resolved.ok { return false @@ -468,7 +610,7 @@ func (s *snapshotFileSystem) FileExists(fileName string) bool { if s.isRemoved(resolved.path) { return false } - host := getHostFileSystem(s.base) + host := getHostFileSystem(s.baseFileSystem()) return host != nil && host.FileExists(resolved.path) } _, ok := s.fileAt(resolved.path) @@ -498,10 +640,13 @@ func (s *snapshotFileSystem) FileExists(fileName string) bool { if s.isRemoved(resolved.path) || s.isRemoved(fallbackPath) || !s.fallsBack() { return false } - return s.base.FileExists(resolved.path) + return s.baseFileSystem().FileExists(resolved.path) } -func (s *snapshotFileSystem) DirectoryExists(directoryName string) bool { +func (s requestFileSystem) DirectoryExists(directoryName string) bool { + if s.isPreSymlinkRemoved(directoryName) { + return false + } resolved := s.resolvePath(directoryName) if !resolved.ok { return false @@ -510,7 +655,7 @@ func (s *snapshotFileSystem) DirectoryExists(directoryName string) bool { if s.isRemoved(resolved.path) { return false } - host := getHostFileSystem(s.base) + host := getHostFileSystem(s.baseFileSystem()) return host != nil && host.DirectoryExists(resolved.path) } _, ok := s.directoryAt(resolved.path) @@ -540,10 +685,13 @@ func (s *snapshotFileSystem) DirectoryExists(directoryName string) bool { if s.isRemoved(resolved.path) || s.isRemoved(fallbackPath) || !s.fallsBack() { return false } - return s.base.DirectoryExists(resolved.path) + return s.baseFileSystem().DirectoryExists(resolved.path) } -func (s *snapshotFileSystem) GetAccessibleEntries(directoryName string) vfs.Entries { +func (s requestFileSystem) GetAccessibleEntries(directoryName string) vfs.Entries { + if s.isPreSymlinkRemoved(directoryName) { + return vfs.Entries{Symlinks: map[string]struct{}{}} + } resolved := s.resolvePath(directoryName) if !resolved.ok { return vfs.Entries{Symlinks: map[string]struct{}{}} @@ -553,6 +701,7 @@ func (s *snapshotFileSystem) GetAccessibleEntries(directoryName string) vfs.Entr } localEntries, hasExplicitListing, hasLocalEntries := s.getLocalEntries(resolved.path) + sealedListing := s.hasSealedListing(resolved.path) if !resolved.followedSymlink && s.isRemoved(directoryName) && !hasLocalEntries { return vfs.Entries{Symlinks: map[string]struct{}{}} } @@ -573,20 +722,21 @@ func (s *snapshotFileSystem) GetAccessibleEntries(directoryName string) vfs.Entr hasLocalEntries = true } hasExplicitListing = hasExplicitListing || targetExplicit + sealedListing = sealedListing || s.hasSealedListing(fallbackPath) } } var result vfs.Entries if resolved.host { if !s.isRemoved(resolved.path) { - if host := getHostFileSystem(s.base); host != nil { + if host := getHostFileSystem(s.baseFileSystem()); host != nil { result = s.removeEntries(resolved.path, host.GetAccessibleEntries(resolved.path)) } } - } else if !s.fallsBack() || hasExplicitListing && !s.layered { + } else if !s.fallsBack() || hasExplicitListing && sealedListing { result = localEntries } else { if !s.isRemoved(directoryName) && !s.isRemoved(resolved.path) && !s.isRemoved(fallbackPath) { - result = s.removeEntries(directoryName, s.base.GetAccessibleEntries(resolved.path)) + result = s.removeEntries(directoryName, s.baseFileSystem().GetAccessibleEntries(resolved.path)) if s.toPath(directoryName) != s.toPath(resolved.path) { result = s.removeEntries(resolved.path, result) } @@ -602,12 +752,39 @@ func (s *snapshotFileSystem) GetAccessibleEntries(directoryName string) vfs.Entr if s.toPath(fallbackPath) != s.toPath(resolved.path) { result = s.addSymlinkEntries(fallbackPath, result) } + result = s.removePreSymlinkEntries(directoryName, result) return result } -func (s *snapshotFileSystem) getLocalEntries(directoryName string) (entries vfs.Entries, explicit bool, ok bool) { - s.mu.RLock() - defer s.mu.RUnlock() +func (s requestFileSystem) removePreSymlinkEntries(directoryName string, entries vfs.Entries) vfs.Entries { + result := cloneEntries(entries) + filter := func(values []string) []string { + return slices.DeleteFunc(values, func(name string) bool { + path := s.toPath(tspath.CombinePaths(directoryName, name)) + for removedPath := range s.preSymlinkRemovedPaths { + if path == removedPath || strings.HasPrefix(string(path), tspath.EnsureTrailingDirectorySeparator(string(removedPath))) { + return true + } + } + return false + }) + } + result.Files = filter(result.Files) + result.Directories = filter(result.Directories) + for name := range result.Symlinks { + if len(filter([]string{name})) == 0 { + delete(result.Symlinks, name) + } + } + return result +} + +func (s requestFileSystem) hasSealedListing(directoryName string) bool { + _, ok := s.sealedListings[s.toPath(directoryName)] + return ok +} + +func (s requestFileSystem) getLocalEntries(directoryName string) (entries vfs.Entries, explicit bool, ok bool) { path := s.toPath(directoryName) if listing, ok := s.directoryListings[path]; ok { return cloneEntries(listing), true, true @@ -667,45 +844,36 @@ func mergeEntries(base vfs.Entries, overlay vfs.Entries, equal func(string, stri return result } -func (s *snapshotFileSystem) removeEntries(directoryName string, entries vfs.Entries) vfs.Entries { - s.mu.RLock() - defer s.mu.RUnlock() - return s.removeEntriesLocked(directoryName, entries) -} - -func (s *snapshotFileSystem) removeEntriesLocked(directoryName string, entries vfs.Entries) vfs.Entries { +func (s requestFileSystem) removeEntries(directoryName string, entries vfs.Entries) vfs.Entries { result := cloneEntries(entries) filter := func(values []string) []string { return slices.DeleteFunc(values, func(name string) bool { - return s.isRemovedLocked(tspath.CombinePaths(directoryName, name)) + return s.isRemoved(tspath.CombinePaths(directoryName, name)) }) } result.Files = filter(result.Files) result.Directories = filter(result.Directories) for name := range result.Symlinks { - if s.isRemovedLocked(tspath.CombinePaths(directoryName, name)) { + if s.isRemoved(tspath.CombinePaths(directoryName, name)) { delete(result.Symlinks, name) } } return result } -func (s *snapshotFileSystem) addSymlinkEntries(directoryName string, entries vfs.Entries) vfs.Entries { +func (s requestFileSystem) addSymlinkEntries(directoryName string, entries vfs.Entries) vfs.Entries { result := cloneEntries(entries) if result.Symlinks == nil { result.Symlinks = map[string]struct{}{} } - s.mu.RLock() directoryPath := s.toPath(directoryName) - var links []snapshotSymlink + var links []requestSymlink for _, symlink := range s.symlinks { if s.toPath(tspath.GetDirectoryPath(symlink.linkName)) == directoryPath { links = append(links, symlink) } } - s.mu.RUnlock() - for _, symlink := range links { name := tspath.GetBaseFileName(symlink.linkName) result.Files = s.deleteEntryName(result.Files, name) @@ -728,15 +896,18 @@ func (s *snapshotFileSystem) addSymlinkEntries(directoryName string, entries vfs return result } -func (s *snapshotFileSystem) deleteEntryName(values []string, value string) []string { +func (s requestFileSystem) deleteEntryName(values []string, value string) []string { return slices.DeleteFunc(values, func(candidate string) bool { return s.equalEntryNames(candidate, value) }) } -func (s *snapshotFileSystem) equalEntryNames(left string, right string) bool { +func (s requestFileSystem) equalEntryNames(left string, right string) bool { return tspath.GetCanonicalFileName(left, s.useCaseSensitiveNames) == tspath.GetCanonicalFileName(right, s.useCaseSensitiveNames) } -func (s *snapshotFileSystem) Realpath(path string) string { +func (s requestFileSystem) Realpath(path string) string { + if s.isPreSymlinkRemoved(path) { + return path + } resolved := s.resolvePath(path) if !resolved.ok { return path @@ -768,7 +939,7 @@ func (s *snapshotFileSystem) Realpath(path string) string { return path } if resolved.host { - if host := getHostFileSystem(s.base); host != nil { + if host := getHostFileSystem(s.baseFileSystem()); host != nil { return host.Realpath(resolved.path) } return path @@ -777,77 +948,76 @@ func (s *snapshotFileSystem) Realpath(path string) string { return path } if s.fallsBack() { - return s.base.Realpath(resolved.path) + return s.baseFileSystem().Realpath(resolved.path) } return resolved.path } -func (s *snapshotFileSystem) WriteFile(fileName string, data string) error { - if s.kind != SnapshotFileSystemKindCache { +func (s requestFileSystem) WriteFile(fileName string, data string) error { + if s.kind != KindCache { return vfs.ErrInvalid } - host := getHostFileSystem(s.base) + host := getHostFileSystem(s.baseFileSystem()) if host == nil { return vfs.ErrInvalid } return host.WriteFile(s.toAbsolutePath(fileName), data) } -func (s *snapshotFileSystem) AppendFile(fileName string, data string) error { - if s.kind != SnapshotFileSystemKindCache { +func (s requestFileSystem) AppendFile(fileName string, data string) error { + if s.kind != KindCache { return vfs.ErrInvalid } - host := getHostFileSystem(s.base) + host := getHostFileSystem(s.baseFileSystem()) if host == nil { return vfs.ErrInvalid } return host.AppendFile(s.toAbsolutePath(fileName), data) } -func (s *snapshotFileSystem) Remove(path string) error { - if s.kind != SnapshotFileSystemKindCache { +func (s requestFileSystem) Remove(path string) error { + if s.kind != KindCache { return vfs.ErrInvalid } - host := getHostFileSystem(s.base) + host := getHostFileSystem(s.baseFileSystem()) if host == nil { return vfs.ErrInvalid } return host.Remove(s.toAbsolutePath(path)) } -func (s *snapshotFileSystem) Chtimes(path string, aTime time.Time, mTime time.Time) error { +func (s requestFileSystem) Chtimes(path string, aTime time.Time, mTime time.Time) error { resolved := s.resolvePath(path) if !resolved.ok { return vfs.ErrInvalid } - if s.kind != SnapshotFileSystemKindCache { + if s.kind != KindCache { return vfs.ErrInvalid } - host := getHostFileSystem(s.base) + host := getHostFileSystem(s.baseFileSystem()) if host == nil { return vfs.ErrInvalid } return host.Chtimes(s.toAbsolutePath(path), aTime, mTime) } -func (s *snapshotFileSystem) Stat(path string) vfs.FileInfo { +func (s requestFileSystem) Stat(path string) vfs.FileInfo { + if s.isPreSymlinkRemoved(path) { + return nil + } resolved := s.resolvePath(path) if !resolved.ok { return nil } - s.mu.RLock() canonicalPath := s.toPath(resolved.path) if file, ok := s.files[canonicalPath]; ok { - info := snapshotFileInfo{name: tspath.GetBaseFileName(file.fileName), size: int64(len(file.content))} - s.mu.RUnlock() + info := requestFileInfo{name: tspath.GetBaseFileName(file.fileName), size: int64(len(file.content))} return info } if directoryName, ok := s.directories[canonicalPath]; ok { - info := snapshotFileInfo{name: tspath.GetBaseFileName(directoryName), directory: true} - s.mu.RUnlock() + info := requestFileInfo{name: tspath.GetBaseFileName(directoryName), directory: true} return info } - s.mu.RUnlock() if !resolved.followedSymlink && s.isRemoved(path) { return nil } @@ -858,28 +1028,24 @@ func (s *snapshotFileSystem) Stat(path string) vfs.FileInfo { return nil } fallbackPath = fallback.path - s.mu.RLock() canonicalFallbackPath := s.toPath(fallbackPath) if file, ok := s.files[canonicalFallbackPath]; ok { - info := snapshotFileInfo{name: tspath.GetBaseFileName(file.fileName), size: int64(len(file.content))} - s.mu.RUnlock() + info := requestFileInfo{name: tspath.GetBaseFileName(file.fileName), size: int64(len(file.content))} return info } if directoryName, ok := s.directories[canonicalFallbackPath]; ok { - info := snapshotFileInfo{name: tspath.GetBaseFileName(directoryName), directory: true} - s.mu.RUnlock() + info := requestFileInfo{name: tspath.GetBaseFileName(directoryName), directory: true} return info } - s.mu.RUnlock() } if s.isRemoved(resolved.path) || s.isRemoved(fallbackPath) { return nil } if resolved.host { - return statFileSystem(getHostFileSystem(s.base), resolved.path) + return statFileSystem(getHostFileSystem(s.baseFileSystem()), resolved.path) } if s.fallsBack() { - return statFileSystem(s.base, resolved.path) + return statFileSystem(s.baseFileSystem(), resolved.path) } return nil } @@ -893,15 +1059,15 @@ func statFileSystem(fileSystem vfs.FS, path string) vfs.FileInfo { } name := tspath.GetBaseFileName(path) if fileSystem.DirectoryExists(path) { - return snapshotFileInfo{name: name, directory: true} + return requestFileInfo{name: name, directory: true} } if fileSystem.FileExists(path) { - return snapshotFileInfo{name: name} + return requestFileInfo{name: name} } return nil } -func (s *snapshotFileSystem) WalkDir(root string, walkFn vfs.WalkDirFunc) error { +func (s requestFileSystem) WalkDir(root string, walkFn vfs.WalkDirFunc) error { originalRoot := s.toAbsolutePath(root) resolved := s.resolvePath(originalRoot) if !resolved.ok { @@ -912,14 +1078,14 @@ func (s *snapshotFileSystem) WalkDir(root string, walkFn vfs.WalkDirFunc) error return walkFn(originalRoot, nil, vfs.ErrNotExist) } visited := map[string]struct{}{} - if err := s.walkDir(originalRoot, snapshotDirEntry{info: info}, walkFn, visited); errors.Is(err, fs.SkipAll) { + if err := s.walkDir(originalRoot, requestDirEntry{info: info}, walkFn, visited); errors.Is(err, fs.SkipAll) { return nil } else { return err } } -func (s *snapshotFileSystem) walkDir(path string, entry snapshotDirEntry, walkFn vfs.WalkDirFunc, visited map[string]struct{}) error { +func (s requestFileSystem) walkDir(path string, entry requestDirEntry, walkFn vfs.WalkDirFunc, visited map[string]struct{}) error { realpath := s.Realpath(path) if _, ok := visited[realpath]; ok { return nil @@ -944,7 +1110,7 @@ func (s *snapshotFileSystem) walkDir(path string, entry snapshotDirEntry, walkFn if childInfo == nil { continue } - if err := s.walkDir(childPath, snapshotDirEntry{info: childInfo}, walkFn, visited); err != nil { + if err := s.walkDir(childPath, requestDirEntry{info: childInfo}, walkFn, visited); err != nil { if errors.Is(err, fs.SkipDir) { return nil } @@ -954,31 +1120,29 @@ func (s *snapshotFileSystem) walkDir(path string, entry snapshotDirEntry, walkFn return nil } -type snapshotFileInfo struct { +type requestFileInfo struct { name string size int64 directory bool } -func (i snapshotFileInfo) Name() string { return i.name } -func (i snapshotFileInfo) Size() int64 { return i.size } -func (i snapshotFileInfo) ModTime() time.Time { return time.Time{} } -func (i snapshotFileInfo) IsDir() bool { return i.directory } -func (i snapshotFileInfo) Sys() any { return nil } -func (i snapshotFileInfo) Mode() fs.FileMode { +func (i requestFileInfo) Name() string { return i.name } +func (i requestFileInfo) Size() int64 { return i.size } +func (i requestFileInfo) ModTime() time.Time { return time.Time{} } +func (i requestFileInfo) IsDir() bool { return i.directory } +func (i requestFileInfo) Sys() any { return nil } +func (i requestFileInfo) Mode() fs.FileMode { if i.directory { return fs.ModeDir | 0o555 } return 0o444 } -type snapshotDirEntry struct { +type requestDirEntry struct { info vfs.FileInfo } -func (e snapshotDirEntry) Name() string { return e.info.Name() } -func (e snapshotDirEntry) IsDir() bool { return e.info.IsDir() } -func (e snapshotDirEntry) Type() fs.FileMode { return e.info.Mode().Type() } -func (e snapshotDirEntry) Info() (fs.FileInfo, error) { return e.info, nil } - -var _ vfs.FS = (*snapshotFileSystem)(nil) +func (e requestDirEntry) Name() string { return e.info.Name() } +func (e requestDirEntry) IsDir() bool { return e.info.IsDir() } +func (e requestDirEntry) Type() fs.FileMode { return e.info.Mode().Type() } +func (e requestDirEntry) Info() (fs.FileInfo, error) { return e.info, nil } diff --git a/tsc/internal/api/snapshotfilesystem_test.go b/tsc/internal/api/requestfilesystem/requestfilesystem_test.go similarity index 64% rename from tsc/internal/api/snapshotfilesystem_test.go rename to tsc/internal/api/requestfilesystem/requestfilesystem_test.go index 6e52e44f71166..244ebba43f8fa 100644 --- a/tsc/internal/api/snapshotfilesystem_test.go +++ b/tsc/internal/api/requestfilesystem/requestfilesystem_test.go @@ -1,27 +1,81 @@ -package api +package requestfilesystem import ( - "context" "testing" - "github.com/microsoft/TypeScript/tsc/internal/testutil/projecttestutil" - "github.com/microsoft/TypeScript/tsc/internal/tspath" "github.com/microsoft/TypeScript/tsc/internal/vfs" "github.com/microsoft/TypeScript/tsc/internal/vfs/trackingvfs" "github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest" "gotest.tools/v3/assert" ) -func TestSnapshotFileSystem(t *testing.T) { +func newRequestFileSystem(params *RequestFileSystem, base vfs.FS, currentDirectory string) (*Handle, error) { + handle := &Handle{} + if err := handle.initializeFromRequest(params, base, currentDirectory); err != nil { + return nil, err + } + return handle, nil +} + +func newLayeredRequestFileSystem(params *RequestFileSystem, base vfs.FS, currentDirectory string) (*Handle, error) { + handle := &Handle{} + if err := handle.initializeLayered(params, base, currentDirectory); err != nil { + return nil, err + } + return handle, nil +} + +func (h *Handle) applyTo(base *Handle) { + requestFileSystemDependenciesMu.Lock() + defer requestFileSystemDependenciesMu.Unlock() + h.applyToLocked(base) +} + +func TestRequestFileSystem(t *testing.T) { t.Parallel() + t.Run("compaction preserves host fallback", func(t *testing.T) { + t.Parallel() + host := vfstest.FromMap(map[string]string{ + "/host.ts": "host", + }, true) + baseFS, err := newRequestFileSystem(&RequestFileSystem{ + Kind: KindCache, + }, host, "/") + assert.NilError(t, err) + base := getRequestFileSystem(baseFS) + assert.Assert(t, base != nil) + assert.Assert(t, base.baseFileSystem() == host) + assert.Assert(t, !base.FileExists("/created-after-base.ts")) + assert.NilError(t, host.WriteFile("/created-after-base.ts", "created")) + + layeredFS, err := newLayeredRequestFileSystem(&RequestFileSystem{ + Kind: KindCache, + Files: map[string]string{"/layered.ts": "layered"}, + }, baseFS, "/") + assert.NilError(t, err) + layered := getRequestFileSystem(layeredFS) + assert.Assert(t, layered != nil) + assert.Assert(t, layered.baseFileSystem() == baseFS) + assert.Assert(t, layered.FileExists("/created-after-base.ts")) + assert.NilError(t, host.Remove("/created-after-base.ts")) + + layered.applyTo(base) + + assert.Assert(t, layered.baseFileSystem() == host) + assert.Assert(t, !layered.FileExists("/created-after-base.ts")) + contents, ok := layered.ReadFile("/host.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, "host") + }) + t.Run("memory is total and never falls back", func(t *testing.T) { t.Parallel() base := &trackingvfs.FS{Inner: vfstest.FromMap(map[string]string{ "/host.ts": "host", }, true)} - fileSystem, err := newSnapshotFileSystem(&SnapshotFileSystem{ - Kind: SnapshotFileSystemKindMemory, + fileSystem, err := newRequestFileSystem(&RequestFileSystem{ + Kind: KindMemory, Files: map[string]string{ "/src/index.ts": "memory", }, @@ -46,12 +100,12 @@ func TestSnapshotFileSystem(t *testing.T) { base := &trackingvfs.FS{Inner: vfstest.FromMap(map[string]string{ "/fallback.ts": "fallback", }, true)} - fileSystem, err := newSnapshotFileSystem(&SnapshotFileSystem{ - Kind: SnapshotFileSystemKindCache, + fileSystem, err := newRequestFileSystem(&RequestFileSystem{ + Kind: KindCache, Files: map[string]string{ "/cached/index.ts": "cached", }, - Directories: map[string]SnapshotDirectoryEntries{ + Directories: map[string]RequestDirectoryEntries{ "/cached": {Files: []string{"index.ts"}, Directories: []string{}}, }, }, base, "/") @@ -74,8 +128,8 @@ func TestSnapshotFileSystem(t *testing.T) { t.Run("layered memory is a total replacement", func(t *testing.T) { t.Parallel() - fileSystem, err := newLayeredSnapshotFileSystem(&SnapshotFileSystem{ - Kind: SnapshotFileSystemKindMemory, + fileSystem, err := newLayeredRequestFileSystem(&RequestFileSystem{ + Kind: KindMemory, Files: map[string]string{ "/memory.ts": "memory", }, @@ -93,12 +147,12 @@ func TestSnapshotFileSystem(t *testing.T) { base := &trackingvfs.FS{Inner: vfstest.FromMap(map[string]string{ "/host.ts": "host", }, true)} - fileSystem, err := newSnapshotFileSystem(&SnapshotFileSystem{ - Kind: SnapshotFileSystemKindMemory, + fileSystem, err := newRequestFileSystem(&RequestFileSystem{ + Kind: KindMemory, Files: map[string]string{ "/packages/pkg/index.d.ts": "export declare const value: number;", }, - Symlinks: map[string]SnapshotSymlink{ + Symlinks: map[string]RequestSymlink{ "/project/node_modules/pkg": {Target: "../../../packages/pkg"}, "/project/pkg.d.ts": {Target: "../packages/pkg/index.d.ts"}, }, @@ -129,15 +183,15 @@ func TestSnapshotFileSystem(t *testing.T) { base := &trackingvfs.FS{Inner: vfstest.FromMap(map[string]string{ "/packages/pkg/index.d.ts": "host content", }, true)} - fileSystem, err := newSnapshotFileSystem(&SnapshotFileSystem{ - Kind: SnapshotFileSystemKindCache, + fileSystem, err := newRequestFileSystem(&RequestFileSystem{ + Kind: KindCache, Files: map[string]string{ "/packages/pkg/index.d.ts": "cached content", }, - Directories: map[string]SnapshotDirectoryEntries{ + Directories: map[string]RequestDirectoryEntries{ "/project/node_modules": {Files: []string{}, Directories: []string{}}, }, - Symlinks: map[string]SnapshotSymlink{ + Symlinks: map[string]RequestSymlink{ "/project/node_modules/pkg": {Target: "/packages/pkg"}, }, }, base, "/") @@ -160,8 +214,8 @@ func TestSnapshotFileSystem(t *testing.T) { "/project/node_modules/pkg": vfstest.Symlink("/host/pkg"), "/host/pkg/index.d.ts": "host content", }, true) - fileSystem, err := newSnapshotFileSystem(&SnapshotFileSystem{ - Kind: SnapshotFileSystemKindCache, + fileSystem, err := newRequestFileSystem(&RequestFileSystem{ + Kind: KindCache, Files: map[string]string{ "/project/node_modules/pkg/index.d.ts": "cached content", }, @@ -181,8 +235,8 @@ func TestSnapshotFileSystem(t *testing.T) { t.Run("layered cache adds changes and blocks removed entries", func(t *testing.T) { t.Parallel() host := vfstest.FromMap(map[string]string{}, true) - base, err := newSnapshotFileSystem(&SnapshotFileSystem{ - Kind: SnapshotFileSystemKindMemory, + base, err := newRequestFileSystem(&RequestFileSystem{ + Kind: KindMemory, Files: map[string]string{ "/keep.ts": "keep", "/change.ts": "old", @@ -194,8 +248,8 @@ func TestSnapshotFileSystem(t *testing.T) { }, host, "/") assert.NilError(t, err) - layered, err := newLayeredSnapshotFileSystem(&SnapshotFileSystem{ - Kind: SnapshotFileSystemKindCache, + layered, err := newLayeredRequestFileSystem(&RequestFileSystem{ + Kind: KindCache, Files: map[string]string{ "/change.ts": "new", "/added.ts": "added", @@ -204,7 +258,7 @@ func TestSnapshotFileSystem(t *testing.T) { "/becomes-file": "file", "/becomes-directory.ts/child.ts": "child", }, - Directories: map[string]SnapshotDirectoryEntries{ + Directories: map[string]RequestDirectoryEntries{ "/": {Files: []string{"added.ts", "becomes-file", "change.ts", "remove.ts"}, Directories: []string{"becomes-directory.ts", "removed-dir"}}, }, RemovedPaths: []string{"/remove.ts", "/removed-dir"}, @@ -241,20 +295,21 @@ func TestSnapshotFileSystem(t *testing.T) { t.Run("new layers override targets of inherited symlinks", func(t *testing.T) { t.Parallel() host := vfstest.FromMap(map[string]string{}, true) - base, err := newSnapshotFileSystem(&SnapshotFileSystem{ - Kind: SnapshotFileSystemKindMemory, + base, err := newRequestFileSystem(&RequestFileSystem{ + Kind: KindMemory, Files: map[string]string{ "/target/change.ts": "old", + "/target/keep.ts": "keep", "/target/remove.ts": "remove", }, - Symlinks: map[string]SnapshotSymlink{ + Symlinks: map[string]RequestSymlink{ "/link": {Target: "/target"}, }, }, host, "/") assert.NilError(t, err) - layered, err := newLayeredSnapshotFileSystem(&SnapshotFileSystem{ - Kind: SnapshotFileSystemKindCache, + layered, err := newLayeredRequestFileSystem(&RequestFileSystem{ + Kind: KindCache, Files: map[string]string{ "/target/change.ts": "new", "/target/added.ts": "added", @@ -271,25 +326,25 @@ func TestSnapshotFileSystem(t *testing.T) { assert.Equal(t, contents, "added") _, ok = layered.ReadFile("/link/remove.ts") assert.Assert(t, !ok) - assert.DeepEqual(t, layered.GetAccessibleEntries("/link").Files, []string{"added.ts", "change.ts"}) + assert.DeepEqual(t, layered.GetAccessibleEntries("/link").Files, []string{"added.ts", "change.ts", "keep.ts"}) }) t.Run("alias tombstones take precedence over inherited symlink targets", func(t *testing.T) { t.Parallel() host := vfstest.FromMap(map[string]string{}, true) - base, err := newSnapshotFileSystem(&SnapshotFileSystem{ - Kind: SnapshotFileSystemKindMemory, + base, err := newRequestFileSystem(&RequestFileSystem{ + Kind: KindMemory, Files: map[string]string{ "/target/file.ts": "old", }, - Symlinks: map[string]SnapshotSymlink{ + Symlinks: map[string]RequestSymlink{ "/link": {Target: "/target"}, }, }, host, "/") assert.NilError(t, err) - layered, err := newLayeredSnapshotFileSystem(&SnapshotFileSystem{ - Kind: SnapshotFileSystemKindCache, + layered, err := newLayeredRequestFileSystem(&RequestFileSystem{ + Kind: KindCache, Files: map[string]string{ "/target/file.ts": "new", }, @@ -305,22 +360,125 @@ func TestSnapshotFileSystem(t *testing.T) { assert.Equal(t, len(layered.GetAccessibleEntries("/link").Files), 0) }) + t.Run("compaction preserves overlays addressed through inherited symlinks", func(t *testing.T) { + t.Parallel() + host := vfstest.FromMap(map[string]string{}, true) + baseFS, err := newRequestFileSystem(&RequestFileSystem{ + Kind: KindMemory, + Files: map[string]string{ + "/target/remove.ts": "remove", + }, + Symlinks: map[string]RequestSymlink{ + "/link": {Target: "/target"}, + }, + }, host, "/") + assert.NilError(t, err) + base := getRequestFileSystem(baseFS) + + layeredFS, err := newLayeredRequestFileSystem(&RequestFileSystem{ + Kind: KindCache, + Files: map[string]string{}, + RemovedPaths: []string{"/link/remove.ts"}, + }, baseFS, "/") + assert.NilError(t, err) + layered := getRequestFileSystem(layeredFS) + + _, ok := layered.ReadFile("/link/remove.ts") + assert.Assert(t, !ok) + + layered.applyTo(base) + + _, ok = layered.ReadFile("/link/remove.ts") + assert.Assert(t, !ok) + }) + + t.Run("compaction removes tombstones from explicit listings", func(t *testing.T) { + t.Parallel() + host := vfstest.FromMap(map[string]string{}, true) + baseFS, err := newRequestFileSystem(&RequestFileSystem{ + Kind: KindMemory, + Files: map[string]string{ + "/dir/remove.ts": "remove", + }, + Directories: map[string]RequestDirectoryEntries{ + "/dir": {Files: []string{"remove.ts"}, Directories: []string{}}, + }, + }, host, "/") + assert.NilError(t, err) + base := getRequestFileSystem(baseFS) + + layeredFS, err := newLayeredRequestFileSystem(&RequestFileSystem{ + Kind: KindCache, + Files: map[string]string{}, + RemovedPaths: []string{"/dir/remove.ts"}, + }, baseFS, "/") + assert.NilError(t, err) + layered := getRequestFileSystem(layeredFS) + assert.Equal(t, len(layered.GetAccessibleEntries("/dir").Files), 0) + + layered.applyTo(base) + + assert.Equal(t, len(layered.GetAccessibleEntries("/dir").Files), 0) + }) + + t.Run("compaction allows recreating a path removed through an inherited symlink", func(t *testing.T) { + t.Parallel() + host := vfstest.FromMap(map[string]string{}, true) + baseFS, err := newRequestFileSystem(&RequestFileSystem{ + Kind: KindMemory, + Files: map[string]string{ + "/target/recreated.ts": "base", + }, + Symlinks: map[string]RequestSymlink{ + "/link": {Target: "/target"}, + }, + }, host, "/") + assert.NilError(t, err) + base := getRequestFileSystem(baseFS) + + removedFS, err := newLayeredRequestFileSystem(&RequestFileSystem{ + Kind: KindCache, + RemovedPaths: []string{"/link/recreated.ts"}, + }, baseFS, "/") + assert.NilError(t, err) + removed := getRequestFileSystem(removedFS) + removed.applyTo(base) + + recreatedFS, err := newLayeredRequestFileSystem(&RequestFileSystem{ + Kind: KindCache, + Files: map[string]string{ + "/link/recreated.ts": "recreated", + }, + }, removedFS, "/") + assert.NilError(t, err) + recreated := getRequestFileSystem(recreatedFS) + contents, ok := recreated.ReadFile("/link/recreated.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, "recreated") + + recreated.applyTo(removed) + + contents, ok = recreated.ReadFile("/link/recreated.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, "recreated") + }) + t.Run("files replacing inherited symlink target directories have empty listings", func(t *testing.T) { t.Parallel() host := vfstest.FromMap(map[string]string{}, true) - base, err := newSnapshotFileSystem(&SnapshotFileSystem{ - Kind: SnapshotFileSystemKindMemory, + base, err := newRequestFileSystem(&RequestFileSystem{ + Kind: KindMemory, Files: map[string]string{ "/target/item/child.ts": "child", }, - Symlinks: map[string]SnapshotSymlink{ + Symlinks: map[string]RequestSymlink{ "/link": {Target: "/target"}, }, }, host, "/") assert.NilError(t, err) - layered, err := newLayeredSnapshotFileSystem(&SnapshotFileSystem{ - Kind: SnapshotFileSystemKindCache, + layered, err := newLayeredRequestFileSystem(&RequestFileSystem{ + Kind: KindCache, Files: map[string]string{ "/target/item": "file", }, @@ -339,8 +497,8 @@ func TestSnapshotFileSystem(t *testing.T) { "/remove.ts": "host", "/removed-dir/gone.ts": "host", }, true)} - fileSystem, err := newSnapshotFileSystem(&SnapshotFileSystem{ - Kind: SnapshotFileSystemKindCache, + fileSystem, err := newRequestFileSystem(&RequestFileSystem{ + Kind: KindCache, Files: map[string]string{}, RemovedPaths: []string{"/remove.ts", "/removed-dir"}, }, base, "/") @@ -352,18 +510,116 @@ func TestSnapshotFileSystem(t *testing.T) { assert.Assert(t, base.SeenFiles.IsEmpty()) }) + t.Run("compacted cache layers retain host fallback", func(t *testing.T) { + t.Parallel() + host := vfstest.FromMap(map[string]string{ + "/host.ts": "host", + "/removed.ts": "host removed", + "/sealed/host.ts": "hidden from listing", + "/open/host.ts": "host listing", + "/open/layer-listed.ts": "host listed", + }, true) + baseFS, err := newRequestFileSystem(&RequestFileSystem{ + Kind: KindCache, + Files: map[string]string{ + "/inherited.ts": "inherited", + "/sealed/inherited.ts": "sealed inherited", + }, + Directories: map[string]RequestDirectoryEntries{ + "/sealed": {Files: []string{"inherited.ts"}, Directories: []string{}}, + }, + RemovedPaths: []string{"/removed.ts"}, + }, host, "/") + assert.NilError(t, err) + base := getRequestFileSystem(baseFS) + + layeredFS, err := newLayeredRequestFileSystem(&RequestFileSystem{ + Kind: KindCache, + Files: map[string]string{ + "/added.ts": "added", + "/sealed/added.ts": "sealed added", + }, + Directories: map[string]RequestDirectoryEntries{ + "/open": {Files: []string{"layer-listed.ts"}, Directories: []string{}}, + }, + }, baseFS, "/") + assert.NilError(t, err) + layered := getRequestFileSystem(layeredFS) + layered.applyTo(base) + assert.Assert(t, getRequestFileSystem(layered.baseFileSystem()) != base) + assert.Equal(t, layered.load().kind, KindCache) + + for path, expected := range map[string]string{ + "/host.ts": "host", + "/inherited.ts": "inherited", + "/added.ts": "added", + } { + contents, ok := layered.ReadFile(path) + assert.Assert(t, ok, path) + assert.Equal(t, contents, expected) + } + _, ok := layered.ReadFile("/removed.ts") + assert.Assert(t, !ok) + assert.DeepEqual(t, layered.GetAccessibleEntries("/sealed").Files, []string{"added.ts", "inherited.ts"}) + assert.DeepEqual(t, layered.GetAccessibleEntries("/open").Files, []string{"host.ts", "layer-listed.ts"}) + }) + + t.Run("compacting a cache layer over memory produces memory", func(t *testing.T) { + t.Parallel() + host := vfstest.FromMap(map[string]string{ + "/host.ts": "host", + }, true) + baseFS, err := newRequestFileSystem(&RequestFileSystem{ + Kind: KindMemory, + Files: map[string]string{ + "/target/inherited.ts": "inherited", + }, + Directories: map[string]RequestDirectoryEntries{ + "/target": {Files: []string{"inherited.ts"}, Directories: []string{}}, + }, + Symlinks: map[string]RequestSymlink{ + "/link": {Target: "/target"}, + }, + }, host, "/") + assert.NilError(t, err) + base := getRequestFileSystem(baseFS) + + layeredFS, err := newLayeredRequestFileSystem(&RequestFileSystem{ + Kind: KindCache, + Files: map[string]string{ + "/target/added.ts": "added", + }, + }, baseFS, "/") + assert.NilError(t, err) + layered := getRequestFileSystem(layeredFS) + layered.applyTo(base) + assert.Equal(t, layered.load().kind, KindMemory) + assert.Assert(t, getRequestFileSystem(layered.baseFileSystem()) != base) + + for path, expected := range map[string]string{ + "/link/inherited.ts": "inherited", + "/link/added.ts": "added", + } { + contents, ok := layered.ReadFile(path) + assert.Assert(t, ok, path) + assert.Equal(t, contents, expected) + } + _, ok := layered.ReadFile("/host.ts") + assert.Assert(t, !ok) + }) + t.Run("memory routes explicit host symlinks to the host only through the link", func(t *testing.T) { t.Parallel() base := &trackingvfs.FS{Inner: vfstest.FromMap(map[string]string{ "/host/node_modules/pkg/index.d.ts": "export declare const hostValue: string;", "/host/outside.ts": "outside", }, true)} - fileSystem, err := newSnapshotFileSystem(&SnapshotFileSystem{ - Kind: SnapshotFileSystemKindMemory, + fileSystem, err := newRequestFileSystem(&RequestFileSystem{ + Kind: KindMemory, Files: map[string]string{ "/project/index.ts": `import { hostValue } from "pkg";`, }, - Symlinks: map[string]SnapshotSymlink{ + Symlinks: map[string]RequestSymlink{ "/project/node_modules": {Target: "/host/node_modules", Host: true}, }, }, base, "/") @@ -390,18 +646,18 @@ func TestSnapshotFileSystem(t *testing.T) { host := &trackingvfs.FS{Inner: vfstest.FromMap(map[string]string{ "/host/pkg/index.d.ts": "host", }, true)} - base, err := newSnapshotFileSystem(&SnapshotFileSystem{ - Kind: SnapshotFileSystemKindMemory, + base, err := newRequestFileSystem(&RequestFileSystem{ + Kind: KindMemory, Files: map[string]string{ "/memory.ts": "memory", }, }, host, "/") assert.NilError(t, err) - layered, err := newLayeredSnapshotFileSystem(&SnapshotFileSystem{ - Kind: SnapshotFileSystemKindCache, + layered, err := newLayeredRequestFileSystem(&RequestFileSystem{ + Kind: KindCache, Files: map[string]string{}, - Symlinks: map[string]SnapshotSymlink{ + Symlinks: map[string]RequestSymlink{ "/project/pkg": {Target: "/host/pkg", Host: true}, }, }, base, "/") @@ -424,34 +680,34 @@ func TestSnapshotFileSystem(t *testing.T) { t.Parallel() base := vfstest.FromMap(map[string]string{}, false) - _, err := newSnapshotFileSystem(&SnapshotFileSystem{ - Kind: SnapshotFileSystemKindMemory, + _, err := newRequestFileSystem(&RequestFileSystem{ + Kind: KindMemory, Files: map[string]string{ `C:\Repo\file.ts`: "first", `c:/repo/file.ts`: "second", }, }, base, `C:\Workspace`) - assert.ErrorContains(t, err, "duplicate snapshot filesystem file path") + assert.ErrorContains(t, err, "duplicate request filesystem file path") - _, err = newSnapshotFileSystem(&SnapshotFileSystem{ - Kind: SnapshotFileSystemKindMemory, + _, err = newRequestFileSystem(&RequestFileSystem{ + Kind: KindMemory, Files: map[string]string{}, - Directories: map[string]SnapshotDirectoryEntries{ + Directories: map[string]RequestDirectoryEntries{ `C:\Repo`: {}, `c:/repo/.`: {}, }, }, base, `C:\Workspace`) - assert.ErrorContains(t, err, "duplicate snapshot filesystem directory path") + assert.ErrorContains(t, err, "duplicate request filesystem directory path") - _, err = newSnapshotFileSystem(&SnapshotFileSystem{ - Kind: SnapshotFileSystemKindMemory, + _, err = newRequestFileSystem(&RequestFileSystem{ + Kind: KindMemory, Files: map[string]string{}, - Symlinks: map[string]SnapshotSymlink{ + Symlinks: map[string]RequestSymlink{ `C:\Repo\link`: {Target: `C:\Target`}, `c:/repo/link`: {Target: `C:\Other`}, }, }, base, `C:\Workspace`) - assert.ErrorContains(t, err, "duplicate snapshot filesystem symlink path") + assert.ErrorContains(t, err, "duplicate request filesystem symlink path") }) t.Run("symlink cycles are treated as missing", func(t *testing.T) { @@ -459,10 +715,10 @@ func TestSnapshotFileSystem(t *testing.T) { base := &trackingvfs.FS{Inner: vfstest.FromMap(map[string]string{ "/host.ts": "host", }, true)} - fileSystem, err := newSnapshotFileSystem(&SnapshotFileSystem{ - Kind: SnapshotFileSystemKindMemory, + fileSystem, err := newRequestFileSystem(&RequestFileSystem{ + Kind: KindMemory, Files: map[string]string{}, - Symlinks: map[string]SnapshotSymlink{ + Symlinks: map[string]RequestSymlink{ "/a": {Target: "/b"}, "/b": {Target: "/a"}, }, @@ -479,12 +735,12 @@ func TestSnapshotFileSystem(t *testing.T) { t.Run("posix relative symlink targets resolve from the link directory", func(t *testing.T) { t.Parallel() base := &trackingvfs.FS{Inner: vfstest.FromMap(map[string]string{}, true)} - fileSystem, err := newSnapshotFileSystem(&SnapshotFileSystem{ - Kind: SnapshotFileSystemKindMemory, + fileSystem, err := newRequestFileSystem(&RequestFileSystem{ + Kind: KindMemory, Files: map[string]string{ "/packages/pkg/index.d.ts": "export declare const value: number;", }, - Symlinks: map[string]SnapshotSymlink{ + Symlinks: map[string]RequestSymlink{ "/project/pkg": {Target: "../packages/pkg"}, }, }, base, `C:\Workspace`) @@ -500,13 +756,13 @@ func TestSnapshotFileSystem(t *testing.T) { t.Run("vscode document URI paths support listings symlinks and tombstones", func(t *testing.T) { t.Parallel() base := &trackingvfs.FS{Inner: vfstest.FromMap(map[string]string{}, true)} - fileSystem, err := newSnapshotFileSystem(&SnapshotFileSystem{ - Kind: SnapshotFileSystemKindMemory, + fileSystem, err := newRequestFileSystem(&RequestFileSystem{ + Kind: KindMemory, Files: map[string]string{ "vscode-remote://ssh-remote+host/workspace/src/index.ts": "index", "vscode-remote://ssh-remote+host/workspace/packages/pkg/a.ts": "package", }, - Symlinks: map[string]SnapshotSymlink{ + Symlinks: map[string]RequestSymlink{ "vscode-remote://ssh-remote+host/workspace/src/pkg": {Target: "../packages/pkg"}, }, RemovedPaths: []string{ @@ -545,15 +801,15 @@ func TestSnapshotFileSystem(t *testing.T) { base := &trackingvfs.FS{Inner: vfstest.FromMap(map[string]string{ "C:/Host/outside.ts": "outside", }, false)} - fileSystem, err := newSnapshotFileSystem(&SnapshotFileSystem{ - Kind: SnapshotFileSystemKindMemory, + fileSystem, err := newRequestFileSystem(&RequestFileSystem{ + Kind: KindMemory, Files: map[string]string{ `C:\Repo\Packages\Pkg\Index.d.ts`: "export declare const windowsValue: number;", }, - Directories: map[string]SnapshotDirectoryEntries{ + Directories: map[string]RequestDirectoryEntries{ `C:\Repo\Project\node_modules`: {Files: []string{}, Directories: []string{"pkg"}}, }, - Symlinks: map[string]SnapshotSymlink{ + Symlinks: map[string]RequestSymlink{ `C:\Repo\Project\node_modules\PKG`: {Target: `..\..\Packages\Pkg`}, `C:\Repo\Project\Current.d.ts`: {Target: `..\Packages\Pkg\Index.d.ts`}, }, @@ -582,12 +838,12 @@ func TestSnapshotFileSystem(t *testing.T) { t.Run("case insensitive symlink matching handles unicode byte length changes", func(t *testing.T) { t.Parallel() base := &trackingvfs.FS{Inner: vfstest.FromMap(map[string]string{}, false)} - fileSystem, err := newSnapshotFileSystem(&SnapshotFileSystem{ - Kind: SnapshotFileSystemKindMemory, + fileSystem, err := newRequestFileSystem(&RequestFileSystem{ + Kind: KindMemory, Files: map[string]string{ "C:/Repo/target.ts": "target", }, - Symlinks: map[string]SnapshotSymlink{ + Symlinks: map[string]RequestSymlink{ "C:/Repo/K": {Target: "C:/Repo/target.ts"}, }, }, base, "C:/Repo") @@ -598,13 +854,13 @@ func TestSnapshotFileSystem(t *testing.T) { assert.Equal(t, contents, "target") }) - t.Run("snapshot filesystems are immutable and cache mutations write through to the host", func(t *testing.T) { + t.Run("request filesystems are immutable and cache mutations write through to the host", func(t *testing.T) { t.Parallel() host := vfstest.FromMap(map[string]string{ "/host.ts": "host", }, true) - memory, err := newSnapshotFileSystem(&SnapshotFileSystem{ - Kind: SnapshotFileSystemKindMemory, + memory, err := newRequestFileSystem(&RequestFileSystem{ + Kind: KindMemory, Files: map[string]string{ "/src/a.ts": "a", }, @@ -617,8 +873,8 @@ func TestSnapshotFileSystem(t *testing.T) { assert.Assert(t, ok) assert.Equal(t, contents, "a") - cache, err := newLayeredSnapshotFileSystem(&SnapshotFileSystem{ - Kind: SnapshotFileSystemKindCache, + cache, err := newLayeredRequestFileSystem(&RequestFileSystem{ + Kind: KindCache, Files: map[string]string{}, }, memory, "/") assert.NilError(t, err) @@ -636,13 +892,13 @@ func TestSnapshotFileSystem(t *testing.T) { base := &trackingvfs.FS{Inner: vfstest.FromMap(map[string]string{ "C:/Host/node_modules/host-pkg/index.d.ts": "export declare const hostValue: boolean;", }, false)} - fileSystem, err := newSnapshotFileSystem(&SnapshotFileSystem{ - Kind: SnapshotFileSystemKindMemory, + fileSystem, err := newRequestFileSystem(&RequestFileSystem{ + Kind: KindMemory, Files: map[string]string{ `C:\Repo\Packages\windows-pkg\index.d.ts`: "export declare const windowsValue: number;", "/repo/packages/posix-pkg/index.d.ts": "export declare const posixValue: string;", }, - Symlinks: map[string]SnapshotSymlink{ + Symlinks: map[string]RequestSymlink{ // Cross between drive-letter and POSIX roots in both directions. `C:\Repo\Project\node_modules\posix-pkg`: {Target: "/repo/packages/posix-pkg"}, "/repo/project/node_modules/windows-pkg": {Target: `C:\Repo\Packages\windows-pkg`}, @@ -682,134 +938,3 @@ func TestSnapshotFileSystem(t *testing.T) { assert.Assert(t, base.SeenFiles.Has("C:/Host/node_modules/host-pkg/index.d.ts")) }) } - -func TestUpdateSnapshotUsesMemoryFileSystem(t *testing.T) { - t.Parallel() - - projectSession, _ := projecttestutil.Setup(map[string]any{ - "/host.ts": "host", - }) - defer projectSession.Close() - session := NewSession(projectSession, nil) - defer session.Close() - - response, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ - OpenProjects: []DocumentIdentifier{{FileName: "/tsconfig.json"}}, - FileSystem: &SnapshotFileSystem{ - Kind: SnapshotFileSystemKindMemory, - Files: map[string]string{ - "/tsconfig.json": `{ "compilerOptions": { "noLib": true }, "files": ["src/index.ts"] }`, - "/src/index.ts": `export const value = "memory";`, - "/src/other.ts": `export const other = true;`, - }, - }, - }) - assert.NilError(t, err) - assert.Equal(t, len(response.Projects), 1) - assert.Equal(t, response.Projects[0].ConfigFileName, "/tsconfig.json") - - snapshot := session.snapshots[response.Snapshot].snapshot - contents, ok := snapshot.ReadFile("/src/index.ts") - assert.Assert(t, ok) - assert.Equal(t, contents, `export const value = "memory";`) - _, ok = snapshot.ReadFile("/host.ts") - assert.Assert(t, !ok) - - // Carrying the same filesystem forward without a delta must preserve - // incremental state instead of forcing a full program rebuild. - program := snapshot.ProjectCollection.GetProjectByPath(tspath.Path("/tsconfig.json")).GetProgram() - unchanged, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{Snapshot: response.Snapshot}) - assert.NilError(t, err) - unchangedSnapshot := session.snapshots[unchanged.Snapshot].snapshot - assert.Assert(t, unchangedSnapshot.ProjectCollection.GetProjectByPath(tspath.Path("/tsconfig.json")).GetProgram() == program) - response = unchanged - - // Supplying a new filesystem replaces inherited snapshot disk caches even - // when the caller does not redundantly list every file in FileChanges. - response, err = session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ - FileSystem: &SnapshotFileSystem{ - Kind: SnapshotFileSystemKindMemory, - Files: map[string]string{ - "/tsconfig.json": `{ "compilerOptions": { "noLib": true }, "files": ["src/index.ts", "src/other.ts"] }`, - "/src/index.ts": `export const value = "updated";`, - "/src/other.ts": `export const other = true;`, - }, - }, - }) - assert.NilError(t, err) - snapshot = session.snapshots[response.Snapshot].snapshot - contents, ok = snapshot.ReadFile("/src/index.ts") - assert.Assert(t, ok) - assert.Equal(t, contents, `export const value = "updated";`) - - // Temporary snapshots retain the base snapshot's supplied filesystem for - // every file other than the temporary overlay. - temporary, err := session.handleUpdateTemporarySnapshot(context.Background(), &UpdateTemporarySnapshotParams{ - Snapshot: response.Snapshot, - File: DocumentIdentifier{FileName: "/src/index.ts"}, - NewText: `export const value = "temporary";`, - }) - assert.NilError(t, err) - temporarySnapshot := session.snapshots[temporary.Snapshot].snapshot - contents, ok = temporarySnapshot.ReadFile("/src/index.ts") - assert.Assert(t, ok) - assert.Equal(t, contents, `export const value = "temporary";`) - contents, ok = temporarySnapshot.ReadFile("/src/other.ts") - assert.Assert(t, ok) - assert.Equal(t, contents, `export const other = true;`) -} - -func TestSnapshotUpdateMemoryFileSystemIsTotal(t *testing.T) { - t.Parallel() - - projectSession, _ := projecttestutil.Setup(map[string]any{ - "/host.ts": "host", - }) - defer projectSession.Close() - session := NewSession(projectSession, nil) - defer session.Close() - - base, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{}) - assert.NilError(t, err) - replaced, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ - Snapshot: base.Snapshot, - FileSystem: &SnapshotFileSystem{ - Kind: SnapshotFileSystemKindMemory, - Files: map[string]string{ - "/memory.ts": "memory", - }, - }, - }) - assert.NilError(t, err) - - snapshot := session.snapshots[replaced.Snapshot].snapshot - contents, ok := snapshot.ReadFile("/memory.ts") - assert.Assert(t, ok) - assert.Equal(t, contents, "memory") - _, ok = snapshot.ReadFile("/host.ts") - assert.Assert(t, !ok) -} - -func TestSnapshotUpdateCarriesHostFileSystemWithoutOverride(t *testing.T) { - t.Parallel() - - projectSession, _ := projecttestutil.Setup(map[string]any{ - "/tsconfig.json": `{ "compilerOptions": { "noLib": true }, "files": ["index.ts"] }`, - "/index.ts": `export const value = true;`, - }) - defer projectSession.Close() - session := NewSession(projectSession, nil) - defer session.Close() - - base, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ - OpenProjects: []DocumentIdentifier{{FileName: "/tsconfig.json"}}, - }) - assert.NilError(t, err) - baseSnapshot := session.snapshots[base.Snapshot].snapshot - program := baseSnapshot.ProjectCollection.GetProjectByPath(tspath.Path("/tsconfig.json")).GetProgram() - - updated, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{Snapshot: base.Snapshot}) - assert.NilError(t, err) - updatedSnapshot := session.snapshots[updated.Snapshot].snapshot - assert.Assert(t, updatedSnapshot.ProjectCollection.GetProjectByPath(tspath.Path("/tsconfig.json")).GetProgram() == program) -} diff --git a/tsc/internal/api/requestfilesystem/requestfilesystemhandle.go b/tsc/internal/api/requestfilesystem/requestfilesystemhandle.go new file mode 100644 index 0000000000000..f14a85381a3be --- /dev/null +++ b/tsc/internal/api/requestfilesystem/requestfilesystemhandle.go @@ -0,0 +1,233 @@ +package requestfilesystem + +import ( + "sync" + "sync/atomic" + "time" + + "github.com/microsoft/TypeScript/tsc/internal/project" + "github.com/microsoft/TypeScript/tsc/internal/vfs" +) + +// This can be replaced with per-handle mutexes if contention is high in practice. +var requestFileSystemDependenciesMu sync.Mutex + +// Handle is a request filesystem whose backing layers can be compacted as snapshots are released. +type Handle struct { + value atomic.Pointer[requestFileSystem] + dependents map[*Handle]struct{} +} + +func (h *Handle) load() *requestFileSystem { + return h.value.Load() +} + +func (h *Handle) store(value *requestFileSystem) { + h.value.Store(value) +} + +func (h *Handle) initialize(value requestFileSystem) { + if h.Initialized() { + panic("request filesystem handle already initialized") + } + h.store(&value) + h.registerWithBase() +} + +// Initialized reports whether the handle contains a request filesystem. +func (h *Handle) Initialized() bool { + return h.load() != nil +} + +func (h *Handle) initializeFromRequest(params *RequestFileSystem, base vfs.FS, currentDirectory string) error { + value, err := newRequestFileSystemWorker(params, base, currentDirectory, false) + if err != nil { + return err + } + h.initialize(*value) + return nil +} + +func (h *Handle) initializeLayered(params *RequestFileSystem, base vfs.FS, currentDirectory string) error { + if params.Kind != KindCache { + return h.initializeFromRequest(params, base, currentDirectory) + } + value, err := newRequestFileSystemWorker(params, base, currentDirectory, true) + if err != nil { + return err + } + h.initialize(*value) + return nil +} + +// InitializeForUpdate initializes the handle from a snapshot update request and its optional base. +func (h *Handle) InitializeForUpdate(params *RequestFileSystem, base *Handle, host vfs.FS, currentDirectory string, fileChanges *project.FileChangeSummary) error { + if params == nil { + if base != nil { + h.CloneFrom(base) + } + return nil + } + baseFS := host + if base != nil { + baseFS = base + } + if base != nil && params.Kind == KindCache { + addFileChanges(fileChanges, params, baseFS, currentDirectory) + return h.initializeLayered(params, baseFS, currentDirectory) + } + return h.initializeFromRequest(params, baseFS, currentDirectory) +} + +// FS returns this handle as a filesystem, or a nil interface when it is uninitialized. +func (h *Handle) FS() vfs.FS { + if !h.Initialized() { + return nil + } + return h +} + +// CloneFrom initializes a zero-value handle with an independently managed copy of source. +func (h *Handle) CloneFrom(source *Handle) { + if source == nil { + return + } + if h.Initialized() { + panic("request filesystem handle already initialized") + } + requestFileSystemDependenciesMu.Lock() + defer requestFileSystemDependenciesMu.Unlock() + value := *source.load() + h.store(&value) + h.registerWithBaseLocked() +} + +func (h *Handle) applyToLocked(base *Handle) { + h.unregisterFromBaseLocked() + value := h.load().applyTo(*base.load()) + h.store(&value) + h.registerWithBaseLocked() +} + +// Release removes this handle from the dependency graph and compacts live dependents. +func (h *Handle) Release() { + if h == nil { + return + } + if h.load() == nil { + return + } + requestFileSystemDependenciesMu.Lock() + defer requestFileSystemDependenciesMu.Unlock() + if h.load() == nil { + return + } + h.compactDependentsLocked() + h.unregisterFromBaseLocked() +} + +func (h *Handle) registerWithBase() { + requestFileSystemDependenciesMu.Lock() + defer requestFileSystemDependenciesMu.Unlock() + h.registerWithBaseLocked() +} + +func (h *Handle) registerWithBaseLocked() { + base := h.layeredBase() + if base == nil { + return + } + if base.dependents == nil { + base.dependents = make(map[*Handle]struct{}) + } + base.dependents[h] = struct{}{} +} + +func (h *Handle) unregisterFromBaseLocked() { + if base := h.layeredBase(); base != nil { + delete(base.dependents, h) + } +} + +func (h *Handle) layeredBase() *Handle { + value := h.load() + if !value.layered { + return nil + } + return getRequestFileSystem(value.baseFileSystem()) +} + +func (h *Handle) compactDependentsLocked() { + for dependent := range h.dependents { + dependent.applyToLocked(h) + dependent.compactDependentsLocked() + h.compactDependentsLocked() + return + } +} + +func (h *Handle) baseFileSystem() vfs.FS { + return h.load().baseFileSystem() +} + +// HasMemoryFileSystem reports whether any backing layer is a total memory filesystem. +func (h *Handle) HasMemoryFileSystem() bool { + for h != nil { + value := h.load() + if value.kind == KindMemory { + return true + } + h = getRequestFileSystem(value.baseFileSystem()) + } + return false +} + +func (h *Handle) UseCaseSensitiveFileNames() bool { + return h.load().UseCaseSensitiveFileNames() +} + +func (h *Handle) ReadFile(fileName string) (string, bool) { + return h.load().ReadFile(fileName) +} + +func (h *Handle) FileExists(fileName string) bool { + return h.load().FileExists(fileName) +} + +func (h *Handle) DirectoryExists(directoryName string) bool { + return h.load().DirectoryExists(directoryName) +} + +func (h *Handle) GetAccessibleEntries(directoryName string) vfs.Entries { + return h.load().GetAccessibleEntries(directoryName) +} + +func (h *Handle) Realpath(path string) string { + return h.load().Realpath(path) +} + +func (h *Handle) WriteFile(fileName string, data string) error { + return h.load().WriteFile(fileName, data) +} + +func (h *Handle) AppendFile(fileName string, data string) error { + return h.load().AppendFile(fileName, data) +} + +func (h *Handle) Remove(path string) error { + return h.load().Remove(path) +} + +func (h *Handle) Chtimes(path string, aTime time.Time, mTime time.Time) error { + return h.load().Chtimes(path, aTime, mTime) +} + +func (h *Handle) Stat(path string) vfs.FileInfo { + return h.load().Stat(path) +} + +func (h *Handle) WalkDir(root string, walkFn vfs.WalkDirFunc) error { + return h.load().WalkDir(root, walkFn) +} + +var _ vfs.FS = (*Handle)(nil) diff --git a/tsc/internal/api/session.go b/tsc/internal/api/session.go index 970edc8551b34..ab2b761c38f08 100644 --- a/tsc/internal/api/session.go +++ b/tsc/internal/api/session.go @@ -13,6 +13,7 @@ import ( "sync/atomic" "github.com/microsoft/TypeScript/tsc/internal/api/encoder" + "github.com/microsoft/TypeScript/tsc/internal/api/requestfilesystem" "github.com/microsoft/TypeScript/tsc/internal/ast" "github.com/microsoft/TypeScript/tsc/internal/astnav" "github.com/microsoft/TypeScript/tsc/internal/checker" @@ -34,7 +35,6 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/transpile" "github.com/microsoft/TypeScript/tsc/internal/tsoptions" "github.com/microsoft/TypeScript/tsc/internal/tspath" - "github.com/microsoft/TypeScript/tsc/internal/vfs" ) var sessionIDCounter atomic.Uint64 @@ -44,8 +44,9 @@ var sessionIDCounter atomic.Uint64 // Multiple clients may hold references to the same snapshot via ref counting; // the registries are cleaned up when refCount reaches zero. type snapshotData struct { - snapshot *project.Snapshot - refCount int + snapshot *project.Snapshot + fileSystem requestfilesystem.Handle + refCount int // Symbol IDs come from ast.GetSymbolId, a global atomic counter, so the same // *ast.Symbol pointer always has the same unique ID across all projects in the @@ -510,14 +511,58 @@ func (s *Session) releaseSnapshot(handle SnapshotID) error { return fmt.Errorf("%w: snapshot %d not found", ErrClientError, handle) } sd.refCount-- - if sd.refCount <= 0 { - delete(s.snapshots, handle) - sd.snapshot.Deref(s.projectSession) + if sd.refCount > 0 { + s.snapshotsMu.Unlock() + return nil } + delete(s.snapshots, snapshotHandle(sd.snapshot)) s.snapshotsMu.Unlock() + + sd.snapshot.Deref(s.projectSession) + sd.fileSystem.Release() return nil } +func newSnapshotData() *snapshotData { + sd := &snapshotData{ + refCount: 1, + symbolRegistry: make(map[SymbolID]*ast.Symbol), + symbolCanonicalProjects: make(map[SymbolID]ProjectID), + projectRegistries: make(map[ProjectID]*projectRegistryData), + } + return sd +} + +func (s *Session) registerSnapshotData(sd *snapshotData, updateLatest bool) (SnapshotID, *snapshotData) { + handle := snapshotHandle(sd.snapshot) + s.snapshotsMu.Lock() + existingSD := s.snapshots[handle] + if existingSD != nil { + existingSD.refCount++ + } else { + s.snapshots[handle] = sd + } + var previous *snapshotData + if updateLatest { + previous = s.snapshots[s.latestSnapshot] + s.latestSnapshot = handle + } + s.snapshotsMu.Unlock() + + if existingSD != nil { + sd.snapshot.Deref(s.projectSession) + sd.fileSystem.Release() + } + return handle, previous +} + +func (sd *snapshotData) fileSystemHandle() *requestfilesystem.Handle { + if !sd.fileSystem.Initialized() { + return nil + } + return &sd.fileSystem +} + // checkerSetup holds the common context needed by handlers that require a type checker. type checkerSetup struct { sd *snapshotData @@ -1013,30 +1058,16 @@ func (s *Session) handleUpdateSnapshot(ctx context.Context, params *UpdateSnapsh fileChanges := s.toFileChangeSummary(params.FileChanges) apiRequest := &project.APISnapshotRequest{} - baseFS := s.projectSession.FS() + var baseRequestFileSystem *requestfilesystem.Handle if baseSD != nil { - baseFS = baseSD.snapshot.FileSystem() - if baseSD.snapshot.HasFileSystemOverride() { - apiRequest.FileSystem = baseFS - } + baseRequestFileSystem = baseSD.fileSystemHandle() } - if params.FileSystem != nil { - var fs vfs.FS - var err error - if baseSD == nil { - fs, err = newSnapshotFileSystem(params.FileSystem, baseFS, s.projectSession.GetCurrentDirectory()) - } else { - if params.FileSystem.Kind == SnapshotFileSystemKindCache { - s.addLayeredFileSystemChanges(&fileChanges, params.FileSystem, baseFS) - } - fs, err = newLayeredSnapshotFileSystem(params.FileSystem, baseFS, s.projectSession.GetCurrentDirectory()) - } - if err != nil { - return nil, fmt.Errorf("%w: %w", ErrClientError, err) - } - apiRequest.FileSystem = fs - apiRequest.ReplaceFileSystem = true + sd := newSnapshotData() + if err := sd.fileSystem.InitializeForUpdate(params.FileSystem, baseRequestFileSystem, s.projectSession.FS(), s.projectSession.GetCurrentDirectory(), &fileChanges); err != nil { + return nil, fmt.Errorf("%w: %w", ErrClientError, err) } + apiRequest.FileSystem = sd.fileSystem.FS() + apiRequest.ReplaceFileSystem = params.FileSystem != nil // Open projects: only take a new ref for projects we aren't already holding open. var openedProjects []tspath.Path @@ -1105,8 +1136,10 @@ func (s *Session) handleUpdateSnapshot(ctx context.Context, params *UpdateSnapsh if err != nil { // APIUpdate returns a ref'd snapshot even on error; release it. snapshot.Deref(s.projectSession) + sd.fileSystem.Release() return nil, fmt.Errorf("%w: failed to update snapshot: %w", ErrClientError, err) } + sd.snapshot = snapshot // Commit ref tracking now that the update succeeded. for _, configPath := range openedProjects { @@ -1122,31 +1155,8 @@ func (s *Session) handleUpdateSnapshot(ctx context.Context, params *UpdateSnapsh s.openFiles.Delete(path) } - // Create or ref-count snapshot data, then atomically read the previous latest - // snapshot (the diff base) and advance latestSnapshot to the new handle. - // If the same snapshot ID is returned (no changes), we increment the ref count - // so each client-side Snapshot can be disposed independently. - handle := snapshotHandle(snapshot) - s.snapshotsMu.Lock() - sd, exists := s.snapshots[handle] - if exists { - // Same snapshot already stored — release the caller's ref since - // the stored snapshot already has one, and bump the API refcount. - snapshot.Deref(s.projectSession) - sd.refCount++ - } else { - sd = &snapshotData{ - snapshot: snapshot, - refCount: 1, - symbolRegistry: make(map[SymbolID]*ast.Symbol), - symbolCanonicalProjects: make(map[SymbolID]ProjectID), - projectRegistries: make(map[ProjectID]*projectRegistryData), - } - s.snapshots[handle] = sd - } - prevSD := s.snapshots[s.latestSnapshot] - s.latestSnapshot = handle - s.snapshotsMu.Unlock() + // Atomically advance latestSnapshot and retain duplicate handles independently. + handle, prevSD := s.registerSnapshotData(sd, true) // Build projects list projects := snapshot.ProjectCollection.Projects() @@ -1182,29 +1192,17 @@ func (s *Session) handleUpdateTemporarySnapshot(ctx context.Context, params *Upd defer func() { _ = s.releaseSnapshot(params.Snapshot) }() uri := params.File.ToURI(s.projectSession.GetCurrentDirectory()) + sd := newSnapshotData() + sd.fileSystem.CloneFrom(baseSD.fileSystemHandle()) - snapshot, err := s.projectSession.APIUpdateTemporary(ctx, baseSD.snapshot, uri, params.NewText) + snapshot, err := s.projectSession.APIUpdateTemporary(ctx, baseSD.snapshot, sd.fileSystem.FS(), uri, params.NewText) if err != nil { + sd.fileSystem.Release() return nil, fmt.Errorf("%w: failed to update temporary snapshot: %w", ErrClientError, err) } + sd.snapshot = snapshot - handle := snapshotHandle(snapshot) - s.snapshotsMu.Lock() - sd, exists := s.snapshots[handle] - if exists { - snapshot.Deref(s.projectSession) - sd.refCount++ - } else { - sd = &snapshotData{ - snapshot: snapshot, - refCount: 1, - symbolRegistry: make(map[SymbolID]*ast.Symbol), - symbolCanonicalProjects: make(map[SymbolID]ProjectID), - projectRegistries: make(map[ProjectID]*projectRegistryData), - } - s.snapshots[handle] = sd - } - s.snapshotsMu.Unlock() + handle, _ := s.registerSnapshotData(sd, false) // Build projects list projects := snapshot.ProjectCollection.Projects() @@ -1238,6 +1236,7 @@ func (s *Session) handleCreateProgram(ctx context.Context, params *CreateProgram } var baseSnapshot *project.Snapshot + var baseRequestFileSystem *requestfilesystem.Handle if params.BaseSnapshot != 0 { baseSD, err := s.retainSnapshotData(params.BaseSnapshot) if err != nil { @@ -1245,6 +1244,7 @@ func (s *Session) handleCreateProgram(ctx context.Context, params *CreateProgram } defer func() { _ = s.releaseSnapshot(params.BaseSnapshot) }() baseSnapshot = baseSD.snapshot + baseRequestFileSystem = baseSD.fileSystemHandle() } var oldProject *project.Project @@ -1258,19 +1258,21 @@ func (s *Session) handleCreateProgram(ctx context.Context, params *CreateProgram if baseSnapshot == nil { baseSnapshot = oldSD.snapshot + baseRequestFileSystem = oldSD.fileSystemHandle() } oldProject, err = oldSD.getProject(params.OldProgram.Project) if err != nil { return nil, err } } + sd := newSnapshotData() + sd.fileSystem.CloneFrom(baseRequestFileSystem) fileChanges := s.toFileChangeSummary(params.FileChanges) if params.BaseSnapshot != 0 && params.OldProgram != nil && params.OldProgram.Snapshot != params.BaseSnapshot && fileChanges.IsEmpty() { fileChanges.InvalidateAll = true fileChanges.IncludesWatchChangeOutsideNodeModules = true } - snapshot := s.projectSession.APICreateProgram( ctx, rootFileNames, @@ -1279,31 +1281,18 @@ func (s *Session) handleCreateProgram(ctx context.Context, params *CreateProgram core.Map(params.CreateProgramOptions.ConfigFileParsingDiagnostics, func(d *DiagnosticResponse) *ast.Diagnostic { return d.ToDiagnostic() }), baseSnapshot, oldProject, + sd.fileSystem.FS(), fileChanges, ) project := snapshot.ProjectCollection.InferredProject() if project == nil { snapshot.Deref(s.projectSession) + sd.fileSystem.Release() return nil, fmt.Errorf("%w: failed to create synthetic project", ErrClientError) } + sd.snapshot = snapshot - handle := snapshotHandle(snapshot) - s.snapshotsMu.Lock() - if sd, exists := s.snapshots[handle]; exists { - // Same snapshot already stored: use the existing retained ref and only bump API refcount. - snapshot.Deref(s.projectSession) - sd.refCount++ - } else { - sd = &snapshotData{ - snapshot: snapshot, - refCount: 1, - symbolRegistry: make(map[SymbolID]*ast.Symbol), - symbolCanonicalProjects: make(map[SymbolID]ProjectID), - projectRegistries: make(map[ProjectID]*projectRegistryData), - } - s.snapshots[handle] = sd - } - s.snapshotsMu.Unlock() + handle, _ := s.registerSnapshotData(sd, false) return &CreateProgramResponse{ Snapshot: handle, @@ -2833,7 +2822,7 @@ func (s *Session) handleEmit(ctx context.Context, params *EmitParams) (*EmitResp if err != nil { return nil, err } - if snapshotFileSystem := getSnapshotFileSystem(sd.snapshot.FileSystem()); snapshotFileSystem != nil && snapshotFileSystem.kind == SnapshotFileSystemKindMemory { + if fileSystem := sd.fileSystemHandle(); fileSystem != nil && fileSystem.HasMemoryFileSystem() { outputFiles = make(map[string]string) var outputMu sync.Mutex options.WriteFile = func(fileName string, text string, _ *compiler.WriteFileData) error { @@ -3815,6 +3804,7 @@ func (s *Session) Close() { defer s.snapshotsMu.Unlock() for handle, sd := range s.snapshots { sd.snapshot.Deref(s.projectSession) + sd.fileSystem.Release() delete(s.snapshots, handle) } } @@ -3889,49 +3879,6 @@ func (s *Session) toFileChangeSummary(changes *APIFileChanges) project.FileChang return summary } -func (s *Session) addLayeredFileSystemChanges(summary *project.FileChangeSummary, fileSystem *SnapshotFileSystem, baseFS vfs.FS) { - cwd := s.projectSession.GetCurrentDirectory() - baseSnapshotFS := getSnapshotFileSystem(baseFS) - addChange := func(fileName string, deleted bool) { - uri := lsconv.FileNameToDocumentURI(fileName) - if deleted { - if baseFS.FileExists(fileName) { - summary.Deleted.Add(uri) - } - return - } - if baseFS.FileExists(fileName) { - summary.Changed.Add(uri) - } else { - summary.Created.Add(uri) - } - } - addChangeAndAliases := func(fileName string, deleted bool) { - addChange(fileName, deleted) - if baseSnapshotFS != nil { - for _, alias := range baseSnapshotFS.aliasesForPath(fileName) { - addChange(alias, deleted) - } - } - } - overlayFiles := make(map[tspath.Path]struct{}, len(fileSystem.Files)) - for fileName := range fileSystem.Files { - absoluteFileName := tspath.GetNormalizedAbsolutePath(fileName, cwd) - overlayFiles[s.toPath(absoluteFileName)] = struct{}{} - addChangeAndAliases(absoluteFileName, false) - } - for _, removedPath := range fileSystem.RemovedPaths { - absoluteFileName := tspath.GetNormalizedAbsolutePath(removedPath, cwd) - if _, replaced := overlayFiles[s.toPath(absoluteFileName)]; replaced { - continue - } - addChangeAndAliases(absoluteFileName, true) - } - if summary.Changed.Len()+summary.Created.Len()+summary.Deleted.Len() > 0 { - summary.IncludesWatchChangeOutsideNodeModules = true - } -} - func (s *Session) getDiagnostics(ctx context.Context, params *GetDiagnosticsParams, getter func(*compiler.Program, context.Context, *ast.SourceFile) []*ast.Diagnostic) ([]*DiagnosticResponse, error) { sd, err := s.getSnapshotData(params.Snapshot) if err != nil { diff --git a/tsc/internal/api/session_createprogram_test.go b/tsc/internal/api/session_createprogram_test.go index bf674317ee35a..6d94ed378be2d 100644 --- a/tsc/internal/api/session_createprogram_test.go +++ b/tsc/internal/api/session_createprogram_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/microsoft/TypeScript/tsc/internal/api/requestfilesystem" "github.com/microsoft/TypeScript/tsc/internal/core" "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" "github.com/microsoft/TypeScript/tsc/internal/project" @@ -138,7 +139,7 @@ func TestCreateProgramWithNoRootFiles(t *testing.T) { assert.Equal(t, len(project.Program.GetSourceFiles()), 0) } -func TestCreateProgramFromSnapshotFileSystem(t *testing.T) { +func TestCreateProgramFromRequestFileSystem(t *testing.T) { t.Parallel() const fileName = "/src/index.ts" @@ -151,8 +152,8 @@ func TestCreateProgramFromSnapshotFileSystem(t *testing.T) { ctx := context.Background() base, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ - FileSystem: &SnapshotFileSystem{ - Kind: SnapshotFileSystemKindMemory, + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindMemory, Files: map[string]string{ fileName: `export const source = "memory";`, }, @@ -170,6 +171,9 @@ func TestCreateProgramFromSnapshotFileSystem(t *testing.T) { assert.NilError(t, err) created, err := session.getSnapshotData(response.Snapshot) assert.NilError(t, err) + baseData, err := session.getSnapshotData(base.Snapshot) + assert.NilError(t, err) + assert.Assert(t, created.fileSystemHandle() != baseData.fileSystemHandle()) program := created.snapshot.ProjectCollection.InferredProject().Program assert.Equal(t, program.GetSourceFile(fileName).Text(), `export const source = "memory";`) } @@ -185,16 +189,16 @@ func TestCreateProgramRebuildsOldProgramFromDifferentBaseSnapshot(t *testing.T) ctx := context.Background() base, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ - FileSystem: &SnapshotFileSystem{ - Kind: SnapshotFileSystemKindMemory, + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindMemory, Files: map[string]string{fileName: `export const source = "base";`}, }, }) assert.NilError(t, err) newer, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ Snapshot: base.Snapshot, - FileSystem: &SnapshotFileSystem{ - Kind: SnapshotFileSystemKindMemory, + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindMemory, Files: map[string]string{fileName: `export const source = "newer";`}, }, }) diff --git a/tsc/internal/api/session_requestfilesystem_test.go b/tsc/internal/api/session_requestfilesystem_test.go new file mode 100644 index 0000000000000..0186d02bd7d5a --- /dev/null +++ b/tsc/internal/api/session_requestfilesystem_test.go @@ -0,0 +1,452 @@ +package api + +import ( + "context" + "errors" + "fmt" + "strconv" + "sync" + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/api/requestfilesystem" + "github.com/microsoft/TypeScript/tsc/internal/testutil/projecttestutil" + "github.com/microsoft/TypeScript/tsc/internal/tspath" + "gotest.tools/v3/assert" +) + +func TestUpdateSnapshotUsesMemoryFileSystem(t *testing.T) { + t.Parallel() + + projectSession, _ := projecttestutil.Setup(map[string]any{ + "/host.ts": "host", + }) + defer projectSession.Close() + session := NewSession(projectSession, nil) + defer session.Close() + + response, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ + OpenProjects: []DocumentIdentifier{{FileName: "/tsconfig.json"}}, + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindMemory, + Files: map[string]string{ + "/tsconfig.json": `{ "compilerOptions": { "noLib": true }, "files": ["src/index.ts"] }`, + "/src/index.ts": `export const value = "memory";`, + "/src/other.ts": `export const other = true;`, + }, + }, + }) + assert.NilError(t, err) + assert.Equal(t, len(response.Projects), 1) + assert.Equal(t, response.Projects[0].ConfigFileName, "/tsconfig.json") + + snapshot := session.snapshots[response.Snapshot].snapshot + contents, ok := snapshot.ReadFile("/src/index.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, `export const value = "memory";`) + _, ok = snapshot.ReadFile("/host.ts") + assert.Assert(t, !ok) + + // Carrying the same filesystem forward without a delta must preserve + // incremental state instead of forcing a full program rebuild. + program := snapshot.ProjectCollection.GetProjectByPath(tspath.Path("/tsconfig.json")).GetProgram() + unchanged, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{Snapshot: response.Snapshot}) + assert.NilError(t, err) + unchangedSnapshot := session.snapshots[unchanged.Snapshot].snapshot + assert.Assert(t, unchangedSnapshot.ProjectCollection.GetProjectByPath(tspath.Path("/tsconfig.json")).GetProgram() == program) + response = unchanged + + // Supplying a new filesystem replaces inherited snapshot disk caches even + // when the caller does not redundantly list every file in FileChanges. + response, err = session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindMemory, + Files: map[string]string{ + "/tsconfig.json": `{ "compilerOptions": { "noLib": true }, "files": ["src/index.ts", "src/other.ts"] }`, + "/src/index.ts": `export const value = "updated";`, + "/src/other.ts": `export const other = true;`, + }, + }, + }) + assert.NilError(t, err) + snapshot = session.snapshots[response.Snapshot].snapshot + contents, ok = snapshot.ReadFile("/src/index.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, `export const value = "updated";`) + + // Temporary snapshots retain the base snapshot's supplied filesystem for + // every file other than the temporary overlay. + temporary, err := session.handleUpdateTemporarySnapshot(context.Background(), &UpdateTemporarySnapshotParams{ + Snapshot: response.Snapshot, + File: DocumentIdentifier{FileName: "/src/index.ts"}, + NewText: `export const value = "temporary";`, + }) + assert.NilError(t, err) + temporarySnapshot := session.snapshots[temporary.Snapshot].snapshot + contents, ok = temporarySnapshot.ReadFile("/src/index.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, `export const value = "temporary";`) + contents, ok = temporarySnapshot.ReadFile("/src/other.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, `export const other = true;`) +} + +func TestSnapshotUpdateMemoryFileSystemIsTotal(t *testing.T) { + t.Parallel() + + projectSession, _ := projecttestutil.Setup(map[string]any{ + "/host.ts": "host", + }) + defer projectSession.Close() + session := NewSession(projectSession, nil) + defer session.Close() + + base, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{}) + assert.NilError(t, err) + replaced, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ + Snapshot: base.Snapshot, + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindMemory, + Files: map[string]string{ + "/memory.ts": "memory", + }, + }, + }) + assert.NilError(t, err) + + snapshot := session.snapshots[replaced.Snapshot].snapshot + contents, ok := snapshot.ReadFile("/memory.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, "memory") + _, ok = snapshot.ReadFile("/host.ts") + assert.Assert(t, !ok) +} + +func TestSnapshotUpdateCarriesHostFileSystemWithoutOverride(t *testing.T) { + t.Parallel() + + projectSession, _ := projecttestutil.Setup(map[string]any{ + "/tsconfig.json": `{ "compilerOptions": { "noLib": true }, "files": ["index.ts"] }`, + "/index.ts": `export const value = true;`, + }) + defer projectSession.Close() + session := NewSession(projectSession, nil) + defer session.Close() + + base, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ + OpenProjects: []DocumentIdentifier{{FileName: "/tsconfig.json"}}, + }) + assert.NilError(t, err) + baseSnapshot := session.snapshots[base.Snapshot].snapshot + program := baseSnapshot.ProjectCollection.GetProjectByPath(tspath.Path("/tsconfig.json")).GetProgram() + + updated, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{Snapshot: base.Snapshot}) + assert.NilError(t, err) + updatedSnapshot := session.snapshots[updated.Snapshot].snapshot + assert.Assert(t, updatedSnapshot.ProjectCollection.GetProjectByPath(tspath.Path("/tsconfig.json")).GetProgram() == program) +} + +func TestEmitFromCacheLayeredOverMemoryReturnsFileContents(t *testing.T) { + t.Parallel() + + projectSession, _ := projecttestutil.Setup(map[string]any{}) + defer projectSession.Close() + session := NewSession(projectSession, nil) + defer session.Close() + ctx := context.Background() + + base, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ + OpenProjects: []DocumentIdentifier{{FileName: "/tsconfig.json"}}, + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindMemory, + Files: map[string]string{ + "/tsconfig.json": `{ "compilerOptions": { "noLib": true, "outDir": "/out" }, "files": ["src/main.ts"] }`, + "/src/main.ts": `export const value: number = 1;`, + }, + }, + }) + assert.NilError(t, err) + layered, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ + Snapshot: base.Snapshot, + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindCache, + Files: map[string]string{}, + }, + }) + assert.NilError(t, err) + assert.Equal(t, len(layered.Projects), 1) + + emitted, err := session.handleEmit(ctx, &EmitParams{ + Snapshot: layered.Snapshot, + Project: layered.Projects[0].Id, + }) + assert.NilError(t, err) + assert.DeepEqual(t, emitted.EmittedFiles, []string{"/out/src/main.js"}) + assert.DeepEqual(t, emitted.EmittedFilesContents, []string{"export const value = 1;\n"}) + + _, err = session.handleRelease(ctx, &ReleaseParams{Snapshot: base.Snapshot}) + assert.NilError(t, err) + emittedAfterRelease, err := session.handleEmit(ctx, &EmitParams{ + Snapshot: layered.Snapshot, + Project: layered.Projects[0].Id, + }) + assert.NilError(t, err) + assert.DeepEqual(t, emittedAfterRelease.EmittedFiles, emitted.EmittedFiles) + assert.DeepEqual(t, emittedAfterRelease.EmittedFilesContents, emitted.EmittedFilesContents) +} + +func TestReleaseSnapshotCompactsSoleLayeredFileSystem(t *testing.T) { + t.Parallel() + + projectSession, _ := projecttestutil.Setup(map[string]any{ + "/host.ts": "host", + }) + defer projectSession.Close() + session := NewSession(projectSession, nil) + defer session.Close() + + base, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindMemory, + Files: map[string]string{ + "/inherited.ts": "inherited", + "/changed.ts": "old", + "/removed.ts": "removed", + }, + }, + }) + assert.NilError(t, err) + baseFileSystem := session.snapshots[base.Snapshot].fileSystemHandle() + assert.Assert(t, baseFileSystem != nil) + + layered, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ + Snapshot: base.Snapshot, + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindCache, + Files: map[string]string{ + "/changed.ts": "new", + "/added.ts": "added", + }, + RemovedPaths: []string{"/removed.ts"}, + }, + }) + assert.NilError(t, err) + layeredSnapshotData := session.snapshots[layered.Snapshot] + layeredSnapshot := layeredSnapshotData.snapshot + layeredFileSystem := layeredSnapshotData.fileSystemHandle() + assert.Assert(t, layeredFileSystem != nil) + assert.Equal(t, session.snapshots[base.Snapshot].refCount, 1) + + _, err = session.handleRelease(context.Background(), &ReleaseParams{Snapshot: base.Snapshot}) + assert.NilError(t, err) + assert.Assert(t, session.snapshots[base.Snapshot] == nil) + + for path, expected := range map[string]string{ + "/inherited.ts": "inherited", + "/changed.ts": "new", + "/added.ts": "added", + } { + contents, readOK := layeredSnapshot.ReadFile(path) + assert.Assert(t, readOK, path) + assert.Equal(t, contents, expected) + } + _, ok := layeredSnapshot.ReadFile("/removed.ts") + assert.Assert(t, !ok) + _, ok = layeredSnapshot.ReadFile("/host.ts") + assert.Assert(t, !ok) + assert.Assert(t, layeredFileSystem.HasMemoryFileSystem()) +} + +func TestEagerSnapshotReleaseDoesNotRetainFileSystemHistory(t *testing.T) { + t.Parallel() + + projectSession, _ := projecttestutil.Setup(map[string]any{}) + defer projectSession.Close() + session := NewSession(projectSession, nil) + defer session.Close() + + response, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindMemory, + Files: map[string]string{ + "/pkg/index.ts": "", + }, + }, + }) + assert.NilError(t, err) + + content := "" + for _, character := range "export const x = 1" { + oldSnapshot := response.Snapshot + content += string(character) + response, err = session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ + Snapshot: oldSnapshot, + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindCache, + Files: map[string]string{ + "/pkg/index.ts": content, + }, + }, + }) + assert.NilError(t, err) + _, err = session.handleRelease(context.Background(), &ReleaseParams{Snapshot: oldSnapshot}) + assert.NilError(t, err) + + assert.Equal(t, len(session.snapshots), 1) + current := session.snapshots[response.Snapshot] + assert.Assert(t, current != nil) + assert.Equal(t, current.refCount, 1) + fileSystem := current.fileSystemHandle() + assert.Assert(t, fileSystem != nil) + assert.Assert(t, fileSystem.HasMemoryFileSystem()) + actual, ok := current.snapshot.ReadFile("/pkg/index.ts") + assert.Assert(t, ok) + assert.Equal(t, actual, content) + } +} + +func TestSnapshotReleaseCompactsChainedFileSystems(t *testing.T) { + t.Parallel() + + projectSession, _ := projecttestutil.Setup(map[string]any{}) + defer projectSession.Close() + session := NewSession(projectSession, nil) + defer session.Close() + + responses := make([]*UpdateSnapshotResponse, 4) + var err error + responses[0], err = session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindMemory, + Files: map[string]string{"/pkg/index.ts": "0"}, + }, + }) + assert.NilError(t, err) + for i := 1; i < len(responses); i++ { + responses[i], err = session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ + Snapshot: responses[i-1].Snapshot, + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindCache, + Files: map[string]string{"/pkg/index.ts": strconv.Itoa(i)}, + }, + }) + assert.NilError(t, err) + } + + _, err = session.handleRelease(context.Background(), &ReleaseParams{Snapshot: responses[0].Snapshot}) + assert.NilError(t, err) + assert.Assert(t, session.snapshots[responses[0].Snapshot] == nil) + + for i := 1; i < len(responses); i++ { + current := session.snapshots[responses[i].Snapshot] + assert.Assert(t, current != nil) + assert.Equal(t, current.refCount, 1) + fileSystem := current.fileSystemHandle() + assert.Assert(t, fileSystem != nil) + assert.Assert(t, fileSystem.HasMemoryFileSystem()) + contents, ok := current.snapshot.ReadFile("/pkg/index.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, strconv.Itoa(i)) + } +} + +func TestTemporarySnapshotRetainsLayeredFileSystemHistory(t *testing.T) { + t.Parallel() + + projectSession, _ := projecttestutil.Setup(map[string]any{}) + defer projectSession.Close() + session := NewSession(projectSession, nil) + defer session.Close() + + base, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindMemory, + Files: map[string]string{"/pkg/index.ts": "base"}, + }, + }) + assert.NilError(t, err) + layered, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ + Snapshot: base.Snapshot, + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindCache, + Files: map[string]string{"/pkg/index.ts": "layered"}, + }, + }) + assert.NilError(t, err) + layeredFileSystem := session.snapshots[layered.Snapshot].fileSystemHandle() + temporary, err := session.handleUpdateTemporarySnapshot(context.Background(), &UpdateTemporarySnapshotParams{ + Snapshot: layered.Snapshot, + File: DocumentIdentifier{FileName: "/pkg/index.ts"}, + NewText: "temporary", + }) + assert.NilError(t, err) + + _, err = session.handleRelease(context.Background(), &ReleaseParams{Snapshot: layered.Snapshot}) + assert.NilError(t, err) + _, err = session.handleRelease(context.Background(), &ReleaseParams{Snapshot: base.Snapshot}) + assert.NilError(t, err) + + current := session.snapshots[temporary.Snapshot] + assert.Assert(t, current != nil) + fileSystem := current.fileSystemHandle() + assert.Assert(t, fileSystem != nil) + assert.Assert(t, fileSystem != layeredFileSystem) + assert.Assert(t, fileSystem.HasMemoryFileSystem()) +} + +func TestSnapshotReleaseCompactionSupportsConcurrentReaders(t *testing.T) { + t.Parallel() + + projectSession, _ := projecttestutil.Setup(map[string]any{}) + defer projectSession.Close() + session := NewSession(projectSession, nil) + defer session.Close() + + files := make(map[string]string, 1024) + for index := range 1024 { + files[fmt.Sprintf("/pkg/file%d.ts", index)] = strconv.Itoa(index) + } + base, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ + FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindMemory, Files: files}, + }) + assert.NilError(t, err) + layered, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ + Snapshot: base.Snapshot, + FileSystem: &requestfilesystem.RequestFileSystem{ + Kind: requestfilesystem.KindCache, + Files: map[string]string{"/pkg/file0.ts": "updated"}, + }, + }) + assert.NilError(t, err) + fileSystem := session.snapshots[layered.Snapshot].fileSystemHandle() + + started := make(chan struct{}) + done := make(chan struct{}) + readerError := make(chan error, 1) + var waitGroup sync.WaitGroup + waitGroup.Go(func() { + close(started) + for { + select { + case <-done: + return + default: + contents, ok := fileSystem.ReadFile("/pkg/file0.ts") + if !ok || contents != "updated" { + readerError <- fmt.Errorf("unexpected overridden file: %q, %t", contents, ok) + return + } + if !fileSystem.FileExists("/pkg/file1023.ts") { + readerError <- errors.New("inherited file disappeared") + return + } + } + } + }) + <-started + _, err = session.handleRelease(context.Background(), &ReleaseParams{Snapshot: base.Snapshot}) + assert.NilError(t, err) + close(done) + waitGroup.Wait() + close(readerError) + assert.NilError(t, <-readerError) +} diff --git a/tsc/internal/project/api.go b/tsc/internal/project/api.go index 356bca5488cdd..3e4ef70dfd307 100644 --- a/tsc/internal/project/api.go +++ b/tsc/internal/project/api.go @@ -49,7 +49,7 @@ func (s *Session) APIUpdate(ctx context.Context, apiFileChanges FileChangeSummar // An error is returned if the file name does not have a recognized script extension. // On success, the returned snapshot carries a single reference (the clone ref); // the caller must call snapshot.Deref(s) when done. -func (s *Session) APIUpdateTemporary(ctx context.Context, baseSnapshot *Snapshot, uri lsproto.DocumentUri, newText string) (*Snapshot, error) { +func (s *Session) APIUpdateTemporary(ctx context.Context, baseSnapshot *Snapshot, fileSystem vfs.FS, uri lsproto.DocumentUri, newText string) (*Snapshot, error) { path := uri.Path(baseSnapshot.UseCaseSensitiveFileNames()) overlays := maps.Clone(baseSnapshot.fs.overlays) @@ -69,9 +69,12 @@ func (s *Session) APIUpdateTemporary(ctx context.Context, baseSnapshot *Snapshot fileChanges.Opened = uri } overlays[path] = newOverlay(uri.FileName(), newText, version, scriptKind) + if fileSystem == nil { + fileSystem = baseSnapshot.fs.fs + } newSnapshot := baseSnapshot.Clone(ctx, SnapshotChange{ - fs: baseSnapshot.fs.fs, + fs: fileSystem, fileSystemOverride: baseSnapshot.fileSystemOverride, fileChanges: fileChanges, ResourceRequest: ResourceRequest{ @@ -92,9 +95,13 @@ func (s *Session) APICreateProgram( configFileParsingDiagnostics []*ast.Diagnostic, oldSnapshot *Snapshot, oldProject *Project, + fileSystem vfs.FS, fileChanges FileChangeSummary, ) *Snapshot { if oldSnapshot != nil { + if fileSystem == nil { + fileSystem = oldSnapshot.fs.fs + } return oldSnapshot.cloneForProgram( ctx, rootFileNames, @@ -102,6 +109,7 @@ func (s *Session) APICreateProgram( projectReferences, configFileParsingDiagnostics, oldProject, + fileSystem, fileChanges, s, ) @@ -109,6 +117,9 @@ func (s *Session) APICreateProgram( snapshot, _ := s.APIUpdate(ctx, fileChanges, nil) defer snapshot.Deref(s) + if fileSystem == nil { + fileSystem = snapshot.fs.fs + } return snapshot.cloneForProgram( ctx, rootFileNames, @@ -116,6 +127,7 @@ func (s *Session) APICreateProgram( projectReferences, configFileParsingDiagnostics, nil, + fileSystem, fileChanges, s, ) diff --git a/tsc/internal/project/refcountcache_test.go b/tsc/internal/project/refcountcache_test.go index d844a345cc8a1..26e518fdb1152 100644 --- a/tsc/internal/project/refcountcache_test.go +++ b/tsc/internal/project/refcountcache_test.go @@ -532,6 +532,7 @@ func TestRefCountingCaches(t *testing.T) { appProject.CommandLine.Errors, baseSnapshot, appProject, + nil, FileChangeSummary{}, ) defer programSnapshot.Deref(session) @@ -562,6 +563,7 @@ func TestRefCountingCaches(t *testing.T) { programProject.CommandLine.Errors, programSnapshot, programProject, + nil, fileChanges, ) defer updatedProgramSnapshot.Deref(session) diff --git a/tsc/internal/project/snapshot.go b/tsc/internal/project/snapshot.go index 3f83ca20cfc9c..6823e94d1a586 100644 --- a/tsc/internal/project/snapshot.go +++ b/tsc/internal/project/snapshot.go @@ -122,6 +122,7 @@ func (s *Snapshot) cloneForProgram( projectReferences []*core.ProjectReference, configFileParsingDiagnostics []*ast.Diagnostic, oldProject *Project, + fileSystem vfs.FS, fileChanges FileChangeSummary, session *Session, ) *Snapshot { @@ -138,7 +139,7 @@ func (s *Snapshot) cloneForProgram( } start := time.Now() - fs := newSnapshotFSBuilder(s.fs.fs, s.fs.overlays, s.fs.overlays, s.fs.diskFiles, s.fs.diskDirectories, s.fs.nodeModulesRealpathAliases, session.options.PositionEncoding, s.toPath) + fs := newSnapshotFSBuilder(fileSystem, s.fs.overlays, s.fs.overlays, s.fs.diskFiles, s.fs.diskDirectories, s.fs.nodeModulesRealpathAliases, session.options.PositionEncoding, s.toPath) fileChanges = s.processFileChanges(fs, fileChanges, logger, nil) newSnapshotID := session.snapshotID.Add(1) From 13d137b5d4504f4afbd097138001bda9a6f0523f Mon Sep 17 00:00:00 2001 From: Wesley Wigham Date: Tue, 1 Sep 2026 19:40:10 -0700 Subject: [PATCH 04/12] Corrections of things broken in the move --- .../requestfilesystem_test.go | 45 +++++++++++++++++++ .../requestfilesystemhandle.go | 14 +++--- tsc/internal/api/session.go | 2 +- 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/tsc/internal/api/requestfilesystem/requestfilesystem_test.go b/tsc/internal/api/requestfilesystem/requestfilesystem_test.go index 244ebba43f8fa..faa0d63faa91f 100644 --- a/tsc/internal/api/requestfilesystem/requestfilesystem_test.go +++ b/tsc/internal/api/requestfilesystem/requestfilesystem_test.go @@ -3,6 +3,7 @@ package requestfilesystem import ( "testing" + "github.com/microsoft/TypeScript/tsc/internal/project" "github.com/microsoft/TypeScript/tsc/internal/vfs" "github.com/microsoft/TypeScript/tsc/internal/vfs/trackingvfs" "github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest" @@ -31,6 +32,50 @@ func (h *Handle) applyTo(base *Handle) { h.applyToLocked(base) } +func TestInitializeForUpdate(t *testing.T) { + t.Parallel() + + t.Run("cache layers over a host-backed snapshot", func(t *testing.T) { + t.Parallel() + host := vfstest.FromMap(map[string]string{ + "/dir/host.ts": "host", + }, true) + var handle Handle + var fileChanges project.FileChangeSummary + err := handle.InitializeForUpdate(&RequestFileSystem{ + Kind: KindCache, + Files: map[string]string{"/dir/cached.ts": "cached"}, + Directories: map[string]RequestDirectoryEntries{ + "/dir": {Files: []string{"cached.ts"}, Directories: []string{}}, + }, + }, nil, host, "/", &fileChanges, true) + assert.NilError(t, err) + assert.DeepEqual(t, handle.GetAccessibleEntries("/dir").Files, []string{"cached.ts", "host.ts"}) + }) + + t.Run("memory starts a new chain", func(t *testing.T) { + t.Parallel() + host := vfstest.FromMap(map[string]string{ + "/host.ts": "host", + }, true) + base, err := newRequestFileSystem(&RequestFileSystem{ + Kind: KindMemory, + Files: map[string]string{"/base.ts": "base"}, + }, host, "/") + assert.NilError(t, err) + + var handle Handle + var fileChanges project.FileChangeSummary + err = handle.InitializeForUpdate(&RequestFileSystem{ + Kind: KindMemory, + Files: map[string]string{"/replacement.ts": "replacement"}, + }, base, host, "/", &fileChanges, true) + assert.NilError(t, err) + assert.Assert(t, handle.baseFileSystem() == host) + assert.Assert(t, getRequestFileSystem(handle.baseFileSystem()) == nil) + }) +} + func TestRequestFileSystem(t *testing.T) { t.Parallel() diff --git a/tsc/internal/api/requestfilesystem/requestfilesystemhandle.go b/tsc/internal/api/requestfilesystem/requestfilesystemhandle.go index f14a85381a3be..b83e45866979e 100644 --- a/tsc/internal/api/requestfilesystem/requestfilesystemhandle.go +++ b/tsc/internal/api/requestfilesystem/requestfilesystemhandle.go @@ -61,22 +61,22 @@ func (h *Handle) initializeLayered(params *RequestFileSystem, base vfs.FS, curre } // InitializeForUpdate initializes the handle from a snapshot update request and its optional base. -func (h *Handle) InitializeForUpdate(params *RequestFileSystem, base *Handle, host vfs.FS, currentDirectory string, fileChanges *project.FileChangeSummary) error { +func (h *Handle) InitializeForUpdate(params *RequestFileSystem, base *Handle, host vfs.FS, currentDirectory string, fileChanges *project.FileChangeSummary, hasBaseSnapshot bool) error { if params == nil { if base != nil { h.CloneFrom(base) } return nil } - baseFS := host - if base != nil { - baseFS = base - } - if base != nil && params.Kind == KindCache { + if params.Kind == KindCache && hasBaseSnapshot { + baseFS := host + if base != nil { + baseFS = base + } addFileChanges(fileChanges, params, baseFS, currentDirectory) return h.initializeLayered(params, baseFS, currentDirectory) } - return h.initializeFromRequest(params, baseFS, currentDirectory) + return h.initializeFromRequest(params, host, currentDirectory) } // FS returns this handle as a filesystem, or a nil interface when it is uninitialized. diff --git a/tsc/internal/api/session.go b/tsc/internal/api/session.go index ab2b761c38f08..48099ed65039a 100644 --- a/tsc/internal/api/session.go +++ b/tsc/internal/api/session.go @@ -1063,7 +1063,7 @@ func (s *Session) handleUpdateSnapshot(ctx context.Context, params *UpdateSnapsh baseRequestFileSystem = baseSD.fileSystemHandle() } sd := newSnapshotData() - if err := sd.fileSystem.InitializeForUpdate(params.FileSystem, baseRequestFileSystem, s.projectSession.FS(), s.projectSession.GetCurrentDirectory(), &fileChanges); err != nil { + if err := sd.fileSystem.InitializeForUpdate(params.FileSystem, baseRequestFileSystem, s.projectSession.FS(), s.projectSession.GetCurrentDirectory(), &fileChanges, baseSD != nil); err != nil { return nil, fmt.Errorf("%w: %w", ErrClientError, err) } apiRequest.FileSystem = sd.fileSystem.FS() From 5941477c5731cf96cdff83a527980138e7504df0 Mon Sep 17 00:00:00 2001 From: Wesley Wigham Date: Tue, 1 Sep 2026 20:03:21 -0700 Subject: [PATCH 05/12] Remove Snapshot.createProgram --- packages/typescript/src/api/async/api.ts | 37 ----- .../typescript/src/api/proto.generated.ts | 5 - packages/typescript/src/api/sync/api.ts | 61 +------- packages/typescript/test/async/api.test.ts | 133 +++++------------- .../test/sync/api-generators.test.ts | 7 - packages/typescript/test/sync/api.test.ts | 133 +++++------------- tsc/internal/api/proto.go | 11 +- tsc/internal/api/session.go | 26 +--- .../api/session_createprogram_test.go | 92 ------------ tsc/internal/project/api.go | 9 -- tsc/internal/project/refcountcache_test.go | 9 +- tsc/internal/project/snapshot.go | 4 +- 12 files changed, 77 insertions(+), 450 deletions(-) diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index ba200c2b5fe87..83fbdec23a357 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -500,26 +500,11 @@ export class API implements FormatDiagnosticsHo return this.createProgramWorker(rootFiles, createProgramOptions, oldProgram, fileChanges); } - /** @internal */ - async createProgramFromSnapshot( - baseSnapshot: Snapshot, - rootFiles: readonly DocumentIdentifier[], - createProgramOptions: CreateProgramOptions, - oldProgram?: Program, - fileChanges?: APIFileChanges, - ): Promise { - if (!this.activeSnapshots.has(baseSnapshot) || baseSnapshot.isDisposed()) { - throw new Error("Cannot create a program from an inactive snapshot"); - } - return this.createProgramWorker(rootFiles, createProgramOptions, oldProgram, fileChanges, baseSnapshot); - } - private async createProgramWorker( rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions, oldProgram?: Program, fileChanges?: APIFileChanges, - baseSnapshot?: Snapshot, ): Promise { await this.ensureInitialized(); @@ -533,7 +518,6 @@ export class API implements FormatDiagnosticsHo const data: CreateProgramResponse = await this.client.apiRequest("createProgram", { rootFiles, createProgramOptions, - ...(baseSnapshot ? { baseSnapshot: baseSnapshot.id } : {}), ...(oldProgram ? { oldProgram: { snapshot: oldProgram.snapshotId, project: oldProgram.getProject().id } } : {}), ...(fileChanges ? { fileChanges } : {}), }); @@ -562,13 +546,6 @@ type EnsureInitialized = () => Promise; // @sync: type EnsureInitialized = interface SnapshotOwner extends FormatDiagnosticsHost { updateSnapshotFrom(baseSnapshot: Snapshot, params?: UpdateSnapshotParams): Promise; - createProgramFromSnapshot( - baseSnapshot: Snapshot, - rootFiles: readonly DocumentIdentifier[], - createProgramOptions: CreateProgramOptions, - oldProgram?: Program, - fileChanges?: APIFileChanges, - ): Promise; } export class InternalAPI { @@ -664,20 +641,6 @@ export class Snapshot { return this.api.updateSnapshotFrom(this, params); } - /** - * Creates an isolated program using this snapshot and its effective filesystem - * as the base. Usage is otherwise identical to {@link API.createProgram}. - */ - async createProgram( - rootFiles: readonly DocumentIdentifier[], - createProgramOptions: CreateProgramOptions, - oldProgram?: Program, - fileChanges?: APIFileChanges, - ): Promise { - this.ensureNotDisposed(); - return this.api.createProgramFromSnapshot(this, rootFiles, createProgramOptions, oldProgram, fileChanges); - } - [globalThis.Symbol.dispose](): void { void this.dispose(); } diff --git a/packages/typescript/src/api/proto.generated.ts b/packages/typescript/src/api/proto.generated.ts index 72fe3fc1f4cae..b45c85fd907b8 100644 --- a/packages/typescript/src/api/proto.generated.ts +++ b/packages/typescript/src/api/proto.generated.ts @@ -257,11 +257,6 @@ export interface UpdateTemporarySnapshotParams { export interface CreateProgramParams { rootFiles: readonly DocumentIdentifier[] | null; createProgramOptions: CreateProgramOptions; - /** - * BaseSnapshot supplies the filesystem and project state from which the - * synthetic program snapshot is cloned. - */ - baseSnapshot?: number; oldProgram?: CreateProgramOldProgramParams; fileChanges?: APIFileChanges; } diff --git a/packages/typescript/src/api/sync/api.ts b/packages/typescript/src/api/sync/api.ts index e678d4e00b312..0d550c2783c36 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -866,39 +866,15 @@ export class API implements FormatDiagnosticsHo ); } - /** @internal */ - get createProgramFromSnapshot(): { - (baseSnapshot: Snapshot, rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions, oldProgram?: Program, fileChanges?: APIFileChanges): Program; - gen(baseSnapshot: Snapshot, rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions, oldProgram?: Program, fileChanges?: APIFileChanges): Generator; - } { - const owner = this; - return cacheGeneratorMethod( - owner, - "createProgramFromSnapshot", - function (baseSnapshot: Snapshot, rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions, oldProgram?: Program, fileChanges?: APIFileChanges): Program { - if (!owner.activeSnapshots.has(baseSnapshot) || baseSnapshot.isDisposed()) { - throw new Error("Cannot create a program from an inactive snapshot"); - } - return owner.createProgramWorker(rootFiles, createProgramOptions, oldProgram, fileChanges, baseSnapshot); - }, - function* (baseSnapshot: Snapshot, rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions, oldProgram?: Program, fileChanges?: APIFileChanges): Generator { - if (!owner.activeSnapshots.has(baseSnapshot) || baseSnapshot.isDisposed()) { - throw new Error("Cannot create a program from an inactive snapshot"); - } - return yield* owner.createProgramWorker.gen(rootFiles, createProgramOptions, oldProgram, fileChanges, baseSnapshot); - }, - ); - } - private get createProgramWorker(): { - (rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions, oldProgram?: Program, fileChanges?: APIFileChanges, baseSnapshot?: Snapshot): Program; - gen(rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions, oldProgram?: Program, fileChanges?: APIFileChanges, baseSnapshot?: Snapshot): Generator; + (rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions, oldProgram?: Program, fileChanges?: APIFileChanges): Program; + gen(rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions, oldProgram?: Program, fileChanges?: APIFileChanges): Generator; } { const owner = this; return cacheGeneratorMethod( owner, "createProgramWorker", - function (rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions, oldProgram?: Program, fileChanges?: APIFileChanges, baseSnapshot?: Snapshot): Program { + function (rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions, oldProgram?: Program, fileChanges?: APIFileChanges): Program { owner.ensureInitialized(); if (fileChanges && !oldProgram) { @@ -911,7 +887,6 @@ export class API implements FormatDiagnosticsHo const data: CreateProgramResponse = owner.client.apiRequest("createProgram", { rootFiles, createProgramOptions, - ...(baseSnapshot ? { baseSnapshot: baseSnapshot.id } : {}), ...(oldProgram ? { oldProgram: { snapshot: oldProgram.snapshotId, project: oldProgram.getProject().id } } : {}), ...(fileChanges ? { fileChanges } : {}), }); @@ -934,7 +909,7 @@ export class API implements FormatDiagnosticsHo owner.activeSnapshots.add(snapshot); return program; }, - function* (rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions, oldProgram?: Program, fileChanges?: APIFileChanges, baseSnapshot?: Snapshot): Generator { + function* (rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions, oldProgram?: Program, fileChanges?: APIFileChanges): Generator { yield* owner.ensureInitialized.gen(); if (fileChanges && !oldProgram) { @@ -947,7 +922,6 @@ export class API implements FormatDiagnosticsHo const data: CreateProgramResponse = yield* apiRequest("createProgram", { rootFiles, createProgramOptions, - ...(baseSnapshot ? { baseSnapshot: baseSnapshot.id } : {}), ...(oldProgram ? { oldProgram: { snapshot: oldProgram.snapshotId, project: oldProgram.getProject().id } } : {}), ...(fileChanges ? { fileChanges } : {}), }); @@ -981,10 +955,6 @@ interface SnapshotOwner extends FormatDiagnosticsHost { (baseSnapshot: Snapshot, params?: UpdateSnapshotParams): Snapshot; gen(baseSnapshot: Snapshot, params?: UpdateSnapshotParams): Generator; }; - createProgramFromSnapshot: { - (baseSnapshot: Snapshot, rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions, oldProgram?: Program, fileChanges?: APIFileChanges): Program; - gen(baseSnapshot: Snapshot, rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions, oldProgram?: Program, fileChanges?: APIFileChanges): Generator; - }; } export class InternalAPI { @@ -1157,29 +1127,6 @@ export class Snapshot { ); } - /** - * Creates an isolated program using this snapshot and its effective filesystem - * as the base. Usage is otherwise identical to {@link API.createProgram}. - */ - get createProgram(): { - (rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions, oldProgram?: Program, fileChanges?: APIFileChanges): Program; - gen(rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions, oldProgram?: Program, fileChanges?: APIFileChanges): Generator; - } { - const owner = this; - return cacheGeneratorMethod( - owner, - "createProgram", - function (rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions, oldProgram?: Program, fileChanges?: APIFileChanges): Program { - owner.ensureNotDisposed(); - return owner.api.createProgramFromSnapshot(owner, rootFiles, createProgramOptions, oldProgram, fileChanges); - }, - function* (rootFiles: readonly DocumentIdentifier[], createProgramOptions: CreateProgramOptions, oldProgram?: Program, fileChanges?: APIFileChanges): Generator { - owner.ensureNotDisposed(); - return yield* owner.api.createProgramFromSnapshot.gen(owner, rootFiles, createProgramOptions, oldProgram, fileChanges); - }, - ); - } - [globalThis.Symbol.dispose](): void { void this.dispose(); } diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index 3fda8f5eb6f3d..562e782121a25 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -3831,14 +3831,13 @@ describe("updateSnapshot file systems", () => { }); try { using snapshot = await api.updateSnapshot({ + openProject: "/tsconfig.json", fileSystem: createMemoryFileSystemWithLib(Object.entries({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true }, files: ["src/main.ts"] }), "/src/main.ts": `export const values: Array = [];`, })), }); - using program = await snapshot.createProgram( - ["/src/main.ts"], - { compilerOptions: { strict: true } }, - ); + const program = snapshot.getProject("/tsconfig.json")!.program; assert.deepEqual(await program.getGlobalDiagnostics(), []); const sourceFileNames = await program.getSourceFileNames(); const defaultLibraryName = sourceFileNames.find(fileName => fileName.includes("/lib.") && fileName.endsWith(".d.ts")); @@ -3861,19 +3860,19 @@ describe("updateSnapshot file systems", () => { }); try { using snapshot = await api.updateSnapshot({ + openFiles: [fileDocument, remoteDocument, notebookDocument], fileSystem: createMemoryFileSystem([ [fileDocument, `export const file = true;`], [remoteDocument, `export const remote = true;`], [notebookDocument, `export const cell = true;`], ]), }); - using program = await snapshot.createProgram( - [fileDocument, remoteDocument, notebookDocument], - { compilerOptions: { noLib: true } }, - ); - assert.equal((await program.getSourceFile(fileDocument))?.text, `export const file = true;`); - assert.equal((await program.getSourceFile(remoteDocument))?.text, `export const remote = true;`); - assert.equal((await program.getSourceFile(notebookDocument))?.text, `export const cell = true;`); + const fileProject = await snapshot.getDefaultProjectForFile(fileDocument); + const remoteProject = await snapshot.getDefaultProjectForFile(remoteDocument); + const notebookProject = await snapshot.getDefaultProjectForFile(notebookDocument); + assert.equal((await fileProject?.program.getSourceFile(fileDocument))?.text, `export const file = true;`); + assert.equal((await remoteProject?.program.getSourceFile(remoteDocument))?.text, `export const remote = true;`); + assert.equal((await notebookProject?.program.getSourceFile(notebookDocument))?.text, `export const cell = true;`); } finally { await api.close(); @@ -4074,7 +4073,11 @@ describe("updateSnapshot file systems", () => { }); try { let snapshot: Snapshot = await api.updateSnapshot({ - fileSystem: createMemoryFileSystem([["/pkg/index.ts", ""]]), + openProject: "/tsconfig.json", + fileSystem: createMemoryFileSystem([ + ["/tsconfig.json", JSON.stringify({ compilerOptions: { noLib: true }, files: ["pkg/index.ts"] })], + ["/pkg/index.ts", ""], + ]), }); try { let content = ""; @@ -4088,10 +4091,7 @@ describe("updateSnapshot file systems", () => { assert.equal(oldSnapshot.isDisposed(), true); } - using program = await snapshot.createProgram( - ["/pkg/index.ts"], - { compilerOptions: { noLib: true } }, - ); + const program = snapshot.getProject("/tsconfig.json")!.program; assert.equal((await program.getSourceFile("/pkg/index.ts"))?.text, "export const x = 1"); } finally { @@ -4114,14 +4114,13 @@ describe("updateSnapshot file systems", () => { try { using snapshot = await api.updateSnapshot(); using replaced = await snapshot.update({ + openProject: "/tsconfig.json", fileSystem: createMemoryFileSystem([ + ["/tsconfig.json", JSON.stringify({ compilerOptions: { noLib: true }, files: ["memory.ts", "host.ts"] })], ["/memory.ts", `export const source = "memory";`], ]), }); - using program = await replaced.createProgram( - ["/memory.ts", "/host.ts"], - { compilerOptions: { noLib: true } }, - ); + const program = replaced.getProject("/tsconfig.json")!.program; assert.equal((await program.getSourceFile("/memory.ts"))?.text, `export const source = "memory";`); assert.equal(await program.getSourceFile("/host.ts"), undefined); } @@ -4136,8 +4135,10 @@ describe("updateSnapshot file systems", () => { }); try { using snapshot = await api.updateSnapshot({ + openProject: "/tsconfig.json", fileSystem: createMemoryFileSystem( Object.entries({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: ["src/main.ts"] }), "/src/main.ts": `import "./link/change"; import "./link/added"; import "./link/remove";`, "/target/change.ts": `export const version = "old";`, "/target/remove.ts": `export const removed = true;`, @@ -4161,10 +4162,7 @@ describe("updateSnapshot file systems", () => { }, ), }); - using program = await updated.createProgram( - ["/src/main.ts"], - { compilerOptions: { noLib: true } }, - ); + const program = updated.getProject("/tsconfig.json")!.program; assert.equal((await program.getSourceFile("/src/link/change.ts"))?.text, `export const version = "new";`); assert.equal((await program.getSourceFile("/src/link/added.ts"))?.text, `export const added = true;`); assert.equal(await program.getSourceFile("/src/link/remove.ts"), undefined); @@ -4174,68 +4172,6 @@ describe("updateSnapshot file systems", () => { } }); - test("Snapshot.createProgram uses the request filesystem as its base", async () => { - const callbackCalls: string[] = []; - const api = new API({ - cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), - fs: { - readFile: path => { - callbackCalls.push(path); - return undefined; - }, - }, - }); - const options = { compilerOptions: { noLib: true, strict: true } }; - try { - using snapshot = await api.updateSnapshot({ - fileSystem: createMemoryFileSystem(Object.entries({ - "/src/main.ts": `import { value } from "./dependency"; export const result = value;`, - "/src/dependency.ts": `export const value = "memory";`, - })), - }); - using program = await snapshot.createProgram(["/src/main.ts"], options); - assert.equal((await program.getSourceFile("/src/dependency.ts"))?.text, `export const value = "memory";`); - - using updated = await snapshot.update({ - fileSystem: createCacheFileSystem(Object.entries({ - "/src/dependency.ts": `export const value = "updated";`, - })), - }); - using updatedProgram = await updated.createProgram( - ["/src/main.ts"], - options, - program, - { changed: ["/src/dependency.ts"] }, - ); - assert.equal((await updatedProgram.getSourceFile("/src/dependency.ts"))?.text, `export const value = "updated";`); - assert.deepEqual(callbackCalls, []); - } - finally { - await api.close(); - } - }); - - test("Snapshot.createProgram rebuilds an old program from a different snapshot when changes are omitted", async () => { - const api = new API({ - cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), - }); - const options = { compilerOptions: { noLib: true } }; - try { - using base = await api.updateSnapshot({ - fileSystem: createMemoryFileSystem([["/src/main.ts", `export const source = "base";`]]), - }); - using newer = await base.update({ - fileSystem: createMemoryFileSystem([["/src/main.ts", `export const source = "newer";`]]), - }); - using oldProgram = await newer.createProgram(["/src/main.ts"], options); - using rebuilt = await base.createProgram(["/src/main.ts"], options, oldProgram); - assert.equal((await rebuilt.getSourceFile("/src/main.ts"))?.text, `export const source = "base";`); - } - finally { - await api.close(); - } - }); - test("memory filesystem emit returns outputs without mutating the host", async () => { const hostWrites: string[] = []; const api = new API({ @@ -4248,14 +4184,13 @@ describe("updateSnapshot file systems", () => { }); try { using snapshot = await api.updateSnapshot({ + openProject: "/tsconfig.json", fileSystem: createMemoryFileSystem(Object.entries({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, outDir: "/out", rootDir: "/src" }, files: ["src/main.ts"] }), "/src/main.ts": `export const value: number = 1;`, })), }); - using program = await snapshot.createProgram( - ["/src/main.ts"], - { compilerOptions: { noLib: true, outDir: "/out" } }, - ); + const program = snapshot.getProject("/tsconfig.json")!.program; const result = await program.emit(); assert.deepEqual(result.emittedFiles, ["/out/main.js"]); assert.deepEqual(result.fileSystem, { @@ -4266,13 +4201,10 @@ describe("updateSnapshot file systems", () => { }); assert.deepEqual(hostWrites, []); - using updated = await snapshot.update({ fileSystem: result.fileSystem! }); - using updatedProgram = await updated.createProgram( - ["/src/main.ts", "/out/main.js"], - { compilerOptions: { allowJs: true, noLib: true } }, - ); - assert.equal((await updatedProgram.getSourceFile("/src/main.ts"))?.text, `export const value: number = 1;`); - assert.equal((await updatedProgram.getSourceFile("/out/main.js"))?.text, `export const value = 1;\n`); + using updated = await snapshot.update({ fileSystem: result.fileSystem!, openFiles: ["/out/main.js"] }); + const outputProject = await updated.getDefaultProjectForFile("/out/main.js"); + assert.equal((await updated.getProject("/tsconfig.json")!.program.getSourceFile("/src/main.ts"))?.text, `export const value: number = 1;`); + assert.equal((await outputProject?.program.getSourceFile("/out/main.js"))?.text, `export const value = 1;\n`); } finally { await api.close(); @@ -4287,14 +4219,13 @@ describe("updateSnapshot file systems", () => { }); try { using snapshot = await api.updateSnapshot({ + openProject: "/tsconfig.json", fileSystem: createCacheFileSystem(Object.entries({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, outDir: "/out", rootDir: "/src" }, files: ["src/main.ts"] }), "/src/main.ts": `export const value: number = 1;`, })), }); - using program = await snapshot.createProgram( - ["/src/main.ts"], - { compilerOptions: { noLib: true, outDir: "/out" } }, - ); + const program = snapshot.getProject("/tsconfig.json")!.program; const result = await program.emit(); assert.equal(result.fileSystem, undefined); assert.equal(host.readFile!("/out/main.js"), `export const value = 1;\n`); diff --git a/packages/typescript/test/sync/api-generators.test.ts b/packages/typescript/test/sync/api-generators.test.ts index 4017c08dcdef4..154ff74281ba8 100644 --- a/packages/typescript/test/sync/api-generators.test.ts +++ b/packages/typescript/test/sync/api-generators.test.ts @@ -142,7 +142,6 @@ const privateGeneratorGetters = new Set([ "API.initializeWorker", "API.updateSnapshotFrom", "API.updateSnapshotWorker", - "API.createProgramFromSnapshot", "API.createProgramWorker", "Checker.getIntrinsicType", "Checker.getWellKnownSignatures", @@ -923,12 +922,6 @@ describe("API - generator batching", () => { const syncUpdated = syncBase.update(); assertSnapshotsEquivalent(generatorUpdated, syncUpdated, "Snapshot.update"); exercisedMethods.add("Snapshot.update"); - - const createProgramOptions = { compilerOptions: { noLib: true } }; - const generatorProgram = snapshotGeneratorAPI.batch(generatorUpdated.createProgram.gen(["/src/index.ts"], createProgramOptions))[0]; - const syncProgram = syncUpdated.createProgram(["/src/index.ts"], createProgramOptions); - assertProgramsEquivalent(generatorProgram, syncProgram, "Snapshot.createProgram"); - exercisedMethods.add("Snapshot.createProgram"); } finally { snapshotGeneratorAPI.close(); diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index 876c80d1de174..34f849bb7d6e2 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -3723,14 +3723,13 @@ describe("updateSnapshot file systems", () => { }); try { using snapshot = api.updateSnapshot({ + openProject: "/tsconfig.json", fileSystem: createMemoryFileSystemWithLib(Object.entries({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true }, files: ["src/main.ts"] }), "/src/main.ts": `export const values: Array = [];`, })), }); - using program = snapshot.createProgram( - ["/src/main.ts"], - { compilerOptions: { strict: true } }, - ); + const program = snapshot.getProject("/tsconfig.json")!.program; assert.deepEqual(program.getGlobalDiagnostics(), []); const sourceFileNames = program.getSourceFileNames(); const defaultLibraryName = sourceFileNames.find(fileName => fileName.includes("/lib.") && fileName.endsWith(".d.ts")); @@ -3753,19 +3752,19 @@ describe("updateSnapshot file systems", () => { }); try { using snapshot = api.updateSnapshot({ + openFiles: [fileDocument, remoteDocument, notebookDocument], fileSystem: createMemoryFileSystem([ [fileDocument, `export const file = true;`], [remoteDocument, `export const remote = true;`], [notebookDocument, `export const cell = true;`], ]), }); - using program = snapshot.createProgram( - [fileDocument, remoteDocument, notebookDocument], - { compilerOptions: { noLib: true } }, - ); - assert.equal((program.getSourceFile(fileDocument))?.text, `export const file = true;`); - assert.equal((program.getSourceFile(remoteDocument))?.text, `export const remote = true;`); - assert.equal((program.getSourceFile(notebookDocument))?.text, `export const cell = true;`); + const fileProject = snapshot.getDefaultProjectForFile(fileDocument); + const remoteProject = snapshot.getDefaultProjectForFile(remoteDocument); + const notebookProject = snapshot.getDefaultProjectForFile(notebookDocument); + assert.equal((fileProject?.program.getSourceFile(fileDocument))?.text, `export const file = true;`); + assert.equal((remoteProject?.program.getSourceFile(remoteDocument))?.text, `export const remote = true;`); + assert.equal((notebookProject?.program.getSourceFile(notebookDocument))?.text, `export const cell = true;`); } finally { api.close(); @@ -3966,7 +3965,11 @@ describe("updateSnapshot file systems", () => { }); try { let snapshot: Snapshot = api.updateSnapshot({ - fileSystem: createMemoryFileSystem([["/pkg/index.ts", ""]]), + openProject: "/tsconfig.json", + fileSystem: createMemoryFileSystem([ + ["/tsconfig.json", JSON.stringify({ compilerOptions: { noLib: true }, files: ["pkg/index.ts"] })], + ["/pkg/index.ts", ""], + ]), }); try { let content = ""; @@ -3980,10 +3983,7 @@ describe("updateSnapshot file systems", () => { assert.equal(oldSnapshot.isDisposed(), true); } - using program = snapshot.createProgram( - ["/pkg/index.ts"], - { compilerOptions: { noLib: true } }, - ); + const program = snapshot.getProject("/tsconfig.json")!.program; assert.equal((program.getSourceFile("/pkg/index.ts"))?.text, "export const x = 1"); } finally { @@ -4006,14 +4006,13 @@ describe("updateSnapshot file systems", () => { try { using snapshot = api.updateSnapshot(); using replaced = snapshot.update({ + openProject: "/tsconfig.json", fileSystem: createMemoryFileSystem([ + ["/tsconfig.json", JSON.stringify({ compilerOptions: { noLib: true }, files: ["memory.ts", "host.ts"] })], ["/memory.ts", `export const source = "memory";`], ]), }); - using program = replaced.createProgram( - ["/memory.ts", "/host.ts"], - { compilerOptions: { noLib: true } }, - ); + const program = replaced.getProject("/tsconfig.json")!.program; assert.equal((program.getSourceFile("/memory.ts"))?.text, `export const source = "memory";`); assert.equal(program.getSourceFile("/host.ts"), undefined); } @@ -4028,8 +4027,10 @@ describe("updateSnapshot file systems", () => { }); try { using snapshot = api.updateSnapshot({ + openProject: "/tsconfig.json", fileSystem: createMemoryFileSystem( Object.entries({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: ["src/main.ts"] }), "/src/main.ts": `import "./link/change"; import "./link/added"; import "./link/remove";`, "/target/change.ts": `export const version = "old";`, "/target/remove.ts": `export const removed = true;`, @@ -4053,10 +4054,7 @@ describe("updateSnapshot file systems", () => { }, ), }); - using program = updated.createProgram( - ["/src/main.ts"], - { compilerOptions: { noLib: true } }, - ); + const program = updated.getProject("/tsconfig.json")!.program; assert.equal((program.getSourceFile("/src/link/change.ts"))?.text, `export const version = "new";`); assert.equal((program.getSourceFile("/src/link/added.ts"))?.text, `export const added = true;`); assert.equal(program.getSourceFile("/src/link/remove.ts"), undefined); @@ -4066,68 +4064,6 @@ describe("updateSnapshot file systems", () => { } }); - test("Snapshot.createProgram uses the request filesystem as its base", () => { - const callbackCalls: string[] = []; - const api = new API({ - cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), - fs: { - readFile: path => { - callbackCalls.push(path); - return undefined; - }, - }, - }); - const options = { compilerOptions: { noLib: true, strict: true } }; - try { - using snapshot = api.updateSnapshot({ - fileSystem: createMemoryFileSystem(Object.entries({ - "/src/main.ts": `import { value } from "./dependency"; export const result = value;`, - "/src/dependency.ts": `export const value = "memory";`, - })), - }); - using program = snapshot.createProgram(["/src/main.ts"], options); - assert.equal((program.getSourceFile("/src/dependency.ts"))?.text, `export const value = "memory";`); - - using updated = snapshot.update({ - fileSystem: createCacheFileSystem(Object.entries({ - "/src/dependency.ts": `export const value = "updated";`, - })), - }); - using updatedProgram = updated.createProgram( - ["/src/main.ts"], - options, - program, - { changed: ["/src/dependency.ts"] }, - ); - assert.equal((updatedProgram.getSourceFile("/src/dependency.ts"))?.text, `export const value = "updated";`); - assert.deepEqual(callbackCalls, []); - } - finally { - api.close(); - } - }); - - test("Snapshot.createProgram rebuilds an old program from a different snapshot when changes are omitted", () => { - const api = new API({ - cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), - }); - const options = { compilerOptions: { noLib: true } }; - try { - using base = api.updateSnapshot({ - fileSystem: createMemoryFileSystem([["/src/main.ts", `export const source = "base";`]]), - }); - using newer = base.update({ - fileSystem: createMemoryFileSystem([["/src/main.ts", `export const source = "newer";`]]), - }); - using oldProgram = newer.createProgram(["/src/main.ts"], options); - using rebuilt = base.createProgram(["/src/main.ts"], options, oldProgram); - assert.equal((rebuilt.getSourceFile("/src/main.ts"))?.text, `export const source = "base";`); - } - finally { - api.close(); - } - }); - test("memory filesystem emit returns outputs without mutating the host", () => { const hostWrites: string[] = []; const api = new API({ @@ -4140,14 +4076,13 @@ describe("updateSnapshot file systems", () => { }); try { using snapshot = api.updateSnapshot({ + openProject: "/tsconfig.json", fileSystem: createMemoryFileSystem(Object.entries({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, outDir: "/out", rootDir: "/src" }, files: ["src/main.ts"] }), "/src/main.ts": `export const value: number = 1;`, })), }); - using program = snapshot.createProgram( - ["/src/main.ts"], - { compilerOptions: { noLib: true, outDir: "/out" } }, - ); + const program = snapshot.getProject("/tsconfig.json")!.program; const result = program.emit(); assert.deepEqual(result.emittedFiles, ["/out/main.js"]); assert.deepEqual(result.fileSystem, { @@ -4158,13 +4093,10 @@ describe("updateSnapshot file systems", () => { }); assert.deepEqual(hostWrites, []); - using updated = snapshot.update({ fileSystem: result.fileSystem! }); - using updatedProgram = updated.createProgram( - ["/src/main.ts", "/out/main.js"], - { compilerOptions: { allowJs: true, noLib: true } }, - ); - assert.equal((updatedProgram.getSourceFile("/src/main.ts"))?.text, `export const value: number = 1;`); - assert.equal((updatedProgram.getSourceFile("/out/main.js"))?.text, `export const value = 1;\n`); + using updated = snapshot.update({ fileSystem: result.fileSystem!, openFiles: ["/out/main.js"] }); + const outputProject = updated.getDefaultProjectForFile("/out/main.js"); + assert.equal((updated.getProject("/tsconfig.json")!.program.getSourceFile("/src/main.ts"))?.text, `export const value: number = 1;`); + assert.equal((outputProject?.program.getSourceFile("/out/main.js"))?.text, `export const value = 1;\n`); } finally { api.close(); @@ -4179,14 +4111,13 @@ describe("updateSnapshot file systems", () => { }); try { using snapshot = api.updateSnapshot({ + openProject: "/tsconfig.json", fileSystem: createCacheFileSystem(Object.entries({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, outDir: "/out", rootDir: "/src" }, files: ["src/main.ts"] }), "/src/main.ts": `export const value: number = 1;`, })), }); - using program = snapshot.createProgram( - ["/src/main.ts"], - { compilerOptions: { noLib: true, outDir: "/out" } }, - ); + const program = snapshot.getProject("/tsconfig.json")!.program; const result = program.emit(); assert.equal(result.fileSystem, undefined); assert.equal(host.readFile!("/out/main.js"), `export const value = 1;\n`); diff --git a/tsc/internal/api/proto.go b/tsc/internal/api/proto.go index 9a83806191956..c6deefa6abfb0 100644 --- a/tsc/internal/api/proto.go +++ b/tsc/internal/api/proto.go @@ -384,13 +384,10 @@ type UpdateTemporarySnapshotParams struct { } type CreateProgramParams struct { - RootFiles []DocumentIdentifier `json:"rootFiles"` - CreateProgramOptions CreateProgramOptions `json:"createProgramOptions"` - // BaseSnapshot supplies the filesystem and project state from which the - // synthetic program snapshot is cloned. - BaseSnapshot SnapshotID `json:"baseSnapshot,omitempty"` - OldProgram *CreateProgramOldProgramParams `json:"oldProgram,omitempty"` - FileChanges *APIFileChanges `json:"fileChanges,omitempty"` + RootFiles []DocumentIdentifier `json:"rootFiles"` + CreateProgramOptions CreateProgramOptions `json:"createProgramOptions"` + OldProgram *CreateProgramOldProgramParams `json:"oldProgram,omitempty"` + FileChanges *APIFileChanges `json:"fileChanges,omitempty"` } type CreateProgramOptions struct { diff --git a/tsc/internal/api/session.go b/tsc/internal/api/session.go index 48099ed65039a..0a1ec52d88cf1 100644 --- a/tsc/internal/api/session.go +++ b/tsc/internal/api/session.go @@ -1235,18 +1235,7 @@ func (s *Session) handleCreateProgram(ctx context.Context, params *CreateProgram rootFileNames[i] = rootFile.ToAbsoluteFileName(s.projectSession.GetCurrentDirectory()) } - var baseSnapshot *project.Snapshot - var baseRequestFileSystem *requestfilesystem.Handle - if params.BaseSnapshot != 0 { - baseSD, err := s.retainSnapshotData(params.BaseSnapshot) - if err != nil { - return nil, err - } - defer func() { _ = s.releaseSnapshot(params.BaseSnapshot) }() - baseSnapshot = baseSD.snapshot - baseRequestFileSystem = baseSD.fileSystemHandle() - } - + var oldSnapshot *project.Snapshot var oldProject *project.Project if params.OldProgram != nil { oldSnapshotID := params.OldProgram.Snapshot @@ -1256,32 +1245,23 @@ func (s *Session) handleCreateProgram(ctx context.Context, params *CreateProgram } defer func() { _ = s.releaseSnapshot(oldSnapshotID) }() - if baseSnapshot == nil { - baseSnapshot = oldSD.snapshot - baseRequestFileSystem = oldSD.fileSystemHandle() - } + oldSnapshot = oldSD.snapshot oldProject, err = oldSD.getProject(params.OldProgram.Project) if err != nil { return nil, err } } sd := newSnapshotData() - sd.fileSystem.CloneFrom(baseRequestFileSystem) fileChanges := s.toFileChangeSummary(params.FileChanges) - if params.BaseSnapshot != 0 && params.OldProgram != nil && params.OldProgram.Snapshot != params.BaseSnapshot && fileChanges.IsEmpty() { - fileChanges.InvalidateAll = true - fileChanges.IncludesWatchChangeOutsideNodeModules = true - } snapshot := s.projectSession.APICreateProgram( ctx, rootFileNames, ¶ms.CreateProgramOptions.CompilerOptions, params.CreateProgramOptions.ProjectReferences, core.Map(params.CreateProgramOptions.ConfigFileParsingDiagnostics, func(d *DiagnosticResponse) *ast.Diagnostic { return d.ToDiagnostic() }), - baseSnapshot, + oldSnapshot, oldProject, - sd.fileSystem.FS(), fileChanges, ) project := snapshot.ProjectCollection.InferredProject() diff --git a/tsc/internal/api/session_createprogram_test.go b/tsc/internal/api/session_createprogram_test.go index 6d94ed378be2d..be053641bef9f 100644 --- a/tsc/internal/api/session_createprogram_test.go +++ b/tsc/internal/api/session_createprogram_test.go @@ -4,7 +4,6 @@ import ( "context" "testing" - "github.com/microsoft/TypeScript/tsc/internal/api/requestfilesystem" "github.com/microsoft/TypeScript/tsc/internal/core" "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" "github.com/microsoft/TypeScript/tsc/internal/project" @@ -139,97 +138,6 @@ func TestCreateProgramWithNoRootFiles(t *testing.T) { assert.Equal(t, len(project.Program.GetSourceFiles()), 0) } -func TestCreateProgramFromRequestFileSystem(t *testing.T) { - t.Parallel() - - const fileName = "/src/index.ts" - projectSession, _ := projecttestutil.Setup(map[string]any{ - fileName: `export const source = "host";`, - }) - defer projectSession.Close() - session := NewSession(projectSession, nil) - defer session.Close() - ctx := context.Background() - - base, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ - FileSystem: &requestfilesystem.RequestFileSystem{ - Kind: requestfilesystem.KindMemory, - Files: map[string]string{ - fileName: `export const source = "memory";`, - }, - }, - }) - assert.NilError(t, err) - - response, err := session.handleCreateProgram(ctx, &CreateProgramParams{ - RootFiles: []DocumentIdentifier{{FileName: fileName}}, - BaseSnapshot: base.Snapshot, - CreateProgramOptions: CreateProgramOptions{ - CompilerOptions: core.CompilerOptions{NoLib: core.TSTrue}, - }, - }) - assert.NilError(t, err) - created, err := session.getSnapshotData(response.Snapshot) - assert.NilError(t, err) - baseData, err := session.getSnapshotData(base.Snapshot) - assert.NilError(t, err) - assert.Assert(t, created.fileSystemHandle() != baseData.fileSystemHandle()) - program := created.snapshot.ProjectCollection.InferredProject().Program - assert.Equal(t, program.GetSourceFile(fileName).Text(), `export const source = "memory";`) -} - -func TestCreateProgramRebuildsOldProgramFromDifferentBaseSnapshot(t *testing.T) { - t.Parallel() - - const fileName = "/src/index.ts" - projectSession, _ := projecttestutil.Setup(map[string]any{}) - defer projectSession.Close() - session := NewSession(projectSession, nil) - defer session.Close() - ctx := context.Background() - - base, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ - FileSystem: &requestfilesystem.RequestFileSystem{ - Kind: requestfilesystem.KindMemory, - Files: map[string]string{fileName: `export const source = "base";`}, - }, - }) - assert.NilError(t, err) - newer, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ - Snapshot: base.Snapshot, - FileSystem: &requestfilesystem.RequestFileSystem{ - Kind: requestfilesystem.KindMemory, - Files: map[string]string{fileName: `export const source = "newer";`}, - }, - }) - assert.NilError(t, err) - oldProgram, err := session.handleCreateProgram(ctx, &CreateProgramParams{ - RootFiles: []DocumentIdentifier{{FileName: fileName}}, - BaseSnapshot: newer.Snapshot, - CreateProgramOptions: CreateProgramOptions{ - CompilerOptions: core.CompilerOptions{NoLib: core.TSTrue}, - }, - }) - assert.NilError(t, err) - - response, err := session.handleCreateProgram(ctx, &CreateProgramParams{ - RootFiles: []DocumentIdentifier{{FileName: fileName}}, - BaseSnapshot: base.Snapshot, - CreateProgramOptions: CreateProgramOptions{ - CompilerOptions: core.CompilerOptions{NoLib: core.TSTrue}, - }, - OldProgram: &CreateProgramOldProgramParams{ - Snapshot: oldProgram.Snapshot, - Project: oldProgram.Project.Id, - }, - }) - assert.NilError(t, err) - created, err := session.getSnapshotData(response.Snapshot) - assert.NilError(t, err) - program := created.snapshot.ProjectCollection.InferredProject().Program - assert.Equal(t, program.GetSourceFile(fileName).Text(), `export const source = "base";`) -} - func TestCreateProgramRemovesAllRootFiles(t *testing.T) { t.Parallel() diff --git a/tsc/internal/project/api.go b/tsc/internal/project/api.go index 3e4ef70dfd307..57119e3b7663d 100644 --- a/tsc/internal/project/api.go +++ b/tsc/internal/project/api.go @@ -95,13 +95,9 @@ func (s *Session) APICreateProgram( configFileParsingDiagnostics []*ast.Diagnostic, oldSnapshot *Snapshot, oldProject *Project, - fileSystem vfs.FS, fileChanges FileChangeSummary, ) *Snapshot { if oldSnapshot != nil { - if fileSystem == nil { - fileSystem = oldSnapshot.fs.fs - } return oldSnapshot.cloneForProgram( ctx, rootFileNames, @@ -109,7 +105,6 @@ func (s *Session) APICreateProgram( projectReferences, configFileParsingDiagnostics, oldProject, - fileSystem, fileChanges, s, ) @@ -117,9 +112,6 @@ func (s *Session) APICreateProgram( snapshot, _ := s.APIUpdate(ctx, fileChanges, nil) defer snapshot.Deref(s) - if fileSystem == nil { - fileSystem = snapshot.fs.fs - } return snapshot.cloneForProgram( ctx, rootFileNames, @@ -127,7 +119,6 @@ func (s *Session) APICreateProgram( projectReferences, configFileParsingDiagnostics, nil, - fileSystem, fileChanges, s, ) diff --git a/tsc/internal/project/refcountcache_test.go b/tsc/internal/project/refcountcache_test.go index 26e518fdb1152..0b43a78014ce4 100644 --- a/tsc/internal/project/refcountcache_test.go +++ b/tsc/internal/project/refcountcache_test.go @@ -514,13 +514,10 @@ func TestRefCountingCaches(t *testing.T) { ctx := context.Background() baseSnapshot, err := session.APIUpdate(ctx, FileChangeSummary{}, &APISnapshotRequest{ - OpenProjects: collections.NewSetFromItems(appConfigPath), - FileSystem: session.fs.fs, - ReplaceFileSystem: true, + OpenProjects: collections.NewSetFromItems(appConfigPath), }) assert.NilError(t, err) defer baseSnapshot.Deref(session) - assert.Assert(t, baseSnapshot.fileSystemOverride) appProject := baseSnapshot.ProjectCollection.GetProjectByPath(baseSnapshot.toPath(appConfigPath)) assert.Assert(t, appProject != nil) @@ -532,11 +529,9 @@ func TestRefCountingCaches(t *testing.T) { appProject.CommandLine.Errors, baseSnapshot, appProject, - nil, FileChangeSummary{}, ) defer programSnapshot.Deref(session) - assert.Assert(t, programSnapshot.fileSystemOverride) programProject := programSnapshot.ProjectCollection.InferredProject() assert.Assert(t, programProject != nil) assert.Assert(t, programProject.Program == appProject.Program) @@ -563,11 +558,9 @@ func TestRefCountingCaches(t *testing.T) { programProject.CommandLine.Errors, programSnapshot, programProject, - nil, fileChanges, ) defer updatedProgramSnapshot.Deref(session) - assert.Assert(t, updatedProgramSnapshot.fileSystemOverride) updatedProgramProject := updatedProgramSnapshot.ProjectCollection.InferredProject() assert.Assert(t, updatedProgramProject != nil) assert.Assert(t, updatedProgramProject.Program != programProject.Program) diff --git a/tsc/internal/project/snapshot.go b/tsc/internal/project/snapshot.go index 6823e94d1a586..62f3a6d0fc097 100644 --- a/tsc/internal/project/snapshot.go +++ b/tsc/internal/project/snapshot.go @@ -122,7 +122,6 @@ func (s *Snapshot) cloneForProgram( projectReferences []*core.ProjectReference, configFileParsingDiagnostics []*ast.Diagnostic, oldProject *Project, - fileSystem vfs.FS, fileChanges FileChangeSummary, session *Session, ) *Snapshot { @@ -139,7 +138,7 @@ func (s *Snapshot) cloneForProgram( } start := time.Now() - fs := newSnapshotFSBuilder(fileSystem, s.fs.overlays, s.fs.overlays, s.fs.diskFiles, s.fs.diskDirectories, s.fs.nodeModulesRealpathAliases, session.options.PositionEncoding, s.toPath) + fs := newSnapshotFSBuilder(session.fs.fs, s.fs.overlays, s.fs.overlays, s.fs.diskFiles, s.fs.diskDirectories, s.fs.nodeModulesRealpathAliases, session.options.PositionEncoding, s.toPath) fileChanges = s.processFileChanges(fs, fileChanges, logger, nil) newSnapshotID := session.snapshotID.Add(1) @@ -226,7 +225,6 @@ func (s *Snapshot) cloneForProgram( newSnapshot.inferredProjectContentMappers = s.inferredProjectContentMappers newSnapshot.inferredProjectContentMapperExtensions = s.inferredProjectContentMapperExtensions newSnapshot.builderLogs = logger - newSnapshot.fileSystemOverride = s.fileSystemOverride for _, project := range newSnapshot.ProjectCollection.Projects() { if project.Program != nil { From 9be6f68daae98993c362e8e19e1b7cec60ff0cbd Mon Sep 17 00:00:00 2001 From: Wesley Wigham Date: Tue, 1 Sep 2026 20:04:07 -0700 Subject: [PATCH 06/12] Remove dead method --- tsc/internal/vfs/cachedvfs/cachedvfs.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tsc/internal/vfs/cachedvfs/cachedvfs.go b/tsc/internal/vfs/cachedvfs/cachedvfs.go index 356c6eb2b410f..7128d24d6bda7 100644 --- a/tsc/internal/vfs/cachedvfs/cachedvfs.go +++ b/tsc/internal/vfs/cachedvfs/cachedvfs.go @@ -27,11 +27,6 @@ func From(fs vfs.FS) *FS { return fsys } -// Unwrap returns the filesystem wrapped by this cache. -func (fsys *FS) Unwrap() vfs.FS { - return fsys.fs -} - func (fsys *FS) DisableAndClearCache() { if fsys.enabled.CompareAndSwap(true, false) { fsys.ClearCache() From ce5172dd01559ce3509813f759e5f5b633e32b22 Mon Sep 17 00:00:00 2001 From: Wesley Wigham Date: Tue, 1 Sep 2026 21:10:36 -0700 Subject: [PATCH 07/12] Swap mutex to atomics Just for fun, really. This is probably never going to be a bottleneck. It's just well-scoped. --- .../requestfilesystem_test.go | 46 +++- .../requestfilesystemhandle.go | 205 +++++++++++++----- 2 files changed, 194 insertions(+), 57 deletions(-) diff --git a/tsc/internal/api/requestfilesystem/requestfilesystem_test.go b/tsc/internal/api/requestfilesystem/requestfilesystem_test.go index faa0d63faa91f..795b3bd7e3325 100644 --- a/tsc/internal/api/requestfilesystem/requestfilesystem_test.go +++ b/tsc/internal/api/requestfilesystem/requestfilesystem_test.go @@ -1,6 +1,7 @@ package requestfilesystem import ( + "sync" "testing" "github.com/microsoft/TypeScript/tsc/internal/project" @@ -27,9 +28,7 @@ func newLayeredRequestFileSystem(params *RequestFileSystem, base vfs.FS, current } func (h *Handle) applyTo(base *Handle) { - requestFileSystemDependenciesMu.Lock() - defer requestFileSystemDependenciesMu.Unlock() - h.applyToLocked(base) + h.compactBase(base) } func TestInitializeForUpdate(t *testing.T) { @@ -76,6 +75,47 @@ func TestInitializeForUpdate(t *testing.T) { }) } +func TestConcurrentCloneAndRelease(t *testing.T) { + t.Parallel() + + host := vfstest.FromMap(map[string]string{}, true) + for range 100 { + base, err := newRequestFileSystem(&RequestFileSystem{ + Kind: KindMemory, + Files: map[string]string{"/base.ts": "base"}, + }, host, "/") + assert.NilError(t, err) + layered, err := newLayeredRequestFileSystem(&RequestFileSystem{ + Kind: KindCache, + Files: map[string]string{"/layered.ts": "layered"}, + }, base, "/") + assert.NilError(t, err) + + var clone Handle + start := make(chan struct{}) + var waitGroup sync.WaitGroup + waitGroup.Go(func() { + <-start + clone.CloneFrom(layered) + }) + waitGroup.Go(func() { + <-start + base.Release() + }) + close(start) + waitGroup.Wait() + + layered.Release() + contents, ok := clone.ReadFile("/base.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, "base") + contents, ok = clone.ReadFile("/layered.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, "layered") + clone.Release() + } +} + func TestRequestFileSystem(t *testing.T) { t.Parallel() diff --git a/tsc/internal/api/requestfilesystem/requestfilesystemhandle.go b/tsc/internal/api/requestfilesystem/requestfilesystemhandle.go index b83e45866979e..46811dc0d070a 100644 --- a/tsc/internal/api/requestfilesystem/requestfilesystemhandle.go +++ b/tsc/internal/api/requestfilesystem/requestfilesystemhandle.go @@ -1,36 +1,59 @@ package requestfilesystem import ( - "sync" "sync/atomic" "time" + "github.com/microsoft/TypeScript/tsc/internal/collections" "github.com/microsoft/TypeScript/tsc/internal/project" "github.com/microsoft/TypeScript/tsc/internal/vfs" ) -// This can be replaced with per-handle mutexes if contention is high in practice. -var requestFileSystemDependenciesMu sync.Mutex - // Handle is a request filesystem whose backing layers can be compacted as snapshots are released. type Handle struct { - value atomic.Pointer[requestFileSystem] - dependents map[*Handle]struct{} + value atomic.Pointer[requestFileSystem] + dependencies atomic.Pointer[dependencyState] } -func (h *Handle) load() *requestFileSystem { - return h.value.Load() +// dependencyState is immutable once published. The dependency graph has a forward edge +// from a child's value to its base and a reverse edge from the base to the child: +// +// child base +// ----- ---- +// registerWithBase +// base := layeredBase(value) +// ---------------- addDependent(child) --> CAS active[D] -> active[D + child] +// verify value still points to base +// -- if not: removeDependent(child) ------> CAS active[D + child] -> active[D] +// +// releaseDependencies closes the reverse-edge set with CAS active[D] -> released and +// gives D to the releaser. Registration racing that CAS is resolved as follows: +// +// addDependent wins releaseDependencies wins +// ----------------- ------------------------ +// release CAS retries and claims addDependent observes released +// a set containing the child and returns false +// | | +// +----------> child.compactBase(base) <---+ +// +// compactBase CASes child.value from a layer over base to the merged value, calls +// base.removeDependent(child) to discard the old reverse edge, then calls +// child.registerWithBase() to register the merged value's new base. Stale reverse edges +// are harmless because compactBase first verifies that child.value still points to base. +// A nil dependencies pointer means active with no dependents; released is terminal. +type dependencyState struct { + released bool + dependents *collections.Set[*Handle] } -func (h *Handle) store(value *requestFileSystem) { - h.value.Store(value) +func (h *Handle) load() *requestFileSystem { + return h.value.Load() } func (h *Handle) initialize(value requestFileSystem) { - if h.Initialized() { + if !h.value.CompareAndSwap(nil, &value) { panic("request filesystem handle already initialized") } - h.store(&value) h.registerWithBase() } @@ -92,21 +115,11 @@ func (h *Handle) CloneFrom(source *Handle) { if source == nil { return } - if h.Initialized() { + value := *source.load() + if !h.value.CompareAndSwap(nil, &value) { panic("request filesystem handle already initialized") } - requestFileSystemDependenciesMu.Lock() - defer requestFileSystemDependenciesMu.Unlock() - value := *source.load() - h.store(&value) - h.registerWithBaseLocked() -} - -func (h *Handle) applyToLocked(base *Handle) { - h.unregisterFromBaseLocked() - value := h.load().applyTo(*base.load()) - h.store(&value) - h.registerWithBaseLocked() + h.registerWithBase() } // Release removes this handle from the dependency graph and compacts live dependents. @@ -117,53 +130,137 @@ func (h *Handle) Release() { if h.load() == nil { return } - requestFileSystemDependenciesMu.Lock() - defer requestFileSystemDependenciesMu.Unlock() - if h.load() == nil { + dependents, released := h.releaseDependencies() + if !released { return } - h.compactDependentsLocked() - h.unregisterFromBaseLocked() + + h.compactDependents(dependents) + h.unregisterFromBase() } func (h *Handle) registerWithBase() { - requestFileSystemDependenciesMu.Lock() - defer requestFileSystemDependenciesMu.Unlock() - h.registerWithBaseLocked() + for { + if h.isReleased() { + return + } + base := h.layeredBase() + if base == nil { + return + } + if !base.addDependent(h) { + h.compactBase(base) + continue + } + if h.isReleased() { + base.removeDependent(h) + return + } + if h.layeredBase() == base { + return + } + base.removeDependent(h) + } } -func (h *Handle) registerWithBaseLocked() { - base := h.layeredBase() - if base == nil { - return +func (h *Handle) unregisterFromBase() { + if base := h.layeredBase(); base != nil { + base.removeDependent(h) + } +} + +func (h *Handle) layeredBase() *Handle { + return layeredBase(h.load()) +} + +func (h *Handle) compactBase(base *Handle) bool { + for { + value := h.load() + if layeredBase(value) != base { + return false + } + compacted := value.applyTo(*base.load()) + if h.value.CompareAndSwap(value, &compacted) { + base.removeDependent(h) + h.registerWithBase() + return true + } } - if base.dependents == nil { - base.dependents = make(map[*Handle]struct{}) +} + +func (h *Handle) compactDependents(dependents *collections.Set[*Handle]) { + for dependent := range dependents.Keys() { + state := dependent.dependencies.Load() + if dependent.compactBase(h) && state != nil { + dependent.compactDependents(state.dependents) + } } - base.dependents[h] = struct{}{} } -func (h *Handle) unregisterFromBaseLocked() { - if base := h.layeredBase(); base != nil { - delete(base.dependents, h) +func (h *Handle) addDependent(dependent *Handle) bool { + for { + state := h.dependencies.Load() + if state != nil { + if state.released { + return false + } + if state.dependents.Has(dependent) { + return true + } + } + dependents := collections.NewSetWithSizeHint[*Handle](1) + if state != nil { + dependents = state.dependents.Clone() + } + dependents.Add(dependent) + if h.dependencies.CompareAndSwap(state, &dependencyState{dependents: dependents}) { + return true + } } } -func (h *Handle) layeredBase() *Handle { - value := h.load() - if !value.layered { - return nil +func (h *Handle) removeDependent(dependent *Handle) { + for { + state := h.dependencies.Load() + if state == nil || state.released { + return + } + if !state.dependents.Has(dependent) { + return + } + dependents := state.dependents.Clone() + dependents.Delete(dependent) + if h.dependencies.CompareAndSwap(state, &dependencyState{dependents: dependents}) { + return + } } - return getRequestFileSystem(value.baseFileSystem()) } -func (h *Handle) compactDependentsLocked() { - for dependent := range h.dependents { - dependent.applyToLocked(h) - dependent.compactDependentsLocked() - h.compactDependentsLocked() - return +func (h *Handle) releaseDependencies() (*collections.Set[*Handle], bool) { + for { + state := h.dependencies.Load() + if state != nil && state.released { + return nil, false + } + if h.dependencies.CompareAndSwap(state, &dependencyState{released: true}) { + if state == nil { + return nil, true + } + return state.dependents, true + } + } +} + +func (h *Handle) isReleased() bool { + state := h.dependencies.Load() + return state != nil && state.released +} + +func layeredBase(value *requestFileSystem) *Handle { + if !value.layered { + return nil } + return getRequestFileSystem(value.baseFileSystem()) } func (h *Handle) baseFileSystem() vfs.FS { From 246e31f210a61e04497a77aaada5b2a4f2e5b772 Mon Sep 17 00:00:00 2001 From: Wesley Wigham Date: Tue, 1 Sep 2026 21:29:44 -0700 Subject: [PATCH 08/12] Know what, try all 3 options I've looked at and bench em --- .../requestfilesystemhandle_bench_test.go | 445 ++++++++++++++++++ 1 file changed, 445 insertions(+) create mode 100644 tsc/internal/api/requestfilesystem/requestfilesystemhandle_bench_test.go diff --git a/tsc/internal/api/requestfilesystem/requestfilesystemhandle_bench_test.go b/tsc/internal/api/requestfilesystem/requestfilesystemhandle_bench_test.go new file mode 100644 index 0000000000000..ad0726cd30dd6 --- /dev/null +++ b/tsc/internal/api/requestfilesystem/requestfilesystemhandle_bench_test.go @@ -0,0 +1,445 @@ +package requestfilesystem + +import ( + "sync" + "sync/atomic" + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/tspath" + "github.com/microsoft/TypeScript/tsc/internal/vfs" + "github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest" +) + +var legacyBenchmarkDependenciesMu sync.Mutex + +type legacyBenchmarkHandle struct { + vfs.FS + value atomic.Pointer[requestFileSystem] + dependents map[*legacyBenchmarkHandle]struct{} +} + +func (h *legacyBenchmarkHandle) initialize(value requestFileSystem) { + h.value.Store(&value) + h.registerWithBase() +} + +func (h *legacyBenchmarkHandle) cloneFrom(source *legacyBenchmarkHandle) { + if source == nil { + return + } + if h.value.Load() != nil { + panic("request filesystem handle already initialized") + } + legacyBenchmarkDependenciesMu.Lock() + defer legacyBenchmarkDependenciesMu.Unlock() + value := *source.value.Load() + h.value.Store(&value) + h.registerWithBaseLocked() +} + +func (h *legacyBenchmarkHandle) release() { + if h == nil || h.value.Load() == nil { + return + } + legacyBenchmarkDependenciesMu.Lock() + defer legacyBenchmarkDependenciesMu.Unlock() + if h.value.Load() == nil { + return + } + h.compactDependentsLocked() + h.unregisterFromBaseLocked() +} + +func (h *legacyBenchmarkHandle) registerWithBase() { + legacyBenchmarkDependenciesMu.Lock() + defer legacyBenchmarkDependenciesMu.Unlock() + h.registerWithBaseLocked() +} + +func (h *legacyBenchmarkHandle) registerWithBaseLocked() { + base := h.layeredBase() + if base == nil { + return + } + if base.dependents == nil { + base.dependents = make(map[*legacyBenchmarkHandle]struct{}) + } + base.dependents[h] = struct{}{} +} + +func (h *legacyBenchmarkHandle) unregisterFromBaseLocked() { + if base := h.layeredBase(); base != nil { + delete(base.dependents, h) + } +} + +func (h *legacyBenchmarkHandle) layeredBase() *legacyBenchmarkHandle { + value := h.value.Load() + if !value.layered { + return nil + } + base, _ := value.baseFileSystem().(*legacyBenchmarkHandle) + return base +} + +func (h *legacyBenchmarkHandle) UseCaseSensitiveFileNames() bool { + return h.value.Load().UseCaseSensitiveFileNames() +} + +func (h *legacyBenchmarkHandle) applyToLocked(base *legacyBenchmarkHandle) { + h.unregisterFromBaseLocked() + value := h.value.Load().applyTo(*base.value.Load()) + h.value.Store(&value) + h.registerWithBaseLocked() +} + +func (h *legacyBenchmarkHandle) compactDependentsLocked() { + for dependent := range h.dependents { + dependent.applyToLocked(h) + dependent.compactDependentsLocked() + h.compactDependentsLocked() + return + } +} + +type perHandleMutexBenchmarkHandle struct { + vfs.FS + mu sync.Mutex + value atomic.Pointer[requestFileSystem] + dependents map[*perHandleMutexBenchmarkHandle]struct{} + released bool +} + +func (h *perHandleMutexBenchmarkHandle) initialize(value requestFileSystem) { + h.mu.Lock() + defer h.mu.Unlock() + if h.value.Load() != nil { + panic("request filesystem handle already initialized") + } + h.value.Store(&value) + h.registerWithBaseLocked() +} + +func (h *perHandleMutexBenchmarkHandle) cloneFrom(source *perHandleMutexBenchmarkHandle) { + if source == nil { + return + } + value := *source.value.Load() + h.mu.Lock() + defer h.mu.Unlock() + if h.value.Load() != nil { + panic("request filesystem handle already initialized") + } + h.value.Store(&value) + h.registerWithBaseLocked() +} + +func (h *perHandleMutexBenchmarkHandle) release() { + if h == nil || h.value.Load() == nil { + return + } + h.mu.Lock() + if h.released { + h.mu.Unlock() + return + } + h.released = true + h.mu.Unlock() + h.compactDependents() + h.unregisterFromBase() +} + +func (h *perHandleMutexBenchmarkHandle) registerWithBaseLocked() { + for { + base := h.layeredBase() + if base == nil { + return + } + base.mu.Lock() + if base.released { + value := h.value.Load().applyTo(*base.value.Load()) + h.value.Store(&value) + base.mu.Unlock() + continue + } + if base.dependents == nil { + base.dependents = make(map[*perHandleMutexBenchmarkHandle]struct{}) + } + base.dependents[h] = struct{}{} + base.mu.Unlock() + return + } +} + +func (h *perHandleMutexBenchmarkHandle) unregisterFromBase() { + if base := h.layeredBase(); base != nil { + base.mu.Lock() + delete(base.dependents, h) + base.mu.Unlock() + } +} + +func (h *perHandleMutexBenchmarkHandle) layeredBase() *perHandleMutexBenchmarkHandle { + value := h.value.Load() + if !value.layered { + return nil + } + base, _ := value.baseFileSystem().(*perHandleMutexBenchmarkHandle) + return base +} + +func (h *perHandleMutexBenchmarkHandle) UseCaseSensitiveFileNames() bool { + return h.value.Load().UseCaseSensitiveFileNames() +} + +func (h *perHandleMutexBenchmarkHandle) compactDependents() { + for { + h.mu.Lock() + var dependent *perHandleMutexBenchmarkHandle + for candidate := range h.dependents { + dependent = candidate + delete(h.dependents, candidate) + break + } + value := h.value.Load() + h.mu.Unlock() + if dependent == nil { + return + } + dependent.mu.Lock() + if !dependent.released && dependent.layeredBase() == h { + compacted := dependent.value.Load().applyTo(*value) + dependent.value.Store(&compacted) + dependent.registerWithBaseLocked() + } + dependent.mu.Unlock() + dependent.compactDependents() + } +} + +func benchmarkRootValue(host vfs.FS) requestFileSystem { + return requestFileSystem{ + kind: KindMemory, + base: host, + currentDirectory: "/", + useCaseSensitiveNames: host.UseCaseSensitiveFileNames(), + files: make(map[tspath.Path]requestFile), + } +} + +func benchmarkLayerValue(base vfs.FS) requestFileSystem { + return requestFileSystem{ + kind: KindCache, + base: base, + layered: true, + currentDirectory: "/", + useCaseSensitiveNames: base.UseCaseSensitiveFileNames(), + } +} + +func benchmarkUpdateValue(base vfs.FS) requestFileSystem { + value := benchmarkLayerValue(base) + value.files = map[tspath.Path]requestFile{ + "/index.ts": {fileName: "/index.ts", content: "export const value = 1"}, + } + return value +} + +func newAtomicBenchmarkRoot(host vfs.FS) *Handle { + handle := &Handle{} + handle.initialize(benchmarkRootValue(host)) + return handle +} + +func newAtomicBenchmarkLayer(base *Handle) *Handle { + handle := &Handle{} + handle.initialize(benchmarkLayerValue(base)) + return handle +} + +func newAtomicBenchmarkUpdate(base *Handle) *Handle { + handle := &Handle{} + handle.initialize(benchmarkUpdateValue(base)) + return handle +} + +func newLegacyBenchmarkRoot(host vfs.FS) *legacyBenchmarkHandle { + handle := &legacyBenchmarkHandle{FS: host} + handle.initialize(benchmarkRootValue(host)) + return handle +} + +func newLegacyBenchmarkLayer(base *legacyBenchmarkHandle) *legacyBenchmarkHandle { + handle := &legacyBenchmarkHandle{FS: base.FS} + handle.initialize(benchmarkLayerValue(base)) + return handle +} + +func newLegacyBenchmarkUpdate(base *legacyBenchmarkHandle) *legacyBenchmarkHandle { + handle := &legacyBenchmarkHandle{FS: base.FS} + handle.initialize(benchmarkUpdateValue(base)) + return handle +} + +func newPerHandleMutexBenchmarkRoot(host vfs.FS) *perHandleMutexBenchmarkHandle { + handle := &perHandleMutexBenchmarkHandle{FS: host} + handle.initialize(benchmarkRootValue(host)) + return handle +} + +func newPerHandleMutexBenchmarkLayer(base *perHandleMutexBenchmarkHandle) *perHandleMutexBenchmarkHandle { + handle := &perHandleMutexBenchmarkHandle{FS: base.FS} + handle.initialize(benchmarkLayerValue(base)) + return handle +} + +func newPerHandleMutexBenchmarkUpdate(base *perHandleMutexBenchmarkHandle) *perHandleMutexBenchmarkHandle { + handle := &perHandleMutexBenchmarkHandle{FS: base.FS} + handle.initialize(benchmarkUpdateValue(base)) + return handle +} + +func BenchmarkHandleDependencies(b *testing.B) { + host := vfstest.FromMap(map[string]string{}, true) + benchmarkIndependentChains(b, host) + benchmarkSharedBase(b, host) + benchmarkTypicalUpdates(b, host) +} + +func benchmarkIndependentChains(b *testing.B, host vfs.FS) { + b.Run("IndependentChains/Atomic", func(b *testing.B) { + b.ReportAllocs() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + root := newAtomicBenchmarkRoot(host) + middle := newAtomicBenchmarkLayer(root) + leaf := newAtomicBenchmarkLayer(middle) + var clone Handle + clone.CloneFrom(leaf) + root.Release() + middle.Release() + leaf.Release() + clone.Release() + } + }) + }) + + b.Run("IndependentChains/PerHandleMutex", func(b *testing.B) { + b.ReportAllocs() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + root := newPerHandleMutexBenchmarkRoot(host) + middle := newPerHandleMutexBenchmarkLayer(root) + leaf := newPerHandleMutexBenchmarkLayer(middle) + clone := &perHandleMutexBenchmarkHandle{FS: host} + clone.cloneFrom(leaf) + root.release() + middle.release() + leaf.release() + clone.release() + } + }) + }) + + b.Run("IndependentChains/GlobalMutex", func(b *testing.B) { + b.ReportAllocs() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + root := newLegacyBenchmarkRoot(host) + middle := newLegacyBenchmarkLayer(root) + leaf := newLegacyBenchmarkLayer(middle) + clone := &legacyBenchmarkHandle{FS: host} + clone.cloneFrom(leaf) + root.release() + middle.release() + leaf.release() + clone.release() + } + }) + }) +} + +func benchmarkSharedBase(b *testing.B, host vfs.FS) { + b.Run("SharedBase/Atomic", func(b *testing.B) { + base := newAtomicBenchmarkRoot(host) + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + dependent := newAtomicBenchmarkLayer(base) + dependent.Release() + } + }) + b.StopTimer() + base.Release() + }) + + b.Run("SharedBase/PerHandleMutex", func(b *testing.B) { + base := newPerHandleMutexBenchmarkRoot(host) + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + dependent := newPerHandleMutexBenchmarkLayer(base) + dependent.release() + } + }) + b.StopTimer() + base.release() + }) + + b.Run("SharedBase/GlobalMutex", func(b *testing.B) { + base := newLegacyBenchmarkRoot(host) + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + dependent := newLegacyBenchmarkLayer(base) + dependent.release() + } + }) + b.StopTimer() + base.release() + }) +} + +func benchmarkTypicalUpdates(b *testing.B, host vfs.FS) { + b.Run("TypicalUpdates/Atomic", func(b *testing.B) { + current := newAtomicBenchmarkRoot(host) + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + next := newAtomicBenchmarkUpdate(current) + current.Release() + current = next + } + b.StopTimer() + current.Release() + }) + + b.Run("TypicalUpdates/PerHandleMutex", func(b *testing.B) { + current := newPerHandleMutexBenchmarkRoot(host) + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + next := newPerHandleMutexBenchmarkUpdate(current) + current.release() + current = next + } + b.StopTimer() + current.release() + }) + + b.Run("TypicalUpdates/GlobalMutex", func(b *testing.B) { + current := newLegacyBenchmarkRoot(host) + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + next := newLegacyBenchmarkUpdate(current) + current.release() + current = next + } + b.StopTimer() + current.release() + }) +} From 301d5649fbdbd2dfb14a7a912cfff823f97ffc22 Mon Sep 17 00:00:00 2001 From: Wesley Wigham Date: Tue, 1 Sep 2026 21:41:33 -0700 Subject: [PATCH 09/12] Sad, the individual mutexes win, the least elegant feeling one --- .../requestfilesystem_test.go | 9 +- .../requestfilesystemhandle.go | 190 +++----- .../requestfilesystemhandle_bench_test.go | 445 ------------------ 3 files changed, 63 insertions(+), 581 deletions(-) delete mode 100644 tsc/internal/api/requestfilesystem/requestfilesystemhandle_bench_test.go diff --git a/tsc/internal/api/requestfilesystem/requestfilesystem_test.go b/tsc/internal/api/requestfilesystem/requestfilesystem_test.go index 795b3bd7e3325..8eb79c6b8c9b7 100644 --- a/tsc/internal/api/requestfilesystem/requestfilesystem_test.go +++ b/tsc/internal/api/requestfilesystem/requestfilesystem_test.go @@ -28,7 +28,14 @@ func newLayeredRequestFileSystem(params *RequestFileSystem, base vfs.FS, current } func (h *Handle) applyTo(base *Handle) { - h.compactBase(base) + h.mu.Lock() + defer h.mu.Unlock() + base.mu.Lock() + delete(base.dependents, h) + value := h.load().applyTo(*base.load()) + base.mu.Unlock() + h.value.Store(&value) + h.registerWithBaseLocked() } func TestInitializeForUpdate(t *testing.T) { diff --git a/tsc/internal/api/requestfilesystem/requestfilesystemhandle.go b/tsc/internal/api/requestfilesystem/requestfilesystemhandle.go index 46811dc0d070a..9e821b33acdad 100644 --- a/tsc/internal/api/requestfilesystem/requestfilesystemhandle.go +++ b/tsc/internal/api/requestfilesystem/requestfilesystemhandle.go @@ -1,49 +1,20 @@ package requestfilesystem import ( + "sync" "sync/atomic" "time" - "github.com/microsoft/TypeScript/tsc/internal/collections" "github.com/microsoft/TypeScript/tsc/internal/project" "github.com/microsoft/TypeScript/tsc/internal/vfs" ) // Handle is a request filesystem whose backing layers can be compacted as snapshots are released. type Handle struct { - value atomic.Pointer[requestFileSystem] - dependencies atomic.Pointer[dependencyState] -} - -// dependencyState is immutable once published. The dependency graph has a forward edge -// from a child's value to its base and a reverse edge from the base to the child: -// -// child base -// ----- ---- -// registerWithBase -// base := layeredBase(value) -// ---------------- addDependent(child) --> CAS active[D] -> active[D + child] -// verify value still points to base -// -- if not: removeDependent(child) ------> CAS active[D + child] -> active[D] -// -// releaseDependencies closes the reverse-edge set with CAS active[D] -> released and -// gives D to the releaser. Registration racing that CAS is resolved as follows: -// -// addDependent wins releaseDependencies wins -// ----------------- ------------------------ -// release CAS retries and claims addDependent observes released -// a set containing the child and returns false -// | | -// +----------> child.compactBase(base) <---+ -// -// compactBase CASes child.value from a layer over base to the merged value, calls -// base.removeDependent(child) to discard the old reverse edge, then calls -// child.registerWithBase() to register the merged value's new base. Stale reverse edges -// are harmless because compactBase first verifies that child.value still points to base. -// A nil dependencies pointer means active with no dependents; released is terminal. -type dependencyState struct { + mu sync.Mutex + value atomic.Pointer[requestFileSystem] + dependents map[*Handle]struct{} released bool - dependents *collections.Set[*Handle] } func (h *Handle) load() *requestFileSystem { @@ -51,10 +22,13 @@ func (h *Handle) load() *requestFileSystem { } func (h *Handle) initialize(value requestFileSystem) { - if !h.value.CompareAndSwap(nil, &value) { + h.mu.Lock() + defer h.mu.Unlock() + if h.Initialized() { panic("request filesystem handle already initialized") } - h.registerWithBase() + h.value.Store(&value) + h.registerWithBaseLocked() } // Initialized reports whether the handle contains a request filesystem. @@ -116,10 +90,13 @@ func (h *Handle) CloneFrom(source *Handle) { return } value := *source.load() - if !h.value.CompareAndSwap(nil, &value) { + h.mu.Lock() + defer h.mu.Unlock() + if h.Initialized() { panic("request filesystem handle already initialized") } - h.registerWithBase() + h.value.Store(&value) + h.registerWithBaseLocked() } // Release removes this handle from the dependency graph and compacts live dependents. @@ -130,139 +107,82 @@ func (h *Handle) Release() { if h.load() == nil { return } - dependents, released := h.releaseDependencies() - if !released { + h.mu.Lock() + if h.released { + h.mu.Unlock() return } + h.released = true + h.mu.Unlock() - h.compactDependents(dependents) + h.compactDependents() h.unregisterFromBase() } -func (h *Handle) registerWithBase() { +func (h *Handle) registerWithBaseLocked() { for { - if h.isReleased() { - return - } base := h.layeredBase() if base == nil { return } - if !base.addDependent(h) { - h.compactBase(base) + base.mu.Lock() + if base.released { + value := h.load().applyTo(*base.load()) + h.value.Store(&value) + base.mu.Unlock() continue } - if h.isReleased() { - base.removeDependent(h) - return - } - if h.layeredBase() == base { - return + if base.dependents == nil { + base.dependents = make(map[*Handle]struct{}) } - base.removeDependent(h) + base.dependents[h] = struct{}{} + base.mu.Unlock() + return } } func (h *Handle) unregisterFromBase() { if base := h.layeredBase(); base != nil { - base.removeDependent(h) + base.mu.Lock() + delete(base.dependents, h) + base.mu.Unlock() } } func (h *Handle) layeredBase() *Handle { - return layeredBase(h.load()) -} - -func (h *Handle) compactBase(base *Handle) bool { - for { - value := h.load() - if layeredBase(value) != base { - return false - } - compacted := value.applyTo(*base.load()) - if h.value.CompareAndSwap(value, &compacted) { - base.removeDependent(h) - h.registerWithBase() - return true - } - } -} - -func (h *Handle) compactDependents(dependents *collections.Set[*Handle]) { - for dependent := range dependents.Keys() { - state := dependent.dependencies.Load() - if dependent.compactBase(h) && state != nil { - dependent.compactDependents(state.dependents) - } - } -} - -func (h *Handle) addDependent(dependent *Handle) bool { - for { - state := h.dependencies.Load() - if state != nil { - if state.released { - return false - } - if state.dependents.Has(dependent) { - return true - } - } - dependents := collections.NewSetWithSizeHint[*Handle](1) - if state != nil { - dependents = state.dependents.Clone() - } - dependents.Add(dependent) - if h.dependencies.CompareAndSwap(state, &dependencyState{dependents: dependents}) { - return true - } + value := h.load() + if !value.layered { + return nil } + return getRequestFileSystem(value.baseFileSystem()) } -func (h *Handle) removeDependent(dependent *Handle) { +func (h *Handle) compactDependents() { for { - state := h.dependencies.Load() - if state == nil || state.released { - return - } - if !state.dependents.Has(dependent) { - return + h.mu.Lock() + var dependent *Handle + for candidate := range h.dependents { + dependent = candidate + delete(h.dependents, candidate) + break } - dependents := state.dependents.Clone() - dependents.Delete(dependent) - if h.dependencies.CompareAndSwap(state, &dependencyState{dependents: dependents}) { + value := h.load() + h.mu.Unlock() + if dependent == nil { return } - } -} -func (h *Handle) releaseDependencies() (*collections.Set[*Handle], bool) { - for { - state := h.dependencies.Load() - if state != nil && state.released { - return nil, false - } - if h.dependencies.CompareAndSwap(state, &dependencyState{released: true}) { - if state == nil { - return nil, true - } - return state.dependents, true + dependent.mu.Lock() + if !dependent.released && dependent.layeredBase() == h { + compacted := dependent.load().applyTo(*value) + dependent.value.Store(&compacted) + dependent.registerWithBaseLocked() } + dependent.mu.Unlock() + dependent.compactDependents() } } -func (h *Handle) isReleased() bool { - state := h.dependencies.Load() - return state != nil && state.released -} - -func layeredBase(value *requestFileSystem) *Handle { - if !value.layered { - return nil - } - return getRequestFileSystem(value.baseFileSystem()) -} - func (h *Handle) baseFileSystem() vfs.FS { return h.load().baseFileSystem() } diff --git a/tsc/internal/api/requestfilesystem/requestfilesystemhandle_bench_test.go b/tsc/internal/api/requestfilesystem/requestfilesystemhandle_bench_test.go deleted file mode 100644 index ad0726cd30dd6..0000000000000 --- a/tsc/internal/api/requestfilesystem/requestfilesystemhandle_bench_test.go +++ /dev/null @@ -1,445 +0,0 @@ -package requestfilesystem - -import ( - "sync" - "sync/atomic" - "testing" - - "github.com/microsoft/TypeScript/tsc/internal/tspath" - "github.com/microsoft/TypeScript/tsc/internal/vfs" - "github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest" -) - -var legacyBenchmarkDependenciesMu sync.Mutex - -type legacyBenchmarkHandle struct { - vfs.FS - value atomic.Pointer[requestFileSystem] - dependents map[*legacyBenchmarkHandle]struct{} -} - -func (h *legacyBenchmarkHandle) initialize(value requestFileSystem) { - h.value.Store(&value) - h.registerWithBase() -} - -func (h *legacyBenchmarkHandle) cloneFrom(source *legacyBenchmarkHandle) { - if source == nil { - return - } - if h.value.Load() != nil { - panic("request filesystem handle already initialized") - } - legacyBenchmarkDependenciesMu.Lock() - defer legacyBenchmarkDependenciesMu.Unlock() - value := *source.value.Load() - h.value.Store(&value) - h.registerWithBaseLocked() -} - -func (h *legacyBenchmarkHandle) release() { - if h == nil || h.value.Load() == nil { - return - } - legacyBenchmarkDependenciesMu.Lock() - defer legacyBenchmarkDependenciesMu.Unlock() - if h.value.Load() == nil { - return - } - h.compactDependentsLocked() - h.unregisterFromBaseLocked() -} - -func (h *legacyBenchmarkHandle) registerWithBase() { - legacyBenchmarkDependenciesMu.Lock() - defer legacyBenchmarkDependenciesMu.Unlock() - h.registerWithBaseLocked() -} - -func (h *legacyBenchmarkHandle) registerWithBaseLocked() { - base := h.layeredBase() - if base == nil { - return - } - if base.dependents == nil { - base.dependents = make(map[*legacyBenchmarkHandle]struct{}) - } - base.dependents[h] = struct{}{} -} - -func (h *legacyBenchmarkHandle) unregisterFromBaseLocked() { - if base := h.layeredBase(); base != nil { - delete(base.dependents, h) - } -} - -func (h *legacyBenchmarkHandle) layeredBase() *legacyBenchmarkHandle { - value := h.value.Load() - if !value.layered { - return nil - } - base, _ := value.baseFileSystem().(*legacyBenchmarkHandle) - return base -} - -func (h *legacyBenchmarkHandle) UseCaseSensitiveFileNames() bool { - return h.value.Load().UseCaseSensitiveFileNames() -} - -func (h *legacyBenchmarkHandle) applyToLocked(base *legacyBenchmarkHandle) { - h.unregisterFromBaseLocked() - value := h.value.Load().applyTo(*base.value.Load()) - h.value.Store(&value) - h.registerWithBaseLocked() -} - -func (h *legacyBenchmarkHandle) compactDependentsLocked() { - for dependent := range h.dependents { - dependent.applyToLocked(h) - dependent.compactDependentsLocked() - h.compactDependentsLocked() - return - } -} - -type perHandleMutexBenchmarkHandle struct { - vfs.FS - mu sync.Mutex - value atomic.Pointer[requestFileSystem] - dependents map[*perHandleMutexBenchmarkHandle]struct{} - released bool -} - -func (h *perHandleMutexBenchmarkHandle) initialize(value requestFileSystem) { - h.mu.Lock() - defer h.mu.Unlock() - if h.value.Load() != nil { - panic("request filesystem handle already initialized") - } - h.value.Store(&value) - h.registerWithBaseLocked() -} - -func (h *perHandleMutexBenchmarkHandle) cloneFrom(source *perHandleMutexBenchmarkHandle) { - if source == nil { - return - } - value := *source.value.Load() - h.mu.Lock() - defer h.mu.Unlock() - if h.value.Load() != nil { - panic("request filesystem handle already initialized") - } - h.value.Store(&value) - h.registerWithBaseLocked() -} - -func (h *perHandleMutexBenchmarkHandle) release() { - if h == nil || h.value.Load() == nil { - return - } - h.mu.Lock() - if h.released { - h.mu.Unlock() - return - } - h.released = true - h.mu.Unlock() - h.compactDependents() - h.unregisterFromBase() -} - -func (h *perHandleMutexBenchmarkHandle) registerWithBaseLocked() { - for { - base := h.layeredBase() - if base == nil { - return - } - base.mu.Lock() - if base.released { - value := h.value.Load().applyTo(*base.value.Load()) - h.value.Store(&value) - base.mu.Unlock() - continue - } - if base.dependents == nil { - base.dependents = make(map[*perHandleMutexBenchmarkHandle]struct{}) - } - base.dependents[h] = struct{}{} - base.mu.Unlock() - return - } -} - -func (h *perHandleMutexBenchmarkHandle) unregisterFromBase() { - if base := h.layeredBase(); base != nil { - base.mu.Lock() - delete(base.dependents, h) - base.mu.Unlock() - } -} - -func (h *perHandleMutexBenchmarkHandle) layeredBase() *perHandleMutexBenchmarkHandle { - value := h.value.Load() - if !value.layered { - return nil - } - base, _ := value.baseFileSystem().(*perHandleMutexBenchmarkHandle) - return base -} - -func (h *perHandleMutexBenchmarkHandle) UseCaseSensitiveFileNames() bool { - return h.value.Load().UseCaseSensitiveFileNames() -} - -func (h *perHandleMutexBenchmarkHandle) compactDependents() { - for { - h.mu.Lock() - var dependent *perHandleMutexBenchmarkHandle - for candidate := range h.dependents { - dependent = candidate - delete(h.dependents, candidate) - break - } - value := h.value.Load() - h.mu.Unlock() - if dependent == nil { - return - } - dependent.mu.Lock() - if !dependent.released && dependent.layeredBase() == h { - compacted := dependent.value.Load().applyTo(*value) - dependent.value.Store(&compacted) - dependent.registerWithBaseLocked() - } - dependent.mu.Unlock() - dependent.compactDependents() - } -} - -func benchmarkRootValue(host vfs.FS) requestFileSystem { - return requestFileSystem{ - kind: KindMemory, - base: host, - currentDirectory: "/", - useCaseSensitiveNames: host.UseCaseSensitiveFileNames(), - files: make(map[tspath.Path]requestFile), - } -} - -func benchmarkLayerValue(base vfs.FS) requestFileSystem { - return requestFileSystem{ - kind: KindCache, - base: base, - layered: true, - currentDirectory: "/", - useCaseSensitiveNames: base.UseCaseSensitiveFileNames(), - } -} - -func benchmarkUpdateValue(base vfs.FS) requestFileSystem { - value := benchmarkLayerValue(base) - value.files = map[tspath.Path]requestFile{ - "/index.ts": {fileName: "/index.ts", content: "export const value = 1"}, - } - return value -} - -func newAtomicBenchmarkRoot(host vfs.FS) *Handle { - handle := &Handle{} - handle.initialize(benchmarkRootValue(host)) - return handle -} - -func newAtomicBenchmarkLayer(base *Handle) *Handle { - handle := &Handle{} - handle.initialize(benchmarkLayerValue(base)) - return handle -} - -func newAtomicBenchmarkUpdate(base *Handle) *Handle { - handle := &Handle{} - handle.initialize(benchmarkUpdateValue(base)) - return handle -} - -func newLegacyBenchmarkRoot(host vfs.FS) *legacyBenchmarkHandle { - handle := &legacyBenchmarkHandle{FS: host} - handle.initialize(benchmarkRootValue(host)) - return handle -} - -func newLegacyBenchmarkLayer(base *legacyBenchmarkHandle) *legacyBenchmarkHandle { - handle := &legacyBenchmarkHandle{FS: base.FS} - handle.initialize(benchmarkLayerValue(base)) - return handle -} - -func newLegacyBenchmarkUpdate(base *legacyBenchmarkHandle) *legacyBenchmarkHandle { - handle := &legacyBenchmarkHandle{FS: base.FS} - handle.initialize(benchmarkUpdateValue(base)) - return handle -} - -func newPerHandleMutexBenchmarkRoot(host vfs.FS) *perHandleMutexBenchmarkHandle { - handle := &perHandleMutexBenchmarkHandle{FS: host} - handle.initialize(benchmarkRootValue(host)) - return handle -} - -func newPerHandleMutexBenchmarkLayer(base *perHandleMutexBenchmarkHandle) *perHandleMutexBenchmarkHandle { - handle := &perHandleMutexBenchmarkHandle{FS: base.FS} - handle.initialize(benchmarkLayerValue(base)) - return handle -} - -func newPerHandleMutexBenchmarkUpdate(base *perHandleMutexBenchmarkHandle) *perHandleMutexBenchmarkHandle { - handle := &perHandleMutexBenchmarkHandle{FS: base.FS} - handle.initialize(benchmarkUpdateValue(base)) - return handle -} - -func BenchmarkHandleDependencies(b *testing.B) { - host := vfstest.FromMap(map[string]string{}, true) - benchmarkIndependentChains(b, host) - benchmarkSharedBase(b, host) - benchmarkTypicalUpdates(b, host) -} - -func benchmarkIndependentChains(b *testing.B, host vfs.FS) { - b.Run("IndependentChains/Atomic", func(b *testing.B) { - b.ReportAllocs() - b.RunParallel(func(pb *testing.PB) { - for pb.Next() { - root := newAtomicBenchmarkRoot(host) - middle := newAtomicBenchmarkLayer(root) - leaf := newAtomicBenchmarkLayer(middle) - var clone Handle - clone.CloneFrom(leaf) - root.Release() - middle.Release() - leaf.Release() - clone.Release() - } - }) - }) - - b.Run("IndependentChains/PerHandleMutex", func(b *testing.B) { - b.ReportAllocs() - b.RunParallel(func(pb *testing.PB) { - for pb.Next() { - root := newPerHandleMutexBenchmarkRoot(host) - middle := newPerHandleMutexBenchmarkLayer(root) - leaf := newPerHandleMutexBenchmarkLayer(middle) - clone := &perHandleMutexBenchmarkHandle{FS: host} - clone.cloneFrom(leaf) - root.release() - middle.release() - leaf.release() - clone.release() - } - }) - }) - - b.Run("IndependentChains/GlobalMutex", func(b *testing.B) { - b.ReportAllocs() - b.RunParallel(func(pb *testing.PB) { - for pb.Next() { - root := newLegacyBenchmarkRoot(host) - middle := newLegacyBenchmarkLayer(root) - leaf := newLegacyBenchmarkLayer(middle) - clone := &legacyBenchmarkHandle{FS: host} - clone.cloneFrom(leaf) - root.release() - middle.release() - leaf.release() - clone.release() - } - }) - }) -} - -func benchmarkSharedBase(b *testing.B, host vfs.FS) { - b.Run("SharedBase/Atomic", func(b *testing.B) { - base := newAtomicBenchmarkRoot(host) - b.ReportAllocs() - b.ResetTimer() - b.RunParallel(func(pb *testing.PB) { - for pb.Next() { - dependent := newAtomicBenchmarkLayer(base) - dependent.Release() - } - }) - b.StopTimer() - base.Release() - }) - - b.Run("SharedBase/PerHandleMutex", func(b *testing.B) { - base := newPerHandleMutexBenchmarkRoot(host) - b.ReportAllocs() - b.ResetTimer() - b.RunParallel(func(pb *testing.PB) { - for pb.Next() { - dependent := newPerHandleMutexBenchmarkLayer(base) - dependent.release() - } - }) - b.StopTimer() - base.release() - }) - - b.Run("SharedBase/GlobalMutex", func(b *testing.B) { - base := newLegacyBenchmarkRoot(host) - b.ReportAllocs() - b.ResetTimer() - b.RunParallel(func(pb *testing.PB) { - for pb.Next() { - dependent := newLegacyBenchmarkLayer(base) - dependent.release() - } - }) - b.StopTimer() - base.release() - }) -} - -func benchmarkTypicalUpdates(b *testing.B, host vfs.FS) { - b.Run("TypicalUpdates/Atomic", func(b *testing.B) { - current := newAtomicBenchmarkRoot(host) - b.ReportAllocs() - b.ResetTimer() - for b.Loop() { - next := newAtomicBenchmarkUpdate(current) - current.Release() - current = next - } - b.StopTimer() - current.Release() - }) - - b.Run("TypicalUpdates/PerHandleMutex", func(b *testing.B) { - current := newPerHandleMutexBenchmarkRoot(host) - b.ReportAllocs() - b.ResetTimer() - for b.Loop() { - next := newPerHandleMutexBenchmarkUpdate(current) - current.release() - current = next - } - b.StopTimer() - current.release() - }) - - b.Run("TypicalUpdates/GlobalMutex", func(b *testing.B) { - current := newLegacyBenchmarkRoot(host) - b.ReportAllocs() - b.ResetTimer() - for b.Loop() { - next := newLegacyBenchmarkUpdate(current) - current.release() - current = next - } - b.StopTimer() - current.release() - }) -} From f9275caca2f541486e5f88aab9d175d9e428bd90 Mon Sep 17 00:00:00 2001 From: Wesley Wigham Date: Wed, 2 Sep 2026 10:29:09 -0700 Subject: [PATCH 10/12] Some deduplication and extra edge case tests --- packages/typescript/src/api/fs.ts | 7 +- packages/typescript/test/async/api.test.ts | 27 ++ packages/typescript/test/sync/api.test.ts | 27 ++ .../requestfilesystem/requestfilesystem.go | 408 ++++++++---------- .../requestfilesystem_test.go | 118 +++++ 5 files changed, 346 insertions(+), 241 deletions(-) diff --git a/packages/typescript/src/api/fs.ts b/packages/typescript/src/api/fs.ts index 53a26ad639211..585973701aaee 100644 --- a/packages/typescript/src/api/fs.ts +++ b/packages/typescript/src/api/fs.ts @@ -39,7 +39,7 @@ export interface FileSystem { export const fsCallbackNames = ["readFile", "fileExists", "directoryExists", "getAccessibleEntries", "realpath", "writeFile"] as const; export interface CreateRequestFileSystemOptions { - /** Complete directory listings. Derived from `files` when omitted. */ + /** Complete directory listings. Memory filesystems derive these from `files` when omitted. */ directories?: Record; symlinks?: Record; /** Files or directory trees hidden from an underlying snapshot or host filesystem. */ @@ -96,7 +96,7 @@ export function createMemoryFileSystemWithLib( }); } -/** Creates a read-through cache request filesystem, deriving directory listings when omitted. */ +/** Creates a read-through cache request filesystem, merging host directory listings when omitted. */ export function createCacheFileSystem( files: RequestFileEntries, options: CreateRequestFileSystemOptions = {}, @@ -118,10 +118,11 @@ function createRequestFileSystem( normalizedFiles.set(fileName, content); } const fileRecord = Object.fromEntries(normalizedFiles); + const directories = options.directories ?? (kind === "memory" ? deriveDirectoryListings(fileRecord) : undefined); return { kind, files: fileRecord, - directories: options.directories ?? deriveDirectoryListings(fileRecord), + ...(directories ? { directories } : {}), ...(options.symlinks ? { symlinks: options.symlinks } : {}), ...(options.removedPaths?.length ? { removedPaths: [...options.removedPaths] } : {}), }; diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index 562e782121a25..0b31e0a4c33d6 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -3931,6 +3931,33 @@ describe("updateSnapshot file systems", () => { } }); + test("cache file system factory preserves host directory entries", async () => { + const api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + fs: createVirtualFileSystem({ + "/src/from-host.ts": `export const host = true;`, + }), + }); + + try { + using snapshot = await api.updateSnapshot({ + openProject: "/tsconfig.json", + fileSystem: createCacheFileSystem([ + ["/tsconfig.json", JSON.stringify({ compilerOptions: { noLib: true }, include: ["src/**/*.ts"] })], + ["/src/from-cache.ts", `export const cache = true;`], + ]), + }); + const program = snapshot.getProject("/tsconfig.json")!.program; + assert.deepEqual( + [...await program.getSourceFileNames()].sort(), + ["/src/from-cache.ts", "/src/from-host.ts"], + ); + } + finally { + await api.close(); + } + }); + test("memory file system resolves packages through internal monorepo symlinks", async () => { const callbackCalls: string[] = []; const api = new API({ diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index 34f849bb7d6e2..ddabb3141f2b5 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -3823,6 +3823,33 @@ describe("updateSnapshot file systems", () => { } }); + test("cache file system factory preserves host directory entries", () => { + const api = new API({ + cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), + fs: createVirtualFileSystem({ + "/src/from-host.ts": `export const host = true;`, + }), + }); + + try { + using snapshot = api.updateSnapshot({ + openProject: "/tsconfig.json", + fileSystem: createCacheFileSystem([ + ["/tsconfig.json", JSON.stringify({ compilerOptions: { noLib: true }, include: ["src/**/*.ts"] })], + ["/src/from-cache.ts", `export const cache = true;`], + ]), + }); + const program = snapshot.getProject("/tsconfig.json")!.program; + assert.deepEqual( + [...program.getSourceFileNames()].sort(), + ["/src/from-cache.ts", "/src/from-host.ts"], + ); + } + finally { + api.close(); + } + }); + test("memory file system resolves packages through internal monorepo symlinks", () => { const callbackCalls: string[] = []; const api = new API({ diff --git a/tsc/internal/api/requestfilesystem/requestfilesystem.go b/tsc/internal/api/requestfilesystem/requestfilesystem.go index fe9814e56d2bc..8eb8a6a9a1ba8 100644 --- a/tsc/internal/api/requestfilesystem/requestfilesystem.go +++ b/tsc/internal/api/requestfilesystem/requestfilesystem.go @@ -92,6 +92,22 @@ type resolvedRequestPath struct { ok bool } +type requestPathKind uint8 + +const ( + requestPathKindMissing requestPathKind = iota + requestPathKindFile + requestPathKindDirectory +) + +type requestPathLookup struct { + path string + kind requestPathKind + fileSystem vfs.FS + followedSymlink bool + ok bool +} + type requestDirectoryBuilder struct { files map[tspath.Path]string directories map[tspath.Path]string @@ -529,167 +545,140 @@ func (s requestFileSystem) directoryAt(path string) (string, bool) { return directory, ok } -func cloneEntries(entries vfs.Entries) vfs.Entries { - result := vfs.Entries{ - Files: slices.Clone(entries.Files), - Directories: slices.Clone(entries.Directories), +func (s requestFileSystem) pathKind(path string) requestPathKind { + if _, ok := s.fileAt(path); ok { + return requestPathKindFile } - if entries.Symlinks != nil { - result.Symlinks = make(map[string]struct{}, len(entries.Symlinks)) - for name := range entries.Symlinks { - result.Symlinks[name] = struct{}{} - } + if _, ok := s.directoryAt(path); ok { + return requestPathKindDirectory } - return result + return requestPathKindMissing } -func (s requestFileSystem) UseCaseSensitiveFileNames() bool { - return s.useCaseSensitiveNames -} - -func (s requestFileSystem) ReadFile(fileName string) (string, bool) { - if s.isPreSymlinkRemoved(fileName) { - return "", false +func (s requestFileSystem) lookupPath(path string) requestPathLookup { + absolutePath := s.toAbsolutePath(path) + if kind := s.pathKind(absolutePath); kind != requestPathKindMissing { + return requestPathLookup{path: absolutePath, kind: kind, ok: true} + } + if s.isPreSymlinkRemoved(path) { + return requestPathLookup{} } - resolved := s.resolvePath(fileName) + resolved := s.resolvePath(path) if !resolved.ok { - return "", false + return requestPathLookup{} + } + result := requestPathLookup{ + path: resolved.path, + followedSymlink: resolved.followedSymlink, + ok: true, } if resolved.host { if s.isRemoved(resolved.path) { - return "", false - } - host := getHostFileSystem(s.baseFileSystem()) - if host == nil { - return "", false + return requestPathLookup{} } - return host.ReadFile(resolved.path) + result.fileSystem = getHostFileSystem(s.baseFileSystem()) + result.ok = result.fileSystem != nil + return result } - file, ok := s.fileAt(resolved.path) - if ok { - return file.content, true - } - if _, ok := s.directoryAt(resolved.path); ok { - return "", false + if kind := s.pathKind(resolved.path); kind != requestPathKindMissing { + result.kind = kind + return result } - if !resolved.followedSymlink && s.isRemoved(fileName) { - return "", false + if !resolved.followedSymlink && s.isRemoved(path) { + return requestPathLookup{} } - fallbackPath := resolved.path if s.fallsBack() { fallback := s.resolveBasePath(resolved.path) if !fallback.ok { - return "", false + return requestPathLookup{} } - fallbackPath = fallback.path - if file, ok := s.fileAt(fallbackPath); ok { - return file.content, true + if fallback.host { + if s.isRemoved(resolved.path) || s.isRemoved(fallback.path) { + return requestPathLookup{} + } + result.path = fallback.path + result.fileSystem = getHostFileSystem(s.baseFileSystem()) + result.ok = result.fileSystem != nil + return result } - if _, ok := s.directoryAt(fallbackPath); ok { - return "", false + if kind := s.pathKind(fallback.path); kind != requestPathKindMissing { + result.path = fallback.path + result.kind = kind + return result } + if s.isRemoved(resolved.path) || s.isRemoved(fallback.path) { + return requestPathLookup{} + } + result.fileSystem = s.baseFileSystem() } - if s.isRemoved(resolved.path) || s.isRemoved(fallbackPath) { - return "", false - } - if s.fallsBack() { - return s.baseFileSystem().ReadFile(resolved.path) - } - return "", false + return result } -func (s requestFileSystem) FileExists(fileName string) bool { - if s.isPreSymlinkRemoved(fileName) { - return false +func (s requestFileSystem) mutationPath(path string) (vfs.FS, string, bool) { + if s.kind != KindCache { + return nil, "", false } - resolved := s.resolvePath(fileName) + resolved := s.resolvePathForOverlay(path) if !resolved.ok { - return false + return nil, "", false } - if resolved.host { - if s.isRemoved(resolved.path) { - return false - } - host := getHostFileSystem(s.baseFileSystem()) - return host != nil && host.FileExists(resolved.path) - } - _, ok := s.fileAt(resolved.path) - if ok { - return true - } - if _, ok := s.directoryAt(resolved.path); ok { - return false - } - if !resolved.followedSymlink && s.isRemoved(fileName) { - return false + host := getHostFileSystem(s.baseFileSystem()) + return host, resolved.path, host != nil +} + +func cloneEntries(entries vfs.Entries) vfs.Entries { + result := vfs.Entries{ + Files: slices.Clone(entries.Files), + Directories: slices.Clone(entries.Directories), } - fallbackPath := resolved.path - if s.fallsBack() { - fallback := s.resolveBasePath(resolved.path) - if !fallback.ok { - return false - } - fallbackPath = fallback.path - if _, ok := s.fileAt(fallbackPath); ok { - return true - } - if _, ok := s.directoryAt(fallbackPath); ok { - return false + if entries.Symlinks != nil { + result.Symlinks = make(map[string]struct{}, len(entries.Symlinks)) + for name := range entries.Symlinks { + result.Symlinks[name] = struct{}{} } } - if s.isRemoved(resolved.path) || s.isRemoved(fallbackPath) || !s.fallsBack() { - return false - } - return s.baseFileSystem().FileExists(resolved.path) + return result } -func (s requestFileSystem) DirectoryExists(directoryName string) bool { - if s.isPreSymlinkRemoved(directoryName) { - return false - } - resolved := s.resolvePath(directoryName) - if !resolved.ok { - return false - } - if resolved.host { - if s.isRemoved(resolved.path) { - return false - } - host := getHostFileSystem(s.baseFileSystem()) - return host != nil && host.DirectoryExists(resolved.path) +func (s requestFileSystem) UseCaseSensitiveFileNames() bool { + return s.useCaseSensitiveNames +} + +func (s requestFileSystem) ReadFile(fileName string) (string, bool) { + lookup := s.lookupPath(fileName) + if !lookup.ok || lookup.kind == requestPathKindDirectory { + return "", false } - _, ok := s.directoryAt(resolved.path) - if ok { - return true + if lookup.fileSystem != nil { + return lookup.fileSystem.ReadFile(lookup.path) } - if _, ok := s.fileAt(resolved.path); ok { - return false + if file, ok := s.fileAt(lookup.path); ok { + return file.content, true } - if !resolved.followedSymlink && s.isRemoved(directoryName) { + return "", false +} + +func (s requestFileSystem) FileExists(fileName string) bool { + lookup := s.lookupPath(fileName) + if !lookup.ok || lookup.kind == requestPathKindDirectory { return false } - fallbackPath := resolved.path - if s.fallsBack() { - fallback := s.resolveBasePath(resolved.path) - if !fallback.ok { - return false - } - fallbackPath = fallback.path - if _, ok := s.directoryAt(fallbackPath); ok { - return true - } - if _, ok := s.fileAt(fallbackPath); ok { - return false - } - } - if s.isRemoved(resolved.path) || s.isRemoved(fallbackPath) || !s.fallsBack() { + return lookup.kind == requestPathKindFile || lookup.fileSystem != nil && lookup.fileSystem.FileExists(lookup.path) +} + +func (s requestFileSystem) DirectoryExists(directoryName string) bool { + lookup := s.lookupPath(directoryName) + if !lookup.ok || lookup.kind == requestPathKindFile { return false } - return s.baseFileSystem().DirectoryExists(resolved.path) + return lookup.kind == requestPathKindDirectory || lookup.fileSystem != nil && lookup.fileSystem.DirectoryExists(lookup.path) } func (s requestFileSystem) GetAccessibleEntries(directoryName string) vfs.Entries { if s.isPreSymlinkRemoved(directoryName) { + if entries, _, ok := s.getLocalEntries(directoryName); ok { + return s.addSymlinkEntries(directoryName, entries) + } return vfs.Entries{Symlinks: map[string]struct{}{}} } resolved := s.resolvePath(directoryName) @@ -706,16 +695,20 @@ func (s requestFileSystem) GetAccessibleEntries(directoryName string) vfs.Entrie return vfs.Entries{Symlinks: map[string]struct{}{}} } fallbackPath := resolved.path + fallbackHost := false if !resolved.host && s.fallsBack() { fallback := s.resolveBasePath(resolved.path) if !fallback.ok { return vfs.Entries{Symlinks: map[string]struct{}{}} } fallbackPath = fallback.path - if _, ok := s.fileAt(fallbackPath); ok { - return vfs.Entries{Symlinks: map[string]struct{}{}} + fallbackHost = fallback.host + if !fallbackHost { + if _, ok := s.fileAt(fallbackPath); ok { + return vfs.Entries{Symlinks: map[string]struct{}{}} + } } - if s.toPath(fallbackPath) != s.toPath(resolved.path) { + if !fallbackHost && s.toPath(fallbackPath) != s.toPath(resolved.path) { targetEntries, targetExplicit, targetLocal := s.getLocalEntries(fallbackPath) if targetLocal { localEntries = mergeEntries(localEntries, targetEntries, s.equalEntryNames) @@ -726,12 +719,25 @@ func (s requestFileSystem) GetAccessibleEntries(directoryName string) vfs.Entrie } } var result vfs.Entries - if resolved.host { - if !s.isRemoved(resolved.path) { + if resolved.host || fallbackHost { + hostPath := resolved.path + if fallbackHost { + hostPath = fallbackPath + } + if !s.isRemoved(directoryName) && !s.isRemoved(resolved.path) && !s.isRemoved(hostPath) { if host := getHostFileSystem(s.baseFileSystem()); host != nil { - result = s.removeEntries(resolved.path, host.GetAccessibleEntries(resolved.path)) + result = s.removeEntries(directoryName, host.GetAccessibleEntries(hostPath)) + if s.toPath(directoryName) != s.toPath(resolved.path) { + result = s.removeEntries(resolved.path, result) + } + if s.toPath(hostPath) != s.toPath(resolved.path) { + result = s.removeEntries(hostPath, result) + } } } + if hasLocalEntries { + result = mergeEntries(result, localEntries, s.equalEntryNames) + } } else if !s.fallsBack() || hasExplicitListing && sealedListing { result = localEntries } else { @@ -749,7 +755,7 @@ func (s requestFileSystem) GetAccessibleEntries(directoryName string) vfs.Entrie } } result = s.addSymlinkEntries(resolved.path, result) - if s.toPath(fallbackPath) != s.toPath(resolved.path) { + if !fallbackHost && s.toPath(fallbackPath) != s.toPath(resolved.path) { result = s.addSymlinkEntries(fallbackPath, result) } result = s.removePreSymlinkEntries(directoryName, result) @@ -760,7 +766,14 @@ func (s requestFileSystem) removePreSymlinkEntries(directoryName string, entries result := cloneEntries(entries) filter := func(values []string) []string { return slices.DeleteFunc(values, func(name string) bool { - path := s.toPath(tspath.CombinePaths(directoryName, name)) + fileName := tspath.CombinePaths(directoryName, name) + if _, ok := s.fileAt(fileName); ok { + return false + } + if _, ok := s.directoryAt(fileName); ok { + return false + } + path := s.toPath(fileName) for removedPath := range s.preSymlinkRemovedPaths { if path == removedPath || strings.HasPrefix(string(path), tspath.EnsureTrailingDirectorySeparator(string(removedPath))) { return true @@ -905,147 +918,66 @@ func (s requestFileSystem) equalEntryNames(left string, right string) bool { } func (s requestFileSystem) Realpath(path string) string { - if s.isPreSymlinkRemoved(path) { - return path - } - resolved := s.resolvePath(path) - if !resolved.ok { - return path - } - if _, ok := s.fileAt(resolved.path); ok { - return resolved.path - } - if _, ok := s.directoryAt(resolved.path); ok { - return resolved.path - } - if !resolved.followedSymlink && s.isRemoved(path) { - return path - } - fallbackPath := resolved.path - if !resolved.host && s.fallsBack() { - fallback := s.resolveBasePath(resolved.path) - if !fallback.ok { - return path - } - fallbackPath = fallback.path - if _, ok := s.fileAt(fallbackPath); ok { - return fallbackPath - } - if _, ok := s.directoryAt(fallbackPath); ok { - return fallbackPath - } - } - if s.isRemoved(resolved.path) || s.isRemoved(fallbackPath) { + lookup := s.lookupPath(path) + if !lookup.ok { return path } - if resolved.host { - if host := getHostFileSystem(s.baseFileSystem()); host != nil { - return host.Realpath(resolved.path) - } - return path - } - if resolved.followedSymlink && !s.fallsBack() { - return path + if lookup.fileSystem != nil { + return lookup.fileSystem.Realpath(lookup.path) } - if s.fallsBack() { - return s.baseFileSystem().Realpath(resolved.path) + if lookup.kind != requestPathKindMissing || !lookup.followedSymlink { + return lookup.path } - return resolved.path + return path } func (s requestFileSystem) WriteFile(fileName string, data string) error { - if s.kind != KindCache { + host, path, ok := s.mutationPath(fileName) + if !ok { return vfs.ErrInvalid } - host := getHostFileSystem(s.baseFileSystem()) - if host == nil { - return vfs.ErrInvalid - } - return host.WriteFile(s.toAbsolutePath(fileName), data) + return host.WriteFile(path, data) } func (s requestFileSystem) AppendFile(fileName string, data string) error { - if s.kind != KindCache { + host, path, ok := s.mutationPath(fileName) + if !ok { return vfs.ErrInvalid } - host := getHostFileSystem(s.baseFileSystem()) - if host == nil { - return vfs.ErrInvalid - } - return host.AppendFile(s.toAbsolutePath(fileName), data) + return host.AppendFile(path, data) } func (s requestFileSystem) Remove(path string) error { - if s.kind != KindCache { + host, path, ok := s.mutationPath(path) + if !ok { return vfs.ErrInvalid } - host := getHostFileSystem(s.baseFileSystem()) - if host == nil { - return vfs.ErrInvalid - } - return host.Remove(s.toAbsolutePath(path)) + return host.Remove(path) } func (s requestFileSystem) Chtimes(path string, aTime time.Time, mTime time.Time) error { - resolved := s.resolvePath(path) - if !resolved.ok { - return vfs.ErrInvalid - } - if s.kind != KindCache { + host, path, ok := s.mutationPath(path) + if !ok { return vfs.ErrInvalid } - host := getHostFileSystem(s.baseFileSystem()) - if host == nil { - return vfs.ErrInvalid - } - return host.Chtimes(s.toAbsolutePath(path), aTime, mTime) + return host.Chtimes(path, aTime, mTime) } func (s requestFileSystem) Stat(path string) vfs.FileInfo { - if s.isPreSymlinkRemoved(path) { - return nil - } - resolved := s.resolvePath(path) - if !resolved.ok { - return nil - } - canonicalPath := s.toPath(resolved.path) - if file, ok := s.files[canonicalPath]; ok { - info := requestFileInfo{name: tspath.GetBaseFileName(file.fileName), size: int64(len(file.content))} - return info - } - if directoryName, ok := s.directories[canonicalPath]; ok { - info := requestFileInfo{name: tspath.GetBaseFileName(directoryName), directory: true} - return info - } - if !resolved.followedSymlink && s.isRemoved(path) { + lookup := s.lookupPath(path) + if !lookup.ok { return nil } - fallbackPath := resolved.path - if !resolved.host && s.fallsBack() { - fallback := s.resolveBasePath(resolved.path) - if !fallback.ok { - return nil - } - fallbackPath = fallback.path - canonicalFallbackPath := s.toPath(fallbackPath) - if file, ok := s.files[canonicalFallbackPath]; ok { - info := requestFileInfo{name: tspath.GetBaseFileName(file.fileName), size: int64(len(file.content))} - return info - } - if directoryName, ok := s.directories[canonicalFallbackPath]; ok { - info := requestFileInfo{name: tspath.GetBaseFileName(directoryName), directory: true} - return info - } - } - if s.isRemoved(resolved.path) || s.isRemoved(fallbackPath) { - return nil + if lookup.fileSystem != nil { + return statFileSystem(lookup.fileSystem, lookup.path) } - if resolved.host { - return statFileSystem(getHostFileSystem(s.baseFileSystem()), resolved.path) + if lookup.kind == requestPathKindFile { + file, _ := s.fileAt(lookup.path) + return requestFileInfo{name: tspath.GetBaseFileName(file.fileName), size: int64(len(file.content))} } - if s.fallsBack() { - return statFileSystem(s.baseFileSystem(), resolved.path) + if lookup.kind == requestPathKindDirectory { + directoryName, _ := s.directoryAt(lookup.path) + return requestFileInfo{name: tspath.GetBaseFileName(directoryName), directory: true} } return nil } diff --git a/tsc/internal/api/requestfilesystem/requestfilesystem_test.go b/tsc/internal/api/requestfilesystem/requestfilesystem_test.go index 8eb79c6b8c9b7..9612b3b8ab272 100644 --- a/tsc/internal/api/requestfilesystem/requestfilesystem_test.go +++ b/tsc/internal/api/requestfilesystem/requestfilesystem_test.go @@ -3,6 +3,7 @@ package requestfilesystem import ( "sync" "testing" + "time" "github.com/microsoft/TypeScript/tsc/internal/project" "github.com/microsoft/TypeScript/tsc/internal/vfs" @@ -555,6 +556,48 @@ func TestRequestFileSystem(t *testing.T) { assert.Equal(t, contents, "recreated") }) + t.Run("compaction allows recreating a descendant of a path removed through an inherited symlink", func(t *testing.T) { + t.Parallel() + host := vfstest.FromMap(map[string]string{}, true) + base, err := newRequestFileSystem(&RequestFileSystem{ + Kind: KindMemory, + Files: map[string]string{ + "/target/dir/existing.ts": "existing", + }, + Symlinks: map[string]RequestSymlink{ + "/link": {Target: "/target"}, + }, + }, host, "/") + assert.NilError(t, err) + + removed, err := newLayeredRequestFileSystem(&RequestFileSystem{ + Kind: KindCache, + RemovedPaths: []string{"/link/dir"}, + }, base, "/") + assert.NilError(t, err) + removed.applyTo(base) + + recreated, err := newLayeredRequestFileSystem(&RequestFileSystem{ + Kind: KindCache, + Files: map[string]string{ + "/link/dir/recreated.ts": "recreated", + }, + }, removed, "/") + assert.NilError(t, err) + contents, ok := recreated.ReadFile("/link/dir/recreated.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, "recreated") + + recreated.applyTo(removed) + + contents, ok = recreated.ReadFile("/link/dir/recreated.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, "recreated") + assert.Assert(t, !recreated.FileExists("/link/dir/existing.ts")) + assert.DeepEqual(t, recreated.GetAccessibleEntries("/link/dir").Files, []string{"recreated.ts"}) + assert.DeepEqual(t, recreated.GetAccessibleEntries("/link").Directories, []string{"dir"}) + }) + t.Run("files replacing inherited symlink target directories have empty listings", func(t *testing.T) { t.Parallel() host := vfstest.FromMap(map[string]string{}, true) @@ -768,6 +811,40 @@ func TestRequestFileSystem(t *testing.T) { assert.Assert(t, host.SeenFiles.Has("/host/pkg/index.d.ts")) }) + t.Run("inherited host symlinks bypass newer cache entries at the target", func(t *testing.T) { + t.Parallel() + host := vfstest.FromMap(map[string]string{ + "/host/pkg/host.ts": "host", + "/host/pkg/removed.ts": "removed", + }, true) + base, err := newRequestFileSystem(&RequestFileSystem{ + Kind: KindMemory, + Files: map[string]string{}, + Symlinks: map[string]RequestSymlink{ + "/link": {Target: "/host/pkg", Host: true}, + }, + }, host, "/") + assert.NilError(t, err) + + layered, err := newLayeredRequestFileSystem(&RequestFileSystem{ + Kind: KindCache, + RemovedPaths: []string{"/link/removed.ts"}, + Files: map[string]string{ + "/host/pkg/host.ts": "cache", + "/host/pkg/cache-only.ts": "cache only", + }, + }, base, "/") + assert.NilError(t, err) + + contents, ok := layered.ReadFile("/link/host.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, "host") + assert.Assert(t, !layered.FileExists("/link/cache-only.ts")) + assert.Assert(t, !layered.FileExists("/link/removed.ts")) + assert.Equal(t, layered.Stat("/link/host.ts").Size(), int64(len("host"))) + assert.DeepEqual(t, layered.GetAccessibleEntries("/link").Files, []string{"host.ts"}) + }) + t.Run("canonical path collisions are rejected", func(t *testing.T) { t.Parallel() base := vfstest.FromMap(map[string]string{}, false) @@ -979,6 +1056,47 @@ func TestRequestFileSystem(t *testing.T) { assert.Assert(t, !host.FileExists("/written.ts")) }) + t.Run("cache mutations follow inherited request symlinks", func(t *testing.T) { + t.Parallel() + host := vfstest.FromMap(map[string]string{ + "/target/write.ts": "target", + "/target/append.ts": "target", + "/target/remove.ts": "target", + "/target/times.ts": "target", + "/link/write.ts": "alias", + "/link/append.ts": "alias", + "/link/remove.ts": "alias", + "/link/times.ts": "alias", + }, true) + base, err := newRequestFileSystem(&RequestFileSystem{ + Kind: KindMemory, + Files: map[string]string{}, + Symlinks: map[string]RequestSymlink{ + "/link": {Target: "/target"}, + }, + }, host, "/") + assert.NilError(t, err) + cache, err := newLayeredRequestFileSystem(&RequestFileSystem{Kind: KindCache}, base, "/") + assert.NilError(t, err) + + assert.NilError(t, cache.WriteFile("/link/write.ts", "written")) + contents, ok := host.ReadFile("/target/write.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, "written") + + assert.NilError(t, cache.AppendFile("/link/append.ts", " appended")) + contents, ok = host.ReadFile("/target/append.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, "target appended") + + assert.NilError(t, cache.Remove("/link/remove.ts")) + assert.Assert(t, !host.FileExists("/target/remove.ts")) + + modified := time.Unix(123, 0) + assert.NilError(t, cache.Chtimes("/link/times.ts", modified, modified)) + assert.Equal(t, host.Stat("/target/times.ts").ModTime(), modified) + }) + t.Run("mixed windows and posix roots support cross-root and relative symlinks", func(t *testing.T) { t.Parallel() base := &trackingvfs.FS{Inner: vfstest.FromMap(map[string]string{ From cd4285e8ffcfe159f9197027ed8ec190169f2573 Mon Sep 17 00:00:00 2001 From: Wesley Wigham Date: Wed, 2 Sep 2026 11:08:43 -0700 Subject: [PATCH 11/12] try/finally -> using --- packages/typescript/test/async/api.test.ts | 726 +++++++++------------ packages/typescript/test/sync/api.test.ts | 709 +++++++++----------- 2 files changed, 640 insertions(+), 795 deletions(-) diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index 60a27dbbcb03a..0216bf727fdb3 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -589,7 +589,7 @@ describe("API", () => { // @sync-skip-block-start describe("API - automatic batching", () => { test("initializes only once for concurrent first requests", async () => { - const api = spawnAPI(); + await using api = spawnAPI(); const client = (api as unknown as { client: { apiRequest(method: string, params: unknown): Promise; }; }).client; @@ -600,16 +600,11 @@ describe("API - automatic batching", () => { return apiRequest(method, params); }; - try { - await Promise.all([ - api.parseCommandLine(["--strict"]), - api.readConfigFile("/tsconfig.json"), - ]); - assert.equal(initializeCalls, 1); - } - finally { - await api.close(); - } + await Promise.all([ + api.parseCommandLine(["--strict"]), + api.readConfigFile("/tsconfig.json"), + ]); + assert.equal(initializeCalls, 1); }); test("batches multiple concurrent requests into one automatically", async () => { @@ -3308,89 +3303,74 @@ describe("updateSnapshot file systems", () => { host.writeFile!(path, content); }, }; - const api = new API({ + await using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs, }); - try { - using snapshot = await api.updateSnapshot({ - openProject: "/tsconfig.json", - fileSystem: { - kind: "memory", - files: { - "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, include: ["src/**/*.ts"] }), - "/src/index.ts": `export const source = "memory";`, - }, - directories: { - "/": { files: ["tsconfig.json"], directories: ["src"] }, - "/src": { files: ["index.ts"], directories: [] }, - }, + using snapshot = await api.updateSnapshot({ + openProject: "/tsconfig.json", + fileSystem: { + kind: "memory", + files: { + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, include: ["src/**/*.ts"] }), + "/src/index.ts": `export const source = "memory";`, }, - }); - const project = snapshot.getProject("/tsconfig.json")!; - const sourceFile = await project.program.getSourceFile("/src/index.ts"); - assert.equal(sourceFile?.text, `export const source = "memory";`); - assert.equal(await project.program.getSourceFile("/host.ts"), undefined); - assert.deepEqual(callbackCalls, []); - } - finally { - await api.close(); - } + directories: { + "/": { files: ["tsconfig.json"], directories: ["src"] }, + "/src": { files: ["index.ts"], directories: [] }, + }, + }, + }); + const project = snapshot.getProject("/tsconfig.json")!; + const sourceFile = await project.program.getSourceFile("/src/index.ts"); + assert.equal(sourceFile?.text, `export const source = "memory";`); + assert.equal(await project.program.getSourceFile("/host.ts"), undefined); + assert.deepEqual(callbackCalls, []); }); test("memory file system with lib resolves the default library", async () => { - const api = new API({ + await using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), }); - try { - using snapshot = await api.updateSnapshot({ - openProject: "/tsconfig.json", - fileSystem: createMemoryFileSystemWithLib(Object.entries({ - "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true }, files: ["src/main.ts"] }), - "/src/main.ts": `export const values: Array = [];`, - })), - }); - const program = snapshot.getProject("/tsconfig.json")!.program; - assert.deepEqual(await program.getGlobalDiagnostics(), []); - const sourceFileNames = await program.getSourceFileNames(); - const defaultLibraryName = sourceFileNames.find(fileName => fileName.includes("/lib.") && fileName.endsWith(".d.ts")); - assert.ok(defaultLibraryName, JSON.stringify(sourceFileNames)); - const defaultLibrary = await program.getSourceFile(defaultLibraryName); - assert.ok(defaultLibrary); - assert.equal(await program.isSourceFileDefaultLibrary(defaultLibrary), true); - } - finally { - await api.close(); - } + using snapshot = await api.updateSnapshot({ + openProject: "/tsconfig.json", + fileSystem: createMemoryFileSystemWithLib(Object.entries({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true }, files: ["src/main.ts"] }), + "/src/main.ts": `export const values: Array = [];`, + })), + }); + const program = snapshot.getProject("/tsconfig.json")!.program; + assert.deepEqual(await program.getGlobalDiagnostics(), []); + const sourceFileNames = await program.getSourceFileNames(); + const defaultLibraryName = sourceFileNames.find(fileName => fileName.includes("/lib.") && fileName.endsWith(".d.ts")); + assert.ok(defaultLibraryName, JSON.stringify(sourceFileNames)); + const defaultLibrary = await program.getSourceFile(defaultLibraryName); + assert.ok(defaultLibrary); + assert.equal(await program.isSourceFileDefaultLibrary(defaultLibrary), true); }); test("memory file system accepts paths decoded from VS Code document URIs", async () => { const fileDocument = { uri: "file:///workspace/file%20name.ts" }; const remoteDocument = { uri: "vscode-remote://ssh-remote+host/workspace/src/remote%20name.ts" }; const notebookDocument = { uri: "vscode-notebook-cell:/workspace/notebook.ipynb/cell%20name.ts" }; - const api = new API({ + await using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), }); - try { - using snapshot = await api.updateSnapshot({ - openFiles: [fileDocument, remoteDocument, notebookDocument], - fileSystem: createMemoryFileSystem([ - [fileDocument, `export const file = true;`], - [remoteDocument, `export const remote = true;`], - [notebookDocument, `export const cell = true;`], - ]), - }); - const fileProject = await snapshot.getDefaultProjectForFile(fileDocument); - const remoteProject = await snapshot.getDefaultProjectForFile(remoteDocument); - const notebookProject = await snapshot.getDefaultProjectForFile(notebookDocument); - assert.equal((await fileProject?.program.getSourceFile(fileDocument))?.text, `export const file = true;`); - assert.equal((await remoteProject?.program.getSourceFile(remoteDocument))?.text, `export const remote = true;`); - assert.equal((await notebookProject?.program.getSourceFile(notebookDocument))?.text, `export const cell = true;`); - } - finally { - await api.close(); - } + using snapshot = await api.updateSnapshot({ + openFiles: [fileDocument, remoteDocument, notebookDocument], + fileSystem: createMemoryFileSystem([ + [fileDocument, `export const file = true;`], + [remoteDocument, `export const remote = true;`], + [notebookDocument, `export const cell = true;`], + ]), + }); + const fileProject = await snapshot.getDefaultProjectForFile(fileDocument); + const remoteProject = await snapshot.getDefaultProjectForFile(remoteDocument); + const notebookProject = await snapshot.getDefaultProjectForFile(notebookDocument); + assert.equal((await fileProject?.program.getSourceFile(fileDocument))?.text, `export const file = true;`); + assert.equal((await remoteProject?.program.getSourceFile(remoteDocument))?.text, `export const remote = true;`); + assert.equal((await notebookProject?.program.getSourceFile(notebookDocument))?.text, `export const cell = true;`); }); test("cache file system bypasses callbacks on hits and falls back on misses", async () => { @@ -3410,71 +3390,61 @@ describe("updateSnapshot file systems", () => { return host.getAccessibleEntries!(path); }, }; - const api = new API({ + await using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs, }); - try { - using snapshot = await api.updateSnapshot({ - openProject: "/tsconfig.json", - fileSystem: { - kind: "cache", - files: { - "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, include: ["src/**/*.ts"] }), - "/src/index.ts": `export const cached = true;`, - }, - directories: { - "/": { files: ["tsconfig.json"], directories: ["src"] }, - "/src": { files: ["fallback.ts", "index.ts"], directories: [] }, - }, + using snapshot = await api.updateSnapshot({ + openProject: "/tsconfig.json", + fileSystem: { + kind: "cache", + files: { + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, include: ["src/**/*.ts"] }), + "/src/index.ts": `export const cached = true;`, }, - }); - const project = snapshot.getProject("/tsconfig.json")!; - assert.equal((await project.program.getSourceFile("/src/index.ts"))?.text, `export const cached = true;`); - assert.equal((await project.program.getSourceFile("/src/fallback.ts"))?.text, `export const fallback = true;`); - - assert.ok(!readFileCalls.includes("/tsconfig.json")); - assert.ok(!readFileCalls.includes("/src/index.ts")); - assert.ok(readFileCalls.includes("/src/fallback.ts")); - assert.ok(!directoryCalls.includes("/")); - assert.ok(!directoryCalls.includes("/src")); - } - finally { - await api.close(); - } + directories: { + "/": { files: ["tsconfig.json"], directories: ["src"] }, + "/src": { files: ["fallback.ts", "index.ts"], directories: [] }, + }, + }, + }); + const project = snapshot.getProject("/tsconfig.json")!; + assert.equal((await project.program.getSourceFile("/src/index.ts"))?.text, `export const cached = true;`); + assert.equal((await project.program.getSourceFile("/src/fallback.ts"))?.text, `export const fallback = true;`); + + assert.ok(!readFileCalls.includes("/tsconfig.json")); + assert.ok(!readFileCalls.includes("/src/index.ts")); + assert.ok(readFileCalls.includes("/src/fallback.ts")); + assert.ok(!directoryCalls.includes("/")); + assert.ok(!directoryCalls.includes("/src")); }); test("cache file system factory preserves host directory entries", async () => { - const api = new API({ + await using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs: createVirtualFileSystem({ "/src/from-host.ts": `export const host = true;`, }), }); - try { - using snapshot = await api.updateSnapshot({ - openProject: "/tsconfig.json", - fileSystem: createCacheFileSystem([ - ["/tsconfig.json", JSON.stringify({ compilerOptions: { noLib: true }, include: ["src/**/*.ts"] })], - ["/src/from-cache.ts", `export const cache = true;`], - ]), - }); - const program = snapshot.getProject("/tsconfig.json")!.program; - assert.deepEqual( - [...await program.getSourceFileNames()].sort(), - ["/src/from-cache.ts", "/src/from-host.ts"], - ); - } - finally { - await api.close(); - } + using snapshot = await api.updateSnapshot({ + openProject: "/tsconfig.json", + fileSystem: createCacheFileSystem([ + ["/tsconfig.json", JSON.stringify({ compilerOptions: { noLib: true }, include: ["src/**/*.ts"] })], + ["/src/from-cache.ts", `export const cache = true;`], + ]), + }); + const program = snapshot.getProject("/tsconfig.json")!.program; + assert.deepEqual( + [...await program.getSourceFileNames()].sort(), + ["/src/from-cache.ts", "/src/from-host.ts"], + ); }); test("memory file system resolves packages through internal monorepo symlinks", async () => { const callbackCalls: string[] = []; - const api = new API({ + await using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs: { readFile: path => { @@ -3484,36 +3454,31 @@ describe("updateSnapshot file systems", () => { }, }); - try { - using snapshot = await api.updateSnapshot({ - openProject: "/project/tsconfig.json", - fileSystem: { - kind: "memory", - files: { - "/project/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, moduleResolution: "node" }, files: ["index.ts"] }), - "/project/index.ts": `import { value } from "pkg"; export { value };`, - "/packages/pkg/index.d.ts": `export declare const value: number;`, - }, - symlinks: { - "/project/node_modules/pkg": { target: "/packages/pkg" }, - }, + using snapshot = await api.updateSnapshot({ + openProject: "/project/tsconfig.json", + fileSystem: { + kind: "memory", + files: { + "/project/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, moduleResolution: "node" }, files: ["index.ts"] }), + "/project/index.ts": `import { value } from "pkg"; export { value };`, + "/packages/pkg/index.d.ts": `export declare const value: number;`, }, - }); - const project = snapshot.getProject("/project/tsconfig.json")!; - assert.equal( - (await project.program.getSourceFile("/packages/pkg/index.d.ts"))?.text, - `export declare const value: number;`, - ); - assert.deepEqual(callbackCalls, []); - } - finally { - await api.close(); - } + symlinks: { + "/project/node_modules/pkg": { target: "/packages/pkg" }, + }, + }, + }); + const project = snapshot.getProject("/project/tsconfig.json")!; + assert.equal( + (await project.program.getSourceFile("/packages/pkg/index.d.ts"))?.text, + `export declare const value: number;`, + ); + assert.deepEqual(callbackCalls, []); }); test("memory file system resolves relative symlink targets", async () => { const callbackCalls: string[] = []; - const api = new API({ + await using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs: { readFile: path => { @@ -3523,124 +3488,109 @@ describe("updateSnapshot file systems", () => { }, }); - try { - using snapshot = await api.updateSnapshot({ - openProject: "/project/tsconfig.json", - fileSystem: { - kind: "memory", - files: { - "/project/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: ["index.ts"] }), - "/project/index.ts": `export { value } from "./pkg";`, - "/packages/pkg/index.d.ts": `export declare const value: number;`, - }, - symlinks: { - "/project/pkg": { target: "../packages/pkg" }, - }, + using snapshot = await api.updateSnapshot({ + openProject: "/project/tsconfig.json", + fileSystem: { + kind: "memory", + files: { + "/project/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: ["index.ts"] }), + "/project/index.ts": `export { value } from "./pkg";`, + "/packages/pkg/index.d.ts": `export declare const value: number;`, }, - }); - const project = snapshot.getProject("/project/tsconfig.json")!; - assert.equal( - (await project.program.getSourceFile("/project/pkg/index.d.ts"))?.text, - `export declare const value: number;`, - ); - assert.deepEqual(callbackCalls, []); - } - finally { - await api.close(); - } + symlinks: { + "/project/pkg": { target: "../packages/pkg" }, + }, + }, + }); + const project = snapshot.getProject("/project/tsconfig.json")!; + assert.equal( + (await project.program.getSourceFile("/project/pkg/index.d.ts"))?.text, + `export declare const value: number;`, + ); + assert.deepEqual(callbackCalls, []); }); test("Snapshot.update layers filesystem edits and removals", async () => { - const api = new API({ + await using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), }); - try { - using snapshot = await api.updateSnapshot({ - openProject: "/tsconfig.json", - fileSystem: createMemoryFileSystem(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 = await snapshot.update({ - fileSystem: createCacheFileSystem( - 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.getProject("/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 assert.rejects(() => snapshot.update(), /can only update the latest snapshot/); // @sync: assert.throws(() => snapshot.update(), /can only update the latest snapshot/); - - using updatedAgain = await updated.update({ - fileSystem: createCacheFileSystem( - Object.entries({ - "/src/added.ts": `export const added = "updated again";`, - }), - { - removedPaths: ["/src/change.ts"], - }, - ), - }); - const updatedAgainProject = updatedAgain.getProject("/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); - } - finally { - await api.close(); - } + using snapshot = await api.updateSnapshot({ + openProject: "/tsconfig.json", + fileSystem: createMemoryFileSystem(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 = await snapshot.update({ + fileSystem: createCacheFileSystem( + 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.getProject("/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 assert.rejects(() => snapshot.update(), /can only update the latest snapshot/); // @sync: assert.throws(() => snapshot.update(), /can only update the latest snapshot/); + + using updatedAgain = await updated.update({ + fileSystem: createCacheFileSystem( + Object.entries({ + "/src/added.ts": `export const added = "updated again";`, + }), + { + removedPaths: ["/src/change.ts"], + }, + ), + }); + const updatedAgainProject = updatedAgain.getProject("/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); }); test("eager snapshot disposal does not retain filesystem history", async () => { - const api = new API({ + await using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), }); + let snapshot: Snapshot = await api.updateSnapshot({ + openProject: "/tsconfig.json", + fileSystem: createMemoryFileSystem([ + ["/tsconfig.json", JSON.stringify({ compilerOptions: { noLib: true }, files: ["pkg/index.ts"] })], + ["/pkg/index.ts", ""], + ]), + }); try { - let snapshot: Snapshot = await api.updateSnapshot({ - openProject: "/tsconfig.json", - fileSystem: createMemoryFileSystem([ - ["/tsconfig.json", JSON.stringify({ compilerOptions: { noLib: true }, files: ["pkg/index.ts"] })], - ["/pkg/index.ts", ""], - ]), - }); - try { - let content = ""; - for (const character of "export const x = 1") { - const oldSnapshot: Snapshot = snapshot; - content += character; - snapshot = await oldSnapshot.update({ - fileSystem: createCacheFileSystem([["/pkg/index.ts", content]]), - }); - await oldSnapshot.dispose(); - assert.equal(oldSnapshot.isDisposed(), true); - } - - const program = snapshot.getProject("/tsconfig.json")!.program; - assert.equal((await program.getSourceFile("/pkg/index.ts"))?.text, "export const x = 1"); - } - finally { - await snapshot.dispose(); + let content = ""; + for (const character of "export const x = 1") { + const oldSnapshot: Snapshot = snapshot; + content += character; + snapshot = await oldSnapshot.update({ + fileSystem: createCacheFileSystem([["/pkg/index.ts", content]]), + }); + await oldSnapshot.dispose(); + assert.equal(oldSnapshot.isDisposed(), true); } + + const program = snapshot.getProject("/tsconfig.json")!.program; + assert.equal((await program.getSourceFile("/pkg/index.ts"))?.text, "export const x = 1"); } finally { - await api.close(); + await snapshot.dispose(); } }); @@ -3648,74 +3598,64 @@ describe("updateSnapshot file systems", () => { const host = createVirtualFileSystem({ "/host.ts": `export const source = "host";`, }); - const api = new API({ + await using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs: host, }); - try { - using snapshot = await api.updateSnapshot(); - using replaced = await snapshot.update({ - openProject: "/tsconfig.json", - fileSystem: createMemoryFileSystem([ - ["/tsconfig.json", JSON.stringify({ compilerOptions: { noLib: true }, files: ["memory.ts", "host.ts"] })], - ["/memory.ts", `export const source = "memory";`], - ]), - }); - const program = replaced.getProject("/tsconfig.json")!.program; - assert.equal((await program.getSourceFile("/memory.ts"))?.text, `export const source = "memory";`); - assert.equal(await program.getSourceFile("/host.ts"), undefined); - } - finally { - await api.close(); - } + using snapshot = await api.updateSnapshot(); + using replaced = await snapshot.update({ + openProject: "/tsconfig.json", + fileSystem: createMemoryFileSystem([ + ["/tsconfig.json", JSON.stringify({ compilerOptions: { noLib: true }, files: ["memory.ts", "host.ts"] })], + ["/memory.ts", `export const source = "memory";`], + ]), + }); + const program = replaced.getProject("/tsconfig.json")!.program; + assert.equal((await program.getSourceFile("/memory.ts"))?.text, `export const source = "memory";`); + assert.equal(await program.getSourceFile("/host.ts"), undefined); }); test("Snapshot.update applies target changes through inherited symlinks", async () => { - const api = new API({ + await using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), }); - try { - using snapshot = await api.updateSnapshot({ - openProject: "/tsconfig.json", - fileSystem: createMemoryFileSystem( - Object.entries({ - "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: ["src/main.ts"] }), - "/src/main.ts": `import "./link/change"; import "./link/added"; import "./link/remove";`, - "/target/change.ts": `export const version = "old";`, - "/target/remove.ts": `export const removed = true;`, - }), - { - symlinks: { - "/src/link": { target: "/target" }, - }, + using snapshot = await api.updateSnapshot({ + openProject: "/tsconfig.json", + fileSystem: createMemoryFileSystem( + Object.entries({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: ["src/main.ts"] }), + "/src/main.ts": `import "./link/change"; import "./link/added"; import "./link/remove";`, + "/target/change.ts": `export const version = "old";`, + "/target/remove.ts": `export const removed = true;`, + }), + { + symlinks: { + "/src/link": { target: "/target" }, }, - ), - }); + }, + ), + }); - using updated = await snapshot.update({ - fileSystem: createCacheFileSystem( - Object.entries({ - "/target/change.ts": `export const version = "new";`, - "/target/added.ts": `export const added = true;`, - }), - { - removedPaths: ["/target/remove.ts"], - }, - ), - }); - const program = updated.getProject("/tsconfig.json")!.program; - assert.equal((await program.getSourceFile("/src/link/change.ts"))?.text, `export const version = "new";`); - assert.equal((await program.getSourceFile("/src/link/added.ts"))?.text, `export const added = true;`); - assert.equal(await program.getSourceFile("/src/link/remove.ts"), undefined); - } - finally { - await api.close(); - } + using updated = await snapshot.update({ + fileSystem: createCacheFileSystem( + Object.entries({ + "/target/change.ts": `export const version = "new";`, + "/target/added.ts": `export const added = true;`, + }), + { + removedPaths: ["/target/remove.ts"], + }, + ), + }); + const program = updated.getProject("/tsconfig.json")!.program; + assert.equal((await program.getSourceFile("/src/link/change.ts"))?.text, `export const version = "new";`); + assert.equal((await program.getSourceFile("/src/link/added.ts"))?.text, `export const added = true;`); + assert.equal(await program.getSourceFile("/src/link/remove.ts"), undefined); }); test("memory filesystem emit returns outputs without mutating the host", async () => { const hostWrites: string[] = []; - const api = new API({ + await using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs: { writeFile: path => { @@ -3723,57 +3663,47 @@ describe("updateSnapshot file systems", () => { }, }, }); - try { - using snapshot = await api.updateSnapshot({ - openProject: "/tsconfig.json", - fileSystem: createMemoryFileSystem(Object.entries({ - "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, outDir: "/out", rootDir: "/src" }, files: ["src/main.ts"] }), - "/src/main.ts": `export const value: number = 1;`, - })), - }); - const program = snapshot.getProject("/tsconfig.json")!.program; - const result = await program.emit(); - assert.deepEqual(result.emittedFiles, ["/out/main.js"]); - assert.deepEqual(result.fileSystem, { - kind: "cache", - files: { - "/out/main.js": `export const value = 1;\n`, - }, - }); - assert.deepEqual(hostWrites, []); + using snapshot = await api.updateSnapshot({ + openProject: "/tsconfig.json", + fileSystem: createMemoryFileSystem(Object.entries({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, outDir: "/out", rootDir: "/src" }, files: ["src/main.ts"] }), + "/src/main.ts": `export const value: number = 1;`, + })), + }); + const program = snapshot.getProject("/tsconfig.json")!.program; + const result = await program.emit(); + assert.deepEqual(result.emittedFiles, ["/out/main.js"]); + assert.deepEqual(result.fileSystem, { + kind: "cache", + files: { + "/out/main.js": `export const value = 1;\n`, + }, + }); + assert.deepEqual(hostWrites, []); - using updated = await snapshot.update({ fileSystem: result.fileSystem!, openFiles: ["/out/main.js"] }); - const outputProject = await updated.getDefaultProjectForFile("/out/main.js"); - assert.equal((await updated.getProject("/tsconfig.json")!.program.getSourceFile("/src/main.ts"))?.text, `export const value: number = 1;`); - assert.equal((await outputProject?.program.getSourceFile("/out/main.js"))?.text, `export const value = 1;\n`); - } - finally { - await api.close(); - } + using updated = await snapshot.update({ fileSystem: result.fileSystem!, openFiles: ["/out/main.js"] }); + const outputProject = await updated.getDefaultProjectForFile("/out/main.js"); + assert.equal((await updated.getProject("/tsconfig.json")!.program.getSourceFile("/src/main.ts"))?.text, `export const value: number = 1;`); + assert.equal((await outputProject?.program.getSourceFile("/out/main.js"))?.text, `export const value = 1;\n`); }); test("cache filesystem emit writes through to the host", async () => { const host = createVirtualFileSystem({}); - const api = new API({ + await using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs: host, }); - try { - using snapshot = await api.updateSnapshot({ - openProject: "/tsconfig.json", - fileSystem: createCacheFileSystem(Object.entries({ - "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, outDir: "/out", rootDir: "/src" }, files: ["src/main.ts"] }), - "/src/main.ts": `export const value: number = 1;`, - })), - }); - const program = snapshot.getProject("/tsconfig.json")!.program; - const result = await program.emit(); - assert.equal(result.fileSystem, undefined); - assert.equal(host.readFile!("/out/main.js"), `export const value = 1;\n`); - } - finally { - await api.close(); - } + using snapshot = await api.updateSnapshot({ + openProject: "/tsconfig.json", + fileSystem: createCacheFileSystem(Object.entries({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, outDir: "/out", rootDir: "/src" }, files: ["src/main.ts"] }), + "/src/main.ts": `export const value: number = 1;`, + })), + }); + const program = snapshot.getProject("/tsconfig.json")!.program; + const result = await program.emit(); + assert.equal(result.fileSystem, undefined); + assert.equal(host.readFile!("/out/main.js"), `export const value = 1;\n`); }); test("memory file system can link node_modules from the host", async () => { @@ -3783,7 +3713,7 @@ describe("updateSnapshot file systems", () => { const host = createVirtualFileSystem({ "/host/node_modules/pkg/index.d.ts": `export declare const value: string;`, }); - const api = new API({ + await using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs: { ...host, @@ -3803,70 +3733,60 @@ describe("updateSnapshot file systems", () => { }, }); - try { - using snapshot = await api.updateSnapshot({ - openProject: "/project/tsconfig.json", - fileSystem: { - kind: "memory", - files: { - "/project/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, moduleResolution: "node" }, files: ["index.ts"] }), - "/project/index.ts": `import { value } from "pkg"; export { value };`, - }, - symlinks: { - "/project/node_modules": { target: "/host/node_modules", host: true }, - }, + using snapshot = await api.updateSnapshot({ + openProject: "/project/tsconfig.json", + fileSystem: { + kind: "memory", + files: { + "/project/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, moduleResolution: "node" }, files: ["index.ts"] }), + "/project/index.ts": `import { value } from "pkg"; export { value };`, }, - }); - const project = snapshot.getProject("/project/tsconfig.json")!; - const sourceFileNames = await project.program.getSourceFileNames(); - assert.ok( - sourceFileNames.includes("/host/node_modules/pkg/index.d.ts"), - JSON.stringify({ sourceFileNames, readFileCalls, directoryExistsCalls, fileExistsCalls }), - ); - assert.equal( - (await project.program.getSourceFile("/host/node_modules/pkg/index.d.ts"))?.text, - `export declare const value: string;`, - ); - assert.ok(readFileCalls.includes("/host/node_modules/pkg/index.d.ts")); - assert.ok(!readFileCalls.some(path => path.startsWith("/project/node_modules"))); - } - finally { - await api.close(); - } + symlinks: { + "/project/node_modules": { target: "/host/node_modules", host: true }, + }, + }, + }); + const project = snapshot.getProject("/project/tsconfig.json")!; + const sourceFileNames = await project.program.getSourceFileNames(); + assert.ok( + sourceFileNames.includes("/host/node_modules/pkg/index.d.ts"), + JSON.stringify({ sourceFileNames, readFileCalls, directoryExistsCalls, fileExistsCalls }), + ); + assert.equal( + (await project.program.getSourceFile("/host/node_modules/pkg/index.d.ts"))?.text, + `export declare const value: string;`, + ); + assert.ok(readFileCalls.includes("/host/node_modules/pkg/index.d.ts")); + assert.ok(!readFileCalls.some(path => path.startsWith("/project/node_modules"))); }); test("Snapshot.update host symlinks bypass an inherited memory filesystem", async () => { const host = createVirtualFileSystem({ "/host/node_modules/pkg/index.d.ts": `export declare const value: string;`, }); - const api = new API({ + await using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs: host, }); - try { - using snapshot = await api.updateSnapshot({ - openProject: "/project/tsconfig.json", - fileSystem: createMemoryFileSystem([ - ["/project/tsconfig.json", JSON.stringify({ compilerOptions: { noLib: true, moduleResolution: "node" }, files: ["index.ts"] })], - ["/project/index.ts", `import { value } from "pkg"; export { value };`], - ]), - }); - using updated = await snapshot.update({ - fileSystem: createCacheFileSystem([], { - symlinks: { - "/project/node_modules": { target: "/host/node_modules", host: true }, - }, - }), - }); - const project = updated.getProject("/project/tsconfig.json")!; - assert.equal( - (await project.program.getSourceFile("/host/node_modules/pkg/index.d.ts"))?.text, - `export declare const value: string;`, - ); - } - finally { - await api.close(); - } + using snapshot = await api.updateSnapshot({ + openProject: "/project/tsconfig.json", + fileSystem: createMemoryFileSystem([ + ["/project/tsconfig.json", JSON.stringify({ compilerOptions: { noLib: true, moduleResolution: "node" }, files: ["index.ts"] })], + ["/project/index.ts", `import { value } from "pkg"; export { value };`], + ]), + }); + using updated = await snapshot.update({ + fileSystem: createCacheFileSystem([], { + symlinks: { + "/project/node_modules": { target: "/host/node_modules", host: true }, + }, + }), + }); + const project = updated.getProject("/project/tsconfig.json")!; + assert.equal( + (await project.program.getSourceFile("/host/node_modules/pkg/index.d.ts"))?.text, + `export declare const value: string;`, + ); }); // TODO: Add request filesystem coverage for `tsc -b` and `tsc -b --clean` diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index b7ddb46bbd429..c256d86b3ac81 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -3193,89 +3193,74 @@ describe("updateSnapshot file systems", () => { host.writeFile!(path, content); }, }; - const api = new API({ + using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs, }); - try { - using snapshot = api.updateSnapshot({ - openProject: "/tsconfig.json", - fileSystem: { - kind: "memory", - files: { - "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, include: ["src/**/*.ts"] }), - "/src/index.ts": `export const source = "memory";`, - }, - directories: { - "/": { files: ["tsconfig.json"], directories: ["src"] }, - "/src": { files: ["index.ts"], directories: [] }, - }, + using snapshot = api.updateSnapshot({ + openProject: "/tsconfig.json", + fileSystem: { + kind: "memory", + files: { + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, include: ["src/**/*.ts"] }), + "/src/index.ts": `export const source = "memory";`, }, - }); - const project = snapshot.getProject("/tsconfig.json")!; - const sourceFile = project.program.getSourceFile("/src/index.ts"); - assert.equal(sourceFile?.text, `export const source = "memory";`); - assert.equal(project.program.getSourceFile("/host.ts"), undefined); - assert.deepEqual(callbackCalls, []); - } - finally { - api.close(); - } + directories: { + "/": { files: ["tsconfig.json"], directories: ["src"] }, + "/src": { files: ["index.ts"], directories: [] }, + }, + }, + }); + const project = snapshot.getProject("/tsconfig.json")!; + const sourceFile = project.program.getSourceFile("/src/index.ts"); + assert.equal(sourceFile?.text, `export const source = "memory";`); + assert.equal(project.program.getSourceFile("/host.ts"), undefined); + assert.deepEqual(callbackCalls, []); }); test("memory file system with lib resolves the default library", () => { - const api = new API({ + using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), }); - try { - using snapshot = api.updateSnapshot({ - openProject: "/tsconfig.json", - fileSystem: createMemoryFileSystemWithLib(Object.entries({ - "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true }, files: ["src/main.ts"] }), - "/src/main.ts": `export const values: Array = [];`, - })), - }); - const program = snapshot.getProject("/tsconfig.json")!.program; - assert.deepEqual(program.getGlobalDiagnostics(), []); - const sourceFileNames = program.getSourceFileNames(); - const defaultLibraryName = sourceFileNames.find(fileName => fileName.includes("/lib.") && fileName.endsWith(".d.ts")); - assert.ok(defaultLibraryName, JSON.stringify(sourceFileNames)); - const defaultLibrary = program.getSourceFile(defaultLibraryName); - assert.ok(defaultLibrary); - assert.equal(program.isSourceFileDefaultLibrary(defaultLibrary), true); - } - finally { - api.close(); - } + using snapshot = api.updateSnapshot({ + openProject: "/tsconfig.json", + fileSystem: createMemoryFileSystemWithLib(Object.entries({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true }, files: ["src/main.ts"] }), + "/src/main.ts": `export const values: Array = [];`, + })), + }); + const program = snapshot.getProject("/tsconfig.json")!.program; + assert.deepEqual(program.getGlobalDiagnostics(), []); + const sourceFileNames = program.getSourceFileNames(); + const defaultLibraryName = sourceFileNames.find(fileName => fileName.includes("/lib.") && fileName.endsWith(".d.ts")); + assert.ok(defaultLibraryName, JSON.stringify(sourceFileNames)); + const defaultLibrary = program.getSourceFile(defaultLibraryName); + assert.ok(defaultLibrary); + assert.equal(program.isSourceFileDefaultLibrary(defaultLibrary), true); }); test("memory file system accepts paths decoded from VS Code document URIs", () => { const fileDocument = { uri: "file:///workspace/file%20name.ts" }; const remoteDocument = { uri: "vscode-remote://ssh-remote+host/workspace/src/remote%20name.ts" }; const notebookDocument = { uri: "vscode-notebook-cell:/workspace/notebook.ipynb/cell%20name.ts" }; - const api = new API({ + using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), }); - try { - using snapshot = api.updateSnapshot({ - openFiles: [fileDocument, remoteDocument, notebookDocument], - fileSystem: createMemoryFileSystem([ - [fileDocument, `export const file = true;`], - [remoteDocument, `export const remote = true;`], - [notebookDocument, `export const cell = true;`], - ]), - }); - const fileProject = snapshot.getDefaultProjectForFile(fileDocument); - const remoteProject = snapshot.getDefaultProjectForFile(remoteDocument); - const notebookProject = snapshot.getDefaultProjectForFile(notebookDocument); - assert.equal((fileProject?.program.getSourceFile(fileDocument))?.text, `export const file = true;`); - assert.equal((remoteProject?.program.getSourceFile(remoteDocument))?.text, `export const remote = true;`); - assert.equal((notebookProject?.program.getSourceFile(notebookDocument))?.text, `export const cell = true;`); - } - finally { - api.close(); - } + using snapshot = api.updateSnapshot({ + openFiles: [fileDocument, remoteDocument, notebookDocument], + fileSystem: createMemoryFileSystem([ + [fileDocument, `export const file = true;`], + [remoteDocument, `export const remote = true;`], + [notebookDocument, `export const cell = true;`], + ]), + }); + const fileProject = snapshot.getDefaultProjectForFile(fileDocument); + const remoteProject = snapshot.getDefaultProjectForFile(remoteDocument); + const notebookProject = snapshot.getDefaultProjectForFile(notebookDocument); + assert.equal((fileProject?.program.getSourceFile(fileDocument))?.text, `export const file = true;`); + assert.equal((remoteProject?.program.getSourceFile(remoteDocument))?.text, `export const remote = true;`); + assert.equal((notebookProject?.program.getSourceFile(notebookDocument))?.text, `export const cell = true;`); }); test("cache file system bypasses callbacks on hits and falls back on misses", () => { @@ -3295,71 +3280,61 @@ describe("updateSnapshot file systems", () => { return host.getAccessibleEntries!(path); }, }; - const api = new API({ + using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs, }); - try { - using snapshot = api.updateSnapshot({ - openProject: "/tsconfig.json", - fileSystem: { - kind: "cache", - files: { - "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, include: ["src/**/*.ts"] }), - "/src/index.ts": `export const cached = true;`, - }, - directories: { - "/": { files: ["tsconfig.json"], directories: ["src"] }, - "/src": { files: ["fallback.ts", "index.ts"], directories: [] }, - }, + using snapshot = api.updateSnapshot({ + openProject: "/tsconfig.json", + fileSystem: { + kind: "cache", + files: { + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, include: ["src/**/*.ts"] }), + "/src/index.ts": `export const cached = true;`, }, - }); - const project = snapshot.getProject("/tsconfig.json")!; - assert.equal((project.program.getSourceFile("/src/index.ts"))?.text, `export const cached = true;`); - assert.equal((project.program.getSourceFile("/src/fallback.ts"))?.text, `export const fallback = true;`); - - assert.ok(!readFileCalls.includes("/tsconfig.json")); - assert.ok(!readFileCalls.includes("/src/index.ts")); - assert.ok(readFileCalls.includes("/src/fallback.ts")); - assert.ok(!directoryCalls.includes("/")); - assert.ok(!directoryCalls.includes("/src")); - } - finally { - api.close(); - } + directories: { + "/": { files: ["tsconfig.json"], directories: ["src"] }, + "/src": { files: ["fallback.ts", "index.ts"], directories: [] }, + }, + }, + }); + const project = snapshot.getProject("/tsconfig.json")!; + assert.equal((project.program.getSourceFile("/src/index.ts"))?.text, `export const cached = true;`); + assert.equal((project.program.getSourceFile("/src/fallback.ts"))?.text, `export const fallback = true;`); + + assert.ok(!readFileCalls.includes("/tsconfig.json")); + assert.ok(!readFileCalls.includes("/src/index.ts")); + assert.ok(readFileCalls.includes("/src/fallback.ts")); + assert.ok(!directoryCalls.includes("/")); + assert.ok(!directoryCalls.includes("/src")); }); test("cache file system factory preserves host directory entries", () => { - const api = new API({ + using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs: createVirtualFileSystem({ "/src/from-host.ts": `export const host = true;`, }), }); - try { - using snapshot = api.updateSnapshot({ - openProject: "/tsconfig.json", - fileSystem: createCacheFileSystem([ - ["/tsconfig.json", JSON.stringify({ compilerOptions: { noLib: true }, include: ["src/**/*.ts"] })], - ["/src/from-cache.ts", `export const cache = true;`], - ]), - }); - const program = snapshot.getProject("/tsconfig.json")!.program; - assert.deepEqual( - [...program.getSourceFileNames()].sort(), - ["/src/from-cache.ts", "/src/from-host.ts"], - ); - } - finally { - api.close(); - } + using snapshot = api.updateSnapshot({ + openProject: "/tsconfig.json", + fileSystem: createCacheFileSystem([ + ["/tsconfig.json", JSON.stringify({ compilerOptions: { noLib: true }, include: ["src/**/*.ts"] })], + ["/src/from-cache.ts", `export const cache = true;`], + ]), + }); + const program = snapshot.getProject("/tsconfig.json")!.program; + assert.deepEqual( + [...program.getSourceFileNames()].sort(), + ["/src/from-cache.ts", "/src/from-host.ts"], + ); }); test("memory file system resolves packages through internal monorepo symlinks", () => { const callbackCalls: string[] = []; - const api = new API({ + using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs: { readFile: path => { @@ -3369,36 +3344,31 @@ describe("updateSnapshot file systems", () => { }, }); - try { - using snapshot = api.updateSnapshot({ - openProject: "/project/tsconfig.json", - fileSystem: { - kind: "memory", - files: { - "/project/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, moduleResolution: "node" }, files: ["index.ts"] }), - "/project/index.ts": `import { value } from "pkg"; export { value };`, - "/packages/pkg/index.d.ts": `export declare const value: number;`, - }, - symlinks: { - "/project/node_modules/pkg": { target: "/packages/pkg" }, - }, + using snapshot = api.updateSnapshot({ + openProject: "/project/tsconfig.json", + fileSystem: { + kind: "memory", + files: { + "/project/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, moduleResolution: "node" }, files: ["index.ts"] }), + "/project/index.ts": `import { value } from "pkg"; export { value };`, + "/packages/pkg/index.d.ts": `export declare const value: number;`, }, - }); - const project = snapshot.getProject("/project/tsconfig.json")!; - assert.equal( - (project.program.getSourceFile("/packages/pkg/index.d.ts"))?.text, - `export declare const value: number;`, - ); - assert.deepEqual(callbackCalls, []); - } - finally { - api.close(); - } + symlinks: { + "/project/node_modules/pkg": { target: "/packages/pkg" }, + }, + }, + }); + const project = snapshot.getProject("/project/tsconfig.json")!; + assert.equal( + (project.program.getSourceFile("/packages/pkg/index.d.ts"))?.text, + `export declare const value: number;`, + ); + assert.deepEqual(callbackCalls, []); }); test("memory file system resolves relative symlink targets", () => { const callbackCalls: string[] = []; - const api = new API({ + using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs: { readFile: path => { @@ -3408,124 +3378,109 @@ describe("updateSnapshot file systems", () => { }, }); - try { - using snapshot = api.updateSnapshot({ - openProject: "/project/tsconfig.json", - fileSystem: { - kind: "memory", - files: { - "/project/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: ["index.ts"] }), - "/project/index.ts": `export { value } from "./pkg";`, - "/packages/pkg/index.d.ts": `export declare const value: number;`, - }, - symlinks: { - "/project/pkg": { target: "../packages/pkg" }, - }, + using snapshot = api.updateSnapshot({ + openProject: "/project/tsconfig.json", + fileSystem: { + kind: "memory", + files: { + "/project/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: ["index.ts"] }), + "/project/index.ts": `export { value } from "./pkg";`, + "/packages/pkg/index.d.ts": `export declare const value: number;`, }, - }); - const project = snapshot.getProject("/project/tsconfig.json")!; - assert.equal( - (project.program.getSourceFile("/project/pkg/index.d.ts"))?.text, - `export declare const value: number;`, - ); - assert.deepEqual(callbackCalls, []); - } - finally { - api.close(); - } + symlinks: { + "/project/pkg": { target: "../packages/pkg" }, + }, + }, + }); + const project = snapshot.getProject("/project/tsconfig.json")!; + assert.equal( + (project.program.getSourceFile("/project/pkg/index.d.ts"))?.text, + `export declare const value: number;`, + ); + assert.deepEqual(callbackCalls, []); }); test("Snapshot.update layers filesystem edits and removals", () => { - const api = new API({ + using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), }); - try { - using snapshot = api.updateSnapshot({ - openProject: "/tsconfig.json", - fileSystem: createMemoryFileSystem(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 = snapshot.update({ - fileSystem: createCacheFileSystem( - 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.getProject("/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); - assert.throws(() => snapshot.update(), /can only update the latest snapshot/); - - using updatedAgain = updated.update({ - fileSystem: createCacheFileSystem( - Object.entries({ - "/src/added.ts": `export const added = "updated again";`, - }), - { - removedPaths: ["/src/change.ts"], - }, - ), - }); - const updatedAgainProject = updatedAgain.getProject("/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); - } - finally { - api.close(); - } + using snapshot = api.updateSnapshot({ + openProject: "/tsconfig.json", + fileSystem: createMemoryFileSystem(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 = snapshot.update({ + fileSystem: createCacheFileSystem( + 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.getProject("/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); + assert.throws(() => snapshot.update(), /can only update the latest snapshot/); + + using updatedAgain = updated.update({ + fileSystem: createCacheFileSystem( + Object.entries({ + "/src/added.ts": `export const added = "updated again";`, + }), + { + removedPaths: ["/src/change.ts"], + }, + ), + }); + const updatedAgainProject = updatedAgain.getProject("/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); }); test("eager snapshot disposal does not retain filesystem history", () => { - const api = new API({ + using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), }); + let snapshot: Snapshot = api.updateSnapshot({ + openProject: "/tsconfig.json", + fileSystem: createMemoryFileSystem([ + ["/tsconfig.json", JSON.stringify({ compilerOptions: { noLib: true }, files: ["pkg/index.ts"] })], + ["/pkg/index.ts", ""], + ]), + }); try { - let snapshot: Snapshot = api.updateSnapshot({ - openProject: "/tsconfig.json", - fileSystem: createMemoryFileSystem([ - ["/tsconfig.json", JSON.stringify({ compilerOptions: { noLib: true }, files: ["pkg/index.ts"] })], - ["/pkg/index.ts", ""], - ]), - }); - try { - let content = ""; - for (const character of "export const x = 1") { - const oldSnapshot: Snapshot = snapshot; - content += character; - snapshot = oldSnapshot.update({ - fileSystem: createCacheFileSystem([["/pkg/index.ts", content]]), - }); - oldSnapshot.dispose(); - assert.equal(oldSnapshot.isDisposed(), true); - } - - const program = snapshot.getProject("/tsconfig.json")!.program; - assert.equal((program.getSourceFile("/pkg/index.ts"))?.text, "export const x = 1"); - } - finally { - snapshot.dispose(); + let content = ""; + for (const character of "export const x = 1") { + const oldSnapshot: Snapshot = snapshot; + content += character; + snapshot = oldSnapshot.update({ + fileSystem: createCacheFileSystem([["/pkg/index.ts", content]]), + }); + oldSnapshot.dispose(); + assert.equal(oldSnapshot.isDisposed(), true); } + + const program = snapshot.getProject("/tsconfig.json")!.program; + assert.equal((program.getSourceFile("/pkg/index.ts"))?.text, "export const x = 1"); } finally { - api.close(); + snapshot.dispose(); } }); @@ -3533,74 +3488,64 @@ describe("updateSnapshot file systems", () => { const host = createVirtualFileSystem({ "/host.ts": `export const source = "host";`, }); - const api = new API({ + using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs: host, }); - try { - using snapshot = api.updateSnapshot(); - using replaced = snapshot.update({ - openProject: "/tsconfig.json", - fileSystem: createMemoryFileSystem([ - ["/tsconfig.json", JSON.stringify({ compilerOptions: { noLib: true }, files: ["memory.ts", "host.ts"] })], - ["/memory.ts", `export const source = "memory";`], - ]), - }); - const program = replaced.getProject("/tsconfig.json")!.program; - assert.equal((program.getSourceFile("/memory.ts"))?.text, `export const source = "memory";`); - assert.equal(program.getSourceFile("/host.ts"), undefined); - } - finally { - api.close(); - } + using snapshot = api.updateSnapshot(); + using replaced = snapshot.update({ + openProject: "/tsconfig.json", + fileSystem: createMemoryFileSystem([ + ["/tsconfig.json", JSON.stringify({ compilerOptions: { noLib: true }, files: ["memory.ts", "host.ts"] })], + ["/memory.ts", `export const source = "memory";`], + ]), + }); + const program = replaced.getProject("/tsconfig.json")!.program; + assert.equal((program.getSourceFile("/memory.ts"))?.text, `export const source = "memory";`); + assert.equal(program.getSourceFile("/host.ts"), undefined); }); test("Snapshot.update applies target changes through inherited symlinks", () => { - const api = new API({ + using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), }); - try { - using snapshot = api.updateSnapshot({ - openProject: "/tsconfig.json", - fileSystem: createMemoryFileSystem( - Object.entries({ - "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: ["src/main.ts"] }), - "/src/main.ts": `import "./link/change"; import "./link/added"; import "./link/remove";`, - "/target/change.ts": `export const version = "old";`, - "/target/remove.ts": `export const removed = true;`, - }), - { - symlinks: { - "/src/link": { target: "/target" }, - }, + using snapshot = api.updateSnapshot({ + openProject: "/tsconfig.json", + fileSystem: createMemoryFileSystem( + Object.entries({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: ["src/main.ts"] }), + "/src/main.ts": `import "./link/change"; import "./link/added"; import "./link/remove";`, + "/target/change.ts": `export const version = "old";`, + "/target/remove.ts": `export const removed = true;`, + }), + { + symlinks: { + "/src/link": { target: "/target" }, }, - ), - }); + }, + ), + }); - using updated = snapshot.update({ - fileSystem: createCacheFileSystem( - Object.entries({ - "/target/change.ts": `export const version = "new";`, - "/target/added.ts": `export const added = true;`, - }), - { - removedPaths: ["/target/remove.ts"], - }, - ), - }); - const program = updated.getProject("/tsconfig.json")!.program; - assert.equal((program.getSourceFile("/src/link/change.ts"))?.text, `export const version = "new";`); - assert.equal((program.getSourceFile("/src/link/added.ts"))?.text, `export const added = true;`); - assert.equal(program.getSourceFile("/src/link/remove.ts"), undefined); - } - finally { - api.close(); - } + using updated = snapshot.update({ + fileSystem: createCacheFileSystem( + Object.entries({ + "/target/change.ts": `export const version = "new";`, + "/target/added.ts": `export const added = true;`, + }), + { + removedPaths: ["/target/remove.ts"], + }, + ), + }); + const program = updated.getProject("/tsconfig.json")!.program; + assert.equal((program.getSourceFile("/src/link/change.ts"))?.text, `export const version = "new";`); + assert.equal((program.getSourceFile("/src/link/added.ts"))?.text, `export const added = true;`); + assert.equal(program.getSourceFile("/src/link/remove.ts"), undefined); }); test("memory filesystem emit returns outputs without mutating the host", () => { const hostWrites: string[] = []; - const api = new API({ + using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs: { writeFile: path => { @@ -3608,57 +3553,47 @@ describe("updateSnapshot file systems", () => { }, }, }); - try { - using snapshot = api.updateSnapshot({ - openProject: "/tsconfig.json", - fileSystem: createMemoryFileSystem(Object.entries({ - "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, outDir: "/out", rootDir: "/src" }, files: ["src/main.ts"] }), - "/src/main.ts": `export const value: number = 1;`, - })), - }); - const program = snapshot.getProject("/tsconfig.json")!.program; - const result = program.emit(); - assert.deepEqual(result.emittedFiles, ["/out/main.js"]); - assert.deepEqual(result.fileSystem, { - kind: "cache", - files: { - "/out/main.js": `export const value = 1;\n`, - }, - }); - assert.deepEqual(hostWrites, []); + using snapshot = api.updateSnapshot({ + openProject: "/tsconfig.json", + fileSystem: createMemoryFileSystem(Object.entries({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, outDir: "/out", rootDir: "/src" }, files: ["src/main.ts"] }), + "/src/main.ts": `export const value: number = 1;`, + })), + }); + const program = snapshot.getProject("/tsconfig.json")!.program; + const result = program.emit(); + assert.deepEqual(result.emittedFiles, ["/out/main.js"]); + assert.deepEqual(result.fileSystem, { + kind: "cache", + files: { + "/out/main.js": `export const value = 1;\n`, + }, + }); + assert.deepEqual(hostWrites, []); - using updated = snapshot.update({ fileSystem: result.fileSystem!, openFiles: ["/out/main.js"] }); - const outputProject = updated.getDefaultProjectForFile("/out/main.js"); - assert.equal((updated.getProject("/tsconfig.json")!.program.getSourceFile("/src/main.ts"))?.text, `export const value: number = 1;`); - assert.equal((outputProject?.program.getSourceFile("/out/main.js"))?.text, `export const value = 1;\n`); - } - finally { - api.close(); - } + using updated = snapshot.update({ fileSystem: result.fileSystem!, openFiles: ["/out/main.js"] }); + const outputProject = updated.getDefaultProjectForFile("/out/main.js"); + assert.equal((updated.getProject("/tsconfig.json")!.program.getSourceFile("/src/main.ts"))?.text, `export const value: number = 1;`); + assert.equal((outputProject?.program.getSourceFile("/out/main.js"))?.text, `export const value = 1;\n`); }); test("cache filesystem emit writes through to the host", () => { const host = createVirtualFileSystem({}); - const api = new API({ + using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs: host, }); - try { - using snapshot = api.updateSnapshot({ - openProject: "/tsconfig.json", - fileSystem: createCacheFileSystem(Object.entries({ - "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, outDir: "/out", rootDir: "/src" }, files: ["src/main.ts"] }), - "/src/main.ts": `export const value: number = 1;`, - })), - }); - const program = snapshot.getProject("/tsconfig.json")!.program; - const result = program.emit(); - assert.equal(result.fileSystem, undefined); - assert.equal(host.readFile!("/out/main.js"), `export const value = 1;\n`); - } - finally { - api.close(); - } + using snapshot = api.updateSnapshot({ + openProject: "/tsconfig.json", + fileSystem: createCacheFileSystem(Object.entries({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, outDir: "/out", rootDir: "/src" }, files: ["src/main.ts"] }), + "/src/main.ts": `export const value: number = 1;`, + })), + }); + const program = snapshot.getProject("/tsconfig.json")!.program; + const result = program.emit(); + assert.equal(result.fileSystem, undefined); + assert.equal(host.readFile!("/out/main.js"), `export const value = 1;\n`); }); test("memory file system can link node_modules from the host", () => { @@ -3668,7 +3603,7 @@ describe("updateSnapshot file systems", () => { const host = createVirtualFileSystem({ "/host/node_modules/pkg/index.d.ts": `export declare const value: string;`, }); - const api = new API({ + using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs: { ...host, @@ -3688,70 +3623,60 @@ describe("updateSnapshot file systems", () => { }, }); - try { - using snapshot = api.updateSnapshot({ - openProject: "/project/tsconfig.json", - fileSystem: { - kind: "memory", - files: { - "/project/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, moduleResolution: "node" }, files: ["index.ts"] }), - "/project/index.ts": `import { value } from "pkg"; export { value };`, - }, - symlinks: { - "/project/node_modules": { target: "/host/node_modules", host: true }, - }, + using snapshot = api.updateSnapshot({ + openProject: "/project/tsconfig.json", + fileSystem: { + kind: "memory", + files: { + "/project/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, moduleResolution: "node" }, files: ["index.ts"] }), + "/project/index.ts": `import { value } from "pkg"; export { value };`, }, - }); - const project = snapshot.getProject("/project/tsconfig.json")!; - const sourceFileNames = project.program.getSourceFileNames(); - assert.ok( - sourceFileNames.includes("/host/node_modules/pkg/index.d.ts"), - JSON.stringify({ sourceFileNames, readFileCalls, directoryExistsCalls, fileExistsCalls }), - ); - assert.equal( - (project.program.getSourceFile("/host/node_modules/pkg/index.d.ts"))?.text, - `export declare const value: string;`, - ); - assert.ok(readFileCalls.includes("/host/node_modules/pkg/index.d.ts")); - assert.ok(!readFileCalls.some(path => path.startsWith("/project/node_modules"))); - } - finally { - api.close(); - } + symlinks: { + "/project/node_modules": { target: "/host/node_modules", host: true }, + }, + }, + }); + const project = snapshot.getProject("/project/tsconfig.json")!; + const sourceFileNames = project.program.getSourceFileNames(); + assert.ok( + sourceFileNames.includes("/host/node_modules/pkg/index.d.ts"), + JSON.stringify({ sourceFileNames, readFileCalls, directoryExistsCalls, fileExistsCalls }), + ); + assert.equal( + (project.program.getSourceFile("/host/node_modules/pkg/index.d.ts"))?.text, + `export declare const value: string;`, + ); + assert.ok(readFileCalls.includes("/host/node_modules/pkg/index.d.ts")); + assert.ok(!readFileCalls.some(path => path.startsWith("/project/node_modules"))); }); test("Snapshot.update host symlinks bypass an inherited memory filesystem", () => { const host = createVirtualFileSystem({ "/host/node_modules/pkg/index.d.ts": `export declare const value: string;`, }); - const api = new API({ + using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs: host, }); - try { - using snapshot = api.updateSnapshot({ - openProject: "/project/tsconfig.json", - fileSystem: createMemoryFileSystem([ - ["/project/tsconfig.json", JSON.stringify({ compilerOptions: { noLib: true, moduleResolution: "node" }, files: ["index.ts"] })], - ["/project/index.ts", `import { value } from "pkg"; export { value };`], - ]), - }); - using updated = snapshot.update({ - fileSystem: createCacheFileSystem([], { - symlinks: { - "/project/node_modules": { target: "/host/node_modules", host: true }, - }, - }), - }); - const project = updated.getProject("/project/tsconfig.json")!; - assert.equal( - (project.program.getSourceFile("/host/node_modules/pkg/index.d.ts"))?.text, - `export declare const value: string;`, - ); - } - finally { - api.close(); - } + using snapshot = api.updateSnapshot({ + openProject: "/project/tsconfig.json", + fileSystem: createMemoryFileSystem([ + ["/project/tsconfig.json", JSON.stringify({ compilerOptions: { noLib: true, moduleResolution: "node" }, files: ["index.ts"] })], + ["/project/index.ts", `import { value } from "pkg"; export { value };`], + ]), + }); + using updated = snapshot.update({ + fileSystem: createCacheFileSystem([], { + symlinks: { + "/project/node_modules": { target: "/host/node_modules", host: true }, + }, + }), + }); + const project = updated.getProject("/project/tsconfig.json")!; + assert.equal( + (project.program.getSourceFile("/host/node_modules/pkg/index.d.ts"))?.text, + `export declare const value: string;`, + ); }); // TODO: Add request filesystem coverage for `tsc -b` and `tsc -b --clean` From 6011f3bb934bb06ef47faa4538b32fa88e172e34 Mon Sep 17 00:00:00 2001 From: Wesley Wigham Date: Thu, 3 Sep 2026 16:10:57 -0700 Subject: [PATCH 12/12] Rename memory/cache to full/layer --- packages/typescript/src/api/async/api.ts | 6 +- packages/typescript/src/api/async/types.ts | 2 +- packages/typescript/src/api/fs.ts | 34 ++--- .../typescript/src/api/proto.generated.ts | 8 +- packages/typescript/src/api/sync/api.ts | 8 +- packages/typescript/src/api/sync/types.ts | 2 +- packages/typescript/test/async/api.test.ts | 87 ++++++------- packages/typescript/test/sync/api.test.ts | 87 ++++++------- tsc/internal/api/proto.go | 4 +- .../requestfilesystem/requestfilesystem.go | 20 +-- .../requestfilesystem_test.go | 118 +++++++++--------- .../requestfilesystemhandle.go | 10 +- tsc/internal/api/session.go | 2 +- .../api/session_requestfilesystem_test.go | 44 +++---- 14 files changed, 217 insertions(+), 215 deletions(-) diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index e4388ab9d001e..89b61d2c8ddd1 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -1439,8 +1439,8 @@ export class Program implements FormatDiagnosticsHost { } /** - * Emits files to the configured filesystem. Cache and host filesystems are - * written through; memory filesystems remain immutable and return emitted + * Emits files to the configured filesystem. Layer and host filesystems are + * written through; full filesystems remain immutable and return emitted * files in {@link EmitResult.fileSystem}. */ async emit(emitOnly?: EmitOnly): Promise { @@ -1451,7 +1451,7 @@ export class Program implements FormatDiagnosticsHost { }); const fileSystem = response.emittedFilesContents.length ? { - kind: "cache" as const, + kind: "layer" as const, files: Object.fromEntries(response.emittedFiles.map((fileName, index) => [fileName, response.emittedFilesContents[index]])), } : undefined; diff --git a/packages/typescript/src/api/async/types.ts b/packages/typescript/src/api/async/types.ts index 6f266a2264988..8c59e3b65a43b 100644 --- a/packages/typescript/src/api/async/types.ts +++ b/packages/typescript/src/api/async/types.ts @@ -404,7 +404,7 @@ export interface EmitResult { readonly emitSkipped: boolean; readonly diagnostics: readonly Diagnostic[]; readonly emittedFiles: readonly string[]; - /** Emitted files captured as a cache layer suitable for {@link Snapshot.update}. */ + /** Emitted files captured as a filesystem layer suitable for {@link Snapshot.update}. */ readonly fileSystem?: RequestFileSystem | undefined; } diff --git a/packages/typescript/src/api/fs.ts b/packages/typescript/src/api/fs.ts index 585973701aaee..0e5b3f0c3ad2b 100644 --- a/packages/typescript/src/api/fs.ts +++ b/packages/typescript/src/api/fs.ts @@ -38,15 +38,15 @@ export interface FileSystem { /** The callback names supported by the Go server for virtual FS delegation. */ export const fsCallbackNames = ["readFile", "fileExists", "directoryExists", "getAccessibleEntries", "realpath", "writeFile"] as const; -export interface CreateRequestFileSystemOptions { - /** Complete directory listings. Memory filesystems derive these from `files` when omitted. */ +export interface CreateFileSystemOptions { + /** Complete directory listings. Full filesystems derive these from `files` when omitted. */ directories?: Record; symlinks?: Record; /** Files or directory trees hidden from an underlying snapshot or host filesystem. */ removedPaths?: readonly string[]; } -export interface CreateMemoryFileSystemWithLibOptions extends CreateRequestFileSystemOptions { +export interface CreateFileSystemWithLibOptions extends CreateFileSystemOptions { /** Default library directory used by a custom or non-embedded compiler executable. */ defaultLibraryPath?: string; } @@ -57,21 +57,21 @@ export interface CreateMemoryFileSystemWithLibOptions extends CreateRequestFileS */ export type RequestFileEntries = readonly (readonly [id: DocumentIdentifier, content: string])[]; -/** Creates a total memory request filesystem, deriving directory listings when omitted. */ -export function createMemoryFileSystem( +/** Creates a full request filesystem, deriving directory listings when omitted. */ +export function createFileSystem( files: RequestFileEntries, - options: CreateRequestFileSystemOptions = {}, + options: CreateFileSystemOptions = {}, ): RequestFileSystem { - return createRequestFileSystem("memory", files, options); + return createRequestFileSystem("full", files, options); } /** - * Creates a total memory request filesystem with the compiler's default library + * Creates a full request filesystem with the compiler's default library * directory mounted read-only through the host filesystem. */ -export function createMemoryFileSystemWithLib( +export function createFileSystemWithLib( files: RequestFileEntries, - options: CreateMemoryFileSystemWithLibOptions = {}, + options: CreateFileSystemWithLibOptions = {}, ): RequestFileSystem { const defaultLibraryPaths = options.defaultLibraryPath ? [normalizePath(options.defaultLibraryPath)] @@ -89,25 +89,25 @@ export function createMemoryFileSystemWithLib( for (const defaultLibraryPath of defaultLibraryPaths) { symlinks[defaultLibraryPath] ??= { target: defaultLibraryPath, host: true }; } - return createRequestFileSystem("memory", files, { + return createRequestFileSystem("full", files, { symlinks, ...(options.directories ? { directories: options.directories } : {}), ...(options.removedPaths?.length ? { removedPaths: options.removedPaths } : {}), }); } -/** Creates a read-through cache request filesystem, merging host directory listings when omitted. */ -export function createCacheFileSystem( +/** Creates a request filesystem layer, merging base directory listings when omitted. */ +export function createFileSystemLayer( files: RequestFileEntries, - options: CreateRequestFileSystemOptions = {}, + options: CreateFileSystemOptions = {}, ): RequestFileSystem { - return createRequestFileSystem("cache", files, options); + return createRequestFileSystem("layer", files, options); } function createRequestFileSystem( kind: RequestFileSystem["kind"], files: RequestFileEntries, - options: CreateRequestFileSystemOptions, + options: CreateFileSystemOptions, ): RequestFileSystem { const normalizedFiles = new Map(); for (const [id, content] of files) { @@ -118,7 +118,7 @@ function createRequestFileSystem( normalizedFiles.set(fileName, content); } const fileRecord = Object.fromEntries(normalizedFiles); - const directories = options.directories ?? (kind === "memory" ? deriveDirectoryListings(fileRecord) : undefined); + const directories = options.directories ?? (kind === "full" ? deriveDirectoryListings(fileRecord) : undefined); return { kind, files: fileRecord, diff --git a/packages/typescript/src/api/proto.generated.ts b/packages/typescript/src/api/proto.generated.ts index b45c85fd907b8..426cbf2adfbe1 100644 --- a/packages/typescript/src/api/proto.generated.ts +++ b/packages/typescript/src/api/proto.generated.ts @@ -208,7 +208,7 @@ export interface UpdateSnapshotParams { fileChanges?: APIFileChanges; /** * FileSystem supplies file contents and directory listings for the new snapshot. - * A memory filesystem is canonical and total. A cache filesystem is checked + * A full filesystem is canonical and total. A filesystem layer is checked * before falling back to the host filesystem. */ fileSystem?: RequestFileSystem; @@ -865,7 +865,7 @@ export interface EmitResponse { emittedFiles: string[]; /** * EmittedFilesContents contains contents parallel to EmittedFiles when the - * source snapshot uses a memory filesystem. It is empty for write-through emits. + * source snapshot uses a full filesystem. It is empty for write-through emits. */ emittedFilesContents: string[]; } @@ -1228,7 +1228,7 @@ export interface APIFileChanges { * for a request that creates a snapshot. */ export interface RequestFileSystem { - kind: "cache" | "memory"; + kind: "full" | "layer"; /** Files maps file names to their complete contents. */ files: Record; /** Directories maps directory names to complete listing results. */ @@ -1451,7 +1451,7 @@ export interface RequestSymlink { target: string; /** * Host routes the target through the host filesystem. This is the only way a - * memory filesystem can access paths not supplied in the request filesystem. + * full filesystem can access paths not supplied in the request filesystem. */ host?: boolean; } diff --git a/packages/typescript/src/api/sync/api.ts b/packages/typescript/src/api/sync/api.ts index 9765dc6639f58..a8f9cba4e9fe6 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -2794,8 +2794,8 @@ export class Program implements FormatDiagnosticsHost { } /** - * Emits files to the configured filesystem. Cache and host filesystems are - * written through; memory filesystems remain immutable and return emitted + * Emits files to the configured filesystem. Layer and host filesystems are + * written through; full filesystems remain immutable and return emitted * files in {@link EmitResult.fileSystem}. */ get emit(): { @@ -2814,7 +2814,7 @@ export class Program implements FormatDiagnosticsHost { }); const fileSystem = response.emittedFilesContents.length ? { - kind: "cache" as const, + kind: "layer" as const, files: Object.fromEntries(response.emittedFiles.map((fileName, index) => [fileName, response.emittedFilesContents[index]])), } : undefined; @@ -2833,7 +2833,7 @@ export class Program implements FormatDiagnosticsHost { }); const fileSystem = response.emittedFilesContents.length ? { - kind: "cache" as const, + kind: "layer" as const, files: Object.fromEntries(response.emittedFiles.map((fileName, index) => [fileName, response.emittedFilesContents[index]])), } : undefined; diff --git a/packages/typescript/src/api/sync/types.ts b/packages/typescript/src/api/sync/types.ts index b608f5c4cd12b..ffa40b3ea09bc 100644 --- a/packages/typescript/src/api/sync/types.ts +++ b/packages/typescript/src/api/sync/types.ts @@ -528,7 +528,7 @@ export interface EmitResult { readonly emitSkipped: boolean; readonly diagnostics: readonly Diagnostic[]; readonly emittedFiles: readonly string[]; - /** Emitted files captured as a cache layer suitable for {@link Snapshot.update}. */ + /** Emitted files captured as a filesystem layer suitable for {@link Snapshot.update}. */ readonly fileSystem?: RequestFileSystem | undefined; } diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index 0216bf727fdb3..97230f898f26c 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -82,9 +82,9 @@ import { type UnionOrIntersectionType, } from "@typescript/typescript/unstable/async"; // @sync: } from "@typescript/typescript/unstable/sync"; import { - createCacheFileSystem, - createMemoryFileSystem, - createMemoryFileSystemWithLib, + createFileSystem, + createFileSystemLayer, + createFileSystemWithLib, createVirtualFileSystem, } from "@typescript/typescript/unstable/fs"; import type { FileSystem } from "@typescript/typescript/unstable/fs"; @@ -3189,7 +3189,7 @@ describe("readFile callback semantics", () => { describe("updateSnapshot file systems", () => { test("request filesystem factories derive directory listings", () => { - const memory = createMemoryFileSystem([ + const memory = createFileSystem([ ["/src/index.ts", "posix"], ["C:\\repo\\src\\index.ts", "windows"], ["file:///literal%20path.ts", "literal file-name string"], @@ -3203,7 +3203,7 @@ describe("updateSnapshot file systems", () => { ["vscode-notebook-cell://authority/workspace/notebook.ipynb/cell.ts", "notebook"], ]); assert.deepEqual(memory, { - kind: "memory", + kind: "full", files: { "/src/index.ts": "posix", "C:/repo/src/index.ts": "windows", @@ -3242,29 +3242,30 @@ describe("updateSnapshot file systems", () => { }); const directories = { "/explicit": { files: ["provided.ts"], directories: [] } }; - const cache = createCacheFileSystem([["/ignored/derived.ts", "cache"]], { + const cache = createFileSystemLayer([["/ignored/derived.ts", "cache"]], { directories, removedPaths: ["/removed.ts", "/removed"], }); + assert.equal(cache.kind, "layer"); assert.deepEqual(cache.directories, directories); assert.deepEqual(cache.removedPaths, ["/removed.ts", "/removed"]); assert.throws( () => - createMemoryFileSystem([ + createFileSystem([ ["/duplicate.ts", "path"], [{ uri: "file:///duplicate.ts" }, "URI"], ]), /Duplicate request filesystem path: \/duplicate\.ts/, ); - const prototypeFileSystem = createMemoryFileSystem([["__proto__", "prototype"]]); + const prototypeFileSystem = createFileSystem([["__proto__", "prototype"]]); assert.equal(prototypeFileSystem.files["__proto__"], "prototype"); assert.ok(Object.hasOwn(prototypeFileSystem.files, "__proto__")); assert.throws( () => - createMemoryFileSystem([ + createFileSystem([ ["/normalized/duplicate.ts", "forward slash"], ["\\normalized\\duplicate.ts", "backslash"], ]), @@ -3272,7 +3273,7 @@ describe("updateSnapshot file systems", () => { ); }); - test("memory file system is total and does not invoke host callbacks", async () => { + test("full file system is total and does not invoke host callbacks", async () => { const callbackCalls: string[] = []; const host = createVirtualFileSystem({ "/host.ts": `export const source = "host";`, @@ -3311,7 +3312,7 @@ describe("updateSnapshot file systems", () => { using snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json", fileSystem: { - kind: "memory", + kind: "full", files: { "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, include: ["src/**/*.ts"] }), "/src/index.ts": `export const source = "memory";`, @@ -3329,13 +3330,13 @@ describe("updateSnapshot file systems", () => { assert.deepEqual(callbackCalls, []); }); - test("memory file system with lib resolves the default library", async () => { + test("full file system with lib resolves the default library", async () => { await using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), }); using snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json", - fileSystem: createMemoryFileSystemWithLib(Object.entries({ + fileSystem: createFileSystemWithLib(Object.entries({ "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true }, files: ["src/main.ts"] }), "/src/main.ts": `export const values: Array = [];`, })), @@ -3350,7 +3351,7 @@ describe("updateSnapshot file systems", () => { assert.equal(await program.isSourceFileDefaultLibrary(defaultLibrary), true); }); - test("memory file system accepts paths decoded from VS Code document URIs", async () => { + test("full file system accepts paths decoded from VS Code document URIs", async () => { const fileDocument = { uri: "file:///workspace/file%20name.ts" }; const remoteDocument = { uri: "vscode-remote://ssh-remote+host/workspace/src/remote%20name.ts" }; const notebookDocument = { uri: "vscode-notebook-cell:/workspace/notebook.ipynb/cell%20name.ts" }; @@ -3359,7 +3360,7 @@ describe("updateSnapshot file systems", () => { }); using snapshot = await api.updateSnapshot({ openFiles: [fileDocument, remoteDocument, notebookDocument], - fileSystem: createMemoryFileSystem([ + fileSystem: createFileSystem([ [fileDocument, `export const file = true;`], [remoteDocument, `export const remote = true;`], [notebookDocument, `export const cell = true;`], @@ -3373,7 +3374,7 @@ describe("updateSnapshot file systems", () => { assert.equal((await notebookProject?.program.getSourceFile(notebookDocument))?.text, `export const cell = true;`); }); - test("cache file system bypasses callbacks on hits and falls back on misses", async () => { + test("file system layer bypasses callbacks on hits and falls back on misses", async () => { const readFileCalls: string[] = []; const directoryCalls: string[] = []; const host = createVirtualFileSystem({ @@ -3398,7 +3399,7 @@ describe("updateSnapshot file systems", () => { using snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json", fileSystem: { - kind: "cache", + kind: "layer", files: { "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, include: ["src/**/*.ts"] }), "/src/index.ts": `export const cached = true;`, @@ -3420,7 +3421,7 @@ describe("updateSnapshot file systems", () => { assert.ok(!directoryCalls.includes("/src")); }); - test("cache file system factory preserves host directory entries", async () => { + test("file system layer factory preserves host directory entries", async () => { await using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs: createVirtualFileSystem({ @@ -3430,7 +3431,7 @@ describe("updateSnapshot file systems", () => { using snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json", - fileSystem: createCacheFileSystem([ + fileSystem: createFileSystemLayer([ ["/tsconfig.json", JSON.stringify({ compilerOptions: { noLib: true }, include: ["src/**/*.ts"] })], ["/src/from-cache.ts", `export const cache = true;`], ]), @@ -3442,7 +3443,7 @@ describe("updateSnapshot file systems", () => { ); }); - test("memory file system resolves packages through internal monorepo symlinks", async () => { + test("full file system resolves packages through internal monorepo symlinks", async () => { const callbackCalls: string[] = []; await using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), @@ -3457,7 +3458,7 @@ describe("updateSnapshot file systems", () => { using snapshot = await api.updateSnapshot({ openProject: "/project/tsconfig.json", fileSystem: { - kind: "memory", + kind: "full", files: { "/project/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, moduleResolution: "node" }, files: ["index.ts"] }), "/project/index.ts": `import { value } from "pkg"; export { value };`, @@ -3476,7 +3477,7 @@ describe("updateSnapshot file systems", () => { assert.deepEqual(callbackCalls, []); }); - test("memory file system resolves relative symlink targets", async () => { + test("full file system resolves relative symlink targets", async () => { const callbackCalls: string[] = []; await using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), @@ -3491,7 +3492,7 @@ describe("updateSnapshot file systems", () => { using snapshot = await api.updateSnapshot({ openProject: "/project/tsconfig.json", fileSystem: { - kind: "memory", + kind: "full", files: { "/project/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: ["index.ts"] }), "/project/index.ts": `export { value } from "./pkg";`, @@ -3516,7 +3517,7 @@ describe("updateSnapshot file systems", () => { }); using snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json", - fileSystem: createMemoryFileSystem(Object.entries({ + fileSystem: createFileSystem(Object.entries({ "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, include: ["src/**/*.ts"], @@ -3529,7 +3530,7 @@ describe("updateSnapshot file systems", () => { }); using updated = await snapshot.update({ - fileSystem: createCacheFileSystem( + fileSystem: createFileSystemLayer( Object.entries({ "/src/change.ts": `export const version = "new";`, "/src/added.ts": `export const added = true;`, @@ -3548,7 +3549,7 @@ describe("updateSnapshot file systems", () => { await assert.rejects(() => snapshot.update(), /can only update the latest snapshot/); // @sync: assert.throws(() => snapshot.update(), /can only update the latest snapshot/); using updatedAgain = await updated.update({ - fileSystem: createCacheFileSystem( + fileSystem: createFileSystemLayer( Object.entries({ "/src/added.ts": `export const added = "updated again";`, }), @@ -3569,7 +3570,7 @@ describe("updateSnapshot file systems", () => { }); let snapshot: Snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json", - fileSystem: createMemoryFileSystem([ + fileSystem: createFileSystem([ ["/tsconfig.json", JSON.stringify({ compilerOptions: { noLib: true }, files: ["pkg/index.ts"] })], ["/pkg/index.ts", ""], ]), @@ -3580,7 +3581,7 @@ describe("updateSnapshot file systems", () => { const oldSnapshot: Snapshot = snapshot; content += character; snapshot = await oldSnapshot.update({ - fileSystem: createCacheFileSystem([["/pkg/index.ts", content]]), + fileSystem: createFileSystemLayer([["/pkg/index.ts", content]]), }); await oldSnapshot.dispose(); assert.equal(oldSnapshot.isDisposed(), true); @@ -3594,7 +3595,7 @@ describe("updateSnapshot file systems", () => { } }); - test("Snapshot.update treats a memory filesystem as a total replacement", async () => { + test("Snapshot.update treats a full filesystem as a total replacement", async () => { const host = createVirtualFileSystem({ "/host.ts": `export const source = "host";`, }); @@ -3605,7 +3606,7 @@ describe("updateSnapshot file systems", () => { using snapshot = await api.updateSnapshot(); using replaced = await snapshot.update({ openProject: "/tsconfig.json", - fileSystem: createMemoryFileSystem([ + fileSystem: createFileSystem([ ["/tsconfig.json", JSON.stringify({ compilerOptions: { noLib: true }, files: ["memory.ts", "host.ts"] })], ["/memory.ts", `export const source = "memory";`], ]), @@ -3621,7 +3622,7 @@ describe("updateSnapshot file systems", () => { }); using snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json", - fileSystem: createMemoryFileSystem( + fileSystem: createFileSystem( Object.entries({ "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: ["src/main.ts"] }), "/src/main.ts": `import "./link/change"; import "./link/added"; import "./link/remove";`, @@ -3637,7 +3638,7 @@ describe("updateSnapshot file systems", () => { }); using updated = await snapshot.update({ - fileSystem: createCacheFileSystem( + fileSystem: createFileSystemLayer( Object.entries({ "/target/change.ts": `export const version = "new";`, "/target/added.ts": `export const added = true;`, @@ -3653,7 +3654,7 @@ describe("updateSnapshot file systems", () => { assert.equal(await program.getSourceFile("/src/link/remove.ts"), undefined); }); - test("memory filesystem emit returns outputs without mutating the host", async () => { + test("full filesystem emit returns outputs without mutating the host", async () => { const hostWrites: string[] = []; await using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), @@ -3665,7 +3666,7 @@ describe("updateSnapshot file systems", () => { }); using snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json", - fileSystem: createMemoryFileSystem(Object.entries({ + fileSystem: createFileSystem(Object.entries({ "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, outDir: "/out", rootDir: "/src" }, files: ["src/main.ts"] }), "/src/main.ts": `export const value: number = 1;`, })), @@ -3674,7 +3675,7 @@ describe("updateSnapshot file systems", () => { const result = await program.emit(); assert.deepEqual(result.emittedFiles, ["/out/main.js"]); assert.deepEqual(result.fileSystem, { - kind: "cache", + kind: "layer", files: { "/out/main.js": `export const value = 1;\n`, }, @@ -3687,7 +3688,7 @@ describe("updateSnapshot file systems", () => { assert.equal((await outputProject?.program.getSourceFile("/out/main.js"))?.text, `export const value = 1;\n`); }); - test("cache filesystem emit writes through to the host", async () => { + test("filesystem layer emit writes through to the host", async () => { const host = createVirtualFileSystem({}); await using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), @@ -3695,7 +3696,7 @@ describe("updateSnapshot file systems", () => { }); using snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json", - fileSystem: createCacheFileSystem(Object.entries({ + fileSystem: createFileSystemLayer(Object.entries({ "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, outDir: "/out", rootDir: "/src" }, files: ["src/main.ts"] }), "/src/main.ts": `export const value: number = 1;`, })), @@ -3706,7 +3707,7 @@ describe("updateSnapshot file systems", () => { assert.equal(host.readFile!("/out/main.js"), `export const value = 1;\n`); }); - test("memory file system can link node_modules from the host", async () => { + test("full file system can link node_modules from the host", async () => { const readFileCalls: string[] = []; const directoryExistsCalls: string[] = []; const fileExistsCalls: string[] = []; @@ -3736,7 +3737,7 @@ describe("updateSnapshot file systems", () => { using snapshot = await api.updateSnapshot({ openProject: "/project/tsconfig.json", fileSystem: { - kind: "memory", + kind: "full", files: { "/project/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, moduleResolution: "node" }, files: ["index.ts"] }), "/project/index.ts": `import { value } from "pkg"; export { value };`, @@ -3760,7 +3761,7 @@ describe("updateSnapshot file systems", () => { assert.ok(!readFileCalls.some(path => path.startsWith("/project/node_modules"))); }); - test("Snapshot.update host symlinks bypass an inherited memory filesystem", async () => { + test("Snapshot.update host symlinks bypass an inherited full filesystem", async () => { const host = createVirtualFileSystem({ "/host/node_modules/pkg/index.d.ts": `export declare const value: string;`, }); @@ -3770,13 +3771,13 @@ describe("updateSnapshot file systems", () => { }); using snapshot = await api.updateSnapshot({ openProject: "/project/tsconfig.json", - fileSystem: createMemoryFileSystem([ + fileSystem: createFileSystem([ ["/project/tsconfig.json", JSON.stringify({ compilerOptions: { noLib: true, moduleResolution: "node" }, files: ["index.ts"] })], ["/project/index.ts", `import { value } from "pkg"; export { value };`], ]), }); using updated = await snapshot.update({ - fileSystem: createCacheFileSystem([], { + fileSystem: createFileSystemLayer([], { symlinks: { "/project/node_modules": { target: "/host/node_modules", host: true }, }, @@ -3792,7 +3793,7 @@ describe("updateSnapshot file systems", () => { // TODO: Add request filesystem coverage for `tsc -b` and `tsc -b --clean` // once build and clean are exposed through the client API. In particular, // verify that clean removes synthetic outputs and that build-mode re-timestamping - // of emitted-but-unchanged files works for memory filesystems, which currently + // of emitted-but-unchanged files works for full filesystems, which currently // do not model modification times. }); diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index c256d86b3ac81..68be8d38e0262 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -57,9 +57,9 @@ import { } from "@typescript/typescript/unstable/ast/factory"; import { visitEachChild } from "@typescript/typescript/unstable/ast/visitor"; import { - createCacheFileSystem, - createMemoryFileSystem, - createMemoryFileSystemWithLib, + createFileSystem, + createFileSystemLayer, + createFileSystemWithLib, createVirtualFileSystem, } from "@typescript/typescript/unstable/fs"; import type { FileSystem } from "@typescript/typescript/unstable/fs"; @@ -3079,7 +3079,7 @@ describe("readFile callback semantics", () => { describe("updateSnapshot file systems", () => { test("request filesystem factories derive directory listings", () => { - const memory = createMemoryFileSystem([ + const memory = createFileSystem([ ["/src/index.ts", "posix"], ["C:\\repo\\src\\index.ts", "windows"], ["file:///literal%20path.ts", "literal file-name string"], @@ -3093,7 +3093,7 @@ describe("updateSnapshot file systems", () => { ["vscode-notebook-cell://authority/workspace/notebook.ipynb/cell.ts", "notebook"], ]); assert.deepEqual(memory, { - kind: "memory", + kind: "full", files: { "/src/index.ts": "posix", "C:/repo/src/index.ts": "windows", @@ -3132,29 +3132,30 @@ describe("updateSnapshot file systems", () => { }); const directories = { "/explicit": { files: ["provided.ts"], directories: [] } }; - const cache = createCacheFileSystem([["/ignored/derived.ts", "cache"]], { + const cache = createFileSystemLayer([["/ignored/derived.ts", "cache"]], { directories, removedPaths: ["/removed.ts", "/removed"], }); + assert.equal(cache.kind, "layer"); assert.deepEqual(cache.directories, directories); assert.deepEqual(cache.removedPaths, ["/removed.ts", "/removed"]); assert.throws( () => - createMemoryFileSystem([ + createFileSystem([ ["/duplicate.ts", "path"], [{ uri: "file:///duplicate.ts" }, "URI"], ]), /Duplicate request filesystem path: \/duplicate\.ts/, ); - const prototypeFileSystem = createMemoryFileSystem([["__proto__", "prototype"]]); + const prototypeFileSystem = createFileSystem([["__proto__", "prototype"]]); assert.equal(prototypeFileSystem.files["__proto__"], "prototype"); assert.ok(Object.hasOwn(prototypeFileSystem.files, "__proto__")); assert.throws( () => - createMemoryFileSystem([ + createFileSystem([ ["/normalized/duplicate.ts", "forward slash"], ["\\normalized\\duplicate.ts", "backslash"], ]), @@ -3162,7 +3163,7 @@ describe("updateSnapshot file systems", () => { ); }); - test("memory file system is total and does not invoke host callbacks", () => { + test("full file system is total and does not invoke host callbacks", () => { const callbackCalls: string[] = []; const host = createVirtualFileSystem({ "/host.ts": `export const source = "host";`, @@ -3201,7 +3202,7 @@ describe("updateSnapshot file systems", () => { using snapshot = api.updateSnapshot({ openProject: "/tsconfig.json", fileSystem: { - kind: "memory", + kind: "full", files: { "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, include: ["src/**/*.ts"] }), "/src/index.ts": `export const source = "memory";`, @@ -3219,13 +3220,13 @@ describe("updateSnapshot file systems", () => { assert.deepEqual(callbackCalls, []); }); - test("memory file system with lib resolves the default library", () => { + test("full file system with lib resolves the default library", () => { using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), }); using snapshot = api.updateSnapshot({ openProject: "/tsconfig.json", - fileSystem: createMemoryFileSystemWithLib(Object.entries({ + fileSystem: createFileSystemWithLib(Object.entries({ "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true }, files: ["src/main.ts"] }), "/src/main.ts": `export const values: Array = [];`, })), @@ -3240,7 +3241,7 @@ describe("updateSnapshot file systems", () => { assert.equal(program.isSourceFileDefaultLibrary(defaultLibrary), true); }); - test("memory file system accepts paths decoded from VS Code document URIs", () => { + test("full file system accepts paths decoded from VS Code document URIs", () => { const fileDocument = { uri: "file:///workspace/file%20name.ts" }; const remoteDocument = { uri: "vscode-remote://ssh-remote+host/workspace/src/remote%20name.ts" }; const notebookDocument = { uri: "vscode-notebook-cell:/workspace/notebook.ipynb/cell%20name.ts" }; @@ -3249,7 +3250,7 @@ describe("updateSnapshot file systems", () => { }); using snapshot = api.updateSnapshot({ openFiles: [fileDocument, remoteDocument, notebookDocument], - fileSystem: createMemoryFileSystem([ + fileSystem: createFileSystem([ [fileDocument, `export const file = true;`], [remoteDocument, `export const remote = true;`], [notebookDocument, `export const cell = true;`], @@ -3263,7 +3264,7 @@ describe("updateSnapshot file systems", () => { assert.equal((notebookProject?.program.getSourceFile(notebookDocument))?.text, `export const cell = true;`); }); - test("cache file system bypasses callbacks on hits and falls back on misses", () => { + test("file system layer bypasses callbacks on hits and falls back on misses", () => { const readFileCalls: string[] = []; const directoryCalls: string[] = []; const host = createVirtualFileSystem({ @@ -3288,7 +3289,7 @@ describe("updateSnapshot file systems", () => { using snapshot = api.updateSnapshot({ openProject: "/tsconfig.json", fileSystem: { - kind: "cache", + kind: "layer", files: { "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, include: ["src/**/*.ts"] }), "/src/index.ts": `export const cached = true;`, @@ -3310,7 +3311,7 @@ describe("updateSnapshot file systems", () => { assert.ok(!directoryCalls.includes("/src")); }); - test("cache file system factory preserves host directory entries", () => { + test("file system layer factory preserves host directory entries", () => { using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs: createVirtualFileSystem({ @@ -3320,7 +3321,7 @@ describe("updateSnapshot file systems", () => { using snapshot = api.updateSnapshot({ openProject: "/tsconfig.json", - fileSystem: createCacheFileSystem([ + fileSystem: createFileSystemLayer([ ["/tsconfig.json", JSON.stringify({ compilerOptions: { noLib: true }, include: ["src/**/*.ts"] })], ["/src/from-cache.ts", `export const cache = true;`], ]), @@ -3332,7 +3333,7 @@ describe("updateSnapshot file systems", () => { ); }); - test("memory file system resolves packages through internal monorepo symlinks", () => { + test("full file system resolves packages through internal monorepo symlinks", () => { const callbackCalls: string[] = []; using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), @@ -3347,7 +3348,7 @@ describe("updateSnapshot file systems", () => { using snapshot = api.updateSnapshot({ openProject: "/project/tsconfig.json", fileSystem: { - kind: "memory", + kind: "full", files: { "/project/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, moduleResolution: "node" }, files: ["index.ts"] }), "/project/index.ts": `import { value } from "pkg"; export { value };`, @@ -3366,7 +3367,7 @@ describe("updateSnapshot file systems", () => { assert.deepEqual(callbackCalls, []); }); - test("memory file system resolves relative symlink targets", () => { + test("full file system resolves relative symlink targets", () => { const callbackCalls: string[] = []; using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), @@ -3381,7 +3382,7 @@ describe("updateSnapshot file systems", () => { using snapshot = api.updateSnapshot({ openProject: "/project/tsconfig.json", fileSystem: { - kind: "memory", + kind: "full", files: { "/project/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: ["index.ts"] }), "/project/index.ts": `export { value } from "./pkg";`, @@ -3406,7 +3407,7 @@ describe("updateSnapshot file systems", () => { }); using snapshot = api.updateSnapshot({ openProject: "/tsconfig.json", - fileSystem: createMemoryFileSystem(Object.entries({ + fileSystem: createFileSystem(Object.entries({ "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, include: ["src/**/*.ts"], @@ -3419,7 +3420,7 @@ describe("updateSnapshot file systems", () => { }); using updated = snapshot.update({ - fileSystem: createCacheFileSystem( + fileSystem: createFileSystemLayer( Object.entries({ "/src/change.ts": `export const version = "new";`, "/src/added.ts": `export const added = true;`, @@ -3438,7 +3439,7 @@ describe("updateSnapshot file systems", () => { assert.throws(() => snapshot.update(), /can only update the latest snapshot/); using updatedAgain = updated.update({ - fileSystem: createCacheFileSystem( + fileSystem: createFileSystemLayer( Object.entries({ "/src/added.ts": `export const added = "updated again";`, }), @@ -3459,7 +3460,7 @@ describe("updateSnapshot file systems", () => { }); let snapshot: Snapshot = api.updateSnapshot({ openProject: "/tsconfig.json", - fileSystem: createMemoryFileSystem([ + fileSystem: createFileSystem([ ["/tsconfig.json", JSON.stringify({ compilerOptions: { noLib: true }, files: ["pkg/index.ts"] })], ["/pkg/index.ts", ""], ]), @@ -3470,7 +3471,7 @@ describe("updateSnapshot file systems", () => { const oldSnapshot: Snapshot = snapshot; content += character; snapshot = oldSnapshot.update({ - fileSystem: createCacheFileSystem([["/pkg/index.ts", content]]), + fileSystem: createFileSystemLayer([["/pkg/index.ts", content]]), }); oldSnapshot.dispose(); assert.equal(oldSnapshot.isDisposed(), true); @@ -3484,7 +3485,7 @@ describe("updateSnapshot file systems", () => { } }); - test("Snapshot.update treats a memory filesystem as a total replacement", () => { + test("Snapshot.update treats a full filesystem as a total replacement", () => { const host = createVirtualFileSystem({ "/host.ts": `export const source = "host";`, }); @@ -3495,7 +3496,7 @@ describe("updateSnapshot file systems", () => { using snapshot = api.updateSnapshot(); using replaced = snapshot.update({ openProject: "/tsconfig.json", - fileSystem: createMemoryFileSystem([ + fileSystem: createFileSystem([ ["/tsconfig.json", JSON.stringify({ compilerOptions: { noLib: true }, files: ["memory.ts", "host.ts"] })], ["/memory.ts", `export const source = "memory";`], ]), @@ -3511,7 +3512,7 @@ describe("updateSnapshot file systems", () => { }); using snapshot = api.updateSnapshot({ openProject: "/tsconfig.json", - fileSystem: createMemoryFileSystem( + fileSystem: createFileSystem( Object.entries({ "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: ["src/main.ts"] }), "/src/main.ts": `import "./link/change"; import "./link/added"; import "./link/remove";`, @@ -3527,7 +3528,7 @@ describe("updateSnapshot file systems", () => { }); using updated = snapshot.update({ - fileSystem: createCacheFileSystem( + fileSystem: createFileSystemLayer( Object.entries({ "/target/change.ts": `export const version = "new";`, "/target/added.ts": `export const added = true;`, @@ -3543,7 +3544,7 @@ describe("updateSnapshot file systems", () => { assert.equal(program.getSourceFile("/src/link/remove.ts"), undefined); }); - test("memory filesystem emit returns outputs without mutating the host", () => { + test("full filesystem emit returns outputs without mutating the host", () => { const hostWrites: string[] = []; using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), @@ -3555,7 +3556,7 @@ describe("updateSnapshot file systems", () => { }); using snapshot = api.updateSnapshot({ openProject: "/tsconfig.json", - fileSystem: createMemoryFileSystem(Object.entries({ + fileSystem: createFileSystem(Object.entries({ "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, outDir: "/out", rootDir: "/src" }, files: ["src/main.ts"] }), "/src/main.ts": `export const value: number = 1;`, })), @@ -3564,7 +3565,7 @@ describe("updateSnapshot file systems", () => { const result = program.emit(); assert.deepEqual(result.emittedFiles, ["/out/main.js"]); assert.deepEqual(result.fileSystem, { - kind: "cache", + kind: "layer", files: { "/out/main.js": `export const value = 1;\n`, }, @@ -3577,7 +3578,7 @@ describe("updateSnapshot file systems", () => { assert.equal((outputProject?.program.getSourceFile("/out/main.js"))?.text, `export const value = 1;\n`); }); - test("cache filesystem emit writes through to the host", () => { + test("filesystem layer emit writes through to the host", () => { const host = createVirtualFileSystem({}); using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), @@ -3585,7 +3586,7 @@ describe("updateSnapshot file systems", () => { }); using snapshot = api.updateSnapshot({ openProject: "/tsconfig.json", - fileSystem: createCacheFileSystem(Object.entries({ + fileSystem: createFileSystemLayer(Object.entries({ "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, outDir: "/out", rootDir: "/src" }, files: ["src/main.ts"] }), "/src/main.ts": `export const value: number = 1;`, })), @@ -3596,7 +3597,7 @@ describe("updateSnapshot file systems", () => { assert.equal(host.readFile!("/out/main.js"), `export const value = 1;\n`); }); - test("memory file system can link node_modules from the host", () => { + test("full file system can link node_modules from the host", () => { const readFileCalls: string[] = []; const directoryExistsCalls: string[] = []; const fileExistsCalls: string[] = []; @@ -3626,7 +3627,7 @@ describe("updateSnapshot file systems", () => { using snapshot = api.updateSnapshot({ openProject: "/project/tsconfig.json", fileSystem: { - kind: "memory", + kind: "full", files: { "/project/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true, moduleResolution: "node" }, files: ["index.ts"] }), "/project/index.ts": `import { value } from "pkg"; export { value };`, @@ -3650,7 +3651,7 @@ describe("updateSnapshot file systems", () => { assert.ok(!readFileCalls.some(path => path.startsWith("/project/node_modules"))); }); - test("Snapshot.update host symlinks bypass an inherited memory filesystem", () => { + test("Snapshot.update host symlinks bypass an inherited full filesystem", () => { const host = createVirtualFileSystem({ "/host/node_modules/pkg/index.d.ts": `export declare const value: string;`, }); @@ -3660,13 +3661,13 @@ describe("updateSnapshot file systems", () => { }); using snapshot = api.updateSnapshot({ openProject: "/project/tsconfig.json", - fileSystem: createMemoryFileSystem([ + fileSystem: createFileSystem([ ["/project/tsconfig.json", JSON.stringify({ compilerOptions: { noLib: true, moduleResolution: "node" }, files: ["index.ts"] })], ["/project/index.ts", `import { value } from "pkg"; export { value };`], ]), }); using updated = snapshot.update({ - fileSystem: createCacheFileSystem([], { + fileSystem: createFileSystemLayer([], { symlinks: { "/project/node_modules": { target: "/host/node_modules", host: true }, }, @@ -3682,7 +3683,7 @@ describe("updateSnapshot file systems", () => { // TODO: Add request filesystem coverage for `tsc -b` and `tsc -b --clean` // once build and clean are exposed through the client API. In particular, // verify that clean removes synthetic outputs and that build-mode re-timestamping - // of emitted-but-unchanged files works for memory filesystems, which currently + // of emitted-but-unchanged files works for full filesystems, which currently // do not model modification times. }); diff --git a/tsc/internal/api/proto.go b/tsc/internal/api/proto.go index c6deefa6abfb0..110293da83331 100644 --- a/tsc/internal/api/proto.go +++ b/tsc/internal/api/proto.go @@ -357,7 +357,7 @@ type UpdateSnapshotParams struct { // FileChanges describes file system changes since the last snapshot. FileChanges *APIFileChanges `json:"fileChanges,omitempty"` // FileSystem supplies file contents and directory listings for the new snapshot. - // A memory filesystem is canonical and total. A cache filesystem is checked + // A full filesystem is canonical and total. A filesystem layer is checked // before falling back to the host filesystem. FileSystem *requestfilesystem.RequestFileSystem `json:"fileSystem,omitempty"` // OpenFiles lists files to keep open for the API client, mirroring LSP's @@ -1364,7 +1364,7 @@ type EmitResponse struct { Diagnostics []*DiagnosticResponse `json:"diagnostics" nonnil:"true"` EmittedFiles []string `json:"emittedFiles" nonnil:"true"` // EmittedFilesContents contains contents parallel to EmittedFiles when the - // source snapshot uses a memory filesystem. It is empty for write-through emits. + // source snapshot uses a full filesystem. It is empty for write-through emits. EmittedFilesContents []string `json:"emittedFilesContents" nonnil:"true"` } diff --git a/tsc/internal/api/requestfilesystem/requestfilesystem.go b/tsc/internal/api/requestfilesystem/requestfilesystem.go index 8eb8a6a9a1ba8..1a53f23670139 100644 --- a/tsc/internal/api/requestfilesystem/requestfilesystem.go +++ b/tsc/internal/api/requestfilesystem/requestfilesystem.go @@ -17,10 +17,10 @@ import ( type Kind string const ( - // KindMemory makes the supplied filesystem canonical and total. - KindMemory Kind = "memory" - // KindCache checks the supplied filesystem before falling back to the host. - KindCache Kind = "cache" + // KindFull makes the supplied filesystem canonical and total. + KindFull Kind = "full" + // KindLayer checks the supplied filesystem before falling back to the host. + KindLayer Kind = "layer" ) // RequestDirectoryEntries is a cached directory listing. Entry names are @@ -36,7 +36,7 @@ type RequestSymlink struct { // native symbolic-link semantics. Target string `json:"target"` // Host routes the target through the host filesystem. This is the only way a - // memory filesystem can access paths not supplied in the request filesystem. + // full filesystem can access paths not supplied in the request filesystem. Host bool `json:"host,omitempty"` } @@ -55,8 +55,8 @@ type RequestFileSystem struct { RemovedPaths []string `json:"removedPaths,omitempty"` } -// requestFileSystem is either a total in-memory filesystem or a read-through -// cache layered over the session host filesystem. Cache misses deliberately go +// requestFileSystem is either a full filesystem or a layer over the session +// host filesystem. Layer misses deliberately go // through base, which may itself be a callback filesystem. type requestFileSystem struct { kind Kind @@ -129,7 +129,7 @@ func getHostFileSystem(fileSystem vfs.FS) vfs.FS { } func newRequestFileSystemWorker(params *RequestFileSystem, base vfs.FS, currentDirectory string, layered bool) (*requestFileSystem, error) { - if params.Kind != KindMemory && params.Kind != KindCache { + if params.Kind != KindFull && params.Kind != KindLayer { return nil, fmt.Errorf("unknown request filesystem kind %q", params.Kind) } @@ -190,7 +190,7 @@ func newRequestFileSystemWorker(params *RequestFileSystem, base vfs.FS, currentD } func (s requestFileSystem) fallsBack() bool { - return s.layered || s.kind == KindCache + return s.layered || s.kind == KindLayer } func (s requestFileSystem) baseFileSystem() vfs.FS { @@ -615,7 +615,7 @@ func (s requestFileSystem) lookupPath(path string) requestPathLookup { } func (s requestFileSystem) mutationPath(path string) (vfs.FS, string, bool) { - if s.kind != KindCache { + if s.kind != KindLayer { return nil, "", false } resolved := s.resolvePathForOverlay(path) diff --git a/tsc/internal/api/requestfilesystem/requestfilesystem_test.go b/tsc/internal/api/requestfilesystem/requestfilesystem_test.go index 9612b3b8ab272..82643c68462fe 100644 --- a/tsc/internal/api/requestfilesystem/requestfilesystem_test.go +++ b/tsc/internal/api/requestfilesystem/requestfilesystem_test.go @@ -42,7 +42,7 @@ func (h *Handle) applyTo(base *Handle) { func TestInitializeForUpdate(t *testing.T) { t.Parallel() - t.Run("cache layers over a host-backed snapshot", func(t *testing.T) { + t.Run("filesystem layers over a host-backed snapshot", func(t *testing.T) { t.Parallel() host := vfstest.FromMap(map[string]string{ "/dir/host.ts": "host", @@ -50,7 +50,7 @@ func TestInitializeForUpdate(t *testing.T) { var handle Handle var fileChanges project.FileChangeSummary err := handle.InitializeForUpdate(&RequestFileSystem{ - Kind: KindCache, + Kind: KindLayer, Files: map[string]string{"/dir/cached.ts": "cached"}, Directories: map[string]RequestDirectoryEntries{ "/dir": {Files: []string{"cached.ts"}, Directories: []string{}}, @@ -66,7 +66,7 @@ func TestInitializeForUpdate(t *testing.T) { "/host.ts": "host", }, true) base, err := newRequestFileSystem(&RequestFileSystem{ - Kind: KindMemory, + Kind: KindFull, Files: map[string]string{"/base.ts": "base"}, }, host, "/") assert.NilError(t, err) @@ -74,7 +74,7 @@ func TestInitializeForUpdate(t *testing.T) { var handle Handle var fileChanges project.FileChangeSummary err = handle.InitializeForUpdate(&RequestFileSystem{ - Kind: KindMemory, + Kind: KindFull, Files: map[string]string{"/replacement.ts": "replacement"}, }, base, host, "/", &fileChanges, true) assert.NilError(t, err) @@ -89,12 +89,12 @@ func TestConcurrentCloneAndRelease(t *testing.T) { host := vfstest.FromMap(map[string]string{}, true) for range 100 { base, err := newRequestFileSystem(&RequestFileSystem{ - Kind: KindMemory, + Kind: KindFull, Files: map[string]string{"/base.ts": "base"}, }, host, "/") assert.NilError(t, err) layered, err := newLayeredRequestFileSystem(&RequestFileSystem{ - Kind: KindCache, + Kind: KindLayer, Files: map[string]string{"/layered.ts": "layered"}, }, base, "/") assert.NilError(t, err) @@ -133,7 +133,7 @@ func TestRequestFileSystem(t *testing.T) { "/host.ts": "host", }, true) baseFS, err := newRequestFileSystem(&RequestFileSystem{ - Kind: KindCache, + Kind: KindLayer, }, host, "/") assert.NilError(t, err) base := getRequestFileSystem(baseFS) @@ -143,7 +143,7 @@ func TestRequestFileSystem(t *testing.T) { assert.NilError(t, host.WriteFile("/created-after-base.ts", "created")) layeredFS, err := newLayeredRequestFileSystem(&RequestFileSystem{ - Kind: KindCache, + Kind: KindLayer, Files: map[string]string{"/layered.ts": "layered"}, }, baseFS, "/") assert.NilError(t, err) @@ -168,7 +168,7 @@ func TestRequestFileSystem(t *testing.T) { "/host.ts": "host", }, true)} fileSystem, err := newRequestFileSystem(&RequestFileSystem{ - Kind: KindMemory, + Kind: KindFull, Files: map[string]string{ "/src/index.ts": "memory", }, @@ -194,7 +194,7 @@ func TestRequestFileSystem(t *testing.T) { "/fallback.ts": "fallback", }, true)} fileSystem, err := newRequestFileSystem(&RequestFileSystem{ - Kind: KindCache, + Kind: KindLayer, Files: map[string]string{ "/cached/index.ts": "cached", }, @@ -222,7 +222,7 @@ func TestRequestFileSystem(t *testing.T) { t.Run("layered memory is a total replacement", func(t *testing.T) { t.Parallel() fileSystem, err := newLayeredRequestFileSystem(&RequestFileSystem{ - Kind: KindMemory, + Kind: KindFull, Files: map[string]string{ "/memory.ts": "memory", }, @@ -241,7 +241,7 @@ func TestRequestFileSystem(t *testing.T) { "/host.ts": "host", }, true)} fileSystem, err := newRequestFileSystem(&RequestFileSystem{ - Kind: KindMemory, + Kind: KindFull, Files: map[string]string{ "/packages/pkg/index.d.ts": "export declare const value: number;", }, @@ -277,7 +277,7 @@ func TestRequestFileSystem(t *testing.T) { "/packages/pkg/index.d.ts": "host content", }, true)} fileSystem, err := newRequestFileSystem(&RequestFileSystem{ - Kind: KindCache, + Kind: KindLayer, Files: map[string]string{ "/packages/pkg/index.d.ts": "cached content", }, @@ -308,7 +308,7 @@ func TestRequestFileSystem(t *testing.T) { "/host/pkg/index.d.ts": "host content", }, true) fileSystem, err := newRequestFileSystem(&RequestFileSystem{ - Kind: KindCache, + Kind: KindLayer, Files: map[string]string{ "/project/node_modules/pkg/index.d.ts": "cached content", }, @@ -329,7 +329,7 @@ func TestRequestFileSystem(t *testing.T) { t.Parallel() host := vfstest.FromMap(map[string]string{}, true) base, err := newRequestFileSystem(&RequestFileSystem{ - Kind: KindMemory, + Kind: KindFull, Files: map[string]string{ "/keep.ts": "keep", "/change.ts": "old", @@ -342,7 +342,7 @@ func TestRequestFileSystem(t *testing.T) { assert.NilError(t, err) layered, err := newLayeredRequestFileSystem(&RequestFileSystem{ - Kind: KindCache, + Kind: KindLayer, Files: map[string]string{ "/change.ts": "new", "/added.ts": "added", @@ -389,7 +389,7 @@ func TestRequestFileSystem(t *testing.T) { t.Parallel() host := vfstest.FromMap(map[string]string{}, true) base, err := newRequestFileSystem(&RequestFileSystem{ - Kind: KindMemory, + Kind: KindFull, Files: map[string]string{ "/target/change.ts": "old", "/target/keep.ts": "keep", @@ -402,7 +402,7 @@ func TestRequestFileSystem(t *testing.T) { assert.NilError(t, err) layered, err := newLayeredRequestFileSystem(&RequestFileSystem{ - Kind: KindCache, + Kind: KindLayer, Files: map[string]string{ "/target/change.ts": "new", "/target/added.ts": "added", @@ -426,7 +426,7 @@ func TestRequestFileSystem(t *testing.T) { t.Parallel() host := vfstest.FromMap(map[string]string{}, true) base, err := newRequestFileSystem(&RequestFileSystem{ - Kind: KindMemory, + Kind: KindFull, Files: map[string]string{ "/target/file.ts": "old", }, @@ -437,7 +437,7 @@ func TestRequestFileSystem(t *testing.T) { assert.NilError(t, err) layered, err := newLayeredRequestFileSystem(&RequestFileSystem{ - Kind: KindCache, + Kind: KindLayer, Files: map[string]string{ "/target/file.ts": "new", }, @@ -457,7 +457,7 @@ func TestRequestFileSystem(t *testing.T) { t.Parallel() host := vfstest.FromMap(map[string]string{}, true) baseFS, err := newRequestFileSystem(&RequestFileSystem{ - Kind: KindMemory, + Kind: KindFull, Files: map[string]string{ "/target/remove.ts": "remove", }, @@ -469,7 +469,7 @@ func TestRequestFileSystem(t *testing.T) { base := getRequestFileSystem(baseFS) layeredFS, err := newLayeredRequestFileSystem(&RequestFileSystem{ - Kind: KindCache, + Kind: KindLayer, Files: map[string]string{}, RemovedPaths: []string{"/link/remove.ts"}, }, baseFS, "/") @@ -489,7 +489,7 @@ func TestRequestFileSystem(t *testing.T) { t.Parallel() host := vfstest.FromMap(map[string]string{}, true) baseFS, err := newRequestFileSystem(&RequestFileSystem{ - Kind: KindMemory, + Kind: KindFull, Files: map[string]string{ "/dir/remove.ts": "remove", }, @@ -501,7 +501,7 @@ func TestRequestFileSystem(t *testing.T) { base := getRequestFileSystem(baseFS) layeredFS, err := newLayeredRequestFileSystem(&RequestFileSystem{ - Kind: KindCache, + Kind: KindLayer, Files: map[string]string{}, RemovedPaths: []string{"/dir/remove.ts"}, }, baseFS, "/") @@ -518,7 +518,7 @@ func TestRequestFileSystem(t *testing.T) { t.Parallel() host := vfstest.FromMap(map[string]string{}, true) baseFS, err := newRequestFileSystem(&RequestFileSystem{ - Kind: KindMemory, + Kind: KindFull, Files: map[string]string{ "/target/recreated.ts": "base", }, @@ -530,7 +530,7 @@ func TestRequestFileSystem(t *testing.T) { base := getRequestFileSystem(baseFS) removedFS, err := newLayeredRequestFileSystem(&RequestFileSystem{ - Kind: KindCache, + Kind: KindLayer, RemovedPaths: []string{"/link/recreated.ts"}, }, baseFS, "/") assert.NilError(t, err) @@ -538,7 +538,7 @@ func TestRequestFileSystem(t *testing.T) { removed.applyTo(base) recreatedFS, err := newLayeredRequestFileSystem(&RequestFileSystem{ - Kind: KindCache, + Kind: KindLayer, Files: map[string]string{ "/link/recreated.ts": "recreated", }, @@ -560,7 +560,7 @@ func TestRequestFileSystem(t *testing.T) { t.Parallel() host := vfstest.FromMap(map[string]string{}, true) base, err := newRequestFileSystem(&RequestFileSystem{ - Kind: KindMemory, + Kind: KindFull, Files: map[string]string{ "/target/dir/existing.ts": "existing", }, @@ -571,14 +571,14 @@ func TestRequestFileSystem(t *testing.T) { assert.NilError(t, err) removed, err := newLayeredRequestFileSystem(&RequestFileSystem{ - Kind: KindCache, + Kind: KindLayer, RemovedPaths: []string{"/link/dir"}, }, base, "/") assert.NilError(t, err) removed.applyTo(base) recreated, err := newLayeredRequestFileSystem(&RequestFileSystem{ - Kind: KindCache, + Kind: KindLayer, Files: map[string]string{ "/link/dir/recreated.ts": "recreated", }, @@ -602,7 +602,7 @@ func TestRequestFileSystem(t *testing.T) { t.Parallel() host := vfstest.FromMap(map[string]string{}, true) base, err := newRequestFileSystem(&RequestFileSystem{ - Kind: KindMemory, + Kind: KindFull, Files: map[string]string{ "/target/item/child.ts": "child", }, @@ -613,7 +613,7 @@ func TestRequestFileSystem(t *testing.T) { assert.NilError(t, err) layered, err := newLayeredRequestFileSystem(&RequestFileSystem{ - Kind: KindCache, + Kind: KindLayer, Files: map[string]string{ "/target/item": "file", }, @@ -633,7 +633,7 @@ func TestRequestFileSystem(t *testing.T) { "/removed-dir/gone.ts": "host", }, true)} fileSystem, err := newRequestFileSystem(&RequestFileSystem{ - Kind: KindCache, + Kind: KindLayer, Files: map[string]string{}, RemovedPaths: []string{"/remove.ts", "/removed-dir"}, }, base, "/") @@ -645,7 +645,7 @@ func TestRequestFileSystem(t *testing.T) { assert.Assert(t, base.SeenFiles.IsEmpty()) }) - t.Run("compacted cache layers retain host fallback", func(t *testing.T) { + t.Run("compacted filesystem layers retain host fallback", func(t *testing.T) { t.Parallel() host := vfstest.FromMap(map[string]string{ "/host.ts": "host", @@ -655,7 +655,7 @@ func TestRequestFileSystem(t *testing.T) { "/open/layer-listed.ts": "host listed", }, true) baseFS, err := newRequestFileSystem(&RequestFileSystem{ - Kind: KindCache, + Kind: KindLayer, Files: map[string]string{ "/inherited.ts": "inherited", "/sealed/inherited.ts": "sealed inherited", @@ -669,7 +669,7 @@ func TestRequestFileSystem(t *testing.T) { base := getRequestFileSystem(baseFS) layeredFS, err := newLayeredRequestFileSystem(&RequestFileSystem{ - Kind: KindCache, + Kind: KindLayer, Files: map[string]string{ "/added.ts": "added", "/sealed/added.ts": "sealed added", @@ -682,7 +682,7 @@ func TestRequestFileSystem(t *testing.T) { layered := getRequestFileSystem(layeredFS) layered.applyTo(base) assert.Assert(t, getRequestFileSystem(layered.baseFileSystem()) != base) - assert.Equal(t, layered.load().kind, KindCache) + assert.Equal(t, layered.load().kind, KindLayer) for path, expected := range map[string]string{ "/host.ts": "host", @@ -699,13 +699,13 @@ func TestRequestFileSystem(t *testing.T) { assert.DeepEqual(t, layered.GetAccessibleEntries("/open").Files, []string{"host.ts", "layer-listed.ts"}) }) - t.Run("compacting a cache layer over memory produces memory", func(t *testing.T) { + t.Run("compacting a filesystem layer over a full filesystem produces a full filesystem", func(t *testing.T) { t.Parallel() host := vfstest.FromMap(map[string]string{ "/host.ts": "host", }, true) baseFS, err := newRequestFileSystem(&RequestFileSystem{ - Kind: KindMemory, + Kind: KindFull, Files: map[string]string{ "/target/inherited.ts": "inherited", }, @@ -720,7 +720,7 @@ func TestRequestFileSystem(t *testing.T) { base := getRequestFileSystem(baseFS) layeredFS, err := newLayeredRequestFileSystem(&RequestFileSystem{ - Kind: KindCache, + Kind: KindLayer, Files: map[string]string{ "/target/added.ts": "added", }, @@ -728,7 +728,7 @@ func TestRequestFileSystem(t *testing.T) { assert.NilError(t, err) layered := getRequestFileSystem(layeredFS) layered.applyTo(base) - assert.Equal(t, layered.load().kind, KindMemory) + assert.Equal(t, layered.load().kind, KindFull) assert.Assert(t, getRequestFileSystem(layered.baseFileSystem()) != base) for path, expected := range map[string]string{ @@ -750,7 +750,7 @@ func TestRequestFileSystem(t *testing.T) { "/host/outside.ts": "outside", }, true)} fileSystem, err := newRequestFileSystem(&RequestFileSystem{ - Kind: KindMemory, + Kind: KindFull, Files: map[string]string{ "/project/index.ts": `import { hostValue } from "pkg";`, }, @@ -782,7 +782,7 @@ func TestRequestFileSystem(t *testing.T) { "/host/pkg/index.d.ts": "host", }, true)} base, err := newRequestFileSystem(&RequestFileSystem{ - Kind: KindMemory, + Kind: KindFull, Files: map[string]string{ "/memory.ts": "memory", }, @@ -790,7 +790,7 @@ func TestRequestFileSystem(t *testing.T) { assert.NilError(t, err) layered, err := newLayeredRequestFileSystem(&RequestFileSystem{ - Kind: KindCache, + Kind: KindLayer, Files: map[string]string{}, Symlinks: map[string]RequestSymlink{ "/project/pkg": {Target: "/host/pkg", Host: true}, @@ -818,7 +818,7 @@ func TestRequestFileSystem(t *testing.T) { "/host/pkg/removed.ts": "removed", }, true) base, err := newRequestFileSystem(&RequestFileSystem{ - Kind: KindMemory, + Kind: KindFull, Files: map[string]string{}, Symlinks: map[string]RequestSymlink{ "/link": {Target: "/host/pkg", Host: true}, @@ -827,7 +827,7 @@ func TestRequestFileSystem(t *testing.T) { assert.NilError(t, err) layered, err := newLayeredRequestFileSystem(&RequestFileSystem{ - Kind: KindCache, + Kind: KindLayer, RemovedPaths: []string{"/link/removed.ts"}, Files: map[string]string{ "/host/pkg/host.ts": "cache", @@ -850,7 +850,7 @@ func TestRequestFileSystem(t *testing.T) { base := vfstest.FromMap(map[string]string{}, false) _, err := newRequestFileSystem(&RequestFileSystem{ - Kind: KindMemory, + Kind: KindFull, Files: map[string]string{ `C:\Repo\file.ts`: "first", `c:/repo/file.ts`: "second", @@ -859,7 +859,7 @@ func TestRequestFileSystem(t *testing.T) { assert.ErrorContains(t, err, "duplicate request filesystem file path") _, err = newRequestFileSystem(&RequestFileSystem{ - Kind: KindMemory, + Kind: KindFull, Files: map[string]string{}, Directories: map[string]RequestDirectoryEntries{ `C:\Repo`: {}, @@ -869,7 +869,7 @@ func TestRequestFileSystem(t *testing.T) { assert.ErrorContains(t, err, "duplicate request filesystem directory path") _, err = newRequestFileSystem(&RequestFileSystem{ - Kind: KindMemory, + Kind: KindFull, Files: map[string]string{}, Symlinks: map[string]RequestSymlink{ `C:\Repo\link`: {Target: `C:\Target`}, @@ -885,7 +885,7 @@ func TestRequestFileSystem(t *testing.T) { "/host.ts": "host", }, true)} fileSystem, err := newRequestFileSystem(&RequestFileSystem{ - Kind: KindMemory, + Kind: KindFull, Files: map[string]string{}, Symlinks: map[string]RequestSymlink{ "/a": {Target: "/b"}, @@ -905,7 +905,7 @@ func TestRequestFileSystem(t *testing.T) { t.Parallel() base := &trackingvfs.FS{Inner: vfstest.FromMap(map[string]string{}, true)} fileSystem, err := newRequestFileSystem(&RequestFileSystem{ - Kind: KindMemory, + Kind: KindFull, Files: map[string]string{ "/packages/pkg/index.d.ts": "export declare const value: number;", }, @@ -926,7 +926,7 @@ func TestRequestFileSystem(t *testing.T) { t.Parallel() base := &trackingvfs.FS{Inner: vfstest.FromMap(map[string]string{}, true)} fileSystem, err := newRequestFileSystem(&RequestFileSystem{ - Kind: KindMemory, + Kind: KindFull, Files: map[string]string{ "vscode-remote://ssh-remote+host/workspace/src/index.ts": "index", "vscode-remote://ssh-remote+host/workspace/packages/pkg/a.ts": "package", @@ -971,7 +971,7 @@ func TestRequestFileSystem(t *testing.T) { "C:/Host/outside.ts": "outside", }, false)} fileSystem, err := newRequestFileSystem(&RequestFileSystem{ - Kind: KindMemory, + Kind: KindFull, Files: map[string]string{ `C:\Repo\Packages\Pkg\Index.d.ts`: "export declare const windowsValue: number;", }, @@ -1008,7 +1008,7 @@ func TestRequestFileSystem(t *testing.T) { t.Parallel() base := &trackingvfs.FS{Inner: vfstest.FromMap(map[string]string{}, false)} fileSystem, err := newRequestFileSystem(&RequestFileSystem{ - Kind: KindMemory, + Kind: KindFull, Files: map[string]string{ "C:/Repo/target.ts": "target", }, @@ -1029,7 +1029,7 @@ func TestRequestFileSystem(t *testing.T) { "/host.ts": "host", }, true) memory, err := newRequestFileSystem(&RequestFileSystem{ - Kind: KindMemory, + Kind: KindFull, Files: map[string]string{ "/src/a.ts": "a", }, @@ -1043,7 +1043,7 @@ func TestRequestFileSystem(t *testing.T) { assert.Equal(t, contents, "a") cache, err := newLayeredRequestFileSystem(&RequestFileSystem{ - Kind: KindCache, + Kind: KindLayer, Files: map[string]string{}, }, memory, "/") assert.NilError(t, err) @@ -1069,14 +1069,14 @@ func TestRequestFileSystem(t *testing.T) { "/link/times.ts": "alias", }, true) base, err := newRequestFileSystem(&RequestFileSystem{ - Kind: KindMemory, + Kind: KindFull, Files: map[string]string{}, Symlinks: map[string]RequestSymlink{ "/link": {Target: "/target"}, }, }, host, "/") assert.NilError(t, err) - cache, err := newLayeredRequestFileSystem(&RequestFileSystem{Kind: KindCache}, base, "/") + cache, err := newLayeredRequestFileSystem(&RequestFileSystem{Kind: KindLayer}, base, "/") assert.NilError(t, err) assert.NilError(t, cache.WriteFile("/link/write.ts", "written")) @@ -1103,7 +1103,7 @@ func TestRequestFileSystem(t *testing.T) { "C:/Host/node_modules/host-pkg/index.d.ts": "export declare const hostValue: boolean;", }, false)} fileSystem, err := newRequestFileSystem(&RequestFileSystem{ - Kind: KindMemory, + Kind: KindFull, Files: map[string]string{ `C:\Repo\Packages\windows-pkg\index.d.ts`: "export declare const windowsValue: number;", "/repo/packages/posix-pkg/index.d.ts": "export declare const posixValue: string;", diff --git a/tsc/internal/api/requestfilesystem/requestfilesystemhandle.go b/tsc/internal/api/requestfilesystem/requestfilesystemhandle.go index 9e821b33acdad..0b70c4fe33bcf 100644 --- a/tsc/internal/api/requestfilesystem/requestfilesystemhandle.go +++ b/tsc/internal/api/requestfilesystem/requestfilesystemhandle.go @@ -46,7 +46,7 @@ func (h *Handle) initializeFromRequest(params *RequestFileSystem, base vfs.FS, c } func (h *Handle) initializeLayered(params *RequestFileSystem, base vfs.FS, currentDirectory string) error { - if params.Kind != KindCache { + if params.Kind != KindLayer { return h.initializeFromRequest(params, base, currentDirectory) } value, err := newRequestFileSystemWorker(params, base, currentDirectory, true) @@ -65,7 +65,7 @@ func (h *Handle) InitializeForUpdate(params *RequestFileSystem, base *Handle, ho } return nil } - if params.Kind == KindCache && hasBaseSnapshot { + if params.Kind == KindLayer && hasBaseSnapshot { baseFS := host if base != nil { baseFS = base @@ -187,11 +187,11 @@ func (h *Handle) baseFileSystem() vfs.FS { return h.load().baseFileSystem() } -// HasMemoryFileSystem reports whether any backing layer is a total memory filesystem. -func (h *Handle) HasMemoryFileSystem() bool { +// HasFullFileSystem reports whether any backing layer is a full filesystem. +func (h *Handle) HasFullFileSystem() bool { for h != nil { value := h.load() - if value.kind == KindMemory { + if value.kind == KindFull { return true } h = getRequestFileSystem(value.baseFileSystem()) diff --git a/tsc/internal/api/session.go b/tsc/internal/api/session.go index 0a1ec52d88cf1..96195228faf02 100644 --- a/tsc/internal/api/session.go +++ b/tsc/internal/api/session.go @@ -2802,7 +2802,7 @@ func (s *Session) handleEmit(ctx context.Context, params *EmitParams) (*EmitResp if err != nil { return nil, err } - if fileSystem := sd.fileSystemHandle(); fileSystem != nil && fileSystem.HasMemoryFileSystem() { + if fileSystem := sd.fileSystemHandle(); fileSystem != nil && fileSystem.HasFullFileSystem() { outputFiles = make(map[string]string) var outputMu sync.Mutex options.WriteFile = func(fileName string, text string, _ *compiler.WriteFileData) error { diff --git a/tsc/internal/api/session_requestfilesystem_test.go b/tsc/internal/api/session_requestfilesystem_test.go index 0186d02bd7d5a..249c107c9556c 100644 --- a/tsc/internal/api/session_requestfilesystem_test.go +++ b/tsc/internal/api/session_requestfilesystem_test.go @@ -14,7 +14,7 @@ import ( "gotest.tools/v3/assert" ) -func TestUpdateSnapshotUsesMemoryFileSystem(t *testing.T) { +func TestUpdateSnapshotUsesFullFileSystem(t *testing.T) { t.Parallel() projectSession, _ := projecttestutil.Setup(map[string]any{ @@ -27,7 +27,7 @@ func TestUpdateSnapshotUsesMemoryFileSystem(t *testing.T) { response, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ OpenProjects: []DocumentIdentifier{{FileName: "/tsconfig.json"}}, FileSystem: &requestfilesystem.RequestFileSystem{ - Kind: requestfilesystem.KindMemory, + Kind: requestfilesystem.KindFull, Files: map[string]string{ "/tsconfig.json": `{ "compilerOptions": { "noLib": true }, "files": ["src/index.ts"] }`, "/src/index.ts": `export const value = "memory";`, @@ -59,7 +59,7 @@ func TestUpdateSnapshotUsesMemoryFileSystem(t *testing.T) { // when the caller does not redundantly list every file in FileChanges. response, err = session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ FileSystem: &requestfilesystem.RequestFileSystem{ - Kind: requestfilesystem.KindMemory, + Kind: requestfilesystem.KindFull, Files: map[string]string{ "/tsconfig.json": `{ "compilerOptions": { "noLib": true }, "files": ["src/index.ts", "src/other.ts"] }`, "/src/index.ts": `export const value = "updated";`, @@ -90,7 +90,7 @@ func TestUpdateSnapshotUsesMemoryFileSystem(t *testing.T) { assert.Equal(t, contents, `export const other = true;`) } -func TestSnapshotUpdateMemoryFileSystemIsTotal(t *testing.T) { +func TestSnapshotUpdateFullFileSystemIsTotal(t *testing.T) { t.Parallel() projectSession, _ := projecttestutil.Setup(map[string]any{ @@ -105,7 +105,7 @@ func TestSnapshotUpdateMemoryFileSystemIsTotal(t *testing.T) { replaced, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ Snapshot: base.Snapshot, FileSystem: &requestfilesystem.RequestFileSystem{ - Kind: requestfilesystem.KindMemory, + Kind: requestfilesystem.KindFull, Files: map[string]string{ "/memory.ts": "memory", }, @@ -145,7 +145,7 @@ func TestSnapshotUpdateCarriesHostFileSystemWithoutOverride(t *testing.T) { assert.Assert(t, updatedSnapshot.ProjectCollection.GetProjectByPath(tspath.Path("/tsconfig.json")).GetProgram() == program) } -func TestEmitFromCacheLayeredOverMemoryReturnsFileContents(t *testing.T) { +func TestEmitFromLayerOverFullFileSystemReturnsFileContents(t *testing.T) { t.Parallel() projectSession, _ := projecttestutil.Setup(map[string]any{}) @@ -157,7 +157,7 @@ func TestEmitFromCacheLayeredOverMemoryReturnsFileContents(t *testing.T) { base, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ OpenProjects: []DocumentIdentifier{{FileName: "/tsconfig.json"}}, FileSystem: &requestfilesystem.RequestFileSystem{ - Kind: requestfilesystem.KindMemory, + Kind: requestfilesystem.KindFull, Files: map[string]string{ "/tsconfig.json": `{ "compilerOptions": { "noLib": true, "outDir": "/out" }, "files": ["src/main.ts"] }`, "/src/main.ts": `export const value: number = 1;`, @@ -168,7 +168,7 @@ func TestEmitFromCacheLayeredOverMemoryReturnsFileContents(t *testing.T) { layered, err := session.handleUpdateSnapshot(ctx, &UpdateSnapshotParams{ Snapshot: base.Snapshot, FileSystem: &requestfilesystem.RequestFileSystem{ - Kind: requestfilesystem.KindCache, + Kind: requestfilesystem.KindLayer, Files: map[string]string{}, }, }) @@ -206,7 +206,7 @@ func TestReleaseSnapshotCompactsSoleLayeredFileSystem(t *testing.T) { base, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ FileSystem: &requestfilesystem.RequestFileSystem{ - Kind: requestfilesystem.KindMemory, + Kind: requestfilesystem.KindFull, Files: map[string]string{ "/inherited.ts": "inherited", "/changed.ts": "old", @@ -221,7 +221,7 @@ func TestReleaseSnapshotCompactsSoleLayeredFileSystem(t *testing.T) { layered, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ Snapshot: base.Snapshot, FileSystem: &requestfilesystem.RequestFileSystem{ - Kind: requestfilesystem.KindCache, + Kind: requestfilesystem.KindLayer, Files: map[string]string{ "/changed.ts": "new", "/added.ts": "added", @@ -253,7 +253,7 @@ func TestReleaseSnapshotCompactsSoleLayeredFileSystem(t *testing.T) { assert.Assert(t, !ok) _, ok = layeredSnapshot.ReadFile("/host.ts") assert.Assert(t, !ok) - assert.Assert(t, layeredFileSystem.HasMemoryFileSystem()) + assert.Assert(t, layeredFileSystem.HasFullFileSystem()) } func TestEagerSnapshotReleaseDoesNotRetainFileSystemHistory(t *testing.T) { @@ -266,7 +266,7 @@ func TestEagerSnapshotReleaseDoesNotRetainFileSystemHistory(t *testing.T) { response, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ FileSystem: &requestfilesystem.RequestFileSystem{ - Kind: requestfilesystem.KindMemory, + Kind: requestfilesystem.KindFull, Files: map[string]string{ "/pkg/index.ts": "", }, @@ -281,7 +281,7 @@ func TestEagerSnapshotReleaseDoesNotRetainFileSystemHistory(t *testing.T) { response, err = session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ Snapshot: oldSnapshot, FileSystem: &requestfilesystem.RequestFileSystem{ - Kind: requestfilesystem.KindCache, + Kind: requestfilesystem.KindLayer, Files: map[string]string{ "/pkg/index.ts": content, }, @@ -297,7 +297,7 @@ func TestEagerSnapshotReleaseDoesNotRetainFileSystemHistory(t *testing.T) { assert.Equal(t, current.refCount, 1) fileSystem := current.fileSystemHandle() assert.Assert(t, fileSystem != nil) - assert.Assert(t, fileSystem.HasMemoryFileSystem()) + assert.Assert(t, fileSystem.HasFullFileSystem()) actual, ok := current.snapshot.ReadFile("/pkg/index.ts") assert.Assert(t, ok) assert.Equal(t, actual, content) @@ -316,7 +316,7 @@ func TestSnapshotReleaseCompactsChainedFileSystems(t *testing.T) { var err error responses[0], err = session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ FileSystem: &requestfilesystem.RequestFileSystem{ - Kind: requestfilesystem.KindMemory, + Kind: requestfilesystem.KindFull, Files: map[string]string{"/pkg/index.ts": "0"}, }, }) @@ -325,7 +325,7 @@ func TestSnapshotReleaseCompactsChainedFileSystems(t *testing.T) { responses[i], err = session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ Snapshot: responses[i-1].Snapshot, FileSystem: &requestfilesystem.RequestFileSystem{ - Kind: requestfilesystem.KindCache, + Kind: requestfilesystem.KindLayer, Files: map[string]string{"/pkg/index.ts": strconv.Itoa(i)}, }, }) @@ -342,7 +342,7 @@ func TestSnapshotReleaseCompactsChainedFileSystems(t *testing.T) { assert.Equal(t, current.refCount, 1) fileSystem := current.fileSystemHandle() assert.Assert(t, fileSystem != nil) - assert.Assert(t, fileSystem.HasMemoryFileSystem()) + assert.Assert(t, fileSystem.HasFullFileSystem()) contents, ok := current.snapshot.ReadFile("/pkg/index.ts") assert.Assert(t, ok) assert.Equal(t, contents, strconv.Itoa(i)) @@ -359,7 +359,7 @@ func TestTemporarySnapshotRetainsLayeredFileSystemHistory(t *testing.T) { base, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ FileSystem: &requestfilesystem.RequestFileSystem{ - Kind: requestfilesystem.KindMemory, + Kind: requestfilesystem.KindFull, Files: map[string]string{"/pkg/index.ts": "base"}, }, }) @@ -367,7 +367,7 @@ func TestTemporarySnapshotRetainsLayeredFileSystemHistory(t *testing.T) { layered, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ Snapshot: base.Snapshot, FileSystem: &requestfilesystem.RequestFileSystem{ - Kind: requestfilesystem.KindCache, + Kind: requestfilesystem.KindLayer, Files: map[string]string{"/pkg/index.ts": "layered"}, }, }) @@ -390,7 +390,7 @@ func TestTemporarySnapshotRetainsLayeredFileSystemHistory(t *testing.T) { fileSystem := current.fileSystemHandle() assert.Assert(t, fileSystem != nil) assert.Assert(t, fileSystem != layeredFileSystem) - assert.Assert(t, fileSystem.HasMemoryFileSystem()) + assert.Assert(t, fileSystem.HasFullFileSystem()) } func TestSnapshotReleaseCompactionSupportsConcurrentReaders(t *testing.T) { @@ -406,13 +406,13 @@ func TestSnapshotReleaseCompactionSupportsConcurrentReaders(t *testing.T) { files[fmt.Sprintf("/pkg/file%d.ts", index)] = strconv.Itoa(index) } base, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ - FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindMemory, Files: files}, + FileSystem: &requestfilesystem.RequestFileSystem{Kind: requestfilesystem.KindFull, Files: files}, }) assert.NilError(t, err) layered, err := session.handleUpdateSnapshot(context.Background(), &UpdateSnapshotParams{ Snapshot: base.Snapshot, FileSystem: &requestfilesystem.RequestFileSystem{ - Kind: requestfilesystem.KindCache, + Kind: requestfilesystem.KindLayer, Files: map[string]string{"/pkg/file0.ts": "updated"}, }, })