The Great Neovim Lua Adventure

🠔 Back to Blog Index


id: 8045ba6a-a9f4-4787-b259-ac29d54b3b2a
tags: neovim,lua,dotfiles,vim

Read This First! Errata and Changes

Unfortunately, this large document contains errors and does not reflect my current Neovim configuration. While changes are expected over time (drift), the errors are unfortunate. What's more unfortunate is that I'm not sure where they are since they were fixed long ago and I neglected to update the blog post.

While this post does contain useful information, please take the configurations with a small grain of salt. Additionally, Neovim updates have changed some of the configurations or made them irrelevant (filetype, for example).

If you're interested in my current Neovim configuration, which is working properly, please refer to my dotfiles.

---

I decided to use the weekend (July 1, 2, 3) to rework my Neovim configuration: I would transition entirely from Vimscript to Lua.

♯Table of Contents

♯Why Switch to Lua?

I decided to change from Vimscript to Lua for the following reasons:

  • Because I could and it seemed interesting.
  • Overhauling my configuration would provide me a chance to review every piece of configuration and make a keep/discard decision.
  • I wanted to update my set of plugins anyway. There were new plugins that interested me, and some old (relative to my config) ones that I wanted to re-evaluate.
  • I despised my multi-file setup and I'm pretty sure I did it wrong in the first place.

♯Resources

I leveraged the following documentation to learn what I needed to do:

I didn't bother reading about Lua despite never having written it. It's easy enough to pick up on the fly, and if you don't have a chunk of custom functions to port (something I do not have!) you likely won't hit any language related confusion.

♯Package Manager

While I previously used vim-plug, I decided to switch to packer.nvim as it's written in and designed for Lua. I loved vim-plug and would use it again for a Vimscript configuration.

♯Configuration Structure

One of my motivations was reworking my (bad) multi-file setup and adopting something reasonable. Step one was determining what my configuration would look like and what files I would be creating.

As a prerequisite, I recommend reading the Where to put Lua files section in [1].

I decided to leverage the following structure:

  • ~/.config/nvim/init.lua would be an aggregator that imports other Lua files.
  • My primary configuration would live in 4 files located in the ~/.config/nvim/lua directory:
    • general.lua: All standard Neovim configuration. Nothing for plugins.
    • colorscheme.lua: Selection of my color scheme and any configuration required for that color scheme.
    • keymap.lua: All key bindings. This includes plugin key bindings - keep them all in one place.
    • packer_init.lua: Setup packages: packer configuration and all plugin imports.
  • If any plugins needed extra configuration, that configuration would live in the ~/.config/nvim/lua/plugins directory. Each plugin would receive a dedicated file.

♯My Approach

After reading documentation and deciding on a file structure, I decided to use the following iterative approach to migrate from Vimscript to Lua:

  • Delete my Vimscript files. Revert to an unmodified Neovim experience.
  • Add init.lua and lua/general.lua. Port my general configuration to general.lua piece by piece while drawing on [2] as a reference.
  • Start Neovim and ensure the configuration appears to work (no errors, show the color column in the correct location).
  • Add all non-plugin key mappings to make editing feel more natural.
  • Import a color scheme using packer to learn how to use packer and wrap up my last remaining non-plugin file.
  • Add plugins one by one. I'll explain my ordering later.

♯General Configuration

This was an easy port. Essentially existing settings in init.vim are remapped to vim.opt.{setting} (with a couple of globals).

I recommend referencing the Neovim Documentation for Options which explains what all of these do.

Minor Issues and Gotchas

  • I had to "rename" some fields. Example: set noswapfile became opt.swapfile = false since noswapfile is not a setting - it just acts upon the setting.
  • colorcolumn is a string, which confused me.
  • The undodir does not expand ~ and will create a ~ directory anywhere you save a file if you don't know this and attempt to set it. It took some head banging for me to figure this out. I eliminated the setting.

New Items

I adopted some changes from [2]:

  • Enable mouse support. I figured it couldn't hurt to try. Not sure if I will bother keeping it.
  • Disabled the intro.

Takeaways

This step helped me understand the available options better and forced me to learn about the actual options in some cases (e.g. reading about swapfile rather than blindly setting noswapfile). I feel like my configuration is more normalized as each setting is a single assignment.

init.lua

Note that all imports in this file are considered relative to the lua directory and do not require file extensions.

require('general')

lua/general.lua

This is (roughly) the initial version that I used. This version adds a setting and fixes a bug.

local g = vim.g       -- Global variables
local opt = vim.opt   -- Set options (global/buffer/windows-scoped)

-----------------------------------------------------------
-- General
-----------------------------------------------------------
opt.mouse = 'a'             -- Enable mouse support
opt.swapfile = false        -- Don't use swapfile
opt.modelines = 0           -- Disable modelines
opt.encoding = 'utf-8'      -- Set default encoding to UTF-8

-- Note that this setting is important for which-key. Also don't reduce it too
-- low, or the behavior will start getting wonky and always show which-key.
opt.timeoutlen = 500        -- Time in ms to wait for a sequnece to complete

