Modern Neovim for the faint of heart

After using Ubuntu since at least 2007 (Feisty Fawn is the earliest background image I absolutely remember), I’ve made the switch to macOS, at least for a time, and at least on my main machine. (I’m also “investing” in a Minisforum UM560, which is going to replace my desktop and my Raspberry Pi, and will continue to run Ubuntu.)

As part of the switch to macOS, I decided to do some dev setup upgrading and experimenting. This including waving goodbye to Tilix, which is anyway undermaintained (feature-complete?) and switching from bash to zsh. But mainly I decided to finally put the time into using Neovim’s new LSP and Treesitter features.

And it was nightmarishly difficult to find the information needed to set it up. Some of this stuff is really new, but as so often with fast-moving stuff written by tech people for other tech people, no one ever explains what their thing actually does or how it fits into the ecosystem. I dabbled with LunarVim, NvChad, AstroNvim, but (a) I’m too opinionated too just adopt their defaults wholeheartedly and (b) it might it impossible to Google problems, not knowing if anything was LunarVim-specific, or Lua-specific, or Neovim-specific or just plain-ol’ Vim-specific. I tried just copy-pasting some configs, but that didn’t really work. I almost gave up, or settled for Coc, but finally decided to figure it from the ground up.

# Neovim with Lua

Since Neovim v0.5 or so, it has supported Lua as a first-class configuration language. This has been so wholeheartedly adopted by the cutting-edge of the community that, if you want to use modern plugins, you effectively have to deal with some Lua configuration. Rather than figuring out how to backport stuff to Vimscript, or have some hodge-podge of copy-pasted stuff, I deleted my init.vim and started with a blank init.lua.

One big downside of this move is that all the Lua-ified Neovim configs floating around are now pretty over-engineered, with subfolders and import patterns and aliases and custom functions. It makes it pretty hard to know which bits are safe to Ctrl-C/Ctrl-V.

Fear not, mine is just a single-file init.lua! The sections below are going to slowly build up to most of that config.

# Basic configuration

If you want any of this to have a good chance of working, I recommend you use Neovim v0.8 and above.

There are some great resources to get started with this:

Here are a few from my config:

vim.opt.hlsearch = true       -- boolean values must be assigned
vim.opt.clipboard = "unnamed" -- and strings quoted
vim.opt.sidescrolloff = 8     -- and numbers like this
vim.cmd("colorscheme gruvbox")-- no Lua command for this!

-- Lua is much nicer for things like this
-- (concatenating two paths for the undodir)
vim.opt.undodir = vim.fn.stdpath("cache") .. "/undo"

-- And for settings that are lists/dicts/etc
vim.opt.listchars = {tab = "▸ ", trail = "·"}

There’s also a new API for autocmd:

