A Morlock's Guide to Lua, Part 3: Using Lua to Configure Nvim
Setting options, using the Vim API, and a little bit about tables and metatables
This is the third entry in a series on using Lua in Neovim. You can check out the first two entries here (and the later entries, once they’re posted.)
Part 3: Configuring Nvim with Lua
When I asked folks on Mastodon why they use Neovim rather than Vim, using Lua (or something that compiles to Lua, like Fennel) came up a lot. Some of that is the language itself — Lua is a weird programming language in a lot of ways, but it’s a much more general purpose language than Vimscript, and it’s fast.
But a lot of it is, I suspect, the Lua Vim API. (Which somewhat confusingly is named vim
— this makes sense within the context of Nvim configuration but its weird when you’re talking about the differences between Nvim and Vim that one of them has a “Vim API” and it’s not Vim.)
I’ll probably come back to this topic since there’s a lot here and I’m not sure how to organize it all. For now this is largely “a bunch of stuff I’ve learned about the Lua programming environment that’s relevant to writing Vim configuration.” It’s ordered roughly in terms of immediate practical value, so the stuff that will help you write some Vim config in Lua immediately is at the top and the stuff that probably won’t come up until you’re debugging something is towards the bottom.
The simplest way to set options
A lot of the Lua that you read and write in Nvim will be to set options. There are a lot of ways to do this, but the most succinct is like this:
vim.o.number = true
This is equivalent to
vim.opt.number = true
And also to
vim.cmd('set number')
Turning boolean settings like this off is also a little simpler in the Lua API than it is in Vimscript. Instead of writing something like
vim.cmd('set nonumber')
You can just write
vim.o.number = false
Configuring Plugins
The other thing you’re likely to do a lot with Lua is pass configuration to plugins. How this works will vary from plugin to plugin but the main interface I’ve seen so far is that the plugin will provide some kind of constructor, and you pass configuration to it as a table. So if we go back to our good friend lspconfig
we’ll see lines like this:
-- Setup language servers.
local lspconfig = require('lspconfig')
lspconfig.pyright.setup {}
where that setup
function creates the connection between Nvim and the LSP, and we’re passing it an empty table as {}
. It could take configuration so you have to give it the argument, but there’s no required configuration so the table is empty.
When we do pass configuration it looks like this:
lspconfig.rust_analyzer.setup {
-- Server-specific settings. See `:help lspconfig-setup`
settings = {
['rust-analyzer'] = {},
},
}
Here we’re passing in a table with a single key, settings.
Its value is another table, and that table contains a string key with a value that is yet another table.
Initializing tables with string indices
One slightly quirky thing here is that this works
settings = {
['rust-analyzer'] = {},
}
And this works
settings = {
rust-analyzer = {},
}
And both of them let you access the value of rust-analyzer like this
vim.inspect(settings['rust-analyzer'])
But this doesn’t work
settings = {
'rust-analyzer' = {},
}
The Lua documentation doesn’t really explain why except to say that you cannot initialize fields “with string indices that are not proper identifiers” in some constructor formats.
What’s up with the vim.api module?
A lot of the Lua that you’ll write or read in Nvim will be coming from Nvim’s “standard library,” a vim
module that Nvim loads for you during startup. The module and its functions are documented in detail in the Nvim docs, but I’m going to pull out a few of the highlights that you should be familiar with.
One thing that you may find confusing about the Nvim Lua API is that there’s often more than one way to do things, one of which involves calling vim.api
submodule, and one in the top level vim
namespace. For instance, to run Vimscript, you can use vim.cmd
vim.cmd('echo "Bees are good"')
Or vim.api.nvim_command
vim.api.nvim_command('echo "Bees are good"')
Or even vim.api.nvim_exec2,
which, somewhat mysteriously, requires a table as a second argument. (The documentation reports that the argument is there for “optional parameters,” but incorrectly claims that the argument is a boolean.)
vim.api.nvim_exec2('echo "Bees are good"', {})
What’s basically going on here is that there’s an underlying Nvim C API that’s available for all kinds of clients. The vim.api
map directly to that API. Then, various submodules in what Nvim refers to as its “standard library” — like cmd
— use those API primitives to build friendlier behaviors.
If you take a look the source code for vim.cmd
for example, you’ll see that it uses vim.api.nvim_cmd
in some cases and vim.api.nvim_exec2
in others. So vim.cmd
is a little more flexible in the arguments that it accepts than either of the api
commands that it uses.
Tables and Metatables
But wait! There’s more ways to call Ex commands!
You can call any Ex command by passing it as an index to vim.cmd,
like this:
vim.cmd.echo("Bees are good")
At this point if you take a look at the struction of nvim.cmd
with print(vim.inspect(vim.cmd))
you’ll see
{
echo = <function 1>,
<metatable> = {
__call = <function 2>,
__index = <function 3>
}
}
And if you run a different cmd
this way it’ll also appear in this table.
{
colorscheme = <function 1>
echo = <function 2>,
<metatable> = {
__call = <function 3>,
__index = <function 4>
}
}
Whenever you run into surprising or clever behavior in Lua, you should start looking for a table or a metatable. The metatable for a table can define the behaviors a table should respond with when you do something weird to it — try to add it to another table, for instance, or try to call it as a function, as we’re doing here.
It’s a little bit tricky to figure out exactly what these functions do, because Lua throws away the source code of a function once its compiled the byte code. Without something like debug enabled those functions are black boxes. But if we look back at the source code, we can find the definition for the __index
function, which is what gets called when we index cmd
with the name of the command we want to run, rather than passing it an argument.
__index = function(t, command)
t[command] = function(...)
local opts
if select('#', ...) == 1 and type(select(1, ...)) == 'table' then
opts = select(1, ...)
-- Move indexed positions in opts to opt.args
if opts[1] and not opts.args then
opts.args = {}
for i = 1, VIM_CMD_ARG_MAX do
if not opts[i] then
break
end
opts.args[i] = opts[i]
opts[i] = nil
end
end
else
opts = { args = { ... } }
end
opts.cmd = command
return vim.api.nvim_cmd(opts, {})
end
return t[command]
end
There’s a bunch of code here but basically what’s happening is that when try to access an absent field on the table, the interpreter turns around and calls the table’s __index function with the table itself as the first argument, t,
and the key as the second argument, which here is command.
In this case, we then do a bunch of stuff with opts
to construct the command and its arguments into the table nvim_cmd
expects. There’s a set of nested if statement I think because a user might pass us arguments as a numbered list in opts
or as a numbered list in opts.args
and we want to make sure we always pass the arguments on as opts.args.
Finally we return the table with the new command in it in that last line, return t[command].
Lua libraries use tables and metatables like this a lot, so I think its worth spending the time to really understand what’s happening when you run into an example like this. They’re how the Lua documentation describes implementing inheritance, for example.
How Neovim alters require
Oh and, remember that mystery last week? Alpha Chen helped me figure out what was happening, and it involves, you guessed it, another table. Lua has a package.loaders
table that provides a list of ways to find packages, and Neovim adds a function to it that searches for paths based on its own runtimepath.
From the Neovim codebase on Github:
if vim.api then
-- Insert vim._load_package after the preloader at position 2
table.insert(package.loaders, 2, vim._load_package)
end
Summary
The most succinct way to set options is with
vim.o.some-option = some-option-value
Like many things in Lua plugins are often configured by assembling a table and then passing that table to a function
Initializing tables with string indices looks a bit weird
The
vim.api
module in Lua provides a bunch of “primitive” functions that are composed into functions in other submodules in thevim
“standard library,” likevim.cmd
andvim.opt
Mysterious Lua behavior probably involves a metatable
References
The __index Metamethod — all of chapter 13 in the Lua docs is useful if you want to understand some of Lua’s more advanced behaviors, but this is the part I referenced for this post
Neovim’s require behavior — source code on Github
Lua’s Table constructors — more detail about all the ways you can assemble tables
Documentation on Nvim’s Lua Standard Library
Documentation on Nvim’s API
Which part of this is useful to you?
Something I’d appreciate knowing a bit more about— Are the “hey here’s this weird behavior in Lua and here’s what’s happening?” sections of this newsletter useful?
Or are you more interested in more tactical information like “here’s this plugin and what it does” or “here’s how to do this editing task?”
I feel like I rabbit-holed a little bit this week and last, on especially the part in here about Lua meta-tables and exactly how “require” works in Neovim. The research for stuff like this is really fun and I find it surprisingly helpful to know this kind of stuff, but it’s a little bit off from what I expected to be writing about.
I can get some sense of what y’all think based on the Substack stats about what gets shared but there’s really no substitute for qualitative data. So, let me know if you’d like to read more deep dives into Lua details, or if you’d prefer I return to things more directly related to editing/refactoring/code navigation.