目录
  1. 1. 一、系统定位
    1. 1.1. 为什么需要 LSP?
    2. 1.2. 配置来源
  2. 2. 二、核心文件地图
  3. 3. 三、LSPClient 底层通信(LSPClient.ts)
    1. 3.1. 传输协议
    2. 3.2. 接口定义
    3. 3.3. 懒加载 + 崩溃恢复
  4. 4. 四、服务器实例管理(LSPServerInstance.ts)
    1. 4.1. 状态机
    2. 4.2. 瞬态错误重试
  5. 5. 五、多服务器路由(LSPServerManager.ts)
    1. 5.1. 路由策略:按文件扩展名
    2. 5.2. 文件同步协议
  6. 6. 六、诊断信息异步交付(LSPDiagnosticRegistry.ts)
    1. 6.1. 核心问题
    2. 6.2. 注册表设计
    3. 6.3. 体量限制
    4. 6.4. 交付流程
  7. 7. 七、诊断格式转换(passiveFeedback.ts)
    1. 7.1. LSP Severity → Claude Severity
    2. 7.2. URI 处理
  8. 8. 八、插件 LSP 集成(lspPluginIntegration.ts)
    1. 8.1. 插件提供 LSP 配置的格式
    2. 8.2. 并行加载所有插件的 LSP 配置
  9. 9. 九、与工具系统的集成
    1. 9.1. 工具调用时的文件同步
    2. 9.2. Claude Edit 时的文件同步
  10. 10. 十、LSP 推荐系统(lspRecommendation.ts)
  11. 11. 十一、关键设计决策
  12. 12. 十二、LSP 协议消息层深度解析
    1. 12.1. 12.1 JSON-RPC 2.0 基础
    2. 12.2. 12.2 消息帧格式(HTTP-Like Header + Body)
    3. 12.3. 12.3 LSP 协议的”三阶段”消息流
    4. 12.4. 12.4 LSP 核心方法分类
  13. 13. 十三、文本同步策略:增量 vs 全量
    1. 13.1. 13.1 TextDocumentSyncKind 的三种模式
    2. 13.2. 13.2 全量同步(Full Sync)
    3. 13.3. 13.3 增量同步(Incremental Sync)
    4. 13.4. 13.4 CC 为什么用全量同步?
    5. 13.5. 13.5 版本号追踪
  14. 14. 十四、Diagnostic 推送机制与批处理
    1. 14.1. 14.1 publishDiagnostics 的推送时序
    2. 14.2. 14.2 诊断批处理的三种策略对比
    3. 14.3. 14.3 诊断信息的过滤与排序
    4. 14.4. 14.4 错误恢复与瞬态错误
  15. 15. 十五、Tree-sitter 与 LSP 的分工
    1. 15.1. 15.1 两种语言分析工具的定位差异
    2. 15.2. 15.2 CC 中的分工配合
    3. 15.3. 15.3 为什么不能合并到 LSP?
    4. 15.4. 15.4 Tree-sitter 在 CC 中的具体应用场景
  16. 16. 十六、LSP 诊断缓存与去重优化
    1. 16.1. 16.1 LRU 诊断缓存
    2. 16.2. 16.2 避免上下文污染
    3. 16.3. 16.3 跨 Turn 去重算法
  17. 17. 涉及源文件
【Claude Code源码剖析】25-LSP 集成与 IDE 能力

⚠️ 学习声明:本文档基于 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> // 启动 LSP 服务进程
initialize(params): Promise<InitializeResult> // LSP 握手
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 = [] // 连接就绪前的订阅队列

// onCrash 回调:进程非正常退出时通知 Owner,触发重启
// isStopping flag:区分主动停止与意外崩溃,避免误报
}

四、服务器实例管理(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 // 指数退避:500ms → 1000ms → 2000ms

rust-analyzer 等 LSP 服务器在项目初次索引期间会返回 -32801,CC 自动重试。


五、多服务器路由(LSPServerManager.ts)

路由策略:按文件扩展名

// 路由表维护
const extensionMap: Map<string, string[]> = new Map()
// e.g. '.ts' → ['typescript-language-server']
// '.py' → ['pylsp']
// '.rs' → ['rust-analyzer']

getServerForFile(filePath: string): LSPServerInstance | undefined {
const ext = path.extname(filePath)
const serverNames = extensionMap.get(ext)
return serverNames?.[0] ? servers.get(serverNames[0]) : undefined
}

文件同步协议

// 文件打开(触发 didOpen 通知)
await manager.openFile(filePath, content)

// 文件修改(触发 didChange 通知)
await manager.changeFile(filePath, content)

// 文件保存(触发 didSave 通知)
await manager.saveFile(filePath)

// 文件关闭(触发 didClose 通知)
await manager.closeFile(filePath)

打开文件追踪openedFiles: Map<string, string> 记录 URI → serverName,防止重复 didOpen,节省服务器资源。


六、诊断信息异步交付(LSPDiagnosticRegistry.ts)

核心问题

LSP 诊断是服务器主动推送textDocument/publishDiagnostics),而 CC 的工具结果是请求-响应模式。需要一个”异步缓冲区”桥接两者。

注册表设计

// 全局待交付诊断
const pendingDiagnostics = new Map<string, PendingLSPDiagnostic>()

// 跨轮次去重(LRU 防止内存泄漏)
const deliveredDiagnostics = new LRUCache<string, Set<string>>({
max: MAX_DELIVERED_FILES, // 最多跟踪 500 个文件
})

体量限制

