华为 | 如何定制 Rust Clippy (下篇)

1,255 阅读11分钟

接上篇

华为 | 如何定制 Rust Clippy (上篇)

自定义 Clippy lints

通过了解 Clippy 工作机制,可以看得出来,如果要自定义 Clippy lints,是需要严重依赖 rustc 版本的,因为 rustc_interface 提供的接口并不稳定。所以维护成本比较高。

如果一定要通过这种方式自定义 Clippy lints ,需要按以下步骤开发。

安装配置 Clippy
  1. 下载 Clippy 源码。
  2. 执行 cargo buildcargo test 。因为Clippy 测试套件非常大,所以可以只测试部分套件,比如,cargo uitest,或 cargo test --test dogfood。如果 UITest 和预期不符,可以使用 cargo dev bless更新相关文件。
  3. Clippy 提供了一些开发工具,可以通过 cargo dev --help 查看。

UI测试的目的是捕捉编译器的完整输出,这样我们就可以测试演示的所有方面。

测试正常的话,修改Clippy 生成二进制的名字,防止影响我们开发环境中安装的 Clippy命令。

  1. Cargo.toml中修改
[[bin]]
name = "cargo-myclippy" // 此处原本是 "cargo-clippy"
test = false
path = "src/main.rs"

[[bin]]
name = "clippy-mydriver" //  此处原本是 "clippy-mydriver"
path = "src/driver.rs"
  1. 修改 src/main.rs
.with_file_name("clippy-mydriver"); // 将使用 `clippy-driver` 的地方修改为 `clippy-mydriver`
起一个有意义的名字

定义 lints 需要先起一个符合 Lints 命名规范 的名字。

Lints 命名规范的首要原则就是:lint 名字要有意义。比如 allow dead_code,这是有意义的,但是allow unsafe_code这个就有点过分了。

具体来说,有几条注意事项:

  1. Lint 名称应该标明被检查的「坏东西」。比如 deprecated,所以,#[allow(deprecated)](items)是合法的。但是 ctypes就不如improper_ctypes 更明确。
  2. 命名要简洁。比如 deprecated,就比 deprecated_item更简洁。
  3. 如果一个 lint 应用于特定的语法,那么请使用复数形式。比如使用 unused_variables而不是unused_variable
  4. 捕捉代码中不必要的、未使用的或无用的方面的行文应该使用术语unused,例如unused_importsunused_typecasts
  5. lint 命名请使用蛇形(snake case)命名,与函数名的方式相同。
设置样板代码

假如新的 lint 叫 foo_functions,因为该lint不需要用到类型信息(比如某结构体是否实现 Drop),所以需要定义 EarlyLintPass。

在 Clippy 项目根目录下,通过以下命令创建 Lint:

cargo dev new_lint --name=foo_functions --pass=early --category=pedantic

如果没有提供 category ,则默认是 nursery 。

执行完该命令以后,在 Clippy-lint/src/ 目录下就会多一个 foo_functions.rs 的文件,文件中包含了样板代码:

use rustc_lint::{EarlyLintPass, EarlyContext};
use rustc_session::{declare_lint_pass, declare_tool_lint};
use rustc_ast::ast::*;

// 此宏用于定义 lint
declare_clippy_lint! {
    /// **What it does:**
    ///
    /// **Why is this bad?**
    ///
    /// **Known problems:** None.
    ///
    /// **Example:**
    ///
    /// ```rust
    /// // example code where clippy issues a warning
    /// ```
    /// Use instead:
    /// ```rust
    /// // example code which does not raise clippy warning
    /// ```
    pub FOO_FUNCTIONS, // lint 名字大写
    pedantic, // lint 分类
    "default lint description" // lint 描述
}

// 定义 lint pass。 注意,lint 和 lint pass 并不一定成对出现
declare_lint_pass!(FooFunctions => [FOO_FUNCTIONS]);

// 因为不需要使用类型信息,此处实现 EarlyLintPass
impl EarlyLintPass for FooFunctions {}


除了此文件,还会创建 test/ui/foo_functions.rs 测试文件。

接下来,需要执行 cargo dev update_lints 命令来注册新 lint。

添加 Lint pass 内容

先来写一些测试代码。

Clippy使用UI测试进行测试。UI测试检查Clippy的输出是否与预期完全一致。每个测试都是一个普通的Rust文件,包含我们要检查的代码。Clippy的输出与一个.stderr文件进行比较。注意,你不需要自己创建这个文件,我们将进一步讨论生成.stderr文件。

我们首先打开在test/ui/foo_functions.rs创建的测试文件。

用一些例子来更新该文件,以便开始使用。

#![warn(clippy::foo_functions)]

// Impl methods
struct A;
impl A {
    pub fn fo(&self) {}
    pub fn foo(&self) {}
    pub fn food(&self) {}
}

