@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.
Files changed (33) hide show
  1. package/README.md +88 -2
  2. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/LICENSE +21 -0
  3. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/README.md +743 -0
  4. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/index.js +17 -0
  5. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/lib/constants.js +184 -0
  6. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/lib/parse.js +1425 -0
  7. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/lib/picomatch.js +361 -0
  8. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/lib/scan.js +403 -0
  9. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/lib/utils.js +72 -0
  10. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/package.json +83 -0
  11. package/node_modules/@mrclrchtr/supi-code-runtime/node_modules/picomatch/posix.js +3 -0
  12. package/node_modules/@mrclrchtr/supi-code-runtime/package.json +4 -1
  13. package/node_modules/@mrclrchtr/supi-code-runtime/src/api.ts +11 -0
  14. package/node_modules/@mrclrchtr/supi-code-runtime/src/file-associations.ts +190 -0
  15. package/node_modules/@mrclrchtr/supi-code-runtime/src/types.ts +22 -0
  16. package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  17. package/package.json +3 -3
  18. package/src/api.ts +1 -0
  19. package/src/client/client-diagnostic-host.ts +3 -0
  20. package/src/client/client-diagnostics.ts +6 -4
  21. package/src/client/client-request-enrollment.ts +66 -22
  22. package/src/client/client-semantic-input-barrier.ts +37 -5
  23. package/src/client/client.ts +136 -43
  24. package/src/manager/manager.ts +261 -22
  25. package/src/provider/refactor-planning.ts +13 -0
  26. package/src/provider/semantic-edit-discovery.ts +151 -0
  27. package/src/provider/semantic-edit-normalizer.ts +97 -129
  28. package/src/provider/semantic-edit-protocol.ts +132 -0
  29. package/src/session/runtime-controller.ts +8 -1
  30. package/src/session/runtime-registry.ts +7 -20
  31. package/src/session/runtime-rename.ts +172 -0
  32. package/src/session/workspace-lsp-runtime.ts +14 -0
  33. 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 file = toFilePath(uri);
138
- const textEdits = changes[uri];
139
- if (!file) {
140
- return { kind: "unavailable", reason: `Workspace edit URI "${uri}" is not a file URI.` };
141
- }
142
- if (!Array.isArray(textEdits)) {
143
- return { kind: "unavailable", reason: "Workspace edit contains malformed changes." };
144
- }
145
- const normalized = normalizeTextEdits(file, textEdits);
146
- if (normalized.kind === "unavailable") return normalized;
147
- edits.push(...normalized.edits);
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
- return preciseOrEmpty(edits, context);
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
- return openVersion === null
246
- ? { file, kind: "disk-content" }
247
- : `Document change for "${file}" requires disk content, but the document is open.`;
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 normalizeTextEdits(
281
+ function establishSynchronizedContentPrecondition(
262
282
  file: string,
263
- values: unknown[],
264
- ): { kind: "precise"; edits: FileEdit[] } | { kind: "unavailable"; reason: string } {
265
- const edits: FileEdit[] = [];
266
- for (const value of values) {
267
- const textEdit = asRecord(value);
268
- if (textEdit && Object.hasOwn(textEdit, "snippet")) {
269
- return { kind: "unavailable", reason: "Workspace edit contains a snippet text edit." };
270
- }
271
- if (textEdit && Object.hasOwn(textEdit, "annotationId")) {
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
- edits.push({ file, range: { start, end }, newText: textEdit.newText });
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
- return { kind: "precise", edits };
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 { WorkspaceRuntime } from "@mrclrchtr/supi-code-runtime/api";
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
- const resolvedPath = this.resolveFilePath(filePath);
240
- const client = await this.manager.ensureFileOpen(resolvedPath, {
241
- recoverProcessCrash: true,
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
+ }