devaslife the GOAT

Overhauling my Neovim LSP and lazy.nvim configurations

Kevin Feng
14 min readJul 26, 2023

--

Another Neovim blog post? I’m starting to understand why ThePrimeagen’s GitHub states that he’s located at the 9th Ring of Vim.

The single most important GitHub profile

Why the comparison between Vim and hell? Well this 60-minute blog post on my Neovim configuration should be ample proof, but to describe it more aptly, terminal-based editors like Vim and Neovim tend to work poorly out-of-the-box (no syntax highlighting, no LSP, no file finder, etc.), but do grant you a superfluous amount of freedom when it comes to configuration: Create whatever keymaps you want, install any plugins that you desire, and if a plugin doesn’t exist, create it yourself! In other words, it’s a tinkerer’s dream come true — in the form of a text editor. As such, once you start configuring Vim, you cannot escape.

If you want to learn more about Neovim configuration at an introductory level, I’d recommend reading this blog post, which provides an in-depth guide on installing an Ubuntu virtual machine and installing and configuring Neovim from scratch.

No, not that kind of Overhaul

LSP Overhaul

For a long time, I was completely indifferent to understanding my own LSP config. I had yanked most of it from a series of video tutorials and had no intentions of digging down into LSP documentation to actually appreciate what was going on under the hood — I was an LSP dilettante.

But that all changed when I started writing Python code for work. I sat down at my computer, launched a terminal into WSL and created a Python file. My first script on the job! And the LSP isn’t providing autocompletion suggestions…

Since I had spent all my time superficially confirming my LSP’s functionality in Lua files, I never realized that it wasn’t working for other languages. I did have thepyright language server installed, so clearly something in the LSP configuration itself was broken.

LSP Zero

LSP Zero is a Neovim plugin designed with the intent to reduce the boilerplate code of traditional LSP configs by combining plugins like nvim-cmp, nvim-lspconfig, and mason.nvim into a single plugin. It’s a “one plugin to rule them all” type of philosophy.

Sauron strikes me as an Emacs user

Rather than fixing my existing LSP config to work with Python, I decided to overhaul the entire thing with LSP Zero. And it’s really easy.

See that code? That’s all you need for a minimal configuration with LSP Zero. The default keymaps are quite sensible and you can get right to installing language servers with Mason and coding in your favorite language with the help of LSP recommendations.

So what does this tiny, but elegant code snippet accomplish? Well the first line just loads the LSP Zero plugin with its preset settings. Simple enough.

After that, LSP Zero calls the on_attach function, which occurs on every buffer that has an LSP associated with it. If we look in the body of the function that on_attach defines, we’ll see that the default keymaps of LSP Zero are loaded for the current buffer:

lsp.default_keymaps({buffer = bufnr})

-- {buffer = bufnr}
-- Means that these default keymaps will apply for the current buffer

In the last line of code, lsp.setup() actually sets up the plugin itself with all of our settings. And although this configuration is already functional, we can make it even more powerful by adding some completion settings, some code snippet sources, and some delicious breadcrumbs.

Who doesn’t love some good panko?

LSP Add-ons

Let’s start by adding our own completion menu settings to our LSP configuration. With the LSP Zero preset configuration, we’ll have autocompletion of variables, functions, fields, etc., but the default completion menu is a bit bland and besides, I have my own preferences for completion keymaps.

If we follow what VonHeikemen recommends, we’ll customize nvim-cmp by using cmp directly. We’ll only use LSP Zero for the minimal config and then tweak the settings of the cmp module itself. It’s also important to note that we need to set up cmp after LSP Zero so that our tweaks will override the settings of LSP Zero and not the other way around:

Here are just a few modifications that we can make to cmp to make things a little bit nicer:

cmp.setup({
mapping = {
['<CR>'] = cmp.mapping.confirm({ select = false }),
},
window = {
completion = cmp.config.window.bordered(),
documentation = cmp.config.window.bordered(),
}
})

First, we change the completion menu settings such that we have to perform <CR> or ENTER to confirm our selection and on top of that, the first entry is not selected by default. This essentially removes this really annoying behavior:

You get to the end of a line, hit ENTER to create a new line and instead, Neovim has autocompleted some gibberish because of the LSP. But by making it so that the completion menu doesn’t have anything selected by default, we’ll have to perform <C-p> or <C-n> (common mappings for previous and next, respectively) to enter the menu at all. Or you can be a goblin and use your arrow keys.

People who use arrow keys instead of <C-p> and <C-n>

Additionally, we set some window options so that the completion menu and its corresponding documentation window have nice smooth borders:

Adding snippets and having them expand with LSP Zero is also quite painless. There are two things we’ll need to do:

  1. Install and configure a snippet engine
  2. Install some snippet sources