// Default trait methods
trait B {
    fn fo(&self) {}
    fn foo(&self) {}
    fn food(&self) {}
}

// Plain functions
fn fo() {}
fn foo() {}
fn food() {}

fn main() {
    // We also don't want to lint method calls
    foo();
    let a = A;
    a.foo();
}

可以使用 TESTNAME=foo_functions cargo uitest来执行测试。

可以看到输出:

test [ui] ui/foo_functions.rs ... ok

接下来,打开 src/foo_functions.rs 编写 Lint 代码。

declare_clippy_lint! {
    /// **What it does:**
    ///
    /// **Why is this bad?**
    ///
    /// **Known problems:** None.
    ///
    /// **Example:**
    ///
    /// ```rust
    /// // example code
    /// ```
    pub FOO_FUNCTIONS,
    pedantic, // 该类型的lint 等级 默认是 Allow
    "function named `foo`, which is not a descriptive name" // 修改 lint 声明的描述内容
}

可以通过执行 cargo dev serve在本地打开网页服务,可以查到 foo_functions显示的描述。

image.png

``Pedantic的默认lint 等级是allow`,定义于 github.com/rust-lang/r…

通常在声明了lint之后,我们必须运行cargo dev update_lints 来更新一些文件,以便 Clippy 知道新的 Lint。由于上面是用cargo dev new_lint ... 命令来生成lint声明,所以这是自动完成的。

虽然 update_lints自动完成了大部分工作,但它并没有自动完成所有工作。我们必须在clippy_lints/src/lib.rsregister_plugins函数中手动注册我们的lint pass

 
pub fn register_plugins(store: &mut rustc_lint::LintStore, sess: &Session, conf: &Conf) {
    // 此处省略 2000 行代码
		// foo_functions
    store.register_early_pass(|| box foo_functions::FooFunctions);

}

该函数有 2000 多行代码,维护起来可想而知多么麻烦了。

因为此 lint pass 只是检查函数名字,不涉及类型检查,所以只需要 AST 层面的处理即可。关于 EarlyLintPass 和 LateLintPass 的区别前文已经介绍过。EarlyLintPass 比 LateLintPass 更快一些,然而 Clippy 的性能并不是关注的重点。

由于我们在检查函数名时不需要类型信息,所以在运行新的lint自动化时,我们使用了--pass=early,所有的样板导入都相应地被添加了。

下一步就可以实现 Lint 的检查逻辑了。

// src/foo_functions.rs 
impl EarlyLintPass for FooFunctions {
  	// 此处 check_fn 是内置 EarlyLintPass trait 包含方法,前文介绍过
    fn check_fn(&mut self, cx: &EarlyContext<'_>, fn_kind: FnKind<'_>, span: Span, _: NodeId) {
        // TODO: Emit lint here 此处编写检查逻辑
    }
}

对于如何检查函数名字,在 clippy_utils/src/diagnostics.rs中定义了一些帮助函数。经过查找,span_lint_and_help函数在此处使用比较适合。

// src/foo_functions.rs 
use clippy_utils::diagnostics::span_lint_and_help;
use rustc_span::Span;
use rustc_ast::{ast::NodeId, visit::FnKind};

impl EarlyLintPass for FooFunctions {
    fn check_fn(&mut self, cx: &EarlyContext<'_>, fn_kind: FnKind<'_>, span: Span, _: NodeId) {
        span_lint_and_help(
            cx,
            FOO_FUNCTIONS,
            span,
            "function named `foo`",
            None,
            "consider using a more meaningful name"
        );
    }
}

执行测试代码,输出如下:

image.png

image.png

诊断信息是有效果了,但是缺乏一些lint检测逻辑。所以进一步修改:

impl EarlyLintPass for FooFunctions {
    fn check_fn(&mut self, cx: &EarlyContext<'_>, fn_kind: FnKind<'_>, span: Span, _: NodeId) {
        // 增加判断逻辑
        fn is_foo_fn(fn_kind: FnKind<'_>) -> bool {
            match fn_kind {
                FnKind::Fn(_, ident, ..) => {
                    // check if `fn` name is `foo`
                    ident.name.as_str() == "foo"
                }
                // ignore closures
                FnKind::Closure(..) => false
            }
        }
        // 增加判断逻辑
        if is_foo_fn(fn_kind) {
            span_lint_and_help(
                cx,
                FOO_FUNCTIONS,
                span,
                "function named `foo`",
                None,
                "consider using a more meaningful name (考虑使用一个更有意义的函数名字)"
            );
        }
    }
}

再次执行测试输出:

image.png

接下来执行:

  1. cargo dev bless 更新 .stderr文件。这个 .stderr文件是需要提交的。如果测试出现错误,记得执行这一步。
  2. cargo test

执行 cargo test 失败,因为 clippy 不允许出现 中文描述。所以,修改:

if is_foo_fn(fn_kind) {
    span_lint_and_help(
      cx,
      FOO_FUNCTIONS,
      span,
      "function named `foo`",
      None,
      "consider using a more meaningful name (考虑使用一个更有意义的函数名字)" // 此处不允许中文,当然你也可以修改 clippy 自身的 lint 配置
    );
}

// 修改为

if is_foo_fn(fn_kind) {
    span_lint_and_help(
      cx,
      FOO_FUNCTIONS,
      span,
      "function named `foo`",
      None,
      "consider using a more meaningful name"
    );
}

测试成功。

最后执行 cargo dev fmt,格式化代码。

到目前为止,自定义 clippy lint 已经完成。

测试 Clippy lint 效果

因为我们自定义的 Clippy 二进制名字已经被修改了,所以可以直接安装,不怕和已安装的clippy有冲突了。

执行以下命令安装自定义的Clippy:

cargo install --bin=cargo-myclippy --bin=clippy-mydriver --path=.

然后重新使用 cargo new clippytest创建一个新项目。

src/main.rs修改为:

#![warn(clippy::foo_functions)]

// Impl methods
struct A;
impl A {
    pub fn fo(&self) {}
    pub fn foo(&self) {}
    pub fn food(&self) {}
}

// Default trait methods
trait B {
    fn fo(&self) {}
    fn foo(&self) {}
    fn food(&self) {}
}

// Plain functions
fn fo() {}
fn foo() {}
fn food() {}

fn main() {
    // We also don't want to lint method calls
    foo();
    let a = A;
    a.foo();
}

【如有必要】然后在 clippytest项目目录下创建 rust-toolchain 文件:

[toolchain]
channel = "nightly-2021-06-17"
components = ["llvm-tools-preview", "rustc-dev", "rust-src"]

这个文件里的配置,要和 官方 rust-clippy 下一致,也就是你fork的那个原项目。

然后命令行执行:cargo myclippy,输出:

image.png

成功!

然后回去 src/main.rs中,将 #![warn(clippy::foo_functions)] 改为 #![error(clippy::foo_functions)],再次执行 cargo myclippy,输出:

image.png 成功!

到此为止,自定义 Clippy Lint 成功!

小结

通过 fork clippy,完全可以定制自己的 Lint 。但是也有很明显的缺陷:

  1. Clippy 内置 lint 很多,需要手工注册自定义lint,想想那个 2000 行的函数就头疼。
  2. Clippy 依赖 rustc_interface 是未稳定的 API 。clippy_utils 里提供的helper方法也是依赖于编译器这个未稳定接口,这样不同编译器版本就会难以兼容。导致不能通用。
  3. 需要命名为自己的 Clippy 二进制文件,避免和原本的 Clippy 冲突。

如果自定义 Lint 可以 PR 更好,但并不是所有自定义 Lint 都可以提交到官方 PR ,必然需要维护自己的/团队的特殊场景的 Lint。就会面对上面的缺陷。

有没有更好的办法呢?

方法二:使用 Dylint

参考:Write Rust lints without forking Clippy

社区有人开发了一个工具: Dylint 。它的特点:

  1. 以动态库的方式来提供 lint 。而 Clippy 是静态库。Clippy 的所有 lint 都使用相同的编译器版本,因此只需要 rustc_driver
  2. Dylint 用户可以选择从不同编译器版本的库中加载 lint。

image.png

Dylint 可以动态构建 rustc_driver。换句话说,如果用户想要 A 版本的编译器库中加载 lint,并且找不到 A 版本的 rustc_driver,Dylint 将构建一个新的 A 版本的rustc_driverrustc_driver缓存在用户的主目录中,因此仅在必要时重建它们。

Dylint 根据它们使用的编译器版本对库进行分组,使用相同编译器版本的库一起加载,并和它们的 lint 一起运行。这允许在 lint 之间共享中间编译结果(如:符号解析,类型检查,trait求解等)。

在上图中,如果库 U 和 V 都使用了 A 版本的编译器,这两个库将被放到同一个分组中。A 版本编译器的rustc_driver将只被调用一次。rustc_driver在将控制权移交给 Rust 编译器之前会在库 U 和库 V 中注册 lint。

安装和配置

通过下面命令安全 dylint:

cargo install cargo-dylint
cargo install dylint-link

然后获取模版项目:

git clone https://github.com/trailofbits/dylint-template

或者使用 cargo-generate来创建模版

cargo generate --git https://github.com/trailofbits/dylint-template

将项目命名为 :mylints

然后进入到项目根目录,执行:

cargo build
cargo dylint fill_me_in --list
编写 lint

因为生成的模版其实和 上面 fork clippy 自定义生成的代码模版类似,所以直接将上面的 lint 代码复制过来。

创建新文件 src/foo_functions.rs

use clippy_utils::diagnostics::span_lint_and_help;
use rustc_ast::{ast::NodeId, visit::FnKind};
use rustc_lint::{EarlyContext, EarlyLintPass};
use rustc_span::Span;
use rustc_lint::LateLintPass;
use rustc_session::{declare_lint, declare_lint_pass};

declare_lint! {
    /// **What it does:**
    ///  检查 以 foo 命名的函数,并给予警告
    /// **Why is this bad?**
    ///    因为该命名没有意义
    /// **Known problems:** None.
    ///
    /// **Example:**
    ///
    /// ```rust
    /// // example code where clippy issues a warning
    /// ```
    /// Use instead:
    ///   考虑使用一个更有意义的函数名字
    /// ```rust
    /// // example code which does not raise clippy warning
    /// ```
    pub FOO_FUNCTIONS,
    Warn, //  注意:这里和  fork Clippy 略有不同
    "function named `foo`, which is not a descriptive name"
}

