Home Rust and Neovim - A Thorough Guide and Walkthrough
Post
Cancel

Rust and Neovim - A Thorough Guide and Walkthrough

Edit: Some readers mentioned an issue with the example Lua code used to configure the simrat39/rust-tools.nvim plugin; that configuration code has been updated with the example configuration recommended in the plugin page as of the date of this edit. Thanks to Nazar Toakarak for letting me know.
Readers have also asked me about the link to my latest Neovim config files, you can find them here.

This post is a detailed explanation and walkthrough of how I set up my Rust development environment and workflow with Neovim.

Prerequisites

  • Have the following installed:
  • Have basic knowledge about Neovim and Lua:
    • How to navigate with the keyboard.
    • How to install / remove plugins.
    • How to create and edit Lua config files.
    • Here is a great tutorial that helped me with these basics.

Why Neovim

Like many developers, I went through a phase of looking for the ideal development set of tools that would be convenient and enjoyable, but most of all, that would boost my productivity.

After extensively trying many text editors and IDEs, I have decided to give Neovim a serious shot and invest time and effort in learning and customizing it heavily.

I have to say, it was no easy feat… But it is also a great experience!

Neovim is a very sophisticated tool that is extremely customizable but carries a steep learning curve. However, once you’re past the basics and force yourself some muscle memory it is unbelievably fast. All the effort pays off massively.

I am, by no means, an expert user of Neovim, but with every keystroke I get faster and more productive.

And I think that is the key thing that makes Neovim a different beast: it _forces_ you to change and adapt, and you actually see and feel yourself getting better and better!

Anyways, here are my Pros and Cons about Neovim…

Pros:

  • Lighting fast.
  • Extremely configurable and customizable.
  • Massive ecosystem of plugins.
  • Cross-platform support.
  • Support for almost any programming language out there.
  • Forces productive habits upon you.
  • Abundant documentation and community support.
  • It is free and open source.

Cons:

  • Significant up-front time investment in learning.
  • Abundance of choice in customization options can be distracting.
  • Steep learning curve.
  • Demands commitment and patience in creating new muscle memory.

Why Neovim for Rust Development

Okay, the above points are all valid from a general point of view, but why is Neovim great specifically for a Rust development workflow? Here are my reasons:

  • Neovim natively supports LSP (Language Server Protocol) in versions 0.5+
  • rust-analyzer is supported through Neovim’s LSP, which essentially give us:
    • Code completion.
    • Some refactoring.
    • Code analysis and linting.
    • Go to definition.
    • Code actions.
    • Access to documentation.
    • Show and go to references.
    • Snippets support.
    • Better syntax highlighting.
    • Code formatting.
  • rust-tools is a fantastic plugin to setup rust-analyzer with Neovim’s LSP .
  • Integrated terminal.
  • Access to Cargo commands.
  • Rustfmt On Save.
  • Rust debugging via:
  • TOML language support.

In essence, we have all the functionality that is needed from an IDE to develop Rust with productivity.

Here are a couple screenshots using my current setup:

Rust development with Neovim and Vimspector Rust development with Neovim and Vimspector

Rust development with Neovim and voldikss/vim-floaterm float terminal plugin Rust development with Neovim and voldikss/vim-floaterm float terminal plugin

Set Up Rust with Neovim

To make things more digestible, here the major activities that what we are going to do:

Rust and Neovim setup diagram - made with mermaid-hs Rust and Neovim Set Up Diagram - made with https://mermaid.live



  1. Download and setup rust-analyzer and codelldb using Neovim’s plugins.
  2. Attach Neovim to rust-analyzer.
  3. Install Neovim’s complete and snippets plugins.
  4. Install Neovim’s tree-sitter and set it up with tree-sitter-rust parser.
  5. Set up vimspector to work with codelldb to debug Rust and Rust Tests.

But before we go into the details, here is my Neovim .config/nvim folder so that the filenames below make sense:

1
2
3
4
5
6
7
8
9
10
11
.config/nvim on  main [!] via 🌙 v5.4.4
 λ tree -L 2
.
├── init.lua
├── lua
│   ├── keys.lua
│   ├── opts.lua
│   ├── plug.lua
│   └── vars.lua
└── plugin
    └── packer_compiled.lua 

1. Download rust-analyzer and CodeLLDB with Neovim’s plugins

The first Neovim plugin, other than Packer (the package manager), we need is Mason:

