rust-analyzer: The Rust Language Server
rust-analyzer is the official Language Server Protocol (LSP) implementation for Rust. It is the engine behind autocomplete, go-to-definition, inline type display, refactors, and red squiggles in your editor. If you used the TypeScript Language Service (the brains inside VS Code’s TypeScript support), rust-analyzer is its direct counterpart for Rust.
Quick Overview
Section titled “Quick Overview”When you edit .ts files, VS Code talks to tsserver (the TypeScript Language Service), which gives you completions, hovers, rename, and inline errors. Rust has the exact same architecture: your editor talks to rust-analyzer over the Language Server Protocol, and rust-analyzer continuously parses and type-checks your crate in the background.
Why it matters to a TypeScript/JavaScript developer: Rust’s compiler is strict, and a tight feedback loop is what makes that strictness pleasant instead of painful. rust-analyzer shows you the inferred type of every let binding, surfaces E0308 type mismatches as you type (not on save), and offers one-keystroke code actions to fill in match arms or implement a trait. The repository’s pinned verification toolchain uses the 2024 edition, and rust-analyzer ships as an official rustup component that tracks it.
TypeScript/JavaScript Example
Section titled “TypeScript/JavaScript Example”In a TypeScript project, the language service is invisible but always running. You never install it directly; it ships with VS Code (or with the typescript package your editor points at). It powers everything that isn’t the type-checker on the command line:
// orders.ts — what tsserver does for you while you typeinterface Order { id: string; total: number; items: string[];}
function summarize(orders: Order[]) { // Hover over `expensive` → tsserver shows: const expensive: Order[] const expensive = orders.filter((o) => o.total > 100);
// Type `.` after `expensive` → completion list of Array methods // Type `o.` inside the callback → completion list of Order fields const ids = expensive.map((o) => o.id);
// Inlay hints (an opt-in editor setting) render the inferred types inline: // const ids: string[] return ids;}
// Red squiggle appears immediately, before you save or run `tsc`:const broken: number = "not a number";// ^^^^^^ Type 'string' is not assignable to type 'number'. ts(2322)You configure tsserver through .vscode/settings.json (and the typescript.* / javascript.* keys), e.g. turning inlay hints on:
// .vscode/settings.json (TypeScript side){ "typescript.inlayHints.parameterNames.enabled": "all", "typescript.inlayHints.variableTypes.enabled": true}Rust Equivalent
Section titled “Rust Equivalent”rust-analyzer plays the identical role. You install it once as a rustup component (your editor extension usually does this for you), and configure it through the rust-analyzer.* keys:
// src/main.rs — what rust-analyzer does for you while you typeuse std::collections::HashMap;
fn parse_port(raw: &str) -> Result<u16, std::num::ParseIntError> { raw.trim().parse()}
fn main() { // Inlay hint renders `: i32` after `count` (i32 is the default integer). let count = 3; let names = vec!["alice", "bob", "carol"];
// Chaining hints reveal the iterator's item type at each `.method()`: let upper: Vec<String> = names .iter() .filter(|n| n.len() > 3) .map(|n| n.to_uppercase()) .collect();
let mut scores = HashMap::new(); scores.insert("alice", 10); scores.insert("bob", 7);
// Inlay hint renders `: i32` after `total`. let total: i32 = scores.values().sum();
match parse_port("8080") { Ok(port) => println!("port = {port}"), Err(e) => println!("bad port: {e}"), }
println!("count={count}, upper={upper:?}, total={total}");}Running this prints:
port = 8080count=3, upper=["ALICE", "CAROL"], total=17And you enable the inline type display through rust-analyzer.* settings, the direct analog of the typescript.inlayHints.* keys:
// .vscode/settings.json (Rust side) — these are ON by default{ "rust-analyzer.inlayHints.typeHints.enable": true, "rust-analyzer.inlayHints.parameterHints.enable": true, "rust-analyzer.inlayHints.chainingHints.enable": true}Note: Installing rust-analyzer is a one-liner:
rustup component add rust-analyzer. Most editor extensions (the VS Code “rust-analyzer” extension, the Zed/Neovim LSP clients) download and manage it for you, so you rarely run this by hand. See Setting Up VS Code for Rust for the full editor walkthrough.
Detailed Explanation
Section titled “Detailed Explanation”What an LSP server actually is
Section titled “What an LSP server actually is”The Language Server Protocol is a JSON-RPC contract Microsoft designed so that one language implementation can serve every editor. tsserver and rust-analyzer both speak it. Your editor (the client) sends requests like “what completions are valid at line 12, column 8?” and the server answers. This is why the same rust-analyzer binary powers VS Code, Neovim, Helix, Zed, and Emacs identically: the intelligence lives in the server, not the editor.
rust-analyzer is its own type-checker, not a cargo check wrapper
Section titled “rust-analyzer is its own type-checker, not a cargo check wrapper”This is the single most important thing to understand, and it is subtler than the TypeScript case. tsserver and tsc share the same type-checking core. rust-analyzer, by contrast, contains its own parser, name resolver, and trait-solving type inference engine, separate from rustc. That is what lets it give you instant feedback on a half-typed expression that rustc would refuse to even parse.
You can see rust-analyzer’s independent analysis directly. Running its diagnostics engine over a project with a type error produces (real output, abbreviated):
processing crate: probe, module: .../src/main.rsError RustcHardError("E0308") from LineCol { line: 1, col: 20 } to LineCol { line: 1, col: 26 }: expected u16, found &'static str
diagnostic scan completeThat expected u16, found &'static str came from rust-analyzer’s own inference, before cargo ran. rust-analyzer also runs the real compiler in the background (cargo check by default) and merges those richer diagnostics in — see the next section.
check on save vs. live diagnostics
Section titled “check on save vs. live diagnostics”rust-analyzer shows you two tiers of diagnostics:
- Live, in-memory diagnostics from its own engine (type mismatches, unresolved names, syntax errors), updated keystroke-by-keystroke.
- Full compiler diagnostics from running
cargo check(therust-analyzer.check.command, default"check") when you save. These include borrow-checker errors and every Clippy lint if you point it at Clippy.
The settings that control tier 2 are rust-analyzer.checkOnSave (default true) and rust-analyzer.check.command (default "check"). A common upgrade is to run Clippy instead of plain check:
{ "rust-analyzer.checkOnSave": true, "rust-analyzer.check.command": "clippy"}Warning: Use the modern
rust-analyzer.check.commandkey. The olderrust-analyzer.checkOnSave.command(a string command nested undercheckOnSave) is deprecated. TodaycheckOnSaveis a plain boolean and the command lives incheck.command. The deprecated form is one of the most common stale-blog-post traps; Setting Up VS Code for Rust covers it in detail. Wiring Clippy in is covered in Linting with Clippy and Common Clippy Lints, Explained.
Inlay hints: Rust’s killer LSP feature
Section titled “Inlay hints: Rust’s killer LSP feature”Because Rust infers most types, the inlay hints rust-analyzer draws are far more valuable than in TypeScript, where you usually wrote the types yourself. Hints are editor decorations: they are not part of your file, they never get saved, and they vanish if you open the file in cat. The ones enabled by default include:
| Hint kind | Setting | Default | Renders |
|---|---|---|---|
| Variable types | inlayHints.typeHints.enable | true | let x → let x: i32 |
| Parameter names | inlayHints.parameterHints.enable | true | f(width, height) → f(width: w, height: h) |
| Method chains | inlayHints.chainingHints.enable | true | type after each .method() in a chain |
| Closing braces | inlayHints.closingBraceHints.enable | true | } // fn main on long blocks |
| Lifetime elision | inlayHints.lifetimeElisionHints.enable | "never" | the elided 'a lifetimes |
| Binding modes | inlayHints.bindingModeHints.enable | false | ref/& inserted by pattern matching |
| Adjustments | inlayHints.expressionAdjustmentHints.enable | "never" | auto-deref/.borrow() the compiler inserts |
The hints that are off by default (lifetime elision, binding modes, expression adjustments) are deeper learning aids. Turning them on for a few weeks is one of the fastest ways to internalize ownership and borrowing — concepts covered in Section 05: Ownership. For example:
// .vscode/settings.json — extra hints that teach you the borrow rules{ "rust-analyzer.inlayHints.lifetimeElisionHints.enable": "always", "rust-analyzer.inlayHints.bindingModeHints.enable": true, "rust-analyzer.inlayHints.expressionAdjustmentHints.enable": "always"}Code actions (quick fixes / “assists”)
Section titled “Code actions (quick fixes / “assists”)”Code actions are the lightbulb menu (Ctrl+. / Cmd+. in VS Code). They are TypeScript’s “Quick Fix” and “Refactor” menus by another name. rust-analyzer ships hundreds; the ones you will reach for daily:
- Add missing match arms — turns a non-exhaustive
matchinto a complete one, generatingVariant => todo!()for each missing case. - Implement missing members — after
impl SomeTrait for MyType {, fills in every required method signature. - Add
use(auto-import) — when you referenceHashMapwith no import, offers to insertuse std::collections::HashMap;. - Extract into function / variable — select an expression and hoist it.
- Wrap return type in
Result/ Convert to?— restructure error handling (see Section 08: Error Handling). - Fill struct fields — expand
Config { .. }to list every field.
The “fill” actions use rust-analyzer.assist.expressionFillDefault (default "todo") to decide whether placeholders are todo!() or Default::default().
Auto-import and import organization
Section titled “Auto-import and import organization”Auto-import (rust-analyzer.completion.autoimport.enable, default true) is like TypeScript’s “auto-import on completion”: pick Duration from the completion list and rust-analyzer adds use std::time::Duration; for you. How it groups those imports is governed by rust-analyzer.imports.granularity.group (default "crate"), which merges imports from the same crate into one use block, the rust-analyzer equivalent of an import-sorting ESLint rule.
Key Differences
Section titled “Key Differences”| Concern | TypeScript (tsserver) | Rust (rust-analyzer) |
|---|---|---|
| Protocol | Language Server Protocol | Language Server Protocol (identical) |
| Shares core with CLI checker? | Yes: tsserver and tsc share internals | No: rust-analyzer has its own engine, separate from rustc |
| Where you get the binary | Bundled with editor / typescript package | rustup component add rust-analyzer (editor manages it) |
| Inline type display | Nice-to-have (you usually wrote the types) | Essential (most types are inferred) |
| Background compile | Type errors only | Type errors plus borrow-check via cargo check/clippy |
| Config namespace | typescript.* / javascript.* | rust-analyzer.* |
| Macro support | N/A | Expands declarative + procedural macros to resolve names |
| Project model | tsconfig.json | Cargo.toml (+ cargo metadata) |
The trait-solving difference
Section titled “The trait-solving difference”tsserver resolves structural types: if two objects have the same shape, they are compatible. rust-analyzer must solve trait obligations — “does Vec<String> implement Iterator?”, “is there a From<Celsius> impl in scope?”. This is why rust-analyzer occasionally pauses on a freshly opened large project: it is priming a cache of trait resolutions (rust-analyzer.cachePriming.enable, default true). Once primed, completion and hover are instant.
Macros are part of the language model
Section titled “Macros are part of the language model”Rust macros generate code, so rust-analyzer must expand them to know what names exist. rust-analyzer.procMacro.enable (default true) lets it compile and run your procedural macros (like #[derive(Serialize)] from Section 15: Serialization). This has no TypeScript analog (TypeScript has no macro system), and it is why a derive can give you working completions on generated methods. The “Expand macro recursively” command shows you the generated code; macros themselves are covered in Section 14: Macros.
Common Pitfalls
Section titled “Common Pitfalls”Pitfall 1: Expecting borrow errors to appear instantly
Section titled “Pitfall 1: Expecting borrow errors to appear instantly”rust-analyzer’s live engine catches type errors keystroke-by-keystroke, but full borrow-checker errors only arrive after the background cargo check finishes (on save by default). New users sometimes write borrow-violating code, see no red squiggle for a second or two, and assume it compiles. It does not — wait for the save-triggered check, or run cargo check yourself.
For example, this real type mismatch does show up live, courtesy of rust-analyzer’s own inference:
fn main() { let port: u16 = "8080"; // does not compile (error[E0308]: mismatched types) println!("{port}");}The real compiler message rust-analyzer surfaces:
error[E0308]: mismatched types --> src/main.rs:2:21 |2 | let port: u16 = "8080"; | --- ^^^^^^ expected `u16`, found `&str` | | | expected due to this
For more information about this error, try `rustc --explain E0308`.Pitfall 2: Trusting inlay hints as if they were source code
Section titled “Pitfall 2: Trusting inlay hints as if they were source code”Inlay hints are rendered decorations, not text in your file. A reader who pastes a screenshot of hinted code into a .rs file will get a syntax error, because let x: i32 = 3 was actually let x = 3 with a fake : i32. VS Code lets you “accept” a hint to materialize it into real code, but until you do, it is purely visual. (TypeScript inlay hints behave the same way; they just matter less there.)
Pitfall 3: “Add missing match arms” is a fix for a real error
Section titled “Pitfall 3: “Add missing match arms” is a fix for a real error”When you write a non-exhaustive match, the code action is offered because the code does not compile. The underlying error is real:
enum Status { Ok, NotFound, ServerError,}
fn label(s: Status) -> &'static str { // does not compile (error[E0004]: non-exhaustive patterns) match s { Status::Ok => "ok", Status::NotFound => "not found", }}
fn main() { println!("{}", label(Status::Ok));}Real compiler output:
error[E0004]: non-exhaustive patterns: `Status::ServerError` not covered --> src/main.rs:8:11 |8 | match s { | ^ pattern `Status::ServerError` not covered...help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown |10~ Status::NotFound => "not found",11~ Status::ServerError => todo!(), |The “Add missing match arms” assist inserts exactly that Status::ServerError => todo!() arm for you.
Pitfall 4: Stale analysis after editing Cargo.toml
Section titled “Pitfall 4: Stale analysis after editing Cargo.toml”If completions for a freshly added dependency don’t appear, rust-analyzer may not have reloaded the workspace. rust-analyzer.cargo.autoreload (default true) usually handles it, but after editing Cargo.toml by hand you can force it with the “rust-analyzer: Restart server” command (or “Reload Workspace”). This is the analog of restarting tsserver when tsconfig.json changes don’t take effect.
Pitfall 5: Missing rust-src breaks standard-library navigation
Section titled “Pitfall 5: Missing rust-src breaks standard-library navigation”Go-to-definition into std (e.g. jumping into Vec::push) needs the standard library source, which ships in the rust-src component. rustup installs it for you in most setups, but if std hovers show “no definition found,” run rustup component add rust-src.
Best Practices
Section titled “Best Practices”- Let your editor extension manage the binary. The VS Code “rust-analyzer” extension (and the Neovim/Zed/Helix LSP integrations) keep rust-analyzer in lockstep with your toolchain. Only run
rustup component add rust-analyzermanually if you are wiring up a bare-bones editor. See Setting Up VS Code for Rust. - Point on-save checks at Clippy once you are comfortable:
"rust-analyzer.check.command": "clippy"gives you lints inline without a separate run. Details in Linting with Clippy. - Use the extra inlay hints as a learning curriculum. Turn on
lifetimeElisionHints,bindingModeHints, andexpressionAdjustmentHintswhile learning ownership; turn them off once the rules are second nature. - Commit a
.vscode/settings.json(or a checked-in editor config) so the whole team gets identical analysis behavior: the equivalent of committing yourtsconfigand ESLint setup. - Learn three keybindings: Go to Definition (
F12), Quick Fix / code actions (Ctrl+./Cmd+.), and Rename Symbol (F2). They cover 80% of daily LSP value. - Prefer per-project config for monorepos. A checked-in
rust-analyzer.toml(or.vscode/settings.json) lets a workspace declare its own features and check command, instead of relying on each developer’s global settings. - Don’t fight cache priming. The brief startup pause on big projects is rust-analyzer building its trait-resolution cache. Leave
cachePriming.enableon; it makes everything after startup snappy.
Real-World Example
Section titled “Real-World Example”Here is the kind of code where rust-analyzer earns its keep: a small status-code module with a trait impl and an exhaustive match. As you write it, rust-analyzer’s “Implement missing members” fills the Display skeleton, “Add missing match arms” completes the match, and inlay hints confirm the inferred types throughout.
use std::fmt;
/// A subset of HTTP statuses for a tiny router.#[derive(Debug, Clone, Copy, PartialEq)]enum Status { Ok, NotFound, ServerError,}
impl fmt::Display for Status { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { // rust-analyzer's "Add missing match arms" generated this skeleton, // then warned (live) until every arm was covered. let text = match self { Status::Ok => "200 OK", Status::NotFound => "404 Not Found", Status::ServerError => "500 Internal Server Error", }; write!(f, "{text}") }}
fn classify(code: u16) -> Status { match code { 200..=299 => Status::Ok, 404 => Status::NotFound, _ => Status::ServerError, }}
fn main() { for code in [200, 404, 503] { // Inlay hints show `code: u16` and the `Status` returned by classify. println!("{code} -> {}", classify(code)); }}Compiling and running prints:
200 -> 200 OK404 -> 404 Not Found503 -> 500 Internal Server ErrorThe workflow that produced it: you typed impl fmt::Display for Status {, pressed Ctrl+., and chose “Implement missing members” to generate the fn fmt stub. Inside it you wrote match self {, and the live diagnostic flagged the non-exhaustive match (E0004) until “Add missing match arms” filled in Status::ServerError. Throughout, chaining and type hints confirmed you were holding a Status, not a &Status or an Option<Status>.
A team-wide configuration
Section titled “A team-wide configuration”A production repo typically checks in editor settings so analysis is consistent across the team. For VS Code:
{ // Run Clippy on save instead of plain `cargo check`. "rust-analyzer.checkOnSave": true, "rust-analyzer.check.command": "clippy",
// Analyze every target (bins, examples, tests, benches), not just the default. "rust-analyzer.cargo.allTargets": true,
// Build with the feature flags the team uses day to day. "rust-analyzer.cargo.features": ["postgres", "tracing"],
// Group auto-imports per crate (one `use` block per crate). "rust-analyzer.imports.granularity.group": "crate"}Editor-independent settings can instead live in a rust-analyzer.toml at the workspace root, which the server reads regardless of which editor each developer uses:
[cargo]allTargets = truefeatures = ["postgres", "tracing"]
[check]command = "clippy"Tip:
rust-analyzer.cargo.allTargets = true(the default) means completions and diagnostics also cover your#[cfg(test)]modules andexamples/, so you get full IDE support inside tests. See Section 13: Testing.
Further Reading
Section titled “Further Reading”- rust-analyzer User Manual — the authoritative reference for every feature and setting.
- rust-analyzer Configuration reference — the full list of
rust-analyzer.*keys with defaults. - Language Server Protocol specification — the JSON-RPC contract shared with tsserver.
- The
rust-analyzerrustup component — how it ships with the toolchain.
Related guide sections:
- Setting Up VS Code for Rust: installing the extension and the modern
check.commandsetup. - Linting with Clippy and Common Clippy Lints, Explained: wiring Clippy into on-save checks.
- Formatting with rustfmt — rustfmt integration (format-on-save) alongside rust-analyzer.
- Debugging Rust: the CodeLLDB flow that pairs with rust-analyzer’s “Run”/“Debug” code lenses.
- Cargo Deep Dive —
cargo metadata, the project model rust-analyzer loads. - Section 05: Ownership: the concepts the extra inlay hints help you learn.
- Section 14: Macros — what “Expand macro recursively” reveals.
- Section 25: Advanced Topics: where deeper trait and type machinery is covered.
Exercises
Section titled “Exercises”Exercise 1: Turn on the teaching hints
Section titled “Exercise 1: Turn on the teaching hints”Difficulty: Beginner
Objective: Build a feel for what rust-analyzer infers by enabling the hints that are off by default.
Instructions:
- Create a project:
cargo new ra_hints && cd ra_hints. - Open it in an editor with rust-analyzer installed.
- Add a
.vscode/settings.json(or your editor’s LSP config) enablinginlayHints.expressionAdjustmentHints.enable = "always"andinlayHints.bindingModeHints.enable = true. - Write the conversion code below. Observe the type hint that appears after
convertedand the adjustment hints around theFrom/intocalls.
#[derive(Debug)]struct Celsius(f64);
#[derive(Debug)]struct Fahrenheit(f64);
impl From<Celsius> for Fahrenheit { fn from(c: Celsius) -> Self { /* ??? convert and return a Fahrenheit */ }}
fn main() { let body = Celsius(37.0); let converted = Fahrenheit::from(body); let freezing: Fahrenheit = Celsius(0.0).into(); println!("body -> {:.1} F", converted.0); println!("freezing -> {:.1} F", freezing.0);}Solution
#[derive(Debug)]struct Celsius(f64);
#[derive(Debug)]struct Fahrenheit(f64);
// rust-analyzer's "Implement missing members" generates the `fn from` stub// after you type `impl From<Celsius> for Fahrenheit {`.impl From<Celsius> for Fahrenheit { fn from(c: Celsius) -> Self { Fahrenheit(c.0 * 9.0 / 5.0 + 32.0) }}
fn main() { let body = Celsius(37.0); // Inlay hint renders `: Fahrenheit` after `converted`. let converted = Fahrenheit::from(body); // `.into()` works because `From` implies `Into`; the hint shows the target type. let freezing: Fahrenheit = Celsius(0.0).into(); println!("body -> {:.1} F", converted.0); println!("freezing -> {:.1} F", freezing.0);}Running prints:
body -> 98.6 Ffreezing -> 32.0 FThe settings to add:
{ "rust-analyzer.inlayHints.expressionAdjustmentHints.enable": "always", "rust-analyzer.inlayHints.bindingModeHints.enable": true}Exercise 2: Trigger and read a real code action
Section titled “Exercise 2: Trigger and read a real code action”Difficulty: Intermediate
Objective: Use the “Add missing match arms” assist on code that genuinely does not compile, and confirm the resulting error went away.
Instructions:
- In a new or existing project, paste the non-exhaustive
matchbelow intosrc/main.rs. - Run
cargo buildand read the realE0004error. - Place your cursor on the
match, open the code-action menu (Ctrl+./Cmd+.), and apply “Add missing match arms.” - Fill in the generated
todo!()arm with a sensible string and confirmcargo buildnow succeeds.
enum Status { Ok, NotFound, ServerError,}
fn label(s: Status) -> &'static str { match s { Status::Ok => "ok", Status::NotFound => "not found", }}
fn main() { println!("{}", label(Status::Ok));}Solution
Before the fix, cargo build reports (real output):
error[E0004]: non-exhaustive patterns: `Status::ServerError` not covered --> src/main.rs:8:11 |8 | match s { | ^ pattern `Status::ServerError` not coveredAfter applying the assist and filling the arm:
enum Status { Ok, NotFound, ServerError,}
fn label(s: Status) -> &'static str { match s { Status::Ok => "ok", Status::NotFound => "not found", Status::ServerError => "server error", }}
fn main() { println!("{}", label(Status::Ok));}Now cargo build succeeds and cargo run prints ok. The lesson: the lightbulb appeared because the code was broken — code actions are fixes for real diagnostics, not cosmetic helpers.
Exercise 3: Switch on-save checks from check to clippy
Section titled “Exercise 3: Switch on-save checks from check to clippy”Difficulty: Intermediate
Objective: Get Clippy lints inline by reconfiguring rust-analyzer’s background check command, using the modern key.
Instructions:
- Write the word-counting program below — it compiles and runs cleanly.
- Set
rust-analyzer.check.commandto"clippy"and ensurerust-analyzer.checkOnSaveistrue. - Introduce a small lint-able pattern (for instance, write
counts.len() == 0somewhere instead ofcounts.is_empty()), save, and watch Clippy’s hint appear inline — without running Clippy in a terminal. - Confirm you used
rust-analyzer.check.command, not the deprecatedrust-analyzer.checkOnSave.command.
use std::collections::BTreeMap;
fn word_counts(text: &str) -> BTreeMap<String, usize> { // TODO: count each lowercased word /* ??? */}
fn main() { let counts = word_counts("the cat the hat THE end"); for (word, n) in &counts { println!("{word}: {n}"); }}Solution
use std::collections::BTreeMap;
fn word_counts(text: &str) -> BTreeMap<String, usize> { let mut counts = BTreeMap::new(); for word in text.split_whitespace() { let key = word.to_lowercase(); *counts.entry(key).or_insert(0) += 1; } counts}
fn main() { let counts = word_counts("the cat the hat THE end"); for (word, n) in &counts { println!("{word}: {n}"); }}Running prints (a BTreeMap iterates in sorted key order):
cat: 1end: 1hat: 1the: 3The configuration that makes Clippy run on save:
// .vscode/settings.json — the modern, correct keys{ "rust-analyzer.checkOnSave": true, "rust-analyzer.check.command": "clippy"}The deprecated form to avoid:
// Deprecated: `checkOnSave.command` is no longer the place for the command.{ "rust-analyzer.checkOnSave": { "command": "clippy" }}With Clippy wired in, writing counts.len() == 0 triggers the clippy::len_zero lint inline, suggesting counts.is_empty(). See Common Clippy Lints, Explained for that and other common lints.