| |

Rust 110 🦀 Custom Lints and Static Analysis with Clippy Plugin Architecture

⏱️ Estimated read: 13 min | |

Clippy is not a separate linter that happens to understand Rust. It is a collection of lint passes that run inside the compiler, using the same HIR and type information that the rest of rustc uses. Adding a custom lint means writing a pass that implements EarlyLintPass or LateLintPass, registering it with the compiler’s lint store, and providing a test case that captures the exact diagnostic output. The architecture is deliberately the same as the compiler’s own lints, which means the learning curve is the compiler’s internal APIs, not a separate plugin framework.

This chapter covers the Clippy lint architecture as it exists for contributors to the Clippy repository. It begins with the declare_clippy_lint! macro and the choice between early and late passes, then explains the diagnostic emission functions and the suggestion system. The final sections address the testing workflow—UI tests, .stderr files, and the cargo dev tooling—and the MSRV handling that makes a lint’s suggestions safe for projects with older minimum versions.


Key point: A Clippy lint is a compiler pass, not a plugin loaded from outside. It runs inside rustc, uses the HIR, and emits diagnostics through the same infrastructure as the compiler’s built-in warnings.


Why the Clippy architecture is the compiler’s architecture

The rustc_private dependency. Clippy is built against the compiler’s internal crates: rustc_lint, rustc_ast, rustc_hir, and rustc_middle. It is not a stable API consumer. The lint passes implement traits from rustc_lint, and the diagnostics use the compiler’s Diagnostic builder. This is why Clippy ships with the toolchain rather than as a separate binary that can be installed independently of the compiler version .

The two pass types. EarlyLintPass runs on the AST, after macro expansion but before HIR lowering. It has no type information, but it is faster and sufficient for syntax-level checks like function naming. LateLintPass runs on the HIR, after type checking, and has access to the type checker’s results. Almost every Clippy lint uses LateLintPass because the interesting checks require knowing what an expression’s type is, not just what it looks like .

The registration model. A lint pass is registered in clippy_lints/src/lib.rs with store.register_early_pass or store.register_late_pass. The cargo dev new_lint command generates the file, the lint declaration, and the test scaffold, but the registration is a manual step in some versions of the tooling . The pass is then available to the compiler’s lint infrastructure, and the user can allow, warn, or deny it by name.

The diagnostic API. Clippy does not print to stderr directly. It calls functions like span_lint, span_lint_and_help, and span_lint_and_sugg from clippy_utils::diagnostics. These functions construct a Diagnostic and emit it through the compiler’s diagnostic emitter, which handles the formatting, the span highlighting, and the interaction with --error-format=json .


a. Declaring a lint and choosing a pass

The declare_clippy_lint! macro takes the lint’s documentation, its name in uppercase, its category, and a short description. The documentation is not optional; it becomes the lint’s page in the Clippy documentation, and it is required for the lint to be accepted into the repository .

declare_clippy_lint! {
    /// ### What it does
    /// Checks for functions named `foo`.
    ///
    /// ### Why is this bad?
    /// `foo` is not a descriptive name.
    ///
    /// ### Example
    /// ```rust
    /// fn foo() {}
    /// ```
    #[clippy::version = "1.29.0"]
    pub FOO_FUNCTIONS,
    pedantic,
    "function named `foo`, which is not a descriptive name"
}

The category determines the lint’s default level. correctness lints are deny-by-default, style and complexity lints are warn-by-default, and pedantic and nursery lints are allow-by-default . The category is not a suggestion; it is the contract with the user about how aggressive the lint is.

The choice between EarlyLintPass and LateLintPass follows from whether the lint needs type information. A lint that checks function names can use the AST and the FnKind from check_fn. A lint that checks whether a method is called on a HashMap needs the HIR’s type resolution and the LateContext .

// Early pass: syntax only.
impl EarlyLintPass for FooFunctions {
    fn check_fn(&mut self, cx: &EarlyContext<'_>, fn_kind: FnKind<'_>, _: &FnDecl, span: Span, _: NodeId) {
        if is_foo_fn(fn_kind) {
            span_help_and_lint(
                cx,
                FOO_FUNCTIONS,
                span,
                "function named `foo`",
                "consider using a more meaningful name",
            );
        }
    }
}

b. Emitting diagnostics and suggestions

The diagnostic functions are the lint’s output. span_lint emits the message with no additional information. span_lint_and_help adds a help message. span_lint_and_sugg adds a machine-applicable suggestion that the user can apply with cargo fix or rustfix. span_lint_and_then gives the closure access to the Diagnostic builder for more complex output .

