Neovim Configuration: An Empirical Analysis
OK. Here we go.
I created my first Neovim config when I was in my senior year of high school. I almost bar for bar copied The Primeagen’s config tutorial that gave you essentially anything you could ever want. After I got his config, I didn’t make any major changes for a long time, almost 2 and a half years. Throughout that span of time, I put the config through it’s paces, and it grew in girth and convolution. For example, I desired one keymap to run any and all code. This means one keymap to open a new terminal, compile if necessary then run the code, for any programming language I threw at it. For some reason this is how I decided I should go about this.
vim.keymap.set("n", "<C-Enter>", function ()
require("beepboop").play_audio("runprogram")
local terminalCmd = "alacritty --command "
local runScriptsPath = "/home/eggbert/.config/nvim/lua/eggbert/scripts/"
local fileName = string.lower(vim.fn.expand("%"))
local fileExtension = string.lower(fileName.sub(fileName, string.len(fileName) - 2, string.len(fileName)))
if fileExtension == "ava" then -- RUN JAVA CODE
if (string.find(fileName, "test") == nil) then
print("RUN JAVA")
os.execute(terminalCmd .. runScriptsPath .. "javarun.sh &")
else
print("RUN TESTS")
os.execute(terminalCmd .. runScriptsPath .. "javatest.sh &")
end
elseif (fileExtension == "cpp" or fileExtension == "hpp" or string.sub(fileExtension, -1) == "c") then -- RUN C++ AND C
os.execute(terminalCmd .. runScriptsPath .. "cpp.sh &")
elseif (fileExtension == "lua") then -- RUN LUA CODE
os.execute(terminalCmd .. runScriptsPath .. "lua.sh " .. fileName)
elseif (fileExtension == "zig") then -- RUN LUA CODE
os.execute(terminalCmd .. runScriptsPath .. "zigrun.sh " .. fileName)
else
print("ion know whut da **** to do wit dis")
end
end)
This… is disgusting. First line is off to a good start with beepboop
(still working on that by the way). But then, all hell breaks loose. I
use “alacritty” directly instead of something more portable like
os.getenv("TERM")
, which bit me back later when I switched
to st. The path for the scripts is
aboslute and hardcoded, here we could use
vim.fn.stdpath("config")
. The most disgusting part is how I
grossly pick out the filetype from the whole filename. The rest of the
code reaks of unplanned nonsense with terrible comments and a
lot of repeating patterns.
My init.lua needs to be at least 6’5”
So why must I ruin your day by showing you this? Because it is no more. After randomly finding out that the nightly build of Neovim added a native package manager and lsp support, I nearly started jumping up and down. Previously I had used packer.nvim which worked, but was getting old and became unmaintained. Then I switched to lazy.nvim which was fine if I’m being honest, but felt a little magical, and since I had been developing beepboop.nvim, I knew my way around Neovim a little better. This is when I wanted to understand my config a bit better, leading me to rewrite it. I had some goals in mind for this:
- Try to keep everything Neovim native: packaging, lsp, and completion
- Use as few plugins as possible
- Keep organized and understandable to me
- Experiment with new plugins, keymaps, or features (for fun)
Forged From Fire
I’ve decided that as I write this It’s going to become a tutorial of
sorts, to show the process of building a config. So we will start where
all good adventure’s do: the init.lua
. This is the only
file Neovim will look for a config in (with a few exceptions). When I
write my config, all the actual configuration happens in other files,
while my init.lua
just loads those files together, almost
like a table of contents for my config. So that we are on the same page
here is the file structure of my config (some extraneous files are left
out).
lua/
options.lua
plugins.lua
remaps.lua
scripts/
init.lua
Now let’s take a look at the entire action packed init.lua
:
require("options")
require("plugins")
require("remaps")
require("autocmd")
Well this looks quite lackluster, doesn’t it? If you said yes, you
would be right, it is! But that is the beauty of it. All this file does
is load the files inside of our lua/
directory one after
the other, simple. Before we look at the other files, Lua is a simple
language, but if anything is confusing, fear not and read the friendly manual.
Baby’s first steps
According to the init.lua
, we will run options.lua
first, so let’s take a look. Options is going to be concerned with
Neovim’s options, not any external or plugin related settings. The basic
structure of setting an option in Neovim looks like this:
vim.opt.number = true
-- or shortened
vim.o.number = true
In this case, number
is a boolean setting, and we set it
to true. But how could a mere boolean make this editing experience any
better, you might be asking? Well we can consult the help pages to find
out. By using :help number
we see can “precede each line
with its line number”. Pretty neat, and guess what, this same pattern
exists for most out-of-the-box Neovim settings. The entire config can be
found here, but
below are some of the most common options people use:
vim.g.mapleader = " " -- We'll talk about this later
vim.o.number = true
vim.o.relativenumber = true -- Line numbers are relative to your cursor
vim.o.tabstop = 4 -- How many columns should be used to display a tab character
vim.o.shiftwidth = 4 -- Number of columns to use for one level of indentation
vim.o.wrap = false -- Disable line wrapping in all buffers
Let’s turn up the heat
The next piece to tackle is the remaps. In remap.lua
there is, again, one simple pattern to use. When setting a keymap we
need to know, what mode it should happen in, what the keys actually are,
and where we want the effects to come from. First, the mode, I have
three keymaps below, all in different modes:
-- When in visual mode, if Shift + J is pressed, move the selection down 1 line
vim.keymap.set("v", "J", ":m '>+1<CR>gv=gv")
-- When in insert mode, if Control + Backspace is pressed, delete an entire word
vim.keymap.set("i", "<C-BS>", "<C-w>")
-- When in normal mode, if Shift + C is pressed, change the hovered word
vim.keymap.set("n", "C", "ciw")
These are quite simple, and give a good taste of keycode maps, where
one keycode is mapped to another keycode. In this case C
to
ciw
or Control + Backspace
to
Control + w
(deleting a word). The other option we have is
to use functions do create more complex keymaps. Here is where we get to
see the new and improved “code runner”.
local lookup = {
lua = "lua.sh",
cpp = "make.sh",
c = "make.sh",
hpp = "make.sh",
h = "make.sh",
zig = "zigrun.sh",
}
vim.keymap.set("n", "<C-Enter>", function ()
local scripts_dir = vim.fn.stdpath("config") .. "/scripts/"
local filetype = vim.bo.filetype
local filename = vim.fn.expand("%s")
vim.uv.spawn(os.getenv("TERM"), {
detached = true,
args = { "-e", scripts_dir .. lookup[filetype], filename }
}, function () end)
end)
I won’t walk through this function and why it’s leagues better than
the dumpster fire from before, all that is important is that this uses a
function in the place of the resulting keycode. This could have been as
simple as the following, printing “Run code” anytime we
pressControl + Enter
.
vim.keymap.set("n", "<C-Enter>", function ()
vim.print("Run code")
end)
The bread
Get your bib ready, because we are officially entering the meat and
potatoes. plugin.lua
contains all the configuration of
external plugins for our editor. As a foreword, many people split the
configuration of their plugins so that each plugin gets its own file,
but I don’t like that for reasons I don’t wish to elaborate on (i don’t
gaf). Now, there is a pattern to adding and configuring plugins, but
this changes for each plugin you come across. Fortunately, almost all
plugin developers have directions for how to configure their plugin, so
be sure to check out the READMEs from their repositories.
To start, note that vim.pack
will contain all the
functionality we need to manage plugins.
vim.pack.add("plugin-repo")
- download a pluginvim.pack.del("plugin-repo")
- remove a pluginvim.pack.get()
- lists all the installed pluginsvim.pack.update("plugin-repo)
- update plugin
To note, if vim.pack.add()
finds the plugin is already
installed, it will do nothing, likewise, if
vim.pack.update()
finds no update, it does nothing.
vim.pack
is extremely simple, and easier to understand than
lazy.nvim in my opinion, but it is certainly newer and far less feature
rich. Nevertheless, here is some basic use.
vim.pack.add({ "https://github.com/windwp/nvim-autopairs" })
require("nvim-autopairs").setup({
map_bs = false,
map_cr = false
})
What’s going on here? Well first we use vim.pack
to add
a plugin called “nvim-autopairs”. After this we have to configure
“nvim-autopairs” so we use require("nvim-autopairs")
to get
access to the plugin itself. Afterwards, most all plugins implement a
setup function, that the user can call in order for any and all plugin
setup to take place. Also notice that we are passinge a table with some
fields to the setup function. This is out plugin’s configuration. Most
plugins take this configuration through the setup function too. In this
case, we are telling “nvim-autopairs” to not make keymaps for the
backspace or enter keys. Note that even if you aren’t passing a
configuration to setup()
, you should still call it, so that
the default configuration can be used. And there you have it, your first
Neovim plugin!
For any plugin you add, the same pattern can be used, but some
plugins require configuration besides the standard
require("name").setup()
. Take a colorscheme for example.
Besides adding the plugin via vim.pack
we also need to
apply the plugin, using vim.cmd
. Here is what this looks
like in my configuration:
vim.pack.add({ "https://github.com/blazkowolf/gruber-darker.nvim" })
-- Set the colorscheme of the editor
vim.cmd("colorscheme gruber-darker")
Here we don’t need to call require().setup()
.
The butter
Typically, people use plugins for lsp, completion, syntax highlighting and code navigation tools. But recently Neovim added support for lsp and completion in-house. Although the completion certainly needs a little time to develop, the lsp works fine, and I will show a bit about how to get it set up. For the uninitiated, lsp stands for “language server protocol” and it exists for most major languages as a way to offer code diagnostics and code completion to an editor. The lsp itself is a server that runs just like any other program, exchanging messages between the editor using it. Here we see the first step of setting up any lsp, and that is getting ahold of the server itself. This can be through your operating system’s native package manager, or most lsps also offer downloads with external package manager’s like cargo or npm. Additionally, if this method of downloading lsps proves too cumbersome, mason.nvim has been popular for quite some time, as a very user friendly way of installing lsps straight from neovim.
As an example for installing and setting up an lsp, I will setup
“clangd”, the usual suspect for c/c++ code. But before we start, all
lsps we want to use with Neovim need to be confgiured by us. We do this
by calling vim.lsp.config()
, with a table as an argument
containing all of our configuration options. I personally don’t want to
configure every lsp I use, so if you don’t either, I can reccommend nvim-lspconfig. This
will give us all the lsp configurations we could ask for, and is a nice
set and forget. Now with that out of the way, lets take a look at
setting up clangd with native Neovim lsp.
vim.pack.add({ "https://github.com/neovim/nvim-lspconfig" })
vim.lsp.enable({ "clangd" })
vim.lsp.log_levels = "off" -- Just to stop a large log file from building up
Can you believe that that’s it! The GOAT editor
strikes again. Finally, lsp isn’t that cool without some completion. You
could go the route of vim.lsp.completion
, but I found that
it lacks a few options I need. I can reccommend blink.cmp for completion,
with a really easy configuration process, but I will leave that as a job
for the reader.
At this point you should have a decent editing experience. If you ever want a reference for what you can do with Neovim, or pointers in writing your own config, looking at other configurations is always a solid option. My complete configuration, for which most of this blog was based, can be found here, and I can also reccommend Squibid’s config as well as Phantom’s config. If you have any questions about Neovim configuration at all, feel free to email be at hdiambrosio@gmail.com. Below I will leave a list of plugins and what they do in case you want some ideas.
- blink.cmp - Completion
- gruvbox.nvim, ashen.nvim and gruber-darker.nvim - Colorschemes
- snacks.picker or mini.pick - Code navigation and searching
- terrible.nvim - Lsp diagnostics tools
- donut.nvim - Banger screen saver
- oil.nvim - THE best file manager on Linux. Must have
- nvim-autopairs - You’re the right parenthesis to my left parenthesis
- cord.nvim - Discord rich presence for Neovim
- markview.nvim - Sleek formatting for Markdown, Typst, and LaTeX
- nvim-treesitter - Syntax highlighting and text objects
- typst-preview.nvim - Take a guess