Here is the Lua code to install these -and other- plugins using Packer:

.config/nvim/lua/plug.lua:

1
2
3
4
5
6
7
8
return require('packer').startup(function()
    -- other plugins...
    
    use 'williamboman/mason.nvim'    
    use 'williamboman/mason-lspconfig.nvim'
   
   -- other plugins...
end)

Then we just save :w, reload :luafile % and install with :PackerInstall.

After Mason is installed, we need to set it up in our config file init.lua:

1
2
3
4
5
6
7
8
9
10
11
-- Mason Setup
require("mason").setup({
    ui = {
        icons = {
            package_installed = "",
            package_pending = "",
            package_uninstalled = "",
        },
    }
})
require("mason-lspconfig").setup()

Thanks to Eric Smith for suggesting including Mason’s setup in this guide.

I could use other package managers to install plugins or LSPs, however, I prefer Packer and Mason because they are written entirely in Lua, are getting traction within the Neovim developer community, and they work great.

Once Mason is installed and setup, we can use it to install and manage rust-analyzer and CodeLLDB:

On Neovim command mode we input :MasonInstall rust-analyzer codelldb.

When Manson finishes, we can check with :Mason that we have rust-analyzer and CodeLLDB installed.

Installing rust-analyzer and CodeLLDB with Mason Mason installing rust-analyzer and CodeLLDB for Neovim

2. Attaching Neovim to rust-analyzer

Next, we continue by installing two essential plugins to attach rust-analyzer to Neovim’s LSP:

Here is the Lua code to install these packages using Packer:

.config/nvim/lua/plug.lua:

1
2
3
4
5
6
7
8
return require('packer').startup(function()
    -- other plugins...
    
    use 'neovim/nvim-lspconfig' 
    use 'simrat39/rust-tools.nvim'
    
    -- other plugins...
end)

Now, we need to setup Neovim so that it can interact with rust-analyzer. We can use the code provided in the example config recommended by the rust-tools.vim plugin:

.config/nvim/init.lua

1
2
3
4
5
6
7
8
9
10
11
12
local rt = require("rust-tools")

rt.setup({
  server = {
    on_attach = function(_, bufnr)
      -- Hover actions
      vim.keymap.set("n", "<C-space>", rt.hover_actions.hover_actions, { buffer = bufnr })
      -- Code action groups
      vim.keymap.set("n", "<Leader>a", rt.code_action_group.code_action_group, { buffer = bufnr })
    end,
  },
})

Further customization options are available via rust-tools configuration.

However, I prefer to leave the defaults in rust-tools, and configure instead through Neovim’s Diagnostics API:

.config/nvim/init.lua

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
-- LSP Diagnostics Options Setup 
local sign = function(opts)
  vim.fn.sign_define(opts.name, {
    texthl = opts.name,
    text = opts.text,
    numhl = ''
  })
end

sign({name = 'DiagnosticSignError', text = ''})
sign({name = 'DiagnosticSignWarn', text = ''})
sign({name = 'DiagnosticSignHint', text = ''})
sign({name = 'DiagnosticSignInfo', text = ''})

vim.diagnostic.config({
    virtual_text = false,
    signs = true,
    update_in_insert = true,
    underline = true,
    severity_sort = false,
    float = {
        border = 'rounded',
        source = 'always',
        header = '',
        prefix = '',
    },
})

vim.cmd([[
set signcolumn=yes
autocmd CursorHold * lua vim.diagnostic.open_float(nil, { focusable = false })
]])

3. Install the Complete and Snippets plugins’ suite:

The following set of plugins allow us to query Neovim’s LSPs, and other sources, to present the auto-complete drop-down menu while typing code.

Thanks to these plugins, and because we have already attached rust-analyzer to Neovim’s LSP, we will be able to get nice IDE-like auto-completion when we work on a Cargo project:

.config/nvim/lua/plug.lua:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
return require('packer').startup(function()
    -- other plugins...

    -- Completion framework:
    use 'hrsh7th/nvim-cmp' 

    -- LSP completion source:
    use 'hrsh7th/cmp-nvim-lsp'

    -- Useful completion sources:
    use 'hrsh7th/cmp-nvim-lua'
    use 'hrsh7th/cmp-nvim-lsp-signature-help'
    use 'hrsh7th/cmp-vsnip'                             
    use 'hrsh7th/cmp-path'                              
    use 'hrsh7th/cmp-buffer'                            
    use 'hrsh7th/vim-vsnip'                             
    
    -- other plugins...
end)