As for the first task, we’ll use nvim_lsp and luasnip. We can add them to cmp under the “sources” setting:

cmp.setup({
sources = {
{ name = 'nvim_lsp' },
{ name = 'luasnip' },
},
mapping = {
['<CR>'] = cmp.mapping.confirm({ select = false }),
},
window = {
completion = cmp.config.window.bordered(),
documentation = cmp.config.window.bordered(),
},
snippet = {
expand = function(args)
require('luasnip').lsp_expand(args.body)
end
}
})

We’ll also want the snippets to expand properly when we hit ENTER, so let’s make that happen by calling on the lsp_expand function from luasnip and setting that under the snippet setting of cmp.

To install snippet sources, simply do so with whatever package manager you desire. This will most likely involve adding it to your bootstrapping code for packer.nvim or the equivalent for lazy.nvim.

And for the cherry on top, let’s add some breadcrumbs to our winbar. Breadcrumbs are a tool that appear in the navigation bar above the contents of a code/text editor that leave a trail (hence the name) showing where you’ve navigated in the file directory and the contents of the file itself (this is where the LSP kicks in).

For example, you’ve probably seen something like this in VS Code:

VS Code breadcrumbs

The beginning of the trail (reading from left to right) indicates where you are in the file directory. In this case, we’re at src/vs/base/browser/ui/button/button.ts meaning we’re currently editing a TypeScript file a few folders down from the root. If we look to the right of that, we’re also inside an interface called IButtonStyles and inside of that interface, we’re editing an option called buttonBackground. This last part is the LSP working its magic, since the language server “understands” what the code is — at least, at a very low level, which is sufficient to provide us with these awesome breadcrumbs.

So let’s add our own breadcrumbs to Neovim with nvim-navic. Integrating it into LSP Zero (we’re really integrating it into nvim-lspconfig) just takes a few lines of code.

Similar to the default keymaps that we added earlier, we want nvim-navic to attach to any buffer with an associated LSP. So heading back to the on_attach function that we wrote earlier, we can effectively attach nvim-navic as well.

local navic = require('nvim-navic')

lsp.on_attach(function(client, bufnr)
lsp.default_keymaps({buffer = bufnr})
if client.server_capabilities.documentSymbolProvider then
navic.attach(client, bufnr)
end
end)

And that’s it! Now we have some very informative breadcrumbs in our Neovim winbar:

Don’t mind the treesitter-context…

That’s about it for my LSP overhaul, but I don’t think I’m done tweaking my LSP configuration just yet. I’m planning on switching nvim-navic to dropbar.nvim, and though I’m kind of late to the party, formatting and linting plugins like null-ls tie into LSP quite nicely.

lazy.nvim Overhaul

Overhauling my LSP configuration was initially inspired by a completely pragmatic motivation: I wanted LSP working to write code for work. My plugin manager (lazy.nvim) overhaul was less pragmatic and a bit more of an affectation.

In just a few days, I was going to be doing a presentation for work — on the topic of Neovim itself! Since I had recently switched over to Neovim from VS Code, I thought that it would be a great idea to test my knowledge via a pedagogical trial. Unfortunately, my Neovim startup time wasn’t where I wanted it to be: It was more than 100 ms 🤮

100% real

Even though startup time does vary based on the device, even my fastest computer was failing to achieve sub 100 starts. I could see anything from a 150 ms startup to one that took nearly 300 ms. Unacceptable.

Since I had done practically zero research on how to lazy-load plugins using lazy.nvim, my method of “lazy-loading” was extremely primitive and achieved close to nothing. Here’s how my old lazy.nvim config was laid out:

📂 ~/.config/nvim
├── 🌑 init.lua
├── 📂 lua
│ ├── 📂 v9
│ ├── 🌑 keymaps.lua
│ └── 🌑 plugins.lua
│ └── 📂 plugin_config
│ ├── 🌑 gruvbox.lua
│ └── 🌑 lualine.lua
│ └── 🌑 nvim-tree.lua
│ └── 🌑 init.lua

Every single plugin along with its lazy.nvim options was stored in a file called plugins.lua, which was where I originally had my packer.nvim bootstrapping code. However, the configuration function for each plugin itself would be under the plugin_config/ directory — each plugin had its own Lua file.

And the way that I tried to lazy-load my plugins was very naive. It looked a little something like this:

local lazypath = vim.fn.stdpath('data') .. '/lazy/lazy.nvim'
if not vim.loop.fs_stat(lazypath) then
vim.fn.system({
'git',
'clone',
'--filter=blob:none',
'https://github.com/folke/lazy.nvim.git',
'--branch=stable', -- latest stable release
lazypath,
})
end

vim.opt.rtp:prepend(lazypath)

