Write Rust lints without forking Clippy

By Samuel Moelius, Staff Engineer
Originally published May 20, 2021

This blog post introduces Dylint, a tool for loading Rust linting rules (or “lints”) from dynamic libraries. Dylint makes it easy for developers to maintain their own personal lint collections.

Previously, the simplest way to write a new Rust lint was to fork Clippy, Rust’s de facto linting tool. But this approach has drawbacks in how one runs and maintains new lints. Dylint minimizes these distractions so that developers can focus on actually writing lints.

First, we’ll go over the current state of Rust linting and the workings of Clippy. Then, we’ll explain how Dylint improves upon the status quo and offer some tips on how to begin using it. Skip to the last section, If you want to get straight to writing lints.

Rust linting and Clippy

Tools like Clippy take advantage of the Rust compiler’s dedicated support for linting. A Rust linter’s core component, called a “driver,” links against an appropriately named library, rustc_driver. By doing so, the driver essentially becomes a wrapper around the Rust compiler.

To run the linter, the RUSTC_WORKSPACE_WRAPPER environment variable is set to point to the driver and runs cargo check. Cargo notices that the environment variable has been set and calls the driver instead of calling rustc. When the driver is called, it sets a callback in the Rust compiler’s Config struct. The callback registers some number of lints, which the Rust compiler then runs alongside its built-in lints.

Clippy performs a few checks to ensure it is enabled but otherwise works in the above manner. (See Figure 1 for Clippy’s architecture.) Although it may not be immediately clear upon installation, Clippy is actually two binaries: a Cargo command and a rustc driver. You can verify this by typing the following:

which cargo-clippy
which clippy-driver

Now suppose you want to write your own lints. What should you do? Well, you’ll need a driver to run them, and Clippy has a driver, so forking Clippy seems like a reasonable step to take. But there are drawbacks to this solution, namely in running and maintaining the lints that you’ll develop.

First, your fork will have its own copies of the two binaries, and it’s a hassle to ensure that they can be found. You’ll have to make sure that at least the Cargo command is in your PATH, and you’ll probably have to rename the binaries so that they won’t interfere with Clippy. Clearly, these steps don’t pose insurmountable problems, but you would probably rather avoid them.

Second, all lints (including Clippy’s) are built upon unstable compiler APIs. Lints compiled together must use the same version of those APIs. To understand why this is an issue, we’ll refer to clippy_utils—a collection of utilities that the Clippy authors have generously made public. Note that clippy_utils uses the same compiler APIs that lints do, and similarly provides no stability guarantees. (See below.)

Suppose you have a fork of Clippy to which you want to add a new lint. Clearly, you’ll want your new lint to use the most recent version of clippy_utils. But suppose that version uses compiler version B, while your fork of Clippy uses compiler version A. Then you’ll be faced with a dilemma: Should you use an older version of clippy_utils (one that uses compiler version A), or should you upgrade all of the lints in your fork to use compiler version B? Neither is a desirable choice.

Dylint addresses both of these problems. First, it provides a single Cargo command, saving you from having to manage multiple such commands. Second, for Dylint, lints are compiled together to produce dynamic libraries. So in the above situation, you could simply store your new lint in a new dynamic library that uses compiler version B. You could use this new library alongside your existing libraries for as long as you’d like and upgrade your existing libraries to the newer compiler version if you so choose.

Dylint provides an additional benefit related to reusing intermediate compilation results. To understand it, we need to examine how Dylint works.

How Dylint works

Like Clippy, Dylint provides a Cargo command. The user specifies to that command the dynamic libraries from which the user wants to load lints. Dylint runs cargo check in a way that ensures the lints are registered before control is handed over to the Rust compiler.

The lint-registration process is more complicated for Dylint than for Clippy, however. All of Clippy’s lints use the same compiler version, so only one driver is needed. But a Dylint user could choose to load lints from libraries that use different compiler versions.