And now, we need to set them up in our configuration files:

First in our Neovim APIs:

.config/nvim/lua/opts.lua

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
--Set completeopt to have a better completion experience
-- :help completeopt
-- menuone: popup even when there's only one match
-- noinsert: Do not insert text until a selection is made
-- noselect: Do not select, force to select one from the menu
-- shortness: avoid showing extra messages when using completion
-- updatetime: set updatetime for CursorHold
vim.opt.completeopt = {'menuone', 'noselect', 'noinsert'}
vim.opt.shortmess = vim.opt.shortmess + { c = true}
vim.api.nvim_set_option('updatetime', 300) 

-- Fixed column for diagnostics to appear
-- Show autodiagnostic popup on cursor hover_range
-- Goto previous / next diagnostic warning / error 
-- Show inlay_hints more frequently 
vim.cmd([[
set signcolumn=yes
autocmd CursorHold * lua vim.diagnostic.open_float(nil, { focusable = false })
]])

And then in our completion plugins:

.config/nvim/init.lua

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
-- Completion Plugin Setup
local cmp = require'cmp'
cmp.setup({
  -- Enable LSP snippets
  snippet = {
    expand = function(args)
        vim.fn["vsnip#anonymous"](args.body)
    end,
  },
  mapping = {
    ['<C-p>'] = cmp.mapping.select_prev_item(),
    ['<C-n>'] = cmp.mapping.select_next_item(),
    -- Add tab support
    ['<S-Tab>'] = cmp.mapping.select_prev_item(),
    ['<Tab>'] = cmp.mapping.select_next_item(),
    ['<C-S-f>'] = cmp.mapping.scroll_docs(-4),
    ['<C-f>'] = cmp.mapping.scroll_docs(4),
    ['<C-Space>'] = cmp.mapping.complete(),
    ['<C-e>'] = cmp.mapping.close(),
    ['<CR>'] = cmp.mapping.confirm({
      behavior = cmp.ConfirmBehavior.Insert,
      select = true,
    })
  },
  -- Installed sources:
  sources = {
    { name = 'path' },                              -- file paths
    { name = 'nvim_lsp', keyword_length = 3 },      -- from language server
    { name = 'nvim_lsp_signature_help'},            -- display function signatures with current parameter emphasized
    { name = 'nvim_lua', keyword_length = 2},       -- complete neovim's Lua runtime API such vim.lsp.*
    { name = 'buffer', keyword_length = 2 },        -- source current buffer
    { name = 'vsnip', keyword_length = 2 },         -- nvim-cmp source for vim-vsnip 
    { name = 'calc'},                               -- source for math calculation
  },
  window = {
      completion = cmp.config.window.bordered(),
      documentation = cmp.config.window.bordered(),
  },
  formatting = {
      fields = {'menu', 'abbr', 'kind'},
      format = function(entry, item)
          local menu_icon ={
              nvim_lsp = 'λ',
              vsnip = '⋗',
              buffer = 'Ω',
              path = '🖫',
          }
          item.menu = menu_icon[entry.source.name]
          return item
      end,
  },
})

With this setup we are now able to have real-time completion capabilities sourcing from rust-analyzer and we can review the associated documentation that pops up:

rust-analyzer sourced for real-time completion in Neovim Rust real-time completion in Neovim

4. Install Tree-sitter and set it up with the tree-sitter-rust parser

Tree-sitter is a fantastic parser generation and incremental parsing library, that supports Rust language bindings and has an available parser Rust tree-sitter-rust.

Neovim’s support of Tree-sitter is, at the time of this post, experimental and should be treated as such; however, I have found no issues so far with my setup.

First we need to insall the nvim-treesitter/nvim-treesitter plugin:

.config/nvim/lua/plug.lua:

1
2
3
4
5
6
7
return require('packer').startup(function()
    -- other plugins...

    use 'nvim-treesitter/nvim-treesitter'

    -- other plugins...
end)

And then configure it… .config/nvim/init.lua

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- Treesitter Plugin Setup 
require('nvim-treesitter.configs').setup {
  ensure_installed = { "lua", "rust", "toml" },
  auto_install = true,
  highlight = {
    enable = true,
    additional_vim_regex_highlighting=false,
  },
  ident = { enable = true }, 
  rainbow = {
    enable = true,
    extended_mode = true,
    max_file_lines = nil,
  }
}

