为何构建 Rust LSP 如此困难
摘要
这篇博客文章阐述了构建 Rust 语言服务器协议(LSP)的技术挑战,借鉴了作者在 Rust Glancer 上的经验以及 rust-analyzer 的见解。
<p><a href="https://lobste.rs/s/q35pnm/why_building_rust_lsp_is_hard">评论</a></p>
查看缓存全文
缓存时间: 2026/09/16 17:28
# 为何构建 Rust LSP 如此困难 · Rust Glancer
来源:https://rust-glancer.github.io/blog/why-lsp-is-hard/
很久很久以前,强大的 matklad(https://github.com/matklad)曾撰写过许多关于 Rust 工具链工作原理的精彩文章。那些时光令人怀念,但可惜的是,rust-analyzer 博客的最后一篇文章也停留在了2023年(https://rust-analyzer.github.io/blog)。
我并非 matklad,但我在 Rust Glancer(https://github.com/rust-glancer/rust-glancer)——一个实验性的 Rust LSP 项目上已耕耘许久。这大概是我参与过的最有趣、最具雄心的项目,我想分享一些在开发过程中学到的东西。
开场梗图对话:"你好,这里是 r/rust"——"matklad 不再写关于 LSP 的文章了"——"那你自己写啊"——"我???"
这将是一篇(希望逻辑连贯的)故事,从 rust-analyzer 和 Rust Glancer 双重视角阐述 Rust LSP 的工作原理:看似简单的事物为何变得困难,看似困难的事物为何更加艰难,以及那些我未曾预料却真实存在的问题。
显然,单篇博文无法涵盖所有内容。本文将是一次技术性较强但偏重架构层面的概述,而非对任何特定主题的深入探讨。深入剖析的内容将以独立文章呈现,前提是我不太懒惰。
否则,请准备好迎接许多具有共同主题的轶事章节:构建 LSP 意味着必须从不完整的信息中生成有用的回复。
> **免责声明**:我并非构建 LSP 的专家。本文旨在激发读者对 LSP 内部机制与注意事项的兴趣,而非提供权威且正式的设计概述。我有意避免使用编译器术语,并在许多地方采用近似表述以侧重整体含义而非精确性。文中提供了大量指向更详细/精确资源的链接,建议读者查阅!此外,我在撰写本文前后阅读了大量 rust-analyzer 代码,但我并非 rust-analyzer 的维护者;如有理解错误——敬请谅解。
## LSP 从何开始?
LSP 服务器有两个对立端:实现语言服务器协议(Language Server Protocol,https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/)的服务器(即"存在一个我可以发送请求并接收格式良好响应的实体"),以及我们希望服务的实际状态(即"发送的查询确实能执行所需操作,并基于某种索引状态运行")。前者看似已成定论,尤其考虑到 tower-lsp-server(https://github.com/tower-lsp-community/tower-lsp-server)的存在。嗯,实则不然。让我们从这里开始,随后当真正需要时再逐步深入索引话题。
LSP 的启动始于客户端发送 `initialize`(https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize)请求,这要求你初始化 LSP(哈)。在响应之前,客户端不会执行任何操作。一旦你响应,它会发送 `initialized`(https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialized)通知,一切就绪,LSP 通信正式开始。
问题在于:何时响应此请求?服务器启动后一无所有,对项目一无所知。只有在收到此请求时,我们才知道讨论的是哪个代码库。而为了实际回答任何查询,我们需要对其“索引”。我们尚未明确“索引”的含义,但这无疑是一项繁重的工作。
是否应阻塞直至所有内容索引完毕?那么用户将体验 10-20-50-100 秒的等待,编辑器几乎无法使用。这不是选项。是否应立即启动?但如何回应即将到来的关于当前打开文件的首次查询?这难道不会导致我们在此处阻塞?如何避免“我们需要索引一切”的可怕问题?
这揭示了编译器与 LSP 之间的第一个巨大差异。编译器对“完成”的定义相当二元:二进制文件(抱歉)要么已编译,要么未编译。技术上,编译的共享库和其他构建产物也可用,但若编译器在你的工作区中编译了 715 个 crate 后停止,你会感到困扰。LSP 则不同:我们可以*几乎立即*提供有用的结果。我们不需要在第一个毫秒获得完整信息,而是需要尽快向用户发送有用的内容。只需决定何为“有用内容”。
原始问题的答案是:我们需要完成最少量的有用工作,以使处理查询成为可能。因此 rust-analyzer 和 Rust Glancer 均验证提供的配置并作出响应。rust-analyzer 在响应*之后*安排工作区发现,而 Rust Glancer 则保持被动,直至首次查询到来。
一旦握手完成,真正的挑战开始:你将收到首个实际查询。通常,这些查询可能是 `textDocument/didOpen`(https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_didOpen)、`textDocument/inlayHint`(https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_inlayHint)和 `textDocument/documentSymbol`(https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_documentSymbol)。若用户操作急切而你运气不佳,其间可能还有 `textDocument/didChange`(https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_didChange)。复杂性就此爆发。
首先,一个有趣的事实:LSP 作为协议,不希望你考虑文件系统。不存在文件系统,只有文档和编辑。这很合理:通常文档未保存,因此你无法知晓其内容。然而事实并非如此。在大多数语言中,对单个文件(或一组打开文件)的隔离分析很快就会失去实用性。
进入地狱:LSP 假定自身是真理之源,但你仍需自行访问文件系统,并且要以同步方式进行。更有趣的是,编辑可能发生在编辑器之外,客户端可能未如实通知你这些事件。这正是我们需要虚拟文件系统和“源代码世代”的原因,例如当前执行请求时的源代码状态标识符。若天真地组合文件系统访问与 LSP 通知,整个项目将陷入无尽的竞态条件。相反,我们将项目源代码加载至内存,声明其为 VFS,并尽力在此加载状态基础上应用任何更改。每次状态变更,我们都更新源代码世代,从而保持内部状态的一致性(并取消因失效而进行中的查询)。
“进行中的查询是什么?”我听到你发问。这是第二个有趣的事实。执行 LSP 查询可能涉及惊人的工作量,且查询并非同等重要。查找符号的引用是相当复杂的任务,而 `hover` 通常开销较低。因此每次执行一个查询并非选项,你需要并行执行读取查询。每当状态变更时,当前运行的查询将对已过时的状态进行无用操作。你的工作是创建一个循环,分离变更与非变更查询,允许读取请求并行运行,并在状态变更时取消任务。此外,若你不幸使用异步,还需序列化传入消息,确保 `didOpen` 和 `didChange` 不会以相反顺序到来——这可能是极难调试的问题(我纳闷为何需要特别说明这点)。
第三个也是最后一个有趣的事实:LSP 作者实际上考虑过你可能尚未准备就绪,因此提供了应对此情况的有用工具,如 `workspace/inlayHint/refresh`(https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_inlayHint_refresh)服务器请求。借助此强大工具,你可以说“抱歉,请稍后重试”,并在初始发送空响应后发送*实际*响应。问题在于并非所有内容都可刷新。文档符号不可刷新;若未立即发送,它们将保持陈旧,直至客户端决定重新请求。这意味着对于某些查询,你可能需要创造性的解决方案。
但我们偏离了主题。客户端正等待内联提示和文档符号。
而我们尚未索引任何内容。
我们该怎么办?
## 服务器,随时待命
幸运的是:为了回答文档符号,我们确实只需索引一件事——当前打开的文件。这实际上是 LSP 快速发挥效用的完美示例。回答此请求只需解析文件。抽象语法树(AST,或更准确地说,具体语法树 CST,但我们稍后会讨论)已能告诉你拥有的结构体、trait、函数、方法等信息。你甚至可以按需在请求中完成此操作。
`textDocument/hover`(https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_hover)在 `fn foo(a: Bar) {}` 中的 `Bar` 上略显复杂:它需要*语义分析*,至少以某种形式。你需要知道光标下的内容来自何处。为此,你需要理解作用域中可用的项目(结构体、方法,你懂的)。为此,你需要定义映射(defmaps):每个 crate 和模块中已解析的“可见范围”映射。为获得定义映射,你需要额外的降级层。你仍可在 AST/CST 层面工作,但并不方便。可能你需要一个*项树*——你对每个文件中定义的项目的表示。因此你需要:解析每个文件 → 从 AST/CST 构建项树 → 解析模块并构建定义映射 → 检查光标下可见内容 → 通过定义映射查找 → 提取已解析项的文档 → 显示它。如果你好奇“光标下”指什么,请为自己准备点美味食物以奖励提出这个好问题——我们稍后会回到这里。目前,这确实是*相当多*的额外工作,但依然相当可控。
内联提示则显著复杂得多(在*函数体内*悬停,如在局部变量上,也同样复杂)。它们出现在*函数体*内部。在 `fn foo() { let a = bar(); }` 中,我们可以说 `fn foo` 是*项声明*,而 `{ let a = bar(); }` 则是真正可怕的函数体部分。注意上述语义模型中我们完全未关心函数体。不仅如此,为了良好的内联提示,我们需要*类型推断*。目前我拒绝详述。
但如果你认为到此结束,请见识 LSP 的最终 Boss:`textDocument/references`(https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_references)。对于内联提示,你需要分析*单个文件*中的函数体。对于引用,你在 VS Code(或任何其他具有相同快捷键的编辑器)中的函数定义上按下无辜的 `Option+Shift+F12`,可怜的服务器必须在*整个工作区图中所有可发现位置*找到*该函数的所有使用*。想想 `Option`。我们指的是*快速遍历*成千上万个函数体,其中需要区分*这个特定的* `Option` 与任何其他名为 `Option` 的项目。这引出了更大的问题:即使你手头有所有已分析的函数体,你可能也不想线性遍历它们以查看是否恰好提及 `Option`。这就是 LSP 特定技巧登场的时刻:你可以使用文本匹配构建引用搜索计划,仅查找*可能*包含此标识符的文件子集,并仅在这些文件中遍历函数体。这仍可能是大量工作。如果你好奇“引用搜索计划”是什么,rust-analyzer 有一篇精彩文章(https://rust-analyzer.github.io/blog/2019/11/13/find-usages.html)(向强大的 matklad 致敬!)。
*旁注:如果你在想“嗯,是的,Option 有很多文本匹配,但这是极端案例”……构建 LSP **全都是**关于那些毁掉用户体验的极端案例,这也是 LSP 困难的原因之一。*
此处有两个要点:
1. 索引本身具有层次,形成几乎明确边界的序列。
2. 不同查询需要不同程度的代码库精度/知识。
LSP 可用的自由度之一是如何利用这些信息。rust-analyzer 和 Rust Glancer 在技术上都具有解析 / 项树 / 定义映射 / 语义层 / 函数体层(我使用 Rust Glancer 术语,但熟悉 rust-analyzer 的人会立即理解),但它们在计算这些数据的方式上有所不同。
rust-analyzer 使用 salsa(https://github.com/salsa-rs/salsa):一个增量数据库。这意味着你可以定义输入以及将输入转换为输出的逻辑,然后输出将被惰性计算并缓存。如果某些输入发生变化,只有输出的相关部分会失效并重新计算。rust-analyzer 模型是*优雅的*:根本没有索引。存在输入与代码库状态之间的关系网络,因此在任何时候你都可以询问*状态*,salsa 将确保为你计算它。它不必“索引”除直接询问内容之外的任何内容。老实说,salsa 感觉像魔法,如果你不熟悉它,我强烈建议花几个晚上了解它,你将脱胎换骨(另请参阅 Durable Incrementality(https://rust-analyzer.github.io/blog/2023/07/24/durable-incrementality.html)和 salsa 文档(https://salsa-rs.netlify.app/))。但即使使用了 salsa,技巧仍有必要。如果每个查询只计算必要内容,即使有缓存,也会有*大量*状态未被计算,编辑器最初可能感觉迟钝。这就是为什么 rust-analyzer 默认启用*缓存预热*(无相关博文,但此 PR(https://github.com/rust-lang/rust-analyzer/pull/4133)是最佳实践!),这基本上会对整个工作区进行语义层的全局索引,因为这是你可能一直需要的信息。函数体可以等到真正需要时再处理。
Rust Glancer 则不同。其重点是低内存占用和编辑器即时重启,两者相辅相成。Rust Glancer 希望尽可能多地预先完成工作,尝试一次性索引*所有内容*,然后将状态卸载到文件系统,以便在初始索引后无需计算太多。但此处也需要技巧!完全索引耗时很长,因此首先,Rust Glancer 一旦完成相关语义分析部分就开始响应查询(记得缓存预热?此处逻辑相似),对于函数体,它将优先处理当前打开的文件。与 rust-analyzer 相比,初始索引需要更长时间,且(当前)可能消耗更多内存,因为它是预先执行且工作更多,但之后基本完成。若某些内容发生变化,你只需更新相关部分。若编辑器重启,状态仍存在于文件系统中,使得索引几乎瞬时完成。仅在极少数情况下(如工作区图变更)需要完全重新索引。
回到查询:我们的 LSP 现在实际上拥有了想要服务的状态,它可能已就绪或未就绪。每当引擎未就绪时,它可能提供不精确的答案。
相似文章
rust-glancer:一个专注于低内存使用的 Rust 替代 LSP
介绍 rust-glancer,一个专注于低内存使用的 Rust 替代语言服务器协议实现,特色是重启后立即索引以及在老硬件上更好的性能。
Rust Glancer
Rust Glancer 是一个新的 Rust LSP 服务器,它使用的 RAM 显著少于 rust-analyzer 等替代品,这在一篇评估其特性和潜力的博客文章中讨论过。
Rust编译器的大语言模型政策
本文介绍了Rust Forge,这是一个Rust编程语言的补充文档仓库,包含构建、贡献和维护文档的说明。
用 LLM 将 TypeScript 编译器、类型检查器和 LSP 移植到 Rust
一个项目展示了完全借助 LLM 将微软的 TypeScript 编译器、类型检查器和 LSP 移植到 Rust 的过程——Anthropic 的 Opus 5.5 以约 2.4 万美元的 API 成本从头重写了整个代码库,实现了与真实项目完全兼容,可作为早期发布的 `tsc-rs` 直接替代品使用。
Python 还是 Rust?使用本地大语言模型编程
一篇讨论帖,询问在使用本地大语言模型辅助编程时,Rust 和 Python 哪个更好,并探讨 Rust 的编译器反馈是否有助于弥补在消费级硬件上运行的小型模型能力较弱的不足。