pi-lsp-lite 0.3.2 → 0.5.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
@@ -53,7 +53,7 @@ The agent sees these too — they're appended to the tool result, so it can self
53
53
  | `pylsp` | Python | `pip install python-lsp-server` |
54
54
  | `clangd` | C/C++ | Xcode CLI tools / `apt install clangd` |
55
55
 
56
- Missing a server? `/lsp-add` lets you configure any LSP server that speaks stdio. Or add it to `.pi-lsp-lite.json`:
56
+ Missing a server? `/lsp-add` lets you configure any LSP server that speaks stdio. Or add it to global config (`~/.pi-lsp-lite.json`):
57
57
 
58
58
  ```json
59
59
  {
@@ -70,7 +70,7 @@ Missing a server? `/lsp-add` lets you configure any LSP server that speaks stdio
70
70
 
71
71
  ## Configuration
72
72
 
73
- Works without config. For customisation, create `.pi-lsp-lite.json` (project) or `~/.pi-lsp-lite.json` (global):
73
+ Works without config. Use project config (`.pi-lsp-lite.json` or `.pi/lsp-lite.json`) for safe local tuning, and global config (`~/.pi-lsp-lite.json`) for trusted executable/server-shape changes like custom servers, `command`, `args`, `extensions`, and `rootPatterns`:
74
74
 
75
75
  | Field | Description | Default |
76
76
  |-------|-------------|---------|
@@ -80,7 +80,7 @@ Works without config. For customisation, create `.pi-lsp-lite.json` (project) or
80
80
  | `diagnosticTimeout` | Global default timeout (ms) | `5000` |
81
81
  | `documentIdleTimeout` | Close idle documents after (ms) | `120000` |
82
82
 
83
- Project config merges over global. Partial overrides work only specify what you want to change.
83
+ Project config merges over global for safe tuning fields. Repositories can disable servers and tune timeouts/retries, but they cannot change the executable, argv, extensions, or root patterns for any existing server; put those trusted changes in global config.
84
84
 
85
85
  ## How it works
86
86
 
@@ -101,7 +101,7 @@ Servers are lazy (spawn on first edit), idle-shutdown after 240s, and clean up o
101
101
  git clone https://github.com/mcphailtom/pi-lsp-lite
102
102
  cd pi-lsp-lite && npm install
103
103
  npm run check # typecheck
104
- npm test # unit tests (106, no servers needed)
104
+ npm test # unit tests (no servers needed)
105
105
  npm run test:integration # real server tests (needs servers on PATH)
106
106
  ```
107
107
 
package/index.ts CHANGED
@@ -4,9 +4,11 @@ import { languageForFile, checkExtensionOverlaps, builtinLanguages, type Languag
4
4
  import { formatDiagnostics } from "./src/format.js";
5
5
  import { DiagnosticSeverity } from "vscode-languageserver-protocol";
6
6
  import { loadConfig, writeGlobalConfig, readGlobalConfig } from "./src/config.js";
7
- import { fileUri, which } from "./src/util.js";
8
- import { installRegistry } from "./src/install-registry.js";
9
- import { resolve, relative, isAbsolute } from "node:path";
7
+ import { fileUri, which, isInsideCwd } from "./src/util.js";
8
+ import { installRegistry, installCommandFor } from "./src/install-registry.js";
9
+ import { buildServerStates, formatServerStates } from "./src/status.js";
10
+ import { resolve } from "node:path";
11
+ import { realpath } from "node:fs/promises";
10
12
  import { fileURLToPath } from "node:url";
11
13
 
12
14
  export default function (pi: ExtensionAPI) {
@@ -28,6 +30,17 @@ export default function (pi: ExtensionAPI) {
28
30
  }
29
31
  }
30
32
 
33
+ async function currentServerStates() {
34
+ return buildServerStates({
35
+ builtins: builtinLanguages,
36
+ active: servers,
37
+ globalConfig: await readGlobalConfig(),
38
+ running: manager.status(),
39
+ installRegistry,
40
+ resolveCommand: which,
41
+ });
42
+ }
43
+
31
44
  pi.on("session_start", async (_event, ctx) => {
32
45
  await initConfig(ctx.cwd);
33
46
  });
@@ -38,16 +51,21 @@ export default function (pi: ExtensionAPI) {
38
51
  const rawPath = event.input?.path;
39
52
  const filePath = typeof rawPath === "string" ? rawPath : undefined;
40
53
  if (!filePath) return;
54
+ if (event.isError) return;
41
55
 
42
- const absolutePath = resolve(ctx.cwd, filePath);
43
- const rel = relative(ctx.cwd, absolutePath);
44
- if (!rel || rel.startsWith("..") || isAbsolute(rel)) return;
56
+ let absolutePath: string;
57
+ try {
58
+ absolutePath = await realpath(resolve(ctx.cwd, filePath));
59
+ } catch {
60
+ return;
61
+ }
62
+ if (!isInsideCwd(absolutePath, ctx.cwd)) return;
45
63
  const langConfig = languageForFile(absolutePath, servers);
46
64
  if (!langConfig) return;
47
65
 
48
66
  try {
49
67
  const result = await manager.handleEdit(absolutePath, langConfig, ctx.cwd);
50
- const formatted = formatDiagnostics(filePath, result);
68
+ const formatted = formatDiagnostics(filePath, result, ctx.cwd);
51
69
  if (!formatted) return;
52
70
 
53
71
  ctx.ui.notify(formatted.trim(), "warning");
@@ -65,19 +83,9 @@ export default function (pi: ExtensionAPI) {
65
83
  });
66
84
 
67
85
  pi.registerCommand("lsp-status", {
68
- description: "Show running LSP servers and recent diagnostic counts",
86
+ description: "Show configured LSP servers, install state, and running processes",
69
87
  handler: async (_args, ctx) => {
70
- const running = manager.status();
71
- if (running.length === 0) {
72
- ctx.ui.notify("pi-lsp-lite: no servers running", "info");
73
- return;
74
- }
75
- const lines = running.map((s) => {
76
- const idle = Math.round((Date.now() - s.lastActivity) / 1000);
77
- const up = Math.round(s.uptime / 1000);
78
- return `${s.id} (pid ${s.pid}) root=${s.root} — ${s.openDocuments} open files, up ${up}s, idle ${idle}s`;
79
- });
80
- ctx.ui.notify(lines.join("\n"), "info");
88
+ ctx.ui.notify(formatServerStates(await currentServerStates()), "info");
81
89
  },
82
90
  });
83
91
 
@@ -163,18 +171,19 @@ export default function (pi: ExtensionAPI) {
163
171
  const rootPatterns = rootRaw ? rootRaw.split(",").map((r) => r.trim()).filter(Boolean) : [];
164
172
 
165
173
  const resolved = await which(command);
166
- if (!resolved) {
167
- ctx.ui.notify(`pi-lsp-lite: "${command}" not found on PATH — server added but won't start until installed`, "warning");
168
- }
169
-
170
174
  await writeGlobalConfig({ servers: { [id]: { command, args, extensions, rootPatterns } } });
171
175
  await initConfig(ctx.cwd);
172
- ctx.ui.notify(`pi-lsp-lite: added server "${id}"`, "info");
176
+
177
+ if (!resolved) {
178
+ ctx.ui.notify(`pi-lsp-lite: configured server "${id}", but "${command}" is missing from PATH — install it manually before use`, "warning");
179
+ return;
180
+ }
181
+ ctx.ui.notify(`pi-lsp-lite: configured server "${id}" (${resolved})`, "info");
173
182
  },
174
183
  });
