Book 第十三章:LSP 如何补上语义理解层
第三部分:本地执行面

第十三章: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 接口,而是把它做成了一个受本地运行时治理的语义查询适配层。

更具体一点,它做了六件事:

  1. 用全局 LSPServerManager 管多个语言服务器,而不是让工具自己直连某个 LSP 进程
  2. 把 1-based 的用户输入位置转换成 LSP 0-based 协议参数
  3. 在发请求前主动保证文件已对对应语言服务器 didOpen
  4. 对某些查询做 Claude Code 风格的二次编排,比如 call hierarchy 两段式请求
  5. 把原始 LSP 结果格式化成稳定、短、可读、按文件聚合的文本结果
  6. 额外过滤 gitignored 结果、控制大文件、补 UI 上下文、接被动 diagnostics 通道

所以这套东西不是:

  • “把 textDocument/definition 暴露出来”

而是:

  • 把语言服务器能力编译成 Claude Code 自己的语义代码理解协议。

3. 源码锚点

这次主要看的源码是:

  • src/tools/LSPTool/LSPTool.ts
  • src/tools/LSPTool/schemas.ts
  • src/tools/LSPTool/formatters.ts
  • src/tools/LSPTool/UI.tsx
  • src/tools/LSPTool/prompt.ts
  • src/tools/LSPTool/symbolContext.ts

配套还看了:

  • src/services/lsp/manager.ts
  • src/services/lsp/LSPServerManager.ts
  • src/services/lsp/passiveFeedback.ts
  • src/tools.ts
  • src/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/definition
  • textDocument/references
  • workspace/symbol

而是更稳定、更 Claude 风格的操作名:

  • goToDefinition
  • findReferences
  • hover
  • documentSymbol
  • workspaceSymbol
  • goToImplementation
  • prepareCallHierarchy
  • incomingCalls
  • outgoingCalls

这说明作者很明确:

  • LSP 协议细节不该直接暴露给模型
  • Claude Code 需要给模型一个更高层、统一、跨语言的操作抽象

5.2 它天然被放在 deferred tools 里

LSPTool 自己有几个关键信号:

  • searchHint: code intelligence (definitions, references, symbols, hover)
  • shouldDefer: true
  • isReadOnly() === true
  • isConcurrencySafe() === 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-started
  • pending
  • success
  • failed

而且是:

  • 先同步创建 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/symbol
  • query: ''

也就是空查询返回全 workspace symbols。

这说明 Claude Code 在这里没有把原始 LSP 查询习惯原封不动留给模型,而是直接给了一个:

  • “全量工作区符号索引入口”

10. incomingCalls / outgoingCalls 不是一个请求,而是两段式编排

这块很能体现 Claude Code 的适配层思路。

LSP 的 call hierarchy 不是一步拿到最终结果。

而是:

  1. textDocument/prepareCallHierarchy
  2. 再拿 CallHierarchyItem 去查 incomingCallsoutgoingCalls

Claude Code 没把这件事暴露给模型,让模型自己拼两次。

而是在工具内部直接包成一个 operation:

  • incomingCalls
  • outgoingCalls

内部自动完成两段请求。

这很重要。

因为它说明 Claude Code 对协议的态度是:

  • 模型不该背底层多步协议,工具层应该把它们包装成单步语义动作。

11. 结果不会直接原样回给模型,而是先被 Claude Code 清洗和再组织

11.1 先过滤 gitignored 结果

findReferencesgoToDefinitiongoToImplementationworkspaceSymbol 这些返回定位结果的操作,在格式化之前会先过一层:

  • filterGitIgnoredLocations()

内部是批量跑:

  • git check-ignore

这很像 Claude Code 一贯的产品判断:

  • 语义上“存在”的结果,不一定值得让模型看见

如果引用或符号只出现在被忽略的生成物、缓存物里,直接回给模型反而会污染判断。

所以这里它优先保留:

  • 对当前仓库分析真正有意义的结果。

11.2 再按 operation 做不同格式器

formatResult() 会根据 operation 分流到不同 formatter:

  • formatGoToDefinitionResult
  • formatFindReferencesResult
  • formatHoverResult
  • formatDocumentSymbolResult
  • formatWorkspaceSymbolResult
  • formatPrepareCallHierarchyResult
  • formatIncomingCallsResult
  • formatOutgoingCallsResult

这些 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 两层

如果输出里有:

  • resultCount
  • fileCount

那么 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 其实有两条通道:

  1. LSPTool 这种主动查询通道
  2. 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 管理层和工具层分开

  • ILspRuntimeManager
  • LspSemanticQueryTool

其中:

  • manager 负责 server 生命周期、extension 路由、open/change/save/close
  • tool 负责 operation 抽象、输入校验、结果格式化

15.2 operation 抽象单独建模

可以直接做:

  • LspOperation.GoToDefinition
  • LspOperation.FindReferences
  • LspOperation.Hover
  • LspOperation.DocumentSymbol
  • LspOperation.WorkspaceSymbol
  • LspOperation.GoToImplementation
  • LspOperation.PrepareCallHierarchy
  • LspOperation.IncomingCalls
  • LspOperation.OutgoingCalls

不要让上层直接拼原始 method 字符串。

15.3 结果格式化单独抽成 formatter 层

  • ILspResultFormatter
  • DefinitionFormatter
  • ReferenceFormatter
  • HoverFormatter
  • CallHierarchyFormatter

因为 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 看到的代码世界就还是:

  • 一堆字符串

而不是:

  • 带语义关系的程序结构。