@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
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CodeRequestControl,
|
|
3
|
+
CodeRequestDeadlineError,
|
|
4
|
+
throwIfCodeRequestInterrupted,
|
|
5
|
+
} from "@mrclrchtr/supi-code-runtime/api";
|
|
6
|
+
|
|
7
|
+
const MAX_PENDING_INPUT_OPERATIONS = 32;
|
|
8
|
+
|
|
9
|
+
type InputOperationKind = "request" | "enrollment" | "request-enrollment";
|
|
10
|
+
|
|
11
|
+
interface PendingInputOperation {
|
|
12
|
+
readonly uri: string | undefined;
|
|
13
|
+
readonly kind: InputOperationKind;
|
|
14
|
+
start(): void;
|
|
15
|
+
cancel(reason: unknown): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Admit first opens between semantic query and refactor-planning attempts on one client.
|
|
20
|
+
* Consecutive readers run concurrently; FIFO admission prevents either new
|
|
21
|
+
* readers or enrollments from overtaking a waiting operation. This is not
|
|
22
|
+
* diagnostic transport ownership: an interrupted semantic attempt can release
|
|
23
|
+
* its admission even when the server has not yet acknowledged cancellation.
|
|
24
|
+
*/
|
|
25
|
+
export class SemanticInputAdmission {
|
|
26
|
+
readonly #pending: PendingInputOperation[] = [];
|
|
27
|
+
#readers = 0;
|
|
28
|
+
#enrolling = false;
|
|
29
|
+
#disposed = false;
|
|
30
|
+
|
|
31
|
+
/** Cover one attempt's synchronization, RPCs and acceptance, never its retry. */
|
|
32
|
+
request<T>(run: () => Promise<T>, control?: CodeRequestControl, uri?: string): Promise<T> {
|
|
33
|
+
return this.#run("request", uri, run, control);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Commit a first open after earlier attempts leave. Demand with original
|
|
38
|
+
* semantic/refactor parameters opts into input invalidation while waiting.
|
|
39
|
+
*/
|
|
40
|
+
enroll(
|
|
41
|
+
uri: string,
|
|
42
|
+
run: () => void,
|
|
43
|
+
control?: CodeRequestControl,
|
|
44
|
+
protectInputs = false,
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
return this.#run(protectInputs ? "request-enrollment" : "enrollment", uri, run, control);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A close cancels queued opens and queries for that document, before dispatch. */
|
|
50
|
+
close(uri: string): void {
|
|
51
|
+
const cancelled = this.#pending.filter((pending) => pending.uri === uri);
|
|
52
|
+
for (const pending of cancelled) this.#pending.splice(this.#pending.indexOf(pending), 1);
|
|
53
|
+
for (const pending of cancelled)
|
|
54
|
+
pending.cancel(new Error("Document closed before input admission."));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Discard queued request parameters after a known non-enrollment input change. */
|
|
58
|
+
invalidateRequests(): void {
|
|
59
|
+
const cancelled = this.#pending.filter((pending) => pending.kind !== "enrollment");
|
|
60
|
+
for (const pending of cancelled) this.#pending.splice(this.#pending.indexOf(pending), 1);
|
|
61
|
+
for (const pending of cancelled)
|
|
62
|
+
pending.cancel(new Error("Semantic inputs changed before request admission."));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Reject queued work without confusing caller completion with transport settlement. */
|
|
66
|
+
dispose(): void {
|
|
67
|
+
this.#disposed = true;
|
|
68
|
+
for (const pending of [...this.#pending]) {
|
|
69
|
+
pending.cancel(new Error("LSP client stopped before input admission."));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
#run<T>(
|
|
74
|
+
kind: InputOperationKind,
|
|
75
|
+
uri: string | undefined,
|
|
76
|
+
run: () => T | Promise<T>,
|
|
77
|
+
control: CodeRequestControl | undefined,
|
|
78
|
+
): Promise<T> {
|
|
79
|
+
throwIfCodeRequestInterrupted(control);
|
|
80
|
+
if (this.#disposed) return Promise.reject(new Error("LSP input admission is disposed."));
|
|
81
|
+
if (this.#pending.length >= MAX_PENDING_INPUT_OPERATIONS) {
|
|
82
|
+
return Promise.reject(new Error("LSP input admission queue is full."));
|
|
83
|
+
}
|
|
84
|
+
return new Promise<T>((resolve, reject) => {
|
|
85
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
86
|
+
let waiting = true;
|
|
87
|
+
const cleanup = () => {
|
|
88
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
89
|
+
control?.signal?.removeEventListener("abort", abort);
|
|
90
|
+
};
|
|
91
|
+
const operation: PendingInputOperation = {
|
|
92
|
+
uri,
|
|
93
|
+
kind,
|
|
94
|
+
cancel: (reason) => {
|
|
95
|
+
if (!waiting) return;
|
|
96
|
+
waiting = false;
|
|
97
|
+
cleanup();
|
|
98
|
+
const index = this.#pending.indexOf(operation);
|
|
99
|
+
if (index !== -1) this.#pending.splice(index, 1);
|
|
100
|
+
reject(reason);
|
|
101
|
+
this.#drain();
|
|
102
|
+
},
|
|
103
|
+
start: () => {
|
|
104
|
+
waiting = false;
|
|
105
|
+
cleanup();
|
|
106
|
+
if (kind !== "request") this.#enrolling = true;
|
|
107
|
+
else this.#readers++;
|
|
108
|
+
const finish = () => {
|
|
109
|
+
if (kind !== "request") this.#enrolling = false;
|
|
110
|
+
else this.#readers--;
|
|
111
|
+
this.#drain();
|
|
112
|
+
};
|
|
113
|
+
try {
|
|
114
|
+
throwIfCodeRequestInterrupted(control);
|
|
115
|
+
Promise.resolve(run()).then(resolve, reject).finally(finish);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
reject(error);
|
|
118
|
+
finish();
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
const abort = () => {
|
|
123
|
+
try {
|
|
124
|
+
throwIfCodeRequestInterrupted(control);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
operation.cancel(error);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
this.#pending.push(operation);
|
|
130
|
+
control?.signal?.addEventListener("abort", abort, { once: true });
|
|
131
|
+
if (control?.deadline !== undefined) {
|
|
132
|
+
timer = setTimeout(
|
|
133
|
+
() => operation.cancel(new CodeRequestDeadlineError()),
|
|
134
|
+
Math.max(0, control.deadline - Date.now()),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
this.#drain();
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#drain(): void {
|
|
142
|
+
while (!this.#disposed && !this.#enrolling && this.#pending.length > 0) {
|
|
143
|
+
const next = this.#pending[0];
|
|
144
|
+
if (next.kind !== "request" && this.#readers > 0) return;
|
|
145
|
+
this.#pending.shift();
|
|
146
|
+
next.start();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether tsserver placed an open file in a configured project.
|
|
3
|
+
*
|
|
4
|
+
* - `configured`: a `tsconfig.json` / `jsconfig.json` program owns the file.
|
|
5
|
+
* - `inferred`: tsserver created an inferred project for the file alone, so
|
|
6
|
+
* project-wide requests such as `workspace/symbol` do not cover the
|
|
7
|
+
* configured program.
|
|
8
|
+
* - `unknown`: the route is not TypeScript or tsserver could not answer.
|
|
9
|
+
*/
|
|
10
|
+
export type ProjectMembership = "configured" | "inferred" | "unknown";
|
|
11
|
+
|
|
12
|
+
/** Build `workspace/executeCommand` params for tsserver `projectInfo`. */
|
|
13
|
+
export function typeScriptProjectInfoParams(uri: string): unknown {
|
|
14
|
+
return {
|
|
15
|
+
command: "typescript.tsserverRequest",
|
|
16
|
+
arguments: [
|
|
17
|
+
"projectInfo",
|
|
18
|
+
{ file: uri, needFileNameList: false },
|
|
19
|
+
// Semantic execution target; the project must be loaded to answer.
|
|
20
|
+
{ executionTarget: 0, expectsResult: true, isAsync: false },
|
|
21
|
+
],
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Read project membership from a tsserver `projectInfo` response.
|
|
27
|
+
* tsserver names projects without a config file `/dev/null/<kind>Project<N>*`.
|
|
28
|
+
*/
|
|
29
|
+
export function readTypeScriptProjectMembership(response: unknown): ProjectMembership {
|
|
30
|
+
if (!isRecord(response) || response.type !== "response" || response.success !== true) {
|
|
31
|
+
return "unknown";
|
|
32
|
+
}
|
|
33
|
+
const body = response.body;
|
|
34
|
+
if (!isRecord(body) || typeof body.configFileName !== "string") return "unknown";
|
|
35
|
+
return body.configFileName.startsWith("/dev/null/") ? "inferred" : "configured";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
39
|
+
return typeof value === "object" && value !== null;
|
|
40
|
+
}
|
package/src/client/client.ts
CHANGED
|
@@ -80,6 +80,11 @@ import type {
|
|
|
80
80
|
SemanticInputSnapshot,
|
|
81
81
|
} from "./client-semantic-input-barrier.ts";
|
|
82
82
|
import { SemanticInputSynchronizationError } from "./client-semantic-input-errors.ts";
|
|
83
|
+
import {
|
|
84
|
+
type ProjectMembership,
|
|
85
|
+
readTypeScriptProjectMembership,
|
|
86
|
+
typeScriptProjectInfoParams,
|
|
87
|
+
} from "./client-typescript-project.ts";
|
|
83
88
|
import { JsonRpcClient, JsonRpcRequestError } from "./transport.ts";
|
|
84
89
|
|
|
85
90
|
const SHUTDOWN_TIMEOUT_MS = 5_000;
|
|
@@ -89,6 +94,12 @@ const CONTENT_MODIFIED_RETRY_DELAY_MS = 100;
|
|
|
89
94
|
|
|
90
95
|
/** Bounded stderr tail kept for startup-failure error messages. */
|
|
91
96
|
const STDERR_TAIL_MAX_CHARS = 4_000;
|
|
97
|
+
/**
|
|
98
|
+
* Bound for draining a failed startup's stdio. An initialize write can fail
|
|
99
|
+
* (`EPIPE`) before the exiting server's stderr is read; waiting for `close`
|
|
100
|
+
* lets the failure report the server's own reason instead of the transport error.
|
|
101
|
+
*/
|
|
102
|
+
const STARTUP_FAILURE_DRAIN_MS = 500;
|
|
92
103
|
|
|
93
104
|
interface SemanticRequestAttemptState {
|
|
94
105
|
inputRevision: number | undefined;
|
|
@@ -492,6 +503,20 @@ function killFailedStartupProcess(child: ChildProcess): void {
|
|
|
492
503
|
child.kill();
|
|
493
504
|
}
|
|
494
505
|
|
|
506
|
+
/** Resolve once `closed()` holds or the child emits `close`, bounded by `ms`. */
|
|
507
|
+
function waitForStdioClose(child: ChildProcess, closed: () => boolean, ms: number): Promise<void> {
|
|
508
|
+
if (closed()) return Promise.resolve();
|
|
509
|
+
return new Promise((resolve) => {
|
|
510
|
+
const done = () => {
|
|
511
|
+
clearTimeout(timer);
|
|
512
|
+
child.off("close", done);
|
|
513
|
+
resolve();
|
|
514
|
+
};
|
|
515
|
+
const timer = setTimeout(done, ms);
|
|
516
|
+
child.once("close", done);
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
|
|
495
520
|
// ── Types ─────────────────────────────────────────────────────────────
|
|
496
521
|
export type ClientStatus = "initializing" | "running" | "error" | "shutdown";
|
|
497
522
|
|
|
@@ -726,19 +751,24 @@ export class LspClient {
|
|
|
726
751
|
});
|
|
727
752
|
this.rpc.onRequest((method, params) => this.handleServerRequest(method, params));
|
|
728
753
|
|
|
729
|
-
// Handle crashes
|
|
754
|
+
// Handle crashes. A failing initialize reports its own detailed failure.
|
|
755
|
+
let reportingStartupFailure = false;
|
|
730
756
|
this.process.on("exit", (_code) => {
|
|
731
|
-
this.handleProcessFailure(new Error("Client crashed"));
|
|
757
|
+
if (!reportingStartupFailure) this.handleProcessFailure(new Error("Client crashed"));
|
|
732
758
|
});
|
|
733
759
|
|
|
734
760
|
this.process.on("error", (_err) => {
|
|
735
|
-
this.handleProcessFailure(new Error("Client process error"));
|
|
761
|
+
if (!reportingStartupFailure) this.handleProcessFailure(new Error("Client process error"));
|
|
736
762
|
});
|
|
737
763
|
|
|
738
764
|
// Capture a bounded stderr tail (discarded otherwise) so startup
|
|
739
765
|
// failures can surface the server's own exit reason — expired builds,
|
|
740
766
|
// missing runtimes, or invalid flags. Stderr never reaches the agent.
|
|
741
767
|
let stderrTail = "";
|
|
768
|
+
let stdioClosed = false;
|
|
769
|
+
this.process.once("close", () => {
|
|
770
|
+
stdioClosed = true;
|
|
771
|
+
});
|
|
742
772
|
this.process.stderr?.on("data", (chunk: Buffer | string) => {
|
|
743
773
|
stderrTail = `${stderrTail}${chunk}`.slice(-STDERR_TAIL_MAX_CHARS);
|
|
744
774
|
});
|
|
@@ -777,12 +807,16 @@ export class LspClient {
|
|
|
777
807
|
|
|
778
808
|
this.armNoProgressTimer();
|
|
779
809
|
} catch (err) {
|
|
810
|
+
const child = this.process;
|
|
811
|
+
reportingStartupFailure = true;
|
|
812
|
+
killFailedStartupProcess(child);
|
|
813
|
+
// Let already-written stderr drain before reporting the failure.
|
|
814
|
+
await waitForStdioClose(child, () => stdioClosed, STARTUP_FAILURE_DRAIN_MS);
|
|
780
815
|
const stderrDetail = stderrTail.trim() ? `\n${this.name} stderr:\n${stderrTail.trim()}` : "";
|
|
781
816
|
const failure = new Error(`${this.name}: initialize failed: ${err}${stderrDetail}`, {
|
|
782
817
|
cause: err,
|
|
783
818
|
});
|
|
784
819
|
this.handleProcessFailure(failure);
|
|
785
|
-
killFailedStartupProcess(this.process);
|
|
786
820
|
throw failure;
|
|
787
821
|
}
|
|
788
822
|
}
|
|
@@ -879,6 +913,33 @@ export class LspClient {
|
|
|
879
913
|
}
|
|
880
914
|
|
|
881
915
|
// ── Document Synchronization and Diagnostics ────────────────────────
|
|
916
|
+
/**
|
|
917
|
+
* Admit a first open between semantic attempts, reading content after the wait.
|
|
918
|
+
* Semantic/refactor callers protect their original inputs; ordinary tracking
|
|
919
|
+
* can adopt fresh content after a known change instead.
|
|
920
|
+
*/
|
|
921
|
+
async openDocument(
|
|
922
|
+
filePath: string,
|
|
923
|
+
readContent: () => string,
|
|
924
|
+
control?: CodeRequestControl,
|
|
925
|
+
protectInputs = false,
|
|
926
|
+
): Promise<void> {
|
|
927
|
+
const trackedCount = this.openFiles.length;
|
|
928
|
+
await this.diagnostics.openDocument(filePath, readContent, control, protectInputs);
|
|
929
|
+
this.publishTrackedFileChange(trackedCount);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/** Restore retained state without resurrecting a closed or replaced document. */
|
|
933
|
+
async restoreDocument(
|
|
934
|
+
filePath: string,
|
|
935
|
+
readContent: () => string,
|
|
936
|
+
isCurrent: () => boolean,
|
|
937
|
+
): Promise<void> {
|
|
938
|
+
const trackedCount = this.openFiles.length;
|
|
939
|
+
await this.diagnostics.restoreDocument(filePath, readContent, isCurrent);
|
|
940
|
+
this.publishTrackedFileChange(trackedCount);
|
|
941
|
+
}
|
|
942
|
+
|
|
882
943
|
/** Open a document, or update it when it is already open. */
|
|
883
944
|
didOpen(filePath: string, content: string): void {
|
|
884
945
|
const trackedCount = this.openFiles.length;
|
|
@@ -1161,6 +1222,23 @@ export class LspClient {
|
|
|
1161
1222
|
);
|
|
1162
1223
|
}
|
|
1163
1224
|
|
|
1225
|
+
/**
|
|
1226
|
+
* Report whether the server placed an open file in a configured project.
|
|
1227
|
+
* Only the TypeScript route can answer; other routes return `unknown`.
|
|
1228
|
+
*/
|
|
1229
|
+
async projectMembership(
|
|
1230
|
+
filePath: string,
|
|
1231
|
+
control?: CodeRequestControl,
|
|
1232
|
+
): Promise<ProjectMembership> {
|
|
1233
|
+
if (!this.hasTypeScriptRequestCommand()) return "unknown";
|
|
1234
|
+
const result = await this.query<unknown>(
|
|
1235
|
+
"workspace/executeCommand",
|
|
1236
|
+
typeScriptProjectInfoParams(fileToUri(filePath)),
|
|
1237
|
+
control,
|
|
1238
|
+
);
|
|
1239
|
+
return result.kind === "completed" ? readTypeScriptProjectMembership(result.data) : "unknown";
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1164
1242
|
async workspaceSymbol(
|
|
1165
1243
|
query: string,
|
|
1166
1244
|
control?: CodeRequestControl,
|
|
@@ -1258,6 +1336,7 @@ export class LspClient {
|
|
|
1258
1336
|
| undefined;
|
|
1259
1337
|
},
|
|
1260
1338
|
{
|
|
1339
|
+
documentUri: params.textDocument.uri,
|
|
1261
1340
|
inputRevision: options.inputRevision,
|
|
1262
1341
|
onVerifiedSnapshot: (snapshot) => {
|
|
1263
1342
|
inputRevision = snapshot.revision;
|
|
@@ -1309,17 +1388,22 @@ export class LspClient {
|
|
|
1309
1388
|
triggerKind: context.triggerKind ?? CodeActionTriggerKind.Invoked,
|
|
1310
1389
|
...(context.only ? { only: [...context.only] } : {}),
|
|
1311
1390
|
};
|
|
1312
|
-
return this.runRefactorTransaction(
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1391
|
+
return this.runRefactorTransaction(
|
|
1392
|
+
"textDocument/codeAction",
|
|
1393
|
+
control,
|
|
1394
|
+
async (transaction) => {
|
|
1395
|
+
const raw = await transaction.rpc.sendRequest(
|
|
1396
|
+
"textDocument/codeAction",
|
|
1397
|
+
{ textDocument: { uri: fileToUri(filePath) }, range, context: requestContext },
|
|
1398
|
+
control,
|
|
1399
|
+
);
|
|
1400
|
+
if (raw === null || raw === undefined) return null;
|
|
1401
|
+
if (!Array.isArray(raw)) throw new Error("LSP server returned malformed code actions.");
|
|
1320
1402
|
|
|
1321
|
-
|
|
1322
|
-
|
|
1403
|
+
return this.resolveCodeActions(raw, transaction, control, requestContext.only);
|
|
1404
|
+
},
|
|
1405
|
+
{ documentUri: fileToUri(filePath) },
|
|
1406
|
+
);
|
|
1323
1407
|
}
|
|
1324
1408
|
|
|
1325
1409
|
async implementation(
|
|
@@ -1384,9 +1468,10 @@ export class LspClient {
|
|
|
1384
1468
|
control: CodeRequestControl | undefined,
|
|
1385
1469
|
operation: (transaction: RefactorTransaction) => Promise<T | null | undefined>,
|
|
1386
1470
|
options: {
|
|
1471
|
+
readonly documentUri: string;
|
|
1387
1472
|
readonly onVerifiedSnapshot?: (snapshot: SemanticInputSnapshot) => void;
|
|
1388
1473
|
readonly inputRevision?: number;
|
|
1389
|
-
}
|
|
1474
|
+
},
|
|
1390
1475
|
): Promise<CodeQueryResult<T | null>> {
|
|
1391
1476
|
throwIfCodeRequestInterrupted(control);
|
|
1392
1477
|
if (!this.rpc || this._status !== "running") {
|
|
@@ -1405,6 +1490,7 @@ export class LspClient {
|
|
|
1405
1490
|
retryGuard.assertCurrent();
|
|
1406
1491
|
const data = await this.runRefactorTransactionAttempt({
|
|
1407
1492
|
method,
|
|
1493
|
+
documentUri: options.documentUri,
|
|
1408
1494
|
control,
|
|
1409
1495
|
operation,
|
|
1410
1496
|
retryGuard,
|
|
@@ -1446,9 +1532,10 @@ export class LspClient {
|
|
|
1446
1532
|
}
|
|
1447
1533
|
}
|
|
1448
1534
|
|
|
1449
|
-
/**
|
|
1535
|
+
/** Admit one prepare/request/resolve attempt, not retries or rename's own enrollment. */
|
|
1450
1536
|
private async runRefactorTransactionAttempt<T>(options: {
|
|
1451
1537
|
method: string;
|
|
1538
|
+
documentUri: string;
|
|
1452
1539
|
control?: CodeRequestControl;
|
|
1453
1540
|
operation: (transaction: RefactorTransaction) => Promise<T | null | undefined>;
|
|
1454
1541
|
retryGuard: SemanticInputRequestRetryGuard;
|
|
@@ -1459,27 +1546,36 @@ export class LspClient {
|
|
|
1459
1546
|
throwIfCodeRequestInterrupted(control);
|
|
1460
1547
|
retryGuard.assertCurrent();
|
|
1461
1548
|
await this.getReady(control);
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
throwIfCodeRequestInterrupted(control);
|
|
1465
|
-
retryGuard.assertCurrent();
|
|
1466
|
-
const rpc = this.rpc;
|
|
1467
|
-
if (!rpc || this._status !== "running") {
|
|
1468
|
-
throw new Error(`LSP request ${method} is unavailable because the client is not running.`);
|
|
1469
|
-
}
|
|
1470
|
-
const data = await operation({
|
|
1471
|
-
rpc,
|
|
1472
|
-
assertCurrent: async () => {
|
|
1549
|
+
return this.diagnostics.withSemanticRequest(
|
|
1550
|
+
async () => {
|
|
1473
1551
|
retryGuard.assertCurrent();
|
|
1474
|
-
|
|
1552
|
+
const inputSnapshot = await this.diagnostics.synchronizeSemanticInputs(control);
|
|
1553
|
+
attempt.inputRevision = inputSnapshot.revision;
|
|
1554
|
+
throwIfCodeRequestInterrupted(control);
|
|
1555
|
+
retryGuard.assertCurrent();
|
|
1556
|
+
const rpc = this.rpc;
|
|
1557
|
+
if (!rpc || this._status !== "running") {
|
|
1558
|
+
throw new Error(
|
|
1559
|
+
`LSP request ${method} is unavailable because the client is not running.`,
|
|
1560
|
+
);
|
|
1561
|
+
}
|
|
1562
|
+
const data = await operation({
|
|
1563
|
+
rpc,
|
|
1564
|
+
assertCurrent: async () => {
|
|
1565
|
+
retryGuard.assertCurrent();
|
|
1566
|
+
return this.diagnostics.assertSemanticInputsCurrent(inputSnapshot, control);
|
|
1567
|
+
},
|
|
1568
|
+
});
|
|
1569
|
+
const verifiedSnapshot = await this.diagnostics.assertSemanticInputsCurrent(
|
|
1570
|
+
inputSnapshot,
|
|
1571
|
+
control,
|
|
1572
|
+
);
|
|
1573
|
+
onVerifiedSnapshot?.(verifiedSnapshot);
|
|
1574
|
+
return data ?? null;
|
|
1475
1575
|
},
|
|
1476
|
-
});
|
|
1477
|
-
const verifiedSnapshot = await this.diagnostics.assertSemanticInputsCurrent(
|
|
1478
|
-
inputSnapshot,
|
|
1479
1576
|
control,
|
|
1577
|
+
options.documentUri,
|
|
1480
1578
|
);
|
|
1481
|
-
onVerifiedSnapshot?.(verifiedSnapshot);
|
|
1482
|
-
return data ?? null;
|
|
1483
1579
|
}
|
|
1484
1580
|
|
|
1485
1581
|
private async query<T>(
|
|
@@ -1651,17 +1747,28 @@ export class LspClient {
|
|
|
1651
1747
|
throwIfCodeRequestInterrupted(control);
|
|
1652
1748
|
retryGuard.assertCurrent();
|
|
1653
1749
|
await this.getReady(control);
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1750
|
+
// Local query methods construct the standard TextDocumentIdentifier envelope.
|
|
1751
|
+
const documentUri = (params as { textDocument?: { uri: string } } | null)?.textDocument?.uri;
|
|
1752
|
+
return this.diagnostics.withSemanticRequest(
|
|
1753
|
+
async () => {
|
|
1754
|
+
retryGuard.assertCurrent();
|
|
1755
|
+
const inputSnapshot = await this.diagnostics.synchronizeSemanticInputs(control);
|
|
1756
|
+
attempt.inputRevision = inputSnapshot.revision;
|
|
1757
|
+
throwIfCodeRequestInterrupted(control);
|
|
1758
|
+
retryGuard.assertCurrent();
|
|
1759
|
+
const rpc = this.rpc;
|
|
1760
|
+
if (!rpc || this._status !== "running") {
|
|
1761
|
+
throw new Error(
|
|
1762
|
+
`LSP request ${method} is unavailable because the client is not running.`,
|
|
1763
|
+
);
|
|
1764
|
+
}
|
|
1765
|
+
const data = (await rpc.sendRequest(method, params, control)) as T | null | undefined;
|
|
1766
|
+
await this.diagnostics.assertSemanticInputsCurrent(inputSnapshot, control);
|
|
1767
|
+
return data ?? null;
|
|
1768
|
+
},
|
|
1769
|
+
control,
|
|
1770
|
+
documentUri,
|
|
1771
|
+
);
|
|
1665
1772
|
}
|
|
1666
1773
|
|
|
1667
1774
|
private handleServerRequest(method: string, params: unknown): unknown {
|
|
@@ -162,6 +162,16 @@ export function getFileScopeDecision(filePath: string, cwd: string): FileScopeDe
|
|
|
162
162
|
};
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
/**
|
|
166
|
+
* Whether a project config's program includes a file, by its own file set or
|
|
167
|
+
* include/exclude rules. Unlike {@link getFileScopeDecision}, the config need
|
|
168
|
+
* not be the file's nearest one (e.g. a test config including `../src`).
|
|
169
|
+
*/
|
|
170
|
+
export function projectConfigIncludesFile(configPath: string, filePath: string): boolean {
|
|
171
|
+
const parsed = parseProjectConfig(configPath);
|
|
172
|
+
return parsed !== null && decideFileScope(parsed, path.resolve(filePath)).included;
|
|
173
|
+
}
|
|
174
|
+
|
|
165
175
|
function isOutOfTree(cwd: string, absolutePath: string): boolean {
|
|
166
176
|
const relative = path.relative(path.resolve(cwd), absolutePath);
|
|
167
177
|
return relative.startsWith(`..${path.sep}`) || relative === "..";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Explain the lack of request-based confirmation when only ambient push evidence is available. */
|
|
2
2
|
export const TENTATIVE_PUSH_UNAVAILABLE_REASON =
|
|
3
|
-
"
|
|
3
|
+
"Only ambient push diagnostics are available for this file; completion of the diagnostic check cannot be confirmed. An empty publication cannot confirm that the document is clean.";
|
|
4
4
|
|
|
5
5
|
/** A final evidence state for one tracked document. */
|
|
6
6
|
export type DiagnosticEvidenceStatus = "confirmed" | "unconfirmed" | "failed" | "removed";
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { collectExtendedProjectConfigs } from "../config/tsconfig-extends.ts";
|
|
3
|
+
import { normalizeTsconfigPath } from "../config/tsconfig-path.ts";
|
|
4
|
+
import { getFileScopeDecision, projectConfigIncludesFile } from "../config/tsconfig-scope.ts";
|
|
5
|
+
import { type AutomaticLspPathPolicy, walkAutomaticLspTree } from "../workspace-path-policy.ts";
|
|
6
|
+
|
|
7
|
+
/** Directory depth searched for project configs below a TypeScript route root. */
|
|
8
|
+
const PROJECT_CONFIG_SCAN_DEPTH = 8;
|
|
9
|
+
/** Project config paths named in one coverage reason. */
|
|
10
|
+
const MAX_NAMED_PROJECTS = 3;
|
|
11
|
+
/** File names tsserver loads as projects; `tsconfig.*.json` variants are never auto-loaded. */
|
|
12
|
+
const LOADABLE_PROJECT_CONFIGS = new Set(["tsconfig.json", "jsconfig.json"]);
|
|
13
|
+
|
|
14
|
+
/** Find loadable project configs under a route root, honoring the automatic path policy. */
|
|
15
|
+
export function discoverTypeScriptProjectConfigs(
|
|
16
|
+
root: string,
|
|
17
|
+
policy: AutomaticLspPathPolicy,
|
|
18
|
+
): string[] {
|
|
19
|
+
const configs: string[] = [];
|
|
20
|
+
walkAutomaticLspTree(policy, root, PROJECT_CONFIG_SCAN_DEPTH, (directory, entries) => {
|
|
21
|
+
for (const entry of entries) {
|
|
22
|
+
if (entry.isFile() && LOADABLE_PROJECT_CONFIGS.has(entry.name)) {
|
|
23
|
+
configs.push(path.join(directory, entry.name));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return false;
|
|
27
|
+
});
|
|
28
|
+
return configs;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Project configs whose program includes `file` but show no sign of being loaded.
|
|
33
|
+
*
|
|
34
|
+
* tsserver answers project-wide requests such as references and
|
|
35
|
+
* implementations only from loaded projects, and loads a project only when one
|
|
36
|
+
* of its files is open. An unloaded including project (typically a test
|
|
37
|
+
* config that includes `../src`) can hold matches the answer omits.
|
|
38
|
+
*
|
|
39
|
+
* A project counts as loaded when it is the nearest config of an open file or
|
|
40
|
+
* of a file in the answer (`resultFiles`): tsserver also loads projects on its
|
|
41
|
+
* own during a search, and a match from a project's own file proves it was searched.
|
|
42
|
+
*
|
|
43
|
+
* Base configs extended by another discovered config are skipped: their
|
|
44
|
+
* default include usually spans the whole tree while they own only root
|
|
45
|
+
* tooling files, so counting them would mark nearly every answer partial.
|
|
46
|
+
*/
|
|
47
|
+
export function findUnloadedTypeScriptProjects(options: {
|
|
48
|
+
readonly file: string;
|
|
49
|
+
readonly cwd: string;
|
|
50
|
+
readonly openFiles: readonly string[];
|
|
51
|
+
readonly resultFiles?: readonly string[];
|
|
52
|
+
readonly configs: readonly string[];
|
|
53
|
+
}): string[] {
|
|
54
|
+
const baseConfigs = new Set(
|
|
55
|
+
options.configs.flatMap((config) => [...collectExtendedProjectConfigs(config)]),
|
|
56
|
+
);
|
|
57
|
+
const loaded = new Set<string>();
|
|
58
|
+
for (const loadedFile of [...options.openFiles, ...(options.resultFiles ?? [])]) {
|
|
59
|
+
const decision = getFileScopeDecision(loadedFile, options.cwd);
|
|
60
|
+
if (decision.status === "included" && decision.configPath) {
|
|
61
|
+
loaded.add(normalizeTsconfigPath(decision.configPath));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return options.configs.filter((config) => {
|
|
65
|
+
const key = normalizeTsconfigPath(config);
|
|
66
|
+
return (
|
|
67
|
+
!loaded.has(key) && !baseConfigs.has(key) && projectConfigIncludesFile(config, options.file)
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Other projects that tsserver's implementation search skips for `file`.
|
|
74
|
+
*
|
|
75
|
+
* tsserver 6 and earlier answer `textDocument/implementation` from the file's
|
|
76
|
+
* default project only, unlike references, which span loaded projects. Any
|
|
77
|
+
* other non-base project may implement the target through an import, and
|
|
78
|
+
* opening its files does not change the answer.
|
|
79
|
+
*/
|
|
80
|
+
export function findSkippedImplementationProjects(options: {
|
|
81
|
+
readonly file: string;
|
|
82
|
+
readonly cwd: string;
|
|
83
|
+
readonly configs: readonly string[];
|
|
84
|
+
}): string[] {
|
|
85
|
+
const baseConfigs = new Set(
|
|
86
|
+
options.configs.flatMap((config) => [...collectExtendedProjectConfigs(config)]),
|
|
87
|
+
);
|
|
88
|
+
const decision = getFileScopeDecision(options.file, options.cwd);
|
|
89
|
+
const own = decision.configPath ? normalizeTsconfigPath(decision.configPath) : undefined;
|
|
90
|
+
return options.configs.filter((config) => {
|
|
91
|
+
const key = normalizeTsconfigPath(config);
|
|
92
|
+
return key !== own && !baseConfigs.has(key);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Partial-result reason for tsserver's single-project implementation search. */
|
|
97
|
+
export function singleProjectImplementationReason(skipped: readonly string[]): string {
|
|
98
|
+
const noun = skipped.length === 1 ? "1 other project is" : `${skipped.length} other projects are`;
|
|
99
|
+
return (
|
|
100
|
+
"TypeScript coverage is limited: the TypeScript 6 or earlier server (tsserver) " +
|
|
101
|
+
`searches implementations only in this file's project; ${noun} not searched. ` +
|
|
102
|
+
"Use references to find uses in other projects, or use TypeScript 7."
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Actionable partial-result reason naming a bounded set of unloaded projects. */
|
|
107
|
+
export function typeScriptCoverageGapReason(unloaded: readonly string[], cwd: string): string {
|
|
108
|
+
const named = unloaded
|
|
109
|
+
.slice(0, MAX_NAMED_PROJECTS)
|
|
110
|
+
.map((config) => path.relative(cwd, config) || path.basename(config));
|
|
111
|
+
const more = unloaded.length > named.length ? `, +${unloaded.length - named.length} more` : "";
|
|
112
|
+
const noun = unloaded.length === 1 ? "project that includes" : "projects that include";
|
|
113
|
+
return (
|
|
114
|
+
`TypeScript coverage is limited: ${unloaded.length} ${noun} this file ` +
|
|
115
|
+
`${unloaded.length === 1 ? "is" : "are"} not loaded (${named.join(", ")}${more}). ` +
|
|
116
|
+
"Open a file from each project and retry."
|
|
117
|
+
);
|
|
118
|
+
}
|