@mrclrchtr/supi-lsp 7.4.0 → 8.0.1

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 (32) hide show
  1. package/README.md +29 -6
  2. package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  3. package/node_modules/@mrclrchtr/supi-code-runtime/src/api.ts +1 -1
  4. package/node_modules/@mrclrchtr/supi-code-runtime/src/query-result.ts +22 -4
  5. package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  6. package/node_modules/@mrclrchtr/supi-core/src/config/config.ts +110 -0
  7. package/node_modules/@mrclrchtr/supi-core/src/config.ts +6 -1
  8. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-schema.ts +2 -1
  9. package/package.json +3 -3
  10. package/src/api.ts +25 -3
  11. package/src/client/client-diagnostic-host.ts +2 -1
  12. package/src/client/client-diagnostic-refresh.ts +5 -2
  13. package/src/client/client-diagnostics.ts +39 -6
  14. package/src/client/client-document-sync.ts +82 -12
  15. package/src/client/client-semantic-input-barrier.ts +28 -5
  16. package/src/client/client.ts +606 -30
  17. package/src/config/capabilities.ts +9 -0
  18. package/src/config/config.ts +69 -8
  19. package/src/config/operation-support.ts +196 -0
  20. package/src/config/server-config.ts +15 -0
  21. package/src/config/types.ts +9 -1
  22. package/src/manager/manager-project-info.ts +2 -0
  23. package/src/manager/manager-workspace-symbol.ts +31 -2
  24. package/src/manager/manager.ts +72 -2
  25. package/src/provider/lsp-refactor-provider.ts +9 -43
  26. package/src/provider/refactor-planning.ts +102 -50
  27. package/src/provider/semantic-edit-normalizer.ts +49 -3
  28. package/src/session/runtime-controller.ts +78 -18
  29. package/src/session/runtime-registry.ts +46 -2
  30. package/src/session/workspace-lsp-runtime.ts +14 -0
  31. package/src/workspace-path-policy.ts +30 -4
  32. package/src/config/lsp-settings.ts +0 -22
@@ -16,9 +16,18 @@ import {
16
16
  recordDebugEvent,
17
17
  truncateDebugIdentity as truncateIdentity,
18
18
  } from "@mrclrchtr/supi-core/debug";
19
- import { fileToUri } from "@mrclrchtr/supi-core/path";
19
+ import { fileToUri, uriToFile } from "@mrclrchtr/supi-core/path";
20
20
  import { type ProgressToken, TextDocumentSyncKind } from "vscode-languageserver-protocol";
21
+ import { CodeActionTriggerKind } from "vscode-languageserver-types";
21
22
  import { CLIENT_CAPABILITIES } from "../config/capabilities.ts";
