@narumitw/pi-lsp 0.19.0 → 0.22.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
@@ -36,7 +36,7 @@ pi -e ./extensions/pi-lsp
36
36
 
37
37
  ## ⚙️ Configuration
38
38
 
39
- If no config is provided, pi-lsp ships a broad catalog of direct-command defaults. Servers are started only when matching files are requested. pi-lsp does not download language servers, so install the commands you need and make them available on `PATH`. During no-config diagnostics, unavailable default commands are filtered before workspace discovery; explicitly selected or custom-configured missing commands still report an error.
39
+ If no config is provided, pi-lsp ships a broad catalog of direct-command defaults. Servers are started only when matching files are requested. pi-lsp does not download language servers, so install the commands you need and make them available on `PATH`. During no-config diagnostics, unavailable default commands are filtered before workspace discovery. If none can run, diagnostics completes successfully and reports the skipped servers. Explicitly selected or custom-configured missing commands still report an error.
40
40
 
41
41
  | Language or format | Default server | Startup command | Extensions |
42
42
  | --- | --- | --- | --- |
@@ -156,6 +156,7 @@ Each server entry supports:
156
156
  - `env`: extra environment variables for the LSP server process.
157
157
  - `initialization`: LSP initialization options and workspace configuration values.
158
158
  - `skipDirectories`: additional directory names to exclude from recursive discovery. Explicitly requested paths remain available.
159
+ - `diagnosticsSettleMs`: positive number of milliseconds without another push-diagnostics publication before using the latest result. Defaults to `800`; the built-in intelephense route uses `4000`. The global timeout remains the upper bound.
159
160
 
160
161
  Global options:
161
162
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-lsp",
3
- "version": "0.19.0",
3
+ "version": "0.22.0",
4
4
  "description": "Pi extension that exposes configurable, language-agnostic LSP tools through a shared runner.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -36,9 +36,9 @@
36
36
  },
37
37
  "devDependencies": {
38
38
  "@biomejs/biome": "2.5.3",
39
- "@earendil-works/pi-coding-agent": "0.80.3",
39
+ "@earendil-works/pi-coding-agent": "0.80.10",
40
40
  "@types/node": "26.1.1",
41
- "typescript": "6.0.3"
41
+ "typescript": "7.0.2"
42
42
  },
43
43
  "repository": {
44
44
  "type": "git",
package/src/adapters.ts CHANGED
@@ -151,6 +151,8 @@ export const DEFAULT_SERVER_CONFIGS: InternalLspServer[] = [
151
151
  command: ["intelephense", "--stdio"],
152
152
  extensions: [".php"],
153
153
  initialization: { intelephense: { telemetry: { enabled: false } } },
154
+ // Publishes empty on didOpen, then real diagnostics ~0.2-3s later.
155
+ diagnosticsSettleMs: 4000,
154
156
  },
155
157
  {
156
158
  name: "prisma",
@@ -419,6 +421,7 @@ function normalizeServer(name: string, value: unknown, label: string): InternalL
419
421
  env: optionalStringRecordField(value, "env", label),
420
422
  initialization: optionalRecordField(value, "initialization", label),
421
423
  skipDirectories: optionalDirectoryNamesField(value, "skipDirectories", label),
424
+ diagnosticsSettleMs: optionalPositiveNumberField(value, "diagnosticsSettleMs", label),
422
425
  };
423
426
  }
424
427
 
@@ -444,6 +447,7 @@ function configToAdapter(config: InternalLspServer): LspServerAdapter {
444
447
  env: config.env,
445
448
  initialization: config.initialization,
446
449
  skipDirectories: new Set([...COMMON_SKIP_DIRECTORIES, ...(config.skipDirectories ?? [])]),
450
+ diagnosticsSettleMs: config.diagnosticsSettleMs,
447
451
  isSupportedFile: (filePath) => extensionSet.has(path.extname(filePath)),
448
452
  languageIdFor: (filePath) => languageIdFor(config, filePath),
449
453
  };
@@ -547,6 +551,15 @@ function stringArrayField(value: Record<string, unknown>, field: string, label:
547
551
  return fieldValue;
548
552
  }
