作者: 吴翱翔@pymongo / 后期编辑: 张汉东

原文: 基于编译器插件定制 clippy lint


本文基于 Rust 月刊先前文章 华为 | 如何定制 Rust Clippy

上述文章中提供了改 clippy 源码或 dylint 库的两个开发定制 lint 的方案,

但 dylint 不能跨平台且以动态库形式分发难以使用,改 clippy 源码不方便与官方 lint 同步

基于上述困难,我便有了以下 lint 框架的设计需求:

  1. 一定要跨平台,同时支持 windows/mac/linux 等主流操作系统
  2. 不要有任何依赖,dylint 依赖 clippy 导致 rust-toolchain 被迫绑定跟 clippy 一样的版本
  3. 代码足够简单 50 行足以加新的 Lint,不用任何宏导致 IDE 看宏代码时变成”瞎子”
  4. 定制的 lint 工具对 用户/使用者 而言要易于使用

首先用 rustc_private 编译器模块将自己 lint 框架的库编译为库(以下简称 lints 库),然后可通过三种渠道运行

  1. rustc plugin 编译器动态库补丁
  2. ui_test
  3. 改 rust 源码引入 lints 库并编译为 toolchains
  4. RUSTC=/path/to/my_rustc cargo check

RUSTC 环境变量

最简单的定制 lint 的方法,将自己写的 lint 注入到 rustc 中假设将自己修改的 rustc 可执行文件叫 my_rustc

然后运行 "RUSTC=my_rustc" cargo check 就能运行自己定制的 lint 的检查了

所以我们将问题简化为 如何改 rustc 源码 + 如何编译 rustc 两个部分(当然这么运行会有问题,后文再展开)

rustc_driver

如果说 rust 源码路径下的 compiler/rustc/src/main.rs 是最终的编译器二进制文件

那么 rustc_driver 模块就是整个编译器的入口和”大脑”,可以通过 rustc_private feature 引入这些编译器的模块

对比 rustc_driver 源码和 rustc 源码在 compiler/rustc/src/main.rs 里面也一行调用 rustc_driver::main()

发现只需要一行代码就能让 自己的代码编译出 rustc 编译器

  1. fn main() {
  2. rustc_driver::RunCompiler::new(&std::env::args().collect::<Vec<_>>(), &mut DefaultCallback).run().unwrap();
  3. }

rustc_driver::RunCompiler.run() 这就是 rustc 编译器的入口函数,我们将自己可执行文件的参数原封不动的转发给 rustc 就能让”编译器”跑起来

编译源码的子问题被简化为直接调用 RunCompiler,修改源码子问题转换成改 DefaultCallback 即可

如何引入 rustc_driver 库

  1. rustup component add rustc-dev 然后就可以用 feature rustc_private
  2. crates.io rustc-ap-rustc_driver 这个是 Rust 官方将编译器模块同步上传的版本
  3. 自己下载 rustc 源码通过路径引入或者通过 github 链接引入

trait rustc_driver::Callback

无论是官方的 clippy/miri 工具还是第三方的 flowistry(类似 rust-analyzer 的 Rust vscode 插件)或者 c2rust 等静态分析相关的项目

都是通过重写编译器回调 trait 的各个回调钩子方法,注入自己静态分析代码的逻辑

可以参考 flowistry 静态分析的这行代码:

https://github.com/willcrichton/flowistry/blob/2f0f843d46995367bf20f76b43315a7199bca70d/src/core/analysis.rs#L50

rustc_driver 找不到标准库

rustc_driver::RunCompiler::new 这样编译出来的 rustc 运行时报错找不到标准库

“can’t find crate for std

有个不完美的解决方案,在 build.rs 中加入一行,编译时”链接”标准库(以下为 Linux 示例)

println!(“cargo:rustc-link-search=/home/w/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/x86_64-unknown-linux-gnu/lib”);

build.rs 的 cargo:rustc-link-search= 默认库搜索类型是 all 也包含 Rust ABI 的 rlib 格式库文件

build.rs 方案 部分解决了「链接标准库」问题,某些时候也能正常运行,但 ldd 依然提示标准库找不到

  1. [w@ww lints]$ ldd ~/.cargo/bin/lints
  2. librustc_driver-e1b628cff3afb6ed.so => not found
  3. libstd-d6566390077dd5f5.so => not found

相比 build.rs 中链接标准库,更可靠的做法是运行时给 rustc 加一个 -L 参数链接标准库

例如以下是我 lint 框架的 ui 测试部分代码,我就是通过 rustc 运行时 -L 参数解决标准库找不到的问题

  1. let stderr = std::process::Command::new("cargo")
  2. .arg("run")
  3. .arg("--")
  4. .arg("--emit=metadata")
  5. .arg("--crate-type=lib")
  6. .arg("--allow=dead_code")
  7. .arg("-L")
  8. .arg(env!("STD_DYLIB_PATH"))
  9. .arg(rs_file_path)
  10. .output()

