@narumitw/pi-python-lsp 0.1.3

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