@trim21/personal-pi-extensions 0.1.593 → 0.1.594
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/package.json +1 -1
- package/src/lib/lsp/client.ts +68 -36
- package/src/lib/lsp/rename.ts +42 -0
package/package.json
CHANGED
package/src/lib/lsp/client.ts
CHANGED
|
@@ -32,7 +32,7 @@ import type {
|
|
|
32
32
|
|
|
33
33
|
import type { LspServerHandle } from "./adapter.js";
|
|
34
34
|
import { LANGUAGE_EXTENSIONS } from "./language.js";
|
|
35
|
-
import { editFilePaths } from "./rename.js";
|
|
35
|
+
import { editFilePaths, stabilityAcceptable, trackStability } from "./rename.js";
|
|
36
36
|
import type { FileChange, FileChangeType } from "./watcher.js";
|
|
37
37
|
|
|
38
38
|
// LSP spec 常量
|
|
@@ -61,13 +61,6 @@ const FILE_CHANGE_TYPE: Record<FileChangeType, number> = {
|
|
|
61
61
|
|
|
62
62
|
export type Diagnostic = VSCodeDiagnostic;
|
|
63
63
|
|
|
64
|
-
/** 两个路径集合是否一致(用于判断 references 结果是否收敛)。 */
|
|
65
|
-
function samePaths(a: Set<string>, b: Set<string>): boolean {
|
|
66
|
-
if (a.size !== b.size) return false;
|
|
67
|
-
for (const path of a) if (!b.has(path)) return false;
|
|
68
|
-
return true;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
64
|
/** LSP MethodNotFound(-32601):服务器未实现 prepareRename / rename 请求。 */
|
|
72
65
|
const LSP_METHOD_NOT_FOUND = -32601;
|
|
73
66
|
|
|
@@ -115,6 +108,9 @@ export class RenameIncompleteError extends Error {
|
|
|
115
108
|
`that textDocument/references did not report (${extra.join(", ")})`,
|
|
116
109
|
);
|
|
117
110
|
}
|
|
111
|
+
if (parts.length === 0) {
|
|
112
|
+
parts.push("the references result has not been stable long enough to trust");
|
|
113
|
+
}
|
|
118
114
|
super(
|
|
119
115
|
`LSP rename incomplete: ${parts.join("; ")}. ` +
|
|
120
116
|
`The server index may still be loading; nothing was modified, retry shortly.`,
|
|
@@ -130,9 +126,20 @@ export class RenameIncompleteError extends Error {
|
|
|
130
126
|
*/
|
|
131
127
|
export const renameVerificationTiming = {
|
|
132
128
|
pollMs: 400,
|
|
133
|
-
budgetMs:
|
|
129
|
+
budgetMs: 15_000,
|
|
134
130
|
/** ContentModified(-32801) 重试上限:服务器处理期间文档被修改,重发请求即可。 */
|
|
135
131
|
contentModifiedRetries: 3,
|
|
132
|
+
/** 接受结果所需的最少连续一致采样次数。 */
|
|
133
|
+
settleSamples: 3,
|
|
134
|
+
/** 就绪已证实(拿到过当前版本的诊断结论)时的稳定窗口下限(ms)。 */
|
|
135
|
+
stableFloorReadyMs: 400,
|
|
136
|
+
/**
|
|
137
|
+
* 就绪未证实(栅栏只是等满预算放行)时的稳定窗口下限(ms)。服务器加载
|
|
138
|
+
* 项目期间 references 只覆盖已发现的文件,残缺答案能连续多次一致(假稳定),
|
|
139
|
+
* 短窗口会把它误判成最终结果——CI 实测漏改跨文件引用,故要求 references
|
|
140
|
+
* 文件集合至少持续一致这么久才接受。
|
|
141
|
+
*/
|
|
142
|
+
stableFloorUnreadyMs: 4_000,
|
|
136
143
|
};
|
|
137
144
|
|
|
138
145
|
/**
|
|
@@ -1027,19 +1034,24 @@ export async function create(input: CreateInput): Promise<LspClient> {
|
|
|
1027
1034
|
});
|
|
1028
1035
|
}
|
|
1029
1036
|
|
|
1037
|
+
/**
|
|
1038
|
+
* 等待「当前文档版本」的诊断结论。返回值表示就绪是否被证实:true = 拿到了
|
|
1039
|
+
* 当前版本的诊断结果(push / pull / 已有结论);false = 等满预算或连接关闭
|
|
1040
|
+
* 都没等到——如 typescript-language-server 对干净文档永不推送、也不实现 pull。
|
|
1041
|
+
*/
|
|
1030
1042
|
async function waitForDocumentDiagnostics(request: {
|
|
1031
1043
|
path: string;
|
|
1032
1044
|
version: number;
|
|
1033
1045
|
after?: number;
|
|
1034
1046
|
signal?: AbortSignal;
|
|
1035
|
-
}): Promise<
|
|
1047
|
+
}): Promise<boolean> {
|
|
1036
1048
|
// 服务器对当前内容的最新结论已在手(最后一次 push 晚于最后一次内容同步,
|
|
1037
1049
|
// 即此后没有再通知过内容变化):直接返回已有结果。部分服务器在诊断集合
|
|
1038
1050
|
// 不变时不再推送(typescript-language-server 空→空不发布),等新 push 只会
|
|
1039
1051
|
// 耗满整个窗口后得到同样的「无诊断」。
|
|
1040
1052
|
const known = published.get(request.path);
|
|
1041
1053
|
const document = files[request.path];
|
|
1042
|
-
if (known !== undefined && document !== undefined && known.at >= document.syncedAt) return;
|
|
1054
|
+
if (known !== undefined && document !== undefined && known.at >= document.syncedAt) return true;
|
|
1043
1055
|
|
|
1044
1056
|
const startedAt = request.after ?? Date.now();
|
|
1045
1057
|
// 「上一份 push 为空」的文档用短安静期(见 clientDefaults.diagnosticsSilentWaitTimeoutMs)。
|
|
@@ -1059,12 +1071,11 @@ export async function create(input: CreateInput): Promise<LspClient> {
|
|
|
1059
1071
|
|
|
1060
1072
|
while (!connectionClosed && !request.signal?.aborted) {
|
|
1061
1073
|
const remaining = budget - (Date.now() - startedAt);
|
|
1062
|
-
if (remaining <= 0) return;
|
|
1074
|
+
if (remaining <= 0) return false;
|
|
1063
1075
|
const result = await requestDocumentDiagnostics(request.path);
|
|
1064
|
-
if (result.matched) return;
|
|
1076
|
+
if (result.matched) return true;
|
|
1065
1077
|
if (result.timedOut) {
|
|
1066
|
-
await pushWait;
|
|
1067
|
-
return;
|
|
1078
|
+
return await pushWait;
|
|
1068
1079
|
}
|
|
1069
1080
|
const next = await Promise.race([
|
|
1070
1081
|
pushWait.then((ready) => (ready ? ("push" as const) : ("timeout" as const))),
|
|
@@ -1073,8 +1084,9 @@ export async function create(input: CreateInput): Promise<LspClient> {
|
|
|
1073
1084
|
),
|
|
1074
1085
|
sleep(Math.min(remaining, PULL_RETRY_INTERVAL_MS)).then(() => "interval" as const),
|
|
1075
1086
|
]);
|
|
1076
|
-
if (next === "push") return;
|
|
1087
|
+
if (next === "push") return true;
|
|
1077
1088
|
}
|
|
1089
|
+
return false;
|
|
1078
1090
|
}
|
|
1079
1091
|
|
|
1080
1092
|
async function waitForFullDiagnostics(request: {
|
|
@@ -1301,13 +1313,15 @@ export async function create(input: CreateInput): Promise<LspClient> {
|
|
|
1301
1313
|
// rename 前强制同步磁盘内容,保证服务器基于最新文本计算编辑
|
|
1302
1314
|
const version = await openDocument({ path: resolvedPath });
|
|
1303
1315
|
// 索引就绪栅栏:项目异步加载完成前,服务器对 references 的答复只包含当前
|
|
1304
|
-
//
|
|
1305
|
-
//
|
|
1306
|
-
//
|
|
1307
|
-
//
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1316
|
+
// 打开的文件(尚未发现其它文件),稳定窗口太短会把它误判成"索引已收敛",
|
|
1317
|
+
// 于是 rename 漏掉跨文件引用——CI 上实测到。服务器为本文件产生的第一份诊断
|
|
1318
|
+
// 报告要等项目加载完成,用它当就绪信号;本会话已经收到过报告(文档早已驻留)
|
|
1319
|
+
// 时不再等待,免得给常见路径白加延迟。返回值记录就绪是否被证实:证明不了
|
|
1320
|
+
//(typescript-language-server 对干净文档永不推送、也不实现 pull,栅栏只能
|
|
1321
|
+
// 等满预算放行)时,下面的稳定窗口用更长的下限。
|
|
1322
|
+
let indexReady = published.has(resolvedPath) || pullDiagnostics.has(resolvedPath);
|
|
1323
|
+
if (!indexReady) {
|
|
1324
|
+
indexReady = await waitForDocumentDiagnostics({
|
|
1311
1325
|
path: resolvedPath,
|
|
1312
1326
|
version,
|
|
1313
1327
|
signal: request.signal,
|
|
@@ -1321,8 +1335,9 @@ export async function create(input: CreateInput): Promise<LspClient> {
|
|
|
1321
1335
|
// references 前置 + rename 双向校验:LSP 没有标准化的"索引完成"信号,
|
|
1322
1336
|
// 服务器(如 tsserver)可能在项目加载完成前回答,导致 rename 漏掉
|
|
1323
1337
|
// 尚未入索引的文件。对策分三层:
|
|
1324
|
-
// 1.
|
|
1325
|
-
// "
|
|
1338
|
+
// 1. 稳定窗口:references 文件集合连续 settleSamples 次一致、且持续
|
|
1339
|
+
// 一致超过稳定下限(就绪未证实用长窗口)才认为收敛,防止"服务器
|
|
1340
|
+
// 根本还没发现某文件"时残缺答案的假稳定被误判为最终结果;
|
|
1326
1341
|
// 2. 覆盖校验(missing):references 报告的文件必须都被 rename edit
|
|
1327
1342
|
// 覆盖,缺失说明服务器索引落后,抛 RenameIncompleteError;
|
|
1328
1343
|
// 3. 一致性校验(extra):rename 触及的文件超出已收敛的 references
|
|
@@ -1404,37 +1419,54 @@ export async function create(input: CreateInput): Promise<LspClient> {
|
|
|
1404
1419
|
return placeholder === undefined ? { edit } : { edit, placeholder };
|
|
1405
1420
|
}
|
|
1406
1421
|
|
|
1422
|
+
const minStableMs = indexReady
|
|
1423
|
+
? renameVerificationTiming.stableFloorReadyMs
|
|
1424
|
+
: renameVerificationTiming.stableFloorUnreadyMs;
|
|
1407
1425
|
const deadline = Date.now() + renameVerificationTiming.budgetMs;
|
|
1408
|
-
let
|
|
1409
|
-
|
|
1426
|
+
let stability = trackStability({
|
|
1427
|
+
previous: undefined,
|
|
1428
|
+
paths: toPaths(locations),
|
|
1429
|
+
now: Date.now(),
|
|
1430
|
+
});
|
|
1410
1431
|
for (;;) {
|
|
1411
1432
|
request.signal?.throwIfAborted();
|
|
1412
|
-
const
|
|
1413
|
-
const
|
|
1414
|
-
|
|
1433
|
+
const now = Date.now();
|
|
1434
|
+
const stable = stabilityAcceptable(stability, {
|
|
1435
|
+
now,
|
|
1436
|
+
minSamples: renameVerificationTiming.settleSamples,
|
|
1437
|
+
minStableMs,
|
|
1438
|
+
});
|
|
1439
|
+
const expired = now >= deadline;
|
|
1440
|
+
if (stable || expired) {
|
|
1415
1441
|
const edit = await sendRename();
|
|
1416
1442
|
if (!edit) throw notRenameable();
|
|
1417
1443
|
const editPaths = editFilePaths(edit);
|
|
1418
1444
|
const missing: string[] = [];
|
|
1419
1445
|
const extra: string[] = [];
|
|
1420
|
-
for (const path of
|
|
1446
|
+
for (const path of stability.paths) {
|
|
1421
1447
|
if (!editPaths.has(path)) missing.push(path);
|
|
1422
1448
|
}
|
|
1423
1449
|
for (const path of editPaths) {
|
|
1424
|
-
if (!
|
|
1450
|
+
if (!stability.paths.has(path)) extra.push(path);
|
|
1425
1451
|
}
|
|
1426
1452
|
if (missing.length === 0 && extra.length === 0) {
|
|
1427
|
-
return placeholder === undefined ? { edit } : { edit, placeholder };
|
|
1453
|
+
if (stable) return placeholder === undefined ? { edit } : { edit, placeholder };
|
|
1454
|
+
// 双向一致但稳定窗口未达标:索引可能仍在加载、残缺答案假稳定,
|
|
1455
|
+
// 宁可报可重试的不完整错误,也不把残缺结果当成功写盘。
|
|
1456
|
+
throw new RenameIncompleteError([], []);
|
|
1428
1457
|
}
|
|
1429
1458
|
if (expired || missing.length > 0) {
|
|
1430
1459
|
throw new RenameIncompleteError(missing, extra);
|
|
1431
1460
|
}
|
|
1432
|
-
//
|
|
1461
|
+
// rename 报出 references 没有的文件:references 快照已过时,
|
|
1433
1462
|
// 继续轮询到重新收敛后再重发 rename 复检(预算耗尽则向上抛)。
|
|
1434
1463
|
}
|
|
1435
|
-
previous = current;
|
|
1436
1464
|
await sleepWithSignal(renameVerificationTiming.pollMs, request.signal);
|
|
1437
|
-
|
|
1465
|
+
stability = trackStability({
|
|
1466
|
+
previous: stability,
|
|
1467
|
+
paths: toPaths(await referencesRequest()),
|
|
1468
|
+
now: Date.now(),
|
|
1469
|
+
});
|
|
1438
1470
|
}
|
|
1439
1471
|
},
|
|
1440
1472
|
async definition(request: InspectPositionRequest): Promise<InspectLocation[]> {
|
package/src/lib/lsp/rename.ts
CHANGED
|
@@ -192,6 +192,48 @@ export function canonicalizeEdit(edit: WorkspaceEdit): string {
|
|
|
192
192
|
|
|
193
193
|
const WORD_PATTERN = /[\p{L}\p{N}_$]+/gu;
|
|
194
194
|
|
|
195
|
+
/** 两个路径集合是否一致(用于判断 references 结果是否收敛)。 */
|
|
196
|
+
export function samePathSet(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
|
|
197
|
+
if (a.size !== b.size) return false;
|
|
198
|
+
for (const path of a) if (!b.has(path)) return false;
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** references 文件集合的稳定窗口:连续一致的采样累加计数,集合变化即重启窗口。 */
|
|
203
|
+
export interface StabilityRun {
|
|
204
|
+
/** 本窗口采样到的文件集合(与后续采样比较用)。 */
|
|
205
|
+
readonly paths: ReadonlySet<string>;
|
|
206
|
+
/** 窗口起始时间戳(ms,最后一次集合变化的时刻)。 */
|
|
207
|
+
readonly since: number;
|
|
208
|
+
/** 窗口内连续一致的采样次数(≥1)。 */
|
|
209
|
+
readonly samples: number;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* 用一次新的采样推进稳定窗口。服务器项目加载期间 references 只覆盖已发现
|
|
214
|
+
* 的文件,残缺答案可以连续多次一致——「两次一致」不等于「索引收敛」,所以
|
|
215
|
+
* 窗口还要持续足够时长(见 stabilityAcceptable)。
|
|
216
|
+
*/
|
|
217
|
+
export function trackStability(input: {
|
|
218
|
+
previous: StabilityRun | undefined;
|
|
219
|
+
paths: ReadonlySet<string>;
|
|
220
|
+
now: number;
|
|
221
|
+
}): StabilityRun {
|
|
222
|
+
const { previous, paths, now } = input;
|
|
223
|
+
if (previous !== undefined && samePathSet(previous.paths, paths)) {
|
|
224
|
+
return { paths, since: previous.since, samples: previous.samples + 1 };
|
|
225
|
+
}
|
|
226
|
+
return { paths, since: now, samples: 1 };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** 稳定窗口是否达到可接受的稳定度:连续 minSamples 次一致,且集合已持续一致 minStableMs 毫秒。 */
|
|
230
|
+
export function stabilityAcceptable(
|
|
231
|
+
run: StabilityRun,
|
|
232
|
+
input: { now: number; minSamples: number; minStableMs: number },
|
|
233
|
+
): boolean {
|
|
234
|
+
return run.samples >= input.minSamples && input.now - run.since >= input.minStableMs;
|
|
235
|
+
}
|
|
236
|
+
|
|
195
237
|
/**
|
|
196
238
|
* 一行内 `symbol` 的候选位置(0-based,列相对行首):
|
|
197
239
|
* - `character` 缺省:枚举行内与 `symbol` 相同的词出现位置;
|