@mrclrchtr/supi-lsp 11.3.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.
Files changed (31) hide show
  1. package/README.md +22 -17
  2. package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  3. package/node_modules/@mrclrchtr/supi-code-runtime/src/api.ts +3 -1
  4. package/node_modules/@mrclrchtr/supi-code-runtime/src/declaration-level.ts +34 -0
  5. package/node_modules/@mrclrchtr/supi-code-runtime/src/types.ts +64 -28
  6. package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  7. package/node_modules/semver/classes/range.js +2 -2
  8. package/node_modules/semver/internal/constants.js +30 -0
  9. package/node_modules/semver/internal/trim-comparator-whitespace.js +438 -0
  10. package/node_modules/semver/package.json +1 -1
  11. package/package.json +3 -3
  12. package/src/api.ts +2 -2
  13. package/src/client/client-diagnostic-capabilities.ts +13 -3
  14. package/src/client/client-diagnostics.ts +25 -9
  15. package/src/client/client-document-sync.ts +7 -5
  16. package/src/client/client-edit-capture.ts +258 -0
  17. package/src/client/client.ts +64 -11
  18. package/src/config/capabilities.ts +8 -0
  19. package/src/config/operation-support.ts +48 -79
  20. package/src/config/tsconfig-scope.ts +10 -0
  21. package/src/manager/manager-typescript-coverage.ts +118 -0
  22. package/src/manager/manager.ts +67 -3
  23. package/src/manager/typescript-file-local.ts +87 -0
  24. package/src/provider/lsp-refactor-provider.ts +21 -35
  25. package/src/provider/refactor-command-capture.ts +139 -0
  26. package/src/provider/refactor-planning.ts +202 -47
  27. package/src/provider/semantic-symbol-mapper.ts +22 -17
  28. package/src/session/route-boundary.ts +26 -0
  29. package/src/session/runtime-registry.ts +106 -16
  30. package/src/session/runtime-rename.ts +3 -0
  31. package/src/session/workspace-lsp-runtime.ts +22 -1
