Neovim 0.5

Neovim 0.5

Neovim just released its biggest and most amibitous extension of the initial vim project yet with version 0.5. I won't delve into the annals of Vim or Neovim, but the tl;dr of the matter is: Neovim has taken off in its support of Lua as a configuration language for the editor, and many people are taking off with it and never looking back.

What's New

Neovim still strives to be an in-place upgrade to Vim in that you can take your Vim configuration and load it into Neovim with no changes in functionality. However, there are changes you can make to Neovim that cannot be ported back to a Vim configuration.

There's really only two things I've really cared about in the leadup to the Neovim 0.5 release:

  • Improvements to the Lua API
  • Ability to load configuration from an init.lua file

These two changes mean that my entire Neovim configuration can now be written in Lua! While it's not the most exciting language in the world, it's eons better than Vimscript and enables developers to think about problems in more standard ways.

The work leading up to this release has also led to an explosion of Lua plugins leveraging the improved API. While Vim had a powerful plugin ecosystem, it feels like Neovim has taken an enormous step ahead in the last year with things like Telescope. The movement has been exciting enough that I've given up making my editor compatible with vanilla Vim.


Vimscript to Lua

I don't want to cover Lua syntax in this post, but instead show off some of the cool things that can be done in Neovim. There's already an excellent guide to converting vimscript operations into Lua operations in the nvim-lua-guide repository. Most of what I learned can be found in there. Once you understand that repository, you can start reading other people's configuration files and should be able to make sense of what they're doing.

Filesystem organization

Vim leveraged the filesystem for functionality. Items in the plugin directory were loaded automatically and items in ftplugin were loaded when a filetype matching a filename was opened in the editor. As mentioned, Neovim is compatible with Vim, and so the filesystem can remain the same. But that doesn't mean it should! This is what my ~/.config/nvim directory looks like:

/Users/simon/.config/nvim/
├── ftplugin
│   ├── bzl.lua
│   ├── gitcommit.lua
│   ├── gitconfig.lua
│   ├── make.lua
│   ├── markdown.lua
│   ├── python.lua
│   ├── rust.lua
│   ├── sh.lua
│   ├── text.lua
│   └── toml.lua
├── init.lua
├── lua
│   ├── autocmds.lua
│   ├── plugins
│   │   ├── init.lua
│   │   ├── lsp.lua
│   │   ├── lualine.lua
│   │   ├── startify.lua
│   │   └── telescope.lua
│   └── utils.lua
└── plugin
    └── packer_compiled.lua

4 directories, 19 files
Output of tree showing my Neovim configuration

