Working with Rust in (neo)vim
Friday, December 16, 2022
I've been using vim for nearly as long as I've been writing code. My first introduction to it was being thrown in the deep end in 2009 by my Intro to CS lab assistant, who told us to write our programs using vi1 on the department servers. Why he told us that, I have no idea. But I got used to switching into and out of insert mode, and also how to save and quit.
At my internship in 2011, I learned to use vim in earnest.
The project I worked on thrashed system memory by running HBase in a test suite over and over, and my work would routinely crash Eclipse2 as a result.
I don't remember if my mentor suggested it or if I used vim on my own, but he did encourage it.
He urged me to learn proper vim and disable the arrow keys to get used to navigating with the
That got me to learn it quickly through immersion and I fell in love.
Now vim3 is how I think about text editing, so I'm mired in it. I'm not leaving vim if I can help it, so I've figured out how to use it effectively for the development I'm doing. And these days, that's Rust as often as I can justify it!
I used to use vim in a pretty bare-bones fashion, but I've slowly been layering in more plugins. (Still far fewer than some people I know, but it cannot be described as a minimalist setup.) One of my batchmates at Recurse Center is a vim aficionado and helped me get a really snazzy setup.
All told, I think vim provides an amazing editing experience for Rust (and in general). This is how I develop Rust in vim!
Plugins and configuration
First let's look at what plugins are installed. (This is all in my public config repo.)
Some general development quality of life ones:
- nerdtree for file navigation.
- fzf for searching for files by name or content
- obsession for saving and resuming sessions more easily
- editorconfig to setup spaces/tabs etc. based on the current project
The Rust-specific ones are:
- rust-tools to setup the Rust LSP automatically for you
- nvim-lspconfig for configuring neovim's LSP (
rust-toolsdepends on this one)
- nvim-cmp, cmp-nvim-lsp4, and cmp-buffer for completions
It's hard to describe a coding workflow through just prose, so I'll use some examples. These are some of the things I run into every day while writing Rust.
The overall workflow is probably familiar to terminal-dwellers, but is different from what IDE-users do. Where an IDE contains all the things (you run your terminal, your tests, your text editor, all in one place!), that's what tmux does for me. When I sit down to code, I start a new tmux session with a window for git commits/logs, another for my editor, and usually another for my tests.
Once I have my editor and test watcher going, the general workflow is:
- Write some code in vim, ideally with tests
- Check on the build/tests, iterate until it passes
- Check clippy for any lints
- Write a messy commit message
- Repeat until I have a unit I want to merge
- Push it my git forge, and squash/merge when CI passes
A lot of this workflow is not unique at all to vim, tmux, or any of the other tools—it's just plain software engineering. I think the more interesting things are how I do some specific things while using vim.
Opening a file.
The scenario is I know that a file exists with some code I want to modify.
If I know the name of the file, I usually use fzf (bound to
control-f) to search by filename and open it directly.
On days when I want to do some sightseeing (more common for codebases I'm not as familiar with, to stumble upon things), I'll navigate through the file tree from nerdtree, but this is rare these days.
And in the cases where I don't even know the name of the file, but just something in it, I use ripgrep (bound on
control-g) to search through the file tree to find any files which have that content!
The beautiful preview panes are a big help in finding things easily.
Creating a new file. This is where I turn to the trusty friend, nerdtree. (Usually. There are tricks to make this faster with the Rust tooling.) I open up nerdtree, navigate to where I want the new file, and enter a name. This is the same for moving or renaming files.
Writing code. This one is pretty common, so I won't spend a lot of time on it. I write code in the idiomatic vim way (I think?), and I don't do anything particularly unusual with it. I do avoid certain things (code folding) which I find confuse me more than help me. I just keep it simple as much as I can, and spend complexity points on the really valuable things.
This is one where I lean on the Rust tools!
I have bound
control-f to run the formatter.
This is a good balance:
It doesn't run automatically (it's jarring when things change out from under me), but it is also so easy to do that I do it often.
It's a great part of my workflow!
I can write something with odd formatting, then hit
control-f and *boom* it's pretty.
Cool Rust code actions
One of my favorite things now is using code actions (provided by Rust's LSP and the neovim integration). They let me make a lot of common actions faster, and are especially powerful combined with Rust's type system!
Create missing files.
From the above section you can probably tell that creating a file was one of my slower manual actions.
Searching for files: super fast!
Making a new one: manual and slow.
This is a little bit easier with code actions.
I just refer to the file (usually
use my_new_module; or something in
lib.rs), then I press
\a and a code action is available to create the missing module!
Generate missing methods.
This is similar to the above.
My old workflow was often to think about what method I would need and write that (at least a stub with a
todo!() inside of it) that I would then use in another place.
That would get the fewest compiler errors as I went.
With code actions, that's flipped on its head:
I first write the places where I use the method, then I let it generate the missing method.
The advantage of working this way is that it can usually write the entire type signature for me, since Rust has a strong type system and there's a lot of information to power its guesses.
(If it can't guess correctly, it does something conservative, like leaves a hole for the type.)
Generate required members for a trait impl.
Oh yeah, no more looking up the docs to know what I need to impl a trait.
I can just make the computer do that work for me.
This is really handy for things like
std::fmt::Display where I might not remember the exact type signature, and even more so for things like
IntoIterator where there are also types I have to define inside the impl block.
Generate missing match arms. This one is probably my favorite. One of the great things about Rust is that you can ensure that matches on enums are total: All the cases are covered. And with that, you can also generate stubs for the cases which are not covered! This works basically like generating the trait impl stubs, but also will add missing match arms for a match block you already have. It's huge, and it's the code action I use the most every day.
Even more code actions! I'm sure there are more super valuable Rust code actions. One that my friend uses a lot is extracting some code into a separate function. I don't use that one much as it doesn't seem to fit my workflow, but I might have to try it out—he's been right about a lot of other workflow improvements so far! If you know of anything else I should try, please reach out and let me know!
Yes, I'm aware that vi and vim are different. I think that vi was symlinked to vim on that system, but I don't know. It doesn't really matter for this story.
IntelliJ was around, but I don't remember people using it. At least I wasn't using NetBeans. I did try. It was worse.
I use "vim" to refer to both vim and neovim. In this article, you can just assume I always mean neovim, since that's what I use exclusively these days.
These names will always trip me up because they have "cmp" and "nvim" in different orders, and somehow that doesn't stay in my head.