vim.api.nvim_create_autocmd("FileType", {
	pattern = { "latex", "tex", "md", "markdown" },
	command = "setlocal spell",

You can see my full config for this section over here.

# Keyboard shortcuts

There’s a whole new way of setting these, and I find it a lot more ergonomic. Instead of the old nnoremap <C-J> <C-W>)C-J>, you use vim.keymap.set("n", "<C-J>", "<C-W><C-J>". The first argument ("n") is for the mode, the second the keys, and the last the command. They are now silent and noremap by default, but you can pass a fourth argument { ... } with other options.

Some of my settings here are:

vim.g.mapleader = ","    -- note that this uses vim.g, not vim.opt

vim.keymap.set("n", "q", "<C-r>")                  -- easier redo
vim.keymap.set("n", "<leader>h", ":%s/")           -- find & replace
vim.keymap.set("n", "<leader>w", ":set wrap!<CR>") -- wrap on/off
vim.keymap.set("n", "]d", vim.diagnostic.goto_next)-- goto errors

With that knowledge you should be mostly fine to set all your keymaps. The fourth argument also has a { buffer = "buffer_name" } option, which is frequently used by plugins to control when their shortcuts are active.

As before, my config for keymaps is here. If you want to get started, you can delete your init.vim and replace it with an init.lua with just these first two sections to see how it goes. You’ll probably need to also delete .local/share/nvim, to stop old plugins from starting up. This should be enough to get most typical Vim settings sorted out. I don’t use much autocmd and augroup stuff, so you may need to do a bit more working out for that.

# Plugins with packer

Another blogger wrote a very useful article on some general Vim tricks, and included an hilarious list of Vim plugin managers. Well it’s 2022 now, and packer is the new kid on the block. The only benefits I’m aware of are:

  1. It uses Lua.
  2. Most modern Neovim plugins only provide examples using packer.
  3. It can self-bootstrap.

Packer is actually pretty well-documented and I didn’t struggle with this bit. It allows you to pass a bunch of options when you add a plugin, such as config = function() ..., but I mostly don’t use this, preferring to set them up separately. One I do use, is the commit = "<sha>" feature, as my editor is working today and I’d like to to stay that way. I wish git tags were more prevalent in the community but I’ll take what I can get.

The bootstrap instructions are here, so I won’t duplicate that. You can then declaratively add all your plugins as follows:

  -- ...

  -- excluding my commit = "<sha>" args for brevity
  use({ "ellisonleao/gruvbox.nvim" })
  use({ "numToStr/Comment.nvim" })
  use({ "nvim-tree/nvim-tree.lua" })
  use({ "lewis6991/gitsigns.nvim" })

  -- and so on ...

Some of the new plugins I’m using to replace older, less trendy ones:

Some other nice plugins that weren’t explicit replacements:

For old-school plugins, that’s all you need to do. For fancy modern Lua plugins, you generally need to run setup() on each of them. For the ones above, this look like this:


-- you can generally also pass config options
  current_line_blame = true,

As before, you can see my config for plugins here. For most plugins, that’s about all you need to know. If all goes well, packer should install itself and all the plugins. You can also run :PackerSync and will try run some updates. It only gets complicated once you get to…

# Tree-sitter

This is the first of the super-modern additions to Neovim. It’s supposed to make syntax highlighting faster and more useful. And, mostly it seems to do that, but I can’t say it’s super-noticeable. Some laggy edge-cases are gone, and the bolding and colouring are slightly more context-specific.

Setting this up was at least pretty straight-forward. First add the following to your list of plugin install declarations:

use({ "nvim-treesitter/nvim-treesitter" })

Then add the following somewhere further down:

  -- this can also be a list of languages
  ensure_installed = all,
  auto_install = true,
  highlight = { enable = true },

Then, after you :PackerSync, you should get the shiny new experience! To check if it’s doing anything, open a file and try :TSBufToggle highlight a few times. My config is here.


With all of that done, we’re finally ready for the good stuff! Language Server Protocol (LSP) is what Microsoft developed for VSCode, and is now the de facto standard for hooking up editors with code completion, diagnostics, refactoring etc. The main way this has been done in (Neo)vim in the past is with CoC, which is a more direct copy of VSCode features. But now Neovim supports LSP natively, and something something that’s probably better?

This is the part that really screwed with my head, as there are so many plugins and standards and things and pieces, and so little clarity over who’s doing what and why.

So, this is what I’ve figured out:

  1. Neovim provides LSP integration out-of-the-box, and in theory you could stop there and manually hook up your language servers.
  2. But most of us don’t want to do that, so neovim/nvim-lspconfig provides pre-built configurations for an enormous list of language servers. You could stop there and manually install your servers.
  3. But most rather just install them with a command, so mason.nvim provides a handy way to :MasonInstall <package>. You could really stop here.
  4. But it would be much nicer to just declare a list of servers and have them automatically installed and hooked up, so mason-lspconfig.nvim does that.
  5. But that doesn’t really provide for formatters and linters, so we then add null-ls.nvim, which allows us to hook any command (eg black, eslint, ruff) into the LSP system.
  6. Buuut we’d still have to install those formatters and linters, which is handled for us by mason-null-ls.nvim.
  7. Oh and one more thing: we have diagnostics and formatters, but no autocomplete yet. I’m using coq_nvim.

And that’s it! To get this going, we first need to add all of these to the packer install section:

use({ "nvim-lua/plenary.nvim" }) -- used by stuff belopw
use({ "williamboman/mason.nvim" })
use({ "williamboman/mason-lspconfig.nvim" })
use({ "neovim/nvim-lspconfig" })
use({ "jose-elias-alvarez/null-ls.nvim" })
use({ "jay-babu/mason-null-ls.nvim" })
use({ "ms-jpq/coq_nvim", branch = "coq" })

Then set up a “table” (Lua-speak for list/dict/mapping) of servers you want to install (we will use this shortly). The list of available servers is here on the mason-lspconfig page. These have empty {} so that we can add config down the line if needed.

local lsp_servers = {
  pyright = {},
  tsserver = {},
  -- and so on

Then we start setting up the LSP plugins, and it must go in this order! First mason and mason-lspconfig:

  ensure_installed = lsp_servers,
  automatic_installation = true,

Not if you reload and run :PackerSync you should get a bunch of messages about servers installing. After that you can run :Mason and get a summary of installed and available servers. Note that none of these will be working until we do the next steps! We first activate coq:

vim.g.coq_settings = { auto_start = "shut-up" }
local coq = require("coq")

Then set up some buffer-specific keyboard-shortcut callbacks. These are just a few, and the official docs list a whole bunch more.

local server_maps = function(opts)
  vim.keymap.set("n", "gd", vim.lsp.buf.definition, opts) -- goto def
  vim.keymap.set("n", "K", vim.lsp.buf.hover, opts) -- see docs
  vim.keymap.set("n", "<leader>fo", function() -- format
    vim.lsp.buf.format({ async = true })
  end, opts)

And then you are ready to activate all of these new LSP servers! We rope together a few of the preceding bits by looping through the lsp_servers and, for each one, we call lspconfig[thing].setup(), and provide the server_maps keymap config from above. But we also wrap the inner bit with the coq.lsp_ensure_capabilities to set up autocomplete. The settings = settings bit will pass any values we added in the lsp_servers table further up. For example, I have some settings to adjust Lua diagnostics.

for lsp, settings in pairs(lsp_servers) do
    on_attach = function(_, buffer)
      server_maps({ buffer = buffer })
    settings = settings,

Now you can run :LspInfo to get info on servers active in your current buffer, and you should be able to <leader>gd around to definitions and have errors and diagnostics popping up, and get some super-fast autocomplete! (You might need to run :COQdeps to get that last part working.)

Lastly, we want the null-lsp formatters and such. This is similar to above:

local null_servers = {
  ensure_installed = null_servers,
  automatic_installation = true,
  automatic_setup = true,

Reload again and these should be installed automatically! You can run :NullLsInfo to see what’s running for the active buffer, and if it’s all working, hit <leader>fo to do some formatting! You can see how all of these are configured in my config here.

# Worth it?

Vim is hard enough to get to grips with, and this set up process is certainly not for everyone. For me, the difficulty is mostly that I have strong opinions about how I want my editor to be set up, and a preference to understand what’s going on under the hood, so just going with the Lunarvim/NVChad/etc flow is almost a non-starter. So it’s an awkward spot where I’m basically obliged to go through this process once every few years.

Purists might prefer a virtually plugin-less experience, but jumping into new codebases and unfamiliar languages/frameworks, I find it extremely valuable to have easy code navigation and autocomplete. Another possibility is just to stick with older, stabler plugins, but they eventually start to get crufty, and my ncm2 setup was already getting a bit fragile. So to do some degree it’s necessary (in this ecosystem) to stay relatively near the cutting-edge, which is even more the case with modern tools like ruff or prettierd, where it’s possible they won’t even be integrated with older tools.

And finally, is it actually better? Yes! My Neovim is noticeably faster, there are now zero weird laggy edge cases, formatting is much faster, and the diagnostics and gotos and things are more useful and reliable.

The big downside is that now there’s a definite split in the Neo/vim community, and now the mountain of Vim-related content on the internet needs translating before I can apply it, and all this pulp up above is worthless to regular Vim users. It’s also a bit tiring dealing with a community that seems intent on trying to reinvent itself every couple of days, but hopefully it will stabilise a bit now.