@zachsents/ts-mcp 0.3.3 → 0.3.6

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.
@@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises";
3
3
  import { extname } from "node:path";
4
4
  import { z } from "zod";
5
5
  const REQUEST_TIMEOUT_MS = 30_000;
6
+ /** JSON-RPC response and server-request envelope accepted from TypeScript. */
6
7
  const jsonRpcMessageSchema = z.object({
7
8
  jsonrpc: z.literal("2.0"),
8
9
  id: z.union([z.number(), z.string()]).optional(),
@@ -17,22 +18,12 @@ const jsonRpcMessageSchema = z.object({
17
18
  })
18
19
  .optional(),
19
20
  });
20
- const hoverResultSchema = z.object({
21
- contents: z.unknown(),
22
- });
23
- /** MarkupContent shape: `{ kind, value }` or just a raw `{ value }` string */
24
- const markupContentSchema = z.object({ value: z.string() });
25
- // Shared LSP building blocks
21
+ /** Shared LSP position shape. */
26
22
  const positionSchema = z.object({ line: z.number(), character: z.number() });
27
23
  const rangeSchema = z.object({ start: positionSchema, end: positionSchema });
28
- // Definition/references response can be Location or LocationLink
24
+ /** Location shape returned by definition and reference requests. */
29
25
  const locationSchema = z.object({ uri: z.string(), range: rangeSchema });
