@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.
@@ -23,6 +23,7 @@ import { CodeActionTriggerKind } from "vscode-languageserver-types";
23
23
  import { CLIENT_CAPABILITIES } from "../config/capabilities.ts";
24
24
  import {
25
25
  getServerOperationSupport,
26
+ isCodeActionKindWithin,
26
27
  supportsCodeActionResolve,
27
28
  supportsCodeActions,
28
29
  supportsPrepareRename,
@@ -32,6 +33,7 @@ import {
32
33
  import type {
33
34
  CodeAction,
34
35
  CodeActionContext,
36
+ Command,
35
37
  Diagnostic,
36
38
  DidChangeWatchedFilesParams,
37
39
  DocumentDiagnosticReport,
@@ -71,6 +73,11 @@ import { createTypeScriptDiagnosticRequestAdapter } from "./client-diagnostic-ty
71
73
  import { ClientDiagnostics } from "./client-diagnostics.ts";
72
74
  import type { ClientDiagnosticSnapshot, DiagnosticEntry } from "./client-document-state.ts";
73
75
  import { type NormalizedDocumentSync, normalizeDocumentSync } from "./client-document-sync.ts";
76
+ import {
77
+ type CommandEditCaptureResult,
78
+ EditCaptureWindow,
79
+ runCommandEditCapture,
80
+ } from "./client-edit-capture.ts";
74
81
  import {
75
82
  SemanticInputRequestRetryGuard,
76
83
  type SemanticInputRequestRetryReason,
@@ -244,13 +251,6 @@ function isLazyCodeAction(value: unknown): value is CodeAction {
244
251
  );
245
252
  }
246
253
 
247
- function codeActionKindMatchesFilter(kind: unknown, filter: string): boolean {
248
- return (
249
- filter === "" ||
250
- (typeof kind === "string" && (kind === filter || kind.startsWith(`${filter}.`)))
251
- );
252
- }
253
-
254
254
  function isValidCommand(value: unknown): value is NonNullable<CodeAction["command"]> {
255
255
  const command = isRecord(value) ? value : null;
256
256
  return (
@@ -546,8 +546,11 @@ export class LspClient {
546
546
  private readonly settingsSnapshot: JsonObject | undefined;
547
547
  private readonly diagnosticRequestAdapter: DiagnosticRequestAdapter;
548
548
  private readonly diagnostics: ClientDiagnostics;
549
+ private readonly editCapture = new EditCaptureWindow();
549
550
  /** Dynamic capability registrations for this client instance only. */
550
551
  private readonly dynamicRegistrations = new ClientDynamicRegistrations();
552
+ /** Workspace association snapshot shared by `didOpen` and selector matching. */
553
+ private readonly fileAssociations: CFamilyFileAssociationResolver | undefined;
551
554
 
552
555
  // ── Readiness (work-done-progress) ──────────────────────────────────
553
556
  private trackedTokens = new Map<ProgressToken, "created" | "active" | "ended">();
@@ -576,6 +579,7 @@ export class LspClient {
576
579
  ) {
577
580
  this.name = name;
578
581
  this.root = root;
582
+ this.fileAssociations = fileAssociations;
579
583
  this.settingsSnapshot = cloneSettings(config.settings);
580
584
  const nativeDiagnosticAdapter: DiagnosticRequestAdapter = {
581
585
  supports: (uri) => this.hasApplicableDiagnosticProvider(uri),
@@ -1053,12 +1057,18 @@ export class LspClient {
1053
1057
  if (
1054
1058
  staticRecord !== undefined &&
1055
1059
  isValidDiagnosticOptions(staticRecord) &&
1056
- isDocumentSelectorApplicable(staticRecord.documentSelector, uri)
1060
+ isDocumentSelectorApplicable(staticRecord.documentSelector, uri, this.fileAssociations)
1057
1061
  ) {
1058
1062
  return staticRecord;
1059
1063
  }
1060
1064
  for (const registration of this.dynamicRegistrations.get(DOCUMENT_DIAGNOSTIC_METHOD)) {
1061
- if (isDocumentSelectorApplicable(registration.options.documentSelector, uri)) {
1065
+ if (
1066
+ isDocumentSelectorApplicable(
1067
+ registration.options.documentSelector,
1068
+ uri,
1069
+ this.fileAssociations,
1070
+ )
1071
+ ) {
1062
1072
  return registration.options;
1063
1073
  }
1064
1074
  }
@@ -1239,6 +1249,37 @@ export class LspClient {
1239
1249
  return result.kind === "completed" ? readTypeScriptProjectMembership(result.data) : "unknown";
1240
1250
  }
1241
1251
 
1252
+ /**
1253
+ * Run one server command and capture the single edit it pushes through
1254
+ * `workspace/applyEdit`. Every pushed edit is refused, so no file changes.
1255
+ * Open documents the edit touches are re-sent afterwards under new versions.
1256
+ */
1257
+ async captureCommandEdits(
1258
+ command: Command,
1259
+ options: {
1260
+ timeoutMs: number;
1261
+ graceMs?: number;
1262
+ drainMs?: number;
1263
+ control?: CodeRequestControl;
1264
+ },
1265
+ ): Promise<CommandEditCaptureResult> {
1266
+ const rpc = this.rpc;
1267
+ if (!rpc || this._status !== "running") {
1268
+ return {
1269
+ kind: "unavailable",
1270
+ reason: "The server command is unavailable because the client is not running.",
1271
+ };
1272
+ }
1273
+ return runCommandEditCapture({
1274
+ command,
1275
+ ...options,
1276
+ window: this.editCapture,
1277
+ sendRequestOwned: (method, params, requestOptions) =>
1278
+ rpc.sendRequestOwned(method, params, requestOptions),
1279
+ resynchronize: (file) => this.diagnostics.resynchronizeOpenDocument(file),
1280
+ });
1281
+ }
1282
+
1242
1283
  async workspaceSymbol(
1243
1284
  query: string,
1244
1285
  control?: CodeRequestControl,
@@ -1430,7 +1471,17 @@ export class LspClient {
1430
1471
  control: CodeRequestControl | undefined,
1431
1472
  only: readonly string[] | undefined,
1432
1473
  ): Promise<CodeAction[]> {
1433
- const candidates = raw.map((value) => {
1474
+ // Servers may still return kinds that `only` excludes (typescript-language-server
1475
+ // adds quickfixes). Drop those before validation so they cannot fail the request.
1476
+ const requested = only
1477
+ ? raw.filter(
1478
+ (value) =>
1479
+ !isRecord(value) ||
1480
+ typeof value.kind !== "string" ||
1481
+ only.some((filter) => isCodeActionKindWithin(value.kind, filter)),
1482
+ )
1483
+ : raw;
1484
+ const candidates = requested.map((value) => {
1434
1485
  const candidate = normalizeCodeActionResponseEntry(value);
1435
1486
  if (!candidate)
1436
1487
  throw new Error("LSP server returned a malformed code action response entry.");
@@ -1443,7 +1494,7 @@ export class LspClient {
1443
1494
  !this.supportsCodeActionResolve ||
1444
1495
  !lazyAction ||
1445
1496
  (only !== undefined &&
1446
- !only.some((filter) => codeActionKindMatchesFilter(lazyAction.kind, filter)))
1497
+ !only.some((filter) => isCodeActionKindWithin(lazyAction.kind, filter)))
1447
1498
  ) {
1448
1499
  actions.push(action);
1449
1500
  continue;
@@ -1773,6 +1824,8 @@ export class LspClient {
1773
1824
 
1774
1825
  private handleServerRequest(method: string, params: unknown): unknown {
1775
1826
  switch (method) {
1827
+ case "workspace/applyEdit":
1828
+ return this.editCapture.handleApplyEdit(params);
1776
1829
  case "workspace/configuration":
1777
1830
  return this.buildWorkspaceConfigurationResult(params);
1778
1831
  case "workspace/workspaceFolders":
@@ -42,15 +42,20 @@ export const CLIENT_CAPABILITIES: ClientCapabilities = {
42
42
  "refactor.extract.constant",
43
43
  "refactor.extract.variable",
44
44
  "refactor.inline",
45
+ "refactor.move",
45
46
  "refactor.rewrite",
46
47
  "source",
47
48
  "source.organizeImports",
48
49
  "source.fixAll",
49
50
  "source.removeUnused",
51
+ "source.removeUnusedImports",
52
+ "source.sortImports",
50
53
  ],
51
54
  },
52
55
  },
53
56
  dataSupport: true,
57
+ // Never advertise `workspace.workspaceEdit.snippetEditSupport` (LSP 3.18):
58
+ // snippet edits cannot become exact previewable text plans.
54
59
  // SuPi resolves only the edit property. Commands and other fields
55
60
  // must remain server-owned and are never advertised as resolvable.
56
61
  resolveSupport: { properties: ["edit"] },
@@ -72,6 +77,9 @@ export const CLIENT_CAPABILITIES: ClientCapabilities = {
72
77
  workDoneProgress: true,
73
78
  },
74
79
  workspace: {
80
+ // Servers may push edits through workspace/applyEdit. SuPi records them
81
+ // only while previewing one command and always replies applied: false.
82
+ applyEdit: true,
75
83
  configuration: true,
76
84
  workspaceFolders: true,
77
85
  workspaceEdit: {
@@ -4,10 +4,8 @@ import type { ServerCapabilities } from "vscode-languageserver-protocol";
4
4
  export type ServerOperationName =
5
5
  | "rename_symbol"
6
6
  | "prepare_rename"
7
- | "extract_function"
8
- | "extract_variable"
9
- | "update_imports"
10
- | "delete_dead_code"
7
+ | "code_action"
8
+ | "source_action"
11
9
  | "code_action_resolve";
12
10
 
13
11
  /** Whether a server advertises one operation in its negotiated capabilities. */
@@ -19,6 +17,13 @@ export interface ServerOperationSupport {
19
17
  readonly supi: "supported" | "limited" | "unsupported";
20
18
  /** A stable explanation for a SuPi planning limitation or deferral. */
21
19
  readonly reason?: string;
20
+ /** Advertised CodeActionKinds in this operation's family (`refactor*` or `source*`). */
21
+ readonly kinds?: readonly string[];
22
+ /**
23
+ * The server may return kinds beyond `kinds`: it advertises CodeActionKind.Empty
24
+ * (`""`) or does not narrow `codeActionKinds` at all.
25
+ */
26
+ readonly anyKind?: boolean;
22
27
  }
23
28
 
24
29
  /** Operation support projected onto a project-server health entry. */
@@ -26,23 +31,6 @@ export type ServerOperationSupportMap = Partial<
26
31
  Record<ServerOperationName, ServerOperationSupport>
27
32
  >;
28
33
 
29
- const REMOVE_UNUSED_KIND = "source.removeUnused";
30
- const REMOVE_UNUSED_IMPORTS_KIND = "source.removeUnusedImports";
31
-
32
- /** Code-action kinds requested by each operation-specific planner. */
33
- export const REFACTOR_CODE_ACTION_KINDS = {
34
- extract_function: ["refactor.extract.function"],
35
- extract_variable: ["refactor.extract.constant", "refactor.extract.variable"],
36
- update_imports: ["source.organizeImports"],
37
- delete_dead_code: [REMOVE_UNUSED_KIND, REMOVE_UNUSED_IMPORTS_KIND],
38
- } as const satisfies Record<
39
- "extract_function" | "extract_variable" | "update_imports" | "delete_dead_code",
40
- readonly string[]
41
- >;
42
-
43
- const EXTRACTION_NAME_LIMITATION =
44
- "Standard LSP code actions do not provide a safe exact extraction-name contract.";
45
-
46
34
  function isRecord(value: unknown): value is Record<string, unknown> {
47
35
  return typeof value === "object" && value !== null && !Array.isArray(value);
48
36
  }
@@ -95,6 +83,22 @@ export function supportsCodeActionResolve(capabilities: ServerCapabilities | nul
95
83
  return codeActionOptions(capabilities)?.resolveProvider === true;
96
84
  }
97
85
 
86
+ /**
87
+ * Test whether a returned CodeActionKind falls under a requested kind: the same
88
+ * kind or a dotted descendant. The empty kind requests every kind.
89
+ */
90
+ export function isCodeActionKindWithin(kind: unknown, requested: string): boolean {
91
+ if (requested === "") return true;
92
+ return typeof kind === "string" && (kind === requested || kind.startsWith(`${requested}.`));
93
+ }
94
+
95
+ /** Return the negotiated `codeActionKinds`, or undefined when the server did not narrow them. */
96
+ export function advertisedCodeActionKinds(
97
+ capabilities: ServerCapabilities | null,
98
+ ): readonly string[] | undefined {
99
+ return codeActionOptions(capabilities)?.kinds;
100
+ }
101
+
98
102
  /** Test the documented hierarchical relationship between two code-action kinds. */
99
103
  export function isCodeActionKindInRange(kind: string, requested: string): boolean {
100
104
  if (kind === "" || requested === "") return true;
@@ -118,46 +122,30 @@ export function supportsRequestedCodeActionKinds(
118
122
  );
119
123
  }
120
124
 
121
- function codeActionAdvertisement(
122
- capabilities: ServerCapabilities | null,
123
- requested: readonly string[],
124
- ): ServerAdvertisement {
125
- if (capabilities === null) return "unknown";
126
- const options = codeActionOptions(capabilities);
127
- if (!options) return "not-advertised";
128
- if (requested.length === 0 || !options.kinds) return "unknown";
129
- if (!supportsRequestedCodeActionKinds(capabilities, requested)) return "not-advertised";
130
-
131
- // A generic parent or the empty kind proves eligibility, but it does not
132
- // prove that this exact operation is implemented by the server.
133
- const exactOrChild = requested.some((wanted) =>
134
- options.kinds?.some(
135
- (kind) => kind === wanted || (wanted !== "" && kind.startsWith(`${wanted}.`)),
136
- ),
137
- );
138
- return exactOrChild ? "advertised" : "unknown";
125
+ function supportedOperation(server: ServerAdvertisement): ServerOperationSupport {
126
+ return { server, supi: "supported" };
139
127
  }
140
128
 
141
- function supportedOperation(
142
- server: ServerAdvertisement,
143
- supi: ServerOperationSupport["supi"] = "supported",
144
- reason?: string,
129
+ /** Project advertised kinds for one CodeActionKind family, such as `refactor` or `source`. */
130
+ function codeActionFamilySupport(
131
+ capabilities: ServerCapabilities | null,
132
+ family: "refactor" | "source",
145
133
  ): ServerOperationSupport {
146
- return { server, supi, ...(reason ? { reason } : {}) };
147
- }
148
-
149
- function deleteDeadCodeSupport(capabilities: ServerCapabilities | null): ServerOperationSupport {
150
- const server = codeActionAdvertisement(capabilities, REFACTOR_CODE_ACTION_KINDS.delete_dead_code);
151
- const importsOnly =
152
- codeActionAdvertisement(capabilities, [REMOVE_UNUSED_KIND]) !== "advertised" &&
153
- codeActionAdvertisement(capabilities, [REMOVE_UNUSED_IMPORTS_KIND]) === "advertised";
154
- return importsOnly
155
- ? supportedOperation(
156
- server,
157
- "limited",
158
- "Only unused-import removal is supported by this mapping, not arbitrary unused declarations.",
159
- )
160
- : supportedOperation(server);
134
+ if (capabilities === null) return supportedOperation("unknown");
135
+ const options = codeActionOptions(capabilities);
136
+ if (!options) return supportedOperation("not-advertised");
137
+ // An unnarrowed provider or the empty kind may return kinds it does not list.
138
+ const anyKind = !options.kinds || options.kinds.includes("");
139
+ const kinds = (options.kinds ?? []).filter(
140
+ (kind) => kind !== "" && isCodeActionKindWithin(kind, family),
141
+ );
142
+ if (kinds.length === 0 && !anyKind) return supportedOperation("not-advertised");
143
+ return {
144
+ server: "advertised",
145
+ supi: "supported",
146
+ ...(kinds.length > 0 ? { kinds } : {}),
147
+ ...(anyKind ? { anyKind } : {}),
148
+ };
161
149
  }
162
150
 
163
151
  /** Build the honest operation-support projection for one negotiated server. */
@@ -185,27 +173,8 @@ export function getServerOperationSupport(
185
173
  return {
186
174
  rename_symbol: supportedOperation(renameServer),
187
175
  prepare_rename: supportedOperation(prepareServer),
188
- extract_function: supportedOperation(
189
- codeActionAdvertisement(capabilities, REFACTOR_CODE_ACTION_KINDS.extract_function),
190
- "limited",
191
- EXTRACTION_NAME_LIMITATION,
192
- ),
193
- extract_variable: supportedOperation(
194
- codeActionAdvertisement(capabilities, REFACTOR_CODE_ACTION_KINDS.extract_variable),
195
- "limited",
196
- EXTRACTION_NAME_LIMITATION,
197
- ),
198
- update_imports: supportedOperation(
199
- codeActionAdvertisement(capabilities, REFACTOR_CODE_ACTION_KINDS.update_imports),
200
- ),
201
- delete_dead_code: deleteDeadCodeSupport(capabilities),
176
+ code_action: codeActionFamilySupport(capabilities, "refactor"),
177
+ source_action: codeActionFamilySupport(capabilities, "source"),
202
178
  code_action_resolve: supportedOperation(resolveServer),
203
179
  };
204
180
  }
205
-
206
- /** Get the exact operation-specific kinds used in a code-action request. */
207
- export function codeActionKindsForOperation(
208
- operation: keyof typeof REFACTOR_CODE_ACTION_KINDS,
209
- ): readonly string[] {
210
- return REFACTOR_CODE_ACTION_KINDS[operation];
211
- }
@@ -1107,7 +1107,9 @@ export class LspManager {
1107
1107
  if (client.status !== "running" || !client.ready) return;
1108
1108
  const key = clientKey(client.name, client.root);
1109
1109
  const recovery = this.processCrashRecoveries.get(key);
1110
- if (recovery) {
1110
+ // An in-flight recovery owns its record until it settles; removing it here
1111
+ // would make that recovery report cancellation for a live replacement.
1112
+ if (recovery && !recovery.pending) {
1111
1113
  recovery.attemptConsumed = false;
1112
1114
  recovery.statusReason = undefined;
1113
1115
  recovery.failureMessage = undefined;
@@ -7,10 +7,10 @@ import type {
7
7
  import type { WorkspaceLspRuntime } from "../session/runtime-registry.ts";
8
8
  import {
9
9
  collectCodeActionResults,
10
- isDeleteDeadCodeCodeAction,
11
- isUpdateImportsCodeAction,
12
- runFilteredCodeActionRefactor,
10
+ runCodeActionRefactor,
13
11
  runRenameRefactor,
12
+ runSourceActionRefactor,
13
+ wholeDocumentRange,
14
14
  } from "./refactor-planning.ts";
15
15
 
16
16
  /** Build optional refactor methods for the semantic provider adapter. */
@@ -45,12 +45,6 @@ async function planRefactor(
45
45
  ): Promise<RefactorResult> {
46
46
  switch (request.operation) {
47
47
  case "rename_symbol":
48
- if (!request.newName) {
49
- return {
50
- kind: "unavailable",
51
- reason: 'Refactor operation "rename_symbol" requires `newName`.',
52
- };
53
- }
54
48
  return runRenameRefactor({
55
49
  lsp,
56
50
  file: request.file,
@@ -58,39 +52,31 @@ async function planRefactor(
58
52
  newName: request.newName,
59
53
  control,
60
54
  });
61
- case "extract_function":
62
- case "extract_variable":
63
- return {
64
- kind: "unavailable",
65
- reason: `Refactor operation "${request.operation}" is unavailable: standard LSP code actions do not provide a safe exact extraction-name contract.`,
66
- };
67
- case "update_imports":
68
- return runFilteredCodeActionRefactor({
55
+ case "code_action":
56
+ return runCodeActionRefactor({
69
57
  lsp,
70
58
  file: request.file,
71
- position: request.position,
72
- operation: "update_imports",
73
- matches: isUpdateImportsCodeAction,
59
+ range: request.range,
60
+ kind: request.kind,
61
+ title: request.title,
62
+ label: "Code action",
74
63
  control,
75
64
  });
76
- case "delete_dead_code":
77
- return runFilteredCodeActionRefactor({
65
+ case "source_action": {
66
+ // LSP source actions apply to the entire file, so request the whole document.
67
+ const range = wholeDocumentRange(request.file);
68
+ if (!range) {
69
+ return { kind: "unavailable", reason: `File ${request.file} could not be read.` };
70
+ }
71
+ return runSourceActionRefactor({
78
72
  lsp,
79
73
  file: request.file,
80
- position: request.position,
81
- operation: "delete_dead_code",
82
- matches: isDeleteDeadCodeCodeAction,
74
+ range,
75
+ kind: request.kind,
76
+ title: request.title,
77
+ label: "Source action",
83
78
  control,
84
79
  });
85
- case "rename_file":
86
- case "move_file":
87
- return {
88
- kind: "unavailable",
89
- reason: `Refactor operation "${request.operation}" is not supported yet. File/resource operations are deferred.`,
90
- };
80
+ }
91
81
  }
92
- return {
93
- kind: "unavailable",
94
- reason: `Refactor operation "${request.operation}" is not supported by the active semantic provider.`,
95
- };
96
82
  }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Plan command-only code actions by capturing the edit their command pushes.
3
+ *
4
+ * Only `refactor*`, `source.organizeImports*`, and `source.fixAll*` commands
5
+ * may run, and only when the server lists the command (if it lists any). Other
6
+ * source commands can have side effects outside the editor, so they never run.
7
+ * Several eligible actions stay ambiguous without running any command.
8
+ */
9
+
10
+ import type {
11
+ CodeRequestControl,
12
+ DisambiguationCandidate,
13
+ DocumentEditPrecondition,
14
+ RefactorResult,
15
+ } from "@mrclrchtr/supi-code-runtime/api";
16
+ import { isCodeActionKindWithin } from "../config/operation-support.ts";
17
+ import type { CodeAction, Command } from "../config/types.ts";
18
+ import type { WorkspaceLspRuntime } from "../session/workspace-lsp-runtime.ts";
19
+ import { normalizeSemanticEdit } from "./semantic-edit-normalizer.ts";
20
+
21
+ const CAPTURE_ELIGIBLE_KINDS = ["refactor", "source.organizeImports", "source.fixAll"] as const;
22
+
23
+ /**
24
+ * Stable reason for command-only actions SuPi will not run: an ineligible kind
25
+ * or a command the server does not list.
26
+ */
27
+ export const COMMAND_ONLY_ACTION_REASON =
28
+ "Matching code actions only run a server command that SuPi does not run for preview.";
29
+
30
+ /** A code action whose only effect is a server command. */
31
+ export type CommandOnlyAction = CodeAction & { command: Command; kind: string };
32
+
33
+ /** Whether a code action has a command and no edit. */
34
+ export function isCommandOnlyAction(action: CodeAction): boolean {
35
+ return Object.hasOwn(action, "command") && !action.edit;
36
+ }
37
+
38
+ /**
39
+ * Select the command-only actions SuPi may run to capture an edit: an eligible
40
+ * kind, and a command the server lists when it lists any.
41
+ */
42
+ export function captureEligibleActions(
43
+ actions: readonly CodeAction[],
44
+ advertisedCommands: readonly string[] | undefined,
45
+ ): CommandOnlyAction[] {
46
+ return actions.filter((action): action is CommandOnlyAction => {
47
+ const command = action.command;
48
+ return (
49
+ isCommandOnlyAction(action) &&
50
+ typeof action.kind === "string" &&
51
+ typeof command?.command === "string" &&
52
+ CAPTURE_ELIGIBLE_KINDS.some((kind) => isCodeActionKindWithin(action.kind, kind)) &&
53
+ (advertisedCommands === undefined || advertisedCommands.includes(command.command))
54
+ );
55
+ });
56
+ }
57
+
58
+ /** Candidates for several eligible command-only actions; no command has run. */
59
+ export function commandCandidates(actions: readonly CommandOnlyAction[]): RefactorResult {
60
+ return {
61
+ kind: "ambiguous",
62
+ candidates: actions.map(
63
+ (action): DisambiguationCandidate => ({
64
+ description: action.title,
65
+ kind: action.kind,
66
+ title: action.title,
67
+ }),
68
+ ),
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Run one action's command, capture its single pushed edit, and normalize it
74
+ * like a direct edit. Versioned preconditions move to the resynced versions,
75
+ * which hold the same content.
76
+ */
77
+ export async function captureCommandAction(options: {
78
+ lsp: WorkspaceLspRuntime;
79
+ file: string;
80
+ action: CommandOnlyAction;
81
+ control?: CodeRequestControl;
82
+ }): Promise<RefactorResult> {
83
+ const { lsp, file, action, control } = options;
84
+ const response = await lsp.captureCodeActionEdit(file, action, control);
85
+ if (!response) {
86
+ return { kind: "unavailable", reason: "No routed LSP client could run the command." };
87
+ }
88
+ const roots = [...response.authorizedMutationRoots];
89
+ const capture = response.value;
90
+ if (!capture || capture.kind === "unavailable") {
91
+ return {
92
+ kind: "unavailable",
93
+ reason: `Command for "${action.title}" is unavailable: ${capture?.reason ?? response.reason ?? "no result."}`,
94
+ authorizedMutationRoots: roots,
95
+ };
96
+ }
97
+
98
+ const before = new Map(capture.resynced.map((entry) => [entry.file, entry]));
99
+ const result = normalizeSemanticEdit(
100
+ { kind: "workspace-edit", edit: capture.edit },
101
+ {
102
+ // Captured edits target the versions from before the resync.
103
+ getOpenDocumentVersion: (candidate) => {
104
+ const current = lsp.getOpenDocumentVersion(candidate);
105
+ const versions = before.get(candidate);
106
+ return versions && current === versions.to ? versions.from : current;
107
+ },
108
+ authorizedMutationRoots: response.authorizedMutationRoots,
109
+ },
110
+ );
111
+ if (result.kind !== "precise") {
112
+ return result.kind === "unavailable"
113
+ ? {
114
+ kind: "unavailable",
115
+ reason: `Captured edit for "${action.title}" could not produce precise edits: ${result.reason}`,
116
+ authorizedMutationRoots: roots,
117
+ }
118
+ : result;
119
+ }
120
+ const preconditions = result.edits.documentPreconditions?.map((precondition) =>
121
+ rebase(precondition, before),
122
+ );
123
+ return {
124
+ ...result,
125
+ edits: { ...result.edits, ...(preconditions ? { documentPreconditions: preconditions } : {}) },
126
+ action: { kind: action.kind, title: action.title, provenance: "captured-command" },
127
+ };
128
+ }
129
+
130
+ function rebase(
131
+ precondition: DocumentEditPrecondition,
132
+ resynced: ReadonlyMap<string, { from: number; to: number }>,
133
+ ): DocumentEditPrecondition {
134
+ if (precondition.kind === "disk-content") return precondition;
135
+ const versions = resynced.get(precondition.file);
136
+ return versions && precondition.version === versions.from
137
+ ? { ...precondition, version: versions.to }
138
+ : precondition;
139
+ }