@mrclrchtr/supi-lsp 0.1.0 → 1.0.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 +112 -0
- package/node_modules/@mrclrchtr/supi-core/README.md +90 -0
- package/node_modules/@mrclrchtr/supi-core/package.json +30 -0
- package/node_modules/@mrclrchtr/supi-core/src/config-settings.ts +76 -0
- package/node_modules/@mrclrchtr/supi-core/src/config.ts +186 -0
- package/node_modules/@mrclrchtr/supi-core/src/context-messages.ts +119 -0
- package/node_modules/@mrclrchtr/supi-core/src/context-provider-registry.ts +36 -0
- package/node_modules/@mrclrchtr/supi-core/src/context-tag.ts +31 -0
- package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +255 -0
- package/node_modules/@mrclrchtr/supi-core/src/index.ts +83 -0
- package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +170 -0
- package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +54 -0
- package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +29 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings-command.ts +15 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings-registry.ts +41 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings-ui.ts +226 -0
- package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +60 -0
- package/package.json +18 -9
- package/{capabilities.ts → src/capabilities.ts} +8 -0
- package/src/client/client-refresh.ts +229 -0
- package/{client.ts → src/client/client.ts} +178 -30
- package/{transport.ts → src/client/transport.ts} +10 -6
- package/src/config.ts +143 -0
- package/src/defaults.json +82 -0
- package/src/diagnostics/diagnostic-augmentation.ts +82 -0
- package/src/diagnostics/diagnostic-display.ts +68 -0
- package/{diagnostic-summary.ts → src/diagnostics/diagnostic-summary.ts} +11 -7
- package/{diagnostics.ts → src/diagnostics/diagnostics.ts} +9 -4
- package/src/diagnostics/stale-diagnostics.ts +47 -0
- package/src/diagnostics/suppression-diagnostics.ts +58 -0
- package/src/format.ts +359 -0
- package/src/guidance.ts +163 -0
- package/src/index.ts +17 -0
- package/src/lsp-state.ts +82 -0
- package/src/lsp.ts +470 -0
- package/src/manager/manager-client-state.ts +34 -0
- package/src/manager/manager-diagnostics.ts +139 -0
- package/src/manager/manager-helpers.ts +39 -0
- package/src/manager/manager-project-info.ts +46 -0
- package/src/manager/manager-types.ts +39 -0
- package/src/manager/manager-workspace-recovery.ts +83 -0
- package/src/manager/manager-workspace-symbol.ts +18 -0
- package/src/manager/manager.ts +550 -0
- package/src/overrides.ts +173 -0
- package/src/pattern-matcher.ts +197 -0
- package/src/renderer.ts +120 -0
- package/src/scanner.ts +153 -0
- package/src/search-fallback.ts +98 -0
- package/src/service-registry.ts +153 -0
- package/src/settings-registration.ts +292 -0
- package/{summary.ts → src/summary.ts} +44 -9
- package/src/tool-actions.ts +430 -0
- package/src/tree-persist.ts +48 -0
- package/src/tsconfig-scope.ts +156 -0
- package/{types.ts → src/types.ts} +123 -0
- package/src/ui.ts +358 -0
- package/{utils.ts → src/utils.ts} +8 -25
- package/src/workspace-sentinels.ts +114 -0
- package/bash-guard.ts +0 -58
- package/config.ts +0 -99
- package/defaults.json +0 -40
- package/format.ts +0 -190
- package/guidance.ts +0 -140
- package/lsp.ts +0 -375
- package/manager.ts +0 -396
- package/overrides.ts +0 -95
- package/recent-paths.ts +0 -126
- package/runtime-state.ts +0 -113
- package/tool-actions.ts +0 -211
- package/tsconfig.json +0 -5
- package/ui.ts +0 -303
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
// LSP Client — wraps a server process + JsonRpcClient.
|
|
2
2
|
// Handles initialize handshake, document sync, shutdown, and crash recovery.
|
|
3
3
|
|
|
4
|
+
// biome-ignore lint/nursery/noExcessiveLinesPerFile: LspClient remains a cohesive stateful wrapper; refresh logic is already split out.
|
|
4
5
|
import { type ChildProcess, spawn } from "node:child_process";
|
|
5
6
|
import { existsSync } from "node:fs";
|
|
6
|
-
import { CLIENT_CAPABILITIES } from "
|
|
7
|
-
import { JsonRpcClient } from "./transport.ts";
|
|
7
|
+
import { CLIENT_CAPABILITIES } from "../capabilities.ts";
|
|
8
8
|
import type {
|
|
9
9
|
CodeAction,
|
|
10
10
|
CodeActionContext,
|
|
11
11
|
Diagnostic,
|
|
12
|
+
DidChangeWatchedFilesParams,
|
|
12
13
|
DocumentSymbol,
|
|
14
|
+
FileEvent,
|
|
13
15
|
Hover,
|
|
14
16
|
InitializeResult,
|
|
15
17
|
Location,
|
|
@@ -24,14 +26,15 @@ import type {
|
|
|
24
26
|
TextDocumentItem,
|
|
25
27
|
VersionedTextDocumentIdentifier,
|
|
26
28
|
WorkspaceEdit,
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
WorkspaceSymbol,
|
|
30
|
+
} from "../types.ts";
|
|
31
|
+
import { detectLanguageId, fileToUri, uriToFile } from "../utils.ts";
|
|
32
|
+
import { JsonRpcClient } from "./transport.ts";
|
|
29
33
|
|
|
30
34
|
const SHUTDOWN_TIMEOUT_MS = 5_000;
|
|
31
35
|
const DIAGNOSTIC_WAIT_MS = 3_000;
|
|
32
36
|
|
|
33
37
|
// ── Types ─────────────────────────────────────────────────────────────
|
|
34
|
-
|
|
35
38
|
export type ClientStatus = "initializing" | "running" | "error" | "shutdown";
|
|
36
39
|
|
|
37
40
|
export interface DiagnosticEntry {
|
|
@@ -39,8 +42,15 @@ export interface DiagnosticEntry {
|
|
|
39
42
|
diagnostics: Diagnostic[];
|
|
40
43
|
}
|
|
41
44
|
|
|
42
|
-
|
|
45
|
+
/** Internal metadata tracked alongside cached diagnostics. */
|
|
46
|
+
export interface DiagnosticCacheEntry {
|
|
47
|
+
diagnostics: Diagnostic[];
|
|
48
|
+
receivedAt: number;
|
|
49
|
+
version?: number;
|
|
50
|
+
resultId?: string;
|
|
51
|
+
}
|
|
43
52
|
|
|
53
|
+
// ── LspClient ─────────────────────────────────────────────────────────
|
|
44
54
|
export class LspClient {
|
|
45
55
|
readonly name: string;
|
|
46
56
|
readonly root: string;
|
|
@@ -52,8 +62,8 @@ export class LspClient {
|
|
|
52
62
|
|
|
53
63
|
/** Open documents: uri → { version, languageId } */
|
|
54
64
|
private openDocs = new Map<string, { version: number; languageId: string }>();
|
|
55
|
-
/** Per-file diagnostics
|
|
56
|
-
private diagnosticStore = new Map<string,
|
|
65
|
+
/** Per-file diagnostics with freshness metadata */
|
|
66
|
+
private diagnosticStore = new Map<string, DiagnosticCacheEntry>();
|
|
57
67
|
/** Listeners waiting for diagnostics on a specific uri */
|
|
58
68
|
private diagnosticWaiters = new Map<string, Array<() => void>>();
|
|
59
69
|
|
|
@@ -74,8 +84,11 @@ export class LspClient {
|
|
|
74
84
|
return Array.from(this.openDocs.keys()).map(uriToFile);
|
|
75
85
|
}
|
|
76
86
|
|
|
77
|
-
|
|
87
|
+
get serverCapabilities(): ServerCapabilities | null {
|
|
88
|
+
return this.capabilities;
|
|
89
|
+
}
|
|
78
90
|
|
|
91
|
+
// ── Lifecycle ───────────────────────────────────────────────────────
|
|
79
92
|
/** Spawn the server process and perform the initialize handshake. */
|
|
80
93
|
async start(): Promise<void> {
|
|
81
94
|
const cmd = this.config.command;
|
|
@@ -181,10 +194,10 @@ export class LspClient {
|
|
|
181
194
|
|
|
182
195
|
this.openDocs.clear();
|
|
183
196
|
this.diagnosticStore.clear();
|
|
197
|
+
this.releaseAllDiagnosticWaiters();
|
|
184
198
|
}
|
|
185
199
|
|
|
186
200
|
// ── Document Synchronization ────────────────────────────────────────
|
|
187
|
-
|
|
188
201
|
/** Open a document (or re-sync if already open). */
|
|
189
202
|
didOpen(filePath: string, content: string): void {
|
|
190
203
|
if (!this.rpc || this._status !== "running") return;
|
|
@@ -267,49 +280,114 @@ export class LspClient {
|
|
|
267
280
|
}
|
|
268
281
|
|
|
269
282
|
// ── Diagnostics ─────────────────────────────────────────────────────
|
|
270
|
-
|
|
271
283
|
/** Get stored diagnostics for a file. */
|
|
272
284
|
getDiagnostics(filePath: string): Diagnostic[] {
|
|
273
|
-
return this.diagnosticStore.get(fileToUri(filePath)) ?? [];
|
|
285
|
+
return this.diagnosticStore.get(fileToUri(filePath))?.diagnostics ?? [];
|
|
274
286
|
}
|
|
275
287
|
|
|
276
|
-
/**
|
|
288
|
+
/**
|
|
289
|
+
* Get all stored diagnostics across all files.
|
|
290
|
+
*
|
|
291
|
+
* Filters out empty entries and — defensively — files that no longer exist
|
|
292
|
+
* on disk. The latter guards against a race where a `publishDiagnostics`
|
|
293
|
+
* notification (already in-flight) arrives *after* `pruneMissingFiles()`
|
|
294
|
+
* has removed the file from `openDocs`, recreating a stale cache entry.
|
|
295
|
+
*/
|
|
277
296
|
getAllDiagnostics(): DiagnosticEntry[] {
|
|
278
297
|
const result: DiagnosticEntry[] = [];
|
|
279
|
-
for (const [uri,
|
|
280
|
-
if (diagnostics.length
|
|
281
|
-
|
|
282
|
-
|
|
298
|
+
for (const [uri, entry] of this.diagnosticStore) {
|
|
299
|
+
if (entry.diagnostics.length === 0) continue;
|
|
300
|
+
// Defensive: drop diagnostics for files deleted after prune. Late
|
|
301
|
+
// in-flight publishDiagnostics notifications can recreate stale entries.
|
|
302
|
+
if (!existsSync(uriToFile(uri))) continue;
|
|
303
|
+
result.push({ uri, diagnostics: entry.diagnostics });
|
|
283
304
|
}
|
|
284
305
|
return result;
|
|
285
306
|
}
|
|
286
307
|
|
|
308
|
+
/** Get the internal cache entry for a file (exposed for testing / version checks). */
|
|
309
|
+
getDiagnosticCacheEntry(uri: string): DiagnosticCacheEntry | undefined {
|
|
310
|
+
return this.diagnosticStore.get(uri);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Clear all pull-diagnostic result IDs, forcing full (not `unchanged`)
|
|
315
|
+
* pull diagnostic responses on the next refresh cycle.
|
|
316
|
+
*
|
|
317
|
+
* Use this after file creation/write operations so that cross-file
|
|
318
|
+
* diagnostics (e.g., `Cannot find module` errors in importing files)
|
|
319
|
+
* are fully re-computed instead of returned as `unchanged`.
|
|
320
|
+
*/
|
|
321
|
+
clearPullResultIds(): void {
|
|
322
|
+
for (const entry of this.diagnosticStore.values()) {
|
|
323
|
+
delete entry.resultId;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Get all currently open document URIs. */
|
|
328
|
+
get openUris(): string[] {
|
|
329
|
+
return Array.from(this.openDocs.keys());
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Check if server supports pull diagnostics. */
|
|
333
|
+
get hasDiagnosticProvider(): boolean {
|
|
334
|
+
return (
|
|
335
|
+
this.capabilities?.diagnosticProvider !== undefined &&
|
|
336
|
+
this.capabilities.diagnosticProvider !== false
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Notify the server that watched workspace files changed. */
|
|
341
|
+
notifyWorkspaceFileChanges(changes: FileEvent[]): void {
|
|
342
|
+
if (!this.rpc || this._status !== "running" || changes.length === 0) return;
|
|
343
|
+
this.rpc.sendNotification("workspace/didChangeWatchedFiles", {
|
|
344
|
+
changes,
|
|
345
|
+
} satisfies DidChangeWatchedFilesParams);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Re-read and re-sync all currently open, existing documents.
|
|
350
|
+
* Delegates to client-refresh module.
|
|
351
|
+
*/
|
|
352
|
+
async refreshOpenDiagnostics(
|
|
353
|
+
options: { maxWaitMs?: number; quietMs?: number } = {},
|
|
354
|
+
): Promise<void> {
|
|
355
|
+
const { refreshClientOpenDiagnostics } = await import("./client-refresh.ts");
|
|
356
|
+
return refreshClientOpenDiagnostics(this, options);
|
|
357
|
+
}
|
|
358
|
+
|
|
287
359
|
/**
|
|
288
360
|
* Sync a file and wait for diagnostics (up to timeout).
|
|
289
361
|
* Returns diagnostics for the file.
|
|
290
362
|
*/
|
|
291
363
|
async syncAndWaitForDiagnostics(filePath: string, content: string): Promise<Diagnostic[]> {
|
|
292
364
|
const uri = fileToUri(filePath);
|
|
365
|
+
const syncStart = Date.now();
|
|
293
366
|
|
|
294
367
|
// Sync the content
|
|
295
368
|
this.didChange(filePath, content);
|
|
296
369
|
|
|
297
|
-
//
|
|
298
|
-
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
370
|
+
// Prefer pull diagnostics when available, but fall back to push notifications.
|
|
371
|
+
if (this.hasDiagnosticProvider) {
|
|
372
|
+
const remaining = DIAGNOSTIC_WAIT_MS - (Date.now() - syncStart);
|
|
373
|
+
if (remaining > 0) {
|
|
374
|
+
try {
|
|
375
|
+
const { pullDiagnosticsForUri } = await import("./client-refresh.ts");
|
|
376
|
+
const pulled = await pullDiagnosticsForUri(this, uri, remaining);
|
|
377
|
+
if (pulled) {
|
|
378
|
+
return this.getDiagnostics(filePath);
|
|
379
|
+
}
|
|
380
|
+
} catch {
|
|
381
|
+
// Pull diagnostics failed — fall back to push wait
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
307
385
|
|
|
386
|
+
await this.waitForDiagnostics(uri, Math.max(0, DIAGNOSTIC_WAIT_MS - (Date.now() - syncStart)));
|
|
308
387
|
return this.getDiagnostics(filePath);
|
|
309
388
|
}
|
|
310
389
|
|
|
311
390
|
// ── LSP Requests ───────────────────────────────────────────────────
|
|
312
|
-
|
|
313
391
|
async hover(filePath: string, position: Position): Promise<Hover | null> {
|
|
314
392
|
return this.request("textDocument/hover", {
|
|
315
393
|
textDocument: { uri: fileToUri(filePath) },
|
|
@@ -341,6 +419,11 @@ export class LspClient {
|
|
|
341
419
|
});
|
|
342
420
|
}
|
|
343
421
|
|
|
422
|
+
async workspaceSymbol(query: string): Promise<SymbolInformation[] | WorkspaceSymbol[] | null> {
|
|
423
|
+
if (!this.capabilities?.workspaceSymbolProvider) return null;
|
|
424
|
+
return this.request("workspace/symbol", { query });
|
|
425
|
+
}
|
|
426
|
+
|
|
344
427
|
async rename(
|
|
345
428
|
filePath: string,
|
|
346
429
|
position: Position,
|
|
@@ -365,8 +448,18 @@ export class LspClient {
|
|
|
365
448
|
});
|
|
366
449
|
}
|
|
367
450
|
|
|
368
|
-
|
|
451
|
+
async implementation(
|
|
452
|
+
filePath: string,
|
|
453
|
+
position: Position,
|
|
454
|
+
): Promise<Location | Location[] | LocationLink[] | null> {
|
|
455
|
+
if (!this.capabilities?.implementationProvider) return null;
|
|
456
|
+
return this.request("textDocument/implementation", {
|
|
457
|
+
textDocument: { uri: fileToUri(filePath) },
|
|
458
|
+
position,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
369
461
|
|
|
462
|
+
// ── Private ─────────────────────────────────────────────────────────
|
|
370
463
|
private async request<T>(method: string, params: unknown): Promise<T | null> {
|
|
371
464
|
if (!this.rpc || this._status !== "running") return null;
|
|
372
465
|
try {
|
|
@@ -377,16 +470,71 @@ export class LspClient {
|
|
|
377
470
|
}
|
|
378
471
|
|
|
379
472
|
private handlePublishDiagnostics(params: PublishDiagnosticsParams): void {
|
|
380
|
-
|
|
473
|
+
// If the publication includes a version and we have a newer synced
|
|
474
|
+
// version for this open document, ignore the stale publication.
|
|
475
|
+
if (params.version !== undefined && params.version !== null) {
|
|
476
|
+
const openDoc = this.openDocs.get(params.uri);
|
|
477
|
+
if (openDoc && params.version < openDoc.version) {
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
this.diagnosticStore.set(params.uri, {
|
|
483
|
+
diagnostics: params.diagnostics,
|
|
484
|
+
receivedAt: Date.now(),
|
|
485
|
+
version: params.version ?? undefined,
|
|
486
|
+
});
|
|
381
487
|
this.releaseDiagnosticWaiters(params.uri);
|
|
382
488
|
}
|
|
383
489
|
|
|
490
|
+
/** Wait for diagnostics on a URI, resolving on publication or timeout. */
|
|
491
|
+
private waitForDiagnostics(uri: string, timeoutMs: number): Promise<void> {
|
|
492
|
+
if (timeoutMs <= 0) return Promise.resolve();
|
|
493
|
+
|
|
494
|
+
return new Promise<void>((resolve) => {
|
|
495
|
+
const waiter = () => {
|
|
496
|
+
clearTimeout(timer);
|
|
497
|
+
this.removeDiagnosticWaiter(uri, waiter);
|
|
498
|
+
resolve();
|
|
499
|
+
};
|
|
500
|
+
const timer = setTimeout(() => {
|
|
501
|
+
this.removeDiagnosticWaiter(uri, waiter);
|
|
502
|
+
resolve();
|
|
503
|
+
}, timeoutMs);
|
|
504
|
+
const waiters = this.diagnosticWaiters.get(uri) ?? [];
|
|
505
|
+
waiters.push(waiter);
|
|
506
|
+
this.diagnosticWaiters.set(uri, waiters);
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Clear all per-file state and wake any diagnostics callers waiting on this URI. */
|
|
384
511
|
private clearFileState(uri: string): void {
|
|
385
512
|
this.openDocs.delete(uri);
|
|
386
513
|
this.diagnosticStore.delete(uri);
|
|
387
514
|
this.releaseDiagnosticWaiters(uri);
|
|
388
515
|
}
|
|
389
516
|
|
|
517
|
+
/** Remove a single pending diagnostics waiter, usually after its timeout fires. */
|
|
518
|
+
private removeDiagnosticWaiter(uri: string, waiter: () => void): void {
|
|
519
|
+
const waiters = this.diagnosticWaiters.get(uri);
|
|
520
|
+
if (!waiters) return;
|
|
521
|
+
|
|
522
|
+
const next = waiters.filter((entry) => entry !== waiter);
|
|
523
|
+
if (next.length > 0) {
|
|
524
|
+
this.diagnosticWaiters.set(uri, next);
|
|
525
|
+
} else {
|
|
526
|
+
this.diagnosticWaiters.delete(uri);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Wake every pending diagnostics waiter during shutdown or bulk cleanup. */
|
|
531
|
+
private releaseAllDiagnosticWaiters(): void {
|
|
532
|
+
for (const uri of Array.from(this.diagnosticWaiters.keys())) {
|
|
533
|
+
this.releaseDiagnosticWaiters(uri);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Wake all diagnostics waiters for a URI and remove them from the waiter map. */
|
|
390
538
|
private releaseDiagnosticWaiters(uri: string): void {
|
|
391
539
|
const waiters = this.diagnosticWaiters.get(uri);
|
|
392
540
|
if (!waiters) return;
|
|
@@ -6,7 +6,7 @@ import type {
|
|
|
6
6
|
JsonRpcNotification,
|
|
7
7
|
JsonRpcRequest,
|
|
8
8
|
JsonRpcResponse,
|
|
9
|
-
} from "
|
|
9
|
+
} from "../types.ts";
|
|
10
10
|
|
|
11
11
|
const CONTENT_LENGTH = "Content-Length: ";
|
|
12
12
|
const HEADER_DELIMITER = "\r\n\r\n";
|
|
@@ -48,18 +48,23 @@ export class JsonRpcClient {
|
|
|
48
48
|
this.notificationHandler = handler;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
/** Send a request and wait for the correlated response. */
|
|
52
|
-
sendRequest(
|
|
51
|
+
/** Send a request and wait for the correlated response, optionally overriding the timeout. */
|
|
52
|
+
sendRequest(
|
|
53
|
+
method: string,
|
|
54
|
+
params?: unknown,
|
|
55
|
+
options?: { timeoutMs?: number },
|
|
56
|
+
): Promise<unknown> {
|
|
53
57
|
if (this.closed) {
|
|
54
58
|
return Promise.reject(new Error("JSON-RPC client is closed"));
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
const id = this.nextId++;
|
|
62
|
+
const timeoutMs = options?.timeoutMs ?? this.timeoutMs;
|
|
58
63
|
const promise = new Promise<unknown>((resolve, reject) => {
|
|
59
64
|
const timer = setTimeout(() => {
|
|
60
65
|
this.pending.delete(id);
|
|
61
|
-
reject(new Error(`Request ${method} (id=${id}) timed out after ${
|
|
62
|
-
},
|
|
66
|
+
reject(new Error(`Request ${method} (id=${id}) timed out after ${timeoutMs}ms`));
|
|
67
|
+
}, timeoutMs);
|
|
63
68
|
|
|
64
69
|
this.pending.set(id, { resolve, reject, timer });
|
|
65
70
|
});
|
|
@@ -104,7 +109,6 @@ export class JsonRpcClient {
|
|
|
104
109
|
}
|
|
105
110
|
|
|
106
111
|
private processBuffer(): void {
|
|
107
|
-
// eslint-disable-next-line no-constant-condition
|
|
108
112
|
while (true) {
|
|
109
113
|
// Look for header delimiter
|
|
110
114
|
const headerEnd = this.buffer.indexOf(HEADER_DELIMITER);
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// LSP server configuration — load defaults, merge with supi config per language key.
|
|
2
|
+
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { loadSupiConfigForScope } from "@mrclrchtr/supi-core";
|
|
6
|
+
import type { LspConfig, ServerConfig } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
// Load defaults at module level — resolve relative to this file.
|
|
9
|
+
// pi loads extensions via jiti, which always provides __dirname.
|
|
10
|
+
const DEFAULTS: LspConfig = JSON.parse(
|
|
11
|
+
fs.readFileSync(path.join(__dirname, "defaults.json"), "utf-8"),
|
|
12
|
+
) as LspConfig;
|
|
13
|
+
|
|
14
|
+
// ── Public API ────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export interface LoadConfigOptions {
|
|
17
|
+
homeDir?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Map from language alias → canonical config key. */
|
|
21
|
+
export const LANGUAGE_ALIASES: Record<string, string> = {
|
|
22
|
+
cpp: "c",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Resolve a language name through aliases. */
|
|
26
|
+
export function resolveLanguageAlias(name: string): string {
|
|
27
|
+
return LANGUAGE_ALIASES[name] ?? name;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function resolveAliasesInOverrides(servers: Record<string, Partial<ServerConfig>>): void {
|
|
31
|
+
for (const [alias, target] of Object.entries(LANGUAGE_ALIASES)) {
|
|
32
|
+
if (servers[alias]) {
|
|
33
|
+
servers[target] = { ...(servers[target] ?? {}), ...servers[alias] };
|
|
34
|
+
delete servers[alias];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Load LSP config: built-in defaults merged with per-language-key overrides
|
|
41
|
+
* from supi config (`~/.pi/agent/supi/config.json` and `.pi/supi/config.json`).
|
|
42
|
+
* Each language key merges individually; omitted fields fall back to defaults.
|
|
43
|
+
*/
|
|
44
|
+
export function loadConfig(cwd: string, options?: LoadConfigOptions): LspConfig {
|
|
45
|
+
const defaults = DEFAULTS;
|
|
46
|
+
|
|
47
|
+
const globalLsp = loadSupiConfigForScope(
|
|
48
|
+
"lsp",
|
|
49
|
+
cwd,
|
|
50
|
+
{ enabled: true, severity: 1, active: [], servers: {} as Record<string, ServerConfig> },
|
|
51
|
+
{ scope: "global", homeDir: options?.homeDir },
|
|
52
|
+
);
|
|
53
|
+
const projectLsp = loadSupiConfigForScope(
|
|
54
|
+
"lsp",
|
|
55
|
+
cwd,
|
|
56
|
+
{ enabled: true, severity: 1, active: [], servers: {} as Record<string, ServerConfig> },
|
|
57
|
+
{ scope: "project" },
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const merged = mergeServerConfigs(defaults.servers, globalLsp.servers, projectLsp.servers);
|
|
61
|
+
|
|
62
|
+
return { servers: merged };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Find which server config handles a given file extension.
|
|
67
|
+
* Returns [languageName, config] or null.
|
|
68
|
+
*/
|
|
69
|
+
export function getServerForFile(
|
|
70
|
+
config: LspConfig,
|
|
71
|
+
filePath: string,
|
|
72
|
+
): [string, ServerConfig] | null {
|
|
73
|
+
const ext = path.extname(filePath).slice(1).toLowerCase();
|
|
74
|
+
if (!ext) return null;
|
|
75
|
+
|
|
76
|
+
for (const [name, server] of Object.entries(config.servers)) {
|
|
77
|
+
if (server.fileTypes.includes(ext)) {
|
|
78
|
+
return [name, server];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Private ───────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
function mergeServerConfigs(
|
|
87
|
+
defaults: Record<string, ServerConfig>,
|
|
88
|
+
globalOverrides: unknown,
|
|
89
|
+
projectOverrides: unknown,
|
|
90
|
+
): Record<string, ServerConfig> {
|
|
91
|
+
const merged: Record<string, ServerConfig> = { ...defaults };
|
|
92
|
+
|
|
93
|
+
const globalServers = isServerRecord(globalOverrides) ? globalOverrides : {};
|
|
94
|
+
const projectServers = isServerRecord(projectOverrides) ? projectOverrides : {};
|
|
95
|
+
|
|
96
|
+
resolveAliasesInOverrides(globalServers);
|
|
97
|
+
resolveAliasesInOverrides(projectServers);
|
|
98
|
+
|
|
99
|
+
// Apply global per-key overrides against defaults
|
|
100
|
+
for (const [lang, override] of Object.entries(globalServers)) {
|
|
101
|
+
const result = mergeSingleServer(defaults[lang], override);
|
|
102
|
+
if (result) merged[lang] = result;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Apply project per-key overrides against the result so far
|
|
106
|
+
for (const [lang, override] of Object.entries(projectServers)) {
|
|
107
|
+
const result = mergeSingleServer(merged[lang] ?? defaults[lang], override);
|
|
108
|
+
if (result) merged[lang] = result;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Remove servers whose final merged config has enabled === false
|
|
112
|
+
for (const [lang, config] of Object.entries(merged)) {
|
|
113
|
+
if (config.enabled === false) {
|
|
114
|
+
delete merged[lang];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return merged;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function mergeSingleServer(
|
|
122
|
+
base: ServerConfig | undefined,
|
|
123
|
+
override: Partial<ServerConfig>,
|
|
124
|
+
): ServerConfig | null {
|
|
125
|
+
if (!base) {
|
|
126
|
+
// New custom language — must have all required fields
|
|
127
|
+
if (
|
|
128
|
+
override.command &&
|
|
129
|
+
Array.isArray(override.fileTypes) &&
|
|
130
|
+
override.fileTypes.length > 0 &&
|
|
131
|
+
Array.isArray(override.rootMarkers) &&
|
|
132
|
+
override.rootMarkers.length > 0
|
|
133
|
+
) {
|
|
134
|
+
return override as ServerConfig;
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
return { ...base, ...override };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isServerRecord(value: unknown): value is Record<string, Partial<ServerConfig>> {
|
|
142
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
143
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
{
|
|
2
|
+
"servers": {
|
|
3
|
+
"typescript": {
|
|
4
|
+
"command": "typescript-language-server",
|
|
5
|
+
"args": ["--stdio"],
|
|
6
|
+
"fileTypes": ["ts", "tsx", "js", "jsx", "mts", "cts", "mjs", "cjs"],
|
|
7
|
+
"rootMarkers": ["tsconfig.json", "jsconfig.json", "package.json"]
|
|
8
|
+
},
|
|
9
|
+
"python": {
|
|
10
|
+
"command": "pyright-langserver",
|
|
11
|
+
"args": ["--stdio"],
|
|
12
|
+
"fileTypes": ["py", "pyi"],
|
|
13
|
+
"rootMarkers": [
|
|
14
|
+
"pyproject.toml",
|
|
15
|
+
"setup.py",
|
|
16
|
+
"setup.cfg",
|
|
17
|
+
"requirements.txt",
|
|
18
|
+
"pyrightconfig.json"
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
"rust": {
|
|
22
|
+
"command": "rust-analyzer",
|
|
23
|
+
"args": [],
|
|
24
|
+
"fileTypes": ["rs"],
|
|
25
|
+
"rootMarkers": ["Cargo.toml"]
|
|
26
|
+
},
|
|
27
|
+
"go": {
|
|
28
|
+
"command": "gopls",
|
|
29
|
+
"args": ["serve"],
|
|
30
|
+
"fileTypes": ["go", "mod"],
|
|
31
|
+
"rootMarkers": ["go.mod", "go.sum"]
|
|
32
|
+
},
|
|
33
|
+
"c": {
|
|
34
|
+
"command": "clangd",
|
|
35
|
+
"args": ["--background-index"],
|
|
36
|
+
"fileTypes": ["c", "h", "cpp", "hpp", "cc", "cxx", "hxx", "c++", "h++"],
|
|
37
|
+
"rootMarkers": ["compile_commands.json", "CMakeLists.txt", ".clangd", "Makefile"]
|
|
38
|
+
},
|
|
39
|
+
"ruby": {
|
|
40
|
+
"command": "ruby-lsp",
|
|
41
|
+
"args": [],
|
|
42
|
+
"fileTypes": ["rb", "erb", "gemspec"],
|
|
43
|
+
"rootMarkers": ["Gemfile", ".ruby-version"]
|
|
44
|
+
},
|
|
45
|
+
"java": {
|
|
46
|
+
"command": "jdtls",
|
|
47
|
+
"args": [],
|
|
48
|
+
"fileTypes": ["java"],
|
|
49
|
+
"rootMarkers": ["pom.xml", "build.gradle"]
|
|
50
|
+
},
|
|
51
|
+
"kotlin": {
|
|
52
|
+
"command": "kotlin-lsp",
|
|
53
|
+
"args": [],
|
|
54
|
+
"fileTypes": ["kt", "kts"],
|
|
55
|
+
"rootMarkers": ["build.gradle.kts", "pom.xml"]
|
|
56
|
+
},
|
|
57
|
+
"bash": {
|
|
58
|
+
"command": "bash-language-server",
|
|
59
|
+
"args": ["start"],
|
|
60
|
+
"fileTypes": ["sh", "bash", "zsh", "ksh"],
|
|
61
|
+
"rootMarkers": []
|
|
62
|
+
},
|
|
63
|
+
"html": {
|
|
64
|
+
"command": "vscode-html-language-server",
|
|
65
|
+
"args": ["--stdio"],
|
|
66
|
+
"fileTypes": ["html", "htm", "xhtml"],
|
|
67
|
+
"rootMarkers": ["package.json"]
|
|
68
|
+
},
|
|
69
|
+
"sql": {
|
|
70
|
+
"command": "sql-language-server",
|
|
71
|
+
"args": ["up", "--method", "stdio"],
|
|
72
|
+
"fileTypes": ["sql"],
|
|
73
|
+
"rootMarkers": []
|
|
74
|
+
},
|
|
75
|
+
"r": {
|
|
76
|
+
"command": "R",
|
|
77
|
+
"args": ["--slave", "-e", "languageserver::run()"],
|
|
78
|
+
"fileTypes": ["r"],
|
|
79
|
+
"rootMarkers": ["DESCRIPTION", "renv.lock", ".Rprofile"]
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import type { LspManager } from "../manager/manager.ts";
|
|
3
|
+
import type { Diagnostic, Hover, MarkedString, MarkupContent } from "../types.ts";
|
|
4
|
+
|
|
5
|
+
const AUGMENT_TIMEOUT_MS = 500;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Augment diagnostics with LSP hover and code_actions at the first severity-1 error.
|
|
9
|
+
* Silently returns null if LSP is unavailable, times out, or there are no errors.
|
|
10
|
+
*/
|
|
11
|
+
export async function augmentDiagnostics(
|
|
12
|
+
filePath: string,
|
|
13
|
+
diags: Diagnostic[],
|
|
14
|
+
manager: LspManager,
|
|
15
|
+
_cwd: string,
|
|
16
|
+
): Promise<string | null> {
|
|
17
|
+
const firstError = diags.find((d) => d.severity === 1);
|
|
18
|
+
if (!firstError) return null;
|
|
19
|
+
|
|
20
|
+
const resolvedPath = path.resolve(filePath);
|
|
21
|
+
const client = await manager.getClientForFile(filePath);
|
|
22
|
+
if (!client) return null;
|
|
23
|
+
|
|
24
|
+
const pos = firstError.range.start;
|
|
25
|
+
|
|
26
|
+
const [hoverResult, codeActionsResult] = await Promise.all([
|
|
27
|
+
withTimeout(client.hover(resolvedPath, pos), AUGMENT_TIMEOUT_MS),
|
|
28
|
+
withTimeout(
|
|
29
|
+
client.codeActions(resolvedPath, { start: pos, end: pos }, { diagnostics: [firstError] }),
|
|
30
|
+
AUGMENT_TIMEOUT_MS,
|
|
31
|
+
),
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
const parts: string[] = [];
|
|
35
|
+
|
|
36
|
+
if (hoverResult) {
|
|
37
|
+
const hoverText = formatHoverForDiagnostics(hoverResult);
|
|
38
|
+
if (hoverText) parts.push(`💡 Hover info:\n${hoverText}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (codeActionsResult && codeActionsResult.length > 0) {
|
|
42
|
+
const titles = codeActionsResult.map((a) => a.title).join(", ");
|
|
43
|
+
parts.push(`💡 Available fix: ${titles}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return parts.length > 0 ? parts.join("\n") : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Extract raw hover text for inline diagnostic augmentation.
|
|
51
|
+
* Intentionally strips markdown code-block framing (unlike formatHover)
|
|
52
|
+
* to keep augmentation concise and readable inside diagnostic output.
|
|
53
|
+
*/
|
|
54
|
+
function formatHoverForDiagnostics(hover: Hover): string {
|
|
55
|
+
const contents = hover.contents;
|
|
56
|
+
let text = "";
|
|
57
|
+
|
|
58
|
+
if (typeof contents === "string") {
|
|
59
|
+
text = contents;
|
|
60
|
+
} else if ("value" in contents) {
|
|
61
|
+
const mc = contents as MarkupContent | { language: string; value: string };
|
|
62
|
+
if ("kind" in mc) text = mc.value;
|
|
63
|
+
else text = mc.value;
|
|
64
|
+
} else if (Array.isArray(contents)) {
|
|
65
|
+
text = (contents as MarkedString[])
|
|
66
|
+
.map((c) => (typeof c === "string" ? c : c.value))
|
|
67
|
+
.join("\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const lines = text.split("\n").slice(0, 3);
|
|
71
|
+
return lines.join("\n");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function withTimeout<T>(promise: Promise<T | null>, ms: number): Promise<T | null> {
|
|
75
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
76
|
+
const timeoutPromise = new Promise<null>((resolve) => {
|
|
77
|
+
timer = setTimeout(() => resolve(null), ms);
|
|
78
|
+
});
|
|
79
|
+
const result = await Promise.race([promise, timeoutPromise]);
|
|
80
|
+
if (timer) clearTimeout(timer);
|
|
81
|
+
return result;
|
|
82
|
+
}
|