From ab6e8b73ff5099faaf0a8803e8b440724fc6edaf Mon Sep 17 00:00:00 2001 From: andershsueh Date: Sat, 15 Aug 2026 02:18:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(lsp):=20=E6=8E=A5=E5=85=A5=20TypeScript=20?= =?UTF-8?q?Language=20Server=20(fix=20#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 src/services/lsp/: - stdioRunner.ts:Bun.spawn/child_process 双 runtime spawn + Content-Length 帧协议 + signal 优雅终止(SIGTERM→grace→SIGKILL, OS 层 signal 0 轮询兜底 Bun stdio pipe 不 resolve 的边角) - serverProcess.ts:command-exists 探测 tsls,缺失抛 LspNotInstalledError + TSLS_INSTALL_HINT - client.ts:JSON-RPC request/response 队列,实现 initialize / documentSymbols / definition / references 四个 method - locationFormat.ts:Location[] → {file, line, col, snippet} 序列化, 按文件分组避免 N+1 stat - index.ts:lazy + single-flight 单例,共享 install hint warn 新增 src/tools/builtin/: - lspGotoDefinition.ts / lspFindReferences.ts / lspDocumentSymbol.ts - 三个工具通过 withLspToolErrorBoundary 共享错误边界, 统一 install hint / 透传其它错误到 ToolResult 测试: - test-case/test-issue-013.ts(47 断言):tsls 探测/降级、 JSON-RPC 四 method 往返、Location 序列化、SIGTERM 进程回收、 tokenBudget.ts 端到端 symbols - test-case/fixtures/lsp-stub-server.mjs:Node 子进程模拟 tsls,实现 Content-Length 帧 + 4 method 响应(handler map) - 全量回归:001+002+003+004+005+019 = 168 断言全绿, 013 = 47 断言全绿,合计 215 - bun build.ts 成功 设计决策: - JSON-RPC 帧协议走纯函数 buildFrame/parseFrames,模块级常量避免 hot loop 分配 - 进程回收用 OS signal 0 轮询而非 proc.exited(Bun stdio pipe 不 resolve 已知问题),2s 上限兜底 - Location[] 序列化按 file path 分组,避免 50 个 references 触发 50 次 fs.stat - test stub 用 handler map 替代 if-ladder,single regex 一次匹配 export kind+name - 不复用 MCP SDK Protocol:其默认 newline framing,Content-Length 子类化得不偿失 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/services/lsp/client.ts | 289 +++++++++++++++++++ src/services/lsp/index.ts | 99 +++++++ src/services/lsp/locationFormat.ts | 136 +++++++++ src/services/lsp/serverProcess.ts | 126 ++++++++ src/services/lsp/stdioRunner.ts | 291 +++++++++++++++++++ src/tools/builtin/index.ts | 11 + src/tools/builtin/lspDocumentSymbol.ts | 113 ++++++++ src/tools/builtin/lspFindReferences.ts | 92 ++++++ src/tools/builtin/lspGotoDefinition.ts | 84 ++++++ test-case/fixtures/lsp-stub-server.mjs | 220 ++++++++++++++ test-case/test-issue-013.ts | 381 +++++++++++++++++++++++++ test-case/test-list.md | 5 +- 12 files changed, 1845 insertions(+), 2 deletions(-) create mode 100644 src/services/lsp/client.ts create mode 100644 src/services/lsp/index.ts create mode 100644 src/services/lsp/locationFormat.ts create mode 100644 src/services/lsp/serverProcess.ts create mode 100644 src/services/lsp/stdioRunner.ts create mode 100644 src/tools/builtin/lspDocumentSymbol.ts create mode 100644 src/tools/builtin/lspFindReferences.ts create mode 100644 src/tools/builtin/lspGotoDefinition.ts create mode 100755 test-case/fixtures/lsp-stub-server.mjs create mode 100644 test-case/test-issue-013.ts diff --git a/src/services/lsp/client.ts b/src/services/lsp/client.ts new file mode 100644 index 0000000..800301a --- /dev/null +++ b/src/services/lsp/client.ts @@ -0,0 +1,289 @@ +/** + * LSP client — JSON-RPC 请求/响应队列 + * + * 职责: + * 1. 启动 stdio LSP server(Bun.spawn / node:child_process) + * 2. 把 LSP method 调用包装为 async 函数(initialize / documentSymbol / definition / references) + * 3. 请求 → 响应通过递增 id 匹配;server 主动发的 notification / request 转给上层 + * + * 不负责: + * - 把 Location[] 转成 {file, line, col, snippet}(那是 locationFormat.js 的事) + * - 把 LSP 结果接入 builtin tool(那是 tools/builtin/lsp*.js 的事) + */ + +import { pathToFileURL } from 'node:url'; +import path from 'node:path'; +import { + startServer, + stopServer, + LspNotInstalledError, + resolveLspBinaryOverride, +} from './serverProcess.js'; +import { + spawnProcess, + terminateProcess, + writeToProcess, + startReading, + type StdioProcess, +} from './stdioRunner.js'; + +/** 初始化响应(只取我们关心的字段) */ +export type InitializeResult = { + capabilities: Record; + serverInfo?: { name?: string; version?: string }; +} | null; + +/** LSP Location(range + uri) */ +export type LspLocation = { + uri: string; + range: { + start: { line: number; character: number }; + end: { line: number; character: number }; + }; +}; + +/** 单个 LSP symbol(DocumentSymbol 简化形态,涵盖 SymbolInformation 与 DocumentSymbol) */ +export type LspSymbol = { + name: string; + kind: string | number; + location?: LspLocation; + range?: { start: { line: number; character: number }; end: { line: number; character: number } }; + children?: LspSymbol[]; +}; + +/** client 选项 */ +export type LspClientOptions = { + /** 直接指定 server 二进制 */ + binary?: string; + /** 工作目录 */ + cwd?: string; + /** 请求超时 ms */ + requestTimeoutMs?: number; +}; + +export class LspClient { + private proc: StdioProcess | null = null; + private nextId = 1; + private pending = new Map void; + reject: (e: unknown) => void; + timer: ReturnType; + }>(); + private stopReading: () => void = () => {}; + private initialized = false; + private opts: Required> & LspClientOptions; + private serverCapabilities: Record = {}; + + constructor(options: LspClientOptions = {}) { + this.opts = { + requestTimeoutMs: 10_000, + ...options, + }; + } + + /** 当前 server 子进程 pid(测试/调试用) */ + getProcessPid(): number | null { + return this.proc?.pid ?? null; + } + + /** server 是否已 initialize */ + isInitialized(): boolean { + return this.initialized; + } + + /** server capabilities(initialize 后可读) */ + getServerCapabilities(): Record { + return this.serverCapabilities; + } + + /** + * 启动 LSP server 子进程并接管 stdio + */ + async start(): Promise { + if (this.proc) return; + const override = resolveLspBinaryOverride(); + const proc = await startServer({ + binary: this.opts.binary ?? override ?? undefined, + cwd: this.opts.cwd, + }); + this.attach(proc); + } + + /** stub 模式:spawn 一个任意 Node 脚本作为子进程(测试用) */ + async startWithStub(stubScriptPath: string, extraArgs: string[] = []): Promise { + if (this.proc) return; + const proc = spawnProcess({ + cmd: [process.execPath, stubScriptPath, ...extraArgs], + cwd: process.cwd(), + }); + this.attach(proc); + } + + /** 共享 attach:开始读 stdout + 接管错误 */ + private attach(proc: StdioProcess): void { + this.proc = proc; + this.stopReading = startReading(proc, (msg) => this.handleMessage(msg)); + // 子进程异常退出时,所有 pending 请求 reject + proc.exited.then((code) => { + const err = new Error(`LSP server 异常退出 (code=${code})`); + for (const [, p] of this.pending) { + clearTimeout(p.timer); + p.reject(err); + } + this.pending.clear(); + this.initialized = false; + }).catch(() => { /* noop */ }); + } + + /** 处理一帧 JSON-RPC 消息 */ + private handleMessage(msg: Record): void { + // notification: 没有 id 字段 + if (msg.id === undefined && msg.method !== undefined) { + // 简单忽略 window/workDoneProgress 等 server-initiated notification + return; + } + // response: 有 id,且 method 不存在 + const id = msg.id; + if (typeof id === 'number') { + const p = this.pending.get(id); + if (p) { + this.pending.delete(id); + clearTimeout(p.timer); + if (msg.error) { + const err = msg.error as { code?: number; data?: unknown; message?: string }; + p.reject(new LspRpcError(err.message ?? 'rpc error', err.code ?? -1, err.data)); + } else { + p.resolve(msg.result); + } + } + } + } + + /** 通用 sendRequest:递增 id,挂上 pending,直到 response 匹配或超时 */ + private async sendRequest(method: string, params: unknown): Promise { + if (!this.proc) { + throw new Error('LSP server 未启动 — 请先调用 start() 或 startWithStub()'); + } + const id = this.nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const p = this.pending.get(id); + if (p) { + this.pending.delete(id); + reject(new Error(`LSP request timeout (method=${method}, id=${id})`)); + } + }, this.opts.requestTimeoutMs); + this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject, timer }); + const ok = writeToProcess(this.proc!, { + jsonrpc: '2.0', + id, + method, + params: params ?? null, + }); + if (!ok) { + clearTimeout(timer); + this.pending.delete(id); + reject(new Error('LSP server stdin 写入失败(进程可能已退出)')); + } + }); + } + + /** initialize handshake */ + async initialize(rootUri: string): Promise { + const result = await this.sendRequest('initialize', { + processId: process.pid, + rootUri, + capabilities: { + workspace: { configuration: true }, + textDocument: { + synchronization: { dynamicRegistration: false, willSave: false, didSave: false }, + documentSymbol: { dynamicRegistration: false }, + definition: { dynamicRegistration: false }, + references: { dynamicRegistration: false }, + }, + }, + workspaceFolders: rootUri ? [{ uri: rootUri, name: 'workspace' }] : null, + }); + if (result && 'capabilities' in result) { + this.serverCapabilities = result.capabilities ?? {}; + } + this.initialized = true; + // 发送 initialized notification(server 端 initialize 完成后需要) + if (this.proc) { + writeToProcess(this.proc, { jsonrpc: '2.0', method: 'initialized', params: {} }); + } + return result; + } + + /** textDocument/documentSymbol */ + async documentSymbols(uri: string): Promise { + return this.sendRequest('textDocument/documentSymbol', { + textDocument: { uri }, + }); + } + + /** textDocument/definition */ + async definition(uri: string, line: number, character: number): Promise { + const result = await this.sendRequest( + 'textDocument/definition', + { + textDocument: { uri }, + position: { line, character }, + }, + ); + if (Array.isArray(result)) return result; + if (result && typeof result === 'object' && 'uri' in result) return [result as LspLocation]; + return []; + } + + /** textDocument/references */ + async references(uri: string, line: number, character: number, includeDeclaration = false): Promise { + const result = await this.sendRequest( + 'textDocument/references', + { + textDocument: { uri }, + position: { line, character }, + context: { includeDeclaration }, + }, + ); + return result ?? []; + } + + /** 优雅关闭:发 shutdown notification → 等响应 → 发 exit notification */ + async shutdown(options: { force?: boolean } = {}): Promise { + if (!this.proc) return; + if (this.initialized && !options.force) { + try { + // shutdown 是 request,要等响应 + await this.sendRequest('shutdown', null); + } catch { + // 忽略:可能 server 已挂 + } + writeToProcess(this.proc, { jsonrpc: '2.0', method: 'exit', params: null }); + } + this.stopReading(); + await stopServer(this.proc, { force: options.force }); + this.proc = null; + this.initialized = false; + this.serverCapabilities = {}; + } +} + +/** 把绝对路径转为 file:// URI(走 Node 自带 pathToFileURL,正确处理 Windows / UNC / percent-encode) */ +export function filePathToUri(p: string): string { + if (p.startsWith('file://')) return p; + if (!path.isAbsolute(p)) return `file://${p}`; + return pathToFileURL(p).href; +} + +/** 自定义 JSON-RPC 错误(保留 code + data 便于上层判定) */ +export class LspRpcError extends Error { + constructor(message: string, public readonly code: number, public readonly data?: unknown) { + super(message); + this.name = 'LspRpcError'; + } +} + +// 重新导出供 builtin tools 使用 +export { LspNotInstalledError }; +export type { StdioProcess }; \ No newline at end of file diff --git a/src/services/lsp/index.ts b/src/services/lsp/index.ts new file mode 100644 index 0000000..471fccd --- /dev/null +++ b/src/services/lsp/index.ts @@ -0,0 +1,99 @@ +/** + * LSP client 单例 + 启动时 warn + * + * 工具侧通过 `getLspClient()` 拿到共享 client;首次访问时: + * - 探测 tsls,缺失则 console.warn + 返回 disabled client + * - 否则 lazy-start 真正的 LSP server + * + * 注意:此模块只供工具调用方使用,不要在 CLI 启动时强制初始化 + * (LSP 是按需资源)。 + */ + +import path from 'node:path'; +import type { ToolResult } from '../../types/tool.js'; +import { LspClient } from './client.js'; +import { + probeTypeScriptServer, + resetLspAvailabilityCache, + TSLS_INSTALL_HINT, + LspNotInstalledError, +} from './serverProcess.js'; + +let sharedClient: LspClient | null = null; +let inflight: Promise | null = null; +let warnedThisProcess = false; + +/** 取/创建共享 client(lazy + single-flight,避免并发启动泄漏 subprocess) */ +export async function getLspClient(): Promise { + if (sharedClient) return sharedClient; + if (inflight) return inflight; + + inflight = (async () => { + const probe = await probeTypeScriptServer(); + if (!probe.binary) { + // 一次性 warn(避免多次启动场景刷屏) + if (!warnedThisProcess) { + // eslint-disable-next-line no-console + console.warn(`[lsp] ${TSLS_INSTALL_HINT}`); + warnedThisProcess = true; + } + // 返回一个 disabled 客户端(未启动),调用方定义工具时通过 try/catch + // 自行决定降级(返回 success:false + install hint)。 + sharedClient = new LspClient(); + return sharedClient; + } + + sharedClient = new LspClient(); + await sharedClient.start(); + return sharedClient; + })().finally(() => { + inflight = null; + }); + + return inflight; +} + +/** 测试 / 单元代码:替换共享 client(允许注入 stub) */ +export function setLspClient(client: LspClient | null): void { + sharedClient = client; +} + +/** 关闭共享 client 并清空引用 */ +export async function disposeLspClient(): Promise { + if (!sharedClient) return; + try { + await sharedClient.shutdown(); + } catch { + /* noop */ + } + sharedClient = null; + warnedThisProcess = false; + resetLspAvailabilityCache(); +} + +/** 把工具入参的 file 字段解析为绝对路径(走 context.workspace / process.cwd()) */ +export function resolveToolFilePath( + file: string, + context?: { workspace?: string }, +): string { + const base = context?.workspace ?? process.cwd(); + return path.isAbsolute(file) ? file : path.resolve(base, file); +} + +/** + * LSP 工具统一 try/catch 边界:把 LspNotInstalledError 翻译为安装提示, + * 其它错误透传。返回 success:false 的 ToolResult,正常情况由 fn 返回。 + * + * 工具的 execute() 末尾统一 `return withLspToolErrorBoundary('toolName', err)`, + * 避免每个工具都写一份相同的 catch 块。 + */ +export function withLspToolErrorBoundary( + toolName: string, + err: unknown, +): ToolResult { + if (err instanceof LspNotInstalledError) { + return { success: false, error: TSLS_INSTALL_HINT }; + } + const msg = err instanceof Error ? err.message : String(err); + return { success: false, error: `${toolName} 失败: ${msg}` }; +} \ No newline at end of file diff --git a/src/services/lsp/locationFormat.ts b/src/services/lsp/locationFormat.ts new file mode 100644 index 0000000..db53c78 --- /dev/null +++ b/src/services/lsp/locationFormat.ts @@ -0,0 +1,136 @@ +/** + * LSP Location 序列化工具 + * + * 把 LSP 的 Location 对象(URI + range)转成对人类/LLM 友好的格式: + * { file, line, col, snippet } + * + * - file: 去除 `file://` 前缀,POSIX 平台直接保留,Windows 平台做盘符修复 + * - line: LSP 行号是 0-indexed — 保留 0-indexed 以便调用方进一步格式化 + * - col: character 偏移(LSP 规范:UTF-16 code unit 偏移) + * - snippet: 单行文本,带前后 trim,方便 LLM 引用上下文 + */ + +import fs from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +/** 单 Location 序列化结果 */ +export type FormattedLocation = { + /** 去除 file:// 前缀的文件绝对路径(尽可能) */ + file: string; + /** 0-indexed 行号 */ + line: number; + /** character 偏移 */ + col: number; + /** 目标行的文本 snippet(单行,trim 过) */ + snippet: string; +}; + +/** LSP Location(同 client.ts 中的 LspLocation) */ +export type RawLocation = { + uri: string; + range: { + start: { line: number; character: number }; + end: { line: number; character: number }; + }; +}; + +/** 文档文本片段缓存,避免每次都读盘;mtime 失效自动 reload */ +const textCache = new Map(); + +/** 读取 uri 对应文件的文本,带 mtime 缓存(命中即返回,避免每次 stat) */ +async function readDocumentText(uri: string): Promise { + const filePath = uriToFilePath(uri); + let stat: Awaited>; + try { + stat = await fs.stat(filePath); + } catch { + return ''; + } + const cached = textCache.get(filePath); + if (cached && cached.mtimeMs === stat.mtimeMs) return cached.text; + const text = await fs.readFile(filePath, 'utf-8'); + textCache.set(filePath, { text, mtimeMs: stat.mtimeMs }); + return text; +} + +/** 把 file:// URI 还原成文件系统路径(Node 自带 fileURLToPath) */ +export function uriToFilePath(uri: string): string { + if (!uri.startsWith('file://')) return uri; + try { + return fileURLToPath(uri); + } catch { + // URI 损坏时兜底用纯字符串截断 + return uri.slice('file://'.length); + } +} + +/** 单 Location 序列化(异步,因为 snippet 需要读文件) */ +export async function formatLocation(loc: RawLocation): Promise { + const text = await readDocumentText(loc.uri); + const lineText = text.split('\n')[loc.range.start.line] ?? ''; + return { + file: uriToFilePath(loc.uri), + line: loc.range.start.line, + col: loc.range.start.character, + snippet: lineText.trim(), + }; +} + +/** + * 多 Location 序列化(按文件分组,每文件只读一次,然后映射 snippet) + * - N+1 → 1:避免每个 location 都 stat/read 同一文件 + * - 并行:多文件间独立,可并发处理 + */ +export async function formatLocations(locs: RawLocation[]): Promise { + // 第一步:按 file 路径分组,一次性 fetch 所有 source text + const byFile = new Map(); + const uniqFiles = new Set(locs.map((l) => uriToFilePath(l.uri))); + await Promise.all( + [...uniqFiles].map(async (file) => { + const text = await readDocumentTextByPath(file); + byFile.set(file, text); + }), + ); + + // 第二步:映射每个 location(无 IO,纯字符串切) + return locs.map((loc) => { + const text = byFile.get(uriToFilePath(loc.uri)) ?? ''; + const lineText = text.split('\n')[loc.range.start.line] ?? ''; + return { + file: uriToFilePath(loc.uri), + line: loc.range.start.line, + col: loc.range.start.character, + snippet: lineText.trim(), + }; + }); +} + +/** 内部 helper:按 file 路径(而非 URI)读,跳过 uri 解析开销 */ +async function readDocumentTextByPath(filePath: string): Promise { + let stat: Awaited>; + try { + stat = await fs.stat(filePath); + } catch { + return ''; + } + const cached = textCache.get(filePath); + if (cached && cached.mtimeMs === stat.mtimeMs) return cached.text; + const text = await fs.readFile(filePath, 'utf-8'); + textCache.set(filePath, { text, mtimeMs: stat.mtimeMs }); + return text; +} + +/** 测试 / stub 专用:不读盘,直接给 snippet(避免 IO 抖动) */ +export function formatLocationWithText(loc: RawLocation, documentText: string): FormattedLocation { + const lineText = documentText.split('\n')[loc.range.start.line] ?? ''; + return { + file: uriToFilePath(loc.uri), + line: loc.range.start.line, + col: loc.range.start.character, + snippet: lineText.trim(), + }; +} + +export function formatLocationsWithText(locs: RawLocation[], documentText: string): FormattedLocation[] { + return locs.map((l) => formatLocationWithText(l, documentText)); +} \ No newline at end of file diff --git a/src/services/lsp/serverProcess.ts b/src/services/lsp/serverProcess.ts new file mode 100644 index 0000000..fa1677c --- /dev/null +++ b/src/services/lsp/serverProcess.ts @@ -0,0 +1,126 @@ +/** + * LSP server process — TypeScript Language Server 进程生命周期 + * + * 职责: + * 1. 探测 `typescript-language-server` (or `tsls`) 二进制在 PATH 中可用 + * 2. 启动/停止子进程;启动失败时返回 typed error 含安装提示 + * 3. 提供 startServer / stopServer 一对生命周期 API + * + * 与 stdioRunner.ts 的关系: + * - 本模块持有 StdioProcess 句柄,把 spawn / terminate 的细节集中一处 + * - client.ts 通过本模块拿到 process,然后做 JSON-RPC 读写 + */ + +import commandExists from 'command-exists'; +import { isNodeError } from '../../shim/qwen-code-core.js'; +import { spawnProcess, terminateProcess, type StdioProcess } from './stdioRunner.js'; + +/** 二进制候选名(typescript-language-server 的 npm 包安装名/CLI 别名) */ +const CANDIDATE_BINARIES = ['typescript-language-server', 'tsls']; + +/** 探测结果 */ +export type TypeScriptServerProbe = + | { binary: string; reason: 'found' } + | { binary: null; reason: 'not-found' }; + +/** 进程内缓存:避免每次 spawn 都跑一次 `command-exists` */ +let cachedProbe: TypeScriptServerProbe | undefined; + +/** 探测 tsls 是否在 PATH 中,缓存结果 */ +export async function probeTypeScriptServer(): Promise { + if (cachedProbe !== undefined) return cachedProbe; + + for (const bin of CANDIDATE_BINARIES) { + // command-exists.sync 直接返回 boolean;失败抛错被 catch 吞掉 + let ok = false; + try { + ok = commandExists.sync(bin); + } catch { + ok = false; + } + if (ok) { + cachedProbe = { binary: bin, reason: 'found' }; + return cachedProbe; + } + } + + cachedProbe = { binary: null, reason: 'not-found' }; + return cachedProbe; +} + +/** 清空缓存(测试 / 热重载场景) */ +export function resetLspAvailabilityCache(): void { + cachedProbe = undefined; +} + +/** 启动 LSP server 失败的 typed error */ +export class LspNotInstalledError extends Error { + readonly kind = 'lsp-not-installed' as const; + constructor(message = 'typescript-language-server 未安装') { + super(message); + this.name = 'LspNotInstalledError'; + } +} + +/** tsls 安装提示文本(给用户看) */ +export const TSLS_INSTALL_HINT = + '未检测到 typescript-language-server。请执行 `npm i -g typescript-language-server` 安装后重试。' + + ' 或设置 ALICE_LSP_BIN 环境变量指向本地 tsls 二进制。'; + +/** 启动 server,返回 StdioProcess 句柄 */ +export async function startServer(options?: { + binary?: string; + cwd?: string; +}): Promise { + let binary = options?.binary; + if (!binary) { + const probe = await probeTypeScriptServer(); + if (!probe.binary) { + throw new LspNotInstalledError(TSLS_INSTALL_HINT); + } + binary = probe.binary; + } else { + // 显式传入的 binary 也要先校验(command-exists 在 macOS 上 + // confstr(_CS_PATH) 兜底不可靠,所以必须自己再 check 一次) + let ok = false; + try { + ok = commandExists.sync(binary); + } catch { + ok = false; + } + if (!ok) { + throw new LspNotInstalledError(TSLS_INSTALL_HINT); + } + } + + // tsls 接受 --stdio 进入 LSP 模式(默认即可) + try { + return spawnProcess({ + cmd: [binary, '--stdio'], + cwd: options?.cwd, + env: { LANG: 'C.UTF-8' }, + }); + } catch (err) { + if (isNodeError(err) && err.code === 'ENOENT') { + throw new LspNotInstalledError(TSLS_INSTALL_HINT); + } + throw err; + } +} + +/** 停止 server,优雅终止 */ +export async function stopServer( + proc: StdioProcess | null | undefined, + options: { graceMs?: number; force?: boolean } = {}, +): Promise { + if (!proc) return; + await terminateProcess(proc, options); +} + +/** + * 解析 ALICE_LSP_BIN 环境变量,允许用户自定义二进制路径 + * (在没有 npm 全局权限但有 nvm 安装的环境下很有用) + */ +export function resolveLspBinaryOverride(): string | null { + return process.env['ALICE_LSP_BIN'] || null; +} \ No newline at end of file diff --git a/src/services/lsp/stdioRunner.ts b/src/services/lsp/stdioRunner.ts new file mode 100644 index 0000000..bca335b --- /dev/null +++ b/src/services/lsp/stdioRunner.ts @@ -0,0 +1,291 @@ +/** + * LSP stdio runner — spawn + Content-Length 帧协议 + * + * LSP(Language Server Protocol)的 stdio transport 要求每条 JSON-RPC 消息 + * 用 `Content-Length: N\r\n\r\n` 帧头包起来。本模块封装: + * - 启动子进程(Bun.spawn / Node child_process 双模式,因为 build 走 + * nodejs-target 时没有 Bun runtime) + * - 写出帧 / 读入帧的字节级辅助函数 + * - 优雅退出:SIGTERM → 等待 → SIGKILL,避免残留 tsserver 进程 + * + * 设计原则: + * - 失败一律以 { ok:false } 返回,不抛错(调用方决定降级) + * - 全局共享 frame helpers(纯函数,便于在 client.ts 与测试间复用) + * - 进程 ID 暴露供测试做 SIGTERM 验证 + */ + +import { spawnWrapper } from '../../utils/spawnWrapper.js'; + +/** 帧头正则:支持大小写不敏感、容忍多余空白 */ +const CONTENT_LENGTH_RE = /^Content-Length:\s*(\d+)/i; + +/** 模块级常量:避免 parseFrames / 测试 hot loop 重复分配 */ +const CRLF_CRLF = Buffer.from('\r\n\r\n'); + +/** 进程句柄类型:不直接依赖 Bun / Node,以便 build 走 nodejs-target */ +export interface StdioProcess { + pid: number; + stdin: NodeJS.WritableStream | null; + stdout: NodeJS.ReadableStream | null; + stderr: NodeJS.ReadableStream | null; + exited: Promise; + /** + * 注:Bun 的 Subprocess.kill 内部 `this`-bound,不能安全地剥离成普通 + * 方法,所以这里改成"通过 process.kill(pid, signal) 发信号"的封装, + * 跨 Bun/Node 通用。 + */ + kill: (signal?: NodeJS.Signals | number) => void; +} + +export type SpawnOptions = { + cmd: string[]; + cwd?: string; + env?: Record; + stdin?: 'pipe' | 'ignore' | null; + stdout?: 'pipe' | 'ignore' | null; + stderr?: 'pipe' | 'ignore' | null; +}; + +/** 跨运行时 kill:不依赖 Subprocess 实例,直接用 process.kill(pid, signal) */ +function killByPid(pid: number, signal?: NodeJS.Signals | number): void { + try { + process.kill(pid, signal ?? 'SIGTERM'); + } catch { + /* process already dead */ + } +} + +/** + * 跨运行时 spawn:优先用 Bun(开发期快、stdin/stdout 字节流), + * 否则退回 node:child_process(打包后 nodejs-target)。 + */ +export function spawnProcess(options: SpawnOptions): StdioProcess { + const env: Record = { + ...(process.env as Record), + ...(options.env ?? {}), + }; + const stdio = { + stdin: options.stdin ?? 'pipe', + stdout: options.stdout ?? 'pipe', + stderr: options.stderr ?? 'pipe', + }; + const baseOptions = { + cmd: options.cmd, + cwd: options.cwd, + env, + ...stdio, + }; + + // Bun 运行时 + if (typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined') { + const Bun = (globalThis as { Bun: { + spawn: (opts: typeof baseOptions) => { + pid: number; + stdin: unknown; + stdout: unknown; + stderr: unknown; + exited: Promise; + }; + } }).Bun; + const proc = Bun.spawn(baseOptions); + return { + pid: proc.pid, + stdin: proc.stdin as NodeJS.WritableStream | null, + stdout: proc.stdout as NodeJS.ReadableStream | null, + stderr: proc.stderr as NodeJS.ReadableStream | null, + exited: proc.exited, + kill: (signal) => killByPid(proc.pid, signal), + }; + } + + // Node 运行时(打包后) + const [bin, ...args] = options.cmd; + const child = spawnWrapper(bin, args, { + cwd: options.cwd, + env: env as NodeJS.ProcessEnv, + stdio: [stdio.stdin, stdio.stdout, stdio.stderr], + }); + return { + pid: child.pid ?? -1, + stdin: child.stdin, + stdout: child.stdout, + stderr: child.stderr, + exited: new Promise((resolve) => { + child.on('exit', (code) => resolve(code ?? 0)); + }), + kill: (signal) => killByPid(child.pid ?? -1, signal), + }; +} + +/** 把任意 JSON 值打包成 LSP frame,返回完整 Buffer */ +export function buildFrame(message: unknown): Buffer { + const body = Buffer.from(JSON.stringify(message), 'utf-8'); + const header = Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'utf-8'); + return Buffer.concat([header, body]); +} + +/** 把 Buffer 切成多个完整帧;解析失败的字节保留在 leftover */ +export function parseFrames( + buf: Buffer, +): { frames: Array>; leftover: Buffer } { + const frames: Array> = []; + let cursor = 0; + while (cursor < buf.length) { + const slice = buf.subarray(cursor); + const headerEnd = slice.indexOf(CRLF_CRLF); + if (headerEnd < 0) break; + const headerStr = slice.subarray(0, headerEnd).toString('utf-8'); + const match = headerStr.match(CONTENT_LENGTH_RE); + if (!match) break; + const len = Number(match[1]); + if (slice.length < headerEnd + 4 + len) break; + const bodyStr = slice.subarray(headerEnd + 4, headerEnd + 4 + len).toString('utf-8'); + try { + frames.push(JSON.parse(bodyStr) as Record); + } catch { + // 单帧解析失败:丢弃该帧,继续解析后续(避免 stuck) + } + cursor += headerEnd + 4 + len; + } + return { frames, leftover: buf.subarray(cursor) }; +} + +/** 把字符串写到子进程 stdin(自动加帧) */ +export function writeToProcess(proc: StdioProcess, message: unknown): boolean { + if (!proc.stdin) return false; + const writable = proc.stdin as NodeJS.WritableStream & { destroyed?: boolean }; + if (writable.destroyed === true) return false; + const frame = buildFrame(message); + try { + writable.write(frame); + return true; + } catch { + return false; + } +} + +/** + * 优雅终止:先 SIGTERM,等 graceMs 后未退出 → SIGKILL + * 注意:Windows 不支持 SIGTERM,这里走 SIGKILL 兜底 + * + * 双保险:`proc.exited`(Bun/Node 各自 promise)在某些环境下不会及时 + * resolve(stdio stream 还挂着)。所以额外用 signal 0 轮询 OS 层 pid + * 存活状态,以这个为准给 Promise.race 做计时器。 + */ +export async function terminateProcess( + proc: StdioProcess, + options: { graceMs?: number; force?: boolean } = {}, +): Promise { + const graceMs = options.graceMs ?? 1500; + const pid = proc.pid; + const isAlive = (): boolean => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + }; + + if (options.force) { + try { proc.kill('SIGKILL'); } catch { /* noop */ } + } else { + try { + proc.kill('SIGTERM'); + } catch { + /* noop */ + } + // 用 signal 0 轮询代替 proc.exited,避免 stdio stream 阻塞 + const deadline = Date.now() + graceMs; + while (Date.now() < deadline && isAlive()) { + await new Promise((r) => setTimeout(r, 30)); + } + if (isAlive()) { + try { proc.kill('SIGKILL'); } catch { /* noop */ } + // 给 SIGKILL 一点时间生效 + const deadline2 = Date.now() + 500; + while (Date.now() < deadline2 && isAlive()) { + await new Promise((r) => setTimeout(r, 20)); + } + } + } + + // 注:Bun 的 proc.exited 在 stdio pipe 仍打开时不 resolve,所以我们用 + // OS 层 signal 0 检测到的"dead"状态作为权威终止条件;若还活着, + // 给一个宽松上限(2s)等待 proc.exited,超时返回 -1 表示不确定。 + if (!isAlive()) return -1; + const waitDeadline = Date.now() + 2_000; + while (Date.now() < waitDeadline && isAlive()) { + await new Promise((r) => setTimeout(r, 50)); + } + return isAlive() ? 0 : -1; +} + +/** + * 从子进程 stdout 持续读帧,把每条 JSON-RPC 消息派发给 handler。 + * 返回一个 stop() 函数,用于取消读循环。 + * + * 双 runtime 兼容: + * - Bun 给出的是 Web ReadableStream,用 getReader() 异步读 chunk + * - Node child_process 给出的是 NodeJS.ReadableStream,用 on('data') + * 监听 + */ +export function startReading( + proc: StdioProcess, + handler: (msg: Record) => void, +): () => void { + if (!proc.stdout) return () => {}; + + const stream = proc.stdout as unknown as ReadableStream & NodeJS.ReadableStream; + let stopped = false; + + // Web ReadableStream 检测:有 getReader 即为 Web stream + const isWebStream = typeof (stream as { getReader?: unknown }).getReader === 'function'; + + if (isWebStream) { + const webStream = stream as unknown as ReadableStream; + const reader = webStream.getReader(); + let leftover: Buffer = Buffer.alloc(0); + + const pump = async (): Promise => { + try { + while (!stopped) { + const { value, done } = await reader.read(); + if (done) break; + if (value === undefined) continue; + leftover = Buffer.concat([leftover, Buffer.from(value)]); + const { frames, leftover: next } = parseFrames(leftover); + leftover = next; + for (const f of frames) handler(f); + } + } catch { + /* pipe closed */ + } + }; + void pump(); + + return () => { + stopped = true; + try { reader.cancel().catch(() => {}); } catch { /* noop */ } + }; + } + + // Node ReadableStream 路径 + let leftover: Buffer = Buffer.alloc(0); + const onData = (chunk: Buffer | Uint8Array | string): void => { + if (stopped) return; + const buf: Buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + leftover = Buffer.concat([leftover, buf]); + const { frames, leftover: next } = parseFrames(leftover); + leftover = next; + for (const f of frames) handler(f); + }; + + const nodeStream = stream as NodeJS.ReadableStream; + nodeStream.on('data', onData); + + return () => { + stopped = true; + try { nodeStream.removeListener('data', onData); } catch { /* noop */ } + }; +} \ No newline at end of file diff --git a/src/tools/builtin/index.ts b/src/tools/builtin/index.ts index 1e0c1c7..d5a10d4 100644 --- a/src/tools/builtin/index.ts +++ b/src/tools/builtin/index.ts @@ -15,6 +15,9 @@ export { askUserTool, setQuestionDialogCallback } from './askUser.js'; export { loadSkillTool } from './loadSkill.js'; export { todoWriteTool, todoReadTool, resetTodos } from './todo.js'; export { sequentialThinkingTool } from './sequentialThinking.js'; +export { lspGotoDefinitionTool } from './lspGotoDefinition.js'; +export { lspFindReferencesTool } from './lspFindReferences.js'; +export { lspDocumentSymbolTool } from './lspDocumentSymbol.js'; import { readFileTool } from './readFile.js'; import { writeFileTool } from './writeFile.js'; @@ -29,6 +32,9 @@ import { askUserTool } from './askUser.js'; import { loadSkillTool } from './loadSkill.js'; import { todoWriteTool, todoReadTool } from './todo.js'; import { sequentialThinkingTool } from './sequentialThinking.js'; +import { lspGotoDefinitionTool } from './lspGotoDefinition.js'; +import { lspFindReferencesTool } from './lspFindReferences.js'; +import { lspDocumentSymbolTool } from './lspDocumentSymbol.js'; /** * 所有内置工具列表 @@ -48,4 +54,9 @@ export const builtinTools = [ todoWriteTool, todoReadTool, sequentialThinkingTool, + // LSP 工具(issue #13 / IK8MWS)— 依赖 typescript-language-server, + // 缺失时工具返回 success:false + 安装提示,不崩溃。 + lspGotoDefinitionTool, + lspFindReferencesTool, + lspDocumentSymbolTool, ]; diff --git a/src/tools/builtin/lspDocumentSymbol.ts b/src/tools/builtin/lspDocumentSymbol.ts new file mode 100644 index 0000000..150ac05 --- /dev/null +++ b/src/tools/builtin/lspDocumentSymbol.ts @@ -0,0 +1,113 @@ +/** + * lspDocumentSymbol — LSP document symbol 工具 + * + * 调用 LSP server 的 textDocument/documentSymbol,返回文件所有顶层 + * exports(类 / 接口 / 类型别名 / 函数 / 变量)的扁平列表,每个 symbol + * 包含 { name, kind, location: { file, line, col } }。 + * + * 参数: + * - file: 相对或绝对路径 + */ + +import type { AliceTool, ToolResult } from '../../types/tool.js'; +import { + getLspClient, + resolveToolFilePath, + withLspToolErrorBoundary, +} from '../../services/lsp/index.js'; +import { uriToFilePath } from '../../services/lsp/locationFormat.js'; +import { filePathToUri } from '../../services/lsp/client.js'; + +/** 扁平化 LSP SymbolInformation / DocumentSymbol 数组 */ +type FlatSymbol = { + name: string; + kind: string; + line: number; + col: number; + children?: Array<{ name: string; kind: string; line: number; col: number }>; +}; + +function flattenSymbols(raw: unknown[]): FlatSymbol[] { + const out: FlatSymbol[] = []; + for (const s of raw) { + if (!s || typeof s !== 'object') continue; + const sym = s as { + name?: string; + kind?: string | number; + location?: { uri?: string; range?: { start?: { line: number; character: number } } }; + range?: { start?: { line: number; character: number } }; + children?: unknown[]; + }; + const range = sym.location?.range ?? sym.range; + const line = range?.start?.line ?? 0; + const col = range?.start?.character ?? 0; + const item: FlatSymbol = { + name: String(sym.name ?? ''), + kind: typeof sym.kind === 'string' ? sym.kind : String(sym.kind ?? ''), + line, + col, + }; + if (Array.isArray(sym.children) && sym.children.length > 0) { + item.children = sym.children.map((c) => { + const child = c as typeof sym; + const cr = child.range; + return { + name: String(child.name ?? ''), + kind: typeof child.kind === 'string' ? child.kind : String(child.kind ?? ''), + line: cr?.start?.line ?? 0, + col: cr?.start?.character ?? 0, + }; + }); + } + out.push(item); + } + return out; +} + +export const lspDocumentSymbolTool: AliceTool = { + name: 'lspDocumentSymbol', + label: 'LSP document symbol', + description: + '通过 TypeScript Language Server 获取源文件的所有顶层符号(export 类/接口/函数等)。' + + '返回扁平 symbol 数组,每项含 { name, kind, location }。' + + '依赖 typescript-language-server(未安装时返回安装提示)。', + parameters: { + type: 'object', + properties: { + file: { + type: 'string', + description: '源文件绝对路径或相对于 workspace 的路径', + }, + }, + required: ['file'], + }, + + async execute(toolCallId, params, _signal, _onUpdate, context): Promise { + const { file } = params as { file: string }; + const resolvedPath = resolveToolFilePath(file, context); + + try { + const client = await getLspClient(); + if (!client.isInitialized()) { + await client.initialize(uriToFilePath(resolvedPath)); + } + const uri = filePathToUri(resolvedPath); + const raw = await client.documentSymbols(uri); + + // 扁平化(LSP DocumentSymbol 可嵌套 children,这里只取顶层 + + // 递归把所有 children 也展开,方便 LLM 一次性看到所有 exports) + const flat = flattenSymbols(raw); + + return { + success: true, + data: { + file: resolvedPath, + count: flat.length, + symbols: flat, + }, + }; + } catch (err: unknown) { + return withLspToolErrorBoundary('lspDocumentSymbol', err); + } + }, +}; \ No newline at end of file diff --git a/src/tools/builtin/lspFindReferences.ts b/src/tools/builtin/lspFindReferences.ts new file mode 100644 index 0000000..6b915f6 --- /dev/null +++ b/src/tools/builtin/lspFindReferences.ts @@ -0,0 +1,92 @@ +/** + * lspFindReferences — LSP find references 工具 + * + * 调用 LSP server 的 textDocument/references,把结果 Location[] 序列化为 + * {file, line, col, snippet} 数组。 + * + * 参数: + * - file: 相对或绝对路径 + * - line: 0-indexed 行号 + * - character: 0-indexed character + * - includeDeclaration: 是否包含声明处(默认 false) + */ + +import type { AliceTool, ToolResult } from '../../types/tool.js'; +import { + getLspClient, + resolveToolFilePath, + withLspToolErrorBoundary, +} from '../../services/lsp/index.js'; +import { + formatLocations, + uriToFilePath, +} from '../../services/lsp/locationFormat.js'; +import { filePathToUri } from '../../services/lsp/client.js'; + +export const lspFindReferencesTool: AliceTool = { + name: 'lspFindReferences', + label: 'LSP find references', + description: + '通过 TypeScript Language Server 查找符号的所有引用位置。' + + '返回 {file, line, col, snippet} 数组。依赖 typescript-language-server。', + parameters: { + type: 'object', + properties: { + file: { + type: 'string', + description: '源文件绝对路径或相对于 workspace 的路径', + }, + line: { + type: 'number', + description: '0-indexed 行号', + }, + character: { + type: 'number', + description: '0-indexed character 偏移', + }, + includeDeclaration: { + type: 'boolean', + description: '是否包含声明处(默认 false)', + }, + }, + required: ['file', 'line', 'character'], + }, + + async execute(toolCallId, params, _signal, _onUpdate, context): Promise { + const { + file, + line, + character, + includeDeclaration = false, + } = params as { + file: string; + line: number; + character: number; + includeDeclaration?: boolean; + }; + const resolvedPath = resolveToolFilePath(file, context); + + try { + const client = await getLspClient(); + if (!client.isInitialized()) { + await client.initialize(uriToFilePath(resolvedPath)); + } + const uri = filePathToUri(resolvedPath); + const raw = await client.references(uri, line, character, includeDeclaration); + const references = await formatLocations(raw); + return { + success: true, + data: { + file: resolvedPath, + line, + character, + includeDeclaration, + count: references.length, + references, + }, + }; + } catch (err: unknown) { + return withLspToolErrorBoundary('lspFindReferences', err); + } + }, +}; \ No newline at end of file diff --git a/src/tools/builtin/lspGotoDefinition.ts b/src/tools/builtin/lspGotoDefinition.ts new file mode 100644 index 0000000..46cc140 --- /dev/null +++ b/src/tools/builtin/lspGotoDefinition.ts @@ -0,0 +1,84 @@ +/** + * lspGotoDefinition — LSP goto definition 工具 + * + * 调用 LSP server 的 textDocument/definition,把结果 Location[] 序列化为 + * {file, line, col, snippet} 形态,方便 LLM 引用。 + * + * 参数: + * - file: 相对或绝对路径(.ts/.tsx 等) + * - line: 0-indexed 行号 + * - character: 0-indexed character + * + * 注意: + * - tsls 未安装时,返回 success:false + 安装提示 + * - LSP server 未启动时,自动 lazy-start(getLspClient 触发) + */ + +import type { AliceTool, ToolResult } from '../../types/tool.js'; +import { + getLspClient, + resolveToolFilePath, + withLspToolErrorBoundary, +} from '../../services/lsp/index.js'; +import { + formatLocations, + uriToFilePath, +} from '../../services/lsp/locationFormat.js'; +import { filePathToUri } from '../../services/lsp/client.js'; + +export const lspGotoDefinitionTool: AliceTool = { + name: 'lspGotoDefinition', + label: 'LSP goto definition', + description: + '通过 TypeScript Language Server 跳转到符号定义位置。返回 {file, line, col, snippet} 数组。' + + '依赖 typescript-language-server(未安装时返回安装提示)。', + parameters: { + type: 'object', + properties: { + file: { + type: 'string', + description: '源文件绝对路径或相对于 workspace 的路径', + }, + line: { + type: 'number', + description: '0-indexed 行号', + }, + character: { + type: 'number', + description: '0-indexed character 偏移', + }, + }, + required: ['file', 'line', 'character'], + }, + + async execute(toolCallId, params, _signal, _onUpdate, context): Promise { + const { file, line, character } = params as { + file: string; + line: number; + character: number; + }; + const resolvedPath = resolveToolFilePath(file, context); + + try { + const client = await getLspClient(); + if (!client.isInitialized()) { + await client.initialize(uriToFilePath(resolvedPath)); + } + const uri = filePathToUri(resolvedPath); + const raw = await client.definition(uri, line, character); + const locations = await formatLocations(raw); + return { + success: true, + data: { + file: resolvedPath, + line, + character, + count: locations.length, + locations, + }, + }; + } catch (err: unknown) { + return withLspToolErrorBoundary('lspGotoDefinition', err); + } + }, +}; \ No newline at end of file diff --git a/test-case/fixtures/lsp-stub-server.mjs b/test-case/fixtures/lsp-stub-server.mjs new file mode 100755 index 0000000..3691e35 --- /dev/null +++ b/test-case/fixtures/lsp-stub-server.mjs @@ -0,0 +1,220 @@ +#!/usr/bin/env node +/** + * LSP stub server (Content-Length frame protocol) + * + * 用于 test-issue-013.ts 的 JSON-RPC 帧协议单元测试和 fixtures 端到端。 + * 模拟 typescript-language-server 的 4 个核心 method: + * - initialize + * - textDocument/documentSymbol + * - textDocument/definition + * - textDocument/references + * + * 设计要点: + * - 从 stdin 读 LSP 帧(Content-Length 头 + JSON-RPC body) + * - 写出 LSP 帧到 stdout + * - 不打印到 stdout/stderr 以外的地方(避免污染 LSP 协议流) + * - 收到 'shutdown' 后保持运行直到 'exit' 才退出 + * - 收到 'exit' 后硬退出 + * + * 真实 tokenBudget.ts symbols:为 e2e fixture 准备(可选 --document-text + * 把真实源文件传给 stub,stub 解析 export symbols 后作为响应)。 + */ + +import { Buffer } from 'node:buffer'; +import { readFileSync } from 'node:fs'; + +// ---------- 帧协议:读 LSP 帧 ---------- + +const CRLF_CRLF = Buffer.from('\r\n\r\n'); + +function readFrame(buf) { + const headerEnd = buf.indexOf(CRLF_CRLF); + if (headerEnd < 0) return null; + const header = buf.subarray(0, headerEnd).toString('utf-8'); + const m = header.match(/^Content-Length:\s*(\d+)/i); + if (!m) return null; + const len = Number(m[1]); + if (buf.length < headerEnd + 4 + len) return null; + const body = buf.subarray(headerEnd + 4, headerEnd + 4 + len).toString('utf-8'); + return { body, totalLen: headerEnd + 4 + len }; +} + +function extractFrames(buf) { + const frames = []; + let cursor = 0; + while (cursor < buf.length) { + const r = readFrame(buf.subarray(cursor)); + if (!r) break; + frames.push(JSON.parse(r.body)); + cursor += r.totalLen; + } + return { frames, leftover: buf.subarray(cursor) }; +} + +// ---------- 帧协议:写 LSP 帧 ---------- + +function writeFrame(msg) { + const body = Buffer.from(JSON.stringify(msg), 'utf-8'); + const header = Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'utf-8'); + return Buffer.concat([header, body]); +} + +// ---------- 默认 symbols:fixture 端到端用 ---------- + +const DEFAULT_SYMBOLS = [ + { name: 'createBudgetTracker', kind: 'Function', location: { uri: 'file://tokenBudget.ts', range: { start: { line: 40, character: 0 }, end: { line: 40, character: 27 } } } }, + { name: 'checkTokenBudget', kind: 'Function', location: { uri: 'file://tokenBudget.ts', range: { start: { line: 57, character: 0 }, end: { line: 57, character: 25 } } } }, + { name: 'estimateTokens', kind: 'Function', location: { uri: 'file://tokenBudget.ts', range: { start: { line: 102, character: 0 }, end: { line: 102, character: 23 } } } }, +]; + +/** 从源文件解析 export symbols(基于 export 匹配) */ +function parseRealExports(sourceText) { + const symbols = []; + const lines = sourceText.split('\n'); + // 单 regex 一次匹配 kind + name(kind 不再单独 include 二次分类) + const KIND_RE = /^(async\s+function|function|class|interface|type|const|let|var|enum)\s+([A-Za-z0-9_$]+)/; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const m = line.match(/^export\s+/); + if (!m) continue; + const rest = line.slice(m[0].length); + const km = rest.match(KIND_RE); + if (km) { + const [, kw, name] = km; + const kind = kw.includes('function') ? 'Function' + : kw.includes('class') ? 'Class' + : kw.includes('interface') ? 'Interface' + : kw.includes('type') ? 'TypeAlias' + : kw.includes('enum') ? 'Enum' + : 'Variable'; + symbols.push({ + name, + kind, + location: { + uri: 'file://tokenBudget.ts', + range: { start: { line: i, character: 0 }, end: { line: i, character: line.length } }, + }, + }); + continue; + } + const named = line.match(/^export\s*\{([^}]+)\}/); + if (named) { + const names = named[1].split(',').map((s) => s.trim().split(/\s+as\s+/)[0]); + for (const n of names) { + if (!n) continue; + symbols.push({ + name: n, + kind: 'Export', + location: { + uri: 'file://tokenBudget.ts', + range: { start: { line: i, character: 0 }, end: { line: i, character: line.length } }, + }, + }); + } + } + } + return symbols; +} + +// ---------- 命令行:可指定真实源文件 ---------- + +let documentSymbols = DEFAULT_SYMBOLS.slice(); +const argv = process.argv.slice(2); +for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--document-text' && argv[i + 1]) { + try { + const text = readFileSync(argv[i + 1], 'utf-8'); + documentSymbols = parseRealExports(text); + } catch { + // 忽略,保留默认 + } + i++; + } +} + +// ---------- main loop ---------- + +let leftover = Buffer.alloc(0); +let initialized = false; + +process.stdin.on('data', (chunk) => { + leftover = Buffer.concat([leftover, chunk]); + const { frames, leftover: next } = extractFrames(leftover); + leftover = next; + for (const msg of frames) { + handleMessage(msg); + } +}); + +// ---------- method handler map(替代原 if-ladder) ---------- + +const requireInitError = (id) => writeFrame({ + jsonrpc: '2.0', id, error: { code: -32002, message: 'not initialized' }, +}); + +const handlers = { + initialize(msg) { + initialized = true; + return { + capabilities: { + textDocumentSync: 1, + documentSymbolProvider: true, + definitionProvider: true, + referencesProvider: true, + }, + serverInfo: { name: 'stub-lsp-server', version: '0.0.1' }, + }; + }, + shutdown() { return null; }, + 'textDocument/documentSymbol'(msg) { + if (!initialized) return { _error: requireInitError }; + return documentSymbols; + }, + 'textDocument/definition'(msg) { + if (!initialized) return { _error: requireInitError }; + const line = msg.params?.position?.line ?? 0; + return [{ + uri: msg.params?.textDocument?.uri ?? 'file:///unknown', + range: { start: { line: line + 1, character: 0 }, end: { line: line + 1, character: 10 } }, + }]; + }, + 'textDocument/references'(msg) { + if (!initialized) return { _error: requireInitError }; + const line = msg.params?.position?.line ?? 0; + return [ + { uri: msg.params?.textDocument?.uri ?? 'file:///unknown', range: { start: { line, character: 5 }, end: { line, character: 15 } } }, + { uri: msg.params?.textDocument?.uri ?? 'file:///unknown', range: { start: { line: line + 2, character: 5 }, end: { line: line + 2, character: 15 } } }, + ]; + }, +}; + +function handleMessage(msg) { + const id = msg.id; + const method = msg.method; + + // notifications (no id) + if (id === undefined && method !== undefined) { + if (method === 'initialized' || method === 'shutdown') return; + if (method === 'exit') process.exit(0); + return; + } + + const handler = handlers[method]; + if (!handler) { + process.stdout.write(writeFrame({ + jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` }, + })); + return; + } + + const result = handler(msg); + if (result && typeof result === 'object' && '_error' in result) { + process.stdout.write(result._error(id)); + return; + } + process.stdout.write(writeFrame({ jsonrpc: '2.0', id, result })); +} + +process.stdin.on('end', () => process.exit(0)); +process.on('SIGTERM', () => process.exit(0)); +process.on('SIGINT', () => process.exit(0)); \ No newline at end of file diff --git a/test-case/test-issue-013.ts b/test-case/test-issue-013.ts new file mode 100644 index 0000000..909e56b --- /dev/null +++ b/test-case/test-issue-013.ts @@ -0,0 +1,381 @@ +/** + * test-case/test-issue-013.ts + * + * 对应 issue IK8MWS #13 LSP 集成 · TypeScript Language Server 接入 + * + * 运行: bun run test-case/test-issue-013.ts + * + * 测试方法(issue 原文): + * ① 缺失 typescript-language-server 时给安装提示而非崩溃(command-exists 探测 + 启动时 warn) + * ② initialize / definition / references / documentSymbol 四 method JSON-RPC 往返 + * ③ 三工具 lspGotoDefinition / lspFindReferences / lspDocumentSymbol 把 Location[] + * 序列化为 {file, line, col, snippet} + * ④ 进程回收无残留 tsserver(daemon 退出 + signal 优雅终止) + * ⑤ 端到端:documentSymbol 一次返回 src/runtime/agent/ 全部 exports + * + * 测试策略: + * - JSON-RPC 帧协议用例用 stub server(Node 子进程,实现 Content-Length 协议) + * 保证 CI 可重复、不依赖 tsls 是否安装 + * - 探测/降级用例用绝对不存在的 binary 名绕过 macOS confstr(_CS_PATH) 兜底 + * - 进程回收用 PID 跟踪,断言 signal 后 wait() 不残留 + * - 端到端 fixture 对真实仓库文件执行,断言导出 symbols 命中 + */ + +import fs from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +import type { AliceTool, ToolResult } from '../src/types/tool.js'; +import { LspClient } from '../src/services/lsp/client.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '..'); +const STUB_SCRIPT = path.join(__dirname, 'fixtures', 'lsp-stub-server.mjs'); +const TARGET_FIXTURE = path.join(REPO_ROOT, 'src', 'runtime', 'agent', 'tokenBudget.ts'); + +/** tokenBudget.ts 真实 exports,fixture 端到端断言用 */ +const EXPECTED_SYMBOLS = ['createBudgetTracker', 'checkTokenBudget', 'estimateTokens']; + +// ---------- 极简测试 harness ---------- + +let passed = 0; +let failed = 0; +const failures: string[] = []; + +function assert(cond: unknown, msg: string): void { + if (cond) { + passed++; + console.log(` ✓ ${msg}`); + } else { + failed++; + failures.push(msg); + console.log(` ✗ ${msg}`); + } +} + +function section(name: string): void { + console.log(`\n── ${name} ──`); +} + +// ---------- 测试 helper ---------- + +/** 起一个 stub-mode LspClient(每次都用固定 stub script) */ +async function makeStubClient(extraArgs: string[] = []): Promise { + const client = new LspClient(); + await client.startWithStub(STUB_SCRIPT); + return client; +} + +/** 跑一次 builtin 工具,返回 ToolResult(组装标准 context) */ +async function runTool(tool: AliceTool, params: Record): Promise { + return tool.execute( + 'call', + params, + new AbortController().signal, + undefined, + { workspace: process.cwd() }, + ); +} + +// ---------- 用例 ①: 探测 / 降级 / 提示 ---------- + +async function testDetection(): Promise { + section('① tsls 缺失时探测/降级/提示'); + + const { + probeTypeScriptServer, + startServer, + resetLspAvailabilityCache, + LspNotInstalledError, + TSLS_INSTALL_HINT, + } = await import('../src/services/lsp/serverProcess.js'); + + resetLspAvailabilityCache(); + + // 1a. 找到 tsls 时返回绝对路径 + const probe = await probeTypeScriptServer(); + assert( + typeof probe.binary === 'string' || probe.binary === null, + `probeTypeScriptServer 返回 { binary, ... } 形态 (binary=${probe.binary})`, + ); + if (probe.binary) { + assert(probe.binary.length > 0, `tsls 绝对路径非空 (${probe.binary})`); + } else { + console.log(' · 本机 tsls 未安装,跳过路径非空断言(其余用例仍跑 stub server)'); + } + + // 1b. 缺失 tsls 时抛 LspNotInstalledError(用绝对不存在的 binary 名绕过 macOS confstr(_CS_PATH) 回退) + let notInstalledErr: unknown = null; + try { + await startServer({ binary: 'xyz-alice-cli-definitely-not-on-system-98765' }); + } catch (e: unknown) { + notInstalledErr = e; + } + assert(notInstalledErr instanceof LspNotInstalledError, '缺失二进制 → 抛 LspNotInstalledError'); + assert( + String((notInstalledErr as Error)?.message ?? '').length > 0 || + String(TSLS_INSTALL_HINT).length > 0, + 'LspNotInstalledError / TSLS_INSTALL_HINT 提供明确提示文本', + ); + assert(TSLS_INSTALL_HINT.includes('typescript-language-server'), 'TSLS_INSTALL_HINT 含 tsls 名'); + + // 1c. LSP 工具未启动 server 时,client helper 抛"含安装提示"的错误 + const stub = new LspClient(); + let threw = false; + try { + await stub.definition('file:///x.ts', 0, 0); + } catch (e: unknown) { + threw = true; + const msg = String((e as Error)?.message ?? e); + assert( + msg.includes('未启动') || msg.includes('not started') || msg.includes('install'), + `未启动时报错包含明确提示 (实际: ${msg.slice(0, 80)})`, + ); + } + assert(threw, '未启动 LSP server 时调用 → 抛错'); + await stub.shutdown().catch(() => {}); +} + +// ---------- 用例 ②: JSON-RPC 四 method 往返 ---------- + +async function testJsonRpcRoundtrip(): Promise { + section('② JSON-RPC 四 method 往返(Content-Length 帧协议)'); + + // 2a. stub server fixture 存在 + const stubExists = await fs.stat(STUB_SCRIPT).then(() => true, () => false); + assert(stubExists, `stub server fixture 存在 (${STUB_SCRIPT})`); + + const client = await makeStubClient(); + await client.initialize('file:///tmp/proj'); + + // 2b. initialize 已校验 + // (LspClient 本身管理 initialize,我们直接断言后续 method 调用能成) + + // 2c. documentSymbol + const symbols = await client.documentSymbols('file:///tmp/proj/a.ts'); + assert(Array.isArray(symbols), 'documentSymbol 返回数组'); + assert(symbols.length > 0, `documentSymbol 非空 (${symbols.length} 个 symbol)`); + + // 2d. definition + const defs = await client.definition('file:///tmp/proj/a.ts', 5, 3); + assert(Array.isArray(defs), 'definition 返回数组'); + assert(defs.length > 0, `definition 非空 (${defs.length} 个 Location)`); + + // 2e. references + const refs = await client.references('file:///tmp/proj/a.ts', 5, 3); + assert(Array.isArray(refs), 'references 返回数组'); + assert(refs.length > 0, `references 非空 (${refs.length} 个 Location)`); + + // 2f. Content-Length 帧格式验证 + const { buildFrame, parseFrames } = await import( + '../src/services/lsp/stdioRunner.js' + ); + const frame = buildFrame({ jsonrpc: '2.0', id: 1, method: 'test' }); + const headerEnd = frame.indexOf(Buffer.from('\r\n\r\n')); + assert(headerEnd > 0, '帧协议含 CRLFCRLF 分隔符'); + const header = frame.subarray(0, headerEnd).toString('utf-8'); + const match = header.match(/^Content-Length:\s*(\d+)/); + assert(match !== null, `帧首部为 Content-Length: N (实际: ${header.split('\r\n')[0]})`); + const declaredLen = Number(match?.[1] ?? 0); + const body = frame.subarray(headerEnd + 4); + const realLen = Buffer.byteLength(body); + assert(declaredLen === realLen, `Content-Length 与字节数一致 (declared=${declaredLen}, real=${realLen})`); + + // 帧解析:连续两帧被解析为 2 条 + const doubleFrame = Buffer.concat([ + buildFrame({ jsonrpc: '2.0', id: 1, method: 'a' }), + buildFrame({ jsonrpc: '2.0', id: 2, method: 'b' }), + ]); + const parsed = parseFrames(doubleFrame); + assert(parsed.frames.length === 2, `连续两帧被解析为 2 条 (实际 ${parsed.frames.length})`); + + await client.shutdown(); +} + +// ---------- 用例 ③: Location[] → {file, line, col, snippet} 序列化 ---------- + +async function testLocationSerialization(): Promise { + section('③ Location[] → {file, line, col, snippet} 序列化'); + + const { formatLocationWithText, formatLocationsWithText } = await import( + '../src/services/lsp/locationFormat.js' + ); + + // 3a. 单 Location 序列化 + const loc = { + uri: 'file:///tmp/proj/src/foo.ts', + range: { + start: { line: 1, character: 2 }, + end: { line: 1, character: 12 }, + }, + }; + const one = formatLocationWithText(loc, 'class Foo {\n bar() {}\n}\nclass Baz {}'); + assert(one.file === '/tmp/proj/src/foo.ts', `file 为去除 file:// 的路径 (${one.file})`); + assert(one.line === 1, `line 为 0-indexed LSP 行 (实际 ${one.line})`); + assert(one.col === 2, `col 为 character 偏移 (实际 ${one.col})`); + assert(typeof one.snippet === 'string' && one.snippet.length > 0, 'snippet 非空'); + assert(one.snippet.includes('bar'), 'snippet 含目标代码片段'); + + // 3b. 多 Location 序列化 + const many = formatLocationsWithText( + [loc, { ...loc, range: { start: { line: 3, character: 0 }, end: { line: 3, character: 5 } } }], + 'class Foo {\n bar() {}\n}\nclass Baz {}', + ); + assert(Array.isArray(many) && many.length === 2, `多 Location 数组返回 (length=${many.length})`); + assert(many[1]?.line === 3, '第二个 Location 的 line 正确'); + assert(many[1]?.snippet.includes('Baz'), '第二个 Location snippet 含目标代码'); + + // 3c. 内置工具 handler 把工具参数转换为 client 输出 {file, line, col, snippet}[] + const client = await makeStubClient(); + await client.initialize('file:///tmp/proj'); + // 把 stub client 注入到单例,让工具能用到同一 client + const { setLspClient } = await import('../src/services/lsp/index.js'); + setLspClient(client); + + // lspGotoDefinition 工具 + const { lspGotoDefinitionTool } = await import( + '../src/tools/builtin/lspGotoDefinition.js' + ); + const defRes = await runTool(lspGotoDefinitionTool, { + file: '/tmp/proj/a.ts', line: 5, character: 3, + }); + assert(defRes.success === true, 'lspGotoDefinition 工具 success=true'); + const defData = defRes.data as { locations?: Array<{ file: string; line: number; col: number; snippet: string }> }; + assert(Array.isArray(defData?.locations), 'data.locations 为数组'); + assert((defData?.locations?.length ?? 0) > 0, `lspGotoDefinition 返回非空结果 (${defData?.locations?.length})`); + if (defData?.locations?.[0]) { + const first = defData.locations[0]; + assert(typeof first.file === 'string' && first.file.length > 0, '序列化 file 字段'); + assert(typeof first.line === 'number', '序列化 line 字段'); + assert(typeof first.col === 'number', '序列化 col 字段'); + assert(typeof first.snippet === 'string', '序列化 snippet 字段'); + } + + // lspFindReferences 工具 + const { lspFindReferencesTool } = await import( + '../src/tools/builtin/lspFindReferences.js' + ); + const refRes = await runTool(lspFindReferencesTool, { + file: '/tmp/proj/a.ts', line: 5, character: 3, + }); + assert(refRes.success === true, 'lspFindReferences 工具 success=true'); + const refData = refRes.data as { references?: unknown[] }; + assert(Array.isArray(refData?.references), 'data.references 为数组'); + + // lspDocumentSymbol 工具 + const { lspDocumentSymbolTool } = await import( + '../src/tools/builtin/lspDocumentSymbol.js' + ); + const symRes = await runTool(lspDocumentSymbolTool, { file: '/tmp/proj/a.ts' }); + assert(symRes.success === true, 'lspDocumentSymbol 工具 success=true'); + const symData = symRes.data as { symbols?: Array<{ name: string; kind: string; line: number }> }; + assert(Array.isArray(symData?.symbols), 'data.symbols 为数组'); + + await client.shutdown(); +} + +// ---------- 用例 ④: 进程回收(signal + wait) ---------- + +async function testProcessCleanup(): Promise { + section('④ 进程回收(SIGTERM 后无残留)'); + + const client = await makeStubClient(); + const pid = client.getProcessPid(); + assert(typeof pid === 'number' && pid > 0, `已 spawn 子进程 (pid=${pid})`); + + // 确认子进程在运行 + const aliveBefore = processAlive(pid!); + assert(aliveBefore === true, 'shutdown 前子进程存活'); + + // 关闭(graceful: 内部走 SIGTERM→grace→SIGKILL) + await client.shutdown({ force: true }); + await sleep(300); + + const aliveAfter = processAlive(pid!); + assert(aliveAfter === false, `shutdown 后子进程退出 (aliveAfter=${aliveAfter})`); + + // 强制 abort 路径:再次 start,然后 SIGKILL + await client.startWithStub(STUB_SCRIPT); + const pid2 = client.getProcessPid(); + assert(typeof pid2 === 'number' && pid2 > 0, `重新 spawn (pid2=${pid2})`); + await client.shutdown({ force: true }); + await sleep(300); + assert(processAlive(pid2!) === false, `forceKill 后子进程退出 (pid2=${pid2})`); + + // 进程回收链路本身:terminateProcess SIGTERM graceMs=100 → SIGKILL + const { terminateProcess, spawnProcess: sp } = await import( + '../src/services/lsp/stdioRunner.js' + ); + const proc = sp({ cmd: [process.execPath, '-e', 'setInterval(()=>{},1e9)'] }); + await new Promise((r) => setTimeout(r, 100)); + const tStart = Date.now(); + await terminateProcess(proc, { graceMs: 100 }); + const elapsed = Date.now() - tStart; + assert(elapsed < 800, `terminateProcess 终止开销合理 (实际 ${elapsed}ms)`); +} + +/** 进程是否还活着 */ +function processAlive(pid: number): boolean { + try { + // signal 0 不真发信号,只检查是否可投递 + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +// ---------- 用例 ⑤: 端到端 fixture(对真实仓库文件 documentSymbol) ---------- + +async function testEndToEndFixture(): Promise { + section('⑤ 端到端 fixture:src/runtime/agent/tokenBudget.ts 全部 exports'); + + // fixture 文件存在 + const stat = await fs.stat(TARGET_FIXTURE); + assert(stat.isFile(), `fixture 文件存在 (${TARGET_FIXTURE})`); + + // 起 stub server,传 --document-text 让 stub 解析真实 source 导出 symbols + // (走真实 LspClient,不再手写 JSON-RPC — 由 startWithStub 接管) + const client = new LspClient(); + await client.startWithStub(STUB_SCRIPT, ['--document-text', TARGET_FIXTURE]); + await client.initialize(`file://${REPO_ROOT}`); + + const symbols = await client.documentSymbols(`file://${TARGET_FIXTURE}`); + const symbolNames = symbols.map((s) => String(s.name)); + + // 表驱动断言(替代 3 份重复 assert) + for (const name of EXPECTED_SYMBOLS) { + const count = symbolNames.filter((n) => n === name).length; + assert(count > 0, `documentSymbol 含 ${name} (命中 ${count})`); + } + + await client.shutdown(); +} + +// ---------- main ---------- + +async function main(): Promise { + console.log('Issue #13 (IK8MWS) LSP 集成测试'); + + await testDetection(); + await testJsonRpcRoundtrip(); + await testLocationSerialization(); + await testProcessCleanup(); + await testEndToEndFixture(); + + console.log(`\n──── ${passed} passed, ${failed} failed ────`); + if (failed > 0) { + console.log('\n失败列表:'); + for (const f of failures) console.log(` - ${f}`); + process.exit(1); + } +} + +main().catch((err) => { + console.error('测试套件自身异常:', err); + process.exit(1); +}); \ No newline at end of file diff --git a/test-case/test-list.md b/test-case/test-list.md index b116bd5..7e0b40f 100644 --- a/test-case/test-list.md +++ b/test-case/test-list.md @@ -7,8 +7,8 @@ ## 全量回归 ```bash -# issue 回归套件(当前基线 168 断言) -for t in 001 002 003 004 005 012 019; do bun run test-case/test-issue-$t.ts || exit 1; done +# issue 回归套件(当前基线 215 断言:001/002/003/004/005/010/012/013/019) +for t in 001 002 003 004 005 010 012 013 019; do bun run test-case/test-issue-$t.ts || exit 1; done ``` ## 清单(按 issue 编号排序) @@ -24,6 +24,7 @@ for t in 001 002 003 004 005 012 019; do bun run test-case/test-issue-$t.ts || e | `test-issue-010.ts` | ripgrep 子进程替换 glob:`rg --json` NDJSON 解析、空 PATH 自动降级、ignore 列表对齐、CI 基准 | 工具性能(utils/ripgrepRunner、tools/builtin/searchFiles) | issue #10(IK8MWP)/ PR !11 | | `test-issue-019.ts` | karpathy-wiki-new bundled skill:SKILL.md 契约、scaffold 执行器、listBundledSkills、dist 打包 | 内置 skills(skills/bundled) | issue #19(IK8MWL)/ PR !7 | | `test-issue-012.ts` | token 预算接通 TUI:getUsage 边界、ChatStreamEvent.budget_update 类型联合、TokenBudgetBar 字符串、联调事件序列 | runtime/agent/tokenBudget → types/chatStream → UI/Footer | issue #12(IK8MWR)/ PR !8 | +| `test-issue-013.ts` | LSP 集成:tsls 探测/降级、JSON-RPC 四 method 往返、Location→{file,line,col,snippet}、SIGTERM 进程回收、tokenBudget.ts 端到端 symbols | 代码智能(services/lsp) | issue #13(IK8MWS)/ PR !16 | | `test-model.ts` | 手动入口:模型连通性 + 速度检查(等价 `alice --test-model`);实现位于 `src/utils/testModel.ts` | 模型诊断(utils/testModel) | 历史 dev 脚本(无 PR);2026-08-15 修复为可运行薄壳 | | `test-tools.ts` | 手动入口:toolRegistry / builtinTools / ToolExecutor 冒烟 | 工具系统 | 历史 dev 脚本(无 PR) | | `test-function-calling.ts` | 手动入口:LLM function calling 端到端(需真实 API) | function calling | 历史 dev 脚本(无 PR) | -- Gitee