opt.shortmess:remove("F"):append("c")

opt.wildignore = {
	'*.class', '*.pyc', '*.swp', '*.o', '*.jar', '*/tmp/*', '*.zip',
	'*.tar', '*.gz', '*.bz2', '*.xz', '*/.git/*', '*/.metals/*', 
	'*/.bloop/*', '*/.bsp/*', '*/node_modules/*'
}

-----------------------------------------------------------
-- Completion
-----------------------------------------------------------

-- menu = use a popup menu to show possible completions
-- menuone = show a menu even if there is only one match
-- noinsert = do not insert text for a match until user selects one
-- noselect = do not select a match from the menu automatically
opt.completeopt = 'menu,menuone,noinsert,noselect'
g.completion_enable_auto_popup = 1 -- Enable completions while typing

-----------------------------------------------------------
-- Neovim UI
-----------------------------------------------------------
opt.number = true           -- Show line number
opt.cursorline = true       -- Highlight the current line
opt.showcmd = true          -- Show incomplete commands at the bottom
opt.ttyfast = true          -- Rendering optimizations
opt.showmatch = true        -- Highlight matching parenthesis
opt.colorcolumn = '80'      -- Line length marker at 80 columns
opt.ruler = true            -- Show row and column number
opt.signcolumn = 'yes'      -- Always show the sign column
opt.foldmethod = 'marker'   -- Enable folding (default 'foldmarker')
opt.splitright = true       -- Vertical split to the right
opt.splitbelow = true       -- Horizontal split to the bottom
opt.linebreak = true        -- Wrap on word boundary
opt.termguicolors = true    -- Enable 24-bit RGB colors
opt.laststatus = 3          -- Set global statusline

-----------------------------------------------------------
-- Tabs, indent
-----------------------------------------------------------
opt.tabstop = 4
opt.shiftwidth = 4
opt.softtabstop = 4
opt.expandtab = true
opt.smarttab = true
opt.autoindent = true

-----------------------------------------------------------
-- Memory, CPU
-----------------------------------------------------------
opt.hidden = true           -- Enable background buffers
opt.lazyredraw = true       -- Faster scrolling
opt.synmaxcol = 240         -- Max column for syntax highlight
opt.updatetime = 500        -- ms to wait for trigger an event

-----------------------------------------------------------
-- History
-----------------------------------------------------------
opt.history = 512
opt.undolevels = 128
opt.undofile = true

-----------------------------------------------------------
-- Search
-----------------------------------------------------------
opt.ignorecase = true
opt.smartcase = true
opt.incsearch = true
opt.showmatch = true
opt.hlsearch = true

-----------------------------------------------------------
-- Startup
-----------------------------------------------------------

-- Disable nvim intro
opt.shortmess:append "sI"

♯Adding Key Mappings

I have a small number of general key mappings, and a larger number for some of the plugins that I use. This section covers the basics of mapping keys and my basic key mappings.

Mapping Keys

The following function defines a key mapping with some default settings. Note that mode, lhs, and rhs are all strings.

local function map(mode, lhs, rhs, opts)
  local options = { noremap = true, silent = true }
  if opts then
    options = vim.tbl_extend('force', options, opts)
  end
  vim.api.nvim_set_keymap(mode, lhs, rhs, options)
end

An example usage could be saving on <leader>s:

map('n', 's', ':up')

The mode is n, for normal mode. The lhs is <leader>s -- I want to run the rhs when I press my leader key followed by s. The rhs (what I want to do when I press the lhs) is the command to save my buffer followed by a carriage return to evaluate that command.

noremap

Please refer to the Neovim map Documentation (Partially copied):

Map the key sequence {lhs} to {rhs} for the modes where the map command applies. Disallow mapping of {rhs}, to avoid nested and recursive mappings. Often used to redefine a command.

It ensures that the mapping I create is non-recursive.

silent

Please refer to the Neovim map Documentation (Partially copied):

To define a mapping which will not be echoed on the command line, add "<silent>" as the first argument.

Minor Issues and Gotchas

I only ran into one issue, and that was with leveraging <Tab>. Essentially it will not be expanded in all contexts.

Essentially this mapping in Vimscript:

inoremap <expr> <Tab> pumvisible() ? "\<C-N>" : "\<Tab>"

Does not translate how you might expect, and will insert \<Tab> and \<C-N> literally.

If you're interested in a full explanation, this is covered (thankfully!) in [1]. The short version is that using vim.keymap.set eliminates the issue and automatically expands termcodes. Example:

-- Use Tab to jump to the next option in a popup menu
vim.keymap.set('i', '<Tab>', function()
    return vim.fn.pumvisible() == 1 and '<C-N>' or '<Tab>'
end, {expr = true})

Takeaways

Mapping keys is pretty much the same. I like the structure that Lua provides, but I'm not feeling strongly about anything.

init.lua

require('general')
require('keymap')

lua/keymap.lua

Note that this snippet only includes the base key mappings since that is what I added in this step. Plugin key mappings will be added later. Neovim comes with a builtin LSP, so I decided to include those mappings in this section.

