@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.
- package/README.md +11 -83
- package/dist/index.js +45 -30
- package/dist/index.js.map +1 -1
- package/dist/lsp-client.js +253 -118
- package/dist/lsp-client.js.map +1 -1
- package/dist/pool.js +92 -27
- package/dist/pool.js.map +1 -1
- package/dist/resolve-binary.js +17 -21
- package/dist/resolve-binary.js.map +1 -1
- package/package.json +16 -11
package/dist/lsp-client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
24
|
+
/** Location shape returned by definition and reference requests. */
|
|
29
25
|
const locationSchema = z.object({ uri: z.string(), range: rangeSchema });
|
|
30
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(`
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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 =
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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 =
|
|
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((
|
|
188
|
-
line:
|
|
189
|
-
character:
|
|
190
|
-
endLine:
|
|
191
|
-
endCharacter:
|
|
192
|
-
severity: SEVERITY_LABELS[
|
|
193
|
-
message
|
|
194
|
-
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
|
-
/**
|
|
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: {
|
|
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 =
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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 =
|
|
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
|
-
/**
|
|
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
|
|
330
|
-
* This ensures that edits to imported/dependency files are picked
|
|
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(
|
|
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
|
-
/**
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
reject
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
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
|
|
408
|
-
|
|
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 +
|
|
500
|
+
const bodyEnd = bodyStart + Number.parseInt(match[1] ?? "0", 10);
|
|
416
501
|
if (this.buffer.length < bodyEnd)
|
|
417
502
|
return;
|
|
418
|
-
const
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
-
/**
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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;
|