/** * LSP 客户端:单条语言服务器连接的封装(移植自 opencode lsp/client.ts)。 * * - vscode-jsonrpc 消息连接 + initialize/initialized 握手; * - didOpen / didChange(按服务器 textDocumentSync 适配增量或全量); * - 诊断双通道:push(textDocument/publishDiagnostics)+ pull * (textDocument/diagnostic、workspace/diagnostic,支持动态注册); * - waitForDiagnostics:document 模式最多等 5s、full 模式最多等 10s, * push 通知带 150ms debounce,pull 请求 3s 超时。 */ import { readFile } from "node:fs/promises"; import { extname, isAbsolute, normalize, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { CancellationTokenSource, createMessageConnection, type MessageConnection, ResponseError, StreamMessageReader, StreamMessageWriter, } from "vscode-jsonrpc/node"; import type { Diagnostic as VSCodeDiagnostic, Hover, Location as LspLocation, LocationLink, Range, WorkspaceEdit, } from "vscode-languageserver-types"; import type { LspServerHandle } from "./adapter.js"; import { LANGUAGE_EXTENSIONS } from "./language.js"; import { editFilePaths } from "./rename.js"; import type { FileChange, FileChangeType } from "./watcher.js"; // LSP spec 常量 const FILE_CHANGE_CREATED = 1; const FILE_CHANGE_CHANGED = 2; const FILE_CHANGE_DELETED = 3; const TEXT_DOCUMENT_SYNC_INCREMENTAL = 2; /** LSP WatchKind 位掩码(FileSystemWatcher.kind,缺省 create|change|delete)。 */ export const WATCH_KIND_CREATE = 1; export const WATCH_KIND_CHANGE = 2; export const WATCH_KIND_DELETE = 4; const WATCH_KIND_ALL = WATCH_KIND_CREATE | WATCH_KIND_CHANGE | WATCH_KIND_DELETE; /** 服务器通过 client/registerCapability 注册的单个 watcher。 */ export interface WatcherGlob { pattern: string; kind: number; } const FILE_CHANGE_TYPE: Record = { created: FILE_CHANGE_CREATED, changed: FILE_CHANGE_CHANGED, deleted: FILE_CHANGE_DELETED, }; export type Diagnostic = VSCodeDiagnostic; /** 两个路径集合是否一致(用于判断 references 结果是否收敛)。 */ function samePaths(a: Set, b: Set): boolean { if (a.size !== b.size) return false; for (const path of a) if (!b.has(path)) return false; return true; } /** LSP MethodNotFound(-32601):服务器未实现 prepareRename / rename 请求。 */ const LSP_METHOD_NOT_FOUND = -32601; /** LSP ContentModified(-32801):服务器处理请求期间内容被修改,重发请求即可。 */ const LSP_CONTENT_MODIFIED = -32801; /** * 位置不可 rename 或服务器不具备 rename 能力;与传输失败等意外错误区分, * 供调用方在多候选探测时跳过该 client 而不是中断整个操作。 */ export class RenameNotPossibleError extends Error {} /** * 服务器不支持某 LSP 方法(MethodNotFound)。与传输失败区分,供服务层跳过 * 该服务器尝试下一个,而不是把整个操作当作失败。 */ export class LspMethodNotSupportedError extends Error { readonly serverID: string; readonly method: string; constructor(serverID: string, method: string) { super(`LSP server "${serverID}" does not support ${method}`); this.serverID = serverID; this.method = method; } } /** * rename edit 未覆盖 references 看到的全部文件:服务器索引可能仍在后台加载。 * 抛出时发生在写盘之前,整个 rename 无副作用,可稍后重试。 */ export class RenameIncompleteError extends Error { readonly missing: readonly string[]; readonly extra: readonly string[]; constructor(missing: readonly string[], extra: readonly string[] = []) { const parts: string[] = []; if (missing.length > 0) { parts.push( `textDocument/references found the symbol in ${missing.length} file(s) ` + `that the rename edit does not cover (${missing.join(", ")})`, ); } if (extra.length > 0) { parts.push( `the rename edit touches ${extra.length} file(s) ` + `that textDocument/references did not report (${extra.join(", ")})`, ); } super( `LSP rename incomplete: ${parts.join("; ")}. ` + `The server index may still be loading; nothing was modified, retry shortly.`, ); this.missing = missing; this.extra = extra; } } /** * rename 覆盖校验的轮询节奏。budgetMs 是 references 收敛 + 重试的总预算; * 测试可临时缩小以缩短等待。 */ export const renameVerificationTiming = { pollMs: 400, budgetMs: 10_000, /** ContentModified(-32801) 重试上限:服务器处理期间文档被修改,重发请求即可。 */ contentModifiedRetries: 3, }; /** * ContentModified 语义:请求处理期间 salsa 数据库被文件变更修改,服务器请 * 客户端重发请求。重发无副作用,直接重试;仅连续超限才向上抛,避免无限循环。 */ async function retryOnContentModified(fn: () => Promise): Promise { for (let attempt = 0; ; attempt++) { try { return await fn(); } catch (error) { if (!(error instanceof ResponseError && error.code === LSP_CONTENT_MODIFIED)) throw error; if (attempt >= renameVerificationTiming.contentModifiedRetries) throw error; } } } interface PrepareRenameResponse { range?: unknown; placeholder?: string; defaultBehavior?: boolean; } /** renameSymbol 的请求与结果(line / character 为 0-based LSP position)。 */ export interface RenameSymbolRequest { path: string; line: number; character: number; newName: string; /** 调用方取消信号:中止时立刻放弃等待并通知服务器取消。 */ signal?: AbortSignal; } export interface RenameSymbolResult { edit: WorkspaceEdit; /** prepareRename 返回的符号当前名;服务器未提供 prepare 时缺省。 */ placeholder?: string; } /** definition / references / hover 请求的输入(line / character 为 0-based LSP position)。 */ export interface InspectPositionRequest { path: string; line: number; character: number; /** 调用方取消信号:中止时立刻放弃等待并通知服务器取消。 */ signal?: AbortSignal; } /** definition / references 归一化后的位置(0-based;1-based 格式化由工具层负责)。 */ export interface InspectLocation { path: string; line: number; character: number; } type DefinitionResult = LspLocation | LspLocation[] | LocationLink | LocationLink[] | null; /** Location / LocationLink → path + 0-based 坐标;非 file: URI 是服务器的意外行为,跳过。 */ function toInspectLocations(result: DefinitionResult): InspectLocation[] { if (result === null) return []; const items = Array.isArray(result) ? result : [result]; const locations: InspectLocation[] = []; for (const item of items) { if ("targetUri" in item) { if (!item.targetUri.startsWith("file:")) continue; const range = (item as Omit & { targetSelectionRange?: Range }) .targetSelectionRange ?? item.targetRange; locations.push({ path: normalize(fileURLToPath(item.targetUri)), line: range.start.line, character: range.start.character, }); } else { if (!item.uri.startsWith("file:")) continue; locations.push({ path: normalize(fileURLToPath(item.uri)), line: item.range.start.line, character: item.range.start.character, }); } } return locations; } /** stderr 尾部保留上限(字符):足够容纳启动失败的最后报错,又不撑爆通知。 */ const STDERR_TAIL_CHARS = 4_000; /** 展开 Error 的 cause 链为一条 message;流包装错误只包一层,逐层展开即可还原根因。 */ function errorChainMessage(error: unknown): string { if (!(error instanceof Error)) return String(error); const parts: string[] = []; let current: unknown = error; while (current instanceof Error && current.message && !parts.includes(current.message)) { parts.push(current.message); current = current.cause; } if (typeof current === "string" || typeof current === "number") parts.push(String(current)); return parts.join(": "); } /** 拼装握手失败的完整原因:错误链 + 进程退出状态 + stderr 尾部。 */ function describeStartupFailure( error: unknown, exitDescription: string | undefined, stderrTail: string, ): string { const parts = [errorChainMessage(error)]; if (exitDescription) parts.push(`server ${exitDescription}`); const stderr = stderrTail.trim(); if (stderr) parts.push(`stderr: ${stderr}`); return parts.join("; "); } export class InitializeError extends Error { readonly serverID: string; constructor(serverID: string, cause: unknown, detail?: string) { super(`Failed to initialize LSP server ${serverID}${detail ? `: ${detail}` : ""}`, { cause }); this.serverID = serverID; } } interface DocumentDiagnosticReport { items?: Diagnostic[]; relatedDocuments?: Record; } interface WorkspaceDiagnosticReport { items?: { uri?: string; items?: Diagnostic[] }[]; } interface DiagnosticRequestResult { handled: boolean; matched: boolean; byFile: Map; /** 单次请求是否超时(区别于正常失败:超时意味着服务器未响应,不应重试)。 */ timedOut: boolean; } /** 一批 pull 请求的聚合结果;timedOut 表示其中至少一个请求超时。 */ interface PullResult { handled: boolean; matched: boolean; timedOut: boolean; } interface CapabilityRegistration { id: string; method: string; registerOptions?: { identifier?: string; workspaceDiagnostics?: boolean; watchers?: { globPattern?: string; kind?: number }[]; }; } interface ServerCapabilities { textDocumentSync?: | number | { change?: number; }; diagnosticProvider?: unknown; renameProvider?: boolean | { prepareProvider?: boolean }; [key: string]: unknown; } /** * create 的直连缺省(单一来源):lsp.ts 的 resolveConfig 解析超时/LRU 时引用 * 同一组数值,保证配置层缺省与直连调用方取值一致。 */ export const clientDefaults = { diagnosticsDebounceMs: 150, diagnosticsDocumentWaitTimeoutMs: 5_000, /** * document 模式等待上限的补充:该文档上一份诊断为空(服务器手里是干净文档)时, * 内容变化后只用这段安静期等新 push,不再等满 diagnosticsDocumentWaitTimeoutMs。 * * typescript-language-server 的 FileDiagnostics.update 在「该类诊断上一轮为空、 * 这一轮仍为空」时直接 return 不推送,且它不实现 pull 诊断;这类文档等满整个 * 窗口也只会在窗口末尾返回同样的「无诊断」,白等一次编辑。诊断集合变成非空时 * 服务器必定推送,所以短安静期不会漏掉这次变更引入的错误。 */ diagnosticsSilentWaitTimeoutMs: 1_500, diagnosticsFullWaitTimeoutMs: 10_000, diagnosticsRequestTimeoutMs: 3_000, initializeTimeoutMs: 45_000, maxOpenDocuments: 32, } as const; export interface CreateInput { serverID: string; server: LspServerHandle; root: string; directory: string; /** 可覆盖的超时参数(缺省用 client 默认值,由全局/本地 lsp.json 配置注入)。 */ diagnosticsDebounceMs?: number; diagnosticsDocumentWaitTimeoutMs?: number; /** 上一份诊断为空的文档的等待上限;缺省见 clientDefaults。 */ diagnosticsSilentWaitTimeoutMs?: number; diagnosticsFullWaitTimeoutMs?: number; diagnosticsRequestTimeoutMs?: number; initializeTimeoutMs?: number; /** 驻留文档上限(LRU 容量,缺省 32);超过时淘汰最久未使用并 didClose。 */ maxOpenDocuments?: number; } export interface LspClient { readonly root: string; readonly serverID: string; readonly connection: MessageConnection; readonly notify: { open(request: { path: string }): Promise; /** 把工作区文件事件批量通知服务器;驻留文档不在此通道(走 didOpen/didChange/退场)。 */ watchedFiles(changes: FileChange[]): Promise; }; /** 服务器注册的 workspace/didChangeWatchedFiles watchers(pattern + kind,按 pattern+kind 去重)。 */ watchers(): WatcherGlob[]; readonly diagnostics: Map; waitForDiagnostics(request: { path: string; version: number; mode?: "document" | "full"; after?: number; signal?: AbortSignal; }): Promise; /** * 符号重命名:先把磁盘内容同步给服务器(didOpen/didChange),再按能力决定 * 是否先发 prepareRename 校验位置,最后发 textDocument/rename 返回 WorkspaceEdit。 * 位置不在符号上 / 服务器不支持 rename 时抛 RenameNotPossibleError。 */ renameSymbol(request: RenameSymbolRequest): Promise; /** * textDocument/definition:归一化后的定义位置列表(Location / LocationLink * 统一转 path + 0-based 坐标;无结果返回空数组)。 */ definition(request: InspectPositionRequest): Promise; /** * textDocument/references:归一化后的引用位置列表(是否含声明处由服务器 * 按 includeDeclaration 决定,此处固定包含,对齐 rename 覆盖校验口径)。 */ references(request: InspectPositionRequest): Promise; /** * textDocument/hover:服务器返回的 contents 原样透传,不做内容归一化; * 服务器无信息时返回 null(合法应答,非错误)。 */ hover(request: InspectPositionRequest): Promise; shutdown(): Promise; } export type Info = LspClient; function getFilePath(uri: string): string | undefined { if (!uri.startsWith("file://")) return undefined; return normalize(fileURLToPath(uri)); } function getSyncKind(capabilities?: ServerCapabilities): number | undefined { if (!capabilities) return undefined; const sync = capabilities.textDocumentSync; if (typeof sync === "number") return sync; return sync?.change; } function hasCurrentFileDiagnostics(filePath: string, results: DiagnosticRequestResult[]) { return results.some((result) => (result.byFile.get(filePath)?.length ?? 0) > 0); } function endPosition(text: string): { line: number; character: number } { const lines = text.split(/\r\n|\r|\n/); return { line: lines.length - 1, character: lines.at(-1)?.length ?? 0, }; } function dedupeDiagnostics(items: Diagnostic[]): Diagnostic[] { const seen = new Set(); return items.filter((item) => { const key = JSON.stringify({ code: item.code, severity: item.severity, message: item.message, source: item.source, range: item.range, }); if (seen.has(key)) return false; seen.add(key); return true; }); } function configurationValue(settings: unknown, section?: string): unknown { if (!section) return settings ?? null; const result = section.split(".").reduce((acc, key) => { if (!acc || typeof acc !== "object" || !(key in acc)) return; return (acc as Record)[key]; }, settings); return result ?? null; } async function withTimeout(promise: Promise, ms: number): Promise { let timer: ReturnType | undefined; try { return await Promise.race([ promise, new Promise((resolve, reject) => { timer = setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms); }), ]); } finally { if (timer) clearTimeout(timer); } } const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); /** 中止请求时的拒绝原因:沿用 signal.reason(默认是 name=AbortError 的 DOMException)。 */ function abortReason(signal: AbortSignal): Error { return signal.reason instanceof Error ? signal.reason : new Error("The operation was aborted"); } /** 可中止的 sleep:中止时以 signal.reason 拒绝,而不是白等到轮询间隔结束。 */ function sleepWithSignal(ms: number, signal: AbortSignal | undefined): Promise { if (!signal) return sleep(ms); return new Promise((resolve, reject) => { const onAbort = (): void => { clearTimeout(timer); reject(abortReason(signal)); }; const timer = setTimeout(() => { signal.removeEventListener("abort", onAbort); resolve(); }, ms); if (signal.aborted) { onAbort(); return; } signal.addEventListener("abort", onAbort, { once: true }); }); } /** pull 诊断失败后的重试间隔。 */ const PULL_RETRY_INTERVAL_MS = 100; function stopProcess(process: LspServerHandle["process"]): Promise { if (process.exitCode !== null) return Promise.resolve(); try { process.kill(); } catch { // Windows 上对已退出/从未成功启动的进程 kill 会抛 EINVAL return Promise.resolve(); } return new Promise((resolve) => { process.once("exit", () => resolve()); process.once("error", () => resolve()); setTimeout(() => { try { process.kill("SIGKILL"); } catch { // 进程已退出,忽略 } }, 1_000).unref(); }); } /** * 驻留文档超容量时的淘汰计划(纯函数,见 create 里的 evictExcess): * - `stale`:lruOrder 里已不在 files 的陈旧 key(被 watchedFiles 的 didClose 移出), * 不占容量,直接回收; * - `evict`:按使用顺序(最久未用在前)应 didClose 的路径。 * * 容量是**尽力而为**的上限:正在等诊断的文档必须留到诊断收集完(关闭会抹掉 * 本次写入的诊断结果),因此可淘汰项不足时宁可少淘汰几个(甚至一个都不淘汰), * 把收敛留给后续 openDocument。调用方的淘汰循环必须单次有界——等待窗口内的文档 * 若被当成"稍后重试"的对象,就是同步自旋(那部分代码里没有 await),会把事件 * 循环整个锁死。 */ export function evictionPlan(options: { /** lruOrder 的迭代序:最久未用在前。 */ order: readonly string[]; /** 该路径是否仍在驻留集合(files)里。 */ isResident: (path: string) => boolean; /** 该路径是否正在等诊断。 */ isWaiting: (path: string) => boolean; maxOpenDocuments: number; }): { stale: string[]; evict: string[] } { const stale: string[] = []; const evictable: string[] = []; for (const path of options.order) { if (!options.isResident(path)) stale.push(path); else if (!options.isWaiting(path)) evictable.push(path); } const excess = options.order.length - stale.length - options.maxOpenDocuments; return { stale, evict: excess > 0 ? evictable.slice(0, excess) : [] }; } export async function create(input: CreateInput): Promise { const diagnosticsDebounceMs = input.diagnosticsDebounceMs ?? clientDefaults.diagnosticsDebounceMs; const diagnosticsDocumentWaitTimeoutMs = input.diagnosticsDocumentWaitTimeoutMs ?? clientDefaults.diagnosticsDocumentWaitTimeoutMs; const diagnosticsSilentWaitTimeoutMs = input.diagnosticsSilentWaitTimeoutMs ?? clientDefaults.diagnosticsSilentWaitTimeoutMs; const diagnosticsFullWaitTimeoutMs = input.diagnosticsFullWaitTimeoutMs ?? clientDefaults.diagnosticsFullWaitTimeoutMs; const diagnosticsRequestTimeoutMs = input.diagnosticsRequestTimeoutMs ?? clientDefaults.diagnosticsRequestTimeoutMs; const initializeTimeoutMs = input.initializeTimeoutMs ?? clientDefaults.initializeTimeoutMs; const maxOpenDocuments = input.maxOpenDocuments ?? clientDefaults.maxOpenDocuments; const connection = createMessageConnection( new StreamMessageReader(input.server.process.stdout), new StreamMessageWriter(input.server.process.stdin), ); // stderr 平时只在尾部保留少量内容(避免子进程大量输出撑爆内存); // 握手失败时随错误输出,服务器 panic / 参数错误等启动原因由此还原。 let stderrTail = ""; input.server.process.stderr.setEncoding("utf8"); input.server.process.stderr.on("data", (chunk: string) => { stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_CHARS); }); /** 连接或服务器进程已关闭;pull 重试循环以此终止,避免无界等待。 */ let connectionClosed = false; let exitDescription: string | undefined; input.server.process.once("exit", (code, signal) => { connectionClosed = true; exitDescription = code === null ? `killed by signal ${signal}` : `exited with code ${code}`; }); connection.onDispose(() => { connectionClosed = true; }); // ── 连接状态 ──────────────────────────────────────────────────────────────── const pushDiagnostics = new Map(); const pullDiagnostics = new Map(); const published = new Map(); const diagnosticRegistrations = new Map(); /** registration id → workspace/didChangeWatchedFiles watchers(pattern + WatchKind 位)。 */ const watcherRegistrations = new Map(); const registrationListeners = new Set<() => void>(); const diagnosticListeners = new Set<(input: { path: string; serverID: string }) => void>(); /** resolvedPath → 客户端已发送的最新文档版本(didOpen=0,didChange 递增)。 */ const documentVersions = new Map(); const mergedDiagnostics = (filePath: string): Diagnostic[] => dedupeDiagnostics([ ...(pushDiagnostics.get(filePath) ?? []), ...(pullDiagnostics.get(filePath) ?? []), ]); const updatePushDiagnostics = (filePath: string, next: Diagnostic[]): void => { pushDiagnostics.set(filePath, next); for (const listener of diagnosticListeners) listener({ path: filePath, serverID: input.serverID }); }; const updatePullDiagnostics = (filePath: string, next: Diagnostic[]): void => { pullDiagnostics.set(filePath, next); }; const emitRegistrationChange = (): void => { for (const listener of registrationListeners) listener(); }; // ── LSP 连接处理器 ───────────────────────────────────────────────────────── connection.onNotification( "textDocument/publishDiagnostics", (params: { uri: string; version?: number; diagnostics: Diagnostic[] }) => { const filePath = getFilePath(params.uri); if (!filePath) return; // 服务器版本滞后于已发送版本时,该 push 对应的是旧内容(异步重算未完成 // 时的迟到结果)。忽略,避免与当前版本结果混淆。 const currentVersion = documentVersions.get(filePath); const isStalePush = typeof params.version === "number" && currentVersion !== undefined && params.version !== currentVersion; if (isStalePush) return; published.set(filePath, { at: Date.now(), version: typeof params.version === "number" ? params.version : undefined, }); const document = files[filePath]; if (document !== undefined) document.lastPushEmpty = params.diagnostics.length === 0; updatePushDiagnostics(filePath, params.diagnostics); }, ); connection.onRequest("window/workDoneProgress/create", () => null); connection.onRequest("workspace/configuration", (params) => { const items = (params as { items?: { section?: string }[] }).items ?? []; return items.map((item) => configurationValue(input.server.settings ?? input.server.initialization, item.section), ); }); connection.onRequest("client/registerCapability", (params) => { const registrations = (params as { registrations?: CapabilityRegistration[] }).registrations ?? []; let changed = false; for (const registration of registrations) { if (registration.method === "workspace/didChangeWatchedFiles") { const watchers = registration.registerOptions?.watchers ?.map((watcher) => ({ pattern: watcher.globPattern, kind: watcher.kind ?? WATCH_KIND_ALL, })) .filter((watcher): watcher is WatcherGlob => typeof watcher.pattern === "string") ?? []; watcherRegistrations.set(registration.id, watchers); } else if (registration.method === "textDocument/diagnostic") { diagnosticRegistrations.set(registration.id, registration); changed = true; } } if (changed) emitRegistrationChange(); }); connection.onRequest("client/unregisterCapability", (params) => { const registrations = (params as { unregisterations?: { id: string; method: string }[] }).unregisterations ?? []; let changed = false; for (const registration of registrations) { if (registration.method === "workspace/didChangeWatchedFiles") { watcherRegistrations.delete(registration.id); } else if (registration.method === "textDocument/diagnostic") { diagnosticRegistrations.delete(registration.id); changed = true; } } if (changed) emitRegistrationChange(); }); connection.onRequest("workspace/workspaceFolders", () => [ { name: "workspace", uri: pathToFileURL(input.root).href }, ]); connection.onRequest("workspace/diagnostic/refresh", () => null); connection.listen(); // ── initialize 握手 ───────────────────────────────────────────────────────── const initialized = await withTimeout( connection.sendRequest<{ capabilities?: ServerCapabilities }>("initialize", { rootUri: pathToFileURL(input.root).href, processId: input.server.process.pid, workspaceFolders: [{ name: "workspace", uri: pathToFileURL(input.root).href }], initializationOptions: { ...input.server.initialization, }, capabilities: { window: { workDoneProgress: true }, workspace: { configuration: true, didChangeWatchedFiles: { dynamicRegistration: true }, diagnostics: { refreshSupport: false }, }, textDocument: { synchronization: { didOpen: true, didChange: true }, diagnostic: { dynamicRegistration: true, relatedDocumentSupport: true }, publishDiagnostics: { versionSupport: false }, }, }, }), initializeTimeoutMs, ).catch(async (error: unknown) => { // 握手失败(超时/拒绝)时清理连接并终止已 spawn 的子进程,避免进程泄漏 connection.end(); connection.dispose(); await stopProcess(input.server.process); throw new InitializeError( input.serverID, error, describeStartupFailure(error, exitDescription, stderrTail), ); }); const syncKind = getSyncKind(initialized.capabilities); const hasStaticPullDiagnostics = Boolean(initialized.capabilities?.diagnosticProvider); // prepareProvider 只在静态声明为对象且显式开启时使用;其余情况跳过 prepare // 直接 rename(能力可能经动态注册,静态声明缺失不代表服务器不支持)。 const renameProvider = initialized.capabilities?.renameProvider; const hasPrepareProvider = typeof renameProvider === "object" && renameProvider.prepareProvider === true; await connection.sendNotification("initialized", {}); const settings = input.server.settings ?? input.server.initialization; if (settings) { await connection.sendNotification("workspace/didChangeConfiguration", { settings }); } /** * syncedAt:最后一次把该文档内容同步给服务器的时刻(didOpen / didChange)。 * lastPushEmpty:服务器对该文档最近一次 push 是否为空(无诊断),随驻留记录 * 一起在 didClose 时清除——只在当前驻留周期内成立。 */ const files: Record< string, { version: number; text: string; syncedAt: number; lastPushEmpty?: boolean } | undefined > = {}; // ── 驻留 LRU ──────────────────────────────────────────────────────────────── /** path → 最近使用时间;迭代序即使用序(头部最久)。 */ const lruOrder = new Map(); /** 正在等待诊断的文档(didClose 淘汰时跳过,见"关闭不得早于诊断收集")。 */ const waitingForDiagnostics = new Set(); const touch = (path: string): void => { lruOrder.delete(path); lruOrder.set(path, Date.now()); }; /** * 超过容量时淘汰最久未使用的文档(didClose 并移出驻留集合)。 * * 单次有界遍历:容量是尽力而为的上限——等待诊断的文档要留到诊断收集完,可淘汰 * 项不足时就少淘汰(见 evictionPlan)。不要写成"跳过等待项再重试"的循环:等待 * 窗口内的文档可能占满驻留集合(例如某路径的等待尚未结束时它又被重新打开), * 那时重试分支没有 await,等于同步自旋。 */ async function evictExcess(): Promise { const plan = evictionPlan({ order: [...lruOrder.keys()], isResident: (path) => files[path] !== undefined, isWaiting: (path) => waitingForDiagnostics.has(path), maxOpenDocuments, }); for (const path of plan.stale) lruOrder.delete(path); for (const path of plan.evict) { lruOrder.delete(path); await connection.sendNotification("textDocument/didClose", { textDocument: { uri: pathToFileURL(path).href }, }); delete files[path]; documentVersions.delete(path); } } // ── 诊断拉取(pull)辅助 ──────────────────────────────────────────────────── const mergeResults = (filePath: string, results: DiagnosticRequestResult[]): PullResult => { if (results.every((result) => !result.handled)) { return { handled: false, matched: false, timedOut: results.some((result) => result.timedOut), }; } const matched = results.some((result) => result.matched); const timedOut = results.some((result) => result.timedOut); const merged = new Map(); for (const result of results) { for (const [target, items] of result.byFile) { const existing = merged.get(target) ?? []; merged.set(target, [...existing, ...items]); } } if (matched && !merged.has(filePath)) merged.set(filePath, []); for (const [target, items] of merged) { updatePullDiagnostics(target, dedupeDiagnostics(items)); } return { handled: true, matched, timedOut }; }; async function requestDiagnosticReport( filePath: string, identifier?: string, ): Promise { let timedOut = false; const report = await withTimeout( connection.sendRequest("textDocument/diagnostic", { ...(identifier && { identifier }), textDocument: { uri: pathToFileURL(filePath).href }, }), diagnosticsRequestTimeoutMs, ).catch((error: unknown) => { if (error instanceof Error && error.message.startsWith("Timeout after")) timedOut = true; return null; }); if (!report) { return { handled: false, matched: false, byFile: new Map(), timedOut }; } const byFile = new Map(); const push = (target: string, items: Diagnostic[]): void => { const existing = byFile.get(target) ?? []; byFile.set(target, [...existing, ...items]); }; let handled = false; let matched = false; if (Array.isArray(report.items)) { push(filePath, report.items); handled = true; matched = true; } for (const [uri, related] of Object.entries(report.relatedDocuments ?? {})) { const relatedPath = getFilePath(uri); if (!relatedPath || !Array.isArray(related.items)) continue; push(relatedPath, related.items); handled = true; matched ||= relatedPath === filePath; } return { handled, matched, byFile, timedOut }; } async function requestWorkspaceDiagnosticReport( filePath: string, identifier?: string, ): Promise { let timedOut = false; const report = await withTimeout( connection.sendRequest("workspace/diagnostic", { ...(identifier && { identifier }), previousResultIds: [], }), diagnosticsRequestTimeoutMs, ).catch((error: unknown) => { if (error instanceof Error && error.message.startsWith("Timeout after")) timedOut = true; return null; }); if (!report) { return { handled: false, matched: false, byFile: new Map(), timedOut }; } const byFile = new Map(); let matched = false; for (const item of report.items ?? []) { const relatedPath = item.uri ? getFilePath(item.uri) : undefined; if (!relatedPath || !Array.isArray(item.items)) continue; const existing = byFile.get(relatedPath) ?? []; byFile.set(relatedPath, [...existing, ...item.items]); matched ||= relatedPath === filePath; } return { handled: true, matched, byFile, timedOut }; } function documentPullState() { const documentRegistrations = [...diagnosticRegistrations.values()].filter( (registration) => registration.registerOptions?.workspaceDiagnostics !== true, ); return { documentIdentifiers: [ ...new Set(documentRegistrations.flatMap((r) => r.registerOptions?.identifier ?? [])), ], supported: hasStaticPullDiagnostics || documentRegistrations.length > 0, }; } function workspacePullState() { const workspaceRegistrations = [...diagnosticRegistrations.values()].filter( (registration) => registration.registerOptions?.workspaceDiagnostics === true, ); return { workspaceIdentifiers: [ ...new Set(workspaceRegistrations.flatMap((r) => r.registerOptions?.identifier ?? [])), ], supported: workspaceRegistrations.length > 0, }; } async function requestDiagnostics( filePath: string, requests: Promise[], done: (results: DiagnosticRequestResult[]) => boolean, ): Promise { if (requests.length === 0) return { handled: false, matched: false, timedOut: false }; return new Promise((resolve) => { const results: DiagnosticRequestResult[] = []; let pending = requests.length; let resolved = false; const finish = (merged: PullResult, force = false) => { if (resolved) return; if (!force && !done(results)) return; resolved = true; resolve(merged); }; for (const request of requests) { void request .then((result) => { results.push(result); pending -= 1; const merged = mergeResults(filePath, results); finish(merged); if (pending === 0) finish(merged, true); return; }) .catch(() => { pending -= 1; if (pending === 0) finish(mergeResults(filePath, results), true); return; }); } }); } // 并发发起 identifier pull,一旦某批已产出当前文件诊断即可放行; // 慢的 pull 继续在后台合并,不按 identifier 串行。见 opencode PR #23771。 async function requestDocumentDiagnostics(filePath: string): Promise { const state = documentPullState(); if (!state.supported) return { handled: false, matched: false, timedOut: false }; return requestDiagnostics( filePath, [ requestDiagnosticReport(filePath), ...state.documentIdentifiers.map((identifier) => requestDiagnosticReport(filePath, identifier), ), ], (results) => hasCurrentFileDiagnostics(filePath, results), ); } async function requestFullDiagnostics(filePath: string): Promise { const documentState = documentPullState(); const workspaceState = workspacePullState(); if (!documentState.supported && !workspaceState.supported) { return { handled: false, matched: false, timedOut: false }; } return mergeResults( filePath, await Promise.all([ ...(documentState.supported ? [requestDiagnosticReport(filePath)] : []), ...documentState.documentIdentifiers.map((identifier) => requestDiagnosticReport(filePath, identifier), ), ...(workspaceState.supported ? [requestWorkspaceDiagnosticReport(filePath)] : []), ...workspaceState.workspaceIdentifiers.map((identifier) => requestWorkspaceDiagnosticReport(filePath, identifier), ), ]), ); } function waitForRegistrationChange(timeout: number): Promise { if (timeout <= 0) return Promise.resolve(false); return new Promise((resolve) => { let finished = false; const timer = setTimeout(() => finish(false), timeout); const finish = (result: boolean) => { if (finished) return; finished = true; clearTimeout(timer); registrationListeners.delete(listener); resolve(result); }; const listener = () => finish(true); registrationListeners.add(listener); }); } function waitForFreshPush(request: { path: string; version: number; after: number; timeout: number; }): Promise { if (request.timeout <= 0) return Promise.resolve(false); return new Promise((resolve) => { let finished = false; let debounceTimer: ReturnType | undefined; const timeoutTimer = setTimeout(() => finish(false), request.timeout); const unsub = () => diagnosticListeners.delete(listener); const finish = (result: boolean) => { if (finished) return; finished = true; if (debounceTimer) clearTimeout(debounceTimer); clearTimeout(timeoutTimer); unsub(); resolve(result); }; const schedule = () => { const hit = published.get(request.path); if (!hit) return; if (typeof hit.version === "number" && hit.version !== request.version) return; if (hit.at < request.after && hit.version !== request.version) return; if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout( () => finish(true), Math.max(0, diagnosticsDebounceMs - (Date.now() - hit.at)), ); }; const listener = (event: { path: string; serverID: string }) => { if (event.path !== request.path || event.serverID !== input.serverID) return; schedule(); }; diagnosticListeners.add(listener); schedule(); }); } async function waitForDocumentDiagnostics(request: { path: string; version: number; after?: number; signal?: AbortSignal; }): Promise { // 服务器对当前内容的最新结论已在手(最后一次 push 晚于最后一次内容同步, // 即此后没有再通知过内容变化):直接返回已有结果。部分服务器在诊断集合 // 不变时不再推送(typescript-language-server 空→空不发布),等新 push 只会 // 耗满整个窗口后得到同样的「无诊断」。 const known = published.get(request.path); const document = files[request.path]; if (known !== undefined && document !== undefined && known.at >= document.syncedAt) return; const startedAt = request.after ?? Date.now(); // 「上一份 push 为空」的文档用短安静期(见 clientDefaults.diagnosticsSilentWaitTimeoutMs)。 const budget = files[request.path]?.lastPushEmpty === true ? Math.min(diagnosticsDocumentWaitTimeoutMs, diagnosticsSilentWaitTimeoutMs) : diagnosticsDocumentWaitTimeoutMs; // pull 与 push 语义相同:都是等「当前文档版本」的诊断结果,统一一个循环。 // 先 pull(拿到即返回);pull 超时说明服务器未响应,不再重试 pull,只等 // 版本匹配的 push 兜底;版本不匹配的 push 一律忽略(防迟到旧结果)。 const pushWait = waitForFreshPush({ path: request.path, version: request.version, after: startedAt, timeout: budget, }); while (!connectionClosed && !request.signal?.aborted) { const remaining = budget - (Date.now() - startedAt); if (remaining <= 0) return; const result = await requestDocumentDiagnostics(request.path); if (result.matched) return; if (result.timedOut) { await pushWait; return; } const next = await Promise.race([ pushWait.then((ready) => (ready ? ("push" as const) : ("timeout" as const))), waitForRegistrationChange(remaining).then((changed) => changed ? ("registration" as const) : ("timeout" as const), ), sleep(Math.min(remaining, PULL_RETRY_INTERVAL_MS)).then(() => "interval" as const), ]); if (next === "push") return; } } async function waitForFullDiagnostics(request: { path: string; version: number; after?: number; signal?: AbortSignal; }): Promise { const startedAt = request.after ?? Date.now(); const pushWait = waitForFreshPush({ path: request.path, version: request.version, after: startedAt, timeout: diagnosticsFullWaitTimeoutMs, }); while (!connectionClosed && !request.signal?.aborted) { const remaining = diagnosticsFullWaitTimeoutMs - (Date.now() - startedAt); if (remaining <= 0) return; const result = await requestFullDiagnostics(request.path); if (result.handled || result.matched) return; if (result.timedOut) { await pushWait; return; } const next = await Promise.race([ pushWait.then((ready) => (ready ? ("push" as const) : ("timeout" as const))), waitForRegistrationChange(remaining).then((changed) => changed ? ("registration" as const) : ("timeout" as const), ), sleep(Math.min(remaining, PULL_RETRY_INTERVAL_MS)).then(() => "interval" as const), ]); if (next === "push") return; } } // ── 公开 API ──────────────────────────────────────────────────────────────── const openDocument = async (request: { path: string }): Promise => { const resolvedPath = normalize( isAbsolute(request.path) ? request.path : resolve(input.directory, request.path), ); const text = await readFile(resolvedPath, "utf8"); const extension = extname(resolvedPath); const languageId = input.server.languageIds?.[extension] ?? LANGUAGE_EXTENSIONS[extension] ?? "plaintext"; const uri = pathToFileURL(resolvedPath).href; const document = files[resolvedPath]; if (document !== undefined) { // 内容与服务器已知文本一致:不重复通知(didChange 会把已有诊断判为过期, // 而部分服务器对内容未产生新结论的文档不再推送,见 diagnosticsSilentWaitTimeoutMs)。 if (document.text === text) { touch(resolvedPath); return document.version; } // didChange:内容已变,旧诊断立即失效。清空缓存避免等待窗口内服务器 // 重算未完成时(大项目可远超窗口)聚合到过期诊断;新 push 到达即填充。 pushDiagnostics.delete(resolvedPath); pullDiagnostics.delete(resolvedPath); const next = document.version + 1; // 保留 lastPushEmpty:安静期判据看的是「变更前服务器最后一份结论是否为空」 files[resolvedPath] = { ...document, version: next, text, syncedAt: Date.now() }; documentVersions.set(resolvedPath, next); await connection.sendNotification("textDocument/didChange", { textDocument: { uri, version: next }, contentChanges: syncKind === TEXT_DOCUMENT_SYNC_INCREMENTAL ? [ { range: { start: { line: 0, character: 0 }, end: endPosition(document.text) }, text, }, ] : [{ text }], }); touch(resolvedPath); await evictExcess(); return next; } pushDiagnostics.delete(resolvedPath); pullDiagnostics.delete(resolvedPath); await connection.sendNotification("textDocument/didOpen", { textDocument: { uri, languageId, version: 0, text }, }); files[resolvedPath] = { version: 0, text, syncedAt: Date.now() }; documentVersions.set(resolvedPath, 0); touch(resolvedPath); await evictExcess(); return 0; }; // ── 只读符号查询(definition / references / hover)────────────────────────── /** 请求前先同步磁盘内容(didOpen/didChange),保证服务器基于最新文本应答。 */ const preparePositionRequest = async (request: InspectPositionRequest) => { const resolvedPath = normalize( isAbsolute(request.path) ? request.path : resolve(input.directory, request.path), ); await openDocument({ path: resolvedPath }); return { uri: pathToFileURL(resolvedPath).href, position: { line: request.line, character: request.character }, }; }; /** * 发一次可取消的请求:signal 中止时既通过 CancellationToken 让服务器停下 * ($/cancelRequest),也立刻以 signal.reason(默认 AbortError)拒绝本地 * promise——服务器可能永远不回应,工具调用不能就这么挂着。 */ async function sendAbortableRequest( method: string, params: object, signal: AbortSignal | undefined, ): Promise { if (!signal) return await connection.sendRequest(method, params); if (signal.aborted) throw abortReason(signal); const source = new CancellationTokenSource(); const aborted = Promise.withResolvers(); // 中止可能恰好在请求已返回之后才触发:那时 race 已经结算,而这个 promise 的 // rejection 没人再看——提前挂一个空 catch,否则它会变成 unhandled rejection // (pi 会因此整体退出)。 void aborted.promise.catch(() => { /* 失败路径由 race 处理 */ }); const onAbort = (): void => { source.cancel(); aborted.reject(abortReason(signal)); }; signal.addEventListener("abort", onAbort, { once: true }); try { return await Promise.race([ connection.sendRequest(method, params, source.token), aborted.promise, ]); } finally { signal.removeEventListener("abort", onAbort); source.dispose(); } } const sendInspectRequest = async ( method: string, message: object, signal?: AbortSignal, ): Promise => { try { return await retryOnContentModified(() => sendAbortableRequest(method, message, signal)); } catch (error) { if (error instanceof ResponseError && error.code === LSP_METHOD_NOT_FOUND) { throw new LspMethodNotSupportedError(input.serverID, method); } throw error; } }; return { root: input.root, get serverID() { return input.serverID; }, watchers(): WatcherGlob[] { return [ ...new Set( [...watcherRegistrations.values()].flat().map((w) => JSON.stringify([w.pattern, w.kind])), ), ].map((key) => { const [pattern, kind] = JSON.parse(key) as [string, number]; return { pattern, kind }; }); }, get connection() { return connection; }, notify: { open: openDocument, async watchedFiles(changes: FileChange[]): Promise { const notified: { uri: string; type: number }[] = []; for (const change of changes) { const resolvedPath = normalize( isAbsolute(change.path) ? change.path : resolve(input.directory, change.path), ); const document = files[resolvedPath]; if (document !== undefined) { // 写后诊断等待中的文档不退场(didClose 可能抹掉本次写入的诊断结果) if (waitingForDiagnostics.has(resolvedPath)) continue; // 驻留文档被外部改动:内容一致的自身写入 echo 完全忽略;否则先 didClose // 让服务器回落磁盘,再以文件事件通知——不 bump 版本,避免与写后等待竞态。 if (change.type === "changed") { let disk: string | undefined; try { disk = await readFile(resolvedPath, "utf8"); } catch { // 文件已被删除或不可读:按磁盘状态变化处理 } if (disk === document.text) continue; } await connection.sendNotification("textDocument/didClose", { textDocument: { uri: pathToFileURL(resolvedPath).href }, }); delete files[resolvedPath]; documentVersions.delete(resolvedPath); } notified.push({ uri: pathToFileURL(resolvedPath).href, type: FILE_CHANGE_TYPE[change.type], }); } if (notified.length === 0) return; await connection.sendNotification("workspace/didChangeWatchedFiles", { changes: notified, }); }, }, async renameSymbol(request: RenameSymbolRequest): Promise { const resolvedPath = normalize( isAbsolute(request.path) ? request.path : resolve(input.directory, request.path), ); const uri = pathToFileURL(resolvedPath).href; // rename 前强制同步磁盘内容,保证服务器基于最新文本计算编辑 const version = await openDocument({ path: resolvedPath }); // 索引就绪栅栏:项目异步加载完成前,服务器对 references 的答复只包含当前 // 打开的文件(尚未发现其它文件),而"连续两次一致"会把它误判成"索引已收 // 敛",于是 rename 漏掉跨文件引用——CI 上实测到(失败那次 3.0s 返回、只改 // 1 个文件,而通过的 7.9s)。服务器为本文件产生的第一份诊断报告要等项目加 // 载完成,用它当就绪信号;本会话已经收到过报告(文档早已驻留)时不再等待, // 免得给常见路径白加延迟。 if (!published.has(resolvedPath) && !pullDiagnostics.has(resolvedPath)) { await waitForDocumentDiagnostics({ path: resolvedPath, version, signal: request.signal, }); } const position = { line: request.line, character: request.character }; const at = `${resolvedPath}:${request.line + 1}:${request.character + 1}`; const notRenameable = () => new RenameNotPossibleError(`LSP server "${input.serverID}" cannot rename at ${at}`); // references 前置 + rename 双向校验:LSP 没有标准化的"索引完成"信号, // 服务器(如 tsserver)可能在项目加载完成前回答,导致 rename 漏掉 // 尚未入索引的文件。对策分三层: // 1. 收敛检测:references 连续两次文件集合一致才认为索引稳定,防止 // "服务器根本还没发现某文件"时校验形同虚设; // 2. 覆盖校验(missing):references 报告的文件必须都被 rename edit // 覆盖,缺失说明服务器索引落后,抛 RenameIncompleteError; // 3. 一致性校验(extra):rename 触及的文件超出已收敛的 references // 集合,说明两次请求之间项目覆盖在增长(rename 晚于 references, // 索引仍在加载),此时 rename 的结果本身不可信——回到 references // 轮询等重新收敛,再重发 rename 复检;预算耗尽仍不一致时抛 // RenameIncompleteError——调用方尚未写盘,整个操作无副作用。 // 服务器不支持 references(MethodNotFound)时跳过校验,信任服务器, // 与编辑器行为一致。 const referencesRequest = () => retryOnContentModified(() => sendAbortableRequest<{ uri: string }[] | null>( "textDocument/references", { textDocument: { uri }, position, context: { includeDeclaration: true }, }, request.signal, ), ); const toPaths = (locations: { uri: string }[] | null): Set => new Set( (locations ?? []).flatMap((location) => location.uri.startsWith("file:") ? [normalize(fileURLToPath(location.uri))] : [], ), ); const sendRename = async (): Promise => { try { return await retryOnContentModified(() => sendAbortableRequest( "textDocument/rename", { textDocument: { uri }, position, newName: request.newName, }, request.signal, ), ); } catch (error) { if (error instanceof ResponseError && error.code === LSP_METHOD_NOT_FOUND) { throw notRenameable(); } throw error; } }; let placeholder: string | undefined; if (hasPrepareProvider) { let prepared: PrepareRenameResponse | null; try { prepared = await retryOnContentModified(() => sendAbortableRequest( "textDocument/prepareRename", { textDocument: { uri }, position }, request.signal, ), ); } catch (error) { if (error instanceof ResponseError && error.code === LSP_METHOD_NOT_FOUND) { throw notRenameable(); } throw error; } if (!prepared) throw notRenameable(); if (typeof prepared.placeholder === "string") placeholder = prepared.placeholder; } let locations: { uri: string }[] | null; try { locations = await referencesRequest(); } catch (error) { if (!(error instanceof ResponseError && error.code === LSP_METHOD_NOT_FOUND)) throw error; const edit = await sendRename(); if (!edit) throw notRenameable(); return placeholder === undefined ? { edit } : { edit, placeholder }; } const deadline = Date.now() + renameVerificationTiming.budgetMs; let previous: Set | undefined; let current = toPaths(locations); for (;;) { request.signal?.throwIfAborted(); const settled = previous !== undefined && samePaths(previous, current); const expired = Date.now() >= deadline; if (settled || expired) { const edit = await sendRename(); if (!edit) throw notRenameable(); const editPaths = editFilePaths(edit); const missing: string[] = []; const extra: string[] = []; for (const path of current) { if (!editPaths.has(path)) missing.push(path); } for (const path of editPaths) { if (!current.has(path)) extra.push(path); } if (missing.length === 0 && extra.length === 0) { return placeholder === undefined ? { edit } : { edit, placeholder }; } if (expired || missing.length > 0) { throw new RenameIncompleteError(missing, extra); } // 收敛后 rename 仍报出 references 没有的文件:references 快照已过时, // 继续轮询到重新收敛后再重发 rename 复检(预算耗尽则向上抛)。 } previous = current; await sleepWithSignal(renameVerificationTiming.pollMs, request.signal); current = toPaths(await referencesRequest()); } }, async definition(request: InspectPositionRequest): Promise { const { uri, position } = await preparePositionRequest(request); return toInspectLocations( await sendInspectRequest( "textDocument/definition", { textDocument: { uri }, position }, request.signal, ), ); }, async references(request: InspectPositionRequest): Promise { const { uri, position } = await preparePositionRequest(request); const locations = await sendInspectRequest( "textDocument/references", { textDocument: { uri }, position, context: { includeDeclaration: true }, }, request.signal, ); return toInspectLocations(locations); }, async hover(request: InspectPositionRequest): Promise { const { uri, position } = await preparePositionRequest(request); return await sendInspectRequest( "textDocument/hover", { textDocument: { uri }, position }, request.signal, ); }, get diagnostics() { const result = new Map(); for (const key of new Set([...pushDiagnostics.keys(), ...pullDiagnostics.keys()])) { result.set(key, mergedDiagnostics(key)); } return result; }, async waitForDiagnostics(request) { const normalizedPath = normalize( isAbsolute(request.path) ? request.path : resolve(input.directory, request.path), ); waitingForDiagnostics.add(normalizedPath); try { if (request.mode === "document") { await waitForDocumentDiagnostics({ path: normalizedPath, version: request.version, after: request.after, signal: request.signal, }); return; } await waitForFullDiagnostics({ path: normalizedPath, version: request.version, after: request.after, signal: request.signal, }); } finally { waitingForDiagnostics.delete(normalizedPath); } }, async shutdown() { connection.end(); connection.dispose(); await stopProcess(input.server.process); }, }; }