@narumitw/pi-lsp 0.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +175 -0
- package/package.json +49 -0
- package/src/adapters.ts +141 -0
- package/src/command.ts +93 -0
- package/src/files.ts +119 -0
- package/src/lsp-client.ts +426 -0
- package/src/pi-lsp.ts +214 -0
- package/src/runner.ts +251 -0
- package/src/text-edits.ts +96 -0
- package/src/types.ts +95 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { commandExists } from "./command.js";
|
|
5
|
+
import { directoryUri } from "./files.js";
|
|
6
|
+
import { positionAt } from "./text-edits.js";
|
|
7
|
+
import type {
|
|
8
|
+
CodeAction,
|
|
9
|
+
JsonRpcMessage,
|
|
10
|
+
LspDiagnostic,
|
|
11
|
+
LspServerAdapter,
|
|
12
|
+
LspTextEdit,
|
|
13
|
+
ServerCommand,
|
|
14
|
+
} from "./types.js";
|
|
15
|
+
|
|
16
|
+
export class LspClient {
|
|
17
|
+
#child?: ChildProcessWithoutNullStreams;
|
|
18
|
+
#buffer = Buffer.alloc(0);
|
|
19
|
+
#nextId = 1;
|
|
20
|
+
#pending = new Map<
|
|
21
|
+
number,
|
|
22
|
+
{
|
|
23
|
+
resolve: (message: JsonRpcMessage) => void;
|
|
24
|
+
reject: (reason: unknown) => void;
|
|
25
|
+
timeout: NodeJS.Timeout;
|
|
26
|
+
}
|
|
27
|
+
>();
|
|
28
|
+
#publishedDiagnostics = new Map<string, LspDiagnostic[]>();
|
|
29
|
+
#diagnosticWaiters = new Map<
|
|
30
|
+
string,
|
|
31
|
+
Array<{
|
|
32
|
+
resolve: (diagnostics: LspDiagnostic[]) => void;
|
|
33
|
+
reject: (reason: unknown) => void;
|
|
34
|
+
timeout: NodeJS.Timeout;
|
|
35
|
+
}>
|
|
36
|
+
>();
|
|
37
|
+
#stderr = "";
|
|
38
|
+
#adapter: LspServerAdapter;
|
|
39
|
+
#command: ServerCommand;
|
|
40
|
+
#cwd: string;
|
|
41
|
+
#timeoutMs: number;
|
|
42
|
+
|
|
43
|
+
constructor(adapter: LspServerAdapter, command: ServerCommand, cwd: string, timeoutMs: number) {
|
|
44
|
+
this.#adapter = adapter;
|
|
45
|
+
this.#command = command;
|
|
46
|
+
this.#cwd = cwd;
|
|
47
|
+
this.#timeoutMs = timeoutMs;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async start() {
|
|
51
|
+
if (!commandExists(this.#command.command, this.#cwd)) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`${this.#adapter.label} LSP command not found: ${this.#command.command}. ${this.#adapter.missingCommandHint}`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const child = spawn(this.#command.command, this.#command.args, {
|
|
58
|
+
cwd: this.#cwd,
|
|
59
|
+
stdio: "pipe",
|
|
60
|
+
});
|
|
61
|
+
this.#child = child;
|
|
62
|
+
child.stdout.on("data", (chunk) => {
|
|
63
|
+
try {
|
|
64
|
+
this.#onData(chunk);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
this.#fail(
|
|
67
|
+
`${this.#adapter.label} LSP server sent invalid JSON-RPC data: ${formatErrorMessage(error)}.${this.#formatStderr()}`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
child.stderr.on("data", (chunk) => {
|
|
72
|
+
this.#stderr += chunk.toString();
|
|
73
|
+
});
|
|
74
|
+
child.stdin.on("error", (error) => {
|
|
75
|
+
this.#fail(
|
|
76
|
+
`${this.#adapter.label} LSP stdin write failed: ${formatErrorMessage(error)}.${this.#formatStderr()}`,
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
child.once("exit", (code, signal) => {
|
|
80
|
+
if (this.#child === child) this.#child = undefined;
|
|
81
|
+
const reason = signal ? `signal ${signal}` : `code ${code ?? "unknown"}`;
|
|
82
|
+
this.#rejectPending(
|
|
83
|
+
(id) =>
|
|
84
|
+
`${this.#adapter.label} LSP server exited before response ${id} (${reason}).${this.#formatStderr()}`,
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
await new Promise<void>((resolve, reject) => {
|
|
89
|
+
child.once("spawn", resolve);
|
|
90
|
+
child.once("error", (error) => {
|
|
91
|
+
const message = `${this.#adapter.label} LSP process failed to start: ${error.message}.${this.#formatStderr()}`;
|
|
92
|
+
this.#rejectPending(message);
|
|
93
|
+
if (this.#child === child) this.#child = undefined;
|
|
94
|
+
reject(new Error(message));
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async initialize(root: string) {
|
|
100
|
+
const rootUri = directoryUri(root);
|
|
101
|
+
const workspaceFolders = [{ uri: rootUri, name: path.basename(root) || "workspace" }];
|
|
102
|
+
const init = this.#adapter.initialize;
|
|
103
|
+
await this.request("initialize", {
|
|
104
|
+
processId: process.pid,
|
|
105
|
+
rootUri,
|
|
106
|
+
workspaceFolders: this.#adapter.serverRequestWorkspaceFolders ? workspaceFolders : null,
|
|
107
|
+
capabilities: {
|
|
108
|
+
textDocument: {
|
|
109
|
+
...(init.codeAction
|
|
110
|
+
? {
|
|
111
|
+
codeAction: {
|
|
112
|
+
dynamicRegistration: init.codeActionDynamicRegistration,
|
|
113
|
+
resolveSupport: { properties: ["edit"] },
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
: {}),
|
|
117
|
+
diagnostic: { dynamicRegistration: init.diagnosticDynamicRegistration },
|
|
118
|
+
...(init.formattingDynamicRegistration === undefined
|
|
119
|
+
? {}
|
|
120
|
+
: { formatting: { dynamicRegistration: init.formattingDynamicRegistration } }),
|
|
121
|
+
publishDiagnostics: {},
|
|
122
|
+
synchronization: {
|
|
123
|
+
didSave: true,
|
|
124
|
+
...(init.didSaveDynamicRegistration === undefined
|
|
125
|
+
? {}
|
|
126
|
+
: { dynamicRegistration: init.didSaveDynamicRegistration }),
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
workspace: {
|
|
130
|
+
configuration: true,
|
|
131
|
+
...(init.didChangeConfigurationDynamicRegistration === undefined
|
|
132
|
+
? {}
|
|
133
|
+
: {
|
|
134
|
+
didChangeConfiguration: {
|
|
135
|
+
dynamicRegistration: init.didChangeConfigurationDynamicRegistration,
|
|
136
|
+
},
|
|
137
|
+
}),
|
|
138
|
+
workspaceEdit: { documentChanges: true },
|
|
139
|
+
workspaceFolders: this.#adapter.serverRequestWorkspaceFolders,
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
this.notify("initialized", {});
|
|
144
|
+
if (this.#adapter.fallbackToPublishDiagnostics) await wait(300);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
didOpen(uri: string, text: string, languageId: string) {
|
|
148
|
+
this.notify("textDocument/didOpen", {
|
|
149
|
+
textDocument: { uri, languageId, version: 1, text },
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
didClose(uri: string) {
|
|
154
|
+
if (!this.#child) return false;
|
|
155
|
+
this.notify("textDocument/didClose", {
|
|
156
|
+
textDocument: { uri },
|
|
157
|
+
});
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async diagnostics(uri: string) {
|
|
162
|
+
try {
|
|
163
|
+
const response = await this.request("textDocument/diagnostic", {
|
|
164
|
+
textDocument: { uri },
|
|
165
|
+
identifier: null,
|
|
166
|
+
previousResultId: null,
|
|
167
|
+
});
|
|
168
|
+
const result = response.result as { items?: LspDiagnostic[] } | undefined;
|
|
169
|
+
return result?.items ?? [];
|
|
170
|
+
} catch (error) {
|
|
171
|
+
if (!this.#adapter.fallbackToPublishDiagnostics || !isUnsupportedMethodError(error)) throw error;
|
|
172
|
+
return this.#waitForPublishedDiagnostics(uri);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async format(uri: string) {
|
|
177
|
+
const response = await this.request("textDocument/formatting", {
|
|
178
|
+
textDocument: { uri },
|
|
179
|
+
options: this.#adapter.formattingOptions,
|
|
180
|
+
});
|
|
181
|
+
return (response.result as LspTextEdit[] | null | undefined) ?? [];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async codeActions(uri: string, text: string, diagnostics: LspDiagnostic[], kind: string) {
|
|
185
|
+
const response = await this.request("textDocument/codeAction", {
|
|
186
|
+
textDocument: { uri },
|
|
187
|
+
range: { start: { line: 0, character: 0 }, end: positionAt(text, text.length) },
|
|
188
|
+
context: { diagnostics, only: [kind] },
|
|
189
|
+
});
|
|
190
|
+
return (response.result as CodeAction[] | null | undefined) ?? [];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async resolveActions(actions: CodeAction[]) {
|
|
194
|
+
const resolvedActions: CodeAction[] = [];
|
|
195
|
+
for (const action of actions) {
|
|
196
|
+
if (action.edit) {
|
|
197
|
+
resolvedActions.push(action);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
const response = await this.request("codeAction/resolve", action);
|
|
203
|
+
resolvedActions.push((response.result as CodeAction | undefined) ?? action);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (!this.#adapter.resolveUnsupportedCodeActions || !isUnsupportedMethodError(error)) throw error;
|
|
206
|
+
resolvedActions.push(action);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return resolvedActions;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async shutdown() {
|
|
214
|
+
if (!this.#child) return;
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
await this.request("shutdown", null);
|
|
218
|
+
this.notify("exit", undefined);
|
|
219
|
+
} catch {
|
|
220
|
+
// The process may already be gone; close below still guarantees cleanup.
|
|
221
|
+
} finally {
|
|
222
|
+
this.close();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
close() {
|
|
227
|
+
this.#rejectPending(`${this.#adapter.label} LSP request cancelled.`);
|
|
228
|
+
|
|
229
|
+
if (this.#child && !this.#child.killed) this.#child.kill("SIGTERM");
|
|
230
|
+
this.#child = undefined;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
#rejectPending(message: string | ((id: number | "diagnostics") => string)) {
|
|
234
|
+
for (const [id, pending] of this.#pending.entries()) {
|
|
235
|
+
clearTimeout(pending.timeout);
|
|
236
|
+
pending.reject(new Error(typeof message === "string" ? message : message(id)));
|
|
237
|
+
}
|
|
238
|
+
this.#pending.clear();
|
|
239
|
+
for (const waiters of this.#diagnosticWaiters.values()) {
|
|
240
|
+
for (const waiter of waiters) {
|
|
241
|
+
clearTimeout(waiter.timeout);
|
|
242
|
+
waiter.reject(new Error(typeof message === "string" ? message : message("diagnostics")));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
this.#diagnosticWaiters.clear();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
#fail(message: string) {
|
|
249
|
+
this.#rejectPending(message);
|
|
250
|
+
if (this.#child && !this.#child.killed) this.#child.kill("SIGTERM");
|
|
251
|
+
this.#child = undefined;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private request(method: string, params: unknown) {
|
|
255
|
+
const id = this.#nextId++;
|
|
256
|
+
|
|
257
|
+
return new Promise<JsonRpcMessage>((resolve, reject) => {
|
|
258
|
+
const timeout = setTimeout(() => {
|
|
259
|
+
this.#pending.delete(id);
|
|
260
|
+
reject(
|
|
261
|
+
new Error(`${this.#adapter.label} LSP request timed out: ${method}.${this.#formatStderr()}`),
|
|
262
|
+
);
|
|
263
|
+
}, this.#timeoutMs);
|
|
264
|
+
this.#pending.set(id, { resolve, reject, timeout });
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
this.#send({ jsonrpc: "2.0", id, method, params });
|
|
268
|
+
} catch (error) {
|
|
269
|
+
clearTimeout(timeout);
|
|
270
|
+
this.#pending.delete(id);
|
|
271
|
+
reject(error);
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private notify(method: string, params: unknown) {
|
|
277
|
+
this.#send(
|
|
278
|
+
params === undefined ? { jsonrpc: "2.0", method } : { jsonrpc: "2.0", method, params },
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
#send(message: JsonRpcMessage) {
|
|
283
|
+
if (!this.#child) throw new Error(`${this.#adapter.label} LSP server is not running.`);
|
|
284
|
+
|
|
285
|
+
const body = JSON.stringify(message);
|
|
286
|
+
try {
|
|
287
|
+
this.#child.stdin.write(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);
|
|
288
|
+
} catch (error) {
|
|
289
|
+
const errorMessage =
|
|
290
|
+
`${this.#adapter.label} LSP stdin write failed: ${formatErrorMessage(error)}.` +
|
|
291
|
+
this.#formatStderr();
|
|
292
|
+
this.#fail(errorMessage);
|
|
293
|
+
throw new Error(errorMessage);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
#onData(chunk: Buffer) {
|
|
298
|
+
this.#buffer = Buffer.concat([this.#buffer, chunk]);
|
|
299
|
+
|
|
300
|
+
while (true) {
|
|
301
|
+
const separator = this.#buffer.indexOf("\r\n\r\n");
|
|
302
|
+
if (separator < 0) return;
|
|
303
|
+
|
|
304
|
+
const header = this.#buffer.subarray(0, separator).toString("utf8");
|
|
305
|
+
const contentLength = /Content-Length:\s*(\d+)/i.exec(header)?.[1];
|
|
306
|
+
if (!contentLength) throw new Error(`Invalid LSP response header: ${header}`);
|
|
307
|
+
|
|
308
|
+
const bodyStart = separator + 4;
|
|
309
|
+
const bodyLength = Number(contentLength);
|
|
310
|
+
if (this.#buffer.length < bodyStart + bodyLength) return;
|
|
311
|
+
|
|
312
|
+
const rawBody = this.#buffer.subarray(bodyStart, bodyStart + bodyLength).toString("utf8");
|
|
313
|
+
this.#buffer = this.#buffer.subarray(bodyStart + bodyLength);
|
|
314
|
+
this.#handleMessage(JSON.parse(rawBody) as JsonRpcMessage);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
#handleMessage(message: JsonRpcMessage) {
|
|
319
|
+
if (Object.hasOwn(message, "id") && !message.method) {
|
|
320
|
+
const pending = typeof message.id === "number" ? this.#pending.get(message.id) : undefined;
|
|
321
|
+
if (!pending) return;
|
|
322
|
+
|
|
323
|
+
clearTimeout(pending.timeout);
|
|
324
|
+
this.#pending.delete(message.id as number);
|
|
325
|
+
if (message.error) {
|
|
326
|
+
pending.reject(new Error(`${this.#adapter.label} LSP error: ${message.error.message}`));
|
|
327
|
+
} else {
|
|
328
|
+
pending.resolve(message);
|
|
329
|
+
}
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (message.method === "textDocument/publishDiagnostics") {
|
|
334
|
+
const params = message.params as { uri?: string; diagnostics?: LspDiagnostic[] } | undefined;
|
|
335
|
+
if (params?.uri) {
|
|
336
|
+
const diagnostics = params.diagnostics ?? [];
|
|
337
|
+
this.#publishedDiagnostics.set(params.uri, diagnostics);
|
|
338
|
+
const waiters = this.#diagnosticWaiters.get(params.uri) ?? [];
|
|
339
|
+
this.#diagnosticWaiters.delete(params.uri);
|
|
340
|
+
for (const waiter of waiters) {
|
|
341
|
+
clearTimeout(waiter.timeout);
|
|
342
|
+
waiter.resolve(diagnostics);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (Object.hasOwn(message, "id") && message.method) {
|
|
349
|
+
this.#respondToServerRequest(message);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
#waitForPublishedDiagnostics(uri: string) {
|
|
354
|
+
const diagnostics = this.#publishedDiagnostics.get(uri);
|
|
355
|
+
if (diagnostics) return Promise.resolve(diagnostics);
|
|
356
|
+
|
|
357
|
+
return new Promise<LspDiagnostic[]>((resolve, reject) => {
|
|
358
|
+
const waiter = {
|
|
359
|
+
resolve,
|
|
360
|
+
reject,
|
|
361
|
+
timeout: setTimeout(() => {
|
|
362
|
+
const waiters = this.#diagnosticWaiters.get(uri)?.filter((entry) => entry !== waiter) ?? [];
|
|
363
|
+
if (waiters.length) this.#diagnosticWaiters.set(uri, waiters);
|
|
364
|
+
else this.#diagnosticWaiters.delete(uri);
|
|
365
|
+
resolve(this.#publishedDiagnostics.get(uri) ?? []);
|
|
366
|
+
}, this.#timeoutMs),
|
|
367
|
+
};
|
|
368
|
+
this.#diagnosticWaiters.set(uri, [...(this.#diagnosticWaiters.get(uri) ?? []), waiter]);
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
#respondToServerRequest(message: JsonRpcMessage) {
|
|
373
|
+
if (message.method === "workspace/configuration") {
|
|
374
|
+
const params = message.params as { items?: unknown[] } | undefined;
|
|
375
|
+
this.#send({
|
|
376
|
+
jsonrpc: "2.0",
|
|
377
|
+
id: message.id,
|
|
378
|
+
result: (params?.items ?? []).map(() => ({})),
|
|
379
|
+
});
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (message.method === "workspace/workspaceFolders") {
|
|
384
|
+
const rootUri = directoryUri(this.#cwd);
|
|
385
|
+
this.#send({
|
|
386
|
+
jsonrpc: "2.0",
|
|
387
|
+
id: message.id,
|
|
388
|
+
result: this.#adapter.serverRequestWorkspaceFolders
|
|
389
|
+
? [{ uri: rootUri, name: path.basename(this.#cwd) || "workspace" }]
|
|
390
|
+
: null,
|
|
391
|
+
});
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (
|
|
396
|
+
message.method === "client/registerCapability" ||
|
|
397
|
+
message.method === "client/unregisterCapability"
|
|
398
|
+
) {
|
|
399
|
+
this.#send({ jsonrpc: "2.0", id: message.id, result: null });
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
this.#send({
|
|
404
|
+
jsonrpc: "2.0",
|
|
405
|
+
id: message.id,
|
|
406
|
+
error: { code: -32601, message: `Method not found: ${message.method ?? "unknown"}` },
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
#formatStderr() {
|
|
411
|
+
const stderr = this.#stderr.trim();
|
|
412
|
+
return stderr ? `\nServer stderr:\n${stderr}` : "";
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function isUnsupportedMethodError(error: unknown) {
|
|
417
|
+
return error instanceof Error && /method not found|not supported|unsupported/i.test(error.message);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function formatErrorMessage(error: unknown) {
|
|
421
|
+
return error instanceof Error ? error.message : String(error);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function wait(ms: number) {
|
|
425
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
426
|
+
}
|
package/src/pi-lsp.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { adapters, biomeAdapter, ruffAdapter, tyAdapter } from "./adapters.js";
|
|
4
|
+
import { commandExists, commandFromEnv } from "./command.js";
|
|
5
|
+
import { runDiagnostics, runFix, runFormat } from "./runner.js";
|
|
6
|
+
|
|
7
|
+
const STATUS_KEY = "lsp";
|
|
8
|
+
|
|
9
|
+
const BiomePathsParameters = {
|
|
10
|
+
paths: Type.Optional(
|
|
11
|
+
Type.Array(Type.String(), {
|
|
12
|
+
description: "Biome-supported files or directories to check. Defaults to the project root.",
|
|
13
|
+
}),
|
|
14
|
+
),
|
|
15
|
+
root: Type.Optional(
|
|
16
|
+
Type.String({ description: "Workspace root for the Biome language server. Defaults to cwd." }),
|
|
17
|
+
),
|
|
18
|
+
limit: Type.Optional(
|
|
19
|
+
Type.Number({ description: "Maximum files to open when directories are provided." }),
|
|
20
|
+
),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const PythonPathsParameters = {
|
|
24
|
+
paths: Type.Optional(
|
|
25
|
+
Type.Array(Type.String(), {
|
|
26
|
+
description: "Python files or directories to check. Defaults to the project root.",
|
|
27
|
+
}),
|
|
28
|
+
),
|
|
29
|
+
root: Type.Optional(
|
|
30
|
+
Type.String({ description: "Workspace root for the language server. Defaults to cwd." }),
|
|
31
|
+
),
|
|
32
|
+
limit: Type.Optional(
|
|
33
|
+
Type.Number({ description: "Maximum Python files to open when directories are provided." }),
|
|
34
|
+
),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const biomeDiagnosticsTool = defineTool({
|
|
38
|
+
name: "biome_lsp_diagnostics",
|
|
39
|
+
label: "Biome LSP: Diagnostics",
|
|
40
|
+
description: "Run Biome's language server and return diagnostics for supported files.",
|
|
41
|
+
promptSnippet: "Get Biome diagnostics through the Biome language server",
|
|
42
|
+
promptGuidelines: [
|
|
43
|
+
"Use biome_lsp_diagnostics when JavaScript, TypeScript, JSON, CSS, GraphQL, or framework files need Biome lint/format diagnostics.",
|
|
44
|
+
"If Biome is missing, report the configuration error and suggest installing @biomejs/biome or setting PI_BIOME_LSP_COMMAND.",
|
|
45
|
+
],
|
|
46
|
+
parameters: Type.Object(BiomePathsParameters),
|
|
47
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
48
|
+
return runDiagnostics(biomeAdapter, params, signal, ctx, STATUS_KEY);
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const biomeFormatTool = defineTool({
|
|
53
|
+
name: "biome_lsp_format",
|
|
54
|
+
label: "Biome LSP: Format",
|
|
55
|
+
description: "Format a Biome-supported file through Biome's language server.",
|
|
56
|
+
promptSnippet: "Format a file through Biome LSP",
|
|
57
|
+
parameters: Type.Object({
|
|
58
|
+
path: Type.String({ description: "File to format." }),
|
|
59
|
+
root: Type.Optional(
|
|
60
|
+
Type.String({ description: "Workspace root for the Biome language server. Defaults to cwd." }),
|
|
61
|
+
),
|
|
62
|
+
write: Type.Optional(
|
|
63
|
+
Type.Boolean({ description: "Write formatted text back to the file. Defaults to false." }),
|
|
64
|
+
),
|
|
65
|
+
}),
|
|
66
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
67
|
+
return runFormat(biomeAdapter, params, signal, ctx, STATUS_KEY);
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const biomeFixTool = defineTool({
|
|
72
|
+
name: "biome_lsp_fix",
|
|
73
|
+
label: "Biome LSP: Fix",
|
|
74
|
+
description: "Apply Biome LSP source fixes or import organization to a file.",
|
|
75
|
+
promptSnippet: "Apply Biome LSP fixes to a file",
|
|
76
|
+
parameters: Type.Object({
|
|
77
|
+
path: Type.String({ description: "File to fix." }),
|
|
78
|
+
root: Type.Optional(
|
|
79
|
+
Type.String({ description: "Workspace root for the Biome language server. Defaults to cwd." }),
|
|
80
|
+
),
|
|
81
|
+
kind: Type.Optional(
|
|
82
|
+
Type.String({
|
|
83
|
+
description:
|
|
84
|
+
"Biome source action kind. Defaults to source.fixAll.biome. Common value: source.organizeImports.biome.",
|
|
85
|
+
}),
|
|
86
|
+
),
|
|
87
|
+
write: Type.Optional(
|
|
88
|
+
Type.Boolean({ description: "Write fixed text back to the file. Defaults to false." }),
|
|
89
|
+
),
|
|
90
|
+
}),
|
|
91
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
92
|
+
return runFix(biomeAdapter, params, signal, ctx, STATUS_KEY);
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const tyDiagnosticsTool = defineTool({
|
|
97
|
+
name: "ty_lsp_diagnostics",
|
|
98
|
+
label: "Python LSP: ty Diagnostics",
|
|
99
|
+
description: "Run ty's language server and return Python type diagnostics for files.",
|
|
100
|
+
promptSnippet: "Get Python type diagnostics from ty's language server",
|
|
101
|
+
promptGuidelines: [
|
|
102
|
+
"Use ty_lsp_diagnostics when Python changes need type-checking through ty's language server.",
|
|
103
|
+
"If ty is missing, report the configuration error and suggest installing ty or setting PI_TY_LSP_COMMAND.",
|
|
104
|
+
],
|
|
105
|
+
parameters: Type.Object(PythonPathsParameters),
|
|
106
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
107
|
+
return runDiagnostics(tyAdapter, params, signal, ctx, STATUS_KEY);
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const ruffDiagnosticsTool = defineTool({
|
|
112
|
+
name: "ruff_lsp_diagnostics",
|
|
113
|
+
label: "Python LSP: Ruff Diagnostics",
|
|
114
|
+
description: "Run Ruff's language server and return Python lint diagnostics for files.",
|
|
115
|
+
promptSnippet: "Get Python lint diagnostics from Ruff's language server",
|
|
116
|
+
promptGuidelines: [
|
|
117
|
+
"Use ruff_lsp_diagnostics when Python changes need Ruff lint checks through the language server.",
|
|
118
|
+
"If ruff is missing, report the configuration error and suggest installing ruff or setting PI_RUFF_LSP_COMMAND.",
|
|
119
|
+
],
|
|
120
|
+
parameters: Type.Object(PythonPathsParameters),
|
|
121
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
122
|
+
return runDiagnostics(ruffAdapter, params, signal, ctx, STATUS_KEY);
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const ruffFormatTool = defineTool({
|
|
127
|
+
name: "ruff_lsp_format",
|
|
128
|
+
label: "Python LSP: Ruff Format",
|
|
129
|
+
description: "Format a Python file through Ruff's language server.",
|
|
130
|
+
promptSnippet: "Format a Python file through Ruff LSP",
|
|
131
|
+
parameters: Type.Object({
|
|
132
|
+
path: Type.String({ description: "Python file to format." }),
|
|
133
|
+
root: Type.Optional(
|
|
134
|
+
Type.String({ description: "Workspace root for the language server. Defaults to cwd." }),
|
|
135
|
+
),
|
|
136
|
+
write: Type.Optional(
|
|
137
|
+
Type.Boolean({ description: "Write formatted text back to the file. Defaults to false." }),
|
|
138
|
+
),
|
|
139
|
+
}),
|
|
140
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
141
|
+
return runFormat(ruffAdapter, params, signal, ctx, STATUS_KEY);
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const ruffFixTool = defineTool({
|
|
146
|
+
name: "ruff_lsp_fix",
|
|
147
|
+
label: "Python LSP: Ruff Fix",
|
|
148
|
+
description: "Apply Ruff LSP source fixes or import organization to a Python file.",
|
|
149
|
+
promptSnippet: "Apply Ruff LSP fixes to a Python file",
|
|
150
|
+
parameters: Type.Object({
|
|
151
|
+
path: Type.String({ description: "Python file to fix." }),
|
|
152
|
+
root: Type.Optional(
|
|
153
|
+
Type.String({ description: "Workspace root for the language server. Defaults to cwd." }),
|
|
154
|
+
),
|
|
155
|
+
kind: Type.Optional(
|
|
156
|
+
Type.String({
|
|
157
|
+
description:
|
|
158
|
+
"Ruff source action kind. Defaults to source.fixAll.ruff. Common value: source.organizeImports.ruff.",
|
|
159
|
+
}),
|
|
160
|
+
),
|
|
161
|
+
write: Type.Optional(
|
|
162
|
+
Type.Boolean({ description: "Write fixed text back to the file. Defaults to false." }),
|
|
163
|
+
),
|
|
164
|
+
}),
|
|
165
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
166
|
+
return runFix(ruffAdapter, params, signal, ctx, STATUS_KEY);
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
export default function lsp(pi: ExtensionAPI) {
|
|
171
|
+
pi.registerTool(biomeDiagnosticsTool);
|
|
172
|
+
pi.registerTool(biomeFormatTool);
|
|
173
|
+
pi.registerTool(biomeFixTool);
|
|
174
|
+
pi.registerTool(tyDiagnosticsTool);
|
|
175
|
+
pi.registerTool(ruffDiagnosticsTool);
|
|
176
|
+
pi.registerTool(ruffFormatTool);
|
|
177
|
+
pi.registerTool(ruffFixTool);
|
|
178
|
+
|
|
179
|
+
pi.registerCommand("lsp", {
|
|
180
|
+
description: "Show shared LSP extension configuration",
|
|
181
|
+
handler: async (_args, ctx) => {
|
|
182
|
+
ctx.ui.notify(buildStatusMessage(), statusLevel());
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
pi.on("session_start", (_event, ctx) => {
|
|
187
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
191
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function buildStatusMessage() {
|
|
196
|
+
return adapters
|
|
197
|
+
.flatMap((adapter) => {
|
|
198
|
+
const command = commandFromEnv(adapter.commandEnvVar, adapter.defaultCommand);
|
|
199
|
+
return [
|
|
200
|
+
`${adapter.label} LSP command: ${command.command} ${command.args.join(" ")}`.trim(),
|
|
201
|
+
`${adapter.label} status: ${commandExists(command.command) ? "ready" : "command missing"}`,
|
|
202
|
+
];
|
|
203
|
+
})
|
|
204
|
+
.join("\n");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function statusLevel() {
|
|
208
|
+
return adapters.every((adapter) => {
|
|
209
|
+
const command = commandFromEnv(adapter.commandEnvVar, adapter.defaultCommand);
|
|
210
|
+
return commandExists(command.command);
|
|
211
|
+
})
|
|
212
|
+
? "info"
|
|
213
|
+
: "warning";
|
|
214
|
+
}
|