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.
@@ -0,0 +1,773 @@
1
+ /**
2
+ * Workspace-wide CSS custom-property and class/id selector index.
3
+ *
4
+ * The CSS/HTML language servers each see one document at a time, so `var(--x)`
5
+ * usages and `#id`/`.class` selectors can't be cross-referenced across files.
6
+ * This is a plain scanner, not a language server: no `@import` resolution, no
7
+ * CSS parser, regex/brace-stack grade. It rescans on every call but reuses each
8
+ * root's parsed index while that root's files are unchanged: the cache key is
9
+ * the `(path, mtime_ns, size)` of every relevant file, re-stat'd on each call,
10
+ * so an edit, add, or delete always invalidates it.
11
+ *
12
+ * One or more named roots are indexed separately, since each may define its own
13
+ * values/markup and mixing them into one answer would be misleading (see
14
+ * `buildWorkspaceIndex`; a project configures its roots via
15
+ * `WEBNAV_MCP_ROOTS`). Positions are 1-indexed lines.
16
+ */
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+ import { EXCLUDED_DIR_NAMES } from "./shared/exclude.js";
20
+ import { pyRepr } from "./shared/format.js";
21
+ import { comparePaths, relativeWithin, resolveReal, toPosix } from "./shared/paths.js";
22
+ import { readTextStrict } from "./shared/text.js";
23
+ export const DEFAULT_ROOT_LABEL = "web";
24
+ const VAR_DECL_RE = /(--[a-zA-Z0-9_-]+)\s*:\s*([^;{}]+);/g;
25
+ const VAR_USE_RE = /var\(\s*(--[a-zA-Z0-9_-]+)\s*(,)?/g;
26
+ const SELECTOR_TOKEN_RE = /[.#][a-zA-Z_-][a-zA-Z0-9_-]*/g;
27
+ const STYLE_BLOCK_RE = /<style\b[^>]*>(.*?)<\/style>/dgis;
28
+ // `(?<![\w-])`, not `\b`: `\b` also matches inside `data-id` / `data-style`.
29
+ const STYLE_ATTR_RE = /(?<![\w-])style\s*=\s*"([^"]*)"/dgi;
30
+ // A `src=` attribute means the tag has no inline body to scan.
31
+ const SCRIPT_BLOCK_RE = /<script\b(?![^>]*\bsrc\s*=)[^>]*>(.*?)<\/script>/dgis;
32
+ // Quote is a backreference (group 1) so `id="x"` and `id='x'` both match; the
33
+ // value is group 2. Used for HTML markup and, since JS string literals may
34
+ // themselves quote HTML (`innerHTML = '<span class="x">'`), for JS source too.
35
+ const ID_ATTR_RE = /(?<![\w-])id\s*=\s*(["'])([^"']+)\1/dgi;
36
+ const CLASS_ATTR_RE = /(?<![\w-])class\s*=\s*(["'])([^"']*)\1/dgi;
37
+ // JS string literals below accept backticks too (template literals); a `${…}`
38
+ // inside one ends the static part and is treated like a `+` concatenation.
39
+ const JS_SETPROPERTY_RE = /\.setProperty\(\s*["'`](--[a-zA-Z0-9_-]+)["'`]/g;
40
+ const JS_GETPROPERTYVALUE_RE = /\.getPropertyValue\(\s*["'`](--[a-zA-Z0-9_-]+)["'`]/g;
41
+ const JS_GET_ELEMENT_BY_ID_RE = /getElementById\(\s*["'`]([^"'`]*)["'`]\s*(\+)?/g;
42
+ const JS_CLASSLIST_RE = /classList\.(add|remove|toggle|contains)\(([^)]*)\)/g;
43
+ // An optional TypeScript type argument (`querySelector<HTMLElement>(...)`, one
44
+ // nesting level) may sit before the `(`.
45
+ const TS_TYPE_ARGS = "(?:<[^()<>]*(?:<[^()<>]*>[^()<>]*)*>)?";
46
+ const JS_QUERY_RE = new RegExp(`\\b(?:querySelector(?:All)?|closest|matches)${TS_TYPE_ARGS}\\(\\s*["'\`]([^"'\`]*)["'\`]`, "g");
47
+ const JS_CLASSNAME_ASSIGN_RE = /className\s*\+?=\s*["'`]([^"'`]*)["'`]\s*(\+)?/g;
48
+ const TEMPLATE_EXPR_RE = /\$\{[^}]*\}/g;
49
+ // A local variable conventionally named like a class list (`mediaClass`,
50
+ // `rowClasses`) being built up with `+=`. The exact identifier `className` is
51
+ // excluded (JS_CLASSNAME_ASSIGN_RE covers it) so the two don't double-record.
52
+ const JS_CLASS_VAR_CONCAT_RE = /\b(?!className\b)\w*[Cc]lass\w*\s*\+=\s*["'`]([^"'`]*)["'`]\s*(\+)?/g;
53
+ // A string literal, optionally followed by `+`: finds dynamic prefixes inside `classList.add(...)` arguments.
54
+ const STRING_LITERAL_RE = /["'`]([^"'`]*)["'`]\s*(\+)?/g;
55
+ // A whole string literal that is itself a bare identifier-like name, not
56
+ // followed by `+`. Projects wrap DOM lookups in their own helpers
57
+ // (`onClick("btn-save", …)`) that no fixed list of DOM APIs can know about.
58
+ const BARE_NAME_LITERAL_RE = /(["'`])([A-Za-z_][A-Za-z0-9_-]*)\1(?!\s*\+)/g;
59
+ function* finditer(re, text) {
60
+ for (const match of text.matchAll(re)) {
61
+ yield match;
62
+ }
63
+ }
64
+ /** 1-indexed line lookup for character offsets of one text (binary search over newline positions). */
65
+ function makeLineAt(text) {
66
+ const newlines = [];
67
+ for (let i = text.indexOf("\n"); i !== -1; i = text.indexOf("\n", i + 1)) {
68
+ newlines.push(i);
69
+ }
70
+ return (index) => {
71
+ let lo = 0;
72
+ let hi = newlines.length;
73
+ while (lo < hi) {
74
+ const mid = (lo + hi) >> 1;
75
+ if (newlines[mid] < index) {
76
+ lo = mid + 1;
77
+ }
78
+ else {
79
+ hi = mid;
80
+ }
81
+ }
82
+ return lo + 1;
83
+ };
84
+ }
85
+ const keepNewlines = (m) => "\n".repeat(m.split("\n").length - 1);
86
+ function stripCssComments(text) {
87
+ return text.replace(/\/\*[\s\S]*?\*\//g, keepNewlines);
88
+ }
89
+ /**
90
+ * Drop `//` and `/* *\/` comments (keeping newlines so line numbers hold), but
91
+ * not comment-looking text inside string literals (`"http://x"`). ' and "
92
+ * strings end at a newline, so a stray quote in a regex literal can't desync
93
+ * the scan past its own line.
94
+ */
95
+ function stripJsComments(text) {
96
+ if (!text.includes("//") && !text.includes("/*")) {
97
+ return text;
98
+ }
99
+ const out = [];
100
+ const n = text.length;
101
+ let quote = "";
102
+ let i = 0;
103
+ while (i < n) {
104
+ const ch = text.charAt(i);
105
+ if (quote) {
106
+ out.push(ch);
107
+ if (ch === "\\" && i + 1 < n) {
108
+ out.push(text.charAt(i + 1));
109
+ i += 2;
110
+ continue;
111
+ }
112
+ if (ch === quote || (ch === "\n" && quote !== "`")) {
113
+ quote = "";
114
+ }
115
+ i++;
116
+ }
117
+ else if (ch === '"' || ch === "'" || ch === "`") {
118
+ quote = ch;
119
+ out.push(ch);
120
+ i++;
121
+ }
122
+ else if (text.startsWith("//", i)) {
123
+ const end = text.indexOf("\n", i);
124
+ i = end < 0 ? n : end;
125
+ }
126
+ else if (text.startsWith("/*", i)) {
127
+ let end = text.indexOf("*/", i + 2);
128
+ end = end < 0 ? n : end + 2;
129
+ out.push("\n".repeat(text.slice(i, end).split("\n").length - 1));
130
+ i = end;
131
+ }
132
+ else {
133
+ out.push(ch);
134
+ i++;
135
+ }
136
+ }
137
+ return out.join("");
138
+ }
139
+ /** A template literal's text up to its first `${…}`, flagged dynamic (the same shape as `"view-" + x`). */
140
+ function staticPrefix(literal, concatFollows) {
141
+ const at = literal.indexOf("${");
142
+ return at >= 0 ? [literal.slice(0, at), true] : [literal, concatFollows];
143
+ }
144
+ function stripHtmlComments(text) {
145
+ return text.replace(/<!--[\s\S]*?-->/g, keepNewlines);
146
+ }
147
+ export class RootIndex {
148
+ name;
149
+ root;
150
+ base;
151
+ varDeclarations = new Map();
152
+ varUsages = new Map();
153
+ selectorHits = new Map();
154
+ /** bare name (no `#`/`.`) -> (file, line) of each JS/TS string literal equal to it */
155
+ stringLiterals = new Map();
156
+ constructor(name, root,
157
+ /** Directory the recorded `file` paths are relative to (the workspace root in multi-root setups). */
158
+ base = root) {
159
+ this.name = name;
160
+ this.root = root;
161
+ this.base = base;
162
+ }
163
+ addDeclaration(decl) {
164
+ pushTo(this.varDeclarations, decl.name, decl);
165
+ }
166
+ addUsage(usage) {
167
+ pushTo(this.varUsages, usage.name, usage);
168
+ }
169
+ addSelectorHit(hit) {
170
+ pushTo(this.selectorHits, hit.token, { detail: "", dynamic: false, ...hit });
171
+ }
172
+ }
173
+ function pushTo(map, key, value) {
174
+ const list = map.get(key);
175
+ if (list) {
176
+ list.push(value);
177
+ }
178
+ else {
179
+ map.set(key, [value]);
180
+ }
181
+ }
182
+ /**
183
+ * Brace-stack pass over (comment-stripped) CSS: one `Block` per `{...}`, each
184
+ * knowing its own selector text and parent, enough to build a context
185
+ * breadcrumb for anything nested inside it.
186
+ */
187
+ function parseBlocks(text) {
188
+ const blocks = [];
189
+ const openStack = [];
190
+ let bufStart = 0;
191
+ for (let i = 0; i < text.length; i++) {
192
+ const ch = text.charAt(i);
193
+ if (ch === "{") {
194
+ const selector = text.slice(bufStart, i).replace(/\s+/g, " ").trim();
195
+ const block = { selector, start: i + 1, end: -1, parent: openStack[openStack.length - 1] };
196
+ openStack.push(block);
197
+ blocks.push(block);
198
+ bufStart = i + 1;
199
+ }
200
+ else if (ch === "}") {
201
+ const closed = openStack.pop();
202
+ if (closed) {
203
+ closed.end = i;
204
+ }
205
+ bufStart = i + 1;
206
+ }
207
+ else if (ch === ";") {
208
+ // A declaration (`color: #abc;`) ends here; without this reset, the
209
+ // next nested rule's selector text would swallow it.
210
+ bufStart = i + 1;
211
+ }
212
+ }
213
+ return blocks;
214
+ }
215
+ function enclosingBlock(blocks, idx) {
216
+ let best;
217
+ for (const b of blocks) {
218
+ if (b.end === -1) {
219
+ continue;
220
+ }
221
+ if (b.start <= idx && idx < b.end && (best === undefined || b.end - b.start < best.end - best.start)) {
222
+ best = b;
223
+ }
224
+ }
225
+ return best;
226
+ }
227
+ function breadcrumb(block) {
228
+ const parts = [];
229
+ for (let b = block; b !== undefined; b = b.parent) {
230
+ if (b.selector) {
231
+ parts.push(b.selector);
232
+ }
233
+ }
234
+ return parts.reverse().join(" › ");
235
+ }
236
+ function scanCssText(text, fileRel, rootIndex, lineOffset = 0) {
237
+ text = stripCssComments(text);
238
+ const blocks = parseBlocks(text);
239
+ const lineAt = makeLineAt(text);
240
+ for (const block of blocks) {
241
+ if (block.end === -1 || block.selector.startsWith("@") || !block.selector) {
242
+ continue;
243
+ }
244
+ const line = lineAt(block.start - 1) + lineOffset;
245
+ for (const part of block.selector.split(",")) {
246
+ for (const match of finditer(SELECTOR_TOKEN_RE, part)) {
247
+ rootIndex.addSelectorHit({ token: match[0], kind: "css", file: fileRel, line, detail: "CSS rule" });
248
+ }
249
+ }
250
+ }
251
+ for (const match of finditer(VAR_DECL_RE, text)) {
252
+ const context = breadcrumb(enclosingBlock(blocks, match.index));
253
+ rootIndex.addDeclaration({
254
+ name: match[1],
255
+ value: match[2].trim(),
256
+ context,
257
+ file: fileRel,
258
+ line: lineAt(match.index) + lineOffset,
259
+ });
260
+ }
261
+ for (const match of finditer(VAR_USE_RE, text)) {
262
+ rootIndex.addUsage({
263
+ name: match[1],
264
+ file: fileRel,
265
+ line: lineAt(match.index) + lineOffset,
266
+ hasFallback: Boolean(match[2]),
267
+ });
268
+ }
269
+ }
270
+ function scanHtmlText(text, fileRel, rootIndex) {
271
+ text = stripHtmlComments(text);
272
+ const lineAt = makeLineAt(text);
273
+ for (const styleMatch of finditer(STYLE_BLOCK_RE, text)) {
274
+ const [start] = styleMatch.indices[1];
275
+ scanCssText(styleMatch[1], fileRel, rootIndex, lineAt(start) - 1);
276
+ }
277
+ for (const attrMatch of finditer(STYLE_ATTR_RE, text)) {
278
+ const [start] = attrMatch.indices[1];
279
+ scanCssText(attrMatch[1], fileRel, rootIndex, lineAt(start) - 1);
280
+ }
281
+ for (const scriptMatch of finditer(SCRIPT_BLOCK_RE, text)) {
282
+ const [start] = scriptMatch.indices[1];
283
+ scanJsText(scriptMatch[1], fileRel, rootIndex, lineAt(start) - 1);
284
+ }
285
+ scanMarkupAttrs(text, fileRel, rootIndex, "html", "id attribute", "class attribute");
286
+ }
287
+ /**
288
+ * `id="x"`/`class="a b"` attributes in `text`. Shared by HTML markup and by JS
289
+ * source, since JS often builds markup from string literals.
290
+ */
291
+ function scanMarkupAttrs(text, fileRel, rootIndex, kind, idDetail, classDetail, lineOffset = 0) {
292
+ const lineAt = makeLineAt(text);
293
+ for (const match of finditer(ID_ATTR_RE, text)) {
294
+ const line = lineAt(match.index) + lineOffset;
295
+ // `id="row-${i}"` (JS template): only the static prefix is known.
296
+ const [name, dynamic] = staticPrefix(match[2], false);
297
+ if (name) {
298
+ rootIndex.addSelectorHit({ token: `#${name}`, kind, file: fileRel, line, detail: idDetail, dynamic });
299
+ }
300
+ }
301
+ for (const match of finditer(CLASS_ATTR_RE, text)) {
302
+ const line = lineAt(match.index) + lineOffset;
303
+ for (const token of match[2].split(/\s+/).filter(Boolean)) {
304
+ const [name, dynamic] = staticPrefix(token, false);
305
+ if (!name) {
306
+ continue;
307
+ }
308
+ rootIndex.addSelectorHit({ token: `.${name}`, kind, file: fileRel, line, detail: classDetail, dynamic });
309
+ }
310
+ }
311
+ }
312
+ /** `[token, dynamic]` pairs; a token cut short by a template `${…}` is a dynamic prefix (`.item-${n}` -> `.item-`). */
313
+ function jsSelectorTokensFromString(value) {
314
+ const replaced = value.replace(TEMPLATE_EXPR_RE, "\0");
315
+ return [...finditer(SELECTOR_TOKEN_RE, replaced)].map((m) => [
316
+ m[0],
317
+ replaced.slice(m.index + m[0].length, m.index + m[0].length + 1) === "\0",
318
+ ]);
319
+ }
320
+ /**
321
+ * Split a class-list string literal into whitespace-separated tokens, tagging
322
+ * the *last* one as a dynamic prefix when something is concatenated right
323
+ * after it (`"tool-status resolution-" + x`). A trailing space in the literal
324
+ * means the concatenation starts a fresh, separate class name, so nothing is
325
+ * marked dynamic in that case.
326
+ */
327
+ function literalTokens(literal, concatFollows) {
328
+ const tokens = literal.split(/\s+/).filter(Boolean);
329
+ if (tokens.length === 0) {
330
+ return [];
331
+ }
332
+ const endsWithSpace = /\s$/.test(literal);
333
+ const dynamicPrefix = concatFollows && !endsWithSpace;
334
+ return tokens.map((tok, i) => [tok, dynamicPrefix && i === tokens.length - 1]);
335
+ }
336
+ function scanJsText(text, fileRel, rootIndex, lineOffset = 0) {
337
+ text = stripJsComments(text);
338
+ const lineAt = makeLineAt(text);
339
+ const at = (match) => lineAt(match.index) + lineOffset;
340
+ const hit = (token, line, detail, dynamic) => rootIndex.addSelectorHit({ token, kind: "js", file: fileRel, line, detail, dynamic });
341
+ for (const match of finditer(JS_SETPROPERTY_RE, text)) {
342
+ rootIndex.addUsage({ name: match[1], file: fileRel, line: at(match), hasFallback: true });
343
+ }
344
+ for (const match of finditer(JS_GETPROPERTYVALUE_RE, text)) {
345
+ rootIndex.addUsage({ name: match[1], file: fileRel, line: at(match), hasFallback: true });
346
+ }
347
+ for (const match of finditer(JS_GET_ELEMENT_BY_ID_RE, text)) {
348
+ const [literal, hasConcat] = staticPrefix(match[1], Boolean(match[2]));
349
+ if (!literal) {
350
+ continue;
351
+ }
352
+ hit(`#${literal}`, at(match), "getElementById", hasConcat);
353
+ }
354
+ for (const match of finditer(JS_CLASSLIST_RE, text)) {
355
+ const method = match[1];
356
+ const line = at(match);
357
+ for (const lit of finditer(STRING_LITERAL_RE, match[2])) {
358
+ const [literal, concatFollows] = staticPrefix(lit[1], Boolean(lit[2]));
359
+ for (const [token, dynamic] of literalTokens(literal, concatFollows)) {
360
+ hit(`.${token}`, line, `classList.${method}`, dynamic);
361
+ }
362
+ }
363
+ }
364
+ for (const match of finditer(JS_QUERY_RE, text)) {
365
+ const line = at(match);
366
+ for (const [token, dynamic] of jsSelectorTokensFromString(match[1])) {
367
+ hit(token, line, "querySelector", dynamic);
368
+ }
369
+ }
370
+ for (const match of finditer(JS_CLASSNAME_ASSIGN_RE, text)) {
371
+ const line = at(match);
372
+ const [literal, concatFollows] = staticPrefix(match[1], Boolean(match[2]));
373
+ for (const [token, dynamic] of literalTokens(literal, concatFollows)) {
374
+ hit(`.${token}`, line, "className", dynamic);
375
+ }
376
+ }
377
+ for (const match of finditer(JS_CLASS_VAR_CONCAT_RE, text)) {
378
+ const line = at(match);
379
+ const [literal, concatFollows] = staticPrefix(match[1], Boolean(match[2]));
380
+ for (const [token, dynamic] of literalTokens(literal, concatFollows)) {
381
+ hit(`.${token}`, line, "class-var +=", dynamic);
382
+ }
383
+ }
384
+ for (const match of finditer(BARE_NAME_LITERAL_RE, text)) {
385
+ pushTo(rootIndex.stringLiterals, match[2], [fileRel, at(match)]);
386
+ }
387
+ scanMarkupAttrs(text, fileRel, rootIndex, "js", "id attribute in JS string", "class attribute in JS string", lineOffset);
388
+ }
389
+ const SCRIPT_SUFFIXES = new Set([".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx"]);
390
+ const INDEXED_SUFFIXES = new Set([".css", ".html", ...SCRIPT_SUFFIXES]);
391
+ // "root\0name\0base" -> (signature, index); see the module docstring.
392
+ const rootCache = new Map();
393
+ function relevantFiles(root) {
394
+ const files = [];
395
+ const walk = (dir) => {
396
+ let entries;
397
+ try {
398
+ entries = fs.readdirSync(dir, { withFileTypes: true });
399
+ }
400
+ catch {
401
+ return;
402
+ }
403
+ for (const entry of entries) {
404
+ if (EXCLUDED_DIR_NAMES.has(entry.name)) {
405
+ continue;
406
+ }
407
+ const full = path.join(dir, entry.name);
408
+ if (entry.isDirectory()) {
409
+ walk(full);
410
+ }
411
+ else if (INDEXED_SUFFIXES.has(path.extname(entry.name).toLowerCase())) {
412
+ if (entry.isFile() || (entry.isSymbolicLink() && isFileFollowingLink(full))) {
413
+ files.push(full);
414
+ }
415
+ }
416
+ }
417
+ };
418
+ walk(root);
419
+ return files.sort(comparePaths);
420
+ }
421
+ function isFileFollowingLink(p) {
422
+ try {
423
+ return fs.statSync(p).isFile();
424
+ }
425
+ catch {
426
+ return false;
427
+ }
428
+ }
429
+ function signatureOf(files) {
430
+ const entries = [];
431
+ for (const file of files) {
432
+ try {
433
+ const st = fs.statSync(file, { bigint: true });
434
+ entries.push(`${file}:${st.mtimeNs}:${st.size}`);
435
+ }
436
+ catch { }
437
+ }
438
+ return entries.join("\n");
439
+ }
440
+ /**
441
+ * Scan `root` for CSS/HTML/JS/TS files. Recorded `file` paths are relative to
442
+ * `workspaceRoot` (defaulting to `root` itself) so multi-root setups report
443
+ * paths consistently with every other tool.
444
+ */
445
+ export function buildRootIndex(root, name, workspaceRoot) {
446
+ const base = workspaceRoot ?? root;
447
+ const files = fs.existsSync(root) && fs.statSync(root).isDirectory() ? relevantFiles(root) : [];
448
+ const signature = signatureOf(files);
449
+ const cacheKey = `${root}\0${name}\0${base}`;
450
+ const cached = rootCache.get(cacheKey);
451
+ if (cached && cached.signature === signature) {
452
+ return cached.index;
453
+ }
454
+ const rootIndex = new RootIndex(name, root, base);
455
+ for (const file of files) {
456
+ const fileRel = toPosix(path.relative(base, file));
457
+ let text;
458
+ try {
459
+ text = readTextStrict(file);
460
+ }
461
+ catch {
462
+ continue; // unreadable or non-UTF-8: skip the file rather than fail every index call
463
+ }
464
+ const suffix = path.extname(file).toLowerCase();
465
+ if (suffix === ".css") {
466
+ scanCssText(text, fileRel, rootIndex);
467
+ }
468
+ else if (suffix === ".html") {
469
+ scanHtmlText(text, fileRel, rootIndex);
470
+ }
471
+ else if (SCRIPT_SUFFIXES.has(suffix)) {
472
+ scanJsText(text, fileRel, rootIndex);
473
+ }
474
+ }
475
+ rootCache.set(cacheKey, { signature, index: rootIndex });
476
+ return rootIndex;
477
+ }
478
+ /** One `RootIndex` per configured root; with no `roots`, the whole workspace is a single root labelled `DEFAULT_ROOT_LABEL`. */
479
+ export function buildWorkspaceIndex(workspaceRoot, roots) {
480
+ const list = roots ?? [[DEFAULT_ROOT_LABEL, workspaceRoot]];
481
+ return list.map(([label, root]) => buildRootIndex(root, label, workspaceRoot));
482
+ }
483
+ /** Parse `WEBNAV_MCP_ROOTS` (`label=relative/path,label2=relative/path2`) into `[label, absolutePath]` pairs. */
484
+ export function parseRootsEnv(raw, workspaceRoot) {
485
+ const roots = [];
486
+ for (const rawPart of raw.split(",")) {
487
+ const part = rawPart.trim();
488
+ if (!part) {
489
+ continue;
490
+ }
491
+ const eq = part.indexOf("=");
492
+ if (eq < 0) {
493
+ throw new Error(`invalid WEBNAV_MCP_ROOTS entry ${pyRepr(part)}; expected label=relative/path`);
494
+ }
495
+ roots.push([part.slice(0, eq).trim(), resolveReal(path.resolve(workspaceRoot, part.slice(eq + 1).trim()))]);
496
+ }
497
+ return roots;
498
+ }
499
+ function normalizeVarName(name) {
500
+ const trimmed = name.trim();
501
+ return trimmed.startsWith("--") ? trimmed : `--${trimmed}`;
502
+ }
503
+ /** `'id'`/`'class'` for a `#foo`/`.foo` token, else `undefined`. */
504
+ export function tokenKind(token) {
505
+ if (token.startsWith("#")) {
506
+ return "id";
507
+ }
508
+ if (token.startsWith(".")) {
509
+ return "class";
510
+ }
511
+ return undefined;
512
+ }
513
+ // -- formatting --------------------------------------------------------------
514
+ const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
515
+ function groupUsagesByFile(usages) {
516
+ const groups = new Map();
517
+ for (const u of usages) {
518
+ const set = groups.get(u.file) ?? new Set();
519
+ set.add(u.line);
520
+ groups.set(u.file, set);
521
+ }
522
+ return [...groups.entries()]
523
+ .sort(([a], [b]) => cmp(a, b))
524
+ .map(([file, lines]) => [file, [...lines].sort((x, y) => x - y)]);
525
+ }
526
+ function isGenerated(fileRel, generated) {
527
+ return generated.some((g) => fileRel === g || fileRel.startsWith(`${g.replace(/\/+$/, "")}/`));
528
+ }
529
+ /**
530
+ * Tag generated output (`WEBNAV_MCP_EXCLUDE`, e.g. `tsc`-emitted JS) so an
531
+ * agent edits the source instead: those hits stay in the index because a
532
+ * root's runtime usages may only exist there, but they are never the file to change.
533
+ */
534
+ function fileLabel(fileRel, generated) {
535
+ return isGenerated(fileRel, generated) ? `${fileRel} [generated]` : fileRel;
536
+ }
537
+ function groupHitsByFile(hits) {
538
+ const groups = new Map();
539
+ for (const h of hits) {
540
+ pushTo(groups, h.file, h);
541
+ }
542
+ return [...groups.entries()].sort(([a], [b]) => cmp(a, b));
543
+ }
544
+ function rootNames(indexes) {
545
+ const names = indexes.map((idx) => idx.name);
546
+ return names.length > 0 ? names.join(" or ") : "any configured root";
547
+ }
548
+ export function formatCssVar(indexes, name, options = {}) {
549
+ const { generated = [], definitionsOnly = false } = options;
550
+ const varName = normalizeVarName(name);
551
+ const sections = [];
552
+ for (const idx of indexes) {
553
+ const decls = idx.varDeclarations.get(varName) ?? [];
554
+ const uses = definitionsOnly ? [] : (idx.varUsages.get(varName) ?? []);
555
+ if (decls.length === 0 && uses.length === 0) {
556
+ continue;
557
+ }
558
+ const lines = [`== ${idx.name} ==`];
559
+ if (decls.length > 0) {
560
+ lines.push("Definitions:");
561
+ lines.push(...decls.map((d) => ` ${d.file}:${d.line} (${d.context || "(top level)"}) = ${d.value}`));
562
+ }
563
+ else {
564
+ lines.push("Definitions: (none)");
565
+ }
566
+ if (uses.length > 0) {
567
+ const byFile = groupUsagesByFile(uses);
568
+ lines.push(`Usages (${uses.length} in ${byFile.length} file(s)):`);
569
+ lines.push(...byFile.map(([f, ns]) => ` ${fileLabel(f, generated)}: ${ns.map((n) => `L${n}`).join(", ")}`));
570
+ }
571
+ else if (!definitionsOnly) {
572
+ lines.push("Usages: (none)");
573
+ }
574
+ sections.push(lines.join("\n"));
575
+ }
576
+ if (sections.length === 0) {
577
+ return `${varName} is not defined or used anywhere under ${rootNames(indexes)}.`;
578
+ }
579
+ return `${varName}\n\n${sections.join("\n\n")}`;
580
+ }
581
+ /**
582
+ * Dynamic hits stored under a *different*, shorter key that `token` could
583
+ * resolve to at runtime: a `getElementById("view-" + x)` hit stored under
584
+ * `#view-` is a plausible match for a query of `#view-components`.
585
+ */
586
+ function dynamicPrefixHits(idx, token) {
587
+ const hits = [];
588
+ for (const [key, keyHits] of idx.selectorHits) {
589
+ if (key === token || !token.startsWith(key)) {
590
+ continue;
591
+ }
592
+ hits.push(...keyHits.filter((h) => h.dynamic));
593
+ }
594
+ return hits;
595
+ }
596
+ /**
597
+ * JS/TS string literals exactly equal to `token`'s bare name, on lines no
598
+ * recognised DOM-API hit already covers: typically an id/class passed to a
599
+ * project's own helper (`onClick("btn-save", …)`).
600
+ */
601
+ function stringLiteralHits(idx, token) {
602
+ const covered = new Set();
603
+ for (const hits of idx.selectorHits.values()) {
604
+ for (const h of hits) {
605
+ if (h.kind === "js") {
606
+ covered.add(`${h.file}\0${h.line}`);
607
+ }
608
+ }
609
+ }
610
+ return (idx.stringLiterals.get(token.slice(1)) ?? [])
611
+ .filter(([file, line]) => !covered.has(`${file}\0${line}`))
612
+ .map(([file, line]) => ({ token, kind: "js", file, line, detail: "string literal", dynamic: false }));
613
+ }
614
+ const hitKey = (h) => JSON.stringify([h.token, h.kind, h.file, h.line, h.detail, h.dynamic]);
615
+ function dedupe(hits) {
616
+ const seen = new Map();
617
+ for (const h of hits) {
618
+ seen.set(hitKey(h), seen.get(hitKey(h)) ?? h);
619
+ }
620
+ return [...seen.values()];
621
+ }
622
+ export function formatSelector(indexes, token, options = {}) {
623
+ const { generated = [], definitionsOnly = false } = options;
624
+ if (tokenKind(token) === undefined) {
625
+ return `${pyRepr(token)} must start with '#' (id) or '.' (class).`;
626
+ }
627
+ const sections = [];
628
+ for (const idx of indexes) {
629
+ const exactHits = idx.selectorHits.get(token) ?? [];
630
+ let allHits = [...exactHits, ...dynamicPrefixHits(idx, token), ...stringLiteralHits(idx, token)];
631
+ if (definitionsOnly) {
632
+ // A class is defined by its CSS rule(s), an id by its markup attribute.
633
+ const wanted = token.startsWith(".") ? "css" : "html";
634
+ const preferred = exactHits.filter((h) => h.kind === wanted);
635
+ allHits = preferred.length > 0 ? preferred : exactHits.filter((h) => h.kind === "css");
636
+ }
637
+ if (allHits.length === 0) {
638
+ continue;
639
+ }
640
+ const lines = [`== ${idx.name} ==`];
641
+ for (const kindLabel of ["html", "css", "js"]) {
642
+ // Dedupes identical hits, e.g. `.a.x, .a .y {` records `.a` twice for one rule line.
643
+ const kindHits = dedupe(allHits.filter((h) => h.kind === kindLabel));
644
+ if (kindHits.length === 0) {
645
+ continue;
646
+ }
647
+ lines.push(`${kindLabel.toUpperCase()} (${kindHits.length}):`);
648
+ for (const [file, fileHits] of groupHitsByFile(kindHits)) {
649
+ const parts = [...fileHits]
650
+ .sort((a, b) => a.line - b.line)
651
+ .map((h) => {
652
+ let detail;
653
+ if (h.dynamic && h.token !== token) {
654
+ const via = `dynamic partial match via ${pyRepr(h.token)}`;
655
+ detail = h.detail ? `${h.detail}, ${via}` : via;
656
+ }
657
+ else if (h.dynamic) {
658
+ detail = h.detail ? `${h.detail}, dynamic partial match` : "dynamic partial match";
659
+ }
660
+ else {
661
+ detail = h.detail;
662
+ }
663
+ return detail ? `L${h.line} (${detail})` : `L${h.line}`;
664
+ });
665
+ lines.push(` ${fileLabel(file, generated)}: ${parts.join(", ")}`);
666
+ }
667
+ }
668
+ sections.push(lines.join("\n"));
669
+ }
670
+ if (sections.length === 0) {
671
+ return `${token} was not found under ${rootNames(indexes)}.`;
672
+ }
673
+ return `${token}\n\n${sections.join("\n\n")}`;
674
+ }
675
+ // -- reference/definition/diagnostics enrichment ------------------------------
676
+ const CSS_TOKEN_UNDER_CURSOR_RE = /(--[a-zA-Z0-9_-]+|[.#][a-zA-Z_-][a-zA-Z0-9_-]*)/g;
677
+ /** `#name`/`.name` for a cursor on a word inside an HTML `id="..."`/`class="..."` value, where the markup spells the selector without its `#`/`.`. */
678
+ function attrTokenAt(lineText, idx) {
679
+ for (const [regex, prefix] of [
680
+ [ID_ATTR_RE, "#"],
681
+ [CLASS_ATTR_RE, "."],
682
+ ]) {
683
+ for (const attr of finditer(regex, lineText)) {
684
+ const [valueStart, valueEnd] = attr.indices[2];
685
+ if (!(valueStart <= idx && idx < valueEnd)) {
686
+ continue;
687
+ }
688
+ for (const word of finditer(/\S+/g, attr[2])) {
689
+ if (valueStart + word.index <= idx && idx < valueStart + word.index + word[0].length) {
690
+ return prefix + word[0];
691
+ }
692
+ }
693
+ return undefined;
694
+ }
695
+ }
696
+ return undefined;
697
+ }
698
+ /** The `--var`, `#id` or `.class` token containing a 1-indexed `column`, including a bare name inside an HTML `id`/`class` attribute value. */
699
+ export function tokenAtPosition(lineText, column) {
700
+ const idx = column - 1;
701
+ const attrToken = attrTokenAt(lineText, idx);
702
+ if (attrToken !== undefined) {
703
+ return attrToken;
704
+ }
705
+ for (const match of finditer(CSS_TOKEN_UNDER_CURSOR_RE, lineText)) {
706
+ if (match.index <= idx && idx < match.index + match[0].length) {
707
+ return match[1];
708
+ }
709
+ }
710
+ return undefined;
711
+ }
712
+ /** The index whose root contains `filePath`, plus the path exactly as that index recorded it (relative to `idx.base`). */
713
+ export function rootIndexForFile(indexes, filePath) {
714
+ const resolved = resolveReal(filePath);
715
+ for (const idx of indexes) {
716
+ if (relativeWithin(resolved, resolveReal(idx.root)) === undefined) {
717
+ continue;
718
+ }
719
+ const rel = relativeWithin(resolved, resolveReal(idx.base));
720
+ if (rel === undefined) {
721
+ continue;
722
+ }
723
+ return [idx, toPosix(rel)];
724
+ }
725
+ return undefined;
726
+ }
727
+ /** True when any root's index has hits for `token` (`#id`/`.class`) or, for a bare-name string literal, its name. */
728
+ export function knowsSelector(indexes, token) {
729
+ return indexes.some((idx) => idx.selectorHits.has(token) || idx.stringLiterals.has(token.slice(1)));
730
+ }
731
+ export function undefinedVarUsages(idx) {
732
+ const result = [];
733
+ for (const [name, usages] of idx.varUsages) {
734
+ if (idx.varDeclarations.has(name)) {
735
+ continue;
736
+ }
737
+ result.push(...usages.filter((usage) => !usage.hasFallback));
738
+ }
739
+ return result;
740
+ }
741
+ /**
742
+ * CSS-defined tokens with no HTML/JS reference. A dynamically-built JS hit
743
+ * elsewhere in the root counts as a reference for any token it's a prefix of;
744
+ * so does a JS/TS string literal equal to the bare name.
745
+ */
746
+ export function unreferencedSelectors(idx) {
747
+ const unreferenced = [];
748
+ for (const [token, hits] of idx.selectorHits) {
749
+ const hasDefinition = hits.some((h) => h.kind === "css");
750
+ const hasReference = hits.some((h) => (h.kind === "html" || h.kind === "js") && !h.dynamic) || idx.stringLiterals.has(token.slice(1));
751
+ if (hasDefinition && !hasReference && dynamicPrefixHits(idx, token).length === 0) {
752
+ unreferenced.push(token);
753
+ }
754
+ }
755
+ return unreferenced.sort(cmp);
756
+ }
757
+ /** Index-derived warning lines for one file: undefined `var(--x)` usages (no fallback) and CSS selectors with no HTML/JS reference in this root. */
758
+ export function diagnosticsForFile(idx, fileRel) {
759
+ const warnings = [];
760
+ for (const usage of undefinedVarUsages(idx)) {
761
+ if (usage.file === fileRel) {
762
+ warnings.push(`${usage.line}:1 [warning] var(${usage.name}) is never defined in ${idx.name}`);
763
+ }
764
+ }
765
+ for (const token of new Set(unreferencedSelectors(idx))) {
766
+ for (const hit of idx.selectorHits.get(token) ?? []) {
767
+ if (hit.kind === "css" && hit.file === fileRel) {
768
+ warnings.push(`${hit.line}:1 [warning] ${token} is never referenced in ${idx.name}'s HTML/JS`);
769
+ }
770
+ }
771
+ }
772
+ return warnings.sort((a, b) => Number.parseInt(a, 10) - Number.parseInt(b, 10));
773
+ }