@narumitw/pi-biome-lsp 0.1.9

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 narumiruna
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # 🧬 pi-biome-lsp — Biome Language Server Tools for Pi
2
+
3
+ [![npm](https://img.shields.io/npm/v/@narumitw/pi-biome-lsp)](https://www.npmjs.com/package/@narumitw/pi-biome-lsp) [![Pi extension](https://img.shields.io/badge/Pi-extension-blue)](https://pi.dev) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
4
+
5
+ `@narumitw/pi-biome-lsp` is a native [Pi coding agent](https://pi.dev) extension that exposes [Biome](https://biomejs.dev/) language-server tools.
6
+
7
+ Use it to give Pi Biome diagnostics, formatting, import organization, and safe source fixes through Language Server Protocol (LSP) workflows.
8
+
9
+ ## ✨ Features
10
+
11
+ - Runs `biome lsp-proxy` on demand for diagnostics.
12
+ - Computes or writes formatting edits for Biome-supported files.
13
+ - Computes or writes Biome source actions such as `source.fixAll.biome` and `source.organizeImports.biome`.
14
+ - Supports workspace roots, file limits, and recursive file discovery.
15
+ - Starts the language server only for tool calls, then shuts it down.
16
+ - Provides clear setup errors when Biome is missing.
17
+
18
+ ## 📦 Install
19
+
20
+ ```bash
21
+ pi install npm:@narumitw/pi-biome-lsp
22
+ ```
23
+
24
+ Try without installing permanently:
25
+
26
+ ```bash
27
+ pi -e npm:@narumitw/pi-biome-lsp
28
+ ```
29
+
30
+ Try this package locally from the repository root:
31
+
32
+ ```bash
33
+ pi -e ./extensions/pi-biome-lsp
34
+ ```
35
+
36
+ ## ✅ Requirements
37
+
38
+ Install Biome somewhere on `PATH`, for example:
39
+
40
+ ```bash
41
+ npm install -D @biomejs/biome
42
+ ```
43
+
44
+ Or provide a custom server command:
45
+
46
+ ```bash
47
+ PI_BIOME_LSP_COMMAND="npx biome lsp-proxy" pi -e ./extensions/pi-biome-lsp
48
+ ```
49
+
50
+ Optional timeout override:
51
+
52
+ ```bash
53
+ PI_BIOME_LSP_TIMEOUT_MS=30000 pi -e ./extensions/pi-biome-lsp
54
+ ```
55
+
56
+ ## 🛠️ Pi tools
57
+
58
+ - `biome_lsp_diagnostics` — start `biome lsp-proxy`, open supported files, and return diagnostics.
59
+ - `biome_lsp_format` — compute or write formatting edits for one file.
60
+ - `biome_lsp_fix` — compute or write source actions such as `source.fixAll.biome` or `source.organizeImports.biome`.
61
+
62
+ ## 🚀 Examples
63
+
64
+ Check a project subset with Biome diagnostics:
65
+
66
+ ```json
67
+ {
68
+ "paths": ["src", "extensions/pi-biome-lsp/src"],
69
+ "limit": 100
70
+ }
71
+ ```
72
+
73
+ Format a TypeScript file with Biome:
74
+
75
+ ```json
76
+ {
77
+ "path": "src/index.ts",
78
+ "write": true
79
+ }
80
+ ```
81
+
82
+ Organize imports with Biome:
83
+
84
+ ```json
85
+ {
86
+ "path": "src/index.ts",
87
+ "kind": "source.organizeImports.biome",
88
+ "write": true
89
+ }
90
+ ```
91
+
92
+ If `paths` is omitted for diagnostics, the tool recursively discovers Biome-supported files under the workspace root, skipping common generated and dependency directories.
93
+
94
+ ## 💬 Command
95
+
96
+ ```text
97
+ /biome-lsp
98
+ ```
99
+
100
+ Shows the configured Biome LSP command and whether it is available on `PATH`.
101
+
102
+ ## 🗂️ Package layout
103
+
104
+ ```txt
105
+ extensions/pi-biome-lsp/
106
+ ├── src/
107
+ │ └── biome-lsp.ts
108
+ ├── README.md
109
+ ├── LICENSE
110
+ ├── tsconfig.json
111
+ └── package.json
112
+ ```
113
+
114
+ ## 🔎 Keywords
115
+
116
+ Pi extension, Pi coding agent, Biome LSP, Biome formatter, Biome linter, import organization, Language Server Protocol, AI coding tools.
117
+
118
+ ## 📄 License
119
+
120
+ MIT. See [`LICENSE`](./LICENSE).
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@narumitw/pi-biome-lsp",
3
+ "version": "0.1.9",
4
+ "description": "Pi extension that exposes Biome language-server tools for diagnostics, formatting, and fixes.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "private": false,
8
+ "keywords": [
9
+ "pi-package",
10
+ "pi-extension",
11
+ "pi",
12
+ "biome",
13
+ "lsp",
14
+ "lint",
15
+ "format"
16
+ ],
17
+ "files": [
18
+ "src",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "pi": {
23
+ "extensions": [
24
+ "./src/biome-lsp.ts"
25
+ ]
26
+ },
27
+ "scripts": {
28
+ "check": "biome check . && npm run typecheck",
29
+ "format": "biome check --write .",
30
+ "typecheck": "tsc --noEmit"
31
+ },
32
+ "dependencies": {
33
+ "typebox": "^1.1.37"
34
+ },
35
+ "devDependencies": {
36
+ "@biomejs/biome": "2.4.14",
37
+ "@mariozechner/pi-coding-agent": "0.73.0",
38
+ "@types/node": "25.6.0",
39
+ "typescript": "6.0.3"
40
+ },
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "https://github.com/narumiruna/pi-extensions",
44
+ "directory": "extensions/pi-biome-lsp"
45
+ }
46
+ }
@@ -0,0 +1,850 @@
1
+ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
2
+ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import process from "node:process";
5
+ import { pathToFileURL } from "node:url";
6
+ import { defineTool, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
7
+ import { Type } from "typebox";
8
+
9
+ const STATUS_KEY = "biome-lsp";
10
+ const DEFAULT_TIMEOUT_MS = 20_000;
11
+ const DEFAULT_FILE_LIMIT = 50;
12
+ const SKIP_DIRECTORIES = new Set([
13
+ ".git",
14
+ ".hg",
15
+ ".next",
16
+ ".nuxt",
17
+ ".output",
18
+ ".svelte-kit",
19
+ "coverage",
20
+ "dist",
21
+ "node_modules",
22
+ "out",
23
+ ]);
24
+ const SUPPORTED_EXTENSIONS = new Set([
25
+ ".astro",
26
+ ".css",
27
+ ".cts",
28
+ ".cjs",
29
+ ".graphql",
30
+ ".gql",
31
+ ".html",
32
+ ".js",
33
+ ".json",
34
+ ".jsonc",
35
+ ".jsx",
36
+ ".mjs",
37
+ ".mts",
38
+ ".svelte",
39
+ ".ts",
40
+ ".tsx",
41
+ ".vue",
42
+ ]);
43
+
44
+ interface ServerCommand {
45
+ command: string;
46
+ args: string[];
47
+ }
48
+
49
+ interface LspPosition {
50
+ line: number;
51
+ character: number;
52
+ }
53
+
54
+ interface LspRange {
55
+ start: LspPosition;
56
+ end: LspPosition;
57
+ }
58
+
59
+ interface LspDiagnostic {
60
+ range: LspRange;
61
+ severity?: number;
62
+ code?: string | number;
63
+ codeDescription?: { href?: string };
64
+ source?: string;
65
+ message: string;
66
+ }
67
+
68
+ interface LspTextEdit {
69
+ range: LspRange;
70
+ newText: string;
71
+ }
72
+
73
+ interface WorkspaceEdit {
74
+ changes?: Record<string, LspTextEdit[]>;
75
+ documentChanges?: Array<{
76
+ textDocument?: { uri?: string; version?: number | null };
77
+ edits?: LspTextEdit[];
78
+ }>;
79
+ }
80
+
81
+ interface CodeAction {
82
+ title: string;
83
+ kind?: string;
84
+ edit?: WorkspaceEdit;
85
+ data?: unknown;
86
+ }
87
+
88
+ interface DiagnosticEntry {
89
+ path: string;
90
+ uri: string;
91
+ diagnostics: LspDiagnostic[];
92
+ }
93
+
94
+ interface JsonRpcMessage {
95
+ jsonrpc?: "2.0";
96
+ id?: number | string | null;
97
+ method?: string;
98
+ params?: unknown;
99
+ result?: unknown;
100
+ error?: { code: number; message: string; data?: unknown };
101
+ }
102
+
103
+ const PathsParameters = {
104
+ paths: Type.Optional(
105
+ Type.Array(Type.String(), {
106
+ description: "Biome-supported files or directories to check. Defaults to the project root.",
107
+ }),
108
+ ),
109
+ root: Type.Optional(
110
+ Type.String({ description: "Workspace root for the Biome language server. Defaults to cwd." }),
111
+ ),
112
+ limit: Type.Optional(
113
+ Type.Number({ description: "Maximum files to open when directories are provided." }),
114
+ ),
115
+ };
116
+
117
+ const biomeDiagnosticsTool = defineTool({
118
+ name: "biome_lsp_diagnostics",
119
+ label: "Biome LSP: Diagnostics",
120
+ description: "Run Biome's language server and return diagnostics for supported files.",
121
+ promptSnippet: "Get Biome diagnostics through the Biome language server",
122
+ promptGuidelines: [
123
+ "Use biome_lsp_diagnostics when JavaScript, TypeScript, JSON, CSS, GraphQL, or framework files need Biome lint/format diagnostics.",
124
+ "If Biome is missing, report the configuration error and suggest installing @biomejs/biome or setting PI_BIOME_LSP_COMMAND.",
125
+ ],
126
+ parameters: Type.Object(PathsParameters),
127
+ async execute(_toolCallId, params, signal) {
128
+ return runDiagnostics(params, signal);
129
+ },
130
+ });
131
+
132
+ const biomeFormatTool = defineTool({
133
+ name: "biome_lsp_format",
134
+ label: "Biome LSP: Format",
135
+ description: "Format a Biome-supported file through Biome's language server.",
136
+ promptSnippet: "Format a file through Biome LSP",
137
+ parameters: Type.Object({
138
+ path: Type.String({ description: "File to format." }),
139
+ root: Type.Optional(
140
+ Type.String({ description: "Workspace root for the Biome language server. Defaults to cwd." }),
141
+ ),
142
+ write: Type.Optional(
143
+ Type.Boolean({ description: "Write formatted text back to the file. Defaults to false." }),
144
+ ),
145
+ }),
146
+ async execute(_toolCallId, params, signal) {
147
+ const root = resolveRoot(params.root);
148
+ const file = resolveBiomeFile(root, params.path);
149
+ const client = new LspClient(getServerCommand(), root, getTimeoutMs());
150
+ const abort = () => client.close();
151
+ signal?.addEventListener("abort", abort, { once: true });
152
+
153
+ try {
154
+ await client.start();
155
+ await client.initialize(root, { codeAction: true });
156
+ const uri = pathToFileURL(file).href;
157
+ const text = readFileSync(file, "utf8");
158
+ client.didOpen(uri, text, languageIdFor(file));
159
+ const edits = await client.format(uri);
160
+ const newText = applyTextEdits(text, edits);
161
+ const changed = newText !== text;
162
+
163
+ if (params.write && changed) writeFileSync(file, newText);
164
+
165
+ return textResult(formatEditSummary("format", root, file, changed, params.write, newText), {
166
+ path: path.relative(root, file) || file,
167
+ uri,
168
+ changed,
169
+ write: params.write ?? false,
170
+ edits,
171
+ text: params.write ? undefined : newText,
172
+ });
173
+ } finally {
174
+ signal?.removeEventListener("abort", abort);
175
+ await client.shutdown();
176
+ }
177
+ },
178
+ });
179
+
180
+ const biomeFixTool = defineTool({
181
+ name: "biome_lsp_fix",
182
+ label: "Biome LSP: Fix",
183
+ description: "Apply Biome LSP source fixes or import organization to a file.",
184
+ promptSnippet: "Apply Biome LSP fixes to a file",
185
+ parameters: Type.Object({
186
+ path: Type.String({ description: "File to fix." }),
187
+ root: Type.Optional(
188
+ Type.String({ description: "Workspace root for the Biome language server. Defaults to cwd." }),
189
+ ),
190
+ kind: Type.Optional(
191
+ Type.String({
192
+ description:
193
+ "Biome source action kind. Defaults to source.fixAll.biome. Common value: source.organizeImports.biome.",
194
+ }),
195
+ ),
196
+ write: Type.Optional(
197
+ Type.Boolean({ description: "Write fixed text back to the file. Defaults to false." }),
198
+ ),
199
+ }),
200
+ async execute(_toolCallId, params, signal) {
201
+ const root = resolveRoot(params.root);
202
+ const file = resolveBiomeFile(root, params.path);
203
+ const actionKind = params.kind?.trim() || "source.fixAll.biome";
204
+ const client = new LspClient(getServerCommand(), root, getTimeoutMs());
205
+ const abort = () => client.close();
206
+ signal?.addEventListener("abort", abort, { once: true });
207
+
208
+ try {
209
+ await client.start();
210
+ await client.initialize(root, { codeAction: true });
211
+ const uri = pathToFileURL(file).href;
212
+ const text = readFileSync(file, "utf8");
213
+ client.didOpen(uri, text, languageIdFor(file));
214
+ const diagnostics = await client.diagnostics(uri);
215
+ const actions = await client.codeActions(uri, text, diagnostics, actionKind);
216
+ const resolvedActions = await client.resolveActions(actions);
217
+ const edits = resolvedActions.flatMap((action) => collectWorkspaceEdits(action.edit, uri));
218
+ const newText = applyTextEdits(text, edits);
219
+ const changed = newText !== text;
220
+
221
+ if (params.write && changed) writeFileSync(file, newText);
222
+
223
+ return textResult(formatEditSummary("fix", root, file, changed, params.write, newText), {
224
+ path: path.relative(root, file) || file,
225
+ uri,
226
+ changed,
227
+ write: params.write ?? false,
228
+ kind: actionKind,
229
+ actions: resolvedActions.map(({ title, kind }) => ({ title, kind })),
230
+ edits,
231
+ text: params.write ? undefined : newText,
232
+ });
233
+ } finally {
234
+ signal?.removeEventListener("abort", abort);
235
+ await client.shutdown();
236
+ }
237
+ },
238
+ });
239
+
240
+ export default function biomeLsp(pi: ExtensionAPI) {
241
+ pi.registerTool(biomeDiagnosticsTool);
242
+ pi.registerTool(biomeFormatTool);
243
+ pi.registerTool(biomeFixTool);
244
+
245
+ pi.registerCommand("biome-lsp", {
246
+ description: "Show Biome LSP extension configuration",
247
+ handler: async (_args, ctx) => {
248
+ ctx.ui.notify(buildStatusMessage(), statusLevel());
249
+ updateStatus(ctx);
250
+ },
251
+ });
252
+
253
+ pi.on("session_start", (_event, ctx) => {
254
+ updateStatus(ctx);
255
+ });
256
+
257
+ pi.on("session_shutdown", (_event, ctx) => {
258
+ ctx.ui.setStatus(STATUS_KEY, undefined);
259
+ });
260
+ }
261
+
262
+ async function runDiagnostics(
263
+ params: { root?: string; paths?: string[]; limit?: number },
264
+ signal: AbortSignal | undefined,
265
+ ) {
266
+ const root = resolveRoot(params.root);
267
+ const files = collectBiomeFiles(root, params.paths, params.limit ?? DEFAULT_FILE_LIMIT);
268
+ if (files.length === 0) {
269
+ return textResult("Biome LSP found no supported files to check.", { root, files: [] });
270
+ }
271
+
272
+ const client = new LspClient(getServerCommand(), root, getTimeoutMs());
273
+ const abort = () => client.close();
274
+ signal?.addEventListener("abort", abort, { once: true });
275
+
276
+ try {
277
+ await client.start();
278
+ await client.initialize(root, { codeAction: true });
279
+
280
+ const entries: DiagnosticEntry[] = [];
281
+ for (const file of files) {
282
+ const uri = pathToFileURL(file).href;
283
+ const text = readFileSync(file, "utf8");
284
+ client.didOpen(uri, text, languageIdFor(file));
285
+ const diagnostics = await client.diagnostics(uri);
286
+ entries.push({ path: path.relative(root, file) || file, uri, diagnostics });
287
+ }
288
+
289
+ return textResult(formatDiagnostics(entries), {
290
+ root,
291
+ command: getServerCommand(),
292
+ files: entries,
293
+ summary: summarize(entries),
294
+ });
295
+ } finally {
296
+ signal?.removeEventListener("abort", abort);
297
+ await client.shutdown();
298
+ }
299
+ }
300
+
301
+ function getServerCommand(): ServerCommand {
302
+ const customCommand = process.env.PI_BIOME_LSP_COMMAND?.trim();
303
+ if (customCommand) {
304
+ const [command, ...args] = splitCommand(customCommand);
305
+ if (command) return { command, args };
306
+ }
307
+
308
+ return { command: "biome", args: ["lsp-proxy"] };
309
+ }
310
+
311
+ function getTimeoutMs() {
312
+ const rawValue = Number(process.env.PI_BIOME_LSP_TIMEOUT_MS ?? DEFAULT_TIMEOUT_MS);
313
+ return Number.isFinite(rawValue) && rawValue > 0 ? rawValue : DEFAULT_TIMEOUT_MS;
314
+ }
315
+
316
+ function updateStatus(ctx: {
317
+ ui: { setStatus: (key: string, value: string | undefined) => void };
318
+ }) {
319
+ const command = getServerCommand();
320
+ ctx.ui.setStatus(STATUS_KEY, `biome-lsp: ${commandExists(command.command) ? "ready" : "missing"}`);
321
+ }
322
+
323
+ function buildStatusMessage() {
324
+ const command = getServerCommand();
325
+ return [
326
+ `Biome LSP command: ${command.command} ${command.args.join(" ")}`.trim(),
327
+ `Biome status: ${commandExists(command.command) ? "ready" : "command missing"}`,
328
+ ].join("\n");
329
+ }
330
+
331
+ function statusLevel() {
332
+ return commandExists(getServerCommand().command) ? "info" : "warning";
333
+ }
334
+
335
+ function resolveRoot(root?: string) {
336
+ return path.resolve(root?.trim() || process.cwd());
337
+ }
338
+
339
+ function directoryUri(directory: string) {
340
+ return pathToFileURL(directory.endsWith(path.sep) ? directory : `${directory}${path.sep}`).href;
341
+ }
342
+
343
+ function resolveBiomeFile(root: string, filePath: string) {
344
+ const resolvedPath = path.resolve(root, filePath);
345
+ if (!existsSync(resolvedPath)) throw new Error(`File does not exist: ${resolvedPath}`);
346
+ if (!statSync(resolvedPath).isFile()) throw new Error(`Expected a file: ${resolvedPath}`);
347
+ if (!isBiomeFile(resolvedPath)) throw new Error(`Expected a Biome-supported file: ${resolvedPath}`);
348
+ return resolvedPath;
349
+ }
350
+
351
+ function collectBiomeFiles(root: string, requestedPaths: string[] | undefined, limit: number) {
352
+ const cappedLimit = Math.max(1, Math.floor(limit));
353
+ const files: string[] = [];
354
+ const inputs = requestedPaths?.length ? requestedPaths : [root];
355
+
356
+ for (const input of inputs) {
357
+ collectPath(path.resolve(root, input), files, cappedLimit);
358
+ if (files.length >= cappedLimit) break;
359
+ }
360
+
361
+ return files;
362
+ }
363
+
364
+ function collectPath(targetPath: string, files: string[], limit: number) {
365
+ if (files.length >= limit || !existsSync(targetPath)) return;
366
+
367
+ const stats = statSync(targetPath);
368
+ if (stats.isFile()) {
369
+ if (isBiomeFile(targetPath)) files.push(targetPath);
370
+ return;
371
+ }
372
+
373
+ if (!stats.isDirectory()) return;
374
+ for (const entry of readdirSync(targetPath, { withFileTypes: true })) {
375
+ if (files.length >= limit) break;
376
+ if (entry.isDirectory() && SKIP_DIRECTORIES.has(entry.name)) continue;
377
+ collectPath(path.join(targetPath, entry.name), files, limit);
378
+ }
379
+ }
380
+
381
+ function isBiomeFile(filePath: string) {
382
+ return SUPPORTED_EXTENSIONS.has(path.extname(filePath));
383
+ }
384
+
385
+ function languageIdFor(filePath: string) {
386
+ const extension = path.extname(filePath);
387
+ if (extension === ".js" || extension === ".cjs" || extension === ".mjs") return "javascript";
388
+ if (extension === ".jsx") return "javascriptreact";
389
+ if (extension === ".ts" || extension === ".cts" || extension === ".mts") return "typescript";
390
+ if (extension === ".tsx") return "typescriptreact";
391
+ if (extension === ".gql") return "graphql";
392
+ if (extension === ".jsonc") return "jsonc";
393
+ return extension.slice(1);
394
+ }
395
+
396
+ function formatDiagnostics(entries: DiagnosticEntry[]) {
397
+ const lines = entries.flatMap((entry) => {
398
+ if (entry.diagnostics.length === 0) return [`${entry.path}: no diagnostics`];
399
+
400
+ return entry.diagnostics.map((diagnostic) => {
401
+ const line = diagnostic.range.start.line + 1;
402
+ const column = diagnostic.range.start.character + 1;
403
+ const severity = severityName(diagnostic.severity);
404
+ const source = diagnostic.source ?? "Biome";
405
+ const code = diagnostic.code === undefined ? "" : ` ${diagnostic.code}`;
406
+ return `${entry.path}:${line}:${column}: ${severity} ${source}${code}: ${diagnostic.message}`;
407
+ });
408
+ });
409
+
410
+ const summary = summarize(entries);
411
+ return [
412
+ `Biome LSP diagnostics: ${summary.diagnostics} diagnostic(s) across ${summary.files} file(s).`,
413
+ "",
414
+ ...lines,
415
+ ].join("\n");
416
+ }
417
+
418
+ function formatEditSummary(
419
+ action: "fix" | "format",
420
+ root: string,
421
+ file: string,
422
+ changed: boolean,
423
+ write: boolean | undefined,
424
+ text: string,
425
+ ) {
426
+ const relativePath = path.relative(root, file) || file;
427
+ const status = changed ? (write ? "updated" : "computed changes for") : "left unchanged";
428
+ const summary = `Biome LSP ${action} ${status} ${relativePath}.`;
429
+ if (write || !changed) return summary;
430
+ return `${summary}\n\n${text}`;
431
+ }
432
+
433
+ function summarize(entries: DiagnosticEntry[]) {
434
+ return {
435
+ files: entries.length,
436
+ diagnostics: entries.reduce((total, entry) => total + entry.diagnostics.length, 0),
437
+ };
438
+ }
439
+
440
+ function severityName(severity: number | undefined) {
441
+ if (severity === 1) return "error";
442
+ if (severity === 2) return "warning";
443
+ if (severity === 3) return "info";
444
+ if (severity === 4) return "hint";
445
+ return "diagnostic";
446
+ }
447
+
448
+ function textResult(text: string, details: unknown) {
449
+ return {
450
+ content: [{ type: "text" as const, text }],
451
+ details,
452
+ };
453
+ }
454
+
455
+ function commandExists(command: string) {
456
+ if (command.includes("/") || command.includes("\\")) return existsSync(command);
457
+
458
+ const pathValue = process.env.PATH ?? "";
459
+ const extensions = process.platform === "win32" ? ["", ".exe", ".cmd", ".bat"] : [""];
460
+ for (const directory of pathValue.split(process.platform === "win32" ? ";" : ":")) {
461
+ if (!directory) continue;
462
+ for (const extension of extensions) {
463
+ if (existsSync(path.join(directory, `${command}${extension}`))) return true;
464
+ }
465
+ }
466
+
467
+ return false;
468
+ }
469
+
470
+ function splitCommand(input: string) {
471
+ const parts: string[] = [];
472
+ let current = "";
473
+ let quote: '"' | "'" | undefined;
474
+ let escaping = false;
475
+
476
+ for (const char of input) {
477
+ if (escaping) {
478
+ current += char;
479
+ escaping = false;
480
+ continue;
481
+ }
482
+
483
+ if (char === "\\") {
484
+ escaping = true;
485
+ continue;
486
+ }
487
+
488
+ if ((char === '"' || char === "'") && !quote) {
489
+ quote = char;
490
+ continue;
491
+ }
492
+
493
+ if (char === quote) {
494
+ quote = undefined;
495
+ continue;
496
+ }
497
+
498
+ if (/\s/.test(char) && !quote) {
499
+ if (current) {
500
+ parts.push(current);
501
+ current = "";
502
+ }
503
+ continue;
504
+ }
505
+
506
+ current += char;
507
+ }
508
+
509
+ if (current) parts.push(current);
510
+ return parts;
511
+ }
512
+
513
+ function positionAt(text: string, offset: number): LspPosition {
514
+ const boundedOffset = Math.max(0, Math.min(offset, text.length));
515
+ let line = 0;
516
+ let lineStart = 0;
517
+
518
+ for (let index = 0; index < boundedOffset; index += 1) {
519
+ if (text[index] === "\n") {
520
+ line += 1;
521
+ lineStart = index + 1;
522
+ }
523
+ }
524
+
525
+ return { line, character: boundedOffset - lineStart };
526
+ }
527
+
528
+ function offsetAt(text: string, position: LspPosition) {
529
+ let line = 0;
530
+ let lineStart = 0;
531
+
532
+ for (let index = 0; index < text.length && line < position.line; index += 1) {
533
+ if (text[index] === "\n") {
534
+ line += 1;
535
+ lineStart = index + 1;
536
+ }
537
+ }
538
+
539
+ if (line < position.line) return text.length;
540
+
541
+ let lineEnd = text.indexOf("\n", lineStart);
542
+ if (lineEnd < 0) lineEnd = text.length;
543
+ return Math.min(lineStart + position.character, lineEnd);
544
+ }
545
+
546
+ function applyTextEdits(text: string, edits: LspTextEdit[]) {
547
+ let output = text;
548
+ const sortedEdits = [...edits].sort((left, right) => {
549
+ const leftOffset = offsetAt(text, left.range.start);
550
+ const rightOffset = offsetAt(text, right.range.start);
551
+ return rightOffset - leftOffset;
552
+ });
553
+
554
+ for (const edit of sortedEdits) {
555
+ const start = offsetAt(output, edit.range.start);
556
+ const end = offsetAt(output, edit.range.end);
557
+ output = `${output.slice(0, start)}${edit.newText}${output.slice(end)}`;
558
+ }
559
+
560
+ return output;
561
+ }
562
+
563
+ function collectWorkspaceEdits(edit: WorkspaceEdit | undefined, uri: string) {
564
+ if (!edit) return [];
565
+ if (edit.documentChanges) {
566
+ return edit.documentChanges.flatMap((change) =>
567
+ change.textDocument?.uri === uri ? (change.edits ?? []) : [],
568
+ );
569
+ }
570
+
571
+ return edit.changes?.[uri] ?? [];
572
+ }
573
+
574
+ class LspClient {
575
+ #child?: ChildProcessWithoutNullStreams;
576
+ #buffer = Buffer.alloc(0);
577
+ #nextId = 1;
578
+ #pending = new Map<
579
+ number,
580
+ {
581
+ resolve: (message: JsonRpcMessage) => void;
582
+ reject: (reason: unknown) => void;
583
+ timeout: NodeJS.Timeout;
584
+ }
585
+ >();
586
+ #publishedDiagnostics = new Map<string, LspDiagnostic[]>();
587
+ #stderr = "";
588
+ #command: ServerCommand;
589
+ #cwd: string;
590
+ #timeoutMs: number;
591
+
592
+ constructor(command: ServerCommand, cwd: string, timeoutMs: number) {
593
+ this.#command = command;
594
+ this.#cwd = cwd;
595
+ this.#timeoutMs = timeoutMs;
596
+ }
597
+
598
+ async start() {
599
+ if (!commandExists(this.#command.command)) {
600
+ throw new Error(
601
+ `Biome LSP command not found: ${this.#command.command}. Install @biomejs/biome or set PI_BIOME_LSP_COMMAND.`,
602
+ );
603
+ }
604
+
605
+ this.#child = spawn(this.#command.command, this.#command.args, {
606
+ cwd: this.#cwd,
607
+ stdio: "pipe",
608
+ });
609
+ this.#child.stdout.on("data", (chunk) => this.#onData(chunk));
610
+ this.#child.stderr.on("data", (chunk) => {
611
+ this.#stderr += chunk.toString();
612
+ });
613
+ this.#child.once("exit", (code, signal) => {
614
+ const reason = signal ? `signal ${signal}` : `code ${code ?? "unknown"}`;
615
+ for (const [id, pending] of this.#pending.entries()) {
616
+ clearTimeout(pending.timeout);
617
+ pending.reject(
618
+ new Error(
619
+ `Biome LSP server exited before response ${id} (${reason}).${this.#formatStderr()}`,
620
+ ),
621
+ );
622
+ }
623
+ this.#pending.clear();
624
+ });
625
+ }
626
+
627
+ async initialize(root: string, options: { codeAction: boolean }) {
628
+ const rootUri = directoryUri(root);
629
+ await this.request("initialize", {
630
+ processId: process.pid,
631
+ rootUri,
632
+ workspaceFolders: [{ uri: rootUri, name: path.basename(root) || "workspace" }],
633
+ capabilities: {
634
+ textDocument: {
635
+ ...(options.codeAction
636
+ ? {
637
+ codeAction: {
638
+ dynamicRegistration: true,
639
+ resolveSupport: { properties: ["edit"] },
640
+ },
641
+ }
642
+ : {}),
643
+ diagnostic: { dynamicRegistration: true },
644
+ formatting: { dynamicRegistration: true },
645
+ publishDiagnostics: {},
646
+ synchronization: { didSave: true, dynamicRegistration: true },
647
+ },
648
+ workspace: {
649
+ configuration: true,
650
+ didChangeConfiguration: { dynamicRegistration: true },
651
+ workspaceEdit: { documentChanges: true },
652
+ workspaceFolders: true,
653
+ },
654
+ },
655
+ });
656
+ this.notify("initialized", {});
657
+ await wait(300);
658
+ }
659
+
660
+ didOpen(uri: string, text: string, languageId: string) {
661
+ this.notify("textDocument/didOpen", {
662
+ textDocument: { uri, languageId, version: 1, text },
663
+ });
664
+ }
665
+
666
+ async diagnostics(uri: string) {
667
+ try {
668
+ const response = await this.request("textDocument/diagnostic", {
669
+ textDocument: { uri },
670
+ identifier: null,
671
+ previousResultId: null,
672
+ });
673
+ const result = response.result as { items?: LspDiagnostic[] } | undefined;
674
+ return result?.items ?? [];
675
+ } catch (error) {
676
+ if (!isUnsupportedMethodError(error)) throw error;
677
+ await wait(300);
678
+ return this.#publishedDiagnostics.get(uri) ?? [];
679
+ }
680
+ }
681
+
682
+ async format(uri: string) {
683
+ const response = await this.request("textDocument/formatting", {
684
+ textDocument: { uri },
685
+ options: { tabSize: 2, insertSpaces: false },
686
+ });
687
+ return (response.result as LspTextEdit[] | null | undefined) ?? [];
688
+ }
689
+
690
+ async codeActions(uri: string, text: string, diagnostics: LspDiagnostic[], kind: string) {
691
+ const response = await this.request("textDocument/codeAction", {
692
+ textDocument: { uri },
693
+ range: { start: { line: 0, character: 0 }, end: positionAt(text, text.length) },
694
+ context: { diagnostics, only: [kind] },
695
+ });
696
+ return (response.result as CodeAction[] | null | undefined) ?? [];
697
+ }
698
+
699
+ async resolveActions(actions: CodeAction[]) {
700
+ const resolvedActions: CodeAction[] = [];
701
+ for (const action of actions) {
702
+ if (action.edit) {
703
+ resolvedActions.push(action);
704
+ continue;
705
+ }
706
+
707
+ try {
708
+ const response = await this.request("codeAction/resolve", action);
709
+ resolvedActions.push((response.result as CodeAction | undefined) ?? action);
710
+ } catch (error) {
711
+ if (!isUnsupportedMethodError(error)) throw error;
712
+ resolvedActions.push(action);
713
+ }
714
+ }
715
+
716
+ return resolvedActions;
717
+ }
718
+
719
+ async shutdown() {
720
+ if (!this.#child) return;
721
+
722
+ try {
723
+ await this.request("shutdown", null);
724
+ this.notify("exit", undefined);
725
+ } catch {
726
+ // The process may already be gone; close below still guarantees cleanup.
727
+ } finally {
728
+ this.close();
729
+ }
730
+ }
731
+
732
+ close() {
733
+ for (const pending of this.#pending.values()) {
734
+ clearTimeout(pending.timeout);
735
+ pending.reject(new Error("Biome LSP request cancelled."));
736
+ }
737
+ this.#pending.clear();
738
+
739
+ if (this.#child && !this.#child.killed) this.#child.kill("SIGTERM");
740
+ this.#child = undefined;
741
+ }
742
+
743
+ private request(method: string, params: unknown) {
744
+ const id = this.#nextId++;
745
+
746
+ return new Promise<JsonRpcMessage>((resolve, reject) => {
747
+ const timeout = setTimeout(() => {
748
+ this.#pending.delete(id);
749
+ reject(new Error(`Biome LSP request timed out: ${method}.${this.#formatStderr()}`));
750
+ }, this.#timeoutMs);
751
+ this.#pending.set(id, { resolve, reject, timeout });
752
+
753
+ try {
754
+ this.#send({ jsonrpc: "2.0", id, method, params });
755
+ } catch (error) {
756
+ clearTimeout(timeout);
757
+ this.#pending.delete(id);
758
+ reject(error);
759
+ }
760
+ });
761
+ }
762
+
763
+ private notify(method: string, params: unknown) {
764
+ this.#send(
765
+ params === undefined ? { jsonrpc: "2.0", method } : { jsonrpc: "2.0", method, params },
766
+ );
767
+ }
768
+
769
+ #send(message: JsonRpcMessage) {
770
+ if (!this.#child) throw new Error("Biome LSP server is not running.");
771
+
772
+ const body = JSON.stringify(message);
773
+ this.#child.stdin.write(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);
774
+ }
775
+
776
+ #onData(chunk: Buffer) {
777
+ this.#buffer = Buffer.concat([this.#buffer, chunk]);
778
+
779
+ while (true) {
780
+ const separator = this.#buffer.indexOf("\r\n\r\n");
781
+ if (separator < 0) return;
782
+
783
+ const header = this.#buffer.subarray(0, separator).toString("utf8");
784
+ const contentLength = /Content-Length:\s*(\d+)/i.exec(header)?.[1];
785
+ if (!contentLength) throw new Error(`Invalid LSP response header: ${header}`);
786
+
787
+ const bodyStart = separator + 4;
788
+ const bodyLength = Number(contentLength);
789
+ if (this.#buffer.length < bodyStart + bodyLength) return;
790
+
791
+ const rawBody = this.#buffer.subarray(bodyStart, bodyStart + bodyLength).toString("utf8");
792
+ this.#buffer = this.#buffer.subarray(bodyStart + bodyLength);
793
+ this.#handleMessage(JSON.parse(rawBody) as JsonRpcMessage);
794
+ }
795
+ }
796
+
797
+ #handleMessage(message: JsonRpcMessage) {
798
+ if (Object.hasOwn(message, "id") && !message.method) {
799
+ const pending = typeof message.id === "number" ? this.#pending.get(message.id) : undefined;
800
+ if (!pending) return;
801
+
802
+ clearTimeout(pending.timeout);
803
+ this.#pending.delete(message.id as number);
804
+ if (message.error) {
805
+ pending.reject(new Error(`Biome LSP error: ${message.error.message}`));
806
+ } else {
807
+ pending.resolve(message);
808
+ }
809
+ return;
810
+ }
811
+
812
+ if (message.method === "textDocument/publishDiagnostics") {
813
+ const params = message.params as { uri?: string; diagnostics?: LspDiagnostic[] } | undefined;
814
+ if (params?.uri) this.#publishedDiagnostics.set(params.uri, params.diagnostics ?? []);
815
+ return;
816
+ }
817
+
818
+ if (Object.hasOwn(message, "id") && message.method) {
819
+ this.#respondToServerRequest(message);
820
+ }
821
+ }
822
+
823
+ #respondToServerRequest(message: JsonRpcMessage) {
824
+ let result: unknown = null;
825
+ if (message.method === "workspace/configuration") {
826
+ const params = message.params as { items?: unknown[] } | undefined;
827
+ result = (params?.items ?? []).map(() => ({}));
828
+ } else if (message.method === "workspace/workspaceFolders") {
829
+ const rootUri = directoryUri(this.#cwd);
830
+ result = [{ uri: rootUri, name: path.basename(this.#cwd) || "workspace" }];
831
+ } else if (message.method === "client/registerCapability") {
832
+ result = null;
833
+ }
834
+
835
+ this.#send({ jsonrpc: "2.0", id: message.id, result });
836
+ }
837
+
838
+ #formatStderr() {
839
+ const stderr = this.#stderr.trim();
840
+ return stderr ? `\nServer stderr:\n${stderr}` : "";
841
+ }
842
+ }
843
+
844
+ function isUnsupportedMethodError(error: unknown) {
845
+ return error instanceof Error && /method not found|not supported|unsupported/i.test(error.message);
846
+ }
847
+
848
+ function wait(ms: number) {
849
+ return new Promise((resolve) => setTimeout(resolve, ms));
850
+ }