@@ -0,0 +1,118 @@
1
+ import * as path from "node:path";
2
+ import { collectExtendedProjectConfigs } from "../config/tsconfig-extends.ts";
3
+ import { normalizeTsconfigPath } from "../config/tsconfig-path.ts";
4
+ import { getFileScopeDecision, projectConfigIncludesFile } from "../config/tsconfig-scope.ts";
5
+ import { type AutomaticLspPathPolicy, walkAutomaticLspTree } from "../workspace-path-policy.ts";
6
+
7
+ /** Directory depth searched for project configs below a TypeScript route root. */
8
+ const PROJECT_CONFIG_SCAN_DEPTH = 8;
9
+ /** Project config paths named in one coverage reason. */
10
+ const MAX_NAMED_PROJECTS = 3;
11
+ /** File names tsserver loads as projects; `tsconfig.*.json` variants are never auto-loaded. */
12
+ const LOADABLE_PROJECT_CONFIGS = new Set(["tsconfig.json", "jsconfig.json"]);
13
+
14
+ /** Find loadable project configs under a route root, honoring the automatic path policy. */
15
+ export function discoverTypeScriptProjectConfigs(
16
+ root: string,
17
+ policy: AutomaticLspPathPolicy,
18
+ ): string[] {
19
+ const configs: string[] = [];
20
+ walkAutomaticLspTree(policy, root, PROJECT_CONFIG_SCAN_DEPTH, (directory, entries) => {
21
+ for (const entry of entries) {
22
+ if (entry.isFile() && LOADABLE_PROJECT_CONFIGS.has(entry.name)) {
23
+ configs.push(path.join(directory, entry.name));
24
+ }
25
+ }
26
+ return false;
27
+ });
28
+ return configs;
29
+ }
30
+
31
+ /**
32
+ * Project configs whose program includes `file` but show no sign of being loaded.
33
+ *
34
+ * tsserver answers project-wide requests such as references and
35
+ * implementations only from loaded projects, and loads a project only when one
36
+ * of its files is open. An unloaded including project (typically a test
37
+ * config that includes `../src`) can hold matches the answer omits.
38
+ *
39
+ * A project counts as loaded when it is the nearest config of an open file or
40
+ * of a file in the answer (`resultFiles`): tsserver also loads projects on its
41
+ * own during a search, and a match from a project's own file proves it was searched.
42
+ *
43
+ * Base configs extended by another discovered config are skipped: their
44
+ * default include usually spans the whole tree while they own only root
45
+ * tooling files, so counting them would mark nearly every answer partial.
46
+ */
47
+ export function findUnloadedTypeScriptProjects(options: {
48
+ readonly file: string;
49
+ readonly cwd: string;
50
+ readonly openFiles: readonly string[];
51
+ readonly resultFiles?: readonly string[];
52
+ readonly configs: readonly string[];
53
+ }): string[] {
54
+ const baseConfigs = new Set(
55
+ options.configs.flatMap((config) => [...collectExtendedProjectConfigs(config)]),
56
+ );
57
+ const loaded = new Set<string>();
58
+ for (const loadedFile of [...options.openFiles, ...(options.resultFiles ?? [])]) {
59
+ const decision = getFileScopeDecision(loadedFile, options.cwd);
60
+ if (decision.status === "included" && decision.configPath) {
61
+ loaded.add(normalizeTsconfigPath(decision.configPath));
62
+ }
63
+ }
64
+ return options.configs.filter((config) => {
65
+ const key = normalizeTsconfigPath(config);
66
+ return (
67
+ !loaded.has(key) && !baseConfigs.has(key) && projectConfigIncludesFile(config, options.file)
68
+ );
69
+ });
70
+ }
71
+
72
+ /**
73
+ * Other projects that tsserver's implementation search skips for `file`.
74
+ *
75
+ * tsserver 6 and earlier answer `textDocument/implementation` from the file's
76
+ * default project only, unlike references, which span loaded projects. Any
77
+ * other non-base project may implement the target through an import, and
78
+ * opening its files does not change the answer.
79
+ */
80
+ export function findSkippedImplementationProjects(options: {
81
+ readonly file: string;
82
+ readonly cwd: string;
83
+ readonly configs: readonly string[];
84
+ }): string[] {
85
+ const baseConfigs = new Set(
86
+ options.configs.flatMap((config) => [...collectExtendedProjectConfigs(config)]),
87
+ );
88
+ const decision = getFileScopeDecision(options.file, options.cwd);
89
+ const own = decision.configPath ? normalizeTsconfigPath(decision.configPath) : undefined;
90
+ return options.configs.filter((config) => {
91
+ const key = normalizeTsconfigPath(config);
92
+ return key !== own && !baseConfigs.has(key);
93
+ });
94
+ }
95
+
96
+ /** Partial-result reason for tsserver's single-project implementation search. */
97
+ export function singleProjectImplementationReason(skipped: readonly string[]): string {
98
+ const noun = skipped.length === 1 ? "1 other project is" : `${skipped.length} other projects are`;
99
+ return (
100
+ "TypeScript coverage is limited: the TypeScript 6 or earlier server (tsserver) " +
101
+ `searches implementations only in this file's project; ${noun} not searched. ` +
102
+ "Use references to find uses in other projects, or use TypeScript 7."
103
+ );
104
+ }
105
+
106
+ /** Actionable partial-result reason naming a bounded set of unloaded projects. */
107
+ export function typeScriptCoverageGapReason(unloaded: readonly string[], cwd: string): string {
108
+ const named = unloaded
109
+ .slice(0, MAX_NAMED_PROJECTS)
110
+ .map((config) => path.relative(cwd, config) || path.basename(config));
111
+ const more = unloaded.length > named.length ? `, +${unloaded.length - named.length} more` : "";
112
+ const noun = unloaded.length === 1 ? "project that includes" : "projects that include";
113
+ return (
114
+ `TypeScript coverage is limited: ${unloaded.length} ${noun} this file ` +
115
+ `${unloaded.length === 1 ? "is" : "are"} not loaded (${named.join(", ")}${more}). ` +
116
+ "Open a file from each project and retry."
117
+ );
118
+ }
@@ -128,6 +128,13 @@ import type {
128
128
  OutstandingDiagnosticSummaryEntry,
129
129
  ServerStatus,
130
130
  } from "./manager-types.ts";