// A suggestion that rustfix can apply automatically.
span_lint_and_sugg(
    cx,
    LINT_NAME,
    expr.span,
    "this range is more readable as an inclusive range",
    "use",
    format!("{}..={}", start, end),
    Applicability::MachineApplicable,
);

The Applicability is the confidence level. MachineApplicable means the suggestion is always correct and can be applied automatically. MaybeIncorrect means a human should review it. HasPlaceholders means the suggestion contains a placeholder that the user must fill in. The applicability determines whether rustfix applies the suggestion without prompting .

The message style is part of the contract. Clippy’s documentation states that messages should be matter-of-fact, avoid capitalization, and avoid punctuation at the end. If multiple sentences are needed, they should be split into an error plus a help or note message . This is not a style preference; it is what keeps the diagnostic output consistent across the hundreds of lints in the repository.


c. Testing, MSRV, and the cargo dev workflow

Clippy’s tests are UI tests. Each lint has a test file in tests/ui/ that contains code that should trigger the lint and code that should not. The test file uses //~^ lint_name comments to mark the lines where the diagnostic is expected . The expected output is stored in a .stderr file alongside the test, and the cargo bless command regenerates the .stderr file from the actual output. The TESTNAME=foo_functions cargo uitest command runs a single test and shows the diff between the expected and actual output.

The cargo dev new_lint command generates the boilerplate: the lint file in clippy_lints/src/, the test file in tests/ui/, and the registration in the lint store. The --pass=early or --pass=late flag sets the pass type, and the --category flag sets the category. The --type flag places the lint in a category directory like functions/ or loops/ instead of as a standalone lint .

The MSRV handling is the detail that makes a lint practical for projects that support older Rust versions. A lint that suggests str::strip_prefix should not fire on a project whose MSRV is below 1.45, because the suggestion would not compile. The lint pass holds an Msrv field, and the Msrv::meets method checks whether the project’s configured MSRV is at least the feature’s MSRV .

pub struct ManualStrip {
    msrv: Msrv,
}

impl ManualStrip {
    pub fn new(msrv: Msrv) -> Self {
        Self { msrv }
    }
}

impl<'tcx> LateLintPass<'tcx> for ManualStrip {
    fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'_>) {
        if !self.msrv.meets(cx, msrvs::STR_STRIP_PREFIX) {
            return;
        }
        // ... lint logic
    }
}

The test file includes both an MSRV-below case and an MSRV-at case, marked with #[clippy::msrv = "1.44"] and #[clippy::msrv = "1.45"] attributes, so that the behavior is verified at both ends of the boundary .


Complete Example Session

// ============================================
// PART 1: THE LINT DECLARATION
// ============================================
// The macro generates the Lint static and the docs.

declare_clippy_lint! {
    /// ### What it does
    /// Checks for functions named `foo`.
    ///
    /// ### Why is this bad?
    /// `foo` is not a descriptive name.
    ///
    /// ### Example
    /// ```rust
    /// fn foo() {}
    /// ```
    #[clippy::version = "1.29.0"]
    pub FOO_FUNCTIONS,
    pedantic,
    "function named `foo`, which is not a descriptive name"
}

// ============================================
// PART 2: THE PASS STRUCT
// ============================================
// The pass holds any state the lint needs.

declare_lint_pass!(FooFunctions => [FOO_FUNCTIONS]);

// ============================================
// PART 3: THE EARLY PASS IMPLEMENTATION
// ============================================
// check_fn runs for every function definition.

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

// ============================================
// PART 4: THE HELPER
// ============================================
// is_foo_fn extracts the name and compares.

fn is_foo_fn(fn_kind: FnKind<'_>) -> bool {
    match fn_kind {
        FnKind::ItemFn(ident, ..) | FnKind::Method(ident, ..) => {
            ident.name.as_str() == "foo"
        }
        FnKind::Closure => false,
    }
}

// ============================================
// PART 5: THE REGISTRATION
// ============================================
// Manual registration in clippy_lints/src/lib.rs.

// reg.register_early_lint_pass(box foo_functions::FooFunctions);

// ============================================
// PART 6: THE TEST FILE
// ============================================
// tests/ui/foo_functions.rs

// #![warn(clippy::foo_functions)]
//
// fn foo() {}
// //~^ foo_functions
//
// fn food() {}  // no lint

// ============================================
// PART 7: THE TEST COMMAND
// ============================================
// TESTNAME=foo_functions cargo uitest
// cargo bless  (to regenerate .stderr)

// ============================================
// PART 8: THE LATE PASS VARIANT
// ============================================
// A lint that needs type information.

