@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
package/src/manager/manager.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import {
|
|
6
|
+
type CFamilyFileAssociationResolver,
|
|
6
7
|
type CodeQueryResult,
|
|
7
8
|
type CodeRequestControl,
|
|
8
9
|
isCodeRequestInterruption,
|
|
@@ -69,6 +70,7 @@ import {
|
|
|
69
70
|
scanWorkspaceSources as scanAutomaticWorkspaceSources,
|
|
70
71
|
type WorkspaceSourceInventory,
|
|
71
72
|
} from "../diagnostics/workspace-sources.ts";
|
|
73
|
+
import { MAX_RENAME_DOCUMENTS } from "../provider/semantic-edit-discovery.ts";
|
|
72
74
|
import { raceRequestControl } from "../session/readiness.ts";
|
|
73
75
|
import {
|
|
74
76
|
emptyProcessCrashRecoveryReport,
|
|
@@ -175,6 +177,57 @@ type FileRoute = {
|
|
|
175
177
|
key: string;
|
|
176
178
|
};
|
|
177
179
|
|
|
180
|
+
function validateRenameEnrollmentFiles(
|
|
181
|
+
client: LspClient,
|
|
182
|
+
files: readonly string[],
|
|
183
|
+
routeKey: string,
|
|
184
|
+
routeForFile: (file: string) => string | null,
|
|
185
|
+
): string | null {
|
|
186
|
+
if (files.length > MAX_RENAME_DOCUMENTS) {
|
|
187
|
+
return "Rename affects too many files to synchronize safely.";
|
|
188
|
+
}
|
|
189
|
+
if (files.length === 0) return "Rename returned no text-edit documents.";
|
|
190
|
+
let canonicalRoot: string;
|
|
191
|
+
try {
|
|
192
|
+
canonicalRoot = fs.realpathSync(client.root);
|
|
193
|
+
if (!fs.statSync(canonicalRoot).isDirectory()) throw new Error("not a directory");
|
|
194
|
+
} catch {
|
|
195
|
+
return "The semantic route root is not available.";
|
|
196
|
+
}
|
|
197
|
+
for (const file of files) {
|
|
198
|
+
if (!path.isAbsolute(file) || path.normalize(file) !== file) {
|
|
199
|
+
return `Rename target "${file}" is not a normalized path.`;
|
|
200
|
+
}
|
|
201
|
+
const pathError = validateRenameTargetFile(file, canonicalRoot);
|
|
202
|
+
if (pathError) return pathError;
|
|
203
|
+
if (routeForFile(file) !== routeKey) {
|
|
204
|
+
return `Rename target "${file}" uses another semantic route.`;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function validateRenameTargetFile(file: string, canonicalRoot: string): string | null {
|
|
211
|
+
let canonicalFile: string;
|
|
212
|
+
try {
|
|
213
|
+
const canonicalParent = fs.realpathSync(path.dirname(file));
|
|
214
|
+
if (!fs.lstatSync(file).isFile()) {
|
|
215
|
+
return `Rename target "${file}" is not a regular file.`;
|
|
216
|
+
}
|
|
217
|
+
canonicalFile = fs.realpathSync(file);
|
|
218
|
+
if (
|
|
219
|
+
path.resolve(canonicalFile) !== path.resolve(path.join(canonicalParent, path.basename(file)))
|
|
220
|
+
) {
|
|
221
|
+
return `Rename target "${file}" resolves through a file link.`;
|
|
222
|
+
}
|
|
223
|
+
} catch {
|
|
224
|
+
return `Rename target "${file}" is not a readable file.`;
|
|
225
|
+
}
|
|
226
|
+
return projectRoots.isWithinOrEqual(canonicalRoot, canonicalFile)
|
|
227
|
+
? null
|
|
228
|
+
: `Rename target "${file}" is outside the semantic route root.`;
|
|
229
|
+
}
|
|
230
|
+
|
|
178
231
|
type ClientRestartResult = {
|
|
179
232
|
key: string;
|
|
180
233
|
serverName: string;
|
|
@@ -182,7 +235,10 @@ type ClientRestartResult = {
|
|
|
182
235
|
restarted: boolean;
|
|
183
236
|
};
|
|
184
237
|
|
|
185
|
-
export type ManagerLifecycleTransitionKind =
|
|
238
|
+
export type ManagerLifecycleTransitionKind =
|
|
239
|
+
| LspClientLifecycleTransitionKind
|
|
240
|
+
| "recovery"
|
|
241
|
+
| "retirement";
|
|
186
242
|
|
|
187
243
|
/** Package-internal aggregate lifecycle snapshot for the runtime controller. */
|
|
188
244
|
export interface ManagerLifecycleTransition {
|
|
@@ -324,6 +380,12 @@ export class LspManager {
|
|
|
324
380
|
private commandAvailability = new Map<string, boolean>();
|
|
325
381
|
/** Guards against concurrent client creation for the same server:root key. */
|
|
326
382
|
private pendingStarts = new Map<string, Promise<LspClient | null>>();
|
|
383
|
+
/** Prevent retired route owners from publishing work after root removal. */
|
|
384
|
+
private routeRetirementGenerations = new Map<string, number>();
|
|
385
|
+
/** Retired route keys stay blocked until the same path is again a directory. */
|
|
386
|
+
private retiredRoutes = new Set<string>();
|
|
387
|
+
/** Runtime-owned cleanup for clients removed by route retirement. */
|
|
388
|
+
private retiredClientShutdowns = new Set<Promise<void>>();
|
|
327
389
|
/** Shared diagnostic-recovery restart for each replaceable client route. */
|
|
328
390
|
private pendingRestarts = new Map<string, Promise<{ files: string[]; restarted: boolean }>>();
|
|
329
391
|
/** Monotonic ownership generation for each replaceable client route. */
|
|
@@ -351,6 +413,7 @@ export class LspManager {
|
|
|
351
413
|
/** Coalesce per-file tracking transitions into one bulk transition. */
|
|
352
414
|
private trackedFilesBatchDepth = 0;
|
|
353
415
|
private trackedFilesBatchChanged = false;
|
|
416
|
+
// biome-ignore lint/complexity/useMaxParams: the host passes one fixed association snapshot with the existing runtime dependencies
|
|
354
417
|
constructor(
|
|
355
418
|
private readonly config: LspConfig,
|
|
356
419
|
private readonly cwd: string,
|
|
@@ -358,6 +421,7 @@ export class LspManager {
|
|
|
358
421
|
private readonly automaticPathPolicy: AutomaticLspPathPolicy = createDefaultAutomaticLspPathPolicy(
|
|
359
422
|
cwd,
|
|
360
423
|
),
|
|
424
|
+
private readonly fileAssociations?: CFamilyFileAssociationResolver,
|
|
361
425
|
) {}
|
|
362
426
|
getCwd(): string {
|
|
363
427
|
return this.cwd;
|
|
@@ -480,6 +544,7 @@ export class LspManager {
|
|
|
480
544
|
): Promise<LspClient | null> {
|
|
481
545
|
throwIfCodeRequestInterrupted(options.control);
|
|
482
546
|
if (this.shuttingDown) return null;
|
|
547
|
+
if (options.recoverProcessCrash) this.retireMissingProjectRoots();
|
|
483
548
|
const route = this.resolveFileRoute(filePath);
|
|
484
549
|
if (!route) return null;
|
|
485
550
|
|
|
@@ -505,6 +570,8 @@ export class LspManager {
|
|
|
505
570
|
if (!serverConfig) return null;
|
|
506
571
|
const key = clientKey(serverName, root);
|
|
507
572
|
const retryFailedStart = options.retryFailedStart === true;
|
|
573
|
+
if (this.shouldBlockRetiredRoute(key, root, retryFailedStart)) return null;
|
|
574
|
+
const routeGeneration = this.getRouteRetirementGeneration(key);
|
|
508
575
|
if (retryFailedStart) this.unavailable.delete(key);
|
|
509
576
|
if (!retryFailedStart && this.getUnavailableReason(key, serverConfig.command)) return null;
|
|
510
577
|
|
|
@@ -540,7 +607,10 @@ export class LspManager {
|
|
|
540
607
|
const pending = this.pendingStarts.get(key);
|
|
541
608
|
if (pending) return raceRequestControl(pending, control);
|
|
542
609
|
|
|
543
|
-
const startPromise = this.performStart(
|
|
610
|
+
const startPromise = this.performStart(
|
|
611
|
+
{ serverName, serverConfig, root, key },
|
|
612
|
+
routeGeneration,
|
|
613
|
+
);
|
|
544
614
|
this.pendingStarts.set(key, startPromise);
|
|
545
615
|
// Keep the shared startup registered until it settles. A cancelled
|
|
546
616
|
// caller only stops waiting; it must not allow a duplicate route start.
|
|
@@ -569,6 +639,7 @@ export class LspManager {
|
|
|
569
639
|
this.handleClientLifecycle(key, generation, client, kind);
|
|
570
640
|
},
|
|
571
641
|
this.cwd,
|
|
642
|
+
this.fileAssociations,
|
|
572
643
|
);
|
|
573
644
|
return client;
|
|
574
645
|
}
|
|
@@ -672,6 +743,7 @@ export class LspManager {
|
|
|
672
743
|
control?: CodeRequestControl,
|
|
673
744
|
): Promise<ProcessCrashDemandResult> {
|
|
674
745
|
throwIfCodeRequestInterrupted(control);
|
|
746
|
+
this.retireMissingProjectRoots();
|
|
675
747
|
const scopes = this.processCrashDiagnosticScopes(demand);
|
|
676
748
|
const candidates = this.selectExplicitRouteCandidates(scopes);
|
|
677
749
|
return this.withExplicitRouteAttempts(candidates, control, async (results) => {
|
|
@@ -850,7 +922,7 @@ export class LspManager {
|
|
|
850
922
|
callback: (results: ExplicitRouteAttemptResult[]) => Promise<T>,
|
|
851
923
|
): Promise<T> {
|
|
852
924
|
throwIfCodeRequestInterrupted(control);
|
|
853
|
-
const leased: string
|
|
925
|
+
const leased: Array<{ key: string; lease: ExplicitRouteAttemptLease }> = [];
|
|
854
926
|
try {
|
|
855
927
|
const promises = candidates.map((candidate) => {
|
|
856
928
|
throwIfCodeRequestInterrupted(control);
|
|
@@ -858,7 +930,7 @@ export class LspManager {
|
|
|
858
930
|
const existing = this.explicitRouteAttempts.get(key);
|
|
859
931
|
if (existing) {
|
|
860
932
|
existing.users++;
|
|
861
|
-
leased.push(key);
|
|
933
|
+
leased.push({ key, lease: existing });
|
|
862
934
|
return raceRequestControl(existing.promise, control);
|
|
863
935
|
}
|
|
864
936
|
const promise = this.performExplicitRouteAttempt(candidate);
|
|
@@ -872,17 +944,17 @@ export class LspManager {
|
|
|
872
944
|
}
|
|
873
945
|
})
|
|
874
946
|
.catch(() => {});
|
|
875
|
-
leased.push(key);
|
|
947
|
+
leased.push({ key, lease });
|
|
876
948
|
return raceRequestControl(promise, control);
|
|
877
949
|
});
|
|
878
950
|
const results = await Promise.all(promises);
|
|
879
951
|
return await callback(results);
|
|
880
952
|
} finally {
|
|
881
|
-
for (const key of leased) {
|
|
882
|
-
const lease = this.explicitRouteAttempts.get(key);
|
|
883
|
-
if (!lease) continue;
|
|
953
|
+
for (const { key, lease } of leased) {
|
|
884
954
|
lease.users--;
|
|
885
|
-
if (lease.users === 0 && lease.settled
|
|
955
|
+
if (lease.users === 0 && lease.settled && this.explicitRouteAttempts.get(key) === lease) {
|
|
956
|
+
this.explicitRouteAttempts.delete(key);
|
|
957
|
+
}
|
|
886
958
|
}
|
|
887
959
|
}
|
|
888
960
|
}
|
|
@@ -891,11 +963,15 @@ export class LspManager {
|
|
|
891
963
|
candidate: ExplicitRouteCandidate,
|
|
892
964
|
): Promise<ExplicitRouteAttemptResult> {
|
|
893
965
|
const { route } = candidate;
|
|
966
|
+
const routeGeneration = this.getRouteRetirementGeneration(route.key);
|
|
894
967
|
const recovery = this.processCrashRecoveries.get(route.key) ?? candidate.processCrashRecovery;
|
|
895
968
|
if (recovery?.statusReason) {
|
|
896
969
|
const client = await this.acquireProcessCrashReplacement(recovery, route, undefined, {
|
|
897
970
|
allowExhausted: true,
|
|
898
971
|
});
|
|
972
|
+
if (this.getRouteRetirementGeneration(route.key) !== routeGeneration) {
|
|
973
|
+
return { candidate, kind: "none", client: null, attempted: false };
|
|
974
|
+
}
|
|
899
975
|
return {
|
|
900
976
|
candidate,
|
|
901
977
|
kind: "process-crash",
|
|
@@ -913,6 +989,9 @@ export class LspManager {
|
|
|
913
989
|
const client = await this.startServerForRoot(route.serverName, route.root, undefined, {
|
|
914
990
|
retryFailedStart: true,
|
|
915
991
|
});
|
|
992
|
+
if (this.getRouteRetirementGeneration(route.key) !== routeGeneration) {
|
|
993
|
+
return { candidate, kind: "none", client: null, attempted: false };
|
|
994
|
+
}
|
|
916
995
|
const failureMessage =
|
|
917
996
|
this.failedStarts.get(route.key)?.failureMessage ?? candidate.startupFailure?.failureMessage;
|
|
918
997
|
return {
|
|
@@ -975,18 +1054,20 @@ export class LspManager {
|
|
|
975
1054
|
control?: CodeRequestControl,
|
|
976
1055
|
options: { skipWhenNoRetainedFile?: boolean } = {},
|
|
977
1056
|
): Promise<ProcessCrashDemandResult> {
|
|
1057
|
+
throwIfCodeRequestInterrupted(control);
|
|
1058
|
+
this.retireMissingProjectRoots();
|
|
978
1059
|
const { immediate, required } = this.selectProcessCrashDemandRoutes(
|
|
979
1060
|
this.snapshotProcessCrashRoutes(),
|
|
980
1061
|
selectFiles,
|
|
981
1062
|
options,
|
|
982
1063
|
);
|
|
983
|
-
const
|
|
1064
|
+
const hasSelectedSupport = immediate.length > 0 || required.length > 0;
|
|
984
1065
|
if (required.length === 0) {
|
|
985
1066
|
const failures = immediate
|
|
986
1067
|
.filter((entry) => !entry.reportOnly)
|
|
987
1068
|
.map((entry) => this.formatProcessCrashDemandFailure(entry.route, entry.route.outcome));
|
|
988
1069
|
return {
|
|
989
|
-
hasSupport,
|
|
1070
|
+
hasSupport: hasSelectedSupport,
|
|
990
1071
|
requiresFreshEvidence: false,
|
|
991
1072
|
startupRetry: emptyStartupRetryReport(),
|
|
992
1073
|
processCrashRecovery: buildProcessCrashRecoveryReport(
|
|
@@ -1039,7 +1120,7 @@ export class LspManager {
|
|
|
1039
1120
|
);
|
|
1040
1121
|
const failedImmediate = immediate.filter((entry) => !entry.reportOnly);
|
|
1041
1122
|
return {
|
|
1042
|
-
hasSupport,
|
|
1123
|
+
hasSupport: immediate.length > 0 || observedRequired.length > 0,
|
|
1043
1124
|
requiresFreshEvidence: observedRequired.length > 0,
|
|
1044
1125
|
startupRetry: emptyStartupRetryReport(),
|
|
1045
1126
|
processCrashRecovery: buildProcessCrashRecoveryReport(
|
|
@@ -1383,12 +1464,8 @@ export class LspManager {
|
|
|
1383
1464
|
* Perform the actual server start — extracted so the public method can
|
|
1384
1465
|
* deduplicate via pendingStarts without wrapping the entire body.
|
|
1385
1466
|
*/
|
|
1386
|
-
private async performStart(
|
|
1387
|
-
serverName
|
|
1388
|
-
serverConfig: ServerConfig,
|
|
1389
|
-
root: string,
|
|
1390
|
-
key: string,
|
|
1391
|
-
): Promise<LspClient | null> {
|
|
1467
|
+
private async performStart(route: FileRoute, routeGeneration: number): Promise<LspClient | null> {
|
|
1468
|
+
const { serverName, serverConfig, root, key } = route;
|
|
1392
1469
|
// Validate command exists
|
|
1393
1470
|
if (!commandExists(serverConfig.command)) {
|
|
1394
1471
|
this.unavailable.set(key, "missing-command");
|
|
@@ -1409,7 +1486,7 @@ export class LspManager {
|
|
|
1409
1486
|
rememberKnownRoot(this.knownRoots, serverName, root);
|
|
1410
1487
|
try {
|
|
1411
1488
|
await client.start();
|
|
1412
|
-
if (this.
|
|
1489
|
+
if (!this.ownsCurrentStart(key, routeGeneration, client)) {
|
|
1413
1490
|
await client.forceKill().catch(() => {});
|
|
1414
1491
|
if (this.clients.get(key) === client) this.clients.delete(key);
|
|
1415
1492
|
return null;
|
|
@@ -1418,6 +1495,11 @@ export class LspManager {
|
|
|
1418
1495
|
this.failedStarts.delete(key);
|
|
1419
1496
|
return client;
|
|
1420
1497
|
} catch (error) {
|
|
1498
|
+
if (!this.ownsCurrentStart(key, routeGeneration, client)) {
|
|
1499
|
+
await client.forceKill().catch(() => {});
|
|
1500
|
+
if (this.clients.get(key) === client) this.clients.delete(key);
|
|
1501
|
+
return null;
|
|
1502
|
+
}
|
|
1421
1503
|
this.unavailable.set(key, "start-failed");
|
|
1422
1504
|
this.failedStarts.set(key, {
|
|
1423
1505
|
serverName,
|
|
@@ -1483,8 +1565,11 @@ export class LspManager {
|
|
|
1483
1565
|
key: string,
|
|
1484
1566
|
): Promise<{ files: string[]; restarted: boolean }> {
|
|
1485
1567
|
const restartEpoch = this.invalidationEpoch;
|
|
1568
|
+
const routeGeneration = this.getRouteRetirementGeneration(key);
|
|
1486
1569
|
const restartPromise = this.restartClient(client).then((result) => {
|
|
1487
|
-
this.
|
|
1570
|
+
if (this.getRouteRetirementGeneration(key) === routeGeneration) {
|
|
1571
|
+
this.recoveryRestartEpochs.set(key, restartEpoch);
|
|
1572
|
+
}
|
|
1488
1573
|
return result;
|
|
1489
1574
|
});
|
|
1490
1575
|
this.pendingRestarts.set(key, restartPromise);
|
|
@@ -1646,6 +1731,7 @@ export class LspManager {
|
|
|
1646
1731
|
throwIfCodeRequestInterrupted(control);
|
|
1647
1732
|
const resolvedPath = resolveSessionPath(this.cwd, filePath);
|
|
1648
1733
|
if (options.retryFailedRoute) {
|
|
1734
|
+
this.retireMissingProjectRoots();
|
|
1649
1735
|
const route = this.resolveFileRoute(resolvedPath);
|
|
1650
1736
|
if (!route || !this.automaticPathPolicy.isEligible(route.root, "directory")) {
|
|
1651
1737
|
return {
|
|
@@ -2092,7 +2178,119 @@ export class LspManager {
|
|
|
2092
2178
|
}
|
|
2093
2179
|
/** Remove any missing files from open-document and diagnostic state. */
|
|
2094
2180
|
pruneMissingFiles(): string[] {
|
|
2095
|
-
|
|
2181
|
+
const removed = pruneMissingFilesFromClients(this.clients.values());
|
|
2182
|
+
this.retireMissingProjectRoots();
|
|
2183
|
+
return removed;
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
/** Remove route-owned state only when filesystem evidence confirms root removal. */
|
|
2187
|
+
private retireMissingProjectRoots(): void {
|
|
2188
|
+
if (this.shuttingDown) return;
|
|
2189
|
+
const routes = new Map<string, { serverName: string; root: string }>();
|
|
2190
|
+
const addRoute = (serverName: string, root: string): void => {
|
|
2191
|
+
routes.set(clientKey(serverName, root), { serverName, root });
|
|
2192
|
+
};
|
|
2193
|
+
|
|
2194
|
+
for (const [serverName, roots] of this.knownRoots) {
|
|
2195
|
+
for (const root of roots) addRoute(serverName, root);
|
|
2196
|
+
}
|
|
2197
|
+
for (const [key, client] of this.clients) {
|
|
2198
|
+
if (this.clientGenerations.has(key)) addRoute(client.name, client.root);
|
|
2199
|
+
}
|
|
2200
|
+
for (const failure of this.failedStarts.values()) {
|
|
2201
|
+
addRoute(failure.serverName, failure.root);
|
|
2202
|
+
}
|
|
2203
|
+
for (const recovery of this.processCrashRecoveries.values()) {
|
|
2204
|
+
addRoute(recovery.failedClient.name, recovery.failedClient.root);
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
let retired = false;
|
|
2208
|
+
for (const [key, route] of routes) {
|
|
2209
|
+
if (!this.isMissingProjectRoot(route.root)) continue;
|
|
2210
|
+
this.retireProjectRoute(key, route.serverName, route.root);
|
|
2211
|
+
retired = true;
|
|
2212
|
+
}
|
|
2213
|
+
if (retired) this.publishLifecycle("retirement");
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
private shouldBlockRetiredRoute(key: string, root: string, retryFailedStart: boolean): boolean {
|
|
2217
|
+
if (retryFailedStart) this.retireMissingProjectRoots();
|
|
2218
|
+
return this.isRetiredRoute(key, root);
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
private ownsCurrentStart(key: string, generation: number, client: LspClient): boolean {
|
|
2222
|
+
return (
|
|
2223
|
+
!this.shuttingDown &&
|
|
2224
|
+
this.getRouteRetirementGeneration(key) === generation &&
|
|
2225
|
+
this.clients.get(key) === client
|
|
2226
|
+
);
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
private isMissingProjectRoot(root: string): boolean {
|
|
2230
|
+
try {
|
|
2231
|
+
return !fs.statSync(root).isDirectory();
|
|
2232
|
+
} catch (error) {
|
|
2233
|
+
return isMissingFileError(error);
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
private isRetiredRoute(key: string, root: string): boolean {
|
|
2238
|
+
if (!this.retiredRoutes.has(key)) return false;
|
|
2239
|
+
try {
|
|
2240
|
+
if (fs.statSync(root).isDirectory()) {
|
|
2241
|
+
this.retiredRoutes.delete(key);
|
|
2242
|
+
return false;
|
|
2243
|
+
}
|
|
2244
|
+
} catch {
|
|
2245
|
+
// Keep the retirement marker until a directory is confirmed at this path.
|
|
2246
|
+
}
|
|
2247
|
+
return true;
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
private getRouteRetirementGeneration(key: string): number {
|
|
2251
|
+
return this.routeRetirementGenerations.get(key) ?? 0;
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
private retireProjectRoute(key: string, serverName: string, root: string): void {
|
|
2255
|
+
this.routeRetirementGenerations.set(key, this.getRouteRetirementGeneration(key) + 1);
|
|
2256
|
+
this.retiredRoutes.add(key);
|
|
2257
|
+
// Detach old work from key-based joins. Promise observers still own cleanup.
|
|
2258
|
+
// Identity checks keep old finalizers from removing replacement entries.
|
|
2259
|
+
this.pendingStarts.delete(key);
|
|
2260
|
+
this.explicitRouteAttempts.delete(key);
|
|
2261
|
+
this.pendingRestarts.delete(key);
|
|
2262
|
+
const roots = this.knownRoots.get(serverName)?.filter((knownRoot) => knownRoot !== root) ?? [];
|
|
2263
|
+
if (roots.length > 0) this.knownRoots.set(serverName, roots);
|
|
2264
|
+
else this.knownRoots.delete(serverName);
|
|
2265
|
+
|
|
2266
|
+
const client = this.clients.get(key);
|
|
2267
|
+
if (client) this.clients.delete(key);
|
|
2268
|
+
this.unavailable.delete(key);
|
|
2269
|
+
this.failedStarts.delete(key);
|
|
2270
|
+
this.processCrashRecoveries.delete(key);
|
|
2271
|
+
this.recoveryRestartEpochs.delete(key);
|
|
2272
|
+
this.clearWarmedWorkspaceSymbolFiles(serverName, root);
|
|
2273
|
+
this.clearWarmedSemanticProjects(serverName, root);
|
|
2274
|
+
this.clearPendingWarmProbes(serverName, root);
|
|
2275
|
+
|
|
2276
|
+
if (client) {
|
|
2277
|
+
const shutdown = this.shutdownRetiredClient(client);
|
|
2278
|
+
this.retiredClientShutdowns.add(shutdown);
|
|
2279
|
+
void shutdown.finally(() => this.retiredClientShutdowns.delete(shutdown)).catch(() => {});
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
private async shutdownRetiredClient(client: LspClient): Promise<void> {
|
|
2284
|
+
try {
|
|
2285
|
+
await client.shutdown();
|
|
2286
|
+
} catch {
|
|
2287
|
+
try {
|
|
2288
|
+
await client.forceKill();
|
|
2289
|
+
} catch {
|
|
2290
|
+
// Retired-route cleanup is best-effort.
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
client.dispose();
|
|
2096
2294
|
}
|
|
2097
2295
|
/** Re-sync all open documents across active clients and return exact evidence coverage. */
|
|
2098
2296
|
async refreshOpenDiagnostics(
|
|
@@ -2189,6 +2387,8 @@ export class LspManager {
|
|
|
2189
2387
|
processCrashDemand?: { scopes?: readonly string[]; explicit?: boolean };
|
|
2190
2388
|
control?: CodeRequestControl;
|
|
2191
2389
|
}): Promise<WorkspaceRecoveryResult> {
|
|
2390
|
+
throwIfCodeRequestInterrupted(options?.control);
|
|
2391
|
+
this.pruneMissingFiles();
|
|
2192
2392
|
const processCrashDemand = options?.processCrashDemand;
|
|
2193
2393
|
const demand = processCrashDemand?.explicit
|
|
2194
2394
|
? await this.recoverExplicitRouteDemand(processCrashDemand, options?.control)
|
|
@@ -2293,7 +2493,8 @@ export class LspManager {
|
|
|
2293
2493
|
this.shuttingDown = true;
|
|
2294
2494
|
const clients = Array.from(this.clients.values());
|
|
2295
2495
|
const shutdowns = clients.map((client) => client.shutdown().catch(() => {}));
|
|
2296
|
-
await Promise.all(shutdowns);
|
|
2496
|
+
await Promise.all([...shutdowns, ...this.retiredClientShutdowns]);
|
|
2497
|
+
this.retiredClientShutdowns.clear();
|
|
2297
2498
|
// Dispose every client so pending or active progress tokens and
|
|
2298
2499
|
// readiness promises cannot outlive the manager.
|
|
2299
2500
|
for (const client of clients) client.dispose();
|
|
@@ -2303,6 +2504,8 @@ export class LspManager {
|
|
|
2303
2504
|
this.handledCrashGenerations.clear();
|
|
2304
2505
|
this.processCrashRecoveries.clear();
|
|
2305
2506
|
this.failedStarts.clear();
|
|
2507
|
+
this.routeRetirementGenerations.clear();
|
|
2508
|
+
this.retiredRoutes.clear();
|
|
2306
2509
|
this.explicitRouteAttempts.clear();
|
|
2307
2510
|
this.recoveryRestartEpochs.clear();
|
|
2308
2511
|
this.unavailable.clear();
|
|
@@ -2765,6 +2968,42 @@ export class LspManager {
|
|
|
2765
2968
|
);
|
|
2766
2969
|
});
|
|
2767
2970
|
}
|
|
2971
|
+
/** Enroll discovered rename documents only after one complete authority check. */
|
|
2972
|
+
async enrollRenameDocuments(
|
|
2973
|
+
client: LspClient,
|
|
2974
|
+
filePaths: readonly string[],
|
|
2975
|
+
control?: CodeRequestControl,
|
|
2976
|
+
): Promise<{ kind: "enrolled" } | { kind: "unavailable"; reason: string }> {
|
|
2977
|
+
throwIfCodeRequestInterrupted(control);
|
|
2978
|
+
const files = [...new Set(filePaths)].sort();
|
|
2979
|
+
const routeKey = clientKey(client.name, client.root);
|
|
2980
|
+
if (this.clients.get(routeKey) !== client) {
|
|
2981
|
+
return { kind: "unavailable", reason: "The semantic route changed during rename planning." };
|
|
2982
|
+
}
|
|
2983
|
+
const validationError = validateRenameEnrollmentFiles(
|
|
2984
|
+
client,
|
|
2985
|
+
files,
|
|
2986
|
+
routeKey,
|
|
2987
|
+
(file) => this.resolveFileRoute(file)?.key ?? null,
|
|
2988
|
+
);
|
|
2989
|
+
if (validationError) return { kind: "unavailable", reason: validationError };
|
|
2990
|
+
|
|
2991
|
+
for (const file of files) {
|
|
2992
|
+
throwIfCodeRequestInterrupted(control);
|
|
2993
|
+
const openedClient = await this.ensureFileOpen(file, {
|
|
2994
|
+
recoverProcessCrash: true,
|
|
2995
|
+
control,
|
|
2996
|
+
});
|
|
2997
|
+
if (openedClient !== client) {
|
|
2998
|
+
return {
|
|
2999
|
+
kind: "unavailable",
|
|
3000
|
+
reason: "The semantic route changed during rename enrollment.",
|
|
3001
|
+
};
|
|
3002
|
+
}
|
|
3003
|
+
}
|
|
3004
|
+
return { kind: "enrolled" };
|
|
3005
|
+
}
|
|
3006
|
+
|
|
2768
3007
|
async ensureFileOpen(
|
|
2769
3008
|
filePath: string,
|
|
2770
3009
|
options: FileClientRequestOptions = {},
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
1
2
|
import type {
|
|
2
3
|
CodePosition,
|
|
3
4
|
CodeRequestControl,
|
|
@@ -38,6 +39,18 @@ export async function runRenameRefactor(options: {
|
|
|
38
39
|
{ kind: "workspace-edit", edit: response.value },
|
|
39
40
|
{
|
|
40
41
|
getOpenDocumentVersion: (candidate) => lsp.getOpenDocumentVersion(candidate),
|
|
42
|
+
getSynchronizedDocumentSnapshot: (candidate) => {
|
|
43
|
+
const snapshot = response.documentSnapshots?.find(
|
|
44
|
+
(document) => path.resolve(document.file) === path.resolve(candidate),
|
|
45
|
+
);
|
|
46
|
+
return snapshot
|
|
47
|
+
? {
|
|
48
|
+
version: snapshot.version,
|
|
49
|
+
contentFingerprint: snapshot.contentFingerprint,
|
|
50
|
+
diskContentFingerprint: snapshot.diskContentFingerprint,
|
|
51
|
+
}
|
|
52
|
+
: null;
|
|
53
|
+
},
|
|
41
54
|
authorizedMutationRoots: response.authorizedMutationRoots,
|
|
42
55
|
},
|
|
43
56
|
);
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import {
|
|
2
|
+
asRecord,
|
|
3
|
+
getResourceOperationKind,
|
|
4
|
+
hasNoInheritedAllowedKeys,
|
|
5
|
+
hasOnlyKeys,
|
|
6
|
+
normalizeTextEdits,
|
|
7
|
+
parseTextDocumentEdit,
|
|
8
|
+
toFilePath,
|
|
9
|
+
} from "./semantic-edit-protocol.ts";
|
|
10
|
+
|
|
11
|
+
export const MAX_RENAME_DOCUMENTS = 64;
|
|
12
|
+
|
|
13
|
+
type DiscoveryResult =
|
|
14
|
+
| { readonly kind: "valid"; readonly files: readonly string[] }
|
|
15
|
+
| { readonly kind: "unavailable"; readonly reason: string };
|
|
16
|
+
|
|
17
|
+
type DiscoveredDocument =
|
|
18
|
+
| { readonly kind: "valid"; readonly file: string; readonly editCount: number }
|
|
19
|
+
| { readonly kind: "unavailable"; readonly reason: string };
|
|
20
|
+
|
|
21
|
+
/** Validate a complete workspace edit and list its text-edit document paths. */
|
|
22
|
+
export function discoverSemanticEditFiles(value: unknown): DiscoveryResult {
|
|
23
|
+
const workspaceEdit = parseWorkspaceEdit(value);
|
|
24
|
+
if (typeof workspaceEdit === "string") {
|
|
25
|
+
return { kind: "unavailable", reason: workspaceEdit };
|
|
26
|
+
}
|
|
27
|
+
return Object.hasOwn(workspaceEdit, "documentChanges")
|
|
28
|
+
? discoverDocumentChanges(workspaceEdit.documentChanges)
|
|
29
|
+
: discoverChangesMap(workspaceEdit.changes);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseWorkspaceEdit(value: unknown): Record<string, unknown> | string {
|
|
33
|
+
const workspaceEdit = asRecord(value);
|
|
34
|
+
if (
|
|
35
|
+
!workspaceEdit ||
|
|
36
|
+
!hasOnlyKeys(workspaceEdit, ["changes", "documentChanges", "changeAnnotations"]) ||
|
|
37
|
+
!hasNoInheritedAllowedKeys(workspaceEdit, ["changes", "documentChanges", "changeAnnotations"])
|
|
38
|
+
) {
|
|
39
|
+
return "LSP server returned a malformed workspace edit.";
|
|
40
|
+
}
|
|
41
|
+
if (Object.hasOwn(workspaceEdit, "changeAnnotations")) {
|
|
42
|
+
return "Workspace edit contains unsupported change annotations.";
|
|
43
|
+
}
|
|
44
|
+
return workspaceEdit;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function discoverDocumentChanges(value: unknown): DiscoveryResult {
|
|
48
|
+
if (!Array.isArray(value)) {
|
|
49
|
+
return { kind: "unavailable", reason: "Workspace edit has malformed documentChanges." };
|
|
50
|
+
}
|
|
51
|
+
const files = new Set<string>();
|
|
52
|
+
let editCount = 0;
|
|
53
|
+
for (const change of value) {
|
|
54
|
+
const result = discoverDocumentChange(change);
|
|
55
|
+
if (result.kind === "unavailable") return result;
|
|
56
|
+
if (result.editCount > 0) {
|
|
57
|
+
files.add(result.file);
|
|
58
|
+
if (files.size > MAX_RENAME_DOCUMENTS) {
|
|
59
|
+
return {
|
|
60
|
+
kind: "unavailable",
|
|
61
|
+
reason: "Rename affects too many files to synchronize safely.",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
editCount += result.editCount;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return finishDiscovery(files, editCount);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function discoverDocumentChange(value: unknown): DiscoveredDocument {
|
|
71
|
+
const documentEdit = asRecord(value);
|
|
72
|
+
const resourceKind = getResourceOperationKind(documentEdit);
|
|
73
|
+
if (resourceKind) {
|
|
74
|
+
return {
|
|
75
|
+
kind: "unavailable",
|
|
76
|
+
reason: `Workspace edit contains the unsupported "${resourceKind}" resource operation.`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const parsed = parseTextDocumentEdit(documentEdit);
|
|
80
|
+
if (!parsed) {
|
|
81
|
+
return { kind: "unavailable", reason: "Workspace edit has a malformed document change." };
|
|
82
|
+
}
|
|
83
|
+
const uri = parsed.textDocument.uri;
|
|
84
|
+
const file = typeof uri === "string" ? toFilePath(uri) : null;
|
|
85
|
+
if (!file) {
|
|
86
|
+
return { kind: "unavailable", reason: "Document change does not contain a valid file URI." };
|
|
87
|
+
}
|
|
88
|
+
if (!hasValidDocumentVersion(parsed.textDocument.version, parsed.textDocument)) {
|
|
89
|
+
return {
|
|
90
|
+
kind: "unavailable",
|
|
91
|
+
reason: `Document change for "${file}" has an invalid version.`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const normalized = normalizeTextEdits(file, parsed.edit.edits);
|
|
95
|
+
if (normalized.kind === "unavailable") return normalized;
|
|
96
|
+
return { kind: "valid", file, editCount: normalized.edits.length };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function hasValidDocumentVersion(version: unknown, textDocument: Record<string, unknown>): boolean {
|
|
100
|
+
return (
|
|
101
|
+
Object.hasOwn(textDocument, "version") &&
|
|
102
|
+
(version === null || (Number.isInteger(version) && (version as number) >= 0))
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function discoverChangesMap(value: unknown): DiscoveryResult {
|
|
107
|
+
const changes = asRecord(value);
|
|
108
|
+
if (!changes) {
|
|
109
|
+
return { kind: "unavailable", reason: "Workspace edit contains no supported changes." };
|
|
110
|
+
}
|
|
111
|
+
const files = new Set<string>();
|
|
112
|
+
let editCount = 0;
|
|
113
|
+
for (const uri of Object.keys(changes).sort((left, right) => left.localeCompare(right))) {
|
|
114
|
+
const result = discoverChangesEntry(uri, changes[uri]);
|
|
115
|
+
if (result.kind === "unavailable") return result;
|
|
116
|
+
if (result.editCount > 0) {
|
|
117
|
+
files.add(result.file);
|
|
118
|
+
if (files.size > MAX_RENAME_DOCUMENTS) {
|
|
119
|
+
return {
|
|
120
|
+
kind: "unavailable",
|
|
121
|
+
reason: "Rename affects too many files to synchronize safely.",
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
editCount += result.editCount;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return finishDiscovery(files, editCount);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function discoverChangesEntry(uri: string, value: unknown): DiscoveredDocument {
|
|
131
|
+
const file = toFilePath(uri);
|
|
132
|
+
if (!file) {
|
|
133
|
+
return { kind: "unavailable", reason: `Workspace edit URI "${uri}" is not a file URI.` };
|
|
134
|
+
}
|
|
135
|
+
if (!Array.isArray(value)) {
|
|
136
|
+
return { kind: "unavailable", reason: "Workspace edit contains malformed changes." };
|
|
137
|
+
}
|
|
138
|
+
const normalized = normalizeTextEdits(file, value);
|
|
139
|
+
if (normalized.kind === "unavailable") return normalized;
|
|
140
|
+
return { kind: "valid", file, editCount: normalized.edits.length };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function finishDiscovery(files: Set<string>, editCount: number): DiscoveryResult {
|
|
144
|
+
if (files.size > MAX_RENAME_DOCUMENTS) {
|
|
145
|
+
return { kind: "unavailable", reason: "Rename affects too many files to synchronize safely." };
|
|
146
|
+
}
|
|
147
|
+
if (editCount === 0) {
|
|
148
|
+
return { kind: "unavailable", reason: "Workspace edit contains no file edits." };
|
|
149
|
+
}
|
|
150
|
+
return { kind: "valid", files: [...files].sort((left, right) => left.localeCompare(right)) };
|
|
151
|
+
}
|