无需分叉Clippy即可编写Rust Lint工具——Dylint全新方案
Samuel Moelius, 高级工程师
2021年11月9日
rust, 程序分析, 工具发布, 开源
最初发布于2021年5月20日
本文介绍Dylint,这是一个用于从动态库加载Rust lint规则(或"lints")的工具。Dylint使开发者能够轻松维护自己的个人lint集合。
此前,编写新Rust lint的最简单方法是分叉Clippy(Rust的事实lint工具)。但这种方法在运行和维护新lint方面存在缺陷。Dylint最大限度地减少了这些干扰,使开发者能够专注于实际编写lint。
首先,我们将介绍Rust lint的现状和Clippy的工作原理。然后,我们将解释Dylint如何改进现状,并提供一些开始使用它的技巧。如果您想直接开始编写lint,请跳至最后一部分。
Rust lint与Clippy
像Clippy这样的工具利用了Rust编译器对lint的专用支持。Rust linter的核心组件称为"驱动程序",链接到一个适当命名的库rustc_driver
。通过这样做,驱动程序本质上成为Rust编译器的包装器。
要运行linter,需将RUSTC_WORKSPACE_WRAPPER
环境变量设置为指向驱动程序并运行cargo check
。Cargo注意到环境变量已设置,并调用驱动程序而不是调用rustc
。当调用驱动程序时,它在Rust编译器的Config结构中设置回调。回调注册一些lint,然后Rust编译器与其内置lint一起运行这些lint。
Clippy执行一些检查以确保其启用,但其他方面以上述方式工作。(参见图1了解Clippy的架构。)虽然安装时可能不会立即清楚,但Clippy实际上是两个二进制文件:一个Cargo命令和一个rustc驱动程序。您可以通过键入以下内容来验证这一点:
|
|
现在假设您想编写自己的lint。您应该做什么?嗯,您需要一个驱动程序来运行它们,而Clippy有一个驱动程序,因此分叉Clippy似乎是合理的步骤。但此解决方案存在缺陷,即在运行和维护您将开发的lint方面。
首先,您的分叉将拥有自己的两个二进制文件副本,确保可以找到它们很麻烦。您必须确保至少Cargo命令在您的PATH中,并且可能必须重命名二进制文件,以免它们干扰Clippy。显然,这些步骤不会带来无法克服的问题,但您可能宁愿避免它们。
其次,所有lint(包括Clippy的)都建立在非稳定的编译器API上。编译在一起的lint必须使用相同版本的这些API。要理解为什么这是一个问题,我们将参考clippy_utils
——Clippy作者慷慨公开的实用程序集合。请注意,clippy_utils
使用与lint相同的编译器API,并且同样不提供稳定性保证。(见下文。)
假设您有一个Clippy的分叉,想向其添加一个新lint。显然,您希望新lint使用最新版本的clippy_utils
。但假设该版本使用编译器版本B,而您的Clippy分叉使用编译器版本A。那么您将面临一个困境:您应该使用旧版本的clippy_utils
(使用编译器版本A的版本),还是应该升级分叉中的所有lint以使用编译器版本B?两者都不是理想的选择。
Dylint解决了这两个问题。首先,它提供单个Cargo命令,使您免于管理多个此类命令。其次,对于Dylint,lint被编译在一起以生成动态库。因此,在上述情况下,您只需将新lint存储在使用编译器版本B的新动态库中。您可以根据需要将此新库与现有库一起使用,并选择将现有库升级到较新的编译器版本。
Dylint提供了与重用中间编译结果相关的额外好处。要理解这一点,我们需要研究Dylint的工作原理。
Dylint的工作原理
与Clippy一样,Dylint提供了一个Cargo命令。用户向该命令指定要从中加载lint的动态库。Dylint以确保在控制权交给Rust编译器之前注册lint的方式运行cargo check
。
然而,lint注册过程对Dylint来说比Clippy更复杂。Clippy的所有lint使用相同的编译器版本,因此只需要一个驱动程序。但Dylint用户可以选择从使用不同编译器版本的库加载lint。
Dylint通过根据需要即时构建新驱动程序来处理这种情况。换句话说,如果用户想从使用编译器版本A的库加载lint,并且找不到编译器版本A的驱动程序,Dylint将构建一个新驱动程序。驱动程序缓存在用户的主目录中,因此仅在必要时重新构建。
这带来了上一节提到的额外好处。Dylint按使用的编译器版本对库进行分组。使用相同编译器版本的库被加载在一起,它们的lint一起运行。这允许中间编译结果(例如,符号解析、类型检查、特征求解等)在lint之间共享。
例如,在图2中,如果库U和V都使用编译器版本A,这些库将被分组在一起。编译器版本A的驱动程序仅被调用一次。驱动程序在将控制权交给Rust编译器之前注册库U和V中的lint。
要理解为什么这种方法有益,请考虑以下情况。假设lint直接存储在编译器驱动程序中而不是动态库中,并回想驱动程序本质上是Rust编译器的包装器。因此,如果在两个使用相同编译器版本的编译器驱动程序中有两个lint,在同一代码上运行这两个驱动程序将相当于编译该代码两次。通过将lint存储在动态库中并按编译器版本对它们进行分组,Dylint避免了这些低效。
应用:项目特定lint
您知道吗?Clippy包含其唯一目的是lint Clippy代码的lint。这是真的。Clippy包含lint以检查,例如,每个lint都有相关的LintPass,使用某些Clippy包装函数而不是它们包装的函数,以及每个lint都有非默认描述。将这些lint应用于Clippy以外的代码没有意义。但没有规则要求所有lint必须是通用的,Clippy利用了这种自由。
Dylint类似地包含其主要目的是lint Dylint代码的lint。例如,在开发Dylint时,我们发现自己编写了如下代码:
|
|
这是不良实践。为什么?因为迟早我们会错误输入字符串字面量:
|
|
更好的方法是使用常量而不是字符串字面量,如下代码:
|
|
因此,在开发Dylint时,我们编写了一个lint来检查这种不良实践并提出适当建议。我们将(并且仍然)将该lint应用于Dylint源代码。该lint称为env_literal
,其当前实现的核心如下:
|
|
以下是它可能产生的警告示例:
|
|
请记住,编译器和clippy_utils
都不为其API提供稳定性保证,因此未来版本的env_literal
可能看起来略有不同。(事实上,在撰写本文时,clippy_utils
API的更改导致了env_literal
实现的更改!)env_literal
的当前版本始终可以在Dylint存储库的examples目录中找到。
然而,Clippy以与Dylint略有不同的方式"lint自身"。Clippy的内部lint被编译到具有特定功能启用的Clippy版本中。但对于Dylint,env_literal
lint被编译到动态库中。因此,env_literal
不是Dylint的一部分。它本质上是输入。
为什么这很重要?因为您可以为项目编写自定义lint,并使用Dylint运行它们,就像Dylint在自身上运行自己的lint一样。Dylint在Dylint存储库上运行的lint来源没有什么重要意义。Dylint同样乐意在您的存储库上运行您存储库的lint。
底线是:如果您发现自己编写不喜欢的代码,并且可以用lint检测该代码,Dylint可以帮助您清除该代码并防止其重新引入。
开始lint
使用以下命令安装Dylint:
|
|
我们还建议安装dylint-link
工具以方便链接:
|
|
编写Dylint库的最简单方法是分叉dylint-template
存储库。该存储库开箱即用地生成可加载库。您可以通过以下方式验证这一点:
|
|
您只需实现LateLintPass
特性并适应要求填充的符号。
编写lint的有用资源包括以下内容:
- 添加新lint(针对Clippy但仍然有用)
- 编写lint的常用工具
rustc_hir
文档
还考虑使用上述clippy_utils
crate。它包括许多低级任务的函数,例如查找符号和打印诊断消息,并使编写lint显著更容易。
我们衷心感谢Clippy作者向Rust社区提供clippy_utils
crate。我们还要感谢Philipp Krones为本文的早期版本提供有益评论。
如果您喜欢这篇文章,请分享: Twitter LinkedIn GitHub Mastodon Hacker News