pi-lsp-lite 0.2.5 → 0.4.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/README.md CHANGED
@@ -1,60 +1,111 @@
1
1
  # pi-lsp-lite
2
2
 
3
- Just LSP diagnostics for [pi](https://github.com/mariozechner/pi) — errors and warnings on every edit, same turn. Go, Rust, TypeScript.
3
+ [![CI](https://img.shields.io/github/actions/workflow/status/mcphailtom/pi-lsp-lite/ci.yml?branch=main&label=CI)](https://github.com/mcphailtom/pi-lsp-lite/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/pi-lsp-lite)](https://www.npmjs.com/package/pi-lsp-lite)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+
7
+ Your agent can't see compiler errors. Now it can.
8
+
9
+ [pi](https://github.com/mariozechner/pi) extension that runs language servers in the background and feeds diagnostics back inline after every edit. Errors appear on the same turn — no context switch, no separate command.
10
+
11
+ **Go · Rust · TypeScript · Python · C/C++**
4
12
 
5
13
  ## Install
6
14
 
7
15
  ```bash
8
- pi install git:github.com/mcphailtom/pi-lsp-lite
16
+ pi install npm:pi-lsp-lite
9
17
  ```
10
18
 
11
- Or from npm:
19
+ That's it. If you have `gopls`, `rust-analyzer`, `typescript-language-server`, `pylsp`, or `clangd` on PATH, diagnostics start flowing automatically.
20
+
21
+ ## What you see
12
22
 
13
- ```bash
14
- pi install npm:pi-lsp-lite
15
23
  ```
24
+ edit ─ src/main.go
25
+ ✓ Edited src/main.go (replaced 2 lines)
16
26
 
17
- ## Prerequisites
27
+ LSP diagnostics for src/main.go (2 errors):
28
+ error 12:5 [compiler] undefined: foo
29
+ error 18:2 [compiler] too many arguments in call to bar
30
+ + 1 diagnostic in 1 other file
31
+ ```
18
32
 
19
- Language servers must be on `PATH`. If missing, that language is silently disabled.
33
+ The agent sees these too they're appended to the tool result, so it can self-correct on the same turn.
34
+
35
+ ## Commands
36
+
37
+ | Command | What it does |
38
+ |---------|-------------|
39
+ | `/lsp-status` | Show running servers, PIDs, workspace roots, uptime |
40
+ | `/lsp-diag` | Show all current diagnostics (or `/lsp-diag path/to/file` for one file) |
41
+ | `/lsp-add` | Interactively add a new language server |
42
+ | `/lsp-remove` | Disable a configured server |
43
+ | `/lsp-toggle` | Flip a server on/off without removing config |
44
+ | `/lsp-install` | Install a missing server binary |
45
+
46
+ ## Supported servers
20
47
 
21
48
  | Server | Language | Install |
22
49
  |--------|----------|---------|
23
50
  | `gopls` | Go | `go install golang.org/x/tools/gopls@latest` |
24
51
  | `rust-analyzer` | Rust | `rustup component add rust-analyzer` |
25
- | `typescript-language-server` | TypeScript/JavaScript | `npm install -g typescript-language-server typescript` |
52
+ | `typescript-language-server` | TypeScript/JS | `npm install -g typescript-language-server typescript` |
53
+ | `pylsp` | Python | `pip install python-lsp-server` |
54
+ | `clangd` | C/C++ | Xcode CLI tools / `apt install clangd` |
55
+
56
+ Missing a server? `/lsp-add` lets you configure any LSP server that speaks stdio. Or add it to `.pi-lsp-lite.json`:
57
+
58
+ ```json
59
+ {
60
+ "servers": {
61
+ "haskell": {
62
+ "extensions": [".hs"],
63
+ "command": "haskell-language-server-wrapper",
64
+ "args": ["--lsp"],
65
+ "rootPatterns": ["cabal.project", "stack.yaml"]
66
+ }
67
+ }
68
+ }
69
+ ```
26
70
 
27
- ## Usage
71
+ ## Configuration
28
72
 
29
- No configuration needed. Once installed, diagnostics appear automatically after every `write` or `edit` to a supported file:
73
+ Works without config. For customisation, create `.pi-lsp-lite.json` (project) or `~/.pi-lsp-lite.json` (global):
30
74
 
31
- ```
32
- ⚠ LSP diagnostics for main.go (2 errors):
33
- error 12:5 [compiler] undefined: foo
34
- error 18:2 [compiler] too many arguments in call to bar
35
- + 1 diagnostic in 1 other file
36
- ```
75
+ | Field | Description | Default |
76
+ |-------|-------------|---------|
77
+ | `servers.<id>.diagnosticTimeout` | Per-attempt timeout (ms) | per-language |
78
+ | `servers.<id>.maxRetries` | Retry attempts on timeout (0-10) | `3` |
79
+ | `servers.<id>.disabled` | Disable this server | `false` |
80
+ | `diagnosticTimeout` | Global default timeout (ms) | `5000` |
81
+ | `documentIdleTimeout` | Close idle documents after (ms) | `120000` |
37
82
 
38
- Use `/lsp-status` to see running servers.
83
+ Project config merges over global. Partial overrides work — only specify what you want to change.
39
84
 
40
85
  ## How it works
41
86
 
42
- 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`).
87
+ 1. Agent writes/edits a file
88
+ 2. Extension detects the language, finds the workspace root
89
+ 3. Spawns (or reuses) an LSP server for that language + root
90
+ 4. Sends `didChange`, waits for `publishDiagnostics`
91
+ 5. If timeout: retries with exponential backoff + jitter (up to `maxRetries` times)
92
+ 6. Filters to errors + warnings, formats, appends to tool result + shows in TUI
93
+
94
+ Cross-file impact is detected via snapshot-diff: if editing `lib.ts` breaks `caller.ts`, you see "+ N diagnostics in M other files".
43
95
 
44
- See [ARCHITECTURE.md](docs/ARCHITECTURE.md) for internals.
96
+ Servers are lazy (spawn on first edit), idle-shutdown after 240s, and clean up on session end.
45
97
 
46
98
  ## Development
47
99
 
48
100
  ```bash
49
101
  git clone https://github.com/mcphailtom/pi-lsp-lite
50
- cd pi-lsp-lite
51
- npm install
52
- npm run check # typecheck
53
- npm test # unit tests
54
- npm run test:integration # requires servers on PATH
102
+ cd pi-lsp-lite && npm install
103
+ npm run check # typecheck
104
+ npm test # unit tests (106, no servers needed)
105
+ npm run test:integration # real server tests (needs servers on PATH)
55
106
  ```
56
107
 
57
- See [CONTRIBUTING.md](docs/CONTRIBUTING.md) for details.
108
+ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md).
58
109
 
59
110
  ## License
60
111
 
package/index.ts CHANGED
@@ -1,32 +1,59 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { createServerManager } from "./src/server-manager.js";
3
- import { languageForFile, checkExtensionOverlaps } from "./src/languages.js";
3
+ import { languageForFile, checkExtensionOverlaps, builtinLanguages, type LanguageServerConfig } from "./src/languages.js";
4
4
  import { formatDiagnostics } from "./src/format.js";
5
- import { resolve, relative, isAbsolute } from "node:path";
5
+ import { DiagnosticSeverity } from "vscode-languageserver-protocol";
6
+ import { loadConfig, writeGlobalConfig, readGlobalConfig } from "./src/config.js";
7
+ import { fileUri, which, isInsideCwd } from "./src/util.js";
8
+ import { installRegistry } from "./src/install-registry.js";
9
+ import { resolve } from "node:path";
10
+ import { realpath } from "node:fs/promises";
11
+ import { fileURLToPath } from "node:url";
6
12
 
7
13
  export default function (pi: ExtensionAPI) {
8
- const manager = createServerManager();
14
+ let servers: LanguageServerConfig[] = [];
15
+ let manager = createServerManager({});
9
16
 
10
- for (const warning of checkExtensionOverlaps()) {
11
- console.error(`[pi-lsp-lite] ${warning}`);
17
+ async function initConfig(cwd: string) {
18
+ await manager.shutdownAll();
19
+ const resolved = await loadConfig(cwd);
20
+ servers = resolved.servers;
21
+ manager = createServerManager({
22
+ diagnosticTimeout: resolved.diagnosticTimeout,
23
+ documentIdleTimeout: resolved.documentIdleTimeout,
24
+ perServerTimeout: resolved.perServerTimeout,
25
+ });
26
+
27
+ for (const warning of checkExtensionOverlaps(servers)) {
28
+ console.error(`[pi-lsp-lite] ${warning}`);
29
+ }
12
30
  }
13
31
 
32
+ pi.on("session_start", async (_event, ctx) => {
33
+ await initConfig(ctx.cwd);
34
+ });
35
+
14
36
  pi.on("tool_result", async (event, ctx) => {
15
37
  if (event.toolName !== "write" && event.toolName !== "edit") return;
16
38
 
17
39
  const rawPath = event.input?.path;
18
40
  const filePath = typeof rawPath === "string" ? rawPath : undefined;
19
41
  if (!filePath) return;
42
+ if (event.isError) return;
20
43
 
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;
44
+ let absolutePath: string;
45
+ try {
46
+ absolutePath = await realpath(resolve(ctx.cwd, filePath));
47
+ } catch {
48
+ return;
49
+ }
50
+ if (!isInsideCwd(absolutePath, ctx.cwd)) return;
51
+ const langConfig = languageForFile(absolutePath, servers);
52
+ if (!langConfig) return;
26
53
 
27
54
  try {
28
- const result = await manager.handleEdit(absolutePath, config, ctx.cwd);
29
- const formatted = formatDiagnostics(filePath, result);
55
+ const result = await manager.handleEdit(absolutePath, langConfig, ctx.cwd);
56
+ const formatted = formatDiagnostics(filePath, result, ctx.cwd);
30
57
  if (!formatted) return;
31
58
 
32
59
  ctx.ui.notify(formatted.trim(), "warning");
@@ -46,12 +73,12 @@ export default function (pi: ExtensionAPI) {
46
73
  pi.registerCommand("lsp-status", {
47
74
  description: "Show running LSP servers and recent diagnostic counts",
48
75
  handler: async (_args, ctx) => {
49
- const servers = manager.status();
50
- if (servers.length === 0) {
76
+ const running = manager.status();
77
+ if (running.length === 0) {
51
78
  ctx.ui.notify("pi-lsp-lite: no servers running", "info");
52
79
  return;
53
80
  }
54
- const lines = servers.map((s) => {
81
+ const lines = running.map((s) => {
55
82
  const idle = Math.round((Date.now() - s.lastActivity) / 1000);
56
83
  const up = Math.round(s.uptime / 1000);
57
84
  return `${s.id} (pid ${s.pid}) root=${s.root} — ${s.openDocuments} open files, up ${up}s, idle ${idle}s`;
@@ -59,4 +86,211 @@ export default function (pi: ExtensionAPI) {
59
86
  ctx.ui.notify(lines.join("\n"), "info");
60
87
  },
61
88
  });
89
+
90
+ pi.registerCommand("lsp-diag", {
91
+ description: "Show current LSP diagnostics for all tracked files (or a specific file)",
92
+ handler: async (args, ctx) => {
93
+ const allDiags = manager.getAllDiagnostics();
94
+
95
+ if (allDiags.size === 0) {
96
+ ctx.ui.notify("pi-lsp-lite: no diagnostics", "info");
97
+ return;
98
+ }
99
+
100
+ const filterPath = args?.trim();
101
+ let filterUri: string | undefined;
102
+ if (filterPath) {
103
+ const abs = resolve(ctx.cwd, filterPath);
104
+ filterUri = fileUri(abs);
105
+ }
106
+
107
+ const lines: string[] = [];
108
+ for (const [uri, diags] of allDiags) {
109
+ if (filterUri && uri !== filterUri) continue;
110
+ const filePath = fileURLToPath(new URL(uri));
111
+ const relevant = diags.filter((d) => d.severity === DiagnosticSeverity.Error || d.severity === DiagnosticSeverity.Warning);
112
+ if (relevant.length === 0) continue;
113
+ lines.push(`${filePath} (${relevant.length} diagnostic${relevant.length !== 1 ? "s" : ""})`);
114
+ for (const d of relevant) {
115
+ const severity = d.severity === DiagnosticSeverity.Error ? "error" : "warning";
116
+ const line = d.range.start.line + 1;
117
+ const col = d.range.start.character + 1;
118
+ const source = d.source ? `[${d.source}] ` : "";
119
+ lines.push(` ${severity} ${line}:${col} ${source}${d.message}`);
120
+ }
121
+ }
122
+
123
+ if (lines.length === 0) {
124
+ ctx.ui.notify(filterPath ? `pi-lsp-lite: no diagnostics for ${filterPath}` : "pi-lsp-lite: no diagnostics", "info");
125
+ return;
126
+ }
127
+
128
+ ctx.ui.notify(lines.join("\n"), "warning");
129
+ },
130
+ });
131
+
132
+ pi.registerCommand("lsp-add", {
133
+ description: "Add a new language server to global config",
134
+ handler: async (_args, ctx) => {
135
+ if (!ctx.hasUI) {
136
+ ctx.ui.notify("pi-lsp-lite: /lsp-add requires interactive mode", "error");
137
+ return;
138
+ }
139
+
140
+ const rawId = await ctx.ui.input("Server ID (e.g. haskell):");
141
+ if (!rawId) return;
142
+ const id = rawId.trim().toLowerCase();
143
+ if (!/^[a-z0-9_-]+$/.test(id)) {
144
+ ctx.ui.notify("pi-lsp-lite: server ID must be lowercase alphanumeric, hyphens, or underscores", "error");
145
+ return;
146
+ }
147
+ const RESERVED_IDS = new Set(["__proto__", "constructor", "prototype"]);
148
+ if (RESERVED_IDS.has(id)) {
149
+ ctx.ui.notify("pi-lsp-lite: reserved ID, choose a different name", "error");
150
+ return;
151
+ }
152
+
153
+ const rawCommand = await ctx.ui.input("Binary command (e.g. haskell-language-server-wrapper):");
154
+ const command = rawCommand?.trim();
155
+ if (!command) return;
156
+
157
+ const argsRaw = await ctx.ui.input("CLI args (comma-separated, or empty):");
158
+ const args = argsRaw ? argsRaw.split(",").map((a) => a.trim()).filter(Boolean) : [];
159
+
160
+ const extRaw = await ctx.ui.input("File extensions (comma-separated, e.g. .hs,.lhs):");
161
+ if (!extRaw) return;
162
+ const extensions = extRaw.split(",").map((e) => e.trim().toLowerCase()).filter(Boolean);
163
+ if (extensions.length === 0) {
164
+ ctx.ui.notify("pi-lsp-lite: at least one extension is required", "error");
165
+ return;
166
+ }
167
+
168
+ const rootRaw = await ctx.ui.input("Root pattern files (comma-separated, or empty):");
169
+ const rootPatterns = rootRaw ? rootRaw.split(",").map((r) => r.trim()).filter(Boolean) : [];
170
+
171
+ const resolved = await which(command);
172
+ if (!resolved) {
173
+ ctx.ui.notify(`pi-lsp-lite: "${command}" not found on PATH — server added but won't start until installed`, "warning");
174
+ }
175
+
176
+ await writeGlobalConfig({ servers: { [id]: { command, args, extensions, rootPatterns } } });
177
+ await initConfig(ctx.cwd);
178
+ ctx.ui.notify(`pi-lsp-lite: added server "${id}"`, "info");
179
+ },
180
+ });
181
+
182
+ pi.registerCommand("lsp-remove", {
183
+ description: "Remove or disable a language server",
184
+ handler: async (_args, ctx) => {
185
+ if (!ctx.hasUI) {
186
+ ctx.ui.notify("pi-lsp-lite: /lsp-remove requires interactive mode", "error");
187
+ return;
188
+ }
189
+
190
+ if (servers.length === 0) {
191
+ ctx.ui.notify("pi-lsp-lite: no servers configured", "info");
192
+ return;
193
+ }
194
+
195
+ const ids = servers.map((s) => s.id);
196
+ const selected = await ctx.ui.select("Remove which server?", ids);
197
+ if (!selected) return;
198
+
199
+ const confirmed = await ctx.ui.confirm("Confirm removal", `Disable server "${selected}"?`);
200
+ if (!confirmed) return;
201
+
202
+ await writeGlobalConfig({ servers: { [selected]: { disabled: true } } });
203
+ await initConfig(ctx.cwd);
204
+ ctx.ui.notify(`pi-lsp-lite: disabled server "${selected}"`, "info");
205
+ },
206
+ });
207
+
208
+ pi.registerCommand("lsp-toggle", {
209
+ description: "Enable or disable a language server",
210
+ handler: async (_args, ctx) => {
211
+ if (!ctx.hasUI) {
212
+ ctx.ui.notify("pi-lsp-lite: /lsp-toggle requires interactive mode", "error");
213
+ return;
214
+ }
215
+
216
+ const builtinIds = new Set(builtinLanguages.map((l) => l.id));
217
+ const activeIds = new Set(servers.map((s) => s.id));
218
+
219
+ // include disabled user-added servers from global config so they can be re-enabled
220
+ const globalConfig = await readGlobalConfig();
221
+ const RESERVED = new Set(["__proto__", "constructor", "prototype"]);
222
+ const globalServerIds = (globalConfig?.servers && typeof globalConfig.servers === "object" && !Array.isArray(globalConfig.servers))
223
+ ? Object.keys(globalConfig.servers).filter((k) => !RESERVED.has(k))
224
+ : [];
225
+ const allIds = new Set<string>([...builtinIds, ...activeIds, ...globalServerIds]);
226
+
227
+ if (allIds.size === 0) {
228
+ ctx.ui.notify("pi-lsp-lite: no servers configured", "info");
229
+ return;
230
+ }
231
+
232
+ const entries = [...allIds];
233
+ const options = entries.map((id) => `${id} ${activeIds.has(id) ? "[enabled]" : "[disabled]"}`);
234
+ const choice = await ctx.ui.select("Toggle which server?", options);
235
+ if (!choice) return;
236
+
237
+ const idx = options.indexOf(choice);
238
+ const id = entries[idx];
239
+ const isCurrentlyEnabled = activeIds.has(id);
240
+
241
+ if (isCurrentlyEnabled) {
242
+ await writeGlobalConfig({ servers: { [id]: { disabled: true } } });
243
+ } else {
244
+ // re-enable: works for both built-ins and user-added servers in global config
245
+ await writeGlobalConfig({ servers: { [id]: { disabled: false } } });
246
+ }
247
+
248
+ await initConfig(ctx.cwd);
249
+ ctx.ui.notify(`pi-lsp-lite: ${isCurrentlyEnabled ? "disabled" : "enabled"} server "${id}"`, "info");
250
+ },
251
+ });
252
+
253
+ pi.registerCommand("lsp-install", {
254
+ description: "Install a missing language server binary",
255
+ handler: async (_args, ctx) => {
256
+ if (!ctx.hasUI) {
257
+ ctx.ui.notify("pi-lsp-lite: /lsp-install requires interactive mode", "error");
258
+ return;
259
+ }
260
+
261
+ const checks = await Promise.all(
262
+ [...installRegistry].map(async ([id, entry]) => {
263
+ const lang = builtinLanguages.find((l) => l.id === id);
264
+ const binary = lang?.command ?? id;
265
+ const found = await which(binary);
266
+ return found ? null : { id, command: binary, installCmd: entry.command, description: entry.description };
267
+ }),
268
+ );
269
+ const missing = checks.filter((c): c is NonNullable<typeof c> => c !== null);
270
+
271
+ if (missing.length === 0) {
272
+ ctx.ui.notify("pi-lsp-lite: all known servers are available", "info");
273
+ return;
274
+ }
275
+
276
+ const options = missing.map((m) => `${m.id} — ${m.description} (${m.command})`);
277
+ const choice = await ctx.ui.select("Install which server?", options);
278
+ if (!choice) return;
279
+
280
+ const idx = options.indexOf(choice);
281
+ const selected = missing[idx];
282
+
283
+ const confirmed = await ctx.ui.confirm("Confirm install", `Run: ${selected.installCmd}`);
284
+ if (!confirmed) return;
285
+
286
+ const result = await pi.exec("sh", ["-c", selected.installCmd]);
287
+ if (result.code !== 0) {
288
+ ctx.ui.notify(`pi-lsp-lite: install failed (exit ${result.code})\n${result.stderr}`, "error");
289
+ return;
290
+ }
291
+
292
+ await initConfig(ctx.cwd);
293
+ ctx.ui.notify(`pi-lsp-lite: installed ${selected.id}`, "info");
294
+ },
295
+ });
62
296
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-lsp-lite",
3
- "version": "0.2.5",
4
- "description": "Just LSP diagnostics for pi — errors and warnings on every edit, same turn. Go, Rust, TypeScript.",
3
+ "version": "0.4.0",
4
+ "description": "LSP diagnostics for pi — errors and warnings on every edit, same turn. Go, Rust, TypeScript, Python, C/C++.",
5
5
  "type": "module",
6
6
  "private": false,
7
7
  "license": "MIT",
@@ -17,7 +17,11 @@
17
17
  "lsp",
18
18
  "gopls",
19
19
  "rust-analyzer",
20
- "typescript-language-server"
20
+ "typescript-language-server",
21
+ "pylsp",
22
+ "clangd",
23
+ "python",
24
+ "cpp"
21
25
  ],
22
26
  "pi": {
23
27
  "extensions": [
package/src/client.ts CHANGED
@@ -21,12 +21,14 @@ export interface OtherFileDiagnostics {
21
21
  uri: string;
22
22
  errorCount: number;
23
23
  warningCount: number;
24
+ firstDiagnostic?: { severity: number; line: number; col: number; message: string; source?: string };
24
25
  }
25
26
 
26
27
  export interface DiagnosticResult {
27
28
  status: "ok" | "timeout" | "unavailable";
28
29
  diagnostics: Diagnostic[];
29
30
  otherFiles: OtherFileDiagnostics[];
31
+ retryAttempts: number;
30
32
  }
31
33
 
32
34
  export interface LspClient {
@@ -35,10 +37,12 @@ export interface LspClient {
35
37
  didChange(uri: string, content: string): void;
36
38
  didClose(uri: string): void;
37
39
  waitForDiagnostics(uri: string, timeoutMs: number): Promise<DiagnosticResult>;
40
+ getAllDiagnostics(): Map<string, Diagnostic[]>;
38
41
  shutdown(): Promise<void>;
39
42
  }
40
43
 
41
44
  const SHUTDOWN_TIMEOUT_MS = 5_000;
45
+ const QUIESCENCE_MS = 200;
42
46
 
43
47
  function countDiagnostics(diags: Diagnostic[]): { errors: number; warnings: number } {
44
48
  let errors = 0;
@@ -50,6 +54,28 @@ function countDiagnostics(diags: Diagnostic[]): { errors: number; warnings: numb
50
54
  return { errors, warnings };
51
55
  }
52
56
 
57
+ function diagnosticFingerprint(d: Diagnostic): string {
58
+ return `${d.severity}:${d.range.start.line}:${d.range.start.character}:${d.message}`;
59
+ }
60
+
61
+ function fingerprintSet(diags: Diagnostic[]): Set<string> {
62
+ const set = new Set<string>();
63
+ for (const d of diags) {
64
+ if (d.severity === DiagnosticSeverity.Error || d.severity === DiagnosticSeverity.Warning) {
65
+ set.add(diagnosticFingerprint(d));
66
+ }
67
+ }
68
+ return set;
69
+ }
70
+
71
+ function setsEqual(a: Set<string>, b: Set<string>): boolean {
72
+ if (a.size !== b.size) return false;
73
+ for (const v of a) {
74
+ if (!b.has(v)) return false;
75
+ }
76
+ return true;
77
+ }
78
+
53
79
  export function createLspClient(child: ChildProcess): LspClient {
54
80
  if (!child.stdout || !child.stdin) {
55
81
  throw new Error("LSP child process must be spawned with stdio: pipe");
@@ -70,21 +96,21 @@ export function createLspClient(child: ChildProcess): LspClient {
70
96
  const diagnosticsMap = new Map<string, DiagnosticEntry>();
71
97
  const documentVersion = new Map<string, number>();
72
98
  const uriGeneration = new Map<string, number>();
99
+ let crossFileCallback: ((changedUri: string) => void) | null = null;
73
100
 
74
101
  connection.onNotification(PublishDiagnosticsNotification.type, (params) => {
75
102
  const entry = diagnosticsMap.get(params.uri);
76
103
  if (entry) {
77
- // only accept diagnostics for the current generation of this URI
78
104
  const currentGen = uriGeneration.get(params.uri) ?? 0;
79
105
  if (entry.generation !== currentGen) return;
80
106
  entry.diagnostics = params.diagnostics;
81
107
  entry.received = true;
82
108
  entry.resolve?.();
83
109
  } else {
84
- // cross-file diagnostics for URIs we haven't opened — accept them
85
110
  const gen = uriGeneration.get(params.uri) ?? 0;
86
111
  diagnosticsMap.set(params.uri, { diagnostics: params.diagnostics, generation: gen, received: true });
87
112
  }
113
+ if (crossFileCallback) crossFileCallback(params.uri);
88
114
  });
89
115
 
90
116
  connection.listen();
@@ -137,7 +163,6 @@ export function createLspClient(child: ChildProcess): LspClient {
137
163
  },
138
164
 
139
165
  didClose(uri: string) {
140
- // bump generation so any in-flight diagnostics for the old open are rejected
141
166
  const gen = (uriGeneration.get(uri) ?? 0) + 1;
142
167
  uriGeneration.set(uri, gen);
143
168
  connection.sendNotification(DidCloseTextDocumentNotification.type, {
@@ -150,11 +175,10 @@ export function createLspClient(child: ChildProcess): LspClient {
150
175
  async waitForDiagnostics(uri: string, timeoutMs: number): Promise<DiagnosticResult> {
151
176
  const targetGen = uriGeneration.get(uri) ?? 0;
152
177
 
153
- // snapshot diagnostic counts for all other tracked URIs before the edit settles
154
- const preSnapshot = new Map<string, { errors: number; warnings: number }>();
178
+ const preSnapshot = new Map<string, Set<string>>();
155
179
  for (const [trackedUri, entry] of diagnosticsMap) {
156
180
  if (trackedUri !== uri) {
157
- preSnapshot.set(trackedUri, countDiagnostics(entry.diagnostics));
181
+ preSnapshot.set(trackedUri, fingerprintSet(entry.diagnostics));
158
182
  }
159
183
  }
160
184
 
@@ -162,31 +186,52 @@ export function createLspClient(child: ChildProcess): LspClient {
162
186
  const result: OtherFileDiagnostics[] = [];
163
187
  for (const [trackedUri, entry] of diagnosticsMap) {
164
188
  if (trackedUri === uri) continue;
189
+ const postFp = fingerprintSet(entry.diagnostics);
190
+ const preFp = preSnapshot.get(trackedUri) ?? new Set();
191
+ if (setsEqual(postFp, preFp)) continue;
165
192
  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
- }
193
+ const first =
194
+ entry.diagnostics.find((d) => d.severity === DiagnosticSeverity.Error) ??
195
+ entry.diagnostics.find((d) => d.severity === DiagnosticSeverity.Warning);
196
+ result.push({
197
+ uri: trackedUri,
198
+ errorCount: post.errors,
199
+ warningCount: post.warnings,
200
+ ...(first && {
201
+ firstDiagnostic: {
202
+ severity: first.severity ?? DiagnosticSeverity.Error,
203
+ line: first.range.start.line,
204
+ col: first.range.start.character,
205
+ message: first.message,
206
+ ...(first.source && { source: first.source }),
207
+ },
208
+ }),
209
+ });
172
210
  }
173
211
  return result;
174
212
  };
175
213
 
176
214
  return new Promise<DiagnosticResult>((resolve) => {
177
- const SETTLE_MS = 50;
178
215
  let settled = false;
216
+ let quiescenceTimer: ReturnType<typeof setTimeout> | null = null;
179
217
 
180
218
  const settle = (status: "ok" | "timeout") => {
181
219
  if (settled) return;
182
220
  settled = true;
183
- setTimeout(() => {
184
- resolve({
185
- status,
186
- diagnostics: diagnosticsMap.get(uri)?.diagnostics ?? [],
187
- otherFiles: collectOtherFiles(),
188
- });
189
- }, SETTLE_MS);
221
+ crossFileCallback = null;
222
+ if (quiescenceTimer) clearTimeout(quiescenceTimer);
223
+ resolve({
224
+ status,
225
+ diagnostics: diagnosticsMap.get(uri)?.diagnostics ?? [],
226
+ otherFiles: collectOtherFiles(),
227
+ retryAttempts: 0,
228
+ });
229
+ };
230
+
231
+ const resetQuiescence = () => {
232
+ if (settled) return;
233
+ if (quiescenceTimer) clearTimeout(quiescenceTimer);
234
+ quiescenceTimer = setTimeout(() => settle("ok"), QUIESCENCE_MS);
190
235
  };
191
236
 
192
237
  const timeout = setTimeout(() => {
@@ -194,19 +239,43 @@ export function createLspClient(child: ChildProcess): LspClient {
194
239
  }, timeoutMs);
195
240
 
196
241
  const entry = diagnosticsMap.get(uri) ?? { diagnostics: [], generation: targetGen, received: false };
197
- if (entry.received) {
242
+
243
+ entry.resolve = () => {
198
244
  clearTimeout(timeout);
199
- settle("ok");
200
- } else {
201
- entry.resolve = () => {
245
+ resetQuiescence();
246
+ };
247
+ diagnosticsMap.set(uri, entry);
248
+
249
+ // when a non-target URI publishes diagnostics that differ from the
250
+ // pre-snapshot, start quiescence — this catches the case where the
251
+ // edited file is valid but dependents break
252
+ crossFileCallback = (changedUri: string) => {
253
+ if (settled || changedUri === uri) return;
254
+ const preFp = preSnapshot.get(changedUri) ?? new Set<string>();
255
+ const postFp = fingerprintSet(diagnosticsMap.get(changedUri)?.diagnostics ?? []);
256
+ if (!setsEqual(preFp, postFp)) {
202
257
  clearTimeout(timeout);
203
- settle("ok");
204
- };
205
- diagnosticsMap.set(uri, entry);
258
+ resetQuiescence();
259
+ }
260
+ };
261
+
262
+ if (entry.received) {
263
+ clearTimeout(timeout);
264
+ resetQuiescence();
206
265
  }
207
266
  });
208
267
  },
209
268
 
269
+ getAllDiagnostics(): Map<string, Diagnostic[]> {
270
+ const result = new Map<string, Diagnostic[]>();
271
+ for (const [uri, entry] of diagnosticsMap) {
272
+ if (entry.diagnostics.length > 0) {
273
+ result.set(uri, [...entry.diagnostics]);
274
+ }
275
+ }
276
+ return result;
277
+ },
278
+
210
279
  async shutdown() {
211
280
  let timer: ReturnType<typeof setTimeout> | undefined;
212
281
  try {
package/src/config.ts ADDED
@@ -0,0 +1,307 @@
1
+ import { readFile, writeFile, mkdir, rename, unlink } from "node:fs/promises";
2
+ import { join, dirname } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { randomUUID } from "node:crypto";
5
+ import { type LanguageServerConfig, builtinLanguages } from "./languages.js";
6
+
7
+ export interface ServerConfigOverride {
8
+ extensions?: string[];
9
+ command?: string;
10
+ args?: string[];
11
+ rootPatterns?: string[];
12
+ diagnosticTimeout?: number;
13
+ maxRetries?: number;
14
+ disabled?: boolean;
15
+ }
16
+
17
+ export interface UserConfig {
18
+ servers?: Record<string, ServerConfigOverride>;
19
+ diagnosticTimeout?: number;
20
+ documentIdleTimeout?: number;
21
+ }
22
+
23
+ export interface ResolvedConfig {
24
+ servers: LanguageServerConfig[];
25
+ diagnosticTimeout: number;
26
+ documentIdleTimeout: number;
27
+ perServerTimeout: Map<string, number>;
28
+ }
29
+
30
+ export const DEFAULT_DIAGNOSTIC_TIMEOUT = 5_000;
31
+ export const DEFAULT_DOCUMENT_IDLE_TIMEOUT = 120_000;
32
+ export const DEFAULT_MAX_RETRIES = 3;
33
+
34
+ const MIN_DIAGNOSTIC_TIMEOUT = 1_000;
35
+ const MAX_DIAGNOSTIC_TIMEOUT = 60_000;
36
+ const MIN_DOCUMENT_IDLE_TIMEOUT = 10_000;
37
+ const MAX_DOCUMENT_IDLE_TIMEOUT = 600_000;
38
+ const MIN_MAX_RETRIES = 0;
39
+ const MAX_MAX_RETRIES = 10;
40
+
41
+ function clamp(value: unknown, min: number, max: number, fallback: number): number {
42
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
43
+ return Math.max(min, Math.min(max, value));
44
+ }
45
+
46
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
47
+ return typeof v === "object" && v !== null && !Array.isArray(v);
48
+ }
49
+
50
+ function isStringArray(v: unknown): v is string[] {
51
+ return Array.isArray(v) && v.every((item) => typeof item === "string");
52
+ }
53
+
54
+ function validateOverride(id: string, raw: unknown): ServerConfigOverride | null {
55
+ if (!isPlainObject(raw)) return null;
56
+
57
+ const override: ServerConfigOverride = {};
58
+
59
+ if (raw.disabled === true) {
60
+ override.disabled = true;
61
+ return override;
62
+ }
63
+
64
+ if (raw.extensions !== undefined) {
65
+ if (!isStringArray(raw.extensions) || raw.extensions.length === 0) {
66
+ console.error(`[pi-lsp-lite] config "${id}": extensions must be a non-empty string array, skipping`);
67
+ return null;
68
+ }
69
+ override.extensions = raw.extensions.map((e) => e.toLowerCase());
70
+ }
71
+
72
+ if (raw.command !== undefined) {
73
+ if (typeof raw.command !== "string" || raw.command.length === 0) {
74
+ console.error(`[pi-lsp-lite] config "${id}": command must be a non-empty string, skipping`);
75
+ return null;
76
+ }
77
+ override.command = raw.command;
78
+ }
79
+
80
+ if (raw.args !== undefined) {
81
+ if (!isStringArray(raw.args)) {
82
+ console.error(`[pi-lsp-lite] config "${id}": args must be a string array, skipping`);
83
+ return null;
84
+ }
85
+ override.args = raw.args;
86
+ }
87
+
88
+ if (raw.rootPatterns !== undefined) {
89
+ if (!isStringArray(raw.rootPatterns)) {
90
+ console.error(`[pi-lsp-lite] config "${id}": rootPatterns must be a string array, skipping`);
91
+ return null;
92
+ }
93
+ override.rootPatterns = raw.rootPatterns;
94
+ }
95
+
96
+ if (raw.diagnosticTimeout !== undefined) {
97
+ override.diagnosticTimeout = clamp(
98
+ raw.diagnosticTimeout,
99
+ MIN_DIAGNOSTIC_TIMEOUT,
100
+ MAX_DIAGNOSTIC_TIMEOUT,
101
+ DEFAULT_DIAGNOSTIC_TIMEOUT,
102
+ );
103
+ }
104
+
105
+ if (raw.maxRetries !== undefined) {
106
+ override.maxRetries = clamp(
107
+ raw.maxRetries,
108
+ MIN_MAX_RETRIES,
109
+ MAX_MAX_RETRIES,
110
+ DEFAULT_MAX_RETRIES,
111
+ );
112
+ }
113
+
114
+ return override;
115
+ }
116
+
117
+ async function readConfigFile(path: string): Promise<UserConfig | null> {
118
+ let content: string;
119
+ try {
120
+ content = await readFile(path, "utf-8");
121
+ } catch (err: unknown) {
122
+ if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") return null;
123
+ console.error(`[pi-lsp-lite] failed to read config ${path}:`, err);
124
+ return null;
125
+ }
126
+
127
+ try {
128
+ const parsed: unknown = JSON.parse(content);
129
+ if (!isPlainObject(parsed)) {
130
+ console.error(`[pi-lsp-lite] config ${path}: expected a JSON object, skipping`);
131
+ return null;
132
+ }
133
+ return parsed as UserConfig;
134
+ } catch (err) {
135
+ console.error(`[pi-lsp-lite] config ${path}: invalid JSON, skipping:`, err);
136
+ return null;
137
+ }
138
+ }
139
+
140
+ async function findProjectConfig(cwd: string): Promise<UserConfig | null> {
141
+ for (const candidate of [
142
+ join(cwd, ".pi-lsp-lite.json"),
143
+ join(cwd, ".pi", "lsp-lite.json"),
144
+ ]) {
145
+ const config = await readConfigFile(candidate);
146
+ if (config) return config;
147
+ }
148
+ return null;
149
+ }
150
+
151
+ type ConfigSource = "global" | "project";
152
+
153
+ interface MergeResult {
154
+ servers: LanguageServerConfig[];
155
+ perServerTimeouts: Map<string, number>;
156
+ }
157
+
158
+ function mergeConfigs(
159
+ base: LanguageServerConfig[],
160
+ overrides: Record<string, ServerConfigOverride>,
161
+ source: ConfigSource,
162
+ ): MergeResult {
163
+ const result = new Map<string, LanguageServerConfig>();
164
+ const perServerTimeouts = new Map<string, number>();
165
+
166
+ for (const server of base) {
167
+ result.set(server.id, { ...server });
168
+ }
169
+
170
+ for (const [id, rawOverride] of Object.entries(overrides)) {
171
+ const override = validateOverride(id, rawOverride);
172
+ if (!override) continue;
173
+
174
+ if (override.disabled) {
175
+ result.delete(id);
176
+ continue;
177
+ }
178
+
179
+ if (override.diagnosticTimeout !== undefined) {
180
+ perServerTimeouts.set(id, override.diagnosticTimeout);
181
+ }
182
+
183
+ const existing = result.get(id);
184
+ if (existing) {
185
+ const { disabled: _, diagnosticTimeout: __, ...lspFields } = override;
186
+ if (source === "project" && lspFields.command !== undefined) {
187
+ console.error(`[pi-lsp-lite] project config cannot override "command" for server "${id}" — ignoring (use global config instead)`);
188
+ delete lspFields.command;
189
+ }
190
+ const defined = Object.fromEntries(
191
+ Object.entries(lspFields).filter(([, v]) => v !== undefined),
192
+ );
193
+ result.set(id, { ...existing, ...defined });
194
+ } else {
195
+ if (source === "project") {
196
+ console.error(`[pi-lsp-lite] project config cannot define new server "${id}" — only global config (~/.pi-lsp-lite.json) can add servers`);
197
+ continue;
198
+ }
199
+ if (!override.command || !override.extensions) {
200
+ console.error(`[pi-lsp-lite] config "${id}" must have at least "command" and "extensions" to define a new server, skipping`);
201
+ continue;
202
+ }
203
+ result.set(id, {
204
+ id,
205
+ extensions: override.extensions,
206
+ command: override.command,
207
+ args: override.args ?? [],
208
+ rootPatterns: override.rootPatterns ?? [],
209
+ ...(override.maxRetries !== undefined && { maxRetries: override.maxRetries }),
210
+ });
211
+ }
212
+ }
213
+
214
+ return { servers: Array.from(result.values()), perServerTimeouts };
215
+ }
216
+
217
+ export function globalConfigFilePath(globalConfigPath?: string): string {
218
+ return globalConfigPath ?? join(homedir(), ".pi-lsp-lite.json");
219
+ }
220
+
221
+ export async function readGlobalConfig(globalConfigPath?: string): Promise<UserConfig | null> {
222
+ return readConfigFile(globalConfigFilePath(globalConfigPath));
223
+ }
224
+
225
+ let writeLock = Promise.resolve();
226
+
227
+ export function writeGlobalConfig(config: UserConfig, globalConfigPath?: string): Promise<void> {
228
+ const op = writeLock.then(() => writeGlobalConfigInner(config, globalConfigPath));
229
+ writeLock = op.catch(() => {});
230
+ return op;
231
+ }
232
+
233
+ async function writeGlobalConfigInner(config: UserConfig, globalConfigPath?: string): Promise<void> {
234
+ const filePath = globalConfigFilePath(globalConfigPath);
235
+ const existing = await readConfigFile(filePath);
236
+ const merged = deepMerge(
237
+ (existing ?? {}) as Record<string, unknown>,
238
+ config as Record<string, unknown>,
239
+ ) as UserConfig;
240
+ const dir = dirname(filePath);
241
+ await mkdir(dir, { recursive: true });
242
+ const tmpPath = join(dir, `.tmp-${randomUUID()}`);
243
+ try {
244
+ await writeFile(tmpPath, JSON.stringify(merged, null, 2) + "\n", "utf-8");
245
+ await rename(tmpPath, filePath);
246
+ } catch (err) {
247
+ await unlink(tmpPath).catch(() => {});
248
+ throw err;
249
+ }
250
+ }
251
+
252
+ const RESERVED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
253
+
254
+ function deepMerge(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown> {
255
+ const result: Record<string, unknown> = Object.create(null);
256
+ for (const key of Object.keys(target)) {
257
+ if (!RESERVED_KEYS.has(key)) result[key] = target[key];
258
+ }
259
+ for (const key of Object.keys(source)) {
260
+ if (RESERVED_KEYS.has(key)) continue;
261
+ const sv = source[key];
262
+ const tv = target[key];
263
+ if (sv === undefined) continue;
264
+ if (sv === null) {
265
+ delete result[key];
266
+ } else if (isPlainObject(sv) && isPlainObject(tv)) {
267
+ result[key] = deepMerge(tv, sv);
268
+ } else {
269
+ result[key] = sv;
270
+ }
271
+ }
272
+ return result;
273
+ }
274
+
275
+ export async function loadConfig(cwd: string, globalConfigPath?: string): Promise<ResolvedConfig> {
276
+ const globalConfig = await readConfigFile(globalConfigFilePath(globalConfigPath));
277
+ const projectConfig = await findProjectConfig(cwd);
278
+
279
+ let servers = [...builtinLanguages];
280
+ const perServerTimeout = new Map<string, number>();
281
+ let diagnosticTimeout = DEFAULT_DIAGNOSTIC_TIMEOUT;
282
+ let documentIdleTimeout = DEFAULT_DOCUMENT_IDLE_TIMEOUT;
283
+
284
+ const layers: [UserConfig | null, ConfigSource][] = [
285
+ [globalConfig, "global"],
286
+ [projectConfig, "project"],
287
+ ];
288
+
289
+ for (const [layer, source] of layers) {
290
+ if (!layer) continue;
291
+ if (layer.servers && isPlainObject(layer.servers)) {
292
+ const merged = mergeConfigs(servers, layer.servers as Record<string, ServerConfigOverride>, source);
293
+ servers = merged.servers;
294
+ for (const [id, timeout] of merged.perServerTimeouts) {
295
+ perServerTimeout.set(id, timeout);
296
+ }
297
+ }
298
+ if (layer.diagnosticTimeout !== undefined) {
299
+ diagnosticTimeout = clamp(layer.diagnosticTimeout, MIN_DIAGNOSTIC_TIMEOUT, MAX_DIAGNOSTIC_TIMEOUT, diagnosticTimeout);
300
+ }
301
+ if (layer.documentIdleTimeout !== undefined) {
302
+ documentIdleTimeout = clamp(layer.documentIdleTimeout, MIN_DOCUMENT_IDLE_TIMEOUT, MAX_DOCUMENT_IDLE_TIMEOUT, documentIdleTimeout);
303
+ }
304
+ }
305
+
306
+ return { servers, diagnosticTimeout, documentIdleTimeout, perServerTimeout };
307
+ }
package/src/format.ts CHANGED
@@ -1,16 +1,30 @@
1
1
  import { DiagnosticSeverity } from "vscode-languageserver-protocol";
2
+ import { fileURLToPath } from "node:url";
3
+ import { relative } from "node:path";
2
4
  import type { DiagnosticResult } from "./client.js";
3
5
 
4
- export function formatDiagnostics(filePath: string, result: DiagnosticResult): string {
5
- const relevant = result.diagnostics.filter(
6
+ const MAX_DIAGNOSTICS_PER_FILE = 50;
7
+
8
+ export function formatDiagnostics(filePath: string, result: DiagnosticResult, cwd?: string): string {
9
+ const allRelevant = result.diagnostics.filter(
6
10
  (d) => d.severity === DiagnosticSeverity.Error || d.severity === DiagnosticSeverity.Warning,
7
11
  );
8
12
 
9
- if (relevant.length === 0 && result.status === "ok" && result.otherFiles.length === 0) return "";
10
- if (result.status === "unavailable") return "";
13
+ if (allRelevant.length === 0 && result.status === "ok" && result.otherFiles.length === 0) return "";
14
+
15
+ if (result.status === "unavailable") {
16
+ return `\n⚠ LSP diagnostics unavailable for ${filePath} (server missing or failed to start)`;
17
+ }
18
+
19
+ const truncated = allRelevant.length > MAX_DIAGNOSTICS_PER_FILE;
20
+ const relevant = truncated ? allRelevant.slice(0, MAX_DIAGNOSTICS_PER_FILE) : allRelevant;
21
+
22
+ const retryNote = result.status === "timeout" && result.retryAttempts > 0
23
+ ? ` after ${result.retryAttempts} ${result.retryAttempts === 1 ? "retry" : "retries"}`
24
+ : "";
11
25
 
12
26
  if (relevant.length === 0 && result.status === "ok" && result.otherFiles.length > 0) {
13
- return `\n⚠ LSP diagnostics for ${filePath}: no issues${otherFilesFooter(result)}`;
27
+ return `\n⚠ LSP diagnostics for ${filePath}: no issues${otherFilesFooter(result, cwd)}`;
14
28
  }
15
29
 
16
30
  const lines = relevant.map((d) => {
@@ -21,23 +35,44 @@ export function formatDiagnostics(filePath: string, result: DiagnosticResult): s
21
35
  return ` ${severity} ${line}:${col} ${source}${d.message}`;
22
36
  });
23
37
 
24
- const errorCount = relevant.filter((d) => d.severity === DiagnosticSeverity.Error).length;
25
- const warnCount = relevant.length - errorCount;
38
+ let errorCount = 0;
39
+ for (const d of allRelevant) {
40
+ if (d.severity === DiagnosticSeverity.Error) errorCount++;
41
+ }
42
+ const warnCount = allRelevant.length - errorCount;
26
43
 
27
44
  const summary = [
28
45
  errorCount > 0 ? `${errorCount} error${errorCount > 1 ? "s" : ""}` : "",
29
46
  warnCount > 0 ? `${warnCount} warning${warnCount > 1 ? "s" : ""}` : "",
30
- result.status === "timeout" ? "timed out, may be incomplete" : "",
47
+ result.status === "timeout" ? `timed out${retryNote}, may be incomplete` : "",
31
48
  ]
32
49
  .filter(Boolean)
33
50
  .join(", ");
34
51
 
35
- return `\n LSP diagnostics for ${filePath} (${summary}):\n${lines.join("\n")}${otherFilesFooter(result)}`;
52
+ const truncatedNote = truncated ? `\n ... and ${allRelevant.length - MAX_DIAGNOSTICS_PER_FILE} more` : "";
53
+
54
+ return `\n⚠ LSP diagnostics for ${filePath} (${summary}):\n${lines.join("\n")}${truncatedNote}${otherFilesFooter(result, cwd)}`;
36
55
  }
37
56
 
38
- function otherFilesFooter(result: DiagnosticResult): string {
57
+ function otherFilesFooter(result: DiagnosticResult, cwd?: string): string {
39
58
  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" : ""}`;
59
+ const lines = result.otherFiles.map((f) => {
60
+ let path: string;
61
+ try {
62
+ const abs = fileURLToPath(f.uri);
63
+ path = cwd ? relative(cwd, abs) : abs;
64
+ } catch {
65
+ path = f.uri;
66
+ }
67
+ const counts = [
68
+ f.errorCount > 0 ? `${f.errorCount} error${f.errorCount > 1 ? "s" : ""}` : "",
69
+ f.warningCount > 0 ? `${f.warningCount} warning${f.warningCount > 1 ? "s" : ""}` : "",
70
+ ].filter(Boolean).join(", ");
71
+ if (!f.firstDiagnostic) return ` ${path} (${counts})`;
72
+ const d = f.firstDiagnostic;
73
+ const sev = d.severity === DiagnosticSeverity.Error ? "error" : "warning";
74
+ const src = d.source ? `[${d.source}] ` : "";
75
+ return ` ${path} (${counts}): ${sev} ${d.line + 1}:${d.col + 1} ${src}${d.message}`;
76
+ });
77
+ return `\n${lines.join("\n")}`;
43
78
  }
@@ -0,0 +1,27 @@
1
+ export interface InstallEntry {
2
+ command: string;
3
+ description: string;
4
+ }
5
+
6
+ export const installRegistry = new Map<string, InstallEntry>([
7
+ ["go", {
8
+ command: "go install golang.org/x/tools/gopls@latest",
9
+ description: "Go language server",
10
+ }],
11
+ ["rust", {
12
+ command: "rustup component add rust-analyzer",
13
+ description: "Rust language server",
14
+ }],
15
+ ["typescript", {
16
+ command: "npm install -g typescript-language-server typescript",
17
+ description: "TypeScript/JavaScript language server",
18
+ }],
19
+ ["python", {
20
+ command: "pip install python-lsp-server",
21
+ description: "Python language server",
22
+ }],
23
+ ["cpp", {
24
+ command: "sudo apt-get install -y clangd || brew install llvm",
25
+ description: "C/C++ language server",
26
+ }],
27
+ ]);
package/src/languages.ts CHANGED
@@ -4,15 +4,19 @@ export interface LanguageServerConfig {
4
4
  command: string;
5
5
  args: string[];
6
6
  rootPatterns: string[];
7
+ diagnosticTimeout?: number;
8
+ maxRetries?: number;
9
+ languageIds?: Record<string, string>;
7
10
  }
8
11
 
9
- export const languages: LanguageServerConfig[] = [
12
+ export const builtinLanguages: LanguageServerConfig[] = [
10
13
  {
11
14
  id: "go",
12
15
  extensions: [".go"],
13
16
  command: "gopls",
14
17
  args: ["serve"],
15
18
  rootPatterns: ["go.mod"],
19
+ diagnosticTimeout: 5_000,
16
20
  },
17
21
  {
18
22
  id: "rust",
@@ -20,6 +24,7 @@ export const languages: LanguageServerConfig[] = [
20
24
  command: "rust-analyzer",
21
25
  args: [],
22
26
  rootPatterns: ["Cargo.toml"],
27
+ diagnosticTimeout: 30_000,
23
28
  },
24
29
  {
25
30
  id: "typescript",
@@ -27,18 +32,45 @@ export const languages: LanguageServerConfig[] = [
27
32
  command: "typescript-language-server",
28
33
  args: ["--stdio"],
29
34
  rootPatterns: ["tsconfig.json", "package.json"],
35
+ diagnosticTimeout: 30_000,
36
+ languageIds: { ".tsx": "typescriptreact", ".js": "javascript", ".jsx": "javascriptreact" },
37
+ },
38
+ {
39
+ id: "python",
40
+ extensions: [".py"],
41
+ command: "pylsp",
42
+ args: [],
43
+ rootPatterns: ["pyproject.toml", "setup.py", "requirements.txt"],
44
+ diagnosticTimeout: 15_000,
45
+ },
46
+ {
47
+ id: "cpp",
48
+ extensions: [".c", ".cc", ".cpp", ".cxx", ".h", ".hpp", ".hxx"],
49
+ command: "clangd",
50
+ args: [],
51
+ rootPatterns: ["compile_commands.json", "CMakeLists.txt", ".clangd"],
52
+ diagnosticTimeout: 15_000,
53
+ languageIds: { ".c": "c", ".h": "c", ".cc": "cpp", ".cxx": "cpp", ".hpp": "cpp", ".hxx": "cpp" },
30
54
  },
31
55
  ];
32
56
 
33
- export function languageForFile(path: string): LanguageServerConfig | undefined {
57
+ export function languageForFile(path: string, configs: LanguageServerConfig[]): LanguageServerConfig | undefined {
34
58
  const lower = path.toLowerCase();
35
- return languages.find((lang) => lang.extensions.some((ext) => lower.endsWith(ext)));
59
+ return configs.find((lang) => lang.extensions.some((ext) => lower.endsWith(ext)));
60
+ }
61
+
62
+ export function languageIdForFile(filePath: string, config: LanguageServerConfig): string {
63
+ if (config.languageIds) {
64
+ const ext = filePath.toLowerCase().match(/\.[^.]+$/)?.[0];
65
+ if (ext && config.languageIds[ext]) return config.languageIds[ext];
66
+ }
67
+ return config.id;
36
68
  }
37
69
 
38
- export function checkExtensionOverlaps(): string[] {
70
+ export function checkExtensionOverlaps(configs: LanguageServerConfig[]): string[] {
39
71
  const warnings: string[] = [];
40
72
  const seen = new Map<string, string>();
41
- for (const lang of languages) {
73
+ for (const lang of configs) {
42
74
  for (const ext of lang.extensions) {
43
75
  const existing = seen.get(ext);
44
76
  if (existing) {
@@ -1,7 +1,9 @@
1
1
  import { spawn, type ChildProcess } from "node:child_process";
2
2
  import { which, fileUri, findWorkspaceRoot } from "./util.js";
3
3
  import { createLspClient, type LspClient, type DiagnosticResult } from "./client.js";
4
- import type { LanguageServerConfig } from "./languages.js";
4
+ import { type LanguageServerConfig, languageIdForFile } from "./languages.js";
5
+ import type { Diagnostic } from "vscode-languageserver-protocol";
6
+ import { DEFAULT_DIAGNOSTIC_TIMEOUT, DEFAULT_DOCUMENT_IDLE_TIMEOUT, DEFAULT_MAX_RETRIES } from "./config.js";
5
7
  import { readFile } from "node:fs/promises";
6
8
 
7
9
  interface ManagedServer {
@@ -20,6 +22,7 @@ interface ManagedServer {
20
22
  export interface ServerManager {
21
23
  handleEdit(filePath: string, config: LanguageServerConfig, cwd: string): Promise<DiagnosticResult>;
22
24
  status(): ServerStatus[];
25
+ getAllDiagnostics(): Map<string, Diagnostic[]>;
23
26
  shutdownAll(): Promise<void>;
24
27
  }
25
28
 
@@ -33,12 +36,24 @@ export interface ServerStatus {
33
36
  }
34
37
 
35
38
  const IDLE_TIMEOUT_MS = 240_000;
36
- const DIAGNOSTIC_TIMEOUT_MS = 5_000;
37
39
  const INIT_TIMEOUT_MS = 10_000;
38
- const DOCUMENT_IDLE_MS = 120_000;
39
40
  const SWEEP_INTERVAL_MS = 60_000;
40
41
 
41
- export function createServerManager(): ServerManager {
42
+ export interface ServerManagerOptions {
43
+ diagnosticTimeout?: number;
44
+ documentIdleTimeout?: number;
45
+ perServerTimeout?: Map<string, number>;
46
+ maxRetries?: number;
47
+ }
48
+
49
+ const RETRY_BASE_DELAY_MS = 500;
50
+ const MAX_RETRY_DELAY_MS = 30_000;
51
+
52
+ export function createServerManager(options: ServerManagerOptions = {}): ServerManager {
53
+ const diagnosticTimeout = options.diagnosticTimeout ?? DEFAULT_DIAGNOSTIC_TIMEOUT;
54
+ const documentIdleTimeout = options.documentIdleTimeout ?? DEFAULT_DOCUMENT_IDLE_TIMEOUT;
55
+ const perServerTimeout = options.perServerTimeout ?? new Map();
56
+ const defaultMaxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
42
57
  const servers = new Map<string, ManagedServer>();
43
58
  const pending = new Map<string, Promise<ManagedServer | null>>();
44
59
  const disabledBinaries = new Set<string>();
@@ -51,7 +66,7 @@ export function createServerManager(): ServerManager {
51
66
  const now = Date.now();
52
67
  for (const server of servers.values()) {
53
68
  const stale = [...server.openDocuments.entries()]
54
- .filter(([, lastActive]) => now - lastActive > DOCUMENT_IDLE_MS);
69
+ .filter(([, lastActive]) => now - lastActive > documentIdleTimeout);
55
70
  for (const [docUri] of stale) {
56
71
  server.client.didClose(docUri);
57
72
  server.openDocuments.delete(docUri);
@@ -156,7 +171,7 @@ export function createServerManager(): ServerManager {
156
171
  idleTimer: null,
157
172
  startTime: now,
158
173
  lastActivity: now,
159
- editQueue: Promise.resolve({ status: "ok", diagnostics: [], otherFiles: [] }),
174
+ editQueue: Promise.resolve({ status: "ok", diagnostics: [], otherFiles: [], retryAttempts: 0 }),
160
175
  };
161
176
 
162
177
  child.on("exit", () => {
@@ -189,27 +204,54 @@ export function createServerManager(): ServerManager {
189
204
  return promise;
190
205
  }
191
206
 
207
+ function getMaxRetries(config: LanguageServerConfig): number {
208
+ const raw = config.maxRetries ?? defaultMaxRetries;
209
+ if (typeof raw !== "number" || !Number.isFinite(raw)) return defaultMaxRetries;
210
+ return Math.max(0, Math.min(10, Math.floor(raw)));
211
+ }
212
+
192
213
  async function doEdit(server: ManagedServer, filePath: string): Promise<DiagnosticResult> {
193
214
  resetIdleTimer(server);
194
215
 
195
216
  const uri = fileUri(filePath);
196
217
  const content = await readFile(filePath, "utf-8");
218
+ const timeout = perServerTimeout.get(server.config.id) ?? server.config.diagnosticTimeout ?? diagnosticTimeout;
219
+ const retries = getMaxRetries(server.config);
197
220
 
198
221
  if (server.openDocuments.has(uri)) {
199
222
  server.client.didChange(uri, content);
200
223
  } else {
201
- server.client.didOpen(uri, server.config.id, content);
224
+ server.client.didOpen(uri, languageIdForFile(filePath, server.config), content);
202
225
  }
203
226
  server.openDocuments.set(uri, Date.now());
204
227
 
205
- return server.client.waitForDiagnostics(uri, DIAGNOSTIC_TIMEOUT_MS);
228
+ let lastResult = await server.client.waitForDiagnostics(uri, timeout);
229
+
230
+ for (let attempt = 0; attempt < retries && lastResult.status === "timeout"; attempt++) {
231
+ resetIdleTimer(server);
232
+ const baseDelay = Math.min(RETRY_BASE_DELAY_MS * 2 ** attempt, MAX_RETRY_DELAY_MS);
233
+ const jitter = baseDelay * Math.random() * 0.5;
234
+ await new Promise((resolve) => setTimeout(resolve, baseDelay + jitter));
235
+
236
+ server.client.didChange(uri, content);
237
+ server.openDocuments.set(uri, Date.now());
238
+ const result = await server.client.waitForDiagnostics(uri, timeout);
239
+ result.retryAttempts = attempt + 1;
240
+
241
+ if (result.status === "ok") {
242
+ return result;
243
+ }
244
+ lastResult = result;
245
+ }
246
+
247
+ return lastResult;
206
248
  }
207
249
 
208
250
  return {
209
251
  async handleEdit(filePath: string, config: LanguageServerConfig, cwd: string): Promise<DiagnosticResult> {
210
252
  const root = await findWorkspaceRoot(filePath, config.rootPatterns, cwd);
211
253
  const server = await ensureServer(config, root);
212
- if (!server) return { status: "unavailable" as const, diagnostics: [], otherFiles: [] };
254
+ if (!server) return { status: "unavailable" as const, diagnostics: [], otherFiles: [], retryAttempts: 0 };
213
255
 
214
256
  // serialize edits per server to avoid concurrent waitForDiagnostics races
215
257
  const result = server.editQueue.then(
@@ -231,6 +273,16 @@ export function createServerManager(): ServerManager {
231
273
  }));
232
274
  },
233
275
 
276
+ getAllDiagnostics(): Map<string, Diagnostic[]> {
277
+ const result = new Map<string, Diagnostic[]>();
278
+ for (const server of servers.values()) {
279
+ for (const [uri, diags] of server.client.getAllDiagnostics()) {
280
+ result.set(uri, diags);
281
+ }
282
+ }
283
+ return result;
284
+ },
285
+
234
286
  async shutdownAll() {
235
287
  stopSweepTimer();
236
288
  const shutdowns = Array.from(servers.values()).map((s) => shutdownServer(s));
package/src/util.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { access, constants } from "node:fs/promises";
2
- import { join, dirname } from "node:path";
2
+ import { join, dirname, relative, isAbsolute } from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
4
 
5
5
  export function fileUri(absolutePath: string): string {
@@ -42,3 +42,8 @@ export async function findWorkspaceRoot(filePath: string, rootPatterns: string[]
42
42
  }
43
43
  return cwd;
44
44
  }
45
+
46
+ export function isInsideCwd(absolutePath: string, cwd: string): boolean {
47
+ const rel = relative(cwd, absolutePath);
48
+ return !!rel && !rel.startsWith("..") && !isAbsolute(rel);
49
+ }