Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ad71d17
Add optional VFS parameters to updateSnapshot
weswigham Aug 31, 2026
a002d9f
Code review feedback and some extra
weswigham Sep 1, 2026
c8ca98f
Big moves and renames, immutable core request fs, auotmatic request f…
weswigham Sep 2, 2026
13d137b
Corrections of things broken in the move
weswigham Sep 2, 2026
5941477
Remove Snapshot.createProgram
weswigham Sep 2, 2026
9be6f68
Remove dead method
weswigham Sep 2, 2026
ce5172d
Swap mutex to atomics
weswigham Sep 2, 2026
246e31f
Know what, try all 3 options I've looked at and bench em
weswigham Sep 2, 2026
301d564
Sad, the individual mutexes win, the least elegant feeling one
weswigham Sep 2, 2026
f9275ca
Some deduplication and extra edge case tests
weswigham Sep 2, 2026
4772a07
Merge branch 'main' into vfs-v2
weswigham Sep 2, 2026
cd4285e
try/finally -> using
weswigham Sep 2, 2026
17e9e55
Merge branch 'main' into vfs-v2
weswigham Sep 3, 2026
6011f3b
Rename memory/cache to full/layer
weswigham Sep 3, 2026
a0d7979
Merge branch 'main' into vfs-v2
weswigham Sep 4, 2026
1e57d77
Thread request FSes through new snapshot host apis
weswigham Sep 4, 2026
0b2af7d
Ensure removals have priority over mounts
weswigham Sep 8, 2026
6ef48d7
Some changes based on copilot reviews
weswigham Sep 8, 2026
1c80317
Eager request fs compaction to optimize for reading
weswigham Sep 8, 2026
20ea871
Merge branch 'main' into vfs-v2
weswigham Sep 8, 2026
d8df834
Test better, extract directory tree to object heirarchy instead of do…
weswigham Sep 9, 2026
9335c2a
Remove more vestigial functionality from old iterations
weswigham Sep 9, 2026
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
56 changes: 48 additions & 8 deletions packages/typescript/src/api/async/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,9 +349,29 @@ export class API<FromLSP extends boolean = false> implements FormatDiagnosticsHo
}

async updateSnapshot(params?: FromLSP extends true ? LSPUpdateSnapshotParams : UpdateSnapshotParams): Promise<Snapshot> {
return this.updateSnapshotWorker(params);
}

/** @internal */
async updateSnapshotFrom(baseSnapshot: Snapshot, params?: UpdateSnapshotParams): Promise<Snapshot> {
if (!this.activeSnapshots.has(baseSnapshot) || baseSnapshot.isDisposed()) {
throw new Error("Cannot update an inactive snapshot");
}
if (baseSnapshot !== this.latestSnapshot) {
// TODO: Support forking active memory/cache snapshots once the server-side
// ownership, project state, and cache semantics have been worked out.
throw new Error("Snapshot.update can only update the latest snapshot");
}
return this.updateSnapshotWorker(params, baseSnapshot);
}