-----------------------------------------------------------
-- Define keymaps for Neovim and plugins.
-----------------------------------------------------------

local function map(mode, lhs, rhs, opts)
  local options = { noremap = true, silent = true }
  if opts then
    options = vim.tbl_extend('force', options, opts)
  end
  vim.api.nvim_set_keymap(mode, lhs, rhs, options)
end

-- Change leader to a comma
vim.g.mapleader = ','

-----------------------------------------------------------
-- General
-----------------------------------------------------------

-- Clear search highlighting
map('n', '<leader>/', ':nohlsearch<CR>')

-- Save current buffer
map('n', '<leader>s', ':up<CR>')

-- Move to previous buffer
map('n', '<leader>bp', ':bp<CR>')

-- Move to next buffer
map('n', '<leader>bn', ':bn<CR>')

-- Delete current buffer
map('n', '<leader>bd', ':bd<CR>')

-- Close the quickfix window
map('n', '<leader>cq', ':ccl<CR>')

-- Use Tab to jump to the next option in a popup menu
vim.keymap.set('i', '<Tab>', function()
    return vim.fn.pumvisible() == 1 and '<C-N>' or '<Tab>'
end, {expr = true})

-- Use Shift+Tab to jump to the previous option in a popup menu
vim.keymap.set('i', '<S-Tab>', function()
    return vim.fn.pumvisible() == 1 and '<C-P>' or '<Tab>'
end, {expr = true})

-----------------------------------------------------------
-- LSP
-----------------------------------------------------------

map('n', 'K', '<cmd>lua vim.lsp.buf.hover()<CR>')
map('n', 'gi', '<cmd>lua vim.lsp.buf.implementation()<CR>')
map('n', 'gr', '<cmd>lua vim.lsp.buf.references()<CR>')
map('n', 'gds', '<cmd>lua vim.lsp.buf.document_symbol()<CR>')
map('n', 'gws', '<cmd>lua vim.lsp.buf.workspace_symbol()<CR>')
map('n', 'gd', '<cmd>lua vim.lsp.buf.definition()<CR>')
map('n', '<leader>cl', '<cmd>lua vim.lsp.codelens.run()<CR>')
map('n', '<leader>D', '<cmd>lua vim.lsp.buf.type_definition()<CR>')
map('n', '<leader>rn', '<cmd>lua vim.lsp.buf.rename()<CR>')
map('n', '<leader>ca', '<cmd>lua vim.lsp.buf.code_action()<CR>')
map('n', '<leader>q', '<cmd>lua vim.lsp.diagnostic.set_loclist()<CR>')
map('n', '<leader>e', '<cmd>lua vim.lsp.diagnostic.open_float()<CR>')
map('n', '[c', '<cmd>lua vim.diagnostic.goto_prev { wrap = false }<CR>')
map('n', ']c', '<cmd>lua vim.diagnostic.goto_next { wrap = false }<CR>')

♯Setup Packer

As a sub-step before installing color schemes I decided to make sure that Packer was working properly. This step is essentially copy/paste from the packer.nvim documentation.

I will discuss the usage of Packer later - this is just concerned with setup.

Takeaways

Installing Packer and creating a base configuration is easy and works as expected. Not a whole lot of difference from vim-plug.

Please note that getting Packer to work like I expected later became challenging because I didn't understand packer and was doing it wrong. For additional detail please read my digression on using packer.

Installation

Copied directly from the documentation (you should check it for the latest version and treat this as a brief reference):

git clone --depth 1 https://github.com/wbthomason/packer.nvim\
    ~/.local/share/nvim/site/pack/packer/start/packer.nvim

Verification

Open Neovim and run :PackerInstall and :PackerStatus.

init.lua

require('packer_init')
require('general')
require('keymap')

packer_init.lua

Note that in each subsequent section I will only show additions to this file.

-- Automatically install packer
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
  packer_bootstrap = fn.system({
    'git',
    'clone',
    '--depth',
    '1',
    'https://github.com/wbthomason/packer.nvim',
    install_path
  })
  vim.o.runtimepath = vim.fn.stdpath('data') .. '/site/pack/*/start/*,' .. vim.o.runtimepath
end

-- Use a protected call so we don't error out on first use
local status_ok, packer = pcall(require, 'packer')
if not status_ok then
  return
end

return require('packer').startup(function(use)
    -- Plugin/package manager.
    use 'wbthomason/packer.nvim'
end)

♯Color Schemes

Thankfully, this section isn't particularly interesting. Adding color schemes offered no surprises and worked first try.

Gotchas

I said that there were no surprises for me, but there is one thing you need to know that I should call out for this article: setting the color scheme requires running a vim command.

vim.cmd 'colorscheme <my_desired_color_scheme>'

Process

First, make the packer updates. Then open Neovim and run:

  • :PackerInstall
  • :PackerSync

Finally, make the remaining file changes and open Neovim to confirm the new color scheme has been applied.

Takeaways

