⚠️ 学习声明:本文档基于 Claude Code 2.1.88 源码分析整理,仅供个人学习研究使用,不做任何商业用途。
LSP(Language Server Protocol)让 Claude 获得”编译器视角”——实时错误、代码导航、类型信息。
一、系统定位
为什么需要 LSP?
| 能力 |
没有 LSP |
有 LSP |
| 错误检测 |
靠 Claude 静态分析或运行 build |
实时 publishDiagnostics 推送 |
| 代码跳转 |
文本搜索(grep) |
textDocument/definition 语义查找 |
| 类型信息 |
无 |
textDocument/hover |
| 补全 |
无 |
textDocument/completion |
配置来源
关键设计:LSP 服务器只能通过插件(Plugin)配置,不能在 user/project settings 中直接设置。
config.ts: getAllLspServers() → loadAllPluginsCacheOnly() → 每个插件的 getPluginLspServers() → 合并所有插件的 LSP 配置(后载入插件胜出)
|
二、核心文件地图
src/services/lsp/ ├── LSPClient.ts # 底层 LSP 客户端(vscode-jsonrpc over stdio) ├── LSPServerInstance.ts # 单个 LSP 服务器实例管理(状态机) ├── LSPServerManager.ts # 多服务器路由管理器(按文件扩展名路由) ├── LSPDiagnosticRegistry.ts # 诊断信息异步注册表(LRU + 去重) ├── passiveFeedback.ts # Diagnostics → Attachment 转换器 ├── config.ts # 从插件加载服务器配置 └── manager.ts # 导出统一的 manager 实例
|
三、LSPClient 底层通信(LSPClient.ts)
传输协议
LSP Server Process ↕ stdio (stdin/stdout) ↕ vscode-jsonrpc MessageConnection LSPClient (CC 内)
|
接口定义
type LSPClient = { readonly capabilities: ServerCapabilities | undefined readonly isInitialized: boolean start(command, args, options): Promise<void> initialize(params): Promise<InitializeResult> sendRequest<T>(method, params): Promise<T> sendNotification(method, params): Promise<void> onNotification(method, handler): void onRequest(method, handler): void stop(): Promise<void> }
|
懒加载 + 崩溃恢复
createLSPClient(serverName, onCrash?) { let pendingHandlers = [] }
|
四、服务器实例管理(LSPServerInstance.ts)
状态机
stopped ──start()──→ starting ──success──→ running ↑ │ └──stop()────────── stopping ←──stop()───┘ │ error (on failure) │ starting (on retry)
|
瞬态错误重试
const LSP_ERROR_CONTENT_MODIFIED = -32801 const MAX_RETRIES_FOR_TRANSIENT_ERRORS = 3 const RETRY_BASE_DELAY_MS = 500
|
rust-analyzer 等 LSP 服务器在项目初次索引期间会返回 -32801,CC 自动重试。
五、多服务器路由(LSPServerManager.ts)
路由策略:按文件扩展名
const extensionMap: Map<string, string[]> = new Map()
getServerForFile(filePath: string): LSPServerInstance | undefined { const ext = path.extname(filePath) const serverNames = extensionMap.get(ext) return serverNames?.[0] ? servers.get(serverNames[0]) : undefined }
|
文件同步协议
await manager.openFile(filePath, content)
await manager.changeFile(filePath, content)
await manager.saveFile(filePath)
await manager.closeFile(filePath)
|
打开文件追踪:openedFiles: Map<string, string> 记录 URI → serverName,防止重复 didOpen,节省服务器资源。
六、诊断信息异步交付(LSPDiagnosticRegistry.ts)
核心问题
LSP 诊断是服务器主动推送(textDocument/publishDiagnostics),而 CC 的工具结果是请求-响应模式。需要一个”异步缓冲区”桥接两者。
注册表设计
const pendingDiagnostics = new Map<string, PendingLSPDiagnostic>()
const deliveredDiagnostics = new LRUCache<string, Set<string>>({ max: MAX_DELIVERED_FILES, })
|
体量限制
const MAX_DIAGNOSTICS_PER_FILE = 10 const MAX_TOTAL_DIAGNOSTICS = 30
|
交付流程
LSP Server → publishDiagnostics 推送 ↓ registerPendingLSPDiagnostic() → 存入 pendingDiagnostics ↓ 下一轮查询开始 ↓ checkForLSPDiagnostics() → 取出待交付诊断 ↓ getLSPDiagnosticAttachments() → 转为 Attachment[] ↓ getAttachments() → 自动注入到对话上下文
|
七、诊断格式转换(passiveFeedback.ts)
LSP Severity → Claude Severity
function mapLSPSeverity(lspSeverity: number | undefined) : 'Error' | 'Warning' | 'Info' | 'Hint'
|
URI 处理
八、插件 LSP 集成(lspPluginIntegration.ts)
插件提供 LSP 配置的格式
type LspServerConfig = { command: string args?: string[] extensionToLanguage: Record<string, string> transport?: 'stdio' | 'socket' env?: Record<string, string> initializationOptions?: unknown settings?: unknown workspaceFolder?: string }
|
并行加载所有插件的 LSP 配置
const results = await Promise.all( plugins.map(plugin => getPluginLspServers(plugin, errors)) )
|
九、与工具系统的集成
工具调用时的文件同步
Claude 调用 FileReadTool 读取 src/main.ts ↓ FileReadTool 执行后通知 LSPServerManager ↓ manager.openFile('src/main.ts', content) // 触发 didOpen ↓ LSP 服务器开始分析该文件 ↓ 几百毫秒后 publishDiagnostics 推送 ↓ 下一轮对话中自动附加诊断信息
|
Claude Edit 时的文件同步
Claude 调用 FileEditTool 修改代码 ↓ manager.changeFile() + manager.saveFile() ↓ LSP 服务器实时更新分析 ↓ 新的类型错误/lint 错误自动推送给 Claude
|
十、LSP 推荐系统(lspRecommendation.ts)
当用户没有配置 LSP 服务器时,CC 可以根据项目语言推荐安装:
检测项目语言(package.json → TypeScript, *.py → Python, ...) ↓ lspRecommendation.ts 生成安装建议 ↓ 通过 CLAUDE.md 或初始化消息展示给用户
|
十一、关键设计决策
| 决策 |
原因 |
| 只通过插件配置 LSP |
避免全局 settings 因 LSP 服务器崩溃影响核心功能 |
| 异步诊断注册表 |
LSP 推送是服务器主动行为,与 CC 请求-响应模型解耦 |
| LRU 去重缓存 |
长会话中同一文件反复诊断,避免重复投喂上下文 |
| 瞬态错误重试(指数退避) |
rust-analyzer 等在索引期间会短暂返回 -32801 |
| 按扩展名路由 |
简单高效,支持多语言并存(TypeScript + Python + Rust) |
| URI 转换降级 |
LSP 服务器偶发 malformed URI,不能因此崩溃整个诊断流程 |
十二、LSP 协议消息层深度解析
12.1 JSON-RPC 2.0 基础
LSP 基于 JSON-RPC 2.0 协议(规范):
JSON-RPC 消息类型: Request → { "jsonrpc": "2.0", "id": 1, "method": "...", "params": {...} } Response → { "jsonrpc": "2.0", "id": 1, "result": {...} } Error → { "jsonrpc": "2.0", "id": 1, "error": { "code": -32801, "message": "..." } } Notification → { "jsonrpc": "2.0", "method": "...", "params": {...} } // 无 id,无响应
|
12.2 消息帧格式(HTTP-Like Header + Body)
这是 LSP 最容易被忽略但最关键的设计——使用类 HTTP 的 Content-Length 头部进行消息帧:
Content-Length: 256\r\n Content-Type: application/vscode-jsonrpc; charset=utf-8\r\n \r\n {"jsonrpc":"2.0","id":1,"method":"textDocument/hover","params":{...}}
|
为什么不用新行分隔符(NDJSON)?
| 方案 |
优势 |
劣势 |
NDJSON(\n 分隔) |
简单,readline 直接消费 |
JSON 内容中的 \n 会导致拆包错误 |
| Content-Length Header |
精确二进制安全 |
需要解析头部 |
LSP 选择 Content-Length 因为诊断信息/代码片段中经常包含换行符,NDJSON 无法安全分帧。
function readMessage(reader: StreamReader): Message { const headers = readHeaders(reader); const contentLength = parseInt(headers['content-length']); const body = reader.readExact(contentLength); return JSON.parse(body); }
|
12.3 LSP 协议的”三阶段”消息流
Phase 1 — 握手阶段 Client → Server: initialize(params: { capabilities, processId, rootUri }) Server → Client: InitializeResult({ capabilities: { hoverProvider:true, ... } }) Client → Server: initialized({}) ← 通知,无响应
Phase 2 — 工作阶段(双向异步) Client → Server: textDocument/didOpen (notification) Server → Client: textDocument/publishDiagnostics (notification) Client → Server: textDocument/hover (request) Server → Client: Hover result (response) Phase 3 — 关闭阶段 Client → Server: shutdown (request) Server → Client: null (response) Client → Server: exit (notification)
|
CC 中对应的初始化代码路径(LSPClient.ts):
async initialize(params: InitializeParams): Promise<InitializeResult> { const result = await this.sendRequest('initialize', params); this.capabilities = result.capabilities; await this.sendNotification('initialized', {}); this.onNotification('textDocument/publishDiagnostics', (params) => { this.diagnosticRegistry.register(params.uri, params.diagnostics); }); return result; }
|
12.4 LSP 核心方法分类
文档同步: textDocument/didOpen, didChange, didClose, didSave
语言特性(请求-响应): textDocument/hover → 类型信息 textDocument/definition → 跳转定义 textDocument/references → 查找引用 textDocument/completion → 自动补全 textDocument/signatureHelp → 函数签名提示 textDocument/codeAction → 快速修复 textDocument/formatting → 代码格式化
诊断(服务器推送): textDocument/publishDiagnostics → 错误/警告(Notification) 工作区: workspace/didChangeConfiguration → 配置变更通知 workspace/symbol → 工作区符号搜索
|
十三、文本同步策略:增量 vs 全量
13.1 TextDocumentSyncKind 的三种模式
LSP 3.17 定义了三种文本同步方式:
enum TextDocumentSyncKind { None = 0, Full = 1, Incremental = 2 }
|
13.2 全量同步(Full Sync)
Client → Server: textDocument/didChange({ textDocument: { uri, version: 3 }, contentChanges: [{ text: "全文内容..." }] })
|
优点:简单,无版本漂移风险。
缺点:大文件(如 5000+ 行)每次都传输完整内容,浪费带宽。
CC 实现:
async changeFile(filePath: string, content: string) { const server = this.getServerForFile(filePath); if (!server) return; const params = { textDocument: { uri: pathToUri(filePath), version: this.getNextVersion(filePath) }, contentChanges: [{ text: content }] }; await server.sendNotification('textDocument/didChange', params); }
|
13.3 增量同步(Incremental Sync)
Client → Server: textDocument/didChange({ textDocument: { uri, version: 3 }, contentChanges: [ { range: { start: {line: 10, character:5}, end: {line:10, character:12} }, rangeLength: 7, // 删除 7 个字符 text: "updated" // 插入 "updated" } ] })
|
优点:大文件时显著减少数据传输。
缺点:版本追踪复杂——如果丢了一个变更,后续所有变更都基于错误的文档状态。
13.4 CC 为什么用全量同步?
| 因素 |
分析 |
| 变更来源 |
CC 通过 FileEdit Tool 修改文件,每次修改后文件内容在内存中已完整存在 |
| 回滚复杂性 |
增量同步需要精确追踪每个 TextEdit 的位置偏移,多工具交错编辑时极易出错 |
| 性能考量 |
CC 处理的单个文件通常 < 1000 行,全量同步的成本可接受 |
| 正确性优先 |
全量同步无版本漂移风险,LSP 诊断的准确性比网络效率更重要 |
13.5 版本号追踪
即使在全量同步下,LSP 仍要求维护 version 字段:
const fileVersions = new Map<string, number>();
function getNextVersion(uri: string): number { const current = fileVersions.get(uri) ?? 0; fileVersions.set(uri, current + 1); return current + 1; }
|
十四、Diagnostic 推送机制与批处理
14.1 publishDiagnostics 的推送时序
时间线 —————————————————————————————————————————→
[0ms] 用户调用 FileEdit tool 修改 src/main.ts [0ms] CC → LSP: textDocument/didChange (version=5)
[100ms] LSP 服务器开始分析... [300ms] TypeScript 编译器完成类型检查 [400ms] LSP → CC: publishDiagnostics({ uri: "file:///src/main.ts", diagnostics: [ { severity: Error, range: ..., message: "Type 'string' is not assignable..." }, { severity: Warning, range: ..., message: "Unused variable 'x'" } ] }) [401ms] LSPDiagnosticRegistry.register() → 存到 pending 队列 [450ms] 下一轮 Agent Turn 开始 [451ms] checkForLSPDiagnostics() → 取出 pending → 注入到 Attachment
|
14.2 诊断批处理的三种策略对比
| 策略 |
行为 |
延迟 |
数据量 |
CC 选择 |
| 即时推送 |
每个 diagnostic 到达立刻注入上下文 |
最低 |
可能碎片化 |
❌ |
| Turn 级批处理 |
等待到下一轮 Agent Turn 开始时批量注入 |
一个 Turn |
适中 |
✅ |
| 会话级聚合 |
累积所有诊断到会话结束 |
最高 |
可能过大 |
❌ |
CC 采用 Turn 级批处理:在每轮 Agent Loop 开始时,checkForLSPDiagnostics() 一次性取出所有 pending 诊断注入到上下文。这保证了诊断的时效性同时避免了碎片化。
14.3 诊断信息的过滤与排序
function filterAndRankDiagnostics( diagnostics: Diagnostic[], config: { maxPerFile: number; maxTotal: number } ): Diagnostic[] { const sorted = diagnostics.sort((a, b) => (a.severity ?? 0) - (b.severity ?? 0) ); const unique = dedupeByLocation(sorted); return truncate(unique, config); }
|
14.4 错误恢复与瞬态错误
rust-analyzer 在项目初次索引期间返回 -32801(ContentModified),表示文档内容在请求处理期间被修改:
const TRANSIENT_ERRORS: Record<number, string> = { [-32801]: 'ContentModified', [-32802]: 'RequestCancelled', [-32002]: 'ServerNotInitialized' };
async function withTransientRetry<T>( fn: () => Promise<T>, retries = 3 ): Promise<T> { for (let i = 0; i < retries; i++) { try { return await fn(); } catch (e) { if (!isTransientError(e) || i === retries - 1) throw e; await delay(RETRY_BASE_DELAY_MS * Math.pow(2, i)); } } }
|
十五、Tree-sitter 与 LSP 的分工
15.1 两种语言分析工具的定位差异
CC 同时使用 Tree-sitter 和 LSP,但它们解决不同的问题:
| 维度 |
Tree-sitter |
LSP |
| 分析类型 |
语法分析(Syntax) |
语义分析(Semantics) |
| 启动速度 |
即时(纯 C 库) |
需启动外部进程(500ms-5s) |
| 错误容忍 |
高(专为不完整代码设计) |
低(类型检查需要完整代码) |
| 提供信息 |
AST、语法节点、语法错误 |
类型信息、引用、诊断 |
| 依赖 |
无外部依赖 |
需要编译器/语言服务 |
| 运行方式 |
同步(进程内) |
异步(stdio/socket 通信) |
15.2 CC 中的分工配合
用户文件 → CC 读取 │ ├─→ Tree-sitter(进程内同步) │ └─ 提供:语法高亮(Token 级别) │ 代码块识别(函数/类边界) │ diff 时的结构感知(语法 aware diff) │ └─→ LSP(进程外异步) └─ 提供:类型错误(Diagnostic) 定义跳转(Definition) 符号引用(References)
|
15.3 为什么不能合并到 LSP?
Tree-sitter 速度优势(语义级延迟对比): Tree-sitter parse: 1-5ms (进程内,无 IPC) LSP hover request: 50-200ms (IPC + 服务器处理) LSP diagnostic push: 100-500ms (从文件变更到推送) 对于高频操作(如每次文件变更后更新语法高亮), Tree-sitter 的速度优势是决定性的
|
15.4 Tree-sitter 在 CC 中的具体应用场景
const tree = parser.parse(content); const functions = tree.rootNode.descendantsOfType('function_declaration');
|
十六、LSP 诊断缓存与去重优化
16.1 LRU 诊断缓存
const diagnosticCache = new LRUCache<string, Diagnostic[]>({ max: 100, ttl: 5000, });
|
16.2 避免上下文污染
LSP 诊断只在用户主动询问时才提供给 LLM(通过 /diagnostics 命令或 Review 请求),避免 System Prompt 被大量诊断信息污染。
16.3 跨 Turn 去重算法
class DiagnosticRegistry { delivered = new LRUCache<string, Set<string>>({ max: 500 }); getNewDiagnostics(uri: string, current: Diagnostic[]): Diagnostic[] { const key = this.deliveryKey(uri); const prev = this.delivered.get(key) ?? new Set(); const newOnes = current.filter(d => !prev.has(this.fingerprint(d))); this.delivered.set(key, new Set(current.map(d => this.fingerprint(d)))); return newOnes; } fingerprint(d: Diagnostic): string { return sha256(`${d.range.start}:${d.range.end}:${d.message}`).slice(0, 12); } }
|
涉及源文件
src/services/lsp/LSPClient.ts
src/services/lsp/LSPServerInstance.ts
src/services/lsp/LSPServerManager.ts
src/services/lsp/LSPDiagnosticRegistry.ts
src/services/lsp/passiveFeedback.ts
src/services/lsp/manager.ts