@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 +21 -0
- package/README.md +120 -0
- package/package.json +46 -0
- package/src/biome-lsp.ts +850 -0
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
|
+
[](https://www.npmjs.com/package/@narumitw/pi-biome-lsp) [](https://pi.dev) [](./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
|
+
}
|
package/src/biome-lsp.ts
ADDED
|
@@ -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
|
+
}
|