549
553
 
554
+ function optionalPositiveNumberField(value: Record<string, unknown>, field: string, label: string) {
555
+ const fieldValue = value[field];
556
+ if (fieldValue === undefined) return undefined;
557
+ if (typeof fieldValue !== "number" || !Number.isFinite(fieldValue) || fieldValue <= 0) {
558
+ throw new Error(`${label}.${field} must be a positive number.`);
559
+ }
560
+ return fieldValue;
561
+ }
562
+
550
563
  function optionalStringRecordField(value: Record<string, unknown>, field: string, label: string) {
551
564
  const fieldValue = value[field];
552
565
  if (fieldValue === undefined) return undefined;
package/src/lsp-client.ts CHANGED
@@ -24,6 +24,9 @@ export function resolveSpawnCommand(
24
24
  };
25
25
  }
26
26
 
27
+ // Quiet period (ms) after each publish before treating push diagnostics as settled.
28
+ const PUBLISHED_DIAGNOSTICS_SETTLE_MS = 800;
29
+
27
30
  export class LspClient {
28
31
  #child?: ChildProcessWithoutNullStreams;
29
32
  #buffer = Buffer.alloc(0);
@@ -39,13 +42,14 @@ export class LspClient {
39
42
  #publishedDiagnostics = new Map<string, LspDiagnostic[]>();
40
43
  #diagnosticWaiters = new Map<
41
44
  string,
42
- Array<{
43
- resolve: (diagnostics: LspDiagnostic[]) => void;
45
+ Set<{
46
+ onPublish: () => void;
44
47
  reject: (reason: unknown) => void;
45
- timeout: NodeJS.Timeout;
48
+ dispose: () => void;
46
49
  }>
47
50
  >();
48
51
  #stderr = "";
52
+ #serverCapabilities: Record<string, unknown> = {};
49
53
  #adapter: LspServerAdapter;
50
54
  #command: ServerCommand;
51
55
  #cwd: string;
@@ -118,18 +122,20 @@ export class LspClient {
118
122
  async initialize(root: string) {
119
123
  const rootUri = directoryUri(root);
120
124
  const workspaceFolders = [{ uri: rootUri, name: path.basename(root) || "workspace" }];
121
- await this.request("initialize", {
125
+ const response = await this.request("initialize", {
122
126
  processId: process.pid,
123
127
  rootUri,
124
128
  workspaceFolders,
125
129
  initializationOptions: this.#adapter.initialization ?? {},
126
130
  capabilities: {
127
131
  textDocument: {
132
+ // This spawn-per-call client can't track dynamic registrations, so
133
+ // capabilities must be advertised statically.
128
134
  codeAction: {
129
- dynamicRegistration: true,
135
+ dynamicRegistration: false,
130
136
  resolveSupport: { properties: ["edit"] },
131
137
  },
132
- diagnostic: { dynamicRegistration: true, relatedDocumentSupport: true },
138
+ diagnostic: { dynamicRegistration: false, relatedDocumentSupport: true },
133
139
  publishDiagnostics: {},
134
140
  synchronization: { didSave: true },
135
141
  },
@@ -140,6 +146,9 @@ export class LspClient {
140
146
  },
141
147
  },
142
148
  });
149
+ this.#serverCapabilities =
150
+ (response.result as { capabilities?: Record<string, unknown> } | undefined)?.capabilities ??
151
+ {};
143
152
  this.notify("initialized", {});
144
153
  if (this.#adapter.initialization) {
145
154
  this.notify("workspace/didChangeConfiguration", { settings: this.#adapter.initialization });
@@ -161,18 +170,17 @@ export class LspClient {
161
170
  }
162
171
 
163
172
  async diagnostics(uri: string) {
164
- try {
165
- const response = await this.request("textDocument/diagnostic", {
166
- textDocument: { uri },
167
- identifier: null,
168
- previousResultId: null,
169
- });
170
- const result = response.result as { items?: LspDiagnostic[] } | undefined;
171
- return result?.items ?? [];
172
- } catch (error) {
173
- if (!isUnsupportedMethodError(error)) throw error;
173
+ // Only pull if the server advertised it; otherwise use push diagnostics.
174
+ if (!this.#serverCapabilities.diagnosticProvider) {
174
175
  return this.#waitForPublishedDiagnostics(uri);
175
176
  }
177
+ const response = await this.request("textDocument/diagnostic", {
178
+ textDocument: { uri },
179
+ identifier: null,
180
+ previousResultId: null,
181
+ });
182
+ const result = response.result as { items?: LspDiagnostic[] } | undefined;
183
+ return result?.items ?? [];
176
184
  }
177
185
 
178
186
  async codeActions(uri: string, text: string, diagnostics: LspDiagnostic[], kind: string) {
@@ -185,20 +193,23 @@ export class LspClient {
185
193
  }
186
194
 
187
195
  async resolveActions(actions: CodeAction[]) {
196
+ // Only resolve when the server advertised resolveProvider; otherwise use the
197
+ // action as-is. Any error from an advertised resolve is real and propagates.
198
+ const codeActionProvider = this.#serverCapabilities.codeActionProvider;
199
+ const canResolve =
200
+ typeof codeActionProvider === "object" &&
201
+ codeActionProvider !== null &&
202
+ (codeActionProvider as { resolveProvider?: boolean }).resolveProvider === true;
203
+
188
204
  const resolvedActions: CodeAction[] = [];
189
205
  for (const action of actions) {
190
- if (action.edit) {
206
+ if (action.edit || !canResolve) {
191
207
  resolvedActions.push(action);
192
208
  continue;
193
209
  }
194
210
 
195
- try {
196
- const response = await this.request("codeAction/resolve", action);
197
- resolvedActions.push((response.result as CodeAction | undefined) ?? action);
198
- } catch (error) {
199
- if (!isUnsupportedMethodError(error)) throw error;
200
- resolvedActions.push(action);
201
- }
211
+ const response = await this.request("codeAction/resolve", action);
212
+ resolvedActions.push((response.result as CodeAction | undefined) ?? action);
202
213
  }
203
214
 
204
215
  return resolvedActions;
@@ -231,8 +242,7 @@ export class LspClient {
231
242
  }
232
243
  this.#pending.clear();
233
244
  for (const waiters of this.#diagnosticWaiters.values()) {
234
- for (const waiter of waiters) {
235
- clearTimeout(waiter.timeout);
245
+ for (const waiter of [...waiters]) {
236
246
  waiter.reject(new Error(typeof message === "string" ? message : message("diagnostics")));
237
247
  }
238
248
  }
@@ -331,11 +341,9 @@ export class LspClient {
331
341
  if (params?.uri) {
332
342
  const diagnostics = params.diagnostics ?? [];
333
343
  this.#publishedDiagnostics.set(params.uri, diagnostics);
334
- const waiters = this.#diagnosticWaiters.get(params.uri) ?? [];
335
- this.#diagnosticWaiters.delete(params.uri);
336
- for (const waiter of waiters) {
337
- clearTimeout(waiter.timeout);
338
- waiter.resolve(diagnostics);
344
+ const waiters = this.#diagnosticWaiters.get(params.uri);
345
+ if (waiters) {
346
+ for (const waiter of [...waiters]) waiter.onPublish();
339
347
  }
340
348
  }
341
349
  return;
@@ -347,26 +355,53 @@ export class LspClient {
347
355
  }
348
356
 
349
357
  #waitForPublishedDiagnostics(uri: string) {
350
- const diagnostics = this.#publishedDiagnostics.get(uri);
351
- if (diagnostics) return Promise.resolve(diagnostics);
352
-
358
+ // See PUBLISHED_DIAGNOSTICS_SETTLE_MS. Bounded by #timeoutMs.
353
359
  return new Promise<LspDiagnostic[]>((resolve, reject) => {
354
- const waiter = {
355
- resolve,
356
- reject,
357
- timeout: setTimeout(() => {
358
- const waiters =
359
- this.#diagnosticWaiters.get(uri)?.filter((entry) => entry !== waiter) ?? [];
360
- if (waiters.length) this.#diagnosticWaiters.set(uri, waiters);
361
- else this.#diagnosticWaiters.delete(uri);
362
- reject(
360
+ let settleTimer: NodeJS.Timeout | undefined;
361
+ let overallTimer: NodeJS.Timeout | undefined;
362
+
363
+ const dispose = () => {
364
+ if (settleTimer) clearTimeout(settleTimer);
365
+ if (overallTimer) clearTimeout(overallTimer);
366
+ const set = this.#diagnosticWaiters.get(uri);
367
+ set?.delete(waiter);
368
+ if (set && set.size === 0) this.#diagnosticWaiters.delete(uri);
369
+ };
370
+ const settleWith = (diagnostics: LspDiagnostic[]) => {
371
+ dispose();
372
+ resolve(diagnostics);
373
+ };
374
+ const fail = (reason: unknown) => {
375
+ dispose();
376
+ reject(reason);
377
+ };
378
+ const onPublish = () => {
379
+ if (settleTimer) clearTimeout(settleTimer);
380
+ settleTimer = setTimeout(
381
+ () => settleWith(this.#publishedDiagnostics.get(uri) ?? []),
382
+ this.#adapter.diagnosticsSettleMs ?? PUBLISHED_DIAGNOSTICS_SETTLE_MS,
383
+ );
384
+ };
385
+
386
+ const waiter = { onPublish, reject: fail, dispose };
387
+ const set = this.#diagnosticWaiters.get(uri) ?? new Set<typeof waiter>();
388
+ set.add(waiter);
389
+ this.#diagnosticWaiters.set(uri, set);
390
+
391
+ overallTimer = setTimeout(() => {
392
+ const latest = this.#publishedDiagnostics.get(uri);
393
+ if (latest !== undefined) {
394
+ settleWith(latest);
395
+ } else {
396
+ fail(
363
397
  new Error(
364
398
  `${this.#adapter.name} LSP did not return diagnostics for ${uri} before timeout.`,
365
399
  ),
366
400
  );
367
- }, this.#timeoutMs),
368
- };
369
- this.#diagnosticWaiters.set(uri, [...(this.#diagnosticWaiters.get(uri) ?? []), waiter]);
401
+ }
402
+ }, this.#timeoutMs);
403
+
404
+ if (this.#publishedDiagnostics.has(uri)) onPublish();
370
405
  });
371
406
  }
372
407
 
@@ -417,13 +452,6 @@ export class LspClient {
417
452
  }
418
453
  }
419
454
 
420
- function isUnsupportedMethodError(error: unknown) {
421
- return (
422
- error instanceof Error &&
423
- /method not found|unhandled method|not supported|unsupported/i.test(error.message)
424
- );
425
- }
426
-
427
455
  function formatErrorMessage(error: unknown) {
428
456
  return error instanceof Error ? error.message : String(error);
429
457
  }
package/src/routes.ts CHANGED
@@ -50,14 +50,6 @@ export function selectDiagnosticRoutes(
50
50
  return false;
51
51
  });
52
52
 
53
- if (runnableCandidates.length === 0 && skipped.length > 0) {
54
- const names = skipped.map((route) => route.adapter.name).join(", ");
55
- throw new Error(
56
- `No available default LSP commands: ${names}. ` +
57
- "Install a matching server command or explicitly select a configured server.",
58
- );
59
- }
60
-
61
53
  const filesByPolicy = new Map<string, string[]>();
62
54
  const routes = runnableCandidates
63
55
  .map((adapter) => {
@@ -71,16 +63,8 @@ export function selectDiagnosticRoutes(
71
63
  })
72
64
  .filter((route) => route.files.length > 0);
73
65
 
74
- if (routes.length === 0) {
66
+ if (routes.length === 0 && skipped.length === 0) {
75
67
  const scope = params.paths?.length ? ` in requested paths: ${params.paths.join(", ")}` : "";
76
- if (skipped.length > 0) {
77
- const names = skipped.map((route) => route.adapter.name).join(", ");
78
- throw new Error(
79
- `No supported files found for available LSP commands${scope}. ` +
80
- `Skipped unavailable default LSP commands: ${names}. ` +
81
- "Install a matching server command or explicitly select a configured server.",
82
- );
83
- }
84
68
  throw new Error(`No supported files found${scope}. ${SUPPORTED_SERVER_DESCRIPTION}`);
85
69
  }
86
70
 
@@ -125,7 +109,13 @@ function filterAdapters(adapters: LspServerAdapter[], selected: string | string[
125
109
  if (names.length === 0) throw new Error("LSP server parameter must not be blank.");
126
110
  const matched = adapters.filter((adapter) => names.includes(adapter.name));
127
111
  const missing = names.filter((name) => !adapters.some((adapter) => adapter.name === name));
128
- if (missing.length) throw new Error(`Unknown LSP server(s): ${missing.join(", ")}.`);
112
+ if (missing.length) {
113
+ const configured = adapters.map((adapter) => adapter.name).join(", ") || "none";
114
+ throw new Error(
115
+ `Unknown LSP server(s): ${missing.join(", ")}. Configured LSP servers: ${configured}. ` +
116
+ "Omit the server parameter to select matching servers automatically.",
117
+ );
118
+ }
129
119
  return matched;
130
120
  }
131
121
 
package/src/runner.ts CHANGED
@@ -47,26 +47,32 @@ export async function runDiagnostics(
47
47
  await client.start();
48
48
  await client.initialize(root);
49
49
 
50
- const entries: DiagnosticEntry[] = [];
51
- for (const file of files) {
52
- throwIfAborted(signal, adapter);
53
- const uri = pathToFileURL(file).href;
54
- const text = readFileSync(file, "utf8");
55
- client.didOpen(uri, text, adapter.languageIdFor(file));
56
- try {
57
- const diagnostics = await client.diagnostics(uri);
58
- entries.push({ path: path.relative(root, file) || file, uri, diagnostics });
59
- } finally {
60
- client.didClose(uri);
50
+ const openedFiles: Array<{ file: string; uri: string }> = [];
51
+ try {
52
+ for (const file of files) {
53
+ throwIfAborted(signal, adapter);
54
+ const uri = pathToFileURL(file).href;
55
+ const text = readFileSync(file, "utf8");
56
+ client.didOpen(uri, text, adapter.languageIdFor(file));
57
+ openedFiles.push({ file, uri });
61
58
  }
62
- }
63
59
 
64
- return textResult(formatDiagnostics(adapter, entries), {
65
- root,
66
- command,
67
- files: entries,
68
- summary: summarize(entries),
69
- });
60
+ const entries: DiagnosticEntry[] = await Promise.all(
61
+ openedFiles.map(async ({ file, uri }) => ({
62
+ path: path.relative(root, file) || file,
63
+ uri,
64
+ diagnostics: await client.diagnostics(uri),
65
+ })),
66
+ );
67
+ return textResult(formatDiagnostics(adapter, entries), {
68
+ root,
69
+ command,
70
+ files: entries,
71
+ summary: summarize(entries),
72
+ });
73
+ } finally {
74
+ for (const { uri } of openedFiles) client.didClose(uri);
75
+ }
70
76
  } finally {
71
77
  ctx.ui.setStatus(statusKey, undefined);
72
78
  signal?.removeEventListener("abort", abort);
package/src/types.ts CHANGED
@@ -67,6 +67,8 @@ export interface ConfiguredLspServer {
67
67
  env?: Record<string, string>;
68
68
  initialization?: Record<string, unknown>;
69
69
  skipDirectories?: string[];
70
+ // Quiet period in ms after the latest push-diagnostics publication.
71
+ diagnosticsSettleMs?: number;
70
72
  }
71
73
 
72
74
  export interface LspConfig {
@@ -89,6 +91,7 @@ export interface LspServerAdapter {
89
91
  env?: Record<string, string>;
90
92
  initialization?: Record<string, unknown>;
91
93
  skipDirectories: Set<string>;
94
+ diagnosticsSettleMs?: number;
92
95
  isSupportedFile: (filePath: string) => boolean;
93
96
  languageIdFor: (filePath: string) => string;
94
97
  }