Contribute to Rust Analyzer

前言

我写 Rust 用的 IDE 是 VSCode 搭配 Rust Analyzer 插件,二者搭配起来使用体验还是相当不错的。Rust Analyzer 虽然只是 VSCode 生态下的一个插件,但实际上可以看成 Rust 语言的一个 LSP(Language Server Protocol) 完整实现,提供了诸如补全提示、代码诊断、引用查找、跳转到定义等 IDE 常用功能。Rust Analyzer 插件可以分为前端和后端,前端是代码仓库中 editors/code 路径下的 TypeScript 项目,其余部分属于后端服务实现代码,我在这次贡献中涉及的是后端服务部分,下文中如无说明默认讨论的是 Rust Analyzer 后端服务。

LSP 只是一个协议,并没有规定实现的形式。Rust Analyzer 由 Rust 语言开发,开发团队为此做了许多工作,比如用 Rust 实现了 Parser、lsp_server 等工具。此外,Rust Analyzer 还有详细的开发文档,为 new contributor 提供了许多指引说明,,是我见过的开源项目中开发文档最详细的项目之一,甚至还有一个 Rust Analyzer 核心开发者主讲的系列视频(虽然我看不太懂听不太懂 🙃)介绍 Rust Analyzer 的工作流程。如果没有这些资料,从上手理解这么一个庞大的项目到能提 PR 解决某个小问题对我来说应该要花费更多的时间精力,再次感慨详细的开发文档对于 new contributor 加入社区的重要性。

入坑

每天都在使用 IDE,我对于 IDE 是如何实现这些功能是比较好奇的,某天心血来潮去 Rust Analyzer 的项目仓库逛逛,浏览了一遍 issue,发现 Feature request: if x.is_some() -> if let Some(_tmp) = x 这个我能看懂的 issue,于是打算从这个 issue 入手,通过 learning by doing 的方式去学习一下如何开发 Rust Analyzer。这个 issue 描述的是希望在 Rust Analyzer 中增加一个 feature,使得 IDE 能够智能感知并为 Option 以及 Result 类型的某些方法提供自动改写功能,如以下代码所示。其中 ┃ 表示光标的位置,意思是当光标停留在某个特定的 context 位置的时候 IDE 能提供一个自动改写功能。

1
2
3
4
5
6
7
8
9
// before
if x.is_┃some() {
...
}

// after
if let Some(┃_tmp) = x {
...
}

过程

我第一版实现的效果是下面这样,当光标停留在 Option 或者 Result 对象的 is_some 或者 is_ok 方法的时候,通过点击小灯泡💡展示当下可选的功能,用户单击选择某个选项即可应用某项功能(在 Rust Analyzer 中这些点击💡后展示的功能称为 assist)。

一开始我完全不知道从哪里入手,粗略过了一遍文档之后也没有什么头绪。于是我转变了思路,在已经合并的 PR 里面找是否有完成了类似的事情的 PR,终于找到了 feat: Add unqualify_method_call assist 这个 PR,它同样是提供了当光标在特定 context 位置时的一项可选的 assistassist 是 Rust Analyzer 的后端服务通过分析当前光标所处的 context 位置所提供的一些“智能”功能,关于小灯泡💡assist Rust Analyzer 的官方博客中有一篇文章做了介绍,我也是在看了这篇博客之后才悟到原来这些 assist 是由服务端提供的而不是预先定义在客户端中的。

从这个参考 PR 中的 Files changed 中我知道了要增加一项 assist 需要在 crates/ide-assists/src/handlers 下增加一个对应的处理方法,然后注册到 /crates/ide-assists/src/lib.rs 中的 pub(crate) fn all() -> &'static [Handler] 方法,调用链如下。当光标停留的时候,Rust Analyzer 会检查所有在 all() 中注册的 assist 是否可用于当前的 context,如果可以则会将这项 assist 返回给客户端,对应的就是我们点击💡时看到的那些选项。

实现