30
- const locationLinkSchema = z.object({
31
- targetUri: z.string(),
32
- targetRange: rangeSchema,
33
- targetSelectionRange: rangeSchema.optional(),
34
- });
35
- // Diagnostics
26
+ /** Diagnostic item shape returned by TypeScript. */
36
27
  const diagnosticItemSchema = z.object({
37
28
  range: rangeSchema,
38
29
  severity: z.number().optional(),
@@ -40,30 +31,9 @@ const diagnosticItemSchema = z.object({
40
31
  source: z.string().optional(),
41
32
  message: z.string(),
42
33
  });
43
- const diagnosticResultSchema = z.object({
44
- kind: z.string(),
45
- items: z.array(diagnosticItemSchema),
46
- });
47
- // Code actions
34
+ /** Text-edit shape shared by code actions and workspace edits. */
48
35
  const textEditSchema = z.object({ range: rangeSchema, newText: z.string() });
49
- const codeActionSchema = z.object({
50
- title: z.string(),
51
- kind: z.string().optional(),
52
- diagnostics: z.array(diagnosticItemSchema).optional(),
53
- edit: z
54
- .object({
55
- changes: z.record(z.string(), z.array(textEditSchema)).optional(),
56
- })
57
- .optional(),
58
- });
59
36
  const SEVERITY_LABELS = ["", "error", "warning", "info", "hint"];
60
- // Document symbols — tsgo returns flat SymbolInformation[], not hierarchical
61
- const symbolInformationSchema = z.object({
62
- name: z.string(),
63
- kind: z.number(),
64
- containerName: z.string().optional(),
65
- location: locationSchema,
66
- });
67
37
  const SYMBOL_KIND_LABELS = {
68
38
  1: "file",
69
39
  2: "module",
@@ -81,27 +51,13 @@ const SYMBOL_KIND_LABELS = {
81
51
  22: "enum member",
82
52
  26: "type parameter",
83
53
  };
84
- // Workspace edit (shared by rename and code actions)
85
- const workspaceEditSchema = z.object({
86
- changes: z.record(z.string(), z.array(textEditSchema)).optional(),
87
- });
88
- // Inlay hints
89
- const inlayHintLabelSchema = z.union([
90
- z.string(),
91
- z.array(z.object({ value: z.string() })),
92
- ]);
93
- const inlayHintSchema = z.object({
94
- position: positionSchema,
95
- label: inlayHintLabelSchema,
96
- kind: z.number().optional(),
97
- });
98
54
  const INLAY_HINT_KIND_LABELS = {
99
55
  1: "type",
100
56
  2: "parameter",
101
57
  };
102
58
  /**
103
- * LSP JSON-RPC client that communicates with a tsgo process over stdio. Handles
104
- * Content-Length message framing, request/response correlation,
59
+ * LSP JSON-RPC client that communicates with a TypeScript process over stdio.
60
+ * Handles Content-Length message framing, request/response correlation,
105
61
  * server-initiated requests (e.g. client/registerCapability), and automatic
106
62
  * document open/change tracking.
107
63
  */
@@ -114,13 +70,20 @@ export class LspClient {
114
70
  buffer = Buffer.alloc(0);
115
71
  documents = new Map();
116
72
  alive = true;
73
+ /**
74
+ * Start a TypeScript LSP child process.
75
+ *
76
+ * @param binaryPath - Absolute path to the native tsc binary.
77
+ * @param rootUri - LSP root URI for the project.
78
+ * @throws When the child process does not expose piped stdio.
79
+ */
117
80
  constructor(binaryPath, rootUri) {
118
81
  this.rootUri = rootUri;
119
82
  this.proc = spawn(binaryPath, ["--lsp", "--stdio"], {
120
83
  stdio: ["pipe", "pipe", "pipe"],
121
84
  });
122
85
  if (!this.proc.stdin || !this.proc.stdout) {
123
- throw new Error("Failed to create tsgo process with piped stdio");
86
+ throw new Error("Failed to create TypeScript process with piped stdio");
124
87
  }
125
88
  this.stdin = this.proc.stdin;
126
89
  this.proc.stdout.on("data", (chunk) => {
@@ -130,22 +93,36 @@ export class LspClient {
130
93
  this.proc.on("exit", (code) => {
131
94
  this.alive = false;
132
95
  for (const [, req] of this.pending) {
133
- req.reject(new Error(`tsgo exited with code ${code}`));
96
+ req.reject(new Error(`TypeScript exited with code ${code}`));
134
97
  clearTimeout(req.timer);
135
98
  }
136
99
  this.pending.clear();
137
100
  });
138
101
  }
139
- /** Create and initialize a new LSP client for a project root */
102
+ /**
103
+ * Create and initialize a new LSP client for a project root
104
+ *
105
+ * @param binaryPath - Absolute path to the native tsc binary.
106
+ * @param rootUri - LSP root URI for the project.
107
+ * @returns An initialized LSP client.
108
+ */
140
109
  static async create(binaryPath, rootUri) {
141
110
  const client = new LspClient(binaryPath, rootUri);
142
111
  await client.initialize();
143
112
  return client;
144
113
  }
114
+ /** @returns Whether the child process is available for requests. */
145
115
  get isAlive() {
146
116
  return this.alive;
147
117
  }
148
- /** Get hover information at a position in a file */
118
+ /**
119
+ * Get hover information at a position in a file
120
+ *
121
+ * @param filePath - Absolute path to the source file.
122
+ * @param line - Zero-based source line.
123
+ * @param character - Zero-based character offset.
124
+ * @returns Rendered hover content, or null when unavailable.
125
+ */
149
126
  async hover(filePath, line, character) {
150
127
  if (!this.alive)
151
128
  throw new Error("LSP client is not alive");
@@ -155,12 +132,19 @@ export class LspClient {
155
132
  textDocument: { uri },
156
133
  position: { line, character },
157
134
  });
158
- const parsed = hoverResultSchema.safeParse(result);
135
+ const parsed = z.object({ contents: z.unknown() }).safeParse(result);
159
136
  if (!parsed.success)
160
137
  return null;
161
138
  return formatHoverContents(parsed.data.contents);
162
139
  }
163
- /** Get definition location(s) for a symbol at a position */
140
+ /**
141
+ * Get definition location(s) for a symbol at a position
142
+ *
143
+ * @param filePath - Absolute path to the source file.
144
+ * @param line - Zero-based source line.
145
+ * @param character - Zero-based character offset.
146
+ * @returns Definition locations reported for the selected symbol.
147
+ */
164
148
  async definition(filePath, line, character) {
165
149
  if (!this.alive)
166
150
  throw new Error("LSP client is not alive");
@@ -172,7 +156,12 @@ export class LspClient {
172
156
  });
173
157
  return parseLocations(result);
174
158
  }
175
- /** Pull diagnostics for a file */
159
+ /**
160
+ * Pull diagnostics for a file
161
+ *
162
+ * @param filePath - Absolute path to the source file.
163
+ * @returns Diagnostics reported for the file.
164
+ */
176
165
  async diagnostics(filePath) {
177
166
  if (!this.alive)
178
167
  throw new Error("LSP client is not alive");
@@ -181,44 +170,74 @@ export class LspClient {
181
170
  const result = await this.sendRequest("textDocument/diagnostic", {
182
171
  textDocument: { uri },
183
172
  });
184
- const parsed = diagnosticResultSchema.safeParse(result);
173
+ const parsed = z
174
+ .object({
175
+ kind: z.string(),
176
+ items: z.array(diagnosticItemSchema),
177
+ })
178
+ .safeParse(result);
185
179
  if (!parsed.success)
186
180
  return [];
187
- return parsed.data.items.map((item) => ({
188
- line: item.range.start.line,
189
- character: item.range.start.character,
190
- endLine: item.range.end.line,
191
- endCharacter: item.range.end.character,
192
- severity: SEVERITY_LABELS[item.severity ?? 1] ?? "error",
193
- message: item.message,
194
- code: item.code,
181
+ return parsed.data.items.map(({ code, message, range, severity }) => ({
182
+ line: range.start.line,
183
+ character: range.start.character,
184
+ endLine: range.end.line,
185
+ endCharacter: range.end.character,
186
+ severity: SEVERITY_LABELS[severity ?? 1] ?? "error",
187
+ message,
188
+ code,
195
189
  }));
196
190
  }
197
- /** Get code actions for a range, optionally scoped to specific diagnostics */
191
+ /**
192
+ * Get code actions for a range, optionally scoped to specific diagnostics
193
+ *
194
+ * @param filePath - Absolute path to the source file.
195
+ * @param range - Source range to inspect.
196
+ * @param range.start - Inclusive start position.
197
+ * @param range.start.line - Zero-based start line.
198
+ * @param range.start.character - Zero-based start character.
199
+ * @param range.end - Exclusive end position.
200
+ * @param range.end.line - Zero-based end line.
201
+ * @param range.end.character - Zero-based end character.
202
+ * @param diagnostics - Diagnostics used to scope available actions.
203
+ * @returns Code actions returned for the range.
204
+ */
198
205
  async codeActions(filePath, range, diagnostics = []) {
199
206
  if (!this.alive)
200
207
  throw new Error("LSP client is not alive");
201
208
  const uri = pathToUri(filePath);
202
209
  await this.ensureDocumentOpen(filePath, uri);
203
- const lspDiagnostics = diagnostics.map((d) => ({
204
- range: {
205
- start: { line: d.line, character: d.character },
206
- end: { line: d.endLine, character: d.endCharacter },
207
- },
208
- severity: SEVERITY_LABELS.indexOf(d.severity) || 1,
209
- message: d.message,
210
- ...(d.code != null ? { code: d.code } : {}),
211
- }));
212
210
  const result = await this.sendRequest("textDocument/codeAction", {
213
211
  textDocument: { uri },
214
212
  range,
215
- context: { diagnostics: lspDiagnostics },
213
+ context: {
214
+ diagnostics: diagnostics.map(({ character, code, endCharacter, endLine, line, message, severity, }) => ({
215
+ range: {
216
+ start: { line, character },
217
+ end: { line: endLine, character: endCharacter },
218
+ },
219
+ severity: SEVERITY_LABELS.indexOf(severity) || 1,
220
+ message,
221
+ ...(code != null ? { code } : {}),
222
+ })),
223
+ },
216
224
  });
217
225
  if (!Array.isArray(result))
218
226
  return [];
219
227
  const actions = [];
220
228
  for (const item of result) {
221
- const parsed = codeActionSchema.safeParse(item);
229
+ const parsed = z
230
+ .object({
231
+ title: z.string(),
232
+ kind: z.string().optional(),
233
+ diagnostics: z.array(diagnosticItemSchema).optional(),
234
+ edit: z
235
+ .object({
236
+ changes: z.record(z.string(), z.array(textEditSchema)).optional(),
237
+ })
238
+ .optional(),
239
+ })
240
+ .safeParse(item);
222
241
  if (!parsed.success || !parsed.data.edit)
223
242
  continue;
224
243
  actions.push({
@@ -229,7 +248,12 @@ export class LspClient {
229
248
  }
230
249
  return actions;
231
250
  }
232
- /** Get a structured outline of all symbols in a file */
251
+ /**
252
+ * Get a structured outline of all symbols in a file
253
+ *
254
+ * @param filePath - Absolute path to the source file.
255
+ * @returns The file's hierarchical symbol outline.
256
+ */
233
257
  async documentSymbols(filePath) {
234
258
  if (!this.alive)
235
259
  throw new Error("LSP client is not alive");
@@ -240,7 +264,15 @@ export class LspClient {
240
264
  return [];
241
265
  return buildSymbolTree(result);
242
266
  }
243
- /** Rename a symbol across the project */
267
+ /**
268
+ * Rename a symbol across the project
269
+ *
270
+ * @param filePath - Absolute path to the source file.
271
+ * @param line - Zero-based source line.
272
+ * @param character - Zero-based character offset.
273
+ * @param newName - Replacement symbol name.
274
+ * @returns Text edits required to perform the rename.
275
+ */
244
276
  async rename(filePath, line, character, newName) {
245
277
  if (!this.alive)
246
278
  throw new Error("LSP client is not alive");
@@ -253,7 +285,14 @@ export class LspClient {
253
285
  });
254
286
  return parseWorkspaceEdit(result);
255
287
  }
256
- /** Get inferred type annotations for a line range */
288
+ /**
289
+ * Get inferred type annotations for a line range
290
+ *
291
+ * @param filePath - Absolute path to the source file.
292
+ * @param startLine - Zero-based inclusive start line.
293
+ * @param endLine - Zero-based exclusive end line.
294
+ * @returns Inlay hints reported for the requested range.
295
+ */
257
296
  async inlayHints(filePath, startLine, endLine) {
258
297
  if (!this.alive)
259
298
  throw new Error("LSP client is not alive");
@@ -270,23 +309,38 @@ export class LspClient {
270
309
  return [];
271
310
  const hints = [];
272
311
  for (const item of result) {
273
- const parsed = inlayHintSchema.safeParse(item);
312
+ const parsed = z
313
+ .object({
314
+ position: positionSchema,
315
+ label: z.union([
316
+ z.string(),
317
+ z.array(z.object({ value: z.string() })),
318
+ ]),
319
+ kind: z.number().optional(),
320
+ })
321
+ .safeParse(item);
274
322
  if (!parsed.success)
275
323
  continue;
276
324
  const label = parsed.data.label;
277
- const text = typeof label === "string"
278
- ? label
279
- : label.map((part) => part.value).join("");
280
325
  hints.push({
281
326
  line: parsed.data.position.line,
282
327
  character: parsed.data.position.character,
283
- text,
328
+ text: typeof label === "string"
329
+ ? label
330
+ : label.map((part) => part.value).join(""),
284
331
  kind: INLAY_HINT_KIND_LABELS[parsed.data.kind ?? 0] ?? "unknown",
285
332
  });
286
333
  }
287
334
  return hints;
288
335
  }
289
- /** Find all references to a symbol at a position */
336
+ /**
337
+ * Find all references to a symbol at a position
338
+ *
339
+ * @param filePath - Absolute path to the source file.
340
+ * @param line - Zero-based source line.
341
+ * @param character - Zero-based character offset.
342
+ * @returns Locations that reference the selected symbol.
343
+ */
290
344
  async references(filePath, line, character) {
291
345
  if (!this.alive)
292
346
  throw new Error("LSP client is not alive");
@@ -313,6 +367,7 @@ export class LspClient {
313
367
  }
314
368
  this.proc.kill();
315
369
  }
370
+ /** Initialize the LSP session. */
316
371
  async initialize() {
317
372
  await this.sendRequest("initialize", {
318
373
  processId: process.pid,
@@ -326,16 +381,15 @@ export class LspClient {
326
381
  this.sendNotification("initialized", {});
327
382
  }
328
383
  /**
329
- * Re-read all previously opened documents from disk and push changes to tsgo.
330
- * This ensures that edits to imported/dependency files are picked up before
331
- * we query a file that depends on them.
384
+ * Re-read all previously opened documents from disk and push changes to
385
+ * TypeScript. This ensures that edits to imported/dependency files are picked
386
+ * up before we query a file that depends on them.
332
387
  */
333
388
  async refreshOpenDocuments() {
334
389
  for (const [uri, state] of this.documents) {
335
- const filePath = uriToPath(uri);
336
390
  let content;
337
391
  try {
338
- content = await readFile(filePath, "utf-8");
392
+ content = await readFile(uriToPath(uri), "utf-8");
339
393
  }
340
394
  catch {
341
395
  continue;
@@ -350,7 +404,12 @@ export class LspClient {
350
404
  }
351
405
  }
352
406
  }
353
- /** Open a document in the LSP if needed, or update it if contents changed */
407
+ /**
408
+ * Open a document in the LSP if needed, or update it if contents changed
409
+ *
410
+ * @param filePath - Absolute path to the source file.
411
+ * @param uri - LSP document URI for the file.
412
+ */
354
413
  async ensureDocumentOpen(filePath, uri) {
355
414
  await this.refreshOpenDocuments();
356
415
  const content = await readFile(filePath, "utf-8");
@@ -376,27 +435,52 @@ export class LspClient {
376
435
  this.documents.set(uri, { version, content });
377
436
  }
378
437
  }
438
+ /**
439
+ * Send an LSP request and await its response.
440
+ *
441
+ * @param method - LSP method name.
442
+ * @param params - JSON-RPC request parameters.
443
+ */
379
444
  sendRequest(method, params) {
380
445
  return new Promise((resolve, reject) => {
381
446
  const id = this.nextId++;
382
- const timer = setTimeout(() => {
383
- this.pending.delete(id);
384
- reject(new Error(`Request "${method}" timed out after ${REQUEST_TIMEOUT_MS}ms`));
385
- }, REQUEST_TIMEOUT_MS);
386
- this.pending.set(id, { resolve, reject, timer });
447
+ this.pending.set(id, {
448
+ resolve,
449
+ reject,
450
+ timer: setTimeout(() => {
451
+ this.pending.delete(id);
452
+ reject(new Error(`Request "${method}" timed out after ${REQUEST_TIMEOUT_MS}ms`));
453
+ }, REQUEST_TIMEOUT_MS),
454
+ });
387
455
  this.send({ jsonrpc: "2.0", id, method, params });
388
456
  });
389
457
  }
458
+ /**
459
+ * Send an LSP notification.
460
+ *
461
+ * @param method - LSP method name.
462
+ * @param params - JSON-RPC notification parameters.
463
+ */
390
464
  sendNotification(method, params) {
391
465
  this.send({ jsonrpc: "2.0", method, params });
392
466
  }
467
+ /**
468
+ * Respond to a server-initiated LSP request.
469
+ *
470
+ * @param id - Identifier of the server request.
471
+ * @param result - JSON-RPC response result.
472
+ */
393
473
  sendResponse(id, result) {
394
474
  this.send({ jsonrpc: "2.0", id, result });
395
475
  }
476
+ /**
477
+ * Write a framed JSON-RPC message to the child process.
478
+ *
479
+ * @param message - JSON-RPC message to serialize and send.
480
+ */
396
481
  send(message) {
397
482
  const body = JSON.stringify(message);
398
- const header = `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n`;
399
- this.stdin.write(header + body);
483
+ this.stdin.write(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);
400
484
  }
401
485
  /** Parse Content-Length framed messages from the buffer */
402
486
  drainBuffer() {
@@ -404,24 +488,29 @@ export class LspClient {
404
488
  const headerEnd = this.buffer.indexOf("\r\n\r\n");
405
489
  if (headerEnd === -1)
406
490
  return;
407
- const header = this.buffer.subarray(0, headerEnd).toString("ascii");
408
- const match = header.match(/Content-Length:\s*(\d+)/);
491
+ const match = this.buffer
492
+ .subarray(0, headerEnd)
493
+ .toString("ascii")
494
+ .match(/Content-Length:\s*(\d+)/);
409
495
  if (!match) {
410
496
  this.buffer = this.buffer.subarray(headerEnd + 4);
411
497
  continue;
412
498
  }
413
- const contentLength = Number.parseInt(match[1] ?? "0", 10);
414
499
  const bodyStart = headerEnd + 4;
415
- const bodyEnd = bodyStart + contentLength;
500
+ const bodyEnd = bodyStart + Number.parseInt(match[1] ?? "0", 10);
416
501
  if (this.buffer.length < bodyEnd)
417
502
  return;
418
- const body = this.buffer.subarray(bodyStart, bodyEnd).toString("utf-8");
503
+ const parsed = jsonRpcMessageSchema.safeParse(JSON.parse(this.buffer.subarray(bodyStart, bodyEnd).toString("utf-8")));
419
504
  this.buffer = this.buffer.subarray(bodyEnd);
420
- const parsed = jsonRpcMessageSchema.safeParse(JSON.parse(body));
421
505
  if (parsed.success)
422
506
  this.handleMessage(parsed.data);
423
507
  }
424
508
  }
509
+ /**
510
+ * Route a parsed JSON-RPC message.
511
+ *
512
+ * @param message - Validated JSON-RPC message from the server.
513
+ */
425
514
  handleMessage(message) {
426
515
  // Response to one of our requests (numeric id, no method)
427
516
  if (typeof message.id === "number" && !message.method) {
@@ -444,22 +533,36 @@ export class LspClient {
444
533
  }
445
534
  }
446
535
  }
536
+ /**
537
+ * Convert a file path to an LSP file URI.
538
+ *
539
+ * @param filePath - Absolute path to convert.
540
+ * @returns The corresponding file URI.
541
+ */
447
542
  function pathToUri(filePath) {
448
543
  return `file://${filePath}`;
449
544
  }
545
+ /**
546
+ * Convert an LSP file URI to a file path.
547
+ *
548
+ * @param uri - File URI to convert.
549
+ * @returns The corresponding absolute file path.
550
+ */
450
551
  function uriToPath(uri) {
451
552
  return uri.replace(/^file:\/\//, "");
452
553
  }
453
554
  /**
454
555
  * Parse Location[] or LocationLink[] from an LSP response into LspLocation[].
455
556
  * Handles single objects, arrays, and null.
557
+ *
558
+ * @param result - Raw LSP definition response.
559
+ * @returns Normalized definition locations.
456
560
  */
457
561
  function parseLocations(result) {
458
562
  if (result == null)
459
563
  return [];
460
- const items = Array.isArray(result) ? result : [result];
461
564
  const locations = [];
462
- for (const item of items) {
565
+ for (const item of Array.isArray(result) ? result : [result]) {
463
566
  const loc = locationSchema.safeParse(item);
464
567
  if (loc.success) {
465
568
  locations.push({
@@ -469,7 +572,13 @@ function parseLocations(result) {
469
572
  });
470
573
  continue;
471
574
  }
472
- const link = locationLinkSchema.safeParse(item);
575
+ const link = z
576
+ .object({
577
+ targetUri: z.string(),
578
+ targetRange: rangeSchema,
579
+ targetSelectionRange: rangeSchema.optional(),
580
+ })
581
+ .safeParse(item);
473
582
  if (link.success) {
474
583
  const range = link.data.targetSelectionRange ?? link.data.targetRange;
475
584
  locations.push({
@@ -484,11 +593,21 @@ function parseLocations(result) {
484
593
  /**
485
594
  * Build a symbol tree from flat SymbolInformation[]. Uses containerName to
486
595
  * reconstruct parent-child relationships.
596
+ *
597
+ * @param items - Raw LSP symbol information records.
598
+ * @returns A hierarchical symbol outline.
487
599
  */
488
600
  function buildSymbolTree(items) {
489
601
  const flat = [];
490
602
  for (const item of items) {
491
- const parsed = symbolInformationSchema.safeParse(item);
603
+ const parsed = z
604
+ .object({
605
+ name: z.string(),
606
+ kind: z.number(),
607
+ containerName: z.string().optional(),
608
+ location: locationSchema,
609
+ })
610
+ .safeParse(item);
492
611
  if (!parsed.success)
493
612
  continue;
494
613
  flat.push({
@@ -522,17 +641,25 @@ function buildSymbolTree(items) {
522
641
  }
523
642
  return roots;
524
643
  }
525
- /** Parse a WorkspaceEdit into flat LspTextEdit[] */
644
+ /**
645
+ * Parse a WorkspaceEdit into flat LspTextEdit[]
646
+ *
647
+ * @param result - Raw LSP workspace edit.
648
+ * @returns Flattened text edits.
649
+ */
526
650
  function parseWorkspaceEdit(result) {
527
- const parsed = workspaceEditSchema.safeParse(result);
651
+ const parsed = z
652
+ .object({
653
+ changes: z.record(z.string(), z.array(textEditSchema)).optional(),
654
+ })
655
+ .safeParse(result);
528
656
  if (!parsed.success || !parsed.data.changes)
529
657
  return [];
530
658
  const edits = [];
531
659
  for (const [editUri, textEdits] of Object.entries(parsed.data.changes)) {
532
- const file = uriToPath(editUri);
533
660
  for (const te of textEdits) {
534
661
  edits.push({
535
- file,
662
+ file: uriToPath(editUri),
536
663
  startLine: te.range.start.line,
537
664
  startCharacter: te.range.start.character,
538
665
  endLine: te.range.end.line,
@@ -543,9 +670,14 @@ function parseWorkspaceEdit(result) {
543
670
  }
544
671
  return edits;
545
672
  }
673
+ /**
674
+ * Return the LSP language identifier for a source file.
675
+ *
676
+ * @param filePath - Source file path.
677
+ * @returns The matching LSP language identifier.
678
+ */
546
679
  function getLanguageId(filePath) {
547
- const ext = extname(filePath);
548
- switch (ext) {
680
+ switch (extname(filePath)) {
549
681
  case ".ts":
550
682
  case ".mts":
551
683
  case ".cts":
@@ -565,6 +697,9 @@ function getLanguageId(filePath) {
565
697
  /**
566
698
  * Extract text from LSP hover contents. Handles MarkupContent (`{ value }`),
567
699
  * plain strings, and arrays of either.
700
+ *
701
+ * @param contents - Raw LSP hover content.
702
+ * @returns Rendered hover text, or null when the content is unsupported.
568
703
  */
569
704
  function formatHoverContents(contents) {
570
705
  const str = z.string().safeParse(contents);
@@ -574,7 +709,7 @@ function formatHoverContents(contents) {
574
709
  const parts = contents.map(formatHoverContents).filter(Boolean);
575
710
  return parts.length > 0 ? parts.join("\n\n") : null;
576
711
  }
577
- const markup = markupContentSchema.safeParse(contents);
712
+ const markup = z.object({ value: z.string() }).safeParse(contents);
578
713
  if (markup.success)
579
714
  return markup.data.value;
580
715
  return null;