@mrclrchtr/supi-code-intelligence 0.1.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 (146) hide show
  1. package/README.md +212 -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/node_modules/@mrclrchtr/supi-lsp/README.md +112 -0
  19. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/README.md +90 -0
  20. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/package.json +30 -0
  21. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/config-settings.ts +76 -0
  22. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/config.ts +186 -0
  23. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/context-messages.ts +119 -0
  24. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/context-provider-registry.ts +36 -0
  25. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/context-tag.ts +31 -0
  26. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +255 -0
  27. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/index.ts +83 -0
  28. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +170 -0
  29. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +54 -0
  30. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +29 -0
  31. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/settings-command.ts +15 -0
  32. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/settings-registry.ts +41 -0
  33. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/settings-ui.ts +226 -0
  34. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/terminal.ts +60 -0
  35. package/node_modules/@mrclrchtr/supi-lsp/package.json +45 -0
  36. package/node_modules/@mrclrchtr/supi-lsp/src/capabilities.ts +62 -0
  37. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-refresh.ts +229 -0
  38. package/node_modules/@mrclrchtr/supi-lsp/src/client/client.ts +545 -0
  39. package/node_modules/@mrclrchtr/supi-lsp/src/client/transport.ts +192 -0
  40. package/node_modules/@mrclrchtr/supi-lsp/src/config.ts +143 -0
  41. package/node_modules/@mrclrchtr/supi-lsp/src/defaults.json +82 -0
  42. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/diagnostic-augmentation.ts +82 -0
  43. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/diagnostic-display.ts +68 -0
  44. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/diagnostic-summary.ts +73 -0
  45. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/diagnostics.ts +98 -0
  46. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/stale-diagnostics.ts +47 -0
  47. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/suppression-diagnostics.ts +58 -0
  48. package/node_modules/@mrclrchtr/supi-lsp/src/format.ts +359 -0
  49. package/node_modules/@mrclrchtr/supi-lsp/src/guidance.ts +163 -0
  50. package/node_modules/@mrclrchtr/supi-lsp/src/index.ts +17 -0
  51. package/node_modules/@mrclrchtr/supi-lsp/src/lsp-state.ts +82 -0
  52. package/node_modules/@mrclrchtr/supi-lsp/src/lsp.ts +470 -0
  53. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-client-state.ts +34 -0
  54. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-diagnostics.ts +139 -0
  55. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-helpers.ts +39 -0
  56. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-project-info.ts +46 -0
  57. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-types.ts +39 -0
  58. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-workspace-recovery.ts +83 -0
  59. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-workspace-symbol.ts +18 -0
  60. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager.ts +550 -0
  61. package/node_modules/@mrclrchtr/supi-lsp/src/overrides.ts +173 -0
  62. package/node_modules/@mrclrchtr/supi-lsp/src/pattern-matcher.ts +197 -0
  63. package/node_modules/@mrclrchtr/supi-lsp/src/renderer.ts +120 -0
  64. package/node_modules/@mrclrchtr/supi-lsp/src/scanner.ts +153 -0
  65. package/node_modules/@mrclrchtr/supi-lsp/src/search-fallback.ts +98 -0
  66. package/node_modules/@mrclrchtr/supi-lsp/src/service-registry.ts +153 -0
  67. package/node_modules/@mrclrchtr/supi-lsp/src/settings-registration.ts +292 -0
  68. package/node_modules/@mrclrchtr/supi-lsp/src/summary.ts +153 -0
  69. package/node_modules/@mrclrchtr/supi-lsp/src/tool-actions.ts +430 -0
  70. package/node_modules/@mrclrchtr/supi-lsp/src/tree-persist.ts +48 -0
  71. package/node_modules/@mrclrchtr/supi-lsp/src/tsconfig-scope.ts +156 -0
  72. package/node_modules/@mrclrchtr/supi-lsp/src/types.ts +409 -0
  73. package/node_modules/@mrclrchtr/supi-lsp/src/ui.ts +358 -0
  74. package/node_modules/@mrclrchtr/supi-lsp/src/utils.ts +122 -0
  75. package/node_modules/@mrclrchtr/supi-lsp/src/workspace-sentinels.ts +114 -0
  76. package/node_modules/@mrclrchtr/supi-tree-sitter/README.md +97 -0
  77. package/node_modules/@mrclrchtr/supi-tree-sitter/package.json +67 -0
  78. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/.gitkeep +0 -0
  79. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/bash/tree-sitter-bash.wasm +0 -0
  80. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/bash/tree-sitter-bash.wasm.json +7 -0
  81. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/c/tree-sitter-c.wasm +0 -0
  82. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/c/tree-sitter-c.wasm.json +7 -0
  83. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/cpp/tree-sitter-cpp.wasm +0 -0
  84. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/cpp/tree-sitter-cpp.wasm.json +7 -0
  85. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/go/tree-sitter-go.wasm +0 -0
  86. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/go/tree-sitter-go.wasm.json +7 -0
  87. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/html/tree-sitter-html.wasm +0 -0
  88. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/html/tree-sitter-html.wasm.json +7 -0
  89. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/java/tree-sitter-java.wasm +0 -0
  90. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/java/tree-sitter-java.wasm.json +7 -0
  91. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/javascript/tree-sitter-javascript.wasm +0 -0
  92. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/javascript/tree-sitter-javascript.wasm.json +7 -0
  93. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/kotlin/tree-sitter-kotlin.wasm +0 -0
  94. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/kotlin/tree-sitter-kotlin.wasm.json +12 -0
  95. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/python/tree-sitter-python.wasm +0 -0
  96. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/python/tree-sitter-python.wasm.json +7 -0
  97. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/r/tree-sitter-r.wasm +0 -0
  98. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/r/tree-sitter-r.wasm.json +7 -0
  99. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/ruby/tree-sitter-ruby.wasm +0 -0
  100. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/ruby/tree-sitter-ruby.wasm.json +7 -0
  101. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/rust/tree-sitter-rust.wasm +0 -0
  102. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/rust/tree-sitter-rust.wasm.json +7 -0
  103. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/sql/tree-sitter-sql.wasm +0 -0
  104. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/sql/tree-sitter-sql.wasm.json +19 -0
  105. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/tsx/tree-sitter-tsx.wasm +0 -0
  106. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/tsx/tree-sitter-tsx.wasm.json +7 -0
  107. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/typescript/tree-sitter-typescript.wasm +0 -0
  108. package/node_modules/@mrclrchtr/supi-tree-sitter/resources/grammars/typescript/tree-sitter-typescript.wasm.json +7 -0
  109. package/node_modules/@mrclrchtr/supi-tree-sitter/scripts/generate-kotlin-wasm.mjs +126 -0
  110. package/node_modules/@mrclrchtr/supi-tree-sitter/scripts/generate-sql-wasm.mjs +144 -0
  111. package/node_modules/@mrclrchtr/supi-tree-sitter/scripts/vendor-wasm.mjs +151 -0
  112. package/node_modules/@mrclrchtr/supi-tree-sitter/src/callees.ts +343 -0
  113. package/node_modules/@mrclrchtr/supi-tree-sitter/src/coordinates.ts +108 -0
  114. package/node_modules/@mrclrchtr/supi-tree-sitter/src/exports.ts +315 -0
  115. package/node_modules/@mrclrchtr/supi-tree-sitter/src/formatting.ts +104 -0
  116. package/node_modules/@mrclrchtr/supi-tree-sitter/src/imports.ts +42 -0
  117. package/node_modules/@mrclrchtr/supi-tree-sitter/src/index.ts +16 -0
  118. package/node_modules/@mrclrchtr/supi-tree-sitter/src/language.ts +116 -0
  119. package/node_modules/@mrclrchtr/supi-tree-sitter/src/node-at.ts +96 -0
  120. package/node_modules/@mrclrchtr/supi-tree-sitter/src/outline.ts +287 -0
  121. package/node_modules/@mrclrchtr/supi-tree-sitter/src/runtime.ts +237 -0
  122. package/node_modules/@mrclrchtr/supi-tree-sitter/src/session.ts +112 -0
  123. package/node_modules/@mrclrchtr/supi-tree-sitter/src/structure.ts +7 -0
  124. package/node_modules/@mrclrchtr/supi-tree-sitter/src/syntax-node.ts +13 -0
  125. package/node_modules/@mrclrchtr/supi-tree-sitter/src/tree-sitter.ts +306 -0
  126. package/node_modules/@mrclrchtr/supi-tree-sitter/src/types.ts +146 -0
  127. package/package.json +47 -0
  128. package/src/actions/affected-action.ts +310 -0
  129. package/src/actions/brief-action.ts +242 -0
  130. package/src/actions/callees-action.ts +134 -0
  131. package/src/actions/callers-action.ts +215 -0
  132. package/src/actions/implementations-action.ts +190 -0
  133. package/src/actions/index-action.ts +187 -0
  134. package/src/actions/pattern-action.ts +232 -0
  135. package/src/architecture.ts +367 -0
  136. package/src/brief-focused.ts +383 -0
  137. package/src/brief.ts +228 -0
  138. package/src/code-intelligence.ts +122 -0
  139. package/src/git-context.ts +65 -0
  140. package/src/guidance.ts +39 -0
  141. package/src/index.ts +28 -0
  142. package/src/resolve-target.ts +104 -0
  143. package/src/search-helpers.ts +283 -0
  144. package/src/target-resolution.ts +368 -0
  145. package/src/tool-actions.ts +109 -0
  146. package/src/types.ts +57 -0