declare_lint_pass!(FooFunctions => [FOO_FUNCTIONS]);


impl EarlyLintPass for FooFunctions {
    fn check_fn(&mut self, cx: &EarlyContext<'_>, fn_kind: FnKind<'_>, span: Span, _: NodeId) {
        fn is_foo_fn(fn_kind: FnKind<'_>) -> bool {
            match fn_kind {
                FnKind::Fn(_, ident, ..) => {
                    // check if `fn` name is `foo`
                    ident.name.as_str() == "foo"
                },
                // ignore closures
                FnKind::Closure(..) => false,
            }
        }

        if is_foo_fn(fn_kind) {
            span_lint_and_help(
                cx,
                FOO_FUNCTIONS,
                span,
                "function named `foo`",
                None,
                "consider using a more meaningful name",
            );
        }
    }
}

代码复制完毕之后,在 src/lib.rs 中添加:

mod foo_functions;

#[no_mangle]
pub fn register_lints(_sess: &rustc_session::Session, lint_store: &mut rustc_lint::LintStore) {
    lint_store.register_lints(&[foo_functions::FOO_FUNCTIONS]);
    lint_store.register_early_pass(|| Box::new(foo_functions::FooFunctions));
}

注意:需要配置当前项目下 .cargo/config.toml 中针对当前架构平台的 target 指定的链接器,否则会报 找不到库 之类的错误。