This is another case where Lua just doesn't differ a whole lot from standard Vimscript. At this point I have encountered my first use of vim.cmd and I'm starting to like the require('x').setup { ... } pattern and not needing to bracket it off in Lua heredocs. Although the outcomes are rather similar, I would say that the Lua development/update process is a bit smoother. I think that part of it is that Lua just feels more "normal" to me.

init.lua

require('packer_init')
require('general')
require('colorscheme')
require('keymap')

lua/packer_init.lua

I import a few color schemes so that I don't forget the ones I'm not using, and in case I want to swap them around quickly.

use 'sainnhe/gruvbox-material'
use 'rebelot/kanagawa.nvim'
use 'marko-cerovac/material.nvim'

lua/colorscheme.lua

I'm using the material.nvim theme.

-- I use dark terminals and schemes, so use a dark background.
vim.opt.background = 'dark'

-- Settings for gruvbox-material
vim.g.gruvbox_material_palette = 'mix'
vim.g.gruvbox_material_background = 'hard'
vim.g.gruvbox_material_enable_bold = 1

-- Settings for material
vim.g.material_style = "palenight"

require('material').setup({

	italics = {
		comments = true, -- Enable italic comments
	},

	plugins = { -- Disable stuff that I just don't need.
		neogit = false,
		sidebar_nvim = false,
		lsp_saga = false,
		nvim_dap = false,
		nvim_navic = false,
		hop = false,
	}
})

-- Set the color scheme
vim.cmd 'colorscheme material'

♯Digession: Using Packer

At this point, before addressing each plugin, I want to address Packer. The main call out is that it's important to read the README.

Why? This section of the README (partially copied/pasted here for reference) is key:

-- You must run this or `PackerSync` whenever you make changes to your plugin 
-- configuration. Regenerate compiled loader file
:PackerCompile

-- Remove any disabled or unused plugins
:PackerClean

-- Clean, then install missing plugins
:PackerInstall

-- Clean, then update and install plugins
:PackerUpdate

-- Perform `PackerUpdate` and then `PackerCompile`
:PackerSync

These commands are important. I feel stupid now, but I kept running :PackerInstall and getting confused when things didn't work. Remember:

You must run :PackerCompile or PackerSync whenever you make changes to your plugin configuration. Regenerate the compiled loader file.

If you don't recompile, your new plugins aren't going to work. Don't be me and skip this step. My general process for installing new plugins:

  • Update packer_init.lua
  • Start Neovim in another window
  • :PackerInstall
  • :PackerSync

♯Plugin: Telescope

Telescope is a fuzzy finder over lists with a large number of integrations. I use it for opening files, swapping between buffers, selecting some commands (e.g. Metals), and for other plugin integrations. I should probably use LSP support more.

It's worth mentioning that I use telescope-fzf-native which provides a C fork of fzf (which I love) for Telescope.

Why Install This Plugin First?

Telescope doesn't have anything crazy going on, is easy to test, and is useful for working on a project with a bunch of files... like my Neovim configuration. I really wanted the file and buffer lookups before starting on any other plugins.

Why Telescope?

To answer "why" in general: I love a workflow backed by fuzzy searching. It helps me reduce context switching and I'm used to it. I started using Telescope in particular to try something new and because a lot of new plugins have support for it. Other plugins work great as well.

Testing the Installation

Open Neovim and press Ctrl+P or ; - a box should appears with a fuzzy finder and preview window.

init.lua

Add the following after installing Telescope.

require('plugins/telescope')

lua/packer_init.lua

Telescope, like many plugins, depends on plenary.nvim.
use {
    'nvim-telescope/telescope.nvim',
    requires = { 
        { 'nvim-lua/plenary.nvim' } 
    }
}

use {
    'nvim-telescope/telescope-fzf-native.nvim', 
    run = 'cmake -S. -Bbuild -DCMAKE_BUILD_TYPE=Release && cmake --build build --config Release && cmake --install build --prefix build' 
}

lua/plugins/telescope.lua

This file is small and could arguably be compressed into packer_init.lua, but for now I've opted to keep it separate.

require('telescope').setup()

-- This is required to use FZF with Telescope.
require('telescope').load_extension('fzf')

lua/keymap.lua

I have a few custom bindings for Telescope. I use the file and buffer searches constantly.

-- Use ';' to search active buffers
map('n', ';', 'Telescope buffers')

-- Use Ctrl+P to show a list of files
map('n', '', 'Telescope find_files')

-- Live Grep
map('n', 'fg', 'Telescope live_grep')

-- Metals commands
map('n', 'fm', 'Telescope metals commands')

♯Plugin: Trouble

Trouble is:

A pretty list for showing diagnostics, references, telescope results, quickfix and location lists to help you solve all the trouble your code is causing.

It's a relatively recent find and I've found it handy.

Why?

I wanted to try leveraging Trouble and getting more used to using lists of output to streamline how I work. So far I would consider a minor win, but I also keep finding more ways to use it that are easy to pick up, such as listing TODOs.

Testing the Installation

Open Neovim and press ,xx and Trouble should open at the bottom. Press ,xx to toggle it closed.

lua/packer_init.lua

Trouble depends upon nvim-web-devicons, another common dependency. Keep in mind that you still need a patched font.

