无需分叉Clippy即可编写Rust代码检查工具

本文介绍Dylint工具,它允许开发者从动态库加载Rust代码检查规则,解决了传统分叉Clippy方式存在的维护和运行难题,支持多编译器版本并共享中间编译结果。

无需分叉Clippy即可编写Rust代码检查工具

Rust代码检查与Clippy

像Clippy这样的工具利用了Rust编译器对代码检查的专用支持。Rust检查器的核心组件称为“驱动程序”,它链接到一个名为rustc_driver的库。通过这种方式,驱动程序本质上成为Rust编译器的包装器。

要运行检查器,需设置RUSTC_WORKSPACE_WRAPPER环境变量指向驱动程序,并运行cargo check。Cargo注意到环境变量已设置,会调用驱动程序而非rustc。驱动程序被调用时,在Rust编译器的Config结构中设置回调。该回调注册若干检查规则,随后Rust编译器会与其内置检查规则一同运行。

Clippy执行一些检查以确保其启用,但其他方面以上述方式工作。(Clippy架构见图1。)安装后可能不明显,但Clippy实际上是两个二进制文件:一个Cargo命令和一个rustc驱动程序。可通过以下命令验证:

1
2
which cargo-clippy
which clippy-driver

现在假设您想编写自己的检查规则。您需要驱动程序来运行它们,而Clippy有一个驱动程序,因此分叉Clippy似乎是合理的步骤。但此解决方案存在缺点,即在运行和维护您将开发的检查规则方面。

首先,您的分叉将拥有这两个二进制文件的副本,确保它们能被找到很麻烦。您必须确保至少Cargo命令在您的PATH中,并且可能必须重命名二进制文件以免与Clippy冲突。显然,这些步骤不构成不可克服的问题,但您可能希望避免它们。

其次,所有检查规则(包括Clippy的)都建立在非稳定的编译器API上。一起编译的检查规则必须使用相同版本的这些API。要理解为何这是一个问题,我们将参考clippy_utils——Clippy作者慷慨公开的实用程序集合。注意clippy_utils使用与检查规则相同的编译器API,同样不提供稳定性保证。(见下文。)

假设您有一个Clippy的分叉,想添加一个新的检查规则。显然,您希望新检查规则使用最新版本的clippy_utils。但假设该版本使用编译器版本B,而您的Clippy分叉使用编译器版本A。那么您将面临一个困境:您应该使用旧版本的clippy_utils(使用编译器版本A的版本),还是升级分叉中的所有检查规则以使用编译器版本B?两者都不是理想的选择。

Dylint解决了这两个问题。首先,它提供一个单一的Cargo命令,使您无需管理多个此类命令。其次,对于Dylint,检查规则被编译在一起以生成动态库。因此,在上述情况下,您只需将新检查规则存储在使用编译器版本B的新动态库中。您可以随意将此新库与现有库一起使用,并在选择时升级现有库到较新的编译器版本。

Dylint还提供了与重用中间编译结果相关的额外好处。要理解这一点,我们需要检查Dylint的工作原理。

Dylint的工作原理

与Clippy类似,Dylint提供了一个Cargo命令。用户向该命令指定要从哪些动态库加载检查规则。Dylint以某种方式运行cargo check,确保在控制权交给Rust编译器之前注册检查规则。

然而,对于Dylint,检查规则注册过程比Clippy更复杂。Clippy的所有检查规则使用相同的编译器版本,因此只需要一个驱动程序。但Dylint用户可以选择从使用不同编译器版本的库加载检查规则。

Dylint通过按需动态构建新驱动程序来处理这种情况。换句话说,如果用户想从使用编译器版本A的库加载检查规则,且找不到适用于编译器版本A的驱动程序,Dylint将构建一个新驱动程序。驱动程序缓存在用户的主目录中,因此仅在必要时重新构建。

这带来了上一节提到的额外好处。Dylint按编译器版本对库进行分组。使用相同编译器版本的库被一起加载,它们的检查规则一起运行。这允许中间编译结果(例如,符号解析、类型检查、特征求解等)在检查规则之间共享。

例如,在图2中,如果库U和V都使用编译器版本A,这些库将被分组在一起。编译器版本A的驱动程序仅被调用一次。驱动程序在将控制权交给Rust编译器之前注册库U和V中的检查规则。

要理解这种方法为何有益,请考虑以下情况。假设检查规则直接存储在编译器驱动程序中,而不是动态库中,并回想驱动程序本质上是Rust编译器的包装器。因此,如果在两个使用相同编译器版本的编译器驱动程序中有两个检查规则,在同一代码上运行这两个驱动程序将相当于编译该代码两次。通过将检查规则存储在动态库中并按编译器版本分组,Dylint避免了这些低效问题。