env!("STD_DYLIB_PATH") 来自 build.rs,具体可看我 lint 框架源码: https://github.com/pymongo/lints

即便运行时加上 -L 参数,但这样的 rustc 依然不容易进行 cargo build 或 codegen,建议仅用于分析代码

45 行代码写个编译器补丁/插件

例如我想添加一个检测函数名为 foo 的 lint 检查规则,首先 cargo 新建一个库:

cargo new —lib rustc_plugin_my_lints

然后在 Cargo.toml 将库的类型设置成 dylib

  1. [lib]
  2. crate-type = ["dylib"]

然后在 src/lib.rs 中写下以下代码:

  1. #![feature(rustc_private)]
  2. extern crate rustc_ast;
  3. extern crate rustc_driver;
  4. extern crate rustc_interface;
  5. extern crate rustc_lint;
  6. extern crate rustc_span;
  7. #[no_mangle]
  8. fn __rustc_plugin_registrar(reg: &mut rustc_driver::plugin::Registry) {
  9. reg.lint_store.register_early_pass(|| Box::new(FnNameIsFoo));
  10. }
  11. struct FnNameIsFoo;
  12. impl FnNameIsFoo {
  13. const LINT: rustc_lint::Lint = {
  14. let mut lint = rustc_lint::Lint::default_fields_for_macro();
  15. lint.name = "fn_name_is_foo";
  16. lint.default_level = rustc_lint::Level::Warn;
  17. lint
  18. };
  19. }
  20. impl rustc_lint::LintPass for FnNameIsFoo {
  21. fn name(&self) -> &'static str {
  22. "fn_name_is_foo"
  23. }
  24. }
  25. impl rustc_lint::EarlyLintPass for FnNameIsFoo {
  26. fn check_fn(
  27. &mut self,
  28. cx: &rustc_lint::EarlyContext<'_>,
  29. fn_kind: rustc_ast::visit::FnKind<'_>,
  30. span: rustc_span::Span,
  31. _: rustc_ast::NodeId,
  32. ) {
  33. if let rustc_ast::visit::FnKind::Fn(_, ident, ..) = fn_kind {
  34. if ident.as_str() == "foo" {
  35. rustc_lint::LintContext::struct_span_lint(cx, &Self::LINT, span, |diagnostic| {
  36. diagnostic.build("foo is a bad name for function").emit();
  37. });
  38. }
  39. }
  40. }
  41. }

然后新建一个 examples/test_plugin.rs 文件测试我们写好的编译器插件/补丁

  1. #![feature(plugin)]
  2. #![plugin(rustc_plugin_my_lints)]
  3. fn foo() {
  4. }
  5. fn main() {
  6. foo();
  7. }

最后可以运行下编译器补丁的测试:

cargo run —example test_plugin

然后得到了这样的输出:

  1. warning: foo is a bad name for function
  2. --> examples/test_plugin.rs:3:1
  3. |
  4. 3 | / fn foo() {
  5. 4 | |
  6. 5 | | }
  7. | |_^
  8. |
  9. = note: `#[warn(fn_name_is_foo)]` on by default

lints 框架的设计思路

由于 plugin feature 已经 deprecated 了,以后可能会被删掉,我的 lint 框架的设计思路是只做 lint 检查规则的功能测试和 ui 测试,定期集成到 rust 源码中编译一套自己的工具链做长远使用,同时提供 my_rustc 可执行文件或者编译器插件库进行使用

在我读了很多 rust-analyzer/rust-clippy/dylint/flowistry 等静态分析相关项目源码后设计了 lints 静态分析框架

这是 lints 项目源码的链接: https://github.com/pymongo/lints

以下是 lints 项目的源码结构和解读:

  1. .
  2. ├── build.rs
  3. ├── Cargo.lock
  4. ├── Cargo.toml
  5. ├── examples
  6. └── compiler_plugin.rs # 编译器插件的示例
  7. ├── README.md # crate lints 的文档
  8. ├── rust-toolchain.toml # 定义所需 rustup components
  9. ├── src
  10. ├── bin
  11. └── rustc_.rs # 用于 ui 测试,也能通过 RUSTC=/path/to/rustc_ cargo c 运行在简单项目
  12. ├── lib.rs
  13. └── lints # early_lint + late_lint 的定义和逻辑
  14. ├── check_enum_size.rs
  15. ├── fn_name_is_foo.rs
  16. └── mod.rs
  17. ├── tests # 测试代码
  18. └── ui_test.rs # ui 测试
  19. └── ui_test # ui 测试用例: 输入+输出+期待值
  20. ├── check_enum_size.rs
  21. ├── check_enum_size.stderr
  22. ├── fn_name_is_foo.rs
  23. └── fn_name_is_foo.stderr

添加一条 lint 检查规则非常简单只需在 src/lints 下加一个文件,定义一个新的结构体并注册在 src/lints/mod.rs 即可

最后,用 rustc plugin 插件不光能定制 lint 还可以做帮组源码中的 enum 排序等等有趣的事情