const MAX_DIAGNOSTICS_PER_FILE = 10   // 单文件最多 10 条诊断
const MAX_TOTAL_DIAGNOSTICS = 30 // 总计最多 30 条

交付流程

LSP Server → publishDiagnostics 推送

registerPendingLSPDiagnostic() → 存入 pendingDiagnostics

下一轮查询开始

checkForLSPDiagnostics() → 取出待交付诊断

getLSPDiagnosticAttachments() → 转为 Attachment[]

getAttachments() → 自动注入到对话上下文

七、诊断格式转换(passiveFeedback.ts)

LSP Severity → Claude Severity

// LSP DiagnosticSeverity:
// 1 = Error, 2 = Warning, 3 = Information, 4 = Hint
function mapLSPSeverity(lspSeverity: number | undefined)
: 'Error' | 'Warning' | 'Info' | 'Hint'

URI 处理

// 支持两种 URI 格式:
// 1. file:// → fileURLToPath() 转换
// 2. 纯路径字符串 → 直接使用
// 3. 转换失败 → 降级使用原始 URI(不抛出)

八、插件 LSP 集成(lspPluginIntegration.ts)

插件提供 LSP 配置的格式

// LspServerConfigSchema(src/utils/plugins/schemas.ts)
type LspServerConfig = {
command: string // LSP 服务器可执行文件
args?: string[] // 启动参数
extensionToLanguage: Record<string, string> // 扩展名→语言ID 映射 { '.ts': 'typescript' }
transport?: 'stdio' | 'socket' // 传输方式,默认 'stdio'
env?: Record<string, string> // 启动环境变量
initializationOptions?: unknown // 服务器初始化选项
settings?: unknown // workspace/didChangeConfiguration 设置
workspaceFolder?: string // 工作目录
}
// ScopedLspServerConfig = LspServerConfig + 插件作用域字段(来自 src/services/lsp/types.ts)

并行加载所有插件的 LSP 配置

const results = await Promise.all(
plugins.map(plugin => getPluginLspServers(plugin, errors))
)
// 容错:单个插件失败不影响其他插件
// 合并:后载入插件的配置覆盖早期插件(Object.assign)

九、与工具系统的集成

工具调用时的文件同步

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 无法安全分帧。

// vscode-jsonrpc 的消息读取循环
function readMessage(reader: StreamReader): Message {
// 1. 读取 HTTP-like headers
const headers = readHeaders(reader); // "Content-Length: 1234\r\n\r\n"
const contentLength = parseInt(headers['content-length']);

// 2. 精确读取 contentLength 字节
const body = reader.readExact(contentLength);

// 3. JSON.parse()
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;

// LSP 规范:initialized 必须在 initialize response 之后发送
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 定义了三种文本同步方式:

// 在 ServerCapabilities 中声明
enum TextDocumentSyncKind {
None = 0, // 不接受文档同步
Full = 1, // 每次发送完整文件内容
Incremental = 2 // 只发送变更部分
}

13.2 全量同步(Full Sync)

Client → Server: textDocument/didChange({
textDocument: { uri, version: 3 },
contentChanges: [{ text: "全文内容..." }]
})

优点:简单,无版本漂移风险。
缺点:大文件(如 5000+ 行)每次都传输完整内容,浪费带宽。

CC 实现:

// manager.ts — 文件内容变更时
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 字段:

// 防止 out-of-order 处理
// LSP Server 收到 version=5 后忽略 version≤5 的所有后续文档变更
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[] {
// 1. 按严重度排序:Error > Warning > Info > Hint
const sorted = diagnostics.sort((a, b) =>
(a.severity ?? 0) - (b.severity ?? 0)
);

// 2. 去重:同一位置 + 同一消息 = 重复
const unique = dedupeByLocation(sorted);

// 3. 截断:单文件最多 MAX_DIAGNOSTICS_PER_FILE (10)
// 总共最多 MAX_TOTAL_DIAGNOSTICS (30)
return truncate(unique, config);
}

14.4 错误恢复与瞬态错误

rust-analyzer 在项目初次索引期间返回 -32801(ContentModified),表示文档内容在请求处理期间被修改:

// LSPServerInstance.ts 的重试逻辑
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)); // 500 → 1000 → 2000ms
}
}
}

十五、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 中的具体应用场景

// 1. 代码块边界识别(用于 diff 生成)
const tree = parser.parse(content);
const functions = tree.rootNode.descendantsOfType('function_declaration');
// → 帮助 CC 精确定位要编辑的函数边界

// 2. 结构感知 diff
// 相比 text-based diff,Tree-sitter 可以:
// - 识别"将 if 语句移入新函数"(结构变化,不是文本替换)
// - 标注"函数签名变更"(参数新增/删除)

// 3. 语法上下文传递给 LLM
// System Prompt 中注入:
// "This file contains 3 classes and 15 methods"
// → 来自 Tree-sitter 的 AST 统计

十六、LSP 诊断缓存与去重优化

16.1 LRU 诊断缓存

const diagnosticCache = new LRUCache<string, Diagnostic[]>({
max: 100, // 最多缓存 100 个文件
ttl: 5000, // 5 秒过期(因为是实时诊断)
});

16.2 避免上下文污染

LSP 诊断只在用户主动询问时才提供给 LLM(通过 /diagnostics 命令或 Review 请求),避免 System Prompt 被大量诊断信息污染。

16.3 跨 Turn 去重算法

// LSPDiagnosticRegistry.ts — 避免将相同诊断反复注入
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 {
// 指纹 = range + message 的 SHA256 前 12 位
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
打赏
  • 微信
  • 支付宝

评论