Converting neovim config to lua

Since I’m using neovim 0.5 on all of my computers, and since I really like Lua as a language, I decided to switch my vim config files over to full lua.

While you can find some docs about converting, they don’t tend to cover everything, so I ended up doing a fair bit of guess and test to get this all working. Hopefully these before/after examples help you!

Init file(s)

I had previously set up my ~/.config/nvim/init.lua to just source my existing .vimrc file. For the sake of the conversion, I’ve decided not to worry about vim-compatibility - I’m doing everything the neovim way, and I’m leaving ~/.vimrc as its own file with a very limited no-plugin config if I ever find myself in vim rather than neovim.

Here’s the main file layout I’m using now:

  • ~/.config/nvim/init.lua: main neovim config, requires more specific files
    • ~/.config/nvim/lua/options.lua: set main options
    • ~/.config/nvim/lua/plugins.lua: the list of plugins I’m loading
    • ~/.config/nvim/lua/plugin-config.lua: config specific to plugins
    • ~/.config/nvim/lua/autocommands.lua: my extra autocommands, mainly filetype stuff
    • ~/.config/nvim/lua/keybindings.lua: custom keybindings
    • ~/.config/nvim/lua/chinook.lua: lua colorscheme
    • ~/.config/nvim/colors/chinook.vim: entry point for lua colorscheme
  • ~/.vimrc: basic vimscript config for vim, totally separate from neovim now!

Loading both Lua and vimscript

This turns out to be quite simple from within init.lua. Use vim.cmd to run the vimscript source command just like you would have before.

-- init.lua

-- source a vimscript file
vim.cmd('source ~/.vim/old_config.vim')

-- require `new_config.lua` from the nvim/lua folder:
require("new_config")

This is nice because you can leave all of your existing config in vimscript and load the bits that you’ve converted from lua. Eventually one everything is converted (assuming you want to go that far!), you won’t need to source any vimscript files.

Including lua in vimscript configs

I was originally running a small amount of lua inline in my vimscript configs using the lua keyword and a heredoc string. Example:

" customize gitsigns
lua <<EOF
require('gitsigns').setup {
  signs = {
    add          = {hl = 'GitSignsAdd'   , text = '┃', numhl='GitSignsAddNr'   , linehl='GitSignsAddLn'},
    change       = {hl = 'GitSignsChange', text = '║', numhl='GitSignsChangeNr', linehl='GitSignsChangeLn'},
    delete       = {hl = 'GitSignsDelete', text = '▁', numhl='GitSignsDeleteNr', linehl='GitSignsDeleteLn'},
    topdelete    = {hl = 'GitSignsDelete', text = '▔', numhl='GitSignsDeleteNr', linehl='GitSignsDeleteLn'},
    changedelete = {hl = 'GitSignsChange', text = '╣', numhl='GitSignsChangeNr', linehl='GitSignsChangeLn'},
  },
}
EOF

Obviously once I moved this to a .lua config files I could run this code directly.

Setting options

There’s a little bit of extra lua coding needed to be able to have a nice readable config for options, but it turns out quite nicely.

Vimscript version

set wrapscan " wrap searches around top/bottom of file
set nowritebackup " no tilde files
set switchbuf=useopen " use an already open window if possible
set splitright " open vsplits in a more natural spot
set textwidth=0 " never wrap lines
set scrolloff=5 " start scrolling when within 5 lines near the top/bottom

Lua version

-- options.lua

-- workaround until https://github.com/neovim/neovim/pull/13479
local scopes = {o = vim.o, b = vim.bo, w = vim.wo}

local function opt(scope, key, value)
  scopes[scope][key] = value
  if scope ~= 'o' then
    scopes['o'][key] = value
  end
end

opt('o', 'wrapscan', true) -- wrap searches around top/bottom of file
opt('o', 'writebackup', false) -- no tilde files
opt('o', 'switchbuf', 'useopen') -- use an already open window if possible
opt('o', 'splitright', true) -- open vsplits in a more natural spot
opt('b', 'textwidth', 0) -- never wrap lines
opt('o', 'scrolloff', 5) -- start scrolling when within 5 lines near the top/bottom