The brains of the organization is the init.lua file, which is the file that Neovim loads on startup. That file then leverages the Lua module system to load functionality from the lua directory.

  1. init.lua: the file Neovim loads on startup. I set most of the basic editor functionality here and then load items from the lua directory
  2. lua/: Neovim's Lua module. All files in this directory can be require'd from Neovim with no effort
  3. lua/autocmds.lua and lua/utils.lua: some things in the Lua DSL are not particularly nice, so I've encapsulated the more complex configurations into these two files
  4. lua/plugins/*.lua: the init.lua file in this directory downloads and loads all my plugins. The other files have a 1-1 mapping with the plugin they configure, similar to an ftplugin file
  5. ftplugin/: because I use the Astronauta plugin, I can put Lua files in the ftplugin directory and have them load just as Vim would load a vimscript file in that directory

Plugin manager

When I last used vanilla Vim, I manually loaded my plugins as git submodules in the pack/plugins/start/ directory. This made things tricky (git submodules are notoriously painful to work with) but also made it impossible to load plugins that depended on other plugins. To do that, you'd need a package manager, but those often involved copying a file into your repository and I didn't like that idea.

But the Neovim plugin ecosystem has really taken off in the last 6 months, and I felt like I was missing out. Even something as common as Telescope had dependencies on 2 other plugins, and I really wanted to be able to use the tool. So I bit the bullet and grabbed a plugin manager.

Packer makes managing plugins extremely easy. If Vim plugin managers had been this good, I might have used one.

I chose packer as my plugin manager, and it seems to do exactly what I need it to so I see no reason to switch. I can even bootstrap packer before trying to load plugins with it. How cool is that? Here's a section of my lua/plugins/init.lua:

local plugins = {
  -- packer managing packer
  "wbthomason/packer.nvim",

  -- easy language server configurations
  "neovim/nvim-lspconfig",
  
  -- better completion engine
  {
    "nvim-lua/completion-nvim",
    -- declare the way the plugin should be loaded
    config = function() require("completion").on_attach() end,
    -- enable the plugin with an autocommand event
    event = "BufEnter",
  },
  
  -- project navigation
  {
    "nvim-telescope/telescope.nvim",
    -- declare dependencies for plugins
    requires = {"nvim-lua/plenary.nvim", "nvim-lua/popup.nvim"},
  },
  
  -- enable ftplugins with lua
  "tjdevries/astronauta.nvim",
}

-- clone packer if it doesn't already exist
local fn = vim.fn
local install_path = fn.stdpath("data") .. "/site/pack/packer/start/packer.nvim"
if fn.empty(fn.glob(install_path)) > 0 then
  fn.system({"git", "clone", "https://github.com/wbthomason/packer.nvim", install_path})
  vim.cmd("packadd packer.nvim")
end

-- load plugin configurations
require("packer").startup({plugins})
-- install any missing plugins
require("packer").install()
Part of my plugin configuration file. Added some comments, as I'd like to avoid going into more detail in this post

‌Packer has been awesome. For most plugins, I can declare the configuration right in the plugin declaration block and there's no need for a separate section of configuration or a special requires call in my init file. For plugins with more complicated setups (like Telescope), I can then have a dedicated file for configurations.


Plugin configuration

The most underrated part of software development is writing documentation. Most projects fall flat trying to document their features and will either half-ass the writing or fail to provide any at all! I've been extremely impressed with the documentation around Vim and Neovim plugins, and generally have had a good time interacting with authors via GitHub issues (or similar).

That being said, configuring plugins is where I believe the Lua DSL really shines. I can introduce complex logic to my plugin configurations without fighting Vimscript idiosyncrasies at every step. For examples, my language server configuration is reasonably complex and much more complex than I'd be willing to write out in Vimscript. Yes, people do it. I am not one of those people.

local os = require("os")
local wo = vim.wo
local bo = vim.bo
local lsp = vim.lsp
local lspconfig = require("lspconfig")


local servers = {
  pyright = "pyright",
  rust_analyzer = "rust-analyzer",
}


-- function to run on any buffer associated with a language server
local function on_attach(client, bufnr)
  wo.signcolumn = "yes"
  bo.omnifunc = "v:lua.vim.lsp.omnifunc"

  local function bufmap(...)
    require("utils").buf_set_keymap(bufnr, ...)
  end

  bufmap('n', 'gD', '<Cmd>lua vim.lsp.buf.declaration()<CR>')
  ...
end


-- enable servers if the executable is installed on the system
for server, executable in pairs(servers) do
  local exit_code = os.execute(
    "command -v " .. executable .. " >/dev/null 2>/dev/null"
  )
  if exit_code == 0 then
    lspconfig[server].setup {on_attach = on_attach}
  end
end

-- enable diagnostics inline
lsp.handlers["textDocument/publishDiagnostics"] = lsp.with(
  lsp.diagnostic.on_publish_diagnostics,
  {signs = true, update_in_insert = true, virtual_text = true}
)

With just these lines of code, I can add an arbitrary number of language servers to the servers variable and know they'll be enabled if the executable is on my system. Then I'll get access to all the cool things that language servers bring. I can navigate my Rust projects more easily, and get hints that something is wrong even before I run through a long compile/test/run cycle to verify my changes.

Language server diagnostics in Rust code, letting me know that I used a semicolon where I should have used a comma

Conclusion

Using Lua as a configuration language for Vim wasn't a big deal when it was first introduced. But as people realized the power of embedding a non-Vimscript language into Neovim, plugin owners started writing more and more ambitious projects, enticing people to learn Lua. But there was still a need for some Vimscript, and so adoption was relatively slow. But now with Neovim 0.5, there's no need to write another line of Vimscript!

Everything can be in Lua, and while some things are still extremely clunky (setting keymaps is not fun), complex configuration has become orders of magnitude easier to write and reason about. And with powerful new plugin managers capable of dependency resolution, there doesn't seem to be a reason to even consider switching back to vanilla Vim.