@narumitw/pi-lsp 0.1.26 → 0.1.27

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/src/lsp-client.ts CHANGED
@@ -9,7 +9,6 @@ import type {
9
9
  JsonRpcMessage,
10
10
  LspDiagnostic,
11
11
  LspServerAdapter,
12
- LspTextEdit,
13
12
  ServerCommand,
14
13
  } from "./types.js";
15
14
 
@@ -50,12 +49,13 @@ export class LspClient {
50
49
  async start() {
51
50
  if (!commandExists(this.#command.command, this.#cwd)) {
52
51
  throw new Error(
53
- `${this.#adapter.label} LSP command not found: ${this.#command.command}. ${this.#adapter.missingCommandHint}`,
52
+ `${this.#adapter.name} LSP command not found: ${this.#command.command}. ${this.#adapter.missingCommandHint}`,
54
53
  );
55
54
  }
56
55
 
57
56
  const child = spawn(this.#command.command, this.#command.args, {
58
57
  cwd: this.#cwd,
58
+ env: { ...process.env, ...this.#adapter.env },
59
59
  stdio: "pipe",
60
60
  });
61
61
  this.#child = child;
@@ -64,7 +64,7 @@ export class LspClient {
64
64
  this.#onData(chunk);
65
65
  } catch (error) {
66
66
  this.#fail(
67
- `${this.#adapter.label} LSP server sent invalid JSON-RPC data: ${formatErrorMessage(error)}.${this.#formatStderr()}`,
67
+ `${this.#adapter.name} LSP server sent invalid JSON-RPC data: ${formatErrorMessage(error)}.${this.#formatStderr()}`,
68
68
  );
69
69
  }
70
70
  });
@@ -73,7 +73,7 @@ export class LspClient {
73
73
  });
74
74
  child.stdin.on("error", (error) => {
75
75
  this.#fail(
76
- `${this.#adapter.label} LSP stdin write failed: ${formatErrorMessage(error)}.${this.#formatStderr()}`,
76
+ `${this.#adapter.name} LSP stdin write failed: ${formatErrorMessage(error)}.${this.#formatStderr()}`,
77
77
  );
78
78
  });
79
79
  child.once("exit", (code, signal) => {
@@ -81,14 +81,14 @@ export class LspClient {
81
81
  const reason = signal ? `signal ${signal}` : `code ${code ?? "unknown"}`;
82
82
  this.#rejectPending(
83
83
  (id) =>
84
- `${this.#adapter.label} LSP server exited before response ${id} (${reason}).${this.#formatStderr()}`,
84
+ `${this.#adapter.name} LSP server exited before response ${id} (${reason}).${this.#formatStderr()}`,
85
85
  );
86
86
  });
87
87
 