With only a few options this seems like a lot of overhead, but I have enough opt lines that the function definition is a minor bit at the top.

Checking the option scope

Note that the textwidth option takes a b as the first arg because it’s a buffer-scoped option. For each option, you need to check it out in the help docs and see whether it’s global, buffer, or window scoped. If you get it wrong, you’ll get an error when loading nvim, so you can also just guess and test a bit.

In nvim, run: :help textwidth

The first few lines in the help doc should contain a line that says one of:

  • local to buffer
  • local to window
  • global

Choose o, w, or b for your option based on the listed scope.

As a bonus, if the option you’re trying to set has been deprecated/removed from neovim, the docs will say so and you don’t need to convert it. :)

Coding in your config

Because lua is a normal programming language, you can easily use variables to keep things in sync, or run some extra commands. Here’s a couple of examples:

reusable variables

-- keep indentation consistent
local indent = 2
opt('b', 'tabstop', indent)
opt('b', 'shiftwidth', indent)
opt('b', 'softtabstop', indent)

leaning on your OS

-- set up undofiles in a specific dir and make sure it exists
local undo_dir = os.getenv("HOME") .. '/.local/share/nvim/undo'
os.execute("mkdir -p " .. undo_dir)
opt('o', 'undodir',  undo_dir)
cmd('set undofile')

I find both of these much more understandable and maintainable than the vimscript equivalents (I didn’t have the mkdir before, I’m comparing vs stackoverflow).

Colorscheme

I switched to a lua colorscheme, which currently involves 2 files:

  • ~/.config/nvim/colors/chinook.vim:
  • ~/.config/nvim/lua/chinook.lua

The contents of chinook.vim are a single line:

lua require('chinook')

To use the colorscheme, I have some lines in options.lua file (uses the same opt function as above):

Vimscript version

" colours and fonts
set background dark
colorscheme chinook
syntax enable

Lua version

-- colours and fonts
opt('o', 'background', 'dark')
vim.cmd('colorscheme chinook')
vim.cmd('syntax enable')

I’m not sure if there’s a more neovim way to do these, but certain things seem to still need vimscript commands to make them happen. That may change in future neovim versions too.

For a good example of a lua colorscheme, check our zephyr

Vimscript commands

Some things seem to still require a vimscript command even though they look like a normal global option. undofile is one of those - I had to use this to have persistent undo across file loads:

vim.cmd('set undofile')

I just put this in options.lua with the other undofile setup.

Assigning variables

Variables mainly came up when I was configuring my vimscript-based plugins from within my lua config files.

Vimscript version

let g:python3_host_prog='/usr/bin/python3'

let g:deoplete#enable_at_startup = 1

let g:ale_linters = {
\   'typescript': ['eslint', 'tslint'],
\   'ruby': ['rubocop'],
\   'jsx': ['stylelint', 'eslint', 'tslint']
\}

let g:ale_fixers = {
\   '*': ['remove_trailing_lines', 'trim_whitespace'],
\}

Lua version

vim.g.python3_host_prog = '/usr/bin/python3'

vim.g['deoplete#enable_at_startup'] = 1

vim.g.ale_linters = {
    typescript = {'eslint', 'tslint'},
    ruby = {'rubocop'},
    jsx = {'stylelint', 'eslint', 'tslint'},
}

vim.g.ale_fixers = {
   ['*'] = {'remove_trailing_lines', 'trim_whitespace'},
}

Keep in mind a couple of lua language things while converting:

  • lua uses {} for arrays/lists as well as for dictionaries
  • foo.bar and foo['bar'] are identical, which lets you use strings with special chars as keys
  • you need to “escape” lua table keys when setting them if they have special chars in them
    • eg my.table = {easy_key = 6, ['hard key'] = 7}

Keybindings

This is a spot where it gets much much more understandable in lua.

Vimscript version

let mapleader=',' " change the <leader> key to be comma

