dsh-lsp-actions 0.2.0

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.
Files changed (81) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/LICENSE +201 -0
  3. package/README.es.md +177 -0
  4. package/README.hi.md +177 -0
  5. package/README.md +183 -0
  6. package/README.pt.md +177 -0
  7. package/README.zh-CN.md +181 -0
  8. package/cordis.patch.yml +17 -0
  9. package/lib/abort.d.ts +13 -0
  10. package/lib/abort.d.ts.map +1 -0
  11. package/lib/abort.js +31 -0
  12. package/lib/abort.js.map +1 -0
  13. package/lib/client.d.ts +173 -0
  14. package/lib/client.d.ts.map +1 -0
  15. package/lib/client.js +926 -0
  16. package/lib/client.js.map +1 -0
  17. package/lib/connection.d.ts +103 -0
  18. package/lib/connection.d.ts.map +1 -0
  19. package/lib/connection.js +241 -0
  20. package/lib/connection.js.map +1 -0
  21. package/lib/edits.d.ts +17 -0
  22. package/lib/edits.d.ts.map +1 -0
  23. package/lib/edits.js +76 -0
  24. package/lib/edits.js.map +1 -0
  25. package/lib/extension.d.ts +9 -0
  26. package/lib/extension.d.ts.map +1 -0
  27. package/lib/extension.js +17 -0
  28. package/lib/extension.js.map +1 -0
  29. package/lib/extra-tools.d.ts +45 -0
  30. package/lib/extra-tools.d.ts.map +1 -0
  31. package/lib/extra-tools.js +406 -0
  32. package/lib/extra-tools.js.map +1 -0
  33. package/lib/framing.d.ts +33 -0
  34. package/lib/framing.d.ts.map +1 -0
  35. package/lib/framing.js +80 -0
  36. package/lib/framing.js.map +1 -0
  37. package/lib/host.d.ts +64 -0
  38. package/lib/host.d.ts.map +1 -0
  39. package/lib/host.js +152 -0
  40. package/lib/host.js.map +1 -0
  41. package/lib/index.d.ts +42 -0
  42. package/lib/index.d.ts.map +1 -0
  43. package/lib/index.js +78 -0
  44. package/lib/index.js.map +1 -0
  45. package/lib/render.d.ts +364 -0
  46. package/lib/render.d.ts.map +1 -0
  47. package/lib/render.js +485 -0
  48. package/lib/render.js.map +1 -0
  49. package/lib/runner.d.ts +55 -0
  50. package/lib/runner.d.ts.map +1 -0
  51. package/lib/runner.js +149 -0
  52. package/lib/runner.js.map +1 -0
  53. package/lib/sandbox.d.ts +69 -0
  54. package/lib/sandbox.d.ts.map +1 -0
  55. package/lib/sandbox.js +98 -0
  56. package/lib/sandbox.js.map +1 -0
  57. package/lib/seam.d.ts +67 -0
  58. package/lib/seam.d.ts.map +1 -0
  59. package/lib/seam.js +62 -0
  60. package/lib/seam.js.map +1 -0
  61. package/lib/servers.d.ts +120 -0
  62. package/lib/servers.d.ts.map +1 -0
  63. package/lib/servers.js +216 -0
  64. package/lib/servers.js.map +1 -0
  65. package/lib/session-cwd.d.ts +15 -0
  66. package/lib/session-cwd.d.ts.map +1 -0
  67. package/lib/session-cwd.js +16 -0
  68. package/lib/session-cwd.js.map +1 -0
  69. package/lib/tools.d.ts +150 -0
  70. package/lib/tools.d.ts.map +1 -0
  71. package/lib/tools.js +543 -0
  72. package/lib/tools.js.map +1 -0
  73. package/lib/translate.d.ts +181 -0
  74. package/lib/translate.d.ts.map +1 -0
  75. package/lib/translate.js +715 -0
  76. package/lib/translate.js.map +1 -0
  77. package/lib/vocabulary.d.ts +167 -0
  78. package/lib/vocabulary.d.ts.map +1 -0
  79. package/lib/vocabulary.js +20 -0
  80. package/lib/vocabulary.js.map +1 -0
  81. package/package.json +95 -0
