Contact Us

Migrating from Vim to Neovim (Lua)

https://oir.mobi/uploads/posts/2019-12/thumbs/1575937316_10-17.jpg

Introduction

In theory, if you decide to move from classic Vim to its more modern clone, Neovim, there is nothing special you need to do. Add source ~/.vimrc to ~/.config/nvim/init.vim, then download or copy your dictionaries. The idea is that Neovim should support every Vim configuration out of the box. However, with a lot of plugins and assorted extensions installed, the config will most likely load with errors, warnings, and other unwelcome surprises. Either way, the whole point of Neovim — the entire difference — is that it supports settings and plugins written in Lua instead of vimscript.

Lua is a more modern interpreted language: it is nicer to write and easier to read. It is also commonly said that the interpreter runs an order of magnitude faster than the native language. As for that order of magnitude, I would have my doubts, but genuinely heavy plugins do seem to run snappier and smoother. Then again, classic Vim is not famous for being slow or glitchy, so that argument is largely a matter of sophistry. The first three claims I agree with completely.

For Neovim in particular, the choice of modern plugins and extensions written in Lua has recently been far richer, no question. The issue is not even whether those alternatives are better, but rather their freshness, the pace of development, and the community's optimism in general. It looks like if not today, then tomorrow Neovim will repeat its predecessor's fate and take its place in popular Linux distributions as the standard replacement for the outdated Vim. Replace it entirely? I would not be so categorical about that, but the possibility exists.

On the other hand, Neovim's philosophy has none of that old asceticism and implied super-reliability. For one, Neovim is not assumed to be present when you work in an exclusive session on a network device's console. The developers implicitly count on a device that ships a modern set of runtimes and has an internet connection. So the task of configuring Neovim is not to stay functional in a stripped-down, minimal environment.

This is why you can find a dozen — no fewer — quality pre-assembled configs on the net these days: install one and you get a do-it-all machine ready for most use cases out of the box. They are essentially separate products with their own brands, their own community of fans and regular users. If you do not enjoy digging into a program's fine settings yourself, one of those brands will probably cover 90% of your needs.

As you can guess, I am one of those people who need the remaining 10%, so there is no saving me. If you are also the kind of person ready to teach Neovim something very personal or unconventional, the rest of this article is for you.

Reset

Since this material is an experiment on myself — on whether it makes sense to walk the whole way from a bare editor to a full IDE — I will keep it simple and start from zero. I already have a map of the direction to take: I will simply repeat the entire path I walked with the original vimscript, but now in Lua.

Before the reset, though, some preparation is in order. First, I will add the settings for the tools I am used to — tmux, zsh + oh-my-zsh, some aliases for GitHub. It is all pretty much standard there, so feel free to download them too.

Pro Vim dotfiles

Second, a caveat: the approach to structuring Lua config files differs greatly from the one in vimscript. It is the classic pattern here: modules, hierarchy, nesting. So we give up the linear structure right away and put everything on its own shelf.

The main file — the entry point — is ~/.config/nvim/init.lua. Create one if it does not exist, and remove the previous ~/.config/nvim/init.vim if you had it.

Core settings

To make the settings easier to manage later, it is recommended to put them in a dedicated lua directory and split the core settings and the plugin settings into separate directories.

$ cd ~/.config/nvim
$ mkdir lua
$ mkdir lua/core lua/plugins

The core settings can also be split by meaning into certain categories.

$ cd lua/core
$ touch options.lua keymaps.lua colorscheme.lua

I took this from someone else. Doing it this way is by no means obligatory, but it looks reasonable. Next, just add references to these files in ~/.config/nvim/init.lua (from here on I will refer to it as the "main file") following Lua's syntax and paradigm, where the dots in the string are read as directories and the .lua extension is appended at the end.

require("core.options")
require("core.keymaps")
require("core.colorscheme")

Programmers should note that Lua's syntax is quite idiosyncratic and very flexible — the same thing can be done ten different ways. That is sometimes annoying, when something simply refuses to work for no visible reason. And sometimes it helps, when something works despite small typos. Let's say it is an even more permissive JavaScript. Each file here is not a macro but a function with an execution result. For those who are no strangers to vimscript, this write-up explains how to get on common ground with Lua quickly.

Options

First I will move the global settings into options.lua, so that I am no longer working in a bare editor.

local opt = vim.opt

-- line number
opt.relativenumber = true
opt.number = true

-- tabs & indentation
opt.tabstop = 4 -- 4 spaces for tabs (prettier default)
opt.shiftwidth = 4 -- 4 spaces for indent width
opt.expandtab = true -- expand tab to spaces
opt.autoindent = true -- copy indent from current line when starting new one

-- line wrapping
opt.wrap = true -- disable line wrapping
opt.linebreak = true

-- search settings
opt.ignorecase = true -- ignore case when searching
opt.smartcase = true -- if you include mixed case in your search, assumes you want case-sensitive