use {
    'folke/trouble.nvim',
    requires = 'kyazdani42/nvim-web-devicons',
    config = function()
        require('trouble').setup()
    end
}

lua/keymap.lua

I primarily toggle Trouble and am still learning to use this plugin effectively.
-- Show/hide Trouble
map('n', 'xx', 'TroubleToggle')

-- Show workspace diagnostics
map('n', 'xw', 'TroubleToggle workspace_diagnostics')

-- Show document diagnostics
map('n', 'xd', 'TroubleToggle document_diagnostics')

-- Show quickfix
map('n', 'xq', 'TroubleToggle quickfix')

-- Show local list
map('n', 'xl', 'TroubleToggle loclist')

-- Show LSP references
map('n', 'xr', 'TroubleToggle lsp_references')

♯Plugin: vim-floaterm

vim-floaterm provides a togglable floating terminal, and I find this plugin incredibly useful.

Why?

Embedding the Neovim terminal in a floating window is an easy and fast way to access a terminal (something I do constantly) with minimal context switching. I could just open another terminal in i3 and use a different key combo to switch to it - sometimes I even do that in conjunction with floaterm! For quick items and especially for leveraging multiple terminals I like keeping them scoped to the project editor.

Testing the Installation

Open Neovim and press ,tt to toggle open a floating terminal. Press that sequence again to close the terminal.

init.lua

Add the following after installing Telescope.

require('plugins/floaterm')

lua/packer_init.lua

use 'voldikss/vim-floaterm'

lua/plugins/floaterm.lua

vim.g.floaterm_width = 0.8
vim.g.floaterm_height = 0.8
vim.g.floaterm_gitcommit = 'split'

lua/keymap.lua

I chose t as my starting letter for terminal. Toggling a terminal is the most common action, so doubling-up on the t makes it easy to execute. I highly recommend keeping bindings for creating new terminals and using prev/next because it can be extremely useful. For example: you can run tests in one terminal check/manage git in the next, etc.

-- Create a new floating terminal
vim.g.floaterm_keymap_new = 'tc'

-- Move to the previous terminal
vim.g.floaterm_keymap_prev = 'tp'

-- Move to the next terminal
vim.g.floaterm_keymap_next = 'tn'

-- Toggle the visibility of the floating terminal
vim.g.floaterm_keymap_toggle = 'tt'

♯Plugin: lualine.nvim

lualine.nvim is a statusline plugin written in Lua. I was pleasantly surprised with the ease of setup, and the correct coloring being provided by the material color scheme that I use.

I don't go too crazy with my status line and have a very vanilla setup.

If you aren't familiar with status line plugins, it adds some coloring and aesthetic appeal to the bar at the bottom of the Neovim instance. It also provides additional information like a basic Git status, the Git branch, and other possible options. It's worth reading the documentation to see if you want to customize this plugin for your own needs.

Why?

I find the additional information and visual cues useful, and they don't get in my way.

Testing the Installation

Open Neovim and you should see the status line in all its glory, with the correct colors.

init.lua

Add the following after installing lualine.nvim.

require('plugins/lualine')

lua/packer_init.lua

Another usage of nvim-web-devicons.

use {
    'nvim-lualine/lualine.nvim',
    requires = 'kyazdani42/nvim-web-devicons'
}

lua/plugins/lualine.lua

Note that this file will be modified later once Metals support is added. This configuration depends on the material theme, so you'll want to change that if you're using something different.

require('lualine').setup {
    options = {
        theme = 'material'
    }
}

♯Plugin: bufferline.nvim

bufferline.nvim is a bufferline plugin for Neovim. It's opinionated and inspired by Doom Emacs.

If you aren't familiar with these plugins, this one essentially adds a bar to the top of your Neovim window that lists out all of the open buffers. It looks nice and has things like an icon indicating the file type and a Git status indicator.

Why?

I went without a bufferline for a long time. Honestly I could go back to it, but I'm not hitting any performance issues and I'm not hitting any screen issues and I'm not hitting any visual noise issues. The tabs aren't annoying, but they help me passively keep context. Sometimes I even look at them directly to force myself to focus on what I have open. It's a good combination with fuzzy search via Telescope.

Testing the Installation

Open Neovim and you should see the buffer line at the top of your Neovim instance - it will have a single "tab" called [No Name] as you're working in an unnamed buffer.

lua/packer_init.lua

Another usage of nvim-web-devicons.
use {
    'akinsho/bufferline.nvim', 
    requires = 'kyazdani42/nvim-web-devicons',
    config = function()
        require('bufferline').setup()
    end
}

♯Plugin: gitsigns.nvim

gitsigns.nvim provides indicators in the sign column based on Git.

This plugin offers a bunch of additional features, but I don't really use them. I like the sign visual component but I prefer interacting with Git purely through the command line.

Why?

I just like the signs. Nice easy visual cue. I could throw away everything else in this plugin... though I have a feeling a lot of people will really enjoy the additional features.

Related Configuration

I use the following in lua/general.lua to ensure the sign column is always visible:

