pi-lsp-lite 0.1.0
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 +63 -0
- package/index.ts +60 -0
- package/package.json +38 -0
- package/src/client.ts +228 -0
- package/src/format.ts +43 -0
- package/src/languages.ts +52 -0
- package/src/server-manager.ts +240 -0
- package/src/util.ts +44 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tom McPhail
|
|
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,63 @@
|
|
|
1
|
+
# pi-lsp-lite
|
|
2
|
+
|
|
3
|
+
[pi](https://github.com/mariozechner/pi) extension that feeds LSP diagnostics back to the agent after every `write` and `edit`. Go, Rust, and TypeScript via `gopls`, `rust-analyzer`, and `typescript-language-server`.
|
|
4
|
+
|
|
5
|
+
The agent sees errors and warnings inline on the same turn as the edit that caused them.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pi install git:github.com/mcphailtom/pi-lsp-lite
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or from npm:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pi install npm:pi-lsp-lite
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Prerequisites
|
|
20
|
+
|
|
21
|
+
Language servers must be on `PATH`. If missing, that language is silently disabled.
|
|
22
|
+
|
|
23
|
+
| Server | Language | Install |
|
|
24
|
+
|--------|----------|---------|
|
|
25
|
+
| `gopls` | Go | `go install golang.org/x/tools/gopls@latest` |
|
|
26
|
+
| `rust-analyzer` | Rust | `rustup component add rust-analyzer` |
|
|
27
|
+
| `typescript-language-server` | TypeScript/JavaScript | `npm install -g typescript-language-server typescript` |
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
No configuration needed. Once installed, diagnostics appear automatically after every `write` or `edit` to a supported file:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
⚠ LSP diagnostics for main.go (2 errors):
|
|
35
|
+
error 12:5 [compiler] undefined: foo
|
|
36
|
+
error 18:2 [compiler] too many arguments in call to bar
|
|
37
|
+
+ 1 diagnostic in 1 other file
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Use `/lsp-status` to see running servers.
|
|
41
|
+
|
|
42
|
+
## How it works
|
|
43
|
+
|
|
44
|
+
Edits trigger `textDocument/didOpen` or `textDocument/didChange` against a long-lived language server. Diagnostics are collected within a 3-second window and appended to the tool result. Workspace roots are detected automatically (`go.mod`, `Cargo.toml`, `tsconfig.json`, `package.json`).
|
|
45
|
+
|
|
46
|
+
See [ARCHITECTURE.md](docs/ARCHITECTURE.md) for internals.
|
|
47
|
+
|
|
48
|
+
## Development
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
git clone https://github.com/mcphailtom/pi-lsp-lite
|
|
52
|
+
cd pi-lsp-lite
|
|
53
|
+
npm install
|
|
54
|
+
npm run check # typecheck
|
|
55
|
+
npm test # unit tests
|
|
56
|
+
npm run test:integration # requires servers on PATH
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
See [CONTRIBUTING.md](docs/CONTRIBUTING.md) for details.
|
|
60
|
+
|
|
61
|
+
## License
|
|
62
|
+
|
|
63
|
+
[MIT](LICENSE)
|
package/index.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { createServerManager } from "./src/server-manager.js";
|
|
3
|
+
import { languageForFile, checkExtensionOverlaps } from "./src/languages.js";
|
|
4
|
+
import { formatDiagnostics } from "./src/format.js";
|
|
5
|
+
import { resolve, relative, isAbsolute } from "node:path";
|
|
6
|
+
|
|
7
|
+
export default function (pi: ExtensionAPI) {
|
|
8
|
+
const manager = createServerManager();
|
|
9
|
+
|
|
10
|
+
for (const warning of checkExtensionOverlaps()) {
|
|
11
|
+
console.error(`[pi-lsp-lite] ${warning}`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
15
|
+
if (event.toolName !== "write" && event.toolName !== "edit") return;
|
|
16
|
+
|
|
17
|
+
const rawPath = event.input?.path;
|
|
18
|
+
const filePath = typeof rawPath === "string" ? rawPath : undefined;
|
|
19
|
+
if (!filePath) return;
|
|
20
|
+
|
|
21
|
+
const absolutePath = resolve(ctx.cwd, filePath);
|
|
22
|
+
const rel = relative(ctx.cwd, absolutePath);
|
|
23
|
+
if (!rel || rel.startsWith("..") || isAbsolute(rel)) return;
|
|
24
|
+
const config = languageForFile(absolutePath);
|
|
25
|
+
if (!config) return;
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const result = await manager.handleEdit(absolutePath, config, ctx.cwd);
|
|
29
|
+
const formatted = formatDiagnostics(filePath, result);
|
|
30
|
+
if (!formatted) return;
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
content: [...event.content, { type: "text" as const, text: formatted }],
|
|
34
|
+
};
|
|
35
|
+
} catch (err) {
|
|
36
|
+
console.error("[pi-lsp-lite]", err);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
pi.on("session_shutdown", async () => {
|
|
41
|
+
await manager.shutdownAll();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
pi.registerCommand("lsp-status", {
|
|
45
|
+
description: "Show running LSP servers and recent diagnostic counts",
|
|
46
|
+
handler: async (_args, ctx) => {
|
|
47
|
+
const servers = manager.status();
|
|
48
|
+
if (servers.length === 0) {
|
|
49
|
+
ctx.ui.notify("pi-lsp-lite: no servers running", "info");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const lines = servers.map((s) => {
|
|
53
|
+
const idle = Math.round((Date.now() - s.lastActivity) / 1000);
|
|
54
|
+
const up = Math.round(s.uptime / 1000);
|
|
55
|
+
return `${s.id} (pid ${s.pid}) root=${s.root} — ${s.openDocuments} open files, up ${up}s, idle ${idle}s`;
|
|
56
|
+
});
|
|
57
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-lsp-lite",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "pi extension: feeds LSP diagnostics back to the agent after every write/edit. Go, Rust, and TypeScript.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"private": false,
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package",
|
|
10
|
+
"pi-extension",
|
|
11
|
+
"lsp",
|
|
12
|
+
"gopls",
|
|
13
|
+
"rust-analyzer",
|
|
14
|
+
"typescript-language-server"
|
|
15
|
+
],
|
|
16
|
+
"pi": {
|
|
17
|
+
"extensions": [
|
|
18
|
+
"./index.ts"
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"vscode-languageserver-protocol": "^3.17.5"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
26
|
+
"typebox": "*"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "^20.0.0",
|
|
30
|
+
"tsx": "^4.21.0",
|
|
31
|
+
"typescript": "^5.4.0"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"check": "tsc --noEmit",
|
|
35
|
+
"test": "tsx --test test/*.test.ts",
|
|
36
|
+
"test:integration": "INTEGRATION=1 tsx --test test/*.test.ts test/integration/*.test.ts"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createProtocolConnection,
|
|
3
|
+
StreamMessageReader,
|
|
4
|
+
StreamMessageWriter,
|
|
5
|
+
InitializeRequest,
|
|
6
|
+
InitializedNotification,
|
|
7
|
+
DidOpenTextDocumentNotification,
|
|
8
|
+
DidChangeTextDocumentNotification,
|
|
9
|
+
DidCloseTextDocumentNotification,
|
|
10
|
+
ShutdownRequest,
|
|
11
|
+
ExitNotification,
|
|
12
|
+
PublishDiagnosticsNotification,
|
|
13
|
+
DiagnosticSeverity,
|
|
14
|
+
type InitializeParams,
|
|
15
|
+
type Diagnostic,
|
|
16
|
+
} from "vscode-languageserver-protocol/node.js";
|
|
17
|
+
import type { ChildProcess } from "node:child_process";
|
|
18
|
+
import { fileUri } from "./util.js";
|
|
19
|
+
|
|
20
|
+
export interface OtherFileDiagnostics {
|
|
21
|
+
uri: string;
|
|
22
|
+
errorCount: number;
|
|
23
|
+
warningCount: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DiagnosticResult {
|
|
27
|
+
status: "ok" | "timeout" | "unavailable";
|
|
28
|
+
diagnostics: Diagnostic[];
|
|
29
|
+
otherFiles: OtherFileDiagnostics[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface LspClient {
|
|
33
|
+
initialize(workspaceRoot: string): Promise<void>;
|
|
34
|
+
didOpen(uri: string, languageId: string, content: string): void;
|
|
35
|
+
didChange(uri: string, content: string): void;
|
|
36
|
+
didClose(uri: string): void;
|
|
37
|
+
waitForDiagnostics(uri: string, timeoutMs: number): Promise<DiagnosticResult>;
|
|
38
|
+
shutdown(): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const SHUTDOWN_TIMEOUT_MS = 5_000;
|
|
42
|
+
|
|
43
|
+
function countDiagnostics(diags: Diagnostic[]): { errors: number; warnings: number } {
|
|
44
|
+
let errors = 0;
|
|
45
|
+
let warnings = 0;
|
|
46
|
+
for (const d of diags) {
|
|
47
|
+
if (d.severity === DiagnosticSeverity.Error) errors++;
|
|
48
|
+
else if (d.severity === DiagnosticSeverity.Warning) warnings++;
|
|
49
|
+
}
|
|
50
|
+
return { errors, warnings };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function createLspClient(child: ChildProcess): LspClient {
|
|
54
|
+
if (!child.stdout || !child.stdin) {
|
|
55
|
+
throw new Error("LSP child process must be spawned with stdio: pipe");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const connection = createProtocolConnection(
|
|
59
|
+
new StreamMessageReader(child.stdout),
|
|
60
|
+
new StreamMessageWriter(child.stdin),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
interface DiagnosticEntry {
|
|
64
|
+
diagnostics: Diagnostic[];
|
|
65
|
+
generation: number;
|
|
66
|
+
received: boolean;
|
|
67
|
+
resolve?: () => void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const diagnosticsMap = new Map<string, DiagnosticEntry>();
|
|
71
|
+
const documentVersion = new Map<string, number>();
|
|
72
|
+
const uriGeneration = new Map<string, number>();
|
|
73
|
+
|
|
74
|
+
connection.onNotification(PublishDiagnosticsNotification.type, (params) => {
|
|
75
|
+
const entry = diagnosticsMap.get(params.uri);
|
|
76
|
+
if (entry) {
|
|
77
|
+
// only accept diagnostics for the current generation of this URI
|
|
78
|
+
const currentGen = uriGeneration.get(params.uri) ?? 0;
|
|
79
|
+
if (entry.generation !== currentGen) return;
|
|
80
|
+
entry.diagnostics = params.diagnostics;
|
|
81
|
+
entry.received = true;
|
|
82
|
+
entry.resolve?.();
|
|
83
|
+
} else {
|
|
84
|
+
// cross-file diagnostics for URIs we haven't opened — accept them
|
|
85
|
+
const gen = uriGeneration.get(params.uri) ?? 0;
|
|
86
|
+
diagnosticsMap.set(params.uri, { diagnostics: params.diagnostics, generation: gen, received: true });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
connection.listen();
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
async initialize(workspaceRoot: string) {
|
|
94
|
+
const params: InitializeParams = {
|
|
95
|
+
processId: child.pid ?? null,
|
|
96
|
+
rootUri: fileUri(workspaceRoot),
|
|
97
|
+
capabilities: {
|
|
98
|
+
textDocument: {
|
|
99
|
+
synchronization: {
|
|
100
|
+
dynamicRegistration: false,
|
|
101
|
+
willSave: false,
|
|
102
|
+
willSaveWaitUntil: false,
|
|
103
|
+
didSave: false,
|
|
104
|
+
},
|
|
105
|
+
publishDiagnostics: {
|
|
106
|
+
relatedInformation: false,
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
workspaceFolders: [{ uri: fileUri(workspaceRoot), name: "workspace" }],
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
await connection.sendRequest(InitializeRequest.type, params);
|
|
114
|
+
connection.sendNotification(InitializedNotification.type, {});
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
didOpen(uri: string, languageId: string, content: string) {
|
|
118
|
+
const gen = (uriGeneration.get(uri) ?? 0) + 1;
|
|
119
|
+
uriGeneration.set(uri, gen);
|
|
120
|
+
documentVersion.set(uri, 1);
|
|
121
|
+
diagnosticsMap.set(uri, { diagnostics: [], generation: gen, received: false });
|
|
122
|
+
connection.sendNotification(DidOpenTextDocumentNotification.type, {
|
|
123
|
+
textDocument: { uri, languageId, version: 1, text: content },
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
didChange(uri: string, content: string) {
|
|
128
|
+
const version = (documentVersion.get(uri) ?? 1) + 1;
|
|
129
|
+
const gen = (uriGeneration.get(uri) ?? 0) + 1;
|
|
130
|
+
uriGeneration.set(uri, gen);
|
|
131
|
+
documentVersion.set(uri, version);
|
|
132
|
+
diagnosticsMap.set(uri, { diagnostics: [], generation: gen, received: false });
|
|
133
|
+
connection.sendNotification(DidChangeTextDocumentNotification.type, {
|
|
134
|
+
textDocument: { uri, version },
|
|
135
|
+
contentChanges: [{ text: content }],
|
|
136
|
+
});
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
didClose(uri: string) {
|
|
140
|
+
// bump generation so any in-flight diagnostics for the old open are rejected
|
|
141
|
+
const gen = (uriGeneration.get(uri) ?? 0) + 1;
|
|
142
|
+
uriGeneration.set(uri, gen);
|
|
143
|
+
connection.sendNotification(DidCloseTextDocumentNotification.type, {
|
|
144
|
+
textDocument: { uri },
|
|
145
|
+
});
|
|
146
|
+
diagnosticsMap.delete(uri);
|
|
147
|
+
documentVersion.delete(uri);
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
async waitForDiagnostics(uri: string, timeoutMs: number): Promise<DiagnosticResult> {
|
|
151
|
+
const targetGen = uriGeneration.get(uri) ?? 0;
|
|
152
|
+
|
|
153
|
+
// snapshot diagnostic counts for all other tracked URIs before the edit settles
|
|
154
|
+
const preSnapshot = new Map<string, { errors: number; warnings: number }>();
|
|
155
|
+
for (const [trackedUri, entry] of diagnosticsMap) {
|
|
156
|
+
if (trackedUri !== uri) {
|
|
157
|
+
preSnapshot.set(trackedUri, countDiagnostics(entry.diagnostics));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const collectOtherFiles = (): OtherFileDiagnostics[] => {
|
|
162
|
+
const result: OtherFileDiagnostics[] = [];
|
|
163
|
+
for (const [trackedUri, entry] of diagnosticsMap) {
|
|
164
|
+
if (trackedUri === uri) continue;
|
|
165
|
+
const post = countDiagnostics(entry.diagnostics);
|
|
166
|
+
const pre = preSnapshot.get(trackedUri) ?? { errors: 0, warnings: 0 };
|
|
167
|
+
const newErrors = post.errors - pre.errors;
|
|
168
|
+
const newWarnings = post.warnings - pre.warnings;
|
|
169
|
+
if (newErrors > 0 || newWarnings > 0) {
|
|
170
|
+
result.push({ uri: trackedUri, errorCount: newErrors, warningCount: newWarnings });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return result;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
return new Promise<DiagnosticResult>((resolve) => {
|
|
177
|
+
const SETTLE_MS = 50;
|
|
178
|
+
let settled = false;
|
|
179
|
+
|
|
180
|
+
const settle = (status: "ok" | "timeout") => {
|
|
181
|
+
if (settled) return;
|
|
182
|
+
settled = true;
|
|
183
|
+
setTimeout(() => {
|
|
184
|
+
resolve({
|
|
185
|
+
status,
|
|
186
|
+
diagnostics: diagnosticsMap.get(uri)?.diagnostics ?? [],
|
|
187
|
+
otherFiles: collectOtherFiles(),
|
|
188
|
+
});
|
|
189
|
+
}, SETTLE_MS);
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const timeout = setTimeout(() => {
|
|
193
|
+
settle("timeout");
|
|
194
|
+
}, timeoutMs);
|
|
195
|
+
|
|
196
|
+
const entry = diagnosticsMap.get(uri) ?? { diagnostics: [], generation: targetGen, received: false };
|
|
197
|
+
if (entry.received) {
|
|
198
|
+
clearTimeout(timeout);
|
|
199
|
+
settle("ok");
|
|
200
|
+
} else {
|
|
201
|
+
entry.resolve = () => {
|
|
202
|
+
clearTimeout(timeout);
|
|
203
|
+
settle("ok");
|
|
204
|
+
};
|
|
205
|
+
diagnosticsMap.set(uri, entry);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
async shutdown() {
|
|
211
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
212
|
+
try {
|
|
213
|
+
await Promise.race([
|
|
214
|
+
connection.sendRequest(ShutdownRequest.type),
|
|
215
|
+
new Promise<never>((_, reject) => {
|
|
216
|
+
timer = setTimeout(() => reject(new Error("shutdown timed out")), SHUTDOWN_TIMEOUT_MS);
|
|
217
|
+
}),
|
|
218
|
+
]);
|
|
219
|
+
connection.sendNotification(ExitNotification.type);
|
|
220
|
+
} catch {
|
|
221
|
+
// timed out or server already exited
|
|
222
|
+
} finally {
|
|
223
|
+
if (timer) clearTimeout(timer);
|
|
224
|
+
}
|
|
225
|
+
connection.dispose();
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { DiagnosticSeverity } from "vscode-languageserver-protocol";
|
|
2
|
+
import type { DiagnosticResult } from "./client.js";
|
|
3
|
+
|
|
4
|
+
export function formatDiagnostics(filePath: string, result: DiagnosticResult): string {
|
|
5
|
+
const relevant = result.diagnostics.filter(
|
|
6
|
+
(d) => d.severity === DiagnosticSeverity.Error || d.severity === DiagnosticSeverity.Warning,
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
if (relevant.length === 0 && result.status === "ok" && result.otherFiles.length === 0) return "";
|
|
10
|
+
if (result.status === "unavailable") return "";
|
|
11
|
+
|
|
12
|
+
if (relevant.length === 0 && result.status === "ok" && result.otherFiles.length > 0) {
|
|
13
|
+
return `\n⚠ LSP diagnostics for ${filePath}: no issues${otherFilesFooter(result)}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const lines = relevant.map((d) => {
|
|
17
|
+
const severity = d.severity === DiagnosticSeverity.Error ? "error" : "warning";
|
|
18
|
+
const line = d.range.start.line + 1;
|
|
19
|
+
const col = d.range.start.character + 1;
|
|
20
|
+
const source = d.source ? `[${d.source}] ` : "";
|
|
21
|
+
return ` ${severity} ${line}:${col} ${source}${d.message}`;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const errorCount = relevant.filter((d) => d.severity === DiagnosticSeverity.Error).length;
|
|
25
|
+
const warnCount = relevant.length - errorCount;
|
|
26
|
+
|
|
27
|
+
const summary = [
|
|
28
|
+
errorCount > 0 ? `${errorCount} error${errorCount > 1 ? "s" : ""}` : "",
|
|
29
|
+
warnCount > 0 ? `${warnCount} warning${warnCount > 1 ? "s" : ""}` : "",
|
|
30
|
+
result.status === "timeout" ? "timed out, may be incomplete" : "",
|
|
31
|
+
]
|
|
32
|
+
.filter(Boolean)
|
|
33
|
+
.join(", ");
|
|
34
|
+
|
|
35
|
+
return `\n⚠ LSP diagnostics for ${filePath} (${summary}):\n${lines.join("\n")}${otherFilesFooter(result)}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function otherFilesFooter(result: DiagnosticResult): string {
|
|
39
|
+
if (result.otherFiles.length === 0) return "";
|
|
40
|
+
const totalDiags = result.otherFiles.reduce((sum, f) => sum + f.errorCount + f.warningCount, 0);
|
|
41
|
+
const fileCount = result.otherFiles.length;
|
|
42
|
+
return `\n + ${totalDiags} diagnostic${totalDiags !== 1 ? "s" : ""} in ${fileCount} other file${fileCount !== 1 ? "s" : ""}`;
|
|
43
|
+
}
|
package/src/languages.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export interface LanguageServerConfig {
|
|
2
|
+
id: string;
|
|
3
|
+
extensions: string[];
|
|
4
|
+
command: string;
|
|
5
|
+
args: string[];
|
|
6
|
+
rootPatterns: string[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const languages: LanguageServerConfig[] = [
|
|
10
|
+
{
|
|
11
|
+
id: "go",
|
|
12
|
+
extensions: [".go"],
|
|
13
|
+
command: "gopls",
|
|
14
|
+
args: ["serve"],
|
|
15
|
+
rootPatterns: ["go.mod"],
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: "rust",
|
|
19
|
+
extensions: [".rs"],
|
|
20
|
+
command: "rust-analyzer",
|
|
21
|
+
args: [],
|
|
22
|
+
rootPatterns: ["Cargo.toml"],
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: "typescript",
|
|
26
|
+
extensions: [".ts", ".tsx", ".js", ".jsx"],
|
|
27
|
+
command: "typescript-language-server",
|
|
28
|
+
args: ["--stdio"],
|
|
29
|
+
rootPatterns: ["tsconfig.json", "package.json"],
|
|
30
|
+
},
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
export function languageForFile(path: string): LanguageServerConfig | undefined {
|
|
34
|
+
const lower = path.toLowerCase();
|
|
35
|
+
return languages.find((lang) => lang.extensions.some((ext) => lower.endsWith(ext)));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function checkExtensionOverlaps(): string[] {
|
|
39
|
+
const warnings: string[] = [];
|
|
40
|
+
const seen = new Map<string, string>();
|
|
41
|
+
for (const lang of languages) {
|
|
42
|
+
for (const ext of lang.extensions) {
|
|
43
|
+
const existing = seen.get(ext);
|
|
44
|
+
if (existing) {
|
|
45
|
+
warnings.push(`extension "${ext}" is claimed by both "${existing}" and "${lang.id}" — "${existing}" wins`);
|
|
46
|
+
} else {
|
|
47
|
+
seen.set(ext, lang.id);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return warnings;
|
|
52
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { which, fileUri, findWorkspaceRoot } from "./util.js";
|
|
3
|
+
import { createLspClient, type LspClient, type DiagnosticResult } from "./client.js";
|
|
4
|
+
import type { LanguageServerConfig } from "./languages.js";
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
|
|
7
|
+
interface ManagedServer {
|
|
8
|
+
config: LanguageServerConfig;
|
|
9
|
+
serverKey: string;
|
|
10
|
+
root: string;
|
|
11
|
+
process: ChildProcess;
|
|
12
|
+
client: LspClient;
|
|
13
|
+
openDocuments: Map<string, number>;
|
|
14
|
+
idleTimer: ReturnType<typeof setTimeout> | null;
|
|
15
|
+
startTime: number;
|
|
16
|
+
lastActivity: number;
|
|
17
|
+
editQueue: Promise<DiagnosticResult>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ServerManager {
|
|
21
|
+
handleEdit(filePath: string, config: LanguageServerConfig, cwd: string): Promise<DiagnosticResult>;
|
|
22
|
+
status(): ServerStatus[];
|
|
23
|
+
shutdownAll(): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ServerStatus {
|
|
27
|
+
id: string;
|
|
28
|
+
root: string;
|
|
29
|
+
pid: number;
|
|
30
|
+
uptime: number;
|
|
31
|
+
openDocuments: number;
|
|
32
|
+
lastActivity: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const IDLE_TIMEOUT_MS = 240_000;
|
|
36
|
+
const DIAGNOSTIC_TIMEOUT_MS = 3_000;
|
|
37
|
+
const INIT_TIMEOUT_MS = 10_000;
|
|
38
|
+
const DOCUMENT_IDLE_MS = 120_000;
|
|
39
|
+
const SWEEP_INTERVAL_MS = 60_000;
|
|
40
|
+
|
|
41
|
+
export function createServerManager(): ServerManager {
|
|
42
|
+
const servers = new Map<string, ManagedServer>();
|
|
43
|
+
const pending = new Map<string, Promise<ManagedServer | null>>();
|
|
44
|
+
const disabledBinaries = new Set<string>();
|
|
45
|
+
const failedRoots = new Set<string>();
|
|
46
|
+
let sweepTimer: ReturnType<typeof setInterval> | null = null;
|
|
47
|
+
|
|
48
|
+
function startSweepTimer() {
|
|
49
|
+
if (sweepTimer) return;
|
|
50
|
+
sweepTimer = setInterval(() => {
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
for (const server of servers.values()) {
|
|
53
|
+
const stale = [...server.openDocuments.entries()]
|
|
54
|
+
.filter(([, lastActive]) => now - lastActive > DOCUMENT_IDLE_MS);
|
|
55
|
+
for (const [docUri] of stale) {
|
|
56
|
+
server.client.didClose(docUri);
|
|
57
|
+
server.openDocuments.delete(docUri);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}, SWEEP_INTERVAL_MS);
|
|
61
|
+
sweepTimer.unref();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function stopSweepTimer() {
|
|
65
|
+
if (sweepTimer) {
|
|
66
|
+
clearInterval(sweepTimer);
|
|
67
|
+
sweepTimer = null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function resetIdleTimer(server: ManagedServer) {
|
|
72
|
+
if (server.idleTimer) clearTimeout(server.idleTimer);
|
|
73
|
+
server.lastActivity = Date.now();
|
|
74
|
+
server.idleTimer = setTimeout(() => shutdownServer(server), IDLE_TIMEOUT_MS);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function killProcess(proc: ChildProcess): Promise<void> {
|
|
78
|
+
if (proc.exitCode !== null) return;
|
|
79
|
+
proc.kill("SIGTERM");
|
|
80
|
+
await new Promise<void>((resolve) => {
|
|
81
|
+
if (proc.exitCode !== null) {
|
|
82
|
+
resolve();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const killTimer = setTimeout(() => {
|
|
86
|
+
proc.kill("SIGKILL");
|
|
87
|
+
}, 2000);
|
|
88
|
+
proc.once("exit", () => {
|
|
89
|
+
clearTimeout(killTimer);
|
|
90
|
+
resolve();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function shutdownServer(server: ManagedServer) {
|
|
96
|
+
if (server.idleTimer) clearTimeout(server.idleTimer);
|
|
97
|
+
await server.client.shutdown();
|
|
98
|
+
await killProcess(server.process);
|
|
99
|
+
if (servers.get(server.serverKey) === server) {
|
|
100
|
+
servers.delete(server.serverKey);
|
|
101
|
+
}
|
|
102
|
+
if (servers.size === 0) stopSweepTimer();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function spawnServer(config: LanguageServerConfig, root: string, serverKey: string): Promise<ManagedServer | null> {
|
|
106
|
+
if (disabledBinaries.has(config.id)) return null;
|
|
107
|
+
if (failedRoots.has(serverKey)) return null;
|
|
108
|
+
|
|
109
|
+
const binaryPath = await which(config.command);
|
|
110
|
+
if (!binaryPath) {
|
|
111
|
+
console.error(`[pi-lsp-lite:${config.id}] ${config.command} not found on PATH, disabling ${config.id}`);
|
|
112
|
+
disabledBinaries.add(config.id);
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const child = spawn(binaryPath, config.args, {
|
|
117
|
+
cwd: root,
|
|
118
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
122
|
+
console.error(`[pi-lsp-lite:${config.id}:${root}]`, chunk.toString().trimEnd());
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
child.on("error", (err) => {
|
|
126
|
+
console.error(`[pi-lsp-lite:${config.id}:${root}] process error:`, err);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const client = createLspClient(child);
|
|
130
|
+
|
|
131
|
+
let initTimer: ReturnType<typeof setTimeout>;
|
|
132
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
133
|
+
initTimer = setTimeout(() => reject(new Error("LSP initialize timed out")), INIT_TIMEOUT_MS);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
await Promise.race([client.initialize(root), timeoutPromise]);
|
|
138
|
+
clearTimeout(initTimer!);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
clearTimeout(initTimer!);
|
|
141
|
+
console.error(`[pi-lsp-lite:${config.id}:${root}] failed to initialize:`, err);
|
|
142
|
+
await killProcess(child);
|
|
143
|
+
client.shutdown().catch(() => {});
|
|
144
|
+
failedRoots.add(serverKey);
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const now = Date.now();
|
|
149
|
+
const server: ManagedServer = {
|
|
150
|
+
config,
|
|
151
|
+
serverKey,
|
|
152
|
+
root,
|
|
153
|
+
process: child,
|
|
154
|
+
client,
|
|
155
|
+
openDocuments: new Map(),
|
|
156
|
+
idleTimer: null,
|
|
157
|
+
startTime: now,
|
|
158
|
+
lastActivity: now,
|
|
159
|
+
editQueue: Promise.resolve({ status: "ok", diagnostics: [], otherFiles: [] }),
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
child.on("exit", () => {
|
|
163
|
+
if (server.idleTimer) clearTimeout(server.idleTimer);
|
|
164
|
+
if (servers.get(serverKey) === server) {
|
|
165
|
+
servers.delete(serverKey);
|
|
166
|
+
}
|
|
167
|
+
if (servers.size === 0) stopSweepTimer();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
resetIdleTimer(server);
|
|
171
|
+
servers.set(serverKey, server);
|
|
172
|
+
startSweepTimer();
|
|
173
|
+
return server;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function ensureServer(config: LanguageServerConfig, root: string): Promise<ManagedServer | null> {
|
|
177
|
+
const serverKey = `${config.id}:${root}`;
|
|
178
|
+
const existing = servers.get(serverKey);
|
|
179
|
+
if (existing) return existing;
|
|
180
|
+
|
|
181
|
+
if (disabledBinaries.has(config.id)) return null;
|
|
182
|
+
if (failedRoots.has(serverKey)) return null;
|
|
183
|
+
|
|
184
|
+
const inflight = pending.get(serverKey);
|
|
185
|
+
if (inflight) return inflight;
|
|
186
|
+
|
|
187
|
+
const promise = spawnServer(config, root, serverKey).finally(() => pending.delete(serverKey));
|
|
188
|
+
pending.set(serverKey, promise);
|
|
189
|
+
return promise;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function doEdit(server: ManagedServer, filePath: string): Promise<DiagnosticResult> {
|
|
193
|
+
resetIdleTimer(server);
|
|
194
|
+
|
|
195
|
+
const uri = fileUri(filePath);
|
|
196
|
+
const content = await readFile(filePath, "utf-8");
|
|
197
|
+
|
|
198
|
+
if (server.openDocuments.has(uri)) {
|
|
199
|
+
server.client.didChange(uri, content);
|
|
200
|
+
} else {
|
|
201
|
+
server.client.didOpen(uri, server.config.id, content);
|
|
202
|
+
}
|
|
203
|
+
server.openDocuments.set(uri, Date.now());
|
|
204
|
+
|
|
205
|
+
return server.client.waitForDiagnostics(uri, DIAGNOSTIC_TIMEOUT_MS);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
async handleEdit(filePath: string, config: LanguageServerConfig, cwd: string): Promise<DiagnosticResult> {
|
|
210
|
+
const root = await findWorkspaceRoot(filePath, config.rootPatterns, cwd);
|
|
211
|
+
const server = await ensureServer(config, root);
|
|
212
|
+
if (!server) return { status: "unavailable" as const, diagnostics: [], otherFiles: [] };
|
|
213
|
+
|
|
214
|
+
// serialize edits per server to avoid concurrent waitForDiagnostics races
|
|
215
|
+
const result = server.editQueue.then(
|
|
216
|
+
() => doEdit(server, filePath),
|
|
217
|
+
() => doEdit(server, filePath),
|
|
218
|
+
);
|
|
219
|
+
server.editQueue = result;
|
|
220
|
+
return result;
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
status(): ServerStatus[] {
|
|
224
|
+
return Array.from(servers.values()).map((s) => ({
|
|
225
|
+
id: s.config.id,
|
|
226
|
+
root: s.root,
|
|
227
|
+
pid: s.process.pid ?? 0,
|
|
228
|
+
uptime: Date.now() - s.startTime,
|
|
229
|
+
openDocuments: s.openDocuments.size,
|
|
230
|
+
lastActivity: s.lastActivity,
|
|
231
|
+
}));
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
async shutdownAll() {
|
|
235
|
+
stopSweepTimer();
|
|
236
|
+
const shutdowns = Array.from(servers.values()).map((s) => shutdownServer(s));
|
|
237
|
+
await Promise.allSettled(shutdowns);
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
}
|
package/src/util.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { access, constants } from "node:fs/promises";
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
|
|
5
|
+
export function fileUri(absolutePath: string): string {
|
|
6
|
+
return pathToFileURL(absolutePath).href;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function which(command: string): Promise<string | null> {
|
|
10
|
+
if (command.includes("/")) {
|
|
11
|
+
try {
|
|
12
|
+
await access(command, constants.X_OK);
|
|
13
|
+
return command;
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const pathDirs = (process.env.PATH ?? "").split(":");
|
|
19
|
+
for (const dir of pathDirs) {
|
|
20
|
+
const candidate = join(dir, command);
|
|
21
|
+
try {
|
|
22
|
+
await access(candidate, constants.X_OK);
|
|
23
|
+
return candidate;
|
|
24
|
+
} catch {}
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function findWorkspaceRoot(filePath: string, rootPatterns: string[], cwd: string): Promise<string> {
|
|
30
|
+
let dir = dirname(filePath);
|
|
31
|
+
while (true) {
|
|
32
|
+
for (const pattern of rootPatterns) {
|
|
33
|
+
try {
|
|
34
|
+
await access(join(dir, pattern));
|
|
35
|
+
return dir;
|
|
36
|
+
} catch {}
|
|
37
|
+
}
|
|
38
|
+
if (dir === cwd) break;
|
|
39
|
+
const parent = dirname(dir);
|
|
40
|
+
if (parent === dir) break;
|
|
41
|
+
dir = parent;
|
|
42
|
+
}
|
|
43
|
+
return cwd;
|
|
44
|
+
}
|