How to Set Up Neovim for Rust Development

Configure Neovim for Rust development by setting up rust-analyzer with nvim-lspconfig to enable code intelligence and inlay hints.

The missing translator

You open Neovim, paste a fresh Rust function, and stare at a blank screen. No autocomplete. No type hints. No red squiggles when you miss a semicolon. It feels like coding in the dark. The problem is not Neovim. It is not Rust. It is the missing translator between your editor and the compiler.

Modern text editors do not understand programming languages natively. They rely on a Language Server Protocol, or LSP. Think of the LSP as a dedicated assistant that sits between your editor and the actual compiler. You type a letter, the editor asks the assistant what could come next. The assistant runs a lightweight analysis, checks your types, and sends back a list of suggestions. For Rust, that assistant is rust-analyzer. It is the official language server maintained by the Rust team. Neovim just needs a bridge to talk to it. That bridge is nvim-lspconfig.

Wire them together correctly and your editor gains type inference, go-to-definition, rename refactoring, and clippy warnings. Wire them together incorrectly and you get a silent server that refuses to index your workspace. The difference comes down to three settings and one Lua callback.

How the language server actually works

rust-analyzer does not run rustc every time you press a key. Compiling your entire crate on every keystroke would freeze your editor. Instead, the server maintains an incremental index of your codebase. It parses Cargo.toml, downloads dependency metadata, and builds a lightweight abstract syntax tree in memory. When you edit a file, only the changed region gets reanalyzed. The server pushes updates to Neovim over a JSON-RPC connection.

This architecture explains why the first time you open a Rust project, autocomplete feels sluggish. The server is downloading crates, parsing macro definitions, and building the type graph. Subsequent opens are instant because the index lives in your target/ directory. The server also runs cargo check in the background to surface diagnostics without producing binary artifacts.

Keep your target/ directory intact between sessions. Deleting it forces a full reindex every time you restart Neovim.

Minimal configuration

You need a Lua configuration block in your init.lua. This block tells Neovim to launch rust-analyzer whenever you open a Rust file, and it passes a few critical settings that make the experience actually usable.

-- Load the LSP configuration plugin
local lspconfig = require('lspconfig')

-- Configure rust-analyzer specifically
lspconfig.rust_analyzer.setup({
  -- Run this function when the server attaches to a buffer
  on_attach = function(client, bufnr)
    -- Turn on inline type hints like `: String` or `: i32`
    vim.lsp.inlay_hint.enable(true, { bufnr = bufnr })
  end,
  -- Pass settings directly to rust-analyzer
  settings = {
    ['rust-analyzer'] = {
      cargo = {
        -- Read build.rs files to generate constants and macros
        buildScripts = { enable = true }
      },
      procMacro = {
        -- Expand procedural macros so you can jump to their definitions
        enable = true
      }
    }
  }
})

The on_attach callback fires the moment the server connects to a buffer. That is the only safe place to enable buffer-local features like inlay hints. The settings table maps directly to rust-analyzer's internal configuration schema. The cargo.buildScripts flag tells the server to execute your build.rs files during indexing. Without it, generated constants and conditional compilation flags remain invisible to autocomplete. The procMacro.enable flag compiles your macro crates in a separate workspace so the server can expand them. Disable it and you lose go-to-definition inside #[derive] and custom attribute macros.

Restart Neovim to apply the changes. The server will spawn automatically on your next Rust file.

What happens when you open a file

When you save the configuration and open a .rs document, Neovim reads the Lua block and spawns the rust-analyzer process. The server immediately searches upward from your current directory for a Cargo.toml. Once it finds the manifest, it parses the dependency tree, resolves versions from the lockfile, and indexes every type, function, and trait in scope.

The on_attach callback executes, enabling inlay hints so your editor displays parameter names and return types directly in the code. The buildScripts setting triggers a lightweight execution of your build scripts, feeding generated constants back into the index. The procMacro setting compiles macro dependencies in isolation, allowing the server to expand them and provide proper autocomplete inside procedural macro invocations.

If you switch to a different project with its own Cargo.toml, Neovim spawns a second server instance. Each workspace gets its own isolated index. This prevents cross-project type collisions and keeps memory usage predictable.

