@mrclrchtr/supi-lsp 11.4.0 → 12.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -59,8 +59,8 @@ A configured route is not a ready server. Features depend on the active server,
59
59
  | Hover, definitions, references, implementations, document/workspace symbols | Supported through the server's corresponding LSP methods; availability and coverage differ by server. |
60
60
  | Diagnostics | Native pull, the eligible TypeScript request adapter, or ambient push. Push observation alone cannot confirm that a file is clean. See the diagnostic policy below. |
61
61
  | `rename_symbol` | Requires server rename support and validated text edits; uses `prepareRename` when advertised. |
62
- | `update_imports` / `delete_dead_code` | Requires matching `source.organizeImports` / (`source.removeUnused` or `source.removeUnusedImports`) code actions that produce validated text edits. Imports-only advertisement reports limited support, not arbitrary unused-declaration removal. |
63
- | `extract_function` / `extract_variable` | Unavailable for all languages: standard LSP code actions do not provide a safe exact extraction-name contract. |
62
+ | `code_action` | Requires a code action of the requested `refactor*` kind for the range that produces validated text edits, directly or by capturing the one edit its command pushes. Names generated by the server appear unchanged in the plan. |
63
+ | `source_action` | Requires a code action of the requested `source*` kind for the whole document that produces validated text edits, directly or (for `source.organizeImports` and `source.fixAll`) by capturing the one edit its command pushes. |
64
64
 
65
65
  The [operation-support model](src/config/operation-support.ts) separates server advertisement from SuPi planning limits. A server can advertise an action that SuPi cannot safely turn into a plan.
66
66
 