@@ -0,0 +1,545 @@
1
+ // LSP Client — wraps a server process + JsonRpcClient.
2
+ // Handles initialize handshake, document sync, shutdown, and crash recovery.
3
+
4
+ // biome-ignore lint/nursery/noExcessiveLinesPerFile: LspClient remains a cohesive stateful wrapper; refresh logic is already split out.
5
+ import { type ChildProcess, spawn } from "node:child_process";
6
+ import { existsSync } from "node:fs";
7
+ import { CLIENT_CAPABILITIES } from "../capabilities.ts";
8
+ import type {
9
+ CodeAction,
10
+ CodeActionContext,
11
+ Diagnostic,
12
+ DidChangeWatchedFilesParams,
13
+ DocumentSymbol,
14
+ FileEvent,
15
+ Hover,
16
+ InitializeResult,
17
+ Location,
18
+ LocationLink,
19
+ Position,
20
+ PublishDiagnosticsParams,
21
+ Range,
22
+ ServerCapabilities,
23
+ ServerConfig,
24
+ SymbolInformation,
25
+ TextDocumentIdentifier,
26
+ TextDocumentItem,
27
+ VersionedTextDocumentIdentifier,
28
+ WorkspaceEdit,
29
+ WorkspaceSymbol,
30
+ } from "../types.ts";
31
+ import { detectLanguageId, fileToUri, uriToFile } from "../utils.ts";
32
+ import { JsonRpcClient } from "./transport.ts";
33
+
34
+ const SHUTDOWN_TIMEOUT_MS = 5_000;
35
+ const DIAGNOSTIC_WAIT_MS = 3_000;
36
+
37
+ // ── Types ─────────────────────────────────────────────────────────────
38
+ export type ClientStatus = "initializing" | "running" | "error" | "shutdown";
39
+
40
+ export interface DiagnosticEntry {
41
+ uri: string;
42
+ diagnostics: Diagnostic[];
43
+ }
44
+
45
+ /** Internal metadata tracked alongside cached diagnostics. */
46
+ export interface DiagnosticCacheEntry {
47
+ diagnostics: Diagnostic[];
48
+ receivedAt: number;
49
+ version?: number;
50
+ resultId?: string;
51
+ }
52
+
53
+ // ── LspClient ─────────────────────────────────────────────────────────
54
+ export class LspClient {
55
+ readonly name: string;
56
+ readonly root: string;
57
+
58
+ private process: ChildProcess | null = null;
59
+ private rpc: JsonRpcClient | null = null;
60
+ private _status: ClientStatus = "initializing";
61
+ private capabilities: ServerCapabilities | null = null;
62
+
63
+ /** Open documents: uri → { version, languageId } */
64
+ private openDocs = new Map<string, { version: number; languageId: string }>();
65
+ /** Per-file diagnostics with freshness metadata */
66
+ private diagnosticStore = new Map<string, DiagnosticCacheEntry>();
67
+ /** Listeners waiting for diagnostics on a specific uri */
68
+ private diagnosticWaiters = new Map<string, Array<() => void>>();
69
+
70
+ constructor(
71
+ name: string,
72
+ private readonly config: ServerConfig,
73
+ root: string,
74
+ ) {
75
+ this.name = name;
76
+ this.root = root;
77
+ }
78
+
79
+ get status(): ClientStatus {
80
+ return this._status;
81
+ }
82
+
83
+ get openFiles(): string[] {
84
+ return Array.from(this.openDocs.keys()).map(uriToFile);
85
+ }
86
+
87
+ get serverCapabilities(): ServerCapabilities | null {
88
+ return this.capabilities;
89
+ }
90
+
91
+ // ── Lifecycle ───────────────────────────────────────────────────────
92
+ /** Spawn the server process and perform the initialize handshake. */
93
+ async start(): Promise<void> {
94
+ const cmd = this.config.command;
95
+ const args = this.config.args ?? [];
96
+
97
+ try {
98
+ this.process = spawn(cmd, args, {
99
+ cwd: this.root,
100
+ stdio: ["pipe", "pipe", "pipe"],
101
+ env: { ...process.env },
102
+ });
103
+ } catch (err) {
104
+ this._status = "error";
105
+ throw new Error(`Failed to spawn ${cmd}: ${err}`, { cause: err });
106
+ }
107
+
108
+ if (!this.process.stdin || !this.process.stdout) {
109
+ this._status = "error";
110
+ this.process.kill();
111
+ throw new Error(`${cmd}: missing stdin/stdout`);
112
+ }
113
+
114
+ this.rpc = new JsonRpcClient(this.process.stdout, this.process.stdin);
115
+
116
+ // Handle notifications
117
+ this.rpc.onNotification((method, params) => {
118
+ if (method === "textDocument/publishDiagnostics") {
119
+ this.handlePublishDiagnostics(params as PublishDiagnosticsParams);
120
+ }
121
+ });
122
+
123
+ // Handle crashes
124
+ this.process.on("exit", (_code) => {
125
+ if (this._status !== "shutdown") {
126
+ this._status = "error";
127
+ }
128
+ this.rpc?.dispose();
129
+ });
130
+
131
+ this.process.on("error", (_err) => {
132
+ if (this._status !== "shutdown") {
133
+ this._status = "error";
134
+ }
135
+ });
136
+
137
+ // Suppress stderr to avoid noise in the agent
138
+ this.process.stderr?.on("data", () => {});
139
+
140
+ // Initialize handshake
141
+ try {
142
+ const result = (await this.rpc.sendRequest("initialize", {
143
+ processId: process.pid,
144
+ rootUri: fileToUri(this.root),
145
+ capabilities: CLIENT_CAPABILITIES,
146
+ initializationOptions: this.config.initializationOptions,
147
+ })) as InitializeResult;
148
+
149
+ this.capabilities = result.capabilities;
150
+ this.rpc.sendNotification("initialized", {});
151
+ this._status = "running";
152
+ } catch (err) {
153
+ this._status = "error";
154
+ this.process.kill();
155
+ throw new Error(`${this.name}: initialize failed: ${err}`, { cause: err });
156
+ }
157
+ }
158
+
159
+ /** Graceful shutdown: send shutdown → exit, kill after timeout. */
160
+ async shutdown(): Promise<void> {
161
+ if (this._status === "shutdown") return;
162
+ this._status = "shutdown";
163
+
164
+ if (!this.rpc || !this.process) return;
165
+
166
+ try {
167
+ // Send shutdown request with a timeout
168
+ await Promise.race([
169
+ this.rpc.sendRequest("shutdown"),
170
+ new Promise((_, reject) =>
171
+ setTimeout(() => reject(new Error("shutdown timeout")), SHUTDOWN_TIMEOUT_MS),
172
+ ),
173
+ ]);
174
+ this.rpc.sendNotification("exit");
175
+ } catch {
176
+ // Timeout or error — force kill
177
+ }
178
+
179
+ this.rpc.dispose();
180
+
181
+ // Wait briefly for clean exit, then force kill
182
+ if (this.process.exitCode === null) {
183
+ await new Promise<void>((resolve) => {
184
+ const timer = setTimeout(() => {
185
+ this.process?.kill("SIGTERM");
186
+ resolve();
187
+ }, 1_000);
188
+ this.process?.on("exit", () => {
189
+ clearTimeout(timer);
190
+ resolve();
191
+ });
192
+ });
193
+ }
194
+
195
+ this.openDocs.clear();
196
+ this.diagnosticStore.clear();
197
+ this.releaseAllDiagnosticWaiters();
198
+ }
199
+
200
+ // ── Document Synchronization ────────────────────────────────────────
201
+ /** Open a document (or re-sync if already open). */
202
+ didOpen(filePath: string, content: string): void {
203
+ if (!this.rpc || this._status !== "running") return;
204
+
205
+ const uri = fileToUri(filePath);
206
+ const languageId = detectLanguageId(filePath);
207
+
208
+ if (this.openDocs.has(uri)) {
209
+ // Already open — send didChange instead
210
+ this.didChange(filePath, content);
211
+ return;
212
+ }
213
+
214
+ this.openDocs.set(uri, { version: 1, languageId });
215
+ this.rpc.sendNotification("textDocument/didOpen", {
216
+ textDocument: {
217
+ uri,
218
+ languageId,
219
+ version: 1,
220
+ text: content,
221
+ } satisfies TextDocumentItem,
222
+ });
223
+ }
224
+
225
+ /** Notify the server of a content change (full document sync). */
226
+ didChange(filePath: string, content: string): void {
227
+ if (!this.rpc || this._status !== "running") return;
228
+
229
+ const uri = fileToUri(filePath);
230
+ const doc = this.openDocs.get(uri);
231
+
232
+ if (!doc) {
233
+ // Not yet open — do a didOpen
234
+ this.didOpen(filePath, content);
235
+ return;
236
+ }
237
+
238
+ doc.version++;
239
+ this.rpc.sendNotification("textDocument/didChange", {
240
+ textDocument: { uri, version: doc.version } satisfies VersionedTextDocumentIdentifier,
241
+ contentChanges: [{ text: content }],
242
+ });
243
+ }
244
+
245
+ /** Close a document and clear any cached state for it. */
246
+ didClose(filePath: string): void {
247
+ const uri = fileToUri(filePath);
248
+ const wasOpen = this.openDocs.has(uri);
249
+
250
+ this.clearFileState(uri);
251
+
252
+ if (!wasOpen || !this.rpc || this._status !== "running") return;
253
+
254
+ this.rpc.sendNotification("textDocument/didClose", {
255
+ textDocument: { uri } satisfies TextDocumentIdentifier,
256
+ });
257
+ }
258
+
259
+ /** Prune missing files from open documents and cached diagnostics. */
260
+ pruneMissingFiles(): string[] {
261
+ const uris = new Set([...this.openDocs.keys(), ...this.diagnosticStore.keys()]);
262
+ const removedFiles: string[] = [];
263
+
264
+ for (const uri of uris) {
265
+ const filePath = uriToFile(uri);
266
+ if (existsSync(filePath)) continue;
267
+
268
+ const wasOpen = this.openDocs.has(uri);
269
+ this.clearFileState(uri);
270
+ removedFiles.push(filePath);
271
+
272
+ if (wasOpen && this.rpc && this._status === "running") {
273
+ this.rpc.sendNotification("textDocument/didClose", {
274
+ textDocument: { uri } satisfies TextDocumentIdentifier,
275
+ });
276
+ }
277
+ }
278
+
279
+ return removedFiles;
280
+ }
281
+
282
+ // ── Diagnostics ─────────────────────────────────────────────────────
283
+ /** Get stored diagnostics for a file. */
284
+ getDiagnostics(filePath: string): Diagnostic[] {
285
+ return this.diagnosticStore.get(fileToUri(filePath))?.diagnostics ?? [];
286
+ }
287
+
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
+ */
296
+ getAllDiagnostics(): DiagnosticEntry[] {
297
+ const result: DiagnosticEntry[] = [];
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 });
304
+ }
305
+ return result;
306
+ }
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
+
359
+ /**
360
+ * Sync a file and wait for diagnostics (up to timeout).
361
+ * Returns diagnostics for the file.
362
+ */
363
+ async syncAndWaitForDiagnostics(filePath: string, content: string): Promise<Diagnostic[]> {
364
+ const uri = fileToUri(filePath);
365
+ const syncStart = Date.now();
366
+
367
+ // Sync the content
368
+ this.didChange(filePath, content);
369
+
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
+ }
385
+
386
+ await this.waitForDiagnostics(uri, Math.max(0, DIAGNOSTIC_WAIT_MS - (Date.now() - syncStart)));
387
+ return this.getDiagnostics(filePath);
388
+ }
389
+
390
+ // ── LSP Requests ───────────────────────────────────────────────────
391
+ async hover(filePath: string, position: Position): Promise<Hover | null> {
392
+ return this.request("textDocument/hover", {
393
+ textDocument: { uri: fileToUri(filePath) },
394
+ position,
395
+ });
396
+ }
397
+
398
+ async definition(
399
+ filePath: string,
400
+ position: Position,
401
+ ): Promise<Location | Location[] | LocationLink[] | null> {
402
+ return this.request("textDocument/definition", {
403
+ textDocument: { uri: fileToUri(filePath) },
404
+ position,
405
+ });
406
+ }
407
+
408
+ async references(filePath: string, position: Position): Promise<Location[] | null> {
409
+ return this.request("textDocument/references", {
410
+ textDocument: { uri: fileToUri(filePath) },
411
+ position,
412
+ context: { includeDeclaration: true },
413
+ });
414
+ }
415
+
416
+ async documentSymbols(filePath: string): Promise<DocumentSymbol[] | SymbolInformation[] | null> {
417
+ return this.request("textDocument/documentSymbol", {
418
+ textDocument: { uri: fileToUri(filePath) },
419
+ });
420
+ }
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
+
427
+ async rename(
428
+ filePath: string,
429
+ position: Position,
430
+ newName: string,
431
+ ): Promise<WorkspaceEdit | null> {
432
+ return this.request("textDocument/rename", {
433
+ textDocument: { uri: fileToUri(filePath) },
434
+ position,
435
+ newName,
436
+ });
437
+ }
438
+
439
+ async codeActions(
440
+ filePath: string,
441
+ range: Range,
442
+ context: CodeActionContext,
443
+ ): Promise<CodeAction[] | null> {
444
+ return this.request("textDocument/codeAction", {
445
+ textDocument: { uri: fileToUri(filePath) },
446
+ range,
447
+ context,
448
+ });
449
+ }
450
+
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
+ }
461
+
462
+ // ── Private ─────────────────────────────────────────────────────────
463
+ private async request<T>(method: string, params: unknown): Promise<T | null> {
464
+ if (!this.rpc || this._status !== "running") return null;
465
+ try {
466
+ return (await this.rpc.sendRequest(method, params)) as T;
467
+ } catch {
468
+ return null;
469
+ }
470
+ }
471
+
472
+ private handlePublishDiagnostics(params: PublishDiagnosticsParams): void {
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
+ });
487
+ this.releaseDiagnosticWaiters(params.uri);
488
+ }
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. */
511
+ private clearFileState(uri: string): void {
512
+ this.openDocs.delete(uri);
513
+ this.diagnosticStore.delete(uri);
514
+ this.releaseDiagnosticWaiters(uri);
515
+ }
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. */
538
+ private releaseDiagnosticWaiters(uri: string): void {
539
+ const waiters = this.diagnosticWaiters.get(uri);
540
+ if (!waiters) return;
541
+
542
+ this.diagnosticWaiters.delete(uri);
543
+ for (const waiter of waiters) waiter();
544
+ }
545
+ }