Do not fight the indexing delay. Wait for the status line to settle before expecting full autocomplete.

A production-ready setup

A daily-driver configuration usually includes keybindings for common LSP actions and a fallback for when the server has not fully indexed yet. You also want to ensure Neovim uses the correct Rust toolchain, especially if you work across multiple projects with different rust-toolchain.toml files.

local lspconfig = require('lspconfig')

lspconfig.rust_analyzer.setup({
  on_attach = function(client, bufnr)
    -- Enable inlay hints for type inference
    vim.lsp.inlay_hint.enable(true, { bufnr = bufnr })

    -- Map common LSP actions to familiar keybindings
    local opts = { noremap = true, silent = true, buffer = bufnr }
    vim.keymap.set('n', 'gd', vim.lsp.buf.definition, opts)
    vim.keymap.set('n', 'K', vim.lsp.buf.hover, opts)
    vim.keymap.set('n', '<leader>rn', vim.lsp.buf.rename, opts)
    vim.keymap.set('n', '<leader>ca', vim.lsp.buf.code_action, opts)
  end,
  settings = {
    ['rust-analyzer'] = {
      cargo = {
        buildScripts = { enable = true }
      },
      procMacro = {
        enable = true
      },
      -- Cache analysis results to speed up subsequent opens
      cachePrimate = { enable = true },
      -- Show diagnostics only for the current file to reduce noise
      diagnostics = {
        enable = true,
        workspace = false
      }
    }
  }
})

The keybindings attach to the buffer, not the global namespace. This prevents conflicts with other plugins and ensures the mappings only exist when rust-analyzer is active. The cachePrimate setting tells the server to prioritize caching type information across sessions. The diagnostics.workspace = false setting limits red squiggles to the open file, which keeps the gutter clean when you are navigating a large monorepo.

The Neovim community treats init.lua as a single source of truth for editor behavior. You will rarely see people split LSP configuration across multiple files unless they are managing a massive plugin ecosystem. Keep your rust-analyzer setup in one block. Also, notice the explicit ['rust-analyzer'] key in the settings table. Lua allows dot notation for simple keys, but the hyphen in rust-analyzer forces bracket notation. It is a small syntax detail that trips up beginners, but writing it correctly prevents silent configuration failures.

Common pitfalls and how to fix them

The most common stumbling block is opening Neovim before running cargo check at least once. rust-analyzer relies on the target/ directory to resolve dependencies and compile procedural macros. If the directory is empty, the server will report missing crates and refuse to provide autocomplete. Run cargo check or cargo build in your terminal first. The server will find the precompiled metadata and index instantly.

The second issue is plugin manager conflicts. If you use lazy.nvim, packer, or vim-plug, ensure nvim-lspconfig is loaded before you call setup(). Loading order matters. If lspconfig is not in the global namespace when your init.lua runs, Neovim throws a module 'lspconfig' not found error. Check your plugin manager documentation for the correct require path.

The third trap is overriding defaults without understanding them. rust-analyzer ships with sensible defaults for formatting, clippy integration, and workspace discovery. Overriding check.command or diagnostics.enable blindly will often break clippy warnings or hide legitimate type errors. Stick to the documented settings and tweak one at a time. If autocomplete stops working, revert your last change and restart the server with :LspRestart.

Check the LSP logs with :LspLog when something breaks. The output shows exactly which settings the server received and where the indexing process stalled.

Choosing your toolchain

Use rust-analyzer when you want official support, accurate type inference, and seamless integration with cargo workspaces. Use rls only if you are maintaining a legacy codebase that predates 2020 and cannot upgrade its toolchain. Use VS Code with the Rust extension when you prefer a zero-configuration setup and do not mind a heavier editor footprint. Use Neovim with nvim-lspconfig when you want a lightweight, keyboard-driven workflow and need full control over keybindings and server behavior. Reach for rust-analyzer's default settings when you are starting out. The community has spent years tuning them for stability. Reach for custom settings overrides only when you have a specific workflow requirement, like disabling workspace-wide diagnostics or forcing a specific clippy profile.

Treat the LSP configuration as infrastructure. Set it up once, verify it works, and stop touching it until your workflow actually changes.

Where to go next