I recently rewrote my Neovim configuration in Lua in order to take advantage of a more intuitive programming language for configuration, Lua-only plugins, and powerful Neovim-only features such as:
- The built in LSP: Language Server Protocol, IDE features like “show docs”, “find references”, “rename”, and “go to definition” for basically any language.
- Treesitter: Powerful parsing of any programming language, which means plugins and syntax highlighting can make powerful and accurate transformations.
In this article I’d like to share some tips from my experience rewriting my config (tl;dr: check this commit to see what changed).
This article pales in comparison
Most of what I’ve learned I’ve learned from this GitHub repository, which is a lot more in-depth than this article.
Lua primer
Lua, at least the kind of Lua you’re likely to use for writing your Vim config, is really intuitive and easy to learn, assuming you already know an interpreted programming language like JavaScript. Here are the least intutive bits, the rest you can probably figure out when you first see it:
- Single line comments are
--
. Multi line comments are--[[commented stuff in here]]
. - Variables are global by default. Use
local myVar = 'foobar'
whenever you declare a variable and you’re unlikely to run into trouble. - “Table” is the central type, which acts as “object”, “map”, and “array”, all in one:
- Map:
{foo = "bar", baz = "bat"}
; like JSON, but=
instead of:
. - Array:
local arr = {'foo', 'bar', 'baz',}
; trailing commas are OK. Arrays are tables where the key is the index implicitly. Lua is 1-indexed. I.e.arr[2] == bar -- true
- Object:
local M = {}
is typical when creating an object whose intent is to be used in other files. Add methods withlocal function M:myMethod(arg) print(arg) end
to add amyMethod
method toM
.
- Map:
- Import modules (files) with
local p = require('module.path')
. The value returned frommodule/path.lua
is stored inp
.require
caches the returned value, so future calls torequire('module.path')
will return the same value. - Parentheses when calling functions with only one argument are optional. This means you sometimes see weird code like
require'somemodule'.method{foo='bar'}
. - String concatenation uses the
..
infix operator.
If you’d like a more accurate and less rambly introduction to Lua, check out this talk by TJ Devries (Neovim core contributor):
Store your configuration in Git
You will mess up. Store your vim config in Git upfront so you can increment on your migration process, and always return to a known state – or even go back to VimL if Lua isn’t for you.
You can find my (embarrassing) evolution of Vim usage on GitHub.
Start with structure before refactoring
Start the migration by putting files in their correct location without really rewriting anything.
Unless you’ve set the $XDG_CONFIG_HOME
variable to something other than ~/.config
, you should make the following change:
- ~/.config/nvim/init.vim
+ ~/.config/nvim/init.lua
Then take the contents of your old .vimrc
and wrap them inside
vim.cmd([[
<contents of .vimrc goes here>
]])
In Lua, [[
and ]]
enclose a multi-line string, so you’ve probably guessed that the above code just evaluates the string (your old VimL config) as VimL.
This will most likely work out of the box.
Indeed, if you struggle to find the native Lua API for the change you want to do, you can call vim.cmd('<some snippet>')
at any time.
Commit now, and you’ll have a lot smaller and more obvious commits going forward.
Switch to a Lua-native package manager
I switched from (the excellent) vim-plug to packer, which lets me use Lua to define the dependencies, run Lua functions on updates or install.
Packer also supports bootstrapping itself, and provides a snippet in its README in order to do just that.
Recommendation:
Don’t mess around with
:PackerCompile
or:PackerSync
unless you really struggle with startup time. They can confuse matters really easily.
Rewrite bit by bit, then commit
I took the following approach to the migration:
- Start by rewriting all my plugins to use
packer
, googling and fixing things as I went along. - Then I moved on to rewriting my ‘general’ settings, basically the settings that were
set something=someVal
in VimL. - Then I moved on to mappings.
- Then I rewrote my
after/ftplugin/{lang}.lua
files - Then I added LSP client configuration.
Key points:
Interacting with the Neovim application will happen via the vim
global variable.
Options
vim.g.my_option = "my value"
for setting global vim options.vim.o.my_option = "my value"
for setting other options.vim.bo.my_option = "my value"
for setting buffer-specific options.vim.wo.my_option = "my value"
for setting window-specific options.
If you’re not sure if an option is buffer
or window
just try setting it to one.
It will usually tell you if it’s wrong if you try and run it.
Mappings
vim.api.nvim_set_keymap(kind, lhs, rhs, options)
is the API that Neovim exposes for defining custom key-mappings, but this is a bit long-winded, so I recommend creating a utility function:
local function map(kind, lhs, rhs, opts)
vim.api.nvim_set_keymap(kind, lhs, rhs, opts)
end
Here kind
can be:
''
: The equivalent of(nore)map
in VimL, i.e. the mapping applies in visual, normal, select, and operator-pending modes.n
: Normal modev
: Visual modei
: Insert mode,- etc.
The nore
part of nnoremap
, inoremap
in VimL is configured as part of an option: noremap = true
.
I reckon most of your bindings will have { noremap = true, silent = true }
, so you might as well assign this table to a variable pass it in to your mappings.
Here’s a snippet from my config where I’m taking advantage of the above:
local function map(kind, lhs, rhs, opts)
vim.api.nvim_set_keymap(kind, lhs, rhs, opts)
end
local silentnoremap = {noremap = true, silent = true}
-- ...
-- Intuitive increment and decrement
map('n', '+', '<c-a>', silentnoremap)
map('n', '-', '<c-x>', silentnoremap)
-- Conveniently enter command mode
-- Don't use silent=true as this removes the command line.
map('n', ';', ':', {noremap=true})
-- Go to definition and pop back up
-- Don't do noremap to allow accessing LSP behaviour
map('n', '<right>', 'gd', { silent = true })
map('n', '<left>', '<c-t>', { silent = true })
Set up LSP
Setting up the native LSP client will largely depend on your own preferences, and the languages you write. That said, I recommend using the following (Packer) plugins:
use 'neovim/nvim-lspconfig' -- Easy LSP configuration
use 'kabouzeid/nvim-lspinstall' -- Install LSP servers on demand with :LSPInstall <name_of_language>
Then configure them according to their instructions. You’ll likely end up with other plugins that also want to integrate with LSP (e.g. Telescope), so your configuration will definitely look different to mine, but here it is regardless, with the hope that it can be useful as a reference, if nothing else.
Create your own namespace to avoid collisions
It is of course up to you if you want to keep your entire configuration in a single init.lua
file, or if you want to spread your code across multiple files, but you have to be careful when organising files other than init.lua
!
To avoid namespace collisions, don’t put your files directly under ~/.config/nvim/lua/foo.lua
, but instead create another directory, named something that won’t collide with the dependencies/plugins that you use, such as using your GitHub username: ~/.config/nvim/lua/kinbiko/foo.lua
.
This way you can create files with configuration and mappings per plugin, should you so wish, without having worry about require
ing the wrong thing.