为什么构建 Rust LSP 如此困难
很久以前,大神 matklad 经常撰写精彩文章,深入剖析 Rust 工具链的运作原理。那是一段黄金时期,但不幸的是,rust-analyzer 博客的最后一次更新还要追溯到 2023 年
我绝非 matklad 那样的专家,但我已经花了不少时间构建 Rust Glancer,这是一个实验性的 Rust LSP。这大概是我从事过最具趣味性且野心最大的项目,我想借此分享一些在开发过程中积累的见解。

我将讲述一个(希望是逻辑连贯的)故事,从 rust-analyzer 和 Rust Glancer 双重视角解析 Rust LSP 的工作机制:那些看似简单实则复杂、看似困难实则更棘手的问题,以及那些我原本以为根本不存在、结果却偏偏存在的情况。
显然,单篇博客无法涵盖所有细节。本文更像是一份极具技术含量但偏向架构层面的综述,而非对文中提及的任何特定主题进行深度剖析。当然,只要我不偷懒,后续会有独立文章深入探讨这些细节。
此外,请准备好迎接许多轶事章节。这些章节有一个共同点:构建 LSP 的核心挑战在于,如何从不完整的信息中生成有用的回答。
免责声明:我并非 LSP 构建领域的专家。本文旨在唤起读者对 LSP 内部机制及潜在陷阱的兴趣,而非提供无歧义且形式化的设计概述。我刻意避免使用编译器术语,并在多处使用近似表述,侧重于传达整体含义而非追求严格精确。文中包含大量链接指向更详细或精确的资料,强烈建议查阅!
另外,在准备本文前后,我阅读了大量 rust-analyzer 代码,但我并非 rust-analyzer 维护者;如果我理解有误,深表歉意。
LSP 从何开始?
LSP 服务器有两个相反的端:一端是实现了 Language Server Protocol 的服务端(即“我能发送请求并收到格式良好的响应”),另一端是实际希望提供服务的状态(即“发送的查询确实能执行所需操作,并作用于某种索引状态之上”)。第一端看起来已经是成熟的技术了,对吧?尤其是 tower-lsp-server 的存在更是如此。嗯,其实并非如此。我们先从这里开始,等到真正需要时再逐步引入索引话题。
LSP 始于客户端发送 initialize 请求,要求初始化 LSP(听起来很怪)。在响应之前,客户端不会做任何事。一旦你响应了,客户端会发送 initialized 通知,一切就绪,LSP 通信正式开始。
问题在于:何时响应这个请求?服务器刚启动时,你一无所有。你根本不了解项目,只有收到这个请求后,你才会知道我们谈论的是哪个代码库。而要真正回答任何查询,我们必须对其进行“索引”。目前我们还不知道索引具体意味着什么,但这无疑是一项繁重的工作。
我们是否要阻塞,直到完成所有索引?那样用户将面临 10 秒、20 秒甚至 50 到 100 秒的等待,编辑器在此期间几乎无法使用。这不可行。那我们是否立刻开始索引?但此时如何回应即将到来的第一个关于当前打开文件的查询?这难道不会导致在那处发生阻塞吗?我们该如何避免那种“必须索引所有内容”的恐惧?
这就暴露了编译器和 LSP 的第一个重大区别。编译器对“完成”的定义相当二元:代码要么编译通过,要么不通过。严格来说,编译出的动态库和其他构建产物也算可用,但实践中,如果编译器编译了你工作区 716 个 crate 中的 715 个就停下来,你肯定会抓狂。LSP 则不同:我们可以几乎立刻给出有用的结果。开头那一毫秒不需要掌握完整信息,而是要尽快给用户返回点有用的东西。问题只是如何定义“有用的东西”。
对开头那个问题的回答是:我们要做的,是让查询请求能被处理所需的最少有效工作。因此 rust-analyzer 和 Rust Glancer 都只做配置校验并回复。rust-analyzer 会把工作区发现安排在回复之后启动,而 Rust Glancer 在收到第一条查询之前一直保持被动。
握手完成后,真正的考验才开始:你会收到第一批实际查询请求。多数情况下,这些请求大概率是 textDocument/didOpen、textDocument/inlayHint 和 textDocument/documentSymbol。如果用户手速快、你又运气不好,中间可能还夹着 textDocument/didChange。于是复杂度瞬间爆炸。
先说个有意思的事实:LSP 这个协议根本不希望你去考虑文件系统。没有文件系统,只有文档和编辑操作。这乍听合理:文档往往还没保存到磁盘,你不可能知道它的内容。但实际上并非如此——在大多数语言中,只孤立地分析单个文件(或一组已打开的文件),很快就会失去意义。
欢迎来到地狱:LSP 假定自己是真理的来源,但你仍需要自行访问文件系统,且必须保证同步。更添趣味的是,编辑可能发生在编辑器之外,而客户端对这类事件的回调未必可靠。正因如此,我们需要引入虚拟文件系统(VFS)和“源码版本”概念,即标识当前请求执行时源码状态的识别码。若天真地将文件系统访问与 LSP 通知混合,整个项目将陷入无尽的竞态条件。正确做法是把项目源码加载到内存中,将其视为 VFS,尽力将任何变更应用到该加载状态之上;每次状态变更时,更新源码版本。这样能维持内部状态的一致性,并在旧状态失效时取消进行中的查询。
“什么进行中的查询?”你可能会问。这正是第二个痛点。执行 LSP 查询可能涉及大量工作,且不同查询差异巨大:查找符号引用是相当复杂的任务,而 hover 通常很轻量。因此逐条处理查询并不可行,必须并行执行只读查询。一旦状态发生变化,当前正在运行的查询就会基于过时的状态做无用功。你的任务是构建一个循环,区分变更型与非变更型查询,让只读请求并行运行,并在状态变更时取消相关任务。此外,如果不幸使用 async,务必对入站消息进行序列化,确保 didOpen 和 didChange 不会以相反顺序到达,否则将会带来难以调试的麻烦(我都不知道自己为什么要特意强调这一点)。
第三个也是最后一个有趣的事实是,LSP 的设计者确实考虑到你可能还没准备好,因此提供了一些有用的机制来应对这种情况,比如 workspace/inlayHint/refresh 服务器请求。借助这样一个强大的工具,你可以说“抱歉,现在请重试”,然后即使在最初什么都没返回的情况下,也能把实际的响应发出去。但问题是,并非所有内容都能刷新。文档符号就不行;如果你没在一开始就发过去,它们就会一直处于陈旧状态,直到客户端自己决定再次询问。这意味着对于某些查询,你可能得动点脑筋。
不过我们跑题了。客户端正在等待 inlay hints 和文档符号。
而我们连一个东西都还没索引完。
该怎么办呢?
服务器,听您差遣
运气不错:要回答文档符号,我们真的只需要索引一样东西。也就是当前打开的文件。 这其实正好体现了 LSP 能有多快发挥作用。要响应这个请求,你只需要解析文件即可。AST(或者更准确说是 CST,稍后会讲到)已经能告诉你有哪些结构、trait、函数、方法等。你甚至可以在请求到达时按需执行。
对 fn foo(a: Bar) {} 中的 Bar 做 textDocument/hover 就有点棘手了:至少需要某种形式的语义分析。你得知道光标下的这个符号是从哪来的。为此,你得先弄清楚当前作用域里有哪些可用的 item(struct、方法等等,你懂的)。这就需要定义映射(definition map):为每个 crate 和模块解析出"从哪里能看到什么"的映射。而要得到定义映射,就需要多一层降级(lowering)。你仍然可以在 AST/CST 层面操作,但会很不方便。你可能更想要一个item tree,也就是自己维护的一份每个文件中定义的 item 的表示。于是流程变成了:解析每个文件 -> 从 AST/CST 构建 item tree -> 解析模块并构建定义映射 -> 检查光标下什么可见 -> 通过 defmap 找到它 -> 提取对应 item 的文档 -> 展示出来。如果你好奇"光标下"到底是什么意思——好问题,值得奖励自己一顿好吃的,这个我们后面再说。目前来看,这部分确实额外增加了不少工作,但还算可控。
Inlay hint 就麻烦得多了(在函数体内部做 hover 也一样,比如悬停在局部变量上)。它们出现在函数体里面。以 fn foo() { let a = bar(); } 为例,可以说 fn foo 是一个item 声明,而 { let a = bar(); } 则是真正可怕的部分函数体。注意,上面描述的语义模型完全没涉及函数体。不仅如此,要做好 inlay hint,还必须有类型推断。至于这部分,我现在拒绝展开细说。
但如果你以为事情到此为止,那就来看看 LSP 的最终 Boss:`textDocument/references`。对于 inlay hints,你只需要分析 单个文件 中的函数体。而处理 references 时,当你在 VS Code(或任何恰好用相同快捷键的编辑器)中对函数定义按下 `option+shift+F12`,服务端就得找出 工作区依赖图中所有可发现位置对该函数的所有引用。想想 `Option` 吧。我们需要 快速 扫描成千上万个函数体,并从中区分出 特定的那个 `Option`,而非其他同名项。这就引出了更大的问题:即使你手头已有所有分析好的函数体,通常也不希望线性遍历所有文件来检查是否恰好提到了 `Option`。这时 LSP 特有的“取巧”手段登场:你可以利用文本匹配构建引用搜索计划,先找出 可能 包含该标识符的文件子集,然后仅在这些地方遍历函数体。即便如此,工作量依然可能巨大。如果你好奇什么是“引用搜索计划”,rust-analyzer 有一篇很棒的文章对此做了介绍(向大神 matklad 致敬!)。
旁注:如果你在想“确实,`Option` 的文本匹配很多,但这是极端情况”……其实构建 LSP 恰恰全是关于那些会破坏用户体验的极端情况,这也是 LSP 构建难度高的原因之一。
这里有两个重点:
- 索引本身有多层结构,形成一个具有既定边界的序列。
- 不同查询需要对代码库具备不同程度的精确度/知识。
LSP 的一个自由度在于如何利用这些信息。 无论是 rust-analyzer 还是 Rust Glancer,技术上都有解析 / item tree / defmaps / 语义层 / 函数体层(此处使用 Rust Glancer 的术语,但熟悉 rust-analyzer 的人应该能立即明白对应关系),但它们在计算这些数据的方式上存在差异。
rust-analyzer 基于 salsa 构建,这是一个增量数据库。它的核心在于定义输入以及将输入转换为输出的逻辑,随后输出会被惰性计算并缓存。一旦部分输入发生变更,只有相关的输出部分会被标记为失效并重新计算。rust-analyzer 的架构设计显得尤为优雅:它完全摒弃了传统的索引概念。系统内部构建了一张由输入与代码库状态关系交织而成的网络,因此在任何时刻,你只需请求特定的状态,salsa 便会确保为你计算出该结果。除了被直接请求的内容外,它不会预先计算或“索引”其他任何数据。坦白说,salsa 宛如魔法。如果你对此尚不熟悉,强烈建议预留几个晚上深入了解,这将彻底改变你的编程思路(可参阅 持久增量性 及 salsa 文档)。然而,即便有 salsa 加持,仍需一些额外手段来优化体验。由于每个查询仅计算必要内容,即使有缓存机制,系统中仍存在大量尚未计算的状态,这可能导致编辑器初期手感迟滞。因此,rust-analyzer 默认启用缓存预热(虽无专门博文介绍,但 此 PR 代表了当前最佳实践)。其本质是为整个工作区执行直至语义层的全局索引操作,因为这些信息通常是用户最常需要即时访问的。而函数体等次要内容,则可等到真正需要时再行计算。
Rust Glancer 走的路线不同。它追求的是低内存占用和编辑器的即时重启,这两件事是相辅相成的。Rust Glancer 想在一开始就尽可能多地把活干完:先对所有内容做一次索引,然后把状态存到文件系统里,这样初始索引之后基本就不用再算什么了。但这里同样需要一些技巧!完整索引耗时很长,所以 Rust Glancer 会在语义分析的相关部分一完成就立刻开始响应查询(还记得前面说的缓存预热吗?逻辑类似),并且会优先处理当前打开的文件。和 rust-analyzer 相比,它的初始索引耗时更长,(目前)也可能占用更多内存——因为它是主动去做更多的事——但之后就基本一劳永逸了:代码有变动,只更新相关的部分;编辑器重启,状态还在文件系统里,索引几乎瞬间完成。只有极少数情况(比如 workspace 依赖图变化)才需要完整重建索引。
回到查询的话题:我们的 LSP 现在真正拥有了它想要提供服务的状态,而这个状态要么就绪、要么没就绪。当引擎没就绪时,它会尽量给出一个尽可能有用的近似答案,而且很多时候还能请求客户端在状态计算完成后刷新结果。
LSP 的原理就是这样!感谢阅读!不过……
一个 workspace,两个 workspace
我敢打赌你在第一章就注意到了那个"(workspaces?)",并且一直在纳闷:为什么我写得好像打开的文件夹一定是个单一的 rust workspace。因为显然不一定。打开的文件夹里可能包含 5 个子文件夹,其中 3 个是 rust workspace,2 个不是。打开的文件夹也可能是某个 workspace 里的一个 crate,甚至可能只有一个 rust 文件而根本算不上一个 crate。上一章其实想得有点太远了,我们得回到起点重新梳理。
让我们从一个简单的问题开始:LSP 是如何被激活的?激活之后,它又如何判断项目是什么?答案显然来自客户端。如果你控制客户端,可以自己定义规则。例如,你可以规定当前文件夹必须包含 Cargo.toml 文件;或者规定任何直接子文件夹中都可能存在 Cargo.toml 文件——rust-analyzer 采用的就是后者。这样,当你打开一个包含 N 个工作区的文件夹时,系统会发现它们并开始索引。你甚至可以做得更彻底:进行递归扫描,检查工作区文件夹内部是否还嵌套了其他工作区(例如,这些工作区位于父工作区 Cargo.toml 的 exclude 配置中)。这是一个极端案例,rust-analyzer 并没有这样做。
反过来的问题同样存在:如果文件夹里有 Rust 文件,但没有 Cargo.toml 怎么办?当你打开一个 *.rs 文件时,客户端仍可能尝试激活你的 LSP,这时该怎么做?一种策略是使用 cargo locate-project 来查找根目录(如果存在的话),即使根目录位于当前目录之外,也继续索引代码库。但用户真的希望这样吗?也许他们特意打开某个文件夹,恰恰是因为不想进行完整的深度分析?
类似地,当你打开一个包含多个项目的文件夹时,用户真的希望发现并分析所有项目吗?有时是需要的:如果你打开一个新项目,发现它没有索引,尽管你一小时前就打开了 IDE,这会很让人恼火。有时则不然:如果你打开一个包含 8 个重量级项目的文件夹,却发现 CPU 风扇狂转,因为 LSP 开始并行索引所有内容,这也同样让人头疼。
与运行 cargo check 这种明确的用户请求不同,LSP 中的用户意图并不清晰。他们只是打开了一个文件夹,并没有明确指示希望得到哪种行为。因此,没有绝对正确的答案,只有项目作者的决策。
rust-analyzer 在工作区发现方面倾向于激进策略,在启用缓存预热(cache priming)后,这种影响可能非常明显。同样,它致力于提供有用的体验,如果为了给用户良好体验需要跳出项目目录,它也会这样做。
Rust Glancer 在此采取了几乎截然相反的立场:它要求Cargo.toml必须存在于当前范围内才能运行分析,并且在你实际打开工作区之前,不会启动索引。这使其在某种程度上更加懒惰和严格:它不试图替用户猜测,也不试图超出用户提供的范围行事。尽管如此,这里也可以提出反驳意见:它仍然会检查 cargo registry。策略很难做到完全纯粹。
但问题并没有到此为止。想象一下,如果该文件夹包含两个 Rust 工作区,其中一个格式正确而另一个不正确,该怎么办?奇怪的是,LSP 本身并没有提供太多工具来区分这些情况。在 rust-analyzer 中,如果由于任何原因无法处理其中任何一个工作区,整个服务器就会进入错误状态,并在 VS Code 的状态面板中标记为红色。即使其他 crate 可以正常工作!然而,rust-analyzer 仍然使用单个进程来管理所有工作区(这是 salsa 的另一个优良特性,使这种模型显得非常自然),因此如果单个 crate 导致 rust-analyzer 崩溃,就会导致全局崩溃。
Rust Glancer 再次采取了不同的方法:LSP 服务器本身只是一个路由器,每个工作区被建模为一个独立的进程(引擎)。LSP 服务器可以按需生成引擎,拥有自己的通信协议,且任何编辑器中的崩溃都意味着全局崩溃。这里的附带好处是有助于低内存占用:不同引擎的数据不会相互混杂,从而减少碎片化(因为许多具有不同生命周期的分配正是导致内存碎片化的原因)。然而,它也有自己的缺点:这显著增加了复杂性,并且总体上与 LSP 设计相悖。它还需要在状态报告方面进行不少复杂操作。
这里得到的有用教训是,即使 LSP 本身是一个定义明确的协议,它仍为实现在具体如何运作以及如何解读用户意图方面留下了大量空间。两种方法都没有绝对的对错之分。这取决于你希望优先考虑什么。用户对什么才是正确的做法确实存在不同意见。
你根本不需要 LSP
到目前为止,我们了解到多少 LSP 的冷知识?好了,下一个来了。
LSP 定义的是一个协议,而协议往往都是怪异针对特定领域通信做过优化的。这个领域显然就是编辑器。它不用字节偏移或字符索引来交流,而是用行和列。更妙的是,协议还要求你的服务器会说 UTF-16。谁能不爱 UTF-16 呢?
问题在于,首先,直接用行、列和 UTF-16 工作并不方便。你大概需要一个协议桥接层,让服务器内部用偏移量和 UTF-8,只在真正和协议交互的边界处才做转换。不过这很正常,不管用什么协议,都算是最佳实践:应用的领域模型没必要和协议的领域模型完全一致,只要两者同构就够了。
但是问题来了:如果你平时用偏移量工作,怎么把它们转换成行和列?每次都要解析整个文件、按行切分、再平移偏移量,那可就有点太低效了。虽然内部表示不必使用协议领域的概念,你仍然需要一些工具让转换高效,比如为每个文件建立行索引。rust-analyzer 和 Rust Glancer 都是这么做的。
有意思的地方在于,即使你想把 LSP 完全抽象掉,也做不到彻底——它总会渗透进你的架构。
而且这种"附带元数据"还不止于此。除了分析和查询,LSP 还要负责编辑操作。它通常能帮你处理 import、提供代码片段,还支持各种 code action,比如把限定路径替换成 import、补全缺失的 trait 成员等。而这类编辑往往包含什么?换行符!但该用哪种呢?总不能简单假设 Windows 上就是 \r\n、Unix 上就是 \n。如果猜错了,就会产生不一致的编辑。所以除了行索引,我们还需要检测并记住每个文件实际使用的换行符类型。顺带一提,另一个重构工具 rustfmt 也得考虑这个问题,不过因为它是整体重写文件而不是做细粒度编辑,所以可以通过配置选择 auto(自动检测)、unix、windows 或 native(跟随操作系统默认值)。
元数据还远不止这些。要正确解析文件,你还必须知道它的版本(edition)。否则,你就无法判断 gen 究竟是一个标识符还是关键字。这就意味着,我们实际上无法孤立地分析单个文件:我们需要 Cargo.toml(或其他项目元数据)才能知道如何正确解析它。
正如你所见,对元数据的需求来自方方面面:LSP、文件内容、Rust 语言本身。说来有趣,像解析这样看似简单的操作,竟然也有状态性的要求。
何时进行索引
好吧,这篇文章很长了,而我们对索引的探讨还只是浅尝辄止,尽管它本应是难度最大的部分。
事实就是,索引 确实 是最难的部分,老实说,它足以支撑一系列篇幅相当的文章。但为了不让我们的 LSP 之旅留下空白,让我们先做一个高层概览。
首先,有一点很重要:不同的 LSP 在底层设计上可能截然不同,处理索引的方式也各不相同。再次提到,rust-analyzer 博客中有一篇 不错的文章 讨论了这个问题。简而言之:
- 第一种:设置“完整分析”和“浅层分析”阶段。完整分析会检查大量内容,而浅层分析速度快且按文件工作。Rust Glancer 等项目采用了这种方式。
- 第二种:利用编译器为你干活,并对它的状态进行快照。虽然这么说有点牵强,但我们可以说首个 Rust LSP —— RLS —— 就是这么做的。这种方法适用于某些语言,特别是基于头文件的语言,但在 Rust 中被证明效率极低。
- 第三种:使其具有增量性或基于查询。LSP 仅计算回答某个查询所需的数据,不去过多考虑其他事情。rust-analyzer 借助 salsa 的力量实现了这一点。
这些方法决定了 如何 执行索引。不过,索引的各个阶段大体上是一样的。在 Rust 中,它们是:
- 解析(我将词法分析视为解析的一部分):接收输入文本并将其转换为 CST 表示。
- 构建项树:提取作为后续索引阶段输入的信息。CST(具体语法树)虽然有用,但粒度太细。你需要知道有哪些 项(items),例如“这是一个具有这些字段、文档注释、属性和可见性的结构体”,而不是“一个带有 N 个标记子节点的结构体节点”。
- 构建定义映射:存在哪些模块,它们包含什么?该模块导出了什么?从该模块可以访问哪些内容(包括:“这是以别名导入的,因此我们必须解析原始导入,并将其作为别名在模块内部可见”)?
- 宏解析:宏很有意思。它们会展开成更多的代码,这些代码也需要进行分析。此外,它们还会引入更多的项,甚至引入新的模块到作用域中。在自身展开之后(这本身有很多怪癖),我们需要确保展开过程改变了 defmap(定义映射)的状态,这使得将宏解析作为 defmap 构建过程的一个子阶段变得很自然。
- 构建项索引:在项树构建完成后,我们可能拥有了每个结构和每个 impl 块的表示,但它们是如何链接的?
impl Foo是与crate::a::Foo还是crate::b::Foo相关的?我们需要一个阶段来创建“链接后的项状态”——我们有哪些唯一的项,哪些 impl 对应于什么,哪些 trait impl 对应于哪个 trait 和 trait 实现者。在这里构建索引尤为重要:能够枚举某个结构的项是必不可少的,因此虽然理论上我们可以处理未链接的项树,但那既低效又不宜人。 - 正文解析。上述所有内容根本不关心函数体,并包含大量有用信息,但函数体才是程序中真正有用的部分。对于函数体,我们需要解析所有语句/表达式/模式,分配所有绑定(例如分配的变量),声明作用域(哪些绑定在何处可见),链接上述所有内容,然后执行类型推断和 trait 求解。后两者 正是 令人望而生畏的部分。
索引完成后,无论我们是分析了整个 workspace,还是只为单次查询做了足够的工作,最终都会得到索引状态(indexed state):也就是我们对项目中各种声明的表示。有了它,才能回答诸如这个变量是什么类型、它有哪些可用方法、这个结构体该显示什么文档之类的问题。
这里的关键在于,索引并不需要遍历所有东西,它的最终形态由我们想要处理的查询决定,而不是由代码库中理论上可以推断出的全部信息决定。
遗憾的是,索引过程并不像上面描述的那样线性。以 defmap 为例:如果代码里有 use bar::baz; use foo::bar;,第一遍扫描时你会知道 bar 在作用域内,但此时还无法立即用它来解析 bar。再比如 use bar::generate_gen_mod; use gen_mod::Foo; generate_gen_mod!();,你需要先把 generate_gen_mod 加入作用域,然后展开它以引入 gen_mod,再分析 gen_mod,之后才能解析 use gen_mod::Foo。所以索引过程中会用上不少"定点循环"(fixed-point loop):不断重复分析直到获得更多信息,一旦没有新信息(或达到循环次数上限)就停止。
类似地,函数体分析也有递归性:函数体本身可以包含 item、宏、impl,而这些东西又可以包含函数体,函数体里又有 item、宏、impl……你懂的。每个函数体还会拥有自己的 defmap,各自带一个定点循环、一个局部 item 索引,以及对内部函数体的分析。
然后就是类型推导和 trait 求解。抱歉,这两块内容要留到下一篇博客再讲。本文讨论的是 LSP 本身,只需要知道它们会为索引状态贡献额外信息就够了。
插曲结束,回到 LSP 的各种坑。
只做一个编译器还不够
编译器本身会做上述"索引"以及更多事情。但它有个奢侈的特权:可以严格行事——代码不对就报错,然后编译失败。
LSP 做不到这一点。IDE 中的代码经常处于错误状态,因为用户正在输入中(当然,如果按传统方式编写的话),而 LSP 存在的意义正是帮助用户补全代码。LSP 不能声明“我不分析这段代码,因为它错误或不完整”。
因此,这场冒险始于解析:无论用户输入了什么,解析必须成功,并且我们必须根据手头现有的信息尝试解读当前状态。我们还得假设用户会打破规则:impl 块中可能存在两个同名方法,可能存在针对作用域内不存在的 trait 的 impl,或者代码本身就不完整。
关于解析机制以及为何需要 CST,你猜是谁已经写过相关文章了(没错,又是他!):1、2、3。
但解析只是问题的一部分。成功解析文件后,我们需要实际处理其中的错误/歧义,并将其转化为有用的信息。
假设文件末尾有一个完全正常的 fn fo。我们需要意识到,由于前一个 token 是 fn,用户的意图很可能是声明一个函数,此时我们甚至可以建议一个包含参数占位符和空函数体的代码片段。如果某个项不在作用域内,我们仍可能找到候选对象并建议添加 import。你大概明白意思了。
所以这是 LSP 的另一条准则:你必须考虑当前状态是暂时错误的且可以被改善的。能走多远完全取决于你的想象力。再一次,你是在尝试猜测用户意图,而非在代码正确的严格世界中工作。
但挑战不止于此!你面对的不仅是错误代码。用户依赖的工具远不止编译器:他们用 cargo,用 rustdoc,还会用 markdown 写文档。作为工具开发者,你必须知道如何解析 cargo 的 JSON 输出来提取诊断信息,记住 rustdoc 支持消歧符,能够提取并运行用户编写的测试,等等。
这里更多是广度而非深度的拓展:你需要考量用户所使用的整套工具链,并做到极致,让工作流感觉"流畅",让你的 LSP "恰如其分"。
光标:LSP 的神
现在我们有了 LSP 服务端、索引状态,还有一堆关于工具链的额外知识。终于要触及 LSP 的核心部分了:光标。
编辑器中的任何操作都基于光标:即文件中需要 LSP 处理的位置。它可能是鼠标指针(例如用于悬停提示),也可能是输入位置(例如用于代码补全)。
有趣的问题在于,如何从"我需要在此处获取悬停信息或补全"过渡到"这个位置到底有什么"?
惯例上,matklad 有一篇绝佳的博文,介绍了 rust-analyzer 如何找到光标下的符号。简而言之,rust-analyzer 寻找的是与语义元素匹配的语法节点对应的源码。这种方法在结合懒加载分析和依赖解析器基础设施进行重构时效果极佳(至少这是我的理解)。
有意思的是,Rust Glancer 在这个问题上采取了几乎相反的立场。那篇文章认为基于 span 的方案一是太慢,因为 LSP 尽量只做最少的分析;二是不利于重构。言下之意还有第三点:语义分析结果可能没被计算出来,但当前文件的语法树永远是现成的。而 Rust Glancer 正好相反:它默认做完整分析并放到文件系统上,同时主动驱逐语法树来释放内存。由于手头随时有完整的语义分析,基于 span 的方案配合分层结构效果相当不错:比如你可以先过滤掉不匹配的文件,再过滤掉不涉及光标位置的函数体,最后遍历函数体内容,找到 span 最精确的那个源代码符号。这种做法确实换不来重构上的好处,所以 Rust Glancer 把重构实现为一组专用算法,不依赖 rowan 之类的东西——这明显不够优雅,但实际效果还不错。另外,不知为何,重构这部分虽然非常重要,占用的实现逻辑却并不多。我还不能确定是否应该把它作为整体架构的基础(就 Rust Glancer 而言;每个项目显然可以自行决定)。
但这只是问题的一半。有时候,仅仅弄清我们身处何处是不够的,最典型的例子就是代码补全。搞清楚我们身处何处之后,还需要给出一份在当前上下文中有意义的候选列表。而正如通常的情况那样,需要补全的位置代码往往是不完整的,这让猜测变得更困难。
做补全时,我们关心的与其说是光标正下方的精确位置,不如说是光标周围的情况。比如:
- 光标是不是紧跟在点号后面?那就需要做点号补全:先弄清点号前面符号的类型,再找出匹配的方法。
- 光标是不是紧跟在
::后面?那可能是关联项、use 路径或限定路径,所以需要检查::前面的内容,有时还要给出不同的候选选项。 - 你是否在结构体初始化器内部?比如
User { na$ }?那么从这个结构体获取字段。或者,如果是User { name: fo$ },那就获取匹配的局部变量。 - 在空文件里,它只是一个
f吗?那么fn关键字(或fn片段)可能适用。
在实践中,这变成了一大堆你想支持的特殊情况。这里的创意可以无限展开:比如,你可能会考虑 crate 的 edition,来决定是否要建议 await 关键字。
又一次了,这变成了一场猜测用户意图的游戏,你猜得越准,用户体验就越好。
但这还不是全部
这篇长文够长了吧?我还可以写得很长。
希望读起来不像是些零散的轶事,因为我的意图是表明,审视 LSP 的视角太多,而且每个视角都有多种实现方法。
这与编译器或 cargo fmt / cargo deny 之类的工具形成了鲜明对比:这些工具在目标上相当确定,预期的行为也大体上清晰且可配置。当用户调用这些工具时,他们确切知道需要什么,调用本身即是意图的体现。
LSP 更像是一场猜测游戏。在每一步,你面对的只是一种可能的错误状态,而你的目标是猜测什么对用户才是合理的。
这很难,但也很有趣!
附注:Rust Glancer 本身已经相当强大,欢迎去试用!如果你想支持这个项目,可以考虑给它点个星标(前提是你确实喜欢/觉得有趣!)和/或关注我的推特(我会在那发布 Rust Glancer 的公告和新文章;我也计划偶尔发布一些关于 Rust 的有趣内容)。我不需要金钱支持,但 Rust 语言本身需要,所以我强烈建议改为赞助 Rust 基金会。