@mrclrchtr/supi-lsp 11.2.0 → 11.4.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 +40 -3
- package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-code-runtime/src/api.ts +2 -1
- package/node_modules/@mrclrchtr/supi-code-runtime/src/capability/types.ts +3 -1
- package/node_modules/@mrclrchtr/supi-code-runtime/src/declaration-level.ts +34 -0
- package/node_modules/@mrclrchtr/supi-code-runtime/src/query-result.ts +9 -0
- package/node_modules/@mrclrchtr/supi-code-runtime/src/types.ts +13 -5
- package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/child-web-tools.ts +110 -0
- package/node_modules/@mrclrchtr/supi-core/src/session.ts +11 -0
- package/node_modules/semver/classes/range.js +2 -2
- package/node_modules/semver/internal/constants.js +30 -0
- package/node_modules/semver/internal/trim-comparator-whitespace.js +438 -0
- package/node_modules/semver/package.json +1 -1
- package/package.json +3 -3
- package/src/client/client-diagnostic-collection.ts +11 -4
- package/src/client/client-diagnostics.ts +77 -6
- package/src/client/client-input-admission.ts +149 -0
- package/src/client/client-typescript-project.ts +40 -0
- package/src/client/client.ts +151 -44
- package/src/config/tsconfig-scope.ts +10 -0
- package/src/diagnostics/evidence.ts +2 -2
- package/src/manager/manager-typescript-coverage.ts +118 -0
- package/src/manager/manager-workspace-symbol.ts +157 -23
- package/src/manager/manager.ts +318 -58
- package/src/manager/typescript-file-local.ts +87 -0
- package/src/provider/semantic-symbol-mapper.ts +22 -17
- package/src/session/runtime-registry.ts +68 -6
- package/src/session/runtime-rename.ts +1 -0
- package/src/session/workspace-lsp-runtime.ts +5 -1
package/src/manager/manager.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
withTimeout,
|
|
20
20
|
} from "../client/client.ts";
|
|
21
21
|
import type { ClientDiagnosticSnapshot } from "../client/client-document-state.ts";
|
|
22
|
+
import type { ProjectMembership } from "../client/client-typescript-project.ts";
|
|
22
23
|
|
|
23
24
|
export { RECOVERY_CLIENT_STARTUP_BOUND_MS } from "../client/client.ts";
|
|
24
25
|
|
|
@@ -127,6 +128,13 @@ import type {
|
|
|
127
128
|
OutstandingDiagnosticSummaryEntry,
|
|
128
129
|
ServerStatus,
|
|
129
130
|
} from "./manager-types.ts";
|
|
131
|
+
import {
|
|
132
|
+
discoverTypeScriptProjectConfigs,
|
|
133
|
+
findSkippedImplementationProjects,
|
|
134
|
+
findUnloadedTypeScriptProjects,
|
|
135
|
+
singleProjectImplementationReason,
|
|
136
|
+
typeScriptCoverageGapReason,
|
|
137
|
+
} from "./manager-typescript-coverage.ts";
|
|
130
138
|
import {
|
|
131
139
|
recoverWorkspaceDiagnostics as recoverWorkspaceDiagnosticsImpl,
|
|
132
140
|
type WorkspaceRecoveryResult,
|
|
@@ -135,9 +143,12 @@ import {
|
|
|
135
143
|
collectWorkspaceSymbols,
|
|
136
144
|
findWorkspaceSymbolWarmTargets,
|
|
137
145
|
getWorkspaceSymbolWarmPosition,
|
|
146
|
+
isWithinAnyScope,
|
|
138
147
|
type WorkspaceSymbolCollection,
|
|
139
148
|
workspaceSymbolCollectionResult,
|
|
149
|
+
workspaceSymbolCoverageGap,
|
|
140
150
|
} from "./manager-workspace-symbol.ts";
|
|
151
|
+
import { isFileLocalTopLevelDeclaration } from "./typescript-file-local.ts";
|
|
141
152
|
|
|
142
153
|
type UnavailableReason = "missing-command" | "start-failed" | "runtime-error";
|
|
143
154
|
|
|
@@ -251,6 +262,8 @@ export interface ManagerLifecycleTransition {
|
|
|
251
262
|
type ManagerLifecycleListener = (transition: ManagerLifecycleTransition) => void;
|
|
252
263
|
|
|
253
264
|
type FileClientRequestOptions = {
|
|
265
|
+
/** Reject known input changes while this call's first open waits to dispatch original parameters. */
|
|
266
|
+
protectInputs?: boolean;
|
|
254
267
|
/** Allow this request to start the route's one process-crash recovery. */
|
|
255
268
|
recoverProcessCrash?: boolean;
|
|
256
269
|
/** Control only this caller's wait for a shared replacement. */
|
|
@@ -397,12 +410,18 @@ export class LspManager {
|
|
|
397
410
|
private invalidationEpoch = 0;
|
|
398
411
|
/** Invalidation generation in which each route last received a recovery restart. */
|
|
399
412
|
private recoveryRestartEpochs = new Map<string, number>();
|
|
400
|
-
/**
|
|
401
|
-
|
|
413
|
+
/**
|
|
414
|
+
* Files already probed for workspace-symbol warm-up, with the project
|
|
415
|
+
* membership the server reported. `inferred` probes stay recorded so they
|
|
416
|
+
* are not reopened, but they do not prove project coverage.
|
|
417
|
+
*/
|
|
418
|
+
private warmedWorkspaceSymbolFiles = new Map<string, ProjectMembership>();
|
|
402
419
|
/** Project roots whose semantic state was warmed with a readiness probe. */
|
|
403
420
|
private warmedSemanticProjects = new Set<string>();
|
|
404
421
|
/** In-flight warm-up probes keyed by project key so concurrent callers share one probe. */
|
|
405
422
|
private pendingWarmProbes = new Map<string, Promise<void>>();
|
|
423
|
+
/** Loadable TypeScript project configs per route root; cleared when a config changes. */
|
|
424
|
+
private typeScriptProjectConfigs = new Map<string, string[]>();
|
|
406
425
|
/** One process-crash recovery budget and shared replacement per route. */
|
|
407
426
|
private processCrashRecoveries = new Map<string, ProcessCrashRecoveryState>();
|
|
408
427
|
/** Client generations that completed the initialize handshake. */
|
|
@@ -434,6 +453,19 @@ export class LspManager {
|
|
|
434
453
|
detected.filter((entry) => this.automaticPathPolicy.isEligible(entry.root, "directory")),
|
|
435
454
|
);
|
|
436
455
|
}
|
|
456
|
+
/** Check configured routing only, independently of executable availability or startup state. */
|
|
457
|
+
hasConfiguredServerForFile(filePath: string): boolean {
|
|
458
|
+
return getServerForFile(this.config, resolveSessionPath(this.cwd, filePath)) !== null;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Observe a retained initial-start failure without retrying or conflating process-crash recovery. */
|
|
462
|
+
hasFailedStartupForFile(filePath: string): boolean {
|
|
463
|
+
const route = this.resolveFileRoute(filePath);
|
|
464
|
+
if (!route || this.processCrashRecoveries.has(route.key)) return false;
|
|
465
|
+
const reason = this.unavailable.get(route.key);
|
|
466
|
+
return reason === "start-failed" || reason === "missing-command";
|
|
467
|
+
}
|
|
468
|
+
|
|
437
469
|
/** Check whether a file path has an available LSP server for explicit semantic operations. */
|
|
438
470
|
canServeFile(filePath: string): boolean {
|
|
439
471
|
const route = this.resolveFileRoute(filePath);
|
|
@@ -550,6 +582,53 @@ export class LspManager {
|
|
|
550
582
|
);
|
|
551
583
|
}
|
|
552
584
|
|
|
585
|
+
/**
|
|
586
|
+
* Coverage gap for a project-wide TypeScript request answered by `client`.
|
|
587
|
+
*
|
|
588
|
+
* Returns a partial-result reason when a TypeScript project that includes
|
|
589
|
+
* `filePath` owns neither an open file nor a file in `resultFiles`, so
|
|
590
|
+
* tsserver may not have searched it; otherwise null. Non-TypeScript routes
|
|
591
|
+
* always return null.
|
|
592
|
+
*
|
|
593
|
+
* Implementation requests not known to use the native backend report every
|
|
594
|
+
* other project instead: tsserver searches only the file's own project.
|
|
595
|
+
*
|
|
596
|
+
* No gap applies when `position` (zero-based) names a top-level declaration
|
|
597
|
+
* its module does not export: no other project can reference it.
|
|
598
|
+
*/
|
|
599
|
+
typeScriptProjectCoverageGap(
|
|
600
|
+
client: LspClient,
|
|
601
|
+
filePath: string,
|
|
602
|
+
request: "references" | "implementation",
|
|
603
|
+
evidence: {
|
|
604
|
+
readonly resultFiles?: readonly string[];
|
|
605
|
+
readonly position?: { readonly line: number; readonly character: number };
|
|
606
|
+
} = {},
|
|
607
|
+
): string | null {
|
|
608
|
+
const { resultFiles = [], position } = evidence;
|
|
609
|
+
if (client.name !== "typescript") return null;
|
|
610
|
+
const rootKey = path.resolve(client.root);
|
|
611
|
+
let configs = this.typeScriptProjectConfigs.get(rootKey);
|
|
612
|
+
if (!configs) {
|
|
613
|
+
configs = discoverTypeScriptProjectConfigs(rootKey, this.automaticPathPolicy);
|
|
614
|
+
this.typeScriptProjectConfigs.set(rootKey, configs);
|
|
615
|
+
}
|
|
616
|
+
const file = resolveSessionPath(this.cwd, filePath);
|
|
617
|
+
if (position && isFileLocalTopLevelDeclaration(file, position)) return null;
|
|
618
|
+
if (request === "implementation" && client.typescriptSelection?.backend !== "native") {
|
|
619
|
+
const skipped = findSkippedImplementationProjects({ file, cwd: this.cwd, configs });
|
|
620
|
+
return skipped.length > 0 ? singleProjectImplementationReason(skipped) : null;
|
|
621
|
+
}
|
|
622
|
+
const unloaded = findUnloadedTypeScriptProjects({
|
|
623
|
+
file,
|
|
624
|
+
cwd: this.cwd,
|
|
625
|
+
openFiles: client.openFiles,
|
|
626
|
+
resultFiles,
|
|
627
|
+
configs,
|
|
628
|
+
});
|
|
629
|
+
return unloaded.length > 0 ? typeScriptCoverageGapReason(unloaded, this.cwd) : null;
|
|
630
|
+
}
|
|
631
|
+
|
|
553
632
|
/** Get or create an LSP client for the given file. */
|
|
554
633
|
async getClientForFile(
|
|
555
634
|
filePath: string,
|
|
@@ -1314,7 +1393,13 @@ export class LspManager {
|
|
|
1314
1393
|
recovery.statusReason = undefined;
|
|
1315
1394
|
recovery.failureMessage = undefined;
|
|
1316
1395
|
this.publishLifecycle("recovery");
|
|
1317
|
-
this.restoreTrackedDocuments(started, recovery.files);
|
|
1396
|
+
await this.restoreTrackedDocuments(started, recovery.files);
|
|
1397
|
+
if (
|
|
1398
|
+
this.isProcessCrashRecoveryCancelled(recovery, key) ||
|
|
1399
|
+
this.clients.get(key) !== started ||
|
|
1400
|
+
started.status !== "running"
|
|
1401
|
+
)
|
|
1402
|
+
return null;
|
|
1318
1403
|
this.recordProcessCrashRecoveryEvent(
|
|
1319
1404
|
route.serverName,
|
|
1320
1405
|
route.root,
|
|
@@ -1399,20 +1484,16 @@ export class LspManager {
|
|
|
1399
1484
|
}
|
|
1400
1485
|
|
|
1401
1486
|
/** Restore route documents from disk and retain failed-document evidence. */
|
|
1402
|
-
private restoreTrackedDocuments(
|
|
1403
|
-
|
|
1487
|
+
private async restoreTrackedDocuments(
|
|
1488
|
+
client: LspClient,
|
|
1489
|
+
files: readonly string[],
|
|
1490
|
+
): Promise<void> {
|
|
1491
|
+
const key = clientKey(client.name, client.root);
|
|
1492
|
+
const isCurrent = () => !this.shuttingDown && this.clients.get(key) === client;
|
|
1404
1493
|
for (const filePath of files) {
|
|
1405
|
-
if (!
|
|
1406
|
-
|
|
1407
|
-
continue;
|
|
1408
|
-
}
|
|
1409
|
-
try {
|
|
1410
|
-
client.didOpen(filePath, fs.readFileSync(filePath, "utf-8"));
|
|
1411
|
-
} catch {
|
|
1412
|
-
failedFiles.push(filePath);
|
|
1413
|
-
}
|
|
1494
|
+
if (!isCurrent()) return;
|
|
1495
|
+
await client.restoreDocument(filePath, () => fs.readFileSync(filePath, "utf-8"), isCurrent);
|
|
1414
1496
|
}
|
|
1415
|
-
for (const filePath of failedFiles) client.markFailedFile(filePath);
|
|
1416
1497
|
}
|
|
1417
1498
|
|
|
1418
1499
|
private markProcessCrashRecoveryFailed(
|
|
@@ -1656,7 +1737,15 @@ export class LspManager {
|
|
|
1656
1737
|
void replacement.forceKill().catch(() => {});
|
|
1657
1738
|
return { files, restarted: false };
|
|
1658
1739
|
}
|
|
1659
|
-
this.restoreTrackedDocuments(replacement, files);
|
|
1740
|
+
await this.restoreTrackedDocuments(replacement, files);
|
|
1741
|
+
if (
|
|
1742
|
+
this.clients.get(key) !== replacement ||
|
|
1743
|
+
this.shuttingDown ||
|
|
1744
|
+
replacement.status !== "running"
|
|
1745
|
+
) {
|
|
1746
|
+
void replacement.forceKill().catch(() => {});
|
|
1747
|
+
return { files, restarted: false };
|
|
1748
|
+
}
|
|
1660
1749
|
this.clearUnconsumedProcessCrashState(key, client);
|
|
1661
1750
|
return { files, restarted: true };
|
|
1662
1751
|
} catch {
|
|
@@ -1766,7 +1855,9 @@ export class LspManager {
|
|
|
1766
1855
|
if (options.retryFailedRoute) {
|
|
1767
1856
|
this.retireMissingProjectRoots();
|
|
1768
1857
|
const route = this.resolveFileRoute(resolvedPath);
|
|
1769
|
-
|
|
1858
|
+
// Exact-file demand, including refresh, bypasses automatic discovery exclusions.
|
|
1859
|
+
// Keep the original route identity for document synchronization and bounded retries.
|
|
1860
|
+
if (!route) {
|
|
1770
1861
|
return {
|
|
1771
1862
|
client: null,
|
|
1772
1863
|
processCrashRecovery: emptyProcessCrashRecoveryReport(),
|
|
@@ -2050,13 +2141,19 @@ export class LspManager {
|
|
|
2050
2141
|
|
|
2051
2142
|
const serverConfig = this.config.servers[firstReady.name];
|
|
2052
2143
|
if (serverConfig) {
|
|
2053
|
-
|
|
2144
|
+
// Candidates for the best-ranked marker root; later ones replace a probe
|
|
2145
|
+
// that lands in an inferred project outside the configured program.
|
|
2146
|
+
const targets = findWorkspaceSymbolWarmTargets(
|
|
2054
2147
|
firstReady.root,
|
|
2055
2148
|
serverConfig.rootMarkers,
|
|
2056
2149
|
serverConfig.fileTypes,
|
|
2057
2150
|
{ policy: this.automaticPathPolicy },
|
|
2058
|
-
)
|
|
2059
|
-
|
|
2151
|
+
);
|
|
2152
|
+
const primaryRoot = targets[0]?.projectRoot;
|
|
2153
|
+
const files = targets
|
|
2154
|
+
.filter((target) => target.projectRoot === primaryRoot)
|
|
2155
|
+
.map((target) => target.file);
|
|
2156
|
+
if (files.length > 0) await this.warmSemanticProject(firstReady, files, false, control);
|
|
2060
2157
|
// The warm-up request can start server indexing after the initial
|
|
2061
2158
|
// readiness wait resolves. Observe the resulting progress before
|
|
2062
2159
|
// reporting the workspace as ready.
|
|
@@ -2357,7 +2454,7 @@ export class LspManager {
|
|
|
2357
2454
|
*/
|
|
2358
2455
|
noteWorkspaceChanges(changes: FileEvent[]): void {
|
|
2359
2456
|
if (changes.length > 0) this.invalidationEpoch++;
|
|
2360
|
-
invalidateProjectConfigCaches(changes);
|
|
2457
|
+
if (invalidateProjectConfigCaches(changes)) this.typeScriptProjectConfigs.clear();
|
|
2361
2458
|
this.notifyWorkspaceFileChanges(changes);
|
|
2362
2459
|
}
|
|
2363
2460
|
|
|
@@ -2546,6 +2643,7 @@ export class LspManager {
|
|
|
2546
2643
|
this.warmedWorkspaceSymbolFiles.clear();
|
|
2547
2644
|
this.warmedSemanticProjects.clear();
|
|
2548
2645
|
this.pendingWarmProbes.clear();
|
|
2646
|
+
this.typeScriptProjectConfigs.clear();
|
|
2549
2647
|
this.pendingRestarts.clear();
|
|
2550
2648
|
}
|
|
2551
2649
|
/** Get status of all servers. */
|
|
@@ -2899,7 +2997,20 @@ export class LspManager {
|
|
|
2899
2997
|
return this.filterWorkspaceSymbolCollection(collection, resolvedScopes);
|
|
2900
2998
|
};
|
|
2901
2999
|
const initial = await collect(this.clients.values(), query);
|
|
2902
|
-
|
|
3000
|
+
// Allowed out-of-scope rows do not show that the server knows the scope yet.
|
|
3001
|
+
const hasScopedResult = (collection: WorkspaceSymbolCollection) =>
|
|
3002
|
+
collection.hasSupport &&
|
|
3003
|
+
collection.results.some(
|
|
3004
|
+
(result) =>
|
|
3005
|
+
!resolvedScopes?.length ||
|
|
3006
|
+
isWithinAnyScope(uriToFile(result.location.uri), resolvedScopes),
|
|
3007
|
+
);
|
|
3008
|
+
// Rows from a route whose warm-up reached only inferred projects may come
|
|
3009
|
+
// from those files alone, so they cannot end the search early.
|
|
3010
|
+
if (
|
|
3011
|
+
!initial.hasSupport ||
|
|
3012
|
+
(hasScopedResult(initial) && !this.hasInferredOnlyWarmRoute(routeMatches))
|
|
3013
|
+
) {
|
|
2903
3014
|
return workspaceSymbolCollectionResult(initial);
|
|
2904
3015
|
}
|
|
2905
3016
|
|
|
@@ -2911,11 +3022,32 @@ export class LspManager {
|
|
|
2911
3022
|
}),
|
|
2912
3023
|
getWarmPosition: getWorkspaceSymbolWarmPosition,
|
|
2913
3024
|
collect,
|
|
3025
|
+
hasResult: hasScopedResult,
|
|
2914
3026
|
routeMatches,
|
|
2915
3027
|
query,
|
|
2916
3028
|
control,
|
|
2917
3029
|
});
|
|
2918
|
-
|
|
3030
|
+
const collection = warmed.collection ?? initial;
|
|
3031
|
+
return workspaceSymbolCollectionResult(
|
|
3032
|
+
warmed.coverageConfirmed
|
|
3033
|
+
? collection
|
|
3034
|
+
: workspaceSymbolCoverageGap(collection, warmed.inferredProbeCount),
|
|
3035
|
+
);
|
|
3036
|
+
}
|
|
3037
|
+
|
|
3038
|
+
/** True when a matching route has inferred warm-up probes and no other probe. */
|
|
3039
|
+
private hasInferredOnlyWarmRoute(routeMatches: (client: LspClient) => boolean): boolean {
|
|
3040
|
+
return Array.from(this.clients.values()).some((client) => {
|
|
3041
|
+
if (!routeMatches(client)) return false;
|
|
3042
|
+
const prefix = `${client.name}:${path.resolve(client.root)}`;
|
|
3043
|
+
let inferred = false;
|
|
3044
|
+
for (const [key, membership] of this.warmedWorkspaceSymbolFiles) {
|
|
3045
|
+
if (key !== prefix && !key.startsWith(`${prefix}${path.sep}`)) continue;
|
|
3046
|
+
if (membership !== "inferred") return false;
|
|
3047
|
+
inferred = true;
|
|
3048
|
+
}
|
|
3049
|
+
return inferred;
|
|
3050
|
+
});
|
|
2919
3051
|
}
|
|
2920
3052
|
|
|
2921
3053
|
private addWorkspaceSymbolDemandEvidence(
|
|
@@ -2938,6 +3070,7 @@ export class LspManager {
|
|
|
2938
3070
|
const results = collection.results.filter((result) => {
|
|
2939
3071
|
const file = uriToFile(result.location.uri);
|
|
2940
3072
|
if (this.isWorkspaceSymbolResultAllowed(file, scopes)) return true;
|
|
3073
|
+
if (!this.isWorkspaceSymbolExclusionRelevant(file, scopes)) return false;
|
|
2941
3074
|
excludedCount++;
|
|
2942
3075
|
const example = displayRelativeFilePath(file, this.cwd);
|
|
2943
3076
|
if (excludedExamples.size < 5) excludedExamples.add(example);
|
|
@@ -2951,6 +3084,26 @@ export class LspManager {
|
|
|
2951
3084
|
};
|
|
2952
3085
|
}
|
|
2953
3086
|
|
|
3087
|
+
/** Only proven-disjoint exclusions can be omitted from scoped completeness. */
|
|
3088
|
+
private isWorkspaceSymbolExclusionRelevant(
|
|
3089
|
+
filePath: string,
|
|
3090
|
+
scopes: readonly string[] | undefined,
|
|
3091
|
+
): boolean {
|
|
3092
|
+
if (!scopes?.length || !path.isAbsolute(filePath)) return true;
|
|
3093
|
+
if (scopes.some((scope) => projectRoots.isWithinOrEqual(scope, filePath))) return true;
|
|
3094
|
+
try {
|
|
3095
|
+
// Observed spellings and physical identities both matter: an excluded
|
|
3096
|
+
// alias may point into scope even when its spelling is outside it.
|
|
3097
|
+
const physicalFile = fs.realpathSync(filePath);
|
|
3098
|
+
return scopes.some((scope) =>
|
|
3099
|
+
projectRoots.isWithinOrEqual(fs.realpathSync(scope), physicalFile),
|
|
3100
|
+
);
|
|
3101
|
+
} catch {
|
|
3102
|
+
// Missing, unreadable or otherwise unresolved paths cannot prove absence.
|
|
3103
|
+
return true;
|
|
3104
|
+
}
|
|
3105
|
+
}
|
|
3106
|
+
|
|
2954
3107
|
/** Keep explicitly selected excluded scopes inspectable while filtering broad results. */
|
|
2955
3108
|
private isWorkspaceSymbolResultAllowed(
|
|
2956
3109
|
filePath: string,
|
|
@@ -3045,15 +3198,22 @@ export class LspManager {
|
|
|
3045
3198
|
const client = await this.getClientForFile(resolvedPath, options);
|
|
3046
3199
|
if (!client) return null;
|
|
3047
3200
|
try {
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3201
|
+
await client.openDocument(
|
|
3202
|
+
resolvedPath,
|
|
3203
|
+
() => {
|
|
3204
|
+
try {
|
|
3205
|
+
return fs.readFileSync(resolvedPath, "utf-8");
|
|
3206
|
+
} catch (error) {
|
|
3207
|
+
this.closeFile(resolvedPath);
|
|
3208
|
+
throw error;
|
|
3209
|
+
}
|
|
3210
|
+
},
|
|
3211
|
+
options.control,
|
|
3212
|
+
options.protectInputs,
|
|
3213
|
+
);
|
|
3053
3214
|
return client;
|
|
3054
3215
|
} catch (error) {
|
|
3055
3216
|
if (isCodeRequestInterruption(error, options.control)) throw error;
|
|
3056
|
-
this.closeFile(resolvedPath);
|
|
3057
3217
|
return null;
|
|
3058
3218
|
}
|
|
3059
3219
|
}
|
|
@@ -3069,13 +3229,29 @@ export class LspManager {
|
|
|
3069
3229
|
symbols: import("../config/types.ts").DocumentSymbol[] | SymbolInformation[] | null,
|
|
3070
3230
|
) => import("../config/types.ts").Position | null;
|
|
3071
3231
|
collect: (clients: Iterable<LspClient>, query: string) => Promise<WorkspaceSymbolCollection>;
|
|
3232
|
+
/** Stop warming once a collection contains a row that answers the request. */
|
|
3233
|
+
hasResult: (collection: WorkspaceSymbolCollection) => boolean;
|
|
3072
3234
|
routeMatches?: (client: LspClient) => boolean;
|
|
3073
3235
|
query: string;
|
|
3074
3236
|
control?: CodeRequestControl;
|
|
3075
|
-
}): Promise<{
|
|
3076
|
-
|
|
3077
|
-
|
|
3237
|
+
}): Promise<{
|
|
3238
|
+
collection: WorkspaceSymbolCollection | null;
|
|
3239
|
+
/** False only when every probe the server classified landed in an inferred project. */
|
|
3240
|
+
coverageConfirmed: boolean;
|
|
3241
|
+
inferredProbeCount: number;
|
|
3242
|
+
}> {
|
|
3243
|
+
const { findWarmTargets, getWarmPosition, collect, hasResult, routeMatches, query, control } =
|
|
3244
|
+
options;
|
|
3078
3245
|
let latestCollection: WorkspaceSymbolCollection | null = null;
|
|
3246
|
+
// A route with no inferred probe keeps today's evidence: non-TypeScript
|
|
3247
|
+
// servers index the workspace, and `unknown` cannot prove a gap.
|
|
3248
|
+
const routeCoverage: boolean[] = [];
|
|
3249
|
+
let inferredProbeCount = 0;
|
|
3250
|
+
const outcome = () => ({
|
|
3251
|
+
collection: latestCollection,
|
|
3252
|
+
coverageConfirmed: routeCoverage.every(Boolean),
|
|
3253
|
+
inferredProbeCount,
|
|
3254
|
+
});
|
|
3079
3255
|
|
|
3080
3256
|
for (const client of Array.from(this.clients.values())) {
|
|
3081
3257
|
if (routeMatches && !routeMatches(client)) continue;
|
|
@@ -3091,13 +3267,29 @@ export class LspManager {
|
|
|
3091
3267
|
serverConfig.fileTypes,
|
|
3092
3268
|
).slice(0, 24);
|
|
3093
3269
|
|
|
3270
|
+
// Marker roots whose program an earlier candidate already loaded.
|
|
3271
|
+
const coveredRoots = new Set<string>();
|
|
3272
|
+
let routeInferred = false;
|
|
3094
3273
|
for (const target of warmTargets) {
|
|
3274
|
+
if (coveredRoots.has(target.projectRoot)) continue;
|
|
3095
3275
|
// A file probe does not load every program under the route root.
|
|
3096
3276
|
const fileKey = `${client.name}:${path.resolve(target.file)}`;
|
|
3097
|
-
|
|
3277
|
+
const previous = this.warmedWorkspaceSymbolFiles.get(fileKey);
|
|
3278
|
+
if (previous === "inferred") {
|
|
3279
|
+
routeInferred = true;
|
|
3280
|
+
inferredProbeCount++;
|
|
3281
|
+
continue;
|
|
3282
|
+
}
|
|
3283
|
+
if (previous !== undefined) {
|
|
3284
|
+
coveredRoots.add(target.projectRoot);
|
|
3285
|
+
continue;
|
|
3286
|
+
}
|
|
3098
3287
|
|
|
3099
|
-
const openedClient = await this.ensureFileOpen(target.file);
|
|
3100
|
-
if (!openedClient)
|
|
3288
|
+
const openedClient = await this.ensureFileOpen(target.file, { control });
|
|
3289
|
+
if (!openedClient) {
|
|
3290
|
+
this.recordWarmProbeEvent(client.name, target.file, "open-failed", control);
|
|
3291
|
+
continue;
|
|
3292
|
+
}
|
|
3101
3293
|
|
|
3102
3294
|
// A cancelled pass must not mark projects warmed on its way out.
|
|
3103
3295
|
throwIfCodeRequestInterrupted(control);
|
|
@@ -3107,32 +3299,84 @@ export class LspManager {
|
|
|
3107
3299
|
getWarmPosition,
|
|
3108
3300
|
control,
|
|
3109
3301
|
);
|
|
3110
|
-
if (available) {
|
|
3111
|
-
this.
|
|
3112
|
-
|
|
3302
|
+
if (!available) {
|
|
3303
|
+
this.recordWarmProbeEvent(client.name, target.file, "probe-unavailable", control);
|
|
3304
|
+
continue;
|
|
3305
|
+
}
|
|
3306
|
+
const membership = await openedClient.projectMembership(target.file, control);
|
|
3307
|
+
throwIfCodeRequestInterrupted(control);
|
|
3308
|
+
this.warmedWorkspaceSymbolFiles.set(fileKey, membership);
|
|
3309
|
+
this.recordWarmProbeEvent(client.name, target.file, membership, control);
|
|
3310
|
+
if (membership === "inferred") {
|
|
3311
|
+
// The file loaded only its own inferred project; workspace/symbol
|
|
3312
|
+
// still cannot see the configured program, so try the next file
|
|
3313
|
+
// without collecting: a hit here could come from that inferred
|
|
3314
|
+
// project alone and would hide the configured program's rows.
|
|
3315
|
+
routeInferred = true;
|
|
3316
|
+
inferredProbeCount++;
|
|
3317
|
+
continue;
|
|
3113
3318
|
}
|
|
3114
3319
|
|
|
3320
|
+
coveredRoots.add(target.projectRoot);
|
|
3115
3321
|
latestCollection = await collect(this.clients.values(), query);
|
|
3116
|
-
if (latestCollection
|
|
3117
|
-
return {
|
|
3322
|
+
if (hasResult(latestCollection)) {
|
|
3323
|
+
return { ...outcome(), coverageConfirmed: true };
|
|
3118
3324
|
}
|
|
3119
3325
|
}
|
|
3326
|
+
routeCoverage.push(coveredRoots.size > 0 || !routeInferred);
|
|
3120
3327
|
}
|
|
3121
3328
|
|
|
3329
|
+
if (inferredProbeCount > 0 && latestCollection === null) {
|
|
3330
|
+
latestCollection = await collect(this.clients.values(), query);
|
|
3331
|
+
}
|
|
3122
3332
|
// A completed-empty retry replaces an earlier failure too.
|
|
3123
|
-
return
|
|
3333
|
+
return outcome();
|
|
3334
|
+
}
|
|
3335
|
+
|
|
3336
|
+
/**
|
|
3337
|
+
* Record one workspace-symbol warm-up probe: which file was opened and
|
|
3338
|
+
* whether the server placed it in a configured project. `unknown` means the
|
|
3339
|
+
* route cannot report membership (non-TypeScript or native TS7).
|
|
3340
|
+
*/
|
|
3341
|
+
private recordWarmProbeEvent(
|
|
3342
|
+
serverName: string,
|
|
3343
|
+
file: string,
|
|
3344
|
+
outcome: ProjectMembership | "open-failed" | "probe-unavailable",
|
|
3345
|
+
control?: CodeRequestControl,
|
|
3346
|
+
): void {
|
|
3347
|
+
try {
|
|
3348
|
+
recordDebugEvent({
|
|
3349
|
+
operationId: control?.operationId,
|
|
3350
|
+
source: "lsp",
|
|
3351
|
+
level: "debug",
|
|
3352
|
+
category: "workspace-symbol.warm-probe",
|
|
3353
|
+
message: `Workspace-symbol warm-up probe: ${outcome}`,
|
|
3354
|
+
cwd: boundCwd(this.cwd),
|
|
3355
|
+
data: {
|
|
3356
|
+
server: truncateIdentity(serverName),
|
|
3357
|
+
file: truncateIdentity(displayRelativeFilePath(file, this.cwd)),
|
|
3358
|
+
outcome,
|
|
3359
|
+
},
|
|
3360
|
+
});
|
|
3361
|
+
} catch {
|
|
3362
|
+
// Warm-up telemetry must never change query behavior.
|
|
3363
|
+
}
|
|
3124
3364
|
}
|
|
3125
3365
|
|
|
3126
3366
|
private async warmSemanticProject(
|
|
3127
3367
|
client: LspClient,
|
|
3128
|
-
filePath: string,
|
|
3368
|
+
filePath: string | readonly string[],
|
|
3129
3369
|
preferExactFile: boolean = false,
|
|
3130
3370
|
control?: CodeRequestControl,
|
|
3131
3371
|
): Promise<void> {
|
|
3132
3372
|
const serverConfig = this.config.servers[client.name];
|
|
3133
3373
|
if (!serverConfig) return;
|
|
3134
3374
|
|
|
3135
|
-
const
|
|
3375
|
+
const candidates = (typeof filePath === "string" ? [filePath] : filePath).map((file) =>
|
|
3376
|
+
resolveSessionPath(this.cwd, file),
|
|
3377
|
+
);
|
|
3378
|
+
const resolvedFile = candidates[0];
|
|
3379
|
+
if (!resolvedFile) return;
|
|
3136
3380
|
const projectRoot = preferExactFile
|
|
3137
3381
|
? resolveRootForFile(resolvedFile, client.name, serverConfig.rootMarkers, {
|
|
3138
3382
|
knownRoots: this.knownRoots,
|
|
@@ -3150,7 +3394,7 @@ export class LspManager {
|
|
|
3150
3394
|
return;
|
|
3151
3395
|
}
|
|
3152
3396
|
|
|
3153
|
-
const probe = this.performWarmProbe(client,
|
|
3397
|
+
const probe = this.performWarmProbe(client, candidates, projectKey, control);
|
|
3154
3398
|
this.pendingWarmProbes.set(projectKey, probe);
|
|
3155
3399
|
try {
|
|
3156
3400
|
await raceRequestControl(probe, control);
|
|
@@ -3161,28 +3405,40 @@ export class LspManager {
|
|
|
3161
3405
|
}
|
|
3162
3406
|
}
|
|
3163
3407
|
|
|
3408
|
+
/**
|
|
3409
|
+
* Probe candidates in order until one loads a non-inferred project.
|
|
3410
|
+
* The project is marked warmed after any successful probe so files that
|
|
3411
|
+
* really sit in inferred projects are not reprobed on every request.
|
|
3412
|
+
*/
|
|
3164
3413
|
private async performWarmProbe(
|
|
3165
3414
|
client: LspClient,
|
|
3166
|
-
|
|
3415
|
+
candidates: readonly string[],
|
|
3167
3416
|
projectKey: string,
|
|
3168
3417
|
control?: CodeRequestControl,
|
|
3169
3418
|
): Promise<void> {
|
|
3170
3419
|
const key = clientKey(client.name, client.root);
|
|
3171
|
-
if (this.clients.get(key) !== client) return;
|
|
3172
|
-
const openedClient = await this.ensureFileOpen(resolvedFile);
|
|
3173
|
-
if (!openedClient || openedClient !== client || this.clients.get(key) !== client) return;
|
|
3174
|
-
|
|
3175
3420
|
// The probe is shared state for concurrent callers. One caller's
|
|
3176
3421
|
// cancellation must not cancel the shared in-flight queries, so only the
|
|
3177
3422
|
// Debug Operation ID is forwarded; each caller races its own wait above.
|
|
3178
3423
|
const sharedControl = { operationId: control?.operationId };
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3424
|
+
let warmed = false;
|
|
3425
|
+
for (const [index, resolvedFile] of candidates.entries()) {
|
|
3426
|
+
if (this.clients.get(key) !== client) return;
|
|
3427
|
+
const openedClient = await this.ensureFileOpen(resolvedFile);
|
|
3428
|
+
if (!openedClient || openedClient !== client || this.clients.get(key) !== client) continue;
|
|
3429
|
+
const available = await this.probeDocumentSymbols(
|
|
3430
|
+
openedClient,
|
|
3431
|
+
resolvedFile,
|
|
3432
|
+
getWorkspaceSymbolWarmPosition,
|
|
3433
|
+
sharedControl,
|
|
3434
|
+
);
|
|
3435
|
+
if (!available) continue;
|
|
3436
|
+
warmed = true;
|
|
3437
|
+
if (index === candidates.length - 1) break;
|
|
3438
|
+
const membership = await openedClient.projectMembership(resolvedFile, sharedControl);
|
|
3439
|
+
if (membership !== "inferred") break;
|
|
3440
|
+
}
|
|
3441
|
+
if (warmed && this.clients.get(key) === client) {
|
|
3186
3442
|
this.warmedSemanticProjects.add(projectKey);
|
|
3187
3443
|
}
|
|
3188
3444
|
}
|
|
@@ -3213,7 +3469,7 @@ export class LspManager {
|
|
|
3213
3469
|
|
|
3214
3470
|
private clearWarmedWorkspaceSymbolFiles(serverName: string, root: string): void {
|
|
3215
3471
|
const prefix = `${serverName}:${path.resolve(root)}`;
|
|
3216
|
-
for (const key of Array.from(this.warmedWorkspaceSymbolFiles)) {
|
|
3472
|
+
for (const key of Array.from(this.warmedWorkspaceSymbolFiles.keys())) {
|
|
3217
3473
|
if (key === prefix || key.startsWith(`${prefix}${path.sep}`)) {
|
|
3218
3474
|
this.warmedWorkspaceSymbolFiles.delete(key);
|
|
3219
3475
|
}
|
|
@@ -3239,15 +3495,19 @@ export class LspManager {
|
|
|
3239
3495
|
}
|
|
3240
3496
|
}
|
|
3241
3497
|
|
|
3242
|
-
|
|
3498
|
+
/** Invalidate parsed project configs; returns whether any config changed. */
|
|
3499
|
+
function invalidateProjectConfigCaches(changes: readonly FileEvent[]): boolean {
|
|
3500
|
+
let changed = false;
|
|
3243
3501
|
for (const change of changes) {
|
|
3244
3502
|
const configPath = uriToFile(change.uri);
|
|
3245
3503
|
if (!isProjectConfigFileName(path.basename(configPath))) continue;
|
|
3504
|
+
changed = true;
|
|
3246
3505
|
invalidateTsconfigCacheForConfig(configPath);
|
|
3247
3506
|
if (change.type === FileChangeType.Created) {
|
|
3248
3507
|
invalidateTsconfigCacheForConfigDir(path.dirname(configPath));
|
|
3249
3508
|
}
|
|
3250
3509
|
}
|
|
3510
|
+
return changed;
|
|
3251
3511
|
}
|
|
3252
3512
|
|
|
3253
3513
|
function collectClientDiagnosticEvidenceDocuments(
|