175
184
 
176
185
  pi.registerCommand("lsp-remove", {
177
- description: "Remove or disable a language server",
186
+ description: "Disable a language server",
178
187
  handler: async (_args, ctx) => {
179
188
  if (!ctx.hasUI) {
180
189
  ctx.ui.notify("pi-lsp-lite: /lsp-remove requires interactive mode", "error");
@@ -187,10 +196,10 @@ export default function (pi: ExtensionAPI) {
187
196
  }
188
197
 
189
198
  const ids = servers.map((s) => s.id);
190
- const selected = await ctx.ui.select("Remove which server?", ids);
199
+ const selected = await ctx.ui.select("Disable which server?", ids);
191
200
  if (!selected) return;
192
201
 
193
- const confirmed = await ctx.ui.confirm("Confirm removal", `Disable server "${selected}"?`);
202
+ const confirmed = await ctx.ui.confirm("Confirm disable", `Disable server "${selected}"?`);
194
203
  if (!confirmed) return;
195
204
 
196
205
  await writeGlobalConfig({ servers: { [selected]: { disabled: true } } });
@@ -240,7 +249,23 @@ export default function (pi: ExtensionAPI) {
240
249
  }
241
250
 
242
251
  await initConfig(ctx.cwd);
243
- ctx.ui.notify(`pi-lsp-lite: ${isCurrentlyEnabled ? "disabled" : "enabled"} server "${id}"`, "info");
252
+
253
+ if (isCurrentlyEnabled) {
254
+ ctx.ui.notify(`pi-lsp-lite: disabled server "${id}"`, "info");
255
+ return;
256
+ }
257
+
258
+ const state = (await currentServerStates()).find((s) => s.id === id);
259
+ if (state?.installed === false) {
260
+ const installHint = state.installable ? "run /lsp-install" : "install it manually";
261
+ ctx.ui.notify(`pi-lsp-lite: enabled server "${id}", but "${state.command}" is missing from PATH — ${installHint} before use`, "warning");
262
+ return;
263
+ }
264
+ if (state?.installed === null) {
265
+ ctx.ui.notify(`pi-lsp-lite: enabled server "${id}", but its command is incomplete — check global config`, "warning");
266
+ return;
267
+ }
268
+ ctx.ui.notify(`pi-lsp-lite: enabled server "${id}"`, "info");
244
269
  },
245
270
  });
246
271
 
@@ -257,13 +282,13 @@ export default function (pi: ExtensionAPI) {
257
282
  const lang = builtinLanguages.find((l) => l.id === id);
258
283
  const binary = lang?.command ?? id;
259
284
  const found = await which(binary);
260
- return found ? null : { id, command: binary, installCmd: entry.command, description: entry.description };
285
+ return found ? null : { id, command: binary, installCmd: installCommandFor(entry), description: entry.description };
261
286
  }),