-- cursor line
opt.cursorline = true -- highlight the current cursor line

-- appearance

-- turn on termguicolors for nightfly colorscheme to work
-- (have to use iterm2 or any other true color terminal)
opt.termguicolors = true
opt.background = "dark" -- colorschemes that can be light or dark will be made dark
opt.signcolumn = "yes" -- show sign column so that text doesn't shift

-- backspace
opt.backspace = "indent,eol,start" -- allow backspace on indent, end of line or insert mode start position

-- clipboard
opt.clipboard:append("unnamedplus") -- use system clipboard as default register

-- split windows
opt.splitright = true -- split vertical window to the right
opt.splitbelow = true -- split horizontal window to the bottom

opt.iskeyword:append("-") -- consider string-string as whole word

-- spelling
opt.spelllang = { "en_us", "ru" } -- Russian and English dictionaries
opt.spell = true

-- redundancy
opt.undofile = true --  keep undo history between sessions
opt.undodir = "~/.vim/undo/" -- keep undo files out of file dir
opt.directory = "~/.vim/swp/" -- keep unsaved changes away from file dir
opt.backupdir = "~/.vim/backup/" -- backups also should not go to git

The settings themselves are explained in the comments; we will not dwell on them. I will only add that in Lua it is customary to shorten commands by creating alias variables for one object or another — in this case, the first line of the script. Programmers will also want to understand that the assignment operator here is actually shorthand for the vim.api.nvim_win_set_option function. You can read about it in the manual, on GitHub, or on the project's Lua-guide page.

Restart, or reload:

:so $MYVIMRC

Colorscheme

Put the following into colorscheme.lua:

-- Color control
vim.g.sonokai_style = "andromeda"
vim.g.sonokai_better_performance = 1

vim.g.sonokai_transparent_background = 1
vim.g.sonokai_diagnostic_text_highlight = 1

-- set colorscheme with protected call
-- in case it isn't installed
local status, _ = pcall(vim.cmd, "colorscheme sonokai")
if not status then
    print("Colorscheme not found, defaulting to builtin")
    vim.cmd([[colorscheme desert]])
    return
end

Why not simply install the scheme? You could. This piece is mostly for demonstration. It shows, among other things, that you can call a command in "safe" mode and handle a missing colorscheme. Note that the second, unconditional call of the built-in colorscheme desert is written as a native vimscript command. So if there is no easy way to run a command from Lua, you can use the syntax with two square brackets, which is multiline on top of everything.

Keep in mind that this is all real configuration, not direct command execution. We define what will be executed later when this function is called. So the configuration is assembled in full first, and then executed on require.

You can call a function from a file separately with the :luafile or :source command:

:luafile ~/.config/nvim/lua/core/colorscheme.lua

Keymaps

And finally, it is time to bring back the familiar key bindings.

-- set leader key to space
vim.g.mapleader = " "

local keymap = vim.keymap -- for conciseness

local cmd = vim.cmd

-- Russian language

cmd("set keymap=russian-jcukenwin")
cmd("set iminsert=0")
cmd("set imsearch=0")

---------------------
-- General Keymaps
---------------------

-- soft wrap remap
local expr_opts = { noremap = true, expr = true, silent = true }
keymap.set({ "n", "v" }, "j", "v:count == 0 ? 'gj' : 'j'", expr_opts)
keymap.set({ "n", "v" }, "k", "v:count == 0 ? 'gk' : 'k'", expr_opts)
keymap.set({ "n", "v" }, "<Down>", "v:count == 0 ? 'gj' : 'j'", expr_opts)
keymap.set({ "n", "v" }, "<Up>", "v:count == 0 ? 'gk' : 'k'", expr_opts)

-- use jk to exit insert mode
keymap.set({ "v", "i" }, "\\'", "<ESC>")
keymap.set({ "v", "i" }, "\\э", "<ESC>")

-- unbind ins toggle
keymap.set("i", "<Ins>", "<ESC>")

-- yank and paste clipboard
--keymap.set({ "n", "v", "x", "t" }, "<leader>y", '"+y')
--keymap.set({ "n", "v", "x", "t" }, "<leader>Y", '"*y')
--keymap.set({ "n", "t" }, "<leader>p", '"+p')
--keymap.set({ "n", "t" }, "<leader>P", '"*p')

-- do not yank on replace or delete
--keymap.set({ "v", "x" }, "<leader>p", '"_d"+p')
--keymap.set({ "v", "x" }, "<leader>P", '"_d"*p')
--keymap.set({ "n", "v", "x", "t" }, "<leader>d", '"_d')

-- copy default reg to/from system/mouse clipboard
keymap.set({"n", "v", "x"}, '<Leader>y', ':let @+=@"<CR>')
keymap.set({"n", "v", "x"}, '<Leader>p', ':let @"=@+<CR>')
keymap.set({"n", "v", "x"}, '<Leader>Y', ':let @*=@"<CR>')
keymap.set({"n", "v", "x"}, '<Leader>P', ':let @"=@*<CR>')

