Migrating my Neovim Config to Lua
I've been a happy Neovim user for the past several years. The pace of development, quality of the product, and energy of the community have made it enjoyable to use. Recently, the project has introduced Lua as a first-class citizen in the editor. In places where you may otherwise be forced to wrangle the mess that is VimL (aka Vimscript), you can instead use a saner, faster scripting language.
I initially refrained from making the jump over to Lua as I held the ideal of being able to return to Vim whenever I wanted. With the release of Vim version 8, which doubled down on VimL, I realized my folly. Vim wasn't going to suddenly adopt the good parts of Neovim, nor was that community going to change overnight. With that, I sat down one Saturday to get to porting. After a few weeks of on-and-off experimentation (and exploring both /r/neovim and GitHub to see how other people did things) , I landed on a stable, Lua-first config that's been humming along for several weeks now.
Let's dive in!
Structure of a Config
One thing Lua brings to the table is a nice import system. This incentivizes smaller, more logical chunks of config. There's quite a variety of practices and preferences in the community. What I ended up with (some elisions) is:
. ├── after │ └── plugin │ └── colors.lua ├── init.lua └── lua ├── configs.lua ├── functions │ └── format-json.lua ├── functions.lua ├── keybindings.lua ├── plugins │ ├── comment.lua │ ├── lualine.lua │ ├── test.lua │ └── treesitter.lua ├── plugins.lua └── utils.lua
Let's start at the beginning.
init.lua
- The Entry Point
This is the first part of the config to be loaded. Its sole purpose is to tell Neovim which modules it should load:
require("configs") require("plugins") require("keybindings") require("functions")
In this case, I chose to split things out into four categories: configs,
plugins, keybindings, and functions. The order here matters, as the later
imports depend on the earlier ones. Imports in init.lua
are resolved to
top-level .lua
files in the lua/
subdirectory.
lua/configs.lua
- Core Config
This file contains all my core Neovim configuration. From tab widths, to highlighting behavior.
It starts with an import
local U = require("utils")
This loads the export of lua/utils.lua
into the module-local variable U
.
utils.lua
contains a few helper functions that are reused throughout my
config.
Next, I alias some verbose Neovim commands to shorter variants to make the config easier to read and update:
local exec = vim.api.nvim_exec -- execute Vimscript local api = vim.api -- neovim commands local autocmd = vim.api.nvim_create_autocmd -- execute autocommands local set = vim.opt -- global options local cmd = vim.cmd -- execute Vim commands
With that out of the way, I can start writing my config! This should look fairly familiar to you:
vim.g.mapleader = "," set.termguicolors = true set.bg = "dark" set.tabstop = 4 set.shiftwidth = 4 set.expandtab = true set.mouse = "a" -- additional config snipped for brevity
One thing I immediately grew to appreciate was being able to configure boolean
settings via true
/false
assignment, rather than set expandtab
or set
noexpandtab
. As a software engineer, this feels more ergonomic.
set.listchars = { nbsp = '⦸', extends = '»', precedes = '«', tab = '▷─', trail = '•', space = ' ' }
Being able to express my config via a real type system improves readability. Who needs a comma-and-colon delimited list when you can use a table?
Of course, one nice thing about scriptable config is being able to introduce
function calls and macros for improved readability. Defining a autocmd
requires: autocmd(<type>, args)
. Verbosity can be reduced (and readability
improved) with some helper functions
function filetype_autocmd(filetype, cmd, params) autocmd("FileType", { pattern = filetype, command = cmd .. ' ' .. params }) end function buffer_autocmd(pattern, cmd, params) autocmd("BufRead", { pattern = pattern, command = cmd .. ' ' .. params }) end function hold_autocmd(pattern, cmd) autocmd("CursorHold", { pattern = pattern, command = cmd }) end filetype_autocmd("html", "setlocal", "ts=4 sts=4 sw=4 omnifunc=htmlcomplete#CompleteTags") buffer_autocmd("*.cls", "set", "ft=apex syntax=java") hold_autocmd("*", "silent call CocActionAsync('highlight')") -- lots of autocmds excluded
Remember that utils import from earlier? Here's where I use it. I query the platform to determine where it should find the system's Python installation.
if U.is_linux() then vim.g.python3_host_prog = "/bin/python" elseif U.is_mac() then vim.g.python3_host_prog = "/usr/local/bin/python3" end
lua/plugins.lua
- Plugins and Plugin Accessories
Plugin configuration is where the migration to Lua really shines. This file contains the entry point for my plugin manager (Packer), along with self-bootstrapping code.
local install_path = fn.stdpath("data") .. "/site/pack/packer/start/packer.nvim" if vim.fn.empty(fn.glob(install_path)) > 0 then packer_bootstrap = fn.system({"git", "clone", "--depth", "1", "https://github.com/wbthomason/packer.nvim", install_path}) end -- Register Packer with vim vim.api.nvim_cmd({ cmd = "packadd", args = {"packer.nvim"}, }, {})
This will pull down the repo if no trace of Packer is found, and install it. Next, we hook into Packer's startup and provide a callback for plugin installation.
return require("packer").startup { function(use) -- Packer can manage itself use "wbthomason/packer.nvim" -- Plugins go here -- Install and compile plugins if packer_bootstrap then require("packer").sync() end end }
If you've used other Vim package managers (like vim-plug) the above use
syntax should seem familiar. Let's add some plugins:
use "simnalamburt/vim-mundo" use "tpope/vim-repeat" use { "nkakouros-original/numbers.nvim", config = [[ require("plugins/numbers") ]] }
Yup, familiar. But what's going on with that config
parameter? Let's take a
look at the file it references, lua/plugins/numbers.lua
.
require "numbers".setup { excluded_filetypes = { "tagbar", "gundo", "minibufexpl", "nerdtree" } }
This syntax is the standard ceremony for Lua-based Neovim plugins. It starts
with requiring the plugin module, invoking the setup
function, and supplying
a table of configuration. These ergonomics are one of the reasons I'm so excited
about what the migration to Lua brings to the table. This spares me from the
configuration bit rot that leads to minutes spent tracking down the source of
unusual behavior. For non-Lua plugins, you can define them as usual:
-- lua/plugins/test.lua -- set a variable using a traditional vim command vim.cmd([[ let test#strategy = "neoterm" ]])
lua/keybindings.lua
- Keybinding Configuration
Now that plugins are out of the way, we need to bind their actions to keys, alongside Neovim's built-in ones.
It starts with a helpful abstraction for mapping.
function map(mode, lhs, rhs, opts) local options = { noremap = true } if opts then options = vim.tbl_extend("force", options, opts) end vim.keymap.set(mode, lhs, rhs, options) end
-
mode
- the editor mode for the mapping (e.g.,i
for "insert" mode). -
lhs
- the keybinding to detect. -
rhs
- the command to execute. -
opts
- additional options for the configuration (e.g.,silent
).
Let's put it to use!
-- Use jj to exit insert mode map("i", "jj", "<Esc>") -- use leader-nt to toggle the NvimTree plugin's visibility in normal mode map("n", "<leader>nt", ":NvimTreeToggle<CR>") -- use leader-t to run the unit test under my cursor, but don't display the command in the UI map("n", "<leader>t", ":TestNearest<CR>", { silent = true })
Remember utils.lua
? Its platform detection helpers make another appearance here:
-- use `gx` to open the path under the cursor using the system handler if U.is_linux() then map("n", "gx", "<Cmd>call jobstart(['xdg-open', expand('<cfile>')])<CR>") elseif U.is_mac() then map("n", "gx", "<Cmd>call jobstart(['open', expand('<cfile>')])<CR>") end
Handy!
lua/functions.lua
- Arbitrary Code Entry Point
Vim's extensibility is a huge boon for resourceful engineers. Sometimes I want
to automate frequent actions with a straightforward Vim command.
lua/functions.lua
is where it starts. It contains imports:
require("functions/format-json")
which reference standalone function modules:
-- lua/functions/format-json.lua -- run the current through Python's builtin JSON formatter vim.cmd([[ com! FormatJSON %!python -m json.tool ]])
Now, I can execute the :FormatJSON
command any time!
after
- What's next?
As the name suggests, Vim's after/
directory is loaded after the init and
plugin phases. This code is always run on startup, which makes it a good
location for commands that need to override default vim state. Presently, I
only use it for color scheme configuration in after/plugin/colors.lua
:
-- set my color scheme, and define some specific highlight overrides for search and matching parentheses vim.cmd([[ let base16colorspace=256 colorscheme base16-onedark hi Search ctermfg=237 ctermbg=13 hi MatchParen cterm=underline ]])
Now, when I load Vim, the color scheme and highlight overrides are set.
The Future
That's it! I've really appreciated how Lua has enabled me to decompose my
.vimrc
, and some of the increased scripting capabilities that come along for
the ride. As you can see, there are still a few places where I'm using cmd
.
When will I be able to replace them? The Neovim developers have expressed:
Duplicating every random Vim command in Lua achieves nothing, except a lot of useless, manually-written code.
nvim_cmd
, released with 0.8, cleaned up a few of those remaining occurrences.
I'm not losing sleep over the remaining ones.