Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 27 additions & 35 deletions packages/typescript/src/api/async/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
}
}
}

Expand All @@ -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, {
Expand All @@ -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();
});
}
Expand All @@ -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<unknown, unknown, void>(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<unknown, unknown, void>(name);
connection.onRequest(requestType, (arg: unknown) => {
return encodeFileSystemCallbackResult(name, callback(arg as string));
});
}
}

Expand Down
220 changes: 76 additions & 144 deletions packages/typescript/src/api/fs.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<string, RequestDirectoryEntries> | undefined;
Expand Down Expand Up @@ -126,130 +185,3 @@ function createRequestFileSystem(
removedPaths: options.removedPaths?.length ? [...options.removedPaths] : undefined,
};
}

interface VDirectory {
type: "directory";
children: Record<string, VNode>;
}

interface VFile {
type: "file";
}

type VNode = VDirectory | VFile;

export function createVirtualFileSystem(files: Record<string, string>): FileSystem {
const root: VDirectory = {
type: "directory",
children: {},
};
const content: Record<string, string> = {};

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;
}
}
Loading
Loading