-- clear search highlights
keymap.set("n", "<leader>nh", ":nohl<CR>")

-- delete single character without copying into register
keymap.set("n", "x", '"_x')

-- increment/decrement numbers
keymap.set("n", "<leader>+", "<C-a>") -- increment
keymap.set("n", "<leader>-", "<C-x>") -- decrement

-- navigate windows
keymap.set("n", "<C-j>", "<C-w>j")
keymap.set("n", "<C-h>", "<C-w>h")
keymap.set("n", "<C-k>", "<C-w>k")
keymap.set("n", "<C-l>", "<C-w>l")

-- move out of terminal
keymap.set("t", "<C-j>", "<C-\\><C-n><C-w>j")
keymap.set("t", "<C-k>", "<C-\\><C-n><C-w>k")
keymap.set("t", "<C-h>", "<C-\\><C-n><C-w>h")
keymap.set("t", "<C-l>", "<C-\\><C-n><C-w>l")

-- move line or v-block
keymap.set("i", "<C-j>", "<Esc><cmd>m .+1<CR>==gi")
keymap.set("i", "<C-k>", "<Esc><cmd>m .-2<CR>==gi")
keymap.set("x", "J", ":m '>+1<CR>gv-gv", { noremap = true, silent = true })
keymap.set("x", "K", ":m '<-2<CR>gv-gv", { noremap = true, silent = true })

-- stay in indent mode
keymap.set("v", ">", ">gv", { noremap = true, silent = true })
keymap.set("v", "<", "<gv", { noremap = true, silent = true })

-- window management
keymap.set("n", "<leader>wv", "<C-w>v") -- split window vertically
keymap.set("n", "<leader>wh", "<C-w>s") -- split window horizontally
keymap.set("n", "<leader>we", "<C-w>=") -- make split windows equal width & height
keymap.set("n", "<leader>wx", ":close<CR>") -- close current split window

-- tabs
keymap.set("n", "<leader>to", ":tabnew<CR>") -- open new tab
keymap.set("n", "<leader>tx", ":tabclose<CR>") -- close current tab
keymap.set("n", "<leader>tn", ":tabn<CR>") --  go to next tab
keymap.set("n", "<leader>tp", ":tabp<CR>") --  go to previous tab

-- buffers
keymap.set("n", "<leader>bo", ":new<CR>") -- open new tab
keymap.set("n", "<leader>bd", ":bdelete<CR>") -- close current tab
keymap.set("n", "<leader>bn", ":bn<CR>") --  go to next tab
keymap.set("n", "<leader>bp", ":bp<CR>") --  go to previous tab

-- shift arrow like gui
keymap.set("n", "<S-Up>", "v<Up>")
keymap.set("n", "<S-Down>", "v<Down>")
keymap.set("n", "<S-Left>", "v<Left>")
keymap.set("n", "<S-Right>", "v<Right>")
keymap.set("v", "<S-Up>", "<Up>")
keymap.set("v", "<S-Down>", "<Down>")
keymap.set("v", "<S-Left>", "<Left>")
keymap.set("v", "<S-Right>", "<Right>")
keymap.set("i", "<S-Up>", "<Esc>v<Up>")
keymap.set("i", "<S-Down>", "<Esc>v<Down>")
keymap.set("i", "<S-Left>", "<Esc>v<Left>")
keymap.set("i", "<S-Right>", "<Esc>v<Right>")

-- copy paste like gui
keymap.set("v", "<C-c>", '"+y<Esc>i')
keymap.set("v", "<C-x>", '"+d<Esc>i')
keymap.set("i", "<C-v>", '"+pi')
keymap.set("i", "<C-v>", '<Esc>"+pi', { noremap = true, silent = true })
keymap.set("i", "<C-z>", "<Esc>ui", { noremap = true, silent = true })
keymap.set("i", "<C-z>", "<Esc>ui", { noremap = true, silent = true })
keymap.set({ "i", "v", "x", "t" }, "<C-a>", "<C-\\><C-n>ggVG", { noremap = true, silent = true })

----------------------
-- Plugin Keybinds
----------------------

I deliberately left some comments in place. At the end there is a stub for plugin key bindings. Note that they can now all be placed in one spot, regardless of where and when the plugin itself is loaded. Also, look at the section on using registers: there I disabled the popular way of working with the system clipboard and put forward my own approach.

This approach differs in that we do not touch the behavior of the standard keys; we extend them with operations that move a register into the clipboard and back. That way the motions tied to the y and p keys are not disturbed, and working with the external clipboard becomes an extra command. I find this approach more intuitive, even though it adds one more small action. It also lets you overwrite registers as many times as you like without clobbering the system clipboard when pasting the same content into several places with overwrite.

That is probably enough for today. Plugins are next.