@mrclrchtr/supi-lsp 10.0.0 → 11.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 +88 -2
- package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/LICENSE +21 -0
- package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/README.md +743 -0
- package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/index.js +17 -0
- package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/lib/constants.js +184 -0
- package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/lib/parse.js +1425 -0
- package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/lib/picomatch.js +361 -0
- package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/lib/scan.js +403 -0
- package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/lib/utils.js +72 -0
- package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/package.json +83 -0
- package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/posix.js +3 -0
- package/node_modules/@mrclrchtr/supi-code-runtime/package.json +4 -1
- package/node_modules/@mrclrchtr/supi-code-runtime/src/api.ts +11 -0
- package/node_modules/@mrclrchtr/supi-code-runtime/src/file-associations.ts +190 -0
- package/node_modules/@mrclrchtr/supi-code-runtime/src/types.ts +22 -0
- package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/package.json +3 -3
- package/src/api.ts +1 -0
- package/src/client/client-diagnostic-host.ts +3 -0
- package/src/client/client-diagnostics.ts +6 -4
- package/src/client/client-request-enrollment.ts +66 -22
- package/src/client/client-semantic-input-barrier.ts +37 -5
- package/src/client/client.ts +136 -43
- package/src/manager/manager.ts +261 -22
- package/src/provider/refactor-planning.ts +13 -0
- package/src/provider/semantic-edit-discovery.ts +151 -0
- package/src/provider/semantic-edit-normalizer.ts +97 -129
- package/src/provider/semantic-edit-protocol.ts +132 -0
- package/src/session/runtime-controller.ts +8 -1
- package/src/session/runtime-registry.ts +7 -20
- package/src/session/runtime-rename.ts +172 -0
- package/src/session/workspace-lsp-runtime.ts +14 -0
- package/src/utils.ts +12 -11
|
@@ -1,9 +1,17 @@
|
|
|
1
|
-
import { fileURLToPath } from "node:url";
|
|
2
1
|
import type {
|
|
3
2
|
DocumentEditPrecondition,
|
|
4
3
|
FileEdit,
|
|
5
4
|
RefactorResult,
|
|
6
5
|
} from "@mrclrchtr/supi-code-runtime/api";
|
|
6
|
+
import {
|
|
7
|
+
asRecord,
|
|
8
|
+
getResourceOperationKind,
|
|
9
|
+
hasNoInheritedAllowedKeys,
|
|
10
|
+
hasOnlyKeys,
|
|
11
|
+
normalizeTextEdits,
|
|
12
|
+
parseTextDocumentEdit,
|
|
13
|
+
toFilePath,
|
|
14
|
+
} from "./semantic-edit-protocol.ts";
|
|
7
15
|
|
|
8
16
|
/** A complete semantic server response that can contain precise text edits. */
|
|
9
17
|
export type SemanticEditResponse =
|
|
@@ -11,8 +19,15 @@ export type SemanticEditResponse =
|
|
|
11
19
|
| { readonly kind: "code-action"; readonly action: unknown };
|
|
12
20
|
|
|
13
21
|
/** Provider route and client document state used to establish mutation preconditions. */
|
|
22
|
+
export interface SemanticDocumentSnapshotEvidence {
|
|
23
|
+
readonly version: number;
|
|
24
|
+
readonly contentFingerprint: string;
|
|
25
|
+
readonly diskContentFingerprint: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
14
28
|
export interface SemanticEditNormalizationContext {
|
|
15
29
|
getOpenDocumentVersion(file: string): number | null;
|
|
30
|
+
getSynchronizedDocumentSnapshot?(file: string): SemanticDocumentSnapshotEvidence | null;
|
|
16
31
|
authorizedMutationRoots: readonly string[];
|
|
17
32
|
}
|
|
18
33
|
|
|
@@ -133,20 +148,46 @@ function normalizeChanges(
|
|
|
133
148
|
}
|
|
134
149
|
|
|
135
150
|
const edits: FileEdit[] = [];
|
|
151
|
+
const documentPreconditions: DocumentEditPrecondition[] = [];
|
|
136
152
|
for (const uri of Object.keys(changes).sort((left, right) => left.localeCompare(right))) {
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
153
|
+
const result = normalizeChangesEntry(uri, changes[uri], context);
|
|
154
|
+
if (result.kind === "unavailable") return result;
|
|
155
|
+
edits.push(...result.edits);
|
|
156
|
+
if (result.precondition) documentPreconditions.push(result.precondition);
|
|
157
|
+
}
|
|
158
|
+
return preciseOrEmpty(
|
|
159
|
+
edits,
|
|
160
|
+
context,
|
|
161
|
+
documentPreconditions.length > 0 ? documentPreconditions : undefined,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function normalizeChangesEntry(
|
|
166
|
+
uri: string,
|
|
167
|
+
value: unknown,
|
|
168
|
+
context: SemanticEditNormalizationContext,
|
|
169
|
+
):
|
|
170
|
+
| { kind: "precise"; edits: FileEdit[]; precondition?: DocumentEditPrecondition }
|
|
171
|
+
| { kind: "unavailable"; reason: string } {
|
|
172
|
+
const file = toFilePath(uri);
|
|
173
|
+
if (!file) {
|
|
174
|
+
return { kind: "unavailable", reason: `Workspace edit URI "${uri}" is not a file URI.` };
|
|
148
175
|
}
|
|
149
|
-
|
|
176
|
+
if (!Array.isArray(value)) {
|
|
177
|
+
return { kind: "unavailable", reason: "Workspace edit contains malformed changes." };
|
|
178
|
+
}
|
|
179
|
+
const normalized = normalizeTextEdits(file, value);
|
|
180
|
+
if (normalized.kind === "unavailable") return normalized;
|
|
181
|
+
if (!context.getSynchronizedDocumentSnapshot) {
|
|
182
|
+
return { kind: "precise", edits: normalized.edits };
|
|
183
|
+
}
|
|
184
|
+
const precondition = establishSynchronizedContentPrecondition(file, context, false);
|
|
185
|
+
if (typeof precondition === "string") return { kind: "unavailable", reason: precondition };
|
|
186
|
+
return {
|
|
187
|
+
kind: "precise",
|
|
188
|
+
edits: normalized.edits,
|
|
189
|
+
...(precondition ? { precondition } : {}),
|
|
190
|
+
};
|
|
150
191
|
}
|
|
151
192
|
|
|
152
193
|
function normalizeDocumentChanges(
|
|
@@ -204,37 +245,6 @@ function normalizeDocumentChange(
|
|
|
204
245
|
: { kind: "precise", edits: normalized.edits, precondition };
|
|
205
246
|
}
|
|
206
247
|
|
|
207
|
-
function parseTextDocumentEdit(edit: Record<string, unknown> | null): {
|
|
208
|
-
edit: Record<"textDocument" | "edits", unknown> & { edits: unknown[] };
|
|
209
|
-
textDocument: Record<string, unknown>;
|
|
210
|
-
} | null {
|
|
211
|
-
const textDocument = asRecord(edit?.textDocument);
|
|
212
|
-
if (
|
|
213
|
-
!edit ||
|
|
214
|
-
!Object.hasOwn(edit, "textDocument") ||
|
|
215
|
-
!Object.hasOwn(edit, "edits") ||
|
|
216
|
-
!hasOnlyKeys(edit, ["textDocument", "edits"]) ||
|
|
217
|
-
!hasNoInheritedAllowedKeys(edit, ["textDocument", "edits"]) ||
|
|
218
|
-
!textDocument ||
|
|
219
|
-
!Object.hasOwn(textDocument, "uri") ||
|
|
220
|
-
!hasOnlyKeys(textDocument, ["uri", "version"]) ||
|
|
221
|
-
!hasNoInheritedAllowedKeys(textDocument, ["uri", "version"]) ||
|
|
222
|
-
!Array.isArray(edit.edits)
|
|
223
|
-
) {
|
|
224
|
-
return null;
|
|
225
|
-
}
|
|
226
|
-
return {
|
|
227
|
-
edit: edit as Record<"textDocument" | "edits", unknown> & { edits: unknown[] },
|
|
228
|
-
textDocument,
|
|
229
|
-
};
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
function getResourceOperationKind(value: Record<string, unknown> | null): string | null {
|
|
233
|
-
return value && (value.kind === "create" || value.kind === "rename" || value.kind === "delete")
|
|
234
|
-
? value.kind
|
|
235
|
-
: null;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
248
|
function establishPrecondition(
|
|
239
249
|
file: string,
|
|
240
250
|
version: unknown,
|
|
@@ -242,9 +252,13 @@ function establishPrecondition(
|
|
|
242
252
|
): DocumentEditPrecondition | string {
|
|
243
253
|
const openVersion = context.getOpenDocumentVersion(file);
|
|
244
254
|
if (version === null) {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
255
|
+
if (openVersion === null) {
|
|
256
|
+
return context.getSynchronizedDocumentSnapshot?.(file)
|
|
257
|
+
? `Document change for "${file}" is no longer open after its synchronized snapshot.`
|
|
258
|
+
: { file, kind: "disk-content" };
|
|
259
|
+
}
|
|
260
|
+
const precondition = establishSynchronizedContentPrecondition(file, context, true, openVersion);
|
|
261
|
+
return precondition ?? `Document change for "${file}" has no verified content precondition.`;
|
|
248
262
|
}
|
|
249
263
|
if (!Number.isInteger(version) || (version as number) < 0) {
|
|
250
264
|
return `Document change for "${file}" has an invalid version.`;
|
|
@@ -255,46 +269,48 @@ function establishPrecondition(
|
|
|
255
269
|
if (openVersion !== version) {
|
|
256
270
|
return `Document change version for "${file}" does not match the open document.`;
|
|
257
271
|
}
|
|
272
|
+
if (context.getSynchronizedDocumentSnapshot?.(file)) {
|
|
273
|
+
return (
|
|
274
|
+
establishSynchronizedContentPrecondition(file, context, true, version as number) ??
|
|
275
|
+
`Document change for "${file}" has no verified content precondition.`
|
|
276
|
+
);
|
|
277
|
+
}
|
|
258
278
|
return { file, kind: "open-document-version", version: version as number };
|
|
259
279
|
}
|
|
260
280
|
|
|
261
|
-
function
|
|
281
|
+
function establishSynchronizedContentPrecondition(
|
|
262
282
|
file: string,
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
return { kind: "unavailable", reason: "Workspace edit contains a text edit annotation." };
|
|
273
|
-
}
|
|
274
|
-
const range = asRecord(textEdit?.range);
|
|
275
|
-
const start = asPosition(range?.start);
|
|
276
|
-
const end = asPosition(range?.end);
|
|
277
|
-
if (
|
|
278
|
-
!textEdit ||
|
|
279
|
-
!Object.hasOwn(textEdit, "range") ||
|
|
280
|
-
!Object.hasOwn(textEdit, "newText") ||
|
|
281
|
-
!hasOnlyKeys(textEdit, ["range", "newText"]) ||
|
|
282
|
-
!hasNoInheritedAllowedKeys(textEdit, ["range", "newText", "snippet", "annotationId"]) ||
|
|
283
|
-
!range ||
|
|
284
|
-
!Object.hasOwn(range, "start") ||
|
|
285
|
-
!Object.hasOwn(range, "end") ||
|
|
286
|
-
!hasOnlyKeys(range, ["start", "end"]) ||
|
|
287
|
-
!hasNoInheritedAllowedKeys(range, ["start", "end"]) ||
|
|
288
|
-
!start ||
|
|
289
|
-
!end ||
|
|
290
|
-
isBefore(end, start) ||
|
|
291
|
-
typeof textEdit.newText !== "string"
|
|
292
|
-
) {
|
|
293
|
-
return { kind: "unavailable", reason: "Workspace edit contains a malformed text edit." };
|
|
283
|
+
context: SemanticEditNormalizationContext,
|
|
284
|
+
required: boolean,
|
|
285
|
+
expectedVersion?: number,
|
|
286
|
+
): DocumentEditPrecondition | null | string {
|
|
287
|
+
const openVersion = context.getOpenDocumentVersion(file);
|
|
288
|
+
const snapshot = context.getSynchronizedDocumentSnapshot?.(file);
|
|
289
|
+
if (openVersion === null) {
|
|
290
|
+
if (required || snapshot) {
|
|
291
|
+
return `Document change for "${file}" requires verified synchronized content, but the document is closed.`;
|
|
294
292
|
}
|
|
295
|
-
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
if (!snapshot) {
|
|
296
|
+
return required
|
|
297
|
+
? `Document change for "${file}" requires a verified synchronized content snapshot, but the document is open without one.`
|
|
298
|
+
: `Open document "${file}" has no verified synchronized content snapshot.`;
|
|
296
299
|
}
|
|
297
|
-
|
|
300
|
+
if (
|
|
301
|
+
snapshot.version !== openVersion ||
|
|
302
|
+
(expectedVersion !== undefined && snapshot.version !== expectedVersion) ||
|
|
303
|
+
snapshot.contentFingerprint.length === 0 ||
|
|
304
|
+
snapshot.contentFingerprint !== snapshot.diskContentFingerprint
|
|
305
|
+
) {
|
|
306
|
+
return `Document change for "${file}" does not match its synchronized content snapshot.`;
|
|
307
|
+
}
|
|
308
|
+
return {
|
|
309
|
+
file,
|
|
310
|
+
kind: "open-document-content",
|
|
311
|
+
version: snapshot.version,
|
|
312
|
+
fingerprint: snapshot.contentFingerprint,
|
|
313
|
+
};
|
|
298
314
|
}
|
|
299
315
|
|
|
300
316
|
function preciseOrEmpty(
|
|
@@ -316,51 +332,3 @@ function preciseOrEmpty(
|
|
|
316
332
|
? { kind: "precise", edits: { edits, documentPreconditions }, authorizedMutationRoots }
|
|
317
333
|
: { kind: "precise", edits: { edits }, authorizedMutationRoots };
|
|
318
334
|
}
|
|
319
|
-
|
|
320
|
-
function hasOnlyKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean {
|
|
321
|
-
const allowedKeys = new Set(allowed);
|
|
322
|
-
return Object.keys(value).every((key) => allowedKeys.has(key));
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
function hasNoInheritedAllowedKeys(
|
|
326
|
-
value: Record<string, unknown>,
|
|
327
|
-
allowed: readonly string[],
|
|
328
|
-
): boolean {
|
|
329
|
-
return allowed.every((key) => !(key in value) || Object.hasOwn(value, key));
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
333
|
-
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
334
|
-
? (value as Record<string, unknown>)
|
|
335
|
-
: null;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
function asPosition(value: unknown): { line: number; character: number } | null {
|
|
339
|
-
const position = asRecord(value);
|
|
340
|
-
return position &&
|
|
341
|
-
Object.hasOwn(position, "line") &&
|
|
342
|
-
Object.hasOwn(position, "character") &&
|
|
343
|
-
hasOnlyKeys(position, ["line", "character"]) &&
|
|
344
|
-
Number.isInteger(position.line) &&
|
|
345
|
-
(position.line as number) >= 0 &&
|
|
346
|
-
Number.isInteger(position.character) &&
|
|
347
|
-
(position.character as number) >= 0
|
|
348
|
-
? { line: position.line as number, character: position.character as number }
|
|
349
|
-
: null;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
function isBefore(
|
|
353
|
-
left: { line: number; character: number },
|
|
354
|
-
right: { line: number; character: number },
|
|
355
|
-
): boolean {
|
|
356
|
-
return left.line < right.line || (left.line === right.line && left.character < right.character);
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
function toFilePath(uri: string): string | null {
|
|
360
|
-
try {
|
|
361
|
-
const parsed = new URL(uri);
|
|
362
|
-
return parsed.protocol === "file:" ? fileURLToPath(parsed) : null;
|
|
363
|
-
} catch {
|
|
364
|
-
return null;
|
|
365
|
-
}
|
|
366
|
-
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import type { FileEdit } from "@mrclrchtr/supi-code-runtime/api";
|
|
3
|
+
|
|
4
|
+
export interface ParsedTextDocumentEdit {
|
|
5
|
+
readonly edit: Record<"textDocument" | "edits", unknown> & { edits: unknown[] };
|
|
6
|
+
readonly textDocument: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type NormalizedTextEdits =
|
|
10
|
+
| { readonly kind: "precise"; readonly edits: FileEdit[] }
|
|
11
|
+
| { readonly kind: "unavailable"; readonly reason: string };
|
|
12
|
+
|
|
13
|
+
/** Convert an object-shaped protocol value to a record. */
|
|
14
|
+
export function asRecord(value: unknown): Record<string, unknown> | null {
|
|
15
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
16
|
+
? (value as Record<string, unknown>)
|
|
17
|
+
: null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Check that a protocol record has no unknown own keys. */
|
|
21
|
+
export function hasOnlyKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean {
|
|
22
|
+
const allowedKeys = new Set(allowed);
|
|
23
|
+
return Object.keys(value).every((key) => allowedKeys.has(key));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Reject inherited values for keys that the protocol record may use. */
|
|
27
|
+
export function hasNoInheritedAllowedKeys(
|
|
28
|
+
value: Record<string, unknown>,
|
|
29
|
+
allowed: readonly string[],
|
|
30
|
+
): boolean {
|
|
31
|
+
return allowed.every((key) => !(key in value) || Object.hasOwn(value, key));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Return the resource-operation kind when a document change is not a text edit. */
|
|
35
|
+
export function getResourceOperationKind(value: Record<string, unknown> | null): string | null {
|
|
36
|
+
return value && (value.kind === "create" || value.kind === "rename" || value.kind === "delete")
|
|
37
|
+
? value.kind
|
|
38
|
+
: null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Parse the strict text-document edit shape without establishing its version. */
|
|
42
|
+
export function parseTextDocumentEdit(
|
|
43
|
+
edit: Record<string, unknown> | null,
|
|
44
|
+
): ParsedTextDocumentEdit | null {
|
|
45
|
+
const textDocument = asRecord(edit?.textDocument);
|
|
46
|
+
if (
|
|
47
|
+
!edit ||
|
|
48
|
+
!Object.hasOwn(edit, "textDocument") ||
|
|
49
|
+
!Object.hasOwn(edit, "edits") ||
|
|
50
|
+
!hasOnlyKeys(edit, ["textDocument", "edits"]) ||
|
|
51
|
+
!hasNoInheritedAllowedKeys(edit, ["textDocument", "edits"]) ||
|
|
52
|
+
!textDocument ||
|
|
53
|
+
!Object.hasOwn(textDocument, "uri") ||
|
|
54
|
+
!hasOnlyKeys(textDocument, ["uri", "version"]) ||
|
|
55
|
+
!hasNoInheritedAllowedKeys(textDocument, ["uri", "version"]) ||
|
|
56
|
+
!Array.isArray(edit.edits)
|
|
57
|
+
) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
edit: edit as Record<"textDocument" | "edits", unknown> & { edits: unknown[] },
|
|
62
|
+
textDocument,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Validate and normalize a complete text-edit collection. */
|
|
67
|
+
export function normalizeTextEdits(file: string, values: unknown[]): NormalizedTextEdits {
|
|
68
|
+
const edits: FileEdit[] = [];
|
|
69
|
+
for (const value of values) {
|
|
70
|
+
const textEdit = asRecord(value);
|
|
71
|
+
if (textEdit && Object.hasOwn(textEdit, "snippet")) {
|
|
72
|
+
return { kind: "unavailable", reason: "Workspace edit contains a snippet text edit." };
|
|
73
|
+
}
|
|
74
|
+
if (textEdit && Object.hasOwn(textEdit, "annotationId")) {
|
|
75
|
+
return { kind: "unavailable", reason: "Workspace edit contains a text edit annotation." };
|
|
76
|
+
}
|
|
77
|
+
const range = asRecord(textEdit?.range);
|
|
78
|
+
const start = asPosition(range?.start);
|
|
79
|
+
const end = asPosition(range?.end);
|
|
80
|
+
if (
|
|
81
|
+
!textEdit ||
|
|
82
|
+
!Object.hasOwn(textEdit, "range") ||
|
|
83
|
+
!Object.hasOwn(textEdit, "newText") ||
|
|
84
|
+
!hasOnlyKeys(textEdit, ["range", "newText"]) ||
|
|
85
|
+
!hasNoInheritedAllowedKeys(textEdit, ["range", "newText", "snippet", "annotationId"]) ||
|
|
86
|
+
!range ||
|
|
87
|
+
!Object.hasOwn(range, "start") ||
|
|
88
|
+
!Object.hasOwn(range, "end") ||
|
|
89
|
+
!hasOnlyKeys(range, ["start", "end"]) ||
|
|
90
|
+
!hasNoInheritedAllowedKeys(range, ["start", "end"]) ||
|
|
91
|
+
!start ||
|
|
92
|
+
!end ||
|
|
93
|
+
isBefore(end, start) ||
|
|
94
|
+
typeof textEdit.newText !== "string"
|
|
95
|
+
) {
|
|
96
|
+
return { kind: "unavailable", reason: "Workspace edit contains a malformed text edit." };
|
|
97
|
+
}
|
|
98
|
+
edits.push({ file, range: { start, end }, newText: textEdit.newText });
|
|
99
|
+
}
|
|
100
|
+
return { kind: "precise", edits };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Convert an LSP file URI to its local path. */
|
|
104
|
+
export function toFilePath(uri: string): string | null {
|
|
105
|
+
try {
|
|
106
|
+
const parsed = new URL(uri);
|
|
107
|
+
return parsed.protocol === "file:" ? fileURLToPath(parsed) : null;
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function asPosition(value: unknown): { line: number; character: number } | null {
|
|
114
|
+
const position = asRecord(value);
|
|
115
|
+
return position &&
|
|
116
|
+
Object.hasOwn(position, "line") &&
|
|
117
|
+
Object.hasOwn(position, "character") &&
|
|
118
|
+
hasOnlyKeys(position, ["line", "character"]) &&
|
|
119
|
+
Number.isInteger(position.line) &&
|
|
120
|
+
(position.line as number) >= 0 &&
|
|
121
|
+
Number.isInteger(position.character) &&
|
|
122
|
+
(position.character as number) >= 0
|
|
123
|
+
? { line: position.line as number, character: position.character as number }
|
|
124
|
+
: null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isBefore(
|
|
128
|
+
left: { line: number; character: number },
|
|
129
|
+
right: { line: number; character: number },
|
|
130
|
+
): boolean {
|
|
131
|
+
return left.line < right.line || (left.line === right.line && left.character < right.character);
|
|
132
|
+
}
|
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
// It does NOT import pi event types or ExtensionAPI.
|
|
9
9
|
|
|
10
10
|
// biome-ignore lint/style/noExcessiveLinesPerFile: session lifecycle, capability projection, and telemetry stay in one controller.
|
|
11
|
-
import type {
|
|
11
|
+
import type {
|
|
12
|
+
CFamilyFileAssociationResolver,
|
|
13
|
+
WorkspaceRuntime,
|
|
14
|
+
} from "@mrclrchtr/supi-code-runtime/api";
|
|
12
15
|
import {
|
|
13
16
|
recordDebugEvent,
|
|
14
17
|
truncateDebugIdentity as truncateIdentity,
|
|
@@ -83,6 +86,8 @@ export interface LspRuntimeControllerOptions {
|
|
|
83
86
|
readonly homeDir?: string;
|
|
84
87
|
/** A policy captured by the owning workspace host. */
|
|
85
88
|
readonly automaticPathPolicy?: AutomaticLspPathPolicy;
|
|
89
|
+
/** The same immutable C-family association snapshot used by Tree-sitter. */
|
|
90
|
+
readonly fileAssociations?: CFamilyFileAssociationResolver;
|
|
86
91
|
}
|
|
87
92
|
|
|
88
93
|
/** Result type from {@link LspRuntimeController.start}. */
|
|
@@ -97,6 +102,7 @@ export type LspRuntimeTransitionKind =
|
|
|
97
102
|
| "readiness"
|
|
98
103
|
| "crash"
|
|
99
104
|
| "recovery"
|
|
105
|
+
| "retirement"
|
|
100
106
|
| "shutdown"
|
|
101
107
|
| "tracked-files";
|
|
102
108
|
|
|
@@ -340,6 +346,7 @@ export class LspRuntimeController {
|
|
|
340
346
|
this.handleManagerLifecycle(manager, generation, transition);
|
|
341
347
|
},
|
|
342
348
|
automaticPathPolicy,
|
|
349
|
+
this.#options.fileAssociations,
|
|
343
350
|
);
|
|
344
351
|
this.#activeManager = manager;
|
|
345
352
|
this.#latestManagerTransition = null;
|
|
@@ -44,6 +44,7 @@ import type {
|
|
|
44
44
|
RecoverDiagnosticsResult,
|
|
45
45
|
StartupRetryReport,
|
|
46
46
|
} from "./runtime-diagnostics.ts";
|
|
47
|
+
import { runRoutedRename } from "./runtime-rename.ts";
|
|
47
48
|
import type {
|
|
48
49
|
BulkTrackFilesResult,
|
|
49
50
|
RoutedMutationResponse,
|
|
@@ -81,6 +82,7 @@ export {
|
|
|
81
82
|
export type {
|
|
82
83
|
BulkTrackFileOutcome,
|
|
83
84
|
BulkTrackFilesResult,
|
|
85
|
+
RoutedMutationDocumentSnapshot,
|
|
84
86
|
RoutedMutationResponse,
|
|
85
87
|
SemanticReadinessResult,
|
|
86
88
|
WorkspaceCodeActionRequestOptions,
|
|
@@ -236,28 +238,13 @@ class DefaultWorkspaceLspRuntime implements WorkspaceLspRuntime {
|
|
|
236
238
|
newName: string,
|
|
237
239
|
control?: CodeRequestControl,
|
|
238
240
|
): Promise<RoutedMutationResponse<WorkspaceEdit | null> | null> {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
241
|
+
return runRoutedRename({
|
|
242
|
+
manager: this.manager,
|
|
243
|
+
file: this.resolveFilePath(filePath),
|
|
244
|
+
position,
|
|
245
|
+
newName,
|
|
242
246
|
control,
|
|
243
247
|
});
|
|
244
|
-
if (!client) return null;
|
|
245
|
-
if (typeof client.renameDetailed === "function") {
|
|
246
|
-
const result = await client.renameDetailed(resolvedPath, position, newName, control);
|
|
247
|
-
return {
|
|
248
|
-
value: result.kind === "completed" ? result.data : null,
|
|
249
|
-
authorizedMutationRoots: [client.root],
|
|
250
|
-
...(result.kind === "unavailable"
|
|
251
|
-
? { reason: result.reason }
|
|
252
|
-
: result.data === null
|
|
253
|
-
? { reason: "LSP server returned no workspace edit." }
|
|
254
|
-
: {}),
|
|
255
|
-
};
|
|
256
|
-
}
|
|
257
|
-
return {
|
|
258
|
-
value: await client.rename(resolvedPath, position, newName, control),
|
|
259
|
-
authorizedMutationRoots: [client.root],
|
|
260
|
-
};
|
|
261
248
|
}
|
|
262
249
|
|
|
263
250
|
getOpenDocumentVersion(filePath: string): number | null {
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import type { CodeRequestControl } from "@mrclrchtr/supi-code-runtime/api";
|
|
3
|
+
import type { LspClient } from "../client/client.ts";
|
|
4
|
+
import type { SemanticInputDocumentSnapshot } from "../client/client-semantic-input-barrier.ts";
|
|
5
|
+
import type { Position, WorkspaceEdit } from "../config/types.ts";
|
|
6
|
+
import type { LspManager } from "../manager/manager.ts";
|
|
7
|
+
import { discoverSemanticEditFiles } from "../provider/semantic-edit-discovery.ts";
|
|
8
|
+
import type {
|
|
9
|
+
RoutedMutationDocumentSnapshot,
|
|
10
|
+
RoutedMutationResponse,
|
|
11
|
+
} from "./workspace-lsp-runtime.ts";
|
|
12
|
+
|
|
13
|
+
/** Route one rename and retain request-scoped evidence for every affected file. */
|
|
14
|
+
export async function runRoutedRename(options: {
|
|
15
|
+
readonly manager: LspManager;
|
|
16
|
+
readonly file: string;
|
|
17
|
+
readonly position: Position;
|
|
18
|
+
readonly newName: string;
|
|
19
|
+
readonly control?: CodeRequestControl;
|
|
20
|
+
}): Promise<RoutedMutationResponse<WorkspaceEdit | null> | null> {
|
|
21
|
+
const { manager, file, position, newName, control } = options;
|
|
22
|
+
const client = await manager.ensureFileOpen(file, {
|
|
23
|
+
recoverProcessCrash: true,
|
|
24
|
+
control,
|
|
25
|
+
});
|
|
26
|
+
if (!client) return null;
|
|
27
|
+
const authorizedMutationRoots = [client.root];
|
|
28
|
+
|
|
29
|
+
if (typeof client.renameDetailedWithSnapshots === "function") {
|
|
30
|
+
return renameWithSnapshots({
|
|
31
|
+
client,
|
|
32
|
+
manager,
|
|
33
|
+
file,
|
|
34
|
+
position,
|
|
35
|
+
newName,
|
|
36
|
+
control,
|
|
37
|
+
authorizedMutationRoots,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
if (typeof client.renameDetailed === "function") {
|
|
41
|
+
const result = await client.renameDetailed(file, position, newName, control);
|
|
42
|
+
return {
|
|
43
|
+
value: result.kind === "completed" ? result.data : null,
|
|
44
|
+
authorizedMutationRoots,
|
|
45
|
+
...(result.kind === "unavailable"
|
|
46
|
+
? { reason: result.reason }
|
|
47
|
+
: result.data === null
|
|
48
|
+
? { reason: "LSP server returned no workspace edit." }
|
|
49
|
+
: {}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
value: await client.rename(file, position, newName, control),
|
|
54
|
+
authorizedMutationRoots,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function renameWithSnapshots(options: {
|
|
59
|
+
readonly client: LspClient;
|
|
60
|
+
readonly manager: LspManager;
|
|
61
|
+
readonly file: string;
|
|
62
|
+
readonly position: Position;
|
|
63
|
+
readonly newName: string;
|
|
64
|
+
readonly control?: CodeRequestControl;
|
|
65
|
+
readonly authorizedMutationRoots: readonly string[];
|
|
66
|
+
}): Promise<RoutedMutationResponse<WorkspaceEdit | null>> {
|
|
67
|
+
const { client, manager, file, position, newName, control, authorizedMutationRoots } = options;
|
|
68
|
+
const first = await client.renameDetailedWithSnapshots(file, position, newName, {
|
|
69
|
+
control,
|
|
70
|
+
snapshotFiles: [file],
|
|
71
|
+
});
|
|
72
|
+
if (first.result.kind === "unavailable" || first.result.data === null) {
|
|
73
|
+
return {
|
|
74
|
+
value: null,
|
|
75
|
+
authorizedMutationRoots,
|
|
76
|
+
...(first.result.kind === "unavailable"
|
|
77
|
+
? { reason: first.result.reason }
|
|
78
|
+
: { reason: "LSP server returned no workspace edit." }),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const discovery = discoverSemanticEditFiles(first.result.data);
|
|
83
|
+
if (discovery.kind === "unavailable") {
|
|
84
|
+
return { value: null, authorizedMutationRoots, reason: discovery.reason };
|
|
85
|
+
}
|
|
86
|
+
if (hasRenameDocumentSnapshots(discovery.files, first.documentSnapshots)) {
|
|
87
|
+
return {
|
|
88
|
+
value: first.result.data,
|
|
89
|
+
authorizedMutationRoots,
|
|
90
|
+
documentSnapshots: mapRenameDocumentSnapshots(first.documentSnapshots),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (first.inputRevision === undefined) {
|
|
95
|
+
return {
|
|
96
|
+
value: null,
|
|
97
|
+
authorizedMutationRoots,
|
|
98
|
+
reason: "Rename has no verified input revision for document enrollment.",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const enrollment = await manager.enrollRenameDocuments(client, discovery.files, control);
|
|
102
|
+
if (enrollment.kind === "unavailable") {
|
|
103
|
+
return { value: null, authorizedMutationRoots, reason: enrollment.reason };
|
|
104
|
+
}
|
|
105
|
+
const snapshotFiles = [...new Set([file, ...discovery.files])].sort((left, right) =>
|
|
106
|
+
left.localeCompare(right),
|
|
107
|
+
);
|
|
108
|
+
const followup = await client.renameDetailedWithSnapshots(file, position, newName, {
|
|
109
|
+
control,
|
|
110
|
+
snapshotFiles,
|
|
111
|
+
inputRevision: first.inputRevision,
|
|
112
|
+
});
|
|
113
|
+
if (followup.result.kind === "unavailable" || followup.result.data === null) {
|
|
114
|
+
return {
|
|
115
|
+
value: null,
|
|
116
|
+
authorizedMutationRoots,
|
|
117
|
+
...(followup.result.kind === "unavailable"
|
|
118
|
+
? { reason: followup.result.reason }
|
|
119
|
+
: { reason: "LSP server returned no workspace edit after document enrollment." }),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const verified = discoverSemanticEditFiles(followup.result.data);
|
|
124
|
+
if (
|
|
125
|
+
verified.kind === "unavailable" ||
|
|
126
|
+
!isSubset(verified.files, discovery.files) ||
|
|
127
|
+
!hasRenameDocumentSnapshots(snapshotFiles, followup.documentSnapshots)
|
|
128
|
+
) {
|
|
129
|
+
return {
|
|
130
|
+
value: null,
|
|
131
|
+
authorizedMutationRoots,
|
|
132
|
+
reason: "Rename response changed its edit targets after synchronized enrollment.",
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
value: followup.result.data,
|
|
137
|
+
authorizedMutationRoots,
|
|
138
|
+
documentSnapshots: mapRenameDocumentSnapshots(followup.documentSnapshots),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function hasRenameDocumentSnapshots(
|
|
143
|
+
files: readonly string[],
|
|
144
|
+
snapshots: readonly SemanticInputDocumentSnapshot[],
|
|
145
|
+
): boolean {
|
|
146
|
+
const byFile = new Map(snapshots.map((snapshot) => [path.resolve(snapshot.filePath), snapshot]));
|
|
147
|
+
return files.every((file) => {
|
|
148
|
+
const snapshot = byFile.get(path.resolve(file));
|
|
149
|
+
return Boolean(
|
|
150
|
+
snapshot &&
|
|
151
|
+
snapshot.version >= 0 &&
|
|
152
|
+
snapshot.contentFingerprint.length > 0 &&
|
|
153
|
+
snapshot.contentFingerprint === snapshot.diskContentFingerprint,
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function mapRenameDocumentSnapshots(
|
|
159
|
+
snapshots: readonly SemanticInputDocumentSnapshot[],
|
|
160
|
+
): RoutedMutationDocumentSnapshot[] {
|
|
161
|
+
return snapshots.map((snapshot) => ({
|
|
162
|
+
file: snapshot.filePath,
|
|
163
|
+
version: snapshot.version,
|
|
164
|
+
contentFingerprint: snapshot.contentFingerprint,
|
|
165
|
+
diskContentFingerprint: snapshot.diskContentFingerprint,
|
|
166
|
+
}));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function isSubset(values: readonly string[], allowed: readonly string[]): boolean {
|
|
170
|
+
const accepted = new Set(allowed.map((file) => path.resolve(file)));
|
|
171
|
+
return values.every((file) => accepted.has(path.resolve(file)));
|
|
172
|
+
}
|