@trim21/personal-pi-extensions 0.0.299 → 0.0.300
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 +3 -1
- package/src/claude-code/files.ts +168 -137
- package/src/lib/lsp/adapter.ts +41 -0
- package/src/lib/lsp/adapters/clangd.ts +26 -0
- package/src/lib/lsp/adapters/index.ts +11 -0
- package/src/lib/lsp/adapters/pyright.ts +50 -0
- package/src/lib/lsp/adapters/ruff.ts +27 -0
- package/src/lib/lsp/adapters/typescript.ts +34 -0
- package/src/lib/lsp/bin.ts +88 -0
- package/src/lib/lsp/client.ts +693 -0
- package/src/lib/lsp/diagnostic.ts +35 -0
- package/src/lib/lsp/language.ts +125 -0
- package/src/lib/lsp/launch.ts +22 -0
- package/src/lib/lsp/lsp.ts +285 -0
- package/src/opencode/{read.ts → files.ts} +276 -31
- package/src/opencode/index.ts +13 -14
- package/src/spawn-agent.ts +6 -4
- package/src/opencode/edit.ts +0 -169
- package/src/opencode/write.ts +0 -116
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LSP 客户端:单条语言服务器连接的封装(移植自 opencode lsp/client.ts)。
|
|
3
|
+
*
|
|
4
|
+
* - vscode-jsonrpc 消息连接 + initialize/initialized 握手;
|
|
5
|
+
* - didOpen / didChange(按服务器 textDocumentSync 适配增量或全量);
|
|
6
|
+
* - 诊断双通道:push(textDocument/publishDiagnostics)+ pull
|
|
7
|
+
* (textDocument/diagnostic、workspace/diagnostic,支持动态注册);
|
|
8
|
+
* - waitForDiagnostics:document 模式最多等 5s、full 模式最多等 10s,
|
|
9
|
+
* push 通知带 150ms debounce,pull 请求 3s 超时。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFile } from "node:fs/promises";
|
|
13
|
+
import { extname, isAbsolute, normalize, resolve } from "node:path";
|
|
14
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
createMessageConnection,
|
|
18
|
+
type MessageConnection,
|
|
19
|
+
StreamMessageReader,
|
|
20
|
+
StreamMessageWriter,
|
|
21
|
+
} from "vscode-jsonrpc/node";
|
|
22
|
+
import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types";
|
|
23
|
+
|
|
24
|
+
import type { LspServerHandle } from "./adapter.js";
|
|
25
|
+
import { LANGUAGE_EXTENSIONS } from "./language.js";
|
|
26
|
+
|
|
27
|
+
// LSP spec 常量
|
|
28
|
+
const FILE_CHANGE_CREATED = 1;
|
|
29
|
+
const FILE_CHANGE_CHANGED = 2;
|
|
30
|
+
const TEXT_DOCUMENT_SYNC_INCREMENTAL = 2;
|
|
31
|
+
|
|
32
|
+
export type Diagnostic = VSCodeDiagnostic;
|
|
33
|
+
|
|
34
|
+
export class InitializeError extends Error {
|
|
35
|
+
readonly serverID: string;
|
|
36
|
+
constructor(serverID: string, cause: unknown) {
|
|
37
|
+
super(`Failed to initialize LSP server ${serverID}`, { cause });
|
|
38
|
+
this.serverID = serverID;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface DocumentDiagnosticReport {
|
|
43
|
+
items?: Diagnostic[];
|
|
44
|
+
relatedDocuments?: Record<string, DocumentDiagnosticReport>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface WorkspaceDiagnosticReport {
|
|
48
|
+
items?: { uri?: string; items?: Diagnostic[] }[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface DiagnosticRequestResult {
|
|
52
|
+
handled: boolean;
|
|
53
|
+
matched: boolean;
|
|
54
|
+
byFile: Map<string, Diagnostic[]>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface CapabilityRegistration {
|
|
58
|
+
id: string;
|
|
59
|
+
method: string;
|
|
60
|
+
registerOptions?: {
|
|
61
|
+
identifier?: string;
|
|
62
|
+
workspaceDiagnostics?: boolean;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface ServerCapabilities {
|
|
67
|
+
textDocumentSync?:
|
|
68
|
+
| number
|
|
69
|
+
| {
|
|
70
|
+
change?: number;
|
|
71
|
+
};
|
|
72
|
+
diagnosticProvider?: unknown;
|
|
73
|
+
[key: string]: unknown;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface CreateInput {
|
|
77
|
+
serverID: string;
|
|
78
|
+
server: LspServerHandle;
|
|
79
|
+
root: string;
|
|
80
|
+
directory: string;
|
|
81
|
+
/** 可覆盖的超时参数(缺省用 client 默认值,由全局/本地 lsp.json 配置注入)。 */
|
|
82
|
+
diagnosticsDebounceMs?: number;
|
|
83
|
+
diagnosticsDocumentWaitTimeoutMs?: number;
|
|
84
|
+
diagnosticsFullWaitTimeoutMs?: number;
|
|
85
|
+
diagnosticsRequestTimeoutMs?: number;
|
|
86
|
+
initializeTimeoutMs?: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface LspClient {
|
|
90
|
+
readonly root: string;
|
|
91
|
+
readonly serverID: string;
|
|
92
|
+
readonly connection: MessageConnection;
|
|
93
|
+
readonly notify: {
|
|
94
|
+
open(request: { path: string }): Promise<number>;
|
|
95
|
+
};
|
|
96
|
+
readonly diagnostics: Map<string, Diagnostic[]>;
|
|
97
|
+
waitForDiagnostics(request: {
|
|
98
|
+
path: string;
|
|
99
|
+
version: number;
|
|
100
|
+
mode?: "document" | "full";
|
|
101
|
+
after?: number;
|
|
102
|
+
}): Promise<void>;
|
|
103
|
+
shutdown(): Promise<void>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type Info = LspClient;
|
|
107
|
+
|
|
108
|
+
function getFilePath(uri: string): string | undefined {
|
|
109
|
+
if (!uri.startsWith("file://")) return undefined;
|
|
110
|
+
return normalize(fileURLToPath(uri));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function getSyncKind(capabilities?: ServerCapabilities): number | undefined {
|
|
114
|
+
if (!capabilities) return undefined;
|
|
115
|
+
const sync = capabilities.textDocumentSync;
|
|
116
|
+
if (typeof sync === "number") return sync;
|
|
117
|
+
return sync?.change;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function hasCurrentFileDiagnostics(filePath: string, results: DiagnosticRequestResult[]) {
|
|
121
|
+
return results.some((result) => (result.byFile.get(filePath)?.length ?? 0) > 0);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function endPosition(text: string): { line: number; character: number } {
|
|
125
|
+
const lines = text.split(/\r\n|\r|\n/);
|
|
126
|
+
return {
|
|
127
|
+
line: lines.length - 1,
|
|
128
|
+
character: lines.at(-1)?.length ?? 0,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function dedupeDiagnostics(items: Diagnostic[]): Diagnostic[] {
|
|
133
|
+
const seen = new Set<string>();
|
|
134
|
+
return items.filter((item) => {
|
|
135
|
+
const key = JSON.stringify({
|
|
136
|
+
code: item.code,
|
|
137
|
+
severity: item.severity,
|
|
138
|
+
message: item.message,
|
|
139
|
+
source: item.source,
|
|
140
|
+
range: item.range,
|
|
141
|
+
});
|
|
142
|
+
if (seen.has(key)) return false;
|
|
143
|
+
seen.add(key);
|
|
144
|
+
return true;
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function configurationValue(settings: unknown, section?: string): unknown {
|
|
149
|
+
if (!section) return settings ?? null;
|
|
150
|
+
const result = section.split(".").reduce<unknown>((acc, key) => {
|
|
151
|
+
if (!acc || typeof acc !== "object" || !(key in acc)) return;
|
|
152
|
+
return (acc as Record<string, unknown>)[key];
|
|
153
|
+
}, settings);
|
|
154
|
+
return result ?? null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
158
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
159
|
+
try {
|
|
160
|
+
return await Promise.race([
|
|
161
|
+
promise,
|
|
162
|
+
new Promise<never>((resolve, reject) => {
|
|
163
|
+
timer = setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms);
|
|
164
|
+
}),
|
|
165
|
+
]);
|
|
166
|
+
} finally {
|
|
167
|
+
if (timer) clearTimeout(timer);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function stopProcess(process: LspServerHandle["process"]): Promise<void> {
|
|
172
|
+
if (process.exitCode !== null) return Promise.resolve();
|
|
173
|
+
process.kill();
|
|
174
|
+
return new Promise((resolve) => {
|
|
175
|
+
process.once("exit", () => resolve());
|
|
176
|
+
setTimeout(() => process.kill("SIGKILL"), 1_000).unref();
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function create(input: CreateInput): Promise<LspClient> {
|
|
181
|
+
const diagnosticsDebounceMs = input.diagnosticsDebounceMs ?? 150;
|
|
182
|
+
const diagnosticsDocumentWaitTimeoutMs = input.diagnosticsDocumentWaitTimeoutMs ?? 5_000;
|
|
183
|
+
const diagnosticsFullWaitTimeoutMs = input.diagnosticsFullWaitTimeoutMs ?? 10_000;
|
|
184
|
+
const diagnosticsRequestTimeoutMs = input.diagnosticsRequestTimeoutMs ?? 3_000;
|
|
185
|
+
const initializeTimeoutMs = input.initializeTimeoutMs ?? 45_000;
|
|
186
|
+
|
|
187
|
+
const connection = createMessageConnection(
|
|
188
|
+
new StreamMessageReader(input.server.process.stdout),
|
|
189
|
+
new StreamMessageWriter(input.server.process.stdin),
|
|
190
|
+
);
|
|
191
|
+
input.server.process.stderr?.resume();
|
|
192
|
+
|
|
193
|
+
// ── 连接状态 ────────────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
const pushDiagnostics = new Map<string, Diagnostic[]>();
|
|
196
|
+
const pullDiagnostics = new Map<string, Diagnostic[]>();
|
|
197
|
+
const published = new Map<string, { at: number; version?: number }>();
|
|
198
|
+
const diagnosticRegistrations = new Map<string, CapabilityRegistration>();
|
|
199
|
+
const registrationListeners = new Set<() => void>();
|
|
200
|
+
const diagnosticListeners = new Set<(input: { path: string; serverID: string }) => void>();
|
|
201
|
+
const mergedDiagnostics = (filePath: string): Diagnostic[] =>
|
|
202
|
+
dedupeDiagnostics([
|
|
203
|
+
...(pushDiagnostics.get(filePath) ?? []),
|
|
204
|
+
...(pullDiagnostics.get(filePath) ?? []),
|
|
205
|
+
]);
|
|
206
|
+
const updatePushDiagnostics = (filePath: string, next: Diagnostic[]): void => {
|
|
207
|
+
pushDiagnostics.set(filePath, next);
|
|
208
|
+
for (const listener of diagnosticListeners)
|
|
209
|
+
listener({ path: filePath, serverID: input.serverID });
|
|
210
|
+
};
|
|
211
|
+
const updatePullDiagnostics = (filePath: string, next: Diagnostic[]): void => {
|
|
212
|
+
pullDiagnostics.set(filePath, next);
|
|
213
|
+
};
|
|
214
|
+
const emitRegistrationChange = (): void => {
|
|
215
|
+
for (const listener of registrationListeners) listener();
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// ── LSP 连接处理器 ─────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
connection.onNotification(
|
|
221
|
+
"textDocument/publishDiagnostics",
|
|
222
|
+
(params: { uri: string; version?: number; diagnostics: Diagnostic[] }) => {
|
|
223
|
+
const filePath = getFilePath(params.uri);
|
|
224
|
+
if (!filePath) return;
|
|
225
|
+
published.set(filePath, {
|
|
226
|
+
at: Date.now(),
|
|
227
|
+
version: typeof params.version === "number" ? params.version : undefined,
|
|
228
|
+
});
|
|
229
|
+
updatePushDiagnostics(filePath, params.diagnostics);
|
|
230
|
+
},
|
|
231
|
+
);
|
|
232
|
+
connection.onRequest("window/workDoneProgress/create", () => null);
|
|
233
|
+
connection.onRequest("workspace/configuration", (params) => {
|
|
234
|
+
const items = (params as { items?: { section?: string }[] }).items ?? [];
|
|
235
|
+
return items.map((item) => configurationValue(input.server.initialization, item.section));
|
|
236
|
+
});
|
|
237
|
+
connection.onRequest("client/registerCapability", (params) => {
|
|
238
|
+
const registrations =
|
|
239
|
+
(params as { registrations?: CapabilityRegistration[] }).registrations ?? [];
|
|
240
|
+
let changed = false;
|
|
241
|
+
for (const registration of registrations) {
|
|
242
|
+
if (registration.method !== "textDocument/diagnostic") continue;
|
|
243
|
+
diagnosticRegistrations.set(registration.id, registration);
|
|
244
|
+
changed = true;
|
|
245
|
+
}
|
|
246
|
+
if (changed) emitRegistrationChange();
|
|
247
|
+
});
|
|
248
|
+
connection.onRequest("client/unregisterCapability", (params) => {
|
|
249
|
+
const registrations =
|
|
250
|
+
(params as { unregisterations?: { id: string; method: string }[] }).unregisterations ?? [];
|
|
251
|
+
let changed = false;
|
|
252
|
+
for (const registration of registrations) {
|
|
253
|
+
if (registration.method !== "textDocument/diagnostic") continue;
|
|
254
|
+
diagnosticRegistrations.delete(registration.id);
|
|
255
|
+
changed = true;
|
|
256
|
+
}
|
|
257
|
+
if (changed) emitRegistrationChange();
|
|
258
|
+
});
|
|
259
|
+
connection.onRequest("workspace/workspaceFolders", () => [
|
|
260
|
+
{ name: "workspace", uri: pathToFileURL(input.root).href },
|
|
261
|
+
]);
|
|
262
|
+
connection.onRequest("workspace/diagnostic/refresh", () => null);
|
|
263
|
+
connection.listen();
|
|
264
|
+
|
|
265
|
+
// ── initialize 握手 ─────────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
const initialized = await withTimeout(
|
|
268
|
+
connection.sendRequest<{ capabilities?: ServerCapabilities }>("initialize", {
|
|
269
|
+
rootUri: pathToFileURL(input.root).href,
|
|
270
|
+
processId: input.server.process.pid,
|
|
271
|
+
workspaceFolders: [{ name: "workspace", uri: pathToFileURL(input.root).href }],
|
|
272
|
+
initializationOptions: {
|
|
273
|
+
...input.server.initialization,
|
|
274
|
+
},
|
|
275
|
+
capabilities: {
|
|
276
|
+
window: { workDoneProgress: true },
|
|
277
|
+
workspace: {
|
|
278
|
+
configuration: true,
|
|
279
|
+
didChangeWatchedFiles: { dynamicRegistration: true },
|
|
280
|
+
diagnostics: { refreshSupport: false },
|
|
281
|
+
},
|
|
282
|
+
textDocument: {
|
|
283
|
+
synchronization: { didOpen: true, didChange: true },
|
|
284
|
+
diagnostic: { dynamicRegistration: true, relatedDocumentSupport: true },
|
|
285
|
+
publishDiagnostics: { versionSupport: false },
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
}),
|
|
289
|
+
initializeTimeoutMs,
|
|
290
|
+
).catch((error: unknown) => {
|
|
291
|
+
throw new InitializeError(input.serverID, error);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const syncKind = getSyncKind(initialized.capabilities);
|
|
295
|
+
const hasStaticPullDiagnostics = Boolean(initialized.capabilities?.diagnosticProvider);
|
|
296
|
+
|
|
297
|
+
await connection.sendNotification("initialized", {});
|
|
298
|
+
|
|
299
|
+
if (input.server.initialization) {
|
|
300
|
+
await connection.sendNotification("workspace/didChangeConfiguration", {
|
|
301
|
+
settings: input.server.initialization,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const files: Record<string, { version: number; text: string }> = {};
|
|
306
|
+
|
|
307
|
+
// ── 诊断拉取(pull)辅助 ────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
const mergeResults = (filePath: string, results: DiagnosticRequestResult[]) => {
|
|
310
|
+
if (results.every((result) => !result.handled)) return { handled: false, matched: false };
|
|
311
|
+
const matched = results.some((result) => result.matched);
|
|
312
|
+
|
|
313
|
+
const merged = new Map<string, Diagnostic[]>();
|
|
314
|
+
for (const result of results) {
|
|
315
|
+
for (const [target, items] of result.byFile) {
|
|
316
|
+
const existing = merged.get(target) ?? [];
|
|
317
|
+
merged.set(target, [...existing, ...items]);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (matched && !merged.has(filePath)) merged.set(filePath, []);
|
|
322
|
+
for (const [target, items] of merged) {
|
|
323
|
+
updatePullDiagnostics(target, dedupeDiagnostics(items));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return { handled: true, matched };
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
async function requestDiagnosticReport(
|
|
330
|
+
filePath: string,
|
|
331
|
+
identifier?: string,
|
|
332
|
+
): Promise<DiagnosticRequestResult> {
|
|
333
|
+
const report = await withTimeout(
|
|
334
|
+
connection.sendRequest<DocumentDiagnosticReport | null>("textDocument/diagnostic", {
|
|
335
|
+
...(identifier && { identifier }),
|
|
336
|
+
textDocument: { uri: pathToFileURL(filePath).href },
|
|
337
|
+
}),
|
|
338
|
+
diagnosticsRequestTimeoutMs,
|
|
339
|
+
).catch(() => null);
|
|
340
|
+
if (!report) return { handled: false, matched: false, byFile: new Map<string, Diagnostic[]>() };
|
|
341
|
+
|
|
342
|
+
const byFile = new Map<string, Diagnostic[]>();
|
|
343
|
+
const push = (target: string, items: Diagnostic[]): void => {
|
|
344
|
+
const existing = byFile.get(target) ?? [];
|
|
345
|
+
byFile.set(target, [...existing, ...items]);
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
let handled = false;
|
|
349
|
+
let matched = false;
|
|
350
|
+
if (Array.isArray(report.items)) {
|
|
351
|
+
push(filePath, report.items);
|
|
352
|
+
handled = true;
|
|
353
|
+
matched = true;
|
|
354
|
+
}
|
|
355
|
+
for (const [uri, related] of Object.entries(report.relatedDocuments ?? {})) {
|
|
356
|
+
const relatedPath = getFilePath(uri);
|
|
357
|
+
if (!relatedPath || !Array.isArray(related.items)) continue;
|
|
358
|
+
push(relatedPath, related.items);
|
|
359
|
+
handled = true;
|
|
360
|
+
matched ||= relatedPath === filePath;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return { handled, matched, byFile };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function requestWorkspaceDiagnosticReport(
|
|
367
|
+
filePath: string,
|
|
368
|
+
identifier?: string,
|
|
369
|
+
): Promise<DiagnosticRequestResult> {
|
|
370
|
+
const report = await withTimeout(
|
|
371
|
+
connection.sendRequest<WorkspaceDiagnosticReport | null>("workspace/diagnostic", {
|
|
372
|
+
...(identifier && { identifier }),
|
|
373
|
+
previousResultIds: [],
|
|
374
|
+
}),
|
|
375
|
+
diagnosticsRequestTimeoutMs,
|
|
376
|
+
).catch(() => null);
|
|
377
|
+
if (!report) return { handled: false, matched: false, byFile: new Map<string, Diagnostic[]>() };
|
|
378
|
+
|
|
379
|
+
const byFile = new Map<string, Diagnostic[]>();
|
|
380
|
+
let matched = false;
|
|
381
|
+
for (const item of report.items ?? []) {
|
|
382
|
+
const relatedPath = item.uri ? getFilePath(item.uri) : undefined;
|
|
383
|
+
if (!relatedPath || !Array.isArray(item.items)) continue;
|
|
384
|
+
const existing = byFile.get(relatedPath) ?? [];
|
|
385
|
+
byFile.set(relatedPath, [...existing, ...item.items]);
|
|
386
|
+
matched ||= relatedPath === filePath;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return { handled: true, matched, byFile };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function documentPullState() {
|
|
393
|
+
const documentRegistrations = [...diagnosticRegistrations.values()].filter(
|
|
394
|
+
(registration) => registration.registerOptions?.workspaceDiagnostics !== true,
|
|
395
|
+
);
|
|
396
|
+
return {
|
|
397
|
+
documentIdentifiers: [
|
|
398
|
+
...new Set(documentRegistrations.flatMap((r) => r.registerOptions?.identifier ?? [])),
|
|
399
|
+
],
|
|
400
|
+
supported: hasStaticPullDiagnostics || documentRegistrations.length > 0,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function workspacePullState() {
|
|
405
|
+
const workspaceRegistrations = [...diagnosticRegistrations.values()].filter(
|
|
406
|
+
(registration) => registration.registerOptions?.workspaceDiagnostics === true,
|
|
407
|
+
);
|
|
408
|
+
return {
|
|
409
|
+
workspaceIdentifiers: [
|
|
410
|
+
...new Set(workspaceRegistrations.flatMap((r) => r.registerOptions?.identifier ?? [])),
|
|
411
|
+
],
|
|
412
|
+
supported: workspaceRegistrations.length > 0,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function requestDiagnostics(
|
|
417
|
+
filePath: string,
|
|
418
|
+
requests: Promise<DiagnosticRequestResult>[],
|
|
419
|
+
done: (results: DiagnosticRequestResult[]) => boolean,
|
|
420
|
+
): Promise<{ handled: boolean; matched: boolean }> {
|
|
421
|
+
if (requests.length === 0) return { handled: false, matched: false };
|
|
422
|
+
|
|
423
|
+
return new Promise<{ handled: boolean; matched: boolean }>((resolve) => {
|
|
424
|
+
const results: DiagnosticRequestResult[] = [];
|
|
425
|
+
let pending = requests.length;
|
|
426
|
+
let resolved = false;
|
|
427
|
+
const finish = (merged: { handled: boolean; matched: boolean }, force = false) => {
|
|
428
|
+
if (resolved) return;
|
|
429
|
+
if (!force && !done(results)) return;
|
|
430
|
+
resolved = true;
|
|
431
|
+
resolve(merged);
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
for (const request of requests) {
|
|
435
|
+
void request
|
|
436
|
+
.then((result) => {
|
|
437
|
+
results.push(result);
|
|
438
|
+
pending -= 1;
|
|
439
|
+
const merged = mergeResults(filePath, results);
|
|
440
|
+
finish(merged);
|
|
441
|
+
if (pending === 0) finish(merged, true);
|
|
442
|
+
return;
|
|
443
|
+
})
|
|
444
|
+
.catch(() => {
|
|
445
|
+
pending -= 1;
|
|
446
|
+
if (pending === 0) finish(mergeResults(filePath, results), true);
|
|
447
|
+
return;
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// 并发发起 identifier pull,一旦某批已产出当前文件诊断即可放行;
|
|
454
|
+
// 慢的 pull 继续在后台合并,不按 identifier 串行。见 opencode PR #23771。
|
|
455
|
+
async function requestDocumentDiagnostics(filePath: string) {
|
|
456
|
+
const state = documentPullState();
|
|
457
|
+
if (!state.supported) return { handled: false, matched: false };
|
|
458
|
+
return requestDiagnostics(
|
|
459
|
+
filePath,
|
|
460
|
+
[
|
|
461
|
+
requestDiagnosticReport(filePath),
|
|
462
|
+
...state.documentIdentifiers.map((identifier) =>
|
|
463
|
+
requestDiagnosticReport(filePath, identifier),
|
|
464
|
+
),
|
|
465
|
+
],
|
|
466
|
+
(results) => hasCurrentFileDiagnostics(filePath, results),
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async function requestFullDiagnostics(filePath: string) {
|
|
471
|
+
const documentState = documentPullState();
|
|
472
|
+
const workspaceState = workspacePullState();
|
|
473
|
+
if (!documentState.supported && !workspaceState.supported) {
|
|
474
|
+
return { handled: false, matched: false };
|
|
475
|
+
}
|
|
476
|
+
return mergeResults(
|
|
477
|
+
filePath,
|
|
478
|
+
await Promise.all([
|
|
479
|
+
...(documentState.supported ? [requestDiagnosticReport(filePath)] : []),
|
|
480
|
+
...documentState.documentIdentifiers.map((identifier) =>
|
|
481
|
+
requestDiagnosticReport(filePath, identifier),
|
|
482
|
+
),
|
|
483
|
+
...(workspaceState.supported ? [requestWorkspaceDiagnosticReport(filePath)] : []),
|
|
484
|
+
...workspaceState.workspaceIdentifiers.map((identifier) =>
|
|
485
|
+
requestWorkspaceDiagnosticReport(filePath, identifier),
|
|
486
|
+
),
|
|
487
|
+
]),
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function waitForRegistrationChange(timeout: number): Promise<boolean> {
|
|
492
|
+
if (timeout <= 0) return Promise.resolve(false);
|
|
493
|
+
return new Promise<boolean>((resolve) => {
|
|
494
|
+
let finished = false;
|
|
495
|
+
const finish = (result: boolean) => {
|
|
496
|
+
if (finished) return;
|
|
497
|
+
finished = true;
|
|
498
|
+
if (timer) clearTimeout(timer);
|
|
499
|
+
registrationListeners.delete(listener);
|
|
500
|
+
resolve(result);
|
|
501
|
+
};
|
|
502
|
+
const listener = () => finish(true);
|
|
503
|
+
registrationListeners.add(listener);
|
|
504
|
+
const timer = setTimeout(() => finish(false), timeout);
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function waitForFreshPush(request: {
|
|
509
|
+
path: string;
|
|
510
|
+
version: number;
|
|
511
|
+
after: number;
|
|
512
|
+
timeout: number;
|
|
513
|
+
}): Promise<boolean> {
|
|
514
|
+
if (request.timeout <= 0) return Promise.resolve(false);
|
|
515
|
+
return new Promise<boolean>((resolve) => {
|
|
516
|
+
let finished = false;
|
|
517
|
+
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
518
|
+
const finish = (result: boolean) => {
|
|
519
|
+
if (finished) return;
|
|
520
|
+
finished = true;
|
|
521
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
522
|
+
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
523
|
+
unsub?.();
|
|
524
|
+
resolve(result);
|
|
525
|
+
};
|
|
526
|
+
const schedule = () => {
|
|
527
|
+
const hit = published.get(request.path);
|
|
528
|
+
if (!hit) return;
|
|
529
|
+
if (typeof hit.version === "number" && hit.version !== request.version) return;
|
|
530
|
+
if (hit.at < request.after && hit.version !== request.version) return;
|
|
531
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
532
|
+
debounceTimer = setTimeout(
|
|
533
|
+
() => finish(true),
|
|
534
|
+
Math.max(0, diagnosticsDebounceMs - (Date.now() - hit.at)),
|
|
535
|
+
);
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
const timeoutTimer = setTimeout(() => finish(false), request.timeout);
|
|
539
|
+
const listener = (event: { path: string; serverID: string }) => {
|
|
540
|
+
if (event.path !== request.path || event.serverID !== input.serverID) return;
|
|
541
|
+
schedule();
|
|
542
|
+
};
|
|
543
|
+
diagnosticListeners.add(listener);
|
|
544
|
+
const unsub = () => diagnosticListeners.delete(listener);
|
|
545
|
+
schedule();
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function waitForDocumentDiagnostics(request: {
|
|
550
|
+
path: string;
|
|
551
|
+
version: number;
|
|
552
|
+
after?: number;
|
|
553
|
+
}): Promise<void> {
|
|
554
|
+
const startedAt = request.after ?? Date.now();
|
|
555
|
+
const pushWait = waitForFreshPush({
|
|
556
|
+
path: request.path,
|
|
557
|
+
version: request.version,
|
|
558
|
+
after: startedAt,
|
|
559
|
+
timeout: diagnosticsDocumentWaitTimeoutMs,
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
while (Date.now() - startedAt < diagnosticsDocumentWaitTimeoutMs) {
|
|
563
|
+
const result = await requestDocumentDiagnostics(request.path);
|
|
564
|
+
if (result.matched) return;
|
|
565
|
+
const remaining = diagnosticsDocumentWaitTimeoutMs - (Date.now() - startedAt);
|
|
566
|
+
if (remaining <= 0) return;
|
|
567
|
+
const next = await Promise.race([
|
|
568
|
+
pushWait.then((ready) => (ready ? "push" : ("timeout" as const))),
|
|
569
|
+
waitForRegistrationChange(remaining).then((changed) =>
|
|
570
|
+
changed ? ("registration" as const) : ("timeout" as const),
|
|
571
|
+
),
|
|
572
|
+
]);
|
|
573
|
+
if (next !== "registration") return;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async function waitForFullDiagnostics(request: {
|
|
578
|
+
path: string;
|
|
579
|
+
version: number;
|
|
580
|
+
after?: number;
|
|
581
|
+
}): Promise<void> {
|
|
582
|
+
const startedAt = request.after ?? Date.now();
|
|
583
|
+
const pushWait = waitForFreshPush({
|
|
584
|
+
path: request.path,
|
|
585
|
+
version: request.version,
|
|
586
|
+
after: startedAt,
|
|
587
|
+
timeout: diagnosticsFullWaitTimeoutMs,
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
while (Date.now() - startedAt < diagnosticsFullWaitTimeoutMs) {
|
|
591
|
+
const result = await requestFullDiagnostics(request.path);
|
|
592
|
+
if (result.handled || result.matched) return;
|
|
593
|
+
const remaining = diagnosticsFullWaitTimeoutMs - (Date.now() - startedAt);
|
|
594
|
+
if (remaining <= 0) return;
|
|
595
|
+
const next = await Promise.race([
|
|
596
|
+
pushWait.then((ready) => (ready ? "push" : ("timeout" as const))),
|
|
597
|
+
waitForRegistrationChange(remaining).then((changed) =>
|
|
598
|
+
changed ? ("registration" as const) : ("timeout" as const),
|
|
599
|
+
),
|
|
600
|
+
]);
|
|
601
|
+
if (next !== "registration") return;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// ── 公开 API ────────────────────────────────────────────────────────────────
|
|
606
|
+
|
|
607
|
+
return {
|
|
608
|
+
root: input.root,
|
|
609
|
+
get serverID() {
|
|
610
|
+
return input.serverID;
|
|
611
|
+
},
|
|
612
|
+
get connection() {
|
|
613
|
+
return connection;
|
|
614
|
+
},
|
|
615
|
+
notify: {
|
|
616
|
+
async open(request: { path: string }): Promise<number> {
|
|
617
|
+
const resolvedPath = normalize(
|
|
618
|
+
isAbsolute(request.path) ? request.path : resolve(input.directory, request.path),
|
|
619
|
+
);
|
|
620
|
+
const text = await readFile(resolvedPath, "utf8");
|
|
621
|
+
const languageId = LANGUAGE_EXTENSIONS[extname(resolvedPath)] ?? "plaintext";
|
|
622
|
+
const uri = pathToFileURL(resolvedPath).href;
|
|
623
|
+
|
|
624
|
+
const document = files[resolvedPath];
|
|
625
|
+
if (document !== undefined) {
|
|
626
|
+
// didChange:不清空既有诊断(如 clangd 只在内容变化时重发),
|
|
627
|
+
// 让服务器下一次 push/pull 自然覆盖。
|
|
628
|
+
await connection.sendNotification("workspace/didChangeWatchedFiles", {
|
|
629
|
+
changes: [{ uri, type: FILE_CHANGE_CHANGED }],
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
const next = document.version + 1;
|
|
633
|
+
files[resolvedPath] = { version: next, text };
|
|
634
|
+
await connection.sendNotification("textDocument/didChange", {
|
|
635
|
+
textDocument: { uri, version: next },
|
|
636
|
+
contentChanges:
|
|
637
|
+
syncKind === TEXT_DOCUMENT_SYNC_INCREMENTAL
|
|
638
|
+
? [
|
|
639
|
+
{
|
|
640
|
+
range: { start: { line: 0, character: 0 }, end: endPosition(document.text) },
|
|
641
|
+
text,
|
|
642
|
+
},
|
|
643
|
+
]
|
|
644
|
+
: [{ text }],
|
|
645
|
+
});
|
|
646
|
+
return next;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
await connection.sendNotification("workspace/didChangeWatchedFiles", {
|
|
650
|
+
changes: [{ uri, type: FILE_CHANGE_CREATED }],
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
pushDiagnostics.delete(resolvedPath);
|
|
654
|
+
pullDiagnostics.delete(resolvedPath);
|
|
655
|
+
await connection.sendNotification("textDocument/didOpen", {
|
|
656
|
+
textDocument: { uri, languageId, version: 0, text },
|
|
657
|
+
});
|
|
658
|
+
files[resolvedPath] = { version: 0, text };
|
|
659
|
+
return 0;
|
|
660
|
+
},
|
|
661
|
+
},
|
|
662
|
+
get diagnostics() {
|
|
663
|
+
const result = new Map<string, Diagnostic[]>();
|
|
664
|
+
for (const key of new Set([...pushDiagnostics.keys(), ...pullDiagnostics.keys()])) {
|
|
665
|
+
result.set(key, mergedDiagnostics(key));
|
|
666
|
+
}
|
|
667
|
+
return result;
|
|
668
|
+
},
|
|
669
|
+
async waitForDiagnostics(request) {
|
|
670
|
+
const normalizedPath = normalize(
|
|
671
|
+
isAbsolute(request.path) ? request.path : resolve(input.directory, request.path),
|
|
672
|
+
);
|
|
673
|
+
if (request.mode === "document") {
|
|
674
|
+
await waitForDocumentDiagnostics({
|
|
675
|
+
path: normalizedPath,
|
|
676
|
+
version: request.version,
|
|
677
|
+
after: request.after,
|
|
678
|
+
});
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
await waitForFullDiagnostics({
|
|
682
|
+
path: normalizedPath,
|
|
683
|
+
version: request.version,
|
|
684
|
+
after: request.after,
|
|
685
|
+
});
|
|
686
|
+
},
|
|
687
|
+
async shutdown() {
|
|
688
|
+
connection.end();
|
|
689
|
+
connection.dispose();
|
|
690
|
+
await stopProcess(input.server.process);
|
|
691
|
+
},
|
|
692
|
+
};
|
|
693
|
+
}
|