262
287
  );
263
288
  const missing = checks.filter((c): c is NonNullable<typeof c> => c !== null);
264
289
 
265
290
  if (missing.length === 0) {
266
- ctx.ui.notify("pi-lsp-lite: all known servers are available", "info");
291
+ ctx.ui.notify("pi-lsp-lite: all built-in installable servers are available; custom servers must be installed manually", "info");
267
292
  return;
268
293
  }
269
294
 
@@ -277,7 +302,9 @@ export default function (pi: ExtensionAPI) {
277
302
  const confirmed = await ctx.ui.confirm("Confirm install", `Run: ${selected.installCmd}`);
278
303
  if (!confirmed) return;
279
304
 
280
- const result = await pi.exec("sh", ["-c", selected.installCmd]);
305
+ const result = process.platform === "win32"
306
+ ? await pi.exec(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", selected.installCmd])
307
+ : await pi.exec("sh", ["-c", selected.installCmd]);
281
308
  if (result.code !== 0) {
282
309
  ctx.ui.notify(`pi-lsp-lite: install failed (exit ${result.code})\n${result.stderr}`, "error");
283
310
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lsp-lite",
3
- "version": "0.3.2",
3
+ "version": "0.5.0",
4
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,
@@ -29,7 +29,9 @@
29
29
  ]
30
30
  },
31
31
  "dependencies": {
32
- "vscode-languageserver-protocol": "^3.17.5"
32
+ "cross-spawn": "^7.0.6",
33
+ "vscode-languageserver-protocol": "~3.18.1",
34
+ "which": "^2.0.2"
33
35
  },
34
36
  "peerDependencies": {
35
37
  "@earendil-works/pi-coding-agent": "*",
@@ -37,13 +39,19 @@
37
39
  },
38
40
  "devDependencies": {
39
41
  "@earendil-works/pi-coding-agent": "^0.74.0",
40
- "@types/node": "^20.0.0",
42
+ "@types/cross-spawn": "^6.0.6",
43
+ "@types/node": "^22.0.0",
44
+ "@types/which": "^2.0.2",
45
+ "cross-env": "^7.0.3",
41
46
  "tsx": "^4.21.0",
42
47
  "typescript": "^5.4.0"
43
48
  },
44
49
  "scripts": {
45
50
  "check": "tsc --noEmit",
46
51
  "test": "tsx --test test/*.test.ts",
47
- "test:integration": "INTEGRATION=1 tsx --test test/*.test.ts test/integration/*.test.ts"
52
+ "test:integration": "cross-env INTEGRATION=1 tsx --test test/*.test.ts test/integration/*.test.ts"
53
+ },
54
+ "engines": {
55
+ "node": ">=22.19.0"
48
56
  }
49
57
  }
package/src/client.ts CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  DiagnosticSeverity,
14
14
  type InitializeParams,
15
15
  type Diagnostic,
16
- } from "vscode-languageserver-protocol/node.js";
16
+ } from "vscode-languageserver-protocol/node";
17
17
  import type { ChildProcess } from "node:child_process";
18
18
  import { fileUri } from "./util.js";
19
19
 
