@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.
- package/README.md +22 -17
- package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-code-runtime/src/api.ts +3 -1
- package/node_modules/@mrclrchtr/supi-code-runtime/src/declaration-level.ts +34 -0
- package/node_modules/@mrclrchtr/supi-code-runtime/src/types.ts +64 -28
- package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/node_modules/semver/classes/range.js +2 -2
- package/node_modules/semver/internal/constants.js +30 -0
- package/node_modules/semver/internal/trim-comparator-whitespace.js +438 -0
- package/node_modules/semver/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/config/tsconfig-scope.ts +10 -0
- package/src/manager/manager-typescript-coverage.ts +118 -0
- package/src/manager/manager.ts +67 -3
- package/src/manager/typescript-file-local.ts +87 -0
- 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/provider/semantic-symbol-mapper.ts +22 -17
- package/src/session/route-boundary.ts +26 -0
- package/src/session/runtime-registry.ts +106 -16
- package/src/session/runtime-rename.ts +3 -0
- package/src/session/workspace-lsp-runtime.ts +22 -1
|
@@ -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
|
+
}
|
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
|
-
}
|
|
@@ -162,6 +162,16 @@ export function getFileScopeDecision(filePath: string, cwd: string): FileScopeDe
|
|
|
162
162
|
};
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
/**
|
|
166
|
+
* Whether a project config's program includes a file, by its own file set or
|
|
167
|
+
* include/exclude rules. Unlike {@link getFileScopeDecision}, the config need
|
|
168
|
+
* not be the file's nearest one (e.g. a test config including `../src`).
|
|
169
|
+
*/
|
|
170
|
+
export function projectConfigIncludesFile(configPath: string, filePath: string): boolean {
|
|
171
|
+
const parsed = parseProjectConfig(configPath);
|
|
172
|
+
return parsed !== null && decideFileScope(parsed, path.resolve(filePath)).included;
|
|
173
|
+
}
|
|
174
|
+
|
|
165
175
|
function isOutOfTree(cwd: string, absolutePath: string): boolean {
|
|
166
176
|
const relative = path.relative(path.resolve(cwd), absolutePath);
|
|
167
177
|
return relative.startsWith(`..${path.sep}`) || relative === "..";
|