@deepseek-ai/dsh-lsp-stdio 0.0.1-rc.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,1188 @@
1
+ import z from "@deepseek-ai/schemastery";
2
+ import { LspError, LspProviderId } from "@deepseek-ai/dsh-lsp";
3
+ import { MAX_TIMER_DELAY_MS, deadline, timeoutOf } from "@deepseek-ai/dsh-timeout";
4
+ import { Buffer as Buffer$1 } from "node:buffer";
5
+ import { assertNever } from "@deepseek-ai/dsh-llm";
6
+ //#region lib/types/abort.js
7
+ /**
8
+ * Shared cancellation helpers for the local LSP provider's host-I/O, queue, and protocol phases.
9
+ * @module @deepseek-ai/dsh-lsp-stdio/abort
10
+ */
11
+ /**
12
+ * Build an abort Error carrying the signal's reason and preserving timeout classification.
13
+ * @param signal - the aborted signal whose reason to surface.
14
+ * @returns the timeout reason if present, else the Error reason, else a generic aborted Error.
15
+ */
16
+ function abortError(signal) {
17
+ const timeout = timeoutOf(signal);
18
+ if (timeout !== void 0) return timeout;
19
+ const reason = signal.reason;
20
+ if (reason instanceof Error) return reason;
21
+ return /* @__PURE__ */ new Error("LSP query aborted");
22
+ }
23
+ /**
24
+ * Throw the signal's classified abort error when it has already fired.
25
+ * @param signal - the optional query cancellation signal.
26
+ */
27
+ function throwIfAborted(signal) {
28
+ if (signal?.aborted) throw abortError(signal);
29
+ }
30
+ /**
31
+ * Await work while allowing a query signal to abandon its wait; the underlying work keeps its own
32
+ * handlers and continues to its owner-defined quiescence boundary.
33
+ * @param work - the owned asynchronous work.
34
+ * @param signal - optional query cancellation.
35
+ * @returns the work result, or a rejection carrying the classified abort reason.
36
+ */
37
+ function abortable(work, signal) {
38
+ if (signal === void 0) return work;
39
+ if (signal.aborted) return Promise.reject(abortError(signal));
40
+ const canceled = Promise.withResolvers();
41
+ const onAbort = () => {
42
+ canceled.reject(abortError(signal));
43
+ };
44
+ signal.addEventListener("abort", onAbort, { once: true });
45
+ const normalized = work.catch((error) => {
46
+ /* v8 ignore next -- owned LSP promises reject with Error; coercion defends the generic helper. */
47
+ throw error instanceof Error ? error : new Error(String(error));
48
+ });
49
+ return Promise.race([normalized, canceled.promise]).finally(() => {
50
+ signal.removeEventListener("abort", onAbort);
51
+ });
52
+ }
53
+ //#endregion
54
+ //#region lib/types/host.js
55
+ /** Filesystem-seam source access for the generic stdio LSP provider. */
56
+ /**
57
+ * Resolve and validate one workspace through `ctx.fs`.
58
+ * @param fs - filesystem provider sharing the language server's execution world.
59
+ * @param workspaceRoot - caller-supplied workspace path.
60
+ * @param signal - optional cancellation around provider operations.
61
+ * @returns stable identity plus process path and file URI.
62
+ */
63
+ async function canonicalizeWorkspace(fs, workspaceRoot, signal) {
64
+ throwIfAborted(signal);
65
+ let target;
66
+ try {
67
+ target = await fs.resolve(workspaceRoot, signal === void 0 ? {} : { signal });
68
+ } catch (error) {
69
+ throwIfAborted(signal);
70
+ throw new Error(`workspace root "${workspaceRoot}" cannot be resolved: ${messageOf(error)}`, { cause: error });
71
+ }
72
+ throwIfAborted(signal);
73
+ const info = await fs.stat(target, signal).catch((error) => {
74
+ throwIfAborted(signal);
75
+ throw error;
76
+ });
77
+ throwIfAborted(signal);
78
+ if (info?.type !== "directory") throw new Error(`workspace root "${workspaceRoot}" is not a directory`);
79
+ return {
80
+ target,
81
+ canonicalPath: fs.processPath(target),
82
+ fileUrl: fs.fileUrl(target)
83
+ };
84
+ }
85
+ /**
86
+ * Resolve, contain, and read one byte-bounded query source through `ctx.fs`.
87
+ * This layer owns the LSP-specific complete-document cap while the filesystem
88
+ * provider owns streaming, regular-file checks, and UTF-8 validation.
89
+ * @param fs - filesystem provider sharing the server's execution world.
90
+ * @param filePath - absolute source path or path relative to `workspace`.
91
+ * @param workspace - already-canonical workspace.
92
+ * @param maxDocumentBytes - largest complete source accepted by this host.
93
+ * @param signal - optional cancellation.
94
+ * @returns canonical file URI and current text.
95
+ */
96
+ async function readHostSource(fs, filePath, workspace, maxDocumentBytes, signal) {
97
+ throwIfAborted(signal);
98
+ let target;
99
+ try {
100
+ target = await fs.resolve(filePath, {
101
+ cwd: workspace.canonicalPath,
102
+ ...signal === void 0 ? {} : { signal }
103
+ });
104
+ } catch (error) {
105
+ throwIfAborted(signal);
106
+ throw new Error(`source "${filePath}" cannot be resolved: ${messageOf(error)}`, { cause: error });
107
+ }
108
+ throwIfAborted(signal);
109
+ if (!fs.contains(workspace.target, target)) throw new Error(`source "${filePath}" resolves outside the workspace`);
110
+ const chunks = [];
111
+ let bytes = 0;
112
+ try {
113
+ const stream = await fs.streamText(target, signal);
114
+ for await (const chunk of stream) {
115
+ throwIfAborted(signal);
116
+ bytes += Buffer$1.byteLength(chunk);
117
+ if (bytes > maxDocumentBytes) break;
118
+ chunks.push(chunk);
119
+ }
120
+ } catch (error) {
121
+ throwIfAborted(signal);
122
+ throw new Error(`source "${filePath}" could not be read: ${messageOf(error)}`, { cause: error });
123
+ }
124
+ if (bytes > maxDocumentBytes) throw new Error(`source "${filePath}" exceeds the ${maxDocumentBytes}-byte limit; reading stopped after ${bytes} bytes`);
125
+ throwIfAborted(signal);
126
+ return {
127
+ fileUrl: fs.fileUrl(target),
128
+ text: chunks.join("")
129
+ };
130
+ }
131
+ function messageOf(error) {
132
+ return error instanceof Error ? error.message : String(error);
133
+ }
134
+ //#endregion
135
+ //#region lib/types/framing.js
136
+ /**
137
+ * LSP base-protocol framing: `Content-Length`-delimited JSON-RPC over a byte stream. The encoder
138
+ * produces one framed buffer; the decoder buffers incoming bytes and yields complete message bodies,
139
+ * bounding the header and total message size so a hostile or broken server cannot exhaust memory.
140
+ * @module @deepseek-ai/dsh-lsp-stdio/framing
141
+ */
142
+ /** The header/body separator in the LSP base protocol. */
143
+ const HEADER_SEPARATOR = "\r\n\r\n";
144
+ /** Cap on the header section so a server that never sends the separator cannot grow the buffer forever. */
145
+ const MAX_HEADER_BYTES = 65536;
146
+ /**
147
+ * Encode one JSON-RPC message as a framed LSP buffer (`Content-Length: N\r\n\r\n<utf-8 json>`).
148
+ * @param message - the JSON-RPC message object to serialize.
149
+ * @returns the framed bytes ready to write to the server's stdin.
150
+ */
151
+ function encodeMessage(message) {
152
+ const body = Buffer.from(JSON.stringify(message), "utf8");
153
+ const header = Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, "ascii");
154
+ return Buffer.concat([header, body]);
155
+ }
156
+ /**
157
+ * A streaming decoder for `Content-Length`-framed JSON-RPC. Feed it stdout chunks; it returns any
158
+ * whole message bodies that completed. It parses only the `Content-Length` header and ignores other
159
+ * headers (e.g. `Content-Type`), matching the base protocol.
160
+ */
161
+ var MessageDecoder = class {
162
+ buffer = Buffer.alloc(0);
163
+ maxMessageBytes;
164
+ /**
165
+ * @param maxMessageBytes - reject any single framed body larger than this (guards memory).
166
+ */
167
+ constructor(maxMessageBytes) {
168
+ this.maxMessageBytes = maxMessageBytes;
169
+ }
170
+ /**
171
+ * Append a chunk and return every message body that is now complete.
172
+ * @param chunk - raw bytes from the server's stdout.
173
+ * @returns the parsed JSON bodies, in arrival order (possibly empty).
174
+ * @throws Error when a header is malformed or a body exceeds `maxMessageBytes`.
175
+ */
176
+ push(chunk) {
177
+ this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk]);
178
+ const messages = [];
179
+ for (;;) {
180
+ const step = this.next();
181
+ if (!step.ready) break;
182
+ messages.push(step.message);
183
+ }
184
+ return messages;
185
+ }
186
+ /** Parse and consume the next complete message, or report that more bytes are needed. */
187
+ next() {
188
+ const separator = this.buffer.indexOf(HEADER_SEPARATOR);
189
+ if (separator < 0) {
190
+ if (this.buffer.length > MAX_HEADER_BYTES) throw new Error(`LSP header exceeded ${MAX_HEADER_BYTES} bytes without a terminator`);
191
+ return { ready: false };
192
+ }
193
+ if (separator > MAX_HEADER_BYTES) throw new Error(`LSP header exceeded ${MAX_HEADER_BYTES} bytes`);
194
+ const contentLength = parseContentLength(this.buffer.toString("ascii", 0, separator));
195
+ if (contentLength > this.maxMessageBytes) throw new Error(`LSP message length ${contentLength} exceeds the ${this.maxMessageBytes}-byte limit`);
196
+ const bodyStart = separator + 4;
197
+ const bodyEnd = bodyStart + contentLength;
198
+ if (this.buffer.length < bodyEnd) return { ready: false };
199
+ const body = this.buffer.toString("utf8", bodyStart, bodyEnd);
200
+ this.buffer = this.buffer.subarray(bodyEnd);
201
+ try {
202
+ return {
203
+ ready: true,
204
+ message: JSON.parse(body)
205
+ };
206
+ } catch (error) {
207
+ /* v8 ignore next -- JSON.parse throws a SyntaxError (an Error); the String() fallback is defensive. */
208
+ throw new Error(`LSP message body was not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
209
+ }
210
+ }
211
+ };
212
+ /** Read the `Content-Length` header value (case-insensitive), rejecting a missing or non-numeric one. */
213
+ function parseContentLength(headerText) {
214
+ for (const line of headerText.split("\r\n")) {
215
+ const colon = line.indexOf(":");
216
+ if (colon < 0) continue;
217
+ if (line.slice(0, colon).trim().toLowerCase() !== "content-length") continue;
218
+ const value = Number(line.slice(colon + 1).trim());
219
+ if (!Number.isInteger(value) || value < 0) throw new Error(`invalid Content-Length header: ${JSON.stringify(line)}`);
220
+ return value;
221
+ }
222
+ throw new Error(`LSP header block missing Content-Length: ${JSON.stringify(headerText)}`);
223
+ }
224
+ //#endregion
225
+ //#region lib/types/connection.js
226
+ /**
227
+ * A JSON-RPC endpoint over one language server spawned through the subprocess
228
+ * capability. Owns id correlation, outbound requests/notifications, and inbound
229
+ * server→client requests: it answers `workspace/configuration` from static
230
+ * config, and rejects `workspace/applyEdit` (this host never applies edits or
231
+ * runs commands). It caps stderr, surfaces framing/decoder failures as a
232
+ * fatal close, and exposes tree-scoped termination through the handle so the
233
+ * instance owns teardown; group/tree mechanics live in the subprocess
234
+ * Service provider.
235
+ * @module @deepseek-ai/dsh-lsp-stdio/connection
236
+ */
237
+ const writeConnectionMessage = (stdin, message, done) => {
238
+ stdin.write(encodeMessage(message), done);
239
+ };
240
+ /** A live JSON-RPC endpoint bound to one child process. */
241
+ var LspConnection = class {
242
+ onServerRequest;
243
+ writer;
244
+ handle;
245
+ stdin;
246
+ decoder;
247
+ pending = /* @__PURE__ */ new Map();
248
+ nextId = 1;
249
+ closeReason;
250
+ /** Set once the process has fully exited; the instance awaits it during teardown. */
251
+ closed;
252
+ /**
253
+ * @param spec - how to launch the server and answer its config requests.
254
+ * @param spawner - the subprocess seam's spawn (the provider passes `ctx.subprocess.spawn`).
255
+ * @param onServerRequest - answers a server→client request; rejects to send an error response.
256
+ * @param writer - message writer; tests inject callback failures without relying on OS pipe races.
257
+ */
258
+ constructor(spec, spawner, onServerRequest, writer = writeConnectionMessage) {
259
+ this.onServerRequest = onServerRequest;
260
+ this.writer = writer;
261
+ this.decoder = new MessageDecoder(spec.maxMessageBytes);
262
+ this.handle = spawner({
263
+ argv: [spec.command, ...spec.args],
264
+ cwd: spec.cwd,
265
+ stdio: {
266
+ stdin: "pipe",
267
+ stdout: "pipe",
268
+ stderr: { maxBytes: spec.maxStderrBytes }
269
+ },
270
+ graceMs: spec.killGraceMs,
271
+ env: spec.env
272
+ });
273
+ /* v8 ignore start -- 'pipe' dispositions expose both streams by the seam contract; defensive. */
274
+ if (this.handle.stdin === void 0 || this.handle.stdout === void 0) throw new Error("lsp-stdio: subprocess implementation dropped a piped protocol stream");
275
+ /* v8 ignore stop */
276
+ this.stdin = this.handle.stdin;
277
+ this.closed = new Promise((resolve) => {
278
+ const close = () => {
279
+ const reason = this.closeReason ?? new Error(this.exitMessage());
280
+ this.closeReason = reason;
281
+ this.failAll(reason);
282
+ resolve();
283
+ };
284
+ this.handle.done.then(close, (error) => {
285
+ this.fail(asError(error));
286
+ close();
287
+ });
288
+ });
289
+ this.stdin.on("error", (error) => {
290
+ this.fail(error);
291
+ });
292
+ this.handle.stdout.on("data", (chunk) => {
293
+ this.onStdout(chunk);
294
+ });
295
+ }
296
+ /** The child's pid, or `-1` when the spawn produced no pid (so signalling is a no-op). */
297
+ get pid() {
298
+ return this.handle.pid;
299
+ }
300
+ /** The retained stderr tail, for diagnostics on a failed server. */
301
+ get stderrTail() {
302
+ /* v8 ignore next -- the collect disposition always exposes a stderr reader; defensive. */
303
+ return this.handle.collected.stderr?.readFrom(0).text ?? "";
304
+ }
305
+ /** Whether the transport has failed even if the child close event has not arrived yet. */
306
+ get failed() {
307
+ return this.closeReason !== void 0;
308
+ }
309
+ /**
310
+ * Test whether a caught error is this connection's retained fatal transport cause.
311
+ * @param error - error caught by the instance or provider.
312
+ * @returns `true` only when this connection produced that exact failure.
313
+ */
314
+ failedWith(error) {
315
+ return this.closeReason === error;
316
+ }
317
+ /**
318
+ * Send a request and await its result.
319
+ * @param method - the JSON-RPC method.
320
+ * @param params - the request params.
321
+ * @returns the response result; rejects on an error response, write failure, or close.
322
+ */
323
+ request(method, params) {
324
+ const id = this.nextId++;
325
+ const promise = new Promise((resolve, reject) => {
326
+ if (this.closeReason !== void 0) {
327
+ reject(this.closeReason);
328
+ return;
329
+ }
330
+ this.pending.set(id, {
331
+ resolve,
332
+ reject
333
+ });
334
+ this.write({
335
+ jsonrpc: "2.0",
336
+ id,
337
+ method,
338
+ params
339
+ }).catch(() => {});
340
+ });
341
+ promise.catch(() => {});
342
+ return promise;
343
+ }
344
+ /**
345
+ * Send a notification (no id, no response).
346
+ * @param method - the JSON-RPC method.
347
+ * @param params - the notification params.
348
+ * @returns a promise that settles when the framed notification has been written.
349
+ */
350
+ notify(method, params) {
351
+ return this.write({
352
+ jsonrpc: "2.0",
353
+ method,
354
+ params
355
+ });
356
+ }
357
+ /**
358
+ * Send a `$/cancelRequest` for an in-flight request id (best-effort; ignores write failure).
359
+ * @param requestId - the numeric id of the request to cancel.
360
+ */
361
+ cancel(requestId) {
362
+ this.write({
363
+ jsonrpc: "2.0",
364
+ method: "$/cancelRequest",
365
+ params: { id: requestId }
366
+ }).catch(() => {});
367
+ }
368
+ /**
369
+ * The id the NEXT `request()` will use, so the instance can pre-arm a cancel.
370
+ * @returns the numeric id the next request will be assigned.
371
+ */
372
+ peekNextId() {
373
+ return this.nextId;
374
+ }
375
+ /** Terminate the server's process tree (the seam's SIGTERM→grace→SIGKILL escalation; idempotent). */
376
+ terminate() {
377
+ this.handle.terminate();
378
+ }
379
+ /**
380
+ * Wait until the owned process tree has exited.
381
+ * @param signal - optional bound for the wait.
382
+ * @returns `true` when the tree exited, or `false` when the signal aborted first.
383
+ */
384
+ async waitForProcessTreeExit(signal) {
385
+ return await this.handle.waitForExit(signal);
386
+ }
387
+ onStdout(chunk) {
388
+ let messages;
389
+ try {
390
+ messages = this.decoder.push(chunk);
391
+ } catch (error) {
392
+ this.fail(asError(error));
393
+ this.handle.terminate();
394
+ return;
395
+ }
396
+ for (const message of messages) this.dispatch(message);
397
+ }
398
+ dispatch(message) {
399
+ if (message === null || typeof message !== "object") return;
400
+ const frame = message;
401
+ const id = frame.id;
402
+ const method = frame.method;
403
+ if (typeof method === "string" && (typeof id === "number" || typeof id === "string")) {
404
+ /* v8 ignore next -- protocol tests exercise response writes; only a simultaneous connection
405
+ failure makes this consumption handler run. */
406
+ this.handleServerRequest(id, method, frame.params).catch(() => {});
407
+ return;
408
+ }
409
+ if (typeof method === "string") return;
410
+ if (typeof id === "number") this.handleResponse(id, frame);
411
+ }
412
+ async handleServerRequest(id, method, params) {
413
+ try {
414
+ const result = await this.onServerRequest(method, params);
415
+ await this.write({
416
+ jsonrpc: "2.0",
417
+ id,
418
+ result
419
+ });
420
+ } catch (error) {
421
+ await this.write({
422
+ jsonrpc: "2.0",
423
+ id,
424
+ error: {
425
+ code: -32601,
426
+ message: asError(error).message
427
+ }
428
+ });
429
+ }
430
+ }
431
+ handleResponse(id, frame) {
432
+ const pending = this.pending.get(id);
433
+ if (!pending) return;
434
+ this.pending.delete(id);
435
+ const error = frame.error;
436
+ if (error !== null && typeof error === "object") {
437
+ const record = error;
438
+ pending.reject(new Error(typeof record.message === "string" ? record.message : "LSP error response"));
439
+ return;
440
+ }
441
+ pending.resolve(frame.result);
442
+ }
443
+ write(message) {
444
+ if (this.closeReason !== void 0) return Promise.reject(this.closeReason);
445
+ return new Promise((resolve, reject) => {
446
+ const done = (error) => {
447
+ if (error === void 0 || error === null) {
448
+ resolve();
449
+ return;
450
+ }
451
+ this.fail(error);
452
+ reject(error);
453
+ };
454
+ try {
455
+ this.writer(this.stdin, message, done);
456
+ } catch (error) {
457
+ const failure = asError(error);
458
+ this.fail(failure);
459
+ reject(failure);
460
+ }
461
+ /* v8 ignore stop */
462
+ });
463
+ }
464
+ /** The exit-close error message, appending the retained stderr tail when the server wrote any. */
465
+ exitMessage() {
466
+ const tail = this.stderrTail.trim();
467
+ return tail === "" ? "language server exited" : `language server exited; stderr: ${tail}`;
468
+ }
469
+ fail(error) {
470
+ /* v8 ignore next -- the second arm (closeReason already set) needs two fail() calls before close; defensive. */
471
+ if (this.closeReason === void 0) this.closeReason = error;
472
+ this.failAll(error);
473
+ }
474
+ failAll(error) {
475
+ const waiting = [...this.pending.values()];
476
+ this.pending.clear();
477
+ for (const pending of waiting) pending.reject(error);
478
+ }
479
+ };
480
+ /** Coerce an unknown thrown value to an `Error`. */
481
+ function asError(value) {
482
+ /* v8 ignore next -- the non-Error branch guards against a non-Error throw, which our paths never produce. */
483
+ return value instanceof Error ? value : new Error(String(value));
484
+ }
485
+ //#endregion
486
+ //#region lib/types/translate.js
487
+ /**
488
+ * Pure protocol translation for the local host: what the server's capabilities allow, and how its
489
+ * `Location`/`LocationLink`/`Hover` payloads normalize into the seam's closed result unions. No I/O
490
+ * or process state — every function here is a pure transform, which the fake-stdio tests pin exactly.
491
+ * @module @deepseek-ai/dsh-lsp-stdio/translate
492
+ */
493
+ /**
494
+ * The `textDocument/*` request method for each LSP operation.
495
+ * @param operation - the LSP operation to map.
496
+ * @returns the LSP request method name.
497
+ */
498
+ function requestMethod(operation) {
499
+ switch (operation) {
500
+ case "goToDefinition": return "textDocument/definition";
501
+ case "findReferences": return "textDocument/references";
502
+ case "goToImplementation": return "textDocument/implementation";
503
+ case "hover": return "textDocument/hover";
504
+ /* v8 ignore next -- exhaustive over the closed LspOperation union; unreachable. */
505
+ default: return assertNever(operation, "requestMethod");
506
+ }
507
+ }
508
+ /** The `ServerCapabilities` provider field backing each operation. */
509
+ function capabilityValue(capabilities, operation) {
510
+ switch (operation) {
511
+ case "goToDefinition": return capabilities.definitionProvider;
512
+ case "findReferences": return capabilities.referencesProvider;
513
+ case "goToImplementation": return capabilities.implementationProvider;
514
+ case "hover": return capabilities.hoverProvider;
515
+ /* v8 ignore next -- exhaustive over the closed LspOperation union; unreachable. */
516
+ default: return assertNever(operation, "capabilityValue");
517
+ }
518
+ }
519
+ /** A provider capability is present when the server sent `true` or an options object (not `false`/absent). */
520
+ function supportsCapability(value) {
521
+ if (value === void 0) return false;
522
+ if (typeof value === "boolean") return value;
523
+ return true;
524
+ }
525
+ /**
526
+ * Whether the server advertises the requested operation.
527
+ * @param capabilities - the server's `initialize` capabilities.
528
+ * @param operation - the LSP operation to check.
529
+ * @returns true when the corresponding provider capability is present.
530
+ */
531
+ function supportsOperation(capabilities, operation) {
532
+ return supportsCapability(capabilityValue(capabilities, operation));
533
+ }
534
+ /**
535
+ * Whether a `textDocumentSync` value permits the transient `didOpen`/`didClose` this host relies on.
536
+ * The legacy enum form implies open/close for `Full`/`Incremental`; the options form requires an
537
+ * explicit `openClose: true`, because the protocol defaults an omitted `openClose` to false.
538
+ * @param sync - the server's advertised `textDocumentSync` capability.
539
+ * @returns true when transient open/close is supported.
540
+ */
541
+ function supportsTransientOpen(sync) {
542
+ if (sync === void 0) return false;
543
+ if (typeof sync === "number") return isOpenCloseKind(sync);
544
+ return sync.openClose === true;
545
+ }
546
+ /** Legacy enum: `Full` (1) or `Incremental` (2) imply open/close support; `None` (0) does not. */
547
+ function isOpenCloseKind(kind) {
548
+ return kind === 1 || kind === 2;
549
+ }
550
+ /**
551
+ * Normalize the negotiated position encoding. An omitted encoding defaults to `utf-16`; any value
552
+ * other than `utf-16` is a protocol error this host does not support.
553
+ * @param encoding - the server's advertised `positionEncoding`, if any.
554
+ * @returns the string `'utf-16'`.
555
+ * @throws Error for any non-`utf-16` encoding.
556
+ */
557
+ function negotiatePositionEncoding(encoding) {
558
+ if (encoding === void 0 || encoding === "utf-16") return "utf-16";
559
+ throw new Error(`server negotiated unsupported position encoding "${encoding}"; this host requires utf-16`);
560
+ }
561
+ /** Convert a wire range to the seam's range (structurally identical, but re-shaped as `readonly`). */
562
+ function toRange(range) {
563
+ return {
564
+ start: {
565
+ line: range.start.line,
566
+ character: range.start.character
567
+ },
568
+ end: {
569
+ line: range.end.line,
570
+ character: range.end.character
571
+ }
572
+ };
573
+ }
574
+ /** Whether a record is a `LocationLink` (has `targetUri` + `targetSelectionRange`). */
575
+ function isLocationLink(value) {
576
+ return typeof value.targetUri === "string" && isRange(value.targetSelectionRange);
577
+ }
578
+ /** Whether a record is a `Location` (has string `uri` + a range). */
579
+ function isLocation(value) {
580
+ return typeof value.uri === "string" && isRange(value.range);
581
+ }
582
+ /** Structural range guard used by both location shapes. */
583
+ function isRange(value) {
584
+ if (value === null || typeof value !== "object") return false;
585
+ const range = value;
586
+ return isPosition(range.start) && isPosition(range.end);
587
+ }
588
+ /** Structural position guard. */
589
+ function isPosition(value) {
590
+ if (value === null || typeof value !== "object") return false;
591
+ const position = value;
592
+ return isProtocolCoordinate(position.line) && isProtocolCoordinate(position.character);
593
+ }
594
+ /** Whether a wire coordinate is a valid nonnegative integer. */
595
+ function isProtocolCoordinate(value) {
596
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
597
+ }
598
+ /**
599
+ * Normalize a navigation result (`Location`, `Location[]`, `LocationLink[]`, or `null`) to the seam's
600
+ * locations. `Location` maps directly; `LocationLink` maps `targetUri` + `targetSelectionRange`.
601
+ * @param payload - the raw `textDocument/definition|references|implementation` result.
602
+ * @returns the normalized locations (empty for `null`/`[]`).
603
+ * @throws Error when an element is neither a `Location` nor a `LocationLink`.
604
+ */
605
+ function normalizeLocations(payload) {
606
+ if (payload === null) return [];
607
+ if (payload === void 0) throw malformedResponse("LSP navigation result was missing");
608
+ const elements = Array.isArray(payload) ? payload : [payload];
609
+ const locations = [];
610
+ for (const element of elements) {
611
+ if (element === null || typeof element !== "object") throw malformedResponse("LSP navigation result contained a non-object entry");
612
+ const record = element;
613
+ if (isLocationLink(record)) {
614
+ const link = record;
615
+ locations.push({
616
+ uri: link.targetUri,
617
+ range: toRange(link.targetSelectionRange)
618
+ });
619
+ } else if (isLocation(record)) {
620
+ const location = record;
621
+ locations.push({
622
+ uri: location.uri,
623
+ range: toRange(location.range)
624
+ });
625
+ } else throw malformedResponse("LSP navigation result contained neither a Location nor a LocationLink");
626
+ }
627
+ return locations;
628
+ }
629
+ /** Render one `MarkedString` (string form verbatim; object form as a language-tagged fenced block). */
630
+ function renderMarkedString(value) {
631
+ if (typeof value === "string") return value;
632
+ return `\`\`\`${value.language}\n${value.value}\n\`\`\``;
633
+ }
634
+ /**
635
+ * Normalize a `Hover` (or `null`) to the seam's hover. `MarkupContent` uses its `value`; a string
636
+ * `MarkedString` is verbatim; a language-tagged `MarkedString` becomes a fenced code block; an array
637
+ * joins its rendered parts with one blank line. The model-facing tool owns the complete result cap.
638
+ * @param payload - the raw `textDocument/hover` result.
639
+ * @returns the normalized hover, or `null` when there is no content.
640
+ * @throws Error when the payload is a non-null, non-object, or structurally invalid hover.
641
+ */
642
+ function normalizeHover(payload) {
643
+ if (payload === null) return null;
644
+ if (payload === void 0) throw malformedResponse("LSP hover result was missing");
645
+ if (typeof payload !== "object") throw malformedResponse("LSP hover result was not an object");
646
+ const hover = payload;
647
+ const contents = renderHoverContents(hover.contents);
648
+ if (contents === "") return null;
649
+ const range = hover.range;
650
+ if (range === void 0) return { contents };
651
+ if (!isRange(range)) throw malformedResponse("LSP hover result contained a malformed range");
652
+ return {
653
+ contents,
654
+ range: toRange(range)
655
+ };
656
+ }
657
+ /** Render the three `Hover.contents` encodings into one string (input is untrusted wire data). */
658
+ function renderHoverContents(contents) {
659
+ if (contents === null || contents === void 0) throw malformedResponse("LSP hover result had no contents");
660
+ if (typeof contents === "string") return contents;
661
+ if (Array.isArray(contents)) return contents.map((value) => {
662
+ if (isMarkedString(value)) return renderMarkedString(value);
663
+ throw malformedResponse("LSP hover contents contained a malformed MarkedString");
664
+ }).join("\n\n");
665
+ if (typeof contents !== "object") throw malformedResponse("LSP hover contents were not MarkupContent, MarkedString, or an array");
666
+ const record = contents;
667
+ if (record.kind === "markdown" || record.kind === "plaintext") {
668
+ if (typeof record.value !== "string") throw malformedResponse("LSP hover MarkupContent value was not a string");
669
+ return record.value;
670
+ }
671
+ if (typeof record.language === "string" && typeof record.value === "string") return renderMarkedString({
672
+ language: record.language,
673
+ value: record.value
674
+ });
675
+ throw malformedResponse("LSP hover contents were not MarkupContent, MarkedString, or an array");
676
+ }
677
+ /** Whether an untrusted value is either form of `MarkedString`. */
678
+ function isMarkedString(value) {
679
+ if (typeof value === "string") return true;
680
+ if (value === null || typeof value !== "object") return false;
681
+ const record = value;
682
+ return typeof record.language === "string" && typeof record.value === "string";
683
+ }
684
+ /** Create the stable structured error used for malformed server result payloads. */
685
+ function malformedResponse(message) {
686
+ return new LspError(message, "LSP_MALFORMED_RESPONSE");
687
+ }
688
+ //#endregion
689
+ //#region lib/types/instance.js
690
+ /**
691
+ * One language-server instance: a connection plus the initialize handshake, the serialized abortable
692
+ * query queue, the transient `didOpen`→request→`didClose` lifecycle, and bounded teardown. One
693
+ * instance owns one `(provider id, canonical workspace)` process. Queries serialize through a single
694
+ * queue so a cancellation that fails to stop the server can terminate it without killing unrelated
695
+ * work; distinct instances run in parallel.
696
+ * @module @deepseek-ai/dsh-lsp-stdio/instance
697
+ */
698
+ /**
699
+ * A single initialized server process. Not exported as a provider — the provider single-flights and
700
+ * pools these. `query()` serializes; `dispose()` rejects queued work and tears the process down.
701
+ */
702
+ var LspInstance = class {
703
+ spec;
704
+ connection;
705
+ capabilities;
706
+ /** The serialization tail: each query awaits the prior one, so lifecycles never interleave. */
707
+ queue = Promise.resolve();
708
+ disposed = false;
709
+ /** The one teardown transaction shared by abort, failure, and explicit disposal. */
710
+ teardownPromise;
711
+ /** Set once the process closes, so the pool can synchronously skip a dead instance. */
712
+ processClosed = false;
713
+ /** Populated once `initialize` succeeds; a failed handshake rejects every query. */
714
+ ready;
715
+ /**
716
+ * @param spec - the launch, initialize, and teardown parameters.
717
+ * @param spawner - the subprocess seam's spawn function.
718
+ * @param writer - optional connection writer used by transport conformance tests.
719
+ */
720
+ constructor(spec, spawner, writer) {
721
+ this.spec = spec;
722
+ this.connection = new LspConnection(spec, spawner, (method, params) => this.answerServerRequest(method, params), writer);
723
+ this.ready = this.initialize();
724
+ this.ready.catch(() => {});
725
+ this.connection.closed.then(() => {
726
+ this.processClosed = true;
727
+ });
728
+ }
729
+ /** Synchronous liveness check: true once the process has closed or the instance was disposed. */
730
+ get dead() {
731
+ return this.processClosed || this.disposed || this.connection.failed;
732
+ }
733
+ /**
734
+ * Test whether a caught query error came from this instance's transport.
735
+ * @param error - error caught by the provider.
736
+ * @returns `true` only for the connection's retained fatal transport cause.
737
+ */
738
+ isTransportFailure(error) {
739
+ return this.connection.failedWith(error);
740
+ }
741
+ /**
742
+ * Run one query through the serialized queue.
743
+ * @param request - the resolved provider query.
744
+ * @param source - the pre-validated, already-read host source (the provider reads before spawning).
745
+ * @param signal - optional cancellation for this query's full lifecycle.
746
+ * @returns the normalized result.
747
+ */
748
+ query(request, source, signal) {
749
+ const run = abortable(this.queue, signal).then(() => this.runQuery(request, source, signal)).catch(async (error) => {
750
+ if (this.isTransportFailure(error)) await this.startTeardown();
751
+ throw error;
752
+ });
753
+ this.queue = this.queue.then(() => run).then(() => void 0, () => void 0);
754
+ return run;
755
+ }
756
+ async initialize() {
757
+ const capabilities = (await this.connection.request("initialize", {
758
+ processId: null,
759
+ rootUri: this.spec.workspaceUri,
760
+ workspaceFolders: [{
761
+ uri: this.spec.workspaceUri,
762
+ name: "workspace"
763
+ }],
764
+ capabilities: CLIENT_CAPABILITIES,
765
+ initializationOptions: this.spec.initializationOptions
766
+ })).capabilities;
767
+ negotiatePositionEncoding(capabilities.positionEncoding);
768
+ this.capabilities = capabilities;
769
+ await this.connection.notify("initialized", {});
770
+ }
771
+ async runQuery(request, source, signal) {
772
+ if (this.disposed) throw new LspError("LSP instance was disposed", "LSP_DISPOSED");
773
+ /* v8 ignore next -- the abortable queue wait rejects a pre-aborted signal before runQuery; this is a belt-and-suspenders guard. */
774
+ if (signal?.aborted) throw abortError(signal);
775
+ try {
776
+ await abortable(this.ready, signal);
777
+ } catch (error) {
778
+ if (!this.dead) await this.startTeardown();
779
+ throw error;
780
+ }
781
+ const capabilities = this.capabilities;
782
+ /* v8 ignore next -- `ready` resolves only after capabilities are set, else it rejects above; defensive. */
783
+ if (capabilities === void 0) throw new Error("LSP instance is not initialized");
784
+ if (!supportsOperation(capabilities, request.operation)) throw new LspError(`server does not support ${request.operation}`, "LSP_UNSUPPORTED_OPERATION");
785
+ if (!supportsTransientOpen(capabilities.textDocumentSync)) throw new LspError("server does not support the transient textDocument/didOpen this host requires", "LSP_UNSUPPORTED_OPERATION");
786
+ const uri = source.fileUrl;
787
+ let opened = false;
788
+ try {
789
+ /* v8 ignore next -- guards an abort landing between the ready wait and didOpen; not deterministically reproducible. */
790
+ if (signal?.aborted) throw abortError(signal);
791
+ try {
792
+ await abortable(this.connection.notify("textDocument/didOpen", { textDocument: {
793
+ uri,
794
+ languageId: request.languageId,
795
+ version: 1,
796
+ text: source.text
797
+ } }), signal);
798
+ } catch (error) {
799
+ await this.startTeardown();
800
+ throw error;
801
+ }
802
+ opened = true;
803
+ const payload = await this.sendRequest(request.operation, uri, request.position, signal);
804
+ return this.normalize(request.operation, payload);
805
+ } finally {
806
+ if (opened && !this.dead) try {
807
+ await this.connection.notify("textDocument/didClose", { textDocument: { uri } });
808
+ } catch {
809
+ try {
810
+ await this.startTeardown();
811
+ } catch {}
812
+ }
813
+ }
814
+ }
815
+ async sendRequest(operation, uri, position, signal) {
816
+ const params = {
817
+ textDocument: { uri },
818
+ position: {
819
+ line: position.line,
820
+ character: position.character
821
+ },
822
+ ...operation === "findReferences" ? { context: { includeDeclaration: true } } : {}
823
+ };
824
+ const requestId = this.connection.peekNextId();
825
+ const send = this.connection.request(requestMethod(operation), params);
826
+ if (signal === void 0) return send;
827
+ return this.raceAbort(send, requestId, signal);
828
+ }
829
+ /**
830
+ * Race a pending request against abort. On abort, send `$/cancelRequest` and give the server a
831
+ * bounded grace to acknowledge; if it does not settle in time, invalidate and tear down the
832
+ * instance so the still-active request cannot overlap the next queued query's document lifecycle.
833
+ */
834
+ async raceAbort(send, requestId, signal) {
835
+ try {
836
+ return await abortable(send, signal);
837
+ } catch (error) {
838
+ if (!signal.aborted) throw error;
839
+ this.connection.cancel(requestId);
840
+ const grace = deadline(void 0, this.spec.killGraceMs, "LSP_CANCEL_GRACE");
841
+ try {
842
+ if (!await Promise.race([send.then(markSettled, markSettled), new Promise((resolve) => {
843
+ /* v8 ignore next -- the cancel-grace deadline signal is freshly armed and not yet aborted here; defensive. */
844
+ if (grace.signal.aborted) {
845
+ resolve(false);
846
+ return;
847
+ }
848
+ grace.signal.addEventListener("abort", () => {
849
+ resolve(false);
850
+ }, { once: true });
851
+ })])) await this.startTeardown();
852
+ } finally {
853
+ grace[Symbol.dispose]();
854
+ }
855
+ throw error;
856
+ }
857
+ }
858
+ normalize(operation, payload) {
859
+ if (operation === "hover") return {
860
+ kind: "hover",
861
+ hover: normalizeHover(payload)
862
+ };
863
+ return {
864
+ kind: "locations",
865
+ locations: normalizeLocations(payload),
866
+ resolvedWorkspaceUri: this.spec.workspaceUri
867
+ };
868
+ }
869
+ answerServerRequest(method, params) {
870
+ if (method === "workspace/configuration") {
871
+ const record = params;
872
+ /* v8 ignore next -- a configuration request always carries an items array; the empty fallback is defensive. */
873
+ const items = Array.isArray(record?.items) ? record.items : [];
874
+ return Promise.resolve(items.map(() => this.spec.configuration));
875
+ }
876
+ if (LIFECYCLE_NOOP_METHODS.has(method)) return Promise.resolve(null);
877
+ if (method === "workspace/applyEdit") return Promise.reject(/* @__PURE__ */ new Error("workspace/applyEdit is not permitted by this host"));
878
+ return Promise.reject(/* @__PURE__ */ new Error(`unsupported server request: ${method}`));
879
+ }
880
+ /**
881
+ * Reject queued work, attempt graceful `shutdown`/`exit`, then escalate SIGTERM→SIGKILL, awaiting
882
+ * process close so nothing outlives disposal.
883
+ */
884
+ async dispose() {
885
+ await this.startTeardown();
886
+ }
887
+ /** Publish disposal once and make every caller await the same quiescence boundary. */
888
+ startTeardown() {
889
+ this.disposed = true;
890
+ this.teardownPromise ??= this.tearDown();
891
+ return this.teardownPromise;
892
+ }
893
+ async tearDown() {
894
+ const shutdownDeadline = deadline(void 0, this.spec.shutdownTimeoutMs, "LSP_SHUTDOWN");
895
+ try {
896
+ await this.gracefulShutdown(shutdownDeadline.signal);
897
+ } catch {} finally {
898
+ shutdownDeadline[Symbol.dispose]();
899
+ }
900
+ await this.forceTerminate();
901
+ }
902
+ /** Best-effort LSP `shutdown`/`exit`, including process close, bounded by `signal`. */
903
+ async gracefulShutdown(signal) {
904
+ await abortable(this.connection.request("shutdown", null), signal);
905
+ await this.connection.notify("exit", null);
906
+ await abortable(this.connection.closed, signal);
907
+ }
908
+ /**
909
+ * Terminate the tree (the seam escalates SIGTERM→`killGraceMs`→SIGKILL),
910
+ * then await leader and helper exit. The awaits are unbounded on purpose:
911
+ * the seam's escalation already committed to SIGKILL, so quiescence — not
912
+ * another timer — is the postcondition disposal owes its callers.
913
+ */
914
+ async forceTerminate() {
915
+ this.connection.terminate();
916
+ await Promise.all([this.connection.closed, this.connection.waitForProcessTreeExit()]);
917
+ }
918
+ };
919
+ /** Server→client request methods this host acknowledges with an empty result (no dynamic registration). */
920
+ const LIFECYCLE_NOOP_METHODS = new Set([
921
+ "window/workDoneProgress/create",
922
+ "client/registerCapability",
923
+ "client/unregisterCapability"
924
+ ]);
925
+ /** Mark a settled request in the cancel-grace race (either outcome means the request finished). */
926
+ function markSettled() {
927
+ return true;
928
+ }
929
+ /**
930
+ * The client capabilities advertised at `initialize`: UTF-16 positions, workspace folders and
931
+ * configuration, markdown/plaintext hover, and link support for definition/implementation. No
932
+ * dynamic registration; the server's returned capabilities are authoritative.
933
+ */
934
+ const CLIENT_CAPABILITIES = {
935
+ general: { positionEncodings: ["utf-16"] },
936
+ workspace: {
937
+ workspaceFolders: true,
938
+ configuration: true
939
+ },
940
+ textDocument: {
941
+ synchronization: { dynamicRegistration: false },
942
+ hover: { contentFormat: ["markdown", "plaintext"] },
943
+ definition: { linkSupport: true },
944
+ implementation: { linkSupport: true },
945
+ references: {}
946
+ }
947
+ };
948
+ //#endregion
949
+ //#region lib/types/index.js
950
+ /**
951
+ * Generic stdio language-server backend for `ctx.lsp`. One plugin instance configures a named table
952
+ * of server commands and registers one isolated provider for each entry. Every provider lazily
953
+ * single-flights one server process per canonical workspace target, serves transient-open queries
954
+ * through it, and replaces a selected transport that fails before or during the next read-only
955
+ * query. Providers read sources through `ctx.fs` and launch servers through
956
+ * `ctx.subprocess`, so both local and remote implementations share one host.
957
+ *
958
+ * Namespace plugin (named exports, no default export). Lifecycle is effect-scoped: disposal
959
+ * unregisters from `ctx.lsp` and tears down every live server.
960
+ * @module @deepseek-ai/dsh-lsp-stdio
961
+ */
962
+ /** Cordis plugin name for loader diagnostics. */
963
+ const name = "lsp-stdio";
964
+ /** Services required by this plugin. */
965
+ const inject = [
966
+ "fs",
967
+ "lsp",
968
+ "subprocess"
969
+ ];
970
+ const LspLocalServerConfig = z.object({
971
+ command: z.string().required(),
972
+ args: z.array(String).default([]),
973
+ env: z.dict(String).default({}),
974
+ extensionToLanguage: z.dict(String).required(),
975
+ initializationOptions: z.any().default(null),
976
+ configuration: z.any().default(null),
977
+ maxMessageBytes: z.number().default(16e6),
978
+ maxStderrBytes: z.number().default(1e6),
979
+ maxDocumentBytes: z.number().default(4e6),
980
+ shutdownTimeoutMs: z.number().max(MAX_TIMER_DELAY_MS).default(5e3),
981
+ killGraceMs: z.number().max(MAX_TIMER_DELAY_MS).default(2e3)
982
+ });
983
+ const Config = z.object({ servers: z.dict(LspLocalServerConfig).required() });
984
+ /** Propagate teardown failures only after every sibling has settled. */
985
+ function throwTeardownFailures(results, message) {
986
+ const failures = [];
987
+ for (const result of results) if (result.status === "rejected") failures.push(result.reason);
988
+ if (failures.length === 1) throw failures[0];
989
+ if (failures.length > 1) throw new AggregateError(failures, message);
990
+ }
991
+ /**
992
+ * Register the configured stdio LSP providers. Resolves every executable at load (after credential
993
+ * scrubbing) before publishing any provider; each process launches lazily on its first matching
994
+ * query.
995
+ * @param ctx - the plugin context carrying `fs`, `lsp`, and `subprocess`.
996
+ * @param config - the resolved plugin configuration (schemastery has filled every default).
997
+ */
998
+ async function apply(ctx, config) {
999
+ const entries = Object.entries(config.servers);
1000
+ if (entries.length === 0) throw new Error("lsp-stdio: servers must contain at least one server");
1001
+ const setupAbort = new AbortController();
1002
+ const stopSetupCancellation = ctx.on("internal/plugin", (fiber) => {
1003
+ if (fiber === ctx.fiber && fiber.uid === null) setupAbort.abort(/* @__PURE__ */ new Error("lsp-stdio setup disposed"));
1004
+ });
1005
+ const providers = await (async () => {
1006
+ const lookups = entries.map(async ([providerId, rawConfig]) => {
1007
+ if (providerId.trim() === "") throw new Error("lsp-stdio: server ids must be non-empty strings");
1008
+ const resolved = rawConfig;
1009
+ validateServerConfig(providerId, resolved);
1010
+ const executable = await ctx.subprocess.resolveExecutable(resolved.command, resolved.env, setupAbort.signal);
1011
+ setupAbort.signal.throwIfAborted();
1012
+ return new LocalLspProvider(providerId, ctx.fs, resolved, executable, (spec) => ctx.subprocess.spawn(spec));
1013
+ });
1014
+ try {
1015
+ return await Promise.all(lookups);
1016
+ } catch (error) {
1017
+ setupAbort.abort(error);
1018
+ await Promise.allSettled(lookups);
1019
+ throw error;
1020
+ } finally {
1021
+ stopSetupCancellation();
1022
+ }
1023
+ })();
1024
+ ctx.effect(() => {
1025
+ const disposers = [];
1026
+ try {
1027
+ for (const provider of providers) disposers.push(ctx.lsp.registerProvider(provider));
1028
+ } catch (error) {
1029
+ for (const dispose of disposers.reverse()) dispose();
1030
+ throw error;
1031
+ }
1032
+ return async () => {
1033
+ for (const dispose of disposers.reverse()) dispose();
1034
+ throwTeardownFailures(await Promise.allSettled(providers.map((provider) => provider.disposeAll())), "lsp-stdio provider teardown failed");
1035
+ };
1036
+ }, "lsp-stdio.registerProviders");
1037
+ }
1038
+ /** Validate one resolved server entry before any provider in the table is registered. */
1039
+ function validateServerConfig(providerId, resolved) {
1040
+ assertTimer(providerId, "shutdownTimeoutMs", resolved.shutdownTimeoutMs);
1041
+ assertTimer(providerId, "killGraceMs", resolved.killGraceMs);
1042
+ assertPositiveInteger(providerId, "maxStderrBytes", resolved.maxStderrBytes);
1043
+ assertPositiveInteger(providerId, "maxMessageBytes", resolved.maxMessageBytes);
1044
+ assertPositiveInteger(providerId, "maxDocumentBytes", resolved.maxDocumentBytes);
1045
+ }
1046
+ /** Reject a timer value Node would clamp instead of scheduling as configured. */
1047
+ function assertTimer(providerId, name, value) {
1048
+ if (!Number.isInteger(value) || value < 1 || value > MAX_TIMER_DELAY_MS) throw new Error(`lsp-stdio: servers.${providerId}.${name} must be a positive integer no greater than ${MAX_TIMER_DELAY_MS}`);
1049
+ }
1050
+ /** Reject a nonpositive or non-integer config value at load, so misconfiguration fails loud. */
1051
+ function assertPositiveInteger(providerId, name, value) {
1052
+ if (!Number.isInteger(value) || value < 1) throw new Error(`lsp-stdio: servers.${providerId}.${name} must be a positive integer`);
1053
+ }
1054
+ /** A pooled generic provider: one server process per canonical workspace, created on demand. */
1055
+ var LocalLspProvider = class {
1056
+ fs;
1057
+ config;
1058
+ executable;
1059
+ spawner;
1060
+ id;
1061
+ extensionToLanguage;
1062
+ /** One live instance per stable canonical workspace identity. */
1063
+ instances = /* @__PURE__ */ new Map();
1064
+ /** One complete source-read→open→query→close serialization tail per canonical workspace. */
1065
+ queues = /* @__PURE__ */ new Map();
1066
+ /** Workspace canonicalizations that have not entered a provider-owned queue yet. */
1067
+ workspaceLookups = /* @__PURE__ */ new Set();
1068
+ lifetime = new AbortController();
1069
+ disposed = false;
1070
+ constructor(providerId, fs, config, executable, spawner) {
1071
+ this.fs = fs;
1072
+ this.config = config;
1073
+ this.executable = executable;
1074
+ this.spawner = spawner;
1075
+ this.id = LspProviderId(providerId);
1076
+ this.extensionToLanguage = config.extensionToLanguage;
1077
+ }
1078
+ /** Read the disposed flag through a method so a `query()` await cannot narrow it to a literal. */
1079
+ isDisposed() {
1080
+ return this.disposed;
1081
+ }
1082
+ /** Reject work that cannot publish or use a provider-owned instance. */
1083
+ assertActive(signal) {
1084
+ /* v8 ignore next -- the seam unregisters this provider before disposal; direct in-flight calls
1085
+ exercise the post-await check instead. */
1086
+ if (this.isDisposed()) throw new LspError("lsp-stdio provider is disposed", "LSP_DISPOSED");
1087
+ if (signal?.aborted) throw abortError(signal);
1088
+ }
1089
+ /** Fuse caller cancellation with provider disposal for every filesystem and protocol await. */
1090
+ querySignal(signal) {
1091
+ return signal === void 0 ? this.lifetime.signal : AbortSignal.any([signal, this.lifetime.signal]);
1092
+ }
1093
+ async query(request, signal) {
1094
+ this.assertActive(signal);
1095
+ const querySignal = this.querySignal(signal);
1096
+ const workspaceResult = canonicalizeWorkspace(this.fs, request.workspaceRoot, querySignal);
1097
+ const workspaceLookup = workspaceResult.then(() => void 0, () => void 0);
1098
+ this.workspaceLookups.add(workspaceLookup);
1099
+ let workspace;
1100
+ try {
1101
+ workspace = await workspaceResult;
1102
+ } finally {
1103
+ this.workspaceLookups.delete(workspaceLookup);
1104
+ }
1105
+ this.assertActive(querySignal);
1106
+ const workspaceKey = workspace.target.targetKey;
1107
+ return this.enqueue(workspaceKey, querySignal, async () => {
1108
+ this.assertActive(querySignal);
1109
+ const source = await readHostSource(this.fs, request.filePath, workspace, this.config.maxDocumentBytes, querySignal);
1110
+ this.assertActive(querySignal);
1111
+ let instance = this.instanceFor(workspaceKey, workspace);
1112
+ try {
1113
+ return await instance.query(request, source, querySignal);
1114
+ } catch (error) {
1115
+ if (!instance.isTransportFailure(error)) throw error;
1116
+ await instance.dispose();
1117
+ this.evictIfCurrent(workspaceKey, instance);
1118
+ this.assertActive(querySignal);
1119
+ instance = this.instanceFor(workspaceKey, workspace);
1120
+ return await instance.query(request, source, querySignal);
1121
+ } finally {
1122
+ if (instance.dead) {
1123
+ await instance.dispose();
1124
+ this.evictIfCurrent(workspaceKey, instance);
1125
+ }
1126
+ }
1127
+ });
1128
+ }
1129
+ /** Serialize one complete query lifecycle for a canonical workspace. */
1130
+ enqueue(workspace, signal, run) {
1131
+ const previous = this.queues.get(workspace) ?? Promise.resolve();
1132
+ const result = abortable(previous, signal).then(run);
1133
+ const tail = previous.then(() => result).then(() => void 0, () => void 0);
1134
+ this.queues.set(workspace, tail);
1135
+ tail.then(() => {
1136
+ if (this.queues.get(workspace) === tail) this.queues.delete(workspace);
1137
+ });
1138
+ return result;
1139
+ }
1140
+ /** Return or synchronously publish the one instance for a canonical workspace. */
1141
+ instanceFor(workspaceKey, workspace) {
1142
+ this.assertActive();
1143
+ const existing = this.instances.get(workspaceKey);
1144
+ if (existing !== void 0) return existing;
1145
+ const created = this.createInstance(workspace);
1146
+ this.instances.set(workspaceKey, created);
1147
+ return created;
1148
+ }
1149
+ /** Drop the slot iff it still contains this instance. */
1150
+ evictIfCurrent(workspace, instance) {
1151
+ /* v8 ignore next -- mismatch requires another query to replace the slot before this finally runs. */
1152
+ if (this.instances.get(workspace) === instance) this.instances.delete(workspace);
1153
+ }
1154
+ createInstance(workspace) {
1155
+ return new LspInstance({
1156
+ command: this.executable,
1157
+ args: this.config.args,
1158
+ cwd: workspace.canonicalPath,
1159
+ workspaceUri: workspace.fileUrl,
1160
+ env: this.config.env,
1161
+ configuration: this.config.configuration,
1162
+ initializationOptions: this.config.initializationOptions,
1163
+ maxMessageBytes: this.config.maxMessageBytes,
1164
+ maxStderrBytes: this.config.maxStderrBytes,
1165
+ shutdownTimeoutMs: this.config.shutdownTimeoutMs,
1166
+ killGraceMs: this.config.killGraceMs
1167
+ }, this.spawner);
1168
+ }
1169
+ /** Dispose every live instance and block further queries. */
1170
+ async disposeAll() {
1171
+ this.disposed = true;
1172
+ this.lifetime.abort(new LspError("lsp-stdio provider is disposed", "LSP_DISPOSED"));
1173
+ const live = [...this.instances.values()];
1174
+ const draining = [...this.queues.values()];
1175
+ const resolving = [...this.workspaceLookups];
1176
+ this.instances.clear();
1177
+ const results = await Promise.allSettled([
1178
+ ...live.map((instance) => instance.dispose()),
1179
+ ...draining,
1180
+ ...resolving
1181
+ ]);
1182
+ this.queues.clear();
1183
+ this.workspaceLookups.clear();
1184
+ throwTeardownFailures(results, "lsp-stdio instance teardown failed");
1185
+ }
1186
+ };
1187
+ //#endregion
1188
+ export { Config, LspConnection, LspInstance, MessageDecoder, apply, canonicalizeWorkspace, encodeMessage, inject, name, negotiatePositionEncoding, normalizeHover, normalizeLocations, readHostSource, requestMethod, supportsOperation, supportsTransientOpen };