@@ -21,6 +21,7 @@ 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 {
@@ -53,6 +54,32 @@ function countDiagnostics(diags: Diagnostic[]): { errors: number; warnings: numb
53
54
  return { errors, warnings };
54
55
  }
55
56
 
57
+ function diagnosticMessage(d: Diagnostic): string {
58
+ return typeof d.message === "string" ? d.message : d.message.value;
59
+ }
60
+
61
+ function diagnosticFingerprint(d: Diagnostic): string {
62
+ return `${d.severity}:${d.range.start.line}:${d.range.start.character}:${diagnosticMessage(d)}`;
63
+ }
64
+
65
+ function fingerprintSet(diags: Diagnostic[]): Set<string> {
66
+ const set = new Set<string>();
67
+ for (const d of diags) {
68
+ if (d.severity === DiagnosticSeverity.Error || d.severity === DiagnosticSeverity.Warning) {
69
+ set.add(diagnosticFingerprint(d));
70
+ }
71
+ }
72
+ return set;
73
+ }
74
+
75
+ function setsEqual(a: Set<string>, b: Set<string>): boolean {
76
+ if (a.size !== b.size) return false;
77
+ for (const v of a) {
78
+ if (!b.has(v)) return false;
79
+ }
80
+ return true;
81
+ }
82
+
56
83
  export function createLspClient(child: ChildProcess): LspClient {
57
84
  if (!child.stdout || !child.stdin) {
58
85
  throw new Error("LSP child process must be spawned with stdio: pipe");
@@ -152,10 +179,10 @@ export function createLspClient(child: ChildProcess): LspClient {
152
179
  async waitForDiagnostics(uri: string, timeoutMs: number): Promise<DiagnosticResult> {
153
180
  const targetGen = uriGeneration.get(uri) ?? 0;
154
181
 
155
- const preSnapshot = new Map<string, { errors: number; warnings: number }>();
182
+ const preSnapshot = new Map<string, Set<string>>();
156
183
  for (const [trackedUri, entry] of diagnosticsMap) {
157
184
  if (trackedUri !== uri) {
158
- preSnapshot.set(trackedUri, countDiagnostics(entry.diagnostics));
185
+ preSnapshot.set(trackedUri, fingerprintSet(entry.diagnostics));
159
186
  }
160
187
  }
161
188
 
@@ -163,13 +190,27 @@ export function createLspClient(child: ChildProcess): LspClient {
163
190
  const result: OtherFileDiagnostics[] = [];
164
191
  for (const [trackedUri, entry] of diagnosticsMap) {
165
192
  if (trackedUri === uri) continue;
193
+ const postFp = fingerprintSet(entry.diagnostics);
194
+ const preFp = preSnapshot.get(trackedUri) ?? new Set();
195
+ if (setsEqual(postFp, preFp)) continue;
166
196
  const post = countDiagnostics(entry.diagnostics);
167
- const pre = preSnapshot.get(trackedUri) ?? { errors: 0, warnings: 0 };
168
- const newErrors = post.errors - pre.errors;
169
- const newWarnings = post.warnings - pre.warnings;
170
- if (newErrors > 0 || newWarnings > 0) {
171
- result.push({ uri: trackedUri, errorCount: newErrors, warningCount: newWarnings });
172
- }
197
+ const first =
198
+ entry.diagnostics.find((d) => d.severity === DiagnosticSeverity.Error) ??
199
+ entry.diagnostics.find((d) => d.severity === DiagnosticSeverity.Warning);
200
+ result.push({
201
+ uri: trackedUri,
202
+ errorCount: post.errors,
203
+ warningCount: post.warnings,
204
+ ...(first && {
205
+ firstDiagnostic: {
206
+ severity: first.severity ?? DiagnosticSeverity.Error,
207
+ line: first.range.start.line,
208
+ col: first.range.start.character,
209
+ message: diagnosticMessage(first),
210
+ ...(first.source && { source: first.source }),
211
+ },
212
+ }),
213
+ });
173
214
  }
174
215
  return result;
175
216
  };
@@ -214,9 +255,9 @@ export function createLspClient(child: ChildProcess): LspClient {
214
255
  // edited file is valid but dependents break
215
256
  crossFileCallback = (changedUri: string) => {
216
257
  if (settled || changedUri === uri) return;
217
- const pre = preSnapshot.get(changedUri) ?? { errors: 0, warnings: 0 };
218
- const post = countDiagnostics(diagnosticsMap.get(changedUri)?.diagnostics ?? []);
219
- if (post.errors !== pre.errors || post.warnings !== pre.warnings) {
258
+ const preFp = preSnapshot.get(changedUri) ?? new Set<string>();
259
+ const postFp = fingerprintSet(diagnosticsMap.get(changedUri)?.diagnostics ?? []);
260
+ if (!setsEqual(preFp, postFp)) {
220
261
  clearTimeout(timeout);
221
262
  resetQuiescence();
222
263
  }
@@ -248,7 +289,7 @@ export function createLspClient(child: ChildProcess): LspClient {
248
289
  timer = setTimeout(() => reject(new Error("shutdown timed out")), SHUTDOWN_TIMEOUT_MS);
249
290
  }),
250
291
  ]);
251
- connection.sendNotification(ExitNotification.type);
292
+ await connection.sendNotification(ExitNotification.type);
252
293
  } catch {
253
294
  // timed out or server already exited
254
295
  } finally {
package/src/config.ts CHANGED
@@ -66,7 +66,7 @@ function validateOverride(id: string, raw: unknown): ServerConfigOverride | null
66
66
  console.error(`[pi-lsp-lite] config "${id}": extensions must be a non-empty string array, skipping`);
67
67
  return null;
68
68
  }
69
- override.extensions = (raw.extensions as string[]).map((e) => e.toLowerCase());
69
+ override.extensions = raw.extensions.map((e) => e.toLowerCase());
70
70
  }
71
71
 
72
72
  if (raw.command !== undefined) {
@@ -74,7 +74,7 @@ function validateOverride(id: string, raw: unknown): ServerConfigOverride | null
74
74
  console.error(`[pi-lsp-lite] config "${id}": command must be a non-empty string, skipping`);
75
75
  return null;
76
76
  }
77
- override.command = raw.command as string;
77
+ override.command = raw.command;
78
78
  }
79
79
 
80
80
  if (raw.args !== undefined) {
@@ -82,7 +82,7 @@ function validateOverride(id: string, raw: unknown): ServerConfigOverride | null
82
82
  console.error(`[pi-lsp-lite] config "${id}": args must be a string array, skipping`);
83
83
  return null;
84
84
  }
85
- override.args = raw.args as string[];
85
+ override.args = raw.args;
86
86
  }
87
87
 
88
88
  if (raw.rootPatterns !== undefined) {
@@ -90,7 +90,7 @@ function validateOverride(id: string, raw: unknown): ServerConfigOverride | null
90
90
  console.error(`[pi-lsp-lite] config "${id}": rootPatterns must be a string array, skipping`);
91
91
  return null;
92
92
  }
93
- override.rootPatterns = raw.rootPatterns as string[];
93
+ override.rootPatterns = raw.rootPatterns;
94
94
  }
