import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { pathToFileURL } from "node:url"; /** * Print the completion labels TypeScript's language server offers at a marker * inside a source file. * * Usage: node --strip-types src/util/__tests__/lsp-completion.ts [] * * The marker (default `DEFAULT_MARKER`, a `COMPLETE` block comment) is stripped * from the source before it is sent; the request position is where the marker * stood. Point it at a scratch file whose expected type is a matcher pattern to * inspect the popup. * * `LspSession` exposes the same probe to the test suite, so completion is * asserted against the server rather than the type system. * * Why a script and not a type assertion: completion is a contextual-type * property that `expect-type` cannot observe, and `Parameters<…>` resolves only * a single overload. The server is the only ground truth. See * development/testing.md § Autocomplete. */ const DEFAULT_MARKER = "/*COMPLETE*/"; const ARGV_PREFIX_LENGTH = 2; const EXIT_DELAY_MS = 200; const FAILURE_EXIT_CODE = 1; const USAGE = "usage: lsp-completion.ts []"; export interface Position { readonly line: number; readonly character: number; } /** A document to probe: `source` carries `marker`, which is stripped. */ export interface CompletionTarget { /** Path relative to the session's repo root; drives URI and resolution. */ readonly file: string; readonly source: string; readonly marker?: string; } export interface CompletionResult { readonly position: Position; readonly labels: readonly string[]; } interface CompletionItem { readonly label: string; } interface RpcMessage { readonly id?: string | number; readonly method?: string; readonly params?: unknown; readonly result?: unknown; readonly error?: unknown; } interface Deferred { readonly resolve: (message: RpcMessage) => void; readonly reject: (error: Error) => void; } const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null; const isRpcMessage = (value: unknown): value is RpcMessage => { if (!isRecord(value)) { return false; } const { id, method } = value; return ( (id === undefined || typeof id === "number" || typeof id === "string") && (method === undefined || typeof method === "string") ); }; const isCompletionItem = (value: unknown): value is CompletionItem => isRecord(value) && typeof value["label"] === "string"; const serverRequestResult = (message: RpcMessage): readonly null[] | null => { if (message.method !== "workspace/configuration") { return null; } const items = isRecord(message.params) ? message.params["items"] : undefined; return Array.isArray(items) ? items.map(() => null) : []; }; const completionLabels = (result: unknown): readonly string[] => { if (!isRecord(result)) { return []; } const { items } = result; if (!Array.isArray(items)) { return []; } return items .filter(isCompletionItem) .map((item) => item.label) .toSorted((left, right) => left.localeCompare(right)); }; const frame = (message: object): string => { const body = JSON.stringify(message); return `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`; }; const markerPosition = (source: string, marker: string): Position => { const index = source.indexOf(marker); if (index === -1) { throw new Error(`marker not found: ${marker}`); } const lines = source.slice(0, index).split("\n"); const lineIndex = lines.length - 1; const line = lines[lineIndex]; return { line: lineIndex, character: line === undefined ? 0 : line.length }; }; /** Minimal JSON-RPC client for `tsc --lsp --stdio`. */ class LspClient { readonly #child: ChildProcessWithoutNullStreams; readonly #pending = new Map(); #buffer = Buffer.alloc(0); #nextId = 1; public constructor(repoRoot: string) { this.#child = spawn( path.join(repoRoot, "node_modules", ".bin", "tsc"), ["--lsp", "--stdio"], { cwd: repoRoot }, ); this.#child.stdout.on("data", (chunk: Buffer) => { this.#consume(chunk); }); this.#child.stderr.on("data", (chunk: Buffer) => { process.stderr.write(chunk); }); } /** Send a request and settle when the server answers it. */ public request(method: string, params?: unknown): Promise { const id = this.#nextId; this.#nextId += 1; const message = frame({ jsonrpc: "2.0", id, method, params }); return new Promise((resolve, reject) => { this.#pending.set(id, { resolve, reject }); this.#child.stdin.write(message); }); } public notify(method: string, params: unknown): void { this.#child.stdin.write(frame({ jsonrpc: "2.0", method, params })); } public dispose(): void { this.#child.stdin.end(); this.#child.kill(); } #consume(chunk: Buffer): void { this.#buffer = Buffer.concat([this.#buffer, chunk]); for (;;) { const headerEnd = this.#buffer.indexOf("\r\n\r\n"); if (headerEnd === -1) { return; } const header = this.#buffer.subarray(0, headerEnd).toString(); const match = /Content-Length: (\d+)/i.exec(header); const lengthText = match === null ? undefined : match[1]; if (lengthText === undefined) { return; } const bodyEnd = headerEnd + 4 + Number(lengthText); if (this.#buffer.length < bodyEnd) { return; } const body = this.#buffer .subarray(headerEnd + 4, bodyEnd) .toString(); this.#buffer = this.#buffer.subarray(bodyEnd); const parsed: unknown = JSON.parse(body); if (isRpcMessage(parsed)) { this.#handle(parsed); } } } #handle(message: RpcMessage): void { // Server -> client request: answer so the server does not stall. if (message.method !== undefined && message.id !== undefined) { this.#child.stdin.write( frame({ jsonrpc: "2.0", id: message.id, result: serverRequestResult(message), }), ); return; } // Response to one of our requests. const { id } = message; if (id === undefined) { return; } const deferred = this.#pending.get(id); if (deferred === undefined) { return; } this.#pending.delete(id); if (message.error === undefined) { deferred.resolve(message); } else { deferred.reject(new Error(JSON.stringify(message.error))); } } } /** * One language server, initialized on first use, shared across probes. Callers * own the lifecycle and must `close()` it. */ export class LspSession { readonly #client: LspClient; readonly #repoRoot: string; #ready: Promise | undefined; public constructor(repoRoot: string) { this.#repoRoot = repoRoot; this.#client = new LspClient(repoRoot); } public completionLabelsAt( target: CompletionTarget, ): Promise { return this.#ensureInitialized().then(() => { const { uri, position } = this.#open(target); return this.#client .request("textDocument/completion", { textDocument: { uri }, position, context: { triggerKind: 1 }, }) .then((response) => ({ position, labels: completionLabels(response.result), })); }); } /** `shutdown` + `exit`, then kill the server; safe after a failed probe. */ public close(): Promise { return this.#client .request("shutdown") .then(() => { this.#client.notify("exit", null); return delay(EXIT_DELAY_MS); }) .finally(() => { this.#client.dispose(); }); } #ensureInitialized(): Promise { this.#ready ??= this.#client .request("initialize", { processId: process.pid, rootUri: pathToFileURL(this.#repoRoot).href, workspaceFolders: [ { uri: pathToFileURL(this.#repoRoot).href, name: "repo" }, ], capabilities: { textDocument: { completion: { completionItem: { snippetSupport: false }, }, publishDiagnostics: {}, }, }, }) .then(() => { this.#client.notify("initialized", {}); }); return this.#ready; } #open(target: CompletionTarget): { readonly uri: string; readonly position: Position; } { const absolute = path.resolve(this.#repoRoot, target.file); const marker = target.marker ?? DEFAULT_MARKER; const position = markerPosition(target.source, marker); const text = target.source.replace(marker, ""); const uri = pathToFileURL(absolute).href; // The server handles `didOpen` in order before the completion request, // so no settle delay is needed. this.#client.notify("textDocument/didOpen", { textDocument: { uri, languageId: "typescript", version: 1, text, }, }); return { uri, position }; } } const main = (args: readonly string[]): Promise => { const [file, markerArgument] = args; if (file === undefined) { process.stderr.write(`${USAGE}\n`); process.exitCode = FAILURE_EXIT_CODE; return Promise.resolve(); } const repoRoot = path.resolve(import.meta.dirname, "../../.."); const absolute = path.resolve(repoRoot, file); const source = fs.readFileSync(absolute, "utf8"); const session = new LspSession(repoRoot); return session .completionLabelsAt({ file, source, marker: markerArgument ?? DEFAULT_MARKER, }) .then(({ position, labels }) => { process.stdout.write( `\n[${file}] completions @ ${position.line}:${position.character}:\n${labels.join(", ")}\n`, ); }) .finally(() => session.close()); }; const isEntryPoint = (): boolean => { const [entry] = process.argv.slice(1, 2); return entry !== undefined && import.meta.url === pathToFileURL(entry).href; }; if (isEntryPoint()) { main(process.argv.slice(ARGV_PREFIX_LENGTH)).catch((error: unknown) => { process.stderr.write( `${error instanceof Error ? error.message : String(error)}\n`, ); process.exitCode = FAILURE_EXIT_CODE; }); }