opt.signcolumn = 'yes'      -- Always show the sign column

Testing the Installation

Open a file in a Git repository (ensure the file you're working on isn't ignored!) and make a change. You should see the sign column updated (e.g. a green bar, a red dash) depending on the type of change you made (add/remove/modify).

lua/packer_init.lua

use {
    'lewis6991/gitsigns.nvim',
    config = function()
        require('gitsigns').setup()
    end
}

♯Plugin: indent-blankline.nvim

indent-blankline.nvim is a cool recent find that adds visual guides. I actually find these quite handy even for small functions. The guides are dim and don't add too much distraction.

On a related note, this plugin offers more customization/features that I don't use as they seem to be ways to add more visuals to the screen (e.g. marking blank characters, marking newlines) that I don't care about and add significant noise to the screen.

I personally find the line-based visual guides, and those alone, to hit a nice balance.

Why?

I wanted to try out visual guides. As I mentioned above, I've been finding them useful.

Testing the Installation

Open a source file with indentation or open Neovim and create a new file and set some file type (e.g. set ft=python) and write some arbitrary code/lines with indentation. Try multiple lines per level and multiple levels. You should see visual guides appear that vertically trace each level of indentation.

lua/packer_init.lua

use {
    'lukas-reineke/indent-blankline.nvim',
    config = function()
        require('indent_blankline').setup()
    end
}

♯Plugin: nvim-teesitter

nvim-treesitter:

The goal of nvim-treesitter is both to provide a simple and easy way to use the interface for tree-sitter in Neovim and to provide some basic functionality such as highlighting based on it.

Tree-sitter is a parser generator tool and incremental parsing library. This plugin leverages tree-sitter and exposes it to Neovim, and right now I pretty much just use it for nice highlighting, although the power of tree-sitter goes far beyond this and there exist many plugins with tree-sitter integration via this plugin.

Why?

Better syntax highlighting, powerful integrations and a generally widespread tool. I feel like the value of adopting tree-sitter will only grow over time.

Testing the Installation

Open Neovim and run :checkhealth treesitter.

Gotcha: Scala 3 Syntax

Unfortunately Scala 3 is not really supported at this time. It's fine I suppose and some of the stuff still works, but if you're like me and trying out the braceless syntax, get used to minor frustration.

This is not at all a tree-sitter problem: nobody is updating the grammar. Also that's not me bitching about it (nobody is paid to do it, nobody has to do it, and I very much understand not having the time or not finding that specific task to be the most gratifying thing ever), moreso just being clear about the current state of affairs.

With the way my life is right now I'm unlikely to jump in, but I'll be incredibly greatful if and when someone else does.

init.lua

require('plugins/treesitter')

lua/packer_init.lua

use {
    'nvim-treesitter/nvim-treesitter',
    run = ':TSUpdate'
}

lua/plugins/treesitter.lua

Adjust this according to what languages you use. It's worth reviewing the README and list of supported languages for this plugin. Note that I don't use HTML because I don't follow normal indentation rules (I mostly do not indent).

require'nvim-treesitter.configs'.setup {
  -- One of "all", "maintained", or a list of languages
  ensure_installed = { 
      "c", "zig", "bash", "scala", "yaml", "css", "javascript",
      "latex", "clojure", "lua", "cpp"
  },

  -- Install languages synchronously (only applied to `ensure_installed`)
  sync_install = false,

  -- List of parsers to ignore installing
  ignore_install = { },

  highlight = {
    enable = true,
    disable = {},
    -- Setting this to true will run `:h syntax` and tree-sitter at the same 
    -- time. Set this to `true` if you depend on 'syntax' being enabled (like 
    -- for indentation). Using this option may slow down your editor, and you 
    -- may see some duplicate highlights. Instead of true it can also be a list 
    -- of languages
    additional_vim_regex_highlighting = false,
  },

  indent = {
    enable = true
  }
}

♯Plugin: nvim-lspconfig

Now that all of the other plugins are accounted for, it's time to move on to the Neovim LSP, completion, and related plugins.

Please note that this plugin is not the Neovim LSP, it's just a set of configuration for it!

Why?

