为什么构建Rust LSP很难 · Rust Glancer
很久很久以前,强大的 matklad 写过一系列精彩文章,讲 Rust 工具链是如何运作的。那是一段美好的时光,可惜最后一篇 rust-analyzer 博客停留在 2023 年。
中文
复制
很久很久以前,强大的 matklad 写过一系列精彩文章,讲 Rust 工具链是如何运作的。那是一段美好的时光,可惜最后一篇 rust-analyzer 博客停留在 2023 年。
我不是 matklad,但我做 Rust Glancer 这个实验性 Rust LSP 已经有一段时间了。这大概是我做过的最有意思、最有野心的项目,我想分享一些在开发过程中学到的东西。

这会是一篇(希望还算连贯的)关于 Rust LSP 如何运作的故事,视角同时来自 rust-analyzer 和 Rust Glancer:看似简单的事情其实很难,看似困难的事情其实更难,而我完全没想到会存在的东西居然真的存在。
显然,一篇博客不可能覆盖所有内容,这会是一篇非常技术性但偏架构层面的概览,而不是对沿途提到的某个具体话题的深入剖析。那些会作为单独的文章发布——前提是我不犯懒。
另外,请做好心理准备,会有很多轶事性的章节,它们有一个共同点:构建 LSP 意味着必须从部分信息中给出有用的答案。
免责声明:我不是构建 LSP 的专家,这篇文章的目的是让读者对 LSP 的内部机制和坑产生兴趣,而不是给出一个明确、正式的设计概览。我刻意避免使用编译器术语,很多地方用近似说法,关注整体含义而非精确性。文中有很多指向更详细、更精确资料的链接,推荐大家去看看! 另外,在准备这篇文章之前和期间,我读过大量 rust-analyzer 的代码,但我不是 rust-analyzer 的维护者;如果哪里说错了——抱歉。
LSP 从哪里开始?
LSP 服务器有两个相反的端点:一个是实现语言服务器协议的服务器(也就是说,“有这么一个东西,我可以向它发送请求并收到格式良好的响应”),另一个是我们想要服务的实际状态(也就是说,“发出去的查询确实做了该做的事,并且运行在某种已索引的状态之上”)。前者看起来已经是解决好的问题了吧?尤其是有 tower-lsp-server 存在的情况下。嗯,并不是。我们就从这里开始,等真正需要索引的时候再逐步讲到它。
LSP 始于客户端发送一个 initialize 请求,它要求你初始化 LSP(嗯)。在你响应之前,客户端什么都不会做。一旦你响应了,它会发送一个 initialized 通知,一切顺利,LSP 通信就此开始。
问题在于:你什么时候响应这个请求?服务器刚启动时,你一无所有。你对项目一无所知,只有收到这个请求时,你才知道我们讨论的是哪个代码库。而要真正回答任何查询,我们需要对它“index™”。我们还不知道索引意味着什么,但这肯定是个大工程。
我们要阻塞到把所有东西都索引完吗?那用户就要享受 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 假定自己是唯一真相来源,但你仍然需要自己访问文件系统,而且要以同步的方式进行。更妙的是,编辑可能发生在编辑器之外,而客户端未必会忠实地通知你这些事件。这就是为什么我们需要虚拟文件系统,以及“源代”(source generation),也就是当前正在执行的请求所对应的源代码状态的标识符。如果我们试图天真地把文件系统访问和 LSP 通知结合起来,整个项目就会变成一个永无止境的竞态条件。所以我们把项目源码加载到内存中,将其声明为 VFS,然后尽最大努力把任何变更应用到这份已加载的状态之上;每次状态改变,我们就更新源代,从而保持内部状态一致(并在进行中的查询失效时取消它们)。
“什么进行中的查询?”我听到你问了。这就是第二个有趣的事实。执行一个 LSP 查询可能涉及惊人的工作量,而且并非所有查询生来平等。查找一个符号的引用是相当不平凡的任务,而 hover 通常很廉价。因此一次只做一个查询不是选项,你需要并行执行读查询。而一旦状态发生变化,当前正在运行的查询就会基于已经过时的状态做无用功。你的任务是构建一个循环,把变更查询和非变更查询分开,让读请求并行运行,并在状态变化时取消工作。另外,如果你不幸用了 async,还要把传入的消息串行化,确保你的 didOpen 和 didChange 不会以相反的顺序到达——那调试起来可真是要命(我很好奇我为什么需要写这条备注)。
第三个、也是最后一个有趣的事实是,LSP 的作者其实考虑过你可能还没准备好,所以给了你一些趁手的工具来应对,比如 workspace/inlayHint/refresh 这个服务端请求。有了这么强大的工具,你可以说“哎呀,现在再试一次吧”,然后把_真正的_响应发出去,哪怕一开始你什么都没发。问题在于,并不是所有东西都能刷新。文档符号就不行;如果你没有立刻把它们发出去,它们就会一直是旧的,直到客户端自己决定该重新问一次。也就是说,对某些查询,你可能得想点别的办法。 不过我们跑题了。客户端还在等着 inlay hints 和文档符号。 而我们到现在连一个东西都还没索引。 怎么办?
服务器,随时待命
运气不错:要响应文档符号请求,我们其实只需要索引一样东西——当前打开的文件。
这恰好也是 LSP 能很快派上用场的完美例子。要响应这个请求,你只需要解析文件。AST(更准确地说是 CST,这个我们后面再说)就能告诉你文件里有哪些结构体、trait、函数、方法等等。你甚至可以在请求处理过程中按需解析。
在 fn foo(a: Bar) {} 中对一个 Bar 做 textDocument/hover 就稍微麻烦一点:它需要某种形式的语义分析。你得知道光标下的东西从哪来。要做到这一点,你得先搞清楚当前作用域里有哪些条目(结构体、方法之类的)可用。为此,你需要定义映射:为每个 crate 和模块构建解析后的“从哪里能看到什么”的映射。而要得到定义映射,你还需要额外一层 lowering。你当然可以继续在 AST/CST 层面干活,但那不会方便。你多半会想要一棵条目树,也就是你自己对每个文件中定义的条目的表示。于是流程变成:解析每个文件 -> 从 AST/CST 构建条目树 -> 解析模块并构建定义映射 -> 检查光标下有什么可见 -> 通过定义映射找到它 -> 提取解析后条目的文档 -> 展示出来。如果你在想“光标下”是什么意思,这个问题问得好,奖励自己吃点好吃的吧,我们后面会回来讲。眼下,这是相当不少的额外工作,但还算应付得来。
Inlay hints 明显更麻烦(悬停在函数体内部,比如局部变量上,也一样)。它们出现在函数体里。在 fn foo() { let a = bar(); } 中,我们可以说 fn foo 是条目声明,而 { let a = bar(); } 才是真正可怕的函数体部分。注意,在上面描述的语义模型里,我们压根不关心函数体。不仅如此,要做好 inlay hints,我们至少还需要类型推断。这个我现在拒绝展开。
但如果你以为到这就结束了,那就来见识 LSP 的最终 boss:textDocument/references。inlay hints 只需要分析单个文件里的函数体。而 references 呢,你在 VS Code(或任何不知为何用了同样快捷键的编辑器)里对着一个函数定义按下无辜的 option+shift+F12,可怜的服务器就得在整个工作区图里所有可发现的位置找出这个函数的所有使用。想想 Option。我们要飞快地扫过成千上万个函数体,还得在其中区分正是这个 Option 和任何其他叫 Option 的条目。这就带来一个更大的问题:就算你手头已经分析好了所有函数体,你多半也不想线性地全部过一遍,看有没有谁碰巧提到 Option。于是 LSP 特有的花招登场了:你可以用文本匹配构建一个引用搜索计划,先找出可能包含这个标识符的文件子集,然后只在这些文件里遍历函数体。即便如此,工作量仍可能很大。如果你想知道“引用搜索计划”是什么,rust-analyzer 有一篇很好的文章(膜拜伟大的 matlkad!)。
旁注:如果你在想“嗯,是啊,Option 确实有很多文本匹配,但那是病态情况”……构建 LSP 全是病态情况,它们会毁掉用户体验,这也是 LSP 难做的原因之一。
这里有两点很重要:
- 索引本身是分层的,这些层构成一个顺序,各层之间的边界基本已经确定。
- 不同的查询对精度、对代码库了解程度的要求不同。
而 LSP 拥有的自由之一,就是如何利用这些信息。 rust-analyzer 和 Rust Glancer 在技术上都具备 parsing / item tree / defmaps / semantic layer / body layer(这里我用的是 Rust Glancer 的术语,但我认为熟悉 rust-analyzer 的人一看就知道哪个是哪个),但两者计算这些数据的方式不同。 rust-analyzer 使用 salsa:一个增量数据库。这意味着你可以定义输入,以及从输入得到输出的逻辑,输出则被惰性计算并记忆化。一旦某些输入发生变化,只有输出中相关的部分会失效并重新计算。rust-analyzer 的模型很_优雅_:根本没有索引这回事。存在的只是一张输入与代码库状态之间的关系网,因此在任何时刻你都可以询问_状态_,salsa 会确保它已经为你算好。除了直接问到的东西,它不需要去“索引”别的什么。说实话,salsa 感觉像魔法,如果你不熟悉它,我强烈建议花几个晚上了解一下,你会变得不一样(另见 Durable Incrementality 和 salsa 文档)。但即便有 salsa,也还是需要一些花招。如果每个查询都只计算必要的东西,那么即使有记忆化,也会有_大量_状态没有被计算,编辑器一开始可能会感觉卡顿。所以 rust-analyzer 默认启用 cache priming(没有相关的博客文章,但这个 PR 就是最新水平!),它基本上会为整个工作区做索引,一直做到 semantic layer,因为这很可能是你随时都需要手边有的信息。body 可以等到真正需要时再说。 Rust Glancer 不一样。它的重点是低内存和编辑器瞬间重启,这两者是相辅相成的。Rust Glancer 希望尽可能急切地多做工作,尽量把_所有东西_一次性索引完,然后把状态卸载到文件系统,这样初次索引之后你就不需要再计算多少了。但这里同样需要花招!完整索引要花很多时间,所以首先,Rust Glancer 在语义分析的相关部分一完成就开始回答查询(还记得 cache priming 吗?逻辑类似),而对于 body,它会优先处理当前打开的文件。与 rust-analyzer 相比,初次索引会花更多时间,而且(目前)可能占用更多内存,因为它很急切、做的工作更多,但之后你基本就完事了。如果有东西变了,你只更新相关的部分。如果编辑器重启,状态仍然在文件系统里,索引几乎是瞬间完成。只有在少数情况下才需要完整重新索引(比如工作区图发生变化时)。 回到查询:我们的 LSP 现在确实拥有了它想要提供的状态,而这个状态要么就绪,要么未就绪。只要引擎尚未就绪,它就可能给出一个不精确但尽可能有用的答案,而且在很多情况下,它能够请求客户端在状态计算完成后刷新结果。 LSP 就是这么工作的!感谢阅读!不过……
一个工作区,两个工作区
我敢打赌你注意到了第一章里的“(工作区?)”,并且肯定从那时起就在想,我为什么写得好像打开的文件夹一定对应一个单一的 Rust 工作区。因为它当然不是。打开的文件夹里可能包含 5 个文件夹,其中 3 个是 Rust 工作区,2 个不是。打开的文件夹可能是某个工作区里的一个 crate。打开的文件夹可能有一个 Rust 文件,但根本不是 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 在工作区发现上尽量积极,如果启用了缓存预热,效果会相当明显。同样,它也会尽量帮忙,为了给用户好的体验,必要时会跑到项目目录之外。
Rust Glancer 在这方面的立场几乎相反:它_要求_ Cargo.toml 在作用域内才会运行分析,而且在你真正打开工作区之前不会开始索引。这让它更懒、更严格:它不替用户猜,也尽量不越出用户给定的范围。不过反过来说:它仍然会检查 cargo registry。策略不可能完全纯粹。
但问题不止于此。想象一下,文件夹里有两个 Rust 工作区。如果一个是良构的,另一个不是呢?奇怪的地方在于,LSP 本身并没有给你多少工具来区分它们。在 rust-analyzer 里,只要有一个工作区因为任何原因无法处理,整个服务器就会进入错误状态,并在 VS Code 状态栏里标红。哪怕其他 crate 还能正常工作!但话说回来,rust-analyzer 仍然用单个进程管理所有工作区(这也是 salsa 的一个好处,它让这种模型显得很自然),所以只要有一个 crate 能让 rust-analyzer 崩溃,它就是全局崩溃。
Rust Glancer 则另辟蹊径:把 LSP 服务器本身只当作一个路由器,每个工作区都建模为独立的进程(engine)。LSP 服务器可以按需启动 engine,与它们之间有一套自己的通信协议,而且任何一个编辑器崩溃都不会导致全局崩溃。额外的好处是有助于降低内存占用:不同 engine 的数据互不混杂,减少了碎片(因为大量生命周期各异的分配正是内存碎片的来源)。但它也有自己的缺点:实现明显更绕,而且总体上与 LSP 的设计相抵触。状态上报也需要不少花招。
这里有用的教训是,即便 LSP 本身是一个定义良好的协议,它仍给实现留出了充足的空间,让它们决定具体想怎么工作、如何理解用户意图。两种做法本身都谈不上对或错。要优先考虑什么,由你自己决定。而用户对什么才是对的,确实各有各的看法。
你其实并不想要 LSP
到目前为止,关于 LSP 我们已经学到了多少有趣的事实?好吧,接下来是下一个。
LSP 定义了一套_协议_,而协议往往很奇怪,是为特定领域内的通信做过优化的。这个领域显然就是编辑器。它说话不用字节偏移或字符索引,而是用行和列。更麻烦的是,协议要求你的服务器懂得说 UTF-16。谁不爱 UTF-16 呢?
问题在于,首先,用行、列和 UTF-16 来干活实在不太方便。你大概会想要某种协议桥接层,让服务器本身用偏移和 UTF-8 工作,只在真正与协议通信的边界附近才转换这些值。不过这很正常,而且无论面对什么协议,这都算得上是最佳实践。应用的领域模型不必等同于协议的领域模型,两者同构就够了。
然而_问题来了:如果你平时都用偏移,那怎么把它们转换成行和列?每次都要解析文件的全部文本、按行切分、再挪动偏移,呃,稍微有点低效。虽然协议的那套领域模型在你的表示里并非_必需,你仍然需要工具来让转换变得高效。比如,为每个文件建立行索引。rust-analyzer 和 Rust Glancer 都是这么做的。
有意思的地方在于,即便你想把 LSP 抽象掉,也做不到彻底;它终究会渗进你的架构里。
元数据也还不止于此。要正确解析文件,你还得知道它的 edition。否则你无从判断 gen 是标识符还是关键字。也就是说,我们没法真正孤立地分析一个文件:要正确解析它,我们甚至需要 Cargo.toml(或其他形式的项目元数据)。
如你所见,对元数据的需求来自四面八方:LSP、文件内容、Rust 本身。从某种意义上说,挺有意思的——连解析这样简单的操作,也不得不带上状态。
索引 wen
好吧好吧,文章已经这么长了,索引却还只是简单带过,而它本该是最难的部分。 问题是,索引确实是最难的部分,说实话,它值得拥有自己的一系列同等篇幅的文章。但为了不让我们的 LSP 之旅留下空白,还是先做个高层概览吧。 首先,一个重要的点:LSP 可以有根本不同的设计,处理索引的方式也可能不同。rust-analyzer 博客里又有一篇好文章讲了这件事。简而言之:
- 第一种:分为“完整分析”和“浅层分析”两个阶段,完整分析检查大量内容,浅层分析则很快,按文件工作。Rust Glancer 等项目采用的就是这种方式。
- 第二种:利用编译器替你干活,并对其状态做快照。虽然有点牵强,但可以说第一个 Rust LSP——RLS——就是这么工作的。这种方式对某些语言有效,尤其是基于头文件的那些,但对 Rust 来说被证明非常低效。
- 第三种:做成增量式/基于查询的。让 LSP 只计算足以回答某个查询的数据,不用多考虑其他东西。rust-analyzer 借助 salsa 就是这么工作的。
这些方式决定了索引如何执行。不过索引的各个阶段大概都差不多。对 Rust 来说是这样的:
- 解析(我认为词法分析是解析的一部分):把输入文本转换成 CST 表示。
- 构建 item tree:提取出作为后续索引阶段输入的信息。CST 有用,但层次太低。你想知道的是自己有哪些 item,比如“这是一个 struct,有这些字段、这段文档注释、这些 attribute,可见性是这样的”,而不是“一个 struct 节点,带 N 个带标签的子节点”。
- 构建 definition map:存在哪些模块,它们包含什么?这个模块导出了什么?从这个模块能到达什么(包括:“这个是以别名导入的,所以我们必须解析出原始的 import,并让它在模块内以别名可见”)?
- 宏解析:宏很有意思。它们会展开出更多代码,而这些代码同样必须被分析。而且它们还能把更多 item 甚至模块带入作用域。在展开自身之后(这本身有一堆怪癖),我们需要确保展开会改变 defmap 的状态,因此把宏解析做成 defmap 构建过程的一个子阶段就很方便。
- 构建 item index:构建完 item tree 之后,我们可能对每个结构体和每个 impl 块都有了表示,但它们之间怎么关联?
impl Foo和crate::a::Foo还是crate::b::Foo有关?我们需要一个阶段来创建“已链接的 item 状态”——我们有哪些唯一的 item,哪些 impl 对应什么,哪些 trait impl 对应哪个 trait 和哪个 trait 实现者。在这里建索引尤其重要:能枚举出一个结构体的 item 是必不可少的,所以虽然理论上我们可以用未链接的 item tree 干活,但那既不高效也不愉快。 - 函数体解析。以上所有内容都完全不关心函数体,而且包含了不少有用信息,但任何程序真正有用的部分恰恰是函数体。而对函数体,我们需要解析所有的语句/表达式/模式,分配所有的绑定(比如被赋值的变量),声明作用域(哪些绑定在哪里可见),把以上全部链接起来,然后进行类型推断和 trait 求解。后两者才是吓人的部分。
索引结束时,无论我们分析的是整个工作区,还是只做了够回答单个查询所需的工作,最终都会得到_索引状态_:它表示项目中声明了哪些东西,让我们能回答这个变量是什么类型、它有哪些可用方法、这个结构应该展示哪份文档,等等。
关键在于,索引并不一定要把一切都过一遍,最终形态由我们想处理的查询决定,而不是由我们能从代码库中推导出的全部理论信息决定。
可惜,索引并不像上面说的那样线性。以 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。所以索引里有一大堆“固定循环”:只要还能拿到新信息就反复分析,一旦没有新信息(或循环次数用尽)就停下来。
函数体的分析同样是递归的:函数体_本身_可以包含 item、宏、impl,它们又可以有函数体,里面再包含 item、宏、impl,如此往复。你懂的。每个函数体也有自己的 defmap,有自己的固定循环、函数体局部 item 的索引,以及对这个函数体内各个函数体的分析。
还有。类型推断。trait 求解。抱歉,这些要留到下一篇博客再揭晓。这里我们聊的是 LSP 本身,对此只需知道这两者会为索引状态补充额外信息就够了。
插曲结束,回到 LSP 的各种怪癖。
只当编译器是不够的
编译器自己会做上面这些“索引”,而且做得更多。但它有一个奢侈之处:它很严格。代码不正确,它就可以冲你吼,然后让编译失败。
LSP 做不到。IDE 里的代码经常是不正确的,因为你正在敲(好吧,如果你用的是_老派方式_),而 LSP 的职责就是帮你把它写完。LSP 不能说“我不分析这段代码,它不正确或不完整”。
于是冒险从解析开始:无论用户敲了什么,解析_必须_成功,我们只能用手头的信息去尽力解释当前状态。我们还必须假设用户会破坏规则:impl 块里可能有两个同名方法,可能有一个 impl 指向作用域里不存在的 trait,或者代码干脆就是不完整的。
解析这部分以及为什么需要 CST,已经有人写过了(你猜是谁——没错,又是他!):1、2、3。
但解析只是问题的一部分。文件解析成功后,我们还得真正处理这些不正确/有歧义的地方,把它变成有用的东西。
想想文件末尾一个再正常不过的 fn fo。我们要做的是意识到,既然前一个 token 是 fn,那用户的意图很可能是声明一个函数,于是我们就能建议一个代码片段,生成带参数占位符和空函数体的函数声明。如果某个 item 不在作用域里,我们仍可能找到候选并建议添加 import。你明白意思。
所以这是 LSP 的另一条准则:你必须考虑到当前状态_不正确_,而且_可以改进_。能做到什么程度,全看你的想象力。你又一次是在猜用户意图,而不是在正确代码的严格世界里工作。
但这还没完!你要处理的不仅是不正确的代码。用户用的不只是编译器:他们还会用 cargo、rustdoc,用 Markdown 写文档。作为工具作者,你得知道如何解析 cargo 的 JSON 输出以提取诊断信息,得记住 rustdoc 支持消歧符,得能提取并运行用户的测试,等等。 这与其说是深度扩展,不如说是广度扩展:你需要考虑用户使用的工具,并做好一切必要的工作,让整个流程感觉“顺畅”,让你的 LSP“自动把事办了”。
Cursor:LSP 之神
现在我们有了 LSP 服务器、索引状态,还有一堆关于工具链的额外知识。是时候动一动 LSP 的核心了:cursor。你在编辑器里做的任何事都围绕 cursor 展开——文件里那个需要 LSP 做出响应的位置。它可能是鼠标光标(比如触发 hover),也可能是输入位置(比如触发补全)。
有意思的地方在于:从「我需要这个位置上的 hover 信息/补全」到「这个位置上到底是什么」,中间是怎么走过去的?
和往常一样,matklad 有一篇很好的文章,讲 rust-analyzer 如何找到光标下的符号。简单说,rust-analyzer 通过匹配语义元素的 syntax node 来定位来源,这和惰性分析的路子、以及把重构建立在 parser 基础设施之上的做法配合得很好(至少我是这么理解的)。
有意思的是,Rust Glancer 在这里几乎站在了反面。那篇文章说,基于 span 的做法 a) 太慢,因为 LSP 会尽量少做分析;b) 对重构来说也没那么方便。隐含的 c) 是:分析结果可能还没算出来,但当前文件的语法树总是现成的。Rust Glancer 恰好相反:默认做完整分析,把结果落到文件系统上,同时会主动驱逐语法树来腾内存。手里有完整的语义分析,基于 span 的做法配合层级结构就相当好用了:你可以(比如)先筛掉不匹配的文件,再筛掉不覆盖光标位置的 body,然后遍历 body 内容,找 span 最精确的那个源符号。这么做拿不到重构上的好处,所以 Rust Glancer 把重构实现成一组专门的算法,不依赖 rowan 这类东西——优雅程度差了不少,但看起来运转得还不错。另外,重构 这块虽然很重要,占的实现逻辑却意外地不多。它该不该成为整体架构的基础,我到现在也没想清楚(就 Rust Glancer 的用途而言;每个项目当然可以自己决定)。
但这只是问题的一半。有时候光知道 我们在哪 还不够,最典型的例子就是补全。知道了 我们在哪 之后,还得给出一份在这个具体上下文里说得通的建议列表。而需要补全的位置,代码往往本来就是不完整的,猜起来就更难了。
对补全来说,我们关心的与其说是光标底下究竟是什么,不如说是光标 周围 有什么。比如:
- 光标是不是正好在点号后面?那我们就需要做点号补全:先判断点号前面那个符号是什么类型,再找出匹配的方法。
- 光标是不是正好在
::后面?那可能是关联项、use 路径或限定路径,所以要检查::前面 是什么,有时还得给出不同的候选。 - 是不是在结构体初始化里,比如
User { na$ }?那就从这个结构体里取字段。如果是User { name: fo$ },就取匹配的局部变量。 - 空文件里是不是只有
f?那fn关键字(或fn片段)可能适用。
实际做起来,这就变成了一大堆你不得不支持的特殊情况。而且这里可以尽情发挥:比如你还可以把 crate 的 edition 也纳入考虑,决定要不要建议 await 关键字。
这又变成了一场猜用户意图的游戏,猜得越准,用户的体验就越好。
但事情不是这样的
这篇文章挺长的,对吧?我还能再写下去。
希望它看起来不像一堆前后矛盾的轶事,因为我的本意是想说明:可以审视 LSP 的角度实在太多,而每个角度下做同一件事又可以有多种做法。
这和编译器或 cargo fmt / cargo deny 这类工具形成了鲜明对比:它们的_目标_相当确定,预期行为或多或少是清晰的、可配置的。用户调用这些工具时,很清楚自己要什么,调用本身就是_表达意图的动作_。
LSP 更像一场猜谜游戏,每一步摆在你面前的都是一个可能并不正确的状态,而你要做的就是猜出什么对用户来说才合理。
这很难。但也很好玩!
P.S. Rust Glancer 本身已经相当能打,你可以去看看!如果你想支持这个项目,可以考虑给它点个 star(但前提是你确实喜欢它 / 觉得它有意思!),以及在 twitter 上关注我(我会在那里发布 Rust Glancer 的公告和新文章;也打算偶尔发一些 Rust 相关的有趣内容)。我不需要金钱上的支持,但 Rust 语言本身需要,所以我强烈建议你去赞助 Rust Foundation。