From 481b7be86ca0a2943f7e935267ddcc95cf9bb367 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Thu, 24 Sep 2026 15:34:58 -0700 Subject: [PATCH 1/8] Remove createVirtualFileSystem, improve callbacks, rename things --- packages/typescript/src/api/async/client.ts | 62 ++++--- packages/typescript/src/api/fs.ts | 161 +++--------------- packages/typescript/src/api/fsCallbacks.ts | 39 +++++ packages/typescript/src/api/options.ts | 19 ++- packages/typescript/src/api/sync/client.ts | 30 ++-- packages/typescript/test/async/api.test.ts | 99 ++++++++++- .../typescript/test/async/api.testUtils.ts | 2 +- packages/typescript/test/async/astnav.test.ts | 6 +- .../test/diagnosticFormatter.test.ts | 6 +- packages/typescript/test/sync/api.test.ts | 99 ++++++++++- .../typescript/test/sync/api.testUtils.ts | 2 +- packages/typescript/test/sync/ast.bench.ts | 2 +- packages/typescript/test/sync/ast.test.ts | 6 +- packages/typescript/test/sync/astnav.test.ts | 6 +- packages/typescript/test/testUtils.ts | 142 +++++++++++++++ tsc/cmd/tsc/api.go | 22 ++- tsc/internal/api/callbackfs.go | 136 ++++++++++++++- tsc/internal/api/callbackfs_test.go | 111 ++++++++++++ tsc/internal/api/server.go | 8 +- 19 files changed, 720 insertions(+), 238 deletions(-) create mode 100644 packages/typescript/src/api/fsCallbacks.ts create mode 100644 tsc/internal/api/callbackfs_test.go diff --git a/packages/typescript/src/api/async/client.ts b/packages/typescript/src/api/async/client.ts index ff7b9c2f60f8f..65931716d0406 100644 --- a/packages/typescript/src/api/async/client.ts +++ b/packages/typescript/src/api/async/client.ts @@ -9,10 +9,11 @@ import { } from "#vscode-jsonrpc/node"; import type { ChildProcess } from "node:child_process"; import type { Socket } from "node:net"; +import type { FileSystemCallbacks } from "../fs.ts"; import { - type FileSystem, - fsCallbackNames, -} from "../fs.ts"; + configureFileSystemCallbacks, + type FileSystemCallbackConfiguration, +} from "../fsCallbacks.ts"; import { type ClientOptions, type ClientSocketOptions, @@ -52,13 +53,17 @@ export class Client { private closed = false; private connecting: Promise | undefined; private timing: TimingCollector | undefined; + private fsConfiguration: FileSystemCallbackConfiguration | undefined; private batchedRequests: { method: APIRequest["method"]; params: APIRequest["params"]; resolve: (value: unknown) => void; reject: (reason?: any) => void; }[] = []; private nextBatch: NodeJS.Immediate | "manual" | undefined; constructor(options: ClientOptions) { this.options = options; - if (isSpawnOptions(options) && options.collectTiming) { - this.timing = new TimingCollector(); + if (isSpawnOptions(options)) { + this.fsConfiguration = configureFileSystemCallbacks(options.fs); + if (options.collectTiming) { + this.timing = new TimingCollector(); + } } } @@ -85,17 +90,8 @@ export class Client { return new Promise((resolve, reject) => { const args = getAPIProcessArgs(options, true); - // Enable virtual FS callbacks for each provided FS function - const enabledCallbacks: string[] = []; - if (options.fs) { - for (const name of fsCallbackNames) { - if (options.fs[name]) { - enabledCallbacks.push(name); - } - } - } - if (enabledCallbacks.length > 0) { - args.push(`--callbacks=${enabledCallbacks.join(",")}`); + if (this.fsConfiguration!.arguments.length > 0) { + args.push(`--callbacks=${this.fsConfiguration!.arguments.join(",")}`); } this.process = spawn(resolveExePath(options), args, { @@ -138,12 +134,12 @@ export class Client { }); } - private registerFSCallbacks(connection: MessageConnection, fs: FileSystem | undefined): void { + private registerFSCallbacks(connection: MessageConnection, fs: FileSystemCallbacks | undefined): void { if (!fs) return; - for (const name of fsCallbackNames) { + for (const name of this.fsConfiguration!.callbackNames) { if (name === "writeFile") { - if (!fs.writeFile) continue; const callback = fs.writeFile; + if (typeof callback !== "function") throw new Error("Invalid writeFile callback configuration"); const requestType = new RequestType<{ path: string; data: string; }, unknown, void>(name); connection.onRequest(requestType, (arg: { path: string; data: string; }) => { @@ -155,19 +151,21 @@ export class Client { } const callback = fs[name]; - if (callback) { - const requestType = new RequestType(name); - connection.onRequest(requestType, (arg: unknown) => { - const result = callback(arg as any); - if (name === "readFile") { - // readFile has 3 returns: string (content), null (not found), undefined (fall back). - // JSON-RPC can't distinguish null from undefined, so wrap in object. - if (result === undefined) return null; - return { content: result }; - } - return result ?? null; - }); - } + if (typeof callback !== "function") throw new Error(`Invalid ${name} callback configuration`); + const requestType = new RequestType(name); + connection.onRequest(requestType, (arg: unknown) => { + const result = callback(arg as string); + if (name === "readFile") { + // JSON-RPC can't distinguish null from undefined, so wrap defined results. + if (result === undefined) return null; + return { content: result }; + } + if (name === "stat") { + if (result === undefined) return null; + return { stat: result }; + } + return result ?? null; + }); } } diff --git a/packages/typescript/src/api/fs.ts b/packages/typescript/src/api/fs.ts index 4904d310b2687..ad00ffbc9a0f7 100644 --- a/packages/typescript/src/api/fs.ts +++ b/packages/typescript/src/api/fs.ts @@ -1,9 +1,6 @@ import getExePath from "#getExePath"; import { dirname } from "node:path"; -import { - getPathComponents, - normalizePath, -} from "./path.ts"; +import { normalizePath } from "./path.ts"; import type { RequestDirectoryEntries, RequestFileSystem, @@ -17,26 +14,37 @@ import { export interface FileSystemEntries { files: string[]; directories: string[]; + /** Names from `files` or `directories` that are symbolic links. */ + symlinks?: string[] | undefined; +} + +export interface FileSystemStat { + /** POSIX-style file mode, matching Node.js `fs.Stats.mode`. */ + mode: number; + /** File size in bytes, matching Node.js `fs.Stats.size`. */ + size: number; + /** Last modification time, matching Node.js `fs.Stats.mtime`. */ + mtime: Date; } -export interface FileSystem { - directoryExists?: ((directoryName: string) => boolean | undefined) | undefined; - fileExists?: ((fileName: string) => boolean | undefined) | undefined; - getAccessibleEntries?: ((directoryName: string) => FileSystemEntries | undefined) | undefined; +export interface FileSystemCallbacks { + directoryExists: ((directoryName: string) => boolean | undefined) | "passthrough"; + fileExists: ((fileName: string) => boolean | undefined) | "passthrough"; + getAccessibleEntries: ((directoryName: string) => FileSystemEntries | undefined) | "passthrough"; /** * Read a file's content. * - Return the file content as a `string` (including `""` for empty files). * - Return `null` to indicate the file does not exist (without falling back to the real FS). * - Return `undefined` to fall back to the real filesystem. */ - readFile?: ((fileName: string) => string | null | undefined) | undefined; - realpath?: ((path: string) => string | undefined) | undefined; - writeFile?: ((path: string, content: string) => void) | undefined; - removeFile?: ((path: string) => void) | undefined; + readFile: ((fileName: string) => string | null | undefined) | "passthrough"; + realpath: ((path: string) => string | undefined) | "passthrough" | "identity"; + stat: ((path: string) => FileSystemStat | null | undefined) | "passthrough" | "infer"; + writeFile: ((path: string, content: string) => void) | "passthrough"; } /** The callback names supported by the Go server for virtual FS delegation. */ -export const fsCallbackNames = ["readFile", "fileExists", "directoryExists", "getAccessibleEntries", "realpath", "writeFile"] as const; +export const fsCallbackNames = ["readFile", "fileExists", "directoryExists", "getAccessibleEntries", "realpath", "stat", "writeFile"] as const; export interface CreateFileSystemOptions { /** Complete directory listings. Full filesystems derive these from `files` when omitted. */ @@ -126,130 +134,3 @@ function createRequestFileSystem( removedPaths: options.removedPaths?.length ? [...options.removedPaths] : undefined, }; } - -interface VDirectory { - type: "directory"; - children: Record; -} - -interface VFile { - type: "file"; -} - -type VNode = VDirectory | VFile; - -export function createVirtualFileSystem(files: Record): FileSystem { - const root: VDirectory = { - type: "directory", - children: {}, - }; - const content: Record = {}; - - for (const filePath of Object.keys(files)) { - content[filePath] = files[filePath]; - addToTree(filePath); - } - - return { - directoryExists, - fileExists, - getAccessibleEntries, - readFile, - realpath: path => path, - writeFile, - removeFile, - }; - - function getNodeFromPath(path: string): VNode | undefined { - if (!path || path === "/") { - return root; - } - const segments = getPathComponents(path).slice(1); - let current: VNode = root; - for (const segment of segments) { - if (current.type !== "directory") { - return undefined; - } - const child: VNode = current.children[segment]; - if (!child) { - return undefined; - } - current = child; - } - return current; - } - - function ensureDirectory(segments: string[]): VDirectory { - let current: VDirectory = root; - for (const segment of segments) { - if (!current.children[segment]) { - current.children[segment] = { type: "directory", children: {} }; - } - else if (current.children[segment].type !== "directory") { - throw new Error(`Cannot create directory: a file already exists at "/${segments.join("/")}"`); - } - current = current.children[segment] as VDirectory; - } - return current; - } - - function addToTree(path: string): void { - const segments = getPathComponents(path).slice(1); - if (segments.length === 0) { - throw new Error(`Invalid file path: "${path}"`); - } - const filename = segments.pop()!; - const dirNode = ensureDirectory(segments); - dirNode.children[filename] = { type: "file" }; - } - - function writeFile(path: string, data: string): void { - content[path] = data; - addToTree(path); - } - - function removeFile(path: string): void { - delete content[path]; - const segments = getPathComponents(path).slice(1); - if (segments.length === 0) return; - const filename = segments.pop()!; - const dirNode = getNodeFromPath("/" + segments.join("/")); - if (dirNode && dirNode.type === "directory") { - delete dirNode.children[filename]; - } - } - - function directoryExists(directoryName: string): boolean { - const node = getNodeFromPath(directoryName); - return !!node && node.type === "directory"; - } - - function fileExists(fileName: string): boolean { - return fileName in content; - } - - function getAccessibleEntries(directoryName: string): FileSystemEntries | undefined { - const node = getNodeFromPath(directoryName); - if (!node || node.type !== "directory") { - return undefined; - } - const fileEntries: string[] = []; - const directories: string[] = []; - for (const [name, child] of Object.entries(node.children)) { - if (child.type === "file") { - fileEntries.push(name); - } - else { - directories.push(name); - } - } - return { files: fileEntries, directories }; - } - - function readFile(fileName: string): string | undefined { - if (fileName in content) { - return content[fileName]; - } - return undefined; - } -} diff --git a/packages/typescript/src/api/fsCallbacks.ts b/packages/typescript/src/api/fsCallbacks.ts new file mode 100644 index 0000000000000..33f9d57cc1cbe --- /dev/null +++ b/packages/typescript/src/api/fsCallbacks.ts @@ -0,0 +1,39 @@ +import { + type FileSystemCallbacks, + fsCallbackNames, +} from "./fs.ts"; + +export interface FileSystemCallbackConfiguration { + callbackNames: (typeof fsCallbackNames[number])[]; + arguments: string[]; +} + +export function configureFileSystemCallbacks(fs: FileSystemCallbacks | undefined): FileSystemCallbackConfiguration { + if (!fs) { + return { callbackNames: [], arguments: [] }; + } + + const callbackNames: (typeof fsCallbackNames[number])[] = []; + const args: string[] = []; + for (const name of fsCallbackNames) { + const value = fs[name]; + if (typeof value === "function") { + callbackNames.push(name); + continue; + } + if (value === "passthrough") { + continue; + } + if (name === "realpath" && value === "identity") { + args.push("realpath:identity"); + continue; + } + if (name === "stat" && value === "infer") { + args.push("stat:infer"); + continue; + } + throw new TypeError(`Invalid filesystem callback '${name}': expected a function${name === "realpath" ? ', "passthrough", or "identity"' : name === "stat" ? ', "passthrough", or "infer"' : ' or "passthrough"'}`); + } + args.push(...callbackNames); + return { callbackNames, arguments: args }; +} diff --git a/packages/typescript/src/api/options.ts b/packages/typescript/src/api/options.ts index 98751e6d99b32..2e8954c83a802 100644 --- a/packages/typescript/src/api/options.ts +++ b/packages/typescript/src/api/options.ts @@ -3,7 +3,9 @@ */ import getExePath from "#getExePath"; -import type { FileSystem } from "./fs.ts"; +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import type { FileSystemCallbacks } from "./fs.ts"; export interface ClientSocketOptions { /** Path to the Unix domain socket or Windows named pipe for API communication */ @@ -17,8 +19,10 @@ export interface ClientSpawnOptions { tsserverPath?: string | undefined; /** Current working directory */ cwd?: string | undefined; - /** Virtual filesystem callbacks */ - fs?: FileSystem | undefined; + /** Host filesystem callbacks. */ + fs?: FileSystemCallbacks | undefined; + /** Whether file names are case-sensitive. Inferred from the client filesystem when omitted. */ + useCaseSensitiveFileNames?: boolean | undefined; /** Allow trusted projects to execute configured external content mapper processes. */ runExternalCode?: boolean | undefined; /** Maximum encoded byte size of each batch response page. Defaults to 300 million bytes. Individual responses can be larger than this size, but this controls where batch pages are cutoff. */ @@ -47,11 +51,20 @@ export function getAPIProcessArgs(options: ClientSpawnOptions, async: boolean): const args = ["--api"]; if (async) args.push("--async"); args.push("--cwd", options.cwd ?? process.cwd()); + args.push(`--useCaseSensitiveFileNames=${options.useCaseSensitiveFileNames ?? inferUseCaseSensitiveFileNames()}`); if (options.runExternalCode) args.push("--runExternalCode"); if (options.collectTiming) args.push("--timing"); return args; } +function inferUseCaseSensitiveFileNames(): boolean { + if (process.platform === "win32") { + return false; + } + const fileName = fileURLToPath(import.meta.url); + return !existsSync(fileName.replace(/\w/g, char => char === char.toUpperCase() ? char.toLowerCase() : char.toUpperCase())); +} + export interface LSPConnectionOptions extends ClientSocketOptions { } diff --git a/packages/typescript/src/api/sync/client.ts b/packages/typescript/src/api/sync/client.ts index d3c3ccc64b9fc..4906d1822ca82 100644 --- a/packages/typescript/src/api/sync/client.ts +++ b/packages/typescript/src/api/sync/client.ts @@ -1,4 +1,4 @@ -import { fsCallbackNames } from "../fs.ts"; +import { configureFileSystemCallbacks } from "../fsCallbacks.ts"; import { type ClientOptions, type ClientSocketOptions, @@ -39,17 +39,9 @@ export class Client { const args = getAPIProcessArgs(options, false); this.maxResponseBytesPerPage = options.maxResponseBytesPerPage; - // Enable virtual FS callbacks for each provided FS function - const enabledCallbacks: (typeof fsCallbackNames[number])[] = []; - if (options.fs) { - for (const name of fsCallbackNames) { - if (options.fs[name]) { - enabledCallbacks.push(name); - } - } - } - if (enabledCallbacks.length > 0) { - args.push(`--callbacks=${enabledCallbacks.join(",")}`); + const fsConfiguration = configureFileSystemCallbacks(options.fs); + if (fsConfiguration.arguments.length > 0) { + args.push(`--callbacks=${fsConfiguration.arguments.join(",")}`); } const collectTiming = options.collectTiming ?? false; @@ -61,10 +53,10 @@ export class Client { this.channel = channel; if (options.fs) { - for (const name of enabledCallbacks) { + for (const name of fsConfiguration.callbackNames) { if (name === "writeFile") { - if (!options.fs.writeFile) continue; const callback = options.fs.writeFile; + if (typeof callback !== "function") throw new Error("Invalid writeFile callback configuration"); channel.registerCallback(name, (_, arg) => { const { path, data } = JSON.parse(arg); @@ -75,15 +67,19 @@ export class Client { continue; } - const callback = options.fs[name]!; + const callback = options.fs[name]; + if (typeof callback !== "function") throw new Error(`Invalid ${name} callback configuration`); channel.registerCallback(name, (_, arg) => { const result = callback(JSON.parse(arg)); if (name === "readFile") { - // readFile has 3 returns: string (content), null (not found), undefined (fall back). - // Wrap in object to preserve null vs undefined distinction. + // Wrap defined results to preserve null vs undefined. if (result === undefined) return ""; return JSON.stringify({ content: result }); } + if (name === "stat") { + if (result === undefined) return ""; + return JSON.stringify({ stat: result }); + } return JSON.stringify(result) ?? ""; }); } diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index a4d58eb8c0480..e2a1a0373350f 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -100,9 +100,8 @@ import { createFileSystem, createFileSystemLayer, createFileSystemWithLib, - createVirtualFileSystem, } from "@typescript/typescript/unstable/fs"; -import type { FileSystem } from "@typescript/typescript/unstable/fs"; +import type { FileSystemCallbacks } from "@typescript/typescript/unstable/fs"; import assert from "node:assert"; import { globSync } from "node:fs"; import { resolve } from "node:path"; @@ -112,7 +111,10 @@ import { } from "node:test"; import { fileURLToPath } from "node:url"; import { isSignatureDeclaration } from "../../src/ast/is.ts"; -import { areTestsFiltered } from "../testUtils.ts"; +import { + areTestsFiltered, + createVirtualFileSystem, +} from "../testUtils.ts"; import { defaultFiles, spawnAPI, @@ -165,6 +167,33 @@ describe("API", { concurrency }, () => { // @ts-expect-error Project ID brands are not interchangeable. const invalid: ConfiguredProjectId = synthetic; void invalid; + + const callbacks: FileSystemCallbacks = { + directoryExists: "passthrough", + fileExists: "passthrough", + getAccessibleEntries: "passthrough", + readFile: "passthrough", + realpath: "identity", + stat: "infer", + writeFile: "passthrough", + }; + void new API({ fs: callbacks }); + // @ts-expect-error Filesystem callback configurations must specify every operation. + void new API({ fs: { readFile: "passthrough" } }); + void new API({ + fs: { + ...callbacks, + // @ts-expect-error Identity is only a valid realpath implementation. + readFile: "identity", + }, + }); + void new API({ + fs: { + ...callbacks, + // @ts-expect-error Infer is only a valid stat implementation. + fileExists: "infer", + }, + }); } }); @@ -3820,6 +3849,13 @@ export const obj = { name }; }); describe("readFile callback semantics", { concurrency }, () => { + test("callback configurations require every operation at runtime", () => { + assert.throws( + () => new API({ fs: { readFile: "passthrough" } as FileSystemCallbacks }), + /Invalid filesystem callback 'fileExists'/, + ); + }); + test("readFile: string returns content, null blocks fallback, undefined falls through to real FS", async () => { const virtualFiles: Record = { "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true } }), @@ -3828,7 +3864,7 @@ describe("readFile callback semantics", { concurrency }, () => { const vfs = createVirtualFileSystem(virtualFiles); const blockedPath = "/src/blocked.ts"; - const fs: FileSystem = { + const fs: FileSystemCallbacks = { ...vfs, readFile: (fileName: string) => { if (fileName === blockedPath) { @@ -3867,6 +3903,31 @@ describe("readFile callback semantics", { concurrency }, () => { const blockedSf = await project.program.getSourceFile(blockedPath); assert.equal(blockedSf, undefined, "Blocked file should not be found (null prevents fallback)"); }); + + test("configured case sensitivity is used by the server and client", async () => { + const files = { + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: ["/src/index.ts"] }), + "/src/index.ts": `export const value = 1;`, + }; + { + await using api = new API({ + cwd: "/", + fs: createVirtualFileSystem(files), + useCaseSensitiveFileNames: false, + }); + const program = (await api.createSnapshot({ openProject: "/tsconfig.json" })).getConfiguredProject("/tsconfig.json")!.program; + assert.ok(await program.getSourceFile("/SRC/INDEX.TS")); + } + { + await using api = new API({ + cwd: "/", + fs: createVirtualFileSystem(files), + useCaseSensitiveFileNames: true, + }); + const program = (await api.createSnapshot({ openProject: "/tsconfig.json" })).getConfiguredProject("/tsconfig.json")!.program; + assert.equal(await program.getSourceFile("/SRC/INDEX.TS"), undefined); + } + }); }); describe("updateSnapshot file systems", { concurrency }, () => { @@ -3941,7 +4002,7 @@ describe("updateSnapshot file systems", { concurrency }, () => { const host = createVirtualFileSystem({ "/host.ts": `export const source = "host";`, }); - const fs: FileSystem = { + const fs: FileSystemCallbacks = { readFile: path => { callbackCalls.push(`readFile:${path}`); return host.readFile!(path); @@ -3962,6 +4023,12 @@ describe("updateSnapshot file systems", { concurrency }, () => { callbackCalls.push(`realpath:${path}`); return path; }, + stat: path => { + callbackCalls.push(`stat:${path}`); + if (host.directoryExists(path)) return { mode: 0o040555, size: 0, mtime: new Date(0) }; + if (host.fileExists(path)) return { mode: 0o100444, size: 0, mtime: new Date(0) }; + return null; + }, writeFile: (path, content) => { callbackCalls.push(`writeFile:${path}`); host.writeFile!(path, content); @@ -4043,7 +4110,7 @@ describe("updateSnapshot file systems", { concurrency }, () => { const host = createVirtualFileSystem({ "/src/fallback.ts": `export const fallback = true;`, }); - const fs: FileSystem = { + const fs: FileSystemCallbacks = { ...host, readFile: path => { readFileCalls.push(path); @@ -4111,10 +4178,16 @@ describe("updateSnapshot file systems", { concurrency }, () => { await using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs: { + directoryExists: "passthrough", + fileExists: "passthrough", + getAccessibleEntries: "passthrough", readFile: path => { callbackCalls.push(path); return undefined; }, + realpath: "passthrough", + stat: "passthrough", + writeFile: "passthrough", }, }); @@ -4145,10 +4218,16 @@ describe("updateSnapshot file systems", { concurrency }, () => { await using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs: { + directoryExists: "passthrough", + fileExists: "passthrough", + getAccessibleEntries: "passthrough", readFile: path => { callbackCalls.push(path); return undefined; }, + realpath: "passthrough", + stat: "passthrough", + writeFile: "passthrough", }, }); @@ -4328,6 +4407,12 @@ describe("updateSnapshot file systems", { concurrency }, () => { await using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs: { + directoryExists: "passthrough", + fileExists: "passthrough", + getAccessibleEntries: "passthrough", + readFile: "passthrough", + realpath: "passthrough", + stat: "passthrough", writeFile: path => { hostWrites.push(path); }, @@ -8066,7 +8151,7 @@ describe("runWithTemporaryFileUpdate", { concurrency }, () => { }); }); -function spawnAPIWithFS(files: Record = { ...defaultFiles }): { api: API; fs: FileSystem; } { +function spawnAPIWithFS(files: Record = { ...defaultFiles }): { api: API; fs: ReturnType; } { const fs = createVirtualFileSystem(files); const api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), diff --git a/packages/typescript/test/async/api.testUtils.ts b/packages/typescript/test/async/api.testUtils.ts index c3ad21541b429..d141caa90c80e 100644 --- a/packages/typescript/test/async/api.testUtils.ts +++ b/packages/typescript/test/async/api.testUtils.ts @@ -2,8 +2,8 @@ import { API, type APIOptions, } from "@typescript/typescript/unstable/async"; -import { createVirtualFileSystem } from "@typescript/typescript/unstable/fs"; import { fileURLToPath } from "node:url"; +import { createVirtualFileSystem } from "../testUtils.ts"; export const defaultFiles = { "/tsconfig.json": "{}", diff --git a/packages/typescript/test/async/astnav.test.ts b/packages/typescript/test/async/astnav.test.ts index f551056711dc0..38595fb6d6afa 100644 --- a/packages/typescript/test/async/astnav.test.ts +++ b/packages/typescript/test/async/astnav.test.ts @@ -13,7 +13,6 @@ import type { Node, SourceFile, } from "@typescript/typescript/unstable/ast"; -import { createVirtualFileSystem } from "@typescript/typescript/unstable/fs"; import assert from "node:assert"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; @@ -24,7 +23,10 @@ import { test, } from "node:test"; import { fileURLToPath } from "node:url"; -import { areTestsFiltered } from "../testUtils.ts"; +import { + areTestsFiltered, + createVirtualFileSystem, +} from "../testUtils.ts"; // --------------------------------------------------------------------------- // Go JSON baseline format diff --git a/packages/typescript/test/diagnosticFormatter.test.ts b/packages/typescript/test/diagnosticFormatter.test.ts index 8c4b62651052d..c3242b27900bc 100644 --- a/packages/typescript/test/diagnosticFormatter.test.ts +++ b/packages/typescript/test/diagnosticFormatter.test.ts @@ -3,13 +3,15 @@ import { formatDiagnostics, formatDiagnosticsWithColorAndContext, } from "@typescript/typescript/unstable/async"; -import { createVirtualFileSystem } from "@typescript/typescript/unstable/fs"; import assert from "node:assert"; import { describe, test, } from "node:test"; -import { areTestsFiltered } from "./testUtils.ts"; +import { + areTestsFiltered, + createVirtualFileSystem, +} from "./testUtils.ts"; describe("diagnosticFormatter", { concurrency: areTestsFiltered() }, () => { test("formats diagnostics with a configured program host", async () => { diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index 977687b3365fe..766ce2809bece 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -61,9 +61,8 @@ import { createFileSystem, createFileSystemLayer, createFileSystemWithLib, - createVirtualFileSystem, } from "@typescript/typescript/unstable/fs"; -import type { FileSystem } from "@typescript/typescript/unstable/fs"; +import type { FileSystemCallbacks } from "@typescript/typescript/unstable/fs"; import { API, type BigIntLiteralType, @@ -120,7 +119,10 @@ import { } from "node:test"; import { fileURLToPath } from "node:url"; import { isSignatureDeclaration } from "../../src/ast/is.ts"; -import { areTestsFiltered } from "../testUtils.ts"; +import { + areTestsFiltered, + createVirtualFileSystem, +} from "../testUtils.ts"; import { defaultFiles, spawnAPI, @@ -173,6 +175,33 @@ describe("API", { concurrency }, () => { // @ts-expect-error Project ID brands are not interchangeable. const invalid: ConfiguredProjectId = synthetic; void invalid; + + const callbacks: FileSystemCallbacks = { + directoryExists: "passthrough", + fileExists: "passthrough", + getAccessibleEntries: "passthrough", + readFile: "passthrough", + realpath: "identity", + stat: "infer", + writeFile: "passthrough", + }; + void new API({ fs: callbacks }); + // @ts-expect-error Filesystem callback configurations must specify every operation. + void new API({ fs: { readFile: "passthrough" } }); + void new API({ + fs: { + ...callbacks, + // @ts-expect-error Identity is only a valid realpath implementation. + readFile: "identity", + }, + }); + void new API({ + fs: { + ...callbacks, + // @ts-expect-error Infer is only a valid stat implementation. + fileExists: "infer", + }, + }); } }); @@ -3688,6 +3717,13 @@ export const obj = { name }; }); describe("readFile callback semantics", { concurrency }, () => { + test("callback configurations require every operation at runtime", () => { + assert.throws( + () => new API({ fs: { readFile: "passthrough" } as FileSystemCallbacks }), + /Invalid filesystem callback 'fileExists'/, + ); + }); + test("readFile: string returns content, null blocks fallback, undefined falls through to real FS", () => { const virtualFiles: Record = { "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true } }), @@ -3696,7 +3732,7 @@ describe("readFile callback semantics", { concurrency }, () => { const vfs = createVirtualFileSystem(virtualFiles); const blockedPath = "/src/blocked.ts"; - const fs: FileSystem = { + const fs: FileSystemCallbacks = { ...vfs, readFile: (fileName: string) => { if (fileName === blockedPath) { @@ -3735,6 +3771,31 @@ describe("readFile callback semantics", { concurrency }, () => { const blockedSf = project.program.getSourceFile(blockedPath); assert.equal(blockedSf, undefined, "Blocked file should not be found (null prevents fallback)"); }); + + test("configured case sensitivity is used by the server and client", () => { + const files = { + "/tsconfig.json": JSON.stringify({ compilerOptions: { noLib: true }, files: ["/src/index.ts"] }), + "/src/index.ts": `export const value = 1;`, + }; + { + using api = new API({ + cwd: "/", + fs: createVirtualFileSystem(files), + useCaseSensitiveFileNames: false, + }); + const program = (api.createSnapshot({ openProject: "/tsconfig.json" })).getConfiguredProject("/tsconfig.json")!.program; + assert.ok(program.getSourceFile("/SRC/INDEX.TS")); + } + { + using api = new API({ + cwd: "/", + fs: createVirtualFileSystem(files), + useCaseSensitiveFileNames: true, + }); + const program = (api.createSnapshot({ openProject: "/tsconfig.json" })).getConfiguredProject("/tsconfig.json")!.program; + assert.equal(program.getSourceFile("/SRC/INDEX.TS"), undefined); + } + }); }); describe("updateSnapshot file systems", { concurrency }, () => { @@ -3809,7 +3870,7 @@ describe("updateSnapshot file systems", { concurrency }, () => { const host = createVirtualFileSystem({ "/host.ts": `export const source = "host";`, }); - const fs: FileSystem = { + const fs: FileSystemCallbacks = { readFile: path => { callbackCalls.push(`readFile:${path}`); return host.readFile!(path); @@ -3830,6 +3891,12 @@ describe("updateSnapshot file systems", { concurrency }, () => { callbackCalls.push(`realpath:${path}`); return path; }, + stat: path => { + callbackCalls.push(`stat:${path}`); + if (host.directoryExists(path)) return { mode: 0o040555, size: 0, mtime: new Date(0) }; + if (host.fileExists(path)) return { mode: 0o100444, size: 0, mtime: new Date(0) }; + return null; + }, writeFile: (path, content) => { callbackCalls.push(`writeFile:${path}`); host.writeFile!(path, content); @@ -3911,7 +3978,7 @@ describe("updateSnapshot file systems", { concurrency }, () => { const host = createVirtualFileSystem({ "/src/fallback.ts": `export const fallback = true;`, }); - const fs: FileSystem = { + const fs: FileSystemCallbacks = { ...host, readFile: path => { readFileCalls.push(path); @@ -3979,10 +4046,16 @@ describe("updateSnapshot file systems", { concurrency }, () => { using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs: { + directoryExists: "passthrough", + fileExists: "passthrough", + getAccessibleEntries: "passthrough", readFile: path => { callbackCalls.push(path); return undefined; }, + realpath: "passthrough", + stat: "passthrough", + writeFile: "passthrough", }, }); @@ -4013,10 +4086,16 @@ describe("updateSnapshot file systems", { concurrency }, () => { using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs: { + directoryExists: "passthrough", + fileExists: "passthrough", + getAccessibleEntries: "passthrough", readFile: path => { callbackCalls.push(path); return undefined; }, + realpath: "passthrough", + stat: "passthrough", + writeFile: "passthrough", }, }); @@ -4196,6 +4275,12 @@ describe("updateSnapshot file systems", { concurrency }, () => { using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs: { + directoryExists: "passthrough", + fileExists: "passthrough", + getAccessibleEntries: "passthrough", + readFile: "passthrough", + realpath: "passthrough", + stat: "passthrough", writeFile: path => { hostWrites.push(path); }, @@ -7915,7 +8000,7 @@ describe("runWithTemporaryFileUpdate", { concurrency }, () => { }); }); -function spawnAPIWithFS(files: Record = { ...defaultFiles }): { api: API; fs: FileSystem; } { +function spawnAPIWithFS(files: Record = { ...defaultFiles }): { api: API; fs: ReturnType; } { const fs = createVirtualFileSystem(files); const api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), diff --git a/packages/typescript/test/sync/api.testUtils.ts b/packages/typescript/test/sync/api.testUtils.ts index 4af16a83f9cc2..1b10f0760cab6 100644 --- a/packages/typescript/test/sync/api.testUtils.ts +++ b/packages/typescript/test/sync/api.testUtils.ts @@ -1,9 +1,9 @@ -import { createVirtualFileSystem } from "@typescript/typescript/unstable/fs"; import { API, type APIOptions, } from "@typescript/typescript/unstable/sync"; import { fileURLToPath } from "node:url"; +import { createVirtualFileSystem } from "../testUtils.ts"; export const defaultFiles = { "/tsconfig.json": "{}", diff --git a/packages/typescript/test/sync/ast.bench.ts b/packages/typescript/test/sync/ast.bench.ts index 96ed71526ecbe..30d57385d40ba 100644 --- a/packages/typescript/test/sync/ast.bench.ts +++ b/packages/typescript/test/sync/ast.bench.ts @@ -20,12 +20,12 @@ import { createIdentifier, createIfStatement, } from "@typescript/typescript/unstable/ast/factory"; -import { createVirtualFileSystem } from "@typescript/typescript/unstable/fs"; import { API } from "@typescript/typescript/unstable/sync"; import assert from "node:assert/strict"; import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; import { Bench } from "tinybench"; +import { createVirtualFileSystem } from "../testUtils.ts"; const treeDepth = 5; const expressionsPerBlock = 6; diff --git a/packages/typescript/test/sync/ast.test.ts b/packages/typescript/test/sync/ast.test.ts index fad091bbb7732..8a9b71a675c12 100644 --- a/packages/typescript/test/sync/ast.test.ts +++ b/packages/typescript/test/sync/ast.test.ts @@ -44,7 +44,6 @@ import { visitNode, visitNodes, } from "@typescript/typescript/unstable/ast/visitor"; -import { createVirtualFileSystem } from "@typescript/typescript/unstable/fs"; import { API, Checker, @@ -56,7 +55,10 @@ import { test, } from "node:test"; import { fileURLToPath } from "node:url"; -import { areTestsFiltered } from "../testUtils.ts"; +import { + areTestsFiltered, + createVirtualFileSystem, +} from "../testUtils.ts"; import { runBenchmarks } from "./ast.bench.ts"; const concurrency = areTestsFiltered(); diff --git a/packages/typescript/test/sync/astnav.test.ts b/packages/typescript/test/sync/astnav.test.ts index 206bc9936a5d0..6901c17db331f 100644 --- a/packages/typescript/test/sync/astnav.test.ts +++ b/packages/typescript/test/sync/astnav.test.ts @@ -17,7 +17,6 @@ import type { Node, SourceFile, } from "@typescript/typescript/unstable/ast"; -import { createVirtualFileSystem } from "@typescript/typescript/unstable/fs"; import { API } from "@typescript/typescript/unstable/sync"; import assert from "node:assert"; import { readFileSync } from "node:fs"; @@ -29,7 +28,10 @@ import { test, } from "node:test"; import { fileURLToPath } from "node:url"; -import { areTestsFiltered } from "../testUtils.ts"; +import { + areTestsFiltered, + createVirtualFileSystem, +} from "../testUtils.ts"; // --------------------------------------------------------------------------- // Go JSON baseline format diff --git a/packages/typescript/test/testUtils.ts b/packages/typescript/test/testUtils.ts index 3760c6ed098b7..e08adf5fb1f3d 100644 --- a/packages/typescript/test/testUtils.ts +++ b/packages/typescript/test/testUtils.ts @@ -1,3 +1,145 @@ +import type { + FileSystemCallbacks, + FileSystemEntries, +} from "../src/api/fs.ts"; +import { getPathComponents } from "../src/api/path.ts"; + export function areTestsFiltered(): boolean { return process.execArgv.some(arg => arg === "--test-name-pattern" || arg.startsWith("--test-name-pattern=")); } + +interface VDirectory { + type: "directory"; + children: Record; +} + +interface VFile { + type: "file"; +} + +type VNode = VDirectory | VFile; + +interface TestFileSystem extends FileSystemCallbacks { + directoryExists(directoryName: string): boolean; + fileExists(fileName: string): boolean; + getAccessibleEntries(directoryName: string): FileSystemEntries | undefined; + readFile(fileName: string): string | undefined; + realpath: "identity"; + stat: "infer"; + writeFile(path: string, data: string): void; + removeFile(path: string): void; +} + +export function createVirtualFileSystem(files: Record): TestFileSystem { + const root: VDirectory = { + type: "directory", + children: {}, + }; + const content: Record = {}; + + for (const filePath of Object.keys(files)) { + content[filePath] = files[filePath]; + addToTree(filePath); + } + + return { + directoryExists, + fileExists, + getAccessibleEntries, + readFile, + realpath: "identity", + stat: "infer", + writeFile, + removeFile, + }; + + function getNodeFromPath(path: string): VNode | undefined { + if (!path || path === "/") { + return root; + } + const segments = getPathComponents(path).slice(1); + let current: VNode = root; + for (const segment of segments) { + if (current.type !== "directory") { + return undefined; + } + const child: VNode = current.children[segment]; + if (!child) { + return undefined; + } + current = child; + } + return current; + } + + function ensureDirectory(segments: string[]): VDirectory { + let current: VDirectory = root; + for (const segment of segments) { + if (!current.children[segment]) { + current.children[segment] = { type: "directory", children: {} }; + } + else if (current.children[segment].type !== "directory") { + throw new Error(`Cannot create directory: a file already exists at "/${segments.join("/")}"`); + } + current = current.children[segment] as VDirectory; + } + return current; + } + + function addToTree(path: string): void { + const segments = getPathComponents(path).slice(1); + if (segments.length === 0) { + throw new Error(`Invalid file path: "${path}"`); + } + const filename = segments.pop()!; + const dirNode = ensureDirectory(segments); + dirNode.children[filename] = { type: "file" }; + } + + function writeFile(path: string, data: string): void { + content[path] = data; + addToTree(path); + } + + function removeFile(path: string): void { + delete content[path]; + const segments = getPathComponents(path).slice(1); + if (segments.length === 0) return; + const filename = segments.pop()!; + const dirNode = getNodeFromPath("/" + segments.join("/")); + if (dirNode && dirNode.type === "directory") { + delete dirNode.children[filename]; + } + } + + function directoryExists(directoryName: string): boolean { + const node = getNodeFromPath(directoryName); + return !!node && node.type === "directory"; + } + + function fileExists(fileName: string): boolean { + return fileName in content; + } + + function getAccessibleEntries(directoryName: string): FileSystemEntries | undefined { + const node = getNodeFromPath(directoryName); + if (!node || node.type !== "directory") { + return undefined; + } + const fileEntries: string[] = []; + const directories: string[] = []; + for (const [name, child] of Object.entries(node.children)) { + if (child.type === "file") { + fileEntries.push(name); + } + else { + directories.push(name); + } + } + return { files: fileEntries, directories }; + } + + function readFile(fileName: string): string | undefined { + return content[fileName]; + } +} diff --git a/tsc/cmd/tsc/api.go b/tsc/cmd/tsc/api.go index 56058687c66e5..f5a3201344678 100644 --- a/tsc/cmd/tsc/api.go +++ b/tsc/cmd/tsc/api.go @@ -12,12 +12,14 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/api" "github.com/microsoft/TypeScript/tsc/internal/bundled" "github.com/microsoft/TypeScript/tsc/internal/core" + "github.com/microsoft/TypeScript/tsc/internal/vfs/osvfs" ) type apiFlags struct { cwd string pipePath string callbacks string + caseSensitive bool async bool timing bool runExternalCode bool @@ -28,7 +30,8 @@ func parseAPIFlags(args []string) (apiFlags, error) { result := apiFlags{} flags.StringVar(&result.cwd, "cwd", core.Must(os.Getwd()), "current working directory") flags.StringVar(&result.pipePath, "pipe", "", "use named pipe or Unix domain socket for communication instead of stdio") - flags.StringVar(&result.callbacks, "callbacks", "", "comma-separated list of FS callbacks to enable (readFile,fileExists,directoryExists,getAccessibleEntries,realpath)") + flags.StringVar(&result.callbacks, "callbacks", "", "comma-separated list of FS callbacks and defaults to enable") + flags.BoolVar(&result.caseSensitive, "useCaseSensitiveFileNames", osvfs.FS().UseCaseSensitiveFileNames(), "treat filesystem paths as case-sensitive") flags.BoolVar(&result.async, "async", false, "use JSON-RPC protocol instead of MessagePack (for async API)") flags.BoolVar(&result.timing, "timing", false, "collect per-request server processing time, folded into the client's timing snapshot") flags.BoolVar(&result.runExternalCode, "runExternalCode", false, "allow projects to execute configured external plugins") @@ -53,14 +56,15 @@ func runAPI(args []string) int { } options := &api.StdioServerOptions{ - Err: os.Stderr, - Cwd: flags.cwd, - DefaultLibraryPath: defaultLibraryPath, - Callbacks: callbacksList, - Async: flags.async, - CollectTiming: flags.timing, - RunExternalCode: flags.runExternalCode, - ContentMapperSpawner: newSystem(), + Err: os.Stderr, + Cwd: flags.cwd, + DefaultLibraryPath: defaultLibraryPath, + Callbacks: callbacksList, + UseCaseSensitiveFileNames: &flags.caseSensitive, + Async: flags.async, + CollectTiming: flags.timing, + RunExternalCode: flags.runExternalCode, + ContentMapperSpawner: newSystem(), } if flags.pipePath != "" { options.PipePath = flags.pipePath diff --git a/tsc/internal/api/callbackfs.go b/tsc/internal/api/callbackfs.go index ad1a674bbdbd9..598c877f6af6f 100644 --- a/tsc/internal/api/callbackfs.go +++ b/tsc/internal/api/callbackfs.go @@ -3,10 +3,13 @@ package api import ( "context" "fmt" + iofs "io/fs" + "slices" "time" "github.com/microsoft/TypeScript/tsc/internal/ipc" "github.com/microsoft/TypeScript/tsc/internal/json" + "github.com/microsoft/TypeScript/tsc/internal/tspath" "github.com/microsoft/TypeScript/tsc/internal/vfs" ) @@ -20,6 +23,9 @@ import ( type callbackFS struct { base vfs.FS enabledCallbacks map[string]bool + realpathIdentity bool + statInfer bool + caseSensitive *bool // conn and ctx are set after connection is established conn ipc.Conn @@ -33,6 +39,7 @@ const ( callbackDirectoryExists = "directoryExists" callbackGetAccessibleEntries = "getAccessibleEntries" callbackRealpath = "realpath" + callbackStat = "stat" callbackWriteFile = "writeFile" ) @@ -43,6 +50,7 @@ func isCallbackName(name string) bool { callbackDirectoryExists, callbackGetAccessibleEntries, callbackRealpath, + callbackStat, callbackWriteFile: return true default: @@ -53,9 +61,12 @@ func isCallbackName(name string) bool { // newCallbackFS creates a new callbackFS wrapping the given base filesystem. // The callbacks slice specifies which filesystem operations should be delegated // to the client (e.g., "readFile", "fileExists"). -func newCallbackFS(base vfs.FS, callbacks []string) *callbackFS { +func newCallbackFS(base vfs.FS, callbacks []string, caseSensitive *bool) *callbackFS { enabled := make(map[string]bool, len(callbacks)) for _, cb := range callbacks { + if cb == "realpath:identity" || cb == "stat:infer" { + continue + } if !isCallbackName(cb) { panic("unknown callback name: " + cb) } @@ -64,6 +75,9 @@ func newCallbackFS(base vfs.FS, callbacks []string) *callbackFS { return &callbackFS{ base: base, enabledCallbacks: enabled, + realpathIdentity: slices.Contains(callbacks, "realpath:identity"), + statInfer: slices.Contains(callbacks, "stat:infer"), + caseSensitive: caseSensitive, } } @@ -95,6 +109,9 @@ func (fs *callbackFS) call(name string, arg any) ([]byte, error) { // UseCaseSensitiveFileNames implements vfs.FS. func (fs *callbackFS) UseCaseSensitiveFileNames() bool { + if fs.caseSensitive != nil { + return *fs.caseSensitive + } return fs.base.UseCaseSensitiveFileNames() } @@ -114,8 +131,8 @@ func (fs *callbackFS) ReadFile(path string) (contents string, ok bool) { var wrapper struct { Content *string `json:"content"` } - if err := json.Unmarshal(result, &wrapper); err != nil { - panic(err) + if unmarshalErr := json.Unmarshal(result, &wrapper); unmarshalErr != nil { + panic(unmarshalErr) } if wrapper.Content == nil { return "", false @@ -165,15 +182,23 @@ func (fs *callbackFS) GetAccessibleEntries(path string) vfs.Entries { var rawEntries *struct { Files []string `json:"files"` Directories []string `json:"directories"` + Symlinks []string `json:"symlinks"` } if err := json.Unmarshal(result, &rawEntries); err != nil { panic(err) } if rawEntries != nil { - return vfs.Entries{ + entries := vfs.Entries{ Files: rawEntries.Files, Directories: rawEntries.Directories, } + if len(rawEntries.Symlinks) > 0 { + entries.Symlinks = make(map[string]struct{}, len(rawEntries.Symlinks)) + for _, name := range rawEntries.Symlinks { + entries.Symlinks[name] = struct{}{} + } + } + return entries } } } @@ -195,9 +220,107 @@ func (fs *callbackFS) Realpath(path string) string { return realpath } } + if fs.realpathIdentity { + return path + } return fs.base.Realpath(path) } +type callbackFileInfo struct { + name string + size int64 + mode iofs.FileMode + modTime time.Time +} + +func (info *callbackFileInfo) Name() string { return info.name } +func (info *callbackFileInfo) Size() int64 { return info.size } +func (info *callbackFileInfo) Mode() iofs.FileMode { return info.mode } +func (info *callbackFileInfo) ModTime() time.Time { return info.modTime } +func (info *callbackFileInfo) IsDir() bool { return info.mode.IsDir() } +func (info *callbackFileInfo) Sys() any { return nil } + +// Stat implements vfs.FS. +func (fs *callbackFS) Stat(path string) vfs.FileInfo { + if fs.isEnabled(callbackStat) { + result, err := fs.call(callbackStat, path) + if err != nil { + panic(err) + } + if len(result) > 0 && string(result) != "null" { + var wrapper struct { + Stat json.Value `json:"stat"` + } + if unmarshalErr := json.Unmarshal(result, &wrapper); unmarshalErr != nil { + panic(unmarshalErr) + } + if string(wrapper.Stat) == "null" { + return nil + } + var stat struct { + Mode uint32 `json:"mode"` + Size int64 `json:"size"` + MTime string `json:"mtime"` + } + if unmarshalErr := json.Unmarshal(wrapper.Stat, &stat); unmarshalErr != nil { + panic(unmarshalErr) + } + info := &callbackFileInfo{ + name: tspath.GetBaseFileName(path), + size: stat.Size, + mode: nodeFileModeToGoFileMode(stat.Mode), + } + info.modTime, err = time.Parse(time.RFC3339Nano, stat.MTime) + if err != nil { + panic(err) + } + return info + } + } + if fs.statInfer { + if fs.DirectoryExists(path) { + return &callbackFileInfo{name: tspath.GetBaseFileName(path), mode: iofs.ModeDir | 0o555} + } + if fs.FileExists(path) { + return &callbackFileInfo{name: tspath.GetBaseFileName(path), mode: 0o444} + } + return nil + } + return fs.base.Stat(path) +} + +func nodeFileModeToGoFileMode(mode uint32) iofs.FileMode { + result := iofs.FileMode(mode & 0o777) + if mode&0o4000 != 0 { + result |= iofs.ModeSetuid + } + if mode&0o2000 != 0 { + result |= iofs.ModeSetgid + } + if mode&0o1000 != 0 { + result |= iofs.ModeSticky + } + switch mode & 0o170000 { + case 0o010000: + result |= iofs.ModeNamedPipe + case 0o020000: + result |= iofs.ModeDevice | iofs.ModeCharDevice + case 0o040000: + result |= iofs.ModeDir + case 0o060000: + result |= iofs.ModeDevice + case 0o100000: + // Regular file. + case 0o120000: + result |= iofs.ModeSymlink + case 0o140000: + result |= iofs.ModeSocket + default: + result |= iofs.ModeIrregular + } + return result +} + // WriteFile implements vfs.FS. func (fs *callbackFS) WriteFile(path string, data string) error { if fs.isEnabled(callbackWriteFile) { @@ -230,8 +353,3 @@ func (fs *callbackFS) Remove(path string) error { func (fs *callbackFS) Chtimes(path string, aTime time.Time, mTime time.Time) error { return fs.base.Chtimes(path, aTime, mTime) } - -// Stat implements vfs.FS - always delegates to base (no callback support). -func (fs *callbackFS) Stat(path string) vfs.FileInfo { - return fs.base.Stat(path) -} diff --git a/tsc/internal/api/callbackfs_test.go b/tsc/internal/api/callbackfs_test.go new file mode 100644 index 0000000000000..5bd6b93613867 --- /dev/null +++ b/tsc/internal/api/callbackfs_test.go @@ -0,0 +1,111 @@ +package api + +import ( + "context" + iofs "io/fs" + "testing" + "time" + + "github.com/microsoft/TypeScript/tsc/internal/json" + "github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest" +) + +type callbackTestConn struct { + responses map[string]json.Value +} + +func (c *callbackTestConn) Run(context.Context) error { + return nil +} + +func (c *callbackTestConn) Call(_ context.Context, method string, _ any) (json.Value, error) { + return c.responses[method], nil +} + +func (c *callbackTestConn) Notify(context.Context, string, any) error { + return nil +} + +func TestCallbackFSDefaults(t *testing.T) { + t.Parallel() + + base := vfstest.FromMap(map[string]string{ + "/file.ts": "content", + }, false) + caseSensitive := true + fs := newCallbackFS(base, []string{"realpath:identity", "stat:infer"}, &caseSensitive) + + if !fs.UseCaseSensitiveFileNames() { + t.Fatal("expected configured case sensitivity") + } + if got := fs.Realpath("/file.ts"); got != "/file.ts" { + t.Fatalf("Realpath() = %q, want identity", got) + } + if info := fs.Stat("/file.ts"); info == nil || info.IsDir() || info.Size() != 0 { + t.Fatalf("Stat(file) = %#v, want inferred file with default metadata", info) + } + if info := fs.Stat("/"); info == nil || !info.IsDir() { + t.Fatalf("Stat(directory) = %#v, want inferred directory", info) + } + if info := fs.Stat("/missing"); info != nil { + t.Fatalf("Stat(missing) = %#v, want nil", info) + } +} + +func TestCallbackFSStatAndEntries(t *testing.T) { + t.Parallel() + + base := vfstest.FromMap(map[string]string{}, true) + fs := newCallbackFS(base, []string{"stat", "getAccessibleEntries"}, nil) + fs.SetConnection(t.Context(), &callbackTestConn{ + responses: map[string]json.Value{ + callbackStat: []byte(`{"stat":{"mode":33060,"size":12,"mtime":"2024-01-02T03:04:05.000Z"}}`), + callbackGetAccessibleEntries: []byte(`{"files":["link.ts"],"directories":["pkg"],"symlinks":["link.ts","pkg"]}`), + }, + }) + + info := fs.Stat("/link.ts") + if info == nil || info.IsDir() || info.Size() != 12 { + t.Fatalf("Stat() = %#v, want callback file metadata", info) + } + if info.Mode() != 0o444 { + t.Fatalf("Mode() = %v, want translated regular-file mode 0444", info.Mode()) + } + wantTime := time.Date(2024, time.January, 2, 3, 4, 5, 0, time.UTC) + if !info.ModTime().Equal(wantTime) { + t.Fatalf("ModTime() = %v, want %v", info.ModTime(), wantTime) + } + + entries := fs.GetAccessibleEntries("/") + if _, ok := entries.Symlinks["link.ts"]; !ok { + t.Fatal("expected file symlink metadata") + } + if _, ok := entries.Symlinks["pkg"]; !ok { + t.Fatal("expected directory symlink metadata") + } +} + +func TestNodeFileModeToGoFileMode(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + node uint32 + goMode iofs.FileMode + }{ + {name: "directory", node: 0o040755, goMode: iofs.ModeDir | 0o755}, + {name: "regular", node: 0o100644, goMode: 0o644}, + {name: "symlink", node: 0o120777, goMode: iofs.ModeSymlink | 0o777}, + {name: "fifo", node: 0o010600, goMode: iofs.ModeNamedPipe | 0o600}, + {name: "socket", node: 0o140600, goMode: iofs.ModeSocket | 0o600}, + {name: "setuid", node: 0o104755, goMode: iofs.ModeSetuid | 0o755}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + if got := nodeFileModeToGoFileMode(test.node); got != test.goMode { + t.Fatalf("nodeFileModeToGoFileMode(%#o) = %#o, want %#o", test.node, got, test.goMode) + } + }) + } +} diff --git a/tsc/internal/api/server.go b/tsc/internal/api/server.go index 12fdda3104886..ff6e135f3df46 100644 --- a/tsc/internal/api/server.go +++ b/tsc/internal/api/server.go @@ -26,6 +26,8 @@ type StdioServerOptions struct { // Callbacks specifies which filesystem operations should be delegated // to the client (e.g., "readFile", "fileExists"). Empty means no callbacks. Callbacks []string + // UseCaseSensitiveFileNames overrides the base filesystem's case sensitivity. + UseCaseSensitiveFileNames *bool // Async enables JSON-RPC protocol with async connection handling. // When false (default), uses MessagePack protocol with sync connection. Async bool @@ -76,10 +78,10 @@ func (s *StdioServer) Run(ctx context.Context) error { fs := bundled.WrapFS(osvfs.FS()) - // Wrap the base FS with callbackFS if callbacks are requested + // Wrap the base FS when callbacks or an explicit case-sensitivity setting are requested. var callbackFS *callbackFS - if len(s.options.Callbacks) > 0 { - callbackFS = newCallbackFS(fs, s.options.Callbacks) + if len(s.options.Callbacks) > 0 || s.options.UseCaseSensitiveFileNames != nil { + callbackFS = newCallbackFS(fs, s.options.Callbacks, s.options.UseCaseSensitiveFileNames) fs = callbackFS } From 393980c39328b8ceb4c6eaceba36ee7cbefe46c9 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Thu, 24 Sep 2026 15:57:27 -0700 Subject: [PATCH 2/8] Rename, allow callbacks to return same sentinels as configuration --- packages/typescript/src/api/async/client.ts | 14 ++-- packages/typescript/src/api/fs.ts | 33 ++++++-- packages/typescript/src/api/fsCallbacks.ts | 11 +-- packages/typescript/src/api/sync/client.ts | 10 +-- packages/typescript/test/async/api.test.ts | 93 +++++++++++---------- packages/typescript/test/sync/api.test.ts | 93 +++++++++++---------- packages/typescript/test/testUtils.ts | 27 +++--- tsc/internal/api/callbackfs.go | 16 ++-- tsc/internal/api/callbackfs_test.go | 26 +++++- 9 files changed, 184 insertions(+), 139 deletions(-) diff --git a/packages/typescript/src/api/async/client.ts b/packages/typescript/src/api/async/client.ts index 65931716d0406..b6ed193479101 100644 --- a/packages/typescript/src/api/async/client.ts +++ b/packages/typescript/src/api/async/client.ts @@ -9,7 +9,10 @@ import { } from "#vscode-jsonrpc/node"; import type { ChildProcess } from "node:child_process"; import type { Socket } from "node:net"; -import type { FileSystemCallbacks } from "../fs.ts"; +import { + type FileSystemCallbacks, + serverFS, +} from "../fs.ts"; import { configureFileSystemCallbacks, type FileSystemCallbackConfiguration, @@ -143,8 +146,7 @@ export class Client { const requestType = new RequestType<{ path: string; data: string; }, unknown, void>(name); connection.onRequest(requestType, (arg: { path: string; data: string; }) => { - callback(arg.path, arg.data); - return null; + return callback(arg.path, arg.data) === serverFS.useOS ? null : true; }); continue; @@ -155,16 +157,14 @@ export class Client { const requestType = new RequestType(name); connection.onRequest(requestType, (arg: unknown) => { const result = callback(arg as string); + if (result === serverFS.useOS) return null; if (name === "readFile") { - // JSON-RPC can't distinguish null from undefined, so wrap defined results. - if (result === undefined) return null; return { content: result }; } if (name === "stat") { - if (result === undefined) return null; return { stat: result }; } - return result ?? null; + return result; }); } } diff --git a/packages/typescript/src/api/fs.ts b/packages/typescript/src/api/fs.ts index ad00ffbc9a0f7..16d225eebeb44 100644 --- a/packages/typescript/src/api/fs.ts +++ b/packages/typescript/src/api/fs.ts @@ -27,20 +27,37 @@ export interface FileSystemStat { mtime: Date; } +const useOS: unique symbol = Symbol("useOS"); +const identity: unique symbol = Symbol("identity"); +const fakeStat: unique symbol = Symbol("fakeStat"); + +export const serverFS: { + /** Delegate the configured operation, or the current callback invocation, to the server's operating-system filesystem. */ + readonly useOS: typeof useOS; + /** Use the input path as its own real path without consulting a filesystem. Valid only for `realpath`. */ + readonly identity: typeof identity; + /** Synthesize stat information from `directoryExists` and `fileExists`. Valid only for `stat`. */ + readonly fakeStat: typeof fakeStat; +} = { + useOS: useOS, + identity: identity, + fakeStat: fakeStat, +}; + export interface FileSystemCallbacks { - directoryExists: ((directoryName: string) => boolean | undefined) | "passthrough"; - fileExists: ((fileName: string) => boolean | undefined) | "passthrough"; - getAccessibleEntries: ((directoryName: string) => FileSystemEntries | undefined) | "passthrough"; + directoryExists: ((directoryName: string) => boolean | typeof serverFS.useOS) | typeof serverFS.useOS; + fileExists: ((fileName: string) => boolean | typeof serverFS.useOS) | typeof serverFS.useOS; + getAccessibleEntries: ((directoryName: string) => FileSystemEntries | typeof serverFS.useOS) | typeof serverFS.useOS; /** * Read a file's content. * - Return the file content as a `string` (including `""` for empty files). * - Return `null` to indicate the file does not exist (without falling back to the real FS). - * - Return `undefined` to fall back to the real filesystem. + * - Return {@link serverFS.useOS} to fall back to the server's operating-system filesystem. */ - readFile: ((fileName: string) => string | null | undefined) | "passthrough"; - realpath: ((path: string) => string | undefined) | "passthrough" | "identity"; - stat: ((path: string) => FileSystemStat | null | undefined) | "passthrough" | "infer"; - writeFile: ((path: string, content: string) => void) | "passthrough"; + readFile: ((fileName: string) => string | null | typeof serverFS.useOS) | typeof serverFS.useOS; + realpath: ((path: string) => string | typeof serverFS.useOS) | typeof serverFS.useOS | typeof serverFS.identity; + stat: ((path: string) => FileSystemStat | null | typeof serverFS.useOS) | typeof serverFS.useOS | typeof serverFS.fakeStat; + writeFile: ((path: string, content: string) => void | typeof serverFS.useOS) | typeof serverFS.useOS; } /** The callback names supported by the Go server for virtual FS delegation. */ diff --git a/packages/typescript/src/api/fsCallbacks.ts b/packages/typescript/src/api/fsCallbacks.ts index 33f9d57cc1cbe..fca229219e680 100644 --- a/packages/typescript/src/api/fsCallbacks.ts +++ b/packages/typescript/src/api/fsCallbacks.ts @@ -1,6 +1,7 @@ import { type FileSystemCallbacks, fsCallbackNames, + serverFS, } from "./fs.ts"; export interface FileSystemCallbackConfiguration { @@ -21,18 +22,18 @@ export function configureFileSystemCallbacks(fs: FileSystemCallbacks | undefined callbackNames.push(name); continue; } - if (value === "passthrough") { + if (value === serverFS.useOS) { continue; } - if (name === "realpath" && value === "identity") { + if (name === "realpath" && value === serverFS.identity) { args.push("realpath:identity"); continue; } - if (name === "stat" && value === "infer") { - args.push("stat:infer"); + if (name === "stat" && value === serverFS.fakeStat) { + args.push("stat:fakeStat"); continue; } - throw new TypeError(`Invalid filesystem callback '${name}': expected a function${name === "realpath" ? ', "passthrough", or "identity"' : name === "stat" ? ', "passthrough", or "infer"' : ' or "passthrough"'}`); + throw new TypeError(`Invalid filesystem callback '${name}': expected a function or a supported filesystem sentinel`); } args.push(...callbackNames); return { callbackNames, arguments: args }; diff --git a/packages/typescript/src/api/sync/client.ts b/packages/typescript/src/api/sync/client.ts index 4906d1822ca82..86abd721ee466 100644 --- a/packages/typescript/src/api/sync/client.ts +++ b/packages/typescript/src/api/sync/client.ts @@ -1,3 +1,4 @@ +import { serverFS } from "../fs.ts"; import { configureFileSystemCallbacks } from "../fsCallbacks.ts"; import { type ClientOptions, @@ -60,8 +61,7 @@ export class Client { channel.registerCallback(name, (_, arg) => { const { path, data } = JSON.parse(arg); - callback(path, data); - return ""; + return callback(path, data) === serverFS.useOS ? "" : "true"; }); continue; @@ -71,16 +71,14 @@ export class Client { if (typeof callback !== "function") throw new Error(`Invalid ${name} callback configuration`); channel.registerCallback(name, (_, arg) => { const result = callback(JSON.parse(arg)); + if (result === serverFS.useOS) return ""; if (name === "readFile") { - // Wrap defined results to preserve null vs undefined. - if (result === undefined) return ""; return JSON.stringify({ content: result }); } if (name === "stat") { - if (result === undefined) return ""; return JSON.stringify({ stat: result }); } - return JSON.stringify(result) ?? ""; + return JSON.stringify(result); }); } } diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index e2a1a0373350f..94129d265a732 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -100,8 +100,9 @@ import { createFileSystem, createFileSystemLayer, createFileSystemWithLib, + type FileSystemCallbacks, + serverFS, } from "@typescript/typescript/unstable/fs"; -import type { FileSystemCallbacks } from "@typescript/typescript/unstable/fs"; import assert from "node:assert"; import { globSync } from "node:fs"; import { resolve } from "node:path"; @@ -169,29 +170,29 @@ describe("API", { concurrency }, () => { void invalid; const callbacks: FileSystemCallbacks = { - directoryExists: "passthrough", - fileExists: "passthrough", - getAccessibleEntries: "passthrough", - readFile: "passthrough", - realpath: "identity", - stat: "infer", - writeFile: "passthrough", + directoryExists: serverFS.useOS, + fileExists: serverFS.useOS, + getAccessibleEntries: serverFS.useOS, + readFile: serverFS.useOS, + realpath: serverFS.identity, + stat: serverFS.fakeStat, + writeFile: serverFS.useOS, }; void new API({ fs: callbacks }); // @ts-expect-error Filesystem callback configurations must specify every operation. - void new API({ fs: { readFile: "passthrough" } }); + void new API({ fs: { readFile: serverFS.useOS } }); void new API({ fs: { ...callbacks, // @ts-expect-error Identity is only a valid realpath implementation. - readFile: "identity", + readFile: serverFS.identity, }, }); void new API({ fs: { ...callbacks, // @ts-expect-error Infer is only a valid stat implementation. - fileExists: "infer", + fileExists: serverFS.fakeStat, }, }); } @@ -3851,12 +3852,12 @@ export const obj = { name }; describe("readFile callback semantics", { concurrency }, () => { test("callback configurations require every operation at runtime", () => { assert.throws( - () => new API({ fs: { readFile: "passthrough" } as FileSystemCallbacks }), + () => new API({ fs: { readFile: serverFS.useOS } as FileSystemCallbacks }), /Invalid filesystem callback 'fileExists'/, ); }); - test("readFile: string returns content, null blocks fallback, undefined falls through to real FS", async () => { + test("readFile: string returns content, null blocks fallback, useOS falls through to the server OS", async () => { const virtualFiles: Record = { "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true } }), "/src/index.ts": `export const x: number = 1;`, @@ -3872,8 +3873,8 @@ describe("readFile callback semantics", { concurrency }, () => { return null; } // Try the VFS first; if it has the file, return its content (string). - // Otherwise return undefined to fall through to the real FS. - return vfs.readFile!(fileName); + // Otherwise use the OS filesystem on the server. + return vfs.readFile(fileName); }, }; @@ -3890,7 +3891,7 @@ describe("readFile callback semantics", { concurrency }, () => { assert.ok(sf, "Virtual file should be found"); assert.equal(sf.text, virtualFiles["/src/index.ts"]); - // 2. undefined fallback: lib files from the real FS should be present. + // 2. useOS fallback: lib files from the server OS should be present. // If readFile returned null for unknowns, lib files would be missing // and `number` would not resolve — this was the original async bug. // Verify by checking that `number` resolves to a proper type (not error). @@ -4178,16 +4179,16 @@ describe("updateSnapshot file systems", { concurrency }, () => { await using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs: { - directoryExists: "passthrough", - fileExists: "passthrough", - getAccessibleEntries: "passthrough", + directoryExists: serverFS.useOS, + fileExists: serverFS.useOS, + getAccessibleEntries: serverFS.useOS, readFile: path => { callbackCalls.push(path); - return undefined; + return serverFS.useOS; }, - realpath: "passthrough", - stat: "passthrough", - writeFile: "passthrough", + realpath: serverFS.useOS, + stat: serverFS.useOS, + writeFile: serverFS.useOS, }, }); @@ -4218,16 +4219,16 @@ describe("updateSnapshot file systems", { concurrency }, () => { await using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs: { - directoryExists: "passthrough", - fileExists: "passthrough", - getAccessibleEntries: "passthrough", + directoryExists: serverFS.useOS, + fileExists: serverFS.useOS, + getAccessibleEntries: serverFS.useOS, readFile: path => { callbackCalls.push(path); - return undefined; + return serverFS.useOS; }, - realpath: "passthrough", - stat: "passthrough", - writeFile: "passthrough", + realpath: serverFS.useOS, + stat: serverFS.useOS, + writeFile: serverFS.useOS, }, }); @@ -4407,12 +4408,12 @@ describe("updateSnapshot file systems", { concurrency }, () => { await using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs: { - directoryExists: "passthrough", - fileExists: "passthrough", - getAccessibleEntries: "passthrough", - readFile: "passthrough", - realpath: "passthrough", - stat: "passthrough", + directoryExists: serverFS.useOS, + fileExists: serverFS.useOS, + getAccessibleEntries: serverFS.useOS, + readFile: serverFS.useOS, + realpath: serverFS.useOS, + stat: serverFS.useOS, writeFile: path => { hostWrites.push(path); }, @@ -6772,7 +6773,7 @@ describe("Program - selected file emit", { concurrency }, () => { ]); assert.equal(result.outputFiles.get("/src/a.js")?.sourceFileName, "/src/a.ts"); assert.match(result.outputFiles.get("/src/a.js")!.text, /export const a = 1/); - assert.equal(fs.readFile?.("/src/a.js"), undefined); + assert.equal(fs.readFile("/src/a.js"), serverFS.useOS); }); test("getDeclarationEmit forces declarations and declaration maps", async () => { @@ -6790,7 +6791,7 @@ describe("Program - selected file emit", { concurrency }, () => { "/src/b.d.ts.map", ]); assert.equal(result.outputFiles.get("/src/a.d.ts")?.sourceFileName, "/src/a.ts"); - assert.equal(fs.readFile?.("/src/a.d.ts"), undefined); + assert.equal(fs.readFile("/src/a.d.ts"), serverFS.useOS); }); test("selected file emit accepts empty arrays", async () => { @@ -7742,9 +7743,9 @@ describe("Program - emit", { concurrency }, () => { const dts = fs.readFile?.("/dist/src/index.d.ts"); const js2 = fs.readFile?.("/dist/src/testing.js"); const dts2 = fs.readFile?.("/dist/src/testing.d.ts"); - assert.strictEqual(js, undefined); + assert.strictEqual(js, serverFS.useOS); assert.strictEqual(dts, `export declare const x: number;\n`); - assert.strictEqual(js2, undefined); + assert.strictEqual(js2, serverFS.useOS); assert.strictEqual(dts2, `export declare const y: string;\n`); }); @@ -7774,9 +7775,9 @@ describe("Program - emit", { concurrency }, () => { const js2 = fs.readFile?.("/dist/src/testing.js"); const dts2 = fs.readFile?.("/dist/src/testing.d.ts"); assert.strictEqual(js, `export const x = 1;\n`); - assert.strictEqual(dts, undefined); + assert.strictEqual(dts, serverFS.useOS); assert.strictEqual(js2, `export const y = 'typescript';\n`); - assert.strictEqual(dts2, undefined); + assert.strictEqual(dts2, serverFS.useOS); }); test("emitToString emits the whole program and respects emitOnly", async () => { @@ -7790,7 +7791,7 @@ describe("Program - emit", { concurrency }, () => { "/dist/src/index.d.ts", "/dist/src/testing.d.ts", ]); - assert.equal(fs.readFile?.("/dist/src/index.js"), undefined); + assert.equal(fs.readFile("/dist/src/index.js"), serverFS.useOS); }); test("whole-program emit includes option-controlled maps", async () => { @@ -7855,8 +7856,8 @@ describe("Program - emit", { concurrency }, () => { assert.equal(result.emitSkipped, true); assert.ok(result.diagnostics.some(d => d.code === 1109)); assert.deepEqual(result.emittedFiles, []); - assert.equal(fs.readFile?.("/dist/src/bad.js"), undefined); - assert.equal(fs.readFile?.("/dist/src/good.js"), undefined); + assert.equal(fs.readFile("/dist/src/bad.js"), serverFS.useOS); + assert.equal(fs.readFile("/dist/src/good.js"), serverFS.useOS); const stringResult = await project.program.emitToString(); assert.equal(stringResult.emitSkipped, true); @@ -7884,7 +7885,7 @@ describe("Program - emit", { concurrency }, () => { emitSkipped: false, outputFiles: new Map(), }); - assert.equal(fs.readFile?.("/src/index.js"), undefined); + assert.equal(fs.readFile("/src/index.js"), serverFS.useOS); }); test("emit rejects unknown files and invalid emitOnly values", async () => { diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index 766ce2809bece..c1391d0da9550 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -61,8 +61,9 @@ import { createFileSystem, createFileSystemLayer, createFileSystemWithLib, + type FileSystemCallbacks, + serverFS, } from "@typescript/typescript/unstable/fs"; -import type { FileSystemCallbacks } from "@typescript/typescript/unstable/fs"; import { API, type BigIntLiteralType, @@ -177,29 +178,29 @@ describe("API", { concurrency }, () => { void invalid; const callbacks: FileSystemCallbacks = { - directoryExists: "passthrough", - fileExists: "passthrough", - getAccessibleEntries: "passthrough", - readFile: "passthrough", - realpath: "identity", - stat: "infer", - writeFile: "passthrough", + directoryExists: serverFS.useOS, + fileExists: serverFS.useOS, + getAccessibleEntries: serverFS.useOS, + readFile: serverFS.useOS, + realpath: serverFS.identity, + stat: serverFS.fakeStat, + writeFile: serverFS.useOS, }; void new API({ fs: callbacks }); // @ts-expect-error Filesystem callback configurations must specify every operation. - void new API({ fs: { readFile: "passthrough" } }); + void new API({ fs: { readFile: serverFS.useOS } }); void new API({ fs: { ...callbacks, // @ts-expect-error Identity is only a valid realpath implementation. - readFile: "identity", + readFile: serverFS.identity, }, }); void new API({ fs: { ...callbacks, // @ts-expect-error Infer is only a valid stat implementation. - fileExists: "infer", + fileExists: serverFS.fakeStat, }, }); } @@ -3719,12 +3720,12 @@ export const obj = { name }; describe("readFile callback semantics", { concurrency }, () => { test("callback configurations require every operation at runtime", () => { assert.throws( - () => new API({ fs: { readFile: "passthrough" } as FileSystemCallbacks }), + () => new API({ fs: { readFile: serverFS.useOS } as FileSystemCallbacks }), /Invalid filesystem callback 'fileExists'/, ); }); - test("readFile: string returns content, null blocks fallback, undefined falls through to real FS", () => { + test("readFile: string returns content, null blocks fallback, useOS falls through to the server OS", () => { const virtualFiles: Record = { "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true } }), "/src/index.ts": `export const x: number = 1;`, @@ -3740,8 +3741,8 @@ describe("readFile callback semantics", { concurrency }, () => { return null; } // Try the VFS first; if it has the file, return its content (string). - // Otherwise return undefined to fall through to the real FS. - return vfs.readFile!(fileName); + // Otherwise use the OS filesystem on the server. + return vfs.readFile(fileName); }, }; @@ -3758,7 +3759,7 @@ describe("readFile callback semantics", { concurrency }, () => { assert.ok(sf, "Virtual file should be found"); assert.equal(sf.text, virtualFiles["/src/index.ts"]); - // 2. undefined fallback: lib files from the real FS should be present. + // 2. useOS fallback: lib files from the server OS should be present. // If readFile returned null for unknowns, lib files would be missing // and `number` would not resolve — this was the original async bug. // Verify by checking that `number` resolves to a proper type (not error). @@ -4046,16 +4047,16 @@ describe("updateSnapshot file systems", { concurrency }, () => { using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs: { - directoryExists: "passthrough", - fileExists: "passthrough", - getAccessibleEntries: "passthrough", + directoryExists: serverFS.useOS, + fileExists: serverFS.useOS, + getAccessibleEntries: serverFS.useOS, readFile: path => { callbackCalls.push(path); - return undefined; + return serverFS.useOS; }, - realpath: "passthrough", - stat: "passthrough", - writeFile: "passthrough", + realpath: serverFS.useOS, + stat: serverFS.useOS, + writeFile: serverFS.useOS, }, }); @@ -4086,16 +4087,16 @@ describe("updateSnapshot file systems", { concurrency }, () => { using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs: { - directoryExists: "passthrough", - fileExists: "passthrough", - getAccessibleEntries: "passthrough", + directoryExists: serverFS.useOS, + fileExists: serverFS.useOS, + getAccessibleEntries: serverFS.useOS, readFile: path => { callbackCalls.push(path); - return undefined; + return serverFS.useOS; }, - realpath: "passthrough", - stat: "passthrough", - writeFile: "passthrough", + realpath: serverFS.useOS, + stat: serverFS.useOS, + writeFile: serverFS.useOS, }, }); @@ -4275,12 +4276,12 @@ describe("updateSnapshot file systems", { concurrency }, () => { using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs: { - directoryExists: "passthrough", - fileExists: "passthrough", - getAccessibleEntries: "passthrough", - readFile: "passthrough", - realpath: "passthrough", - stat: "passthrough", + directoryExists: serverFS.useOS, + fileExists: serverFS.useOS, + getAccessibleEntries: serverFS.useOS, + readFile: serverFS.useOS, + realpath: serverFS.useOS, + stat: serverFS.useOS, writeFile: path => { hostWrites.push(path); }, @@ -6640,7 +6641,7 @@ describe("Program - selected file emit", { concurrency }, () => { ]); assert.equal(result.outputFiles.get("/src/a.js")?.sourceFileName, "/src/a.ts"); assert.match(result.outputFiles.get("/src/a.js")!.text, /export const a = 1/); - assert.equal(fs.readFile?.("/src/a.js"), undefined); + assert.equal(fs.readFile("/src/a.js"), serverFS.useOS); }); test("getDeclarationEmit forces declarations and declaration maps", () => { @@ -6658,7 +6659,7 @@ describe("Program - selected file emit", { concurrency }, () => { "/src/b.d.ts.map", ]); assert.equal(result.outputFiles.get("/src/a.d.ts")?.sourceFileName, "/src/a.ts"); - assert.equal(fs.readFile?.("/src/a.d.ts"), undefined); + assert.equal(fs.readFile("/src/a.d.ts"), serverFS.useOS); }); test("selected file emit accepts empty arrays", () => { @@ -7610,9 +7611,9 @@ describe("Program - emit", { concurrency }, () => { const dts = fs.readFile?.("/dist/src/index.d.ts"); const js2 = fs.readFile?.("/dist/src/testing.js"); const dts2 = fs.readFile?.("/dist/src/testing.d.ts"); - assert.strictEqual(js, undefined); + assert.strictEqual(js, serverFS.useOS); assert.strictEqual(dts, `export declare const x: number;\n`); - assert.strictEqual(js2, undefined); + assert.strictEqual(js2, serverFS.useOS); assert.strictEqual(dts2, `export declare const y: string;\n`); }); @@ -7642,9 +7643,9 @@ describe("Program - emit", { concurrency }, () => { const js2 = fs.readFile?.("/dist/src/testing.js"); const dts2 = fs.readFile?.("/dist/src/testing.d.ts"); assert.strictEqual(js, `export const x = 1;\n`); - assert.strictEqual(dts, undefined); + assert.strictEqual(dts, serverFS.useOS); assert.strictEqual(js2, `export const y = 'typescript';\n`); - assert.strictEqual(dts2, undefined); + assert.strictEqual(dts2, serverFS.useOS); }); test("emitToString emits the whole program and respects emitOnly", () => { @@ -7658,7 +7659,7 @@ describe("Program - emit", { concurrency }, () => { "/dist/src/index.d.ts", "/dist/src/testing.d.ts", ]); - assert.equal(fs.readFile?.("/dist/src/index.js"), undefined); + assert.equal(fs.readFile("/dist/src/index.js"), serverFS.useOS); }); test("whole-program emit includes option-controlled maps", () => { @@ -7723,8 +7724,8 @@ describe("Program - emit", { concurrency }, () => { assert.equal(result.emitSkipped, true); assert.ok(result.diagnostics.some(d => d.code === 1109)); assert.deepEqual(result.emittedFiles, []); - assert.equal(fs.readFile?.("/dist/src/bad.js"), undefined); - assert.equal(fs.readFile?.("/dist/src/good.js"), undefined); + assert.equal(fs.readFile("/dist/src/bad.js"), serverFS.useOS); + assert.equal(fs.readFile("/dist/src/good.js"), serverFS.useOS); const stringResult = project.program.emitToString(); assert.equal(stringResult.emitSkipped, true); @@ -7752,7 +7753,7 @@ describe("Program - emit", { concurrency }, () => { emitSkipped: false, outputFiles: new Map(), }); - assert.equal(fs.readFile?.("/src/index.js"), undefined); + assert.equal(fs.readFile("/src/index.js"), serverFS.useOS); }); test("emit rejects unknown files and invalid emitOnly values", () => { diff --git a/packages/typescript/test/testUtils.ts b/packages/typescript/test/testUtils.ts index e08adf5fb1f3d..5894acb8eefee 100644 --- a/packages/typescript/test/testUtils.ts +++ b/packages/typescript/test/testUtils.ts @@ -1,6 +1,7 @@ -import type { - FileSystemCallbacks, - FileSystemEntries, +import { + type FileSystemCallbacks, + type FileSystemEntries, + serverFS, } from "../src/api/fs.ts"; import { getPathComponents } from "../src/api/path.ts"; @@ -22,10 +23,10 @@ type VNode = VDirectory | VFile; interface TestFileSystem extends FileSystemCallbacks { directoryExists(directoryName: string): boolean; fileExists(fileName: string): boolean; - getAccessibleEntries(directoryName: string): FileSystemEntries | undefined; - readFile(fileName: string): string | undefined; - realpath: "identity"; - stat: "infer"; + getAccessibleEntries(directoryName: string): FileSystemEntries | typeof serverFS.useOS; + readFile(fileName: string): string | typeof serverFS.useOS; + realpath: typeof serverFS.identity; + stat: typeof serverFS.fakeStat; writeFile(path: string, data: string): void; removeFile(path: string): void; } @@ -47,8 +48,8 @@ export function createVirtualFileSystem(files: Record): TestFile fileExists, getAccessibleEntries, readFile, - realpath: "identity", - stat: "infer", + realpath: serverFS.identity, + stat: serverFS.fakeStat, writeFile, removeFile, }; @@ -121,10 +122,10 @@ export function createVirtualFileSystem(files: Record): TestFile return fileName in content; } - function getAccessibleEntries(directoryName: string): FileSystemEntries | undefined { + function getAccessibleEntries(directoryName: string): FileSystemEntries | typeof serverFS.useOS { const node = getNodeFromPath(directoryName); if (!node || node.type !== "directory") { - return undefined; + return serverFS.useOS; } const fileEntries: string[] = []; const directories: string[] = []; @@ -139,7 +140,7 @@ export function createVirtualFileSystem(files: Record): TestFile return { files: fileEntries, directories }; } - function readFile(fileName: string): string | undefined { - return content[fileName]; + function readFile(fileName: string): string | typeof serverFS.useOS { + return content[fileName] ?? serverFS.useOS; } } diff --git a/tsc/internal/api/callbackfs.go b/tsc/internal/api/callbackfs.go index 598c877f6af6f..384714a22136b 100644 --- a/tsc/internal/api/callbackfs.go +++ b/tsc/internal/api/callbackfs.go @@ -24,7 +24,7 @@ type callbackFS struct { base vfs.FS enabledCallbacks map[string]bool realpathIdentity bool - statInfer bool + fakeStat bool caseSensitive *bool // conn and ctx are set after connection is established @@ -64,7 +64,7 @@ func isCallbackName(name string) bool { func newCallbackFS(base vfs.FS, callbacks []string, caseSensitive *bool) *callbackFS { enabled := make(map[string]bool, len(callbacks)) for _, cb := range callbacks { - if cb == "realpath:identity" || cb == "stat:infer" { + if cb == "realpath:identity" || cb == "stat:fakeStat" { continue } if !isCallbackName(cb) { @@ -76,7 +76,7 @@ func newCallbackFS(base vfs.FS, callbacks []string, caseSensitive *bool) *callba base: base, enabledCallbacks: enabled, realpathIdentity: slices.Contains(callbacks, "realpath:identity"), - statInfer: slices.Contains(callbacks, "stat:infer"), + fakeStat: slices.Contains(callbacks, "stat:fakeStat"), caseSensitive: caseSensitive, } } @@ -118,7 +118,7 @@ func (fs *callbackFS) UseCaseSensitiveFileNames() bool { // ReadFile implements vfs.FS. // // The readFile callback uses a wrapped response format to distinguish three states: -// - undefined (fall back to real FS): null or empty on wire +// - useOS: null or empty on wire // - null (not found, no fallback): {"content": null} // - string content: {"content": "..."} func (fs *callbackFS) ReadFile(path string) (contents string, ok bool) { @@ -277,7 +277,7 @@ func (fs *callbackFS) Stat(path string) vfs.FileInfo { return info } } - if fs.statInfer { + if fs.fakeStat { if fs.DirectoryExists(path) { return &callbackFileInfo{name: tspath.GetBaseFileName(path), mode: iofs.ModeDir | 0o555} } @@ -329,11 +329,13 @@ func (fs *callbackFS) WriteFile(path string, data string) error { Data string `json:"data"` }{Path: path, Data: data} - _, err := fs.call(callbackWriteFile, payload) + result, err := fs.call(callbackWriteFile, payload) if err != nil { return err } - return nil + if len(result) > 0 && string(result) != "null" { + return nil + } } return fs.base.WriteFile(path, data) diff --git a/tsc/internal/api/callbackfs_test.go b/tsc/internal/api/callbackfs_test.go index 5bd6b93613867..0e5e4f99bbe62 100644 --- a/tsc/internal/api/callbackfs_test.go +++ b/tsc/internal/api/callbackfs_test.go @@ -33,7 +33,7 @@ func TestCallbackFSDefaults(t *testing.T) { "/file.ts": "content", }, false) caseSensitive := true - fs := newCallbackFS(base, []string{"realpath:identity", "stat:infer"}, &caseSensitive) + fs := newCallbackFS(base, []string{"realpath:identity", "stat:fakeStat"}, &caseSensitive) if !fs.UseCaseSensitiveFileNames() { t.Fatal("expected configured case sensitivity") @@ -109,3 +109,27 @@ func TestNodeFileModeToGoFileMode(t *testing.T) { }) } } + +func TestCallbackFSWriteFilePassthrough(t *testing.T) { + t.Parallel() + + base := vfstest.FromMap(map[string]string{}, true) + fs := newCallbackFS(base, []string{"writeFile"}, nil) + conn := &callbackTestConn{responses: map[string]json.Value{callbackWriteFile: nil}} + fs.SetConnection(t.Context(), conn) + + if err := fs.WriteFile("/use-os.ts", "content"); err != nil { + t.Fatal(err) + } + if content, ok := base.ReadFile("/use-os.ts"); !ok || content != "content" { + t.Fatalf("base ReadFile() = %q, %v, want OS filesystem content", content, ok) + } + + conn.responses[callbackWriteFile] = []byte("true") + if err := fs.WriteFile("/handled.ts", "content"); err != nil { + t.Fatal(err) + } + if _, ok := base.ReadFile("/handled.ts"); ok { + t.Fatal("handled callback write unexpectedly reached base filesystem") + } +} From 665ed19c96d293577cf9133e29a25e4a8d4debe8 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Thu, 24 Sep 2026 16:17:59 -0700 Subject: [PATCH 3/8] Review --- packages/typescript/src/api/async/client.ts | 24 ++++++--- packages/typescript/src/api/fsCallbacks.ts | 56 ++++++++++++++++++++- packages/typescript/src/api/sync/client.ts | 10 +++- packages/typescript/test/async/api.test.ts | 37 ++++++++++++++ packages/typescript/test/sync/api.test.ts | 14 ++++++ tsc/internal/api/callbackfs.go | 2 +- tsc/internal/api/callbackfs_test.go | 11 +++- 7 files changed, 140 insertions(+), 14 deletions(-) diff --git a/packages/typescript/src/api/async/client.ts b/packages/typescript/src/api/async/client.ts index b6ed193479101..213f0336b44c1 100644 --- a/packages/typescript/src/api/async/client.ts +++ b/packages/typescript/src/api/async/client.ts @@ -16,6 +16,7 @@ import { import { configureFileSystemCallbacks, type FileSystemCallbackConfiguration, + validateFileSystemCallbackResult, } from "../fsCallbacks.ts"; import { type ClientOptions, @@ -56,14 +57,13 @@ export class Client { private closed = false; private connecting: Promise | undefined; private timing: TimingCollector | undefined; - private fsConfiguration: FileSystemCallbackConfiguration | undefined; private batchedRequests: { method: APIRequest["method"]; params: APIRequest["params"]; resolve: (value: unknown) => void; reject: (reason?: any) => void; }[] = []; private nextBatch: NodeJS.Immediate | "manual" | undefined; constructor(options: ClientOptions) { this.options = options; if (isSpawnOptions(options)) { - this.fsConfiguration = configureFileSystemCallbacks(options.fs); + configureFileSystemCallbacks(options.fs); if (options.collectTiming) { this.timing = new TimingCollector(); } @@ -92,9 +92,10 @@ export class Client { return new Promise((resolve, reject) => { const args = getAPIProcessArgs(options, true); + const fsConfiguration = configureFileSystemCallbacks(options.fs); - if (this.fsConfiguration!.arguments.length > 0) { - args.push(`--callbacks=${this.fsConfiguration!.arguments.join(",")}`); + if (fsConfiguration.arguments.length > 0) { + args.push(`--callbacks=${fsConfiguration.arguments.join(",")}`); } this.process = spawn(resolveExePath(options), args, { @@ -113,7 +114,7 @@ export class Client { const reader = new StreamMessageReader(this.process.stdout!); const writer = new StreamMessageWriter(this.process.stdin!); this.connection = createMessageConnection(reader, writer); - this.registerFSCallbacks(this.connection, options.fs); + this.registerFSCallbacks(this.connection, options.fs, fsConfiguration); this.connection.listen(); }); } @@ -137,16 +138,22 @@ export class Client { }); } - private registerFSCallbacks(connection: MessageConnection, fs: FileSystemCallbacks | undefined): void { + private registerFSCallbacks( + connection: MessageConnection, + fs: FileSystemCallbacks | undefined, + configuration: FileSystemCallbackConfiguration, + ): void { if (!fs) return; - for (const name of this.fsConfiguration!.callbackNames) { + for (const name of configuration.callbackNames) { if (name === "writeFile") { const callback = fs.writeFile; if (typeof callback !== "function") throw new Error("Invalid writeFile callback configuration"); const requestType = new RequestType<{ path: string; data: string; }, unknown, void>(name); connection.onRequest(requestType, (arg: { path: string; data: string; }) => { - return callback(arg.path, arg.data) === serverFS.useOS ? null : true; + const result = callback(arg.path, arg.data); + validateFileSystemCallbackResult(name, result); + return result === serverFS.useOS ? null : true; }); continue; @@ -157,6 +164,7 @@ export class Client { const requestType = new RequestType(name); connection.onRequest(requestType, (arg: unknown) => { const result = callback(arg as string); + validateFileSystemCallbackResult(name, result); if (result === serverFS.useOS) return null; if (name === "readFile") { return { content: result }; diff --git a/packages/typescript/src/api/fsCallbacks.ts b/packages/typescript/src/api/fsCallbacks.ts index fca229219e680..ffb7a09369825 100644 --- a/packages/typescript/src/api/fsCallbacks.ts +++ b/packages/typescript/src/api/fsCallbacks.ts @@ -33,8 +33,62 @@ export function configureFileSystemCallbacks(fs: FileSystemCallbacks | undefined args.push("stat:fakeStat"); continue; } - throw new TypeError(`Invalid filesystem callback '${name}': expected a function or a supported filesystem sentinel`); + throw new TypeError(`Invalid filesystem callback '${name}': expected a function or a supported serverFS sentinel`); } args.push(...callbackNames); return { callbackNames, arguments: args }; } + +export function validateFileSystemCallbackResult(name: typeof fsCallbackNames[number], result: unknown): void { + if (result === serverFS.useOS) { + return; + } + + let valid: boolean; + switch (name) { + case "directoryExists": + case "fileExists": + valid = typeof result === "boolean"; + break; + case "getAccessibleEntries": { + const entries = result as { files?: unknown; directories?: unknown; symlinks?: unknown; } | null; + valid = !!entries + && isStringArray(entries.files) + && isStringArray(entries.directories) + && (entries.symlinks === undefined || isStringArray(entries.symlinks)); + break; + } + case "readFile": + valid = typeof result === "string" || result === null; + break; + case "realpath": + valid = typeof result === "string"; + break; + case "stat": { + const stat = result as { mode?: unknown; size?: unknown; mtime?: unknown; } | null; + valid = stat === null + || !!stat + && typeof stat.mode === "number" + && Number.isSafeInteger(stat.mode) + && stat.mode >= 0 + && stat.mode <= 0xffff_ffff + && typeof stat.size === "number" + && Number.isSafeInteger(stat.size) + && stat.size >= 0 + && stat.mtime instanceof Date + && !Number.isNaN(stat.mtime.getTime()); + break; + } + case "writeFile": + valid = result === undefined; + break; + } + + if (!valid) { + throw new TypeError(`Invalid result from filesystem callback '${name}'`); + } +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every(element => typeof element === "string"); +} diff --git a/packages/typescript/src/api/sync/client.ts b/packages/typescript/src/api/sync/client.ts index 86abd721ee466..ecf4024b22772 100644 --- a/packages/typescript/src/api/sync/client.ts +++ b/packages/typescript/src/api/sync/client.ts @@ -1,5 +1,8 @@ import { serverFS } from "../fs.ts"; -import { configureFileSystemCallbacks } from "../fsCallbacks.ts"; +import { + configureFileSystemCallbacks, + validateFileSystemCallbackResult, +} from "../fsCallbacks.ts"; import { type ClientOptions, type ClientSocketOptions, @@ -61,7 +64,9 @@ export class Client { channel.registerCallback(name, (_, arg) => { const { path, data } = JSON.parse(arg); - return callback(path, data) === serverFS.useOS ? "" : "true"; + const result = callback(path, data); + validateFileSystemCallbackResult(name, result); + return result === serverFS.useOS ? "" : "true"; }); continue; @@ -71,6 +76,7 @@ export class Client { if (typeof callback !== "function") throw new Error(`Invalid ${name} callback configuration`); channel.registerCallback(name, (_, arg) => { const result = callback(JSON.parse(arg)); + validateFileSystemCallbackResult(name, result); if (result === serverFS.useOS) return ""; if (name === "readFile") { return JSON.stringify({ content: result }); diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index 94129d265a732..01bede1ba78ce 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -3857,6 +3857,43 @@ describe("readFile callback semantics", { concurrency }, () => { ); }); + test("invalid callback results do not fall through to the server OS", async () => { + const fs: FileSystemCallbacks = { + ...createVirtualFileSystem({ + "/tsconfig.json": "{}", + }), + readFile: (() => undefined) as unknown as FileSystemCallbacks["readFile"], + }; + await using api = new API({ cwd: "/", fs }); + await assert.rejects( // @sync: assert.throws( + () => api.readConfigFile("/tsconfig.json"), + /Invalid result from filesystem callback 'readFile'/, + ); + }); + + // @sync-skip-block-start + test("callback configuration and implementations are read together when connecting", async () => { + const fs: FileSystemCallbacks = { + directoryExists: serverFS.useOS, + fileExists: serverFS.useOS, + getAccessibleEntries: serverFS.useOS, + readFile: serverFS.useOS, + realpath: serverFS.identity, + stat: serverFS.fakeStat, + writeFile: serverFS.useOS, + }; + await using api = new API({ fs }); + let calls = 0; + fs.readFile = () => { + calls++; + return null; + }; + + await api.readConfigFile(fileURLToPath(new URL("../../package.json", import.meta.url).toString())); + assert.ok(calls > 0); + }); + // @sync-skip-block-end + test("readFile: string returns content, null blocks fallback, useOS falls through to the server OS", async () => { const virtualFiles: Record = { "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true } }), diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index c1391d0da9550..d9e6b614553c2 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -3725,6 +3725,20 @@ describe("readFile callback semantics", { concurrency }, () => { ); }); + test("invalid callback results do not fall through to the server OS", () => { + const fs: FileSystemCallbacks = { + ...createVirtualFileSystem({ + "/tsconfig.json": "{}", + }), + readFile: (() => undefined) as unknown as FileSystemCallbacks["readFile"], + }; + using api = new API({ cwd: "/", fs }); + assert.throws( + () => api.readConfigFile("/tsconfig.json"), + /Invalid result from filesystem callback 'readFile'/, + ); + }); + test("readFile: string returns content, null blocks fallback, useOS falls through to the server OS", () => { const virtualFiles: Record = { "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true } }), diff --git a/tsc/internal/api/callbackfs.go b/tsc/internal/api/callbackfs.go index 384714a22136b..55c36af0733db 100644 --- a/tsc/internal/api/callbackfs.go +++ b/tsc/internal/api/callbackfs.go @@ -192,7 +192,7 @@ func (fs *callbackFS) GetAccessibleEntries(path string) vfs.Entries { Files: rawEntries.Files, Directories: rawEntries.Directories, } - if len(rawEntries.Symlinks) > 0 { + if rawEntries.Symlinks != nil { entries.Symlinks = make(map[string]struct{}, len(rawEntries.Symlinks)) for _, name := range rawEntries.Symlinks { entries.Symlinks[name] = struct{}{} diff --git a/tsc/internal/api/callbackfs_test.go b/tsc/internal/api/callbackfs_test.go index 0e5e4f99bbe62..9813294d79ef1 100644 --- a/tsc/internal/api/callbackfs_test.go +++ b/tsc/internal/api/callbackfs_test.go @@ -57,12 +57,13 @@ func TestCallbackFSStatAndEntries(t *testing.T) { base := vfstest.FromMap(map[string]string{}, true) fs := newCallbackFS(base, []string{"stat", "getAccessibleEntries"}, nil) - fs.SetConnection(t.Context(), &callbackTestConn{ + conn := &callbackTestConn{ responses: map[string]json.Value{ callbackStat: []byte(`{"stat":{"mode":33060,"size":12,"mtime":"2024-01-02T03:04:05.000Z"}}`), callbackGetAccessibleEntries: []byte(`{"files":["link.ts"],"directories":["pkg"],"symlinks":["link.ts","pkg"]}`), }, - }) + } + fs.SetConnection(t.Context(), conn) info := fs.Stat("/link.ts") if info == nil || info.IsDir() || info.Size() != 12 { @@ -83,6 +84,12 @@ func TestCallbackFSStatAndEntries(t *testing.T) { if _, ok := entries.Symlinks["pkg"]; !ok { t.Fatal("expected directory symlink metadata") } + + conn.responses[callbackGetAccessibleEntries] = []byte(`{"files":[],"directories":["src"],"symlinks":[]}`) + entries = fs.GetAccessibleEntries("/") + if entries.Symlinks == nil { + t.Fatal("explicitly empty symlink metadata was treated as unavailable") + } } func TestNodeFileModeToGoFileMode(t *testing.T) { From 53888a3118cf608b915482d33e11050d0f45af77 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Fri, 25 Sep 2026 10:19:55 -0700 Subject: [PATCH 4/8] Use wrapped wire format for everything --- packages/typescript/src/api/async/client.ts | 22 +-- packages/typescript/src/api/fs.ts | 23 ++- packages/typescript/src/api/fsCallbacks.ts | 56 +++++-- packages/typescript/src/api/sync/client.ts | 18 +- packages/typescript/test/async/api.test.ts | 33 +++- packages/typescript/test/sync/api.test.ts | 31 +++- tsc/internal/api/callbackfs.go | 174 ++++++++++++++------ tsc/internal/api/callbackfs_test.go | 71 +++++++- 8 files changed, 299 insertions(+), 129 deletions(-) diff --git a/packages/typescript/src/api/async/client.ts b/packages/typescript/src/api/async/client.ts index 213f0336b44c1..55217182385c2 100644 --- a/packages/typescript/src/api/async/client.ts +++ b/packages/typescript/src/api/async/client.ts @@ -9,14 +9,11 @@ import { } from "#vscode-jsonrpc/node"; import type { ChildProcess } from "node:child_process"; import type { Socket } from "node:net"; -import { - type FileSystemCallbacks, - serverFS, -} from "../fs.ts"; +import type { FileSystemCallbacks } from "../fs.ts"; import { configureFileSystemCallbacks, + encodeFileSystemCallbackResult, type FileSystemCallbackConfiguration, - validateFileSystemCallbackResult, } from "../fsCallbacks.ts"; import { type ClientOptions, @@ -151,9 +148,7 @@ export class Client { const requestType = new RequestType<{ path: string; data: string; }, unknown, void>(name); connection.onRequest(requestType, (arg: { path: string; data: string; }) => { - const result = callback(arg.path, arg.data); - validateFileSystemCallbackResult(name, result); - return result === serverFS.useOS ? null : true; + return encodeFileSystemCallbackResult(name, callback(arg.path, arg.data)); }); continue; @@ -163,16 +158,7 @@ export class Client { if (typeof callback !== "function") throw new Error(`Invalid ${name} callback configuration`); const requestType = new RequestType(name); connection.onRequest(requestType, (arg: unknown) => { - const result = callback(arg as string); - validateFileSystemCallbackResult(name, result); - if (result === serverFS.useOS) return null; - if (name === "readFile") { - return { content: result }; - } - if (name === "stat") { - return { stat: result }; - } - return result; + return encodeFileSystemCallbackResult(name, callback(arg as string)); }); } } diff --git a/packages/typescript/src/api/fs.ts b/packages/typescript/src/api/fs.ts index 16d225eebeb44..8f65dc026dc92 100644 --- a/packages/typescript/src/api/fs.ts +++ b/packages/typescript/src/api/fs.ts @@ -30,6 +30,7 @@ export interface FileSystemStat { const useOS: unique symbol = Symbol("useOS"); const identity: unique symbol = Symbol("identity"); const fakeStat: unique symbol = Symbol("fakeStat"); +const noop: unique symbol = Symbol("noop"); export const serverFS: { /** Delegate the configured operation, or the current callback invocation, to the server's operating-system filesystem. */ @@ -38,10 +39,13 @@ export const serverFS: { readonly identity: typeof identity; /** Synthesize stat information from `directoryExists` and `fileExists`. Valid only for `stat`. */ readonly fakeStat: typeof fakeStat; + /** Ignore writes without invoking a callback or writing to the server's operating-system filesystem. Valid only for `writeFile`. */ + readonly noop: typeof noop; } = { useOS: useOS, identity: identity, fakeStat: fakeStat, + noop: noop, }; export interface FileSystemCallbacks { @@ -51,13 +55,22 @@ export interface FileSystemCallbacks { /** * Read a file's content. * - Return the file content as a `string` (including `""` for empty files). - * - Return `null` to indicate the file does not exist (without falling back to the real FS). + * - Return `undefined` to indicate the file does not exist. * - Return {@link serverFS.useOS} to fall back to the server's operating-system filesystem. */ - readFile: ((fileName: string) => string | null | typeof serverFS.useOS) | typeof serverFS.useOS; - realpath: ((path: string) => string | typeof serverFS.useOS) | typeof serverFS.useOS | typeof serverFS.identity; - stat: ((path: string) => FileSystemStat | null | typeof serverFS.useOS) | typeof serverFS.useOS | typeof serverFS.fakeStat; - writeFile: ((path: string, content: string) => void | typeof serverFS.useOS) | typeof serverFS.useOS; + readFile: ((fileName: string) => string | undefined | typeof serverFS.useOS) | typeof serverFS.useOS; + realpath: + | ((path: string) => string | typeof serverFS.useOS | typeof serverFS.identity) + | typeof serverFS.useOS + | typeof serverFS.identity; + stat: + | ((path: string) => FileSystemStat | undefined | typeof serverFS.useOS | typeof serverFS.fakeStat) + | typeof serverFS.useOS + | typeof serverFS.fakeStat; + writeFile: + | ((path: string, content: string) => void | typeof serverFS.useOS | typeof serverFS.noop) + | typeof serverFS.useOS + | typeof serverFS.noop; } /** The callback names supported by the Go server for virtual FS delegation. */ diff --git a/packages/typescript/src/api/fsCallbacks.ts b/packages/typescript/src/api/fsCallbacks.ts index ffb7a09369825..27fdac903ca00 100644 --- a/packages/typescript/src/api/fsCallbacks.ts +++ b/packages/typescript/src/api/fsCallbacks.ts @@ -9,6 +9,14 @@ export interface FileSystemCallbackConfiguration { arguments: string[]; } +export type FileSystemCallbackResponse = + | { kind: "value"; value?: unknown; } + | { kind: "missing"; } + | { kind: "useOS"; } + | { kind: "identity"; } + | { kind: "fakeStat"; } + | { kind: "noop"; }; + export function configureFileSystemCallbacks(fs: FileSystemCallbacks | undefined): FileSystemCallbackConfiguration { if (!fs) { return { callbackNames: [], arguments: [] }; @@ -33,15 +41,31 @@ export function configureFileSystemCallbacks(fs: FileSystemCallbacks | undefined args.push("stat:fakeStat"); continue; } + if (name === "writeFile" && value === serverFS.noop) { + args.push("writeFile:noop"); + continue; + } throw new TypeError(`Invalid filesystem callback '${name}': expected a function or a supported serverFS sentinel`); } args.push(...callbackNames); return { callbackNames, arguments: args }; } -export function validateFileSystemCallbackResult(name: typeof fsCallbackNames[number], result: unknown): void { +export function encodeFileSystemCallbackResult( + name: typeof fsCallbackNames[number], + result: unknown, +): FileSystemCallbackResponse { if (result === serverFS.useOS) { - return; + return { kind: "useOS" }; + } + if (name === "realpath" && result === serverFS.identity) { + return { kind: "identity" }; + } + if (name === "stat" && result === serverFS.fakeStat) { + return { kind: "fakeStat" }; + } + if (name === "writeFile" && result === serverFS.noop) { + return { kind: "noop" }; } let valid: boolean; @@ -59,24 +83,25 @@ export function validateFileSystemCallbackResult(name: typeof fsCallbackNames[nu break; } case "readFile": - valid = typeof result === "string" || result === null; + if (result === undefined) return { kind: "missing" }; + valid = typeof result === "string"; break; case "realpath": valid = typeof result === "string"; break; case "stat": { - const stat = result as { mode?: unknown; size?: unknown; mtime?: unknown; } | null; - valid = stat === null - || !!stat - && typeof stat.mode === "number" - && Number.isSafeInteger(stat.mode) - && stat.mode >= 0 - && stat.mode <= 0xffff_ffff - && typeof stat.size === "number" - && Number.isSafeInteger(stat.size) - && stat.size >= 0 - && stat.mtime instanceof Date - && !Number.isNaN(stat.mtime.getTime()); + if (result === undefined) return { kind: "missing" }; + const stat = result as { mode?: unknown; size?: unknown; mtime?: unknown; } | undefined; + valid = !!stat + && typeof stat.mode === "number" + && Number.isSafeInteger(stat.mode) + && stat.mode >= 0 + && stat.mode <= 0xffff_ffff + && typeof stat.size === "number" + && Number.isSafeInteger(stat.size) + && stat.size >= 0 + && stat.mtime instanceof Date + && !Number.isNaN(stat.mtime.getTime()); break; } case "writeFile": @@ -87,6 +112,7 @@ export function validateFileSystemCallbackResult(name: typeof fsCallbackNames[nu if (!valid) { throw new TypeError(`Invalid result from filesystem callback '${name}'`); } + return name === "writeFile" ? { kind: "value" } : { kind: "value", value: result }; } function isStringArray(value: unknown): value is string[] { diff --git a/packages/typescript/src/api/sync/client.ts b/packages/typescript/src/api/sync/client.ts index ecf4024b22772..42255e9513a1b 100644 --- a/packages/typescript/src/api/sync/client.ts +++ b/packages/typescript/src/api/sync/client.ts @@ -1,7 +1,6 @@ -import { serverFS } from "../fs.ts"; import { configureFileSystemCallbacks, - validateFileSystemCallbackResult, + encodeFileSystemCallbackResult, } from "../fsCallbacks.ts"; import { type ClientOptions, @@ -64,9 +63,7 @@ export class Client { channel.registerCallback(name, (_, arg) => { const { path, data } = JSON.parse(arg); - const result = callback(path, data); - validateFileSystemCallbackResult(name, result); - return result === serverFS.useOS ? "" : "true"; + return JSON.stringify(encodeFileSystemCallbackResult(name, callback(path, data))); }); continue; @@ -75,16 +72,7 @@ export class Client { const callback = options.fs[name]; if (typeof callback !== "function") throw new Error(`Invalid ${name} callback configuration`); channel.registerCallback(name, (_, arg) => { - const result = callback(JSON.parse(arg)); - validateFileSystemCallbackResult(name, result); - if (result === serverFS.useOS) return ""; - if (name === "readFile") { - return JSON.stringify({ content: result }); - } - if (name === "stat") { - return JSON.stringify({ stat: result }); - } - return JSON.stringify(result); + return JSON.stringify(encodeFileSystemCallbackResult(name, callback(JSON.parse(arg)))); }); } } diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index 01bede1ba78ce..5966361a2520d 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -179,6 +179,15 @@ describe("API", { concurrency }, () => { writeFile: serverFS.useOS, }; void new API({ fs: callbacks }); + void new API({ fs: { ...callbacks, writeFile: serverFS.noop } }); + void new API({ + fs: { + ...callbacks, + realpath: () => serverFS.identity, + stat: () => serverFS.fakeStat, + writeFile: () => serverFS.noop, + }, + }); // @ts-expect-error Filesystem callback configurations must specify every operation. void new API({ fs: { readFile: serverFS.useOS } }); void new API({ @@ -195,6 +204,13 @@ describe("API", { concurrency }, () => { fileExists: serverFS.fakeStat, }, }); + void new API({ + fs: { + ...callbacks, + // @ts-expect-error Noop is only a valid writeFile implementation. + readFile: serverFS.noop, + }, + }); } }); @@ -3862,7 +3878,7 @@ describe("readFile callback semantics", { concurrency }, () => { ...createVirtualFileSystem({ "/tsconfig.json": "{}", }), - readFile: (() => undefined) as unknown as FileSystemCallbacks["readFile"], + readFile: (() => null) as unknown as FileSystemCallbacks["readFile"], }; await using api = new API({ cwd: "/", fs }); await assert.rejects( // @sync: assert.throws( @@ -3886,7 +3902,7 @@ describe("readFile callback semantics", { concurrency }, () => { let calls = 0; fs.readFile = () => { calls++; - return null; + return undefined; }; await api.readConfigFile(fileURLToPath(new URL("../../package.json", import.meta.url).toString())); @@ -3894,7 +3910,7 @@ describe("readFile callback semantics", { concurrency }, () => { }); // @sync-skip-block-end - test("readFile: string returns content, null blocks fallback, useOS falls through to the server OS", async () => { + test("readFile: string returns content, undefined blocks fallback, useOS falls through to the server OS", async () => { const virtualFiles: Record = { "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true } }), "/src/index.ts": `export const x: number = 1;`, @@ -3906,8 +3922,7 @@ describe("readFile callback semantics", { concurrency }, () => { ...vfs, readFile: (fileName: string) => { if (fileName === blockedPath) { - // null = file not found, don't fall back to real FS - return null; + return undefined; } // Try the VFS first; if it has the file, return its content (string). // Otherwise use the OS filesystem on the server. @@ -3929,7 +3944,7 @@ describe("readFile callback semantics", { concurrency }, () => { assert.equal(sf.text, virtualFiles["/src/index.ts"]); // 2. useOS fallback: lib files from the server OS should be present. - // If readFile returned null for unknowns, lib files would be missing + // If readFile returned undefined rather than useOS for unknowns, lib files would be missing // and `number` would not resolve — this was the original async bug. // Verify by checking that `number` resolves to a proper type (not error). const pos = virtualFiles["/src/index.ts"].indexOf("x:"); @@ -3937,9 +3952,9 @@ describe("readFile callback semantics", { concurrency }, () => { assert.ok(type, "Type should resolve"); assert.ok(type.flags & TypeFlags.Number, `Expected number type, got flags ${type.flags}`); - // 3. null blocks fallback: blocked file should not be found + // 3. undefined blocks fallback: blocked file should not be found const blockedSf = await project.program.getSourceFile(blockedPath); - assert.equal(blockedSf, undefined, "Blocked file should not be found (null prevents fallback)"); + assert.equal(blockedSf, undefined, "Blocked file should not be found"); }); test("configured case sensitivity is used by the server and client", async () => { @@ -4065,7 +4080,7 @@ describe("updateSnapshot file systems", { concurrency }, () => { callbackCalls.push(`stat:${path}`); if (host.directoryExists(path)) return { mode: 0o040555, size: 0, mtime: new Date(0) }; if (host.fileExists(path)) return { mode: 0o100444, size: 0, mtime: new Date(0) }; - return null; + return undefined; }, writeFile: (path, content) => { callbackCalls.push(`writeFile:${path}`); diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index d9e6b614553c2..25315b0fef7df 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -187,6 +187,15 @@ describe("API", { concurrency }, () => { writeFile: serverFS.useOS, }; void new API({ fs: callbacks }); + void new API({ fs: { ...callbacks, writeFile: serverFS.noop } }); + void new API({ + fs: { + ...callbacks, + realpath: () => serverFS.identity, + stat: () => serverFS.fakeStat, + writeFile: () => serverFS.noop, + }, + }); // @ts-expect-error Filesystem callback configurations must specify every operation. void new API({ fs: { readFile: serverFS.useOS } }); void new API({ @@ -203,6 +212,13 @@ describe("API", { concurrency }, () => { fileExists: serverFS.fakeStat, }, }); + void new API({ + fs: { + ...callbacks, + // @ts-expect-error Noop is only a valid writeFile implementation. + readFile: serverFS.noop, + }, + }); } }); @@ -3730,7 +3746,7 @@ describe("readFile callback semantics", { concurrency }, () => { ...createVirtualFileSystem({ "/tsconfig.json": "{}", }), - readFile: (() => undefined) as unknown as FileSystemCallbacks["readFile"], + readFile: (() => null) as unknown as FileSystemCallbacks["readFile"], }; using api = new API({ cwd: "/", fs }); assert.throws( @@ -3739,7 +3755,7 @@ describe("readFile callback semantics", { concurrency }, () => { ); }); - test("readFile: string returns content, null blocks fallback, useOS falls through to the server OS", () => { + test("readFile: string returns content, undefined blocks fallback, useOS falls through to the server OS", () => { const virtualFiles: Record = { "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true } }), "/src/index.ts": `export const x: number = 1;`, @@ -3751,8 +3767,7 @@ describe("readFile callback semantics", { concurrency }, () => { ...vfs, readFile: (fileName: string) => { if (fileName === blockedPath) { - // null = file not found, don't fall back to real FS - return null; + return undefined; } // Try the VFS first; if it has the file, return its content (string). // Otherwise use the OS filesystem on the server. @@ -3774,7 +3789,7 @@ describe("readFile callback semantics", { concurrency }, () => { assert.equal(sf.text, virtualFiles["/src/index.ts"]); // 2. useOS fallback: lib files from the server OS should be present. - // If readFile returned null for unknowns, lib files would be missing + // If readFile returned undefined rather than useOS for unknowns, lib files would be missing // and `number` would not resolve — this was the original async bug. // Verify by checking that `number` resolves to a proper type (not error). const pos = virtualFiles["/src/index.ts"].indexOf("x:"); @@ -3782,9 +3797,9 @@ describe("readFile callback semantics", { concurrency }, () => { assert.ok(type, "Type should resolve"); assert.ok(type.flags & TypeFlags.Number, `Expected number type, got flags ${type.flags}`); - // 3. null blocks fallback: blocked file should not be found + // 3. undefined blocks fallback: blocked file should not be found const blockedSf = project.program.getSourceFile(blockedPath); - assert.equal(blockedSf, undefined, "Blocked file should not be found (null prevents fallback)"); + assert.equal(blockedSf, undefined, "Blocked file should not be found"); }); test("configured case sensitivity is used by the server and client", () => { @@ -3910,7 +3925,7 @@ describe("updateSnapshot file systems", { concurrency }, () => { callbackCalls.push(`stat:${path}`); if (host.directoryExists(path)) return { mode: 0o040555, size: 0, mtime: new Date(0) }; if (host.fileExists(path)) return { mode: 0o100444, size: 0, mtime: new Date(0) }; - return null; + return undefined; }, writeFile: (path, content) => { callbackCalls.push(`writeFile:${path}`); diff --git a/tsc/internal/api/callbackfs.go b/tsc/internal/api/callbackfs.go index 55c36af0733db..4d882cde7fd21 100644 --- a/tsc/internal/api/callbackfs.go +++ b/tsc/internal/api/callbackfs.go @@ -25,6 +25,7 @@ type callbackFS struct { enabledCallbacks map[string]bool realpathIdentity bool fakeStat bool + writeFileNoop bool caseSensitive *bool // conn and ctx are set after connection is established @@ -64,7 +65,7 @@ func isCallbackName(name string) bool { func newCallbackFS(base vfs.FS, callbacks []string, caseSensitive *bool) *callbackFS { enabled := make(map[string]bool, len(callbacks)) for _, cb := range callbacks { - if cb == "realpath:identity" || cb == "stat:fakeStat" { + if cb == "realpath:identity" || cb == "stat:fakeStat" || cb == "writeFile:noop" { continue } if !isCallbackName(cb) { @@ -77,6 +78,7 @@ func newCallbackFS(base vfs.FS, callbacks []string, caseSensitive *bool) *callba enabledCallbacks: enabled, realpathIdentity: slices.Contains(callbacks, "realpath:identity"), fakeStat: slices.Contains(callbacks, "stat:fakeStat"), + writeFileNoop: slices.Contains(callbacks, "writeFile:noop"), caseSensitive: caseSensitive, } } @@ -107,6 +109,26 @@ func (fs *callbackFS) call(name string, arg any) ([]byte, error) { return result, nil } +type callbackResponse struct { + Kind string `json:"kind"` + Value json.Value `json:"value"` +} + +func decodeCallbackResponse(result []byte) callbackResponse { + var response callbackResponse + if err := json.Unmarshal(result, &response); err != nil { + panic(err) + } + if response.Kind == "" { + panic("filesystem callback response is missing a kind") + } + return response +} + +func invalidCallbackResponse(name string, response callbackResponse) { + panic(fmt.Sprintf("invalid %s callback response kind: %s", name, response.Kind)) +} + // UseCaseSensitiveFileNames implements vfs.FS. func (fs *callbackFS) UseCaseSensitiveFileNames() bool { if fs.caseSensitive != nil { @@ -116,28 +138,26 @@ func (fs *callbackFS) UseCaseSensitiveFileNames() bool { } // ReadFile implements vfs.FS. -// -// The readFile callback uses a wrapped response format to distinguish three states: -// - useOS: null or empty on wire -// - null (not found, no fallback): {"content": null} -// - string content: {"content": "..."} func (fs *callbackFS) ReadFile(path string) (contents string, ok bool) { if fs.isEnabled(callbackReadFile) { result, err := fs.call(callbackReadFile, path) if err != nil { panic(err) } - if len(result) > 0 && string(result) != "null" { - var wrapper struct { - Content *string `json:"content"` - } - if unmarshalErr := json.Unmarshal(result, &wrapper); unmarshalErr != nil { - panic(unmarshalErr) - } - if wrapper.Content == nil { - return "", false + response := decodeCallbackResponse(result) + switch response.Kind { + case "value": + var content string + if err := json.Unmarshal(response.Value, &content); err != nil { + panic(err) } - return *wrapper.Content, true + return content, true + case "missing": + return "", false + case "useOS": + return fs.base.ReadFile(path) + default: + invalidCallbackResponse(callbackReadFile, response) } } return fs.base.ReadFile(path) @@ -150,8 +170,18 @@ func (fs *callbackFS) FileExists(path string) bool { if err != nil { panic(err) } - if len(result) > 0 && string(result) != "null" { - return string(result) == "true" + response := decodeCallbackResponse(result) + switch response.Kind { + case "value": + var exists bool + if err := json.Unmarshal(response.Value, &exists); err != nil { + panic(err) + } + return exists + case "useOS": + return fs.base.FileExists(path) + default: + invalidCallbackResponse(callbackFileExists, response) } } return fs.base.FileExists(path) @@ -164,8 +194,18 @@ func (fs *callbackFS) DirectoryExists(path string) bool { if err != nil { panic(err) } - if len(result) > 0 && string(result) != "null" { - return string(result) == "true" + response := decodeCallbackResponse(result) + switch response.Kind { + case "value": + var exists bool + if err := json.Unmarshal(response.Value, &exists); err != nil { + panic(err) + } + return exists + case "useOS": + return fs.base.DirectoryExists(path) + default: + invalidCallbackResponse(callbackDirectoryExists, response) } } return fs.base.DirectoryExists(path) @@ -178,28 +218,32 @@ func (fs *callbackFS) GetAccessibleEntries(path string) vfs.Entries { if err != nil { panic(err) } - if len(result) > 0 { + response := decodeCallbackResponse(result) + switch response.Kind { + case "value": var rawEntries *struct { Files []string `json:"files"` Directories []string `json:"directories"` Symlinks []string `json:"symlinks"` } - if err := json.Unmarshal(result, &rawEntries); err != nil { + if err := json.Unmarshal(response.Value, &rawEntries); err != nil { panic(err) } - if rawEntries != nil { - entries := vfs.Entries{ - Files: rawEntries.Files, - Directories: rawEntries.Directories, - } - if rawEntries.Symlinks != nil { - entries.Symlinks = make(map[string]struct{}, len(rawEntries.Symlinks)) - for _, name := range rawEntries.Symlinks { - entries.Symlinks[name] = struct{}{} - } + entries := vfs.Entries{ + Files: rawEntries.Files, + Directories: rawEntries.Directories, + } + if rawEntries.Symlinks != nil { + entries.Symlinks = make(map[string]struct{}, len(rawEntries.Symlinks)) + for _, name := range rawEntries.Symlinks { + entries.Symlinks[name] = struct{}{} } - return entries } + return entries + case "useOS": + return fs.base.GetAccessibleEntries(path) + default: + invalidCallbackResponse(callbackGetAccessibleEntries, response) } } return fs.base.GetAccessibleEntries(path) @@ -212,12 +256,20 @@ func (fs *callbackFS) Realpath(path string) string { if err != nil { panic(err) } - if len(result) > 0 && string(result) != "null" { + response := decodeCallbackResponse(result) + switch response.Kind { + case "value": var realpath string - if err := json.Unmarshal(result, &realpath); err != nil { + if err := json.Unmarshal(response.Value, &realpath); err != nil { panic(err) } return realpath + case "identity": + return path + case "useOS": + return fs.base.Realpath(path) + default: + invalidCallbackResponse(callbackRealpath, response) } } if fs.realpathIdentity { @@ -247,22 +299,15 @@ func (fs *callbackFS) Stat(path string) vfs.FileInfo { if err != nil { panic(err) } - if len(result) > 0 && string(result) != "null" { - var wrapper struct { - Stat json.Value `json:"stat"` - } - if unmarshalErr := json.Unmarshal(result, &wrapper); unmarshalErr != nil { - panic(unmarshalErr) - } - if string(wrapper.Stat) == "null" { - return nil - } + response := decodeCallbackResponse(result) + switch response.Kind { + case "value": var stat struct { Mode uint32 `json:"mode"` Size int64 `json:"size"` MTime string `json:"mtime"` } - if unmarshalErr := json.Unmarshal(wrapper.Stat, &stat); unmarshalErr != nil { + if unmarshalErr := json.Unmarshal(response.Value, &stat); unmarshalErr != nil { panic(unmarshalErr) } info := &callbackFileInfo{ @@ -275,20 +320,32 @@ func (fs *callbackFS) Stat(path string) vfs.FileInfo { panic(err) } return info + case "missing": + return nil + case "fakeStat": + return fs.fakeStatForPath(path) + case "useOS": + return fs.base.Stat(path) + default: + invalidCallbackResponse(callbackStat, response) } } if fs.fakeStat { - if fs.DirectoryExists(path) { - return &callbackFileInfo{name: tspath.GetBaseFileName(path), mode: iofs.ModeDir | 0o555} - } - if fs.FileExists(path) { - return &callbackFileInfo{name: tspath.GetBaseFileName(path), mode: 0o444} - } - return nil + return fs.fakeStatForPath(path) } return fs.base.Stat(path) } +func (fs *callbackFS) fakeStatForPath(path string) vfs.FileInfo { + if fs.DirectoryExists(path) { + return &callbackFileInfo{name: tspath.GetBaseFileName(path), mode: iofs.ModeDir | 0o555} + } + if fs.FileExists(path) { + return &callbackFileInfo{name: tspath.GetBaseFileName(path), mode: 0o444} + } + return nil +} + func nodeFileModeToGoFileMode(mode uint32) iofs.FileMode { result := iofs.FileMode(mode & 0o777) if mode&0o4000 != 0 { @@ -333,10 +390,19 @@ func (fs *callbackFS) WriteFile(path string, data string) error { if err != nil { return err } - if len(result) > 0 && string(result) != "null" { + response := decodeCallbackResponse(result) + switch response.Kind { + case "value", "noop": return nil + case "useOS": + return fs.base.WriteFile(path, data) + default: + invalidCallbackResponse(callbackWriteFile, response) } } + if fs.writeFileNoop { + return nil + } return fs.base.WriteFile(path, data) } diff --git a/tsc/internal/api/callbackfs_test.go b/tsc/internal/api/callbackfs_test.go index 9813294d79ef1..b5cccc4e4ac20 100644 --- a/tsc/internal/api/callbackfs_test.go +++ b/tsc/internal/api/callbackfs_test.go @@ -59,8 +59,8 @@ func TestCallbackFSStatAndEntries(t *testing.T) { fs := newCallbackFS(base, []string{"stat", "getAccessibleEntries"}, nil) conn := &callbackTestConn{ responses: map[string]json.Value{ - callbackStat: []byte(`{"stat":{"mode":33060,"size":12,"mtime":"2024-01-02T03:04:05.000Z"}}`), - callbackGetAccessibleEntries: []byte(`{"files":["link.ts"],"directories":["pkg"],"symlinks":["link.ts","pkg"]}`), + callbackStat: []byte(`{"kind":"value","value":{"mode":33060,"size":12,"mtime":"2024-01-02T03:04:05.000Z"}}`), + callbackGetAccessibleEntries: []byte(`{"kind":"value","value":{"files":["link.ts"],"directories":["pkg"],"symlinks":["link.ts","pkg"]}}`), }, } fs.SetConnection(t.Context(), conn) @@ -76,6 +76,10 @@ func TestCallbackFSStatAndEntries(t *testing.T) { if !info.ModTime().Equal(wantTime) { t.Fatalf("ModTime() = %v, want %v", info.ModTime(), wantTime) } + conn.responses[callbackStat] = []byte(`{"kind":"missing"}`) + if info := fs.Stat("/missing.ts"); info != nil { + t.Fatalf("Stat(missing) = %#v, want nil", info) + } entries := fs.GetAccessibleEntries("/") if _, ok := entries.Symlinks["link.ts"]; !ok { @@ -85,7 +89,7 @@ func TestCallbackFSStatAndEntries(t *testing.T) { t.Fatal("expected directory symlink metadata") } - conn.responses[callbackGetAccessibleEntries] = []byte(`{"files":[],"directories":["src"],"symlinks":[]}`) + conn.responses[callbackGetAccessibleEntries] = []byte(`{"kind":"value","value":{"files":[],"directories":["src"],"symlinks":[]}}`) entries = fs.GetAccessibleEntries("/") if entries.Symlinks == nil { t.Fatal("explicitly empty symlink metadata was treated as unavailable") @@ -122,7 +126,7 @@ func TestCallbackFSWriteFilePassthrough(t *testing.T) { base := vfstest.FromMap(map[string]string{}, true) fs := newCallbackFS(base, []string{"writeFile"}, nil) - conn := &callbackTestConn{responses: map[string]json.Value{callbackWriteFile: nil}} + conn := &callbackTestConn{responses: map[string]json.Value{callbackWriteFile: []byte(`{"kind":"useOS"}`)}} fs.SetConnection(t.Context(), conn) if err := fs.WriteFile("/use-os.ts", "content"); err != nil { @@ -132,11 +136,68 @@ func TestCallbackFSWriteFilePassthrough(t *testing.T) { t.Fatalf("base ReadFile() = %q, %v, want OS filesystem content", content, ok) } - conn.responses[callbackWriteFile] = []byte("true") + conn.responses[callbackWriteFile] = []byte(`{"kind":"value"}`) if err := fs.WriteFile("/handled.ts", "content"); err != nil { t.Fatal(err) } if _, ok := base.ReadFile("/handled.ts"); ok { t.Fatal("handled callback write unexpectedly reached base filesystem") } + + conn.responses[callbackWriteFile] = []byte(`{"kind":"noop"}`) + if err := fs.WriteFile("/noop.ts", "content"); err != nil { + t.Fatal(err) + } + if _, ok := base.ReadFile("/noop.ts"); ok { + t.Fatal("noop callback write unexpectedly reached base filesystem") + } +} + +func TestCallbackFSWriteFileNoop(t *testing.T) { + t.Parallel() + + base := vfstest.FromMap(map[string]string{}, true) + fs := newCallbackFS(base, []string{"writeFile:noop"}, nil) + + if err := fs.WriteFile("/ignored.ts", "content"); err != nil { + t.Fatal(err) + } + if _, ok := base.ReadFile("/ignored.ts"); ok { + t.Fatal("noop write unexpectedly reached base filesystem") + } +} + +func TestCallbackFSPerCallFakeStat(t *testing.T) { + t.Parallel() + + base := vfstest.FromMap(map[string]string{}, true) + fs := newCallbackFS(base, []string{"stat", "directoryExists", "fileExists"}, nil) + fs.SetConnection(t.Context(), &callbackTestConn{ + responses: map[string]json.Value{ + callbackStat: []byte(`{"kind":"fakeStat"}`), + callbackDirectoryExists: []byte(`{"kind":"value","value":false}`), + callbackFileExists: []byte(`{"kind":"value","value":true}`), + }, + }) + + info := fs.Stat("/virtual.ts") + if info == nil || info.IsDir() { + t.Fatalf("Stat() = %#v, want fake file stat", info) + } +} + +func TestCallbackFSPerCallIdentityRealpath(t *testing.T) { + t.Parallel() + + base := vfstest.FromMap(map[string]string{}, true) + fs := newCallbackFS(base, []string{"realpath"}, nil) + fs.SetConnection(t.Context(), &callbackTestConn{ + responses: map[string]json.Value{ + callbackRealpath: []byte(`{"kind":"identity"}`), + }, + }) + + if got := fs.Realpath("/virtual.ts"); got != "/virtual.ts" { + t.Fatalf("Realpath() = %q, want identity", got) + } } From 67ef65970c590fd84746f88537decbac2e0f5bab Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Fri, 25 Sep 2026 10:28:45 -0700 Subject: [PATCH 5/8] Centralize callback/serverFS function support matrix --- packages/typescript/src/api/fs.ts | 3 - packages/typescript/src/api/fsCallbacks.ts | 90 +++++++++++++--------- 2 files changed, 55 insertions(+), 38 deletions(-) diff --git a/packages/typescript/src/api/fs.ts b/packages/typescript/src/api/fs.ts index 8f65dc026dc92..fd2db38779528 100644 --- a/packages/typescript/src/api/fs.ts +++ b/packages/typescript/src/api/fs.ts @@ -73,9 +73,6 @@ export interface FileSystemCallbacks { | typeof serverFS.noop; } -/** The callback names supported by the Go server for virtual FS delegation. */ -export const fsCallbackNames = ["readFile", "fileExists", "directoryExists", "getAccessibleEntries", "realpath", "stat", "writeFile"] as const; - export interface CreateFileSystemOptions { /** Complete directory listings. Full filesystems derive these from `files` when omitted. */ directories?: Record | undefined; diff --git a/packages/typescript/src/api/fsCallbacks.ts b/packages/typescript/src/api/fsCallbacks.ts index 27fdac903ca00..63d798757615c 100644 --- a/packages/typescript/src/api/fsCallbacks.ts +++ b/packages/typescript/src/api/fsCallbacks.ts @@ -1,28 +1,54 @@ import { type FileSystemCallbacks, - fsCallbackNames, serverFS, } from "./fs.ts"; -export interface FileSystemCallbackConfiguration { - callbackNames: (typeof fsCallbackNames[number])[]; - arguments: string[]; -} +type ServerFSSentinelName = keyof typeof serverFS; +type ServerFSSentinel = typeof serverFS[ServerFSSentinelName]; +type ServerFSSentinelResponse = { + [K in ServerFSSentinelName]: { kind: K; }; +}[ServerFSSentinelName]; export type FileSystemCallbackResponse = | { kind: "value"; value?: unknown; } | { kind: "missing"; } - | { kind: "useOS"; } - | { kind: "identity"; } - | { kind: "fakeStat"; } - | { kind: "noop"; }; + | ServerFSSentinelResponse; + +interface FileSystemCallbackDefinition { + serverFS: readonly ServerFSSentinel[]; +} + +const fileSystemCallbackTable: Record = { + readFile: { serverFS: [serverFS.useOS] }, + fileExists: { serverFS: [serverFS.useOS] }, + directoryExists: { serverFS: [serverFS.useOS] }, + getAccessibleEntries: { serverFS: [serverFS.useOS] }, + realpath: { + serverFS: [serverFS.useOS, serverFS.identity], + }, + stat: { + serverFS: [serverFS.useOS, serverFS.fakeStat], + }, + writeFile: { + serverFS: [serverFS.useOS, serverFS.noop], + }, +}; + +type FileSystemCallbackName = keyof typeof fileSystemCallbackTable; +const fsCallbackNames = Object.keys(fileSystemCallbackTable) as FileSystemCallbackName[]; +const serverFSSentinelNames = Object.keys(serverFS) as ServerFSSentinelName[]; + +export interface FileSystemCallbackConfiguration { + callbackNames: FileSystemCallbackName[]; + arguments: string[]; +} export function configureFileSystemCallbacks(fs: FileSystemCallbacks | undefined): FileSystemCallbackConfiguration { if (!fs) { return { callbackNames: [], arguments: [] }; } - const callbackNames: (typeof fsCallbackNames[number])[] = []; + const callbackNames: FileSystemCallbackName[] = []; const args: string[] = []; for (const name of fsCallbackNames) { const value = fs[name]; @@ -30,19 +56,12 @@ export function configureFileSystemCallbacks(fs: FileSystemCallbacks | undefined callbackNames.push(name); continue; } - if (value === serverFS.useOS) { - continue; - } - if (name === "realpath" && value === serverFS.identity) { - args.push("realpath:identity"); - continue; - } - if (name === "stat" && value === serverFS.fakeStat) { - args.push("stat:fakeStat"); - continue; - } - if (name === "writeFile" && value === serverFS.noop) { - args.push("writeFile:noop"); + const sentinel = fileSystemCallbackTable[name].serverFS.find(sentinel => sentinel === value); + if (sentinel) { + const sentinelName = getServerFSSentinelName(sentinel); + if (sentinelName !== "useOS") { + args.push(`${name}:${sentinelName}`); + } continue; } throw new TypeError(`Invalid filesystem callback '${name}': expected a function or a supported serverFS sentinel`); @@ -52,20 +71,12 @@ export function configureFileSystemCallbacks(fs: FileSystemCallbacks | undefined } export function encodeFileSystemCallbackResult( - name: typeof fsCallbackNames[number], + name: FileSystemCallbackName, result: unknown, ): FileSystemCallbackResponse { - if (result === serverFS.useOS) { - return { kind: "useOS" }; - } - if (name === "realpath" && result === serverFS.identity) { - return { kind: "identity" }; - } - if (name === "stat" && result === serverFS.fakeStat) { - return { kind: "fakeStat" }; - } - if (name === "writeFile" && result === serverFS.noop) { - return { kind: "noop" }; + const sentinel = fileSystemCallbackTable[name].serverFS.find(sentinel => sentinel === result); + if (sentinel) { + return { kind: getServerFSSentinelName(sentinel) }; } let valid: boolean; @@ -118,3 +129,12 @@ export function encodeFileSystemCallbackResult( function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every(element => typeof element === "string"); } + +function getServerFSSentinelName(sentinel: ServerFSSentinel): ServerFSSentinelName { + for (const name of serverFSSentinelNames) { + if (serverFS[name] === sentinel) { + return name; + } + } + throw new TypeError("Unknown serverFS sentinel"); +} From 476e28f82539c55ea34be30cc78943fd003f45dd Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Fri, 25 Sep 2026 10:36:15 -0700 Subject: [PATCH 6/8] Add serverFS.error --- packages/typescript/src/api/fs.ts | 39 ++++++++++++---- packages/typescript/src/api/fsCallbacks.ts | 14 +++--- packages/typescript/test/async/api.test.ts | 22 +++++++++ packages/typescript/test/sync/api.test.ts | 22 +++++++++ tsc/internal/api/callbackfs.go | 43 +++++++++++++---- tsc/internal/api/callbackfs_test.go | 54 ++++++++++++++++++++++ 6 files changed, 169 insertions(+), 25 deletions(-) diff --git a/packages/typescript/src/api/fs.ts b/packages/typescript/src/api/fs.ts index fd2db38779528..fcd6ff6cdcf77 100644 --- a/packages/typescript/src/api/fs.ts +++ b/packages/typescript/src/api/fs.ts @@ -31,6 +31,7 @@ const useOS: unique symbol = Symbol("useOS"); const identity: unique symbol = Symbol("identity"); const fakeStat: unique symbol = Symbol("fakeStat"); const noop: unique symbol = Symbol("noop"); +const error: unique symbol = Symbol("error"); export const serverFS: { /** Delegate the configured operation, or the current callback invocation, to the server's operating-system filesystem. */ @@ -41,36 +42,54 @@ export const serverFS: { readonly fakeStat: typeof fakeStat; /** Ignore writes without invoking a callback or writing to the server's operating-system filesystem. Valid only for `writeFile`. */ readonly noop: typeof noop; + /** Panic if the configured operation, or current callback invocation, reaches the server filesystem. */ + readonly error: typeof error; } = { useOS: useOS, identity: identity, fakeStat: fakeStat, noop: noop, + error: error, }; export interface FileSystemCallbacks { - directoryExists: ((directoryName: string) => boolean | typeof serverFS.useOS) | typeof serverFS.useOS; - fileExists: ((fileName: string) => boolean | typeof serverFS.useOS) | typeof serverFS.useOS; - getAccessibleEntries: ((directoryName: string) => FileSystemEntries | typeof serverFS.useOS) | typeof serverFS.useOS; + directoryExists: + | ((directoryName: string) => boolean | typeof serverFS.useOS | typeof serverFS.error) + | typeof serverFS.useOS + | typeof serverFS.error; + fileExists: + | ((fileName: string) => boolean | typeof serverFS.useOS | typeof serverFS.error) + | typeof serverFS.useOS + | typeof serverFS.error; + getAccessibleEntries: + | ((directoryName: string) => FileSystemEntries | typeof serverFS.useOS | typeof serverFS.error) + | typeof serverFS.useOS + | typeof serverFS.error; /** * Read a file's content. * - Return the file content as a `string` (including `""` for empty files). * - Return `undefined` to indicate the file does not exist. * - Return {@link serverFS.useOS} to fall back to the server's operating-system filesystem. */ - readFile: ((fileName: string) => string | undefined | typeof serverFS.useOS) | typeof serverFS.useOS; + readFile: + | ((fileName: string) => string | undefined | typeof serverFS.useOS | typeof serverFS.error) + | typeof serverFS.useOS + | typeof serverFS.error; realpath: - | ((path: string) => string | typeof serverFS.useOS | typeof serverFS.identity) + | ((path: string) => string | typeof serverFS.useOS | typeof serverFS.identity | typeof serverFS.error) | typeof serverFS.useOS - | typeof serverFS.identity; + | typeof serverFS.identity + | typeof serverFS.error; stat: - | ((path: string) => FileSystemStat | undefined | typeof serverFS.useOS | typeof serverFS.fakeStat) + | ((path: string) => FileSystemStat | undefined | typeof serverFS.useOS | typeof serverFS.fakeStat | typeof serverFS.error) | typeof serverFS.useOS - | typeof serverFS.fakeStat; + | typeof serverFS.fakeStat + | typeof serverFS.error; writeFile: - | ((path: string, content: string) => void | typeof serverFS.useOS | typeof serverFS.noop) + | ((path: string, content: string) => void | typeof serverFS.useOS | typeof serverFS.noop | typeof serverFS.error) | typeof serverFS.useOS - | typeof serverFS.noop; + | typeof serverFS.noop + | typeof serverFS.error; } export interface CreateFileSystemOptions { diff --git a/packages/typescript/src/api/fsCallbacks.ts b/packages/typescript/src/api/fsCallbacks.ts index 63d798757615c..d2c8db3ffa5d6 100644 --- a/packages/typescript/src/api/fsCallbacks.ts +++ b/packages/typescript/src/api/fsCallbacks.ts @@ -19,18 +19,18 @@ interface FileSystemCallbackDefinition { } const fileSystemCallbackTable: Record = { - readFile: { serverFS: [serverFS.useOS] }, - fileExists: { serverFS: [serverFS.useOS] }, - directoryExists: { serverFS: [serverFS.useOS] }, - getAccessibleEntries: { serverFS: [serverFS.useOS] }, + readFile: { serverFS: [serverFS.useOS, serverFS.error] }, + fileExists: { serverFS: [serverFS.useOS, serverFS.error] }, + directoryExists: { serverFS: [serverFS.useOS, serverFS.error] }, + getAccessibleEntries: { serverFS: [serverFS.useOS, serverFS.error] }, realpath: { - serverFS: [serverFS.useOS, serverFS.identity], + serverFS: [serverFS.useOS, serverFS.identity, serverFS.error], }, stat: { - serverFS: [serverFS.useOS, serverFS.fakeStat], + serverFS: [serverFS.useOS, serverFS.fakeStat, serverFS.error], }, writeFile: { - serverFS: [serverFS.useOS, serverFS.noop], + serverFS: [serverFS.useOS, serverFS.noop, serverFS.error], }, }; diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index 5966361a2520d..6354b859f9b42 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -188,6 +188,28 @@ describe("API", { concurrency }, () => { writeFile: () => serverFS.noop, }, }); + void new API({ + fs: { + directoryExists: serverFS.error, + fileExists: serverFS.error, + getAccessibleEntries: serverFS.error, + readFile: serverFS.error, + realpath: serverFS.error, + stat: serverFS.error, + writeFile: serverFS.error, + }, + }); + void new API({ + fs: { + directoryExists: () => serverFS.error, + fileExists: () => serverFS.error, + getAccessibleEntries: () => serverFS.error, + readFile: () => serverFS.error, + realpath: () => serverFS.error, + stat: () => serverFS.error, + writeFile: () => serverFS.error, + }, + }); // @ts-expect-error Filesystem callback configurations must specify every operation. void new API({ fs: { readFile: serverFS.useOS } }); void new API({ diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index 25315b0fef7df..76c8a57c2b3e5 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -196,6 +196,28 @@ describe("API", { concurrency }, () => { writeFile: () => serverFS.noop, }, }); + void new API({ + fs: { + directoryExists: serverFS.error, + fileExists: serverFS.error, + getAccessibleEntries: serverFS.error, + readFile: serverFS.error, + realpath: serverFS.error, + stat: serverFS.error, + writeFile: serverFS.error, + }, + }); + void new API({ + fs: { + directoryExists: () => serverFS.error, + fileExists: () => serverFS.error, + getAccessibleEntries: () => serverFS.error, + readFile: () => serverFS.error, + realpath: () => serverFS.error, + stat: () => serverFS.error, + writeFile: () => serverFS.error, + }, + }); // @ts-expect-error Filesystem callback configurations must specify every operation. void new API({ fs: { readFile: serverFS.useOS } }); void new API({ diff --git a/tsc/internal/api/callbackfs.go b/tsc/internal/api/callbackfs.go index 4d882cde7fd21..e6c209cc3176d 100644 --- a/tsc/internal/api/callbackfs.go +++ b/tsc/internal/api/callbackfs.go @@ -5,6 +5,7 @@ import ( "fmt" iofs "io/fs" "slices" + "strings" "time" "github.com/microsoft/TypeScript/tsc/internal/ipc" @@ -26,6 +27,7 @@ type callbackFS struct { realpathIdentity bool fakeStat bool writeFileNoop bool + errorCallbacks map[string]bool caseSensitive *bool // conn and ctx are set after connection is established @@ -64,7 +66,15 @@ func isCallbackName(name string) bool { // to the client (e.g., "readFile", "fileExists"). func newCallbackFS(base vfs.FS, callbacks []string, caseSensitive *bool) *callbackFS { enabled := make(map[string]bool, len(callbacks)) + errorCallbacks := make(map[string]bool) for _, cb := range callbacks { + if name, isError := strings.CutSuffix(cb, ":error"); isError { + if !isCallbackName(name) { + panic("unknown callback name: " + name) + } + errorCallbacks[name] = true + continue + } if cb == "realpath:identity" || cb == "stat:fakeStat" || cb == "writeFile:noop" { continue } @@ -79,6 +89,7 @@ func newCallbackFS(base vfs.FS, callbacks []string, caseSensitive *bool) *callba realpathIdentity: slices.Contains(callbacks, "realpath:identity"), fakeStat: slices.Contains(callbacks, "stat:fakeStat"), writeFileNoop: slices.Contains(callbacks, "writeFile:noop"), + errorCallbacks: errorCallbacks, caseSensitive: caseSensitive, } } @@ -114,7 +125,7 @@ type callbackResponse struct { Value json.Value `json:"value"` } -func decodeCallbackResponse(result []byte) callbackResponse { +func decodeCallbackResponse(name string, result []byte) callbackResponse { var response callbackResponse if err := json.Unmarshal(result, &response); err != nil { panic(err) @@ -122,6 +133,9 @@ func decodeCallbackResponse(result []byte) callbackResponse { if response.Kind == "" { panic("filesystem callback response is missing a kind") } + if response.Kind == "error" { + panic("filesystem callback returned serverFS.error: " + name) + } return response } @@ -129,6 +143,12 @@ func invalidCallbackResponse(name string, response callbackResponse) { panic(fmt.Sprintf("invalid %s callback response kind: %s", name, response.Kind)) } +func (fs *callbackFS) panicIfError(name string) { + if fs.errorCallbacks[name] { + panic("filesystem operation configured with serverFS.error: " + name) + } +} + // UseCaseSensitiveFileNames implements vfs.FS. func (fs *callbackFS) UseCaseSensitiveFileNames() bool { if fs.caseSensitive != nil { @@ -139,12 +159,13 @@ func (fs *callbackFS) UseCaseSensitiveFileNames() bool { // ReadFile implements vfs.FS. func (fs *callbackFS) ReadFile(path string) (contents string, ok bool) { + fs.panicIfError(callbackReadFile) if fs.isEnabled(callbackReadFile) { result, err := fs.call(callbackReadFile, path) if err != nil { panic(err) } - response := decodeCallbackResponse(result) + response := decodeCallbackResponse(callbackReadFile, result) switch response.Kind { case "value": var content string @@ -165,12 +186,13 @@ func (fs *callbackFS) ReadFile(path string) (contents string, ok bool) { // FileExists implements vfs.FS. func (fs *callbackFS) FileExists(path string) bool { + fs.panicIfError(callbackFileExists) if fs.isEnabled(callbackFileExists) { result, err := fs.call(callbackFileExists, path) if err != nil { panic(err) } - response := decodeCallbackResponse(result) + response := decodeCallbackResponse(callbackFileExists, result) switch response.Kind { case "value": var exists bool @@ -189,12 +211,13 @@ func (fs *callbackFS) FileExists(path string) bool { // DirectoryExists implements vfs.FS. func (fs *callbackFS) DirectoryExists(path string) bool { + fs.panicIfError(callbackDirectoryExists) if fs.isEnabled(callbackDirectoryExists) { result, err := fs.call(callbackDirectoryExists, path) if err != nil { panic(err) } - response := decodeCallbackResponse(result) + response := decodeCallbackResponse(callbackDirectoryExists, result) switch response.Kind { case "value": var exists bool @@ -213,12 +236,13 @@ func (fs *callbackFS) DirectoryExists(path string) bool { // GetAccessibleEntries implements vfs.FS. func (fs *callbackFS) GetAccessibleEntries(path string) vfs.Entries { + fs.panicIfError(callbackGetAccessibleEntries) if fs.isEnabled(callbackGetAccessibleEntries) { result, err := fs.call(callbackGetAccessibleEntries, path) if err != nil { panic(err) } - response := decodeCallbackResponse(result) + response := decodeCallbackResponse(callbackGetAccessibleEntries, result) switch response.Kind { case "value": var rawEntries *struct { @@ -251,12 +275,13 @@ func (fs *callbackFS) GetAccessibleEntries(path string) vfs.Entries { // Realpath implements vfs.FS. func (fs *callbackFS) Realpath(path string) string { + fs.panicIfError(callbackRealpath) if fs.isEnabled(callbackRealpath) { result, err := fs.call(callbackRealpath, path) if err != nil { panic(err) } - response := decodeCallbackResponse(result) + response := decodeCallbackResponse(callbackRealpath, result) switch response.Kind { case "value": var realpath string @@ -294,12 +319,13 @@ func (info *callbackFileInfo) Sys() any { return nil } // Stat implements vfs.FS. func (fs *callbackFS) Stat(path string) vfs.FileInfo { + fs.panicIfError(callbackStat) if fs.isEnabled(callbackStat) { result, err := fs.call(callbackStat, path) if err != nil { panic(err) } - response := decodeCallbackResponse(result) + response := decodeCallbackResponse(callbackStat, result) switch response.Kind { case "value": var stat struct { @@ -380,6 +406,7 @@ func nodeFileModeToGoFileMode(mode uint32) iofs.FileMode { // WriteFile implements vfs.FS. func (fs *callbackFS) WriteFile(path string, data string) error { + fs.panicIfError(callbackWriteFile) if fs.isEnabled(callbackWriteFile) { payload := struct { Path string `json:"path"` @@ -390,7 +417,7 @@ func (fs *callbackFS) WriteFile(path string, data string) error { if err != nil { return err } - response := decodeCallbackResponse(result) + response := decodeCallbackResponse(callbackWriteFile, result) switch response.Kind { case "value", "noop": return nil diff --git a/tsc/internal/api/callbackfs_test.go b/tsc/internal/api/callbackfs_test.go index b5cccc4e4ac20..b5d57ecf03f86 100644 --- a/tsc/internal/api/callbackfs_test.go +++ b/tsc/internal/api/callbackfs_test.go @@ -2,7 +2,9 @@ package api import ( "context" + "fmt" iofs "io/fs" + "strings" "testing" "time" @@ -201,3 +203,55 @@ func TestCallbackFSPerCallIdentityRealpath(t *testing.T) { t.Fatalf("Realpath() = %q, want identity", got) } } + +func TestCallbackFSError(t *testing.T) { + t.Parallel() + + names := []string{ + callbackReadFile, + callbackFileExists, + callbackDirectoryExists, + callbackGetAccessibleEntries, + callbackRealpath, + callbackStat, + callbackWriteFile, + } + callbacks := make([]string, len(names)) + for i, name := range names { + callbacks[i] = name + ":error" + } + base := vfstest.FromMap(map[string]string{}, true) + fs := newCallbackFS(base, callbacks, nil) + for _, name := range names { + if !fs.errorCallbacks[name] { + t.Fatalf("%s was not configured to panic", name) + } + } + assertPanicsWith(t, "serverFS.error: readFile", func() { + fs.ReadFile("/unexpected.ts") + }) + + callbackFS := newCallbackFS(base, []string{"fileExists"}, nil) + callbackFS.SetConnection(t.Context(), &callbackTestConn{ + responses: map[string]json.Value{ + callbackFileExists: []byte(`{"kind":"error"}`), + }, + }) + assertPanicsWith(t, "serverFS.error: fileExists", func() { + callbackFS.FileExists("/unexpected.ts") + }) +} + +func assertPanicsWith(t *testing.T, expected string, cb func()) { + t.Helper() + defer func() { + value := recover() + if value == nil { + t.Fatal("expected panic") + } + if message := fmt.Sprint(value); !strings.Contains(message, expected) { + t.Fatalf("panic = %q, want substring %q", message, expected) + } + }() + cb() +} From e12f912babb268a091c71bd2e7207baf499f26e3 Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Fri, 25 Sep 2026 10:45:44 -0700 Subject: [PATCH 7/8] Update error message for fakeStat implementation Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/typescript/test/async/api.test.ts | 2 +- packages/typescript/test/sync/api.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index 6354b859f9b42..06a526d361416 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -222,7 +222,7 @@ describe("API", { concurrency }, () => { void new API({ fs: { ...callbacks, - // @ts-expect-error Infer is only a valid stat implementation. + // @ts-expect-error fakeStat is only a valid stat implementation. fileExists: serverFS.fakeStat, }, }); diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index 76c8a57c2b3e5..d389654ed5d54 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -230,7 +230,7 @@ describe("API", { concurrency }, () => { void new API({ fs: { ...callbacks, - // @ts-expect-error Infer is only a valid stat implementation. + // @ts-expect-error fakeStat is only a valid stat implementation. fileExists: serverFS.fakeStat, }, }); From e2f0810cffe998b89da458560655a6042d40d71c Mon Sep 17 00:00:00 2001 From: Andrew Branch Date: Fri, 25 Sep 2026 10:50:07 -0700 Subject: [PATCH 8/8] Format --- packages/typescript/test/async/api.test.ts | 2 +- packages/typescript/test/sync/api.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index 06a526d361416..c3136831b395b 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -222,7 +222,7 @@ describe("API", { concurrency }, () => { void new API({ fs: { ...callbacks, - // @ts-expect-error fakeStat is only a valid stat implementation. + // @ts-expect-error fakeStat is only a valid stat implementation. fileExists: serverFS.fakeStat, }, }); diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index d389654ed5d54..56bc3e674b22b 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -230,7 +230,7 @@ describe("API", { concurrency }, () => { void new API({ fs: { ...callbacks, - // @ts-expect-error fakeStat is only a valid stat implementation. + // @ts-expect-error fakeStat is only a valid stat implementation. fileExists: serverFS.fakeStat, }, });