I used to use coc.nvim which worked incredibly well, and is still an excellent plugin. There were a couple of factors in my switching to use only the Neovim LSP:

  • I wanted to try it. I love that Neovim is going this direction.
  • It seemed mature enough to use without too much hassle.
  • Metals is moving to the Neovim LSP as the primary target. Since I mostly write Scala at this time, moving now made the most sense.
  • The other things I write (e.g. Zig, Janet, whatever I'm fiddling with) all have integrations that I was able to get working easily.

Testing the Installation

Make sure that Neovim starts! To really test the LSP, you'll need to use some LSP configuration. I'll share examples of that later.

init.lua

require('plugins/lsp')

lua/packer_init.lua

use 'neovim/nvim-lspconfig'

lua/plugins/lsp.lua

This will require customization according to what you want to use the LSP for. I've omitted my language-specific things here, and I also do not have nvim-cmp support listed in this snippet.

Relevant sections:

-- Not used yet!
local lsp = require('lspconfig')

-- Not used yet!
-- Use an on_attach function to configure after LSP attaches to buffer
local on_attach = function(client, bufnr)
  local function buf_set_option(...) vim.api.nvim_buf_set_option(bufnr, ...) end
  buf_set_option('omnifunc', 'v:lua.vim.lsp.omnifunc')
end

♯Plugin: nvim-cmp

nvim-cmp is a completion engine for Neovim. It will help you with completion of things like words in your buffer, system paths, and LSP sources (e.g. showing available functions or variables while you type). We need to configure all of those sources and also advertise nvim-cmp to our LSP plugins.

Why?

I needed a completion engine that worked well with the Neovim LSP. I actually tried out coq.nvim but I couldn't get it working and haven't found nvim-cmp to be slow. Rather than bang my head against a wall, I decided to use a working solution and enjoy the decent documentation and examples.

Testing the Installation

Open Neovim and start writing. Example, that I just tested:

Testing testing one two three banana on a bear.

Then start typing on a new line and see what happens. You should get a box that suggests completions if you type a letter of something that should be impacted. Let's say your buffer letter cutoff is 3. Typing o should show one as an option, but not on because on is below the threshold.

init.lua

require('plugins/cmp')

lua/packer_init.lua

use {
    'hrsh7th/nvim-cmp',
    requires = {
        'hrsh7th/cmp-nvim-lsp',
        'hrsh7th/cmp-path',
        'hrsh7th/cmp-buffer'
    }
}

lua/plugins/cmp.lua

I currently leverage three completion sources:

  • The builtin Neovim LSP
  • System paths
  • The current buffer, where completion starts after 4 characters.

This configuration also allows for using (enter key) to select an item from the list of available options and Tab and Shift+Tab to navigate those lists when they pop up.

local cmp = require('cmp')

cmp.setup{
    sources = {
        { name = 'nvim_lsp' },
        { name = 'path' },
        { name = 'buffer', option = { keyword_length = 4 }, },
    },
    mapping = {
        ['<CR>'] = cmp.mapping.confirm({}),
        ['<Tab>'] = function(fallback)
            if cmp.visible() then
                cmp.select_next_item()
            else
                fallback()
            end
        end,
        ['<S-Tab>'] = function(fallback)
            if cmp.visible() then
                cmp.select_prev_item()
            else
                fallback()
            end
        end,
    },
}

lua/plugins/lsp.lua

Again, these capabilities are not yet used - I'm deviating a little bit from my actual process (since I have my languages and such picked out) to show more minimal examples.

-- Establish a set of capabilities so we can advertise nvim-cmp support.
local capabilities = vim.lsp.protocol.make_client_capabilities()

-- Add nvim-cmp support to the capabilities.
capabilities = require('cmp_nvim_lsp').update_capabilities(capabilities)

♯Plugin: nvim-metals

nvim-metals is a plugin for Neovim which uses Neovim's LSP with the Metals language server. I'm primarily a Scala developer right now, and this is my go-to.

Testing the Installation

Open a Scala project, e.g. nvim build.sbt. You should be prompted to import the project.

Gotcha: Filetype Restrictions

At first I tried to do this:

use {
    'scalameta/nvim-metals', 
    requires = { 'nvim-lua/plenary.nvim' },
    ft = { 'scala', 'sbt' }
}

and I failed. Using ft just shredded Metals and I have no idea why. Since I got it working, I haven't bothered to dig further. Yes, shame on me, but it works fine as-is and I wanted to get back to writing code.

Gotcha: Adding New Files

I typically add new files while editing by using :e <path>. While syntax highlighting applies properly, code completion does not. I'm assuming that Metals doesn't know about the file for some reason. I can run a reimport of the project and the problem persists, but closing Neovim and opening it fixes it.

As a less heavy-handed approach, I should try closing the buffer and reopening the file in a new buffer. There also might be a good way to create a file that allows Metals to immediately process it.

init.lua

require('plugins/scala')

lua/packer_init.lua

use {
    'scalameta/nvim-metals', 
    requires = { 'nvim-lua/plenary.nvim' }
}

lua/plugins/scala.lua

Some things of note:

  • Per my comments, lualine needs an update for Metals.
  • We're advertising the nvim-cmp capabilities to Metals.
-- Autocmd that will actually be in charging of starting the whole thing
-- Note that Metals will only be initialized for filetypes selected below.
local nvim_metals_group = vim.api.nvim_create_augroup(
    'nvim-metals', 
    { 
        clear = true 
    }
)

vim.api.nvim_create_autocmd('FileType', {
    pattern = { 'scala', 'sbt', 'java' },
    callback = function()
        metals_config = require('metals').bare_config()

        -- Capabilities for completion.
        local capabilities = vim.lsp.protocol.make_client_capabilities()
        capabilities = require('cmp_nvim_lsp').update_capabilities(capabilities)

        metals_config.settings = {
            showImplicitArguments = true,
            showInferredType = true
        }

        -- This is a tricky option and _nothing_ will show up unless...
        -- "However, to enable this you _must_ have the metals status shown in your 
        --  status bar somehow."
        metals_config.init_options.statusBarProvider = 'on'

        metals_config.capabilities = require('cmp_nvim_lsp').update_capabilities(capabilities)
        require('metals').initialize_or_attach(metals_config)
    end,
    group = nvim_metals_group,
})

lua/plugins/lualine.lua

We need to add Metals support to lualine since I enabled statusBarProvider. This is a nice way to get Metals status updates in my statusline.

local function metals_status_for_lualine()
    return vim.g["metals_status"] or ""
end

require('lualine').setup {
    options = {
        theme = 'material'
    },
    sections = { 
        lualine_c = { 
            'filename', 
            metals_status_for_lualine 
        } 
    }
}

lua/keymap.lua

Finally, let's add Telescope support for browsing the available Metals commands.

-- Metals commands
map('n', 'fm', 'Telescope metals commands')

♯Plugin: nnn.nvim

nnn.nvim is a file manager plugin for Neovim based on nnn. nnn is a terminal file manager that is full-featured while being very small and very fast.

Why?

For the most part I've eschewed file manager plugins and have found quick fuzzy search sufficient for typical use. I've also found the file managers to be clunky. What nnn solves is giving me a file-level view, and I'm not sure whether I'll use that or something like LSP project views more. I do like that nnn is intuitive and responsive. It also stays out of my way unless I ask for it. So far I'm happy with the plugin as a limited-use utility.

Testing the Installation

Open Neovim and press Ctrl+A and then either n for the explorer or p for the picker.

lua/packer_init.lua

use {
    'luukvbaal/nnn.nvim',
    config = function() 
        require('nnn').setup()
    end
}

lua/keymap.lua

Press Ctrl+A followed by n to open nnn explorer in a vertical split. This is nice for having a persistent view of all files in a project. Press Ctrl+A followed by p to open the picker as an interactive file manager alternative to the fuzzy search-based Ctrl+P based on Telescope.

-- Toggle the nnn file explorer in a vertical split.
map('n', '<C-A>n', '<cmd>NnnExplorer<CR>')

-- Toggle the nnn file picker in a floating window.
map('n', '<C-A>p', '<cmd>NnnPicker %:p:h<CR>')

♯Plugin: todo-comments.nvim

todo-comments.nvim is a really neat plugin that is another one of my new discoveries. I'm using the following features:

  • Keyword highlights. My most common keyword is "TODO"
  • Trouble integration.
  • Telescope integration.

The integrations in this case are super useful if you routinely use TODOs to leave notes for yourself in comments.

Why?

The visual indicators are helpful since I generally want to draw focus to my TODOs. The additional power of listing/searching saves me time because I used to do this manually and have to find and open files myself. My workflows are just more smooth with this extracted to a plugin.

Testing the Installation

Open Neovim and type TODO: Something and the TODO portion should be highlighted. It should also update the sign column.

lua/packer_init.lua

use {
    'folke/todo-comments.nvim',
    requires = 'nvim-lua/plenary.nvim',
    config = function()
        require('todo-comments').setup()
    end
}

lua/keymap.lua

-- Search project TODOs in Telescope
map('n', '<leader>ft', '<cmd>TodoTelescope<CR>')

-- Show project TODOs in Trouble
map('n', '<leader>xt', '<cmd>TodoTrouble<CR>')

♯Plugin: filetype.nvim

filetype.nvim replaces the default filetype support loaded on startup. The README does a good job of describing the problem.

Why?

The default is slow. This is less so. ¯\_(ツ)_/¯

Testing the Installation

Neovim should work. I haven't bothered trying to benchmark it and honestly this isn't something I had considered a problem; my computer is fast enough that I didn't really notice. Regardless, I figured it couldn't hurt.

lua/packer_init.lua

use 'nathom/filetype.nvim'

♯Example: Zig Support

I really like Zig. I think it's a fantastic effort and I've been following it for years, occasionally poking around with some small projects. I have Zig LSP support via zls and that serves as a good sample case for showing off what adding support for a language could look like using nvim-lspconfig.

The first thing to check is whether your language server is supported by this configuration set. It turns out that zls is supported.

lua/plugins/lsp.lua

Remember that capabilities refers to a set of capabilities to which we added nvim-cmp support earlier in this article.

lsp.zls.setup {
  on_attach = on_attach,
  flags = {
    debounce_text_changes = 150
  },
  capabilities = capabilities
}

Additional Plugin Support

You may want to leverage zig.vim and take advantage of format-on-save, though things like syntax are covered by Zig's maintained tree-sitter implementation.

♯Closing Thoughts

It required drastically more effort to write this article than it did to migrate my configuration from Vimscript to Lua. So far I'm completely happy with my decision:

  • I have not lost any functionality.
  • I have gained functionality through reevaluating my configuration.
  • I'm far happier with my config organization.
  • I learned a decent amount in the process.

If you're on the edge of trying this out yourself but not sure if it will be too difficult or take too much time, I would encourage you to give it a try! Remember: you can always create a quick backup of your Vimscript and restore it if things go awry.