Rust Analyzer 实现了一系列数据结构用于映射用户在 VSCode 中写的代码,参考其他的一些 assist,我在实现 replace_is_method_with_if_let_method 这个 assist 的思路如下:

  1. 首先检查光标当前所处的 context 位置是否是一个 if 表达式,因为我要实现的 assist 是改写 if x.is_some() {},所以要确保光标所处的位置是一个 if 表达式,这样才有必要进行后续的操作;
  2. 读取 if 表达式中的 condition 部分,检查该 condition 是否是一个方法调用(x.is_some()),如果不是那这就不是我要的操作目标,直接 return 即可;
  3. 如果是一个方法调用的话,这个方法调用的名字是否是 is_some 或者 is_ok,若不是则直接 return;
  4. 确保光标位置的 context 是我要操作的目标代码后,构造出替代后的代码语句实现代码替换。

得益于 Rust Analyzer 所做的一些抽象,只用 30 多行代码我就完成了 replace_is_method_with_if_let_method 这项 assist。思路其实很简单,而且 Rust Analyzer 也有许多 api 用于代码 context 检查以及信息提取(比如读取某个方法调用的发起对象名以及方法名等),完成的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
pub(crate) fn replace_is_method_with_if_let_method(
acc: &mut Assists,
ctx: &AssistContext<'_>,
) -> Option<()> {
let if_expr = ctx.find_node_at_offset::<ast::IfExpr>()?;

let cond = if_expr.condition()?;
let call_expr = match cond {
ast::Expr::MethodCallExpr(call) => call,
_ => return None,
};

let name_ref = call_expr.name_ref()?;
match name_ref.text().as_str() {
"is_some" | "is_ok" => {
let receiver = call_expr.receiver()?;
let target = call_expr.syntax().text_range();

let (assist_id, message, text) = if name_ref.text() == "is_some" {
("replace_is_some_with_if_let_some", "Replace `is_some` with `if let Some`", "Some")
} else {
("replace_is_ok_with_if_let_ok", "Replace `is_ok` with `if let Ok`", "Ok")
};

acc.add(AssistId(assist_id, AssistKind::RefactorRewrite), message, target, |edit| {
let replacement = format!("let {}({}) = {}", text, "${0:_tmp}", receiver);
edit.replace(target, replacement);
})
}
_ => return None,
}
}

我认为这次的难点不在于实现思路,而是在于理解项目,知道要在哪里做出什么样的修改,理解 Rust Analyzer 的哪些数据结构对应了用户侧写的代码。关于这一点可以参考 Rust Analyzer 的 Syntax tree 结构,在 VSCode 中写下 rust 代码,按下 ctrl + shift + p 之后在搜索栏中输入 Syntax tree,按下回车之后就能在编辑器右侧得到代码的语法树结构。比如 let x: i32 = 1; 这行代码对应的语法树结果是下面这样的。其中 PATH_TYPEPATH_SEGMENTNAME_REF 这些关键字对应了 crates/syntax/src/ast/generated/nodes.rs 中对应的 struct

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
LET_STMT@621..636
LET_KW@621..624 "let"
WHITESPACE@624..625 " "
IDENT_PAT@625..626
NAME@625..626
IDENT@625..626 "x"
COLON@626..627 ":"
WHITESPACE@627..628 " "
PATH_TYPE@628..631
PATH@628..631
PATH_SEGMENT@628..631
NAME_REF@628..631
IDENT@628..631 "i32"
WHITESPACE@631..632 " "
EQ@632..633 "="
WHITESPACE@633..634 " "
LITERAL@634..635
INT_NUMBER@634..635 "1"
SEMICOLON@635..636 ";"

细节

在 replace_is_method_with_if_let_method 的初版实现中,完成代码改写后光标并不会自动选中 _tmp 变量。我希望它可以,这样用户在选择应用这项 assist 之后不需要任何鼠标和键盘的操作就可以直接编辑这个变量名。同样的,一开始我不知道如何实现,翻看了其他 assist 的实现似乎也没找到这样的功能,于是问了 ChatGPT,得到了一个能 work 的方法,利用 VSCode 中的 ${0:_tmp} 表达式实现自动范围选择。不过我这里的实现是用粗糙的手动构造方式,或者 Rust Analyzer 里有封装好某个对应的 api 而我还没找到…

参考


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!