impl<'tcx> LateLintPass<'tcx> for TypeAwareLint {
    fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'_>) {
        // cx.typeck_results() gives the type of expr.
        if is_hashmap_method_call(cx, expr) {
            span_lint_and_help(
                cx,
                TYPE_AWARE_LINT,
                expr.span,
                "HashMap method called",
                None,
                "this may be slow",
            );
        }
    }
}

// ============================================
// PART 9: THE MSRV CHECK
// ============================================
// Only suggest features that the project's MSRV supports.

if !self.msrv.meets(cx, msrvs::STR_STRIP_PREFIX) {
    return;
}

// ============================================
// PART 10: THE FULL PICTURE
// ============================================
// The lint is a compiler pass.
// The diagnostic functions are the output.
// The UI tests are the contract.

// cargo dev new_lint --name=foo_functions --pass=early --category=pedantic
// cargo dev update_lints
// TESTNAME=foo_functions cargo uitest
// cargo bless

The ten parts build a complete lint from declaration to test. The pattern is consistent: the macro declares the lint, the pass implements the check, the diagnostic functions emit the output, and the UI test captures the expected behavior.


Quick Reference

Pass Types

PassRuns OnType InfoSpeedUse For
EarlyLintPassASTNoFasterSyntax-only checks
LateLintPassHIRYesSlowerType-aware checks

Diagnostic Functions

FunctionOutput
span_lintMessage only
span_lint_and_helpMessage + help
span_lint_and_suggMessage + fix suggestion
span_lint_and_thenCustom diagnostic builder

Applicability

ValueMeaningAuto-fix
MachineApplicableAlways correctYes
MaybeIncorrectNeeds reviewNo
HasPlaceholdersContains placeholderNo

cargo dev Commands

CommandPurpose
cargo dev new_lintGenerate lint file and test
cargo dev update_lintsUpdate lint registry
cargo uitestRun UI tests
cargo blessRegenerate .stderr files

Best Practices

✅ Do This:

// Use LateLintPass when type information is needed.
impl<'tcx> LateLintPass<'tcx> for MyLint { ... }                     // ✅
// Use span_lint_and_sugg for auto-fixable issues.
span_lint_and_sugg(cx, LINT, span, "msg", "use", sugg, MachineApplicable); // ✅
// Check MSRV before suggesting newer APIs.
if !self.msrv.meets(cx, msrvs::STR_STRIP_PREFIX) { return; }         // ✅
// Write the UI test before the logic.
// tests/ui/my_lint.rs with //~^ my_lint markers.                    // ✅

❌ Don’t Do This:

// Don't use EarlyLintPass for type checks.
// The AST has no type information.                                  // ❌
// Don't suggest APIs without MSRV protection.
// The suggestion may not compile on older toolchains.              // ❌
// Don't skip the .stderr file.
// The UI test compares against it.                                 // ❌

Common Pitfalls

PitfallWhy It HappensFix
“unknown clippy lint”Lint not registered in lib.rsAdd register_*_pass
UI test fails after change.stderr out of dateRun cargo bless
Suggestion doesn’t compileMSRV not checkedAdd msrv.meets guard
Lint fires on valid codePass checks wrong node typeVerify check_* method and type
Diagnostic output inconsistentMessage style violatedFollow Clippy message guidelines

Real-World Examples

1. Lint Declaration

declare_clippy_lint! { pub FOO_FUNCTIONS, pedantic, "function named foo" }

2. Early Pass

impl EarlyLintPass for FooFunctions { fn check_fn(...) { ... } }

3. Late Pass

impl<'tcx> LateLintPass<'tcx> for MyLint { fn check_expr(...) { ... } }

4. Emit with Help

span_lint_and_help(cx, LINT, span, "msg", None, "help");

5. Emit with Suggestion

span_lint_and_sugg(cx, LINT, span, "msg", "use", sugg, MachineApplicable);

6. Register Pass

reg.register_late_lint_pass(box my_lint::MyLint::new(conf));

7. UI Test Marker

fn foo() {} //~^ foo_functions

8. Test Command

TESTNAME=foo_functions cargo uitest

9. Bless

cargo bless

10. MSRV Guard

if !self.msrv.meets(cx, msrvs::STR_STRIP_PREFIX) { return; }

Visual

The Clippy Pipeline