95
95
 
96
96
  if (raw.diagnosticTimeout !== undefined) {
@@ -150,12 +150,18 @@ async function findProjectConfig(cwd: string): Promise<UserConfig | null> {
150
150
 
151
151
  type ConfigSource = "global" | "project";
152
152
 
153
+ interface MergeResult {
154
+ servers: LanguageServerConfig[];
155
+ perServerTimeouts: Map<string, number>;
156
+ }
157
+
153
158
  function mergeConfigs(
154
159
  base: LanguageServerConfig[],
155
160
  overrides: Record<string, ServerConfigOverride>,
156
161
  source: ConfigSource,
157
- ): LanguageServerConfig[] {
162
+ ): MergeResult {
158
163
  const result = new Map<string, LanguageServerConfig>();
164
+ const perServerTimeouts = new Map<string, number>();
159
165
 
160
166
  for (const server of base) {
161
167
  result.set(server.id, { ...server });
@@ -170,9 +176,21 @@ function mergeConfigs(
170
176
  continue;
171
177
  }
172
178
 
179
+ if (override.diagnosticTimeout !== undefined) {
180
+ perServerTimeouts.set(id, override.diagnosticTimeout);
181
+ }
182
+
173
183
  const existing = result.get(id);
174
184
  if (existing) {
175
185
  const { disabled: _, diagnosticTimeout: __, ...lspFields } = override;
186
+ if (source === "project") {
187
+ for (const field of ["command", "args", "extensions", "rootPatterns"] as const) {
188
+ if (lspFields[field] !== undefined) {
189
+ console.error(`[pi-lsp-lite] project config cannot override "${field}" for server "${id}" — ignoring (use global config instead)`);
190
+ delete lspFields[field];
191
+ }
192
+ }
193
+ }
176
194
  const defined = Object.fromEntries(
177
195
  Object.entries(lspFields).filter(([, v]) => v !== undefined),
178
196
  );
@@ -197,7 +215,7 @@ function mergeConfigs(
197
215
  }
198
216
  }
199
217
 
200
- return Array.from(result.values());
218
+ return { servers: Array.from(result.values()), perServerTimeouts };
201
219
  }
202
220
 
203
221
  export function globalConfigFilePath(globalConfigPath?: string): string {
@@ -275,12 +293,10 @@ export async function loadConfig(cwd: string, globalConfigPath?: string): Promis
275
293
  for (const [layer, source] of layers) {
276
294
  if (!layer) continue;
277
295
  if (layer.servers && isPlainObject(layer.servers)) {
278
- servers = mergeConfigs(servers, layer.servers as Record<string, ServerConfigOverride>, source);
279
- for (const [id, rawOverride] of Object.entries(layer.servers)) {
280
- const override = validateOverride(id, rawOverride);
281
- if (override?.diagnosticTimeout !== undefined) {
282
- perServerTimeout.set(id, override.diagnosticTimeout);
283
- }
296
+ const merged = mergeConfigs(servers, layer.servers as Record<string, ServerConfigOverride>, source);
297
+ servers = merged.servers;
298
+ for (const [id, timeout] of merged.perServerTimeouts) {
299
+ perServerTimeout.set(id, timeout);
284
300
  }
285
301
  }
286
302
  if (layer.diagnosticTimeout !== undefined) {
package/src/format.ts CHANGED
@@ -1,20 +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;
11
21
 
12
22
  const retryNote = result.status === "timeout" && result.retryAttempts > 0
13
23
  ? ` after ${result.retryAttempts} ${result.retryAttempts === 1 ? "retry" : "retries"}`
14
24
  : "";
15
25
 
16
26
  if (relevant.length === 0 && result.status === "ok" && result.otherFiles.length > 0) {
17
- return `\n⚠ LSP diagnostics for ${filePath}: no issues${otherFilesFooter(result)}`;
27
+ return `\n⚠ LSP diagnostics for ${filePath}: no issues${otherFilesFooter(result, cwd)}`;
18
28
  }
19
29
 
20
30
  const lines = relevant.map((d) => {
@@ -25,8 +35,11 @@ export function formatDiagnostics(filePath: string, result: DiagnosticResult): s
25
35
  return ` ${severity} ${line}:${col} ${source}${d.message}`;
26
36
  });
27
37
 
28
- const errorCount = relevant.filter((d) => d.severity === DiagnosticSeverity.Error).length;
29
- 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;
30
43
 
31
44
  const summary = [
32
45
  errorCount > 0 ? `${errorCount} error${errorCount > 1 ? "s" : ""}` : "",
@@ -36,12 +49,30 @@ export function formatDiagnostics(filePath: string, result: DiagnosticResult): s
36
49
  .filter(Boolean)
37
50
  .join(", ");
38
51
 
39
- 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)}`;
40
55
  }
41
56
 
42
- function otherFilesFooter(result: DiagnosticResult): string {
57
+ function otherFilesFooter(result: DiagnosticResult, cwd?: string): string {
43
58
  if (result.otherFiles.length === 0) return "";
44
- const totalDiags = result.otherFiles.reduce((sum, f) => sum + f.errorCount + f.warningCount, 0);
45
- const fileCount = result.otherFiles.length;
46
- 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")}`;
47
78
  }
@@ -1,27 +1,37 @@
1
1
  export interface InstallEntry {
2
- command: string;
2
+ // Per-platform install command; `win32` overrides `default` on Windows.
3
+ command: { default: string; win32?: string };
3
4
  description: string;
4
5
  }
5
6
 
6
7
  export const installRegistry = new Map<string, InstallEntry>([
7
8
  ["go", {
8
- command: "go install golang.org/x/tools/gopls@latest",
9
+ command: { default: "go install golang.org/x/tools/gopls@latest" },
9
10
  description: "Go language server",
10
11
  }],
11
12
  ["rust", {
12
- command: "rustup component add rust-analyzer",
13
+ command: { default: "rustup component add rust-analyzer" },
13
14
  description: "Rust language server",
14
15
  }],
15
16
  ["typescript", {
16
- command: "npm install -g typescript-language-server typescript",
17
+ command: { default: "npm install -g typescript-language-server typescript" },
17
18
  description: "TypeScript/JavaScript language server",
18
19
  }],
19
20
  ["python", {
20
- command: "pip install python-lsp-server",
21
+ command: { default: "pip install python-lsp-server" },
21
22
  description: "Python language server",
22
23
  }],
23
24
  ["cpp", {
24
- command: "sudo apt-get install -y clangd || brew install llvm",
25
+ command: {
26
+ default: "sudo apt-get install -y clangd || brew install llvm",
27
+ win32: "winget install -e --id LLVM.clangd",
28
+ },
25
29
  description: "C/C++ language server",
26
30
  }],
27
31
  ]);
32
+
33
+ // Resolve the install command for the current platform.
34
+ export function installCommandFor(entry: InstallEntry): string {
35
+ if (process.platform === "win32" && entry.command.win32) return entry.command.win32;
36
+ return entry.command.default;
37
+ }
package/src/languages.ts CHANGED
@@ -6,6 +6,7 @@ export interface LanguageServerConfig {
6
6
  rootPatterns: string[];
7
7
  diagnosticTimeout?: number;
8
8
  maxRetries?: number;
9
+ languageIds?: Record<string, string>;
9
10
  }
10
11
 
11
12
  export const builtinLanguages: LanguageServerConfig[] = [
@@ -32,6 +33,7 @@ export const builtinLanguages: LanguageServerConfig[] = [
32
33
  args: ["--stdio"],
33
34
  rootPatterns: ["tsconfig.json", "package.json"],
34
35
  diagnosticTimeout: 30_000,
36
+ languageIds: { ".tsx": "typescriptreact", ".js": "javascript", ".jsx": "javascriptreact" },
35
37
  },
36
38
  {
37
39
  id: "python",
@@ -48,6 +50,7 @@ export const builtinLanguages: LanguageServerConfig[] = [
48
50
  args: [],
49
51
  rootPatterns: ["compile_commands.json", "CMakeLists.txt", ".clangd"],
50
52
  diagnosticTimeout: 15_000,
53
+ languageIds: { ".c": "c", ".h": "c", ".cc": "cpp", ".cxx": "cpp", ".hpp": "cpp", ".hxx": "cpp" },
51
54
  },
52
55
  ];
53
56
 
@@ -56,6 +59,14 @@ export function languageForFile(path: string, configs: LanguageServerConfig[]):
56
59
  return configs.find((lang) => lang.extensions.some((ext) => lower.endsWith(ext)));
57
60
  }
58
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;
68
+ }
69
+
59
70
  export function checkExtensionOverlaps(configs: LanguageServerConfig[]): string[] {
60
71
  const warnings: string[] = [];
61
72
  const seen = new Map<string, string>();
@@ -1,7 +1,8 @@
1
- import { spawn, type ChildProcess } from "node:child_process";
1
+ import { type ChildProcess } from "node:child_process";
2
+ import spawn from "cross-spawn";
2
3
  import { which, fileUri, findWorkspaceRoot } from "./util.js";
3
4
  import { createLspClient, type LspClient, type DiagnosticResult } from "./client.js";
4
- import type { LanguageServerConfig } from "./languages.js";
5
+ import { type LanguageServerConfig, languageIdForFile } from "./languages.js";
5
6
  import type { Diagnostic } from "vscode-languageserver-protocol";
6
7
  import { DEFAULT_DIAGNOSTIC_TIMEOUT, DEFAULT_DOCUMENT_IDLE_TIMEOUT, DEFAULT_MAX_RETRIES } from "./config.js";
7
8
  import { readFile } from "node:fs/promises";
@@ -221,7 +222,7 @@ export function createServerManager(options: ServerManagerOptions = {}): ServerM
221
222
  if (server.openDocuments.has(uri)) {
222
223
  server.client.didChange(uri, content);
223
224
  } else {
224
- server.client.didOpen(uri, server.config.id, content);
225
+ server.client.didOpen(uri, languageIdForFile(filePath, server.config), content);
225
226
  }
226
227
  server.openDocuments.set(uri, Date.now());
227
228
 
package/src/status.ts ADDED
@@ -0,0 +1,95 @@
1
+ import type { UserConfig } from "./config.js";
2
+ import type { InstallEntry } from "./install-registry.js";
3
+ import type { LanguageServerConfig } from "./languages.js";
4
+ import type { ServerStatus } from "./server-manager.js";
5
+
6
+ export interface ServerState {
7
+ id: string;
8
+ command: string | null;
9
+ enabled: boolean;
10
+ installed: boolean | null;
11
+ installable: boolean;
12
+ running: ServerStatus[];
13
+ }
14
+
15
+ export interface BuildServerStatesOptions {
16
+ builtins: LanguageServerConfig[];
17
+ active: LanguageServerConfig[];
18
+ globalConfig: UserConfig | null;
19
+ running: ServerStatus[];
20
+ installRegistry: Map<string, InstallEntry>;
21
+ resolveCommand(command: string): Promise<string | null>;
22
+ }
23
+
24
+ const RESERVED_SERVER_KEYS = new Set(["__proto__", "constructor", "prototype"]);
25
+
26
+ function globalServerIds(globalConfig: UserConfig | null): string[] {
27
+ const servers = globalConfig?.servers;
28
+ if (!servers || typeof servers !== "object" || Array.isArray(servers)) return [];
29
+ return Object.keys(servers).filter((id) => !RESERVED_SERVER_KEYS.has(id));
30
+ }
31
+
32
+ function commandFor(id: string, active: Map<string, LanguageServerConfig>, builtins: Map<string, LanguageServerConfig>, globalConfig: UserConfig | null): string | null {
33
+ const activeConfig = active.get(id);
34
+ if (activeConfig) return activeConfig.command;
35
+
36
+ const globalCommand = globalConfig?.servers?.[id]?.command;
37
+ if (typeof globalCommand === "string" && globalCommand.length > 0) return globalCommand;
38
+
39
+ const builtin = builtins.get(id);
40
+ return builtin?.command ?? null;
41
+ }
42
+
43
+ export async function buildServerStates(options: BuildServerStatesOptions): Promise<ServerState[]> {
44
+ const active = new Map(options.active.map((server) => [server.id, server]));
45
+ const builtins = new Map(options.builtins.map((server) => [server.id, server]));
46
+ const runningById = new Map<string, ServerStatus[]>();
47
+
48
+ for (const status of options.running) {
49
+ const entries = runningById.get(status.id) ?? [];
50
+ entries.push(status);
51
+ runningById.set(status.id, entries);
52
+ }
53
+
54
+ const ids = new Set<string>([
55
+ ...builtins.keys(),
56
+ ...active.keys(),
57
+ ...globalServerIds(options.globalConfig),
58
+ ]);
59
+
60
+ const states = await Promise.all([...ids].sort().map(async (id): Promise<ServerState> => {
61
+ const command = commandFor(id, active, builtins, options.globalConfig);
62
+ const installed = command ? (await options.resolveCommand(command)) !== null : null;
63
+ return {
64
+ id,
65
+ command,
66
+ enabled: active.has(id),
67
+ installed,
68
+ installable: options.installRegistry.has(id),
69
+ running: runningById.get(id) ?? [],
70
+ };
71
+ }));
72
+
73
+ return states;
74
+ }
75
+
76
+ export function formatServerStates(states: ServerState[]): string {
77
+ if (states.length === 0) return "pi-lsp-lite: no servers configured";
78
+
79
+ return states.map((state) => {
80
+ const enabled = state.enabled ? "enabled" : "disabled";
81
+ const installed = state.installed === null ? "unknown" : state.installed ? "installed" : "missing";
82
+ const installHint = state.installed === false
83
+ ? state.installable ? "installable via /lsp-install" : "manual install required"
84
+ : "";
85
+ const running = state.running.length > 0
86
+ ? state.running.map((s) => {
87
+ const idle = Math.round((Date.now() - s.lastActivity) / 1000);
88
+ const up = Math.round(s.uptime / 1000);
89
+ return `running pid=${s.pid} root=${s.root} open=${s.openDocuments} up=${up}s idle=${idle}s`;
90
+ }).join("; ")
91
+ : "not running";
92
+ const command = state.command ? `cmd=${state.command}` : "cmd=unknown";
93
+ return [state.id, enabled, installed, running, command, installHint].filter(Boolean).join(" — ");
94
+ }).join("\n");
95
+ }
package/src/util.ts CHANGED
@@ -1,29 +1,28 @@
1
- import { access, constants } from "node:fs/promises";
2
- import { join, dirname } from "node:path";
1
+ import { access } from "node:fs/promises";
2
+ import { join, dirname, relative, isAbsolute } from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
+ import which_ from "which";
4
5
 
5
6
  export function fileUri(absolutePath: string): string {
6
7
  return pathToFileURL(absolutePath).href;
7
8
  }
8
9
 
10
+ // Find a binary on PATH. Delegates to the `which` package — the same resolver
11
+ // cross-spawn uses to locate the command it launches — so preflight resolution
12
+ // and the eventual spawn agree on every platform.
13
+ function isNotFoundError(err: unknown): boolean {
14
+ return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
15
+ }
16
+
9
17
  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;
18
+ try {
19
+ return await which_(command);
20
+ } catch (err) {
21
+ if (!isNotFoundError(err)) {
22
+ console.error(`[pi-lsp-lite] failed to resolve command "${command}":`, err);
16
23
  }
24
+ return null;
17
25
  }
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
26
  }
28
27
 
29
28
  export async function findWorkspaceRoot(filePath: string, rootPatterns: string[], cwd: string): Promise<string> {
@@ -42,3 +41,8 @@ export async function findWorkspaceRoot(filePath: string, rootPatterns: string[]
42
41
  }
43
42
  return cwd;
44
43
  }
44
+
45
+ export function isInsideCwd(absolutePath: string, cwd: string): boolean {
46
+ const rel = relative(cwd, absolutePath);
47
+ return !!rel && !rel.startsWith("..") && !isAbsolute(rel);
48
+ }
@@ -1,30 +0,0 @@
1
- name: CI
2
-
3
- on:
4
- push:
5
- branches: [main]
6
- pull_request:
7
- branches: [main]
8
-
9
- permissions:
10
- contents: read
11
-
12
- jobs:
13
- check:
14
- name: typecheck + unit tests
15
- runs-on: ubuntu-latest
16
- steps:
17
- - uses: actions/checkout@v6
18
-
19
- - uses: actions/setup-node@v6
20
- with:
21
- node-version: 20
22
- cache: npm
23
-
24
- - run: npm ci
25
-
26
- - name: typecheck
27
- run: npm run check
28
-
29
- - name: unit tests
30
- run: npm test
@@ -1,79 +0,0 @@
1
- name: Integration Tests
2
-
3
- on:
4
- pull_request:
5
- branches: [main]
6
-
7
- permissions:
8
- contents: read
9
-
10
- jobs:
11
- gopls:
12
- name: gopls
13
- runs-on: ubuntu-latest
14
- steps:
15
- - uses: actions/checkout@v6
16
- - uses: actions/setup-node@v6
17
- with:
18
- node-version: 20
19
- cache: npm
20
- - uses: actions/setup-go@v6
21
- with:
22
- go-version: stable
23
- - run: go install golang.org/x/tools/gopls@latest
24
- - run: npm ci
25
- - run: INTEGRATION=1 npx tsx --test test/*.test.ts test/integration/gopls.test.ts
26
-
27
- rust-analyzer:
28
- name: rust-analyzer
29
- runs-on: ubuntu-latest
30
- steps:
31
- - uses: actions/checkout@v6
32
- - uses: actions/setup-node@v6
33
- with:
34
- node-version: 20
35
- cache: npm
36
- - run: |
37
- rustup update stable
38
- rustup component add rust-analyzer
39
- - run: npm ci
40
- - run: INTEGRATION=1 npx tsx --test test/*.test.ts test/integration/rust-analyzer.test.ts
41
-
42
- typescript:
43
- name: typescript-language-server
44
- runs-on: ubuntu-latest
45
- steps:
46
- - uses: actions/checkout@v6
47
- - uses: actions/setup-node@v6
48
- with:
49
- node-version: 20
50
- cache: npm
51
- - run: npm install -g typescript-language-server typescript
52
- - run: npm ci
53
- - run: INTEGRATION=1 npx tsx --test test/*.test.ts test/integration/typescript.test.ts
54
-
55
- pylsp:
56
- name: pylsp
57
- runs-on: ubuntu-latest
58
- steps:
59
- - uses: actions/checkout@v6
60
- - uses: actions/setup-node@v6
61
- with:
62
- node-version: 20
63
- cache: npm
64
- - run: pip install 'python-lsp-server[all]'
65
- - run: npm ci
66
- - run: INTEGRATION=1 npx tsx --test test/*.test.ts test/integration/pylsp.test.ts
67
-
68
- clangd:
69
- name: clangd
70
- runs-on: ubuntu-latest
71
- steps:
72
- - uses: actions/checkout@v6
73
- - uses: actions/setup-node@v6
74
- with:
75
- node-version: 20
76
- cache: npm
77
- - run: sudo apt-get update -qq && sudo apt-get install -y -qq clangd
78
- - run: npm ci
79
- - run: INTEGRATION=1 npx tsx --test test/*.test.ts test/integration/clangd.test.ts
@@ -1,34 +0,0 @@
1
- name: Publish to npm
2
-
3
- on:
4
- release:
5
- types: [published]
6
-
7
- permissions:
8
- contents: read
9
- id-token: write
10
-
11
- jobs:
12
- publish:
13
- name: publish to npm
14
- runs-on: ubuntu-latest
15
- environment: public
16
- steps:
17
- - uses: actions/checkout@v6
18
-
19
- - uses: actions/setup-node@v6
20
- with:
21
- node-version: 24
22
- registry-url: https://registry.npmjs.org
23
- cache: npm
24
-
25
- - run: npm ci
26
-
27
- - name: typecheck
28
- run: npm run check
29
-
30
- - name: unit tests
31
- run: npm test
32
-
33
- - name: publish
34
- run: npm publish --access public