第十三章:LSP 如何补上语义理解层
从 go-to-definition 到 call hierarchy,看 LSPTool 怎么补上语义代码理解层。
1. 为什么 LSPTool 要单独拆
前面我们已经拆过两类很像“找代码”的能力:
GlobTool/GrepTool负责按文件名和文本内容找FileReadTool负责把文件内容按 Claude Code 的协议读进来
但 LSPTool 不是这条线上的一个小补充。
它真正补的是另一层能力:
不是“字符串里有没有这个词”,而是“这个位置在语言语义上到底是什么”。
比如:
- 这个符号定义在哪
- 谁引用了它
- hover 文档是什么
- 一个函数被谁调用、又调用了谁
- 当前文件/整个工作区有哪些语义符号
所以它本质上不是“更高级的 grep”,而是:
Claude Code 把 Language Server 的语义世界,翻译成模型能稳定消费的文本上下文。
2. 先说结论
我对 LSPTool 的总体判断是:
Claude Code 没把 LSP 当成一个裸 RPC 接口,而是把它做成了一个受本地运行时治理的语义查询适配层。
更具体一点,它做了六件事:
- 用全局
LSPServerManager管多个语言服务器,而不是让工具自己直连某个 LSP 进程 - 把 1-based 的用户输入位置转换成 LSP 0-based 协议参数
- 在发请求前主动保证文件已对对应语言服务器
didOpen - 对某些查询做 Claude Code 风格的二次编排,比如 call hierarchy 两段式请求
- 把原始 LSP 结果格式化成稳定、短、可读、按文件聚合的文本结果
- 额外过滤 gitignored 结果、控制大文件、补 UI 上下文、接被动 diagnostics 通道
所以这套东西不是:
- “把
textDocument/definition暴露出来”
而是:
- 把语言服务器能力编译成 Claude Code 自己的语义代码理解协议。
3. 源码锚点
这次主要看的源码是:
src/tools/LSPTool/LSPTool.tssrc/tools/LSPTool/schemas.tssrc/tools/LSPTool/formatters.tssrc/tools/LSPTool/UI.tsxsrc/tools/LSPTool/prompt.tssrc/tools/LSPTool/symbolContext.ts
配套还看了:
src/services/lsp/manager.tssrc/services/lsp/LSPServerManager.tssrc/services/lsp/passiveFeedback.tssrc/tools.tssrc/main.tsx
4. 总体结构图
flowchart TD
A["LSPTool input"] --> B["validateInput()"]
B --> C["waitForInitialization()"]
C --> D["getLspServerManager()"]
D --> E["getMethodAndParams()"]
E --> F["ensure file open in LSP"]
F --> G["manager.sendRequest()"]
G --> H{"operation 类型"}
H -- "普通查询" --> I["definition / refs / hover / symbols"]
H -- "call hierarchy" --> J["prepareCallHierarchy"]
J --> K["incomingCalls / outgoingCalls 二次请求"]
I --> L["filterGitIgnoredLocations()"]
K --> L
L --> M["formatResult()"]
M --> N["UI summary + tool_result"]
O["manager.ts"] --> P["全局 singleton 初始化状态"]
P --> Q["LSPServerManager"]
Q --> R["按扩展名路由到具体 LSP server"]
Q --> S["open/change/save/close 同步"]
Q --> T["passiveFeedback diagnostics"]
这张图里最重要的一点是:
LSPTool 自己并不直接拥有某个语言服务器,它只是站在 Claude Code 运行时上面,向一个多 server 管理层发语义请求。
5. LSPTool 的定位:不是 raw LSP client,而是语义查询工具
5.1 它对模型暴露的是“操作名”,不是 LSP 方法名
inputSchema 里给模型看的不是:
textDocument/definitiontextDocument/referencesworkspace/symbol
而是更稳定、更 Claude 风格的操作名:
goToDefinitionfindReferenceshoverdocumentSymbolworkspaceSymbolgoToImplementationprepareCallHierarchyincomingCallsoutgoingCalls
这说明作者很明确:
- LSP 协议细节不该直接暴露给模型
- Claude Code 需要给模型一个更高层、统一、跨语言的操作抽象
5.2 它天然被放在 deferred tools 里
LSPTool 自己有几个关键信号:
searchHint: code intelligence (definitions, references, symbols, hover)shouldDefer: trueisReadOnly() === trueisConcurrencySafe() === true
这四个点放一起很说明定位:
- 它是只读的
- 它适合并发
- 它不是每轮都必须首屏曝光
- 它应该通过
ToolSearch被模型按需发现
也就是说,Claude Code 把它看成:
高价值但非首轮必备的语义查询工具。
6. 入口验证:它不是随便给个 file + line 就发请求
validateInput() 这块做得比表面看起来细。
6.1 schema 不是只为了类型安全,也是为了更好的错误提示
inputSchema 自己是普通对象 schema,但真正校验时又额外走了一次:
lspToolInputSchema()的 discriminated union
这意味着作者并不满足于“能 parse 过就行”,而是更关心:
- 不同 operation 的输入能不能给出更清晰的错误信息
6.2 它会先做文件系统级验证
在真正走 LSP 之前,会先检查:
- 路径是否存在
- 是不是 regular file
如果文件根本不存在,直接在工具层失败,不把压力扔给语言服务器。
6.3 它还有一个 Windows 安全细节
这里有个很容易忽略、但很成熟的处理:
- UNC 路径
\\\\或//时,跳过文件系统操作
注释里已经写明目的:
- 避免 NTLM credential leak
这说明 Claude Code 在这类看似“只读查询”工具里,也在主动收系统级安全坑。
7. 初始化和可用性:LSPTool 可不可用,不只看工具是否注册
7.1 工具注册和工具可用是两回事
tools.ts 里,LSPTool 只有在:
ENABLE_LSP_TOOL环境变量打开
时才会进入基础工具全集。
但即便注册了,也不代表它一定可用。
isEnabled() 最终看的是:
isLspConnected()
也就是至少要有一个健康的语言服务器实例。
7.2 manager 初始化是异步启动、不阻塞主流程的
main.tsx 在启动阶段会调用:
initializeLspServerManager()
但 manager.ts 这层设计很明显不是同步阻塞式初始化。
它的状态机是:
not-startedpendingsuccessfailed
而且是:
- 先同步创建 singleton manager
- 再后台异步
initialize() - 调用方通过
waitForInitialization()按需等
这说明 Claude Code 不想让:
- LSP server 配置加载
- plugin LSP 发现
- server 初始化失败
影响整个 CLI 启动节奏。
7.3 失败时不是炸进程,而是工具层优雅降级
如果 manager 没初始化成功,LSPTool.call() 不会抛爆,而是回:
LSP server manager not initialized
如果某个文件类型没有对应 server,也会回:
No LSP server available for file type
这让 LSPTool 很像一个“有条件可用的增强工具”,而不是系统刚需。
8. 真正请求前,Claude Code 会先把文件同步进语言服务器
这是 LSPTool 最关键的设计点之一。
8.1 它不会假设 server 已经知道这个文件
在发任何请求前,call() 会先检查:
manager.isFileOpen(absolutePath)
如果文件还没对对应 server didOpen,它会:
- 本地打开文件
- 检查大小
- 读取 UTF-8 内容
manager.openFile(...)
注释里也点明了原因:
- 很多 LSP server 在没收到
textDocument/didOpen之前,相关操作根本不工作
所以 Claude Code 并不是“拿路径发 RPC”,而是在维护:
- Claude Code 本地文件状态
- LSP server 文档状态
之间的一致性。
8.2 它还对大文件做了 10MB 硬限制
如果文件太大:
- 超过
10MB
就不会继续同步到 LSP,而是直接返回:
- 文件太大,不做 LSP 分析
这很合理,因为 LSP 这种语义查询的收益和成本并不是无限扩张的。
作者显然不希望一个超大文件把:
- 文件读取
- didOpen
- server 内存
- 请求延迟
全部拖垮。
9. 请求映射:模型的 operation 会被翻成 LSP 协议
getMethodAndParams() 这层做了很标准、但很关键的协议映射。
9.1 位置坐标会从 1-based 转成 0-based
用户/模型输入用的是:
- 1-based line
- 1-based character
这和编辑器展示一致。
真正发给 LSP 的时候才转成:
- 0-based
这是一个很对的设计。
因为 Claude Code 站在“用户工具协议”这一层时,优先应该贴近编辑器心智,而不是协议内部心智。
9.2 workspaceSymbol 这里做了一个 Claude 式简化
它发的是:
workspace/symbolquery: ''
也就是空查询返回全 workspace symbols。
这说明 Claude Code 在这里没有把原始 LSP 查询习惯原封不动留给模型,而是直接给了一个:
- “全量工作区符号索引入口”
10. incomingCalls / outgoingCalls 不是一个请求,而是两段式编排
这块很能体现 Claude Code 的适配层思路。
LSP 的 call hierarchy 不是一步拿到最终结果。
而是:
- 先
textDocument/prepareCallHierarchy - 再拿
CallHierarchyItem去查incomingCalls或outgoingCalls
Claude Code 没把这件事暴露给模型,让模型自己拼两次。
而是在工具内部直接包成一个 operation:
incomingCallsoutgoingCalls
内部自动完成两段请求。
这很重要。
因为它说明 Claude Code 对协议的态度是:
- 模型不该背底层多步协议,工具层应该把它们包装成单步语义动作。
11. 结果不会直接原样回给模型,而是先被 Claude Code 清洗和再组织
11.1 先过滤 gitignored 结果
findReferences、goToDefinition、goToImplementation、workspaceSymbol 这些返回定位结果的操作,在格式化之前会先过一层:
filterGitIgnoredLocations()
内部是批量跑:
git check-ignore
这很像 Claude Code 一贯的产品判断:
- 语义上“存在”的结果,不一定值得让模型看见
如果引用或符号只出现在被忽略的生成物、缓存物里,直接回给模型反而会污染判断。
所以这里它优先保留:
- 对当前仓库分析真正有意义的结果。
11.2 再按 operation 做不同格式器
formatResult() 会根据 operation 分流到不同 formatter:
formatGoToDefinitionResultformatFindReferencesResultformatHoverResultformatDocumentSymbolResultformatWorkspaceSymbolResultformatPrepareCallHierarchyResultformatIncomingCallsResultformatOutgoingCallsResult
这些 formatter 不是简单 stringify。
它们做了很多 Claude Code 风格的整理:
- URI 转相对路径
- 只在相对路径更短且不以
../../开头时才显示相对路径 - 按文件聚合结果
- 行列号转回 1-based
LocationLink转普通Location- 层级 symbol 用缩进树表示
- workspace symbol 附带 container 信息
- incoming/outgoing calls 还会展示 call sites
这说明 Claude Code 的目标不是“把 LSP 数据给模型”,而是:
- 把 LSP 数据压成模型友好的阅读结果。
11.3 malformed LSP data 也有防御式处理
格式化层里大量在防:
uri缺失location缺失- malformed URI decode 失败
而且不是静默吞掉,而是:
- debug log
- error log
- 过滤非法项
- 剩下的继续返回
这说明作者默认认为:
- 不同 LSP server 的数据质量并不稳定
因此 Claude Code 需要做容错边界。
12. UI 也不是简单显示结果,它还做了上下文压缩
12.1 tool use message 会尝试显示“当前符号”
UI.tsx 里最有意思的一点是:
- 对
goToDefinition/findReferences/hover/goToImplementation - 它不会只显示
file + line + character - 会先调用
getSymbolAtPosition(...)
而 symbolContext.ts 为了在同步 React render 里快速工作,还专门:
- 只读文件前
64KB - 只在那个窗口内提取当前位置 symbol
- 超出窗口或异常就优雅回退
这说明 Claude Code 甚至连 tool header 都在做优化:
- 用户看到的是 “对哪个 symbol 做了查询”
- 而不是一堆抽象坐标
12.2 结果展示分 collapsed / expanded 两层
如果输出里有:
resultCountfileCount
那么 UI 不会直接把全文铺开,而是优先显示摘要:
- 找到几个结果
- 跨几个文件
- 非 verbose 下用
Ctrl+O扩展
这说明 LSPTool 从设计上就不是“大段原始文本输出工具”,而是带统计摘要的语义查询工具。
13. LSP 在 Claude Code 里还有一个“被动通道”
这一点很值得注意。
Claude Code 接 LSP,不只是为了主动查询。
manager.ts 初始化成功后,还会:
registerLSPNotificationHandlers(lspManagerInstance)
而 passiveFeedback.ts 会监听:
textDocument/publishDiagnostics
再把 diagnostics 转成 Claude 自己的 attachment / diagnostic 格式,异步注册进去。
这说明 Claude Code 里 LSP 其实有两条通道:
LSPTool这种主动查询通道- diagnostics 这种被动反馈通道
也就是说,它不是把语言服务器当成“按需 API”,而是在逐步把它变成 Claude Code 的长期语义背景源。
14. 把这些设计放一起看,Claude Code 暴露出了什么思路
LSPTool 这一层,我觉得最重要的设计思路有五条。
14.1 协议细节对模型隐藏,语义动作对模型暴露
- 模型看到的是
goToDefinition - 不是
textDocument/definition
14.2 语言服务器由 runtime 统一托管,不由工具自己管理
- 全局 singleton
- 初始化状态机
- 多 server 扩展名路由
14.3 工具负责把多步协议压成一步语义动作
- call hierarchy 是最典型的例子
14.4 输出必须按 Claude Code 的阅读方式重组
- 相对路径
- 分文件聚合
- 计数摘要
- malformed 容错
14.5 语义结果还要继续服从 Claude Code 的仓库边界
- gitignored 结果过滤
- 大文件不做
- 无 server 时优雅降级
15. 如果翻成 C#,我建议怎么拆
这一层如果要翻成 C#,我不建议只做一个:
ILanguageServerClient
然后所有东西都直接调它。
更合适的拆法大概是:
15.1 管理层和工具层分开
ILspRuntimeManagerLspSemanticQueryTool
其中:
- manager 负责 server 生命周期、extension 路由、open/change/save/close
- tool 负责 operation 抽象、输入校验、结果格式化
15.2 operation 抽象单独建模
可以直接做:
LspOperation.GoToDefinitionLspOperation.FindReferencesLspOperation.HoverLspOperation.DocumentSymbolLspOperation.WorkspaceSymbolLspOperation.GoToImplementationLspOperation.PrepareCallHierarchyLspOperation.IncomingCallsLspOperation.OutgoingCalls
不要让上层直接拼原始 method 字符串。
15.3 结果格式化单独抽成 formatter 层
ILspResultFormatterDefinitionFormatterReferenceFormatterHoverFormatterCallHierarchyFormatter
因为 Claude Code 这一层真正有价值的,恰恰不是调到了数据,而是把数据格式化成了模型能用的文本。
15.4 被动 diagnostics 通道不要丢
建议在 C# 版里保留:
LspDiagnosticBridge
这样你的系统不会只在“模型主动问”时有语义能力,而是能持续接收语言服务器反馈。
16. 最后收一下
LSPTool 真正说明的是:
Claude Code 并没有把 LSP 当成一个额外插件,而是把它做成了语义代码理解层。
它对上给模型暴露的是:
- 定义
- 引用
- hover
- 符号
- 调用关系
它对下真正处理的是:
- 多 server 生命周期
- 文件同步
- 协议映射
- 结果清洗
- diagnostics 接入
所以这层的关键,不是“Claude Code 会不会调用 LSP”,而是:
- 它有没有把 LSP 变成自己 runtime 的稳定语义接口。
从这份源码看,答案是有,而且做得相当克制。
如果后面要做 C# 版,我会把它放在 Grep/Read 之后、MCP 之后尽快实现。
因为一旦少了这层,你的 agent 看到的代码世界就还是:
- 一堆字符串
而不是:
- 带语义关系的程序结构。