Learn Rust Series (#59) - Clippy, rustfmt, and Writing Idiomatic Rust
Learn Rust Series (#59) - Clippy, rustfmt, and Writing Idiomatic Rust
What will I learn
- You will learn what
rustfmtandclippyare, and why running both on every commit is standard practice; - how
rustfmtremoves every formatting debate by enforcing one canonical style automatically; - what kinds of issues
clippycatches -- needless clones, verbose patterns, and genuine bugs; - a whole set of concrete idioms clippy nudges you toward, with the before-and-after in real code;
- how to configure lint levels so a warning you honestly disagree with does not nag you forever.
Requirements
- A working modern computer running macOS, Windows or Ubuntu, with Cargo;
- An installed Rust toolchain (via rustup, from rustup.rs), plus
rustup component add clippy rustfmt; - The previous fifty-eight episodes, especially iterators,
Option, and pattern matching; - The ambition to learn systems programming from the ground up.
Difficulty
- Beginner
Curriculum (of the Learn Rust Series):
- Learn Rust Series (#1) - Introduction to Rust
- Learn Rust Series (#2) - Variables, Types, Functions
- Learn Rust Series (#3) - Ownership & Borrowing
- Learn Rust Series (#4) - Control Flow & Pattern Matching
- Learn Rust Series (#5) - Structs & Enums
- Learn Rust Series (#6) - Error Handling
- Learn Rust Series (#7) - Collections
- Learn Rust Series (#8) - Traits & Generics
- Learn Rust Series (#9) - Modules & Crates
- Learn Rust Series (#10) - Lifetimes
- Learn Rust Series (#11) - Closures & the Iterator Trait
- Learn Rust Series (#12) - Smart Pointers: Box, Rc & RefCell
- Learn Rust Series (#13) - Concurrency: Threads, Channels, Arc & Mutex
- Learn Rust Series (#14) - Mini Project: A Command-Line To-Do App
- Learn Rust Series (#15) - Trait Objects & Dynamic Dispatch
- Learn Rust Series (#16) - Static vs Dynamic Dispatch
- Learn Rust Series (#17) - Associated Types vs Generic Parameters
- Learn Rust Series (#18) - Operator Overloading with std::ops
- Learn Rust Series (#19) - Deref, DerefMut & Deref Coercion
- Learn Rust Series (#20) - Drop & Deterministic Destruction (RAII)
- Learn Rust Series (#21) - From, Into, TryFrom & Idiomatic Conversions
- Learn Rust Series (#22) - Deriving Common Traits
- Learn Rust Series (#23) - The Orphan Rule & Trait Coherence
- Learn Rust Series (#24) - Blanket Implementations & the Newtype Pattern
- Learn Rust Series (#25) - Marker Traits: Sized, Send, Sync & Copy
- Learn Rust Series (#26) - Const Generics: Types That Depend on Values
- Learn Rust Series (#27) - Generic Associated Types & Lending Iterators
- Learn Rust Series (#28) - Sealed Traits & Designing Stable APIs
- Learn Rust Series (#29) - Typestate Programming: State Machines in the Type System
- Learn Rust Series (#30) - Mini Project: A Generic Units-of-Measure Library
- Learn Rust Series (#31) - Move Semantics Deep Dive
- Learn Rust Series (#32) - Interior Mutability: Cell & RefCell
- Learn Rust Series (#33) - Rc Internals: Reference Counting & Shared Ownership
- Learn Rust Series (#34) - Arc: Thread-Safe Reference Counting & Its Cost
- Learn Rust Series (#35) - Weak References & Breaking Reference Cycles
- Learn Rust Series (#36) - Cow: Clone-on-Write for Borrow-or-Own APIs
- Learn Rust Series (#37) - Pin & Self-Referential Structs
- Learn Rust Series (#38) - PhantomData, Zero-Sized Types & Marker Lifetimes
- Learn Rust Series (#39) - Variance: Covariance, Contravariance & Why It Matters
- Learn Rust Series (#40) - Arena & Bump Allocation Patterns
- Learn Rust Series (#41) - Building Your Own Smart Pointer
- Learn Rust Series (#42) - Drop Order, the Drop Check & Leak Safety
- Learn Rust Series (#43) - std::mem: swap, replace, take & forget
- Learn Rust Series (#44) - Higher-Ranked Trait Bounds & Lifetime Elision
- Learn Rust Series (#45) - Mini Project: A Doubly-Linked List, Safe then Unsafe
- Learn Rust Series (#46) - Result Combinators: map, map_err, and_then, ok_or
- Learn Rust Series (#47) - Option Combinators & Null-Free Programming
- Learn Rust Series (#48) - Custom Error Types & the std::error::Error Trait
- Learn Rust Series (#49) - thiserror: Ergonomic Library Errors
- Learn Rust Series (#50) - anyhow: Flexible Application-Level Errors & Context
- Learn Rust Series (#51) - Panics, Unwinding, abort, and catch_unwind
- Learn Rust Series (#52) - Testing: Unit Tests, Integration Tests, and Doctests
- Learn Rust Series (#53) - Property-Based Testing with proptest
- Learn Rust Series (#54) - Fuzzing with cargo-fuzz and libFuzzer
- Learn Rust Series (#55) - Benchmarking with Criterion and Reading the Numbers
- Learn Rust Series (#56) - Cargo Workspaces and Multi-Crate Projects
- Learn Rust Series (#57) - Feature Flags and Conditional Compilation (cfg)
- Learn Rust Series (#58) - Build Scripts (build.rs) and Generating Code at Build Time
- Learn Rust Series (#59) - Clippy, rustfmt, and Writing Idiomatic Rust (this post)
Learn Rust Series (#59) - Clippy, rustfmt, and Writing Idiomatic Rust
Two tools ship with the Rust toolchain that quietly make everyone's code better, and the striking thing is that neither of them changes what your program does. rustfmt reformats your source to one canonical style, so nobody on a team ever argues about braces, indentation, or where a long function signature should wrap again. clippy is a linter with hundreds of rules that catch not just style slips but genuine bugs -- a == that should be !=, a comparison that is always true, a .clone() you never needed to write. Running cargo fmt and cargo clippy before every commit is one of the cheapest quality habits in the language, and clippy in particular is a superb teacher of idiomatic Rust ;-)
That teaching angle is why this episode sits where it does. We have spent a lot of recent episodes on machinery -- testing, benchmarking, workspaces, feature flags, build scripts -- and now we turn the tools back on the code we write in between all that machinery. The compiler already tells you when your code is wrong. Clippy tells you when your code is correct but clumsy, and shows you the fluent form a seasoned Rust programmer would have reached for instead. Read enough of its suggestions and you slowly absorb the language's taste, almost by osmosis. So let us first clear last episode's homework, and then meet the two tools properly.
Solutions to Episode 58 Exercises
Episode 58 was build scripts, and all three exercises were about build.rs talking back to Cargo. Here they are in full.
Exercise 1 asked for a build.rs that emits cargo:rustc-cfg=fast_path only when a chosen environment variable is set, consumed in the crate with a #[cfg(fast_path)] / #[cfg(not(fast_path))] pair, printing which path is active -- and remembering the rerun-if-env-changed line so Cargo re-runs the script when the variable changes:
// build.rs
fn main() {
// only emit the cfg when ENABLE_FAST_PATH is present in the environment
if std::env::var("ENABLE_FAST_PATH").is_ok() {
println!("cargo:rustc-cfg=fast_path");
}
// without this, Cargo would not know to re-run when the variable flips
println!("cargo:rerun-if-env-changed=ENABLE_FAST_PATH");
println!("cargo:rerun-if-changed=build.rs");
}
// src/main.rs
#[cfg(fast_path)]
fn route() -> &'static str { "fast path (build.rs saw the env var)" }
#[cfg(not(fast_path))]
fn route() -> &'static str { "portable path" }
fn main() {
println!("using the {}", route());
}
The if ... is_ok() guard means the cargo:rustc-cfg line is printed conditionally, so fast_path only exists when you build with ENABLE_FAST_PATH=1 cargo build. As always, the not(...) fallback arm guarantees a definition of route on every build, so the crate compiles whether or not the flag fired.
Exercise 2 wanted a build.rs that generates a pub const GREETINGS: [&str; 3] array into OUT_DIR by building the source as a String, then includes it and prints all three entries:
// build.rs
use std::fs;
use std::path::Path;
fn main() {
let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR is set by Cargo");
let dest = Path::new(&out_dir).join("greetings.rs");
let code = "pub const GREETINGS: [&str; 3] = [\"hi\", \"hey\", \"hello\"];\n";
fs::write(&dest, code).expect("failed to write greetings.rs");
println!("cargo:rerun-if-changed=build.rs");
}
// src/main.rs
include!(concat!(env!("OUT_DIR"), "/greetings.rs")); // brings GREETINGS into scope
fn main() {
for g in GREETINGS {
println!("{g}");
}
}
The script writes ordinary Rust text into the private OUT_DIR, and include!(concat!(env!("OUT_DIR"), "/greetings.rs")) splices it in at compile time exactly as if you had typed the array yourself. The generated file has no idea it was generated -- to the compiler it is just source.
Exercise 3 used cargo:rustc-env to expose a BUILD_ID string derived from CARGO_PKG_VERSION and PROFILE, read in the crate with env!("BUILD_ID"), with a comment explaining why env! beats std::env::var here:
// build.rs
fn main() {
let version = std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.0.0".into());
let profile = std::env::var("PROFILE").unwrap_or_default();
println!("cargo:rustc-env=BUILD_ID={version}-{profile}"); // e.g. "0.1.0-release"
}
// src/main.rs
fn main() {
// env! reads the value AT COMPILE TIME and bakes it into the binary as a
// &'static str. std::env::var would instead read the *runtime* environment
// of whoever launches the program -- where BUILD_ID does not exist -- so it
// is the wrong tool. We want the identity frozen at build time, not re-read
// from the user's shell.
println!("build id: {}", env!("BUILD_ID"));
}
The heart of it is that env! is a macro that runs during compilation and fails the build if the variable is missing, giving you a &'static str fixed forever into the binary. std::env::var is a runtime function returning a Result from whatever environment the process happens to run in. For build stamping you want the value frozen at build time -- so env! it is. Right, homework cleared. Now, the tools that grade our style.
rustfmt: one canonical style, zero arguments
cargo fmt rewrites your source to the standard Rust style: four-space indentation, spaces around operators, a canonical layout for long function signatures and match arms, trailing commas in the right places. The point is not that this particular style is objectively perfect -- reasonable people can prefer other brace conventions. The point is that it is consistent and automatic, so a code review is about logic and never about whitespace. Take a deliberately messy snippet and let the tool tidy it:
// before `cargo fmt` -- valid Rust, but a mess a human had to eyeball
fn add(a:i32,b:i32)->i32{a+b}
fn main(){let r=add( 1,2 );println!("{r}");}
// after `cargo fmt` -- the canonical layout, produced mechanically
fn add(a: i32, b: i32) -> i32 {
a + b
}
fn main() {
let r = add(1, 2);
println!("{r}");
}
Both versions compile to the exact same program; formatting is a purely cosmetic transform. But the formatted one is the one your whole team will read for years, so uniformity wins. You configure the handful of tunables in a rustfmt.toml at your project root:
# rustfmt.toml
max_width = 100 # wrap lines past 100 columns
edition = "2021" # match your crate's edition so parsing is correct
tab_spaces = 4 # the default, spelled out for clarity
The habit that makes this pay off is running cargo fmt --check in CI. That mode does not rewrite anything -- it exits with an error if any file is not already formatted, which turns "please run rustfmt before pushing" from a nagging review comment into an automated gate. Get that in place and the entire formatting debate disappears from your team forever, which is a genuinely lovely thing to never think about again.
clippy: a linter that also teaches
cargo clippy runs the compiler with an extra pass of lints on top of the normal build. Where rustc warns you about correctness, clippy also warns about code that is correct-but-clumsy, and -- this is the part I love -- it usually shows you the idiomatic form right in the message. Take the classic needless_bool combined with needless_return. The verbose version works perfectly, but it is writing four lines where the language wants one:
fn is_even_verbose(n: i32) -> bool {
if n % 2 == 0 {
return true; // clippy::needless_return + needless_bool
} else {
return false;
}
}
fn is_even(n: i32) -> bool {
n % 2 == 0 // the comparison IS already the boolean you want to return
}
fn main() {
println!("{} {}", is_even_verbose(4), is_even(4)); // true true
}
Clippy sees the if cond { return true } else { return false } shape and tells you, in so many words, that the condition already is the boolean, so just return it. That is a tiny example, but multiply it across a codebase and clippy is quietly retraining your reflexes toward expression-oriented Rust.
Idiom: iterate, do not index
A C-style for i in 0..v.len() loop whose body only ever touches v[i] is what clippy calls a needless_range_loop. The iterator form is clearer about intent, and as a bonus it skips the bounds check that indexing does on every single access:
fn sum_indexed(v: &[i32]) -> i32 {
let mut total = 0;
for i in 0..v.len() {
total += v[i]; // clippy::needless_range_loop -- the index i is pure ceremony
}
total
}
fn sum_idiomatic(v: &[i32]) -> i32 {
v.iter().sum() // says exactly what it means, no manual counter
}
fn main() {
let data = [1, 2, 3, 4];
println!("{} {}", sum_indexed(&data), sum_idiomatic(&data)); // 10 10
}
This connects straight back to the iterator episodes: once you internalise iter, map, filter, sum, and friends, the index-juggling version starts to look as dated as it actually is. Clippy is just there to catch you when you slip back into old habits.
Idiom: if let over a two-arm match
When only one arm of a match is genuinely interesting and the other is a throwaway catch-all, if let ... else reads better and is what clippy's single_match family will suggest:
fn describe(opt: Option<i32>) -> String {
if let Some(n) = opt {
format!("got {n}")
} else {
"nothing".to_string()
}
}
fn main() {
println!("{}", describe(Some(7))); // got 7
println!("{}", describe(None)); // nothing
}
The full match opt { Some(n) => ..., None => ... } is not wrong, but when the shape is "one case I care about, plus an else", if let carries less visual weight. Save match for when you truly are branching on several arms.
Idiom: combinators over match-and-unwrap
Reaching for a match and then hand-writing what a combinator already does is another thing clippy flags (manual_map, manual_unwrap_or, and cousins). The Option and Result combinators we covered a few episodes back express the intent directly:
fn parse_or_zero(s: &str) -> i32 {
// NOT: match s.parse::() { Ok(n) => n, Err(_) => 0 }
s.parse::<i32>().unwrap_or(0)
}
fn first_upper(s: &str) -> Option<char> {
// NOT: match s.chars().next() { Some(c) => Some(c.to_ascii_uppercase()), None => None }
s.chars().next().map(|c| c.to_ascii_uppercase())
}
fn main() {
println!("{} {}", parse_or_zero("42"), parse_or_zero("x")); // 42 0
println!("{:?}", first_upper("hive")); // Some('H')
}
Every time you write match x { Some(v) => Some(f(v)), None => None }, that is literally x.map(f), and clippy will say so. Learning to see those shapes is half of writing fluent Rust.
Idiom: accept &str, return impl Trait
Two API idioms clippy and the wider community push hard. First, take &str rather than &String: a &str accepts both a string slice and (through deref coercion, which we studied back in the Deref episode) a &String, so it demands strictly less of your caller. Second, return impl Trait for iterators rather than naming some unwieldy concrete type that ties your hands and leaks implementation detail:
fn greet(name: &str) -> String {
format!("hello, {name}") // a &String coerces to &str at the call site, for free
}
fn evens(limit: u32) -> impl Iterator<Item = u32> {
(0..limit).filter(|n| n % 2 == 0) // caller never needs the exact Filter type
}
fn main() {
let owned = String::from("world");
println!("{}", greet(&owned)); // hello, world
println!("{}", greet("literal")); // hello, literal
println!("{:?}", evens(10).collect::<Vec<_>>()); // [0, 2, 4, 6, 8]
}
The ptr_arg lint is the one that nags about &String; the impl Trait return is more of a community convention than a hard lint, but both make your APIs kinder to use. A function that asks for the loosest thing it can accept and promises the loosest thing it can return is easier to call and easier to change later.
Idiom: matches! for a boolean pattern test
When all you need is "does this value match a pattern, yes or no", the matches! macro beats a match that maps every arm to true or false:
#[derive(Debug, PartialEq)]
enum State {
Idle,
Running,
Done,
}
fn is_active(s: &State) -> bool {
matches!(s, State::Running) // one line; clippy::match_like_matches_macro suggests exactly this
}
fn main() {
println!("{}", is_active(&State::Running)); // true
println!("{}", is_active(&State::Idle)); // false
}
matches! even supports guards -- matches!(n, 1..=9 if n % 2 == 0) -- so it scales to surprisingly rich tests while staying a single expression. It is one of those small tools that, once you know it, you reach for constantly.
Idiom: the needless clone, clippy's most famous catch
If clippy is known for one lint above all others, it is the needless .clone(). Beginners sprinkle clones to make borrow-checker errors go away, and clippy is forever pointing out that a borrow would have done the job with no allocation at all:
fn total_len_wasteful(items: &[String]) -> usize {
let mut total = 0;
for item in items {
let owned = item.clone(); // clippy: unnecessary clone, you only read it
total += owned.len();
}
total
}
fn total_len(items: &[String]) -> usize {
// just borrow each String -- .len() needs a &self, nothing more
items.iter().map(|item| item.len()).sum()
}
fn main() {
let words = vec![String::from("hive"), String::from("rust")];
println!("{} {}", total_len_wasteful(&words), total_len(&words)); // 8 8
}
The wasteful version heap-allocates a fresh String on every iteration purely to call .len() on it, then throws it away. Clippy's redundant_clone and the pedantic clone lints exist to stamp this out, and following them makes your code both cleaner and measurably faster. This is a nice reminder that idiomatic and efficient usually point the same direction in Rust.
Tuning the lints
Clippy is opinionated, and once in a while you will genuinely disagree with a suggestion -- or a lint will fire on code that is deliberately written the "wrong" way for a good reason. You have precise control. You can silence a single instance with an #[allow(...)] attribute right on the item, promote a lint to a hard compile error with #[deny(...)], or set crate-wide policy at the top of lib.rs or main.rs:
#![warn(clippy::all)] // the standard, sensible default set (crate-wide)
#![deny(clippy::unwrap_used)] // make a chosen lint a hard error across the crate
fn parse(s: &str) -> i32 {
// this specific unwrap is fine here (we control the input), so allow just this one
#[allow(clippy::unwrap_used)]
let n = s.parse::<i32>().unwrap();
n
}
fn main() {
println!("{}", parse("123")); // 123
}
You can also drop a clippy.toml in your project to tune thresholds -- for example too-many-arguments-threshold or the cognitive-complexity limit -- when your team's taste differs from the defaults. And in CI, the phrase to know is cargo clippy -- -D warnings, which turns every clippy warning into a build failure so no lint can quietly rot in the codebase.
Having said all that, the default lint set is very well chosen, and the right instinct when clippy complains is almost always to read why and change the code, not to reach for #[allow]. Treat it as a patient, tireless reviewer who has seen every beginner mistake there is -- because that is quite literally what it is. Silencing a lint should feel like a small defeat you can justify out loud, not a reflex.
How Go, Python and C handle this
A glance sideways sharpens the picture, as always. Go pioneered the "one true format" idea that Rust adopted: gofmt is non-negotiable, ships with the toolchain, and the entire Go community formats identically -- there is not even a max_width knob to argue over, which is arguably the most Go decision imaginable. For the linting half, Go has go vet built in for likely bugs, plus the excellent third-party staticcheck. The split is roughly the same as Rust's rustfmt-plus-clippy, just spread across a couple more binaries:
// gofmt enforces layout; go vet flags this printf mismatch as a likely bug
fmt.Printf("%d\n", "not a number") // vet: arg is a string, verb %d wants an int
Python landed in the same place but took a decade longer and a lot more bloodshed to get there. For years the ecosystem juggled flake8, pylint, isort, and endless .cfg bikeshedding. Then black arrived as the deliberately un-configurable formatter (its slogan is essentially "you get one style and you will like it", very much the rustfmt philosophy), and more recently ruff consolidated linting and formatting into one blisteringly fast tool. Modern Python has finally converged on the Rust-style "format automatically, lint aggressively" workflow:
# ruff / black reformat this, and ruff's linter flags the unused import
import os # F401: imported but unused
x=1+2 # reformatted to: x = 1 + 2
C and C++ have clang-format for layout and clang-tidy for lint-style checks, and they are genuinely good tools -- but crucially they are opt-in and not standardised. Every C project picks its own .clang-format, half of them do not run a linter at all, and there is no cultural expectation that all C code looks alike. The contrast with Rust is the whole point: in Rust, cargo fmt and cargo clippy come in the box, everyone runs them, and so a random crate you open on docs.rs already looks and reads like your own. That shared baseline is worth a surprising amount, and it is one of the quiet reasons the Rust ecosystem feels so coherent.
Wrapping up
So here is the shape of it. rustfmt, driven by cargo fmt, mechanically rewrites your source to one canonical style so formatting stops being a thing anyone discusses -- configure the few knobs in rustfmt.toml and enforce it with cargo fmt --check in CI. clippy, driven by cargo clippy, layers hundreds of lints on top of the compiler that catch clumsy-but-correct code and outright bugs alike, and it teaches you idiomatic Rust in the process: return the boolean directly, iterate instead of index, prefer if let and combinators and matches!, take &str not &String, and stop cloning what you can borrow. When you truly disagree, #[allow], #[deny], #![warn(clippy::all)], and clippy.toml give you exact control -- but reach for them sparingly, because the defaults are wiser than they first appear. Wire cargo clippy -- -D warnings into CI and your codebase stays honest.
We now have, across the last stretch of episodes, an almost complete picture of what it takes to ship a crate you would not be embarrassed by: it can test itself, benchmark itself, split into a workspace, compile conditionally, generate parts of itself at build time, and now hold itself to a consistent style and a high linting bar. The one thing we have never done is put all of that together in a single, real, end-to-end project -- take a genuinely useful little library from an empty directory to something formatted, linted, tested, documented, and actually ready to hand to another person. That is a very different exercise from learning one feature at a time, and it is exactly where we are headed next ;-)
Exercises
- Take a function that uses
for i in 0..v.len()to build a newVecof each element squared, and rewrite it as an iterator chain using.iter().map(...).collect(). Runcargo clippyon both versions and note what it says about the loop. - Replace a
matchon your own three-variant enum that returnstruefor one variant andfalsefor the rest with a singlematches!call, and confirm the behaviour is identical for each variant. - Write a function that takes
&Stringand only reads it, then change the parameter to&str, and call the new version with both a&String(via&owned) and a string literal to prove&straccepts both. Add#![warn(clippy::all)]at the top and check theptr_arglint is happy.
Thanks for reading, and de groeten! ;-)