[target.aarch64-apple-darwin]
linker = "dylint-link"

[target.x86_64-apple-darwin]
linker = "dylint-link"

[target.x86_64-unknown-linux-gnu]
linker = "dylint-link"

然后执行 cargo build 编译成功。

接下来需要设置几个环境变量:

export MY_LINTS_PATH=/Work/Projects/myworkspace/mylints
export DYLINT_LIBRARY_PATH=$MY_LINTS_PATH/target/debug

然后执行 cargo test。可以看到 uitest 的输出。

但是 dylint 有个缺点,就是 uitest 无法像 clippy那样(cargo dev bless) 更新引用。所以 cargo test 会测试失败。

但是可以在 src/lib.rs 中,添加:

#[allow(dead_code)]
fn foo() {}

然后在 mylints项目下执行: cargo dylint --all 。就能看到 lint 生效了。

以上是我们编写了独立的 lints。

测试独立项目

随便创建一个 新的项目 myproject,将 src/main.rs 换成和前面测试 clippy 时候用的代码。

基于前面设置好的 mylints ,我们只需要直接使用 cargo dylint --all 命令即可。

然后在该项目根目录下执行:

cargo dylint --all -- --manifest-path=/Work/Projects/myproject/Cargo.toml

然后就可以正常执行 lint 检测了。

小结

使用 dylint 比较麻烦的是,文档不是很全,测试不支持更新引用,不如 fork clippy 方便测试。

但是 dylint 确实比较小巧,只需要维护我们自定义的lint 即可,不再需要维护 2000 多行的注册lint代码。

使用 dylint 的时候,因为也依赖了 clippy 的 clippy_utils,所以需要和 clippy 的 rustc 版本保持一致。

总结

上面总结了两种定制 Clippy Lints 的方法,各有优劣。

一个观点:

  • 第一种方法比较适合 大公司/大团队,因为第一种方法比较完善,功能齐备,只是需要一个专门的团队来维护这个 lints。并且还有可能给上游去发 PR (如果需要),形成正向反馈,让工具更加完善。另外,也许可以给 Clippy 提供一个 Plugin 机制,方便维护定制的 Lint。

  • 第二种方法适合小团队,没有多余的人力去维护,只需要定制自己的一些 lints 使用即可。

欢迎在评论区留言交流。

有用的参考资源:

以下资源对你编写 lint 将很有帮助: