// oxlint-disable no-magic-numbers unicorn/no-null - tolerable here, this is a helper 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"; import { type CompletionItem, type CompletionList, CompletionRequest, ConfigurationRequest, createMessageConnection, DidOpenTextDocumentNotification, InitializedNotification, InitializeRequest, type MessageConnection, ShutdownRequest, StreamMessageReader, StreamMessageWriter, } from "vscode-languageserver-protocol/node"; /** * 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[]; } const completionLabels = ( result: CompletionItem[] | CompletionList | null, ): readonly string[] => { if (result === null) { return []; } const items = Array.isArray(result) ? result : result.items; return items .map((item) => item.label) .toSorted((left, right) => left.localeCompare(right)); }; 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 }; }; /** * One language server, initialized on first use, shared across probes. Callers * own the lifecycle and must `close()` it. */ export class LspSession { readonly #child: ChildProcessWithoutNullStreams; readonly #connection: MessageConnection; readonly #repoRoot: string; #ready: Promise | undefined; public constructor(repoRoot: string) { this.#repoRoot = repoRoot; this.#child = spawn( path.join(repoRoot, "node_modules", ".bin", "tsc"), ["--lsp", "--stdio"], { cwd: repoRoot }, ); this.#child.stderr.on("data", (chunk: Buffer) => { process.stderr.write(chunk); }); this.#connection = createMessageConnection( new StreamMessageReader(this.#child.stdout), new StreamMessageWriter(this.#child.stdin), ); // Server -> client requests the server waits on: answer so it proceeds. // `workspace/configuration` wants one reply per requested item; a // catch-all covers the rest (e.g. `client/registerCapability`). this.#connection.onRequest(ConfigurationRequest.type, ({ items }) => items.map(() => null), ); this.#connection.onRequest(() => null); this.#connection.listen(); } public completionLabelsAt( target: CompletionTarget, ): Promise { return this.#ensureInitialized() .then(() => this.#open(target)) .then(({ uri, position }) => this.#connection .sendRequest(CompletionRequest.type, { textDocument: { uri }, position, context: { triggerKind: 1 }, }) .then((result) => ({ position, labels: completionLabels(result), })), ); } /** `shutdown` + close stdin, then kill the server; safe after a failed probe. */ public close(): Promise { return ( this.#connection .sendRequest(ShutdownRequest.type) // The TS 7 Go server logs a bare `context canceled` to stderr // when it handles `exit`; EOF on stdin shuts it down cleanly // (exit 0, no output) instead. .then(() => { this.#child.stdin.end(); }) .then(() => delay(EXIT_DELAY_MS)) .finally(() => { this.#connection.dispose(); this.#child.kill(); }) ); } #ensureInitialized(): Promise { this.#ready ??= this.#initialize(); return this.#ready; } #initialize(): Promise { return this.#connection .sendRequest(InitializeRequest.type, { 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.#connection.sendNotification( InitializedNotification.type, {}, ), ); } #open(target: CompletionTarget): Promise<{ 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. return this.#connection .sendNotification(DidOpenTextDocumentNotification.type, { textDocument: { uri, languageId: "typescript", version: 1, text, }, }) .then(() => ({ 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; }); }