Skip to content
rusthacks.dev
Tooling

Editing Rust in Neovim With LSP and Treesitter Setup

Neovim talks to rust-analyzer through its built-in LSP client. A short config gives completion, diagnostics, formatting and Treesitter highlighting, and rustaceanvim adds Rust extras.

14 min read
Summarize in AI
Flat illustration for the post “Editing Rust in Neovim With LSP and Treesitter Setup”, composed around tooling.

Neovim ships with a language server client, and since version 0.11 it also ships the configuration API and the default key mappings that make a plugin-free Rust setup practical. rust-analyzer provides the intelligence: type-aware hover, go to definition, references, rename, diagnostics from the compiler and from clippy, and formatting through rustfmt. This guide sets up that stack with about twenty lines of Lua and no plugins at all, then drives it in a headless Neovim, a Neovim with no screen that runs a script, to show what each feature returns. Everything below was run, and the output is real. Versions: Neovim 0.12.6, rust-analyzer 1.99.0, rustc 1.99.0, on an Apple M1 Max with macOS. Completion menus and the visual look of diagnostics need a screen, so those parts are described from the documented behaviour and are marked.

What the pieces do

Three programs cooperate, and knowing which one answers which question saves time when something breaks. Neovim is the editor and the LSP client: it starts a server, sends it the buffer's text and your requests, and shows the replies. rust-analyzer is the language server: it parses and type-checks your crate itself, quickly and incrementally, to answer hover and navigation requests. cargo check (or cargo clippy) is run by rust-analyzer in the background on save to produce the compiler's own diagnostics. So the editor never talks to rustc; the server does. That is why two sources of errors appear on screen, labelled rust-analyzer and clippy in the output further down: the first is instant and approximate, the second is slower and exact.

Install rust-analyzer with rustup, so it matches the toolchain, and confirm Neovim's version, because the configuration API used here does not exist before 0.11:

Shell
rustup component add rust-analyzer
rust-analyzer --version
nvim --version | head -1
Text
rust-analyzer 1.99.0 (b940084d 2026-09-28)
NVIM v0.12.6

Using the rustup component rather than a separate download has one useful property: rust-analyzer reports the same version string as the compiler it was built with, 1.99.0 here, so there is no mismatch between the analyser's idea of the standard library and the compiler's. A distribution package or a download from the project can lag behind a new compiler.

The whole configuration

Put this in ~/.config/nvim/init.lua (or, to try it first, in any file passed with nvim -u). It defines the server once and turns it on. The first call describes how to start and attach; the second activates it for every Rust buffer:

init.lualua
vim.lsp.config('rust_analyzer', {
  cmd = { 'rust-analyzer' },
  filetypes = { 'rust' },
  root_markers = { 'Cargo.toml', '.git' },
  settings = { ['rust-analyzer'] = { check = { command = 'clippy' } } },
})
vim.lsp.enable('rust_analyzer')

Each field has one job. cmd is the program to run. filetypes limits attachment to Rust buffers. root_markers is how Neovim decides what the project is: it walks up from the file until it finds a Cargo.toml, and that directory becomes the root passed to the server, which means a workspace with several crates should have its own Cargo.toml at the top. settings forwards options to rust-analyzer; here, check.command = 'clippy' makes the background check run clippy instead of plain cargo check, so lint warnings show up as diagnostics. There is no plugin manager and no lspconfig: in 0.11 and later, the configuration API is part of Neovim, and plugins like nvim-lspconfig mostly supply ready-made defaults for other languages.

Does it attach? Ask Neovim

A headless run is the way to test an editor setup without looking at it, and a script is a way to prove the configuration rather than assume it. I pointed Neovim at a small crate (the signed-integer parser from the VS Code guide, a library plus a binary) and ran a Lua script with nvim --headless -u init.lua -c "luafile test.lua". The script opens src/main.rs, waits for a client, and prints what it found:

