Files
tiny-pattern-ts/src/util/__tests__/lsp-completion.ts
T
tmu 9e5522c131 🚚 Home test helpers behind #test-utils/*
src/util/__tests__/ holds shared test helpers, reached from anywhere
in the source tree as `#test-utils/<name>.ts` through
package.json#imports. A bare `~/…` is not a valid imports key — Node
requires `#` — and a direction-free specifier keeps
import/no-relative-parent-imports from ever firing. The `__tests__`
folder name is the pattern tsconfig.build.json already excludes, so
helpers can never ship. Lint scoping: import/no-nodejs-modules off for
the folder (the probe spawns a language server) and
typescript/promise-function-async off to match its promise-returning
style. The LSP probe moves in from scripts/; a smoke test pins the
specifier's resolution and typing without starting a server.
2026-09-17 13:23:27 +00:00

356 lines
11 KiB
TypeScript

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 <file> [<marker>]
*
* 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 <file> [<marker>]";
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<string, unknown> =>
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<string | number, Deferred>();
#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<RpcMessage> {
const id = this.#nextId;
this.#nextId += 1;
const message = frame({ jsonrpc: "2.0", id, method, params });
return new Promise<RpcMessage>((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<void> | undefined;
public constructor(repoRoot: string) {
this.#repoRoot = repoRoot;
this.#client = new LspClient(repoRoot);
}
public completionLabelsAt(
target: CompletionTarget,
): Promise<CompletionResult> {
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<void> {
return this.#client
.request("shutdown")
.then(() => {
this.#client.notify("exit", null);
return delay(EXIT_DELAY_MS);
})
.finally(() => {
this.#client.dispose();
});
}
#ensureInitialized(): Promise<void> {
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<void> => {
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;
});
}