We need to be sure that the ensure_installed = {... "rust", "toml" ...} variable is part of the setup function and that "rust" and "toml" languages are included…

nvim-treesitter allows us to install the tree-sitter-rust parser, effectively enabling all the benefits of Tree-sitter for Rust in Neovim!

We can also check the status of our parsers with :TSInstallInfo and update them with :TSUpdate on the command prompt.

nvim-treesitter also provides a nice view of our parsers with the :checkhealth command and the specific support (e.g. Highlights, Folds, etc.) for each language:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
nvim-treesitter: require("nvim-treesitter.health").check()
========================================================================
## Installation
  - WARNING: `tree-sitter` executable not found (parser generator, only needed for :TSInstallFromGrammar, not required for :TSInstall)
  - OK: `node` found v17.7.1 (only needed for :TSInstallFromGrammar)
  - OK: `git` executable found.
  - OK: `cc` executable found. Selected from { vim.NIL, "cc", "gcc", "clang", "cl", "zig" }
    Version: cc (SUSE Linux) 12.1.1 20220721 [revision 4f15d2234608e82159d030dadb17af678cfad626]
  - OK: Neovim was compiled with tree-sitter runtime ABI version 14 (required >=13). Parsers must be compatible with runtime ABI.

## Parser/Features H L F I J
  - toml           ✓ ✓ ✓ ✓ ✓
  - lua            ✓ ✓ ✓ ✓ ✓
  - rust           ✓ ✓ ✓ ✓ ✓

  Legend: H[ighlight], L[ocals], F[olds], I[ndents], In[j]ections
         +) multiple parsers found, only one will be used
         x) errors found in the query, try to run :TSUpdate {lang}

And we can customize as we please.

In my case, I am connecting the folding API in Neovim to the Tree-sitters folding functions:

.config/nvim/lua/opts.lua

1
2
3
-- Treesitter folding 
vim.wo.foldmethod = 'expr'
vim.wo.foldexpr = 'nvim_treesitter#foldexpr()'

And here is the result of my Rust code being folded in Neovim using Tree-sitter:

Rust code folding in Neovim using Tree-sitter Rust code folding in Neovim using Tree-sitter

5. Set up Vimspector and CodeLLDB to debug Rust and Rust Tests

Okay, now comes one of the most important features of any IDE: having a debugger a couple of clicks away to troubleshoot your code.

As of today, my preferred method is to use the Vimspector plugin with CodeLLDB.

There is also the alternative to use nvim-DAP instead of Vimspector. It looks like a fantastic initiative that is getting traction, but I still have not enabled it in my workflow. Definitely worth a look!

Remember that we already installed CodeLLDB via Mason, so now we just need to install the Vimspector plugin:

.config/nvim/lua/plug.lua:

1
2
3
4
5
6
7
return require('packer').startup(function()
    -- other plugins...

    use 'puremourning/vimspector'

    -- other plugins...
end)

Second, we can configure Vimspector UI and keymap options to our liking:

.config/nvim/lua/opts.lua:

1
2
3
4
5
6
-- Vimspector options
vim.cmd([[
let g:vimspector_sidebar_width = 85
let g:vimspector_bottombar_height = 15
let g:vimspector_terminal_maxwidth = 70
]])

.config/nvim/lua/keys.lua

1
2
3
4
5
6
7
8
9
10
11
12
13
-- Vimspector
vim.cmd([[
nmap <F9> <cmd>call vimspector#Launch()<cr>
nmap <F5> <cmd>call vimspector#StepOver()<cr>
nmap <F8> <cmd>call vimspector#Reset()<cr>
nmap <F11> <cmd>call vimspector#StepOver()<cr>")
nmap <F12> <cmd>call vimspector#StepOut()<cr>")
nmap <F10> <cmd>call vimspector#StepInto()<cr>")
]])
map('n', "Db", ":call vimspector#ToggleBreakpoint()<cr>")
map('n', "Dw", ":call vimspector#AddWatch()<cr>")
map('n', "De", ":call vimspector#Evaluate()<cr>")

