webnav-ts-mcp 0.1.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 +21 -0
- package/README.md +257 -0
- package/dist/cli.js +34 -0
- package/dist/glob.js +65 -0
- package/dist/langCommand.js +71 -0
- package/dist/outlineImports.js +46 -0
- package/dist/server.js +165 -0
- package/dist/shared/errors.js +80 -0
- package/dist/shared/exclude.js +32 -0
- package/dist/shared/format.js +598 -0
- package/dist/shared/log.js +11 -0
- package/dist/shared/lspClient.js +653 -0
- package/dist/shared/lspTypes.js +2 -0
- package/dist/shared/notices.js +83 -0
- package/dist/shared/params.js +33 -0
- package/dist/shared/paths.js +37 -0
- package/dist/shared/resolve.js +398 -0
- package/dist/shared/text.js +29 -0
- package/dist/shared/workspace.js +139 -0
- package/dist/webIndex.js +773 -0
- package/dist/webnav.js +586 -0
- package/package.json +61 -0
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal async LSP client: generic JSON-RPC/LSP wire protocol plumbing.
|
|
3
|
+
*
|
|
4
|
+
* Not a general-purpose LSP library: only the handful of requests the MCP
|
|
5
|
+
* tools need. Framing and request correlation come from `vscode-jsonrpc`;
|
|
6
|
+
* what stays here is the policy around it: document sync from disk,
|
|
7
|
+
* file-change refresh, config-triggered restarts, retry on ContentModified.
|
|
8
|
+
* Language-server-specific bits (how to launch the server, its languageId)
|
|
9
|
+
* are the caller's responsibility.
|
|
10
|
+
*/
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import crypto from "node:crypto";
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { pathToFileURL } from "node:url";
|
|
16
|
+
import { CancellationTokenSource, createMessageConnection, ResponseError, StreamMessageReader, StreamMessageWriter, } from "vscode-jsonrpc/node";
|
|
17
|
+
import { InvalidPositionError, LanguageServerExitedError, LspRequestError, LspTimeoutError } from "./errors.js";
|
|
18
|
+
import { EXCLUDED_DIR_NAMES } from "./exclude.js";
|
|
19
|
+
import { uriToPath } from "./format.js";
|
|
20
|
+
import { debug } from "./log.js";
|
|
21
|
+
import { LINE_BREAK_RE, readTextStrict } from "./text.js";
|
|
22
|
+
/** Max ms to wait for a push-only server's publishDiagnostics after a sync. */
|
|
23
|
+
export const PUSH_DIAGNOSTICS_TIMEOUT_MS = 5000;
|
|
24
|
+
// Server->client requests that only need an acknowledgement.
|
|
25
|
+
const NULL_REPLY_METHODS = new Set([
|
|
26
|
+
"client/registerCapability",
|
|
27
|
+
"client/unregisterCapability",
|
|
28
|
+
"window/workDoneProgress/create",
|
|
29
|
+
"workspace/diagnostic/refresh",
|
|
30
|
+
"workspace/semanticTokens/refresh",
|
|
31
|
+
"workspace/inlayHint/refresh",
|
|
32
|
+
"workspace/codeLens/refresh",
|
|
33
|
+
]);
|
|
34
|
+
const STDERR_TAIL_LINES = 10;
|
|
35
|
+
// LSP `ContentModified` (-32801) and `ServerCancelled` (-32802): both mean "ask again".
|
|
36
|
+
const RETRYABLE_CODES = new Set([-32801, -32802]);
|
|
37
|
+
const CONTENT_MODIFIED_BACKOFF_MS = [100, 250, 500];
|
|
38
|
+
const DEFAULT_TIMEOUT_MS = 20_000;
|
|
39
|
+
// LSP `FileChangeType`.
|
|
40
|
+
const FILE_CREATED = 1;
|
|
41
|
+
const FILE_CHANGED = 2;
|
|
42
|
+
const FILE_DELETED = 3;
|
|
43
|
+
function deferred() {
|
|
44
|
+
const d = { done: false };
|
|
45
|
+
d.promise = new Promise((resolve) => {
|
|
46
|
+
d.resolve = () => {
|
|
47
|
+
d.done = true;
|
|
48
|
+
resolve();
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
return d;
|
|
52
|
+
}
|
|
53
|
+
function statStamp(p) {
|
|
54
|
+
const st = fs.statSync(p, { bigint: true });
|
|
55
|
+
return { mtimeNs: st.mtimeNs, size: Number(st.size) };
|
|
56
|
+
}
|
|
57
|
+
const sameStamp = (a, b) => a !== undefined && b !== undefined && a[0] === b[0] && a[1] === b[1];
|
|
58
|
+
export class LspClient {
|
|
59
|
+
workspaceRoot;
|
|
60
|
+
command;
|
|
61
|
+
languageId;
|
|
62
|
+
watchSuffixes;
|
|
63
|
+
watchIgnore;
|
|
64
|
+
openWatchedChanges;
|
|
65
|
+
configNames;
|
|
66
|
+
onRestart;
|
|
67
|
+
onNotice;
|
|
68
|
+
languageIds;
|
|
69
|
+
requestTimeoutMs;
|
|
70
|
+
proc;
|
|
71
|
+
conn;
|
|
72
|
+
exited;
|
|
73
|
+
alive = false;
|
|
74
|
+
started = false;
|
|
75
|
+
stderrTail = [];
|
|
76
|
+
diagnosticsByUri = new Map();
|
|
77
|
+
openFiles = new Map();
|
|
78
|
+
// uri -> set by the first publishDiagnostics after the document was last
|
|
79
|
+
// synced, so push-only servers can be awaited instead of answered stale.
|
|
80
|
+
diagEvents = new Map();
|
|
81
|
+
// uri -> (document version, documentSymbol result); the version bumps exactly when the text changes.
|
|
82
|
+
symbolCache = new Map();
|
|
83
|
+
watchSnapshot;
|
|
84
|
+
configSnapshot;
|
|
85
|
+
configHashes = new Map();
|
|
86
|
+
refreshChain = Promise.resolve();
|
|
87
|
+
constructor(options) {
|
|
88
|
+
this.workspaceRoot = options.workspaceRoot;
|
|
89
|
+
this.command = options.command;
|
|
90
|
+
this.languageId = options.languageId;
|
|
91
|
+
this.watchSuffixes = options.watchSuffixes ?? new Set();
|
|
92
|
+
this.watchIgnore = options.watchIgnore;
|
|
93
|
+
this.openWatchedChanges = options.openWatchedChanges ?? false;
|
|
94
|
+
this.configNames = options.configNames ?? new Set();
|
|
95
|
+
this.onRestart = options.onRestart;
|
|
96
|
+
this.onNotice = options.onNotice;
|
|
97
|
+
this.languageIds = options.languageIds ?? {};
|
|
98
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
99
|
+
}
|
|
100
|
+
get isAlive() {
|
|
101
|
+
return this.alive;
|
|
102
|
+
}
|
|
103
|
+
async start() {
|
|
104
|
+
if (this.started) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
this.stderrTail = [];
|
|
108
|
+
const [executable, ...args] = this.command;
|
|
109
|
+
if (executable === undefined) {
|
|
110
|
+
throw new Error("empty language server command");
|
|
111
|
+
}
|
|
112
|
+
const proc = spawn(executable, args, { cwd: this.workspaceRoot, stdio: ["pipe", "pipe", "pipe"] });
|
|
113
|
+
this.proc = proc;
|
|
114
|
+
this.alive = true;
|
|
115
|
+
let onSpawnError = () => { };
|
|
116
|
+
const spawnFailed = new Promise((_, reject) => {
|
|
117
|
+
onSpawnError = reject;
|
|
118
|
+
});
|
|
119
|
+
spawnFailed.catch(() => { });
|
|
120
|
+
this.exited = new Promise((_, reject) => {
|
|
121
|
+
proc.once("error", (error) => {
|
|
122
|
+
this.alive = false;
|
|
123
|
+
onSpawnError(error);
|
|
124
|
+
reject(error);
|
|
125
|
+
});
|
|
126
|
+
proc.once("close", () => {
|
|
127
|
+
this.alive = false;
|
|
128
|
+
reject(new LanguageServerExitedError(this.exitMessage()));
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
this.exited.catch(() => { });
|
|
132
|
+
let stderrBuffer = "";
|
|
133
|
+
proc.stderr?.setEncoding("utf8");
|
|
134
|
+
// Only kept to explain an exit; routine logging isn't surfaced.
|
|
135
|
+
proc.stderr?.on("data", (chunk) => {
|
|
136
|
+
stderrBuffer += chunk;
|
|
137
|
+
const lines = stderrBuffer.split(/\r?\n/);
|
|
138
|
+
stderrBuffer = lines.pop() ?? "";
|
|
139
|
+
for (const line of lines) {
|
|
140
|
+
if (line.trim()) {
|
|
141
|
+
this.stderrTail.push(line.trimEnd());
|
|
142
|
+
if (this.stderrTail.length > STDERR_TAIL_LINES) {
|
|
143
|
+
this.stderrTail.shift();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
proc.stdin?.on("error", () => { }); // EPIPE after the server died: surfaced via `exited`
|
|
149
|
+
const stdout = proc.stdout;
|
|
150
|
+
const stdin = proc.stdin;
|
|
151
|
+
if (!stdout || !stdin) {
|
|
152
|
+
throw new Error("language server subprocess has no stdio pipes");
|
|
153
|
+
}
|
|
154
|
+
const conn = createMessageConnection(new StreamMessageReader(stdout), new StreamMessageWriter(stdin));
|
|
155
|
+
this.conn = conn;
|
|
156
|
+
conn.onRequest((method, params) => this.replyToServerRequest(method, params));
|
|
157
|
+
conn.onNotification("textDocument/publishDiagnostics", (params) => {
|
|
158
|
+
if (params.uri) {
|
|
159
|
+
this.diagnosticsByUri.set(params.uri, params.diagnostics ?? []);
|
|
160
|
+
this.diagEvents.get(params.uri)?.resolve();
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
conn.onError(() => { });
|
|
164
|
+
conn.onClose(() => { });
|
|
165
|
+
conn.listen();
|
|
166
|
+
const rootUri = pathToFileURL(this.workspaceRoot).href;
|
|
167
|
+
try {
|
|
168
|
+
await Promise.race([
|
|
169
|
+
this.request("initialize", {
|
|
170
|
+
processId: null,
|
|
171
|
+
rootUri,
|
|
172
|
+
capabilities: {
|
|
173
|
+
textDocument: {
|
|
174
|
+
synchronization: { didSave: true },
|
|
175
|
+
publishDiagnostics: {},
|
|
176
|
+
documentSymbol: { hierarchicalDocumentSymbolSupport: true },
|
|
177
|
+
callHierarchy: {},
|
|
178
|
+
typeHierarchy: {},
|
|
179
|
+
},
|
|
180
|
+
workspace: { workspaceFolders: true, didChangeWatchedFiles: { dynamicRegistration: true } },
|
|
181
|
+
},
|
|
182
|
+
workspaceFolders: [{ uri: rootUri, name: path.basename(this.workspaceRoot) }],
|
|
183
|
+
}),
|
|
184
|
+
spawnFailed,
|
|
185
|
+
]);
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
await this.stop();
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
this.notify("initialized", {});
|
|
192
|
+
this.started = true;
|
|
193
|
+
}
|
|
194
|
+
async stop() {
|
|
195
|
+
const proc = this.proc;
|
|
196
|
+
const conn = this.conn;
|
|
197
|
+
if (!proc || !conn) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (this.alive && this.started) {
|
|
201
|
+
try {
|
|
202
|
+
await this.request("shutdown", null, 5000);
|
|
203
|
+
this.notify("exit", null);
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
debug("language server didn't respond to shutdown in time; terminating it directly", error);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
conn.dispose();
|
|
210
|
+
const closed = new Promise((resolve) => {
|
|
211
|
+
if (proc.exitCode !== null || proc.signalCode !== null) {
|
|
212
|
+
resolve();
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
proc.once("close", () => resolve());
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
proc.kill();
|
|
219
|
+
await Promise.race([closed, new Promise((resolve) => setTimeout(resolve, 3000).unref())]);
|
|
220
|
+
this.alive = false;
|
|
221
|
+
this.started = false;
|
|
222
|
+
this.proc = undefined;
|
|
223
|
+
this.conn = undefined;
|
|
224
|
+
}
|
|
225
|
+
/** Stop the server and start it again with no memory of the old session. */
|
|
226
|
+
async restart() {
|
|
227
|
+
await this.stop();
|
|
228
|
+
this.diagnosticsByUri = new Map();
|
|
229
|
+
this.openFiles = new Map();
|
|
230
|
+
this.diagEvents = new Map();
|
|
231
|
+
this.symbolCache = new Map();
|
|
232
|
+
await this.start();
|
|
233
|
+
if (this.onRestart) {
|
|
234
|
+
await this.onRestart(this);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// -- wire protocol -----------------------------------------------------
|
|
238
|
+
exitMessage() {
|
|
239
|
+
let message = `language server exited: ${this.command.join(" ")}`;
|
|
240
|
+
if (this.stderrTail.length > 0) {
|
|
241
|
+
message += `\nIts last stderr output:\n${this.stderrTail.join("\n")}`;
|
|
242
|
+
}
|
|
243
|
+
return message;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Answer a server->client request so the server never waits on us
|
|
247
|
+
* (`workspace/configuration`, `client/registerCapability`, progress
|
|
248
|
+
* creation, ...). Unknown methods get MethodNotFound.
|
|
249
|
+
*/
|
|
250
|
+
replyToServerRequest(method, params) {
|
|
251
|
+
if (method === "workspace/configuration") {
|
|
252
|
+
const items = params?.items ?? [];
|
|
253
|
+
return items.map(() => null);
|
|
254
|
+
}
|
|
255
|
+
if (NULL_REPLY_METHODS.has(method)) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
throw new ResponseError(-32601, `Method not found: ${method}`);
|
|
259
|
+
}
|
|
260
|
+
running() {
|
|
261
|
+
if (!this.conn) {
|
|
262
|
+
throw new Error("LspClient.start() must be awaited before use");
|
|
263
|
+
}
|
|
264
|
+
return this.conn;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Send a request, retrying when the server reports the document changed
|
|
268
|
+
* mid-flight (LSP says a client should re-issue such a request); other
|
|
269
|
+
* errors surface as-is.
|
|
270
|
+
*/
|
|
271
|
+
async request(method, params, timeoutMs = this.requestTimeoutMs) {
|
|
272
|
+
for (const delay of CONTENT_MODIFIED_BACKOFF_MS) {
|
|
273
|
+
try {
|
|
274
|
+
return await this.requestOnce(method, params, timeoutMs);
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
if (!(error instanceof LspRequestError) || error.code === undefined || !RETRYABLE_CODES.has(error.code)) {
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
debug(`retrying ${method} after LSP error ${error.code}`);
|
|
281
|
+
}
|
|
282
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
283
|
+
}
|
|
284
|
+
return this.requestOnce(method, params, timeoutMs);
|
|
285
|
+
}
|
|
286
|
+
async requestOnce(method, params, timeoutMs) {
|
|
287
|
+
const conn = this.running();
|
|
288
|
+
const cancel = new CancellationTokenSource();
|
|
289
|
+
let timer;
|
|
290
|
+
const timedOut = new Promise((_, reject) => {
|
|
291
|
+
timer = setTimeout(() => {
|
|
292
|
+
cancel.cancel();
|
|
293
|
+
reject(new LspTimeoutError(`${method} timed out after ${timeoutMs}ms`));
|
|
294
|
+
}, timeoutMs);
|
|
295
|
+
});
|
|
296
|
+
timedOut.catch(() => { });
|
|
297
|
+
try {
|
|
298
|
+
const pending = conn.sendRequest(method, params ?? null, cancel.token);
|
|
299
|
+
pending.catch(() => { });
|
|
300
|
+
const races = [pending, timedOut];
|
|
301
|
+
if (this.exited) {
|
|
302
|
+
races.push(this.exited);
|
|
303
|
+
}
|
|
304
|
+
return await Promise.race(races);
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
if (error instanceof ResponseError) {
|
|
308
|
+
throw new LspRequestError(method, error.code, error.message);
|
|
309
|
+
}
|
|
310
|
+
throw error;
|
|
311
|
+
}
|
|
312
|
+
finally {
|
|
313
|
+
clearTimeout(timer);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
notify(method, params) {
|
|
317
|
+
this.running()
|
|
318
|
+
.sendNotification(method, params ?? null)
|
|
319
|
+
.catch((error) => debug(`could not send ${method}`, error));
|
|
320
|
+
}
|
|
321
|
+
// -- document sync -------------------------------------------------------
|
|
322
|
+
toPath(filePath) {
|
|
323
|
+
const abs = path.isAbsolute(filePath) ? filePath : path.join(this.workspaceRoot, filePath);
|
|
324
|
+
try {
|
|
325
|
+
return fs.realpathSync(abs);
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
return path.resolve(abs);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
languageIdFor(p) {
|
|
332
|
+
return this.languageIds[path.extname(p).toLowerCase()] ?? this.languageId;
|
|
333
|
+
}
|
|
334
|
+
async ensureOpen(filePath) {
|
|
335
|
+
const abs = this.toPath(filePath);
|
|
336
|
+
const uri = pathToFileURL(abs).href;
|
|
337
|
+
const stamp = statStamp(abs);
|
|
338
|
+
const known = this.openFiles.get(uri);
|
|
339
|
+
if (known && known.mtimeNs === stamp.mtimeNs && known.size === stamp.size) {
|
|
340
|
+
return uri;
|
|
341
|
+
}
|
|
342
|
+
const text = readTextStrict(abs);
|
|
343
|
+
this.diagEvents.set(uri, deferred());
|
|
344
|
+
// The previous version's pushed diagnostics describe text that no longer
|
|
345
|
+
// exists; keeping them would resurface fixed errors as a "cache fallback".
|
|
346
|
+
if (known) {
|
|
347
|
+
this.diagnosticsByUri.delete(uri);
|
|
348
|
+
}
|
|
349
|
+
if (!known) {
|
|
350
|
+
this.notify("textDocument/didOpen", {
|
|
351
|
+
textDocument: { uri, languageId: this.languageIdFor(abs), version: 1, text },
|
|
352
|
+
});
|
|
353
|
+
this.openFiles.set(uri, { uri, version: 1, ...stamp });
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
const version = known.version + 1;
|
|
357
|
+
this.notify("textDocument/didChange", {
|
|
358
|
+
textDocument: { uri, version },
|
|
359
|
+
contentChanges: [{ text }],
|
|
360
|
+
});
|
|
361
|
+
this.openFiles.set(uri, { uri, version, ...stamp });
|
|
362
|
+
}
|
|
363
|
+
return uri;
|
|
364
|
+
}
|
|
365
|
+
closeDocument(uri) {
|
|
366
|
+
this.notify("textDocument/didClose", { textDocument: { uri } });
|
|
367
|
+
this.openFiles.delete(uri);
|
|
368
|
+
this.diagnosticsByUri.delete(uri);
|
|
369
|
+
this.diagEvents.delete(uri);
|
|
370
|
+
// Reopening restarts versions at 1, which could falsely match an old entry.
|
|
371
|
+
this.symbolCache.delete(uri);
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* (mtime_ns, size) of every watched source file and every `configNames`
|
|
375
|
+
* file, keyed by resolved path. Skips nested checkouts: a directory holding
|
|
376
|
+
* a `.git` entry below the root is another repository or linked worktree.
|
|
377
|
+
*/
|
|
378
|
+
scanWatched() {
|
|
379
|
+
const sources = new Map();
|
|
380
|
+
const configs = new Map();
|
|
381
|
+
if (this.watchSuffixes.size === 0 && this.configNames.size === 0) {
|
|
382
|
+
return { sources, configs };
|
|
383
|
+
}
|
|
384
|
+
let root;
|
|
385
|
+
try {
|
|
386
|
+
root = fs.realpathSync(this.workspaceRoot);
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
root = path.resolve(this.workspaceRoot);
|
|
390
|
+
}
|
|
391
|
+
const pending = [root];
|
|
392
|
+
for (let directory = pending.pop(); directory !== undefined; directory = pending.pop()) {
|
|
393
|
+
let entries;
|
|
394
|
+
try {
|
|
395
|
+
entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
if (directory !== root && entries.some((e) => e.name === ".git")) {
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
for (const entry of entries) {
|
|
404
|
+
const full = path.join(directory, entry.name);
|
|
405
|
+
if (entry.isDirectory()) {
|
|
406
|
+
if (!EXCLUDED_DIR_NAMES.has(entry.name)) {
|
|
407
|
+
pending.push(full);
|
|
408
|
+
}
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
const isConfig = this.configNames.has(entry.name);
|
|
412
|
+
if (!isConfig && !this.watchSuffixes.has(path.extname(entry.name).toLowerCase())) {
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (!isConfig && this.watchIgnore?.(full)) {
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
try {
|
|
419
|
+
const st = fs.statSync(full, { bigint: true });
|
|
420
|
+
if (st.isDirectory()) {
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
const key = entry.isSymbolicLink() ? fs.realpathSync(full) : full;
|
|
424
|
+
(isConfig ? configs : sources).set(key, [st.mtimeNs, Number(st.size)]);
|
|
425
|
+
}
|
|
426
|
+
catch { }
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return { sources, configs };
|
|
430
|
+
}
|
|
431
|
+
/** Names of config files whose *content* differs from the last time we looked. */
|
|
432
|
+
changedConfigs(configs, previous) {
|
|
433
|
+
const touched = new Set();
|
|
434
|
+
const hashes = new Map();
|
|
435
|
+
for (const [file, stamp] of configs) {
|
|
436
|
+
const known = this.configHashes.get(file);
|
|
437
|
+
if (known !== undefined && previous !== undefined && sameStamp(previous.get(file), stamp)) {
|
|
438
|
+
hashes.set(file, known);
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
let digest;
|
|
442
|
+
try {
|
|
443
|
+
digest = crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
hashes.set(file, digest);
|
|
449
|
+
if (previous !== undefined && digest !== known) {
|
|
450
|
+
touched.add(path.basename(file));
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
for (const file of this.configHashes.keys()) {
|
|
454
|
+
if (!hashes.has(file)) {
|
|
455
|
+
touched.add(path.basename(file));
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
this.configHashes = hashes;
|
|
459
|
+
return [...touched].sort();
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Bring the language server's view of the disk up to date.
|
|
463
|
+
*
|
|
464
|
+
* Servers only see what a client tells them: a document opened with
|
|
465
|
+
* `didOpen` is frozen at that text, and files created/edited/deleted behind
|
|
466
|
+
* the server's back are invisible to workspace-wide answers. Call this
|
|
467
|
+
* before every tool call; it costs one stat walk. Open documents are
|
|
468
|
+
* re-synced (or closed when deleted), and watched-suffix files that
|
|
469
|
+
* appeared/changed/vanished are reported via `workspace/didChangeWatchedFiles`.
|
|
470
|
+
* A change to a `configNames` file restarts the server instead (it only
|
|
471
|
+
* reads its project config at startup).
|
|
472
|
+
*/
|
|
473
|
+
refresh() {
|
|
474
|
+
const run = this.refreshChain.then(() => this.refreshNow());
|
|
475
|
+
this.refreshChain = run.catch(() => { });
|
|
476
|
+
return run;
|
|
477
|
+
}
|
|
478
|
+
async refreshNow() {
|
|
479
|
+
const { sources: current, configs } = this.scanWatched();
|
|
480
|
+
const previousConfigs = this.configSnapshot;
|
|
481
|
+
this.configSnapshot = configs;
|
|
482
|
+
if (previousConfigs !== undefined && !sameStampMaps(configs, previousConfigs)) {
|
|
483
|
+
const touched = this.changedConfigs(configs, previousConfigs);
|
|
484
|
+
if (touched.length > 0) {
|
|
485
|
+
// A fresh server also sees the disk as it is now, so nothing else needs reporting.
|
|
486
|
+
this.watchSnapshot = current;
|
|
487
|
+
await this.restart();
|
|
488
|
+
this.onNotice?.(`restarted the language server because ${touched.join(", ")} changed`);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
else if (previousConfigs === undefined) {
|
|
493
|
+
this.changedConfigs(configs, undefined);
|
|
494
|
+
}
|
|
495
|
+
const changes = [];
|
|
496
|
+
const previous = this.watchSnapshot;
|
|
497
|
+
if (previous !== undefined) {
|
|
498
|
+
for (const [file, stamp] of current) {
|
|
499
|
+
const before = previous.get(file);
|
|
500
|
+
if (before === undefined) {
|
|
501
|
+
changes.push({ uri: pathToFileURL(file).href, type: FILE_CREATED });
|
|
502
|
+
}
|
|
503
|
+
else if (!sameStamp(before, stamp)) {
|
|
504
|
+
changes.push({ uri: pathToFileURL(file).href, type: FILE_CHANGED });
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
for (const file of previous.keys()) {
|
|
508
|
+
if (!current.has(file)) {
|
|
509
|
+
changes.push({ uri: pathToFileURL(file).href, type: FILE_DELETED });
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
this.watchSnapshot = current;
|
|
514
|
+
if (changes.length > 0) {
|
|
515
|
+
this.notify("workspace/didChangeWatchedFiles", { changes });
|
|
516
|
+
}
|
|
517
|
+
if (this.openWatchedChanges) {
|
|
518
|
+
for (const change of changes) {
|
|
519
|
+
if (change.type !== FILE_DELETED) {
|
|
520
|
+
await this.ensureOpen(uriToPath(change.uri));
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
for (const [uri, known] of [...this.openFiles]) {
|
|
525
|
+
if (known.mtimeNs === -1n) {
|
|
526
|
+
continue; // scratch document: never on disk
|
|
527
|
+
}
|
|
528
|
+
const file = uriToPath(uri);
|
|
529
|
+
if (!fs.existsSync(file)) {
|
|
530
|
+
this.closeDocument(uri);
|
|
531
|
+
}
|
|
532
|
+
else {
|
|
533
|
+
await this.ensureOpen(file);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
// -- LSP calls used by the MCP tools --------------------------------------
|
|
538
|
+
/** Reject a position outside the file, so a typo isn't answered with an empty result indistinguishable from "nothing there". */
|
|
539
|
+
checkPosition(filePath, line, column) {
|
|
540
|
+
const lines = readTextStrict(this.toPath(filePath)).split(LINE_BREAK_RE);
|
|
541
|
+
if (lines.length > 1 && lines[lines.length - 1] === "") {
|
|
542
|
+
lines.pop(); // the empty "line" after the final newline isn't a line anyone can point at
|
|
543
|
+
}
|
|
544
|
+
if (!(line >= 1 && line <= lines.length)) {
|
|
545
|
+
throw new InvalidPositionError(`line ${line} is out of range: ${filePath} has ${lines.length} line(s) (lines are 1-indexed)`);
|
|
546
|
+
}
|
|
547
|
+
const width = (lines[line - 1] ?? "").length; // JS string length is already UTF-16 code units
|
|
548
|
+
if (!(column >= 1 && column <= width + 1)) {
|
|
549
|
+
throw new InvalidPositionError(`column ${column} is out of range: line ${line} of ${filePath} is ${width} character(s) long ` +
|
|
550
|
+
"(columns are 1-indexed UTF-16 offsets; a tab counts as one)");
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
async positionRequest(method, filePath, line, column, extra = {}) {
|
|
554
|
+
const uri = await this.ensureOpen(filePath);
|
|
555
|
+
this.checkPosition(filePath, line, column);
|
|
556
|
+
return this.request(method, {
|
|
557
|
+
textDocument: { uri },
|
|
558
|
+
position: { line: line - 1, character: column - 1 },
|
|
559
|
+
...extra,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
async hover(filePath, line, column) {
|
|
563
|
+
return (await this.positionRequest("textDocument/hover", filePath, line, column)) ?? {};
|
|
564
|
+
}
|
|
565
|
+
async definition(filePath, line, column) {
|
|
566
|
+
const result = await this.positionRequest("textDocument/definition", filePath, line, column);
|
|
567
|
+
if (result === null || result === undefined) {
|
|
568
|
+
return [];
|
|
569
|
+
}
|
|
570
|
+
return Array.isArray(result) ? result : [result];
|
|
571
|
+
}
|
|
572
|
+
async references(filePath, line, column, options = {}) {
|
|
573
|
+
const result = await this.positionRequest("textDocument/references", filePath, line, column, {
|
|
574
|
+
context: { includeDeclaration: options.includeDeclaration ?? true },
|
|
575
|
+
});
|
|
576
|
+
return result ?? [];
|
|
577
|
+
}
|
|
578
|
+
async workspaceSymbol(query) {
|
|
579
|
+
return (await this.request("workspace/symbol", { query })) ?? [];
|
|
580
|
+
}
|
|
581
|
+
async diagnostics(filePath) {
|
|
582
|
+
const uri = await this.ensureOpen(filePath);
|
|
583
|
+
const cached = this.diagnosticsByUri.get(uri) ?? [];
|
|
584
|
+
let result;
|
|
585
|
+
try {
|
|
586
|
+
result = await this.request("textDocument/diagnostic", { textDocument: { uri } });
|
|
587
|
+
}
|
|
588
|
+
catch (error) {
|
|
589
|
+
if (!(error instanceof LspRequestError)) {
|
|
590
|
+
throw error;
|
|
591
|
+
}
|
|
592
|
+
// HTML/CSS servers often only push publishDiagnostics and reject pull. If
|
|
593
|
+
// the document was just (re)synced, the push for this version hasn't
|
|
594
|
+
// necessarily arrived yet: wait for it rather than report stale text.
|
|
595
|
+
const event = this.diagEvents.get(uri);
|
|
596
|
+
if (event && !event.done) {
|
|
597
|
+
let timer;
|
|
598
|
+
await Promise.race([
|
|
599
|
+
event.promise,
|
|
600
|
+
new Promise((resolve) => {
|
|
601
|
+
timer = setTimeout(resolve, PUSH_DIAGNOSTICS_TIMEOUT_MS);
|
|
602
|
+
}),
|
|
603
|
+
]);
|
|
604
|
+
clearTimeout(timer);
|
|
605
|
+
}
|
|
606
|
+
return this.diagnosticsByUri.get(uri) ?? [];
|
|
607
|
+
}
|
|
608
|
+
if (result?.kind === "unchanged") {
|
|
609
|
+
return cached;
|
|
610
|
+
}
|
|
611
|
+
// A pull answer for the current version is authoritative, empty included.
|
|
612
|
+
return result?.items ?? cached;
|
|
613
|
+
}
|
|
614
|
+
async documentSymbol(filePath) {
|
|
615
|
+
const uri = await this.ensureOpen(filePath);
|
|
616
|
+
const version = this.openFiles.get(uri)?.version ?? 0;
|
|
617
|
+
const hit = this.symbolCache.get(uri);
|
|
618
|
+
if (hit && hit.version === version) {
|
|
619
|
+
return hit.result;
|
|
620
|
+
}
|
|
621
|
+
const result = (await this.request("textDocument/documentSymbol", { textDocument: { uri } })) ?? [];
|
|
622
|
+
this.symbolCache.set(uri, { version, result });
|
|
623
|
+
return result;
|
|
624
|
+
}
|
|
625
|
+
async implementation(filePath, line, column) {
|
|
626
|
+
const result = await this.positionRequest("textDocument/implementation", filePath, line, column);
|
|
627
|
+
if (result === null || result === undefined) {
|
|
628
|
+
return [];
|
|
629
|
+
}
|
|
630
|
+
return Array.isArray(result) ? result : [result];
|
|
631
|
+
}
|
|
632
|
+
async prepareCallHierarchy(filePath, line, column) {
|
|
633
|
+
const uri = await this.ensureOpen(filePath);
|
|
634
|
+
return ((await this.request("textDocument/prepareCallHierarchy", {
|
|
635
|
+
textDocument: { uri },
|
|
636
|
+
position: { line: line - 1, character: column - 1 },
|
|
637
|
+
})) ?? []);
|
|
638
|
+
}
|
|
639
|
+
async incomingCalls(item) {
|
|
640
|
+
return (await this.request("callHierarchy/incomingCalls", { item })) ?? [];
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
function sameStampMaps(a, b) {
|
|
644
|
+
if (a.size !== b.size) {
|
|
645
|
+
return false;
|
|
646
|
+
}
|
|
647
|
+
for (const [key, stamp] of a) {
|
|
648
|
+
if (!sameStamp(stamp, b.get(key))) {
|
|
649
|
+
return false;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
return true;
|
|
653
|
+
}
|