map <F1> <Esc> " avoid opening help on F1, let it be escape instead
imap <F1> <Esc>
nnoremap <CR> :noh<CR><CR> " hit enter to clear search highlighting
imap <expr><silent><C-h> pumvisible() ? deoplete#close_popup() .
      \ "\<Plug>(neosnippet_jump_or_expand)" : "\<CR>"

Lua Version

-- keybindings.lua

-- need a map method to handle the different kinds of key maps
local function map(mode, combo, mapping, opts)
  local options = {noremap = true}
  if opts then
    options = vim.tbl_extend('force', options, opts)
  end
  vim.api.nvim_set_keymap(mode, combo, mapping, options)
end

vim.g.mapleader = ',' -- change the <leader> key to be comma

map('', '<F1>', '<Esc>') -- avoid opening help, treat it like escape (all modes)
map('n', '<CR>', ':noh<CR><CR>', {noremap = true}) -- clears search highlight & still be enter
map('i', '<C-h>', 'pumvisible() ? deoplete#close_popup() ' ..
    			  '"<Plug>(neosnippet_jump_or_expand)": "<CR>"', {expr = true, silent = true})

Plugin Managers

I originally had a heck of a time getting lua require to find plugins when I was using pathogen to manage plugins. Switching to vim-plug fixed that, but I ended up switching to paq as a pure-lua plugin manager since it did everything I needed and ended up being faster.

I may have just been doing something silly with pathogen plugins, but since even the author recommends other plugin managers these days it made sense to change.

Avoiding require errors

This is more for when you’re messing with different plugins, but it’s possible to make a lua require not throw errors on startup when that plugin isn’t installed. There’s a few ways to do it listed online, but I wrote a slightly different one:

-- plugin-config.lua

function has_module(name)
  if pcall(function() require(name) end) then
    return true
  else
    return false
  end
end

if has_module('gitsigns') then
  require('gitsigns').setup {}
-- etc

Is if faster?

TL;DR: loading from Lua files isn’t noticeably faster, but since I switched to treesitter and lua plugins, everything sped up.

My methodology was to run 2 profiling tools:

  • https://github.com/hyiltiz/vim-plugins-profile to generate graphs
    • I had to modify it slightly to know about the paq plugin manager. I used the perl version
  • nvim --startuptime vim_profile.fulllua.log to get overall timing numbers

Vimscript Startup time

Full startup time: 320ms

The 4 slowest scripts were:

  • vim plugin loading (pathogen): > 50ms
  • main.vim (where I set most of my options): 18ms
  • neovim filetype.vim (filetype detection): 16ms
  • colorscheme (jwombat, my custom colorscheme): 4ms

Vimscript startup graph

Lua Startup time

Full startup time: 185ms

The 4 slowest scripts were:

  • neovim filetype.vim (filetype detection): 9ms
  • plugin loading (paq): > 5ms
  • paq.nvim (the loader itself): 4ms
  • colorscheme (chinook): 4ms

Comparison

After the dust settled, I’d cut about 40% off of my startup time! It was already quite fast, and everyone’s setup will be different.

Reasons things sped up:

  • I switched to treesitter which got rid of a ton of plugins
    • also appears to have sped up neovim filetype detection on startup
  • I switched from pathogen to paq
  • I switched to other lua-based plugins
  • setting options seems to actually be faster in lua

The primary reason that it’s faster is almost certainly the switch to lighter-weight lua plugins. But I wouldn’t have been able to use those without switching at least parts of my neovim config to lua first!

Summary

The good

  • Actually easy to run both vimscript and lua side-by-side
  • Cleaner & more powerful config
  • If you know lua aleady, it’s a lot easier and more fun to write
  • I switched up a bunch of plugins which made things faster

The Bad

Neovim 0.5 could still stand to make some of these things easier, and in some cases there are already PRs up to do that.

  • Some things end up being more work, like writing your own helper methods
  • if you don’t know lua already, there’s a learning curve there
  • lua on its own isn’t really faster, though setting options seems to be!

The Ugly

  • Some things still require vimscript commands and won’t always be obvious that they do (undofile)
  • might not play nice with your plugin manager (pathogen)

Overall, I’m happy I spent the time to convert everything, and whether it’s due to