Skip to content
>_rs4ts

rust-analyzer: The Rust Language Server

20 min readupdated byAhmet ZeybekAhmet Zeybek· Jul 10, 2026

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.


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.


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 type
interface 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-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 type
use 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 = 8080
count=3, upper=["ALICE", "CAROL"], total=17

And 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.


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.rs
Error RustcHardError("E0308") from LineCol { line: 1, col: 20 } to LineCol { line: 1, col: 26 }: expected u16, found &'static str
diagnostic scan complete

That 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.

rust-analyzer shows you two tiers of diagnostics:

  1. Live, in-memory diagnostics from its own engine (type mismatches, unresolved names, syntax errors), updated keystroke-by-keystroke.
  2. Full compiler diagnostics from running cargo check (the rust-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:

.vscode/settings.json
{
"rust-analyzer.checkOnSave": true,
"rust-analyzer.check.command": "clippy"
}

Warning: Use the modern rust-analyzer.check.command key. The older rust-analyzer.checkOnSave.command (a string command nested under checkOnSave) is deprecated. Today checkOnSave is a plain boolean and the command lives in check.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.

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 kindSettingDefaultRenders
Variable typesinlayHints.typeHints.enabletruelet xlet x: i32
Parameter namesinlayHints.parameterHints.enabletruef(width, height)f(width: w, height: h)
Method chainsinlayHints.chainingHints.enabletruetype after each .method() in a chain
Closing bracesinlayHints.closingBraceHints.enabletrue} // fn main on long blocks
Lifetime elisioninlayHints.lifetimeElisionHints.enable"never"the elided 'a lifetimes
Binding modesinlayHints.bindingModeHints.enablefalseref/& inserted by pattern matching
AdjustmentsinlayHints.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 match into a complete one, generating Variant => 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 reference HashMap with no import, offers to insert use 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 (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.


ConcernTypeScript (tsserver)Rust (rust-analyzer)
ProtocolLanguage Server ProtocolLanguage Server Protocol (identical)
Shares core with CLI checker?Yes: tsserver and tsc share internalsNo: rust-analyzer has its own engine, separate from rustc
Where you get the binaryBundled with editor / typescript packagerustup component add rust-analyzer (editor manages it)
Inline type displayNice-to-have (you usually wrote the types)Essential (most types are inferred)
Background compileType errors onlyType errors plus borrow-check via cargo check/clippy
Config namespacetypescript.* / javascript.*rust-analyzer.*
Macro supportN/AExpands declarative + procedural macros to resolve names
Project modeltsconfig.jsonCargo.toml (+ cargo metadata)

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.

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.


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.


  • 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-analyzer manually 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, and expressionAdjustmentHints while 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 your tsconfig and 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.enable on; it makes everything after startup snappy.

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 OK
404 -> 404 Not Found
503 -> 500 Internal Server Error

The 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 production repo typically checks in editor settings so analysis is consistent across the team. For VS Code:

.vscode/settings.json
{
// 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:

rust-analyzer.toml
[cargo]
allTargets = true
features = ["postgres", "tracing"]
[check]
command = "clippy"

Tip: rust-analyzer.cargo.allTargets = true (the default) means completions and diagnostics also cover your #[cfg(test)] modules and examples/, so you get full IDE support inside tests. See Section 13: Testing.


Related guide sections:


Difficulty: Beginner

Objective: Build a feel for what rust-analyzer infers by enabling the hints that are off by default.

Instructions:

  1. Create a project: cargo new ra_hints && cd ra_hints.
  2. Open it in an editor with rust-analyzer installed.
  3. Add a .vscode/settings.json (or your editor’s LSP config) enabling inlayHints.expressionAdjustmentHints.enable = "always" and inlayHints.bindingModeHints.enable = true.
  4. Write the conversion code below. Observe the type hint that appears after converted and the adjustment hints around the From/into calls.
#[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 F
freezing -> 32.0 F

The settings to add:

.vscode/settings.json
{
"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:

  1. In a new or existing project, paste the non-exhaustive match below into src/main.rs.
  2. Run cargo build and read the real E0004 error.
  3. Place your cursor on the match, open the code-action menu (Ctrl+. / Cmd+.), and apply “Add missing match arms.”
  4. Fill in the generated todo!() arm with a sensible string and confirm cargo build now 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 covered

After 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:

  1. Write the word-counting program below — it compiles and runs cleanly.
  2. Set rust-analyzer.check.command to "clippy" and ensure rust-analyzer.checkOnSave is true.
  3. Introduce a small lint-able pattern (for instance, write counts.len() == 0 somewhere instead of counts.is_empty()), save, and watch Clippy’s hint appear inline — without running Clippy in a terminal.
  4. Confirm you used rust-analyzer.check.command, not the deprecated rust-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: 1
end: 1
hat: 1
the: 3

The 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.