@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 +21 -16
- package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-code-runtime/src/api.ts +1 -0
- package/node_modules/@mrclrchtr/supi-code-runtime/src/types.ts +51 -23
- package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/package.json +3 -3
- package/src/api.ts +2 -2
- package/src/client/client-diagnostic-capabilities.ts +13 -3
- package/src/client/client-diagnostics.ts +25 -9
- package/src/client/client-document-sync.ts +7 -5
- package/src/client/client-edit-capture.ts +258 -0
- package/src/client/client.ts +64 -11
- package/src/config/capabilities.ts +8 -0
- package/src/config/operation-support.ts +48 -79
- package/src/manager/manager.ts +3 -1
- package/src/provider/lsp-refactor-provider.ts +21 -35
- package/src/provider/refactor-command-capture.ts +139 -0
- package/src/provider/refactor-planning.ts +202 -47
- package/src/session/route-boundary.ts +26 -0
- package/src/session/runtime-registry.ts +57 -12
- package/src/session/runtime-rename.ts +3 -0
- package/src/session/workspace-lsp-runtime.ts +22 -1
package/src/client/client.ts
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
| "
|
|
8
|
-
| "
|
|
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
|
|
122
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
return
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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
|
-
}
|
package/src/manager/manager.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 "
|
|
62
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
59
|
+
range: request.range,
|
|
60
|
+
kind: request.kind,
|
|
61
|
+
title: request.title,
|
|
62
|
+
label: "Code action",
|
|
74
63
|
control,
|
|
75
64
|
});
|
|
76
|
-
case "
|
|
77
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
74
|
+
range,
|
|
75
|
+
kind: request.kind,
|
|
76
|
+
title: request.title,
|
|
77
|
+
label: "Source action",
|
|
83
78
|
control,
|
|
84
79
|
});
|
|
85
|
-
|
|
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
|
+
}
|