package/lib/client.js ADDED
@@ -0,0 +1,926 @@
1
+ /**
2
+ * The plugin's own minimal LSP action client — the fallback for compositions where the official
3
+ * `ctx.lsp` seam does not serve action operations yet. One server process per (server entry,
4
+ * canonical workspace), lazily spawned and serialized; each action runs the transient
5
+ * didOpen→request→didClose lifecycle the servers expect, and teardown is bounded. See
6
+ * `docs/seam-extension-notes.md` for the migration story to the official seam.
7
+ * @module dsh-lsp-actions/client
8
+ */
9
+ import { abortable } from "./abort.js";
10
+ import { LspConnection, LspRpcError } from "./connection.js";
11
+ import { canonicalizeWorkspace, normalizeFileUri, readHostSource, workspaceRelativePath } from "./host.js";
12
+ import { decodeTextEdits, negotiatePositionEncoding, normalizeCodeActions, normalizeCompletionItems, normalizeDiagnostics, normalizeEdits, normalizeInlayHints, normalizeSignatures, normalizeSymbols, normalizeWorkspaceEdit, PositionCodec, requestMethod, supportsAction, supportsPullDiagnostics, supportsTransientOpen, } from "./translate.js";
13
+ import { LspActionError } from "./vocabulary.js";
14
+ import { DEFAULT_MAX_DOCUMENT_BYTES } from "./servers.js";
15
+ /**
16
+ * Pooled minimal LSP action client. Instances are single-flight per canonical workspace and are
17
+ * evicted as soon as they die; every await observes cancellation.
18
+ */
19
+ export class LspActionClient {
20
+ subprocess;
21
+ fs;
22
+ maxDocumentBytes;
23
+ instances = new Map();
24
+ lifetime = new AbortController();
25
+ disposed = false;
26
+ constructor(subprocess, fs,
27
+ /** Byte cap for documents the rename flow reads back to decode cross-file positions. */
28
+ maxDocumentBytes = DEFAULT_MAX_DOCUMENT_BYTES) {
29
+ this.subprocess = subprocess;
30
+ this.fs = fs;
31
+ this.maxDocumentBytes = maxDocumentBytes;
32
+ }
33
+ /** Spawn one connection through the subprocess seam (the real provider at runtime). */
34
+ spawner = spec => this.subprocess.spawn(spec);
35
+ /**
36
+ * Run the diagnostics action through the routed server.
37
+ * @param server - the routed server entry.
38
+ * @param request - the action request.
39
+ * @param signal - optional cancellation.
40
+ * @returns the normalized diagnostics result.
41
+ */
42
+ diagnostics(server, request, signal) {
43
+ return this.run(server, request, signal, instance => instance.diagnostics(request, signal));
44
+ }
45
+ /**
46
+ * Run the formatting action through the routed server; the client only RETURNS edits, it never
47
+ * writes files — the tool applies them through `ctx.fs` write-intent.
48
+ * @param server - the routed server entry.
49
+ * @param request - the action request (carries the optional range).
50
+ * @param signal - optional cancellation.
51
+ * @returns the normalized edits result.
52
+ */
53
+ formatDocument(server, request, signal) {
54
+ return this.run(server, request, signal, instance => instance.formatDocument(request, signal));
55
+ }
56
+ /**
57
+ * Run the completion action through the routed server.
58
+ * @param server - the routed server entry.
59
+ * @param request - the action request (carries the cursor position).
60
+ * @param signal - optional cancellation.
61
+ * @returns the normalized completion result.
62
+ */
63
+ completion(server, request, signal) {
64
+ return this.run(server, request, signal, instance => instance.completion(request, signal));
65
+ }
66
+ /**
67
+ * Run the code actions action through the routed server; the client only REPORTS edits and
68
+ * commands, it never applies them — applying one is the model's own write/edit decision.
69
+ * @param server - the routed server entry.
70
+ * @param request - the action request (carries the optional range and kind filters).
71
+ * @param signal - optional cancellation.
72
+ * @returns the normalized code actions result.
73
+ */
74
+ codeActions(server, request, signal) {
75
+ return this.run(server, request, signal, instance => instance.codeActions(request, signal));
76
+ }
77
+ /**
78
+ * Run a workspace-wide symbol search through the routed server (no document involved).
79
+ * @param server - the routed server entry.
80
+ * @param workspaceRoot - the workspace to search.
81
+ * @param query - the symbol name query.
82
+ * @param signal - optional cancellation.
83
+ * @returns the normalized symbols result.
84
+ */
85
+ workspaceSymbols(server, workspaceRoot, query, signal) {
86
+ return this.runBare(server, workspaceRoot, signal, instance => instance.workspaceSymbols(query, signal));
87
+ }
88
+ /**
89
+ * Run a workspace-wide symbol search with one document kept open: project-based servers (tsls)
90
+ * refuse document-free `workspace/symbol`, so the routing file stays transiently open for the
91
+ * request and closes afterwards.
92
+ * @param server - the routed server entry.
93
+ * @param request - the action request (whose source is kept open during the search).
94
+ * @param query - the symbol name query.
95
+ * @param signal - optional cancellation.
96
+ * @returns the normalized symbols result.
97
+ */
98
+ workspaceSymbolsInDocument(server, request, query, signal) {
99
+ return this.run(server, request, signal, instance => instance.workspaceSymbolsInDocument(request, query, signal));
100
+ }
101
+ /**
102
+ * Run a document symbol listing through the routed server.
103
+ * @param server - the routed server entry.
104
+ * @param request - the action request.
105
+ * @param signal - optional cancellation.
106
+ * @returns the normalized symbols result.
107
+ */
108
+ documentSymbols(server, request, signal) {
109
+ return this.run(server, request, signal, instance => instance.documentSymbols(request, signal));
110
+ }
111
+ /**
112
+ * Run signature help through the routed server.
113
+ * @param server - the routed server entry.
114
+ * @param request - the action request (carries the cursor position).
115
+ * @param signal - optional cancellation.
116
+ * @returns the normalized signatures result.
117
+ */
118
+ signatureHelp(server, request, signal) {
119
+ return this.run(server, request, signal, instance => instance.signatureHelp(request, signal));
120
+ }
121
+ /**
122
+ * Run inlay hints through the routed server.
123
+ * @param server - the routed server entry.
124
+ * @param request - the action request (carries the optional range).
125
+ * @param signal - optional cancellation.
126
+ * @returns the normalized inlay hints result.
127
+ */
128
+ inlayHints(server, request, signal) {
129
+ return this.run(server, request, signal, instance => instance.inlayHints(request, signal));
130
+ }
131
+ /**
132
+ * Run a symbol rename through the routed server; the client only RETURNS the grouped edits, it
133
+ * never writes files — the tool applies them through `ctx.fs` write-intent. Cross-document
134
+ * positions are decoded per document (reading the target text for non-utf-16 servers), so the
135
+ * returned edits are utf-16 for every document.
136
+ * @param server - the routed server entry.
137
+ * @param request - the action request (carries the cursor position).
138
+ * @param newName - the new symbol name.
139
+ * @param signal - optional cancellation.
140
+ * @returns the normalized, decoded rename result.
141
+ */
142
+ rename(server, request, newName, signal) {
143
+ return this.run(server, request, signal, async (instance, querySignal) => {
144
+ const workspace = await canonicalizeWorkspace(this.fs, request.workspaceRoot, querySignal);
145
+ this.assertActive(querySignal);
146
+ const raw = await instance.rename(request, newName, querySignal);
147
+ return this.decodeRenameEdits(raw, request, workspace, querySignal);
148
+ });
149
+ }
150
+ /** Disposed flag through a method so an await cannot narrow it to a literal. */
151
+ isDisposed() {
152
+ return this.disposed;
153
+ }
154
+ /** Reject work that cannot publish or use a client-owned instance. */
155
+ assertActive(signal) {
156
+ if (this.isDisposed())
157
+ throw new LspActionError('LSP action client is disposed', 'LSP_ACTION_SERVER_FAILED');
158
+ if (signal?.aborted)
159
+ throw signal.reason;
160
+ }
161
+ /** Fuse caller cancellation with client disposal for every filesystem and protocol await. */
162
+ querySignal(signal) {
163
+ return signal === undefined
164
+ ? this.lifetime.signal
165
+ : AbortSignal.any([signal, this.lifetime.signal]);
166
+ }
167
+ async run(server, request, signal, body) {
168
+ this.assertActive(signal);
169
+ const querySignal = this.querySignal(signal);
170
+ const workspace = await canonicalizeWorkspace(this.fs, request.workspaceRoot, querySignal);
171
+ this.assertActive(querySignal);
172
+ const key = `${server.serverId}\u0000${workspace.target.targetKey}`;
173
+ return this.runScoped(key, workspace, server, querySignal, body);
174
+ }
175
+ /** The document-free sibling of {@link run}: serves workspace-scoped actions (symbol search). */
176
+ async runBare(server, workspaceRoot, signal, body) {
177
+ this.assertActive(signal);
178
+ const querySignal = this.querySignal(signal);
179
+ const workspace = await canonicalizeWorkspace(this.fs, workspaceRoot, querySignal);
180
+ this.assertActive(querySignal);
181
+ const key = `${server.serverId}\u0000${workspace.target.targetKey}`;
182
+ return this.runScoped(key, workspace, server, querySignal, body);
183
+ }
184
+ /** Serialize and run one action attempt pair (retry-once included) for a canonical workspace. */
185
+ runScoped(key, workspace, server, signal, body) {
186
+ return this.enqueue(key, signal, async () => {
187
+ this.assertActive(signal);
188
+ try {
189
+ return await this.runOnce(key, workspace, server, signal, body);
190
+ }
191
+ catch (error) {
192
+ // A structured server failure with the first instance dying during its handshake gets one
193
+ // fresh-spawn retry, matching the official stdio host's single bad-transport retry.
194
+ // Mid-action failures surface as plain connection errors and never retry here.
195
+ if (isHandshakeServerFailure(error)) {
196
+ return await this.runOnce(key, workspace, server, signal, body);
197
+ }
198
+ throw error;
199
+ }
200
+ });
201
+ }
202
+ /** One attempt: publish/borrow the instance, run the action, evict the dead, arm the idle timer. */
203
+ async runOnce(key, workspace, server, signal, body) {
204
+ const instance = this.instanceFor(key, workspace, server);
205
+ try {
206
+ return await body(instance, signal);
207
+ }
208
+ finally {
209
+ // A dead instance can never serve again: evict it so the next call spawns fresh.
210
+ if (instance.dead) {
211
+ await instance.dispose().catch(() => { });
212
+ if (this.instances.get(key) === instance)
213
+ this.instances.delete(key);
214
+ this.clearIdleTimer(key);
215
+ }
216
+ else if (server.entry.idleTimeoutMs > 0) {
217
+ this.armIdleTimer(key, instance, server.entry.idleTimeoutMs);
218
+ }
219
+ }
220
+ }
221
+ /** Idle-eviction timers per workspace key; a use resets, a fire disposes and evicts. */
222
+ idleTimers = new Map();
223
+ armIdleTimer(key, instance, idleTimeoutMs) {
224
+ this.clearIdleTimer(key);
225
+ const timer = setTimeout(() => {
226
+ if (this.instances.get(key) !== instance)
227
+ return;
228
+ this.instances.delete(key);
229
+ this.idleTimers.delete(key);
230
+ void instance.dispose().catch(() => { });
231
+ }, idleTimeoutMs);
232
+ timer.unref?.();
233
+ this.idleTimers.set(key, timer);
234
+ }
235
+ clearIdleTimer(key) {
236
+ const timer = this.idleTimers.get(key);
237
+ if (timer === undefined)
238
+ return;
239
+ clearTimeout(timer);
240
+ this.idleTimers.delete(key);
241
+ }
242
+ /** Serialize one complete action lifecycle per canonical workspace. */
243
+ enqueue(key, signal, run) {
244
+ const previous = this.tailFor(key);
245
+ const result = abortable(previous, signal).then(run);
246
+ // The tail follows the actual prior work even when this caller aborts its wait, so later
247
+ // callers still serialize without inheriting an earlier action's outcome.
248
+ this.setTail(key, previous.then(() => result).then(() => undefined, () => undefined));
249
+ return result;
250
+ }
251
+ /** The serialization tail for one workspace key (per-instance queues live on the instance). */
252
+ tails = new Map();
253
+ tailFor(key) {
254
+ return this.tails.get(key) ?? Promise.resolve();
255
+ }
256
+ setTail(key, tail) {
257
+ this.tails.set(key, tail);
258
+ void tail.then(() => {
259
+ if (this.tails.get(key) === tail)
260
+ this.tails.delete(key);
261
+ });
262
+ }
263
+ /** Return or synchronously publish the one instance for a canonical workspace. */
264
+ instanceFor(key, workspace, server) {
265
+ this.assertActive();
266
+ const existing = this.instances.get(key);
267
+ if (existing !== undefined)
268
+ return existing;
269
+ const created = new LspActionInstance({
270
+ executable: server.executable,
271
+ args: server.entry.args,
272
+ cwd: workspace.canonicalPath,
273
+ workspaceUri: workspace.fileUrl,
274
+ env: server.entry.env,
275
+ configuration: server.entry.configuration,
276
+ initializationOptions: server.entry.initializationOptions,
277
+ formattingOptions: server.entry.formattingOptions,
278
+ maxMessageBytes: server.entry.maxMessageBytes,
279
+ maxStderrBytes: server.entry.maxStderrBytes,
280
+ killGraceMs: server.entry.killGraceMs,
281
+ shutdownTimeoutMs: server.entry.shutdownTimeoutMs,
282
+ diagnosticsSettleMs: server.entry.diagnosticsSettleMs,
283
+ diagnosticsDebounceMs: server.entry.diagnosticsDebounceMs,
284
+ idleTimeoutMs: server.entry.idleTimeoutMs,
285
+ }, this.spawner);
286
+ this.instances.set(key, created);
287
+ return created;
288
+ }
289
+ /** Dispose every live instance and block further actions. */
290
+ async disposeAll() {
291
+ this.disposed = true;
292
+ this.lifetime.abort(new LspActionError('LSP action client is disposed', 'LSP_ACTION_SERVER_FAILED'));
293
+ for (const timer of this.idleTimers.values())
294
+ clearTimeout(timer);
295
+ this.idleTimers.clear();
296
+ const live = [...this.instances.values()];
297
+ this.instances.clear();
298
+ const results = await Promise.allSettled(live.map(instance => instance.dispose()));
299
+ const failures = [];
300
+ for (const result of results) {
301
+ if (result.status === 'rejected')
302
+ failures.push(result.reason);
303
+ }
304
+ if (failures.length > 0)
305
+ throw new AggregateError(failures, 'lsp-actions instance teardown failed');
306
+ }
307
+ /**
308
+ * Decode one rename's grouped wire edits into utf-16, per document: the origin document uses
309
+ * the source text the server saw; any other document is read back for its codec (utf-16 servers
310
+ * need no read — their positions are already utf-16). An unreadable or out-of-workspace target
311
+ * on a non-utf-16 server is a structured conflict, never silently mis-decoded positions.
312
+ */
313
+ async decodeRenameEdits(raw, request, workspace, signal) {
314
+ const decoded = {};
315
+ const originUri = normalizeFileUri(request.source.fileUrl);
316
+ for (const [uri, edits] of Object.entries(raw.edits)) {
317
+ let codec;
318
+ if (raw.encoding === 'utf-16') {
319
+ codec = undefined;
320
+ }
321
+ else if (normalizeFileUri(uri) === originUri) {
322
+ codec = new PositionCodec(request.source.text);
323
+ }
324
+ else {
325
+ const relative = workspaceRelativePath(workspace, uri);
326
+ if (relative === undefined) {
327
+ throw new LspActionError(`the language server's rename edits a document outside the workspace (${uri}), whose ${raw.encoding} positions cannot be decoded`, 'LSP_ACTION_CONFLICT');
328
+ }
329
+ let target;
330
+ try {
331
+ target = (await readHostSource(this.fs, relative, workspace, this.maxDocumentBytes, signal)).text;
332
+ }
333
+ catch (error) {
334
+ throw new LspActionError(`the language server's rename edits a document that cannot be read (${uri}): ${messageOf(error)}`, 'LSP_ACTION_CONFLICT', { cause: error });
335
+ }
336
+ codec = new PositionCodec(target);
337
+ }
338
+ decoded[uri] = decodeTextEdits(edits, codec, raw.encoding);
339
+ }
340
+ return { kind: 'rename', edits: decoded };
341
+ }
342
+ }
343
+ /** One initialized server process: handshake, serialized queue, and bounded teardown. */
344
+ class LspActionInstance {
345
+ spec;
346
+ connection;
347
+ capabilities;
348
+ positionEncoding = 'utf-16';
349
+ queue = Promise.resolve();
350
+ disposed = false;
351
+ teardownPromise;
352
+ processClosed = false;
353
+ /** True when the handshake (initialize) ended in failure — the one retryable server state. */
354
+ handshakeFailed = false;
355
+ ready;
356
+ /** Latest pushed diagnostic batch per document URI (push-only servers). */
357
+ pushed = new Map();
358
+ pushWaiters = new Map();
359
+ /** Per-document text (and codec) for the transiently opened document, for push-path conversion. */
360
+ openedDocs = new Map();
361
+ constructor(spec, spawner) {
362
+ this.spec = spec;
363
+ this.connection = new LspConnection({
364
+ command: spec.executable,
365
+ args: spec.args,
366
+ cwd: spec.cwd,
367
+ env: spec.env,
368
+ maxMessageBytes: spec.maxMessageBytes,
369
+ maxStderrBytes: spec.maxStderrBytes,
370
+ killGraceMs: spec.killGraceMs,
371
+ configuration: spec.configuration,
372
+ }, spawner, (method, params) => this.answerServerRequest(method, params));
373
+ this.connection.onNotification((method, params) => { this.onNotification(method, params); });
374
+ this.ready = this.initialize().catch((error) => {
375
+ this.handshakeFailed = true;
376
+ throw error;
377
+ });
378
+ // A handshake rejection must not surface as an unhandled rejection before the first action
379
+ // awaits it; actions attach the real handler.
380
+ this.ready.catch(() => { });
381
+ void this.connection.closed.then(() => { this.processClosed = true; });
382
+ }
383
+ /** Synchronous liveness check: true once the process closed or the instance was disposed. */
384
+ get dead() {
385
+ return this.processClosed || this.disposed || this.connection.failed;
386
+ }
387
+ async initialize() {
388
+ try {
389
+ const result = await this.connection.request('initialize', {
390
+ // A subprocess provider may run in another PID namespace or machine;
391
+ // the host PID would let the server monitor an unrelated process.
392
+ processId: null,
393
+ rootUri: this.spec.workspaceUri,
394
+ workspaceFolders: [{ uri: this.spec.workspaceUri, name: 'workspace' }],
395
+ capabilities: CLIENT_CAPABILITIES,
396
+ initializationOptions: this.spec.initializationOptions,
397
+ });
398
+ const capabilities = result.capabilities;
399
+ if (capabilities === null || typeof capabilities !== 'object') {
400
+ throw new LspActionError('language server initialize result had no capabilities', 'LSP_ACTION_MALFORMED_RESPONSE');
401
+ }
402
+ this.capabilities = capabilities;
403
+ this.positionEncoding = negotiatePositionEncoding(this.capabilities.positionEncoding);
404
+ await this.connection.notify('initialized', {});
405
+ }
406
+ catch (error) {
407
+ // Handshake failures are always server-side: the initialize request raced no signal here.
408
+ throw serverFailed(error, this.connection.stderrTail);
409
+ }
410
+ }
411
+ /** Await the handshake, tearing the instance down when it ends in failure. */
412
+ async readyGate(signal) {
413
+ if (this.disposed)
414
+ throw new LspActionError('LSP action client is disposed', 'LSP_ACTION_SERVER_FAILED');
415
+ if (signal?.aborted)
416
+ throw signal.reason;
417
+ try {
418
+ await abortable(this.ready, signal);
419
+ }
420
+ catch (error) {
421
+ if (!this.dead)
422
+ await this.startTeardown().catch(() => { });
423
+ throw error;
424
+ }
425
+ }
426
+ /** Run one action through the serialized queue. */
427
+ runSerialized(signal, run) {
428
+ // Serialize behind prior work but observe abort DURING the queue wait too, so a hung earlier
429
+ // action cannot block a later caller's timeout.
430
+ const result = abortable(this.queue, signal).then(run);
431
+ this.queue = this.queue.then(() => result).then(() => undefined, () => undefined);
432
+ return result;
433
+ }
434
+ diagnostics(request, signal) {
435
+ return this.runSerialized(signal, async () => {
436
+ await this.readyGate(signal);
437
+ this.assertSupports('diagnostics', request);
438
+ const capabilities = this.requiredCapabilities();
439
+ const pull = supportsPullDiagnostics(capabilities);
440
+ return await this.withDocument(request, signal, async (uri) => {
441
+ if (pull) {
442
+ const payload = await this.sendRequest('textDocument/diagnostic', { textDocument: { uri } }, signal);
443
+ return { kind: 'diagnostics', diagnostics: normalizeDiagnostics(payload, this.decodeFor(request.source.text)) };
444
+ }
445
+ const diagnostics = await this.waitForPushedDiagnostics(uri, signal);
446
+ return { kind: 'diagnostics', diagnostics };
447
+ });
448
+ });
449
+ }
450
+ formatDocument(request, signal) {
451
+ return this.runSerialized(signal, async () => {
452
+ await this.readyGate(signal);
453
+ this.assertSupports('formatDocument', request);
454
+ return await this.withDocument(request, signal, async (uri) => {
455
+ const method = requestMethod({ operation: 'formatDocument', range: request.range });
456
+ const payload = await this.sendRequest(method, {
457
+ textDocument: { uri },
458
+ ...this.spec.formattingOptions === null ? {} : { options: this.spec.formattingOptions },
459
+ ...request.range === undefined ? {} : { range: this.encodeRange(request.source.text, request.range) },
460
+ }, signal);
461
+ return { kind: 'edits', edits: normalizeEdits(payload, this.decodeFor(request.source.text)) };
462
+ });
463
+ });
464
+ }
465
+ completion(request, signal) {
466
+ return this.runSerialized(signal, async () => {
467
+ await this.readyGate(signal);
468
+ this.assertSupports('completion', request);
469
+ return await this.withDocument(request, signal, async (uri) => {
470
+ const payload = await this.sendRequest('textDocument/completion', {
471
+ textDocument: { uri },
472
+ position: this.encodePosition(request.source.text, request.position),
473
+ }, signal);
474
+ return { kind: 'completion', items: normalizeCompletionItems(payload, this.decodeFor(request.source.text)) };
475
+ });
476
+ });
477
+ }
478
+ codeActions(request, signal) {
479
+ return this.runSerialized(signal, async () => {
480
+ await this.readyGate(signal);
481
+ this.assertSupports('codeAction', request);
482
+ return await this.withDocument(request, signal, async (uri) => {
483
+ const diagnostics = await this.collectDiagnostics(uri, request, signal);
484
+ const range = request.range ?? firstDiagnosticRange(diagnostics) ?? documentRange(request.source.text);
485
+ const encode = this.encodeAll(request.source.text);
486
+ const payload = await this.sendRequest('textDocument/codeAction', {
487
+ textDocument: { uri },
488
+ range: encode === undefined ? range : { start: encode(range.start), end: encode(range.end) },
489
+ context: {
490
+ diagnostics: diagnostics.map(diagnostic => wireDiagnostic(diagnostic, encode)),
491
+ ...request.onlyKinds === undefined ? {} : { only: request.onlyKinds },
492
+ },
493
+ }, signal);
494
+ return { kind: 'codeActions', items: normalizeCodeActions(payload, this.decodeFor(request.source.text)) };
495
+ });
496
+ });
497
+ }
498
+ workspaceSymbols(query, signal) {
499
+ return this.runSerialized(signal, async () => {
500
+ await this.readyGate(signal);
501
+ const capabilities = this.requiredCapabilities();
502
+ if (!supportsAction(capabilities, 'workspaceSymbol', false)) {
503
+ throw new LspActionError('server does not support workspaceSymbol', 'LSP_ACTION_UNSUPPORTED');
504
+ }
505
+ const payload = await this.sendRequest('workspace/symbol', { query }, signal);
506
+ return { kind: 'symbols', items: normalizeSymbols(payload, undefined) };
507
+ });
508
+ }
509
+ /** The document-open variant of {@link workspaceSymbols}, for project-based servers. */
510
+ workspaceSymbolsInDocument(request, query, signal) {
511
+ return this.runSerialized(signal, async () => {
512
+ await this.readyGate(signal);
513
+ const capabilities = this.requiredCapabilities();
514
+ if (!supportsAction(capabilities, 'workspaceSymbol', false)) {
515
+ throw new LspActionError('server does not support workspaceSymbol', 'LSP_ACTION_UNSUPPORTED');
516
+ }
517
+ return await this.withDocument(request, signal, async () => {
518
+ const payload = await this.sendRequest('workspace/symbol', { query }, signal);
519
+ return { kind: 'symbols', items: normalizeSymbols(payload, undefined) };
520
+ });
521
+ });
522
+ }
523
+ documentSymbols(request, signal) {
524
+ return this.runSerialized(signal, async () => {
525
+ await this.readyGate(signal);
526
+ this.assertSupports('documentSymbol', request);
527
+ return await this.withDocument(request, signal, async (uri) => {
528
+ const payload = await this.sendRequest('textDocument/documentSymbol', { textDocument: { uri } }, signal);
529
+ return { kind: 'symbols', items: normalizeSymbols(payload, uri, this.decodeFor(request.source.text)) };
530
+ });
531
+ });
532
+ }
533
+ signatureHelp(request, signal) {
534
+ return this.runSerialized(signal, async () => {
535
+ await this.readyGate(signal);
536
+ this.assertSupports('signatureHelp', request);
537
+ return await this.withDocument(request, signal, async (uri) => {
538
+ const payload = await this.sendRequest('textDocument/signatureHelp', {
539
+ textDocument: { uri },
540
+ position: this.encodePosition(request.source.text, request.position),
541
+ }, signal);
542
+ return { kind: 'signatures', ...normalizeSignatures(payload) };
543
+ });
544
+ });
545
+ }
546
+ inlayHints(request, signal) {
547
+ return this.runSerialized(signal, async () => {
548
+ await this.readyGate(signal);
549
+ this.assertSupports('inlayHint', request);
550
+ return await this.withDocument(request, signal, async (uri) => {
551
+ const payload = await this.sendRequest('textDocument/inlayHint', {
552
+ textDocument: { uri },
553
+ ...request.range === undefined ? {} : { range: this.encodeRange(request.source.text, request.range) },
554
+ }, signal);
555
+ return { kind: 'inlayHints', items: normalizeInlayHints(payload, this.decodeFor(request.source.text)) };
556
+ });
557
+ });
558
+ }
559
+ /**
560
+ * Run a rename through the server and return the grouped wire edits (positions still in the
561
+ * server's encoding; the client decodes them per document). `textDocument/prepareRename` runs
562
+ * first as advisory validation: a `null` answer is the structured no-symbol failure, while a
563
+ * server without the method (or that rejects it on a transient document) still serves rename.
564
+ */
565
+ rename(request, newName, signal) {
566
+ return this.runSerialized(signal, async () => {
567
+ await this.readyGate(signal);
568
+ this.assertSupports('rename', request);
569
+ return await this.withDocument(request, signal, async (uri) => {
570
+ const position = this.encodePosition(request.source.text, request.position);
571
+ if (position === undefined)
572
+ throw new Error('rename requires a cursor position');
573
+ try {
574
+ const prepared = await this.sendRequest('textDocument/prepareRename', {
575
+ textDocument: { uri },
576
+ position,
577
+ }, signal);
578
+ if (prepared === null || prepared === undefined) {
579
+ throw new LspActionError('no symbol to rename at this position', 'LSP_ACTION_NO_SYMBOL');
580
+ }
581
+ }
582
+ catch (error) {
583
+ // Advisory only: an error RESPONSE (including method-not-found) does not stop the call;
584
+ // a transport failure does.
585
+ if (!(error instanceof LspRpcError))
586
+ throw error;
587
+ }
588
+ const payload = await this.sendRequest('textDocument/rename', {
589
+ textDocument: { uri },
590
+ position,
591
+ newName,
592
+ }, signal);
593
+ return { kind: 'rename', edits: normalizeWorkspaceEdit(payload), encoding: this.positionEncoding };
594
+ });
595
+ });
596
+ }
597
+ /** Collect this document's diagnostics for a code-action context (pull or push-settle). */
598
+ async collectDiagnostics(uri, request, signal) {
599
+ const capabilities = this.requiredCapabilities();
600
+ if (supportsPullDiagnostics(capabilities)) {
601
+ const payload = await this.sendRequest('textDocument/diagnostic', { textDocument: { uri } }, signal);
602
+ return normalizeDiagnostics(payload, this.decodeFor(request.source.text));
603
+ }
604
+ return await this.waitForPushedDiagnostics(uri, signal);
605
+ }
606
+ /** The codec for one document, or undefined when the server speaks utf-16. */
607
+ codecFor(text) {
608
+ return this.positionEncoding === 'utf-16' ? undefined : new PositionCodec(text);
609
+ }
610
+ /** Encode a utf-16 cursor position into the server's encoding. */
611
+ encodePosition(text, position) {
612
+ if (position === undefined)
613
+ return undefined;
614
+ return this.codecFor(text)?.encode(position, this.positionEncoding) ?? position;
615
+ }
616
+ /** Encode a utf-16 range into the server's encoding. */
617
+ encodeRange(text, range) {
618
+ if (range === undefined)
619
+ return undefined;
620
+ const codec = this.codecFor(text);
621
+ if (codec === undefined)
622
+ return range;
623
+ return { start: codec.encode(range.start, this.positionEncoding), end: codec.encode(range.end, this.positionEncoding) };
624
+ }
625
+ /** A result-side position decoder for one document, or undefined for utf-16 servers. */
626
+ decodeFor(text) {
627
+ const codec = this.codecFor(text);
628
+ if (codec === undefined)
629
+ return undefined;
630
+ return position => codec.decode(position, this.positionEncoding);
631
+ }
632
+ /** A position encoder for one document, or undefined for utf-16 servers. */
633
+ encodeAll(text) {
634
+ const codec = this.codecFor(text);
635
+ if (codec === undefined)
636
+ return undefined;
637
+ return position => codec.encode(position, this.positionEncoding);
638
+ }
639
+ requiredCapabilities() {
640
+ if (this.capabilities === undefined) {
641
+ throw new Error('LSP action instance is not initialized');
642
+ }
643
+ return this.capabilities;
644
+ }
645
+ /** Reject an action the server did not advertise, or a sync mode this client cannot serve. */
646
+ assertSupports(operation, request) {
647
+ const capabilities = this.requiredCapabilities();
648
+ if (!supportsAction(capabilities, operation, request.range !== undefined)) {
649
+ throw new LspActionError(`server does not support ${operation}`, 'LSP_ACTION_UNSUPPORTED');
650
+ }
651
+ if (!supportsTransientOpen(capabilities.textDocumentSync)) {
652
+ throw new LspActionError('server does not support the transient textDocument/didOpen this client requires', 'LSP_ACTION_UNSUPPORTED');
653
+ }
654
+ }
655
+ /** Run `body` inside the transient didOpen→didClose document lifecycle. */
656
+ async withDocument(request, signal, body) {
657
+ const uri = request.source.fileUrl;
658
+ const key = normalizeFileUri(uri);
659
+ let opened = false;
660
+ try {
661
+ if (signal?.aborted)
662
+ throw signal.reason;
663
+ try {
664
+ await abortable(this.connection.notify('textDocument/didOpen', {
665
+ textDocument: { uri, languageId: request.languageId, version: 1, text: request.source.text },
666
+ }), signal);
667
+ }
668
+ catch (error) {
669
+ // A canceled backpressured write or failed stdin leaves the protocol stream unusable.
670
+ await this.startTeardown().catch(() => { });
671
+ throw error;
672
+ }
673
+ opened = true;
674
+ // Remember the opened text so push notifications (publishDiagnostics) can be decoded from
675
+ // the server's position encoding.
676
+ this.openedDocs.set(key, { text: request.source.text, codec: this.codecFor(request.source.text) });
677
+ return await body(uri);
678
+ }
679
+ finally {
680
+ this.openedDocs.delete(key);
681
+ // A disposed or closed instance is already tearing down; sending didClose would race it.
682
+ if (opened && !this.dead) {
683
+ try {
684
+ await this.connection.notify('textDocument/didClose', { textDocument: { uri } });
685
+ }
686
+ catch {
687
+ try {
688
+ await this.startTeardown();
689
+ }
690
+ catch {
691
+ // Teardown owns all expected process races; preserve the settled action outcome.
692
+ }
693
+ }
694
+ }
695
+ }
696
+ }
697
+ /** Send a request, racing it against abort with a bounded cancel grace. */
698
+ async sendRequest(method, params, signal) {
699
+ const requestId = this.connection.peekNextId();
700
+ const send = this.connection.request(method, params);
701
+ if (signal === undefined)
702
+ return await send;
703
+ try {
704
+ return await abortable(send, signal);
705
+ }
706
+ catch (error) {
707
+ if (!signal.aborted)
708
+ throw error;
709
+ this.connection.cancel(requestId);
710
+ // Wait, bounded, for the server to honor the cancellation; if it does not, the request is
711
+ // still running: terminate the instance so nothing outlives the action.
712
+ const grace = AbortSignal.timeout(this.spec.killGraceMs);
713
+ const settled = await Promise.race([
714
+ send.then(markSettled, markSettled),
715
+ new Promise((resolve) => {
716
+ if (grace.aborted) {
717
+ resolve(false);
718
+ return;
719
+ }
720
+ grace.addEventListener('abort', () => { resolve(false); }, { once: true });
721
+ }),
722
+ ]);
723
+ if (!settled)
724
+ await this.startTeardown().catch(() => { });
725
+ throw error;
726
+ }
727
+ }
728
+ /**
729
+ * Collect push-only diagnostics for one URI: the LATEST batch wins. Batches replace each other
730
+ * (per LSP push semantics) while the settle window is open, and each push resets a short quiet
731
+ * period (`diagnosticsDebounceMs`), so a server that publishes partial then complete batches
732
+ * yields the complete one; the settle deadline resolves whatever is latest (empty when nothing
733
+ * arrived).
734
+ */
735
+ waitForPushedDiagnostics(uri, signal) {
736
+ const key = normalizeFileUri(uri);
737
+ return new Promise((resolve, reject) => {
738
+ const settle = AbortSignal.timeout(this.spec.diagnosticsSettleMs);
739
+ const fused = signal === undefined ? settle : AbortSignal.any([signal, settle]);
740
+ let done = false;
741
+ let timer;
742
+ const takeLatest = () => {
743
+ if (done)
744
+ return;
745
+ done = true;
746
+ if (timer !== undefined)
747
+ clearTimeout(timer);
748
+ this.pushWaiters.delete(key);
749
+ const batch = this.pushed.get(key);
750
+ this.pushed.delete(key);
751
+ if (signal?.aborted) {
752
+ reject(signal.reason);
753
+ return;
754
+ }
755
+ resolve(batch ?? []);
756
+ };
757
+ // A push resets the quiet period: keep collecting while the server is still talking.
758
+ const onPush = () => {
759
+ if (timer !== undefined)
760
+ clearTimeout(timer);
761
+ timer = setTimeout(takeLatest, this.spec.diagnosticsDebounceMs);
762
+ timer.unref?.();
763
+ };
764
+ if (fused.aborted) {
765
+ takeLatest();
766
+ return;
767
+ }
768
+ this.pushWaiters.set(key, onPush);
769
+ fused.addEventListener('abort', takeLatest, { once: true });
770
+ });
771
+ }
772
+ onNotification(method, params) {
773
+ if (method !== 'textDocument/publishDiagnostics')
774
+ return;
775
+ const record = params;
776
+ if (record === null || typeof record !== 'object' || typeof record.uri !== 'string')
777
+ return;
778
+ let diagnostics;
779
+ try {
780
+ const codec = this.openedDocs.get(normalizeFileUri(record.uri))?.codec;
781
+ const decode = codec === undefined
782
+ ? undefined
783
+ : (position) => codec.decode(position, this.positionEncoding);
784
+ diagnostics = normalizeDiagnostics(record.diagnostics, decode);
785
+ }
786
+ catch {
787
+ // A malformed push batch cannot fail a notification path; the next pull/settle sees no batch.
788
+ return;
789
+ }
790
+ // The latest batch replaces any earlier one for the same URI, per LSP push semantics. URIs are
791
+ // compared normalized: servers re-spell file URIs (tsserver pushes lowercase percent-encoded
792
+ // drive letters), so a raw string comparison would miss every batch.
793
+ const key = normalizeFileUri(record.uri);
794
+ this.pushed.set(key, diagnostics);
795
+ const waiter = this.pushWaiters.get(key);
796
+ if (waiter !== undefined)
797
+ waiter();
798
+ }
799
+ answerServerRequest(method, params) {
800
+ if (method === 'workspace/configuration') {
801
+ // Answer each requested item with its section value when the static configuration object
802
+ // carries the section; otherwise fall back to the one static value for every item.
803
+ const record = params;
804
+ const items = Array.isArray(record?.items) ? record.items : [];
805
+ return Promise.resolve(items.map(item => configurationSectionValue(this.spec.configuration, item)));
806
+ }
807
+ if (LIFECYCLE_NOOP_METHODS.has(method)) {
808
+ // Accept lifecycle bookkeeping requests with an empty result; we register nothing dynamic.
809
+ return Promise.resolve(null);
810
+ }
811
+ if (method === 'workspace/applyEdit') {
812
+ // This client never applies server edits; the tool applies them through ctx.fs write-intent.
813
+ return Promise.reject(new Error('workspace/applyEdit is not permitted by this client'));
814
+ }
815
+ return Promise.reject(new Error(`unsupported server request: ${method}`));
816
+ }
817
+ /** Reject queued work, attempt graceful shutdown, then escalate to tree termination. */
818
+ async dispose() {
819
+ await this.startTeardown();
820
+ }
821
+ /** Publish disposal once and make every caller await the same quiescence boundary. */
822
+ startTeardown() {
823
+ this.disposed = true;
824
+ this.teardownPromise ??= this.tearDown();
825
+ return this.teardownPromise;
826
+ }
827
+ async tearDown() {
828
+ const budget = AbortSignal.timeout(this.spec.shutdownTimeoutMs);
829
+ try {
830
+ await abortable(this.connection.request('shutdown', null), budget);
831
+ await abortable(this.connection.notify('exit', null), budget);
832
+ await abortable(this.connection.closed, budget);
833
+ }
834
+ catch {
835
+ // Graceful shutdown failed or timed out; process-tree cleanup below remains authoritative.
836
+ }
837
+ this.connection.terminate();
838
+ // The terminate escalation already committed to SIGKILL, so quiescence — not another timer —
839
+ // is the postcondition disposal owes its callers.
840
+ await Promise.all([
841
+ this.connection.closed,
842
+ this.connection.waitForProcessTreeExit(),
843
+ ]);
844
+ }
845
+ }
846
+ /** Mark a settled request in the cancel-grace race (either outcome means the request finished). */
847
+ function markSettled() {
848
+ return true;
849
+ }
850
+ /** Whether a failure is the structured handshake failure the client retries once. */
851
+ function isHandshakeServerFailure(error) {
852
+ return error instanceof LspActionError && error.code === 'LSP_ACTION_SERVER_FAILED';
853
+ }
854
+ /** Project one normalized diagnostic back to its wire form (for code-action contexts). */
855
+ function wireDiagnostic(diagnostic, encode) {
856
+ const range = encode === undefined
857
+ ? diagnostic.range
858
+ : { start: encode(diagnostic.range.start), end: encode(diagnostic.range.end) };
859
+ return {
860
+ range,
861
+ severity: diagnostic.severity,
862
+ message: diagnostic.message,
863
+ ...diagnostic.source === undefined ? {} : { source: diagnostic.source },
864
+ ...diagnostic.code === undefined ? {} : { code: diagnostic.code },
865
+ };
866
+ }
867
+ /** The range of the first diagnostic, for a code-action request without an explicit range. */
868
+ function firstDiagnosticRange(diagnostics) {
869
+ return diagnostics[0]?.range;
870
+ }
871
+ /** The whole-document range, for a code-action request with neither a range nor diagnostics. */
872
+ function documentRange(text) {
873
+ const lines = text.split('\n');
874
+ return {
875
+ start: { line: 0, character: 0 },
876
+ end: { line: lines.length - 1, character: (lines[lines.length - 1] ?? '').length },
877
+ };
878
+ }
879
+ /**
880
+ * The configuration answer for one `workspace/configuration` item: the named section when the
881
+ * static configuration is a plain object carrying it, else the whole static value (the tolerant
882
+ * fallback servers like tsls accept).
883
+ */
884
+ function configurationSectionValue(configuration, item) {
885
+ const section = item?.section;
886
+ if (typeof section === 'string'
887
+ && configuration !== null
888
+ && typeof configuration === 'object'
889
+ && !Array.isArray(configuration)) {
890
+ const value = configuration[section];
891
+ if (value !== undefined)
892
+ return value;
893
+ }
894
+ return configuration;
895
+ }
896
+ /** Wrap a server-side failure with its stderr tail, keeping the structured failure code. */
897
+ function serverFailed(error, stderrTail) {
898
+ const message = error instanceof Error ? error.message : String(error);
899
+ const tail = stderrTail.trim();
900
+ return new LspActionError(tail === '' ? `language server failed: ${message}` : `language server failed: ${message}; stderr: ${tail}`, 'LSP_ACTION_SERVER_FAILED', { cause: error });
901
+ }
902
+ /** Coerce an unknown thrown value to a message string. */
903
+ function messageOf(error) {
904
+ return error instanceof Error ? error.message : String(error);
905
+ }
906
+ /** Server→client request methods this client acknowledges with an empty result. */
907
+ const LIFECYCLE_NOOP_METHODS = new Set([
908
+ 'window/workDoneProgress/create',
909
+ 'client/registerCapability',
910
+ 'client/unregisterCapability',
911
+ ]);
912
+ /**
913
+ * The client capabilities advertised at `initialize`: UTF-16 positions, workspace folders and
914
+ * configuration, completion, and published diagnostics. No dynamic registration; the server's
915
+ * returned capabilities are authoritative.
916
+ */
917
+ const CLIENT_CAPABILITIES = {
918
+ general: { positionEncodings: ['utf-16'] },
919
+ workspace: { workspaceFolders: true, configuration: true },
920
+ textDocument: {
921
+ synchronization: { dynamicRegistration: false },
922
+ completion: {},
923
+ publishDiagnostics: { relatedInformation: false },
924
+ },
925
+ };
926
+ //# sourceMappingURL=client.js.map