@mrclrchtr/supi-lsp 7.4.0 → 8.0.1
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 +29 -6
- package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-code-runtime/src/api.ts +1 -1
- package/node_modules/@mrclrchtr/supi-code-runtime/src/query-result.ts +22 -4
- package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/config/config.ts +110 -0
- package/node_modules/@mrclrchtr/supi-core/src/config.ts +6 -1
- package/node_modules/@mrclrchtr/supi-core/src/settings/settings-schema.ts +2 -1
- package/package.json +3 -3
- package/src/api.ts +25 -3
- package/src/client/client-diagnostic-host.ts +2 -1
- package/src/client/client-diagnostic-refresh.ts +5 -2
- package/src/client/client-diagnostics.ts +39 -6
- package/src/client/client-document-sync.ts +82 -12
- package/src/client/client-semantic-input-barrier.ts +28 -5
- package/src/client/client.ts +606 -30
- package/src/config/capabilities.ts +9 -0
- package/src/config/config.ts +69 -8
- package/src/config/operation-support.ts +196 -0
- package/src/config/server-config.ts +15 -0
- package/src/config/types.ts +9 -1
- package/src/manager/manager-project-info.ts +2 -0
- package/src/manager/manager-workspace-symbol.ts +31 -2
- package/src/manager/manager.ts +72 -2
- package/src/provider/lsp-refactor-provider.ts +9 -43
- package/src/provider/refactor-planning.ts +102 -50
- package/src/provider/semantic-edit-normalizer.ts +49 -3
- package/src/session/runtime-controller.ts +78 -18
- package/src/session/runtime-registry.ts +46 -2
- package/src/session/workspace-lsp-runtime.ts +14 -0
- package/src/workspace-path-policy.ts +30 -4
- package/src/config/lsp-settings.ts +0 -22
package/src/client/client.ts
CHANGED
|
@@ -16,9 +16,18 @@ import {
|
|
|
16
16
|
recordDebugEvent,
|
|
17
17
|
truncateDebugIdentity as truncateIdentity,
|
|
18
18
|
} from "@mrclrchtr/supi-core/debug";
|
|
19
|
-
import { fileToUri } from "@mrclrchtr/supi-core/path";
|
|
19
|
+
import { fileToUri, uriToFile } from "@mrclrchtr/supi-core/path";
|
|
20
20
|
import { type ProgressToken, TextDocumentSyncKind } from "vscode-languageserver-protocol";
|
|
21
|
+
import { CodeActionTriggerKind } from "vscode-languageserver-types";
|
|
21
22
|
import { CLIENT_CAPABILITIES } from "../config/capabilities.ts";
|
|
23
|
+
import {
|
|
24
|
+
getServerOperationSupport,
|
|
25
|
+
supportsCodeActionResolve,
|
|
26
|
+
supportsCodeActions,
|
|
27
|
+
supportsPrepareRename,
|
|
28
|
+
supportsRename,
|
|
29
|
+
supportsRequestedCodeActionKinds,
|
|
30
|
+
} from "../config/operation-support.ts";
|
|
22
31
|
import type {
|
|
23
32
|
CodeAction,
|
|
24
33
|
CodeActionContext,
|
|
@@ -29,6 +38,8 @@ import type {
|
|
|
29
38
|
FileEvent,
|
|
30
39
|
Hover,
|
|
31
40
|
InitializeResult,
|
|
41
|
+
JsonObject,
|
|
42
|
+
JsonValue,
|
|
32
43
|
Location,
|
|
33
44
|
LocationLink,
|
|
34
45
|
Position,
|
|
@@ -57,6 +68,7 @@ import { createPriorityDiagnosticRequestAdapter } from "./client-diagnostic-requ
|
|
|
57
68
|
import { createTypeScriptDiagnosticRequestAdapter } from "./client-diagnostic-typescript.ts";
|
|
58
69
|
import { ClientDiagnostics } from "./client-diagnostics.ts";
|
|
59
70
|
import type { ClientDiagnosticSnapshot, DiagnosticEntry } from "./client-document-state.ts";
|
|
71
|
+
import { type NormalizedDocumentSync, normalizeDocumentSync } from "./client-document-sync.ts";
|
|
60
72
|
import { SemanticInputRequestRetryGuard } from "./client-request-enrollment.ts";
|
|
61
73
|
import {
|
|
62
74
|
isSemanticInputEnrollmentError,
|
|
@@ -70,6 +82,11 @@ interface SemanticRequestAttemptState {
|
|
|
70
82
|
inputRevision: number | undefined;
|
|
71
83
|
}
|
|
72
84
|
|
|
85
|
+
interface RefactorTransaction {
|
|
86
|
+
rpc: JsonRpcClient;
|
|
87
|
+
assertCurrent: () => Promise<void>;
|
|
88
|
+
}
|
|
89
|
+
|
|
73
90
|
/**
|
|
74
91
|
* Fixed bound after which a running client that never became ready is
|
|
75
92
|
* considered readiness-stalled and eligible for a recovery restart.
|
|
@@ -105,6 +122,291 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
105
122
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
106
123
|
}
|
|
107
124
|
|
|
125
|
+
function isPosition(value: unknown): value is Position {
|
|
126
|
+
return (
|
|
127
|
+
isRecord(value) &&
|
|
128
|
+
Object.hasOwn(value, "line") &&
|
|
129
|
+
Object.hasOwn(value, "character") &&
|
|
130
|
+
Object.keys(value).every((key) => key === "line" || key === "character") &&
|
|
131
|
+
Number.isInteger(value.line) &&
|
|
132
|
+
(value.line as number) >= 0 &&
|
|
133
|
+
Number.isInteger(value.character) &&
|
|
134
|
+
(value.character as number) >= 0
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isRange(value: unknown): value is Range {
|
|
139
|
+
return (
|
|
140
|
+
isRecord(value) &&
|
|
141
|
+
Object.hasOwn(value, "start") &&
|
|
142
|
+
Object.hasOwn(value, "end") &&
|
|
143
|
+
Object.keys(value).every((key) => key === "start" || key === "end") &&
|
|
144
|
+
isPosition(value.start) &&
|
|
145
|
+
isPosition(value.end) &&
|
|
146
|
+
!isBefore(value.end, value.start)
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isBefore(left: Position, right: Position): boolean {
|
|
151
|
+
return left.line < right.line || (left.line === right.line && left.character < right.character);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isPositionInRange(position: Position, range: Range): boolean {
|
|
155
|
+
return !isBefore(position, range.start) && !isBefore(range.end, position);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function hasOnlyKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean {
|
|
159
|
+
const allowedKeys = new Set(allowed);
|
|
160
|
+
return Object.keys(value).every((key) => allowedKeys.has(key));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function validatePrepareRenameResult(value: unknown, position: Position): string | null {
|
|
164
|
+
if (value === null || value === undefined) {
|
|
165
|
+
return "Rename is unavailable at the requested position.";
|
|
166
|
+
}
|
|
167
|
+
if (!isRecord(value)) return "LSP server returned a malformed prepareRename result.";
|
|
168
|
+
if (isRange(value)) {
|
|
169
|
+
return isPositionInRange(position, value)
|
|
170
|
+
? null
|
|
171
|
+
: "LSP server returned a prepareRename range that excludes the requested position.";
|
|
172
|
+
}
|
|
173
|
+
if (
|
|
174
|
+
hasOnlyKeys(value, ["range", "placeholder"]) &&
|
|
175
|
+
Object.hasOwn(value, "range") &&
|
|
176
|
+
Object.hasOwn(value, "placeholder") &&
|
|
177
|
+
typeof value.placeholder === "string" &&
|
|
178
|
+
isRange(value.range)
|
|
179
|
+
) {
|
|
180
|
+
return isPositionInRange(position, value.range)
|
|
181
|
+
? null
|
|
182
|
+
: "LSP server returned a prepareRename range that excludes the requested position.";
|
|
183
|
+
}
|
|
184
|
+
if (
|
|
185
|
+
hasOnlyKeys(value, ["defaultBehavior"]) &&
|
|
186
|
+
Object.hasOwn(value, "defaultBehavior") &&
|
|
187
|
+
typeof value.defaultBehavior === "boolean"
|
|
188
|
+
) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
return "LSP server returned a malformed prepareRename result.";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function isLazyCodeAction(value: unknown): value is CodeAction {
|
|
195
|
+
return (
|
|
196
|
+
isRecord(value) &&
|
|
197
|
+
Object.hasOwn(value, "title") &&
|
|
198
|
+
typeof value.title === "string" &&
|
|
199
|
+
value.title.trim().length > 0 &&
|
|
200
|
+
!Object.hasOwn(value, "edit") &&
|
|
201
|
+
!Object.hasOwn(value, "command") &&
|
|
202
|
+
!Object.hasOwn(value, "disabled") &&
|
|
203
|
+
hasNoInheritedAllowedKeys(value, [
|
|
204
|
+
"title",
|
|
205
|
+
"kind",
|
|
206
|
+
"diagnostics",
|
|
207
|
+
"isPreferred",
|
|
208
|
+
"disabled",
|
|
209
|
+
"edit",
|
|
210
|
+
"command",
|
|
211
|
+
"data",
|
|
212
|
+
])
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function codeActionKindMatchesFilter(kind: unknown, filter: string): boolean {
|
|
217
|
+
return (
|
|
218
|
+
filter === "" ||
|
|
219
|
+
(typeof kind === "string" && (kind === filter || kind.startsWith(`${filter}.`)))
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function isValidCommand(value: unknown): value is NonNullable<CodeAction["command"]> {
|
|
224
|
+
const command = isRecord(value) ? value : null;
|
|
225
|
+
return (
|
|
226
|
+
command !== null &&
|
|
227
|
+
Object.hasOwn(command, "title") &&
|
|
228
|
+
typeof command.title === "string" &&
|
|
229
|
+
command.title.trim().length > 0 &&
|
|
230
|
+
Object.hasOwn(command, "command") &&
|
|
231
|
+
typeof command.command === "string" &&
|
|
232
|
+
hasOnlyKeys(command, ["title", "tooltip", "command", "arguments"]) &&
|
|
233
|
+
hasNoInheritedAllowedKeys(command, ["title", "tooltip", "command", "arguments"]) &&
|
|
234
|
+
(!Object.hasOwn(command, "tooltip") || typeof command.tooltip === "string") &&
|
|
235
|
+
(!Object.hasOwn(command, "arguments") || Array.isArray(command.arguments))
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Validate one code-action response before matching or lazy resolution. */
|
|
240
|
+
function normalizeCodeActionResponseEntry(value: unknown): CodeAction | null {
|
|
241
|
+
const entry = isRecord(value) ? value : null;
|
|
242
|
+
if (!hasValidCodeActionTitle(entry)) return null;
|
|
243
|
+
|
|
244
|
+
// A protocol response may be a Command, not only a CodeAction. Keep it as
|
|
245
|
+
// an edit-less CodeAction so later planning reports it as unsupported.
|
|
246
|
+
if (typeof entry.command === "string") return normalizeCommandCodeAction(entry);
|
|
247
|
+
return isValidCodeActionEntry(entry) ? (entry as CodeAction) : null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function hasValidCodeActionTitle(
|
|
251
|
+
entry: Record<string, unknown> | null,
|
|
252
|
+
): entry is Record<string, unknown> {
|
|
253
|
+
return (
|
|
254
|
+
entry !== null &&
|
|
255
|
+
Object.hasOwn(entry, "title") &&
|
|
256
|
+
typeof entry.title === "string" &&
|
|
257
|
+
entry.title.trim().length > 0
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function normalizeCommandCodeAction(entry: Record<string, unknown>): CodeAction | null {
|
|
262
|
+
return hasOnlyKeys(entry, ["title", "tooltip", "command", "arguments"]) && isValidCommand(entry)
|
|
263
|
+
? { title: entry.title as string, command: entry as NonNullable<CodeAction["command"]> }
|
|
264
|
+
: null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function isValidCodeActionEntry(entry: Record<string, unknown>): boolean {
|
|
268
|
+
return (
|
|
269
|
+
hasOnlyKeys(entry, [
|
|
270
|
+
"title",
|
|
271
|
+
"kind",
|
|
272
|
+
"diagnostics",
|
|
273
|
+
"isPreferred",
|
|
274
|
+
"disabled",
|
|
275
|
+
"edit",
|
|
276
|
+
"command",
|
|
277
|
+
"data",
|
|
278
|
+
"tags",
|
|
279
|
+
]) &&
|
|
280
|
+
hasNoInheritedAllowedKeys(entry, [
|
|
281
|
+
"title",
|
|
282
|
+
"kind",
|
|
283
|
+
"diagnostics",
|
|
284
|
+
"isPreferred",
|
|
285
|
+
"disabled",
|
|
286
|
+
"edit",
|
|
287
|
+
"command",
|
|
288
|
+
"data",
|
|
289
|
+
"tags",
|
|
290
|
+
]) &&
|
|
291
|
+
isValidOptionalCodeActionFields(entry)
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function isValidOptionalCodeActionFields(entry: Record<string, unknown>): boolean {
|
|
296
|
+
return (
|
|
297
|
+
(!Object.hasOwn(entry, "kind") || typeof entry.kind === "string") &&
|
|
298
|
+
(!Object.hasOwn(entry, "diagnostics") || Array.isArray(entry.diagnostics)) &&
|
|
299
|
+
(!Object.hasOwn(entry, "isPreferred") || typeof entry.isPreferred === "boolean") &&
|
|
300
|
+
(!Object.hasOwn(entry, "disabled") || isValidDisabledCodeAction(entry.disabled)) &&
|
|
301
|
+
(!Object.hasOwn(entry, "command") || isValidCommand(entry.command)) &&
|
|
302
|
+
(!Object.hasOwn(entry, "tags") || Array.isArray(entry.tags))
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function isValidDisabledCodeAction(value: unknown): boolean {
|
|
307
|
+
const disabled = isRecord(value) ? value : null;
|
|
308
|
+
return (
|
|
309
|
+
disabled !== null &&
|
|
310
|
+
Object.hasOwn(disabled, "reason") &&
|
|
311
|
+
typeof disabled.reason === "string" &&
|
|
312
|
+
hasOnlyKeys(disabled, ["reason"]) &&
|
|
313
|
+
hasNoInheritedAllowedKeys(disabled, ["reason"])
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function hasNoInheritedAllowedKeys(
|
|
318
|
+
value: Record<string, unknown>,
|
|
319
|
+
allowed: readonly string[],
|
|
320
|
+
): boolean {
|
|
321
|
+
return allowed.every((key) => !(key in value) || Object.hasOwn(value, key));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function isCodeActionContext(value: unknown): value is CodeActionContext {
|
|
325
|
+
if (
|
|
326
|
+
!isRecord(value) ||
|
|
327
|
+
!Object.hasOwn(value, "diagnostics") ||
|
|
328
|
+
!Array.isArray(value.diagnostics)
|
|
329
|
+
) {
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
if (
|
|
333
|
+
!hasOnlyKeys(value, ["diagnostics", "only", "triggerKind"]) ||
|
|
334
|
+
!hasNoInheritedAllowedKeys(value, ["diagnostics", "only", "triggerKind"])
|
|
335
|
+
) {
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
if (
|
|
339
|
+
Object.hasOwn(value, "only") &&
|
|
340
|
+
value.only !== undefined &&
|
|
341
|
+
(!Array.isArray(value.only) ||
|
|
342
|
+
value.only.some(
|
|
343
|
+
(kind) => typeof kind !== "string" || (kind.length > 0 && kind.trim().length === 0),
|
|
344
|
+
))
|
|
345
|
+
) {
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
return (
|
|
349
|
+
!Object.hasOwn(value, "triggerKind") ||
|
|
350
|
+
value.triggerKind === undefined ||
|
|
351
|
+
value.triggerKind === CodeActionTriggerKind.Invoked ||
|
|
352
|
+
value.triggerKind === CodeActionTriggerKind.Automatic
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const EMPTY_SETTINGS: JsonObject = Object.freeze({});
|
|
357
|
+
|
|
358
|
+
/** Clone and freeze a JSON settings value so one client owns one stable snapshot. */
|
|
359
|
+
function cloneJsonValue(value: JsonValue): JsonValue {
|
|
360
|
+
if (Array.isArray(value)) {
|
|
361
|
+
return Object.freeze(value.map((entry) => cloneJsonValue(entry))) as JsonValue;
|
|
362
|
+
}
|
|
363
|
+
if (isRecord(value)) {
|
|
364
|
+
const clone: Record<string, JsonValue> = Object.create(null);
|
|
365
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
366
|
+
clone[key] = cloneJsonValue(entry as JsonValue);
|
|
367
|
+
}
|
|
368
|
+
return Object.freeze(clone) as JsonValue;
|
|
369
|
+
}
|
|
370
|
+
return value;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function cloneSettings(settings: JsonObject | undefined): JsonObject | undefined {
|
|
374
|
+
return settings === undefined ? undefined : (cloneJsonValue(settings) as JsonObject);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Resolve a dotted section against an own-property-only JSON settings tree. */
|
|
378
|
+
function lookupSettings(settings: JsonObject, section: unknown): JsonValue | undefined {
|
|
379
|
+
if (section === undefined || section === "") return settings;
|
|
380
|
+
if (typeof section !== "string") return undefined;
|
|
381
|
+
const parts = section.split(".");
|
|
382
|
+
if (parts.some((part) => part.length === 0)) return undefined;
|
|
383
|
+
|
|
384
|
+
let current: JsonValue = settings;
|
|
385
|
+
for (const part of parts) {
|
|
386
|
+
if (!isRecord(current) || !Object.hasOwn(current, part)) return undefined;
|
|
387
|
+
current = current[part] as JsonValue;
|
|
388
|
+
}
|
|
389
|
+
return current;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Test whether a configuration scope URI belongs to this server workspace. */
|
|
393
|
+
function isApplicableConfigurationScope(scopeUri: unknown, root: string): boolean {
|
|
394
|
+
if (scopeUri === undefined) return true;
|
|
395
|
+
if (typeof scopeUri !== "string" || !scopeUri.startsWith("file://")) return false;
|
|
396
|
+
let scopePath: string;
|
|
397
|
+
try {
|
|
398
|
+
scopePath = uriToFile(scopeUri);
|
|
399
|
+
} catch {
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
if (scopePath === scopeUri) return false;
|
|
403
|
+
const relative = path.relative(path.resolve(root), path.resolve(scopePath));
|
|
404
|
+
return (
|
|
405
|
+
relative === "" ||
|
|
406
|
+
(!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative))
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
108
410
|
/**
|
|
109
411
|
* Read and validate the `registrations` array of a `client/registerCapability`
|
|
110
412
|
* request. Each entry must be a record with string `id` and `method`; the
|
|
@@ -193,6 +495,10 @@ export class LspClient {
|
|
|
193
495
|
private rpc: JsonRpcClient | null = null;
|
|
194
496
|
private _status: ClientStatus = "initializing";
|
|
195
497
|
private capabilities: ServerCapabilities | null = null;
|
|
498
|
+
/** Negotiated once after initialize for this client generation. */
|
|
499
|
+
private normalizedDocumentSync: NormalizedDocumentSync = normalizeDocumentSync(undefined);
|
|
500
|
+
/** Stable server-owned settings for this client generation. */
|
|
501
|
+
private readonly settingsSnapshot: JsonObject | undefined;
|
|
196
502
|
private readonly diagnosticRequestAdapter: DiagnosticRequestAdapter;
|
|
197
503
|
private readonly diagnostics: ClientDiagnostics;
|
|
198
504
|
/** Dynamic capability registrations for this client instance only. */
|
|
@@ -223,6 +529,7 @@ export class LspClient {
|
|
|
223
529
|
) {
|
|
224
530
|
this.name = name;
|
|
225
531
|
this.root = root;
|
|
532
|
+
this.settingsSnapshot = cloneSettings(config.settings);
|
|
226
533
|
const nativeDiagnosticAdapter: DiagnosticRequestAdapter = {
|
|
227
534
|
supports: (uri) => this.hasApplicableDiagnosticProvider(uri),
|
|
228
535
|
sourceFor: (uri) => (this.hasApplicableDiagnosticProvider(uri) ? "pull" : undefined),
|
|
@@ -254,8 +561,9 @@ export class LspClient {
|
|
|
254
561
|
cwd: cwd,
|
|
255
562
|
isOperational: () => this.rpc !== null && this._status === "running",
|
|
256
563
|
diagnosticRequestAdapter: this.diagnosticRequestAdapter,
|
|
257
|
-
|
|
564
|
+
documentSync: () => this.getDocumentSyncOptions(),
|
|
258
565
|
sendNotification: (method, params) => {
|
|
566
|
+
if (!this.shouldSendDocumentNotification(method)) return;
|
|
259
567
|
if (this.rpc) void this.rpc.sendNotification(method, params);
|
|
260
568
|
},
|
|
261
569
|
});
|
|
@@ -273,11 +581,54 @@ export class LspClient {
|
|
|
273
581
|
return this.capabilities;
|
|
274
582
|
}
|
|
275
583
|
|
|
584
|
+
/** Whether this route advertises the standard rename request. */
|
|
585
|
+
get supportsRename(): boolean {
|
|
586
|
+
return supportsRename(this.capabilities);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** Whether this route advertises `textDocument/prepareRename`. */
|
|
590
|
+
get supportsPrepareRename(): boolean {
|
|
591
|
+
return supportsPrepareRename(this.capabilities);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** Whether this route advertises `textDocument/codeAction`. */
|
|
595
|
+
get supportsCodeActions(): boolean {
|
|
596
|
+
return supportsCodeActions(this.capabilities);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Whether this route advertises standard code-action resolution. */
|
|
600
|
+
get supportsCodeActionResolve(): boolean {
|
|
601
|
+
return supportsCodeActionResolve(this.capabilities);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Capability facts used by the project-server health surface. */
|
|
605
|
+
get operationSupport() {
|
|
606
|
+
return getServerOperationSupport(this.capabilities);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** Whether the server can answer a request for each supplied code-action kind. */
|
|
610
|
+
supportsCodeActionKinds(requested: readonly string[]): boolean {
|
|
611
|
+
return supportsRequestedCodeActionKinds(this.capabilities, requested);
|
|
612
|
+
}
|
|
613
|
+
|
|
276
614
|
/** Whether the server requires range-based document changes. */
|
|
277
615
|
get usesIncrementalDocumentSync(): boolean {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
616
|
+
return this.getDocumentSyncOptions().change === TextDocumentSyncKind.Incremental;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
private getDocumentSyncOptions(): NormalizedDocumentSync {
|
|
620
|
+
return this.normalizedDocumentSync;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** Filter document lifecycle traffic against the negotiated server options. */
|
|
624
|
+
private shouldSendDocumentNotification(method: string): boolean {
|
|
625
|
+
const sync = this.getDocumentSyncOptions();
|
|
626
|
+
if (method === "textDocument/didOpen" || method === "textDocument/didClose") {
|
|
627
|
+
return sync.openClose;
|
|
628
|
+
}
|
|
629
|
+
if (method === "textDocument/didChange") return sync.change !== TextDocumentSyncKind.None;
|
|
630
|
+
if (method === "textDocument/didSave") return sync.save;
|
|
631
|
+
return true;
|
|
281
632
|
}
|
|
282
633
|
|
|
283
634
|
/** Whether the server is currently not indexing and ready to serve queries. */
|
|
@@ -374,12 +725,14 @@ export class LspClient {
|
|
|
374
725
|
throw new Error(`${this.name}: client shutdown during initialize`);
|
|
375
726
|
}
|
|
376
727
|
this.capabilities = result.capabilities;
|
|
728
|
+
this.normalizedDocumentSync = normalizeDocumentSync(result.capabilities.textDocumentSync);
|
|
377
729
|
await this.rpc.sendNotification("initialized", {});
|
|
378
730
|
if (this._status !== "initializing") {
|
|
379
731
|
throw new Error(`${this.name}: client shutdown during initialize`);
|
|
380
732
|
}
|
|
381
733
|
this._status = "running";
|
|
382
734
|
this.publishLifecycle("startup");
|
|
735
|
+
this.sendInitialConfigurationChange();
|
|
383
736
|
|
|
384
737
|
this.armNoProgressTimer();
|
|
385
738
|
} catch (err) {
|
|
@@ -686,6 +1039,11 @@ export class LspClient {
|
|
|
686
1039
|
} satisfies DidChangeWatchedFilesParams);
|
|
687
1040
|
}
|
|
688
1041
|
|
|
1042
|
+
/** Synchronize a tracked file and notify the server after a real disk write. */
|
|
1043
|
+
noteWorkspaceWrite(filePath: string): void {
|
|
1044
|
+
this.diagnostics.noteWorkspaceWrite(filePath);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
689
1047
|
/** Re-read open documents, then collect pull diagnostics or wait for push diagnostics. */
|
|
690
1048
|
async refreshOpenDiagnostics(
|
|
691
1049
|
options: { maxWaitMs?: number; quietMs?: number } & CodeRequestControl = {},
|
|
@@ -775,11 +1133,53 @@ export class LspClient {
|
|
|
775
1133
|
newName: string,
|
|
776
1134
|
control?: CodeRequestControl,
|
|
777
1135
|
): Promise<WorkspaceEdit | null> {
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
1136
|
+
const result = await this.renameDetailed(filePath, position, newName, control);
|
|
1137
|
+
return result.kind === "completed" ? result.data : null;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/** Run rename with capability gating, optional preparation, and freshness checks. */
|
|
1141
|
+
async renameDetailed(
|
|
1142
|
+
filePath: string,
|
|
1143
|
+
position: Position,
|
|
1144
|
+
newName: string,
|
|
1145
|
+
control?: CodeRequestControl,
|
|
1146
|
+
): Promise<CodeQueryResult<WorkspaceEdit | null>> {
|
|
1147
|
+
if (!this.supportsRename) {
|
|
1148
|
+
return unavailableCodeQuery("The server does not advertise textDocument/rename.");
|
|
1149
|
+
}
|
|
1150
|
+
if (
|
|
1151
|
+
typeof filePath !== "string" ||
|
|
1152
|
+
filePath.length === 0 ||
|
|
1153
|
+
!isPosition(position) ||
|
|
1154
|
+
typeof newName !== "string" ||
|
|
1155
|
+
newName.trim().length === 0
|
|
1156
|
+
) {
|
|
1157
|
+
return unavailableCodeQuery(
|
|
1158
|
+
"Rename requires a valid file, position, and non-empty new name.",
|
|
1159
|
+
);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
const params = {
|
|
1163
|
+
textDocument: { uri: fileToUri(filePath) },
|
|
1164
|
+
position,
|
|
1165
|
+
newName,
|
|
1166
|
+
};
|
|
1167
|
+
return this.runRefactorTransaction("textDocument/rename", control, async (transaction) => {
|
|
1168
|
+
if (this.supportsPrepareRename) {
|
|
1169
|
+
const prepared = await transaction.rpc.sendRequest(
|
|
1170
|
+
"textDocument/prepareRename",
|
|
1171
|
+
{ textDocument: { uri: fileToUri(filePath) }, position },
|
|
1172
|
+
control,
|
|
1173
|
+
);
|
|
1174
|
+
const preparationError = validatePrepareRenameResult(prepared, position);
|
|
1175
|
+
if (preparationError) throw new Error(preparationError);
|
|
1176
|
+
await transaction.assertCurrent();
|
|
1177
|
+
}
|
|
1178
|
+
return (await transaction.rpc.sendRequest("textDocument/rename", params, control)) as
|
|
1179
|
+
| WorkspaceEdit
|
|
1180
|
+
| null
|
|
1181
|
+
| undefined;
|
|
1182
|
+
});
|
|
783
1183
|
}
|
|
784
1184
|
|
|
785
1185
|
async codeActions(
|
|
@@ -788,11 +1188,46 @@ export class LspClient {
|
|
|
788
1188
|
context: CodeActionContext,
|
|
789
1189
|
control?: CodeRequestControl,
|
|
790
1190
|
): Promise<CodeAction[] | null> {
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
1191
|
+
const result = await this.codeActionsDetailed(filePath, range, context, control);
|
|
1192
|
+
return result.kind === "completed" ? result.data : null;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/** Run code actions with explicit context, standard resolution, and freshness checks. */
|
|
1196
|
+
async codeActionsDetailed(
|
|
1197
|
+
filePath: string,
|
|
1198
|
+
range: Range,
|
|
1199
|
+
context: CodeActionContext,
|
|
1200
|
+
control?: CodeRequestControl,
|
|
1201
|
+
): Promise<CodeQueryResult<CodeAction[] | null>> {
|
|
1202
|
+
if (!this.supportsCodeActions) {
|
|
1203
|
+
return unavailableCodeQuery("The server does not advertise textDocument/codeAction.");
|
|
1204
|
+
}
|
|
1205
|
+
if (typeof filePath !== "string" || filePath.length === 0 || !isRange(range)) {
|
|
1206
|
+
return unavailableCodeQuery("Code actions require a valid file and range.");
|
|
1207
|
+
}
|
|
1208
|
+
if (!isCodeActionContext(context)) {
|
|
1209
|
+
return unavailableCodeQuery("Code actions require a valid context.");
|
|
1210
|
+
}
|
|
1211
|
+
if (context.only && !this.supportsCodeActionKinds(context.only)) {
|
|
1212
|
+
return unavailableCodeQuery("The server does not advertise the requested code-action kind.");
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
const requestContext: CodeActionContext = {
|
|
1216
|
+
...context,
|
|
1217
|
+
triggerKind: context.triggerKind ?? CodeActionTriggerKind.Invoked,
|
|
1218
|
+
...(context.only ? { only: [...context.only] } : {}),
|
|
1219
|
+
};
|
|
1220
|
+
return this.runRefactorTransaction("textDocument/codeAction", control, async (transaction) => {
|
|
1221
|
+
const raw = await transaction.rpc.sendRequest(
|
|
1222
|
+
"textDocument/codeAction",
|
|
1223
|
+
{ textDocument: { uri: fileToUri(filePath) }, range, context: requestContext },
|
|
1224
|
+
control,
|
|
1225
|
+
);
|
|
1226
|
+
if (raw === null || raw === undefined) return null;
|
|
1227
|
+
if (!Array.isArray(raw)) throw new Error("LSP server returned malformed code actions.");
|
|
1228
|
+
|
|
1229
|
+
return this.resolveCodeActions(raw, transaction, control, requestContext.only);
|
|
1230
|
+
});
|
|
796
1231
|
}
|
|
797
1232
|
|
|
798
1233
|
async implementation(
|
|
@@ -811,6 +1246,137 @@ export class LspClient {
|
|
|
811
1246
|
}
|
|
812
1247
|
|
|
813
1248
|
// ── Private ─────────────────────────────────────────────────────────
|
|
1249
|
+
private async resolveCodeActions(
|
|
1250
|
+
raw: unknown[],
|
|
1251
|
+
transaction: RefactorTransaction,
|
|
1252
|
+
control: CodeRequestControl | undefined,
|
|
1253
|
+
only: readonly string[] | undefined,
|
|
1254
|
+
): Promise<CodeAction[]> {
|
|
1255
|
+
const candidates = raw.map((value) => {
|
|
1256
|
+
const candidate = normalizeCodeActionResponseEntry(value);
|
|
1257
|
+
if (!candidate)
|
|
1258
|
+
throw new Error("LSP server returned a malformed code action response entry.");
|
|
1259
|
+
return candidate;
|
|
1260
|
+
});
|
|
1261
|
+
const actions: CodeAction[] = [];
|
|
1262
|
+
for (const action of candidates) {
|
|
1263
|
+
const lazyAction = isLazyCodeAction(action) ? action : undefined;
|
|
1264
|
+
if (
|
|
1265
|
+
!this.supportsCodeActionResolve ||
|
|
1266
|
+
!lazyAction ||
|
|
1267
|
+
(only !== undefined &&
|
|
1268
|
+
!only.some((filter) => codeActionKindMatchesFilter(lazyAction.kind, filter)))
|
|
1269
|
+
) {
|
|
1270
|
+
actions.push(action);
|
|
1271
|
+
continue;
|
|
1272
|
+
}
|
|
1273
|
+
await transaction.assertCurrent();
|
|
1274
|
+
const resolved = await transaction.rpc.sendRequest("codeAction/resolve", lazyAction, control);
|
|
1275
|
+
await transaction.assertCurrent();
|
|
1276
|
+
if (!isRecord(resolved)) {
|
|
1277
|
+
throw new Error("LSP server returned a malformed resolved code action.");
|
|
1278
|
+
}
|
|
1279
|
+
const resolvedAction = normalizeCodeActionResponseEntry({ ...lazyAction, ...resolved });
|
|
1280
|
+
if (!resolvedAction) {
|
|
1281
|
+
throw new Error("LSP server returned a malformed resolved code action.");
|
|
1282
|
+
}
|
|
1283
|
+
actions.push(resolvedAction);
|
|
1284
|
+
}
|
|
1285
|
+
return actions;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
private async runRefactorTransaction<T>(
|
|
1289
|
+
method: string,
|
|
1290
|
+
control: CodeRequestControl | undefined,
|
|
1291
|
+
operation: (transaction: RefactorTransaction) => Promise<T | null | undefined>,
|
|
1292
|
+
): Promise<CodeQueryResult<T | null>> {
|
|
1293
|
+
throwIfCodeRequestInterrupted(control);
|
|
1294
|
+
if (!this.rpc || this._status !== "running") {
|
|
1295
|
+
return unavailableCodeQuery(
|
|
1296
|
+
`LSP request ${method} is unavailable because the client is not running.`,
|
|
1297
|
+
);
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
const retryGuard = new SemanticInputRequestRetryGuard((revision) =>
|
|
1301
|
+
this.diagnostics.getSemanticInputChangeSince(revision),
|
|
1302
|
+
);
|
|
1303
|
+
for (;;) {
|
|
1304
|
+
const attempt: SemanticRequestAttemptState = { inputRevision: undefined };
|
|
1305
|
+
try {
|
|
1306
|
+
retryGuard.assertCurrent();
|
|
1307
|
+
const data = await this.runRefactorTransactionAttempt({
|
|
1308
|
+
method,
|
|
1309
|
+
control,
|
|
1310
|
+
operation,
|
|
1311
|
+
retryGuard,
|
|
1312
|
+
attempt,
|
|
1313
|
+
});
|
|
1314
|
+
if (retryGuard.hasRetried) {
|
|
1315
|
+
this.recordSemanticRetry({ method, control, retryGuard, outcome: "completed" });
|
|
1316
|
+
}
|
|
1317
|
+
return completedCodeQuery(data);
|
|
1318
|
+
} catch (error) {
|
|
1319
|
+
const failure = this.handleRefactorTransactionFailure<T>({
|
|
1320
|
+
method,
|
|
1321
|
+
control,
|
|
1322
|
+
retryGuard,
|
|
1323
|
+
attempt,
|
|
1324
|
+
error,
|
|
1325
|
+
});
|
|
1326
|
+
if (failure) return failure;
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
private handleRefactorTransactionFailure<T>(options: {
|
|
1332
|
+
method: string;
|
|
1333
|
+
control?: CodeRequestControl;
|
|
1334
|
+
retryGuard: SemanticInputRequestRetryGuard;
|
|
1335
|
+
attempt: SemanticRequestAttemptState;
|
|
1336
|
+
error: unknown;
|
|
1337
|
+
}): CodeQueryResult<T | null> | null {
|
|
1338
|
+
const { method, control, retryGuard, attempt, error } = options;
|
|
1339
|
+
try {
|
|
1340
|
+
this.prepareSemanticRetry({ method, control, retryGuard, attempt, error });
|
|
1341
|
+
return null;
|
|
1342
|
+
} catch (retryError) {
|
|
1343
|
+
if (isCodeRequestInterruption(retryError, control)) throw retryError;
|
|
1344
|
+
const detail = retryError instanceof Error ? retryError.message : String(retryError);
|
|
1345
|
+
return unavailableCodeQuery(`LSP request ${method} failed: ${detail}`);
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/** Run the complete prepare/request/resolve transaction for one input snapshot. */
|
|
1350
|
+
private async runRefactorTransactionAttempt<T>(options: {
|
|
1351
|
+
method: string;
|
|
1352
|
+
control?: CodeRequestControl;
|
|
1353
|
+
operation: (transaction: RefactorTransaction) => Promise<T | null | undefined>;
|
|
1354
|
+
retryGuard: SemanticInputRequestRetryGuard;
|
|
1355
|
+
attempt: SemanticRequestAttemptState;
|
|
1356
|
+
}): Promise<T | null> {
|
|
1357
|
+
const { method, control, operation, retryGuard, attempt } = options;
|
|
1358
|
+
throwIfCodeRequestInterrupted(control);
|
|
1359
|
+
retryGuard.assertCurrent();
|
|
1360
|
+
await this.getReady(control);
|
|
1361
|
+
const inputSnapshot = await this.diagnostics.synchronizeSemanticInputs(control);
|
|
1362
|
+
attempt.inputRevision = inputSnapshot.revision;
|
|
1363
|
+
throwIfCodeRequestInterrupted(control);
|
|
1364
|
+
retryGuard.assertCurrent();
|
|
1365
|
+
const rpc = this.rpc;
|
|
1366
|
+
if (!rpc || this._status !== "running") {
|
|
1367
|
+
throw new Error(`LSP request ${method} is unavailable because the client is not running.`);
|
|
1368
|
+
}
|
|
1369
|
+
const data = await operation({
|
|
1370
|
+
rpc,
|
|
1371
|
+
assertCurrent: async () => {
|
|
1372
|
+
retryGuard.assertCurrent();
|
|
1373
|
+
await this.diagnostics.assertSemanticInputsCurrent(inputSnapshot, control);
|
|
1374
|
+
},
|
|
1375
|
+
});
|
|
1376
|
+
await this.diagnostics.assertSemanticInputsCurrent(inputSnapshot, control);
|
|
1377
|
+
return data ?? null;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
814
1380
|
private async query<T>(
|
|
815
1381
|
method: string,
|
|
816
1382
|
params: unknown,
|
|
@@ -955,15 +1521,6 @@ export class LspClient {
|
|
|
955
1521
|
return data ?? null;
|
|
956
1522
|
}
|
|
957
1523
|
|
|
958
|
-
private async request<T>(
|
|
959
|
-
method: string,
|
|
960
|
-
params: unknown,
|
|
961
|
-
control?: CodeRequestControl,
|
|
962
|
-
): Promise<T | null> {
|
|
963
|
-
const result = await this.query<T>(method, params, control);
|
|
964
|
-
return result.kind === "unavailable" ? null : result.data;
|
|
965
|
-
}
|
|
966
|
-
|
|
967
1524
|
private handleServerRequest(method: string, params: unknown): unknown {
|
|
968
1525
|
switch (method) {
|
|
969
1526
|
case "workspace/configuration":
|
|
@@ -1038,11 +1595,30 @@ export class LspClient {
|
|
|
1038
1595
|
});
|
|
1039
1596
|
}
|
|
1040
1597
|
|
|
1598
|
+
/** Send the initial settings snapshot after the initialize handshake. */
|
|
1599
|
+
private sendInitialConfigurationChange(): void {
|
|
1600
|
+
if (this.settingsSnapshot === undefined || !this.rpc || this._status !== "running") return;
|
|
1601
|
+
void this.rpc.sendNotification("workspace/didChangeConfiguration", {
|
|
1602
|
+
settings: this.settingsSnapshot,
|
|
1603
|
+
});
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1041
1606
|
private buildWorkspaceConfigurationResult(params: unknown): unknown[] {
|
|
1042
|
-
if (!params ||
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
return items.map(() =>
|
|
1607
|
+
if (!isRecord(params) || !Object.hasOwn(params, "items") || !Array.isArray(params.items)) {
|
|
1608
|
+
return [];
|
|
1609
|
+
}
|
|
1610
|
+
return params.items.map((item) => this.buildWorkspaceConfigurationItem(item));
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
private buildWorkspaceConfigurationItem(item: unknown): JsonValue | null {
|
|
1614
|
+
if (!isRecord(item)) return null;
|
|
1615
|
+
const scopeUri = Object.hasOwn(item, "scopeUri") ? item.scopeUri : undefined;
|
|
1616
|
+
if (!isApplicableConfigurationScope(scopeUri, this.root)) return null;
|
|
1617
|
+
const section = Object.hasOwn(item, "section") ? item.section : undefined;
|
|
1618
|
+
if (this.settingsSnapshot === undefined) {
|
|
1619
|
+
return section === undefined || section === "" ? EMPTY_SETTINGS : null;
|
|
1620
|
+
}
|
|
1621
|
+
return lookupSettings(this.settingsSnapshot, section) ?? null;
|
|
1046
1622
|
}
|
|
1047
1623
|
|
|
1048
1624
|
/**
|
|
@@ -1130,12 +1706,12 @@ export class LspClient {
|
|
|
1130
1706
|
private pendingReady(): Promise<void> {
|
|
1131
1707
|
if (this._isReady) return Promise.resolve();
|
|
1132
1708
|
if (this._readyPromise !== null) return this._readyPromise;
|
|
1133
|
-
// If no progress timer was ever armed and no tokens are tracked,
|
|
1709
|
+
// If no progress timer was ever armed and no active tokens are tracked,
|
|
1134
1710
|
// the server was either never started with a real process (test scenario)
|
|
1135
1711
|
// or completed before any progress tracking began. Resolve immediately
|
|
1136
1712
|
// only when the client is still running — a crash or shutdown clears
|
|
1137
1713
|
// both fields but must not report the client as ready.
|
|
1138
|
-
if (this.noProgressTimer === null && this.
|
|
1714
|
+
if (this.noProgressTimer === null && !this.hasActiveTokens()) {
|
|
1139
1715
|
if (this._status === "running") {
|
|
1140
1716
|
this._isReady = true;
|
|
1141
1717
|
this.everReady = true;
|