Skip to content
Open
65 changes: 57 additions & 8 deletions packages/typescript/src/api/async/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,9 +354,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 @@ -485,6 +505,15 @@ export class API<FromLSP extends boolean = false> implements FormatDiagnosticsHo
createProgramOptions: CreateProgramOptions,
oldProgram?: Program,
fileChanges?: APIFileChanges,
): Promise<Program> {
return this.createProgramWorker(rootFiles, createProgramOptions, oldProgram, fileChanges);
}

private async createProgramWorker(
rootFiles: readonly DocumentIdentifier[],
createProgramOptions: CreateProgramOptions,
oldProgram?: Program,
fileChanges?: APIFileChanges,
): Promise<Program> {
await this.ensureInitialized();

Expand Down Expand Up @@ -524,6 +553,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 @@ -560,6 +593,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 @@ -568,18 +602,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 @@ -606,10 +641,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 @@ -1396,21 +1439,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. Cache and host filesystems are
* written through; memory 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: "cache" as const,
files: Object.fromEntries(response.emittedFiles.map((fileName, index) => [fileName, response.emittedFilesContents[index]])),
}
: undefined;
return {
emitSkipped: response.emitSkipped,
diagnostics: response.diagnostics,
emittedFiles: response.emittedFiles,
...(fileSystem ? { fileSystem } : {}),
};
}

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 cache 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 CreateRequestFileSystemOptions {
/** Complete directory listings. Memory 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 CreateMemoryFileSystemWithLibOptions extends CreateRequestFileSystemOptions {
/** 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 total memory request filesystem, deriving directory listings when omitted. */
export function createMemoryFileSystem(
files: RequestFileEntries,
options: CreateRequestFileSystemOptions = {},
): RequestFileSystem {
return createRequestFileSystem("memory", files, options);
}

/**
* Creates a total memory request filesystem with the compiler's default library
* directory mounted read-only through the host filesystem.
*/
export function createMemoryFileSystemWithLib(
files: RequestFileEntries,
options: CreateMemoryFileSystemWithLibOptions = {},
): 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("memory", files, {
symlinks,
...(options.directories ? { directories: options.directories } : {}),
...(options.removedPaths?.length ? { removedPaths: options.removedPaths } : {}),
});
}

/** Creates a read-through cache request filesystem, merging host directory listings when omitted. */
export function createCacheFileSystem(
files: RequestFileEntries,
options: CreateRequestFileSystemOptions = {},
): RequestFileSystem {
return createRequestFileSystem("cache", files, options);
}

function createRequestFileSystem(
kind: RequestFileSystem["kind"],
files: RequestFileEntries,
options: CreateRequestFileSystemOptions,
): 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 === "memory" ? 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