And finally, the tricky part:

  1. Compile our Rust project with cargo build, cargo run, cargo test, so that we can produce a binary.
  2. Create a .vimspector.json file at the root of your Cargo directory.
  3. Attach CodeLLDB and your Rust application binary to Vimspector in the .vimspector.json file.
  4. Create a debugger breakpoint in your code with :call vimspector#ToggleBreakpoint().
  5. Launch Vimspector and start debugging with :call vimspector#Launch().

app/.vimspector.json

1
2
3
4
5
6
7
8
9
10
11
12
{
  "configurations": {
    "launch": {
      "adapter": "CodeLLDB",
      "filetypes": [ "rust" ],
      "configuration": {
        "request": "launch",
        "program": "${workspaceRoot}/target/debug/app"
      }
    }
  }
}

And, if everything went well we should be greeted by a new debugging session of our Rust code in Neovim.

Rust debugging in Neovim with Vimspector and CodeLLDB Rust debugging in Neovim with Vimspector and CodeLLDB

If we want to debug Rust Tests we need to:

  1. Compile the test with cargo test.
  2. Locate the binary produced and printed by the cargo test command (target/debug/deps/app-0683da2c6affeec0 in the example below).
  3. Update .vimspector.json to attach the test binary to the debugging session.
  4. Set a breakpoint inside our test scope and launch Vimspector.
1
2
3
4
5
6
7
8
9
10
 λ cargo test
   Compiling app v0.1.0 (/home/rsdlt/Documents/RustProjects/app)
    Finished test [unoptimized + debuginfo] target(s) in 0.26s
     Running unittests src/main.rs (target/debug/deps/app-0683da2c6affeec0)

running 2 tests
test tests::first_test ... ok
test tests::test_persona ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

app/.vimspector.json

1
2
3
4
5
6
7
8
9
10
11
12
{
  "configurations": {
    "launch": {
      "adapter": "CodeLLDB",
      "filetypes": [ "rust" ],
      "configuration": {
        "request": "launch",
        "program": "${workspaceRoot}/target/debug/deps/app-0683da2c6affeec0"
      }
    }
  }
}

And again, if everything is properly configured we are greeted with a debugging session of a Rust Test:

Rust debugging a test in Neovim with Vimspector and CodeLLDB Debugging a Rust Test in Neovim with Vimspector and CodeLLDB

Phew, the hard part is over…

Cargo Power with Terminal Access

One of my favorite plugins in Neovim is voldikss/vim-floaterm. I just love the capability to hit a keystroke and have a terminal prompt pop-up right in front of me to input a fast command and then return to my code just as quickly…

In Rust development I mainly use the terminal to execute Cargo commands like cargo run, cargo build, cargo check and cargo test, among others.

To install this plugin:

.config/nvim/lua/plug.lua:

1
2
3
4
5
6
7
return require('packer').startup(function()
    -- other plugins...

    use 'voldikss/vim-floaterm'

    -- other plugins...
end)

And I have it configured so that I can toggle the terminal pop-up by just pressing t after creating a session with <leader>ft:

1
2
3
4
-- FloaTerm configuration
map('n', "<leader>ft", ":FloatermNew --name=myfloat --height=0.8 --width=0.7 --autoclose=2 fish <CR> ")
map('n', "t", ":FloatermToggle myfloat<CR>")
map('t', "<Esc>", "<C-\\><C-n>:q<CR>")

Searching at the Speed of Rust

Rust is fast, and so should be getting anywhere in our code.

There are countless plugins and solutions to find and jump within a project. However, in my experience these two plugins are the most essential:

Telescope can leverage other plugins like BurntSushi/ripgrep and sharkdp/fd

Hop is unbelievably joyful, fun and effective to use.

Project status

I mainly use four plugins to keep my project nice and tidy:

And of course, with just one keystroke we can toggle on / off each of these features really fast…

Rust IDE-like features in Neovim Rust in Neovim with File Explorer, Tagbar, TODOs and Trouble

Better Coding Experience

Other plugins that I use that make my coding experience more enjoyable are:

Nice Look & Feel

And last, but not least, we can make Neovim look awesome with just a few plugins and tweaks:


Links, references and disclaimers:

Here is the full list of all the Neovim plugins I use as of the date of this post. The credit, and my gratitude, goes to all the hardworking developers that make these awesome open source tools:

Neovim Plugins: (In alphabetical order)

This post is licensed under CC BY 4.0 by the author.
Source code samples licensed under MIT or Apache-2.0