IDE理解Rust究竟需要什么?
免责声明: 本文使用基于 AI 的写作与沟通助手创建。在这些工具的帮助下,这场内容丰富的直播节目的核心主题被提炼为简洁的博客文章格式。
Rust IDE 是如何理解代码的?这是近期一场 RustRover 直播中探讨的核心问题。参与嘉宾包括 Zed 的 Rust 工程师、rust-analyzer 团队负责人 Lukas Wirth,以及 JetBrains RustRover 的工程师 Vlad Beskrovny。讨论并非比较编辑器或辩论偏好,而是聚焦于 IDE 分析 Rust 代码时实际发生的底层机制。
如果你错过了这场直播,可以在 JetBrains 电视台上观看完整录像。以下是本次直播关键问题与见解的结构化回顾。
Q1:Lukas 和 Vlad 是如何开始学习 Rust 的?
在深入编译器前端和 IDE 架构之前,直播从一个更个人化的问题开始:他们最初是如何接触编程的?有趣的是,Lukas 和 Vlad 都提到用 Java 为 Minecraft 编写模组是他们最早的编程经历之一。Lukas 在上学时就开始用 Java 为 Minecraft 写模组,进入大学后自学了 Rust。
“我上大学时自学了 Rust,之后基本上就不再用其他语言了。”
[LOADING...] Lukas Wirth rust-analyzer
Vlad 在 2014 年左右发现了 Rust,但直到加入 JetBrains 并从事 IntelliJ Rust 插件(RustRover 的前身)的工作后,才开始认真编写 Rust 代码。
Q2:为什么 Rust IDE 需要重新实现编译器的部分功能?
为了提供代码补全、跳转到声明、语义高亮和重构等功能,Rust IDE 必须像编译器一样深入理解这门语言。
“为了提供补全和跳转到声明等智能功能,我们基本上需要重新实现半个编译器,也就是整个编译器前端。”
[LOADING...] Vlad Beskrovny RustRover
那么为什么不直接复用编译器呢?编译器的优化目标是吞吐量——将源代码高效地转换为二进制文件。而 IDE 的优化目标是延迟——在开发者输入时,能多快地响应小型交互式查询。
“我输入一个点号,补全列表能多快出现?此时我不关心其他函数体、其他文件或项目中的任何其他文件。我只想让补全立刻出现。”
[LOADING...]
这种差异从根本上改变了架构。编译器倾向于急切且顺序地处理代码:解析所有内容、解析所有符号、展开所有宏、推导所有类型。而 IDE 则试图只计算当前交互所需的最少信息。
Q3:Rust 工具链是如何从 RLS 演进到 rust-analyzer 和 RustRover 的?
直播还回顾了 Rust 工具链的历史。在 rust-analyzer 出现之前,Rust 的主要语言服务器是 RLS(Rust Language Server)。RLS 试图直接基于编译器构建 IDE 功能,采用“保存分析”的方式:编译器生成包含语义信息的大型 JSON 输出,之后语言服务器再查询这些数据。在实践中,这种方法在延迟和处理不完整代码方面表现不佳。
“用这种方式几乎不可能实现补全功能,因为 rustc 几乎无法处理不完整的代码,而用户在需要补全时碰上的几乎都是这种情况。”
[LOADING...]
RLS 最终被 rust-analyzer 取代,后者采用了一种更注重 IDE 响应速度的增量式架构。
讨论还提到了 IntelliJ Rust 项目的起源,该项目后来演变为 RustRover。有趣的是,rust-analyzer 和 IntelliJ Rust 都起源于 Alex Kladov 的工作,尽管后来两个项目在架构上走向了截然不同的方向。
Q4:为什么 Rust 的名称解析如此困难?
Rust 的模块图是循环的,这意味着 IDE 无法像许多其他语言那样以简单的方式增量解析名称。Lukas 用一个嵌套重导出的链式例子进行了演示:解析单个符号需要追踪多个模块、别名和通配符导入,才能到达原始声明。为了支持这种工作流,IDE 需要反复:
- 收集模块
- 解析导入
- 展开宏
- 收集新生成的项
- 重复该过程,直到没有未解析的符号为止
这种重复过程常被称为“不动点迭代”。不幸的是,对于工具作者来说,宏使得这一过程更加复杂。
Q5:为什么过程宏对 IDE 来说如此具有挑战性?
理论上,过程宏只是一个将 token 转换为其他 token 的函数。实践中,过程宏是动态加载的库,它们可以:
- 访问文件系统
- 读取环境变量
- 执行任意代码
- 导致进程崩溃
- 或直接终止执行
“过程宏不止这些,它们是动态链接库。它们可以在宿主系统上做任何想做的事。”
[LOADING...]
这给 IDE 带来了巨大挑战。如果过程宏在 IDE 进程内部崩溃,可能导致整个 IDE 会话终止。为了避免这种情况,rust-analyzer 和 RustRover 都将过程宏的执行隔离到单独进程中,并通过自定义协议进行通信。
“如果过程宏真的硬崩溃或退出进程,最坏的情况我们只是丢失了一个过程宏服务器,可以重新启动它。但至少 IDE 还在运行。”
[LOADING...]
Q6:为什么 Rust 类型推导难以复制?
Rust 的类型系统带来了另一层复杂性。对工具作者来说,好消息是 Rust 的类型推导在函数体内部基本上是局部的,这使得增量分析成为可能。坏消息是 Rust 包含无数特殊的推导规则和边界情况,IDE 必须精确复制这些规则。
“Rust 类型推导的问题在于它包含了太多任意规则。我们需要一丝不苟地复制数以千计的任意规则。”
[LOADING...]
在直播中,他演示了几个例子,其中微小的结构变化完全改变了代码能否编译通过。有些行为甚至依赖于推导过程中表达式处理的内部顺序。这些细节直接影响编辑器功能,例如:
- 补全
- 诊断
- 导航
- 检查
- 以及提示信息
与编译器不同,IDE 即使在代码不完整的情况下也必须提供有用的语义结果。
Q7:RustRover 如何分析大型 Rust 项目?
RustRover 首先根据 Cargo 元数据和 crate 依赖关系构建项目模型。然后使用 PSI(程序结构接口)对项目文件建立索引。PSI 是 JetBrains IDE 中使用的抽象层。Vlad 说,PSI 可以由以下两者之一支持:
- 完整的语法树
- 或仅包含声明和签名的轻量级“桩”
这使得 RustRover 无需急切地完整解析每个文件,从而显著减少内存占用并提高响应速度。索引系统本身使用 MapReduce 风格的架构,文件被独立且增量地处理。
一个尤其有趣的细节是,在索引期间,RustRover 可以在某些阶段跳过解析函数体,因为桩只需要声明和签名。
“在索引期间,我们根本不解析函数体。”
[LOADING...]
相反,RustRover 可以通过词法分析和计数花括号来高效地遍历文件结构,从而显著加快索引速度。更广泛的观点是,现代 IDE 不能纯粹是惰性的。在某些时候,它们仍然需要急切分析。
“IDE 设计的真正艺术在于把这个分界线画在正确的位置。”
[LOADING...]
Q8:rust-analyzer 采用什么不同的方法来解决同一问题?
虽然 RustRover 严重依赖索引基础设施,但 rust-analyzer 使用查询驱动的架构,其灵感来源于 Rust 编译器本身。语义操作被建模为带记忆的、依赖追踪的查询,使用 Salsa 框架实现。
“rust-analyzer 中所有语义上有趣的部分都被封装在所谓的查询后面。”
[LOADING...]
这使得 rust-analyzer 可以仅使受编辑影响的精确语义信息失效并重新计算。不必要的依赖可能会在每次按键时意外地触发计算失效,这使得性能优化变得非常微妙。
Lukas 解释了 rust-analyzer 内部使用的多层垃圾回收和内存优化技术,包括:
- LRU 查询缓存
- 符号驻留
- 以及用于类型内部的自定义标记-清除跟踪收集器
Q9:IDE 分析如何与调试关联?
Vlad 演示了 RustRover 如何将语义分析直接集成到调试工作流中。RustRover 的调试器使用定制的 LLDB 集成以及 IDE 生成的 MIR 表示来评估表达式。当开发者在调试会话中评估表达式时,RustRover 会为相关表达式图生成 MIR,序列化该 MIR,并通过调试器后端进行解释。
这是一个强有力的例子,表明现代 IDE 越来越不像文本编辑器,而更像围绕语言构建的完整语义环境。直播以一个快速的观众提问结束:关于调试异步 Rust 工作流,以及 RustRover 是否有可能像 Rider 一样可视化 Tokio 异步任务。
Q10:Rust 工具作者是否暗中讨厌 Rust?
“通常,让语言用起来更愉快的功能往往会在实现端引入更多复杂性。”
[LOADING...]
Vlad 补充道:
“我热爱 Rust。否则你怎么解释我花了九年时间经历所有这些复杂性?”
[LOADING...]
同时,两人都承认,有些 Rust 功能在语言早期就已经引入,当时生态系统尚未完全理解它们对工具链的长期影响,特别是在过程宏方面。
如果你对 Rust 工具链、编译器内部原理、IDE 架构或语言设计权衡感兴趣,Lukas Wirth 和 Vlad Beskrovny 的完整讨论值得一看。