🔀 Merge chore/test-util-home into main
This commit is contained in:
commit
02634f41de
9 files changed
+458
-7
No files matched your search
+8
-1
@@ -22,7 +22,8 @@
|
|||||||
"eslint/sort-imports": "off",
|
"eslint/sort-imports": "off",
|
||||||
"import/consistent-type-specifier-style": "off",
|
"import/consistent-type-specifier-style": "off",
|
||||||
"unicorn/prefer-export-from": "off",
|
"unicorn/prefer-export-from": "off",
|
||||||
"typescript/method-signature-style": "off"
|
"typescript/method-signature-style": "off",
|
||||||
|
"typescript/promise-function-async": "off"
|
||||||
},
|
},
|
||||||
"options": { "typeAware": true },
|
"options": { "typeAware": true },
|
||||||
"env": { "builtin": true, "es2024": true, "node": true },
|
"env": { "builtin": true, "es2024": true, "node": true },
|
||||||
@@ -41,6 +42,12 @@
|
|||||||
"rules": {
|
"rules": {
|
||||||
"import/no-nodejs-modules": "off"
|
"import/no-nodejs-modules": "off"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": ["src/util/__tests__/**"],
|
||||||
|
"rules": {
|
||||||
|
"import/no-nodejs-modules": "off"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"ignorePatterns": ["dist", "node_modules", "coverage"]
|
"ignorePatterns": ["dist", "node_modules", "coverage"]
|
||||||
|
|||||||
@@ -25,10 +25,10 @@ first-action facts. Do not restate evolving prose here — it will drift.
|
|||||||
Don't silence the type system to force a green run. As an agent these are forbidden:
|
Don't silence the type system to force a green run. As an agent these are forbidden:
|
||||||
|
|
||||||
- `// @ts-nocheck`, `// @ts-ignore`, `// @ts-expect-error`
|
- `// @ts-nocheck`, `// @ts-ignore`, `// @ts-expect-error`
|
||||||
- `// oxlint-disable` / `// oxlint-disable-next-line`
|
- `// oxlint-disable` / `// oxlint-disable-next-line` — the sole exception is the fixed file-level `typescript/no-floating-promises` header at the top of a `*.test.ts` file, spelled exactly as [CONTRIBUTING.md § Rules the tools don't enforce](./CONTRIBUTING.md#rules-the-tools-dont-enforce) prescribes
|
||||||
- `as` casts used to push an expression through (type-aware oxlint already flags unsafe assertions)
|
- `as` casts used to push an expression through (type-aware oxlint already flags unsafe assertions)
|
||||||
|
|
||||||
Fix the root cause with the type system instead — narrowing, generics, `satisfies`, conditional / mapped types, utility types (`NonNullable`, `Exclude`, …). TypeScript can express it; that's the intended tool. The `oxlint-disable`-location rule in [CONTRIBUTING.md § Rules the tools don't enforce](./CONTRIBUTING.md#rules-the-tools-dont-enforce) is a **human** last-resort convention (so a reviewer can spot a deliberate suppression) — it is not permission for you to add one. If the types genuinely cannot express something, stop and surface the conflict (commit message / handover) rather than suppress it.
|
Fix the root cause with the type system instead — narrowing, generics, `satisfies`, conditional / mapped types, utility types (`NonNullable`, `Exclude`, …). TypeScript can express it; that's the intended tool. The `oxlint-disable`-location rule in [CONTRIBUTING.md § Rules the tools don't enforce](./CONTRIBUTING.md#rules-the-tools-dont-enforce) is a **human** last-resort convention (so a reviewer can spot a deliberate suppression) — it is not permission for you to add one, except the one fixed `*.test.ts` file-level header named there. If the types genuinely cannot express something, stop and surface the conflict (commit message / handover) rather than suppress it.
|
||||||
|
|
||||||
The same applies to the checks themselves: **never `git commit --no-verify`** (or otherwise skip a pre-commit / pre-push hook). The checks are fast and offline, so a redundant run is fine — bypassing a hook to get green is the identical anti-pattern. If a commit already skipped a hook, redo it through one: `git reset --soft HEAD~1 && git commit -C <skipped-sha>`.
|
The same applies to the checks themselves: **never `git commit --no-verify`** (or otherwise skip a pre-commit / pre-push hook). The checks are fast and offline, so a redundant run is fine — bypassing a hook to get green is the identical anti-pattern. If a commit already skipped a hook, redo it through one: `git reset --soft HEAD~1 && git commit -C <skipped-sha>`.
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- house shared test helpers under `src/util/__tests__/`, addressed by the `#test-utils/*` package self-reference instead of relative paths
|
||||||
|
|
||||||
## [0.2.0] - 2026-09-16
|
## [0.2.0] - 2026-09-16
|
||||||
|
|
||||||
- allow ternaries and lowercase comments in oxlint
|
- allow ternaries and lowercase comments in oxlint
|
||||||
|
|||||||
+5
-1
@@ -151,7 +151,11 @@ reaches for by default:
|
|||||||
- **`oxlint-disable` directives live in source, not `.oxlintrc.json`.** The
|
- **`oxlint-disable` directives live in source, not `.oxlintrc.json`.** The
|
||||||
trade-off must sit next to the code it silences. This is a _human_ last-resort
|
trade-off must sit next to the code it silences. This is a _human_ last-resort
|
||||||
convention; agents must not add these — see
|
convention; agents must not add these — see
|
||||||
[AGENTS.md § Never do](./AGENTS.md#never-do). (why:
|
[AGENTS.md § Never do](./AGENTS.md#never-do). The sole agent exception is the
|
||||||
|
fixed file-level header at the top of a `*.test.ts` file, spelled exactly:
|
||||||
|
`/* oxlint-disable typescript/no-floating-promises -- expectTypeOf() is a sync
|
||||||
|
type-assertion library that the type-aware linter misidentifies as a promise
|
||||||
|
*/` — any other suppression stays human-last-resort. (why:
|
||||||
[development/tooling.md](./development/tooling.md#oxlint-disable-directives-live-next-to-the-code))
|
[development/tooling.md](./development/tooling.md#oxlint-disable-directives-live-next-to-the-code))
|
||||||
- **Don't put slow / network / whole-project scans in `check` or pre-commit.**
|
- **Don't put slow / network / whole-project scans in `check` or pre-commit.**
|
||||||
Advisory scans are not correctness gates; they belong under `maintain:`. (why:
|
Advisory scans are not correctness gates; they belong under `maintain:`. (why:
|
||||||
|
|||||||
@@ -63,6 +63,54 @@ separated by a blank line; an empty block drops its label.
|
|||||||
- Unlabeled ordering (bare blank lines): the seams still have to be found by
|
- Unlabeled ordering (bare blank lines): the seams still have to be found by
|
||||||
reading; the labels cost nothing.
|
reading; the labels cost nothing.
|
||||||
|
|
||||||
|
## Test helpers
|
||||||
|
|
||||||
|
The rule is enforced by `import/no-relative-parent-imports`; this section records
|
||||||
|
why the mechanism is shaped like this.
|
||||||
|
|
||||||
|
#### Decision (2026-09)
|
||||||
|
|
||||||
|
A shared test helper — code that scattered `*.test.ts` files import to do their
|
||||||
|
testing — lives under `src/util/__tests__/` and is addressed by the
|
||||||
|
`#test-utils/…` self-reference (`package.json#imports`:
|
||||||
|
`"#test-utils/*": "./src/util/__tests__/*"`), never by a relative path:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { LspSession } from "#test-utils/lsp-completion.ts";
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Why
|
||||||
|
|
||||||
|
- The lint rule bans upward (`../`) imports, and a cross-cutting helper can
|
||||||
|
always be placed above _some_ scattered consumer, wherever it goes. Name
|
||||||
|
beats path: a `#test-utils/…` specifier has no direction, so the rule never
|
||||||
|
fires and file moves only touch the one mapping in `package.json`.
|
||||||
|
- `#…` is Node's reserved prefix for _private_ subpath imports: publishing
|
||||||
|
`package.json` leaks nothing and resolves nothing for consumers.
|
||||||
|
- `__tests__` as the folder name is not about tests living there; it is the
|
||||||
|
directory pattern `tsconfig.build.json` already excludes, so a helper can
|
||||||
|
never be emitted into `dist/` and shipped by accident.
|
||||||
|
- Node's own resolver handles `#…` under `--strip-types`, and `tsc` resolves it
|
||||||
|
via the same `imports` field — one mechanism for runtime and type gate, no
|
||||||
|
loader needed.
|
||||||
|
- The helper uses `node:` builtins (it drives a language server), so
|
||||||
|
`import/no-nodejs-modules` is off for `src/util/__tests__/**` in
|
||||||
|
`.oxlintrc.json` — the same exception the old `scripts/**` scope carried.
|
||||||
|
|
||||||
|
#### Rejected
|
||||||
|
|
||||||
|
- A helper beside the tests (`src/util/test.ts`, flat `src/`): composes only
|
||||||
|
while `src/` stays flat; the first nested test reaching it reintroduces the
|
||||||
|
banned upward import.
|
||||||
|
- Bare `~/…` specifier: not valid in `imports` (keys must start with `#`) —
|
||||||
|
resolution fails at runtime with `ERR_MODULE_NOT_FOUND`. A `#~/…` “home”
|
||||||
|
shorthand was dropped in review; `#test-utils/…` states what it is.
|
||||||
|
- tsconfig `paths` alias: resolves for `tsc` but not for plain
|
||||||
|
`node --test --strip-types` (no loader hook), breaking the fast tier.
|
||||||
|
- Turning `import/no-relative-parent-imports` off for test files: the rule
|
||||||
|
still guards non-test helpers importing each other, and the exemption is
|
||||||
|
only needed for the one specifier the mapping already solves cleanly.
|
||||||
|
|
||||||
## Known issues
|
## Known issues
|
||||||
|
|
||||||
- The type-aware linter misidentifies `expectTypeOf()` as a floating promise, so
|
- The type-aware linter misidentifies `expectTypeOf()` as a floating promise, so
|
||||||
|
|||||||
@@ -27,6 +27,9 @@
|
|||||||
],
|
],
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
"imports": {
|
||||||
|
"#test-utils/*": "./src/util/__tests__/*"
|
||||||
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
|
|||||||
+3
-3
@@ -73,9 +73,9 @@ git show-ref --verify --quiet "refs/heads/${BASE}" || {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Derive the remote rather than hardcoding it: this repo has `origin` (ssh) and
|
# Derive the remote rather than hardcoding it: `main` tracks `origin` (ssh)
|
||||||
# `origin_https`, and `main` tracks the latter — `git fetch origin main` would
|
# here; a hardcoded name would check currency against a ref that may not
|
||||||
# check currency against a ref that is never updated here.
|
# exist on a differently configured clone.
|
||||||
# `--quiet` echoes the unresolved `main@{upstream}` literal to stdout on
|
# `--quiet` echoes the unresolved `main@{upstream}` literal to stdout on
|
||||||
# failure, so it cannot be paired with a `$(...) || fallback`; the non-quiet
|
# failure, so it cannot be paired with a `$(...) || fallback`; the non-quiet
|
||||||
# form prints nothing on failure and the fallback runs.
|
# form prints nothing on failure and the fallback runs.
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/* oxlint-disable typescript/no-floating-promises -- expectTypeOf() is a sync type-assertion library that the type-aware linter misidentifies as a promise */
|
||||||
|
import { strict as assert } from "node:assert";
|
||||||
|
import { test } from "node:test";
|
||||||
|
|
||||||
|
import { expectTypeOf } from "expect-type";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type CompletionResult,
|
||||||
|
type CompletionTarget,
|
||||||
|
LspSession,
|
||||||
|
} from "#test-utils/lsp-completion.ts";
|
||||||
|
|
||||||
|
// The `#test-utils/*` self-reference (package.json#imports) is the one route
|
||||||
|
// scattered test files take to reach test helpers; this pins that it resolves
|
||||||
|
// and types without starting a language server
|
||||||
|
// (development/testing.md § Test helpers).
|
||||||
|
test("test-helper home: `#test-utils/…` resolves to the helper and types it", () => {
|
||||||
|
// Arrange
|
||||||
|
const target: CompletionTarget = {
|
||||||
|
file: "src/util/__tests__/lsp-completion.ts",
|
||||||
|
source: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Assert — no session is constructed: the constructor spawns `tsc --lsp`,
|
||||||
|
// so only the resolved values and their types are checked here.
|
||||||
|
expectTypeOf(LspSession).toBeConstructibleWith("repo-root");
|
||||||
|
expectTypeOf<CompletionResult>().toMatchTypeOf<{
|
||||||
|
labels: readonly string[];
|
||||||
|
}>();
|
||||||
|
assert.equal(typeof LspSession, "function");
|
||||||
|
assert.equal(target.file, "src/util/__tests__/lsp-completion.ts");
|
||||||
|
});
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user