@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.
Files changed (71) hide show
  1. package/README.md +112 -0
  2. package/node_modules/@mrclrchtr/supi-core/README.md +90 -0
  3. package/node_modules/@mrclrchtr/supi-core/package.json +30 -0
  4. package/node_modules/@mrclrchtr/supi-core/src/config-settings.ts +76 -0
  5. package/node_modules/@mrclrchtr/supi-core/src/config.ts +186 -0
  6. package/node_modules/@mrclrchtr/supi-core/src/context-messages.ts +119 -0
  7. package/node_modules/@mrclrchtr/supi-core/src/context-provider-registry.ts +36 -0
  8. package/node_modules/@mrclrchtr/supi-core/src/context-tag.ts +31 -0
  9. package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +255 -0
  10. package/node_modules/@mrclrchtr/supi-core/src/index.ts +83 -0
  11. package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +170 -0
  12. package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +54 -0
  13. package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +29 -0
  14. package/node_modules/@mrclrchtr/supi-core/src/settings-command.ts +15 -0
  15. package/node_modules/@mrclrchtr/supi-core/src/settings-registry.ts +41 -0
  16. package/node_modules/@mrclrchtr/supi-core/src/settings-ui.ts +226 -0
  17. package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +60 -0
  18. package/package.json +18 -9
  19. package/{capabilities.ts → src/capabilities.ts} +8 -0
  20. package/src/client/client-refresh.ts +229 -0
  21. package/{client.ts → src/client/client.ts} +178 -30
  22. package/{transport.ts → src/client/transport.ts} +10 -6
  23. package/src/config.ts +143 -0
  24. package/src/defaults.json +82 -0
  25. package/src/diagnostics/diagnostic-augmentation.ts +82 -0
  26. package/src/diagnostics/diagnostic-display.ts +68 -0
  27. package/{diagnostic-summary.ts → src/diagnostics/diagnostic-summary.ts} +11 -7
  28. package/{diagnostics.ts → src/diagnostics/diagnostics.ts} +9 -4
  29. package/src/diagnostics/stale-diagnostics.ts +47 -0
  30. package/src/diagnostics/suppression-diagnostics.ts +58 -0
  31. package/src/format.ts +359 -0
  32. package/src/guidance.ts +163 -0
  33. package/src/index.ts +17 -0
  34. package/src/lsp-state.ts +82 -0
  35. package/src/lsp.ts +470 -0
  36. package/src/manager/manager-client-state.ts +34 -0
  37. package/src/manager/manager-diagnostics.ts +139 -0
  38. package/src/manager/manager-helpers.ts +39 -0
  39. package/src/manager/manager-project-info.ts +46 -0
  40. package/src/manager/manager-types.ts +39 -0
  41. package/src/manager/manager-workspace-recovery.ts +83 -0
  42. package/src/manager/manager-workspace-symbol.ts +18 -0
  43. package/src/manager/manager.ts +550 -0
  44. package/src/overrides.ts +173 -0
  45. package/src/pattern-matcher.ts +197 -0
  46. package/src/renderer.ts +120 -0
  47. package/src/scanner.ts +153 -0
  48. package/src/search-fallback.ts +98 -0
  49. package/src/service-registry.ts +153 -0
  50. package/src/settings-registration.ts +292 -0
  51. package/{summary.ts → src/summary.ts} +44 -9
  52. package/src/tool-actions.ts +430 -0
  53. package/src/tree-persist.ts +48 -0
  54. package/src/tsconfig-scope.ts +156 -0
  55. package/{types.ts → src/types.ts} +123 -0
  56. package/src/ui.ts +358 -0
  57. package/{utils.ts → src/utils.ts} +8 -25
  58. package/src/workspace-sentinels.ts +114 -0
  59. package/bash-guard.ts +0 -58
  60. package/config.ts +0 -99
  61. package/defaults.json +0 -40
  62. package/format.ts +0 -190
  63. package/guidance.ts +0 -140
  64. package/lsp.ts +0 -375
  65. package/manager.ts +0 -396
  66. package/overrides.ts +0 -95
  67. package/recent-paths.ts +0 -126
  68. package/runtime-state.ts +0 -113
  69. package/tool-actions.ts +0 -211
  70. package/tsconfig.json +0 -5
  71. 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 "./capabilities.ts";
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
- } from "./types.ts";
28
- import { detectLanguageId, fileToUri, uriToFile } from "./utils.ts";
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
- // ── LspClient ─────────────────────────────────────────────────────────
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 from the server */
56
- private diagnosticStore = new Map<string, Diagnostic[]>();
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
- // ── Lifecycle ───────────────────────────────────────────────────────
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
- /** Get all stored diagnostics across all files. */
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, diagnostics] of this.diagnosticStore) {
280
- if (diagnostics.length > 0) {
281
- result.push({ uri, diagnostics });
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
- // Wait for publishDiagnostics or timeout
298
- await new Promise<void>((resolve) => {
299
- const timer = setTimeout(resolve, DIAGNOSTIC_WAIT_MS);
300
- const waiters = this.diagnosticWaiters.get(uri) ?? [];
301
- waiters.push(() => {
302
- clearTimeout(timer);
303
- resolve();
304
- });
305
- this.diagnosticWaiters.set(uri, waiters);
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
- // ── Private ─────────────────────────────────────────────────────────
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
- this.diagnosticStore.set(params.uri, params.diagnostics);
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 "./types.ts";
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(method: string, params?: unknown): Promise<unknown> {
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 ${this.timeoutMs}ms`));
62
- }, this.timeoutMs);
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
+ }