@narumitw/pi-lsp 0.20.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 +2 -1
- package/package.json +1 -1
- package/src/adapters.ts +13 -0
- package/src/lsp-client.ts +81 -53
- package/src/routes.ts +8 -18
- package/src/runner.ts +24 -18
- package/src/types.ts +3 -0
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
|
|
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
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
|
-
|
|
43
|
-
|
|
45
|
+
Set<{
|
|
46
|
+
onPublish: () => void;
|
|
44
47
|
reject: (reason: unknown) => void;
|
|
45
|
-
|
|
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:
|
|
135
|
+
dynamicRegistration: false,
|
|
130
136
|
resolveSupport: { properties: ["edit"] },
|
|
131
137
|
},
|
|
132
|
-
diagnostic: { dynamicRegistration:
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
336
|
-
|
|
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
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
}
|
|
368
|
-
};
|
|
369
|
-
|
|
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)
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
}
|