| name | writing-rust-lints |
| description | Use when creating custom Rust lints with Dylint, implementing LateLintPass or EarlyLintPass, checking code patterns with rustc internals, or when user mentions "lint", "dylint", or "clippy-like" |
Writing Rust Lints with Dylint
Create custom Rust lints that run as dynamic libraries via Dylint.
When to Use
- Creating project-specific lints for code patterns
- Enforcing team conventions that Clippy doesn't cover
- Building reusable lint libraries
- Any task involving
LateLintPass, EarlyLintPass, or rustc internals
Quick Start
cargo dylint new my_lint_name
cd my_lint_name
cargo build && cargo test
Macro Selection
digraph macro_selection {
"Need custom lint?" [shape=diamond];
"Has state/config?" [shape=diamond];
"Multiple lints?" [shape=diamond];
"Use declare_late_lint!" [shape=box];
"Use impl_late_lint!" [shape=box];
"Manual register_lints" [shape=box];
"Need custom lint?" -> "Has state/config?" [label="yes"];
"Has state/config?" -> "Multiple lints?" [label="no"];
"Has state/config?" -> "Use impl_late_lint!" [label="yes"];
"Multiple lints?" -> "Use declare_late_lint!" [label="no"];
"Multiple lints?" -> "Manual register_lints" [label="yes"];
}
declare_late_lint!: Single lint, no state. Generates everything.
impl_late_lint!: Single lint WITH state/config. Pass initializer.
- Manual
register_lints: Multiple lints in one library. Use dylint_library!().
NEVER mix: If using declare_late_lint!, do NOT manually write dylint_library!() or register_lints.
EarlyLintPass (AST-level)
For lints that don't need type information (AST-only):
#![feature(rustc_private)]
extern crate rustc_ast;
extern crate rustc_lint;
extern crate rustc_session;
use rustc_ast::ast::{Expr, ExprKind};
use rustc_lint::{EarlyContext, EarlyLintPass, LintContext};
use rustc_session::{declare_lint, declare_lint_pass};
declare_lint! {
pub MY_EARLY_LINT,
Warn,
"description"
}
declare_lint_pass!(MyEarlyLint => [MY_EARLY_LINT]);
impl EarlyLintPass for MyEarlyLint {
fn check_expr(&mut self, cx: &EarlyContext<'_>, expr: &Expr) {
if let ExprKind::MethodCall(method_call) = &expr.kind {
if method_call.seg.ident.name.as_str() == "unwrap" {
cx.lint(MY_EARLY_LINT, |diag| {
diag.primary_message("found unwrap");
});
}
}
}
}
dylint_linting::dylint_library!();
Key differences from LateLintPass:
- Use
rustc_session::declare_lint! + declare_lint_pass! (NOT dylint macros)
- Use
EarlyContext instead of LateContext
- No
cx.typeck_results() - types not available
- AST types from
rustc_ast (not rustc_hir)
- Still need
dylint_linting::dylint_library!() at end
Project Structure
my_lint/
├── .cargo/config.toml # Linker config
├── Cargo.toml
├── src/lib.rs # Lint implementation
├── ui/ # MUST be named "ui" (not "ui_tests")
│ ├── main.rs # Test code
│ └── main.stderr # Expected output (trailing newline!)
Cargo.toml
[package]
name = "my_lint"
version = "0.1.0"
edition = "2024"
publish = false
[lib]
crate-type = ["cdylib"]
[dependencies]
clippy_utils = { git = "https://github.com/rust-lang/rust-clippy", rev = "CURRENT_REV" }
dylint_linting = "5.0.0"
[dev-dependencies]
dylint_testing = "5.0.0"
[package.metadata.rust-analyzer]
rustc_private = true
.cargo/config.toml
[target.x86_64-unknown-linux-gnu]
linker = "dylint-link"
[target.x86_64-apple-darwin]
linker = "dylint-link"
[target.aarch64-apple-darwin]
linker = "dylint-link"
Simple Lint Template
#![feature(rustc_private)]
#![warn(unused_extern_crates)]
extern crate rustc_hir;
extern crate rustc_lint;
extern crate rustc_span;
use clippy_utils::diagnostics::span_lint_and_help;
use rustc_hir::{Expr, ExprKind};
use rustc_lint::{LateContext, LateLintPass};
dylint_linting::declare_late_lint! {
pub MY_LINT_NAME,
Warn,
"brief description"
}
impl<'tcx> LateLintPass<'tcx> for MyLintName {
fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) {
}
}
#[test]
fn ui() {
dylint_testing::ui_test(env!("CARGO_PKG_NAME"), "ui");
}
Configurable Lint Template
#![feature(rustc_private)]
#![warn(unused_extern_crates)]
extern crate rustc_hir;
extern crate rustc_lint;
extern crate rustc_span;
use clippy_utils::diagnostics::span_lint_and_help;
use rustc_hir::{Expr, ExprKind};
use rustc_lint::{LateContext, LateLintPass};
use serde::Deserialize;
dylint_linting::impl_late_lint! {
pub MY_LINT_NAME,
Warn,
"brief description",
MyLintName::new()
}
#[derive(Default, Deserialize)]
struct Config {
option_name: Option<u64>,
}
struct MyLintName {
config: Config,
}
impl MyLintName {
pub fn new() -> Self {
Self {
config: dylint_linting::config_or_default(env!("CARGO_PKG_NAME")),
}
}
}
impl<'tcx> LateLintPass<'tcx> for MyLintName {
fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) {
let limit = self.config.option_name.unwrap_or(100);
}
}
Config in dylint.toml (workspace root):
[my_lint_name]
option_name = 200
LateLintPass Signatures
CRITICAL: These exact signatures. The API is unstable - don't guess.
impl<'tcx> LateLintPass<'tcx> for MyLint {
fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) {}
fn check_fn(
&mut self,
cx: &LateContext<'tcx>,
kind: FnKind<'tcx>,
decl: &'tcx FnDecl<'_>,
body: &'tcx Body<'_>,
span: Span,
local_def_id: LocalDefId,
) {}
fn check_item(&mut self, cx: &LateContext<'tcx>, item: &'tcx Item<'tcx>) {}
fn check_stmt(&mut self, cx: &LateContext<'tcx>, stmt: &'tcx Stmt<'tcx>) {}
fn check_block(&mut self, cx: &LateContext<'tcx>, block: &'tcx Block<'tcx>) {}
fn check_pat(&mut self, cx: &LateContext<'tcx>, pat: &'tcx Pat<'tcx>) {}
fn check_local(&mut self, cx: &LateContext<'tcx>, local: &'tcx LetStmt<'tcx>) {}
fn check_arm(&mut self, cx: &LateContext<'tcx>, arm: &'tcx Arm<'tcx>) {}
}
Import for check_fn:
use rustc_hir::{intravisit::FnKind, Body, FnDecl};
use rustc_hir::def_id::LocalDefId;
Type Checking
WRONG (string matching):
let ty_str = format!("{adt_def:?}");
if ty_str.contains("Option") { ... }
RIGHT (diagnostic items):
use rustc_span::sym;
let ty = cx.typeck_results().expr_ty(expr);
if let ty::Adt(adt, _) = ty.kind() {
if cx.tcx.is_diagnostic_item(sym::Option, adt.did()) { ... }
if cx.tcx.is_diagnostic_item(sym::Result, adt.did()) { ... }
if cx.tcx.is_diagnostic_item(sym::Vec, adt.did()) { ... }
}
Enum Variant Inspection
For linting match patterns and enum usage:
use rustc_middle::ty;
fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) {
if let ExprKind::Match(scrutinee, arms, _) = expr.kind {
let ty = cx.typeck_results().expr_ty(scrutinee);
if let ty::Adt(adt, _) = ty.kind() {
if !adt.is_enum() {
return;
}
let variants = adt.variants();
for (idx, variant) in variants.iter_enumerated() {
let variant_name = variant.name;
let variant_def_id = variant.def_id;
}
}
}
}
Resolving Matched Variants:
use rustc_hir::def::Res;
fn get_matched_variant(cx: &LateContext, pat: &Pat) -> Option<usize> {
if let PatKind::TupleStruct(qpath, _, _) | PatKind::Struct(qpath, _, _) = &pat.kind {
let res = cx.qpath_res(qpath, pat.hir_id);
if let Res::Def(_, def_id) = res {
}
}
None
}
Path Matching
For function calls:
use clippy_utils::{paths::PathLookup, value_path};
static STD_ENV_VAR: PathLookup = value_path!(std::env::var);
fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) {
if let ExprKind::Call(callee, args) = expr.kind {
if STD_ENV_VAR.matches_path(cx, callee) {
}
}
}
For method calls:
if let ExprKind::MethodCall(path, receiver, args, span) = expr.kind {
if path.ident.name == sym::unwrap {
let ty = cx.typeck_results().expr_ty(receiver);
}
}
Diagnostic Functions
use clippy_utils::diagnostics::{span_lint, span_lint_and_help, span_lint_and_sugg};
use rustc_errors::Applicability;
span_lint(cx, MY_LINT, span, "message");
span_lint_and_help(
cx, MY_LINT, span,
"problem message",
None,
"suggestion text",
);
span_lint_and_sugg(
cx, MY_LINT, span,
"problem message",
"try",
"replacement_code".to_string(),
Applicability::MachineApplicable,
);
Test Structure
ui/main.rs:
fn should_warn() {
problematic_code();
}
fn should_not_warn() {
acceptable_code();
}
fn main() {}
ui/main.stderr:
warning: lint message here
--> $DIR/main.rs:2:5
|
LL | problematic_code();
| ^^^^^^^^^^^^^^^^^^
|
= note: `#[warn(my_lint_name)]` on by default
warning: 1 warning emitted
IMPORTANT: Include trailing blank line in .stderr.
Common Mistakes
| Mistake | Fix |
|---|
Using HirId in check_fn | Use LocalDefId |
| String formatting for types | Use diagnostic items |
Manual register_lints with declare_late_lint! | Pick one pattern |
Test dir named ui_tests/ | Must be ui/ |
Missing trailing newline in .stderr | Add blank line at end |
| Inventing config parsing | Use dylint_linting::config_or_default() |
| Wrong clippy_utils rev | Match rust-toolchain |
| Manual Copy/Clone checks | Use implements_trait() with lang items |
| Guessing async detection | Use header.is_async() from FnKind |
| Walking body manually | Use custom Visitor trait impl |
| Wrong MIR access | Use cx.tcx.optimized_mir(def_id.to_def_id()) |
| Wrong snippet for suggestions | Use receiver span, not whole expr span |
| Crate-level attr detection | Use check_crate + cx.tcx.hir().krate_attrs() |
| Parsing attr arguments | Use attr.meta() + MetaItemKind::List |
| Generic bounds checking | Use generics.bounds_for_param() |
| Closure capture analysis | Use cx.typeck_results().closure_min_captures_flattened() |
EarlyLintPass with declare_late_lint! | Use rustc_session::declare_lint! + declare_lint_pass! |
EarlyLintPass without dylint_library!() | Still need dylint_linting::dylint_library!() at end |
Using cx.typeck_results() in EarlyLintPass | Types not available in EarlyLintPass |
Skip Macro-Generated Code
fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) {
if expr.span.from_expansion() {
return;
}
}
Check If Public
fn check_fn(
&mut self,
cx: &LateContext<'tcx>,
_kind: FnKind<'tcx>,
_decl: &'tcx FnDecl<'_>,
_body: &'tcx Body<'_>,
_span: Span,
local_def_id: LocalDefId,
) {
if !cx.effective_visibilities.is_exported(local_def_id) {
return;
}
}
Detect Async Functions
use rustc_hir::intravisit::FnKind;
fn check_fn(
&mut self,
cx: &LateContext<'tcx>,
kind: FnKind<'tcx>,
_decl: &'tcx FnDecl<'_>,
body: &'tcx Body<'_>,
_span: Span,
_local_def_id: LocalDefId,
) {
let is_async = match kind {
FnKind::ItemFn(_, _, header) => header.is_async(),
FnKind::Method(_, sig) => sig.header.is_async(),
FnKind::Closure => false,
};
if is_async {
}
}
Check Trait Implementation
use clippy_utils::ty::implements_trait;
fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) {
let ty = cx.typeck_results().expr_ty(expr);
if let Some(copy_trait) = cx.tcx.lang_items().copy_trait() {
if implements_trait(cx, ty, copy_trait, &[]) {
}
}
if let Some(clone_trait) = cx.tcx.lang_items().clone_trait() {
if implements_trait(cx, ty, clone_trait, &[]) {
}
}
if let Some(iterator_trait) = cx.tcx.get_diagnostic_item(sym::Iterator) {
if implements_trait(cx, ty, iterator_trait, &[]) {
}
}
}
Custom HIR Visitor
For complex analysis that walks the entire function body:
use rustc_hir::intravisit::{self, Visitor};
struct MyVisitor<'a, 'tcx> {
cx: &'a LateContext<'tcx>,
found_pattern: bool,
}
impl<'a, 'tcx> Visitor<'tcx> for MyVisitor<'a, 'tcx> {
fn visit_expr(&mut self, expr: &'tcx Expr<'tcx>) {
if let ExprKind::Call(callee, args) = expr.kind {
self.found_pattern = true;
}
intravisit::walk_expr(self, expr);
}
}
fn check_fn(&mut self, cx: &LateContext<'tcx>, ..., body: &'tcx Body<'_>, ...) {
let mut visitor = MyVisitor { cx, found_pattern: false };
visitor.visit_body(body);
if visitor.found_pattern {
}
}
MIR Analysis (Advanced)
For complex data-flow analysis, use MIR instead of HIR:
extern crate rustc_middle;
use rustc_middle::mir::{Body, BasicBlock, StatementKind, TerminatorKind};
fn check_fn(
&mut self,
cx: &LateContext<'tcx>,
_kind: FnKind<'tcx>,
_decl: &'tcx FnDecl<'_>,
_body: &'tcx Body<'_>,
_span: Span,
local_def_id: LocalDefId,
) {
let mir: &Body<'tcx> = cx.tcx.optimized_mir(local_def_id.to_def_id());
for (bb_idx, bb_data) in mir.basic_blocks.iter_enumerated() {
for stmt in &bb_data.statements {
if let StatementKind::Assign(box (place, rvalue)) = &stmt.kind {
}
}
if let Some(term) = &bb_data.terminator {
match &term.kind {
TerminatorKind::Call { func, args, .. } => { }
TerminatorKind::Return => { }
_ => {}
}
}
}
}
Source Snippets for Suggestions
When generating fix suggestions, extract receiver from method calls:
use clippy_utils::source::snippet;
fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) {
if let ExprKind::Binary(op, left, right) = expr.kind
&& op.node == BinOpKind::Eq
&& let ExprKind::MethodCall(method, receiver, _, _) = left.kind
&& method.ident.name.as_str() == "len"
&& is_zero(right)
{
let receiver_snippet = snippet(cx, receiver.span, "..");
span_lint_and_sugg(
cx,
MY_LINT,
expr.span,
"use `.is_empty()` instead",
"try",
format!("{}.is_empty()", receiver_snippet),
Applicability::MachineApplicable,
);
}
}
Key: For .len() → .is_empty(), use receiver.span, not left.span.
Check Attributes
For linting #[...] and #![...] attributes:
use rustc_hir::Attribute;
use rustc_ast::AttrStyle;
impl<'tcx> LateLintPass<'tcx> for MyLint {
fn check_attribute(&mut self, cx: &LateContext<'tcx>, attr: &'tcx Attribute) {
if attr.has_name(sym::allow) {
if attr.style == AttrStyle::Inner {
} else {
}
}
}
}
Crate-level vs Module-level Inner Attributes:
Inner attributes (#![...]) can appear at crate root OR inside modules. To check specifically for crate-level:
fn check_crate(&mut self, cx: &LateContext<'tcx>) {
for attr in cx.tcx.hir().krate_attrs() {
if attr.has_name(sym::allow) {
span_lint(cx, MY_LINT, attr.span(), "crate-level allow is too broad");
}
}
}
Extract Attribute Arguments:
use rustc_ast::{MetaItemKind, NestedMetaItem};
fn check_attribute(&mut self, cx: &LateContext<'tcx>, attr: &'tcx Attribute) {
if attr.has_name(sym::allow) {
if let Some(meta) = attr.meta() {
if let MetaItemKind::List(items) = meta.kind {
for item in items {
if let NestedMetaItem::MetaItem(mi) = item {
let lint_name = mi.path.segments.iter()
.map(|s| s.ident.to_string())
.collect::<Vec<_>>()
.join("::");
}
}
}
}
}
}
Check Generic Bounds
For linting generic type parameters and their bounds:
use rustc_hir::{Generics, GenericBound, GenericParam, GenericParamKind};
impl<'tcx> LateLintPass<'tcx> for MyLint {
fn check_generics(&mut self, cx: &LateContext<'tcx>, generics: &'tcx Generics<'tcx>) {
for param in generics.params {
if let GenericParamKind::Type { .. } = param.kind {
let has_debug = generics.bounds_for_param(param.def_id).any(|bound| {
bound.bounds.iter().any(|b| {
if let GenericBound::Trait(poly_trait_ref, _) = b {
let trait_def_id = poly_trait_ref.trait_ref.trait_def_id();
if let Some(debug_trait) = cx.tcx.get_diagnostic_item(sym::Debug) {
return trait_def_id == Some(debug_trait);
}
}
false
})
});
if !has_debug {
span_lint(
cx,
MY_LINT,
param.span,
format!("type parameter `{}` should have Debug bound", param.name.ident()),
);
}
}
}
}
}
Check Closure Captures
For analyzing what closures capture:
use rustc_middle::ty::{self, UpvarCapture};
fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) {
if let ExprKind::Closure(closure) = expr.kind {
let def_id = closure.def_id;
let captures = cx.typeck_results().closure_min_captures_flattened(def_id);
for capture in captures {
let place = &capture.place;
let capture_kind = capture.info.capture_kind;
match capture_kind {
UpvarCapture::ByValue => {
}
UpvarCapture::ByRef(borrow_kind) => {
use rustc_middle::mir::BorrowKind;
match borrow_kind {
BorrowKind::Immutable => { }
BorrowKind::Mutable { .. } => {
span_lint(
cx,
MY_LINT,
expr.span,
"closure captures mutable reference",
);
}
BorrowKind::Unique => { }
}
}
}
}
}
}
Reference Files
For detailed API documentation, see the reference files in this skill directory:
dylint-reference.md - Complete Dylint usage guide
rustc-internals-reference.md - HIR, types, spans
clippy-utils-reference.md - Helper functions
example-lints.md - Real-world examples