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
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. 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