88
88
  await new Promise<void>((resolve, reject) => {
89
89
  child.once("spawn", resolve);
90
90
  child.once("error", (error) => {
91
- const message = `${this.#adapter.label} LSP process failed to start: ${error.message}.${this.#formatStderr()}`;
91
+ const message = `${this.#adapter.name} LSP process failed to start: ${error.message}.${this.#formatStderr()}`;
92
92
  this.#rejectPending(message);
93
93
  if (this.#child === child) this.#child = undefined;
94
94
  reject(new Error(message));
@@ -99,49 +99,32 @@ export class LspClient {
99
99
  async initialize(root: string) {
100
100
  const rootUri = directoryUri(root);
101
101
  const workspaceFolders = [{ uri: rootUri, name: path.basename(root) || "workspace" }];
102
- const init = this.#adapter.initialize;
103
102
  await this.request("initialize", {
104
103
  processId: process.pid,
105
104
  rootUri,
106
- workspaceFolders: this.#adapter.serverRequestWorkspaceFolders ? workspaceFolders : null,
105
+ workspaceFolders,
106
+ initializationOptions: this.#adapter.initialization ?? {},
107
107
  capabilities: {
108
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 }),
109
+ codeAction: {
110
+ dynamicRegistration: true,
111
+ resolveSupport: { properties: ["edit"] },
127
112
  },
113
+ diagnostic: { dynamicRegistration: true, relatedDocumentSupport: true },
114
+ publishDiagnostics: {},
115
+ synchronization: { didSave: true },
128
116
  },
129
117
  workspace: {
130
118
  configuration: true,
131
- ...(init.didChangeConfigurationDynamicRegistration === undefined
132
- ? {}
133
- : {
134
- didChangeConfiguration: {
135
- dynamicRegistration: init.didChangeConfigurationDynamicRegistration,
136
- },
137
- }),
138
119
  workspaceEdit: { documentChanges: true },
139
- workspaceFolders: this.#adapter.serverRequestWorkspaceFolders,
120
+ workspaceFolders: true,
140
121
  },
141
122
  },
142
123
  });
143
124
  this.notify("initialized", {});
144
- if (this.#adapter.fallbackToPublishDiagnostics) await wait(300);
125
+ if (this.#adapter.initialization) {
126
+ this.notify("workspace/didChangeConfiguration", { settings: this.#adapter.initialization });
127
+ }
145
128
  }
146
129
 
147
130
  didOpen(uri: string, text: string, languageId: string) {
@@ -168,19 +151,11 @@ export class LspClient {
168
151
  const result = response.result as { items?: LspDiagnostic[] } | undefined;
169
152
  return result?.items ?? [];
170
153
  } catch (error) {
171
- if (!this.#adapter.fallbackToPublishDiagnostics || !isUnsupportedMethodError(error)) throw error;
154
+ if (!isUnsupportedMethodError(error)) throw error;
172
155
  return this.#waitForPublishedDiagnostics(uri);
173
156
  }
174
157
  }
175
158
 
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
159
  async codeActions(uri: string, text: string, diagnostics: LspDiagnostic[], kind: string) {
185
160
  const response = await this.request("textDocument/codeAction", {
186
161
  textDocument: { uri },
@@ -202,7 +177,7 @@ export class LspClient {
202
177
  const response = await this.request("codeAction/resolve", action);
203
178
  resolvedActions.push((response.result as CodeAction | undefined) ?? action);
204
179
  } catch (error) {
205
- if (!this.#adapter.resolveUnsupportedCodeActions || !isUnsupportedMethodError(error)) throw error;
180
+ if (!isUnsupportedMethodError(error)) throw error;
206
181
  resolvedActions.push(action);
207
182
  }
208
183
  }
@@ -224,7 +199,7 @@ export class LspClient {
224
199
  }
225
200
 
226
201
  close() {
227
- this.#rejectPending(`${this.#adapter.label} LSP request cancelled.`);
202
+ this.#rejectPending(`${this.#adapter.name} LSP request cancelled.`);
228
203
 
229
204
  if (this.#child && !this.#child.killed) this.#child.kill("SIGTERM");
230
205
  this.#child = undefined;
@@ -258,7 +233,7 @@ export class LspClient {
258
233
  const timeout = setTimeout(() => {
259
234
  this.#pending.delete(id);
260
235
  reject(
261
- new Error(`${this.#adapter.label} LSP request timed out: ${method}.${this.#formatStderr()}`),
236
+ new Error(`${this.#adapter.name} LSP request timed out: ${method}.${this.#formatStderr()}`),
262
237
  );
263
238
  }, this.#timeoutMs);
264
239
  this.#pending.set(id, { resolve, reject, timeout });
@@ -280,14 +255,14 @@ export class LspClient {
280
255
  }
281
256
 
282
257
  #send(message: JsonRpcMessage) {
283
- if (!this.#child) throw new Error(`${this.#adapter.label} LSP server is not running.`);
258
+ if (!this.#child) throw new Error(`${this.#adapter.name} LSP server is not running.`);
284
259
 
285
260
  const body = JSON.stringify(message);
286
261
  try {
287
262
  this.#child.stdin.write(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);
288
263
  } catch (error) {
289
264
  const errorMessage =
290
- `${this.#adapter.label} LSP stdin write failed: ${formatErrorMessage(error)}.` +
265
+ `${this.#adapter.name} LSP stdin write failed: ${formatErrorMessage(error)}.` +
291
266
  this.#formatStderr();
292
267
  this.#fail(errorMessage);
293
268
  throw new Error(errorMessage);
@@ -323,7 +298,7 @@ export class LspClient {
323
298
  clearTimeout(pending.timeout);
324
299
  this.#pending.delete(message.id as number);
325
300
  if (message.error) {
326
- pending.reject(new Error(`${this.#adapter.label} LSP error: ${message.error.message}`));
301
+ pending.reject(new Error(`${this.#adapter.name} LSP error: ${message.error.message}`));
327
302
  } else {
328
303
  pending.resolve(message);
329
304
  }
@@ -362,7 +337,11 @@ export class LspClient {
362
337
  const waiters = this.#diagnosticWaiters.get(uri)?.filter((entry) => entry !== waiter) ?? [];
363
338
  if (waiters.length) this.#diagnosticWaiters.set(uri, waiters);
364
339
  else this.#diagnosticWaiters.delete(uri);
365
- resolve(this.#publishedDiagnostics.get(uri) ?? []);
340
+ reject(
341
+ new Error(
342
+ `${this.#adapter.name} LSP did not return diagnostics for ${uri} before timeout.`,
343
+ ),
344
+ );
366
345
  }, this.#timeoutMs),
367
346
  };
368
347
  this.#diagnosticWaiters.set(uri, [...(this.#diagnosticWaiters.get(uri) ?? []), waiter]);
@@ -371,11 +350,11 @@ export class LspClient {
371
350
 
372
351
  #respondToServerRequest(message: JsonRpcMessage) {
373
352
  if (message.method === "workspace/configuration") {
374
- const params = message.params as { items?: unknown[] } | undefined;
353
+ const params = message.params as { items?: Array<{ section?: string }> } | undefined;
375
354
  this.#send({
376
355
  jsonrpc: "2.0",
377
356
  id: message.id,
378
- result: (params?.items ?? []).map(() => ({})),
357
+ result: (params?.items ?? []).map((item) => this.#configurationValue(item.section)),
379
358
  });
380
359
  return;
381
360
  }
@@ -385,9 +364,7 @@ export class LspClient {
385
364
  this.#send({
386
365
  jsonrpc: "2.0",
387
366
  id: message.id,
388
- result: this.#adapter.serverRequestWorkspaceFolders
389
- ? [{ uri: rootUri, name: path.basename(this.#cwd) || "workspace" }]
390
- : null,
367
+ result: [{ uri: rootUri, name: path.basename(this.#cwd) || "workspace" }],
391
368
  });
392
369
  return;
393
370
  }
@@ -407,6 +384,11 @@ export class LspClient {
407
384
  });
408
385
  }
409
386
 
387
+ #configurationValue(section: string | undefined) {
388
+ if (!section) return this.#adapter.initialization ?? {};
389
+ return this.#adapter.initialization?.[section] ?? {};
390
+ }
391
+
410
392
  #formatStderr() {
411
393
  const stderr = this.#stderr.trim();
412
394
  return stderr ? `\nServer stderr:\n${stderr}` : "";
@@ -420,7 +402,3 @@ function isUnsupportedMethodError(error: unknown) {
420
402
  function formatErrorMessage(error: unknown) {
421
403
  return error instanceof Error ? error.message : String(error);
422
404
  }
423
-
424
- function wait(ms: number) {
425
- return new Promise((resolve) => setTimeout(resolve, ms));
426
- }
package/src/pi-lsp.ts CHANGED
@@ -1,185 +1,140 @@
1
1
  import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
- import { adapters, biomeAdapter, ruffAdapter, tyAdapter } from "./adapters.js";
3
+ import { loadRuntime } from "./adapters.js";
4
4
  import { commandExists, commandFromEnv } from "./command.js";
5
- import { runDiagnostics, runFix, runFormat } from "./runner.js";
5
+ import { resolveRoot } from "./files.js";
6
+ import { selectDiagnosticRoutes, selectFixRoute } from "./routes.js";
7
+ import { DEFAULT_FILE_LIMIT, runDiagnostics, runFix, textResult } from "./runner.js";
6
8
 
7
9
  const STATUS_KEY = "lsp";
8
10
 
9
- const BiomePathsParameters = {
11
+ const ServerParameter = Type.Optional(
12
+ Type.Union([Type.String(), Type.Array(Type.String())], {
13
+ description:
14
+ "Optional configured LSP server name, or names for diagnostics. Defaults to all servers matching the file extension.",
15
+ }),
16
+ );
17
+
18
+ const DiagnosticsParameters = Type.Object({
10
19
  paths: Type.Optional(
11
20
  Type.Array(Type.String(), {
12
- description: "Biome-supported files or directories to check. Defaults to the project root.",
21
+ description:
22
+ "Files or directories to check. Defaults to the workspace root and routes by configured server extensions.",
13
23
  }),
14
24
  ),
15
25
  root: Type.Optional(
16
- Type.String({ description: "Workspace root for the Biome language server. Defaults to cwd." }),
26
+ Type.String({ description: "Workspace root for language servers. Defaults to cwd." }),
17
27
  ),
18
- limit: Type.Optional(
19
- Type.Number({ description: "Maximum files to open when directories are provided." }),
20
- ),
21
- };
28
+ limit: Type.Optional(Type.Number({ description: "Maximum files to open per selected server." })),
29
+ server: ServerParameter,
30
+ });
22
31
 
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
- ),
32
+ const SingleFileParameters = {
33
+ path: Type.String({
34
+ description: "File to process. The server is selected from configured file extensions.",
35
+ }),
29
36
  root: Type.Optional(
30
- Type.String({ description: "Workspace root for the language server. Defaults to cwd." }),
37
+ Type.String({ description: "Workspace root for language servers. Defaults to cwd." }),
31
38
  ),
32
- limit: Type.Optional(
33
- Type.Number({ description: "Maximum Python files to open when directories are provided." }),
39
+ write: Type.Optional(
40
+ Type.Boolean({ description: "Write changed text back to the file. Defaults to false." }),
41
+ ),
42
+ server: Type.Optional(
43
+ Type.String({
44
+ description: "Optional configured LSP server name. Defaults to extension-based inference.",
45
+ }),
34
46
  ),
35
47
  };
36
48
 
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",
49
+ const lspDiagnosticsTool = defineTool({
50
+ name: "lsp_diagnostics",
51
+ label: "LSP: Diagnostics",
52
+ description: "Run diagnostics using configured, language-agnostic LSP server routes.",
53
+ promptSnippet: "Get diagnostics from configured LSP servers selected by file extension",
101
54
  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.",
55
+ "Use lsp_diagnostics when files need diagnostics from a configured LSP server.",
56
+ "Use the server parameter only when the user asks for a specific configured LSP server or multiple servers match the same extension.",
57
+ "If a configured server command is missing, report the configuration error and suggest installing the command or setting its PI_<SERVER>_LSP_COMMAND environment variable.",
104
58
  ],
105
- parameters: Type.Object(PythonPathsParameters),
59
+ parameters: DiagnosticsParameters,
106
60
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
107
- return runDiagnostics(tyAdapter, params, signal, ctx, STATUS_KEY);
61
+ const requestedRoot = resolveRoot(params.root);
62
+ const { adapters, timeoutMs } = loadRuntime(requestedRoot);
63
+ const { root, routes } = selectDiagnosticRoutes(
64
+ adapters,
65
+ { ...params, root: requestedRoot },
66
+ DEFAULT_FILE_LIMIT,
67
+ );
68
+ const results = [];
69
+ for (const route of routes) {
70
+ const result = await runDiagnostics(
71
+ route.adapter,
72
+ { root, paths: params.paths, limit: params.limit, files: route.files },
73
+ timeoutMs,
74
+ signal,
75
+ ctx,
76
+ STATUS_KEY,
77
+ );
78
+ results.push({ route, result });
79
+ }
80
+
81
+ const text = results
82
+ .map(({ route, result }) => `${route.reason}\n\n${textFromResult(result)}`)
83
+ .join("\n\n---\n\n");
84
+ return textResult(text, {
85
+ root,
86
+ routes: results.map(({ route, result }) => ({
87
+ server: route.adapter.name,
88
+ backend: route.adapter.name,
89
+ reason: route.reason,
90
+ files: route.files,
91
+ details: result.details,
92
+ })),
93
+ });
108
94
  },
109
95
  });
110
96
 
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",
97
+ const lspFixTool = defineTool({
98
+ name: "lsp_fix",
99
+ label: "LSP: Fix",
100
+ description: "Apply source fixes or import organization using configured LSP server routes.",
101
+ promptSnippet: "Apply configured LSP source fixes to a file",
116
102
  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.",
103
+ "Use lsp_fix for files handled by a configured LSP code-action server.",
104
+ "Use kind when the server needs a specific source action kind such as source.organizeImports.",
119
105
  ],
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
106
  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
- ),
107
+ ...SingleFileParameters,
155
108
  kind: Type.Optional(
156
109
  Type.String({
157
- description:
158
- "Ruff source action kind. Defaults to source.fixAll.ruff. Common value: source.organizeImports.ruff.",
110
+ description: "Source action kind. Defaults to source.fixAll.",
159
111
  }),
160
112
  ),
161
- write: Type.Optional(
162
- Type.Boolean({ description: "Write fixed text back to the file. Defaults to false." }),
163
- ),
164
113
  }),
165
114
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
166
- return runFix(ruffAdapter, params, signal, ctx, STATUS_KEY);
115
+ const requestedRoot = resolveRoot(params.root);
116
+ const { adapters, timeoutMs } = loadRuntime(requestedRoot);
117
+ const { root, route } = selectFixRoute(adapters, { ...params, root: requestedRoot });
118
+ return runFix(
119
+ route.adapter,
120
+ { root, path: params.path, kind: params.kind, write: params.write },
121
+ timeoutMs,
122
+ signal,
123
+ ctx,
124
+ STATUS_KEY,
125
+ );
167
126
  },
168
127
  });
169
128
 
170
129
  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);
130
+ pi.registerTool(lspDiagnosticsTool);
131
+ pi.registerTool(lspFixTool);
178
132
 
179
133
  pi.registerCommand("lsp", {
180
134
  description: "Show shared LSP extension configuration",
181
135
  handler: async (_args, ctx) => {
182
- ctx.ui.notify(buildStatusMessage(), statusLevel());
136
+ const { adapters } = loadRuntime(ctx.cwd);
137
+ ctx.ui.notify(buildStatusMessage(adapters, ctx.cwd), statusLevel(adapters, ctx.cwd));
183
138
  },
184
139
  });
185
140
 
@@ -192,22 +147,26 @@ export default function lsp(pi: ExtensionAPI) {
192
147
  });
193
148
  }
194
149
 
195
- function buildStatusMessage() {
150
+ function textFromResult(result: { content?: Array<{ type?: string; text?: string }> }) {
151
+ return result.content?.find((item) => item.type === "text")?.text ?? "";
152
+ }
153
+
154
+ function buildStatusMessage(adapters: ReturnType<typeof loadRuntime>["adapters"], cwd: string) {
196
155
  return adapters
197
156
  .flatMap((adapter) => {
198
157
  const command = commandFromEnv(adapter.commandEnvVar, adapter.defaultCommand);
199
158
  return [
200
- `${adapter.label} LSP command: ${command.command} ${command.args.join(" ")}`.trim(),
201
- `${adapter.label} status: ${commandExists(command.command) ? "ready" : "command missing"}`,
159
+ `${adapter.name} LSP command: ${command.command} ${command.args.join(" ")}`.trim(),
160
+ `${adapter.name} status: ${commandExists(command.command, cwd) ? "ready" : "command missing"}`,
202
161
  ];
203
162
  })
204
163
  .join("\n");
205
164
  }
206
165
 
207
- function statusLevel() {
166
+ function statusLevel(adapters: ReturnType<typeof loadRuntime>["adapters"], cwd: string) {
208
167
  return adapters.every((adapter) => {
209
168
  const command = commandFromEnv(adapter.commandEnvVar, adapter.defaultCommand);
210
- return commandExists(command.command);
169
+ return commandExists(command.command, cwd);
211
170
  })
212
171
  ? "info"
213
172
  : "warning";
package/src/routes.ts ADDED
@@ -0,0 +1,98 @@
1
+ import path from "node:path";
2
+ import { collectSupportedFiles, resolveRoot } from "./files.js";
3
+ import type { LspServerAdapter } from "./types.js";
4
+
5
+ export type LspAction = "diagnostics" | "fix";
6
+
7
+ export interface DiagnosticRoute {
8
+ adapter: LspServerAdapter;
9
+ reason: string;
10
+ files: string[];
11
+ }
12
+
13
+ export interface SingleFileRoute {
14
+ adapter: LspServerAdapter;
15
+ reason: string;
16
+ }
17
+
18
+ export interface DiagnosticRouteParams {
19
+ root?: string;
20
+ paths?: string[];
21
+ limit?: number;
22
+ server?: string | string[];
23
+ }
24
+
25
+ export interface SingleFileRouteParams {
26
+ root?: string;
27
+ path: string;
28
+ server?: string;
29
+ }
30
+
31
+ export const SUPPORTED_SERVER_DESCRIPTION =
32
+ "Supported LSP servers are defined by pi-lsp config and selected by file extension.";
33
+
34
+ export function selectDiagnosticRoutes(
35
+ adapters: LspServerAdapter[],
36
+ params: DiagnosticRouteParams,
37
+ defaultLimit: number,
38
+ ) {
39
+ const root = resolveRoot(params.root);
40
+ const candidates = filterAdapters(adapters, params.server);
41
+ const filesByExtensions = new Map<string, string[]>();
42
+ const routes = candidates
43
+ .map((adapter) => {
44
+ const key = adapter.extensions.join("\0");
45
+ let files = filesByExtensions.get(key);
46
+ if (!files) {
47
+ files = collectSupportedFiles(adapter, root, params.paths, params.limit ?? defaultLimit);
48
+ filesByExtensions.set(key, files);
49
+ }
50
+ return { adapter, reason: `${adapter.name} diagnostics`, files };
51
+ })
52
+ .filter((route) => route.files.length > 0);
53
+
54
+ if (routes.length === 0) {
55
+ const scope = params.paths?.length ? ` in requested paths: ${params.paths.join(", ")}` : "";
56
+ throw new Error(`No supported files found${scope}. ${SUPPORTED_SERVER_DESCRIPTION}`);
57
+ }
58
+
59
+ return { root, routes };
60
+ }
61
+
62
+ export function selectFixRoute(adapters: LspServerAdapter[], params: SingleFileRouteParams) {
63
+ const root = resolveRoot(params.root);
64
+ const file = path.resolve(root, params.path);
65
+ const candidates = filterAdapters(adapters, params.server).filter((adapter) => adapter.isSupportedFile(file));
66
+ if (candidates.length === 0) throw unsupportedFileError("fix", params.path, params.server);
67
+ if (!params.server && candidates.length > 1) {
68
+ throw new Error(
69
+ `Multiple LSP servers support ${params.path}: ${candidates.map((adapter) => adapter.name).join(", ")}. ` +
70
+ "Specify the server parameter for lsp_fix.",
71
+ );
72
+ }
73
+ const adapter = candidates[0];
74
+ return {
75
+ root,
76
+ route: {
77
+ adapter,
78
+ reason: `${adapter.name} fix`,
79
+ },
80
+ };
81
+ }
82
+
83
+ function filterAdapters(adapters: LspServerAdapter[], selected: string | string[] | undefined) {
84
+ if (!selected) return adapters;
85
+ const names = [...new Set((Array.isArray(selected) ? selected : [selected]).map((name) => name.trim()))].filter(
86
+ (name) => name.length > 0,
87
+ );
88
+ if (names.length === 0) throw new Error("LSP server parameter must not be blank.");
89
+ const matched = adapters.filter((adapter) => names.includes(adapter.name));
90
+ const missing = names.filter((name) => !adapters.some((adapter) => adapter.name === name));
91
+ if (missing.length) throw new Error(`Unknown LSP server(s): ${missing.join(", ")}.`);
92
+ return matched;
93
+ }
94
+
95
+ function unsupportedFileError(action: LspAction, filePath: string, server: string | undefined) {
96
+ const override = server ? ` for server '${server}'` : "";
97
+ return new Error(`No ${action} route supports ${filePath}${override}. ${SUPPORTED_SERVER_DESCRIPTION}`);
98
+ }