23
+ import {
24
+ getServerOperationSupport,
25
+ supportsCodeActionResolve,
26
+ supportsCodeActions,
27
+ supportsPrepareRename,
28
+ supportsRename,
29
+ supportsRequestedCodeActionKinds,
30
+ } from "../config/operation-support.ts";
22
31
  import type {
23
32
  CodeAction,
24
33
  CodeActionContext,
@@ -29,6 +38,8 @@ import type {
29
38
  FileEvent,
30
39
  Hover,
31
40
  InitializeResult,
41
+ JsonObject,
42
+ JsonValue,
32
43
  Location,
33
44
  LocationLink,
34
45
  Position,
@@ -57,6 +68,7 @@ import { createPriorityDiagnosticRequestAdapter } from "./client-diagnostic-requ
57
68
  import { createTypeScriptDiagnosticRequestAdapter } from "./client-diagnostic-typescript.ts";
58
69
  import { ClientDiagnostics } from "./client-diagnostics.ts";
59
70
  import type { ClientDiagnosticSnapshot, DiagnosticEntry } from "./client-document-state.ts";
71
+ import { type NormalizedDocumentSync, normalizeDocumentSync } from "./client-document-sync.ts";
60
72
  import { SemanticInputRequestRetryGuard } from "./client-request-enrollment.ts";
61
73
  import {
62
74
  isSemanticInputEnrollmentError,
@@ -70,6 +82,11 @@ interface SemanticRequestAttemptState {
70
82
  inputRevision: number | undefined;
71
83
  }
72
84
 
85
+ interface RefactorTransaction {
86
+ rpc: JsonRpcClient;
87
+ assertCurrent: () => Promise<void>;
88
+ }
89
+
73
90
  /**
74
91
  * Fixed bound after which a running client that never became ready is
75
92
  * considered readiness-stalled and eligible for a recovery restart.
@@ -105,6 +122,291 @@ function isRecord(value: unknown): value is Record<string, unknown> {
105
122
  return typeof value === "object" && value !== null && !Array.isArray(value);
106
123
  }
107
124
 
125
+ function isPosition(value: unknown): value is Position {
126
+ return (
127
+ isRecord(value) &&
128
+ Object.hasOwn(value, "line") &&
129
+ Object.hasOwn(value, "character") &&
130
+ Object.keys(value).every((key) => key === "line" || key === "character") &&
131
+ Number.isInteger(value.line) &&
132
+ (value.line as number) >= 0 &&
133
+ Number.isInteger(value.character) &&
134
+ (value.character as number) >= 0
135
+ );
136
+ }
137
+
138
+ function isRange(value: unknown): value is Range {
139
+ return (
140
+ isRecord(value) &&
141
+ Object.hasOwn(value, "start") &&
142
+ Object.hasOwn(value, "end") &&
143
+ Object.keys(value).every((key) => key === "start" || key === "end") &&
144
+ isPosition(value.start) &&
145
+ isPosition(value.end) &&
146
+ !isBefore(value.end, value.start)
147
+ );
148
+ }
149
+
150
+ function isBefore(left: Position, right: Position): boolean {
151
+ return left.line < right.line || (left.line === right.line && left.character < right.character);
152
+ }
153
+
154
+ function isPositionInRange(position: Position, range: Range): boolean {
155
+ return !isBefore(position, range.start) && !isBefore(range.end, position);
156
+ }
157
+
158
+ function hasOnlyKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean {
159
+ const allowedKeys = new Set(allowed);
160
+ return Object.keys(value).every((key) => allowedKeys.has(key));
161
+ }
162
+
163
+ function validatePrepareRenameResult(value: unknown, position: Position): string | null {
164
+ if (value === null || value === undefined) {
165
+ return "Rename is unavailable at the requested position.";
166
+ }
167
+ if (!isRecord(value)) return "LSP server returned a malformed prepareRename result.";
168
+ if (isRange(value)) {
169
+ return isPositionInRange(position, value)
170
+ ? null
171
+ : "LSP server returned a prepareRename range that excludes the requested position.";
172
+ }
173
+ if (
174
+ hasOnlyKeys(value, ["range", "placeholder"]) &&
175
+ Object.hasOwn(value, "range") &&
176
+ Object.hasOwn(value, "placeholder") &&
177
+ typeof value.placeholder === "string" &&
178
+ isRange(value.range)
179
+ ) {
180
+ return isPositionInRange(position, value.range)
181
+ ? null
182
+ : "LSP server returned a prepareRename range that excludes the requested position.";
183
+ }
184
+ if (
185
+ hasOnlyKeys(value, ["defaultBehavior"]) &&
186
+ Object.hasOwn(value, "defaultBehavior") &&
187
+ typeof value.defaultBehavior === "boolean"
188
+ ) {
189
+ return null;
190
+ }
191
+ return "LSP server returned a malformed prepareRename result.";
192
+ }
193
+
194
+ function isLazyCodeAction(value: unknown): value is CodeAction {
195
+ return (
196
+ isRecord(value) &&
197
+ Object.hasOwn(value, "title") &&
198
+ typeof value.title === "string" &&
199
+ value.title.trim().length > 0 &&
200
+ !Object.hasOwn(value, "edit") &&
201
+ !Object.hasOwn(value, "command") &&
202
+ !Object.hasOwn(value, "disabled") &&
203
+ hasNoInheritedAllowedKeys(value, [
204
+ "title",
205
+ "kind",
206
+ "diagnostics",
207
+ "isPreferred",
208
+ "disabled",
209
+ "edit",
210
+ "command",
211
+ "data",
212
+ ])
213
+ );
214
+ }
215
+
216
+ function codeActionKindMatchesFilter(kind: unknown, filter: string): boolean {
217
+ return (
218
+ filter === "" ||
219
+ (typeof kind === "string" && (kind === filter || kind.startsWith(`${filter}.`)))
220
+ );
221
+ }
222
+
223
+ function isValidCommand(value: unknown): value is NonNullable<CodeAction["command"]> {
224
+ const command = isRecord(value) ? value : null;
225
+ return (
226
+ command !== null &&
227
+ Object.hasOwn(command, "title") &&
228
+ typeof command.title === "string" &&
229
+ command.title.trim().length > 0 &&
230
+ Object.hasOwn(command, "command") &&
231
+ typeof command.command === "string" &&
232
+ hasOnlyKeys(command, ["title", "tooltip", "command", "arguments"]) &&
233
+ hasNoInheritedAllowedKeys(command, ["title", "tooltip", "command", "arguments"]) &&
234
+ (!Object.hasOwn(command, "tooltip") || typeof command.tooltip === "string") &&
235
+ (!Object.hasOwn(command, "arguments") || Array.isArray(command.arguments))
236
+ );
237
+ }
238
+
239
+ /** Validate one code-action response before matching or lazy resolution. */
240
+ function normalizeCodeActionResponseEntry(value: unknown): CodeAction | null {
241
+ const entry = isRecord(value) ? value : null;
242
+ if (!hasValidCodeActionTitle(entry)) return null;
243
+
244
+ // A protocol response may be a Command, not only a CodeAction. Keep it as
245
+ // an edit-less CodeAction so later planning reports it as unsupported.
246
+ if (typeof entry.command === "string") return normalizeCommandCodeAction(entry);
247
+ return isValidCodeActionEntry(entry) ? (entry as CodeAction) : null;
248
+ }
249
+
250
+ function hasValidCodeActionTitle(
251
+ entry: Record<string, unknown> | null,
252
+ ): entry is Record<string, unknown> {
253
+ return (
254
+ entry !== null &&
255
+ Object.hasOwn(entry, "title") &&
256
+ typeof entry.title === "string" &&
257
+ entry.title.trim().length > 0
258
+ );
259
+ }
260
+
261
+ function normalizeCommandCodeAction(entry: Record<string, unknown>): CodeAction | null {
262
+ return hasOnlyKeys(entry, ["title", "tooltip", "command", "arguments"]) && isValidCommand(entry)
263
+ ? { title: entry.title as string, command: entry as NonNullable<CodeAction["command"]> }
264
+ : null;
265
+ }
266
+
267
+ function isValidCodeActionEntry(entry: Record<string, unknown>): boolean {
268
+ return (
269
+ hasOnlyKeys(entry, [
270
+ "title",
271
+ "kind",
272
+ "diagnostics",
273
+ "isPreferred",
274
+ "disabled",
275
+ "edit",
276
+ "command",
277
+ "data",
278
+ "tags",
279
+ ]) &&
280
+ hasNoInheritedAllowedKeys(entry, [
281
+ "title",
282
+ "kind",
283
+ "diagnostics",
284
+ "isPreferred",
285
+ "disabled",
286
+ "edit",
287
+ "command",
288
+ "data",
289
+ "tags",
290
+ ]) &&
291
+ isValidOptionalCodeActionFields(entry)
292
+ );
293
+ }
294
+
295
+ function isValidOptionalCodeActionFields(entry: Record<string, unknown>): boolean {
296
+ return (
297
+ (!Object.hasOwn(entry, "kind") || typeof entry.kind === "string") &&
298
+ (!Object.hasOwn(entry, "diagnostics") || Array.isArray(entry.diagnostics)) &&
299
+ (!Object.hasOwn(entry, "isPreferred") || typeof entry.isPreferred === "boolean") &&
300
+ (!Object.hasOwn(entry, "disabled") || isValidDisabledCodeAction(entry.disabled)) &&
301
+ (!Object.hasOwn(entry, "command") || isValidCommand(entry.command)) &&
302
+ (!Object.hasOwn(entry, "tags") || Array.isArray(entry.tags))
303
+ );
304
+ }
305
+
306
+ function isValidDisabledCodeAction(value: unknown): boolean {
307
+ const disabled = isRecord(value) ? value : null;
308
+ return (
309
+ disabled !== null &&
310
+ Object.hasOwn(disabled, "reason") &&
311
+ typeof disabled.reason === "string" &&
312
+ hasOnlyKeys(disabled, ["reason"]) &&
313
+ hasNoInheritedAllowedKeys(disabled, ["reason"])
314
+ );
315
+ }
316
+
317
+ function hasNoInheritedAllowedKeys(
318
+ value: Record<string, unknown>,
319
+ allowed: readonly string[],
320
+ ): boolean {
321
+ return allowed.every((key) => !(key in value) || Object.hasOwn(value, key));
322
+ }
323
+
324
+ function isCodeActionContext(value: unknown): value is CodeActionContext {
325
+ if (
326
+ !isRecord(value) ||
327
+ !Object.hasOwn(value, "diagnostics") ||
328
+ !Array.isArray(value.diagnostics)
329
+ ) {
330
+ return false;
331
+ }
332
+ if (
333
+ !hasOnlyKeys(value, ["diagnostics", "only", "triggerKind"]) ||
334
+ !hasNoInheritedAllowedKeys(value, ["diagnostics", "only", "triggerKind"])
335
+ ) {
336
+ return false;
337
+ }
338
+ if (
339
+ Object.hasOwn(value, "only") &&
340
+ value.only !== undefined &&
341
+ (!Array.isArray(value.only) ||
342
+ value.only.some(
343
+ (kind) => typeof kind !== "string" || (kind.length > 0 && kind.trim().length === 0),
344
+ ))
345
+ ) {
346
+ return false;
347
+ }
348
+ return (
349
+ !Object.hasOwn(value, "triggerKind") ||
350
+ value.triggerKind === undefined ||
351
+ value.triggerKind === CodeActionTriggerKind.Invoked ||
352
+ value.triggerKind === CodeActionTriggerKind.Automatic
353
+ );
354
+ }
355
+
356
+ const EMPTY_SETTINGS: JsonObject = Object.freeze({});
357
+
358
+ /** Clone and freeze a JSON settings value so one client owns one stable snapshot. */
359
+ function cloneJsonValue(value: JsonValue): JsonValue {
360
+ if (Array.isArray(value)) {
361
+ return Object.freeze(value.map((entry) => cloneJsonValue(entry))) as JsonValue;
362
+ }
363
+ if (isRecord(value)) {
364
+ const clone: Record<string, JsonValue> = Object.create(null);
365
+ for (const [key, entry] of Object.entries(value)) {
366
+ clone[key] = cloneJsonValue(entry as JsonValue);
367
+ }
368
+ return Object.freeze(clone) as JsonValue;
369
+ }
370
+ return value;
371
+ }
372
+
373
+ function cloneSettings(settings: JsonObject | undefined): JsonObject | undefined {
374
+ return settings === undefined ? undefined : (cloneJsonValue(settings) as JsonObject);
375
+ }
376
+
377
+ /** Resolve a dotted section against an own-property-only JSON settings tree. */
378
+ function lookupSettings(settings: JsonObject, section: unknown): JsonValue | undefined {
379
+ if (section === undefined || section === "") return settings;
380
+ if (typeof section !== "string") return undefined;
381
+ const parts = section.split(".");
382
+ if (parts.some((part) => part.length === 0)) return undefined;
383
+
384
+ let current: JsonValue = settings;
385
+ for (const part of parts) {
386
+ if (!isRecord(current) || !Object.hasOwn(current, part)) return undefined;
387
+ current = current[part] as JsonValue;
388
+ }
389
+ return current;
390
+ }
391
+
392
+ /** Test whether a configuration scope URI belongs to this server workspace. */
393
+ function isApplicableConfigurationScope(scopeUri: unknown, root: string): boolean {
394
+ if (scopeUri === undefined) return true;
395
+ if (typeof scopeUri !== "string" || !scopeUri.startsWith("file://")) return false;
396
+ let scopePath: string;
397
+ try {
398
+ scopePath = uriToFile(scopeUri);
399
+ } catch {
400
+ return false;
401
+ }
402
+ if (scopePath === scopeUri) return false;
403
+ const relative = path.relative(path.resolve(root), path.resolve(scopePath));
404
+ return (
405
+ relative === "" ||
406
+ (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative))
407
+ );
408
+ }
409
+
108
410
  /**
109
411
  * Read and validate the `registrations` array of a `client/registerCapability`
110
412
  * request. Each entry must be a record with string `id` and `method`; the
@@ -193,6 +495,10 @@ export class LspClient {
193
495
  private rpc: JsonRpcClient | null = null;
194
496
  private _status: ClientStatus = "initializing";
195
497
  private capabilities: ServerCapabilities | null = null;
498
+ /** Negotiated once after initialize for this client generation. */
499
+ private normalizedDocumentSync: NormalizedDocumentSync = normalizeDocumentSync(undefined);
500
+ /** Stable server-owned settings for this client generation. */
501
+ private readonly settingsSnapshot: JsonObject | undefined;
196
502
  private readonly diagnosticRequestAdapter: DiagnosticRequestAdapter;
197
503
  private readonly diagnostics: ClientDiagnostics;
198
504
  /** Dynamic capability registrations for this client instance only. */
@@ -223,6 +529,7 @@ export class LspClient {
223
529
  ) {
224
530
  this.name = name;
225
531
  this.root = root;
532
+ this.settingsSnapshot = cloneSettings(config.settings);
226
533
  const nativeDiagnosticAdapter: DiagnosticRequestAdapter = {
227
534
  supports: (uri) => this.hasApplicableDiagnosticProvider(uri),
228
535
  sourceFor: (uri) => (this.hasApplicableDiagnosticProvider(uri) ? "pull" : undefined),
@@ -254,8 +561,9 @@ export class LspClient {
254
561
  cwd: cwd,
255
562
  isOperational: () => this.rpc !== null && this._status === "running",
256
563
  diagnosticRequestAdapter: this.diagnosticRequestAdapter,
257
- usesIncrementalDocumentSync: () => this.usesIncrementalDocumentSync,
564
+ documentSync: () => this.getDocumentSyncOptions(),
258
565
  sendNotification: (method, params) => {
566
+ if (!this.shouldSendDocumentNotification(method)) return;
259
567
  if (this.rpc) void this.rpc.sendNotification(method, params);
260
568
  },
261
569
  });
@@ -273,11 +581,54 @@ export class LspClient {
273
581
  return this.capabilities;
274
582
  }
275
583
 
584
+ /** Whether this route advertises the standard rename request. */
585
+ get supportsRename(): boolean {
586
+ return supportsRename(this.capabilities);
587
+ }
588
+
589
+ /** Whether this route advertises `textDocument/prepareRename`. */
590
+ get supportsPrepareRename(): boolean {
591
+ return supportsPrepareRename(this.capabilities);
592
+ }
593
+
594
+ /** Whether this route advertises `textDocument/codeAction`. */
595
+ get supportsCodeActions(): boolean {
596
+ return supportsCodeActions(this.capabilities);
597
+ }
598
+
599
+ /** Whether this route advertises standard code-action resolution. */
600
+ get supportsCodeActionResolve(): boolean {
601
+ return supportsCodeActionResolve(this.capabilities);
602
+ }
603
+
604
+ /** Capability facts used by the project-server health surface. */
605
+ get operationSupport() {
606
+ return getServerOperationSupport(this.capabilities);
607
+ }
608
+
609
+ /** Whether the server can answer a request for each supplied code-action kind. */
610
+ supportsCodeActionKinds(requested: readonly string[]): boolean {
611
+ return supportsRequestedCodeActionKinds(this.capabilities, requested);
612
+ }
613
+
276
614
  /** Whether the server requires range-based document changes. */
277
615
  get usesIncrementalDocumentSync(): boolean {
278
- const sync = this.capabilities?.textDocumentSync;
279
- const change = typeof sync === "number" ? sync : sync?.change;
280
- return change === TextDocumentSyncKind.Incremental;
616
+ return this.getDocumentSyncOptions().change === TextDocumentSyncKind.Incremental;
617
+ }
618
+
619
+ private getDocumentSyncOptions(): NormalizedDocumentSync {
620
+ return this.normalizedDocumentSync;
621
+ }
622
+
623
+ /** Filter document lifecycle traffic against the negotiated server options. */
624
+ private shouldSendDocumentNotification(method: string): boolean {
625
+ const sync = this.getDocumentSyncOptions();
626
+ if (method === "textDocument/didOpen" || method === "textDocument/didClose") {
627
+ return sync.openClose;
628
+ }
629
+ if (method === "textDocument/didChange") return sync.change !== TextDocumentSyncKind.None;
630
+ if (method === "textDocument/didSave") return sync.save;
631
+ return true;
281
632
  }
282
633
 
283
634
  /** Whether the server is currently not indexing and ready to serve queries. */
@@ -374,12 +725,14 @@ export class LspClient {
374
725
  throw new Error(`${this.name}: client shutdown during initialize`);
375
726
  }
376
727
  this.capabilities = result.capabilities;
728
+ this.normalizedDocumentSync = normalizeDocumentSync(result.capabilities.textDocumentSync);
377
729
  await this.rpc.sendNotification("initialized", {});
378
730
  if (this._status !== "initializing") {
379
731
  throw new Error(`${this.name}: client shutdown during initialize`);
380
732
  }
381
733
  this._status = "running";
382
734
  this.publishLifecycle("startup");
735
+ this.sendInitialConfigurationChange();
383
736
 
384
737
  this.armNoProgressTimer();
385
738
  } catch (err) {
@@ -686,6 +1039,11 @@ export class LspClient {
686
1039
  } satisfies DidChangeWatchedFilesParams);
687
1040
  }
688
1041
 
1042
+ /** Synchronize a tracked file and notify the server after a real disk write. */
1043
+ noteWorkspaceWrite(filePath: string): void {
1044
+ this.diagnostics.noteWorkspaceWrite(filePath);
1045
+ }
1046
+
689
1047
  /** Re-read open documents, then collect pull diagnostics or wait for push diagnostics. */
690
1048
  async refreshOpenDiagnostics(
691
1049
  options: { maxWaitMs?: number; quietMs?: number } & CodeRequestControl = {},
@@ -775,11 +1133,53 @@ export class LspClient {
775
1133
  newName: string,
776
1134
  control?: CodeRequestControl,
777
1135
  ): Promise<WorkspaceEdit | null> {
778
- return this.request(
779
- "textDocument/rename",
780
- { textDocument: { uri: fileToUri(filePath) }, position, newName },
781
- control,
782
- );
1136
+ const result = await this.renameDetailed(filePath, position, newName, control);
1137
+ return result.kind === "completed" ? result.data : null;
1138
+ }
1139
+
1140
+ /** Run rename with capability gating, optional preparation, and freshness checks. */
1141
+ async renameDetailed(
1142
+ filePath: string,
1143
+ position: Position,
1144
+ newName: string,
1145
+ control?: CodeRequestControl,
1146
+ ): Promise<CodeQueryResult<WorkspaceEdit | null>> {
1147
+ if (!this.supportsRename) {
1148
+ return unavailableCodeQuery("The server does not advertise textDocument/rename.");
1149
+ }
1150
+ if (
1151
+ typeof filePath !== "string" ||
1152
+ filePath.length === 0 ||
1153
+ !isPosition(position) ||
1154
+ typeof newName !== "string" ||
1155
+ newName.trim().length === 0
1156
+ ) {
1157
+ return unavailableCodeQuery(
1158
+ "Rename requires a valid file, position, and non-empty new name.",
1159
+ );
1160
+ }
1161
+
1162
+ const params = {
1163
+ textDocument: { uri: fileToUri(filePath) },
1164
+ position,
1165
+ newName,
1166
+ };
1167
+ return this.runRefactorTransaction("textDocument/rename", control, async (transaction) => {
1168
+ if (this.supportsPrepareRename) {
1169
+ const prepared = await transaction.rpc.sendRequest(
1170
+ "textDocument/prepareRename",
1171
+ { textDocument: { uri: fileToUri(filePath) }, position },
1172
+ control,
1173
+ );
1174
+ const preparationError = validatePrepareRenameResult(prepared, position);
1175
+ if (preparationError) throw new Error(preparationError);
1176
+ await transaction.assertCurrent();
1177
+ }
1178
+ return (await transaction.rpc.sendRequest("textDocument/rename", params, control)) as
1179
+ | WorkspaceEdit
1180
+ | null
1181
+ | undefined;
1182
+ });
783
1183
  }
784
1184
 
785
1185
  async codeActions(
@@ -788,11 +1188,46 @@ export class LspClient {
788
1188
  context: CodeActionContext,
789
1189
  control?: CodeRequestControl,
790
1190
  ): Promise<CodeAction[] | null> {
791
- return this.request(
792
- "textDocument/codeAction",
793
- { textDocument: { uri: fileToUri(filePath) }, range, context },
794
- control,
795
- );
1191
+ const result = await this.codeActionsDetailed(filePath, range, context, control);
1192
+ return result.kind === "completed" ? result.data : null;
1193
+ }
1194
+
1195
+ /** Run code actions with explicit context, standard resolution, and freshness checks. */
1196
+ async codeActionsDetailed(
1197
+ filePath: string,
1198
+ range: Range,
1199
+ context: CodeActionContext,
1200
+ control?: CodeRequestControl,
1201
+ ): Promise<CodeQueryResult<CodeAction[] | null>> {
1202
+ if (!this.supportsCodeActions) {
1203
+ return unavailableCodeQuery("The server does not advertise textDocument/codeAction.");
1204
+ }
1205
+ if (typeof filePath !== "string" || filePath.length === 0 || !isRange(range)) {
1206
+ return unavailableCodeQuery("Code actions require a valid file and range.");
1207
+ }
1208
+ if (!isCodeActionContext(context)) {
1209
+ return unavailableCodeQuery("Code actions require a valid context.");
1210
+ }
1211
+ if (context.only && !this.supportsCodeActionKinds(context.only)) {
1212
+ return unavailableCodeQuery("The server does not advertise the requested code-action kind.");
1213
+ }
1214
+
1215
+ const requestContext: CodeActionContext = {
1216
+ ...context,
1217
+ triggerKind: context.triggerKind ?? CodeActionTriggerKind.Invoked,
1218
+ ...(context.only ? { only: [...context.only] } : {}),
1219
+ };
1220
+ return this.runRefactorTransaction("textDocument/codeAction", control, async (transaction) => {
1221
+ const raw = await transaction.rpc.sendRequest(
1222
+ "textDocument/codeAction",
1223
+ { textDocument: { uri: fileToUri(filePath) }, range, context: requestContext },
1224
+ control,
1225
+ );
1226
+ if (raw === null || raw === undefined) return null;
1227
+ if (!Array.isArray(raw)) throw new Error("LSP server returned malformed code actions.");
1228
+
1229
+ return this.resolveCodeActions(raw, transaction, control, requestContext.only);
1230
+ });
796
1231
  }
797
1232
 
798
1233
  async implementation(
@@ -811,6 +1246,137 @@ export class LspClient {
811
1246
  }
812
1247
 
813
1248
  // ── Private ─────────────────────────────────────────────────────────
1249
+ private async resolveCodeActions(
1250
+ raw: unknown[],
1251
+ transaction: RefactorTransaction,
1252
+ control: CodeRequestControl | undefined,
1253
+ only: readonly string[] | undefined,
1254
+ ): Promise<CodeAction[]> {
1255
+ const candidates = raw.map((value) => {
1256
+ const candidate = normalizeCodeActionResponseEntry(value);
1257
+ if (!candidate)
1258
+ throw new Error("LSP server returned a malformed code action response entry.");
1259
+ return candidate;
1260
+ });
1261
+ const actions: CodeAction[] = [];
1262
+ for (const action of candidates) {
1263
+ const lazyAction = isLazyCodeAction(action) ? action : undefined;
1264
+ if (
1265
+ !this.supportsCodeActionResolve ||
1266
+ !lazyAction ||
1267
+ (only !== undefined &&
1268
+ !only.some((filter) => codeActionKindMatchesFilter(lazyAction.kind, filter)))
1269
+ ) {
1270
+ actions.push(action);
1271
+ continue;
1272
+ }
1273
+ await transaction.assertCurrent();
1274
+ const resolved = await transaction.rpc.sendRequest("codeAction/resolve", lazyAction, control);
1275
+ await transaction.assertCurrent();
1276
+ if (!isRecord(resolved)) {
1277
+ throw new Error("LSP server returned a malformed resolved code action.");
1278
+ }
1279
+ const resolvedAction = normalizeCodeActionResponseEntry({ ...lazyAction, ...resolved });
1280
+ if (!resolvedAction) {
1281
+ throw new Error("LSP server returned a malformed resolved code action.");
1282
+ }
1283
+ actions.push(resolvedAction);
1284
+ }
1285
+ return actions;
1286
+ }
1287
+
1288
+ private async runRefactorTransaction<T>(
1289
+ method: string,
1290
+ control: CodeRequestControl | undefined,
1291
+ operation: (transaction: RefactorTransaction) => Promise<T | null | undefined>,
1292
+ ): Promise<CodeQueryResult<T | null>> {
1293
+ throwIfCodeRequestInterrupted(control);
1294
+ if (!this.rpc || this._status !== "running") {
1295
+ return unavailableCodeQuery(
1296
+ `LSP request ${method} is unavailable because the client is not running.`,
1297
+ );
1298
+ }
1299
+
1300
+ const retryGuard = new SemanticInputRequestRetryGuard((revision) =>
1301
+ this.diagnostics.getSemanticInputChangeSince(revision),
1302
+ );
1303
+ for (;;) {
1304
+ const attempt: SemanticRequestAttemptState = { inputRevision: undefined };
1305
+ try {
1306
+ retryGuard.assertCurrent();
1307
+ const data = await this.runRefactorTransactionAttempt({
1308
+ method,
1309
+ control,
1310
+ operation,
1311
+ retryGuard,
1312
+ attempt,
1313
+ });
1314
+ if (retryGuard.hasRetried) {
1315
+ this.recordSemanticRetry({ method, control, retryGuard, outcome: "completed" });
1316
+ }
1317
+ return completedCodeQuery(data);
1318
+ } catch (error) {
1319
+ const failure = this.handleRefactorTransactionFailure<T>({
1320
+ method,
1321
+ control,
1322
+ retryGuard,
1323
+ attempt,
1324
+ error,
1325
+ });
1326
+ if (failure) return failure;
1327
+ }
1328
+ }
1329
+ }
1330
+
1331
+ private handleRefactorTransactionFailure<T>(options: {
1332
+ method: string;
1333
+ control?: CodeRequestControl;
1334
+ retryGuard: SemanticInputRequestRetryGuard;
1335
+ attempt: SemanticRequestAttemptState;
1336
+ error: unknown;
1337
+ }): CodeQueryResult<T | null> | null {
1338
+ const { method, control, retryGuard, attempt, error } = options;
1339
+ try {
1340
+ this.prepareSemanticRetry({ method, control, retryGuard, attempt, error });
1341
+ return null;
1342
+ } catch (retryError) {
1343
+ if (isCodeRequestInterruption(retryError, control)) throw retryError;
1344
+ const detail = retryError instanceof Error ? retryError.message : String(retryError);
1345
+ return unavailableCodeQuery(`LSP request ${method} failed: ${detail}`);
1346
+ }
1347
+ }
1348
+
1349
+ /** Run the complete prepare/request/resolve transaction for one input snapshot. */
1350
+ private async runRefactorTransactionAttempt<T>(options: {
1351
+ method: string;
1352
+ control?: CodeRequestControl;
1353
+ operation: (transaction: RefactorTransaction) => Promise<T | null | undefined>;
1354
+ retryGuard: SemanticInputRequestRetryGuard;
1355
+ attempt: SemanticRequestAttemptState;
1356
+ }): Promise<T | null> {
1357
+ const { method, control, operation, retryGuard, attempt } = options;
1358
+ throwIfCodeRequestInterrupted(control);
1359
+ retryGuard.assertCurrent();
1360
+ await this.getReady(control);
1361
+ const inputSnapshot = await this.diagnostics.synchronizeSemanticInputs(control);
1362
+ attempt.inputRevision = inputSnapshot.revision;
1363
+ throwIfCodeRequestInterrupted(control);
1364
+ retryGuard.assertCurrent();
1365
+ const rpc = this.rpc;
1366
+ if (!rpc || this._status !== "running") {
1367
+ throw new Error(`LSP request ${method} is unavailable because the client is not running.`);
1368
+ }
1369
+ const data = await operation({
1370
+ rpc,
1371
+ assertCurrent: async () => {
1372
+ retryGuard.assertCurrent();
1373
+ await this.diagnostics.assertSemanticInputsCurrent(inputSnapshot, control);
1374
+ },
1375
+ });
1376
+ await this.diagnostics.assertSemanticInputsCurrent(inputSnapshot, control);
1377
+ return data ?? null;
1378
+ }
1379
+
814
1380
  private async query<T>(
815
1381
  method: string,
816
1382
  params: unknown,
@@ -955,15 +1521,6 @@ export class LspClient {
955
1521
  return data ?? null;
956
1522
  }
957
1523
 
958
- private async request<T>(
959
- method: string,
960
- params: unknown,
961
- control?: CodeRequestControl,
962
- ): Promise<T | null> {
963
- const result = await this.query<T>(method, params, control);
964
- return result.kind === "unavailable" ? null : result.data;
965
- }
966
-
967
1524
  private handleServerRequest(method: string, params: unknown): unknown {
968
1525
  switch (method) {
969
1526
  case "workspace/configuration":
@@ -1038,11 +1595,30 @@ export class LspClient {
1038
1595
  });
1039
1596
  }
1040
1597
 
1598
+ /** Send the initial settings snapshot after the initialize handshake. */
1599
+ private sendInitialConfigurationChange(): void {
1600
+ if (this.settingsSnapshot === undefined || !this.rpc || this._status !== "running") return;
1601
+ void this.rpc.sendNotification("workspace/didChangeConfiguration", {
1602
+ settings: this.settingsSnapshot,
1603
+ });
1604
+ }
1605
+
1041
1606
  private buildWorkspaceConfigurationResult(params: unknown): unknown[] {
1042
- if (!params || typeof params !== "object") return [];
1043
- const items = (params as { items?: unknown }).items;
1044
- if (!Array.isArray(items)) return [];
1045
- return items.map(() => null);
1607
+ if (!isRecord(params) || !Object.hasOwn(params, "items") || !Array.isArray(params.items)) {
1608
+ return [];
1609
+ }
1610
+ return params.items.map((item) => this.buildWorkspaceConfigurationItem(item));
1611
+ }
1612
+
1613
+ private buildWorkspaceConfigurationItem(item: unknown): JsonValue | null {
1614
+ if (!isRecord(item)) return null;
1615
+ const scopeUri = Object.hasOwn(item, "scopeUri") ? item.scopeUri : undefined;
1616
+ if (!isApplicableConfigurationScope(scopeUri, this.root)) return null;
1617
+ const section = Object.hasOwn(item, "section") ? item.section : undefined;
1618
+ if (this.settingsSnapshot === undefined) {
1619
+ return section === undefined || section === "" ? EMPTY_SETTINGS : null;
1620
+ }
1621
+ return lookupSettings(this.settingsSnapshot, section) ?? null;
1046
1622
  }
1047
1623
 
1048
1624
  /**
@@ -1130,12 +1706,12 @@ export class LspClient {
1130
1706
  private pendingReady(): Promise<void> {
1131
1707
  if (this._isReady) return Promise.resolve();
1132
1708
  if (this._readyPromise !== null) return this._readyPromise;
1133
- // If no progress timer was ever armed and no tokens are tracked,
1709
+ // If no progress timer was ever armed and no active tokens are tracked,
1134
1710
  // the server was either never started with a real process (test scenario)
1135
1711
  // or completed before any progress tracking began. Resolve immediately
1136
1712
  // only when the client is still running — a crash or shutdown clears
1137
1713
  // both fields but must not report the client as ready.
1138
- if (this.noProgressTimer === null && this.trackedTokens.size === 0) {
1714
+ if (this.noProgressTimer === null && !this.hasActiveTokens()) {
1139
1715
  if (this._status === "running") {
1140
1716
  this._isReady = true;
1141
1717
  this.everReady = true;