local plugins = {
{ 'nvim-tree/nvim-tree.lua', lazy = true },
{ 'nvim-tree/nvim-web-devicons', lazy = true },
{ 'nvim-lualine/lualine.nvim', lazy = true },
{ 'marko-cerovac/material.nvim', lazy = true },
{ 'nvim-treesitter/nvim-treesitter', lazy = true },
}

local opts = {}

require('lazy').setup(plugins, opts)

As you can see, I set the “lazy” option of every individual plugin to be true, but I didn’t actually specify the conditions on which to lazy-load them (ie. load this plugin if another plugins requires it, or load this plugin when Vim creates a new file, etc.). This isn’t to say that this configuration did nothing for my startup time, but it definitely could have been optimized a bit.

Manually setting some plugins to be lazy-loaded in this manner made Neovim marginally faster, but it wasn’t anything revolutionary. Everything still felt sluggish, and the main reason for that was due my plugins’ configuration code was running.

When I initially set up my Neovim config, I used packer.nvim as my plugin manager, adding each plugin’s installation code into a file called plugins.lua, which consisted of packer’s bootstrapping code:

local ensure_packer = function()
local fn = vim.fn
local install_path = fn.stdpath('data')..'/site/pack/packer/start/packer.nvim'
if fn.empty(fn.glob(install_path)) > 0 then
fn.system({'git', 'clone', '--depth', '1', 'https://github.com/wbthomason/packer.nvim', install_path})
vim.cmd [[packadd packer.nvim]]
return true
end
return false
end

local packer_bootstrap = ensure_packer()

return require('packer').startup(function(use)
use 'wbthomason/packer.nvim'
-- My plugins here
-- use 'foo1/bar1.nvim'
-- use 'foo2/bar2.nvim'

-- Automatically set up your configuration after cloning packer.nvim
-- Put this at the end after all plugins
if packer_bootstrap then
require('packer').sync()
end
end)

As mentioned earlier, none of my plugins’ configuration settings were included in this file — a good thing, since that would have been very messy. Instead, the overarching init.lua file (the one under ~/.config/nvim) calls on v9.plugin_config, which itself contains an init.lua as an entry point. This init.lua requires all the other files in the v9/plugin_config directory, effectively running the code that configures each plugin. This is rather inefficient, since every single plugin is configured and loaded even though they might not be needed right away at start up.

Here’s a file tree with some Lua code in-line to explain what I mean (sorry for the cursed code alignment):

📂 ~/.config/nvim
├── 🌑 init.lua --> require('v9.plugin_config')
| -- this calls on the init.lua* file under plugin_config
├── 📂 lua
│ ├── 📂 v9
│ ├── 🌑 keymaps.lua
│ └── 🌑 plugins.lua
│ └── 📂 plugin_config
│ ├── 🌑 gruvbox.lua
│ └── 🌑 lualine.lua
│ └── 🌑 nvim-tree.lua
│ └── 🌑 init.lua* --> require('v9.plugin_config.gruvbox')
require('v9.plugin_config.gruvbox')
require('v9.plugin_config.gruvbox')
-- this init.lua file calls on every single
-- configuration file under plugin_config

And though you can lazy-load plugins with packer, I never tested that feature out. When I switched over to lazy.nvim, I only swapped out the bootstrapping code and removed compiled packer files — a barebones swap, if you will. So my configuration still looked like the file tree above even after switching plugin managers.

It wasn’t until one day while I was browsing r/neovim (as I always am) did I find a comment on the elegance of configuring lazy.nvim by modularizing plugins to their own files, with each file achieving both installation and configuration (with full control over lazy-loading). The file structure that this Reddit comment was describing would look something like this:

📂 ~/.config/nvim
├── 🌑 init.lua
├── 📂 lua
│ ├── 📂 plugins
│ ├── 🌑 gruvbox.lua
│ └── 🌑 lualine.lua
│ └── 🌑 nvim-tree.lua
│ ├── 📂 v9
│ ├── 🌑 keymaps.lua
│ └── 🌑 lazy.lua

With this file structure, there’s a few key differences to take note of. First, the lazy.lua file (which is the same thing as the plugins.lua file name that I used for packer) doesn’t reference any plugin installation or configuration code from within itself. Instead, we’ll have it pull from a folder called plugins directly under the lua directory — remember that Neovim automatically searches for files under the lua directory:

local lazypath = vim.fn.stdpath('data') .. '/lazy/lazy.nvim'
if not vim.loop.fs_stat(lazypath) then
vim.fn.system({
'git',
'clone',
'--filter=blob:none',
'https://github.com/folke/lazy.nvim.git',
'--branch=stable', -- latest stable release
lazypath,
})
end

vim.opt.rtp:prepend(lazypath)

require('lazy').setup('plugins')

