@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/LICENSE +201 -0
- package/dist/index.d.mts +1247 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2604 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +34 -0
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
|