The protocol cross-link and the TS-internal shutdown-race explanation were too detailed for the category doc. The helper comment keeps a short, self-contained note on the stdin-EOF workaround.
255 lines
8.7 KiB
TypeScript
255 lines
8.7 KiB
TypeScript
// 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 <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[];
|
|
}
|
|
|
|
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<void> | 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<CompletionResult> {
|
|
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<void> {
|
|
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<void> {
|
|
this.#ready ??= this.#initialize();
|
|
return this.#ready;
|
|
}
|
|
|
|
#initialize(): Promise<void> {
|
|
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<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;
|
|
});
|
|
}
|