一个应用:项目特定检查规则

您知道吗?Clippy包含一些检查规则,其唯一目的是检查Clippy的代码。这是真的。例如,Clippy包含检查规则来验证每个检查规则是否有相关的LintPass,是否使用了某些Clippy包装函数而不是它们包装的函数,以及每个检查规则是否有非默认描述。将这些检查规则应用于Clippy以外的代码没有意义。但没有规则要求所有检查规则必须是通用的,Clippy利用了这种自由。

Dylint类似地包含主要目的是检查Dylint代码的检查规则。例如,在开发Dylint时,我们发现自己编写了如下代码:

1
2
3
let rustup_toolchain = std::env::var("RUSTUP_TOOLCHAIN")?;
...
std::env::remove_var("RUSTUP_TOOLCHAIN");

这是不良实践。为什么?因为迟早我们会错误输入字符串字面量:

1
std::env::remove_var("RUSTUP_TOOLCHIAN"); // 糟糕

更好的方法是使用常量而不是字符串字面量,如下代码所示:

1
2
3
const RUSTUP_TOOLCHAIN: &str = "RUSTUP_TOOLCHAIN";
...
std::env::remove_var(RUSTUP_TOOLCHAIN);

因此,在开发Dylint时,我们编写了一个检查规则来检查这种不良实践并提出适当建议。我们将(并且仍然)将该检查规则应用于Dylint源代码。该检查规则称为env_literal,其当前实现的核心部分如下:

 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
impl<'tcx> LateLintPass<'tcx> for EnvLiteral {
   fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &Expr<'_>) {
      if_chain! {
         if let ExprKind::Call(callee, args) = expr.kind;
         if is_expr_path_def_path(cx, callee, &REMOVE_VAR)
            || is_expr_path_def_path(cx, callee, &SET_VAR)
            || is_expr_path_def_path(cx, callee, &VAR);
         if !args.is_empty();
         if let ExprKind::Lit(lit) = &args[0].kind;
         if let LitKind::Str(symbol, _) = lit.node;
         let ident = symbol.to_ident_string();
         if is_upper_snake_case(&ident);
         then {
            span_lint_and_help(
               cx,
               ENV_LITERAL,
               args[0].span,
               "referring to an environment variable with a string literal is error prone",
               None,
               &format!("define a constant `{}` and use that instead", ident),
            );
         }
      }
   }
}

以下是它可能产生的警告示例:

1
2
3
4
5
6
7
8
warning: referring to an environment variable with a string literal is error prone
 --> src/main.rs:2:27
  |
2 |     let _ = std::env::var("RUSTFLAGS");
  |                           ^^^^^^^^^^^
  |
  = note: `#[warn(env_literal)]` on by default
  = help: define a constant `RUSTFLAGS` and use that instead

请注意,编译器和clippy_utils都不为其API提供稳定性保证,因此未来版本的env_literal可能看起来略有不同。(事实上,在撰写本文时,clippy_utils API的更改导致了env_literal实现的更改!)env_literal的当前版本始终可以在Dylint存储库的examples目录中找到。

然而,Clippy以略微不同的方式“自我检查”。Clippy的内部检查规则被编译到启用了特定功能的Clippy版本中。但对于Dylint,env_literal检查规则被编译到动态库中。因此,env_literal不是Dylint的一部分。它本质上是输入。

为什么这很重要?因为您可以为您的项目编写自定义检查规则,并使用Dylint运行它们,就像Dylint对其自身运行自己的检查规则一样。Dylint在Dylint存储库上运行的检查规则的来源并不重要。Dylint同样乐意在您的存储库上运行您存储库的检查规则。

底线是:如果您发现自己编写了不喜欢的代码,并且可以通过检查规则检测到该代码,Dylint可以帮助您清除该代码并防止其重新引入。

开始编写检查规则

使用以下命令安装Dylint:

1
cargo install cargo-dylint

我们还建议安装dylint-link工具以方便链接:

1
cargo install dylint-link

编写Dylint库的最简单方法是分叉dylint-template存储库。该存储库开箱即用地生成可加载库。您可以通过以下方式验证:

1
2
3
4
git clone https://github.com/trailofbits/dylint-template
cd dylint-template
cargo build
DYLINT_LIBRARY_PATH=$PWD/target/debug cargo dylint fill_me_in --list

您只需实现LateLintPass特性并处理需要填充的符号。

编写检查规则的有用资源包括:

还考虑使用上述clippy_utils crate。它包括许多低级任务的函数,例如查找符号和打印诊断消息,并使编写检查规则显著更容易。

我们衷心感谢Clippy作者向Rust社区提供clippy_utils crate。我们还要感谢Philipp Krones对本文早期版本提供了有益评论。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计