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,83 @@
1
+ /**
2
+ * Out-of-band messages for the agent, appended to tool results.
3
+ *
4
+ * Two kinds: one-shot notices posted while a call runs (e.g. "restarted the
5
+ * language server because tsconfig.json changed") and a sticky one while the
6
+ * server's own source files differ from what it started with. A stdio MCP
7
+ * server can't reload itself, so the most it can do about stale code is say
8
+ * so on every call.
9
+ */
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ const RECHECK_MS = 2000;
13
+ function sourceStamp(dirs) {
14
+ const found = [];
15
+ const walk = (dir) => {
16
+ let entries;
17
+ try {
18
+ entries = fs.readdirSync(dir, { withFileTypes: true });
19
+ }
20
+ catch {
21
+ return;
22
+ }
23
+ for (const entry of entries) {
24
+ const full = path.join(dir, entry.name);
25
+ if (entry.isDirectory()) {
26
+ walk(full);
27
+ }
28
+ else if (entry.name.endsWith(".js")) {
29
+ try {
30
+ const st = fs.statSync(full, { bigint: true });
31
+ found.push(`${full}:${st.mtimeNs}:${st.size}`);
32
+ }
33
+ catch { }
34
+ }
35
+ }
36
+ };
37
+ for (const dir of dirs) {
38
+ walk(dir);
39
+ }
40
+ return found.sort().join("\n");
41
+ }
42
+ export class NoticeBoard {
43
+ serverName;
44
+ sourceDirs;
45
+ recheckMs;
46
+ startedWith;
47
+ pending = [];
48
+ checkedAt = Date.now();
49
+ stale = false;
50
+ constructor(serverName, sourceDirs, recheckMs = RECHECK_MS) {
51
+ this.serverName = serverName;
52
+ this.sourceDirs = sourceDirs;
53
+ this.recheckMs = recheckMs;
54
+ this.startedWith = sourceStamp(sourceDirs);
55
+ }
56
+ post(message) {
57
+ if (!this.pending.includes(message)) {
58
+ this.pending.push(message);
59
+ }
60
+ }
61
+ codeIsStale() {
62
+ // A stat walk per tool call adds up; a few seconds' lag in noticing an edit is fine.
63
+ const now = Date.now();
64
+ if (now - this.checkedAt >= this.recheckMs) {
65
+ this.checkedAt = now;
66
+ this.stale = sourceStamp(this.sourceDirs) !== this.startedWith;
67
+ }
68
+ return this.stale;
69
+ }
70
+ /** One-shot notices posted since the last call, plus the sticky stale-code line. */
71
+ drain() {
72
+ const notices = this.pending;
73
+ this.pending = [];
74
+ if (this.codeIsStale()) {
75
+ notices.push(`the ${this.serverName} server's own code changed since it started; restart the MCP servers to use the new version.`);
76
+ }
77
+ return notices;
78
+ }
79
+ annotate(text) {
80
+ const notices = this.drain();
81
+ return notices.length === 0 ? text : text + notices.map((n) => `\n\n[${this.serverName}] ${n}`).join("");
82
+ }
83
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Param-name aliases for MCP tools: agents often guess `query` vs `name`.
3
+ *
4
+ * Throwing a `ToolInputError` (rendered as tool text) beats the framework's
5
+ * opaque schema-validation wall, which made agents abandon the MCP after one
6
+ * wrong guess.
7
+ */
8
+ import { ToolInputError } from "./errors.js";
9
+ export function resolveNameQuery(options) {
10
+ const { preferred, example } = options;
11
+ const params = { [preferred]: undefined, ...options.params };
12
+ let value = params[preferred];
13
+ if (!value) {
14
+ for (const [key, candidate] of Object.entries(params)) {
15
+ if (key !== preferred && candidate) {
16
+ value = candidate;
17
+ break;
18
+ }
19
+ }
20
+ }
21
+ if (value) {
22
+ return value;
23
+ }
24
+ const others = Object.keys(params).filter((key) => key !== preferred);
25
+ const hint = `Pass \`${preferred}\` (e.g. ${preferred}='${example}')`;
26
+ if (others.length === 0) {
27
+ throw new ToolInputError(`${hint}.`);
28
+ }
29
+ const alias = others.length === 1
30
+ ? `\`${others[0]}\` is accepted as an alias`
31
+ : `aliases accepted: ${others.map((key) => `\`${key}\``).join(", ")}`;
32
+ throw new ToolInputError(`${hint}; ${alias}.`);
33
+ }
@@ -0,0 +1,37 @@
1
+ /** Path helpers: symlink-resolving normalisation and containment checks. */
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ /** Like Python's `Path.resolve()`: absolute, symlinks resolved when the path exists. */
5
+ export function resolveReal(p) {
6
+ const abs = path.resolve(p);
7
+ try {
8
+ return fs.realpathSync(abs);
9
+ }
10
+ catch {
11
+ return abs;
12
+ }
13
+ }
14
+ /** `child` relative to `parent` when it sits inside it (or equals it), else `undefined`. */
15
+ export function relativeWithin(child, parent) {
16
+ const rel = path.relative(parent, child);
17
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
18
+ return undefined;
19
+ }
20
+ return rel;
21
+ }
22
+ export function toPosix(p) {
23
+ return p.split(path.sep).join("/");
24
+ }
25
+ /** Component-wise path ordering, matching how Python orders `Path` objects. */
26
+ export function comparePaths(a, b) {
27
+ const pa = a.split(path.sep);
28
+ const pb = b.split(path.sep);
29
+ for (let i = 0; i < Math.min(pa.length, pb.length); i++) {
30
+ const x = pa[i];
31
+ const y = pb[i];
32
+ if (x !== y) {
33
+ return x < y ? -1 : 1;
34
+ }
35
+ }
36
+ return pa.length - pb.length;
37
+ }
@@ -0,0 +1,398 @@
1
+ /**
2
+ * Name-based symbol resolution for the composite tools (`symbol_info`): a
3
+ * `workspace/symbol` lookup with tiered ranking, dotted `Class.method`
4
+ * resolution via `documentSymbol`, and disambiguation by `file_path`, so the
5
+ * tools take a name instead of a hand-computed position.
6
+ */
7
+ import path from "node:path";
8
+ import { isToolError, ToolInputError } from "./errors.js";
9
+ import { isExcluded } from "./exclude.js";
10
+ import { filterWorkspaceSymbols, isHierarchicalDocumentSymbols, matchTier, pyRepr, rankWorkspaceSymbols, symbolKindLabel, toSymbolTree, uriToPath, uriToRelative, workspaceSymbolPosition, } from "./format.js";
11
+ import { LINE_BREAK_RE, readTextStrict } from "./text.js";
12
+ // SymbolKind values that are class members: a candidate with one of these gets
13
+ // qualified as `Class.member` in an ambiguity listing.
14
+ const MEMBER_KINDS = new Set([6, 7, 9]); // Method, Property, Constructor
15
+ const CLASS_KIND = 5;
16
+ /** No single confident match for a name-based symbol query: not found, or ambiguous. */
17
+ export class SymbolResolutionError extends ToolInputError {
18
+ name = "SymbolResolutionError";
19
+ }
20
+ function resolvedFromSymbol(sym) {
21
+ const [uri, line, column] = workspaceSymbolPosition(sym);
22
+ const rng = sym.location?.range ?? {};
23
+ const startLine = rng.start?.line ?? line;
24
+ const endLine = rng.end?.line ?? startLine;
25
+ return {
26
+ name: sym.name || "?",
27
+ kind: sym.kind,
28
+ uri,
29
+ line,
30
+ column,
31
+ rangeStartLine: startLine,
32
+ rangeEndLine: endLine,
33
+ };
34
+ }
35
+ function memberSpan(sym) {
36
+ const rng = sym.location?.range ?? {};
37
+ const start = rng.start?.line ?? 0;
38
+ return [start, rng.end?.line ?? start];
39
+ }
40
+ /** Hierarchical `DocumentSymbol` named `name` (any depth), preferring the one whose identifier sits on `line`. */
41
+ function findNamedNode(symbols, name, line) {
42
+ const found = [];
43
+ const walk = (nodes) => {
44
+ for (const node of nodes) {
45
+ if (String(node.name) === name) {
46
+ found.push(node);
47
+ }
48
+ walk(node.children ?? []);
49
+ }
50
+ };
51
+ walk(symbols);
52
+ for (const node of found) {
53
+ const sel = (node.selectionRange ?? node.range)?.start;
54
+ if ((sel?.line ?? -1) === line) {
55
+ return node;
56
+ }
57
+ }
58
+ return found[0];
59
+ }
60
+ function resolvedFromHierarchicalNode(memberName, node, uri) {
61
+ const selStart = node.selectionRange?.start ?? node.range?.start ?? {};
62
+ const rng = node.range ?? {};
63
+ const startLine = rng.start?.line ?? 0;
64
+ const endLine = rng.end?.line ?? startLine;
65
+ return {
66
+ name: memberName,
67
+ kind: node.kind,
68
+ uri,
69
+ line: selStart.line ?? startLine,
70
+ column: selStart.character ?? 0,
71
+ rangeStartLine: startLine,
72
+ rangeEndLine: endLine,
73
+ };
74
+ }
75
+ /** The Class node of a `toSymbolTree` hierarchy whose range contains `targetLine`, innermost first. */
76
+ function enclosingClassName(nodes, targetLine) {
77
+ for (const node of nodes) {
78
+ if (!(node.startLine <= targetLine && targetLine <= node.endLine)) {
79
+ continue;
80
+ }
81
+ const inner = enclosingClassName(node.children, targetLine);
82
+ if (inner !== undefined) {
83
+ return inner;
84
+ }
85
+ return node.kind === CLASS_KIND ? node.name : undefined;
86
+ }
87
+ return undefined;
88
+ }
89
+ /** For Method/Property/Constructor candidates, resolve their enclosing class so an ambiguity listing can show `Class.member`. */
90
+ async function qualifyCandidates(client, workspaceRoot, candidates) {
91
+ const qualified = new Map();
92
+ const trees = new Map();
93
+ for (const [i, sym] of candidates.entries()) {
94
+ if (sym.kind === undefined || !MEMBER_KINDS.has(sym.kind)) {
95
+ continue;
96
+ }
97
+ const rel = uriToRelative(sym.location?.uri ?? "", workspaceRoot);
98
+ let tree = trees.get(rel);
99
+ if (tree === undefined) {
100
+ try {
101
+ tree = toSymbolTree(await client.documentSymbol(rel));
102
+ }
103
+ catch (error) {
104
+ if (!isToolError(error)) {
105
+ throw error;
106
+ }
107
+ tree = [];
108
+ }
109
+ trees.set(rel, tree);
110
+ }
111
+ const className = enclosingClassName(tree, memberSpan(sym)[0]);
112
+ if (className) {
113
+ qualified.set(i, className);
114
+ }
115
+ }
116
+ return qualified;
117
+ }
118
+ async function formatCandidates(client, candidates, workspaceRoot) {
119
+ const shown = candidates.slice(0, 10);
120
+ const qualified = await qualifyCandidates(client, workspaceRoot, shown);
121
+ return shown
122
+ .map((sym, i) => {
123
+ const name = sym.name || "?";
124
+ const qualifier = qualified.get(i);
125
+ const display = qualifier ? `${qualifier}.${name}` : name;
126
+ const [uri, line, col] = workspaceSymbolPosition(sym);
127
+ const rel = uri ? uriToRelative(uri, workspaceRoot) : "?";
128
+ return `${display} [${symbolKindLabel(sym.kind)}] (${rel}:${line + 1}:${col + 1})`;
129
+ })
130
+ .join("\n");
131
+ }
132
+ const notFound = (query) => new SymbolResolutionError(`No symbol found matching ${pyRepr(query)}.`);
133
+ async function ambiguous(client, query, candidates, workspaceRoot) {
134
+ const sameFile = new Set(candidates.map((c) => c.location?.uri ?? "")).size === 1;
135
+ // Narrowing by file_path still leaves several same-named symbols in one file
136
+ // (a module-level function and a same-named class method), so point at the
137
+ // position-based escape hatch instead of advice that won't help.
138
+ const hint = sameFile
139
+ ? "these all live in the same file; use search_symbol to get exact line/column, then hover/definition/references with that position"
140
+ : "pass file_path to disambiguate";
141
+ return new SymbolResolutionError(`${candidates.length} symbols match ${pyRepr(query)}; ${hint}:\n${await formatCandidates(client, candidates, workspaceRoot)}`);
142
+ }
143
+ function matchesFile(sym, workspaceRoot, filePath) {
144
+ const rel = uriToRelative(sym.location?.uri ?? "", workspaceRoot);
145
+ const target = filePath.replaceAll("\\", "/");
146
+ return rel === target || rel.endsWith(`/${target}`) || target.endsWith(`/${rel}`);
147
+ }
148
+ async function exactCandidates(client, workspaceRoot, name, filePath) {
149
+ const symbols = await client.workspaceSymbol(name);
150
+ // Same filter as search_symbol: drop export-list Variable twins and identical
151
+ // (name, kind, file) dupes so `symbol_info("foo")` isn't ambiguous between
152
+ // `function foo` and `export { foo }`.
153
+ const ranked = filterWorkspaceSymbols(rankWorkspaceSymbols(symbols, name));
154
+ let exact = ranked.filter((s) => matchTier(s.name ?? "", name) <= 1);
155
+ // Prefer a case-exact match over a merely case-insensitive one when both exist.
156
+ const caseExact = exact.filter((s) => matchTier(s.name ?? "", name) === 0);
157
+ if (caseExact.length > 0) {
158
+ exact = caseExact;
159
+ }
160
+ if (filePath !== undefined) {
161
+ const narrowed = exact.filter((s) => matchesFile(s, workspaceRoot, filePath));
162
+ if (exact.length > 0 && narrowed.length === 0) {
163
+ // Silently answering with a symbol from a different file than the one the
164
+ // caller named would be a confidently wrong result.
165
+ throw new SymbolResolutionError(`No symbol ${pyRepr(name)} in ${pyRepr(filePath)}; ${exact.length} match(es) elsewhere:\n` +
166
+ (await formatCandidates(client, exact, workspaceRoot)));
167
+ }
168
+ exact = narrowed;
169
+ }
170
+ return exact;
171
+ }
172
+ async function resolveSimple(client, workspaceRoot, query, filePath) {
173
+ const exact = await exactCandidates(client, workspaceRoot, query, filePath);
174
+ const [only, ...rest] = exact;
175
+ if (only === undefined) {
176
+ throw notFound(query);
177
+ }
178
+ if (rest.length > 0) {
179
+ throw await ambiguous(client, query, exact, workspaceRoot);
180
+ }
181
+ return resolvedFromSymbol(only);
182
+ }
183
+ async function resolveDotted(client, workspaceRoot, query, filePath) {
184
+ // `Outer.Inner.method`: the first segment is looked up workspace-wide, the
185
+ // rest are walked down through that symbol's document-symbol tree.
186
+ const [first = "", ...parts] = query.split(".");
187
+ const memberName = parts[parts.length - 1] ?? "";
188
+ const container = await resolveSimple(client, workspaceRoot, first, filePath);
189
+ const relPath = uriToRelative(container.uri, workspaceRoot);
190
+ const members = await client.documentSymbol(relPath);
191
+ if (isHierarchicalDocumentSymbols(members)) {
192
+ let found = findNamedNode(members, container.name, container.line);
193
+ for (const part of parts) {
194
+ found = (found?.children ?? []).find((c) => String(c.name) === part);
195
+ }
196
+ if (found) {
197
+ return resolvedFromHierarchicalNode(memberName, found, container.uri);
198
+ }
199
+ }
200
+ else {
201
+ // Flat `SymbolInformation`: narrow by range containment one segment at a time.
202
+ let span = [container.rangeStartLine, container.rangeEndLine];
203
+ let match;
204
+ for (const part of parts) {
205
+ const candidates = members.filter((m) => {
206
+ const [start, end] = memberSpan(m);
207
+ return (String(m.name ?? "") === part &&
208
+ span[0] <= start &&
209
+ start <= span[1] &&
210
+ !(start === span[0] && end === span[1]));
211
+ });
212
+ if (candidates.length > 1) {
213
+ throw await ambiguous(client, query, candidates, workspaceRoot);
214
+ }
215
+ const [only] = candidates;
216
+ if (only === undefined) {
217
+ match = undefined;
218
+ break;
219
+ }
220
+ match = only;
221
+ span = memberSpan(only);
222
+ }
223
+ if (match) {
224
+ return resolvedFromSymbol(match);
225
+ }
226
+ }
227
+ // Inherited members only make sense for a plain `Class.member` query.
228
+ const inherited = parts.length === 1 ? await resolveInherited(client, workspaceRoot, container, memberName) : undefined;
229
+ if (inherited === undefined) {
230
+ throw notFound(query);
231
+ }
232
+ return inherited;
233
+ }
234
+ // Guards against pathological (or cyclic) hierarchies; real class graphs are far smaller.
235
+ const MAX_CLASSES_VISITED = 64;
236
+ /**
237
+ * Names in the `extends`/`implements` clauses of a declaration, with their offsets in `text`.
238
+ * `text` starts at the declaration's name; the clause list ends at the first `{` outside any `<...>`/`(...)`.
239
+ * Generic arguments (`Base<T>`) are skipped, `a.B` yields `B`, and call expressions
240
+ * (`extends mixin(Base)`) are ignored because there is no class to follow.
241
+ */
242
+ export function heritageNames(text) {
243
+ const found = [];
244
+ let inHeritage = false;
245
+ let depth = 0;
246
+ let candidate;
247
+ const flush = () => {
248
+ if (candidate) {
249
+ found.push(candidate);
250
+ }
251
+ candidate = undefined;
252
+ };
253
+ // `=>` is one token so its `>` doesn't close a type-parameter list (`<T extends () => void>`).
254
+ for (const match of text.matchAll(/=>|[A-Za-z_$][\w$]*|[<>(){}[\],.]/g)) {
255
+ const token = match[0];
256
+ const offset = match.index ?? 0;
257
+ if (token === "{" && depth === 0) {
258
+ break; // the class/interface body; a `{` inside `<...>` is an object type and doesn't end the header
259
+ }
260
+ if (token === "<" || token === "(" || token === "{" || token === "[") {
261
+ if (token === "(" && depth === 0) {
262
+ candidate = undefined; // `extends mixin(Base)`
263
+ }
264
+ depth++;
265
+ }
266
+ else if (token === ">" || token === ")" || token === "}" || token === "]") {
267
+ depth = Math.max(0, depth - 1);
268
+ }
269
+ else if (depth > 0 || token === "=>") {
270
+ // inside type arguments or call arguments
271
+ }
272
+ else if (token === ",") {
273
+ flush();
274
+ }
275
+ else if (token === ".") {
276
+ // qualified name: the last segment replaces the previous one
277
+ }
278
+ else if (token === "extends" || token === "implements") {
279
+ flush();
280
+ inHeritage = true;
281
+ }
282
+ else if (inHeritage) {
283
+ candidate = { name: token, offset };
284
+ }
285
+ }
286
+ flush();
287
+ return found;
288
+ }
289
+ function offsetToPosition(text, offset) {
290
+ const before = text.slice(0, offset);
291
+ const line = before.split("\n").length - 1;
292
+ return { line, character: offset - (before.lastIndexOf("\n") + 1) };
293
+ }
294
+ /** Where each class named in `node`'s `extends`/`implements` clauses is declared. */
295
+ async function heritageTargets(client, workspaceRoot, relPath, node) {
296
+ const sel = (node.selectionRange ?? node.range)?.start;
297
+ if (!sel) {
298
+ return [];
299
+ }
300
+ const lines = readTextStrict(path.resolve(workspaceRoot, relPath)).split(LINE_BREAK_RE);
301
+ const startOffset = lines.slice(0, sel.line ?? 0).reduce((sum, l) => sum + l.length + 1, 0) + (sel.character ?? 0);
302
+ const source = lines.join("\n");
303
+ const text = source.slice(startOffset, startOffset + 2000);
304
+ const targets = [];
305
+ for (const { name, offset } of heritageNames(text)) {
306
+ const at = offsetToPosition(text, offset);
307
+ const line = (sel.line ?? 0) + at.line;
308
+ const character = at.line === 0 ? (sel.character ?? 0) + at.character : at.character;
309
+ let locations;
310
+ try {
311
+ locations = await client.definition(relPath, line + 1, character + 1);
312
+ }
313
+ catch (error) {
314
+ if (!isToolError(error)) {
315
+ throw error;
316
+ }
317
+ continue;
318
+ }
319
+ for (const loc of locations) {
320
+ const uri = loc.uri ?? loc.targetUri ?? "";
321
+ const rng = loc.targetSelectionRange ?? loc.range ?? loc.targetRange;
322
+ const abs = uriToPath(uri);
323
+ // Vendored/library bases (`extends HTMLElement`) have no source worth walking.
324
+ if (!uri || isExcluded(abs, workspaceRoot) || abs.endsWith(".d.ts")) {
325
+ continue;
326
+ }
327
+ targets.push({ uri, name, line: rng?.start?.line ?? 0 });
328
+ }
329
+ }
330
+ return targets;
331
+ }
332
+ /**
333
+ * `Class.member` where `member` is inherited. Follows `extends`/`implements`
334
+ * clauses breadth-first (resolving each named base with `textDocument/definition`,
335
+ * across files) and returns the first base that declares `member` directly.
336
+ * TypeScript 7's language server has no type hierarchy request, so the
337
+ * clauses are read from the source instead.
338
+ */
339
+ async function resolveInherited(client, workspaceRoot, container, memberName) {
340
+ const queue = [{ uri: container.uri, name: container.name, line: container.line }];
341
+ const seen = new Set([`${container.uri}\0${container.name}\0${container.line}`]);
342
+ while (queue.length > 0 && seen.size <= MAX_CLASSES_VISITED) {
343
+ const current = queue.shift();
344
+ const relPath = uriToRelative(current.uri, workspaceRoot);
345
+ let members;
346
+ let bases;
347
+ try {
348
+ members = await client.documentSymbol(relPath);
349
+ const node = isHierarchicalDocumentSymbols(members)
350
+ ? findNamedNode(members, current.name, current.line)
351
+ : undefined;
352
+ bases = node ? await heritageTargets(client, workspaceRoot, relPath, node) : [];
353
+ }
354
+ catch (error) {
355
+ if (!isToolError(error)) {
356
+ throw error;
357
+ }
358
+ continue;
359
+ }
360
+ for (const base of bases) {
361
+ const key = `${base.uri}\0${base.name}\0${base.line}`;
362
+ if (seen.has(key)) {
363
+ continue;
364
+ }
365
+ seen.add(key);
366
+ queue.push(base);
367
+ try {
368
+ const baseMembers = await client.documentSymbol(uriToRelative(base.uri, workspaceRoot));
369
+ if (!isHierarchicalDocumentSymbols(baseMembers)) {
370
+ continue;
371
+ }
372
+ const baseNode = findNamedNode(baseMembers, base.name, base.line);
373
+ const member = (baseNode?.children ?? []).find((c) => String(c.name) === memberName);
374
+ if (member) {
375
+ return resolvedFromHierarchicalNode(memberName, member, base.uri);
376
+ }
377
+ }
378
+ catch (error) {
379
+ if (!isToolError(error)) {
380
+ throw error;
381
+ }
382
+ }
383
+ }
384
+ }
385
+ return undefined;
386
+ }
387
+ /**
388
+ * Resolve a name (or dotted `Class.method`) to a single symbol position.
389
+ *
390
+ * Throws `SymbolResolutionError` (a `ToolInputError`) when nothing matches or
391
+ * several symbols tie on an exact name: callers should pass `file_path` to
392
+ * disambiguate rather than guess.
393
+ */
394
+ export function resolveSymbol(client, workspaceRoot, query, filePath) {
395
+ return query.includes(".")
396
+ ? resolveDotted(client, workspaceRoot, query, filePath)
397
+ : resolveSimple(client, workspaceRoot, query, filePath);
398
+ }
@@ -0,0 +1,29 @@
1
+ /** Text helpers shared by the file readers and formatters. */
2
+ import fs from "node:fs";
3
+ import { Utf8DecodeError } from "./errors.js";
4
+ // `ignoreBOM: true` keeps a leading BOM in the text, so offsets match what the
5
+ // language server is sent (and what the Python implementation reported).
6
+ const strictUtf8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
7
+ /** Read a file as UTF-8, failing (like Python's `read_text`) instead of substituting U+FFFD. */
8
+ export function readTextStrict(filePath) {
9
+ const bytes = fs.readFileSync(filePath);
10
+ try {
11
+ return strictUtf8.decode(bytes);
12
+ }
13
+ catch {
14
+ throw new Utf8DecodeError("invalid UTF-8 byte sequence");
15
+ }
16
+ }
17
+ /** Line terminators as LSP defines them (`\r\n`, `\r`, `\n`). */
18
+ export const LINE_BREAK_RE = /\r\n|\r|\n/;
19
+ /** Lines of `text` without terminators; a trailing terminator doesn't add an empty last line. */
20
+ export function splitLines(text) {
21
+ const lines = text.split(LINE_BREAK_RE);
22
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
23
+ lines.pop();
24
+ }
25
+ return lines;
26
+ }
27
+ export function escapeRegExp(text) {
28
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
29
+ }