-- the above line does not pull from a local table called plugins
-- instead it's pulling from a string called 'plugins'
-- lazy.nvim will interpret this as a folder called plugins
-- under the lua directory

And second, the plugin configuration files need to return tables themselves (since lazy.lua is expecting tables to execute the setup() function on). For example, this is what the lualine.lua file might look like:

return {
'nvim-lualine/lualine.nvim', -- plugin to install
event = { 'BufReadPre', 'BufNewFile' }, -- lazy-loading (more on this later)
config = function () -- the configuration code
local status_ok, lualine = pcall(require, 'lualine')
if not status_ok then
return
end

lualine.setup({
options = {
icons_enabled = true,
theme = 'auto',
},
sections = {
lualine_a = {
{
'filename',
path = 1,
},
},
},
})
end
}

In the case of having multiple plugins being installed and configured in a single file, just return multiple tables! It’s that easy!

return {
{
'windwp/nvim-autopairs', -- plugin 1
event = { 'InsertEnter' },
config = function()
local npairs_status_ok, npairs = pcall(require, 'nvim-autopairs')

npairs.setup({})

end
},
{
'windwp/nvim-ts-autotag', -- plugin 2
event = { 'InsertEnter' },

config = function()
local autotag_status_ok, autotag = pcall(require, 'nvim-ts-autotag')

if not autotag_status_ok then
return
end

autotag.setup({})
end
}
}

On top of this nice, modular design, lazy.nvim automatically detects when changes are written to your plugin configuration, and you’ll get a message that reads something like this:

This way, if you want to install a new plugin, you can simply create a new file that returns a table including the name of the plugin, write your changes to the buffer, open up the lazy.nvim UI and install it right away — all without restarting Neovim!

Looks like I’ve gone off on quite the tangent. Back to my trials and tribulations with overhauling lazy.nvim…

As I started to tinker with my Neovim plugin configuration in the hopes of drastically cutting down my startup time, I ran into a few speed bumps. I tried to change the syntax of my plugin configuration to match what was described in the Reddit comment, but the process wasn’t so smooth. Neovim kept breaking, and that was most definitely due to my lack of knowledge on lazy.nvim.

When Neovim broke, I would undo my changes and try again with some slightly different tactics. After three or four tries and Neovim breaking every single time, I decided that maybe it would be better to start “from scratch.” I deleted all of my plugin configuration files and added them back, one by one.

For a short period in my commit history, every single commit added one plugin back to my configuration. I did this over the course of an hour or so, and because I was adding plugins back incrementally (rather than all at once), I could observe which plugins actually affected startup time the most.

More importantly, as I added plugins back to my Neovim configuration, I made sure to be precise about my lazy-loading by specifying one of two lazy.nvim options:

  • event, which can be set to Neovim autocommand events, either standalone or in a table
  • cmd, which can be set to a Vim command

In the case of an event, lazy.nvim will load the plugin as soon as that Neovim autocommand event fires. Some examples are “BufReadPre,” which occurs when Neovim starts to edit an already-existing file, “BufNewFile,” which occurs when Neovim starts to edit a non-existing file, or “VimEnter,” which occurs after Neovim loads all vimrc files, creates all the windows and loads their buffers. If you want to learn more about Neovim’s autocommand events, check out the official documentation.

For Vim commands, lazy.nvim will load the plugin as soon as the user executes that command. For example, with the nvim-tetris plugin, there’s no reason to have the Tetris game loaded unless I want to play it with the :Tetris command. As such, we can set cmd to Tetris, which will prevent lazy.nvim from loading it unless we execute the command of that same name.

Here’s a partial code snippet from my LSP configuration that uses both event and cmd:

    {
'VonHeikemen/lsp-zero.nvim',
event = { 'BufReadPre', 'BufNewFile' },
cmd = 'Mason',
branch = 'v2.x',
dependencies = {

...

Essentially the LSP Zero plugin will load if Neovim starts editing an already-existing file or a new file, or if the :Mason command is executed. This leads to much faster startup times, especially in the case of large plugin configurations like LSPs.

If you yourself are lazy and don’t want to figure out the specifics of which commands or which Vim autocommand events are optimal for lazy-loading, you can also just set every plugin that isn’t crucial for the initial UI to lazy-load upon an event called “VeryLazy,” which is a special event defined by lazy.nvim itself. This event is triggered after “VimEnter” autocommands are processed and “LazyDone,” which is when lazy.nvim has finished starting up and loading your Neovim configuration.

If everything goes well, you’ll find yourself with a glorious startup time.

Check out that sub 40 start!

Kevin Feng || Website || GitHub || LinkedIn || Medium (you’re already here!)

--

--

Kevin Feng

Computer Science Student | Independent Software Developer | PC Hardware Enthusiast