test.lua (first part)lua
local function log(...) io.stdout:write(table.concat(vim.tbl_map(tostring, { ... }), ' '), '\n') end
vim.cmd('edit src/main.rs')
local ok = vim.wait(60000, function() return #vim.lsp.get_clients({ bufnr = 0 }) > 0 end, 200)
local c = vim.lsp.get_clients({ bufnr = 0 })[1]
log('attached:', ok, c and c.name, c and c.root_dir and vim.fs.basename(c.root_dir))
log('server version:', c.server_info and c.server_info.version)
log('capabilities: hover', c.server_capabilities.hoverProvider ~= nil,
    'rename', c.server_capabilities.renameProvider ~= nil,
    'inlayHint', c.server_capabilities.inlayHintProvider ~= nil)
Text
attached: true rust_analyzer nv
server version: 1.99.0 (b940084d 2026-09-28)
capabilities: hover true rename true inlayHint true

The client attached, named rust_analyzer, with the root directory nv, the folder holding Cargo.toml. The server identifies as 1.99.0. The capabilities line is the server telling the client what it can do; any feature listed as false would have no mapping to call. If attached is false in your own run, the usual causes are, in order: the file is not detected as Rust (:set filetype?), no Cargo.toml above the file so no root is found, or rust-analyzer is not on the PATH Neovim sees. The command :checkhealth vim.lsp reports the active clients and flags a missing executable.

The keys you get for free

From 0.11 on, Neovim defines default mappings for LSP actions when a server attaches, so a fresh configuration is already usable. I asked Neovim what each key is bound to in a Rust buffer after attach:

Text
K      vim.lsp.buf.hover()
grn    vim.lsp.buf.rename()
gra    vim.lsp.buf.code_action()
grr    vim.lsp.buf.references()
gri    vim.lsp.buf.implementation()
grt    vim.lsp.buf.type_definition()
gO     vim.lsp.buf.document_symbol()
<C-S>  vim.lsp.buf.signature_help()

Notice what is missing from that list: go to definition. In recent Neovim it is not a gd mapping but the editor's tag stack: for a buffer with an attached server, Ctrl-] jumps to the definition and Ctrl-t returns, using the server through the tagfunc option. I did not press those keys in the headless run; I called the underlying request instead, as the next section shows. Most people add gd as a habit, and it is one line in a callback.

What each request returns

The script places the cursor on the call to parse_signed in main.rs (line 7, column 21) and sends the same protocol requests the keys send. rust-analyzer answers null until it has indexed the project, so the helper retries until it gets a real answer; in an interactive session you notice this as hover that does nothing for the first seconds in a big workspace. These are the results.

Hover returned the crate, the full signature and the doc comment, as Markdown:

Text
```rust
app
```
```rust
pub fn parse_signed(input: &str) -> Result<i64, String>
```
---
Parses a signed decimal integer, allowing a leading + or -.

That is the documentation comment from lib.rs, pulled across files by the server's index. In the editor it appears in a floating window when you press K. It is also a quick way to see an inferred type: hover over a let binding and you get the type the compiler assigned.

Definition returned lib.rs line 1, the function in the other file of the crate. References returned 4: the definition, the call in main.rs, and the two uses in the unit tests inside lib.rs. The count includes the declaration because the request asks for it (includeDeclaration = true); grr shows the list in the quickfix window, and the quickfix list is the way to review a rename before you make it.

Rename is the request that shows why a language server beats a search-and-replace. Asked to rename parse_signed to parse_number, the server returned a workspace edit touching two files:

Text
rename parse_signed -> parse_number edits: lib.rs x3, main.rs x1

Three edits in lib.rs (the definition and the two test calls) and one in main.rs, four in total, each at a real use of the function. Text search would also have rewritten a comment, a string and any other function that happens to contain the name; the server rewrites only the symbol. The edits are returned and not applied by the request itself, so in the editor grn asks for the new name and then applies them across all open and unopened files.

Diagnostics: two sources, two speeds

To see diagnostics, the script writes a bad line into the buffer, saves, and waits until the client reports something. First a type error, let unused_value: i32 = "text";:

Text
diagnostic: line 5 rust-analyzer expected i32, found &'static str

rust-analyzer's own type checker found it as soon as the text changed, without running cargo. That is the fast, approximate source. A second run replaced the error with code that compiles but that clippy dislikes, let v = vec![1]; let _n = v.len() == 0;. This time the diagnostics came from the background cargo clippy run triggered by the save, a couple of seconds later:

Text
diagnostic: line 5 clippy length comparison to zero  ...  `#[warn(clippy::len_zero)]` on by default
diagnostic: line 5 clippy using `is_empty` is clearer and more explicit: `v.is_empty()`

(The first message is shortened where marked; the full text includes a link to the lint's documentation.) Two things matter here. The source field says which tool spoke, which is how you tell an approximation from the compiler. And clippy's output is the reason for the setting at the top: with the default check.command = "check", the second case would have produced no diagnostic at all. A type error also hides clippy's findings, since clippy does not run on code that does not compile; fix the errors first and the lints appear.

Formatting through the server

rust-analyzer formats by calling rustfmt, and Neovim exposes that as vim.lsp.buf.format(). I replaced the buffer with deliberately cramped code and formatted it:

lua
vim.api.nvim_buf_set_lines(0, 0, -1, false, { 'fn main(){let x=1;println!("{x}");}' })
vim.lsp.buf.format({ async = false, timeout_ms = 20000 })
Text
fn main() {
    let x = 1;
    println!("{x}");
}

That is rustfmt's output, produced through the language server, using the same rustfmt.toml the command line would use. Because it goes through the protocol, the same call formats other languages with their servers, which is the point of having one mapping.

A useful init.lua

The defaults are enough to start. A few additions make daily use smoother. This is the full file I would keep: it adds format on save, a gd mapping, inlay hints and a diagnostic style, all through LspAttach, which fires once for each buffer that gets a client. I ran the first block of this file in the headless tests above; the extra mappings and the visual settings below are standard Neovim API that I did not exercise in a headless session, so check them in your editor:

init.lua (extended)lua
vim.lsp.config('rust_analyzer', {
  cmd = { 'rust-analyzer' },
  filetypes = { 'rust' },
  root_markers = { 'Cargo.toml', '.git' },
  settings = {
    ['rust-analyzer'] = {
      check = { command = 'clippy' },
      cargo = { allFeatures = true },
    },
  },
})
vim.lsp.enable('rust_analyzer')

vim.diagnostic.config({ virtual_text = true, severity_sort = true })

vim.api.nvim_create_autocmd('LspAttach', {
  callback = function(args)
    local client = vim.lsp.get_client_by_id(args.data.client_id)
    local opts = { buffer = args.buf }
    vim.keymap.set('n', 'gd', vim.lsp.buf.definition, opts)
    vim.keymap.set('n', '<leader>f', function() vim.lsp.buf.format({ async = true }) end, opts)
    if client and client:supports_method('textDocument/inlayHint') then
      vim.lsp.inlay_hint.enable(true, { bufnr = args.buf })
    end
    if client and client:supports_method('textDocument/completion') then
      vim.lsp.completion.enable(true, client.id, args.buf, { autotrigger = true })
    end
  end,
})

vim.api.nvim_create_autocmd('BufWritePre', {
  pattern = '*.rs',
  callback = function() vim.lsp.buf.format({ async = false }) end,
})

Walk through the additions. cargo.allFeatures = true makes the analyser (and the check) build with every feature enabled, which stops code under #[cfg(feature = "x")] from being greyed out, at the cost of checking features you may not ship. vim.diagnostic.config puts the message at the end of the line and sorts errors above warnings. In the LspAttach callback, client:supports_method guards each feature, so the same file works with servers that lack them. Inlay hints draw inferred types and parameter names as faint text, and are turned on per buffer. Completion uses the built-in client: with autotrigger the menu opens as you type. The BufWritePre autocommand runs the server's formatter before each save of a Rust file, so formatting is never a separate step. When I queried inlay hints for the first twenty lines of main.rs in the headless run, the server advertised support but the response was an empty list, and I did not investigate why; I cannot show you hint output from this guide.

Performance and the first minute

The first request after opening a project is slow, and everything after is fast, because rust-analyzer must load the project's metadata, index the standard library and your dependencies, and then run a first cargo clippy. The helper in my script waited with retries for that reason, and your editor does the same when hover returns nothing in the first seconds. On large workspaces three settings help. Set the check command to check rather than clippy if the lint run is slow, and run clippy from a terminal or CI instead. Keep the target directory used by the analyser separate from your terminal builds with rust-analyzer.cargo.targetDir = true, so the two do not invalidate each other's cache and block each other on the cargo lock. And exclude generated directories with files.excludeDirs if the index feels heavy. I did not benchmark these on a large project; they are the commonly suggested knobs, and your own measurements decide.

When it does not work: ask Neovim

Most failures in an LSP setup are one of three things, and the editor can tell you which. The health check lists every active client, the command it was started with, the root directory it chose and the settings it was sent, which is the whole configuration as the server received it, not as you remember writing it. This is the report from the headless session, trimmed to the parts that matter:

Text
vim.lsp:
- LSP log level : WARN
- Log path: /Users/petehouston/.local/state/nvim/lsp.log
vim.lsp: Active Clients ~
- rust_analyzer (id: 1)
  - Version: 1.99.0 (b940084d 2026-09-28)
  - Root directory: .../scratchpad/nv
  - Command: { "rust-analyzer" }
  - Settings: { ["rust-analyzer"] = { check = { command = "clippy" } } }
  - Attached buffers: 1
vim.lsp: Enabled Configurations ~
- rust_analyzer:
  - filetypes: rust
  - root_markers: { "Cargo.toml", ".git" }

Read it as a checklist. If Active Clients is empty, the server never started or never attached: check that the file's filetype matches filetypes, that a root marker exists above the file and that the command is on the PATH Neovim inherited (a GUI launcher often has a shorter PATH than your shell). If the client is listed but features are missing, compare Settings with what you intended; a typo in a nested key is silently ignored by the server. If the Root directory is not the folder you expected, the analyser is indexing the wrong project, which shows up as "cannot find module" errors on code that compiles: in a workspace with several Cargo.toml files, the nearest one wins unless a root marker above it matches first. The log file named in the report holds the protocol traffic at higher levels. Turn it up with vim.lsp.log.set_level('debug') for one session, reproduce the problem, read the file, and turn it back down, because debug logging records every document change and grows quickly.

Two habits keep the setup healthy over time. Update rust-analyzer with the toolchain (rustup update), since the component is versioned with the compiler and a mismatched analyser is the most common cause of wrong "cannot find" errors after a compiler upgrade. And keep the editor configuration in version control next to your other dotfiles; the entire Rust setup here is under thirty lines of Lua, so a clean machine reaches a working editor in the time it takes to install the component and copy one file.

Workspaces and multiple crates

A repository with several crates under one [workspace] is where the root choice matters most. If the top-level Cargo.toml lists the members, open any file and the analyser should load the whole workspace, so go to definition and rename work across crates. Neovim picks the root by the markers in order, taking the first directory upward that contains any of them. With Cargo.toml listed first and each member crate having its own, the nearest member wins, and the analyser then sees only that crate and its dependencies, not its siblings. For rust-analyzer this is usually harmless, because it reads the workspace from the manifest and discovers the other members itself, but if rename misses a sibling crate, the root is the first thing to check in the health report. The reliable fix is to put the workspace root marker first by testing for [workspace] in a function passed as root_dir, and the lazy fix is to start Neovim from the workspace root.

Where this setup stops

  • Everything visual was not seen. Floating windows, virtual text, the completion menu and inlay hints need a screen. The tests prove the server returns the right data to the client, not how it looks.
  • Debugging is separate. The language server does not run your program. Stepping through code in Neovim needs the debug adapter protocol and a plugin such as nvim-dap, which this guide does not cover.
  • Tests and run commands. VS Code shows a "Run" lens above each test, which comes from rust-analyzer's code lens support. Neovim lets you list the available actions with gra but has no lens by default; running a single test is usually a terminal command or a plugin.
  • It depends on the Neovim version. The configuration API and the default keys in this guide need 0.11 or later and were run on 0.12.6. On 0.10 you configure the client through vim.lsp.start or a plugin.
  • Macros and build scripts cost time. A project with heavy procedural macros or large build scripts makes the first load noticeably slower, and a proc-macro crash can disable highlighting for that crate until reload.
  • One platform. Everything ran on macOS, using the rustup rust-analyzer. A Linux package or a different install path changes the cmd and nothing else.

The measured result is a plugin-free setup that attached on the first try, returned a hover with the doc comment, found four references, planned a four-edit rename across two files, reported a type error and two clippy findings, and formatted through rustfmt. The editing experience comes from a few lines of configuration and a server that already knows your crate.

Taggedneovim
Share

Keep reading

Flat illustration for the post “Incident Response for Rust Services a Short Runbook”, composed around deploy.
DevOps

Incident Response for Rust Services a Short Runbook

A one-page runbook for when a Rust service misbehaves: stabilize first, check recent changes, read the four golden signals, find the failing dependency, and use the Rust-specific evidence: panics, memory, executor stalls.

4 min read
Flat illustration for the post “Debugging Async Backtraces and Task Dumps in Tokio”, composed around async.
Tooling

Debugging Async Backtraces and Task Dumps in Tokio

A hung async service has no useful thread backtrace. Tokio task dumps capture the await stack of every task. Enable tokio_unstable and taskdump, trigger a dump from a signal or endpoint, and read where each task is parked.

4 min read
Flat illustration for the post “Rust Error Too Many Open Files and Raising the Limit”, composed around deploy.
Tooling

Rust Error Too Many Open Files and Raising the Limit

Os error 24 means the process hit its file descriptor limit. Check the limit and the live count, find the leak or the missing bound, raise the limit in systemd, Docker or code, and cap concurrency so it stays low.

4 min read

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.