@@ -106,7 +106,7 @@ The required [Linux/macOS CI matrix](../../.github/workflows/ci.yml) keeps [sele
106
106
 
107
107
  [Bounded config comparisons](__tests__/integration/typescript-config-coverage.test.ts) compare TS6 and native TS7 `--showConfig` root-file coverage for TS/JS defaults, include/exclude, `allowJs`/`checkJs`, inherited patterns (including late-created files), and separate referenced projects. These cases found no false exclusion; filtering is unchanged. **Config coverage state is not TypeScript program membership**: imports may bring excluded files into a program, and references do not merge root-file lists.
108
108
 
109
- Native `delete_dead_code` consumes `source.removeUnusedImports` through the same safe plan/apply workflow ([#554](https://github.com/mrclrchtr/supi/issues/554)). This mapping removes unused imports only: operation support reports `server: advertised`, `supi: limited`, with an imports-only reason. It does not qualify arbitrary unused-declaration removal. Extraction remains unavailable ([#467](https://github.com/mrclrchtr/supi/issues/467)); no TLS/native feature-parity claim is made. Only the pinned combinations are tested, not every stable TS7 release. Focused qualification passed on macOS arm64 (Node 24.22.1) and Linux arm64 (Debian container, Node 24.22.0), including the native/TS6 public TS/JS matrix. The unused-import follow-up has local macOS arm64 verification only; its Linux verification remains pending. The pinned Linux/macOS CI jobs remain pending until they run; this is not a qualification claim for the complete multi-language baseline.
109
+ Native TypeScript 7 offers `source.removeUnusedImports` through the same safe plan/apply workflow ([#554](https://github.com/mrclrchtr/supi/issues/554)); it removes unused imports only, not arbitrary unused declarations. Native TypeScript 7 advertises no `refactor*` kinds, so `code_action` extraction is unavailable there; no TLS/native feature-parity claim is made. Only the pinned combinations are tested, not every stable TS7 release. Focused qualification passed on macOS arm64 (Node 24.22.1) and Linux arm64 (Debian container, Node 24.22.0), including the native/TS6 public TS/JS matrix. The unused-import follow-up has local macOS arm64 verification only; its Linux verification remains pending. The pinned Linux/macOS CI jobs remain pending until they run; this is not a qualification claim for the complete multi-language baseline.
110
110
 
111
111
  ### Java startup and project JDKs
112
112
 
@@ -320,21 +320,26 @@ Disable one language with `lsp.servers.<language>.enabled: false`. Use `/supi-ci
320
320
  - explicit ready, pending, inactive, disabled, and unavailable registry states
321
321
  - a `SemanticProvider` adapter for `supi-code-runtime`
322
322
  - precise rename and code-action edit conversion
323
- - operation-specific code-action kind filters and standard lazy edit resolution
323
+ - code-action kind-prefix filters, exact-title selection, and standard lazy edit resolution
324
324
  - negotiated operation support in project-server status
325
325
 
326
- Operation-specific planners request `source.organizeImports` for import updates and
327
- both `source.removeUnused` and `source.removeUnusedImports` for dead-code removal.
328
- These distinct kinds are alternatives, not parent/child kinds; either exact kind
329
- or its dotted descendants can match the returned `CodeAction.kind`, never its title.
330
- An imports-only advertisement reports a SuPi limitation rather than general
331
- unused-declaration support. Edit-only actions are validated before planning. Command actions,
332
- resource operations, snippets, annotations, malformed actions, and stale responses
333
- stay unavailable. Distinct matching plans return an ambiguous result instead of
334
- selecting the first action. Rename uses `prepareRename` when the server advertises
335
- it and otherwise uses direct standard rename. Standard LSP does not provide
336
- a safe exact extraction-name contract, so `extract_function` and `extract_variable`
337
- plans report a SuPi planning limitation instead of selecting a generated name.
326
+ Code- and source-action planners request exactly the caller's kind with `only` and
327
+ match the returned `CodeAction.kind` or its dotted descendants, never the title.
328
+ Entries outside `only` are dropped before validation. Disabled actions never match;
329
+ an optional exact title narrows the matches. Edit-only actions are validated before
330
+ planning. One eligible command-only action (`refactor*`, `source.organizeImports*`,
331
+ `source.fixAll*`; a command the server lists when it lists any) runs once: SuPi
332
+ advertises `workspace.applyEdit`, refuses every pushed edit with `applied: false`,
333
+ and plans the single edit captured while the command runs or shortly after it
334
+ answers, even when the server then reports the command as failed because the
335
+ edit was refused (clangd does). A timed-out or cancelled command keeps refusing late edits until it
336
+ settles. Touched open documents are re-sent as full text under a new version.
337
+ Running a command may still change server-side state. Resource operations,
338
+ snippets, annotations, malformed actions, and stale responses stay unavailable. Distinct matching plans return an
339
+ ambiguous result with each candidate's kind and title instead of selecting the first
340
+ action. Rename uses `prepareRename` when the server advertises it and otherwise uses
341
+ direct standard rename. Code actions choose generated names; plans preview them as
342
+ the server returns them.
338
343
 
339
344
  Rename responses that need additional document state can trigger one bounded discovery/enrollment retry. SuPi validates edit shape and canonical mutation paths, enrolls at most 64 affected files on the same route, and requests fresh edits. The first response is not a plan. Numeric versions must still match. A null version for an open document requires verified synchronized-content evidence; the plan retains the request fingerprint and client version. Missing snapshots, changed inputs, a larger follow-up edit set, and unsafe edits remain unavailable. Planning and apply revalidate these preconditions; no version is discarded to make an edit fit.
340
345
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-code-runtime",
3
- "version": "11.4.0",
3
+ "version": "12.0.0",
4
4
  "description": "Shared workspace context and capability contracts for code intelligence",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -60,6 +60,7 @@ export type {
60
60
  ImportData,
61
61
  NodeAtData,
62
62
  OutlineData,
63
+ RefactorActionIdentity,
63
64
  RefactorOperation,
64
65
  RefactorRequest,
65
66
  RefactorResult,
@@ -193,31 +193,53 @@ export interface WorkspaceEdit {
193
193
  }
194
194
 
195
195
  /**
196
- * Supported refactor operation names for the current semantic planning path.
197
- */
198
- export type RefactorOperation =
199
- | "rename_symbol"
200
- | "extract_function"
201
- | "extract_variable"
202
- | "rename_file"
203
- | "move_file"
204
- | "update_imports"
205
- | "delete_dead_code";
206
-
207
- /**
208
- * Operation-aware refactor planning request.
196
+ * Operation-aware refactor planning request. Each operation carries only the
197
+ * scope it needs:
209
198
  *
210
- * Consumers provide a target file/position plus operation-specific options.
211
- * File/resource operations remain requestable so providers can reject them
212
- * explicitly and honestly until shared workspace-edit resource ops exist.
199
+ * - `rename_symbol`: one symbol position and the new name.
200
+ * - `code_action`: one file range and a CodeActionKind prefix under `refactor`.
201
+ * - `source_action`: one whole file and a CodeActionKind prefix under `source`.
202
+ *
203
+ * `kind` matches the requested kind and its dotted sub-kinds. `title`, when
204
+ * present, selects one action by its exact title.
213
205
  */
214
- export interface RefactorRequest {
215
- operation: RefactorOperation;
216
- file: string;
217
- position: CodePosition;
218
- range?: SourceRange;
219
- newName?: string;
220
- destination?: string;
206
+ export type RefactorRequest =
207
+ | {
208
+ operation: "rename_symbol";
209
+ file: string;
210
+ position: CodePosition;
211
+ newName: string;
212
+ }
213
+ | {
214
+ operation: "code_action";
215
+ file: string;
216
+ range: SourceRange;
217
+ kind: string;
218
+ title?: string;
219
+ }
220
+ | {
221
+ operation: "source_action";
222
+ file: string;
223
+ kind: string;
224
+ title?: string;
225
+ };
226
+
227
+ /** Supported refactor operation names. */
228
+ export type RefactorOperation = RefactorRequest["operation"];
229
+
230
+ /** The code action selected for a precise code- or source-action plan. */
231
+ export interface RefactorActionIdentity {
232
+ /** Server-reported CodeActionKind. */
233
+ kind: string;
234
+ /** Server-reported action title. */
235
+ title: string;
236
+ /**
237
+ * How the edits were obtained: `edit` from the action itself (after
238
+ * `codeAction/resolve` when needed), or `captured-command` from the single
239
+ * `workspace/applyEdit` the server pushed while running the action's command.
240
+ * Captured edits were refused, never applied.
241
+ */
242
+ provenance?: "edit" | "captured-command";
221
243
  }
222
244
 
223
245
  /**
@@ -228,6 +250,10 @@ export interface DisambiguationCandidate {
228
250
  file?: string;
229
251
  line?: number;
230
252
  character?: number;
253
+ /** Server-reported CodeActionKind, for code-action candidates. */
254
+ kind?: string;
255
+ /** Exact action title to pass back as `title`, for code-action candidates. */
256
+ title?: string;
231
257
  }
232
258
 
233
259
  /**
@@ -241,6 +267,8 @@ export type RefactorResult =
241
267
  | {
242
268
  kind: "precise";
243
269
  edits: WorkspaceEdit;
270
+ /** The selected code action, for code- and source-action plans. */
271
+ action?: RefactorActionIdentity;
244
272
  /** Provider roots authorized by the semantic route. Consumers canonicalize before storage. */
245
273
  authorizedMutationRoots: string[];
246
274
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-core",
3
- "version": "11.4.0",
3
+ "version": "12.0.0",
4
4
  "description": "Shared settings, configuration, reporting, and session infrastructure",
5
5
  "license": "MIT",
6
6
  "repository": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-lsp",
3
- "version": "11.4.0",
3
+ "version": "12.0.0",
4
4
  "description": "Language Server Protocol runtime for SuPi code intelligence",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -38,8 +38,8 @@
38
38
  "vscode-jsonrpc": "^10.0.0",
39
39
  "vscode-languageserver-protocol": "^3.18.5",
40
40
  "vscode-languageserver-types": "^3.18.5",
41
- "@mrclrchtr/supi-code-runtime": "11.4.0",
42
- "@mrclrchtr/supi-core": "11.4.0"
41
+ "@mrclrchtr/supi-code-runtime": "12.0.0",
42
+ "@mrclrchtr/supi-core": "12.0.0"
43
43
  },
44
44
  "bundledDependencies": [
45
45
  "@mrclrchtr/supi-code-runtime",
package/src/api.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  // Public API surface for the LSP session-scoped service.
2
2
 
3
+ export type { CommandEditCaptureResult } from "./client/client-edit-capture.ts";
3
4
  export { isMissingFileError } from "./client/client-file-state.ts";
4
5
  export {
5
6
  getExplicitlyDisabledLanguages,
@@ -7,10 +8,8 @@ export {
7
8
  loadConfig,
8
9
  } from "./config/config.ts";
9
10
  export {
10
- codeActionKindsForOperation,
11
11
  getServerOperationSupport,
12
12
  isCodeActionKindInRange,
13
- REFACTOR_CODE_ACTION_KINDS,
14
13
  supportsCodeActionResolve,
15
14
  supportsCodeActions,
16
15
  supportsPrepareRename,
@@ -91,6 +90,7 @@ export type {
91
90
  ProcessCrashRecoveryOutcome,
92
91
  ProcessCrashRecoveryReport,
93
92
  RecoverDiagnosticsResult,
93
+ RoutedCodeActionResponse,
94
94
  RoutedMutationDocumentSnapshot,
95
95
  RoutedMutationResponse,
96
96
  SemanticReadinessResult,
@@ -7,6 +7,7 @@
7
7
  // dynamic set for `textDocument/diagnostic` is non-empty.
8
8
 
9
9
  import { posix as posixPath } from "node:path";
10
+ import type { CFamilyFileAssociationResolver } from "@mrclrchtr/supi-code-runtime/api";
10
11
  import { uriToFile } from "@mrclrchtr/supi-core/path";
11
12
  import { detectLanguageId } from "../utils.ts";
12
13
 
@@ -51,12 +52,21 @@ export function isValidDocumentSelector(value: unknown): boolean {
51
52
  );
52
53
  }
53
54
 
54
- /** Match one valid document selector against a file URI. */
55
- export function isDocumentSelectorApplicable(selector: unknown, uri: string): boolean {
55
+ /**
56
+ * Match one valid document selector against a file URI.
57
+ *
58
+ * Pass the client's file associations so the language matches the ID sent in
59
+ * `didOpen` (for example, a `.h` file associated with `cpp`).
60
+ */
61
+ export function isDocumentSelectorApplicable(
62
+ selector: unknown,
63
+ uri: string,
64
+ associations?: CFamilyFileAssociationResolver,
65
+ ): boolean {
56
66
  if (selector === undefined || selector === null) return true;
57
67
  if (!isValidDocumentSelector(selector)) return false;
58
68
  if (!Array.isArray(selector) || selector.length === 0) return false;
59
- const language = detectLanguageId(uriToFile(uri));
69
+ const language = detectLanguageId(uriToFile(uri), associations);
60
70
  const scheme = readUriScheme(uri);
61
71
  const filePath = uriToFile(uri).replaceAll("\\", "/");
62
72
  return selector.some((entry) => {
@@ -237,22 +237,17 @@ export class ClientDiagnostics {
237
237
  for (const update of updates) {
238
238
  const document = this.#openDocs.get(update.document.uri);
239
239
  if (!document) throw new Error("Semantic input changed while synchronization was running.");
240
- this.#synchronizeTrackedDocument(
241
- update.document.uri,
242
- update.document.filePath,
243
- update.content,
244
- document,
245
- );
240
+ this.#synchronizeTrackedDocument(update.document, update.content, document);
246
241
  this.#failedUris.delete(update.document.uri);
247
242
  }
248
243
  }
249
244
 
250
245
  /** Apply one tracked-document change with the shared client state. */
251
246
  #synchronizeTrackedDocument(
252
- uri: string,
253
- filePath: string,
247
+ { uri, filePath }: { readonly uri: string; readonly filePath: string },
254
248
  content: string,
255
249
  document: OpenDocumentState,
250
+ options: { fullText?: boolean } = {},
256
251
  ): void {
257
252
  synchronizeTrackedDocument({
258
253
  uri,
@@ -267,6 +262,7 @@ export class ClientDiagnostics {
267
262
  markUnversionedSyncMoment: () => this.#unversionedPushSyncMoments.set(uri, Date.now()),
268
263
  clearFailedFile: () => this.#failedUris.delete(uri),
269
264
  open: () => this.didOpen(filePath, content),
265
+ ...(options.fullText ? { fullText: true } : {}),
270
266
  });
271
267
  }
272
268
 
@@ -394,7 +390,27 @@ export class ClientDiagnostics {
394
390
  const nextFingerprint = fingerprintDocumentContent(content);
395
391
  if (doc.contentFingerprint === nextFingerprint) return;
396
392
  this.#advanceInputRevision();
397
- this.#synchronizeTrackedDocument(uri, filePath, content, doc);
393
+ this.#synchronizeTrackedDocument({ uri, filePath }, content, doc);
394
+ }
395
+
396
+ /**
397
+ * Re-send an open document's tracked content under a new version, even when
398
+ * it is unchanged. The tracked content matches disk: semantic requests
399
+ * synchronize open documents with disk before they run. Used after a server ran a command whose pushed
400
+ * edit was refused, so the server's model matches the client again.
401
+ * Returns the versions before and after, or null when the document is not open.
402
+ */
403
+ resynchronizeOpenDocument(filePath: string): { from: number; to: number } | null {
404
+ if (!this.host.isOperational()) return null;
405
+ const uri = fileToUri(filePath);
406
+ const doc = this.#openDocs.get(uri);
407
+ if (!doc) return null;
408
+ const from = doc.version;
409
+ this.#advanceInputRevision();
410
+ // Full text: the server may have changed its own copy, so a range computed
411
+ // from the client's content could no longer cover the whole document.
412
+ this.#synchronizeTrackedDocument({ uri, filePath }, doc.content, doc, { fullText: true });
413
+ return { from, to: doc.version };
398
414
  }
399
415
 
400
416
  /** Send a save after a disk change that the input barrier observed. */
@@ -73,6 +73,8 @@ interface SynchronizeDocumentOptions {
73
73
  documentSync: NormalizedDocumentSync;
74
74
  waiters: DiagnosticWaitRegistry;
75
75
  sendNotification: NotificationSender;
76
+ /** Send the whole text without a range, even under incremental sync (LSP allows both). */
77
+ fullText?: boolean;
76
78
  }
77
79
 
78
80
  function endPosition(content: string): { line: number; character: number } {
@@ -117,11 +119,9 @@ export function synchronizeDocument(options: SynchronizeDocumentOptions): void {
117
119
  retainDocumentSynchronization(options);
118
120
  options.document.version = options.version;
119
121
  options.document.synchronizationId = options.synchronizationId;
120
- const changes = contentChanges(
121
- options.document.content,
122
- options.content,
123
- options.documentSync.change,
124
- );
122
+ const changes = options.fullText
123
+ ? [{ text: options.content }]
124
+ : contentChanges(options.document.content, options.content, options.documentSync.change);
125
125
  options.document.content = options.content;
126
126
  options.document.contentFingerprint = fingerprintDocumentContent(options.content);
127
127
  if (options.documentSync.change === TextDocumentSyncKind.None) return;
@@ -161,6 +161,7 @@ export function synchronizeTrackedDocument(options: {
161
161
  markUnversionedSyncMoment?(): void;
162
162
  clearFailedFile?(): void;
163
163
  open(): void;
164
+ fullText?: boolean;
164
165
  }): void {
165
166
  if (!options.document) {
166
167
  options.open();
@@ -177,6 +178,7 @@ export function synchronizeTrackedDocument(options: {
177
178
  documentSync: options.documentSync,
178
179
  waiters: options.waiters,
179
180
  sendNotification: options.sendNotification,
181
+ ...(options.fullText ? { fullText: true } : {}),
180
182
  });
181
183
  options.clearFailedFile?.();
182
184
  }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Capture window for server-pushed `workspace/applyEdit` requests.
3
+ *
4
+ * SuPi never applies an edit that a server pushes. While one command runs for
5
+ * preview, each pushed edit is recorded and refused; outside that window every
6
+ * pushed edit is refused as unsolicited (LSP 3.17 `ApplyWorkspaceEditResult`).
7
+ */
8
+
9
+ import {
10
+ type CodeRequestControl,
11
+ isCodeRequestInterruption,
12
+ } from "@mrclrchtr/supi-code-runtime/api";
13
+ import { uriToFile } from "@mrclrchtr/supi-core/path";
14
+ import type { Command, WorkspaceEdit } from "../config/types.ts";
15
+ import { LSP_REQUEST_TIMEOUT_ERROR_CODE } from "../debug-telemetry.ts";
16
+ import { JsonRpcRequestError } from "./transport.ts";
17
+
18
+ /** Reply for an edit recorded for preview. */
19
+ export const CAPTURED_EDIT_REPLY = {
20
+ applied: false,
21
+ failureReason: "SuPi captured this edit for preview.",
22
+ } as const;
23
+
24
+ /** Reply for an edit that arrives outside a capture window. */
25
+ export const UNSOLICITED_EDIT_REPLY = {
26
+ applied: false,
27
+ failureReason: "SuPi does not accept unsolicited workspace edits.",
28
+ } as const;
29
+
30
+ /** Result of running one command inside a capture window. */
31
+ export type CommandEditCaptureResult =
32
+ | {
33
+ readonly kind: "captured";
34
+ /** The single edit the server pushed while the command ran. */
35
+ readonly edit: WorkspaceEdit;
36
+ /** Open documents re-sent afterwards, with their versions before and after. */
37
+ readonly resynced: readonly { file: string; from: number; to: number }[];
38
+ }
39
+ | { readonly kind: "unavailable"; readonly reason: string };
40
+
41
+ /** Reply for an edit from a command SuPi stopped waiting for. */
42
+ export const ABANDONED_EDIT_REPLY = {
43
+ applied: false,
44
+ failureReason: "SuPi stopped waiting for the command that produced this edit.",
45
+ } as const;
46
+
47
+ /**
48
+ * Records pushed edits for at most one capture per client. After a capture
49
+ * stops waiting (timeout, abort, deadline) the window keeps draining until the
50
+ * abandoned command settles, so its late edits are refused and can never be
51
+ * attributed to the next capture.
52
+ */
53
+ export class EditCaptureWindow {
54
+ #state: "idle" | "capturing" | "draining" = "idle";
55
+ #edits: unknown[] = [];
56
+ #onEdit: (() => void) | undefined;
57
+
58
+ /** Open the window. Returns false while another capture runs or drains. */
59
+ open(): boolean {
60
+ if (this.#state !== "idle") return false;
61
+ this.#state = "capturing";
62
+ this.#edits = [];
63
+ return true;
64
+ }
65
+
66
+ /** Stop capturing and return the edits pushed so far. */
67
+ close(): unknown[] {
68
+ const edits = this.#edits;
69
+ this.#edits = [];
70
+ this.#onEdit = undefined;
71
+ if (this.#state === "capturing") this.#state = "idle";
72
+ return edits;
73
+ }
74
+
75
+ /** Stop capturing but refuse new captures until `settled` resolves. */
76
+ drain(settled: Promise<void>): unknown[] {
77
+ const edits = this.close();
78
+ this.#state = "draining";
79
+ void settled.then(() => {
80
+ if (this.#state === "draining") this.#state = "idle";
81
+ });
82
+ return edits;
83
+ }
84
+
85
+ /** Wait up to `ms` for the first edit when none has arrived yet. */
86
+ async waitForEdit(ms: number): Promise<void> {
87
+ if (this.#edits.length > 0 || ms <= 0) return;
88
+ await new Promise<void>((resolve) => {
89
+ const timer = setTimeout(resolve, ms);
90
+ this.#onEdit = () => {
91
+ clearTimeout(timer);
92
+ resolve();
93
+ };
94
+ });
95
+ }
96
+
97
+ /** Answer one `workspace/applyEdit` request; the edit is never applied. */
98
+ handleApplyEdit(
99
+ params: unknown,
100
+ ): typeof CAPTURED_EDIT_REPLY | typeof UNSOLICITED_EDIT_REPLY | typeof ABANDONED_EDIT_REPLY {
101
+ if (this.#state === "draining") return ABANDONED_EDIT_REPLY;
102
+ if (this.#state === "idle") return UNSOLICITED_EDIT_REPLY;
103
+ const edit =
104
+ typeof params === "object" && params !== null
105
+ ? (params as { edit?: unknown }).edit
106
+ : undefined;
107
+ this.#edits.push(edit);
108
+ this.#onEdit?.();
109
+ return CAPTURED_EDIT_REPLY;
110
+ }
111
+ }
112
+
113
+ /** Collect the file URIs a captured edit touches, for resynchronization. */
114
+ export function editedDocumentUris(edit: unknown): string[] {
115
+ if (typeof edit !== "object" || edit === null) return [];
116
+ const uris = new Set<string>();
117
+ const { changes, documentChanges } = edit as {
118
+ changes?: unknown;
119
+ documentChanges?: unknown;
120
+ };
121
+ if (typeof changes === "object" && changes !== null) {
122
+ for (const uri of Object.keys(changes)) uris.add(uri);
123
+ }
124
+ if (Array.isArray(documentChanges)) {
125
+ for (const change of documentChanges) {
126
+ const uri = (change as { textDocument?: { uri?: unknown } } | null)?.textDocument?.uri;
127
+ if (typeof uri === "string") uris.add(uri);
128
+ }
129
+ }
130
+ return [...uris];
131
+ }
132
+
133
+ /** Default wait for an edit a server pushes just after answering the command. */
134
+ const DEFAULT_GRACE_MS = 250;
135
+
136
+ /** Collaborators for one command capture on one client. */
137
+ export interface CommandEditCaptureOptions {
138
+ readonly command: Command;
139
+ /** Upper bound for the command to finish. */
140
+ readonly timeoutMs: number;
141
+ /** Wait for an edit pushed after the command answers; LSP allows that order. */
142
+ readonly graceMs?: number;
143
+ /** Upper bound for refusing late edits from an abandoned command. */
144
+ readonly drainMs?: number;
145
+ readonly control?: CodeRequestControl;
146
+ readonly window: EditCaptureWindow;
147
+ /** Send one request and expose when its transport lifetime ends. */
148
+ sendRequestOwned(
149
+ method: string,
150
+ params: unknown,
151
+ options: { timeoutMs: number } & CodeRequestControl,
152
+ ): { result: Promise<unknown>; settled: Promise<void> };
153
+ /** Re-send one open document; null when it is not open. */
154
+ resynchronize(file: string): { from: number; to: number } | null;
155
+ }
156
+
157
+ /**
158
+ * Run one command inside a capture window. Exactly one pushed edit becomes the
159
+ * result; none, several, a timeout, or a server error are unavailable.
160
+ * Cancellation and deadline expiry propagate. Every touched open document is
161
+ * resynced, and an abandoned command keeps the window draining.
162
+ */
163
+ export async function runCommandEditCapture(
164
+ options: CommandEditCaptureOptions,
165
+ ): Promise<CommandEditCaptureResult> {
166
+ const { command, timeoutMs, control, window } = options;
167
+ if (!window.open()) {
168
+ return {
169
+ kind: "unavailable",
170
+ reason: "Another server command capture is running on this client; retry shortly.",
171
+ };
172
+ }
173
+ const request = options.sendRequestOwned(
174
+ "workspace/executeCommand",
175
+ { command: command.command, ...(command.arguments ? { arguments: command.arguments } : {}) },
176
+ { ...control, timeoutMs },
177
+ );
178
+ let serverError: string | undefined;
179
+ try {
180
+ await request.result;
181
+ } catch (error) {
182
+ if (!(error instanceof JsonRpcRequestError) || isCodeRequestInterruption(error, control)) {
183
+ return abandonCapture(options, request.settled, error);
184
+ }
185
+ // The server answered with an error, so the command is over. clangd
186
+ // reports that the refused edit "was not applied"; the edit still counts.
187
+ serverError = error.message;
188
+ }
189
+ if (serverError === undefined) await window.waitForEdit(options.graceMs ?? DEFAULT_GRACE_MS);
190
+ const edits = window.close();
191
+ const resynced = resynchronizeEdits(edits, options.resynchronize);
192
+ if (edits.length === 0) {
193
+ return {
194
+ kind: "unavailable",
195
+ reason:
196
+ serverError === undefined
197
+ ? "The server command produced no edit; there may be nothing to change."
198
+ : `The server command failed: ${serverError}`,
199
+ };
200
+ }
201
+ if (edits.length > 1) {
202
+ return {
203
+ kind: "unavailable",
204
+ reason: `The server command produced ${edits.length} edits; only one can be previewed.`,
205
+ };
206
+ }
207
+ return { kind: "captured", edit: edits[0] as WorkspaceEdit, resynced };
208
+ }
209
+
210
+ /** Close or drain the window after SuPi stopped waiting for the command, then report or rethrow. */
211
+ function abandonCapture(
212
+ options: CommandEditCaptureOptions,
213
+ settled: Promise<void>,
214
+ error: unknown,
215
+ ): CommandEditCaptureResult {
216
+ const { window, timeoutMs, control } = options;
217
+ const drainMs = options.drainMs ?? timeoutMs;
218
+ const edits = drainMs > 0 ? window.drain(boundedSettle(settled, drainMs)) : window.close();
219
+ resynchronizeEdits(edits, options.resynchronize);
220
+ if (isCodeRequestInterruption(error, control)) throw error;
221
+ return {
222
+ kind: "unavailable",
223
+ reason: isTimeout(error)
224
+ ? `The server command did not finish within ${timeoutMs} ms.`
225
+ : `The server command could not run: ${error instanceof Error ? error.message : String(error)}`,
226
+ };
227
+ }
228
+
229
+ function boundedSettle(settled: Promise<void>, ms: number): Promise<void> {
230
+ return Promise.race([settled, new Promise<void>((resolve) => setTimeout(resolve, ms))]);
231
+ }
232
+
233
+ function resynchronizeEdits(
234
+ edits: readonly unknown[],
235
+ resynchronize: CommandEditCaptureOptions["resynchronize"],
236
+ ): { file: string; from: number; to: number }[] {
237
+ const uris = new Set(edits.flatMap((edit) => editedDocumentUris(edit)));
238
+ const resynced: { file: string; from: number; to: number }[] = [];
239
+ for (const uri of uris) {
240
+ let file: string;
241
+ try {
242
+ file = uriToFile(uri);
243
+ } catch {
244
+ continue;
245
+ }
246
+ const versions = resynchronize(file);
247
+ if (versions) resynced.push({ file, ...versions });
248
+ }
249
+ return resynced;
250
+ }
251
+
252
+ function isTimeout(error: unknown): boolean {
253
+ return (
254
+ typeof error === "object" &&
255
+ error !== null &&
256
+ (error as { code?: unknown }).code === LSP_REQUEST_TIMEOUT_ERROR_CODE
257
+ );
258
+ }