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,598 @@
|
|
|
1
|
+
/** Shared formatting helpers for tool responses. */
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { ToolInputError } from "./errors.js";
|
|
6
|
+
import { escapeRegExp, readTextStrict, splitLines } from "./text.js";
|
|
7
|
+
// LSP SymbolKind
|
|
8
|
+
const SYMBOL_KINDS = {
|
|
9
|
+
1: "File",
|
|
10
|
+
2: "Module",
|
|
11
|
+
3: "Namespace",
|
|
12
|
+
4: "Package",
|
|
13
|
+
5: "Class",
|
|
14
|
+
6: "Method",
|
|
15
|
+
7: "Property",
|
|
16
|
+
8: "Field",
|
|
17
|
+
9: "Constructor",
|
|
18
|
+
10: "Enum",
|
|
19
|
+
11: "Interface",
|
|
20
|
+
12: "Function",
|
|
21
|
+
13: "Variable",
|
|
22
|
+
14: "Constant",
|
|
23
|
+
15: "String",
|
|
24
|
+
16: "Number",
|
|
25
|
+
17: "Boolean",
|
|
26
|
+
18: "Array",
|
|
27
|
+
19: "Object",
|
|
28
|
+
20: "Key",
|
|
29
|
+
21: "Null",
|
|
30
|
+
22: "EnumMember",
|
|
31
|
+
23: "Struct",
|
|
32
|
+
24: "Event",
|
|
33
|
+
25: "Operator",
|
|
34
|
+
26: "TypeParameter",
|
|
35
|
+
};
|
|
36
|
+
export const DEFAULT_SEARCH_SYMBOL_LIMIT = 50;
|
|
37
|
+
export const DEFAULT_DIAGNOSTICS_LIMIT = 200;
|
|
38
|
+
// When the LSP range starts on a decorator line, walk this many lines past
|
|
39
|
+
// start to find the identifier.
|
|
40
|
+
const NAME_LOOKAHEAD_LINES = 8;
|
|
41
|
+
const DIAGNOSTIC_SEVERITIES = { 1: "error", 2: "warning", 3: "info", 4: "hint" };
|
|
42
|
+
export function uriToPath(uri) {
|
|
43
|
+
try {
|
|
44
|
+
const parsed = new URL(uri);
|
|
45
|
+
if (parsed.protocol === "file:") {
|
|
46
|
+
return fileURLToPath(parsed);
|
|
47
|
+
}
|
|
48
|
+
return decodeURIComponent(parsed.pathname);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return uri;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export function uriToRelative(uri, workspaceRoot) {
|
|
55
|
+
const abs = uriToPath(uri);
|
|
56
|
+
const rel = path.relative(workspaceRoot, abs);
|
|
57
|
+
if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
58
|
+
return rel === "" ? "." : abs;
|
|
59
|
+
}
|
|
60
|
+
return rel.split(path.sep).join("/");
|
|
61
|
+
}
|
|
62
|
+
const linesCache = new Map();
|
|
63
|
+
/** File lines, memoised per (path, mtime, size). `undefined` when unreadable or not UTF-8. */
|
|
64
|
+
function readLines(filePath) {
|
|
65
|
+
try {
|
|
66
|
+
const stat = fs.statSync(filePath);
|
|
67
|
+
const hit = linesCache.get(filePath);
|
|
68
|
+
if (hit && hit.mtimeMs === stat.mtimeMs && hit.size === stat.size) {
|
|
69
|
+
return hit.lines;
|
|
70
|
+
}
|
|
71
|
+
const lines = splitLines(readTextStrict(filePath));
|
|
72
|
+
linesCache.set(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, lines });
|
|
73
|
+
if (linesCache.size > 128) {
|
|
74
|
+
const oldest = linesCache.keys().next().value;
|
|
75
|
+
if (oldest !== undefined) {
|
|
76
|
+
linesCache.delete(oldest);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return lines;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export function snippet(uri, startLine, endLine, context = 0) {
|
|
86
|
+
const lines = readLines(uriToPath(uri));
|
|
87
|
+
if (lines === undefined) {
|
|
88
|
+
return "";
|
|
89
|
+
}
|
|
90
|
+
const lo = Math.max(0, startLine - context);
|
|
91
|
+
const hi = Math.min(lines.length, endLine + 1 + context);
|
|
92
|
+
const numbered = [];
|
|
93
|
+
for (let i = lo; i < hi; i++) {
|
|
94
|
+
numbered.push(`${String(i + 1).padStart(5)} | ${lines[i]}`);
|
|
95
|
+
}
|
|
96
|
+
return numbered.join("\n");
|
|
97
|
+
}
|
|
98
|
+
/** Prefer a LocationLink's selection range when present (narrower symbol span). */
|
|
99
|
+
function locationRange(loc) {
|
|
100
|
+
if ("targetSelectionRange" in loc) {
|
|
101
|
+
return loc.targetSelectionRange ?? {};
|
|
102
|
+
}
|
|
103
|
+
return loc.range ?? loc.targetRange ?? {};
|
|
104
|
+
}
|
|
105
|
+
export function formatLocation(loc, workspaceRoot) {
|
|
106
|
+
const uri = loc.uri ?? loc.targetUri ?? "";
|
|
107
|
+
const rng = locationRange(loc);
|
|
108
|
+
const start = rng.start ?? {};
|
|
109
|
+
const end = rng.end ?? {};
|
|
110
|
+
const startLine = start.line ?? 0;
|
|
111
|
+
const startCol = start.character ?? 0;
|
|
112
|
+
const endLine = end.line ?? startLine;
|
|
113
|
+
const header = `${uriToRelative(uri, workspaceRoot)}:${startLine + 1}:${startCol + 1}`;
|
|
114
|
+
const body = snippet(uri, startLine, endLine, 2);
|
|
115
|
+
return body ? `${header}\n${body}` : header;
|
|
116
|
+
}
|
|
117
|
+
export function symbolKindLabel(kind) {
|
|
118
|
+
if (typeof kind === "number") {
|
|
119
|
+
return SYMBOL_KINDS[kind] ?? `Kind${kind}`;
|
|
120
|
+
}
|
|
121
|
+
return "?";
|
|
122
|
+
}
|
|
123
|
+
/** 0-based (line, character) for `name` near the LSP range, else the range start. */
|
|
124
|
+
function namePosition(uri, name, startLine, startCol, endLine) {
|
|
125
|
+
if (!name) {
|
|
126
|
+
return [startLine, startCol];
|
|
127
|
+
}
|
|
128
|
+
const lines = readLines(uriToPath(uri));
|
|
129
|
+
if (lines === undefined || startLine < 0 || startLine >= lines.length) {
|
|
130
|
+
return [startLine, startCol];
|
|
131
|
+
}
|
|
132
|
+
const rangeEnd = endLine === undefined ? startLine : Math.max(startLine, endLine);
|
|
133
|
+
const hi = Math.min(Math.max(rangeEnd, startLine + NAME_LOOKAHEAD_LINES), lines.length - 1);
|
|
134
|
+
// Whole-identifier match, so `id` doesn't hit `valid`/`uuid`.
|
|
135
|
+
const pattern = new RegExp(`(?<![\\w$])${escapeRegExp(name)}(?![\\w$])`, "g");
|
|
136
|
+
for (let lineNo = startLine; lineNo <= hi; lineNo++) {
|
|
137
|
+
const line = lines[lineNo] ?? "";
|
|
138
|
+
if (line.trimStart().startsWith("@")) {
|
|
139
|
+
continue; // decorator lines mention names without declaring them
|
|
140
|
+
}
|
|
141
|
+
let match = null;
|
|
142
|
+
if (lineNo === startLine) {
|
|
143
|
+
// Skip keywords (class/function) that often begin the range.
|
|
144
|
+
pattern.lastIndex = Math.min(Math.max(0, startCol), line.length);
|
|
145
|
+
match = pattern.exec(line);
|
|
146
|
+
if (!match) {
|
|
147
|
+
pattern.lastIndex = 0;
|
|
148
|
+
match = pattern.exec(line);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
pattern.lastIndex = 0;
|
|
153
|
+
match = pattern.exec(line);
|
|
154
|
+
}
|
|
155
|
+
if (match) {
|
|
156
|
+
return [lineNo, match.index];
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return [startLine, startCol];
|
|
160
|
+
}
|
|
161
|
+
/** (uri, 0-based line, 0-based character) aimed at the symbol name. */
|
|
162
|
+
export function workspaceSymbolPosition(sym) {
|
|
163
|
+
const loc = sym.location ?? {};
|
|
164
|
+
const uri = loc.uri ?? "";
|
|
165
|
+
const selStart = sym.selectionRange?.start;
|
|
166
|
+
if (selStart) {
|
|
167
|
+
return [uri, selStart.line ?? 0, selStart.character ?? 0];
|
|
168
|
+
}
|
|
169
|
+
const start = loc.range?.start ?? {};
|
|
170
|
+
const end = loc.range?.end ?? {};
|
|
171
|
+
const startLine = start.line ?? 0;
|
|
172
|
+
const startCol = start.character ?? 0;
|
|
173
|
+
const [line, col] = namePosition(uri, sym.name ?? "", startLine, startCol, end.line);
|
|
174
|
+
return [uri, line, col];
|
|
175
|
+
}
|
|
176
|
+
export function formatWorkspaceSymbol(sym, workspaceRoot) {
|
|
177
|
+
const name = sym.name || "?";
|
|
178
|
+
const kind = symbolKindLabel(sym.kind);
|
|
179
|
+
const [uri, line, col] = workspaceSymbolPosition(sym);
|
|
180
|
+
const rel = uri ? uriToRelative(uri, workspaceRoot) : "?";
|
|
181
|
+
return `${name} [${kind}] (${rel}:${line + 1}:${col + 1})`;
|
|
182
|
+
}
|
|
183
|
+
// Tier for names that only match the language server's fuzzy subsequence search.
|
|
184
|
+
const FUZZY_TIER = 4;
|
|
185
|
+
export function matchTier(name, query) {
|
|
186
|
+
if (name === query) {
|
|
187
|
+
return 0;
|
|
188
|
+
}
|
|
189
|
+
const foldedName = name.toLowerCase();
|
|
190
|
+
const foldedQuery = query.toLowerCase();
|
|
191
|
+
if (foldedName === foldedQuery) {
|
|
192
|
+
return 1;
|
|
193
|
+
}
|
|
194
|
+
if (foldedName.startsWith(foldedQuery)) {
|
|
195
|
+
return 2;
|
|
196
|
+
}
|
|
197
|
+
if (foldedName.includes(foldedQuery)) {
|
|
198
|
+
return 3;
|
|
199
|
+
}
|
|
200
|
+
return FUZZY_TIER;
|
|
201
|
+
}
|
|
202
|
+
// Property/Field symbols rank after declarations within the same match tier:
|
|
203
|
+
// tsserver reports every `state.foo = x` assignment as its own Property symbol.
|
|
204
|
+
const LOW_PRIORITY_KINDS = new Set([7, 8]);
|
|
205
|
+
// Kinds that are "real" declarations: when one shares a name+file with a
|
|
206
|
+
// Variable, the Variable is almost always the `export { foo }` list entry.
|
|
207
|
+
const DECLARATION_KINDS = new Set([5, 10, 11, 12, 14]); // Class, Enum, Interface, Function, Constant
|
|
208
|
+
const VARIABLE_KIND = 13;
|
|
209
|
+
const TEST_DIR_NAMES = new Set(["tests", "test", "__tests__", "spec", "specs"]);
|
|
210
|
+
/** A path that looks like test code: under a tests-like directory or named like a test file. */
|
|
211
|
+
function isTestPath(p) {
|
|
212
|
+
const parts = p.replaceAll("\\", "/").split("/");
|
|
213
|
+
const name = parts[parts.length - 1] ?? "";
|
|
214
|
+
return (parts.slice(0, -1).some((part) => TEST_DIR_NAMES.has(part)) ||
|
|
215
|
+
name.startsWith("test_") ||
|
|
216
|
+
[".test.ts", ".test.js", ".spec.ts", ".spec.js"].some((suffix) => name.endsWith(suffix)));
|
|
217
|
+
}
|
|
218
|
+
function symbolFileKey(sym) {
|
|
219
|
+
const loc = sym.location;
|
|
220
|
+
return loc?.uri || loc?.targetUri || "";
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Exact → case-insensitive exact → prefix → substring → other; within a tier,
|
|
224
|
+
* production code before tests, then declarations before properties/fields.
|
|
225
|
+
*/
|
|
226
|
+
export function rankWorkspaceSymbols(symbols, query) {
|
|
227
|
+
if (!query) {
|
|
228
|
+
return [...symbols];
|
|
229
|
+
}
|
|
230
|
+
const key = (sym) => [
|
|
231
|
+
matchTier(sym.name ?? "", query),
|
|
232
|
+
isTestPath(symbolFileKey(sym)) ? 1 : 0,
|
|
233
|
+
sym.kind !== undefined && LOW_PRIORITY_KINDS.has(sym.kind) ? 1 : 0,
|
|
234
|
+
];
|
|
235
|
+
return symbols
|
|
236
|
+
.map((sym) => ({ sym, key: key(sym) }))
|
|
237
|
+
.sort((a, b) => a.key[0] - b.key[0] || a.key[1] - b.key[1] || a.key[2] - b.key[2])
|
|
238
|
+
.map((entry) => entry.sym);
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Drop export-list Variable noise and repeated Property/Field assignments.
|
|
242
|
+
* Preserves input order (call after `rankWorkspaceSymbols`).
|
|
243
|
+
*/
|
|
244
|
+
export function filterWorkspaceSymbols(symbols) {
|
|
245
|
+
const declarationKeys = new Set(symbols
|
|
246
|
+
.filter((sym) => sym.kind !== undefined && DECLARATION_KINDS.has(sym.kind))
|
|
247
|
+
.map((sym) => `${sym.name ?? ""}\0${symbolFileKey(sym)}`));
|
|
248
|
+
const filtered = [];
|
|
249
|
+
const seenLowPriority = new Set();
|
|
250
|
+
for (const sym of symbols) {
|
|
251
|
+
const name = sym.name ?? "";
|
|
252
|
+
const kind = sym.kind;
|
|
253
|
+
const fileKey = symbolFileKey(sym);
|
|
254
|
+
if (kind === VARIABLE_KIND && declarationKeys.has(`${name}\0${fileKey}`)) {
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (kind !== undefined && LOW_PRIORITY_KINDS.has(kind)) {
|
|
258
|
+
const key = `${name}\0${kind}\0${fileKey}`;
|
|
259
|
+
if (seenLowPriority.has(key)) {
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
seenLowPriority.add(key);
|
|
263
|
+
}
|
|
264
|
+
filtered.push(sym);
|
|
265
|
+
}
|
|
266
|
+
return filtered;
|
|
267
|
+
}
|
|
268
|
+
const KIND_BY_LABEL = new Map(Object.entries(SYMBOL_KINDS).map(([kind, label]) => [label.toLowerCase(), Number(kind)]));
|
|
269
|
+
/** `"class,function"` (case-insensitive SymbolKind labels) → kind numbers; blank → no filter. */
|
|
270
|
+
export function parseKindFilter(kind) {
|
|
271
|
+
if (kind === undefined || kind === null || !kind.trim()) {
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
const wanted = new Set();
|
|
275
|
+
for (const part of kind.split(",")) {
|
|
276
|
+
const label = part.trim().toLowerCase();
|
|
277
|
+
if (!label) {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const number = KIND_BY_LABEL.get(label);
|
|
281
|
+
if (number === undefined) {
|
|
282
|
+
const valid = [...KIND_BY_LABEL.keys()].sort().join(", ");
|
|
283
|
+
throw new ToolInputError(`unknown symbol kind '${part.trim()}'; use one or more of: ${valid}`);
|
|
284
|
+
}
|
|
285
|
+
wanted.add(number);
|
|
286
|
+
}
|
|
287
|
+
return wanted.size > 0 ? wanted : undefined;
|
|
288
|
+
}
|
|
289
|
+
/** Python `fnmatch` semantics: `*` crosses `/`, `?` is one char, `[seq]`/`[!seq]` classes. */
|
|
290
|
+
function fnmatchToRegExp(pattern) {
|
|
291
|
+
let out = "";
|
|
292
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
293
|
+
const ch = pattern.charAt(i);
|
|
294
|
+
if (ch === "*") {
|
|
295
|
+
out += ".*";
|
|
296
|
+
}
|
|
297
|
+
else if (ch === "?") {
|
|
298
|
+
out += ".";
|
|
299
|
+
}
|
|
300
|
+
else if (ch === "[") {
|
|
301
|
+
let j = i + 1;
|
|
302
|
+
if (pattern.charAt(j) === "!") {
|
|
303
|
+
j++;
|
|
304
|
+
}
|
|
305
|
+
if (pattern.charAt(j) === "]") {
|
|
306
|
+
j++;
|
|
307
|
+
}
|
|
308
|
+
while (j < pattern.length && pattern.charAt(j) !== "]") {
|
|
309
|
+
j++;
|
|
310
|
+
}
|
|
311
|
+
if (j >= pattern.length) {
|
|
312
|
+
out += "\\[";
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
let body = pattern.slice(i + 1, j).replaceAll("\\", "\\\\");
|
|
316
|
+
if (body.startsWith("!")) {
|
|
317
|
+
body = `^${body.slice(1)}`;
|
|
318
|
+
}
|
|
319
|
+
else if (body.startsWith("^")) {
|
|
320
|
+
body = `\\${body}`;
|
|
321
|
+
}
|
|
322
|
+
out += `[${body}]`;
|
|
323
|
+
i = j;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
out += escapeRegExp(ch);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return new RegExp(`^${out}$`, "s");
|
|
331
|
+
}
|
|
332
|
+
function pathFilterMatches(relPath, patternRaw) {
|
|
333
|
+
let pattern = patternRaw.trim().replaceAll("\\", "/");
|
|
334
|
+
if (pattern.startsWith("./")) {
|
|
335
|
+
pattern = pattern.slice(2);
|
|
336
|
+
}
|
|
337
|
+
if (/[*?[]/.test(pattern)) {
|
|
338
|
+
return fnmatchToRegExp(pattern).test(relPath);
|
|
339
|
+
}
|
|
340
|
+
return relPath.startsWith(pattern);
|
|
341
|
+
}
|
|
342
|
+
/** Keep symbols of the given kinds under `path` (workspace-relative prefix, or a glob when it contains `*?[`). */
|
|
343
|
+
export function filterSymbolsByKindAndPath(symbols, workspaceRoot, options) {
|
|
344
|
+
const { kinds, path: pathFilter } = options;
|
|
345
|
+
const hasPath = Boolean(pathFilter?.trim());
|
|
346
|
+
if (kinds === undefined && !hasPath) {
|
|
347
|
+
return symbols;
|
|
348
|
+
}
|
|
349
|
+
return symbols.filter((sym) => {
|
|
350
|
+
if (kinds !== undefined && (sym.kind === undefined || !kinds.has(sym.kind))) {
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
if (hasPath && !pathFilterMatches(uriToRelative(symbolFileKey(sym), workspaceRoot), pathFilter)) {
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
return true;
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Ranked, capped listing. Unless `fuzzy`, loose subsequence-only hits (tier 4)
|
|
361
|
+
* are hidden whenever the name really contains the query somewhere (tiers 0-3).
|
|
362
|
+
*/
|
|
363
|
+
export function formatWorkspaceSymbols(symbols, workspaceRoot, options = {}) {
|
|
364
|
+
const { query = "", limit = DEFAULT_SEARCH_SYMBOL_LIMIT, fuzzy = false } = options;
|
|
365
|
+
if (symbols.length === 0) {
|
|
366
|
+
return "";
|
|
367
|
+
}
|
|
368
|
+
let ranked = filterWorkspaceSymbols(rankWorkspaceSymbols(symbols, query));
|
|
369
|
+
let hiddenFuzzy = 0;
|
|
370
|
+
if (query && !fuzzy) {
|
|
371
|
+
const real = ranked.filter((sym) => matchTier(sym.name ?? "", query) < FUZZY_TIER);
|
|
372
|
+
if (real.length > 0) {
|
|
373
|
+
hiddenFuzzy = ranked.length - real.length;
|
|
374
|
+
ranked = real;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
const shown = ranked.slice(0, Math.max(0, limit));
|
|
378
|
+
const lines = shown.map((sym) => formatWorkspaceSymbol(sym, workspaceRoot));
|
|
379
|
+
const omitted = ranked.length - shown.length;
|
|
380
|
+
if (omitted > 0) {
|
|
381
|
+
lines.push(`… and ${omitted} more (showing first ${shown.length}); narrow with kind=… or path=…`);
|
|
382
|
+
}
|
|
383
|
+
if (hiddenFuzzy > 0) {
|
|
384
|
+
lines.push(`(${hiddenFuzzy} looser fuzzy match${hiddenFuzzy !== 1 ? "es" : ""} whose names don't contain ` +
|
|
385
|
+
`${pyRepr(query)} hidden; pass fuzzy=true to list them)`);
|
|
386
|
+
}
|
|
387
|
+
return lines.join("\n");
|
|
388
|
+
}
|
|
389
|
+
/** Python-style `repr()` of a string, as the tool texts have always quoted names. */
|
|
390
|
+
export function pyRepr(text) {
|
|
391
|
+
if (text.includes("'") && !text.includes('"')) {
|
|
392
|
+
return `"${text.replaceAll("\\", "\\\\")}"`;
|
|
393
|
+
}
|
|
394
|
+
return `'${text.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
|
|
395
|
+
}
|
|
396
|
+
export function formatDiagnostic(item) {
|
|
397
|
+
const start = item.range?.start ?? {};
|
|
398
|
+
const severity = DIAGNOSTIC_SEVERITIES[item.severity ?? -1] ?? "?";
|
|
399
|
+
const code = item.code;
|
|
400
|
+
const tag = code !== undefined && code !== null && code !== "" ? `${severity} ${code}` : severity;
|
|
401
|
+
const lines = splitLines(String(item.message ?? ""));
|
|
402
|
+
if (lines.length === 0) {
|
|
403
|
+
lines.push("");
|
|
404
|
+
}
|
|
405
|
+
const header = `${(start.line ?? 0) + 1}:${(start.character ?? 0) + 1} [${tag}] ${lines[0]}`;
|
|
406
|
+
return [header, ...lines.slice(1).map((line) => ` ${line}`)].join("\n");
|
|
407
|
+
}
|
|
408
|
+
/** Capped, so a badly broken file can't flood the caller with an unbounded wall of text. */
|
|
409
|
+
export function formatDiagnostics(items, limit = DEFAULT_DIAGNOSTICS_LIMIT) {
|
|
410
|
+
if (items.length === 0) {
|
|
411
|
+
return "No diagnostics.";
|
|
412
|
+
}
|
|
413
|
+
const shown = items.slice(0, Math.max(0, limit));
|
|
414
|
+
const lines = shown.map(formatDiagnostic);
|
|
415
|
+
const omitted = items.length - shown.length;
|
|
416
|
+
if (omitted > 0) {
|
|
417
|
+
lines.push(`… and ${omitted} more (showing first ${shown.length})`);
|
|
418
|
+
}
|
|
419
|
+
return lines.join("\n");
|
|
420
|
+
}
|
|
421
|
+
export const DEFAULT_REFERENCE_FILE_LIMIT = 25;
|
|
422
|
+
/** Compact `path: L12, L40, …` grouping (no snippets). */
|
|
423
|
+
export function formatReferencesGrouped(locations, workspaceRoot, options = {}) {
|
|
424
|
+
const { fileLimit = DEFAULT_REFERENCE_FILE_LIMIT, withColumns = false } = options;
|
|
425
|
+
if (locations.length === 0) {
|
|
426
|
+
return "No references found.";
|
|
427
|
+
}
|
|
428
|
+
const groups = new Map();
|
|
429
|
+
for (const loc of locations) {
|
|
430
|
+
const uri = loc.uri ?? loc.targetUri ?? "";
|
|
431
|
+
const start = locationRange(loc).start ?? {};
|
|
432
|
+
const line = (start.line ?? 0) + 1;
|
|
433
|
+
const col = (start.character ?? 0) + 1;
|
|
434
|
+
const rel = uriToRelative(uri, workspaceRoot);
|
|
435
|
+
let group = groups.get(rel);
|
|
436
|
+
if (!group) {
|
|
437
|
+
group = new Map();
|
|
438
|
+
groups.set(rel, group);
|
|
439
|
+
}
|
|
440
|
+
group.set(`${line}:${col}`, [line, col]);
|
|
441
|
+
}
|
|
442
|
+
let total = 0;
|
|
443
|
+
for (const group of groups.values()) {
|
|
444
|
+
total += group.size;
|
|
445
|
+
}
|
|
446
|
+
const files = [...groups.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
447
|
+
const shown = files.slice(0, fileLimit);
|
|
448
|
+
const tag = (line, col) => (withColumns ? `L${line}:${col}` : `L${line}`);
|
|
449
|
+
const lines = [`${total} reference(s) in ${files.length} file(s):`];
|
|
450
|
+
for (const [rel, positions] of shown) {
|
|
451
|
+
const sorted = [...positions.values()].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
452
|
+
lines.push(`${rel}: ${sorted.map(([l, c]) => tag(l, c)).join(", ")}`);
|
|
453
|
+
}
|
|
454
|
+
const omitted = files.length - shown.length;
|
|
455
|
+
if (omitted > 0) {
|
|
456
|
+
lines.push(`… and ${omitted} more file(s)`);
|
|
457
|
+
}
|
|
458
|
+
return lines.join("\n");
|
|
459
|
+
}
|
|
460
|
+
export const DEFAULT_REFERENCES_SNIPPET_LIMIT = 8;
|
|
461
|
+
/** Full per-location snippets for a few hits; above `snippetLimit`, the compact grouped listing. */
|
|
462
|
+
export function formatReferences(locations, workspaceRoot, snippetLimit = DEFAULT_REFERENCES_SNIPPET_LIMIT) {
|
|
463
|
+
if (locations.length === 0) {
|
|
464
|
+
return "No references found at that position.";
|
|
465
|
+
}
|
|
466
|
+
if (locations.length <= snippetLimit) {
|
|
467
|
+
return locations.map((loc) => formatLocation(loc, workspaceRoot)).join("\n\n");
|
|
468
|
+
}
|
|
469
|
+
const grouped = formatReferencesGrouped(locations, workspaceRoot, { withColumns: true });
|
|
470
|
+
return `(compact list: ${locations.length} > ${snippetLimit} hits)\n${grouped}`;
|
|
471
|
+
}
|
|
472
|
+
/** Nested `DocumentSymbol` (has `range`, no `location`) rather than flat `SymbolInformation`. */
|
|
473
|
+
export function isHierarchicalDocumentSymbols(symbols) {
|
|
474
|
+
const first = symbols[0];
|
|
475
|
+
return first !== undefined && "range" in first && !("location" in first);
|
|
476
|
+
}
|
|
477
|
+
function documentSymbolNode(sym) {
|
|
478
|
+
const start = sym.range?.start ?? {};
|
|
479
|
+
const end = sym.range?.end ?? {};
|
|
480
|
+
return {
|
|
481
|
+
name: sym.name || "?",
|
|
482
|
+
kind: sym.kind,
|
|
483
|
+
startLine: start.line ?? 0,
|
|
484
|
+
endLine: end.line ?? start.line ?? 0,
|
|
485
|
+
// Some servers (tsserver) return siblings alphabetically, not in file order.
|
|
486
|
+
children: (sym.children ?? []).map(documentSymbolNode).sort((a, b) => a.startLine - b.startLine),
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
/** Nest a flat `SymbolInformation` list by range containment. */
|
|
490
|
+
function nestSymbolInformation(symbols) {
|
|
491
|
+
const nodes = symbols.map((sym) => {
|
|
492
|
+
const start = sym.location?.range?.start ?? {};
|
|
493
|
+
const end = sym.location?.range?.end ?? {};
|
|
494
|
+
return {
|
|
495
|
+
name: sym.name || "?",
|
|
496
|
+
kind: sym.kind,
|
|
497
|
+
startLine: start.line ?? 0,
|
|
498
|
+
endLine: end.line ?? start.line ?? 0,
|
|
499
|
+
children: [],
|
|
500
|
+
};
|
|
501
|
+
});
|
|
502
|
+
nodes.sort((a, b) => a.startLine - b.startLine || b.endLine - b.startLine - (a.endLine - a.startLine));
|
|
503
|
+
const roots = [];
|
|
504
|
+
const stack = [];
|
|
505
|
+
for (const node of nodes) {
|
|
506
|
+
for (let top = stack[stack.length - 1]; top && !(top.startLine <= node.startLine && node.endLine <= top.endLine);) {
|
|
507
|
+
stack.pop();
|
|
508
|
+
top = stack[stack.length - 1];
|
|
509
|
+
}
|
|
510
|
+
const parent = stack[stack.length - 1];
|
|
511
|
+
(parent ? parent.children : roots).push(node);
|
|
512
|
+
stack.push(node);
|
|
513
|
+
}
|
|
514
|
+
return roots;
|
|
515
|
+
}
|
|
516
|
+
/** Normalise either shape `documentSymbol` can return into `{name, kind, startLine, endLine, children}`. */
|
|
517
|
+
export function toSymbolTree(symbols) {
|
|
518
|
+
if (isHierarchicalDocumentSymbols(symbols)) {
|
|
519
|
+
return symbols.map(documentSymbolNode).sort((a, b) => a.startLine - b.startLine);
|
|
520
|
+
}
|
|
521
|
+
return nestSymbolInformation(symbols);
|
|
522
|
+
}
|
|
523
|
+
// Kinds whose children are implementation detail (locals, callbacks,
|
|
524
|
+
// object-literal keys) rather than structure: Method, Constructor, Function, Variable, Constant.
|
|
525
|
+
export const LOCALS_HOLDER_KINDS = new Set([6, 9, 12, 13, 14]);
|
|
526
|
+
/** Indented `name [Kind] :start-end` tree; children of `collapseKinds` symbols are left out. */
|
|
527
|
+
export function formatOutline(symbols, options = {}) {
|
|
528
|
+
const { indent = " ", collapseKinds = new Set() } = options;
|
|
529
|
+
if (symbols.length === 0) {
|
|
530
|
+
return "No symbols found.";
|
|
531
|
+
}
|
|
532
|
+
const lines = [];
|
|
533
|
+
const walk = (nodes, depth) => {
|
|
534
|
+
for (const node of nodes) {
|
|
535
|
+
const start = node.startLine + 1;
|
|
536
|
+
const end = node.endLine + 1;
|
|
537
|
+
const span = start === end ? `:${start}` : `:${start}-${end}`;
|
|
538
|
+
lines.push(`${indent.repeat(depth)}${node.name} [${symbolKindLabel(node.kind)}] ${span}`);
|
|
539
|
+
if (node.kind === undefined || !collapseKinds.has(node.kind)) {
|
|
540
|
+
walk(node.children, depth + 1);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
walk(toSymbolTree(symbols), 0);
|
|
545
|
+
return lines.join("\n");
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* `callHierarchy/incomingCalls` results as `caller [Kind] (path:line) calls at L.., L..`:
|
|
549
|
+
* the caller's own position plus every call-site line within it, so an agent sees who
|
|
550
|
+
* calls a function without imports/type-only usages mixed in (unlike `references`).
|
|
551
|
+
*/
|
|
552
|
+
export function formatCallers(incoming, workspaceRoot) {
|
|
553
|
+
if (incoming.length === 0) {
|
|
554
|
+
return "No callers found.";
|
|
555
|
+
}
|
|
556
|
+
return incoming
|
|
557
|
+
.map((call) => {
|
|
558
|
+
const from = call.from ?? {};
|
|
559
|
+
const callerLine = (from.selectionRange?.start?.line ?? 0) + 1;
|
|
560
|
+
const sites = [...new Set((call.fromRanges ?? []).map((r) => (r.start?.line ?? 0) + 1))].sort((a, b) => a - b);
|
|
561
|
+
const callSites = sites.length > 0 ? sites.map((n) => `L${n}`).join(", ") : `L${callerLine}`;
|
|
562
|
+
const rel = uriToRelative(from.uri ?? "", workspaceRoot);
|
|
563
|
+
return `${from.name || "?"} [${symbolKindLabel(from.kind)}] (${rel}:${callerLine}) calls at ${callSites}`;
|
|
564
|
+
})
|
|
565
|
+
.join("\n");
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* The symbol whose name sits at a 0-based position, qualified by its enclosing
|
|
569
|
+
* classes/interfaces (`Square.area`). Falls back to the innermost symbol whose
|
|
570
|
+
* range contains the position.
|
|
571
|
+
*/
|
|
572
|
+
export function symbolAt(symbols, line, character) {
|
|
573
|
+
const walk = (nodes, owners) => {
|
|
574
|
+
for (const node of nodes) {
|
|
575
|
+
const start = node.selectionRange?.start ?? node.range?.start;
|
|
576
|
+
const inRange = node.range?.start !== undefined &&
|
|
577
|
+
node.range.end !== undefined &&
|
|
578
|
+
(node.range.start.line ?? 0) <= line &&
|
|
579
|
+
line <= (node.range.end.line ?? 0);
|
|
580
|
+
if (!inRange) {
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
const nested = owners.concat(node.kind === 5 || node.kind === 11 ? [node.name ?? "?"] : []);
|
|
584
|
+
const inner = walk(node.children ?? [], nested);
|
|
585
|
+
if (inner) {
|
|
586
|
+
return inner;
|
|
587
|
+
}
|
|
588
|
+
if (start && (start.line ?? 0) === line && (start.character ?? 0) === character) {
|
|
589
|
+
return { label: [...owners, node.name ?? "?"].join("."), kind: node.kind };
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return undefined;
|
|
593
|
+
};
|
|
594
|
+
if (isHierarchicalDocumentSymbols(symbols)) {
|
|
595
|
+
return walk(symbols, []);
|
|
596
|
+
}
|
|
597
|
+
return undefined;
|
|
598
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Diagnostics go to stderr (stdout carries the MCP protocol) and only when `WEBNAV_MCP_DEBUG` is set. */
|
|
2
|
+
const enabled = Boolean(process.env.WEBNAV_MCP_DEBUG);
|
|
3
|
+
export function debug(message, error) {
|
|
4
|
+
if (enabled) {
|
|
5
|
+
const detail = error instanceof Error ? ` (${error.message})` : "";
|
|
6
|
+
process.stderr.write(`[webnav] ${message}${detail}\n`);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function warn(message) {
|
|
10
|
+
process.stderr.write(`[webnav] ${message}\n`);
|
|
11
|
+
}
|