@sackville-mcp/lsp 0.0.1-alpha.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.
package/dist/index.mjs ADDED
@@ -0,0 +1,2604 @@
1
+ import { spawn } from "node:child_process";
2
+ import { ResponseError, StreamMessageReader, StreamMessageWriter, createMessageConnection } from "vscode-jsonrpc/node.js";
3
+ import { ApplyWorkspaceEditRequest, CallHierarchyIncomingCallsRequest, CallHierarchyOutgoingCallsRequest, CallHierarchyPrepareRequest, ConfigurationRequest, DefinitionRequest, DidChangeTextDocumentNotification, DidChangeWorkspaceFoldersNotification, DidCloseTextDocumentNotification, DidOpenTextDocumentNotification, DocumentDiagnosticRequest, DocumentSymbolRequest, ExitNotification, HoverRequest, InitializeRequest, InitializedNotification, PrepareRenameRequest, PublishDiagnosticsNotification, ReferencesRequest, RegistrationRequest, RenameRequest, ShutdownRequest, TypeDefinitionRequest, UnregistrationRequest, WorkDoneProgressCreateRequest, WorkspaceSymbolRequest } from "vscode-languageserver-protocol";
4
+ import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
+ import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
7
+ import { createHash } from "node:crypto";
8
+ //#region src/encoding.ts
9
+ const SUPPORTED = [
10
+ "utf-8",
11
+ "utf-16",
12
+ "utf-32"
13
+ ];
14
+ /**
15
+ * The encodings we advertise to the server, in preference order. UTF-16 is first
16
+ * deliberately: it is the JS-native, best-tested path (a server honouring our preference
17
+ * picks the one we exercise most). We still implement all three for servers that only
18
+ * speak UTF-8 (e.g. clangd).
19
+ */
20
+ const PREFERRED_ENCODINGS = ["utf-16", "utf-8"];
21
+ /**
22
+ * Resolve the encoding to use from the server's reported `positionEncoding`. Absent ⇒ the
23
+ * spec default, UTF-16. **Present but unsupported ⇒ throw** — never silently default, or
24
+ * we would do offset math in the wrong unit and return wrong locations.
25
+ */
26
+ function resolvePositionEncoding(reported) {
27
+ if (reported === void 0) return "utf-16";
28
+ if (SUPPORTED.includes(reported)) return reported;
29
+ throw new Error(`server negotiated an unsupported position encoding: ${reported} (expected one of ${SUPPORTED.join(", ")})`);
30
+ }
31
+ /** Length of a string in the code units of `encoding`. */
32
+ function codeUnits(s, encoding) {
33
+ switch (encoding) {
34
+ case "utf-16": return s.length;
35
+ case "utf-8": return Buffer.byteLength(s, "utf8");
36
+ case "utf-32": return [...s].length;
37
+ }
38
+ }
39
+ /** Strip a single leading BOM (U+FEFF) — it must not shift line-1 column math. */
40
+ function stripBom(text) {
41
+ return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
42
+ }
43
+ /**
44
+ * Convert a 1-based human column (counting code points) on `lineText` to a 0-based LSP
45
+ * `character` offset in `encoding`. A column past the line end clamps to the line length.
46
+ */
47
+ function toLspCharacter(lineText, humanColumn, encoding) {
48
+ const cps = [...lineText];
49
+ const take = Math.max(0, Math.min(humanColumn - 1, cps.length));
50
+ return codeUnits(cps.slice(0, take).join(""), encoding);
51
+ }
52
+ /**
53
+ * Inverse of {@link toLspCharacter}: map a 0-based LSP `character` offset back to a 1-based
54
+ * human code-point column. An offset landing inside a multi-unit code point clamps to that
55
+ * code point's start; an offset past the line end clamps to one past the last column.
56
+ */
57
+ function fromLspCharacter(lineText, lspCharacter, encoding) {
58
+ if (lspCharacter <= 0) return 1;
59
+ const cps = [...lineText];
60
+ let units = 0;
61
+ for (let k = 0; k < cps.length; k++) {
62
+ const u = codeUnits(cps[k], encoding);
63
+ if (units + u > lspCharacter) return k + 1;
64
+ units += u;
65
+ }
66
+ return cps.length + 1;
67
+ }
68
+ /** Split text into line contents on LF / CR / CRLF, excluding the terminators (LSP's model). */
69
+ function splitLines(text) {
70
+ return text.split(/\r\n|\r|\n/);
71
+ }
72
+ /**
73
+ * Convert a human 1-based line:column over the full source `text` to a 0-based LSP
74
+ * `Position` in `encoding`. Lines are split on LF/CR/CRLF WITHOUT normalizing the document
75
+ * (the text we send the server is its source of truth); a leading BOM is stripped before
76
+ * line-1 column math.
77
+ */
78
+ function toLspPosition(text, humanLine, humanColumn, encoding) {
79
+ const lineText = splitLines(stripBom(text))[humanLine - 1] ?? "";
80
+ return {
81
+ line: humanLine - 1,
82
+ character: toLspCharacter(lineText, humanColumn, encoding)
83
+ };
84
+ }
85
+ /** Inverse of {@link toLspPosition}: map an LSP `Position` back to human 1-based line:column. */
86
+ function fromLspPosition(text, position, encoding) {
87
+ const lineText = splitLines(stripBom(text))[position.line] ?? "";
88
+ return {
89
+ line: position.line + 1,
90
+ column: fromLspCharacter(lineText, position.character, encoding)
91
+ };
92
+ }
93
+ /** JS-string (UTF-16) offset WITHIN `lineText` for a 0-based LSP `character` in `encoding`. */
94
+ function lspCharacterToJsOffset(lineText, character, encoding) {
95
+ if (character <= 0) return 0;
96
+ let units = 0;
97
+ let js = 0;
98
+ for (const cp of lineText) {
99
+ if (units >= character) break;
100
+ const u = codeUnits(cp, encoding);
101
+ if (units + u > character) break;
102
+ units += u;
103
+ js += cp.length;
104
+ }
105
+ return js;
106
+ }
107
+ /**
108
+ * Convert an LSP `Position` to an ABSOLUTE JS-string (UTF-16 `.length`) index into `text`, for
109
+ * splicing edits (the write-mode core). Unlike {@link toLspPosition} this does NOT use
110
+ * `splitLines` — that splitter discards terminator identity and would shift the offset by one
111
+ * JS code unit per CRLF line above the position. We raw-scan terminators (counting their actual
112
+ * length) and count negotiated code units only WITHIN the target line, returning the cumulative
113
+ * JS index. A leading BOM is stripped for line/character math (matching `toLspPosition`) but its
114
+ * length is added back, so the returned offset indexes into the real, BOM-bearing `text`.
115
+ */
116
+ function lspPositionToOffset(text, position, encoding) {
117
+ const bom = text.charCodeAt(0) === 65279 ? 1 : 0;
118
+ const body = bom ? text.slice(1) : text;
119
+ const n = body.length;
120
+ let i = 0;
121
+ let line = 0;
122
+ while (line < position.line && i < n) {
123
+ const ch = body.charCodeAt(i);
124
+ if (ch === 13) {
125
+ i += body.charCodeAt(i + 1) === 10 ? 2 : 1;
126
+ line++;
127
+ } else if (ch === 10) {
128
+ i += 1;
129
+ line++;
130
+ } else i += 1;
131
+ }
132
+ let lineEnd = i;
133
+ while (lineEnd < n) {
134
+ const ch = body.charCodeAt(lineEnd);
135
+ if (ch === 13 || ch === 10) break;
136
+ lineEnd++;
137
+ }
138
+ const within = lspCharacterToJsOffset(body.slice(i, lineEnd), position.character, encoding);
139
+ return bom + i + within;
140
+ }
141
+ //#endregion
142
+ //#region src/normalize.ts
143
+ function isLocationLink(item) {
144
+ return item.targetUri !== void 0;
145
+ }
146
+ /** Normalize any definition/references/typeDefinition result shape to a flat location list. */
147
+ function normalizeLocations(result) {
148
+ if (result == null) return [];
149
+ return (Array.isArray(result) ? result : [result]).map((item) => {
150
+ if (isLocationLink(item)) return {
151
+ uri: item.targetUri,
152
+ range: item.targetSelectionRange,
153
+ fullRange: item.targetRange
154
+ };
155
+ return {
156
+ uri: item.uri,
157
+ range: item.range
158
+ };
159
+ });
160
+ }
161
+ function markedToString(m) {
162
+ if (typeof m === "string") return m;
163
+ return `\`\`\`${m.language}\n${m.value}\n\`\`\``;
164
+ }
165
+ /** Normalize hover contents (MarkupContent | MarkedString | MarkedString[]) to one string. */
166
+ function normalizeHover(hover) {
167
+ if (hover == null) return null;
168
+ const c = hover.contents;
169
+ let value;
170
+ if (Array.isArray(c)) value = c.map(markedToString).join("\n\n");
171
+ else if (typeof c === "string") value = c;
172
+ else if ("kind" in c) value = c.value;
173
+ else value = markedToString(c);
174
+ return hover.range ? {
175
+ value,
176
+ range: hover.range
177
+ } : { value };
178
+ }
179
+ const SYMBOL_KIND_NAMES = [
180
+ "File",
181
+ "Module",
182
+ "Namespace",
183
+ "Package",
184
+ "Class",
185
+ "Method",
186
+ "Property",
187
+ "Field",
188
+ "Constructor",
189
+ "Enum",
190
+ "Interface",
191
+ "Function",
192
+ "Variable",
193
+ "Constant",
194
+ "String",
195
+ "Number",
196
+ "Boolean",
197
+ "Array",
198
+ "Object",
199
+ "Key",
200
+ "Null",
201
+ "EnumMember",
202
+ "Struct",
203
+ "Event",
204
+ "Operator",
205
+ "TypeParameter"
206
+ ];
207
+ function symbolKindName(kind) {
208
+ return SYMBOL_KIND_NAMES[kind - 1] ?? "Unknown";
209
+ }
210
+ function isSymbolInformation(s) {
211
+ return s.location !== void 0;
212
+ }
213
+ function normalizeDocumentSymbol(s) {
214
+ const out = {
215
+ name: s.name,
216
+ kind: s.kind,
217
+ kindName: symbolKindName(s.kind),
218
+ range: s.range
219
+ };
220
+ if (s.detail !== void 0) out.detail = s.detail;
221
+ if (s.children && s.children.length > 0) out.children = s.children.map(normalizeDocumentSymbol);
222
+ return out;
223
+ }
224
+ /** Normalize documentSymbol's dual shape (hierarchical DocumentSymbol[] or flat SymbolInformation[]). */
225
+ function normalizeDocumentSymbols(result) {
226
+ if (result == null || result.length === 0) return [];
227
+ const first = result[0];
228
+ if (isSymbolInformation(first)) return result.map((s) => {
229
+ const out = {
230
+ name: s.name,
231
+ kind: s.kind,
232
+ kindName: symbolKindName(s.kind),
233
+ range: s.location.range
234
+ };
235
+ if (s.containerName !== void 0) out.container = s.containerName;
236
+ return out;
237
+ });
238
+ return result.map(normalizeDocumentSymbol);
239
+ }
240
+ function hasRange(loc) {
241
+ return loc.range !== void 0;
242
+ }
243
+ /** Normalize a `workspace/symbol` result (flat `SymbolInformation[]`/`WorkspaceSymbol[]`). */
244
+ function normalizeWorkspaceSymbols(result) {
245
+ if (result == null) return [];
246
+ return result.map((s) => {
247
+ const out = {
248
+ name: s.name,
249
+ kind: s.kind,
250
+ kindName: symbolKindName(s.kind),
251
+ uri: s.location.uri
252
+ };
253
+ if (hasRange(s.location)) out.range = s.location.range;
254
+ if (s.containerName !== void 0) out.container = s.containerName;
255
+ return out;
256
+ });
257
+ }
258
+ const SEVERITY_NAMES = [
259
+ "Error",
260
+ "Warning",
261
+ "Information",
262
+ "Hint"
263
+ ];
264
+ const TAG_NAMES = {
265
+ 1: "Unnecessary",
266
+ 2: "Deprecated"
267
+ };
268
+ function diagnosticSeverityName(severity) {
269
+ return SEVERITY_NAMES[severity - 1] ?? "Unknown";
270
+ }
271
+ /** Normalize a `publishDiagnostics` `diagnostics[]` (push model) — severity/tag names, related info. */
272
+ function normalizeDiagnostics(items) {
273
+ if (items == null) return [];
274
+ return items.map((d) => {
275
+ const out = {
276
+ range: d.range,
277
+ message: d.message
278
+ };
279
+ if (d.severity !== void 0) {
280
+ out.severity = d.severity;
281
+ out.severityName = diagnosticSeverityName(d.severity);
282
+ }
283
+ if (d.code !== void 0) out.code = d.code;
284
+ if (d.source !== void 0) out.source = d.source;
285
+ if (d.tags && d.tags.length > 0) out.tags = d.tags.map((t) => TAG_NAMES[t] ?? `Tag(${t})`);
286
+ if (d.relatedInformation && d.relatedInformation.length > 0) out.related = d.relatedInformation.map((r) => ({
287
+ uri: r.location.uri,
288
+ range: r.location.range,
289
+ message: r.message
290
+ }));
291
+ return out;
292
+ });
293
+ }
294
+ /**
295
+ * Diagnostics from a PULL-model `textDocument/diagnostic` report. A `full` report carries `items`;
296
+ * an `unchanged` report means "identical to the previousResultId" — Sackville's single-shot reads
297
+ * send no previousResultId, so a server should always answer `full`; we treat `unchanged`/absent
298
+ * defensively as no items. The items themselves are normalized by the shared `normalizeDiagnostics`
299
+ * (the report is just the pull envelope around the same `Diagnostic[]` the push model publishes).
300
+ */
301
+ function diagnosticsFromReport(report) {
302
+ return report?.kind === "full" ? normalizeDiagnostics(report.items) : [];
303
+ }
304
+ function normalizeCallHierarchyItem(item) {
305
+ const out = {
306
+ name: item.name,
307
+ kind: item.kind,
308
+ kindName: symbolKindName(item.kind),
309
+ uri: item.uri,
310
+ range: item.range,
311
+ selectionRange: item.selectionRange
312
+ };
313
+ if (item.detail !== void 0 && item.detail !== "") out.detail = item.detail;
314
+ return out;
315
+ }
316
+ function normalizeCallHierarchyItems(result) {
317
+ if (result == null) return [];
318
+ return result.map(normalizeCallHierarchyItem);
319
+ }
320
+ /** `callHierarchy/incomingCalls` → `{from, fromRanges}[]`; the edge item is the CALLER. */
321
+ function normalizeIncomingCalls(result) {
322
+ if (result == null) return [];
323
+ return result.map((c) => ({
324
+ item: normalizeCallHierarchyItem(c.from),
325
+ fromRanges: c.fromRanges ?? []
326
+ }));
327
+ }
328
+ /** `callHierarchy/outgoingCalls` → `{to, fromRanges}[]`; the edge item is the CALLEE. */
329
+ function normalizeOutgoingCalls(result) {
330
+ if (result == null) return [];
331
+ return result.map((c) => ({
332
+ item: normalizeCallHierarchyItem(c.to),
333
+ fromRanges: c.fromRanges ?? []
334
+ }));
335
+ }
336
+ function decideStatus(isEmpty, ready) {
337
+ if (!isEmpty) return "ok";
338
+ return ready ? "no_result" : "not_ready";
339
+ }
340
+ function isResourceOp(member) {
341
+ const kind = member.kind;
342
+ return kind === "create" || kind === "rename" || kind === "delete";
343
+ }
344
+ /**
345
+ * Normalize a `WorkspaceEdit` to a uniform `files → edits` list plus a separate, flagged list of
346
+ * resource operations. Handles both shapes (ADR 0011 addendum §2.5):
347
+ * - `documentChanges` takes PRECEDENCE over `changes` when both are present (never merged).
348
+ * - Per-file and per-edit order is preserved.
349
+ * - `CreateFile`/`RenameFile`/`DeleteFile` members are surfaced under `resourceOps`, never
350
+ * translated into a TextEdit (v1 refuses to apply them).
351
+ * - An `AnnotatedTextEdit` is normalized to `{range,newText}`; if its annotation is
352
+ * `needsConfirmation` the edit carries `needsConfirmation: true` + the label (a preview-only
353
+ * signal, excluded from apply) so the server's safety signal is never silently dropped.
354
+ *
355
+ * NOTE: real `typescript-language-server` 5.3.0 returns the legacy `changes` map for an ordinary
356
+ * rename even when the client advertises `documentChanges` (see `test/fixtures/README.md`); the
357
+ * `documentChanges` branch is exercised by a synthesized fixture.
358
+ */
359
+ function normalizeWorkspaceEdit(raw) {
360
+ if (raw == null) return {
361
+ files: [],
362
+ resourceOps: [],
363
+ operations: []
364
+ };
365
+ const annotations = raw.changeAnnotations ?? {};
366
+ const mapEdit = (e) => {
367
+ const out = {
368
+ range: e.range,
369
+ newText: e.newText
370
+ };
371
+ if (e.annotationId !== void 0) {
372
+ const ann = annotations[e.annotationId];
373
+ if (ann?.needsConfirmation) out.needsConfirmation = true;
374
+ if (ann?.label !== void 0) out.annotationLabel = ann.label;
375
+ }
376
+ return out;
377
+ };
378
+ if (raw.documentChanges !== void 0) {
379
+ const files = [];
380
+ const resourceOps = [];
381
+ const operations = [];
382
+ for (const member of raw.documentChanges) if (isResourceOp(member)) {
383
+ const uris = member.kind === "rename" ? [member.oldUri, member.newUri].filter((u) => u !== void 0) : member.uri !== void 0 ? [member.uri] : [];
384
+ resourceOps.push({
385
+ kind: member.kind,
386
+ uris
387
+ });
388
+ if (member.kind === "rename") operations.push({
389
+ type: "rename",
390
+ oldUri: member.oldUri ?? "",
391
+ newUri: member.newUri ?? "",
392
+ ...member.options ? { options: member.options } : {}
393
+ });
394
+ else operations.push({
395
+ type: member.kind,
396
+ uri: member.uri ?? "",
397
+ ...member.options ? { options: member.options } : {}
398
+ });
399
+ } else {
400
+ const edits = member.edits.map(mapEdit);
401
+ files.push({
402
+ uri: member.textDocument.uri,
403
+ edits
404
+ });
405
+ operations.push({
406
+ type: "edit",
407
+ uri: member.textDocument.uri,
408
+ edits
409
+ });
410
+ }
411
+ return {
412
+ files,
413
+ resourceOps,
414
+ operations
415
+ };
416
+ }
417
+ if (raw.changes !== void 0) {
418
+ const files = Object.entries(raw.changes).map(([uri, edits]) => ({
419
+ uri,
420
+ edits: edits.map(mapEdit)
421
+ }));
422
+ return {
423
+ files,
424
+ resourceOps: [],
425
+ operations: files.map((f) => ({
426
+ type: "edit",
427
+ uri: f.uri,
428
+ edits: f.edits
429
+ }))
430
+ };
431
+ }
432
+ return {
433
+ files: [],
434
+ resourceOps: [],
435
+ operations: []
436
+ };
437
+ }
438
+ /**
439
+ * Normalize a `prepareRename` result. `null` ⇒ the position is NOT renameable (the engine maps
440
+ * this to a structured refusal, distinct from a tri-state `no_result`). A bare `Range`,
441
+ * `{range, placeholder}`, and `{defaultBehavior}` all mean renameable — the real
442
+ * `typescript-language-server` 5.3.0 returns a bare `Range` (see `test/fixtures/README.md`).
443
+ */
444
+ function normalizePrepareRename(raw) {
445
+ if (raw == null) return null;
446
+ if ("start" in raw && "end" in raw) return { range: raw };
447
+ if ("range" in raw) {
448
+ const out = { range: raw.range };
449
+ if (raw.placeholder !== void 0) out.placeholder = raw.placeholder;
450
+ return out;
451
+ }
452
+ if ("defaultBehavior" in raw) return { defaultBehavior: raw.defaultBehavior };
453
+ return {};
454
+ }
455
+ //#endregion
456
+ //#region src/client.ts
457
+ /**
458
+ * The LSP JSON-RPC client (ADR 0011, slice 2) — one live, stateful, bidirectional session
459
+ * with a language-server subprocess, the documented exception to ARCHITECTURE §1's
460
+ * no-live-RPC rule. It leans on Microsoft's reference transport (`vscode-jsonrpc` for
461
+ * Content-Length framing + id correlation, `vscode-languageserver-protocol` for method
462
+ * constants) — the playwright-core pattern, NOT a hand-rolled framing layer.
463
+ *
464
+ * The five corners the adversarial pass flagged as load-bearing, all handled here:
465
+ *
466
+ * 1. **Encoding negotiation.** Advertise `positionEncodings: ["utf-16","utf-8"]`, read back
467
+ * `ServerCapabilities.positionEncoding`, and do ALL offset math in that unit (via
468
+ * `encoding.ts`). Absent ⇒ spec-default UTF-16; present-but-unsupported ⇒ fail loud.
469
+ * 2. **Tri-state readiness.** An empty result is `no_result` only when the server is READY;
470
+ * while an indexing `$/progress` work-done token is active it is `not_ready` — never
471
+ * collapsed into "no definition". One authoritative deadline (the operator timeout) with
472
+ * the bounded retry/backoff living INSIDE it; the first call returns `not_ready` fast.
473
+ * 3. **Deadlock-safe inbound replies.** The client MUST answer every id-bearing server
474
+ * request (`workspace/configuration`, `window/workDoneProgress/create`,
475
+ * `client/registerCapability`) or it deadlocks — in particular it must answer
476
+ * `workDoneProgress/create` before the `$/progress` that drives readiness arrives.
477
+ * 4. **Document lifecycle.** `didOpen` full-text once, reference-counted, NO `didClose` by
478
+ * default (the per-(server,uri) mutex that serializes the open+query critical section
479
+ * lives in the manager — slice 3).
480
+ * 5. **Capability gating + provenance.** Every request is gated on its `*Provider`
481
+ * capability; `serverInfo.{name,version}` rides on every result (turns "silently wrong"
482
+ * into "wrong-but-attributed").
483
+ *
484
+ * All time-based code goes through the injected clock (`now`/`delay`) — the production code
485
+ * never calls `setTimeout`/`setInterval` directly except inside the default `delay` seam, so
486
+ * the gate drives retry/backoff deterministically with a fake clock.
487
+ */
488
+ /** Default live spawn: a child process over stdio, framed by the reference transport. */
489
+ const defaultServerSpawn = (spec) => {
490
+ const child = spawn(spec.command, spec.args, { stdio: [
491
+ "pipe",
492
+ "pipe",
493
+ "pipe"
494
+ ] });
495
+ const connection = createMessageConnection(new StreamMessageReader(child.stdout), new StreamMessageWriter(child.stdin));
496
+ return {
497
+ connection,
498
+ dispose() {
499
+ connection.dispose();
500
+ child.kill("SIGKILL");
501
+ }
502
+ };
503
+ };
504
+ /** Thrown when a navigation is requested against a capability the server did not advertise. */
505
+ var LspUnsupportedError = class extends Error {
506
+ constructor(message) {
507
+ super(message);
508
+ this.name = "LspUnsupportedError";
509
+ }
510
+ };
511
+ const defaultDelay$1 = (ms) => new Promise((r) => setTimeout(r, ms));
512
+ /**
513
+ * `ResponseError` codes a server uses to signal "not ready / nothing to answer here" rather than
514
+ * returning an empty result. Some servers (rust-analyzer) ERROR where tsserver returns empty —
515
+ * `withRetry` routes these through the SAME tri-state path as an empty result (indexing ⇒ not_ready
516
+ * and retry within the deadline; settled ⇒ no_result), instead of letting them throw as a hard
517
+ * failure. Any OTHER error (a genuine protocol/internal failure) propagates unchanged.
518
+ * -32801 ContentModified / -32802 ServerCancelled / -32800 RequestCancelled — the spec's
519
+ * retry/cancel codes; -32602 InvalidParams — rust-analyzer's "No references found at position".
520
+ */
521
+ const SOFT_NOT_READY_CODES = new Set([
522
+ -32801,
523
+ -32802,
524
+ -32800,
525
+ -32602
526
+ ]);
527
+ var LspClient = class {
528
+ conn;
529
+ timeoutMs;
530
+ noRetry;
531
+ now;
532
+ delay;
533
+ baseBackoffMs;
534
+ maxBackoffMs;
535
+ listening = false;
536
+ _encoding = "utf-16";
537
+ _serverInfo;
538
+ _capabilities = {};
539
+ /** Active indexing work-done-progress tokens — non-empty ⇒ the server is still indexing. */
540
+ activeProgress = /* @__PURE__ */ new Set();
541
+ /** Resolvers waiting for indexing to DRAIN (the active set to empty); fired on the final `end`. */
542
+ drainWaiters = [];
543
+ /**
544
+ * Open documents (open-once, no `didClose` by default). `version` is the per-uri monotonic
545
+ * document version: seeded at 1 by `didOpen`, pre-incremented by each `applyEdited` `didChange`
546
+ * — versions MUST strictly increase or the server ignores the change and keeps stale text.
547
+ */
548
+ open = /* @__PURE__ */ new Map();
549
+ /**
550
+ * Pushed diagnostics per uri (the PUSH model — `textDocument/publishDiagnostics` is a server
551
+ * notification, not a request; tsserver advertises no `diagnosticProvider`, so pull diagnostics
552
+ * are unavailable). An entry's absence ⇒ the server hasn't published for that uri yet.
553
+ */
554
+ diagnostics = /* @__PURE__ */ new Map();
555
+ /** Uris freshly `didOpen`ed whose first post-open publish hasn't arrived yet. */
556
+ awaitingDiagnostics = /* @__PURE__ */ new Set();
557
+ /** Resolvers waiting for the NEXT publish on a uri (keyed); fired by the publish handler. */
558
+ diagnosticsWaiters = /* @__PURE__ */ new Map();
559
+ constructor(connection, options) {
560
+ this.conn = connection;
561
+ this.timeoutMs = options.timeoutMs;
562
+ this.noRetry = options.noRetry ?? false;
563
+ this.now = options.now ?? Date.now;
564
+ this.delay = options.delay ?? defaultDelay$1;
565
+ this.baseBackoffMs = options.baseBackoffMs ?? 50;
566
+ this.maxBackoffMs = options.maxBackoffMs ?? 1e3;
567
+ }
568
+ get encoding() {
569
+ return this._encoding;
570
+ }
571
+ get serverInfo() {
572
+ return this._serverInfo;
573
+ }
574
+ get capabilities() {
575
+ return this._capabilities;
576
+ }
577
+ /** Indexing is active when at least one `$/progress` work-done token is open. */
578
+ get indexing() {
579
+ return this.activeProgress.size > 0;
580
+ }
581
+ /** Any non-`false`, non-absent `*Provider` value counts as enabled (LSP convention). */
582
+ supports(provider) {
583
+ const v = this._capabilities[provider];
584
+ return v !== void 0 && v !== false;
585
+ }
586
+ /**
587
+ * `prepareRename` is advertised only by the OBJECT form `renameProvider: {prepareProvider:true}`
588
+ * — the boolean `supports()` helper cannot detect it, so the engine must check this before
589
+ * calling `prepareRename` (bare `renameProvider: true` supports rename but not prepare).
590
+ */
591
+ get supportsPrepareRename() {
592
+ const rp = this._capabilities.renameProvider;
593
+ return typeof rp === "object" && rp !== null && rp.prepareProvider === true;
594
+ }
595
+ /**
596
+ * Whether the server accepts `workspace/didChangeWorkspaceFolders` — the gate for the manager's
597
+ * grow-only warm-server reuse. Advertised by `ServerCapabilities.workspace.workspaceFolders`:
598
+ * `supported !== false` AND `changeNotifications` truthy (the spec allows `true` OR a string
599
+ * registration id). rust-analyzer advertises it; the captured tsserver 5.3.0 does NOT, so the
600
+ * manager falls back to spawning a fresh per-group server there.
601
+ */
602
+ get supportsWorkspaceFolderChange() {
603
+ const wf = this._capabilities.workspace?.workspaceFolders;
604
+ if (wf === void 0 || wf.supported === false) return false;
605
+ return wf.changeNotifications === true || typeof wf.changeNotifications === "string";
606
+ }
607
+ /**
608
+ * Handshake. Registers the deadlock-safe inbound handlers + the `$/progress` listener
609
+ * BEFORE `listen()`, advertises the preferred encodings, then reads back the negotiated
610
+ * encoding + capabilities + provenance and sends `initialized`.
611
+ */
612
+ async initialize(rootUri, opts = {}) {
613
+ this.installInboundHandlers();
614
+ if (!this.listening) {
615
+ this.conn.listen();
616
+ this.listening = true;
617
+ }
618
+ const result = await this.conn.sendRequest(InitializeRequest.method, {
619
+ processId: process.pid ?? null,
620
+ clientInfo: { name: "sackville-lsp" },
621
+ rootUri,
622
+ capabilities: {
623
+ general: { positionEncodings: PREFERRED_ENCODINGS },
624
+ textDocument: {
625
+ synchronization: { dynamicRegistration: false },
626
+ definition: { linkSupport: true },
627
+ typeDefinition: { linkSupport: true },
628
+ references: {},
629
+ hover: { contentFormat: ["markdown", "plaintext"] },
630
+ documentSymbol: { hierarchicalDocumentSymbolSupport: true },
631
+ publishDiagnostics: {
632
+ relatedInformation: true,
633
+ tagSupport: { valueSet: [1, 2] },
634
+ versionSupport: true,
635
+ codeDescriptionSupport: true
636
+ },
637
+ diagnostic: {
638
+ dynamicRegistration: false,
639
+ relatedDocumentSupport: false
640
+ },
641
+ callHierarchy: { dynamicRegistration: false },
642
+ rename: {
643
+ dynamicRegistration: false,
644
+ prepareSupport: true,
645
+ prepareSupportDefaultBehavior: 1
646
+ }
647
+ },
648
+ window: { workDoneProgress: true },
649
+ workspace: {
650
+ configuration: true,
651
+ workspaceFolders: true,
652
+ symbol: { dynamicRegistration: false },
653
+ workspaceEdit: {
654
+ documentChanges: true,
655
+ resourceOperations: [
656
+ "create",
657
+ "rename",
658
+ "delete"
659
+ ],
660
+ normalizesLineEndings: false
661
+ }
662
+ }
663
+ },
664
+ workspaceFolders: opts.workspaceFolders ?? [{
665
+ uri: rootUri,
666
+ name: "root"
667
+ }],
668
+ initializationOptions: opts.initializationOptions
669
+ });
670
+ this._capabilities = result.capabilities ?? {};
671
+ this._serverInfo = result.serverInfo;
672
+ this._encoding = resolvePositionEncoding(this._capabilities.positionEncoding);
673
+ this.conn.sendNotification(InitializedNotification.method, {});
674
+ return {
675
+ encoding: this._encoding,
676
+ serverInfo: this._serverInfo,
677
+ capabilities: this._capabilities
678
+ };
679
+ }
680
+ /** Register the id-bearing inbound replies (deadlock-safe) + the `$/progress` tracker. */
681
+ installInboundHandlers() {
682
+ this.conn.onRequest(ConfigurationRequest.method, (params) => (params?.items ?? []).map(() => null));
683
+ this.conn.onRequest(WorkDoneProgressCreateRequest.method, () => null);
684
+ this.conn.onRequest(RegistrationRequest.method, () => null);
685
+ this.conn.onRequest(UnregistrationRequest.method, () => null);
686
+ this.conn.onRequest(ApplyWorkspaceEditRequest.method, () => ({
687
+ applied: false,
688
+ failureReason: "sackville applies rename edits itself; server-initiated edits are declined"
689
+ }));
690
+ this.conn.onNotification(PublishDiagnosticsNotification.method, (p) => {
691
+ this.diagnostics.set(p.uri, { items: p.diagnostics ?? [] });
692
+ this.awaitingDiagnostics.delete(p.uri);
693
+ for (const w of this.diagnosticsWaiters.get(p.uri)?.splice(0) ?? []) w();
694
+ });
695
+ this.conn.onNotification("$/progress", (p) => {
696
+ const kind = p?.value?.kind;
697
+ if (kind === "begin") this.activeProgress.add(p.token);
698
+ else if (kind === "end") {
699
+ this.activeProgress.delete(p.token);
700
+ if (this.activeProgress.size === 0) for (const w of this.drainWaiters.splice(0)) w();
701
+ }
702
+ });
703
+ }
704
+ /** A promise that resolves the next time indexing drains to empty (an `end` clears the set). */
705
+ indexingDrain() {
706
+ return new Promise((resolve) => this.drainWaiters.push(resolve));
707
+ }
708
+ /**
709
+ * Wait until the server is NOT indexing, bounded by `deadline`. Returns true if it settled,
710
+ * false if the deadline elapsed while still indexing. Event-driven (resolves on the `$/progress`
711
+ * `end`), with the injected `delay` as the deadline backstop — so the gate drives it
712
+ * deterministically and a real session blocks no longer than the operator timeout.
713
+ */
714
+ async awaitIndexingSettled(deadline) {
715
+ while (this.indexing) {
716
+ const remaining = deadline - this.now();
717
+ if (remaining <= 0) return false;
718
+ const drained = this.indexingDrain();
719
+ if (!this.indexing) return true;
720
+ await Promise.race([drained, this.delay(remaining)]);
721
+ }
722
+ return true;
723
+ }
724
+ /** Open a document full-text once (version 1); subsequent calls just bump the refcount. */
725
+ ensureOpen(uri, languageId, text) {
726
+ const entry = this.open.get(uri);
727
+ if (entry !== void 0) {
728
+ entry.refs += 1;
729
+ return;
730
+ }
731
+ this.open.set(uri, {
732
+ refs: 1,
733
+ version: 1,
734
+ languageId
735
+ });
736
+ this.awaitingDiagnostics.add(uri);
737
+ this.conn.sendNotification(DidOpenTextDocumentNotification.method, { textDocument: {
738
+ uri,
739
+ languageId,
740
+ version: 1,
741
+ text
742
+ } });
743
+ }
744
+ /** Decrement a document's refcount (keeps the entry + version). Does NOT `didClose`. */
745
+ releaseDoc(uri) {
746
+ const entry = this.open.get(uri);
747
+ if (entry === void 0) return;
748
+ entry.refs = Math.max(0, entry.refs - 1);
749
+ }
750
+ /**
751
+ * After Sackville writes `newText` to `uri` on disk (write-mode, ADR 0011 addendum), resync the
752
+ * server's in-memory buffer with a **full-text `didChange`** so a later navigation sees
753
+ * post-rename positions (we never `didClose`, so the server still holds the pre-rename text).
754
+ * The version is **pre-incremented** — it must be strictly greater than the last `didOpen`/
755
+ * `didChange` or the server ignores the change. No-op for a uri the server never opened (it
756
+ * re-reads fresh on the next `didOpen`). Full-text (no incremental ranges) — correctness over
757
+ * bytes, and it avoids re-introducing offset math on the server-bound path.
758
+ */
759
+ applyEdited(uri, newText) {
760
+ const entry = this.open.get(uri);
761
+ if (entry === void 0) return;
762
+ entry.version += 1;
763
+ this.conn.sendNotification(DidChangeTextDocumentNotification.method, {
764
+ textDocument: {
765
+ uri,
766
+ version: entry.version
767
+ },
768
+ contentChanges: [{ text: newText }]
769
+ });
770
+ }
771
+ /**
772
+ * A file moved on disk (resource-op `RenameFile`). The open-once/no-`didClose` invariant keys the
773
+ * server buffer by `oldUri`, which now names a non-existent path — a later query would be silently
774
+ * wrong (the worst failure class). MIGRATE the open entry: `didClose(oldUri)` + `didOpen(newUri)`
775
+ * with the moved text, carrying the refcount and the languageId to the new key. NO-OP if `oldUri`
776
+ * was never opened (Sackville opens only the queried file, so the renamed file is usually closed).
777
+ * Run inside the held multi-URI lock so no concurrent query races the key migration.
778
+ */
779
+ didFileRename(oldUri, newUri, newText) {
780
+ const entry = this.open.get(oldUri);
781
+ if (entry === void 0) return;
782
+ this.open.delete(oldUri);
783
+ this.diagnostics.delete(oldUri);
784
+ this.awaitingDiagnostics.delete(oldUri);
785
+ this.conn.sendNotification(DidCloseTextDocumentNotification.method, { textDocument: { uri: oldUri } });
786
+ this.open.set(newUri, {
787
+ refs: entry.refs,
788
+ version: 1,
789
+ languageId: entry.languageId
790
+ });
791
+ this.awaitingDiagnostics.add(newUri);
792
+ this.conn.sendNotification(DidOpenTextDocumentNotification.method, { textDocument: {
793
+ uri: newUri,
794
+ languageId: entry.languageId,
795
+ version: 1,
796
+ text: newText
797
+ } });
798
+ }
799
+ /**
800
+ * A file was deleted on disk (resource-op `DeleteFile`). `didClose` + evict the open entry so the
801
+ * server stops tracking a buffer for a path that no longer exists. NO-OP if it was never opened.
802
+ */
803
+ didFileDelete(uri) {
804
+ if (this.open.get(uri) === void 0) return;
805
+ this.open.delete(uri);
806
+ this.diagnostics.delete(uri);
807
+ this.awaitingDiagnostics.delete(uri);
808
+ this.conn.sendNotification(DidCloseTextDocumentNotification.method, { textDocument: { uri } });
809
+ }
810
+ /**
811
+ * Notify the server that the workspace-folder set changed (`workspace/didChangeWorkspaceFolders`)
812
+ * — the wire primitive behind the manager's grow-only warm-server reuse. A fire-and-forget
813
+ * notification, so it is ordered on the single connection BEFORE any subsequent request: a query
814
+ * sent right after sees the new folder scope. Capability gating is the caller's job (the manager
815
+ * only calls this when {@link supportsWorkspaceFolderChange}); the folders are operator-allowlisted
816
+ * roots, never agent-supplied paths.
817
+ */
818
+ changeWorkspaceFolders(added, removed) {
819
+ this.conn.sendNotification(DidChangeWorkspaceFoldersNotification.method, { event: {
820
+ added,
821
+ removed
822
+ } });
823
+ }
824
+ async definition(uri, position) {
825
+ if (!this.supports("definitionProvider")) throw new LspUnsupportedError("server does not advertise definition support");
826
+ return this.navigateLocations(DefinitionRequest.method, {
827
+ textDocument: { uri },
828
+ position
829
+ });
830
+ }
831
+ async typeDefinition(uri, position) {
832
+ if (!this.supports("typeDefinitionProvider")) throw new LspUnsupportedError("server does not advertise typeDefinition support");
833
+ return this.navigateLocations(TypeDefinitionRequest.method, {
834
+ textDocument: { uri },
835
+ position
836
+ });
837
+ }
838
+ async references(uri, position) {
839
+ if (!this.supports("referencesProvider")) throw new LspUnsupportedError("server does not advertise references support");
840
+ return this.navigateLocations(ReferencesRequest.method, {
841
+ textDocument: { uri },
842
+ position,
843
+ context: { includeDeclaration: true }
844
+ });
845
+ }
846
+ async hover(uri, position) {
847
+ if (!this.supports("hoverProvider")) throw new LspUnsupportedError("server does not advertise hover support");
848
+ return this.withRetry(() => this.conn.sendRequest(HoverRequest.method, {
849
+ textDocument: { uri },
850
+ position
851
+ }), (raw) => normalizeHover(raw), (h) => h === null);
852
+ }
853
+ /** Document symbols — the file outline. Position-less (whole document); tri-state like the rest. */
854
+ async documentSymbols(uri) {
855
+ if (!this.supports("documentSymbolProvider")) throw new LspUnsupportedError("server does not advertise documentSymbol support");
856
+ return this.withRetry(() => this.conn.sendRequest(DocumentSymbolRequest.method, { textDocument: { uri } }), (raw) => normalizeDocumentSymbols(raw), (syms) => syms.length === 0);
857
+ }
858
+ /** A promise that resolves on the next `publishDiagnostics` for `uri`. */
859
+ nextDiagnostics(uri) {
860
+ return new Promise((resolve) => {
861
+ const list = this.diagnosticsWaiters.get(uri) ?? [];
862
+ list.push(resolve);
863
+ this.diagnosticsWaiters.set(uri, list);
864
+ });
865
+ }
866
+ /**
867
+ * Diagnostics for an OPEN document (ADR 0011 staged tail; PUSH model). NOT capability-gated —
868
+ * `textDocument/publishDiagnostics` is a server notification every server may send, and tsserver
869
+ * advertises no `diagnosticProvider` (pull diagnostics are staged). The caller (manager.run) has
870
+ * already `didOpen`ed the file, which triggers the server's publish.
871
+ *
872
+ * Readiness (grounded in the captured timeline — `didOpen` → `$/progress` begin/end → publish
873
+ * ~60ms AFTER the project loads): wait out the project-load `$/progress`, then return the publish
874
+ * once the file's first post-open publish has arrived. An EMPTY publish is a legitimate `ok` (a
875
+ * clean file), never `no_result`. If the project never settles or no publish arrives within the
876
+ * deadline ⇒ `not_ready` (retry), the same honest-tri-state posture as the navigation reads.
877
+ */
878
+ async documentDiagnostics(uri) {
879
+ if (this.supports("diagnosticProvider")) return this.pullDiagnostics(uri);
880
+ return this.pushDiagnostics(uri);
881
+ }
882
+ /** PUSH model — accumulate the server's `publishDiagnostics` for an open file (see {@link documentDiagnostics}). */
883
+ async pushDiagnostics(uri) {
884
+ const deadline = this.now() + this.timeoutMs;
885
+ while (true) {
886
+ await this.awaitIndexingSettled(deadline);
887
+ if (!this.indexing && !this.awaitingDiagnostics.has(uri)) {
888
+ const cached = this.diagnostics.get(uri);
889
+ return this.wrap("ok", normalizeDiagnostics(cached?.items));
890
+ }
891
+ const remaining = deadline - this.now();
892
+ if (remaining <= 0) {
893
+ const cached = this.diagnostics.get(uri);
894
+ return this.wrap("not_ready", normalizeDiagnostics(cached?.items));
895
+ }
896
+ await Promise.race([this.nextDiagnostics(uri), this.delay(remaining)]);
897
+ }
898
+ }
899
+ /**
900
+ * PULL model — `textDocument/diagnostic` (LSP 3.17), capability-gated on `diagnosticProvider`.
901
+ * A request/response (unlike push), so it is deterministic for a single-shot read. Echoes the
902
+ * provider's `identifier` when one was advertised (rust-analyzer requires it). Tri-state, with
903
+ * the diagnostics-specific twist that an EMPTY report is a legitimate `ok` (a clean file), NEVER
904
+ * `no_result` — so this does NOT reuse {@link withRetry} (whose empty ⇒ no_result is wrong here).
905
+ * Readiness: wait out the project-load `$/progress`; if the send (re)starts indexing, re-query the
906
+ * loaded project within the deadline; a soft "not ready" `ResponseError` backs off and retries
907
+ * inside the deadline. Still indexing at the deadline ⇒ `not_ready` (retry).
908
+ */
909
+ async pullDiagnostics(uri) {
910
+ const provider = this._capabilities.diagnosticProvider;
911
+ const deadline = this.now() + this.timeoutMs;
912
+ let attempt = 0;
913
+ while (true) {
914
+ await this.awaitIndexingSettled(deadline);
915
+ if (this.indexing) return this.wrap("not_ready", []);
916
+ let report = null;
917
+ let softNotReady = false;
918
+ try {
919
+ report = await this.conn.sendRequest(DocumentDiagnosticRequest.method, {
920
+ textDocument: { uri },
921
+ ...provider?.identifier ? { identifier: provider.identifier } : {}
922
+ });
923
+ } catch (err) {
924
+ if (!(err instanceof ResponseError) || !SOFT_NOT_READY_CODES.has(err.code)) throw err;
925
+ softNotReady = true;
926
+ }
927
+ if (this.indexing && this.now() < deadline) continue;
928
+ if (!softNotReady) return this.wrap(this.indexing ? "not_ready" : "ok", diagnosticsFromReport(report));
929
+ attempt += 1;
930
+ const backoff = Math.min(this.baseBackoffMs * 2 ** (attempt - 1), this.maxBackoffMs);
931
+ if (this.noRetry || this.now() + backoff > deadline) return this.wrap("not_ready", []);
932
+ await this.delay(backoff);
933
+ }
934
+ }
935
+ /**
936
+ * `workspace/symbol` — project-wide symbol search by name (ADR 0011 staged tail). Position-less
937
+ * and file-less: the query is just a name fragment matched against the whole indexed workspace,
938
+ * so it needs no open document (the project is loaded at `initialize`). Tri-state like the rest —
939
+ * an empty result while the project is still indexing is `not_ready`, never collapsed into
940
+ * "no such symbol" (the cold-load trap the rest of the client already guards). Handles both the
941
+ * flat `SymbolInformation[]` (range present) and the uri-only `WorkspaceSymbol[]` shapes.
942
+ */
943
+ async workspaceSymbols(query) {
944
+ if (!this.supports("workspaceSymbolProvider")) throw new LspUnsupportedError("server does not advertise workspace symbol support");
945
+ return this.withRetry(() => this.conn.sendRequest(WorkspaceSymbolRequest.method, { query }), (raw) => normalizeWorkspaceSymbols(raw), (syms) => syms.length === 0);
946
+ }
947
+ /**
948
+ * `textDocument/prepareRename` — the cheap validate-first pre-flight (write-mode, ADR 0011
949
+ * addendum). Tri-state: `null` while indexing ⇒ `not_ready`; `null` while ready ⇒ `no_result`
950
+ * (the engine maps that to a structured "not renameable here" refusal); a non-null outcome ⇒
951
+ * `ok`. Only callable when {@link supportsPrepareRename} (the engine skips it otherwise).
952
+ */
953
+ async prepareRename(uri, position) {
954
+ if (!this.supportsPrepareRename) throw new LspUnsupportedError("server does not advertise prepareRename support");
955
+ return this.withRetry(() => this.conn.sendRequest(PrepareRenameRequest.method, {
956
+ textDocument: { uri },
957
+ position
958
+ }), (raw) => normalizePrepareRename(raw), (outcome) => outcome === null);
959
+ }
960
+ /**
961
+ * `textDocument/rename` — compute the cross-file `WorkspaceEdit` for renaming the symbol at
962
+ * `position` to `newName`. Capability-gated on `renameProvider`; normalized to the uniform
963
+ * `files`/`resourceOps` shape; tri-state (empty/`null` while indexing ⇒ `not_ready`). This
964
+ * computes only — applying the edit to disk is the gated engine's job (Slice F), never here.
965
+ */
966
+ async rename(uri, position, newName) {
967
+ if (!this.supports("renameProvider")) throw new LspUnsupportedError("server does not advertise rename support");
968
+ return this.withRetry(() => this.conn.sendRequest(RenameRequest.method, {
969
+ textDocument: { uri },
970
+ position,
971
+ newName
972
+ }), (raw) => normalizeWorkspaceEdit(raw), (we) => we.files.length === 0 && we.resourceOps.length === 0);
973
+ }
974
+ /**
975
+ * Call hierarchy — a TWO-round-trip protocol. `prepareCallHierarchy` resolves the symbol at
976
+ * `position` to one or more `CallHierarchyItem`s (null vs empty is distinct; overloads yield
977
+ * MANY — we keep them all, never silently the first); then per item we fetch incoming or
978
+ * outgoing calls. Tri-state lives on the PREPARE step (empty-while-indexing ⇒ not_ready). A
979
+ * prepared item with no callers/callees is a legitimate `ok` with empty `calls`. The RAW item
980
+ * is passed back to the calls request (it may carry an opaque `data` field the server needs).
981
+ */
982
+ async callHierarchy(uri, position, direction) {
983
+ if (!this.supports("callHierarchyProvider")) throw new LspUnsupportedError("server does not advertise callHierarchy support");
984
+ const prepared = await this.withRetry(() => this.conn.sendRequest(CallHierarchyPrepareRequest.method, {
985
+ textDocument: { uri },
986
+ position
987
+ }), (raw) => raw ?? [], (items) => items.length === 0);
988
+ if (prepared.status !== "ok") return this.wrap(prepared.status, []);
989
+ const method = direction === "incoming" ? CallHierarchyIncomingCallsRequest.method : CallHierarchyOutgoingCallsRequest.method;
990
+ const groups = [];
991
+ for (const item of prepared.result) {
992
+ const raw = await this.conn.sendRequest(method, { item });
993
+ const calls = direction === "incoming" ? normalizeIncomingCalls(raw) : normalizeOutgoingCalls(raw);
994
+ groups.push({
995
+ source: normalizeCallHierarchyItem(item),
996
+ calls
997
+ });
998
+ }
999
+ return this.wrap("ok", groups);
1000
+ }
1001
+ navigateLocations(method, params) {
1002
+ return this.withRetry(() => this.conn.sendRequest(method, params), (raw) => normalizeLocations(raw), (locs) => locs.length === 0);
1003
+ }
1004
+ /**
1005
+ * The tri-state request loop: settle → send → decide, all inside one operator deadline.
1006
+ *
1007
+ * The load-bearing rule (ADR 0011 addendum — proven by a live `typescript-language-server`
1008
+ * capture): **a result returned while the server is still indexing the project is NOT
1009
+ * trustworthy** — tsserver answers an early request from a single-file *inferred* project
1010
+ * (a non-empty BUT PARTIAL answer — e.g. a cross-file rename that sees only the opened file)
1011
+ * and only *then* finishes loading the configured project. So:
1012
+ *
1013
+ * 1. Before sending, wait out any in-flight indexing (`awaitIndexingSettled`) so we hit the
1014
+ * loaded project.
1015
+ * 2. After sending, if indexing is active (the send itself triggered the configured-project
1016
+ * load), the answer is from the unstable inferred project — loop to settle + re-query.
1017
+ * 3. Once the server is settled: a non-empty result is `ok`; an empty result is `no_result`
1018
+ * (retried with bounded backoff) — or `not_ready` only if we hit the deadline still indexing.
1019
+ *
1020
+ * This trades the old "return `not_ready` fast" for "wait for the correct answer within the
1021
+ * deadline" — correctness over latency, bounded by the operator timeout.
1022
+ */
1023
+ async withRetry(send, normalize, isEmpty) {
1024
+ const deadline = this.now() + this.timeoutMs;
1025
+ let attempt = 0;
1026
+ while (true) {
1027
+ await this.awaitIndexingSettled(deadline);
1028
+ let value;
1029
+ let softError = false;
1030
+ try {
1031
+ value = normalize(await send());
1032
+ } catch (err) {
1033
+ if (!(err instanceof ResponseError) || !SOFT_NOT_READY_CODES.has(err.code)) throw err;
1034
+ value = normalize(null);
1035
+ softError = true;
1036
+ }
1037
+ if (this.indexing && this.now() < deadline) continue;
1038
+ const status = decideStatus(isEmpty(value), !this.indexing);
1039
+ if (status !== "no_result") return this.wrap(status, value);
1040
+ if (softError) return this.wrap("no_result", value);
1041
+ attempt += 1;
1042
+ const backoff = Math.min(this.baseBackoffMs * 2 ** (attempt - 1), this.maxBackoffMs);
1043
+ if (this.noRetry || this.now() + backoff > deadline) return this.wrap("no_result", value);
1044
+ await this.delay(backoff);
1045
+ }
1046
+ }
1047
+ wrap(status, result) {
1048
+ return {
1049
+ status,
1050
+ result,
1051
+ serverInfo: this._serverInfo,
1052
+ encoding: this._encoding
1053
+ };
1054
+ }
1055
+ /** Graceful teardown: LSP `shutdown` request then the `exit` notification. */
1056
+ async shutdown() {
1057
+ try {
1058
+ await this.conn.sendRequest(ShutdownRequest.method);
1059
+ } catch {}
1060
+ try {
1061
+ this.conn.sendNotification(ExitNotification.method);
1062
+ } catch {}
1063
+ }
1064
+ };
1065
+ //#endregion
1066
+ //#region src/registry.ts
1067
+ /** Thrown on a malformed operator registry or a request for an unbound language. */
1068
+ var LspRegistryError = class extends Error {
1069
+ constructor(message) {
1070
+ super(message);
1071
+ this.name = "LspRegistryError";
1072
+ }
1073
+ };
1074
+ function isStringArray(v) {
1075
+ return Array.isArray(v) && v.every((x) => typeof x === "string");
1076
+ }
1077
+ /**
1078
+ * Parse + validate the operator JSON registry. Fails loud on anything malformed — an
1079
+ * operator misconfiguration must be a clear error, never a silently-empty or
1080
+ * partially-parsed registry an agent then queries against.
1081
+ */
1082
+ function parseServerRegistry(json) {
1083
+ let raw;
1084
+ try {
1085
+ raw = JSON.parse(json);
1086
+ } catch (e) {
1087
+ throw new LspRegistryError(`registry is not valid JSON: ${e.message}`);
1088
+ }
1089
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new LspRegistryError("registry must be a JSON object keyed by language");
1090
+ const out = {};
1091
+ for (const [language, value] of Object.entries(raw)) {
1092
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new LspRegistryError(`registry entry for "${language}" must be an object`);
1093
+ const entry = value;
1094
+ if (typeof entry.command !== "string" || entry.command.length === 0) throw new LspRegistryError(`registry entry for "${language}" needs a non-empty string command`);
1095
+ if (entry.args !== void 0 && !isStringArray(entry.args)) throw new LspRegistryError(`registry entry for "${language}" args must be an array of strings`);
1096
+ if (entry.languageId !== void 0 && typeof entry.languageId !== "string") throw new LspRegistryError(`registry entry for "${language}" languageId must be a string`);
1097
+ out[language] = {
1098
+ command: entry.command,
1099
+ args: entry.args ?? [],
1100
+ ...entry.initializationOptions !== void 0 ? { initializationOptions: entry.initializationOptions } : {},
1101
+ ...entry.languageId !== void 0 ? { languageId: entry.languageId } : {}
1102
+ };
1103
+ }
1104
+ if (Object.keys(out).length === 0) throw new LspRegistryError("registry is empty — bind at least one language→server");
1105
+ return out;
1106
+ }
1107
+ /** Resolve a language to its registered server, or refuse it (never spawns an unbound server). */
1108
+ function resolveServer(registry, language) {
1109
+ const entry = registry[language];
1110
+ if (!entry) throw new LspRegistryError(`no server bound for language "${language}" (bound: ${Object.keys(registry).join(", ") || "(none)"})`);
1111
+ return entry;
1112
+ }
1113
+ //#endregion
1114
+ //#region src/manager.ts
1115
+ /**
1116
+ * The `LanguageServerManager` (ADR 0011, slice 3) — owns the resident language-server
1117
+ * subprocesses. The right analogy is the browser pillar's `BrowserManager` (a resident,
1118
+ * code-executing subprocess), NOT the short-lived test-runner: lazy spawn, idle reaper,
1119
+ * caps, injected clock. Three deliberate divergences the browser analogy hides:
1120
+ *
1121
+ * - **Keyed by `(language, projectRoot)` and SHARED across MCP sessions** (not one ephemeral
1122
+ * context per session). A server is expensive to spawn and *warm* (indexing takes
1123
+ * seconds-to-minutes), so it is reused across calls with a longer idle TTL than browser's.
1124
+ * - **Per-`(server, uri)` async mutex.** JSON-RPC id-correlation makes concurrent *requests*
1125
+ * safe, but the document lifecycle is shared mutable state: two concurrent queries each
1126
+ * sending `didOpen(version 1)` on the same file is a protocol violation. The mutex
1127
+ * serializes the open+query critical section per file (open-once + refcount lives in the
1128
+ * client; the mutex is what makes that race-free under concurrency).
1129
+ * - **The reaper respects an in-flight counter** and resets the idle clock on request start:
1130
+ * never reap a server with `inFlight > 0`; on reap send LSP `shutdown` → `exit` with a
1131
+ * clock-driven grace before the hard `dispose()` (SIGKILL). Deterministic in tests via the
1132
+ * injected clock + `sweepIdle(nowMs)`.
1133
+ *
1134
+ * Safety (re-derived from the browser pillar): `rootUri`/`workspaceFolders` are pinned to the
1135
+ * operator-allowlisted `projectRoot` — the agent never supplies a root, and a root outside
1136
+ * `allowedRoots` is refused before any spawn. The agent supplies only a `language`; an unbound
1137
+ * language is refused by the registry, never spawned. `allowRun` (the paired gate) lives one
1138
+ * layer up in `query.ts`.
1139
+ */
1140
+ /** Thrown when the manager refuses a request (root outside the allowlist, server cap reached). */
1141
+ var LspManagerError = class extends Error {
1142
+ constructor(message) {
1143
+ super(message);
1144
+ this.name = "LspManagerError";
1145
+ }
1146
+ };
1147
+ /** The LSP capabilities `lsp_languages` reports per active server (v1 + staged tools). */
1148
+ const REPORTED_CAPABILITIES = {
1149
+ definition: "definitionProvider",
1150
+ references: "referencesProvider",
1151
+ hover: "hoverProvider",
1152
+ typeDefinition: "typeDefinitionProvider",
1153
+ documentSymbol: "documentSymbolProvider",
1154
+ workspaceSymbol: "workspaceSymbolProvider",
1155
+ callHierarchy: "callHierarchyProvider"
1156
+ };
1157
+ /** A server is keyed by `(language, sorted root group)`; parts are joined with `\x1f` (unit
1158
+ * separator) so a path with a space can never collide with the language separator — and, unlike
1159
+ * a raw NUL, it keeps this file as plain text for grep/find-replace tooling. */
1160
+ const serverKey = (language, roots) => `${language}\x1f${roots.join("")}`;
1161
+ const defaultDelay = (ms) => new Promise((r) => setTimeout(r, ms));
1162
+ var LanguageServerManager = class {
1163
+ registry;
1164
+ allowedRoots;
1165
+ timeoutMs;
1166
+ serverSpawn;
1167
+ idleTtlMs;
1168
+ maxServers;
1169
+ shutdownGraceMs;
1170
+ noRetry;
1171
+ now;
1172
+ delay;
1173
+ servers = /* @__PURE__ */ new Map();
1174
+ /** In-flight spawn+initialize, so concurrent first callers share one initialization. */
1175
+ initing = /* @__PURE__ */ new Map();
1176
+ reaper;
1177
+ constructor(options) {
1178
+ this.registry = options.registry;
1179
+ this.allowedRoots = options.allowedRoots;
1180
+ this.timeoutMs = options.timeoutMs;
1181
+ this.serverSpawn = options.serverSpawn ?? defaultServerSpawn;
1182
+ this.idleTtlMs = options.idleTtlMs ?? 15 * 6e4;
1183
+ this.maxServers = options.maxServers ?? 8;
1184
+ this.shutdownGraceMs = options.shutdownGraceMs ?? 2e3;
1185
+ this.noRetry = options.noRetry ?? false;
1186
+ this.now = options.now ?? Date.now;
1187
+ this.delay = options.delay ?? defaultDelay;
1188
+ }
1189
+ get serverCount() {
1190
+ return this.servers.size;
1191
+ }
1192
+ /**
1193
+ * Run `fn` against the (shared, lazily-spawned) server for `(language, projectRoot)`, under
1194
+ * the per-`(server, uri)` mutex. The document is opened once (refcounted in the client) and
1195
+ * the server's idle clock is reset on entry and exit; `inFlight` is held for the duration so
1196
+ * the reaper cannot tear the server down mid-request.
1197
+ */
1198
+ async run(input, fn) {
1199
+ const entry = await this.acquire(input.language, input.projectRoot, input.workspaceRoots);
1200
+ return this.withUriLock(entry, input.uri, async () => {
1201
+ entry.inFlight += 1;
1202
+ entry.lastUsedAt = this.now();
1203
+ try {
1204
+ const languageId = this.registry[input.language]?.languageId ?? input.language;
1205
+ entry.client.ensureOpen(input.uri, languageId, input.text);
1206
+ const result = await fn(entry.client);
1207
+ entry.lastUsedAt = this.now();
1208
+ return result;
1209
+ } finally {
1210
+ entry.inFlight -= 1;
1211
+ }
1212
+ });
1213
+ }
1214
+ /**
1215
+ * Like {@link run}, but holds the per-`(server, uri)` lock for MANY uris at once — the
1216
+ * primitive a multi-file write needs (Slice F′). Locks are acquired in a deterministic SORTED
1217
+ * order so two concurrent multi-file renames can never deadlock (no lock-ordering cycle), and
1218
+ * are all held across the whole `fn` (the stage+commit+`didChange` window). Does NOT `didOpen`
1219
+ * any document — the caller opened what it needs during the compute phase (open-once/refcount).
1220
+ */
1221
+ async runWithUris(input, fn) {
1222
+ const entry = await this.acquire(input.language, input.projectRoot, input.workspaceRoots);
1223
+ return this.withUriLocks(entry, input.uris, async () => {
1224
+ entry.inFlight += 1;
1225
+ entry.lastUsedAt = this.now();
1226
+ try {
1227
+ const result = await fn(entry.client);
1228
+ entry.lastUsedAt = this.now();
1229
+ return result;
1230
+ } finally {
1231
+ entry.inFlight -= 1;
1232
+ }
1233
+ });
1234
+ }
1235
+ /**
1236
+ * The resolved, sorted, de-duplicated root group for a query — the primary `projectRoot` plus any
1237
+ * `workspaceRoots`. EVERY member is `assertRootAllowed`'d (refused before any spawn), so a
1238
+ * multi-root group cannot smuggle in an un-allowlisted folder.
1239
+ */
1240
+ resolveGroup(projectRoot, workspaceRoots) {
1241
+ const all = [projectRoot, ...workspaceRoots].map((r) => resolve(r));
1242
+ for (const r of all) this.assertRootAllowed(r);
1243
+ return [...new Set(all)].sort();
1244
+ }
1245
+ /** Resolve (and lazily spawn+initialize) the shared server for `(language, root group)`. */
1246
+ async acquire(language, projectRoot, workspaceRoots = []) {
1247
+ const roots = this.resolveGroup(projectRoot, workspaceRoots);
1248
+ const spec = resolveServer(this.registry, language);
1249
+ const key = serverKey(language, roots);
1250
+ const existing = this.servers.get(key);
1251
+ if (existing) {
1252
+ existing.lastUsedAt = this.now();
1253
+ return existing;
1254
+ }
1255
+ const grown = this.tryGrowExisting(language, roots, key);
1256
+ if (grown) return grown;
1257
+ const pending = this.initing.get(key);
1258
+ if (pending) return pending;
1259
+ const p = this.spawnAndInit(language, roots, key, spec);
1260
+ this.initing.set(key, p);
1261
+ try {
1262
+ return await p;
1263
+ } finally {
1264
+ this.initing.delete(key);
1265
+ }
1266
+ }
1267
+ /**
1268
+ * Find the UNIQUELY-largest warm server whose root group is a strict subset of `roots` (same
1269
+ * language, and it must advertise workspace-folder change support), grow it to `roots` in place,
1270
+ * and re-key it. Returns the grown entry, or `undefined` when there is no candidate or the largest
1271
+ * subset is ambiguous (≥2 candidates tie for the most roots) — in which case we spawn fresh rather
1272
+ * than guess which to mutate. Grow-only: a subset request of an existing LARGER server is NOT
1273
+ * shrunk (it spawns its own server) — keeping keys, `describe()`, and confinement unambiguous.
1274
+ */
1275
+ tryGrowExisting(language, roots, newKey) {
1276
+ const group = new Set(roots);
1277
+ const candidates = [...this.servers.values()].filter((e) => e.language === language && e.client.supportsWorkspaceFolderChange && e.roots.length < roots.length && e.roots.every((r) => group.has(r)));
1278
+ if (candidates.length === 0) return void 0;
1279
+ const maxSize = Math.max(...candidates.map((e) => e.roots.length));
1280
+ const largest = candidates.filter((e) => e.roots.length === maxSize);
1281
+ if (largest.length !== 1) return void 0;
1282
+ const entry = largest[0];
1283
+ const added = roots.filter((r) => !entry.roots.includes(r)).map((r) => ({
1284
+ uri: pathToFileURL(r).toString(),
1285
+ name: basename(r)
1286
+ }));
1287
+ entry.client.changeWorkspaceFolders(added, []);
1288
+ this.servers.delete(entry.key);
1289
+ entry.key = newKey;
1290
+ entry.roots = roots;
1291
+ entry.projectRoot = roots[0] ?? "";
1292
+ entry.lastUsedAt = this.now();
1293
+ this.servers.set(newKey, entry);
1294
+ return entry;
1295
+ }
1296
+ async spawnAndInit(language, roots, key, spec) {
1297
+ if (this.servers.size >= this.maxServers) throw new LspManagerError(`max servers reached (${this.maxServers}); reap or wait`);
1298
+ const connection = this.serverSpawn({
1299
+ command: spec.command,
1300
+ args: spec.args,
1301
+ initializationOptions: spec.initializationOptions
1302
+ });
1303
+ const client = new LspClient(connection.connection, {
1304
+ timeoutMs: this.timeoutMs,
1305
+ noRetry: this.noRetry,
1306
+ now: this.now,
1307
+ delay: this.delay
1308
+ });
1309
+ const folders = roots.map((r) => ({
1310
+ uri: pathToFileURL(r).toString(),
1311
+ name: basename(r)
1312
+ }));
1313
+ await client.initialize(folders[0]?.uri ?? "", {
1314
+ initializationOptions: spec.initializationOptions,
1315
+ workspaceFolders: folders
1316
+ });
1317
+ const entry = {
1318
+ key,
1319
+ language,
1320
+ projectRoot: roots[0] ?? "",
1321
+ roots,
1322
+ client,
1323
+ connection,
1324
+ lastUsedAt: this.now(),
1325
+ inFlight: 0,
1326
+ locks: /* @__PURE__ */ new Map()
1327
+ };
1328
+ this.servers.set(key, entry);
1329
+ return entry;
1330
+ }
1331
+ /** Provenance for every currently-live server (drives the always-on `lsp_languages` tool). */
1332
+ describe() {
1333
+ return [...this.servers.values()].map((entry) => {
1334
+ const capabilities = {};
1335
+ for (const [name, provider] of Object.entries(REPORTED_CAPABILITIES)) capabilities[name] = entry.client.supports(provider);
1336
+ return {
1337
+ language: entry.language,
1338
+ projectRoot: entry.projectRoot,
1339
+ ...entry.roots.length > 1 ? { roots: entry.roots } : {},
1340
+ ...entry.client.serverInfo ? { serverInfo: entry.client.serverInfo } : {},
1341
+ capabilities
1342
+ };
1343
+ });
1344
+ }
1345
+ assertRootAllowed(projectRoot) {
1346
+ const root = resolve(projectRoot);
1347
+ if (!this.allowedRoots.map((r) => resolve(r)).includes(root)) throw new LspManagerError(`project root ${projectRoot} is not in the operator allowlist`);
1348
+ }
1349
+ /** Serialize `fn` against all other callers holding the same `(server, uri)` lock. */
1350
+ async withUriLock(entry, uri, fn) {
1351
+ const prev = entry.locks.get(uri) ?? Promise.resolve();
1352
+ let release;
1353
+ const current = new Promise((r) => {
1354
+ release = r;
1355
+ });
1356
+ entry.locks.set(uri, prev.then(() => current));
1357
+ await prev;
1358
+ try {
1359
+ return await fn();
1360
+ } finally {
1361
+ release();
1362
+ }
1363
+ }
1364
+ /**
1365
+ * Acquire the per-uri locks for EVERY uri (deduped, SORTED — deadlock-free ordering) and hold
1366
+ * them all across `fn`, releasing in reverse on exit. Each lock chains on the same per-uri
1367
+ * promise the single-uri `withUriLock` uses, so a multi-file write serializes correctly against
1368
+ * any concurrent single-file query on one of its files.
1369
+ */
1370
+ async withUriLocks(entry, uris, fn) {
1371
+ const sorted = [...new Set(uris)].sort();
1372
+ const releases = [];
1373
+ for (const uri of sorted) {
1374
+ const prev = entry.locks.get(uri) ?? Promise.resolve();
1375
+ let release;
1376
+ const current = new Promise((r) => {
1377
+ release = r;
1378
+ });
1379
+ entry.locks.set(uri, prev.then(() => current));
1380
+ await prev;
1381
+ releases.push(release);
1382
+ }
1383
+ try {
1384
+ return await fn();
1385
+ } finally {
1386
+ for (const release of releases.reverse()) release();
1387
+ }
1388
+ }
1389
+ /**
1390
+ * Reap every server idle for at least `idleTtlMs` that has NO in-flight request. Returns the
1391
+ * reaped keys. Drives the deterministic reaper logic; `nowMs` defaults to the injected clock.
1392
+ */
1393
+ async sweepIdle(nowMs = this.now()) {
1394
+ const reaped = [];
1395
+ for (const [key, entry] of this.servers) {
1396
+ if (entry.inFlight > 0) continue;
1397
+ if (nowMs - entry.lastUsedAt >= this.idleTtlMs) reaped.push(key);
1398
+ }
1399
+ await Promise.all(reaped.map((key) => this.reap(key)));
1400
+ return reaped;
1401
+ }
1402
+ async reap(key) {
1403
+ const entry = this.servers.get(key);
1404
+ if (!entry) return;
1405
+ this.servers.delete(key);
1406
+ await this.gracefulStop(entry);
1407
+ }
1408
+ /** LSP `shutdown` → `exit` (graceful), then a clock-driven grace, then the hard `dispose()`. */
1409
+ async gracefulStop(entry) {
1410
+ try {
1411
+ await entry.client.shutdown();
1412
+ } catch {}
1413
+ await this.delay(this.shutdownGraceMs);
1414
+ entry.connection.dispose();
1415
+ }
1416
+ /** Start the production idle reaper (a `setInterval` driving the injected-clock `sweepIdle`). */
1417
+ startReaper(intervalMs) {
1418
+ this.stopReaper();
1419
+ this.reaper = setInterval(() => {
1420
+ this.sweepIdle();
1421
+ }, intervalMs);
1422
+ this.reaper.unref?.();
1423
+ }
1424
+ stopReaper() {
1425
+ if (this.reaper) {
1426
+ clearInterval(this.reaper);
1427
+ this.reaper = void 0;
1428
+ }
1429
+ }
1430
+ /** Gracefully stop and dispose every server; the manager can be reused afterward. */
1431
+ async shutdown() {
1432
+ this.stopReaper();
1433
+ const entries = [...this.servers.values()];
1434
+ this.servers.clear();
1435
+ this.initing.clear();
1436
+ await Promise.all(entries.map((entry) => this.gracefulStop(entry)));
1437
+ }
1438
+ };
1439
+ //#endregion
1440
+ //#region src/confine.ts
1441
+ /**
1442
+ * The shared paired-gate + path-confinement guards for the LSP engines (ADR 0011). Factored
1443
+ * out of `query.ts` so the read engine (`LspQueryEngine`) and the write engine
1444
+ * (`LspRenameEngine`, Slice F) share one implementation — with a `resolveSymlinks` mode that
1445
+ * the WRITE path always sets.
1446
+ *
1447
+ * The read path confines the single queried file lexically (lower stakes). The write path must
1448
+ * confine EVERY file a `WorkspaceEdit` would touch, and lexically is not enough: `resolve()`
1449
+ * does not canonicalize symlinks, so a symlink INSIDE the root pointing OUTSIDE it passes a
1450
+ * prefix check and a write would clobber the out-of-root target. The write path therefore
1451
+ * `realpath`-canonicalizes the root and each target's nearest existing ancestor and re-asserts
1452
+ * containment, and refuses any non-`file://` scheme. Confinement is pure path/metadata work —
1453
+ * it runs BEFORE any target file content is read.
1454
+ */
1455
+ /** Thrown when the paired operator gate denies a query/edit (allowRun off, out-of-bounds, bad scheme). */
1456
+ var LspGateError = class extends Error {
1457
+ constructor(message) {
1458
+ super(message);
1459
+ this.name = "LspGateError";
1460
+ }
1461
+ };
1462
+ /** The paired deny-by-default gate: `allowRun` + the operator root allowlist. */
1463
+ function assertAllowed(allowRun, allowedRoots, projectRoot) {
1464
+ if (!allowRun) throw new LspGateError("LSP navigation is not enabled (the operator must set allowRun)");
1465
+ const root = resolve(projectRoot);
1466
+ if (!allowedRoots.map((r) => resolve(r)).includes(root)) throw new LspGateError(`project root ${projectRoot} is not in the operator allowlist`);
1467
+ }
1468
+ /** Lexical containment: `child` must equal `root` or sit beneath it. */
1469
+ function assertInside(root, child, message) {
1470
+ if (child !== root && !child.startsWith(root + sep)) throw new LspGateError(message);
1471
+ }
1472
+ /** Canonicalize `abs` by realpath-ing its deepest existing ancestor and re-appending the tail. */
1473
+ function realpathNearest(abs) {
1474
+ let existing = abs;
1475
+ const tail = [];
1476
+ while (!existsSync(existing)) {
1477
+ const parent = dirname(existing);
1478
+ if (parent === existing) break;
1479
+ tail.unshift(basename(existing));
1480
+ existing = parent;
1481
+ }
1482
+ const real = realpathSync(existing);
1483
+ return tail.length > 0 ? resolve(real, ...tail) : real;
1484
+ }
1485
+ /**
1486
+ * Confine a project-relative-or-absolute `file` to `projectRoot`, returning the absolute path.
1487
+ * Read path: lexical (default). Write path (`resolveSymlinks: true`): additionally
1488
+ * realpath-canonicalizes the root + the target's nearest existing ancestor and re-asserts
1489
+ * containment, closing the symlink-escape hole a lexical `resolve()` misses.
1490
+ */
1491
+ function confineFile(projectRoot, file, opts = {}) {
1492
+ const root = resolve(projectRoot);
1493
+ const abs = resolve(root, file);
1494
+ assertInside(root, abs, `file ${file} escapes the project root ${projectRoot}`);
1495
+ if (opts.resolveSymlinks) {
1496
+ let realRoot;
1497
+ try {
1498
+ realRoot = realpathSync(root);
1499
+ } catch {
1500
+ throw new LspGateError(`project root ${projectRoot} does not exist`);
1501
+ }
1502
+ assertInside(realRoot, realpathNearest(abs), `file ${file} escapes the project root ${projectRoot} after symlink resolution`);
1503
+ }
1504
+ return abs;
1505
+ }
1506
+ /**
1507
+ * Like {@link confineEditedUri}, but confines to a GROUP of allowlisted roots (the multi-root
1508
+ * write path): a `WorkspaceEdit` for a monorepo legitimately edits files in any bound workspace
1509
+ * folder, so the edited URI is accepted when it realpath-confines to ANY root in the group, and
1510
+ * refused only when it escapes EVERY root. Refuses a non-`file://` scheme once, up front (it can
1511
+ * never confine to any root). Returns the absolute filesystem path.
1512
+ */
1513
+ function confineEditedUriToRoots(roots, uri) {
1514
+ let abs;
1515
+ try {
1516
+ abs = fileURLToPath(uri);
1517
+ } catch {
1518
+ throw new LspGateError(`edited document ${uri} is not a file:// URI (refused for write)`);
1519
+ }
1520
+ for (const root of roots) try {
1521
+ return confineFile(root, abs, { resolveSymlinks: true });
1522
+ } catch {}
1523
+ throw new LspGateError(`edited document ${uri} escapes every allowlisted root (refused for write)`);
1524
+ }
1525
+ //#endregion
1526
+ //#region src/query.ts
1527
+ /**
1528
+ * The gated LSP query engine (ADR 0011, slice 4) — the agent-facing entry that ties the
1529
+ * operator gate, the `LanguageServerManager`, and the encoding core together. Mirrors
1530
+ * coverage's `runScoped`: a **paired deny-by-default operator gate** (`allowRun` +
1531
+ * `allowedRoots` + the manager's per-request deadline), because every navigation answer
1532
+ * requires a live, code-executing, indexing daemon to exist. There is **no "free read" tier**
1533
+ * here — unlike `search_docs`/`list_requests`.
1534
+ *
1535
+ * The engine owns the I/O the protocol-level client must not: it **confines the queried file
1536
+ * to the project root** (no traversal), reads its text, converts the human 1-based line:col to
1537
+ * a 0-based LSP `Position` in the server's **negotiated encoding**, drives `manager.run`, and
1538
+ * maps the result ranges back to human 1-based line:col (reading each target file's text for an
1539
+ * encoding-faithful inverse; best-effort `+1` fallback when a target — e.g. a dep's `.d.ts` —
1540
+ * is unreadable). Tri-state status passes through untouched (never collapse `not_ready` into
1541
+ * "no result"). `serverInfo` provenance rides on every result; its absence is surfaced as a
1542
+ * `versionWarning` (an answer that cannot be attributed to a server version). The richer
1543
+ * warn-on-toolchain-mismatch heuristic (reusing `core.detectInstalledVersion`) is staged to the
1544
+ * surface, which can pass detected `toolchain` provenance to echo here.
1545
+ */
1546
+ const defaultReadFile$1 = (p) => {
1547
+ try {
1548
+ return readFileSync(p, "utf8");
1549
+ } catch {
1550
+ return;
1551
+ }
1552
+ };
1553
+ /** The position-based kinds — those that require a `line`/`column`. */
1554
+ const POSITION_KINDS = new Set([
1555
+ "definition",
1556
+ "typeDefinition",
1557
+ "references",
1558
+ "hover",
1559
+ "callHierarchy"
1560
+ ]);
1561
+ var LspQueryEngine = class {
1562
+ manager;
1563
+ allowRun;
1564
+ allowedRoots;
1565
+ readFile;
1566
+ constructor(options) {
1567
+ this.manager = options.manager;
1568
+ this.allowRun = options.allowRun;
1569
+ this.allowedRoots = options.allowedRoots;
1570
+ this.readFile = options.readFile ?? defaultReadFile$1;
1571
+ }
1572
+ async query(input) {
1573
+ assertAllowed(this.allowRun, this.allowedRoots, input.projectRoot);
1574
+ for (const root of input.workspaceRoots ?? []) assertAllowed(this.allowRun, this.allowedRoots, root);
1575
+ if (input.kind === "workspaceSymbol") return this.queryWorkspaceSymbol(input);
1576
+ if (input.file === void 0) throw new LspGateError(`the ${input.kind} query requires a file`);
1577
+ const absFile = confineFile(input.projectRoot, input.file);
1578
+ const text = this.readFile(absFile);
1579
+ if (text === void 0) throw new LspGateError(`cannot read file ${input.file} in ${input.projectRoot}`);
1580
+ const uri = pathToFileURL(absFile).toString();
1581
+ if (POSITION_KINDS.has(input.kind) && (input.line === void 0 || input.column === void 0)) throw new LspGateError(`the ${input.kind} query requires a line and column`);
1582
+ const nav = await this.manager.run({
1583
+ language: input.language,
1584
+ projectRoot: input.projectRoot,
1585
+ uri,
1586
+ text,
1587
+ ...input.workspaceRoots ? { workspaceRoots: input.workspaceRoots } : {}
1588
+ }, (client) => {
1589
+ switch (input.kind) {
1590
+ case "documentSymbols": return client.documentSymbols(uri);
1591
+ case "diagnostics": return client.documentDiagnostics(uri);
1592
+ default: {
1593
+ const pos = toLspPosition(text, input.line, input.column, client.encoding);
1594
+ switch (input.kind) {
1595
+ case "definition": return client.definition(uri, pos);
1596
+ case "typeDefinition": return client.typeDefinition(uri, pos);
1597
+ case "references": return client.references(uri, pos);
1598
+ case "hover": return client.hover(uri, pos);
1599
+ case "callHierarchy": return client.callHierarchy(uri, pos, input.direction ?? "incoming");
1600
+ default: throw new LspGateError(`unsupported position-based query kind: ${input.kind}`);
1601
+ }
1602
+ }
1603
+ }
1604
+ });
1605
+ return this.shape(input, nav, uri, text);
1606
+ }
1607
+ /**
1608
+ * Run the project-wide `workspace/symbol` search and shape its cross-file result.
1609
+ *
1610
+ * `workspace/symbol` takes no position, but a real server still needs a *project* to search.
1611
+ * Some servers — notably `typescript-language-server` — only build a project once a document is
1612
+ * open, and answer `workspace/symbol` with a "No Project" error otherwise (caught running the
1613
+ * greeter example live, the cold-load lesson again). So the agent may pass an OPTIONAL anchor
1614
+ * `file`: when present we open it (establishing the project) before searching; when absent we
1615
+ * search with no document open, which works for eager indexers (gopls, rust-analyzer) that load
1616
+ * the project at `initialize`.
1617
+ */
1618
+ async queryWorkspaceSymbol(input) {
1619
+ if (input.query === void 0) throw new LspGateError("the workspaceSymbol query requires a `query` string");
1620
+ const query = input.query;
1621
+ let nav;
1622
+ if (input.file !== void 0) {
1623
+ const absFile = confineFile(input.projectRoot, input.file);
1624
+ const text = this.readFile(absFile);
1625
+ if (text === void 0) throw new LspGateError(`cannot read anchor file ${input.file} in ${input.projectRoot}`);
1626
+ const uri = pathToFileURL(absFile).toString();
1627
+ nav = await this.manager.run({
1628
+ language: input.language,
1629
+ projectRoot: input.projectRoot,
1630
+ uri,
1631
+ text
1632
+ }, (client) => client.workspaceSymbols(query));
1633
+ } else nav = await this.manager.runWithUris({
1634
+ language: input.language,
1635
+ projectRoot: input.projectRoot,
1636
+ uris: [],
1637
+ ...input.workspaceRoots ? { workspaceRoots: input.workspaceRoots } : {}
1638
+ }, (client) => client.workspaceSymbols(query));
1639
+ const base = this.baseResult(input, nav);
1640
+ const cache = /* @__PURE__ */ new Map();
1641
+ return {
1642
+ ...base,
1643
+ workspaceSymbols: nav.result.map((s) => this.mapWorkspaceSymbol(s, nav.encoding, cache))
1644
+ };
1645
+ }
1646
+ /** The shared provenance/status envelope every result carries (status + encoding + provenance). */
1647
+ baseResult(input, nav) {
1648
+ const { encoding, serverInfo } = nav;
1649
+ const versionWarning = serverInfo === void 0 ? "the language server did not report its version (serverInfo); the answer cannot be attributed to a specific server version" : void 0;
1650
+ return {
1651
+ status: nav.status,
1652
+ kind: input.kind,
1653
+ encoding,
1654
+ ...serverInfo ? { serverInfo } : {},
1655
+ ...input.toolchain ? { toolchain: input.toolchain } : {},
1656
+ ...versionWarning ? { versionWarning } : {}
1657
+ };
1658
+ }
1659
+ shape(input, nav, queriedUri, queriedText) {
1660
+ const { encoding } = nav;
1661
+ const base = this.baseResult(input, nav);
1662
+ if (input.kind === "diagnostics") {
1663
+ const diags = nav.result;
1664
+ const cache = new Map([[queriedUri, queriedText]]);
1665
+ return {
1666
+ ...base,
1667
+ diagnostics: diags.map((d) => this.mapDiagnostic(d, queriedText, encoding, cache))
1668
+ };
1669
+ }
1670
+ if (input.kind === "hover") {
1671
+ const hover = nav.result;
1672
+ if (!hover) return base;
1673
+ return {
1674
+ ...base,
1675
+ hover: {
1676
+ value: hover.value,
1677
+ ...hover.range ? { range: this.mapRange(queriedText, hover.range, encoding) } : {}
1678
+ }
1679
+ };
1680
+ }
1681
+ if (input.kind === "documentSymbols") {
1682
+ const symbols = nav.result;
1683
+ return {
1684
+ ...base,
1685
+ symbols: symbols.map((s) => this.mapSymbol(s, queriedText, encoding))
1686
+ };
1687
+ }
1688
+ if (input.kind === "callHierarchy") {
1689
+ const direction = input.direction ?? "incoming";
1690
+ const groups = nav.result;
1691
+ const cache = new Map([[queriedUri, queriedText]]);
1692
+ return {
1693
+ ...base,
1694
+ callHierarchy: groups.map((g) => this.mapCallGroup(g, direction, encoding, queriedUri, cache))
1695
+ };
1696
+ }
1697
+ const locations = nav.result;
1698
+ const cache = /* @__PURE__ */ new Map();
1699
+ return {
1700
+ ...base,
1701
+ locations: locations.map((loc) => this.mapLocation(loc, encoding, queriedUri, queriedText, cache))
1702
+ };
1703
+ }
1704
+ mapLocation(loc, encoding, queriedUri, queriedText, cache) {
1705
+ const text = loc.uri === queriedUri ? queriedText : this.textForUri(loc.uri, cache);
1706
+ return {
1707
+ uri: loc.uri,
1708
+ range: this.mapRange(text, loc.range, encoding),
1709
+ ...loc.fullRange ? { fullRange: this.mapRange(text, loc.fullRange, encoding) } : {},
1710
+ mapped: text !== void 0
1711
+ };
1712
+ }
1713
+ /** Map a normalized document symbol (and its children) to human coords in the queried file. */
1714
+ mapSymbol(s, text, encoding) {
1715
+ const out = {
1716
+ name: s.name,
1717
+ kind: s.kind,
1718
+ kindName: s.kindName,
1719
+ range: this.mapRange(text, s.range, encoding)
1720
+ };
1721
+ if (s.detail !== void 0) out.detail = s.detail;
1722
+ if (s.container !== void 0) out.container = s.container;
1723
+ if (s.children && s.children.length > 0) out.children = s.children.map((c) => this.mapSymbol(c, text, encoding));
1724
+ return out;
1725
+ }
1726
+ /** Map a diagnostic's range (queried file) + any relatedInformation ranges (their own files). */
1727
+ mapDiagnostic(d, queriedText, encoding, cache) {
1728
+ const out = {
1729
+ range: this.mapRange(queriedText, d.range, encoding),
1730
+ message: d.message
1731
+ };
1732
+ if (d.severity !== void 0) out.severity = d.severity;
1733
+ if (d.severityName !== void 0) out.severityName = d.severityName;
1734
+ if (d.code !== void 0) out.code = d.code;
1735
+ if (d.source !== void 0) out.source = d.source;
1736
+ if (d.tags !== void 0) out.tags = d.tags;
1737
+ if (d.related !== void 0) out.related = d.related.map((r) => ({
1738
+ uri: r.uri,
1739
+ range: this.mapRange(this.textForUri(r.uri, cache), r.range, encoding),
1740
+ message: r.message
1741
+ }));
1742
+ return out;
1743
+ }
1744
+ /** Map a workspace symbol to human coords, reading its OWN target file (cross-file, read-only). */
1745
+ mapWorkspaceSymbol(s, encoding, cache) {
1746
+ const out = {
1747
+ name: s.name,
1748
+ kind: s.kind,
1749
+ kindName: s.kindName,
1750
+ uri: s.uri,
1751
+ mapped: false
1752
+ };
1753
+ if (s.container !== void 0) out.container = s.container;
1754
+ if (s.range !== void 0) {
1755
+ const text = this.textForUri(s.uri, cache);
1756
+ out.range = this.mapRange(text, s.range, encoding);
1757
+ out.mapped = text !== void 0;
1758
+ }
1759
+ return out;
1760
+ }
1761
+ mapCallItem(item, encoding, cache) {
1762
+ const text = this.textForUri(item.uri, cache);
1763
+ return {
1764
+ name: item.name,
1765
+ kind: item.kind,
1766
+ kindName: item.kindName,
1767
+ ...item.detail !== void 0 ? { detail: item.detail } : {},
1768
+ uri: item.uri,
1769
+ range: this.mapRange(text, item.range, encoding),
1770
+ selectionRange: this.mapRange(text, item.selectionRange, encoding)
1771
+ };
1772
+ }
1773
+ mapCallGroup(g, direction, encoding, queriedUri, cache) {
1774
+ return {
1775
+ source: this.mapCallItem(g.source, encoding, cache),
1776
+ direction,
1777
+ calls: g.calls.map((c) => {
1778
+ const fromText = this.textForUri(direction === "incoming" ? c.item.uri : queriedUri, cache);
1779
+ return {
1780
+ item: this.mapCallItem(c.item, encoding, cache),
1781
+ fromRanges: c.fromRanges.map((r) => this.mapRange(fromText, r, encoding))
1782
+ };
1783
+ })
1784
+ };
1785
+ }
1786
+ textForUri(uri, cache) {
1787
+ if (cache.has(uri)) return cache.get(uri);
1788
+ let text;
1789
+ try {
1790
+ text = this.readFile(fileURLToPath(uri));
1791
+ } catch {
1792
+ text = void 0;
1793
+ }
1794
+ cache.set(uri, text);
1795
+ return text;
1796
+ }
1797
+ /** Map an LSP 0-based range to a human 1-based range, encoding-faithfully when text is known. */
1798
+ mapRange(text, range, encoding) {
1799
+ if (text === void 0) return {
1800
+ start: {
1801
+ line: range.start.line + 1,
1802
+ column: range.start.character + 1
1803
+ },
1804
+ end: {
1805
+ line: range.end.line + 1,
1806
+ column: range.end.character + 1
1807
+ }
1808
+ };
1809
+ return {
1810
+ start: fromLspPosition(text, range.start, encoding),
1811
+ end: fromLspPosition(text, range.end, encoding)
1812
+ };
1813
+ }
1814
+ };
1815
+ //#endregion
1816
+ //#region src/apply.ts
1817
+ /**
1818
+ * The pure write-mode apply core (ADR 0011 write-mode addendum, Slice A). No I/O, no spawn —
1819
+ * the most defensible TDD entry for write-mode, pinning down the corruption-class vectors
1820
+ * (encoding-wrong offsets, overlapping edits, edit-ordering) before any disk write exists.
1821
+ *
1822
+ * The silent-wrong trap mirrors the read path: a TextEdit `range.character` is in the
1823
+ * NEGOTIATED encoding's code units, so applying it must resolve each position to an absolute
1824
+ * JS index via `lspPositionToOffset` (which is CRLF/BOM/non-BMP faithful) and never via a
1825
+ * naive line:column arithmetic.
1826
+ */
1827
+ /** Thrown when two edits in one document overlap or share a start offset. */
1828
+ var OverlappingEditError = class extends Error {
1829
+ constructor(message) {
1830
+ super(message);
1831
+ this.name = "OverlappingEditError";
1832
+ }
1833
+ };
1834
+ /**
1835
+ * Apply a set of LSP TextEdits to one document's `text`, encoding-faithfully. Each edit's
1836
+ * positions are resolved to absolute JS offsets, validated, then spliced in DESCENDING start
1837
+ * order so an earlier edit never invalidates a later one's offsets.
1838
+ *
1839
+ * Enforced invariants (throw {@link OverlappingEditError}, never silently corrupt):
1840
+ * - Two edits sharing a start offset are refused (subsumes a zero-length double insertion);
1841
+ * with distinct starts the splice order is total and JS sort stability is never relied on.
1842
+ * - A true overlap (`prev.end > next.start`) is refused. Adjacency (`prev.end == next.start`)
1843
+ * is allowed.
1844
+ */
1845
+ function applyTextEdits(text, edits, encoding) {
1846
+ const sorted = [...edits.map((e) => {
1847
+ const start = lspPositionToOffset(text, e.range.start, encoding);
1848
+ const end = lspPositionToOffset(text, e.range.end, encoding);
1849
+ if (end < start) throw new OverlappingEditError(`edit range end (${end}) precedes its start (${start}) — malformed range`);
1850
+ return {
1851
+ start,
1852
+ end,
1853
+ newText: e.newText
1854
+ };
1855
+ })].sort((a, b) => a.start - b.start);
1856
+ for (let k = 1; k < sorted.length; k++) {
1857
+ const prev = sorted[k - 1];
1858
+ const cur = sorted[k];
1859
+ if (cur.start === prev.start) throw new OverlappingEditError(`two edits share start offset ${cur.start}`);
1860
+ if (prev.end > cur.start) throw new OverlappingEditError(`edits overlap: [${prev.start},${prev.end}) and [${cur.start},${cur.end})`);
1861
+ }
1862
+ let out = text;
1863
+ for (let k = sorted.length - 1; k >= 0; k--) {
1864
+ const s = sorted[k];
1865
+ out = out.slice(0, s.start) + s.newText + out.slice(s.end);
1866
+ }
1867
+ return out;
1868
+ }
1869
+ const MAX_RENAME_NAME_LENGTH = 255;
1870
+ /**
1871
+ * A coarse injection guard for a rename target. `newName` is sent verbatim to the server and
1872
+ * then written verbatim into EVERY edited site, so a newline or path separator in it is a
1873
+ * corruption/injection vector. This is a defensive bound, NOT a per-language identifier
1874
+ * validator: it rejects empty / over-length / multi-line / path-separator / control-character
1875
+ * names and accepts everything else (incl. non-ASCII letters). Validated before the rename
1876
+ * request is sent to the server.
1877
+ */
1878
+ function isPlausibleRenameName(newName) {
1879
+ if (typeof newName !== "string") return false;
1880
+ if (newName.length === 0 || newName.length > MAX_RENAME_NAME_LENGTH) return false;
1881
+ if (newName.includes("/") || newName.includes("\\")) return false;
1882
+ if (/[\u0000-\u001f\u007f]/.test(newName)) return false;
1883
+ return true;
1884
+ }
1885
+ //#endregion
1886
+ //#region src/rename.ts
1887
+ /**
1888
+ * The gated LSP rename engine (ADR 0011 write-mode addendum, Slices F + F′). The first WRITE
1889
+ * surface of the pillar. It mirrors `LspQueryEngine` but layers a SECOND operator gate —
1890
+ * `allowWrite` — on top of the read gate (`allowRun` + `allowedRoots`): rename is **dry-run by
1891
+ * default** (compute + preview, ZERO disk writes) and applies to disk only when `allowWrite` is
1892
+ * set AND every safety condition holds.
1893
+ *
1894
+ * Apply is a separate phase from compute (it may need more locks than the compute phase held).
1895
+ * Single- AND multi-file edits apply, the latter under the manager's multi-URI lock (sorted,
1896
+ * deadlock-free) held across the whole stage→commit→`didChange` window. Every touched file is
1897
+ * confined to the root group (realpath, all-or-nothing) BEFORE any I/O. Resource operations
1898
+ * (CreateFile/RenameFile/DeleteFile) APPLY in `documentChanges` order interleaved with text edits;
1899
+ * the replay runs over a per-file `Fate` VFS keyed by ORIGINAL uri, so content flows through a
1900
+ * rename (an edit to a renamed file's new path composes onto the carried content) and net-no-op
1901
+ * batches (create-then-delete) drop out. `ignoreIfExists`/`ignoreIfNotExists` are conditional
1902
+ * no-ops. `overwrite` (Create/Rename truncate-and-replace of an EXISTING regular file) APPLIES only
1903
+ * behind the separate operator `allowDestructiveResourceOps` gate, auditing the clobbered bytes and
1904
+ * surfacing `overwritten[]`; a symlink/directory target, recursive/directory delete, `overwrite` on
1905
+ * a delete, and genuinely ambiguous batches (a rename cycle, two renames into one target, editing a
1906
+ * renamed-away path, deleting a path that is also a rename/create target) all stay refused. A
1907
+ * mid-commit fault is terminal (`partial`).
1908
+ *
1909
+ * The adversarial corrections baked in here: oldText is sliced with absolute offsets (never
1910
+ * reconstructed from line:col); apply is staleness-guarded (the queried file vs its compute hash;
1911
+ * each on-disk edit site vs the old identifier — a not-yet-on-disk rename target is skipped, so an
1912
+ * import fix-up in a moved file never trips it) then stage-then-commit (the injected writer);
1913
+ * out-of-root edits never have their bytes read/surfaced; the post-commit `didChange`/`didFileRename`
1914
+ * doc-sync runs inside the held lock(s) and carries the bytes that ACTUALLY landed (pristine on a
1915
+ * partial commit, never the projected edit); secrets are redacted in every surfaced hunk.
1916
+ */
1917
+ const defaultReadFile = (p) => {
1918
+ try {
1919
+ return readFileSync(p, "utf8");
1920
+ } catch {
1921
+ return;
1922
+ }
1923
+ };
1924
+ const SKIP_DIRS = new Set([
1925
+ "node_modules",
1926
+ ".git",
1927
+ ".hg",
1928
+ ".svn",
1929
+ "__pycache__",
1930
+ ".venv",
1931
+ "venv",
1932
+ "env",
1933
+ ".env",
1934
+ ".tox",
1935
+ ".nox",
1936
+ ".mypy_cache",
1937
+ ".pytest_cache",
1938
+ ".ruff_cache",
1939
+ "dist",
1940
+ "build",
1941
+ "target",
1942
+ ".idea",
1943
+ ".vscode"
1944
+ ]);
1945
+ const MAX_SCAN_FILES = 5e3;
1946
+ /** Default lister: a bounded, symlink-safe recursive walk collecting `extension` files, skipping
1947
+ * dependency/VCS/cache/build dirs and any dotdir. Stops (`truncated`) at {@link MAX_SCAN_FILES}.
1948
+ * The partial-rename guard is OFF until a lister is wired (like `redact`, the surfaces wire this). */
1949
+ const defaultListFiles = (roots, { extension }) => {
1950
+ const files = [];
1951
+ const seenDirs = /* @__PURE__ */ new Set();
1952
+ let truncated = false;
1953
+ const walk = (dir) => {
1954
+ if (truncated) return;
1955
+ let real;
1956
+ try {
1957
+ real = realpathSync(dir);
1958
+ } catch {
1959
+ return;
1960
+ }
1961
+ if (seenDirs.has(real)) return;
1962
+ seenDirs.add(real);
1963
+ let entries;
1964
+ try {
1965
+ entries = readdirSync(dir, { withFileTypes: true });
1966
+ } catch {
1967
+ return;
1968
+ }
1969
+ for (const e of entries) {
1970
+ if (truncated) return;
1971
+ const full = join(dir, e.name);
1972
+ if (e.isDirectory()) {
1973
+ if (SKIP_DIRS.has(e.name) || e.name.startsWith(".")) continue;
1974
+ walk(full);
1975
+ } else if (e.isFile() && extname(e.name) === extension) {
1976
+ if (files.length >= MAX_SCAN_FILES) {
1977
+ truncated = true;
1978
+ return;
1979
+ }
1980
+ files.push(full);
1981
+ }
1982
+ }
1983
+ };
1984
+ for (const r of roots) walk(r);
1985
+ return {
1986
+ files,
1987
+ truncated
1988
+ };
1989
+ };
1990
+ const realpathOrSelf = (p) => {
1991
+ try {
1992
+ return realpathSync(p);
1993
+ } catch {
1994
+ return p;
1995
+ }
1996
+ };
1997
+ const ID_CHAR = /[\p{L}\p{N}_$]/u;
1998
+ /** The identifier token (code-point aware) spanning a 1-based human `line`/`column` in `text`. */
1999
+ function identifierAt(text, line, column) {
2000
+ const ln = text.split(/\r\n|\r|\n/)[line - 1];
2001
+ if (ln === void 0) return "";
2002
+ const cps = [...ln];
2003
+ let i = column - 1;
2004
+ if ((i < 0 || i >= cps.length || !ID_CHAR.test(cps[i])) && i > 0 && ID_CHAR.test(cps[i - 1])) i -= 1;
2005
+ if (i < 0 || i >= cps.length || !ID_CHAR.test(cps[i])) return "";
2006
+ let s = i;
2007
+ let e = i + 1;
2008
+ while (s > 0 && ID_CHAR.test(cps[s - 1])) s -= 1;
2009
+ while (e < cps.length && ID_CHAR.test(cps[e])) e += 1;
2010
+ return cps.slice(s, e).join("");
2011
+ }
2012
+ /** A whole-word matcher for `name` (not flanked by identifier chars). `name` is regex-escaped. */
2013
+ function wholeWordRegex(name) {
2014
+ const esc = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2015
+ return new RegExp(`(?<![\\p{L}\\p{N}_$])${esc}(?![\\p{L}\\p{N}_$])`, "u");
2016
+ }
2017
+ const MAX_SUSPECTS = 50;
2018
+ let tempCounter = 0;
2019
+ const defaultRenameWriter = { commit(ops) {
2020
+ const temps = /* @__PURE__ */ new Map();
2021
+ try {
2022
+ for (const op of ops) {
2023
+ if (op.kind !== "write") continue;
2024
+ mkdirSync(dirname(op.absPath), { recursive: true });
2025
+ tempCounter += 1;
2026
+ const tmp = `${op.absPath}.sackville-rename-${process.pid}-${tempCounter}`;
2027
+ writeFileSync(tmp, op.newText, "utf8");
2028
+ const fd = openSync(tmp, "r+");
2029
+ fsyncSync(fd);
2030
+ closeSync(fd);
2031
+ temps.set(op.absPath, tmp);
2032
+ }
2033
+ } catch (err) {
2034
+ for (const tmp of temps.values()) try {
2035
+ unlinkSync(tmp);
2036
+ } catch {}
2037
+ throw err;
2038
+ }
2039
+ const completed = [];
2040
+ try {
2041
+ for (const op of ops) {
2042
+ if (op.kind === "write") renameSync(temps.get(op.absPath), op.absPath);
2043
+ else if (op.kind === "rename") renameSync(op.fromAbs, op.toAbs);
2044
+ else unlinkSync(op.absPath);
2045
+ completed.push(op);
2046
+ }
2047
+ } catch (err) {
2048
+ for (const [abs, tmp] of temps) if (!completed.some((c) => c.kind === "write" && c.absPath === abs)) try {
2049
+ unlinkSync(tmp);
2050
+ } catch {}
2051
+ return {
2052
+ completed,
2053
+ partial: true,
2054
+ error: err.message
2055
+ };
2056
+ }
2057
+ return {
2058
+ completed,
2059
+ partial: false
2060
+ };
2061
+ } };
2062
+ function isRegularFile(abs) {
2063
+ try {
2064
+ return statSync(abs).isFile();
2065
+ } catch {
2066
+ return false;
2067
+ }
2068
+ }
2069
+ /**
2070
+ * A symlink-AWARE regular-file check for an OVERWRITE target. Refuses a symlink (no clobber THROUGH a
2071
+ * link — the digest would read the link target's bytes while `renameSync`/atomic-write replaces the
2072
+ * link itself, a silent audit lie + the real file survives) and refuses a directory. `lstatSync` does
2073
+ * NOT follow the link, so a symlink ⇒ `isFile()` is false ⇒ refused. Used only on an overwrite target.
2074
+ */
2075
+ function isOverwritableRegularFile(abs) {
2076
+ try {
2077
+ return lstatSync(abs).isFile();
2078
+ } catch {
2079
+ return false;
2080
+ }
2081
+ }
2082
+ /** True if a path exists on disk (any kind), WITHOUT following a symlink. Used to detect an overwrite
2083
+ * destination that `liveOccupied` (content-read-based) misses — notably a DIRECTORY, whose content
2084
+ * read returns undefined so it would otherwise look unoccupied. */
2085
+ function existsLstat(abs) {
2086
+ try {
2087
+ lstatSync(abs);
2088
+ return true;
2089
+ } catch {
2090
+ return false;
2091
+ }
2092
+ }
2093
+ const sha256 = (s) => createHash("sha256").update(s, "utf8").digest("hex");
2094
+ var LspRenameEngine = class {
2095
+ manager;
2096
+ allowRun;
2097
+ allowedRoots;
2098
+ allowWrite;
2099
+ allowPartialRename;
2100
+ allowDestructiveResourceOps;
2101
+ readFile;
2102
+ /** When unset the partial-rename guard is inactive (the bin/CLI/MCP wire `defaultListFiles`). */
2103
+ listFiles;
2104
+ writer;
2105
+ redact;
2106
+ constructor(options) {
2107
+ this.manager = options.manager;
2108
+ this.allowRun = options.allowRun;
2109
+ this.allowedRoots = options.allowedRoots;
2110
+ this.allowWrite = options.allowWrite;
2111
+ this.allowPartialRename = options.allowPartialRename ?? false;
2112
+ this.allowDestructiveResourceOps = options.allowDestructiveResourceOps ?? false;
2113
+ this.readFile = options.readFile ?? defaultReadFile;
2114
+ this.listFiles = options.listFiles;
2115
+ this.writer = options.writer ?? defaultRenameWriter;
2116
+ this.redact = options.redact ?? ((t) => t);
2117
+ }
2118
+ async rename(input) {
2119
+ assertAllowed(this.allowRun, this.allowedRoots, input.projectRoot);
2120
+ for (const root of input.workspaceRoots ?? []) assertAllowed(this.allowRun, this.allowedRoots, root);
2121
+ const queriedAbs = confineFile(input.projectRoot, input.file);
2122
+ const text = this.readFile(queriedAbs);
2123
+ if (text === void 0) throw new LspGateError(`cannot read file ${input.file} in ${input.projectRoot}`);
2124
+ if (!isPlausibleRenameName(input.newName)) throw new LspGateError(`invalid rename target ${JSON.stringify(input.newName)}`);
2125
+ const queriedUri = pathToFileURL(queriedAbs).toString();
2126
+ const run = await this.manager.run({
2127
+ language: input.language,
2128
+ projectRoot: input.projectRoot,
2129
+ uri: queriedUri,
2130
+ text,
2131
+ ...input.workspaceRoots ? { workspaceRoots: input.workspaceRoots } : {}
2132
+ }, async (client) => {
2133
+ const pos = toLspPosition(text, input.line, input.column, client.encoding);
2134
+ const empty = {
2135
+ files: [],
2136
+ resourceOps: [],
2137
+ operations: []
2138
+ };
2139
+ if (client.supportsPrepareRename) {
2140
+ const prep = await client.prepareRename(queriedUri, pos);
2141
+ if (prep.status === "not_ready") return base(prep, empty, { applied: false });
2142
+ if (prep.status === "no_result") return base(prep, empty, { applied: false }, "rename is not valid at this position");
2143
+ }
2144
+ const r = await client.rename(queriedUri, pos, input.newName);
2145
+ return base(r, r.result, { applied: false });
2146
+ });
2147
+ const guard = run.status === "ok" && this.listFiles ? this.assessCompleteness(run.edit, input, queriedAbs, text) : void 0;
2148
+ const destructiveBatch = run.status === "ok" && run.edit.operations.some((o) => o.type !== "edit" && o.options?.overwrite === true);
2149
+ const blockedByGuard = (guard?.completeness === "suspect" || destructiveBatch && guard?.completeness === "unknown") && !this.allowPartialRename;
2150
+ const apply = run.status === "ok" && this.allowWrite && !blockedByGuard ? await this.applyEdit(run.edit, input, queriedUri, text, run.encoding) : { applied: false };
2151
+ const guardRefusal = !(this.allowWrite && blockedByGuard) ? void 0 : guard?.completeness === "suspect" ? `rename may be INCOMPLETE: the symbol ${JSON.stringify(guard?.oldName ?? "")} also appears in ${guard?.suspectFiles.length} same-language file(s) not in this edit (e.g. ${guard?.suspectFiles.slice(0, 3).join(", ")}). The language server may scope rename to open files; nothing was written. Re-run with allowPartialRename to apply anyway.` : `rename completeness could not be verified: the same-language scan for ${JSON.stringify(guard?.oldName ?? "")} was TRUNCATED, and this batch contains a DESTRUCTIVE overwrite — an unverifiable scan is treated as blocking; nothing was written. Re-run with allowPartialRename to apply anyway.`;
2152
+ return this.shape(input, {
2153
+ ...run,
2154
+ apply,
2155
+ refused: guardRefusal ?? apply.refused ?? run.refused
2156
+ }, queriedUri, text, guard);
2157
+ }
2158
+ /**
2159
+ * Decide + execute the apply, consuming the ordered `operations` (text edits interleaved with
2160
+ * CreateFile/RenameFile/DeleteFile). Confine EVERY touched URI (edit + create + rename old&new +
2161
+ * delete) to the root group all-or-nothing BEFORE any I/O; refuse the v1 cuts early (non-default
2162
+ * resource-op options; editing a file also renamed in the same batch). Then, under the multi-URI
2163
+ * lock over ALL touched URIs, replay the ops over a virtual content map (no writes) with the
2164
+ * staleness guards, build a physical plan, stage-then-commit it, and resync any open buffer
2165
+ * (`didChange` for an edited file, `didClose`+`didOpen` migration for a renamed/deleted one).
2166
+ */
2167
+ async applyEdit(edit, input, queriedUri, text, encoding) {
2168
+ const ops = edit.operations;
2169
+ if (ops.length === 0) return { applied: false };
2170
+ const group = [input.projectRoot, ...input.workspaceRoots ?? []];
2171
+ const abs = /* @__PURE__ */ new Map();
2172
+ const rel = (uri) => relative(input.projectRoot, abs.get(uri));
2173
+ for (const op of ops) {
2174
+ const uris = op.type === "rename" ? [op.oldUri, op.newUri] : [op.uri];
2175
+ for (const u of uris) {
2176
+ if (abs.has(u)) continue;
2177
+ try {
2178
+ abs.set(u, confineEditedUriToRoots(group, u));
2179
+ } catch {
2180
+ return {
2181
+ applied: false,
2182
+ refused: "an edited file is outside the project root; previewed only"
2183
+ };
2184
+ }
2185
+ }
2186
+ }
2187
+ const destructiveAllowed = this.allowWrite && this.allowDestructiveResourceOps;
2188
+ for (const op of ops) {
2189
+ if (op.type === "edit") continue;
2190
+ if (op.type === "delete" && op.options?.recursive === true) return {
2191
+ applied: false,
2192
+ refused: "recursive/directory delete is unsupported (refused — not enabled by any gate)"
2193
+ };
2194
+ if (op.type === "delete" && op.options?.overwrite === true) return {
2195
+ applied: false,
2196
+ refused: "overwrite is not a valid option on a delete (malformed; refused)"
2197
+ };
2198
+ if ((op.type === "create" || op.type === "rename") && op.options?.overwrite === true && !destructiveAllowed) return {
2199
+ applied: false,
2200
+ refused: "resource-op overwrite requires the operator destructive-resource-ops gate (allowDestructiveResourceOps + allowWrite); previewed only"
2201
+ };
2202
+ }
2203
+ return this.manager.runWithUris({
2204
+ language: input.language,
2205
+ projectRoot: input.projectRoot,
2206
+ uris: [...abs.keys()],
2207
+ ...input.workspaceRoots ? { workspaceRoots: input.workspaceRoots } : {}
2208
+ }, async (client) => {
2209
+ const refuse = (msg) => ({
2210
+ applied: false,
2211
+ refused: msg
2212
+ });
2213
+ const vfs = /* @__PURE__ */ new Map();
2214
+ const created = /* @__PURE__ */ new Set();
2215
+ const order = [];
2216
+ const ordered = /* @__PURE__ */ new Set();
2217
+ const aliasMap = /* @__PURE__ */ new Map();
2218
+ const diskBefore = /* @__PURE__ */ new Map();
2219
+ const renamedOld = /* @__PURE__ */ new Set();
2220
+ const overwroteExisting = /* @__PURE__ */ new Set();
2221
+ const overwrites = /* @__PURE__ */ new Map();
2222
+ const clobbered = /* @__PURE__ */ new Map();
2223
+ const diskCache = /* @__PURE__ */ new Map();
2224
+ const readDisk = (u) => {
2225
+ if (!diskCache.has(u)) diskCache.set(u, this.readFile(abs.get(u)));
2226
+ return diskCache.get(u);
2227
+ };
2228
+ const resolveOrig = (u) => aliasMap.get(u) ?? u;
2229
+ const touch = (o) => {
2230
+ if (!ordered.has(o)) {
2231
+ ordered.add(o);
2232
+ order.push(o);
2233
+ }
2234
+ if (!diskBefore.has(o)) diskBefore.set(o, readDisk(o) ?? "");
2235
+ };
2236
+ const contentOf = (u) => {
2237
+ const f = vfs.get(resolveOrig(u));
2238
+ if (f) return f.kind === "live" ? f.content : void 0;
2239
+ return readDisk(u);
2240
+ };
2241
+ const liveOccupied = (u) => {
2242
+ const f = vfs.get(resolveOrig(u));
2243
+ return f ? f.kind === "live" : readDisk(u) !== void 0;
2244
+ };
2245
+ let expectedOld;
2246
+ for (const op of ops) if (op.type === "create") {
2247
+ if (resolveOrig(op.uri) !== op.uri) return refuse(`cannot create ${rel(op.uri)}: conflicts with another operation`);
2248
+ if (liveOccupied(op.uri)) {
2249
+ if (op.options?.overwrite === true) {
2250
+ const a = abs.get(op.uri);
2251
+ if (!isOverwritableRegularFile(a)) return refuse(`cannot overwrite ${rel(op.uri)}: not a regular file on disk (symlink/directory — refused)`);
2252
+ if (op.uri === queriedUri && sha256(readDisk(op.uri) ?? "") !== sha256(text)) return refuse("the file changed on disk since the rename was computed; re-query and retry");
2253
+ touch(op.uri);
2254
+ vfs.set(op.uri, {
2255
+ kind: "live",
2256
+ finalUri: op.uri,
2257
+ content: ""
2258
+ });
2259
+ overwroteExisting.add(op.uri);
2260
+ overwrites.set(a, rel(op.uri));
2261
+ continue;
2262
+ }
2263
+ if (op.options?.ignoreIfExists === true) continue;
2264
+ return refuse(`cannot create ${rel(op.uri)}: it already exists`);
2265
+ }
2266
+ touch(op.uri);
2267
+ vfs.set(op.uri, {
2268
+ kind: "live",
2269
+ finalUri: op.uri,
2270
+ content: ""
2271
+ });
2272
+ created.add(op.uri);
2273
+ } else if (op.type === "delete") {
2274
+ if (contentOf(op.uri) === void 0) {
2275
+ if (op.options?.ignoreIfNotExists === true) continue;
2276
+ return refuse(`cannot delete ${rel(op.uri)}: it does not exist`);
2277
+ }
2278
+ const o = resolveOrig(op.uri);
2279
+ if (!created.has(o) && !isRegularFile(abs.get(o))) return refuse(`cannot delete ${rel(op.uri)}: not a regular file (recursive/directory delete unsupported in v1)`);
2280
+ touch(o);
2281
+ vfs.set(o, { kind: "deleted" });
2282
+ } else if (op.type === "rename") {
2283
+ const src = contentOf(op.oldUri);
2284
+ if (src === void 0) return refuse(`cannot rename ${rel(op.oldUri)}: it does not exist`);
2285
+ const o = resolveOrig(op.oldUri);
2286
+ if (renamedOld.has(op.newUri) || resolveOrig(op.newUri) === o) return refuse(`cannot rename ${rel(op.oldUri)} onto a same-batch source (rename cycle in this edit)`);
2287
+ if (resolveOrig(op.newUri) !== op.newUri) return refuse(`cannot rename to ${rel(op.newUri)}: it is already a target in this edit`);
2288
+ const newAbs = abs.get(op.newUri);
2289
+ if (op.options?.overwrite === true && (liveOccupied(op.newUri) || existsLstat(newAbs))) {
2290
+ if (vfs.get(op.newUri)?.kind === "live") return refuse(`cannot overwrite ${rel(op.newUri)}: it is created or edited in this same edit (refused)`);
2291
+ if (!isOverwritableRegularFile(newAbs)) return refuse(`cannot overwrite ${rel(op.newUri)}: not a regular file on disk (symlink/directory — refused)`);
2292
+ if (op.newUri === queriedUri && sha256(readDisk(op.newUri) ?? "") !== sha256(text)) return refuse("the file changed on disk since the rename was computed; re-query and retry");
2293
+ clobbered.set(op.newUri, readDisk(op.newUri) ?? "");
2294
+ overwrites.set(newAbs, rel(op.newUri));
2295
+ } else if (liveOccupied(op.newUri)) {
2296
+ if (op.options?.ignoreIfExists === true) continue;
2297
+ return refuse(`cannot rename to ${rel(op.newUri)}: it already exists`);
2298
+ }
2299
+ touch(o);
2300
+ vfs.set(o, {
2301
+ kind: "live",
2302
+ finalUri: op.newUri,
2303
+ content: src
2304
+ });
2305
+ aliasMap.set(op.newUri, o);
2306
+ renamedOld.add(op.oldUri);
2307
+ } else {
2308
+ if (renamedOld.has(op.uri)) return refuse(`cannot edit ${rel(op.uri)}: it was renamed in this edit; address the new path`);
2309
+ const base = contentOf(op.uri);
2310
+ if (base === void 0) return refuse(`cannot read edited file ${rel(op.uri)}`);
2311
+ if (op.uri === queriedUri && sha256(base) !== sha256(text)) return refuse("the file changed on disk since the rename was computed; re-query and retry");
2312
+ if (op.uri === queriedUri && op.edits[0]) expectedOld = sliceByOffsets(base, op.edits[0].range, encoding);
2313
+ const o = resolveOrig(op.uri);
2314
+ const f = vfs.get(o);
2315
+ const finalUri = f?.kind === "live" ? f.finalUri : op.uri;
2316
+ touch(o);
2317
+ vfs.set(o, {
2318
+ kind: "live",
2319
+ finalUri,
2320
+ content: applyTextEdits(base, op.edits, encoding)
2321
+ });
2322
+ }
2323
+ if (expectedOld !== void 0) for (const op of ops) {
2324
+ if (op.type !== "edit") continue;
2325
+ const oo = resolveOrig(op.uri);
2326
+ if (created.has(oo) || overwroteExisting.has(oo)) continue;
2327
+ const cur = readDisk(op.uri);
2328
+ if (cur === void 0) continue;
2329
+ for (const e of op.edits) if (sliceByOffsets(cur, e.range, encoding) !== expectedOld) return refuse("an edit site no longer matches the renamed symbol; re-query and retry");
2330
+ }
2331
+ const liveFinalAbs = /* @__PURE__ */ new Set();
2332
+ for (const o of order) {
2333
+ const f = vfs.get(o);
2334
+ if (f?.kind === "live") liveFinalAbs.add(abs.get(f.finalUri));
2335
+ }
2336
+ for (const o of order) {
2337
+ if (vfs.get(o)?.kind !== "deleted" || created.has(o)) continue;
2338
+ if (liveFinalAbs.has(abs.get(o))) return refuse(`cannot delete ${rel(o)}: its path is a rename or create target in this edit`);
2339
+ }
2340
+ const physical = [];
2341
+ const digests = [];
2342
+ const digestForPhysical = [];
2343
+ const extraRowsForPhysical = [];
2344
+ const push = (p, d, extra = []) => {
2345
+ physical.push(p);
2346
+ digestForPhysical.push(d);
2347
+ extraRowsForPhysical.push(extra);
2348
+ };
2349
+ const clobberRow = (finalUri) => {
2350
+ if (!clobbered.has(finalUri)) return [];
2351
+ return [digests.push({
2352
+ file: `${rel(finalUri)} (overwritten)`,
2353
+ before: sha256(clobbered.get(finalUri)),
2354
+ after: ""
2355
+ }) - 1];
2356
+ };
2357
+ for (const o of order) {
2358
+ const f = vfs.get(o);
2359
+ if (f?.kind !== "live") continue;
2360
+ const moved = f.finalUri !== o;
2361
+ const before = sha256(diskBefore.get(o) ?? "");
2362
+ const contentChanged = created.has(o) || overwroteExisting.has(o) || sha256(f.content) !== before;
2363
+ if (!moved) {
2364
+ if (contentChanged) {
2365
+ const d = digests.push({
2366
+ file: rel(o),
2367
+ before,
2368
+ after: sha256(f.content)
2369
+ }) - 1;
2370
+ push({
2371
+ kind: "write",
2372
+ absPath: abs.get(o),
2373
+ newText: f.content
2374
+ }, d);
2375
+ }
2376
+ } else if (created.has(o)) {
2377
+ const d = digests.push({
2378
+ file: rel(f.finalUri),
2379
+ before,
2380
+ after: sha256(f.content)
2381
+ }) - 1;
2382
+ push({
2383
+ kind: "write",
2384
+ absPath: abs.get(f.finalUri),
2385
+ newText: f.content
2386
+ }, d);
2387
+ } else if (contentChanged) {
2388
+ const d = digests.push({
2389
+ file: `${rel(o)} → ${rel(f.finalUri)}`,
2390
+ before,
2391
+ after: sha256(f.content)
2392
+ }) - 1;
2393
+ push({
2394
+ kind: "rename",
2395
+ fromAbs: abs.get(o),
2396
+ toAbs: abs.get(f.finalUri)
2397
+ }, d, clobberRow(f.finalUri));
2398
+ push({
2399
+ kind: "write",
2400
+ absPath: abs.get(f.finalUri),
2401
+ newText: f.content
2402
+ }, d);
2403
+ } else {
2404
+ const d = digests.push({
2405
+ file: `${rel(o)} → ${rel(f.finalUri)}`,
2406
+ before,
2407
+ after: before
2408
+ }) - 1;
2409
+ push({
2410
+ kind: "rename",
2411
+ fromAbs: abs.get(o),
2412
+ toAbs: abs.get(f.finalUri)
2413
+ }, d, clobberRow(f.finalUri));
2414
+ }
2415
+ }
2416
+ for (const o of order) {
2417
+ if (vfs.get(o)?.kind !== "deleted" || created.has(o)) continue;
2418
+ const d = digests.push({
2419
+ file: `${rel(o)} (deleted)`,
2420
+ before: sha256(diskBefore.get(o) ?? ""),
2421
+ after: ""
2422
+ }) - 1;
2423
+ push({
2424
+ kind: "delete",
2425
+ absPath: abs.get(o)
2426
+ }, d);
2427
+ }
2428
+ if (physical.length === 0) return { applied: false };
2429
+ const res = this.writer.commit(physical);
2430
+ const landed = new Set(res.completed);
2431
+ const landedWrite = (a) => res.completed.some((p) => p.kind === "write" && p.absPath === a);
2432
+ const landedRename = (a) => res.completed.some((p) => p.kind === "rename" && p.toAbs === a);
2433
+ const landedDelete = (a) => res.completed.some((p) => p.kind === "delete" && p.absPath === a);
2434
+ for (const o of order) {
2435
+ const f = vfs.get(o);
2436
+ if (f?.kind !== "live") continue;
2437
+ const moved = f.finalUri !== o;
2438
+ const toAbs = abs.get(f.finalUri);
2439
+ if (created.has(o)) {
2440
+ if (landedWrite(toAbs)) if (o === queriedUri && moved) client.didFileRename(o, f.finalUri, f.content);
2441
+ else client.applyEdited(f.finalUri, f.content);
2442
+ } else if (moved) {
2443
+ if (landedRename(toAbs)) {
2444
+ if (clobbered.has(f.finalUri)) client.didFileDelete(f.finalUri);
2445
+ const wl = landedWrite(toAbs);
2446
+ client.didFileRename(o, f.finalUri, wl ? f.content : diskBefore.get(o) ?? "");
2447
+ }
2448
+ } else if (landedWrite(abs.get(o))) client.applyEdited(o, f.content);
2449
+ }
2450
+ for (const o of order) {
2451
+ if (vfs.get(o)?.kind !== "deleted" || created.has(o)) continue;
2452
+ if (landedDelete(abs.get(o))) client.didFileDelete(o);
2453
+ }
2454
+ const outDigests = res.partial ? [...new Set(physical.flatMap((p, i) => landed.has(p) ? [digestForPhysical[i], ...extraRowsForPhysical[i]] : []))].map((i) => digests[i]) : digests;
2455
+ const overwritten = [...overwrites].filter(([a]) => landedWrite(a) || landedRename(a)).map(([, r]) => r);
2456
+ return {
2457
+ applied: true,
2458
+ digests: outDigests,
2459
+ ...overwritten.length ? { overwritten } : {},
2460
+ ...res.partial ? {
2461
+ partial: true,
2462
+ partialError: res.error
2463
+ } : {}
2464
+ };
2465
+ });
2466
+ }
2467
+ /**
2468
+ * The partial-rename completeness guard. Extracts the old identifier at the queried position,
2469
+ * then scans the allowlisted root group for same-language files that mention it as a whole word
2470
+ * but are NOT covered by the server's edit (text edits + resource-op endpoints). Any such file is
2471
+ * a SUSPECT — the edit is likely partial (an open-files-scoped server). `unknown` ⇒ the scan was
2472
+ * truncated (cap hit). Server-agnostic: a whole-project-rename server covers every use ⇒ `complete`.
2473
+ */
2474
+ assessCompleteness(edit, input, queriedAbs, queriedText) {
2475
+ const lister = this.listFiles;
2476
+ const oldName = identifierAt(queriedText, input.line, input.column);
2477
+ const group = [input.projectRoot, ...input.workspaceRoots ?? []];
2478
+ if (!lister || !oldName) return {
2479
+ completeness: "complete",
2480
+ suspectFiles: [],
2481
+ oldName
2482
+ };
2483
+ const covered = /* @__PURE__ */ new Set();
2484
+ const cover = (uri) => {
2485
+ try {
2486
+ covered.add(confineEditedUriToRoots(group, uri));
2487
+ } catch {}
2488
+ };
2489
+ for (const f of edit.files) cover(f.uri);
2490
+ for (const op of edit.operations) if (op.type === "rename") {
2491
+ cover(op.oldUri);
2492
+ cover(op.newUri);
2493
+ } else cover(op.uri);
2494
+ const { files, truncated } = lister(group, { extension: extname(queriedAbs) });
2495
+ const re = wholeWordRegex(oldName);
2496
+ const suspectsAbs = [];
2497
+ for (const f of files) {
2498
+ const real = realpathOrSelf(f);
2499
+ if (covered.has(real)) continue;
2500
+ const t = this.readFile(real);
2501
+ if (t === void 0) continue;
2502
+ if (re.test(t)) {
2503
+ suspectsAbs.push(real);
2504
+ if (suspectsAbs.length >= MAX_SUSPECTS) break;
2505
+ }
2506
+ }
2507
+ return {
2508
+ completeness: suspectsAbs.length ? "suspect" : truncated ? "unknown" : "complete",
2509
+ suspectFiles: suspectsAbs.map((p) => relative(input.projectRoot, p)),
2510
+ oldName
2511
+ };
2512
+ }
2513
+ shape(input, run, queriedUri, queriedText, guard) {
2514
+ const { edit, encoding, serverInfo } = run;
2515
+ const totalEditCount = edit.files.reduce((n, f) => n + f.edits.length, 0);
2516
+ const group = [input.projectRoot, ...input.workspaceRoots ?? []];
2517
+ const edits = edit.files.map((f) => this.previewFile(f, group, queriedUri, queriedText, encoding));
2518
+ const versionWarning = serverInfo === void 0 ? "the language server did not report its version (serverInfo); the rename cannot be attributed to a specific server version" : void 0;
2519
+ const relUri = (uri) => {
2520
+ try {
2521
+ return relative(input.projectRoot, confineEditedUriToRoots(group, uri));
2522
+ } catch {
2523
+ return "(out of project root)";
2524
+ }
2525
+ };
2526
+ const resourceOps = edit.resourceOps.map((op) => ({
2527
+ kind: op.kind,
2528
+ uris: op.uris.map(relUri)
2529
+ }));
2530
+ return {
2531
+ status: run.status,
2532
+ kind: "rename",
2533
+ applied: run.apply.applied,
2534
+ ...run.refused ? { refused: run.refused } : {},
2535
+ newName: input.newName,
2536
+ fileCount: edit.files.length,
2537
+ totalEditCount,
2538
+ edits,
2539
+ ...resourceOps.length > 0 ? { resourceOps } : {},
2540
+ ...run.apply.digests ? { digests: run.apply.digests } : {},
2541
+ ...run.apply.overwritten ? { overwritten: run.apply.overwritten } : {},
2542
+ ...run.apply.partial ? { partial: true } : {},
2543
+ ...run.apply.partialError ? { partialError: run.apply.partialError } : {},
2544
+ ...serverInfo ? { serverInfo } : {},
2545
+ ...input.toolchain ? { toolchain: input.toolchain } : {},
2546
+ ...guard ? { completeness: guard.completeness } : {},
2547
+ ...guard && guard.suspectFiles.length > 0 ? { suspectedMissedFiles: guard.suspectFiles } : {},
2548
+ encoding,
2549
+ ...versionWarning ? { versionWarning } : {}
2550
+ };
2551
+ }
2552
+ previewFile(f, group, queriedUri, queriedText, encoding) {
2553
+ let abs;
2554
+ try {
2555
+ abs = confineEditedUriToRoots(group, f.uri);
2556
+ } catch {
2557
+ return {
2558
+ uri: f.uri,
2559
+ file: "(out of project root)",
2560
+ editCount: f.edits.length,
2561
+ outOfRoot: true
2562
+ };
2563
+ }
2564
+ const text = f.uri === queriedUri ? queriedText : this.readFile(abs);
2565
+ const out = {
2566
+ uri: f.uri,
2567
+ file: relative(group[0] ?? "", abs),
2568
+ editCount: f.edits.length
2569
+ };
2570
+ if (text !== void 0) out.hunks = f.edits.map((e) => this.hunk(text, e, encoding));
2571
+ return out;
2572
+ }
2573
+ hunk(text, e, encoding) {
2574
+ const out = {
2575
+ range: {
2576
+ start: fromLspPosition(text, e.range.start, encoding),
2577
+ end: fromLspPosition(text, e.range.end, encoding)
2578
+ },
2579
+ oldText: this.redact(sliceByOffsets(text, e.range, encoding)),
2580
+ newText: this.redact(e.newText)
2581
+ };
2582
+ if (e.needsConfirmation) out.needsConfirmation = true;
2583
+ if (e.annotationLabel !== void 0) out.annotationLabel = this.redact(e.annotationLabel);
2584
+ return out;
2585
+ }
2586
+ };
2587
+ /** The OLD text of an edit, sliced by absolute offsets — NEVER reconstructed from line:col. */
2588
+ function sliceByOffsets(text, range, encoding) {
2589
+ return text.slice(lspPositionToOffset(text, range.start, encoding), lspPositionToOffset(text, range.end, encoding));
2590
+ }
2591
+ function base(nav, edit, apply, refused) {
2592
+ return {
2593
+ status: nav.status,
2594
+ edit,
2595
+ encoding: nav.encoding,
2596
+ ...nav.serverInfo ? { serverInfo: nav.serverInfo } : {},
2597
+ ...refused ? { refused } : {},
2598
+ apply
2599
+ };
2600
+ }
2601
+ //#endregion
2602
+ export { LanguageServerManager, LspClient, LspGateError, LspManagerError, LspQueryEngine, LspRegistryError, LspRenameEngine, LspUnsupportedError, PREFERRED_ENCODINGS, decideStatus, defaultListFiles, defaultRenameWriter, defaultServerSpawn, diagnosticSeverityName, fromLspCharacter, fromLspPosition, normalizeCallHierarchyItem, normalizeCallHierarchyItems, normalizeDiagnostics, normalizeDocumentSymbols, normalizeHover, normalizeIncomingCalls, normalizeLocations, normalizeOutgoingCalls, normalizePrepareRename, normalizeWorkspaceEdit, normalizeWorkspaceSymbols, parseServerRegistry, resolvePositionEncoding, resolveServer, symbolKindName, toLspCharacter, toLspPosition };
2603
+
2604
+ //# sourceMappingURL=index.mjs.map