@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
|
@@ -1,14 +1,28 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
1
2
|
import * as path from "node:path";
|
|
2
3
|
import type {
|
|
3
4
|
CodePosition,
|
|
4
5
|
CodeRequestControl,
|
|
6
|
+
DisambiguationCandidate,
|
|
7
|
+
RefactorActionIdentity,
|
|
5
8
|
RefactorResult,
|
|
6
9
|
SourceRange,
|
|
7
10
|
} from "@mrclrchtr/supi-code-runtime/api";
|
|
8
11
|
import { CodeActionTriggerKind } from "vscode-languageserver-types";
|
|
9
|
-
import {
|
|
12
|
+
import { isCodeActionKindInRange, isCodeActionKindWithin } from "../config/operation-support.ts";
|
|
10
13
|
import type { CodeAction } from "../config/types.ts";
|
|
11
|
-
import
|
|
14
|
+
import { raceRequestControl } from "../session/readiness.ts";
|
|
15
|
+
import type { RoutedCodeActionResponse, WorkspaceLspRuntime } from "../session/runtime-registry.ts";
|
|
16
|
+
import {
|
|
17
|
+
COMMAND_ONLY_ACTION_REASON,
|
|
18
|
+
captureCommandAction,
|
|
19
|
+
captureEligibleActions,
|
|
20
|
+
commandCandidates,
|
|
21
|
+
isCommandOnlyAction,
|
|
22
|
+
} from "./refactor-command-capture.ts";
|
|
23
|
+
|
|
24
|
+
export { COMMAND_ONLY_ACTION_REASON };
|
|
25
|
+
|
|
12
26
|
import {
|
|
13
27
|
normalizeSemanticEdit,
|
|
14
28
|
type SemanticEditNormalizationContext,
|
|
@@ -70,48 +84,135 @@ export function collectCodeActionResults(
|
|
|
70
84
|
return results;
|
|
71
85
|
}
|
|
72
86
|
|
|
73
|
-
|
|
87
|
+
/** Options for one code- or source-action planning request. */
|
|
88
|
+
export interface CodeActionRefactorOptions {
|
|
74
89
|
lsp: WorkspaceLspRuntime;
|
|
75
90
|
file: string;
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
91
|
+
range: SourceRange;
|
|
92
|
+
kind: string;
|
|
93
|
+
title?: string;
|
|
94
|
+
/** Operation label used in unavailable reasons. */
|
|
95
|
+
label: "Code action" | "Source action";
|
|
80
96
|
control?: CodeRequestControl;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Plan one code action of the requested kind over a file range.
|
|
101
|
+
*
|
|
102
|
+
* `kind` matches itself and its dotted sub-kinds, per the LSP CodeActionKind
|
|
103
|
+
* hierarchy. Disabled actions never match. `title` narrows matches to one exact
|
|
104
|
+
* title. Distinct precise plans stay ambiguous. One eligible command-only
|
|
105
|
+
* action runs once so its pushed edit can be captured; see
|
|
106
|
+
* `refactor-command-capture.ts`.
|
|
107
|
+
*/
|
|
108
|
+
export async function runCodeActionRefactor(
|
|
109
|
+
options: CodeActionRefactorOptions,
|
|
110
|
+
): Promise<RefactorResult> {
|
|
111
|
+
return (await attemptCodeActionRefactor(options)).result;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Delays before re-requesting a source action that returned no action of the kind. */
|
|
115
|
+
const SOURCE_ACTION_RETRY_DELAYS_MS = [500, 1000] as const;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Plan a whole-file source action, retrying briefly when a server that
|
|
119
|
+
* advertises the requested kind returns no action of it.
|
|
120
|
+
*
|
|
121
|
+
* typescript-language-server returns no fix-all or unused-code source actions
|
|
122
|
+
* while diagnostics for a just-opened or just-changed file are pending, and
|
|
123
|
+
* waits itself about 500 ms in the equivalent quick-fix path.
|
|
124
|
+
*/
|
|
125
|
+
export async function runSourceActionRefactor(
|
|
126
|
+
options: CodeActionRefactorOptions,
|
|
127
|
+
): Promise<RefactorResult> {
|
|
128
|
+
let attempt = await attemptCodeActionRefactor(options);
|
|
129
|
+
for (const delayMs of SOURCE_ACTION_RETRY_DELAYS_MS) {
|
|
130
|
+
if (!attempt.retryable) break;
|
|
131
|
+
await raceRequestControl(
|
|
132
|
+
new Promise((resolve) => setTimeout(resolve, delayMs)),
|
|
133
|
+
options.control,
|
|
134
|
+
);
|
|
135
|
+
attempt = await attemptCodeActionRefactor(options);
|
|
136
|
+
}
|
|
137
|
+
return attempt.result;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* One planning attempt. `retryable` marks a zero-match response from a server
|
|
142
|
+
* that advertises the requested kind, so a later request may still return it.
|
|
143
|
+
*/
|
|
144
|
+
async function attemptCodeActionRefactor(
|
|
145
|
+
options: CodeActionRefactorOptions,
|
|
146
|
+
): Promise<{ result: RefactorResult; retryable: boolean }> {
|
|
147
|
+
const { lsp, file, range, kind, title, label } = options;
|
|
148
|
+
const response = await lsp.codeActions(file, range, options.control, {
|
|
149
|
+
only: [kind],
|
|
85
150
|
triggerKind: CodeActionTriggerKind.Invoked,
|
|
86
151
|
});
|
|
87
|
-
if (!response
|
|
152
|
+
if (!response) {
|
|
88
153
|
return {
|
|
89
|
-
|
|
90
|
-
|
|
154
|
+
result: {
|
|
155
|
+
kind: "unavailable",
|
|
156
|
+
reason: `${label} "${kind}" is unavailable: no routed LSP client.`,
|
|
157
|
+
},
|
|
158
|
+
retryable: false,
|
|
91
159
|
};
|
|
92
160
|
}
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
161
|
+
const roots = [...response.authorizedMutationRoots];
|
|
162
|
+
const unavailable = (reason: string, retryable = false) => ({
|
|
163
|
+
result: { kind: "unavailable" as const, reason, authorizedMutationRoots: roots },
|
|
164
|
+
retryable,
|
|
165
|
+
});
|
|
166
|
+
if (response.reason || !response.value) {
|
|
167
|
+
return unavailable(
|
|
168
|
+
`${label} "${kind}" is unavailable: ${response.reason ?? "no code actions were returned."}`,
|
|
169
|
+
);
|
|
99
170
|
}
|
|
100
171
|
|
|
101
|
-
const
|
|
172
|
+
const ofKind = response.value
|
|
173
|
+
.filter(isTitledCodeAction)
|
|
174
|
+
.filter(
|
|
175
|
+
(action) => isCodeActionKindWithin(action.kind, kind) && !Object.hasOwn(action, "disabled"),
|
|
176
|
+
);
|
|
177
|
+
if (ofKind.length === 0) {
|
|
178
|
+
const advertised = advertisedFamilyKinds(response.advertisedKinds, kind);
|
|
179
|
+
return unavailable(
|
|
180
|
+
noKindMatchReason(kind, advertised),
|
|
181
|
+
advertised.kinds.some((candidate) => isCodeActionKindInRange(candidate, kind)),
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
const matching = title === undefined ? ofKind : ofKind.filter((action) => action.title === title);
|
|
102
185
|
if (matching.length === 0) {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
186
|
+
const titles = [...new Set(ofKind.map((action) => `"${action.title}"`))].join(", ");
|
|
187
|
+
return unavailable(
|
|
188
|
+
`No "${kind}" code action has the title "${title}". Available titles: ${titles}.`,
|
|
189
|
+
);
|
|
107
190
|
}
|
|
108
191
|
|
|
109
|
-
|
|
192
|
+
return { result: await planMatchingActions(options, matching, response), retryable: false };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Turn the matching actions into one plan, candidates, or an unavailable reason. */
|
|
196
|
+
async function planMatchingActions(
|
|
197
|
+
options: CodeActionRefactorOptions,
|
|
198
|
+
matching: CodeAction[],
|
|
199
|
+
response: RoutedCodeActionResponse,
|
|
200
|
+
): Promise<RefactorResult> {
|
|
201
|
+
const { lsp, file, kind } = options;
|
|
202
|
+
const roots = [...response.authorizedMutationRoots];
|
|
203
|
+
const unavailable = (reason: string): RefactorResult => ({
|
|
204
|
+
kind: "unavailable",
|
|
205
|
+
reason,
|
|
206
|
+
authorizedMutationRoots: roots,
|
|
207
|
+
});
|
|
208
|
+
const plans = collectPrecisePlans(matching, {
|
|
110
209
|
getOpenDocumentVersion: (candidate) => lsp.getOpenDocumentVersion(candidate),
|
|
111
210
|
authorizedMutationRoots: response.authorizedMutationRoots,
|
|
112
|
-
};
|
|
113
|
-
const
|
|
114
|
-
if (plans.precise.length === 1)
|
|
211
|
+
});
|
|
212
|
+
const [single] = plans.precise;
|
|
213
|
+
if (plans.precise.length === 1 && single) {
|
|
214
|
+
return { ...single.result, action: { ...actionIdentity(single.action), provenance: "edit" } };
|
|
215
|
+
}
|
|
115
216
|
if (plans.precise.length > 1) {
|
|
116
217
|
return {
|
|
117
218
|
kind: "ambiguous",
|
|
@@ -120,26 +221,84 @@ export async function runFilteredCodeActionRefactor(options: {
|
|
|
120
221
|
),
|
|
121
222
|
};
|
|
122
223
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
224
|
+
if (matching.every(isCommandOnlyAction)) {
|
|
225
|
+
const eligible = captureEligibleActions(matching, response.advertisedCommands);
|
|
226
|
+
const [only] = eligible;
|
|
227
|
+
if (eligible.length === 1 && only) {
|
|
228
|
+
return captureCommandAction({ lsp, file, action: only, control: options.control });
|
|
229
|
+
}
|
|
230
|
+
return eligible.length > 1
|
|
231
|
+
? commandCandidates(eligible)
|
|
232
|
+
: unavailable(COMMAND_ONLY_ACTION_REASON);
|
|
233
|
+
}
|
|
234
|
+
return unavailable(
|
|
235
|
+
plans.unavailableReason
|
|
126
236
|
? `Matching code action is unavailable: ${plans.unavailableReason}`
|
|
127
|
-
: `Matching
|
|
128
|
-
|
|
237
|
+
: `Matching "${kind}" code actions did not produce precise edits.`,
|
|
238
|
+
);
|
|
129
239
|
}
|
|
130
240
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
241
|
+
/** Compute the zero-based UTF-16 range that covers a file's full current disk text. */
|
|
242
|
+
export function wholeDocumentRange(file: string): SourceRange | null {
|
|
243
|
+
let text: string;
|
|
244
|
+
try {
|
|
245
|
+
text = readFileSync(file, "utf-8");
|
|
246
|
+
} catch {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
const lines = text.split(/\r\n|\r|\n/);
|
|
250
|
+
return {
|
|
251
|
+
start: { line: 0, character: 0 },
|
|
252
|
+
end: { line: lines.length - 1, character: lines.at(-1)?.length ?? 0 },
|
|
253
|
+
};
|
|
134
254
|
}
|
|
135
255
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
256
|
+
function isTitledCodeAction(value: unknown): value is CodeAction {
|
|
257
|
+
return (
|
|
258
|
+
typeof value === "object" &&
|
|
259
|
+
value !== null &&
|
|
260
|
+
typeof (value as { title?: unknown }).title === "string"
|
|
140
261
|
);
|
|
141
262
|
}
|
|
142
263
|
|
|
264
|
+
/** Advertised kinds in one CodeActionKind family, such as `source*`. */
|
|
265
|
+
interface AdvertisedFamilyKinds {
|
|
266
|
+
/** Listed non-empty kinds in the requested kind's top-level family. */
|
|
267
|
+
readonly kinds: readonly string[];
|
|
268
|
+
/** The server lists CodeActionKind.Empty or no kinds, so it may return any kind. */
|
|
269
|
+
readonly anyKind: boolean;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function advertisedFamilyKinds(
|
|
273
|
+
advertised: readonly string[] | undefined,
|
|
274
|
+
kind: string,
|
|
275
|
+
): AdvertisedFamilyKinds {
|
|
276
|
+
const family = kind.split(".")[0] ?? kind;
|
|
277
|
+
return {
|
|
278
|
+
kinds: (advertised ?? []).filter(
|
|
279
|
+
(candidate) => candidate !== "" && isCodeActionKindWithin(candidate, family),
|
|
280
|
+
),
|
|
281
|
+
anyKind: !advertised || advertised.includes(""),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function noKindMatchReason(kind: string, advertised: AdvertisedFamilyKinds): string {
|
|
286
|
+
const parts = [`No code action matches kind "${kind}" here.`];
|
|
287
|
+
if (advertised.kinds.length > 0) {
|
|
288
|
+
parts.push(`Server-advertised kinds: ${advertised.kinds.join(", ")}.`);
|
|
289
|
+
}
|
|
290
|
+
if (advertised.anyKind) {
|
|
291
|
+
parts.push("The server may return any code-action kind but returned none of this kind.");
|
|
292
|
+
} else if (advertised.kinds.length === 0) {
|
|
293
|
+
parts.push("The server advertises no kinds in this family.");
|
|
294
|
+
}
|
|
295
|
+
return parts.join(" ");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function actionIdentity(action: CodeAction): RefactorActionIdentity {
|
|
299
|
+
return { kind: typeof action.kind === "string" ? action.kind : "", title: action.title };
|
|
300
|
+
}
|
|
301
|
+
|
|
143
302
|
interface PreciseCodeActionPlan {
|
|
144
303
|
action: CodeAction;
|
|
145
304
|
result: Extract<RefactorResult, { kind: "precise" }>;
|
|
@@ -187,18 +346,14 @@ function refactorCandidate(
|
|
|
187
346
|
result: Extract<RefactorResult, { kind: "precise" }>,
|
|
188
347
|
index: number,
|
|
189
348
|
plans: readonly { action: CodeAction }[],
|
|
190
|
-
): {
|
|
191
|
-
description: string;
|
|
192
|
-
file?: string;
|
|
193
|
-
line?: number;
|
|
194
|
-
character?: number;
|
|
195
|
-
} {
|
|
349
|
+
): DisambiguationCandidate {
|
|
196
350
|
const firstEdit = result.edits.edits[0];
|
|
197
351
|
const sameTitleCount = plans.filter((plan) => plan.action.title === action.title).length;
|
|
198
352
|
const description =
|
|
199
353
|
sameTitleCount > 1 ? `${action.title} (alternative ${index + 1})` : action.title;
|
|
200
354
|
return {
|
|
201
355
|
description,
|
|
356
|
+
...actionIdentity(action),
|
|
202
357
|
...(firstEdit
|
|
203
358
|
? {
|
|
204
359
|
file: firstEdit.file,
|
|
@@ -2,10 +2,11 @@ import { readFile } from "node:fs/promises";
|
|
|
2
2
|
import type {
|
|
3
3
|
CodeLocation,
|
|
4
4
|
CodeSymbol,
|
|
5
|
-
|
|
5
|
+
DeclarationLevel,
|
|
6
6
|
DocumentCodeSymbol,
|
|
7
7
|
SourceRange,
|
|
8
8
|
} from "@mrclrchtr/supi-code-runtime/api";
|
|
9
|
+
import { childDeclarationLevel } from "@mrclrchtr/supi-code-runtime/api";
|
|
9
10
|
import { uriToFile } from "@mrclrchtr/supi-core/path";
|
|
10
11
|
import type {
|
|
11
12
|
DocumentSymbol,
|
|
@@ -100,15 +101,26 @@ export async function mapDocumentSymbols(
|
|
|
100
101
|
): Promise<DocumentCodeSymbol[]> {
|
|
101
102
|
return flattenDocumentSymbols(symbols, filePath, null, {
|
|
102
103
|
sourceLines: await readSourceLines(filePath),
|
|
103
|
-
|
|
104
|
+
parent: null,
|
|
104
105
|
});
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
/** Level of a symbol reported inside `parent`, or top-level without one. */
|
|
109
|
+
function levelWithin(
|
|
110
|
+
parent: { readonly kind: string; readonly level: DeclarationLevel } | null,
|
|
111
|
+
kind: string,
|
|
112
|
+
): DeclarationLevel {
|
|
113
|
+
return parent ? childDeclarationLevel(parent, kind) : "top-level";
|
|
114
|
+
}
|
|
115
|
+
|
|
107
116
|
function flattenDocumentSymbols(
|
|
108
117
|
symbols: DocumentSymbol[] | SymbolInformation[],
|
|
109
118
|
filePath: string,
|
|
110
119
|
container: string | null,
|
|
111
|
-
context: {
|
|
120
|
+
context: {
|
|
121
|
+
sourceLines: readonly string[] | null;
|
|
122
|
+
parent: { readonly kind: string; readonly level: DeclarationLevel } | null;
|
|
123
|
+
},
|
|
112
124
|
): DocumentCodeSymbol[] {
|
|
113
125
|
const result: DocumentCodeSymbol[] = [];
|
|
114
126
|
for (const sym of symbols) {
|
|
@@ -116,15 +128,13 @@ function flattenDocumentSymbols(
|
|
|
116
128
|
let document: DocumentSymbol | null = null;
|
|
117
129
|
if (isSymbolInformation(sym)) information = sym;
|
|
118
130
|
else document = sym;
|
|
131
|
+
const kind = symbolKindName(sym.kind);
|
|
132
|
+
const level = information ? "unknown" : levelWithin(context.parent, kind);
|
|
133
|
+
const childContext = { sourceLines: context.sourceLines, parent: { kind, level } };
|
|
119
134
|
const declarationRange = toValidRange(document?.range ?? information?.location.range);
|
|
120
135
|
if (!declarationRange) {
|
|
121
136
|
if (document?.children?.length) {
|
|
122
|
-
result.push(
|
|
123
|
-
...flattenDocumentSymbols(document.children, filePath, sym.name, {
|
|
124
|
-
sourceLines: context.sourceLines,
|
|
125
|
-
nesting: "nested",
|
|
126
|
-
}),
|
|
127
|
-
);
|
|
137
|
+
result.push(...flattenDocumentSymbols(document.children, filePath, sym.name, childContext));
|
|
128
138
|
}
|
|
129
139
|
continue;
|
|
130
140
|
}
|
|
@@ -134,23 +144,18 @@ function flattenDocumentSymbols(
|
|
|
134
144
|
: null;
|
|
135
145
|
const symbol: DocumentCodeSymbol = {
|
|
136
146
|
name: sym.name,
|
|
137
|
-
kind
|
|
147
|
+
kind,
|
|
138
148
|
file: filePath,
|
|
139
149
|
declarationAnchor: { line: declStart.line + 1, character: declStart.character + 1 },
|
|
140
150
|
container: information?.containerName ?? container,
|
|
141
|
-
|
|
151
|
+
level,
|
|
142
152
|
};
|
|
143
153
|
if (nameStart) {
|
|
144
154
|
symbol.nameAnchor = { line: nameStart.line + 1, character: nameStart.character + 1 };
|
|
145
155
|
}
|
|
146
156
|
result.push(symbol);
|
|
147
157
|
if (document?.children?.length) {
|
|
148
|
-
result.push(
|
|
149
|
-
...flattenDocumentSymbols(document.children, filePath, sym.name, {
|
|
150
|
-
sourceLines: context.sourceLines,
|
|
151
|
-
nesting: "nested",
|
|
152
|
-
}),
|
|
153
|
-
);
|
|
158
|
+
result.push(...flattenDocumentSymbols(document.children, filePath, sym.name, childContext));
|
|
154
159
|
}
|
|
155
160
|
}
|
|
156
161
|
return result;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Project-root boundary for mutation requests on one routed client. */
|
|
2
|
+
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
4
|
+
import { isWithinOrEqual } from "@mrclrchtr/supi-core/project";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Return why a file cannot be refactored by the client routed to it, or null.
|
|
8
|
+
*
|
|
9
|
+
* A file without its own project markers routes to a server rooted at the
|
|
10
|
+
* session directory. Its edits would fail the apply-time root check, so refuse
|
|
11
|
+
* before the request. Real paths are compared, so a link inside the root
|
|
12
|
+
* cannot reach an outside file.
|
|
13
|
+
*/
|
|
14
|
+
export function outsideRouteRootReason(file: string, root: string): string | null {
|
|
15
|
+
return isWithinOrEqual(canonical(root), canonical(file))
|
|
16
|
+
? null
|
|
17
|
+
: `File ${file} is outside the language server's project root ${root}.`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function canonical(file: string): string {
|
|
21
|
+
try {
|
|
22
|
+
return realpathSync(file);
|
|
23
|
+
} catch {
|
|
24
|
+
return file;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// Shared session-scoped LSP service registry reused by peer extensions.
|
|
2
2
|
|
|
3
3
|
// biome-ignore lint/style/noExcessiveLinesPerFile: session registry, runtime recovery, and telemetry stay in one file; splitting would hide the recovery contract.
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
4
5
|
import {
|
|
5
6
|
type CodeQueryResult,
|
|
6
7
|
type CodeRequestControl,
|
|
7
8
|
isCodeRequestInterruption,
|
|
8
9
|
mapCodeQueryResult,
|
|
10
|
+
partialCodeQuery,
|
|
9
11
|
throwIfCodeRequestInterrupted,
|
|
10
12
|
unavailableCodeQuery,
|
|
11
13
|
} from "@mrclrchtr/supi-code-runtime/api";
|
|
@@ -16,6 +18,8 @@ import {
|
|
|
16
18
|
import { resolveToolPath as resolveSessionPath } from "@mrclrchtr/supi-core/path";
|
|
17
19
|
import { createSessionStateRegistry } from "@mrclrchtr/supi-core/session";
|
|
18
20
|
import { CodeActionTriggerKind } from "vscode-languageserver-types";
|
|
21
|
+
import type { CommandEditCaptureResult } from "../client/client-edit-capture.ts";
|
|
22
|
+
import { advertisedCodeActionKinds } from "../config/operation-support.ts";
|
|
19
23
|
import type {
|
|
20
24
|
CodeAction,
|
|
21
25
|
Diagnostic,
|
|
@@ -37,7 +41,12 @@ import type {
|
|
|
37
41
|
WorkspaceSentinelSyncResult,
|
|
38
42
|
} from "../diagnostics/workspace-sentinels.ts";
|
|
39
43
|
import type { LspManager } from "../manager/manager.ts";
|
|
44
|
+
import {
|
|
45
|
+
COMMAND_ONLY_ACTION_REASON,
|
|
46
|
+
captureEligibleActions,
|
|
47
|
+
} from "../provider/refactor-command-capture.ts";
|
|
40
48
|
import { raceReadinessValue } from "./readiness.ts";
|
|
49
|
+
import { outsideRouteRootReason } from "./route-boundary.ts";
|
|
41
50
|
import type {
|
|
42
51
|
DiagnosticEvidenceSummary,
|
|
43
52
|
ProcessCrashRecoveryReport,
|
|
@@ -47,6 +56,7 @@ import type {
|
|
|
47
56
|
import { runRoutedRename } from "./runtime-rename.ts";
|
|
48
57
|
import type {
|
|
49
58
|
BulkTrackFilesResult,
|
|
59
|
+
RoutedCodeActionResponse,
|
|
50
60
|
RoutedMutationResponse,
|
|
51
61
|
SemanticReadinessResult,
|
|
52
62
|
WorkspaceCodeActionRequestOptions,
|
|
@@ -54,6 +64,9 @@ import type {
|
|
|
54
64
|
WorkspaceLspRuntimeState,
|
|
55
65
|
} from "./workspace-lsp-runtime.ts";
|
|
56
66
|
|
|
67
|
+
/** Upper bound for a server command run to capture its pushed edit. */
|
|
68
|
+
const COMMAND_CAPTURE_TIMEOUT_MS = 10_000;
|
|
69
|
+
|
|
57
70
|
export type {
|
|
58
71
|
ProcessCrashDiagnosticDemand,
|
|
59
72
|
WorkspaceLspDiagnosticSurface,
|
|
@@ -82,6 +95,7 @@ export {
|
|
|
82
95
|
export type {
|
|
83
96
|
BulkTrackFileOutcome,
|
|
84
97
|
BulkTrackFilesResult,
|
|
98
|
+
RoutedCodeActionResponse,
|
|
85
99
|
RoutedMutationDocumentSnapshot,
|
|
86
100
|
RoutedMutationResponse,
|
|
87
101
|
SemanticReadinessResult,
|
|
@@ -193,9 +207,14 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
|
|
|
193
207
|
control,
|
|
194
208
|
});
|
|
195
209
|
if (!client) return unavailableFileQuery("references", resolvedPath);
|
|
196
|
-
return
|
|
197
|
-
|
|
198
|
-
|
|
210
|
+
return this.withProjectCoverage(
|
|
211
|
+
client,
|
|
212
|
+
{ file: resolvedPath, position },
|
|
213
|
+
"references",
|
|
214
|
+
mapCodeQueryResult(
|
|
215
|
+
await client.references(resolvedPath, position, control),
|
|
216
|
+
(data) => data ?? [],
|
|
217
|
+
),
|
|
199
218
|
);
|
|
200
219
|
}
|
|
201
220
|
|
|
@@ -211,7 +230,30 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
|
|
|
211
230
|
control,
|
|
212
231
|
});
|
|
213
232
|
if (!client) return unavailableFileQuery("implementation", resolvedPath);
|
|
214
|
-
return
|
|
233
|
+
return this.withProjectCoverage(
|
|
234
|
+
client,
|
|
235
|
+
{ file: resolvedPath, position },
|
|
236
|
+
"implementation",
|
|
237
|
+
await client.implementation(resolvedPath, position, control),
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Downgrade a completed project-wide answer to partial when the TypeScript
|
|
243
|
+
* server may not have searched every relevant project.
|
|
244
|
+
*/
|
|
245
|
+
private withProjectCoverage<T extends Location[] | Location | LocationLink[] | null>(
|
|
246
|
+
client: NonNullable<Awaited<ReturnType<LspManager["ensureFileOpen"]>>>,
|
|
247
|
+
target: { readonly file: string; readonly position: Position },
|
|
248
|
+
request: "references" | "implementation",
|
|
249
|
+
result: CodeQueryResult<T>,
|
|
250
|
+
): CodeQueryResult<T> {
|
|
251
|
+
if (result.kind !== "completed") return result;
|
|
252
|
+
const gap = this.manager.typeScriptProjectCoverageGap(client, target.file, request, {
|
|
253
|
+
resultFiles: locationFiles(result.data),
|
|
254
|
+
position: target.position,
|
|
255
|
+
});
|
|
256
|
+
return gap ? partialCodeQuery(result.data, gap) : result;
|
|
215
257
|
}
|
|
216
258
|
|
|
217
259
|
async documentSymbols(
|
|
@@ -258,12 +300,39 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
|
|
|
258
300
|
return this.manager.getOpenDocumentVersion(this.resolveFilePath(filePath));
|
|
259
301
|
}
|
|
260
302
|
|
|
303
|
+
async captureCodeActionEdit(
|
|
304
|
+
filePath: string,
|
|
305
|
+
action: CodeAction,
|
|
306
|
+
control?: CodeRequestControl,
|
|
307
|
+
): Promise<RoutedMutationResponse<CommandEditCaptureResult | null> | null> {
|
|
308
|
+
const resolvedPath = this.resolveFilePath(filePath);
|
|
309
|
+
const client = await this.manager.ensureFileOpen(resolvedPath, {
|
|
310
|
+
recoverProcessCrash: true,
|
|
311
|
+
protectInputs: true,
|
|
312
|
+
control,
|
|
313
|
+
});
|
|
314
|
+
if (!client) return null;
|
|
315
|
+
const authorizedMutationRoots = [client.root];
|
|
316
|
+
const outside = outsideRouteRootReason(resolvedPath, client.root);
|
|
317
|
+
if (outside) return { value: null, authorizedMutationRoots, reason: outside };
|
|
318
|
+
const listed = client.serverCapabilities?.executeCommandProvider?.commands;
|
|
319
|
+
const [eligible] = captureEligibleActions([action], Array.isArray(listed) ? listed : undefined);
|
|
320
|
+
if (!eligible) {
|
|
321
|
+
return { value: null, authorizedMutationRoots, reason: COMMAND_ONLY_ACTION_REASON };
|
|
322
|
+
}
|
|
323
|
+
const value = await client.captureCommandEdits(eligible.command, {
|
|
324
|
+
timeoutMs: COMMAND_CAPTURE_TIMEOUT_MS,
|
|
325
|
+
...(control ? { control } : {}),
|
|
326
|
+
});
|
|
327
|
+
return { value, authorizedMutationRoots };
|
|
328
|
+
}
|
|
329
|
+
|
|
261
330
|
async codeActions(
|
|
262
331
|
filePath: string,
|
|
263
332
|
positionOrRange: Position | Range,
|
|
264
333
|
control?: CodeRequestControl,
|
|
265
334
|
options?: WorkspaceCodeActionRequestOptions,
|
|
266
|
-
): Promise<
|
|
335
|
+
): Promise<RoutedCodeActionResponse | null> {
|
|
267
336
|
const resolvedPath = this.resolveFilePath(filePath);
|
|
268
337
|
const client = await this.manager.ensureFileOpen(resolvedPath, {
|
|
269
338
|
recoverProcessCrash: true,
|
|
@@ -271,6 +340,10 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
|
|
|
271
340
|
control,
|
|
272
341
|
});
|
|
273
342
|
if (!client) return null;
|
|
343
|
+
const outside = outsideRouteRootReason(resolvedPath, client.root);
|
|
344
|
+
if (outside) {
|
|
345
|
+
return { value: null, authorizedMutationRoots: [client.root], reason: outside };
|
|
346
|
+
}
|
|
274
347
|
|
|
275
348
|
const range = isRange(positionOrRange)
|
|
276
349
|
? positionOrRange
|
|
@@ -285,21 +358,23 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
|
|
|
285
358
|
triggerKind: options?.triggerKind ?? CodeActionTriggerKind.Invoked,
|
|
286
359
|
...(options?.only ? { only: [...options.only] } : {}),
|
|
287
360
|
};
|
|
361
|
+
const advertisedKinds = advertisedCodeActionKinds(client.serverCapabilities ?? null);
|
|
362
|
+
const advertisedCommands = client.serverCapabilities?.executeCommandProvider?.commands;
|
|
363
|
+
const routed = {
|
|
364
|
+
authorizedMutationRoots: [client.root],
|
|
365
|
+
...(advertisedKinds ? { advertisedKinds } : {}),
|
|
366
|
+
...(Array.isArray(advertisedCommands) ? { advertisedCommands } : {}),
|
|
367
|
+
};
|
|
288
368
|
if (typeof client.codeActionsDetailed === "function") {
|
|
289
369
|
const result = await client.codeActionsDetailed(resolvedPath, range, context, control);
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
...
|
|
294
|
-
? { reason: result.reason }
|
|
295
|
-
: result.data === null
|
|
296
|
-
? { reason: "LSP server returned no code actions." }
|
|
297
|
-
: {}),
|
|
298
|
-
};
|
|
370
|
+
// A completed `null` result means the server has no actions here (LSP spec).
|
|
371
|
+
return result.kind === "completed"
|
|
372
|
+
? { ...routed, value: result.data ?? [] }
|
|
373
|
+
: { ...routed, value: null, reason: result.reason };
|
|
299
374
|
}
|
|
300
375
|
return {
|
|
301
|
-
|
|
302
|
-
|
|
376
|
+
...routed,
|
|
377
|
+
value: (await client.codeActions(resolvedPath, range, context, control)) ?? [],
|
|
303
378
|
};
|
|
304
379
|
}
|
|
305
380
|
|
|
@@ -605,6 +680,21 @@ export async function waitForWorkspaceLspRuntime(
|
|
|
605
680
|
return state;
|
|
606
681
|
}
|
|
607
682
|
|
|
683
|
+
/** Local file paths of LSP locations or location links; non-file URIs are skipped. */
|
|
684
|
+
function locationFiles(data: Location[] | Location | LocationLink[] | null): string[] {
|
|
685
|
+
const items: (Location | LocationLink)[] =
|
|
686
|
+
data === null ? [] : Array.isArray(data) ? data : [data];
|
|
687
|
+
return items.flatMap((item) => {
|
|
688
|
+
const uri = "targetUri" in item ? item.targetUri : item.uri;
|
|
689
|
+
if (!uri.startsWith("file:")) return [];
|
|
690
|
+
try {
|
|
691
|
+
return [fileURLToPath(uri)];
|
|
692
|
+
} catch {
|
|
693
|
+
return [];
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
|
|
608
698
|
/** Remove the LSP service state for a session cwd. */
|
|
609
699
|
export function clearWorkspaceLspRuntime(cwd: string): void {
|
|
610
700
|
registry.clear(cwd);
|
|
@@ -5,6 +5,7 @@ import type { SemanticInputDocumentSnapshot } from "../client/client-semantic-in
|
|
|
5
5
|
import type { Position, WorkspaceEdit } from "../config/types.ts";
|
|
6
6
|
import type { LspManager } from "../manager/manager.ts";
|
|
7
7
|
import { discoverSemanticEditFiles } from "../provider/semantic-edit-discovery.ts";
|
|
8
|
+
import { outsideRouteRootReason } from "./route-boundary.ts";
|
|
8
9
|
import type {
|
|
9
10
|
RoutedMutationDocumentSnapshot,
|
|
10
11
|
RoutedMutationResponse,
|
|
@@ -26,6 +27,8 @@ export async function runRoutedRename(options: {
|
|
|
26
27
|
});
|
|
27
28
|
if (!client) return null;
|
|
28
29
|
const authorizedMutationRoots = [client.root];
|
|
30
|
+
const outside = outsideRouteRootReason(file, client.root);
|
|
31
|
+
if (outside) return { value: null, authorizedMutationRoots, reason: outside };
|
|
29
32
|
|
|
30
33
|
if (typeof client.renameDetailedWithSnapshots === "function") {
|
|
31
34
|
return renameWithSnapshots({
|