131
+ import {
132
+ discoverTypeScriptProjectConfigs,
133
+ findSkippedImplementationProjects,
134
+ findUnloadedTypeScriptProjects,
135
+ singleProjectImplementationReason,
136
+ typeScriptCoverageGapReason,
137
+ } from "./manager-typescript-coverage.ts";
131
138
  import {
132
139
  recoverWorkspaceDiagnostics as recoverWorkspaceDiagnosticsImpl,
133
140
  type WorkspaceRecoveryResult,
@@ -141,6 +148,7 @@ import {
141
148
  workspaceSymbolCollectionResult,
142
149
  workspaceSymbolCoverageGap,
143
150
  } from "./manager-workspace-symbol.ts";
151
+ import { isFileLocalTopLevelDeclaration } from "./typescript-file-local.ts";
144
152
 
145
153
  type UnavailableReason = "missing-command" | "start-failed" | "runtime-error";
146
154
 
@@ -412,6 +420,8 @@ export class LspManager {
412
420
  private warmedSemanticProjects = new Set<string>();
413
421
  /** In-flight warm-up probes keyed by project key so concurrent callers share one probe. */
414
422
  private pendingWarmProbes = new Map<string, Promise<void>>();
423
+ /** Loadable TypeScript project configs per route root; cleared when a config changes. */
424
+ private typeScriptProjectConfigs = new Map<string, string[]>();
415
425
  /** One process-crash recovery budget and shared replacement per route. */
416
426
  private processCrashRecoveries = new Map<string, ProcessCrashRecoveryState>();
417
427
  /** Client generations that completed the initialize handshake. */
@@ -572,6 +582,53 @@ export class LspManager {
572
582
  );
573
583
  }
574
584
 
585
+ /**
586
+ * Coverage gap for a project-wide TypeScript request answered by `client`.
587
+ *
588
+ * Returns a partial-result reason when a TypeScript project that includes
589
+ * `filePath` owns neither an open file nor a file in `resultFiles`, so
590
+ * tsserver may not have searched it; otherwise null. Non-TypeScript routes
591
+ * always return null.
592
+ *
593
+ * Implementation requests not known to use the native backend report every
594
+ * other project instead: tsserver searches only the file's own project.
595
+ *
596
+ * No gap applies when `position` (zero-based) names a top-level declaration
597
+ * its module does not export: no other project can reference it.
598
+ */
599
+ typeScriptProjectCoverageGap(
600
+ client: LspClient,
601
+ filePath: string,
602
+ request: "references" | "implementation",
603
+ evidence: {
604
+ readonly resultFiles?: readonly string[];
605
+ readonly position?: { readonly line: number; readonly character: number };
606
+ } = {},
607
+ ): string | null {
608
+ const { resultFiles = [], position } = evidence;
609
+ if (client.name !== "typescript") return null;
610
+ const rootKey = path.resolve(client.root);
611
+ let configs = this.typeScriptProjectConfigs.get(rootKey);
612
+ if (!configs) {
613
+ configs = discoverTypeScriptProjectConfigs(rootKey, this.automaticPathPolicy);
614
+ this.typeScriptProjectConfigs.set(rootKey, configs);
615
+ }
616
+ const file = resolveSessionPath(this.cwd, filePath);
617
+ if (position && isFileLocalTopLevelDeclaration(file, position)) return null;
618
+ if (request === "implementation" && client.typescriptSelection?.backend !== "native") {
619
+ const skipped = findSkippedImplementationProjects({ file, cwd: this.cwd, configs });
620
+ return skipped.length > 0 ? singleProjectImplementationReason(skipped) : null;
621
+ }
622
+ const unloaded = findUnloadedTypeScriptProjects({
623
+ file,
624
+ cwd: this.cwd,
625
+ openFiles: client.openFiles,
626
+ resultFiles,
627
+ configs,
628
+ });
629
+ return unloaded.length > 0 ? typeScriptCoverageGapReason(unloaded, this.cwd) : null;
630
+ }
631
+
575
632
  /** Get or create an LSP client for the given file. */
576
633
  async getClientForFile(
577
634
  filePath: string,
@@ -1050,7 +1107,9 @@ export class LspManager {
1050
1107
  if (client.status !== "running" || !client.ready) return;
1051
1108
  const key = clientKey(client.name, client.root);
1052
1109
  const recovery = this.processCrashRecoveries.get(key);
1053
- 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) {
1054
1113
  recovery.attemptConsumed = false;
1055
1114
  recovery.statusReason = undefined;
1056
1115
  recovery.failureMessage = undefined;
@@ -2397,7 +2456,7 @@ export class LspManager {
2397
2456
  */
2398
2457
  noteWorkspaceChanges(changes: FileEvent[]): void {
2399
2458
  if (changes.length > 0) this.invalidationEpoch++;
2400
- invalidateProjectConfigCaches(changes);
2459
+ if (invalidateProjectConfigCaches(changes)) this.typeScriptProjectConfigs.clear();
2401
2460
  this.notifyWorkspaceFileChanges(changes);
2402
2461
  }
2403
2462
 
@@ -2586,6 +2645,7 @@ export class LspManager {
2586
2645
  this.warmedWorkspaceSymbolFiles.clear();
2587
2646
  this.warmedSemanticProjects.clear();
2588
2647
  this.pendingWarmProbes.clear();
2648
+ this.typeScriptProjectConfigs.clear();
2589
2649
  this.pendingRestarts.clear();
2590
2650
  }
2591
2651
  /** Get status of all servers. */
@@ -3437,15 +3497,19 @@ export class LspManager {
3437
3497
  }
3438
3498
  }
3439
3499
 
3440
- function invalidateProjectConfigCaches(changes: readonly FileEvent[]): void {
3500
+ /** Invalidate parsed project configs; returns whether any config changed. */
3501
+ function invalidateProjectConfigCaches(changes: readonly FileEvent[]): boolean {
3502
+ let changed = false;
3441
3503
  for (const change of changes) {
3442
3504
  const configPath = uriToFile(change.uri);
3443
3505
  if (!isProjectConfigFileName(path.basename(configPath))) continue;
3506
+ changed = true;
3444
3507
  invalidateTsconfigCacheForConfig(configPath);
3445
3508
  if (change.type === FileChangeType.Created) {
3446
3509
  invalidateTsconfigCacheForConfigDir(path.dirname(configPath));
3447
3510
  }
3448
3511
  }
3512
+ return changed;
3449
3513
  }
3450
3514
 
3451
3515
  function collectClientDiagnosticEvidenceDocuments(
@@ -0,0 +1,87 @@
1
+ import * as fs from "node:fs";
2
+ import ts from "typescript";
3
+
4
+ /**
5
+ * Whether the identifier at `position` names a top-level declaration that only
6
+ * its own file can reference: the file is an ES module and the declaration is
7
+ * neither exported by a modifier nor by an export clause or assignment.
8
+ *
9
+ * Other TypeScript projects can then hold no references or implementations,
10
+ * so a project coverage gap cannot apply. Script files (no import or export),
11
+ * members, unreadable files, and non-declaration positions return false.
12
+ *
13
+ * @param position Zero-based LSP position.
14
+ */
15
+ export function isFileLocalTopLevelDeclaration(
16
+ filePath: string,
17
+ position: { readonly line: number; readonly character: number },
18
+ ): boolean {
19
+ let text: string;
20
+ try {
21
+ text = fs.readFileSync(filePath, "utf8");
22
+ } catch {
23
+ return false;
24
+ }
25
+ const sourceFile = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true);
26
+ if (!ts.isExternalModule(sourceFile)) return false;
27
+ let offset: number;
28
+ try {
29
+ offset = ts.getPositionOfLineAndCharacter(sourceFile, position.line, position.character);
30
+ } catch {
31
+ return false;
32
+ }
33
+ const statement = sourceFile.statements.find(
34
+ (candidate) => candidate.getStart(sourceFile) <= offset && offset < candidate.end,
35
+ );
36
+ if (!statement) return false;
37
+ const name = topLevelDeclarationNameAt(statement, offset, sourceFile);
38
+ return name !== null && !hasExportModifier(statement) && !exportedByName(sourceFile, name);
39
+ }
40
+
41
+ /** Name of the declaration `statement` declares at `offset`, or null. */
42
+ function topLevelDeclarationNameAt(
43
+ statement: ts.Statement,
44
+ offset: number,
45
+ sourceFile: ts.SourceFile,
46
+ ): string | null {
47
+ const names: ts.Identifier[] = [];
48
+ if (
49
+ ts.isFunctionDeclaration(statement) ||
50
+ ts.isClassDeclaration(statement) ||
51
+ ts.isInterfaceDeclaration(statement) ||
52
+ ts.isTypeAliasDeclaration(statement) ||
53
+ ts.isEnumDeclaration(statement)
54
+ ) {
55
+ if (statement.name) names.push(statement.name);
56
+ } else if (ts.isVariableStatement(statement)) {
57
+ for (const declaration of statement.declarationList.declarations) {
58
+ if (ts.isIdentifier(declaration.name)) names.push(declaration.name);
59
+ }
60
+ }
61
+ const hit = names.find((name) => name.getStart(sourceFile) <= offset && offset <= name.end);
62
+ return hit ? hit.text : null;
63
+ }
64
+
65
+ function hasExportModifier(statement: ts.Statement): boolean {
66
+ return (
67
+ ts.canHaveModifiers(statement) &&
68
+ (ts.getModifiers(statement) ?? []).some(
69
+ (modifier) =>
70
+ modifier.kind === ts.SyntaxKind.ExportKeyword ||
71
+ modifier.kind === ts.SyntaxKind.DeclareKeyword,
72
+ )
73
+ );
74
+ }
75
+
76
+ /** `export { name }`, `export default name`, or `export = name` in this file. */
77
+ function exportedByName(sourceFile: ts.SourceFile, name: string): boolean {
78
+ return sourceFile.statements.some((statement) => {
79
+ if (ts.isExportAssignment(statement)) {
80
+ return ts.isIdentifier(statement.expression) && statement.expression.text === name;
81
+ }
82
+ if (!ts.isExportDeclaration(statement) || statement.moduleSpecifier) return false;
83
+ const clause = statement.exportClause;
84
+ if (!clause || !ts.isNamedExports(clause)) return false;
85
+ return clause.elements.some((element) => (element.propertyName ?? element.name).text === name);
86
+ });
87
+ }
@@ -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
+ }