Dylint handles such situations by building new drivers on-the-fly as needed. In other words, if a user wants to load lints from a library that uses compiler version A and no driver can be found for compiler version A, Dylint will build a new one. Drivers are cached in the user’s home directory, so they are rebuilt only when necessary.

This brings us to the additional benefit alluded to in the previous section. Dylint groups libraries by the compiler version they use. Libraries that use the same compiler version are loaded together, and their lints are run together. This allows intermediate compilation results (e.g., symbol resolution, type checking, trait solving, etc.) to be shared among the lints.

For example, in Figure 2, if libraries U and V both used compiler version A, the libraries would be grouped together. The driver for compiler version A would be invoked only once. The driver would register the lints in libraries U and V before handing control over to the Rust compiler.

To understand why this approach is beneficial, consider the following. Suppose that lints were stored directly in compiler drivers rather than dynamic libraries, and recall that a driver is essentially a wrapper around the Rust compiler. So if one had two lints in two compiler drivers that used the same compiler version, running those two drivers on the same code would amount to compiling that code twice. By storing lints in dynamic libraries and grouping them by compiler version, Dylint avoids these inefficiencies.

An application: Project-specific lints

Did you know that Clippy contains lints whose sole purpose is to lint Clippy’s code? It’s true. Clippy contains lints to check, for example, that every lint has an associated LintPass, that certain Clippy wrapper functions are used instead of the functions they wrap, and that every lint has a non-default description. It wouldn’t make sense to apply these lints to code other than Clippy’s. But there’s no rule that all lints must be general purpose, and Clippy takes advantage of this liberty.

Dylint similarly includes lints whose primary purpose is to lint Dylint’s code. For example, while developing Dylint, we found ourselves writing code like the following:

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

This was bad practice. Why? Because it was only a matter of time until we fat-fingered the string literal:

std::env::remove_var("RUSTUP_TOOLCHIAN"); // Oops

A better approach is to use a constant instead of a string literal, as in the below code:

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

So while working on Dylint, we wrote a lint to check for this bad practice and to make an appropriate suggestion. We applied (and still apply) that lint to the Dylint source code. The lint is called env_literal and the core of its current implementation is as follows:

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),
            );
         }
      }
   }
}

Here is an example of a warning it could produce:

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

Recall that neither the compiler nor clippy_utils provide stability guarantees for its APIs, so future versions of env_literal may look slightly different. (In fact, a change to clippy_utils‘ APIs resulted in a change env_literal’s implementation while this article was being written!) The current version of env_literal can always be found in the examples directory of the Dylint repository.

Clippy “lints itself” in a slightly different way than Dylint, however. Clippy’s internal lints are compiled into a version of Clippy with a particular feature enabled. But for Dylint, the env_literal lint is compiled into a dynamic library. Thus, env_literal is not a part of Dylint. It’s essentially input.

Why is this important? Because you can write custom lints for your project and use Dylint to run them just as Dylint runs its own lints on itself. There’s nothing significant about the source of the lints that Dylint runs on the Dylint repository. Dylint would just as readily run your repository’s lints on your repository.

The bottom line is this: If you find yourself writing code you do not like and you can detect that code with a lint, Dylint can help you weed out that code and prevent its reintroduction.

Get to linting

Install Dylint with the following command:

cargo install cargo-dylint

We also recommend installing the dylint-link tool to facilitate linking:

cargo install dylint-link

The easiest way to write a Dylint library is to fork the dylint-template repository. The repository produces a loadable library right out of the box. You can verify this as follows:

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

All you have to do is implement the LateLintPass trait and accommodate the symbols asking to be filled in.

Helpful resources for writing lints include the following:

Adding a new lint (targeted at Clippy but still useful)

Also consider using the clippy_utils crate mentioned above. It includes functions for many low-level tasks, such as looking up symbols and printing diagnostic messages, and makes writing lints significantly easier.

We owe a sincere thanks to the Clippy authors for making the clippy_utils crate available to the Rust community. We would also like to thank Philipp Krones for providing helpful comments on an earlier version of this post.

Article Link: Write Rust lints without forking Clippy | Trail of Bits Blog