private async updateSnapshotWorker(
params?: LSPUpdateSnapshotParams | UpdateSnapshotParams,
baseSnapshot?: Snapshot,
): Promise<Snapshot> {
await this.ensureInitialized();

const requestParams = toUpdateSnapshotRequest(params);
const requestParams = toUpdateSnapshotRequest(params, baseSnapshot?.id);
const data = await this.client.apiRequest("updateSnapshot", requestParams);

// Retain cached source files from previous snapshot for unchanged files
Expand Down Expand Up @@ -519,6 +539,10 @@ export class API<FromLSP extends boolean = false> implements FormatDiagnosticsHo

type EnsureInitialized = () => Promise<void>; // @sync: type EnsureInitialized = (() => void) & { gen(): Generator<ProtocolRequest, void, ProtocolResponse["result"]>; };

interface SnapshotOwner extends FormatDiagnosticsHost {
updateSnapshotFrom(baseSnapshot: Snapshot, params?: UpdateSnapshotParams): Promise<Snapshot>;
}

export class InternalAPI {
private client: Client;
private ensureInitialized: EnsureInitialized;
Expand Down Expand Up @@ -555,6 +579,7 @@ export class Snapshot {
private disposed: boolean = false;
private disposePromise: Promise<void> | undefined;
private onDispose: () => void;
private api: SnapshotOwner;
private snapshotRegistry: SnapshotObjectRegistry;
readonly internal: SnapshotInternalAPI;

Expand All @@ -563,18 +588,19 @@ export class Snapshot {
client: Client,
sourceFileCache: SourceFileCache,
toPath: (fileName: string) => Path,
formatDiagnosticsHost: FormatDiagnosticsHost,
api: SnapshotOwner,
onDispose: () => void,
) {
this.id = data.snapshot;
this.client = client;
this.toPath = toPath;
this.api = api;
this.onDispose = onDispose;
this.projectMap = new Map();
this.snapshotRegistry = new SnapshotObjectRegistry(client, this.id, projectId => this.projectMap.get(projectId));

for (const projData of data.projects) {
const project = new Project(projData, this.id, client, sourceFileCache, toPath, formatDiagnosticsHost, this.snapshotRegistry);
const project = new Project(projData, this.id, client, sourceFileCache, toPath, api, this.snapshotRegistry);
this.projectMap.set(toPath(projData.configFileName), project);
}

Expand All @@ -601,10 +627,18 @@ export class Snapshot {
return this.projectMap.get(this.toPath(data.configFileName));
}

/**
* Creates the next snapshot, layering its filesystem over this snapshot's
* filesystem. This snapshot must still be active and be the latest snapshot.
*/
async update(params?: UpdateSnapshotParams): Promise<Snapshot> {
this.ensureNotDisposed();
return this.api.updateSnapshotFrom(this, params);
}

[globalThis.Symbol.dispose](): void {
void this.dispose();
}

dispose(): Promise<void> {
return this.disposePromise ??= this.disposeWorker();
}
Expand Down Expand Up @@ -1391,21 +1425,27 @@ export class Program implements FormatDiagnosticsHost {
}

/**
* Emits files to the configured filesystem.
*
* When the API has a virtual filesystem with a `writeFile` callback, output
* is written there. Otherwise, the server writes directly to the host filesystem.
* Emits files to the configured filesystem. Layer and host filesystems are
* written through; full filesystems remain immutable and return emitted
* files in {@link EmitResult.fileSystem}.
*/
async emit(emitOnly?: EmitOnly): Promise<EmitResult> {
const response = await this.client.apiRequest("emit", {
snapshot: this.snapshotId,
project: this.project.id,
...(emitOnly !== undefined ? { emitOnly } : {}),
});
const fileSystem = response.emittedFilesContents.length
? {
kind: "layer" as const,
files: Object.fromEntries(response.emittedFiles.map((fileName, index) => [fileName, response.emittedFilesContents[index]])),
}
: undefined;
return {
emitSkipped: response.emitSkipped,
diagnostics: response.diagnostics,
emittedFiles: response.emittedFiles,
...(fileSystem ? { fileSystem } : {}),
};
}

Expand Down
7 changes: 6 additions & 1 deletion packages/typescript/src/api/async/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import type {
NamedTupleMember,
ParameterDeclaration,
} from "../../ast/ast.ts";
import type { Diagnostic } from "../proto.ts";
import type {
Diagnostic,
RequestFileSystem,
} from "../proto.ts";
import type {
NodeHandle,
Signature,
Expand Down Expand Up @@ -401,6 +404,8 @@ export interface EmitResult {
readonly emitSkipped: boolean;
readonly diagnostics: readonly Diagnostic[];
readonly emittedFiles: readonly string[];
/** Emitted files captured as a filesystem layer suitable for {@link Snapshot.update}. */
readonly fileSystem?: RequestFileSystem | undefined;
}

export interface EmitOutput {
Expand Down
150 changes: 149 additions & 1 deletion packages/typescript/src/api/fs.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,18 @@
import { getPathComponents } from "./path.ts";
import getExePath from "#getExePath";
import { dirname } from "node:path";
import {
getPathComponents,
normalizePath,
} from "./path.ts";
import type {
RequestDirectoryEntries,
RequestFileSystem,
RequestSymlink,
} from "./proto.generated.ts";
import {
type DocumentIdentifier,
resolveFileName,
} from "./proto.ts";

export interface FileSystemEntries {
files: string[];
Expand All @@ -24,6 +38,140 @@ export interface FileSystem {
/** The callback names supported by the Go server for virtual FS delegation. */
export const fsCallbackNames = ["readFile", "fileExists", "directoryExists", "getAccessibleEntries", "realpath", "writeFile"] as const;

export interface CreateFileSystemOptions {
/** Complete directory listings. Full filesystems derive these from `files` when omitted. */
directories?: Record<string, RequestDirectoryEntries>;
symlinks?: Record<string, RequestSymlink>;
/** Files or directory trees hidden from an underlying snapshot or host filesystem. */
removedPaths?: readonly string[];
}

export interface CreateFileSystemWithLibOptions extends CreateFileSystemOptions {
/** Default library directory used by a custom or non-embedded compiler executable. */
defaultLibraryPath?: string;
}

/**
* Files supplied to a request filesystem. String identifiers are file names;
* use `{ uri }` when supplying a document URI so it can be decoded correctly.
*/
export type RequestFileEntries = readonly (readonly [id: DocumentIdentifier, content: string])[];

/** Creates a full request filesystem, deriving directory listings when omitted. */
export function createFileSystem(
files: RequestFileEntries,
options: CreateFileSystemOptions = {},
): RequestFileSystem {
return createRequestFileSystem("full", files, options);
}

/**
* Creates a full request filesystem with the compiler's default library
* directory mounted read-only through the host filesystem.
*/
export function createFileSystemWithLib(
files: RequestFileEntries,
options: CreateFileSystemWithLibOptions = {},
): RequestFileSystem {
const defaultLibraryPaths = options.defaultLibraryPath
? [normalizePath(options.defaultLibraryPath)]
: [normalizePath("bundled:///libs")];
if (!options.defaultLibraryPath) {
try {
defaultLibraryPaths.push(normalizePath(dirname(getExePath())));
}
catch {
// A socket-connected embedded server can provide bundled libs without
// a locally installed compiler executable.
}
}
const symlinks = { ...options.symlinks };
for (const defaultLibraryPath of defaultLibraryPaths) {
symlinks[defaultLibraryPath] ??= { target: defaultLibraryPath, host: true };
}
return createRequestFileSystem("full", files, {
symlinks,
...(options.directories ? { directories: options.directories } : {}),
...(options.removedPaths?.length ? { removedPaths: options.removedPaths } : {}),
});
}

/** Creates a request filesystem layer, merging base directory listings when omitted. */
export function createFileSystemLayer(
files: RequestFileEntries,
options: CreateFileSystemOptions = {},
): RequestFileSystem {
return createRequestFileSystem("layer", files, options);
}

function createRequestFileSystem(
kind: RequestFileSystem["kind"],
files: RequestFileEntries,
options: CreateFileSystemOptions,
): RequestFileSystem {
const normalizedFiles = new Map<string, string>();
for (const [id, content] of files) {
const fileName = normalizePath(resolveFileName(id));
if (normalizedFiles.has(fileName)) {
throw new Error(`Duplicate request filesystem path: ${fileName}`);
}
normalizedFiles.set(fileName, content);
}
const fileRecord = Object.fromEntries(normalizedFiles);
const directories = options.directories ?? (kind === "full" ? deriveDirectoryListings(fileRecord) : undefined);
return {
kind,
files: fileRecord,
...(directories ? { directories } : {}),
...(options.symlinks ? { symlinks: options.symlinks } : {}),
...(options.removedPaths?.length ? { removedPaths: [...options.removedPaths] } : {}),
};
}

function deriveDirectoryListings(files: Record<string, string>): Record<string, RequestDirectoryEntries> {
const listings = new Map<string, { files: Set<string>; directories: Set<string>; }>();
const getListing = (directory: string) => {
let listing = listings.get(directory);
if (!listing) {
listing = { files: new Set(), directories: new Set() };
listings.set(directory, listing);
}
return listing;
};

for (const inputPath of Object.keys(files)) {
const filePath = normalizePath(inputPath);
const fileName = getBaseName(filePath);
let directory = getDirectory(filePath);
getListing(directory).files.add(fileName);

let parent = getDirectory(directory);
while (parent !== directory) {
getListing(parent).directories.add(getBaseName(directory));
directory = parent;
parent = getDirectory(directory);
}
}

return Object.fromEntries([...listings].map(([directory, listing]) => [directory, {
files: [...listing.files],
directories: [...listing.directories],
}]));
}

function getDirectory(path: string): string {
const components = getPathComponents(path);
if (components.length <= 1) return components[0] ?? "";
components.pop();
const root = components.shift()!;
return root + components.join("/");
}

function getBaseName(path: string): string {
const components = getPathComponents(path);
return components.at(-1) ?? "";
}

interface VDirectory {
type: "directory";
children: Record<string, VNode>;
Expand Down
5 changes: 3 additions & 2 deletions packages/typescript/src/api/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -548,13 +548,14 @@ export function documentURIToFileName(uri: string): string {
throw new Error("invalid file URI: " + uri);
}

const path = decodeURIComponent(parsed.pathname);

// UNC path: file://server/share/...
if (parsed.host !== "") {
return "//" + parsed.host + parsed.pathname;
return "//" + parsed.host + path;
}

// Local file - fix Windows path by removing leading slash before volume
const path = decodeURIComponent(parsed.pathname);
if (path.length >= 3 && path.charCodeAt(0) === CharacterCodesSlash) {
const [volume, rest, ok] = splitVolumePath(path.substring(1));
if (ok) {
Expand Down
Loading