diff --git a/packages/typescript/src/api/async/client.ts b/packages/typescript/src/api/async/client.ts index ff7b9c2f60f8f..55217182385c2 100644 --- a/packages/typescript/src/api/async/client.ts +++ b/packages/typescript/src/api/async/client.ts @@ -9,10 +9,12 @@ 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, + encodeFileSystemCallbackResult, + type FileSystemCallbackConfiguration, +} from "../fsCallbacks.ts"; import { type ClientOptions, type ClientSocketOptions, @@ -57,8 +59,11 @@ export class Client { constructor(options: ClientOptions) { this.options = options; - if (isSpawnOptions(options) && options.collectTiming) { - this.timing = new TimingCollector(); + if (isSpawnOptions(options)) { + configureFileSystemCallbacks(options.fs); + if (options.collectTiming) { + this.timing = new TimingCollector(); + } } } @@ -84,18 +89,10 @@ export class Client { return new Promise((resolve, reject) => { const args = getAPIProcessArgs(options, true); + const fsConfiguration = configureFileSystemCallbacks(options.fs); - // 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 (fsConfiguration.arguments.length > 0) { + args.push(`--callbacks=${fsConfiguration.arguments.join(",")}`); } this.process = spawn(resolveExePath(options), args, { @@ -114,7 +111,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(); }); } @@ -138,36 +135,31 @@ export class Client { }); } - private registerFSCallbacks(connection: MessageConnection, fs: FileSystem | undefined): void { + private registerFSCallbacks( + connection: MessageConnection, + fs: FileSystemCallbacks | undefined, + configuration: FileSystemCallbackConfiguration, + ): void { if (!fs) return; - for (const name of fsCallbackNames) { + for (const name of configuration.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; }) => { - callback(arg.path, arg.data); - return null; + return encodeFileSystemCallbackResult(name, callback(arg.path, arg.data)); }); continue; } 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) => { + return encodeFileSystemCallbackResult(name, callback(arg as string)); + }); } } diff --git a/packages/typescript/src/api/fs.ts b/packages/typescript/src/api/fs.ts index 42c3713c12aa4..eb2a3f884cc16 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,27 +14,89 @@ 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; +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. */ + 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; + /** 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.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 `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 `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 | undefined) | undefined; - realpath?: ((path: string) => string | undefined) | undefined; - writeFile?: ((path: string, content: string) => void) | undefined; - removeFile?: ((path: string) => void) | undefined; + 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 | typeof serverFS.error) + | typeof serverFS.useOS + | typeof serverFS.identity + | typeof serverFS.error; + stat: + | ((path: string) => FileSystemStat | undefined | typeof serverFS.useOS | typeof serverFS.fakeStat | typeof serverFS.error) + | typeof serverFS.useOS + | typeof serverFS.fakeStat + | typeof serverFS.error; + writeFile: + | ((path: string, content: string) => void | typeof serverFS.useOS | typeof serverFS.noop | typeof serverFS.error) + | typeof serverFS.useOS + | typeof serverFS.noop + | typeof serverFS.error; + removeFile: + | ((path: string) => void | typeof serverFS.useOS | typeof serverFS.noop | typeof serverFS.error) + | typeof serverFS.useOS + | typeof serverFS.noop + | typeof serverFS.error; } -/** The callback names supported by the Go server for virtual FS delegation. */ -export const fsCallbackNames = ["readFile", "fileExists", "directoryExists", "getAccessibleEntries", "realpath", "writeFile", "removeFile"] as const; - export interface CreateFileSystemOptions { /** Complete directory listings. Full filesystems derive these from `files` when omitted. */ directories?: Record | undefined; @@ -126,130 +185,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..bba5efa4155e5 --- /dev/null +++ b/packages/typescript/src/api/fsCallbacks.ts @@ -0,0 +1,144 @@ +import { + type FileSystemCallbacks, + serverFS, +} from "./fs.ts"; + +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"; } + | ServerFSSentinelResponse; + +interface FileSystemCallbackDefinition { + serverFS: readonly ServerFSSentinel[]; +} + +const fileSystemCallbackTable: Record = { + 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.error], + }, + stat: { + serverFS: [serverFS.useOS, serverFS.fakeStat, serverFS.error], + }, + writeFile: { + serverFS: [serverFS.useOS, serverFS.noop, serverFS.error], + }, + removeFile: { + serverFS: [serverFS.useOS, serverFS.noop, serverFS.error], + }, +}; + +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: FileSystemCallbackName[] = []; + const args: string[] = []; + for (const name of fsCallbackNames) { + const value = fs[name]; + if (typeof value === "function") { + callbackNames.push(name); + continue; + } + 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`); + } + args.push(...callbackNames); + return { callbackNames, arguments: args }; +} + +export function encodeFileSystemCallbackResult( + name: FileSystemCallbackName, + result: unknown, +): FileSystemCallbackResponse { + const sentinel = fileSystemCallbackTable[name].serverFS.find(sentinel => sentinel === result); + if (sentinel) { + return { kind: getServerFSSentinelName(sentinel) }; + } + + 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": + if (result === undefined) return { kind: "missing" }; + valid = typeof result === "string"; + break; + case "realpath": + valid = typeof result === "string"; + break; + case "stat": { + 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": + case "removeFile": + valid = result === undefined; + break; + } + + if (!valid) { + throw new TypeError(`Invalid result from filesystem callback '${name}'`); + } + return name === "writeFile" || name === "removeFile" ? { kind: "value" } : { kind: "value", value: result }; +} + +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"); +} 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..42255e9513a1b 100644 --- a/packages/typescript/src/api/sync/client.ts +++ b/packages/typescript/src/api/sync/client.ts @@ -1,4 +1,7 @@ -import { fsCallbackNames } from "../fs.ts"; +import { + configureFileSystemCallbacks, + encodeFileSystemCallbackResult, +} from "../fsCallbacks.ts"; import { type ClientOptions, type ClientSocketOptions, @@ -39,17 +42,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,30 +56,23 @@ 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); - callback(path, data); - return ""; + return JSON.stringify(encodeFileSystemCallbackResult(name, callback(path, data))); }); 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. - if (result === undefined) return ""; - return JSON.stringify({ content: 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 3226d409e17a8..61860822aab53 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -100,9 +100,9 @@ import { createFileSystem, createFileSystemLayer, createFileSystemWithLib, - createVirtualFileSystem, + type FileSystemCallbacks, + serverFS, } from "@typescript/typescript/unstable/fs"; -import type { FileSystem } from "@typescript/typescript/unstable/fs"; import assert from "node:assert"; import { globSync } from "node:fs"; import { resolve } from "node:path"; @@ -112,7 +112,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 +168,74 @@ describe("API", { concurrency }, () => { // @ts-expect-error Project ID brands are not interchangeable. const invalid: ConfiguredProjectId = synthetic; void invalid; + + const callbacks: FileSystemCallbacks = { + directoryExists: serverFS.useOS, + fileExists: serverFS.useOS, + getAccessibleEntries: serverFS.useOS, + readFile: serverFS.useOS, + realpath: serverFS.identity, + stat: serverFS.fakeStat, + writeFile: serverFS.useOS, + removeFile: 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, + }, + }); + 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, + removeFile: 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, + removeFile: () => serverFS.error, + }, + }); + // @ts-expect-error Filesystem callback configurations must specify every operation. + void new API({ fs: { readFile: serverFS.useOS } }); + void new API({ + fs: { + ...callbacks, + // @ts-expect-error Identity is only a valid realpath implementation. + readFile: serverFS.identity, + }, + }); + void new API({ + fs: { + ...callbacks, + // @ts-expect-error fakeStat is only a valid stat implementation. + fileExists: serverFS.fakeStat, + }, + }); + void new API({ + fs: { + ...callbacks, + // @ts-expect-error Noop is only a valid writeFile implementation. + readFile: serverFS.noop, + }, + }); } }); @@ -1452,8 +1523,8 @@ describe("BuildOrchestrator", () => { ]); assert.equal(response.statistics.Projects, 1); assert.equal(response.statistics.ProjectsBuilt, 0); - assert.equal(fs.readFile!("/a/dist/index.d.ts"), undefined); - assert.equal(fs.readFile!("/a/dist/index.js"), undefined); + assert.equal(fs.readFile!("/a/dist/index.d.ts"), serverFS.useOS); + assert.equal(fs.readFile!("/a/dist/index.js"), serverFS.useOS); }); test("rebuilds projects after multiple file system changes", async () => { @@ -1498,9 +1569,9 @@ describe("BuildOrchestrator", () => { assert.ok(fs.readFile!("/b/dist/index.js")); assert.ok(fs.readFile!("/a/dist/index.js")); assert.equal((await orchestrator.clean()).status, 0); - assert.equal(fs.readFile!("/c/dist/index.js"), undefined); - assert.equal(fs.readFile!("/b/dist/index.js"), undefined); - assert.equal(fs.readFile!("/a/dist/index.js"), undefined); + assert.equal(fs.readFile!("/c/dist/index.js"), serverFS.useOS); + assert.equal(fs.readFile!("/b/dist/index.js"), serverFS.useOS); + assert.equal(fs.readFile!("/a/dist/index.js"), serverFS.useOS); }); test("builds and cleans selected projects after file system changes", async () => { @@ -1513,20 +1584,20 @@ describe("BuildOrchestrator", () => { assert.equal((await orchestrator.build("/a/tsconfig.json")).status, 0); assert.match(fs.readFile!("/a/dist/index.js")!, /export const a = 1/); - assert.equal(fs.readFile!("/b/dist/index.js"), undefined); - assert.equal(fs.readFile!("/c/dist/index.js"), undefined); + assert.equal(fs.readFile!("/b/dist/index.js"), serverFS.useOS); + assert.equal(fs.readFile!("/c/dist/index.js"), serverFS.useOS); fs.writeFile!("/a/src/index.ts", `export const a = 10;`); assert.equal((await orchestrator.build("/b/tsconfig.json")).status, 0); assert.match(fs.readFile!("/a/dist/index.js")!, /export const a = 1/); assert.match(fs.readFile!("/b/dist/index.js")!, /export const b = 2/); - assert.equal(fs.readFile!("/c/dist/index.js"), undefined); + assert.equal(fs.readFile!("/c/dist/index.js"), serverFS.useOS); fs.writeFile!("/b/src/index.ts", `export const b = 20;`); assert.equal((await orchestrator.clean("/a/tsconfig.json")).status, 0); - assert.equal(fs.readFile!("/a/dist/index.js"), undefined); + assert.equal(fs.readFile!("/a/dist/index.js"), serverFS.useOS); assert.match(fs.readFile!("/b/dist/index.js")!, /export const b = 2/); - assert.equal(fs.readFile!("/c/dist/index.js"), undefined); + assert.equal(fs.readFile!("/c/dist/index.js"), serverFS.useOS); assert.equal((await orchestrator.build()).status, 0); assert.match(fs.readFile!("/a/dist/index.js")!, /export const a = 10/); @@ -1535,12 +1606,12 @@ describe("BuildOrchestrator", () => { assert.equal((await orchestrator.clean("/b/tsconfig.json")).status, 0); assert.match(fs.readFile!("/a/dist/index.js")!, /export const a = 10/); - assert.equal(fs.readFile!("/b/dist/index.js"), undefined); + assert.equal(fs.readFile!("/b/dist/index.js"), serverFS.useOS); assert.match(fs.readFile!("/c/dist/index.js")!, /export const c = 3/); fs.writeFile!("/b/dist/index.js", `export const b = 2`); assert.equal((await orchestrator.clean("/b/tsconfig.json")).status, 0); - assert.equal(fs.readFile!("/b/dist/index.js"), undefined); + assert.equal(fs.readFile!("/b/dist/index.js"), serverFS.useOS); }); test("builds only references of a selected project", async () => { @@ -1557,7 +1628,7 @@ describe("BuildOrchestrator", () => { assert.equal((await orchestrator.buildReferences("/c/tsconfig.json")).status, 0); assert.match(fs.readFile!("/a/dist/index.js")!, /export const a = 1/); assert.match(fs.readFile!("/b/dist/index.js")!, /export const b = 2/); - assert.equal(fs.readFile!("/c/dist/index.js"), undefined); + assert.equal(fs.readFile!("/c/dist/index.js"), serverFS.useOS); }); test("cleans only references of a selected project", async () => { @@ -1576,16 +1647,16 @@ describe("BuildOrchestrator", () => { assert.ok(fs.readFile!("/c/dist/index.js")); assert.equal((await orchestrator.cleanReferences("/c/tsconfig.json")).status, 0); - assert.equal(fs.readFile!("/a/dist/index.js"), undefined); - assert.equal(fs.readFile!("/b/dist/index.js"), undefined); + assert.equal(fs.readFile!("/a/dist/index.js"), serverFS.useOS); + assert.equal(fs.readFile!("/b/dist/index.js"), serverFS.useOS); assert.ok(fs.readFile!("/c/dist/index.js")); assert.equal((await orchestrator.build()).status, 0); assert.ok(fs.readFile!("/a/dist/index.js")); assert.ok(fs.readFile!("/b/dist/index.js")); assert.equal((await orchestrator.cleanReferences()).status, 0); - assert.equal(fs.readFile!("/a/dist/index.js"), undefined); - assert.equal(fs.readFile!("/b/dist/index.js"), undefined); + assert.equal(fs.readFile!("/a/dist/index.js"), serverFS.useOS); + assert.equal(fs.readFile!("/b/dist/index.js"), serverFS.useOS); assert.ok(fs.readFile!("/c/dist/index.js")); }); @@ -1622,12 +1693,12 @@ describe("BuildOrchestrator", () => { fs.writeFile!("/d/lib/index.js", `export const d = 40;`); assert.equal((await orchestrator.clean("/d/tsconfig.json")).status, 0); - assert.equal(fs.readFile!("/d/dist/index.js"), undefined); + assert.equal(fs.readFile!("/d/dist/index.js"), serverFS.useOS); assert.ok(fs.readFile!("/d/lib/index.js")); assert.equal((await orchestrator.build()).status, 0); assert.match(fs.readFile!("/d/lib/index.js")!, /export const d = 4/); - assert.equal(fs.readFile!("/d/dist/index.js"), undefined); + assert.equal(fs.readFile!("/d/dist/index.js"), serverFS.useOS); }); }); @@ -4276,7 +4347,52 @@ export const obj = { name }; }); describe("readFile callback semantics", { concurrency }, () => { - test("readFile: string returns content, null blocks fallback, undefined falls through to real FS", async () => { + test("callback configurations require every operation at runtime", () => { + assert.throws( + () => new API({ fs: { readFile: serverFS.useOS } as FileSystemCallbacks }), + /Invalid filesystem callback 'fileExists'/, + ); + }); + + test("invalid callback results do not fall through to the server OS", async () => { + const fs: FileSystemCallbacks = { + ...createVirtualFileSystem({ + "/tsconfig.json": "{}", + }), + readFile: (() => null) 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, + removeFile: serverFS.useOS, + }; + await using api = new API({ fs }); + let calls = 0; + fs.readFile = () => { + calls++; + return undefined; + }; + + 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, 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;`, @@ -4284,16 +4400,15 @@ 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) { - // 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 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); }, }; @@ -4310,8 +4425,8 @@ 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. - // If readFile returned null for unknowns, lib files would be missing + // 2. useOS fallback: lib files from the server OS should be present. + // 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:"); @@ -4319,9 +4434,34 @@ 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 () => { + 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); + } }); }); @@ -4397,7 +4537,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); @@ -4418,10 +4558,20 @@ 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 undefined; + }, writeFile: (path, content) => { callbackCalls.push(`writeFile:${path}`); host.writeFile!(path, content); }, + removeFile: path => { + callbackCalls.push(`removeFile:${path}`); + host.removeFile(path); + }, }; await using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), @@ -4499,7 +4649,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); @@ -4567,10 +4717,17 @@ describe("updateSnapshot file systems", { concurrency }, () => { await using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs: { + directoryExists: serverFS.useOS, + fileExists: serverFS.useOS, + getAccessibleEntries: serverFS.useOS, readFile: path => { callbackCalls.push(path); - return undefined; + return serverFS.useOS; }, + realpath: serverFS.useOS, + stat: serverFS.useOS, + writeFile: serverFS.useOS, + removeFile: serverFS.useOS, }, }); @@ -4601,10 +4758,17 @@ describe("updateSnapshot file systems", { concurrency }, () => { await using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs: { + directoryExists: serverFS.useOS, + fileExists: serverFS.useOS, + getAccessibleEntries: serverFS.useOS, readFile: path => { callbackCalls.push(path); - return undefined; + return serverFS.useOS; }, + realpath: serverFS.useOS, + stat: serverFS.useOS, + writeFile: serverFS.useOS, + removeFile: serverFS.useOS, }, }); @@ -4784,9 +4948,16 @@ describe("updateSnapshot file systems", { concurrency }, () => { await using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs: { + directoryExists: serverFS.useOS, + fileExists: serverFS.useOS, + getAccessibleEntries: serverFS.useOS, + readFile: serverFS.useOS, + realpath: serverFS.useOS, + stat: serverFS.useOS, writeFile: path => { hostWrites.push(path); }, + removeFile: serverFS.useOS, }, }); using snapshot = await api.createSnapshot({ @@ -7143,7 +7314,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 () => { @@ -7161,7 +7332,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 () => { @@ -8113,9 +8284,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`); }); @@ -8145,9 +8316,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 () => { @@ -8161,7 +8332,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 () => { @@ -8226,8 +8397,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); @@ -8255,7 +8426,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 () => { @@ -8522,7 +8693,10 @@ describe("runWithTemporaryFileUpdate", { concurrency }, () => { }); }); -function spawnAPIWithFS(files: Record = { ...defaultFiles }, onWrite?: (path: string) => void): { api: API; fs: FileSystem; } { +function spawnAPIWithFS( + files: Record = { ...defaultFiles }, + onWrite?: (path: string) => void, +): { api: API; fs: ReturnType; } { const fs = createVirtualFileSystem(files); if (onWrite) { const writeFile = fs.writeFile!; 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 aaa550e6ba456..96128ab7c3f00 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -61,9 +61,9 @@ import { createFileSystem, createFileSystemLayer, createFileSystemWithLib, - createVirtualFileSystem, + type FileSystemCallbacks, + serverFS, } from "@typescript/typescript/unstable/fs"; -import type { FileSystem } from "@typescript/typescript/unstable/fs"; import { API, type BigIntLiteralType, @@ -120,7 +120,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 +176,74 @@ describe("API", { concurrency }, () => { // @ts-expect-error Project ID brands are not interchangeable. const invalid: ConfiguredProjectId = synthetic; void invalid; + + const callbacks: FileSystemCallbacks = { + directoryExists: serverFS.useOS, + fileExists: serverFS.useOS, + getAccessibleEntries: serverFS.useOS, + readFile: serverFS.useOS, + realpath: serverFS.identity, + stat: serverFS.fakeStat, + writeFile: serverFS.useOS, + removeFile: 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, + }, + }); + 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, + removeFile: 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, + removeFile: () => serverFS.error, + }, + }); + // @ts-expect-error Filesystem callback configurations must specify every operation. + void new API({ fs: { readFile: serverFS.useOS } }); + void new API({ + fs: { + ...callbacks, + // @ts-expect-error Identity is only a valid realpath implementation. + readFile: serverFS.identity, + }, + }); + void new API({ + fs: { + ...callbacks, + // @ts-expect-error fakeStat is only a valid stat implementation. + fileExists: serverFS.fakeStat, + }, + }); + void new API({ + fs: { + ...callbacks, + // @ts-expect-error Noop is only a valid writeFile implementation. + readFile: serverFS.noop, + }, + }); } }); @@ -1319,8 +1390,8 @@ describe("BuildOrchestrator", () => { ]); assert.equal(response.statistics.Projects, 1); assert.equal(response.statistics.ProjectsBuilt, 0); - assert.equal(fs.readFile!("/a/dist/index.d.ts"), undefined); - assert.equal(fs.readFile!("/a/dist/index.js"), undefined); + assert.equal(fs.readFile!("/a/dist/index.d.ts"), serverFS.useOS); + assert.equal(fs.readFile!("/a/dist/index.js"), serverFS.useOS); }); test("rebuilds projects after multiple file system changes", () => { @@ -1365,9 +1436,9 @@ describe("BuildOrchestrator", () => { assert.ok(fs.readFile!("/b/dist/index.js")); assert.ok(fs.readFile!("/a/dist/index.js")); assert.equal((orchestrator.clean()).status, 0); - assert.equal(fs.readFile!("/c/dist/index.js"), undefined); - assert.equal(fs.readFile!("/b/dist/index.js"), undefined); - assert.equal(fs.readFile!("/a/dist/index.js"), undefined); + assert.equal(fs.readFile!("/c/dist/index.js"), serverFS.useOS); + assert.equal(fs.readFile!("/b/dist/index.js"), serverFS.useOS); + assert.equal(fs.readFile!("/a/dist/index.js"), serverFS.useOS); }); test("builds and cleans selected projects after file system changes", () => { @@ -1380,20 +1451,20 @@ describe("BuildOrchestrator", () => { assert.equal((orchestrator.build("/a/tsconfig.json")).status, 0); assert.match(fs.readFile!("/a/dist/index.js")!, /export const a = 1/); - assert.equal(fs.readFile!("/b/dist/index.js"), undefined); - assert.equal(fs.readFile!("/c/dist/index.js"), undefined); + assert.equal(fs.readFile!("/b/dist/index.js"), serverFS.useOS); + assert.equal(fs.readFile!("/c/dist/index.js"), serverFS.useOS); fs.writeFile!("/a/src/index.ts", `export const a = 10;`); assert.equal((orchestrator.build("/b/tsconfig.json")).status, 0); assert.match(fs.readFile!("/a/dist/index.js")!, /export const a = 1/); assert.match(fs.readFile!("/b/dist/index.js")!, /export const b = 2/); - assert.equal(fs.readFile!("/c/dist/index.js"), undefined); + assert.equal(fs.readFile!("/c/dist/index.js"), serverFS.useOS); fs.writeFile!("/b/src/index.ts", `export const b = 20;`); assert.equal((orchestrator.clean("/a/tsconfig.json")).status, 0); - assert.equal(fs.readFile!("/a/dist/index.js"), undefined); + assert.equal(fs.readFile!("/a/dist/index.js"), serverFS.useOS); assert.match(fs.readFile!("/b/dist/index.js")!, /export const b = 2/); - assert.equal(fs.readFile!("/c/dist/index.js"), undefined); + assert.equal(fs.readFile!("/c/dist/index.js"), serverFS.useOS); assert.equal((orchestrator.build()).status, 0); assert.match(fs.readFile!("/a/dist/index.js")!, /export const a = 10/); @@ -1402,12 +1473,12 @@ describe("BuildOrchestrator", () => { assert.equal((orchestrator.clean("/b/tsconfig.json")).status, 0); assert.match(fs.readFile!("/a/dist/index.js")!, /export const a = 10/); - assert.equal(fs.readFile!("/b/dist/index.js"), undefined); + assert.equal(fs.readFile!("/b/dist/index.js"), serverFS.useOS); assert.match(fs.readFile!("/c/dist/index.js")!, /export const c = 3/); fs.writeFile!("/b/dist/index.js", `export const b = 2`); assert.equal((orchestrator.clean("/b/tsconfig.json")).status, 0); - assert.equal(fs.readFile!("/b/dist/index.js"), undefined); + assert.equal(fs.readFile!("/b/dist/index.js"), serverFS.useOS); }); test("builds only references of a selected project", () => { @@ -1424,7 +1495,7 @@ describe("BuildOrchestrator", () => { assert.equal((orchestrator.buildReferences("/c/tsconfig.json")).status, 0); assert.match(fs.readFile!("/a/dist/index.js")!, /export const a = 1/); assert.match(fs.readFile!("/b/dist/index.js")!, /export const b = 2/); - assert.equal(fs.readFile!("/c/dist/index.js"), undefined); + assert.equal(fs.readFile!("/c/dist/index.js"), serverFS.useOS); }); test("cleans only references of a selected project", () => { @@ -1443,16 +1514,16 @@ describe("BuildOrchestrator", () => { assert.ok(fs.readFile!("/c/dist/index.js")); assert.equal((orchestrator.cleanReferences("/c/tsconfig.json")).status, 0); - assert.equal(fs.readFile!("/a/dist/index.js"), undefined); - assert.equal(fs.readFile!("/b/dist/index.js"), undefined); + assert.equal(fs.readFile!("/a/dist/index.js"), serverFS.useOS); + assert.equal(fs.readFile!("/b/dist/index.js"), serverFS.useOS); assert.ok(fs.readFile!("/c/dist/index.js")); assert.equal((orchestrator.build()).status, 0); assert.ok(fs.readFile!("/a/dist/index.js")); assert.ok(fs.readFile!("/b/dist/index.js")); assert.equal((orchestrator.cleanReferences()).status, 0); - assert.equal(fs.readFile!("/a/dist/index.js"), undefined); - assert.equal(fs.readFile!("/b/dist/index.js"), undefined); + assert.equal(fs.readFile!("/a/dist/index.js"), serverFS.useOS); + assert.equal(fs.readFile!("/b/dist/index.js"), serverFS.useOS); assert.ok(fs.readFile!("/c/dist/index.js")); }); @@ -1489,12 +1560,12 @@ describe("BuildOrchestrator", () => { fs.writeFile!("/d/lib/index.js", `export const d = 40;`); assert.equal((orchestrator.clean("/d/tsconfig.json")).status, 0); - assert.equal(fs.readFile!("/d/dist/index.js"), undefined); + assert.equal(fs.readFile!("/d/dist/index.js"), serverFS.useOS); assert.ok(fs.readFile!("/d/lib/index.js")); assert.equal((orchestrator.build()).status, 0); assert.match(fs.readFile!("/d/lib/index.js")!, /export const d = 4/); - assert.equal(fs.readFile!("/d/dist/index.js"), undefined); + assert.equal(fs.readFile!("/d/dist/index.js"), serverFS.useOS); }); }); @@ -4110,7 +4181,28 @@ export const obj = { name }; }); describe("readFile callback semantics", { concurrency }, () => { - test("readFile: string returns content, null blocks fallback, undefined falls through to real FS", () => { + test("callback configurations require every operation at runtime", () => { + assert.throws( + () => new API({ fs: { readFile: serverFS.useOS } as FileSystemCallbacks }), + /Invalid filesystem callback 'fileExists'/, + ); + }); + + test("invalid callback results do not fall through to the server OS", () => { + const fs: FileSystemCallbacks = { + ...createVirtualFileSystem({ + "/tsconfig.json": "{}", + }), + readFile: (() => null) 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, 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;`, @@ -4118,16 +4210,15 @@ 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) { - // 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 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); }, }; @@ -4144,8 +4235,8 @@ 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. - // If readFile returned null for unknowns, lib files would be missing + // 2. useOS fallback: lib files from the server OS should be present. + // 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:"); @@ -4153,9 +4244,34 @@ 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", () => { + 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); + } }); }); @@ -4231,7 +4347,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); @@ -4252,10 +4368,20 @@ 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 undefined; + }, writeFile: (path, content) => { callbackCalls.push(`writeFile:${path}`); host.writeFile!(path, content); }, + removeFile: path => { + callbackCalls.push(`removeFile:${path}`); + host.removeFile(path); + }, }; using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), @@ -4333,7 +4459,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); @@ -4401,10 +4527,17 @@ describe("updateSnapshot file systems", { concurrency }, () => { using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs: { + directoryExists: serverFS.useOS, + fileExists: serverFS.useOS, + getAccessibleEntries: serverFS.useOS, readFile: path => { callbackCalls.push(path); - return undefined; + return serverFS.useOS; }, + realpath: serverFS.useOS, + stat: serverFS.useOS, + writeFile: serverFS.useOS, + removeFile: serverFS.useOS, }, }); @@ -4435,10 +4568,17 @@ describe("updateSnapshot file systems", { concurrency }, () => { using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs: { + directoryExists: serverFS.useOS, + fileExists: serverFS.useOS, + getAccessibleEntries: serverFS.useOS, readFile: path => { callbackCalls.push(path); - return undefined; + return serverFS.useOS; }, + realpath: serverFS.useOS, + stat: serverFS.useOS, + writeFile: serverFS.useOS, + removeFile: serverFS.useOS, }, }); @@ -4618,9 +4758,16 @@ describe("updateSnapshot file systems", { concurrency }, () => { using api = new API({ cwd: fileURLToPath(new URL("../../../../", import.meta.url).toString()), fs: { + directoryExists: serverFS.useOS, + fileExists: serverFS.useOS, + getAccessibleEntries: serverFS.useOS, + readFile: serverFS.useOS, + realpath: serverFS.useOS, + stat: serverFS.useOS, writeFile: path => { hostWrites.push(path); }, + removeFile: serverFS.useOS, }, }); using snapshot = api.createSnapshot({ @@ -6977,7 +7124,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", () => { @@ -6995,7 +7142,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", () => { @@ -7947,9 +8094,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`); }); @@ -7979,9 +8126,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", () => { @@ -7995,7 +8142,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", () => { @@ -8060,8 +8207,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); @@ -8089,7 +8236,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", () => { @@ -8337,7 +8484,10 @@ describe("runWithTemporaryFileUpdate", { concurrency }, () => { }); }); -function spawnAPIWithFS(files: Record = { ...defaultFiles }, onWrite?: (path: string) => void): { api: API; fs: FileSystem; } { +function spawnAPIWithFS( + files: Record = { ...defaultFiles }, + onWrite?: (path: string) => void, +): { api: API; fs: ReturnType; } { const fs = createVirtualFileSystem(files); if (onWrite) { const writeFile = fs.writeFile!; 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 bdb20c1d77110..670c779a31790 100644 --- a/packages/typescript/test/sync/ast.test.ts +++ b/packages/typescript/test/sync/ast.test.ts @@ -57,7 +57,6 @@ import { visitNode, visitNodes, } from "@typescript/typescript/unstable/ast/visitor"; -import { createVirtualFileSystem } from "@typescript/typescript/unstable/fs"; import { API, Checker, @@ -69,7 +68,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..225279c212d7c 100644 --- a/packages/typescript/test/testUtils.ts +++ b/packages/typescript/test/testUtils.ts @@ -1,3 +1,146 @@ +import { + type FileSystemCallbacks, + type FileSystemEntries, + serverFS, +} 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 | typeof serverFS.useOS; + readFile(fileName: string): any; + realpath: typeof serverFS.identity; + stat: typeof serverFS.fakeStat; + 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: serverFS.identity, + stat: serverFS.fakeStat, + 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 | typeof serverFS.useOS { + const node = getNodeFromPath(directoryName); + if (!node || node.type !== "directory") { + return serverFS.useOS; + } + 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): any { + return content[fileName] ?? serverFS.useOS; + } +} 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 ee85dfc742005..6ed9dbb8d40c9 100644 --- a/tsc/internal/api/callbackfs.go +++ b/tsc/internal/api/callbackfs.go @@ -3,10 +3,14 @@ package api import ( "context" "fmt" + iofs "io/fs" + "slices" + "strings" "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 +24,12 @@ import ( type callbackFS struct { base vfs.FS enabledCallbacks map[string]bool + realpathIdentity bool + fakeStat bool + writeFileNoop bool + removeFileNoop bool + errorCallbacks map[string]bool + caseSensitive *bool // conn and ctx are set after connection is established conn ipc.Conn @@ -33,6 +43,7 @@ const ( callbackDirectoryExists = "directoryExists" callbackGetAccessibleEntries = "getAccessibleEntries" callbackRealpath = "realpath" + callbackStat = "stat" callbackWriteFile = "writeFile" callbackRemoveFile = "removeFile" ) @@ -44,6 +55,7 @@ func isCallbackName(name string) bool { callbackDirectoryExists, callbackGetAccessibleEntries, callbackRealpath, + callbackStat, callbackWriteFile, callbackRemoveFile: return true @@ -55,9 +67,20 @@ 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)) + 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" || cb == "removeFile:noop" { + continue + } if !isCallbackName(cb) { panic("unknown callback name: " + cb) } @@ -66,6 +89,12 @@ func newCallbackFS(base vfs.FS, callbacks []string) *callbackFS { return &callbackFS{ base: base, enabledCallbacks: enabled, + realpathIdentity: slices.Contains(callbacks, "realpath:identity"), + fakeStat: slices.Contains(callbacks, "stat:fakeStat"), + writeFileNoop: slices.Contains(callbacks, "writeFile:noop"), + removeFileNoop: slices.Contains(callbacks, "removeFile:noop"), + errorCallbacks: errorCallbacks, + caseSensitive: caseSensitive, } } @@ -95,34 +124,65 @@ 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(name string, 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") + } + if response.Kind == "error" { + panic("filesystem callback returned serverFS.error: " + name) + } + return response +} + +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 { + return *fs.caseSensitive + } return fs.base.UseCaseSensitiveFileNames() } // 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 -// - null (not found, no fallback): {"content": null} -// - string content: {"content": "..."} 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) } - if len(result) > 0 && string(result) != "null" { - var wrapper struct { - Content *string `json:"content"` - } - if err := json.Unmarshal(result, &wrapper); err != nil { + response := decodeCallbackResponse(callbackReadFile, result) + switch response.Kind { + case "value": + var content string + if err := json.Unmarshal(response.Value, &content); err != nil { panic(err) } - if wrapper.Content == nil { - return "", false - } - 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) @@ -130,13 +190,24 @@ 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) } - if len(result) > 0 && string(result) != "null" { - return string(result) == "true" + response := decodeCallbackResponse(callbackFileExists, 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) @@ -144,13 +215,24 @@ 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) } - if len(result) > 0 && string(result) != "null" { - return string(result) == "true" + response := decodeCallbackResponse(callbackDirectoryExists, 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) @@ -158,25 +240,38 @@ 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) } - if len(result) > 0 { + response := decodeCallbackResponse(callbackGetAccessibleEntries, 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 { - return vfs.Entries{ - Files: rawEntries.Files, - Directories: rawEntries.Directories, + 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 + case "useOS": + return fs.base.GetAccessibleEntries(path) + default: + invalidCallbackResponse(callbackGetAccessibleEntries, response) } } return fs.base.GetAccessibleEntries(path) @@ -184,34 +279,159 @@ 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) } - if len(result) > 0 && string(result) != "null" { + response := decodeCallbackResponse(callbackRealpath, 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 { + 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 { + fs.panicIfError(callbackStat) + if fs.isEnabled(callbackStat) { + result, err := fs.call(callbackStat, path) + if err != nil { + panic(err) + } + response := decodeCallbackResponse(callbackStat, 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(response.Value, &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 + case "missing": + return nil + case "fakeStat": + return fs.fakeStatForPath(path) + case "useOS": + return fs.base.Stat(path) + default: + invalidCallbackResponse(callbackStat, response) + } + } + if fs.fakeStat { + 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 { + 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 { + fs.panicIfError(callbackWriteFile) if fs.isEnabled(callbackWriteFile) { payload := struct { Path string `json:"path"` Data string `json:"data"` }{Path: path, Data: data} - _, err := fs.call(callbackWriteFile, payload) + result, err := fs.call(callbackWriteFile, payload) if err != nil { return err } + response := decodeCallbackResponse(callbackWriteFile, 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 } @@ -225,9 +445,24 @@ func (fs *callbackFS) AppendFile(path string, data string) error { // Remove implements vfs.FS. func (fs *callbackFS) Remove(path string) error { + fs.panicIfError(callbackRemoveFile) if fs.isEnabled(callbackRemoveFile) { - _, err := fs.call(callbackRemoveFile, path) - return err + result, err := fs.call(callbackRemoveFile, path) + if err != nil { + return err + } + response := decodeCallbackResponse(callbackRemoveFile, result) + switch response.Kind { + case "value", "noop": + return nil + case "useOS": + return fs.base.Remove(path) + default: + invalidCallbackResponse(callbackRemoveFile, response) + } + } + if fs.removeFileNoop { + return nil } return fs.base.Remove(path) } @@ -236,8 +471,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..6f3443e0fb0e2 --- /dev/null +++ b/tsc/internal/api/callbackfs_test.go @@ -0,0 +1,273 @@ +package api + +import ( + "context" + "fmt" + iofs "io/fs" + "strings" + "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:fakeStat"}, &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) + conn := &callbackTestConn{ + responses: map[string]json.Value{ + 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) + + 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) + } + 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 { + t.Fatal("expected file symlink metadata") + } + if _, ok := entries.Symlinks["pkg"]; !ok { + t.Fatal("expected directory symlink metadata") + } + + 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") + } +} + +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) + } + }) + } +} + +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: []byte(`{"kind":"useOS"}`)}} + 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(`{"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) + } +} + +func TestCallbackFSError(t *testing.T) { + t.Parallel() + + names := []string{ + callbackReadFile, + callbackFileExists, + callbackDirectoryExists, + callbackGetAccessibleEntries, + callbackRealpath, + callbackStat, + callbackWriteFile, + callbackRemoveFile, + } + + 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 TestCallbackFSRemoveFileNoop(t *testing.T) { + t.Parallel() + + base := vfstest.FromMap(map[string]string{"/retained.ts": "content"}, true) + fs := newCallbackFS(base, []string{"removeFile:noop"}, nil) + + if err := fs.Remove("/retained.ts"); err != nil { + t.Fatal(err) + } + if _, ok := base.ReadFile("/retained.ts"); !ok { + t.Fatal("noop removal unexpectedly reached base filesystem") + } +} + +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() +} 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 }