┌─────────────────────────────────────────────────────────┐
│  SOURCE CODE                                            │
│         │                                               │
│         ▼  rustc parser                                 │
│  AST                                                    │
│         │                                               │
│         ▼  EarlyLintPass                                │
│  SYNTAX LINTS                                           │
│         │                                               │
│         ▼  HIR lowering + type check                    │
│  HIR + TYPE INFO                                        │
│         │                                               │
│         ▼  LateLintPass                                 │
│  TYPE-AWARE LINTS                                       │
│         │                                               │
│         ▼  diagnostics                                  │
│  WARNINGS / SUGGESTIONS                                 │
└─────────────────────────────────────────────────────────┘

The Lint Registration

┌─────────────────────────────────────────────────────────┐
│  clippy_lints/src/lib.rs                                │
│                                                         │
│  reg.register_early_lint_pass(                          │
│      box foo_functions::FooFunctions                    │
│  );                                                     │
│                                                         │
│  The pass is added to the compiler's lint store.        │
│  The user can then allow/warn/deny by name.             │
└─────────────────────────────────────────────────────────┘

The UI Test

┌─────────────────────────────────────────────────────────┐
│  tests/ui/foo_functions.rs                              │
│  #![warn(clippy::foo_functions)]                        │
│                                                         │
│  fn foo() {}                                            │
│  //~^ foo_functions    ← expected diagnostic            │
│                                                         │
│  fn food() {}          ← no diagnostic expected         │
│                                                         │
│  tests/ui/foo_functions.stderr                          │
│  ┌───────────────────────────────────────────────────┐  │
│  │  error: function named `foo`                      │  │
│  │   --> $DIR/foo_functions.rs:4:1                    │  │
│  │    |                                               │  │
│  │  4 | fn foo() {}                                   │  │
│  │    | ^^^^^^^^^^                                    │  │
│  └───────────────────────────────────────────────────┘  │
│                                                         │
│  cargo bless regenerates the .stderr file.              │
└─────────────────────────────────────────────────────────┘

The MSRV Boundary

┌─────────────────────────────────────────────────────────┐
│  Project MSRV: 1.44                                     │
│  Suggested API: str::strip_prefix (MSRV 1.45)           │
│         │                                               │
│         ▼  self.msrv.meets(msrvs::STR_STRIP_PREFIX)     │
│  false → return (no lint)                               │
│                                                         │
│  Project MSRV: 1.45                                     │
│         │                                               │
│         ▼  self.msrv.meets(...)                         │
│  true → emit the lint with the suggestion               │
│                                                         │
│  The lint does not suggest code that the project        │
│  cannot compile.                                        │
└─────────────────────────────────────────────────────────┘

Summary

ItemValue
Lint declarationdeclare_clippy_lint! macro
Pass typesEarlyLintPass (AST), LateLintPass (HIR + types)
Diagnostic functionsspan_lint, span_lint_and_help, span_lint_and_sugg
ApplicabilityMachineApplicable, MaybeIncorrect
Registrationregister_early_lint_pass / register_late_lint_pass
Test frameworkUI tests with .stderr files
Test commandTESTNAME=<lint> cargo uitest
Regenerate outputcargo bless
MSRV handlingMsrv field + msrv.meets
Scaffoldingcargo dev new_lint

Key takeaways:

  • A Clippy lint is a compiler pass, not an external plugin. It runs inside rustc, uses the HIR, and emits diagnostics through the same infrastructure as the compiler’s built-in lints .
  • The pass type determines what the lint can see. EarlyLintPass sees the AST and no types. LateLintPass sees the HIR and the type checker’s results. Almost every real lint uses the late pass .
  • The diagnostic functions are the output. span_lint for a message, span_lint_and_sugg for a machine-applicable fix. The Applicability determines whether rustfix applies the suggestion automatically .
  • UI tests are the contract. Each lint has a test file with //~^ lint_name markers and a .stderr file with the expected output. The cargo bless command regenerates the .stderr after a change .
  • MSRV handling prevents bad suggestions. A lint that suggests str::strip_prefix on a project with MSRV 1.44 would produce code that does not compile. The Msrv::meets check prevents this .
  • cargo dev new_lint generates the scaffold. The command creates the lint file, the test file, and the registration boilerplate. The --pass and --category flags set the lint’s behavior and default level .
  • The message style is part of the contract. Matter-of-fact, no capitalization, no trailing punctuation. Multiple sentences become an error plus a help message. This keeps the diagnostic output consistent across the repository .

Remember: Clippy lints are built with the compiler’s own APIs, which means the learning curve is the HIR and the lint infrastructure, not a separate plugin framework. The lint is a pass, the pass implements a trait, and the trait method runs for each node of the relevant type. The diagnostic functions emit the message, the suggestion system provides the fix, and the UI tests verify the exact output. The MSRV check keeps the suggestions safe for older projects, and the cargo dev tooling reduces the boilerplate to a single command.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!