thomas sheard.

Writing my NeoVim config from scratch

This entry is long. Sorry. I've included a table of contents to make navigation easier, and clicking a section heading will bring you back to it.

contents

  1. contents
  2. introduction
  3. getting started
    1. first steps
    2. my keymaps
    3. options
    4. my options
  4. plugins
    1. installing a plugin manager
    2. installing plugins
  5. treesitter
  6. lualine
  7. completion
    1. the error of my ways
    2. lsp management
    3. completion engine
  8. luasnip
  9. autopairs and tabout
  10. telescope
  11. oil
  12. closing remarks

introduction

Since learning Vim motions, NeoVim (nvim) has quickly become my preferred text editor and IDE. As anyone who has gone down the rabbit hole of vim (or more generally, system) customisation (also termed ricing) knows: the rabbit hole is deep. My nvim config is forever evolving closer to my idea of a perfect development environment, alongside my understanding of my system as a whole.

However I find that I modify my config sporadically in short and intense bursts, mostly when my frustration with a certain feature finally gets the better of me and I decide to 'just do it myself'. Increasingly, this has become unwieldy and difficult to maintain; every time I delve into the dotfiles I have to relearn many things that I have already been through, which is both frustrating and a waste of time. Many 2AMs have come and gone while I re-read documentation instead of fixing what I actually intended to.

This has led me to the (quite rational, I think) decision to to re-write the entire thing from scratch, and document the process for future reference.

getting started

My preferred directory hierarchy for a modular Neovim config looks something like this:

~
|- .config/
    |- nvim/
        |- init.lua
        |- lua/
        |- user/
            |- init.lua
            |- plugin_1.lua
            |- plugin_2.lua
            :
            |- plugin_n.lua

This allows us to do several handy things. Firstly, we can localise our config for each plugin in plugin.lua files, which makes everything much more maintainable. Secondly, we can create multiple user profiles that have different configurations, and we can swap between them by simply changing the directory that we source in the first init.lua. This is neat, as it provides lots of scope for experimentation (you could create forks of your user config to safely try new things!), and anybody with this hierarchy can simply pull somebody else's profile.

first steps

To start, we need to replicate the file tree above (not including the package.lua files, just make the two init.luas for now). We then want to populate the first init.lua like this:

require('user') 

This just tells nvim which 'profile' to use; this is what you would change to swap between different configs, as all config files are contained within the required() directory. I'll call my user profile core from this point onward, but it could be anything.

The most important things (in my opinion) to start with are keymaps and options. They do not require a plugin manager to setup and will make building the rest of our config much smoother.

To do this, we need keymaps.lua and options.lua in the core user directory.

keymaps

The most important key to bind for this section is the leader key, which we define in the core/init.lua file. Your leader key acts a signal key that activates a second layer of keybinds in Normal mode, and can be used to make an effectively unlimited amount of key chords for whatever your heart may desire. To define the leader key, we put this in the second init.lua file:

vim.g.mapleader = " "

I use the spacebar (" ") as my leader key, as it is very easily reachable and has no critical function in normal mode (it jumps the cursor forward similar to l, but it also traverses line breaks). Note that whichever key is mapped to the leader key will have a delay (of a defineable length, which we will see later) on its usual function while nvim waits for further inputs to activate chord mappings, but will still enact the primary function after this delay. There is also the option to maplocalleader, which can be used to define keymaps for only the open buffer, which allows for filetype specific mappings (!)

Now to add some proper keybinds in the keymaps.lua file. A keymap in vim usually has the format of:

vim.keymap.set(mode, key, command, opts)

Which is not very long but I don't like typing it over and over (and, often, we don't need the opts arg) so we can create a lua function to make it shorter:

-- shorten keymap function
local function keymap(mode, key, command, opts)
    opts = opts or {}
    vim.keymap.set(mode, key, command, opts)
end

This just sets the function keymap() to take the same paramaters as vim.keymap.set(), and adds the condition that the opts argument can be omitted (by accepting an empty lua table). Nice!

Following this, we can start defining keymaps with the keymap() function. Let's start with a simple example:

keymap('i', '<C-c>', '<Esc>') -- lol

We start by calling keymap(). All arguments are strings, passed with single quotations. The first argument is the mode(s) that the keymap should be active in; i for insert, n for normal, v for visual, etc. The second argument is the key(s) we want to map. The < and > indicate a special keycode instead of literal keypresses; passing Esc would be interpreted as pressing uppercase E, lowercase s and lowercase c as a key chord, while <Esc> means the actual escape key. To use pre-existing keychords, we use both < > and a hyphen: <C-c> signifies Ctrl + c.

This keybind, then, takes the key chord Ctrl + c in insert mode and executes Esc. The astute may notice that this is, frankly, a rather silly keybind to have, as both Ctrl + c and Esc have the effect of returning to normal mode when pressed. The reason I have this is to do with using snippets, which we will get to later – it's just because <C-c> and <Esc> have subtly different behaviours, which results in some escape actions not being activated properly when the former is used to complete them (also, my Caps Lock is an additional Control, which I find I find easier to reach than Escape).

Now we have the basics down, we can go crazy with it. What follows is my keymaps.lua file, in several sections, with most keymaps commented with explanation.

my keymaps.lua file

Some fundamental keybinds, like the previous = example, and essential functions like saving, sourcing, and quitting files:

 -- shorten keymap function
local function keymap(mode, key, command, opts)
  opts = opts or {}
  vim.keymap.set(mode, key, command, opts)
end

-- documentation:
-- https://vimhelp.org/vim_faq.txt.html#faq-20.5

-- SYSTEM

keymap('i', '<C-c>', '<Esc>') -- I CAN EXPLAIN

keymap('n', '<leader>w', '<C-w>') -- remap window navigation before i remap <C-w>
keymap('n', '<C-w>', ':w<cr>') -- saving
keymap('n', '<C-s>', ':so<cr>') -- sourcing
keymap('n', '<C-q>', ':q<cr>') -- quitting
keymap('n', '<leader>c', vim.cmd.nohlsearch) -- clear search highlighting

Motion remaps, such as j/k, being able to move highlighted text with J and K, and some other preferences of mine:

-- MOTIONS

keymap('i', '', '') -- control backspace deletes entire word

-- allow j / k to move within lines for multiline text
-- while retaining counted relative motions

keymap('n', 'j', function ()
  return vim.v.count > 0 and 'j' or 'gj'
end, { expr = true })

keymap('n', 'k', function ()
  return vim.v.count > 0 and 'k' or 'gk'
end, { expr = true })

keymap('v', 'J', ":m '>+1<CR>gv=gv") -- visual mode text shifting
keymap('v', 'K', ":m '<-2<CR>gv=gv") -- using J / K (down, up)

keymap('n', '', 'o') -- enter to newline in insert 
keymap('n', '', 'O') -- shift + enter to newline above in insert 

keymap('n', 'o', 'o<Esc>') -- preference for o / O (escapes after motioning)
keymap('n', 'O', 'O<Esc>')

keymap('n', '<C-d>', '<C-d>zz') -- cursor remains centered in <C-d> / <C-u>
keymap('n', '<C-u>', '<C-u>zz')

keymap('n', '<C-j>', '<C-e>') -- scroll screen without cursor
keymap('n', '<C-k>', '<C-y>')

The j/k remaps define simple lua functions, checking whether or not there is a count before the motion. If there is not (ie. plain vertical navigation) then they'll navigate through each display line in multiline (broken) text. If there is a count, it will only traverse true line numbers. This means that it's easy to navigate vertically within broken lines, but relative line numbers are still accurate for counted motion. They are lua translations of u/monkoose's vimscript solution to this problem.

Quality of life mappings for common manipulations, like indenting =, yanking entire lines Y, etc:

-- QOL

keymap('n', '=', 'V=') -- easier indenting
keymap('n', 'Y', 'Vy') -- easier line yanking

keymap('n', '<C-n>', 'a<Enter><Esc>k$') -- <C-n> to break line at cursor

keymap('n', '<C-a>', 'A;<C-c>') -- appending ; to current line in normal / insert mode
keymap('i', '<C-a>', '<C-c>A;<C-c>')

And that's all! For inbuilt nvim keys, at least - we will add more when we start adding plugins.

options

Vim options are the preferences that change fundamental behaviours of neovim. They set things like indenting, search query matching, and auto-saving behaviours.

To set preferences, we edit options.lua with

vim.opt.<optionname> = <value>

To find options that we might like to set, we can refer to the :help page or the NeoVim documentation, both of which contain the same information. Here are my preferences, again broken down by area.

my options.lua file

System options, like the chord input delay mentioned above, swap file behaviour, and mouse behaviour:

-- system

vim.opt.timeoutlen = 300 -- delay for key chord inputs (eg. after leader is pressed)
vim.opt.filetype = "on" -- detects filetype

-- saving / edit history

vim.opt.undofile = true -- whether nvim should save an undotree file
vim.opt.updatetime = 250 -- time (ms) without an edit for a swap file to be written

-- navigation

vim.opt.mouse = '' -- disable mouse ("learn thy motions!")
vim.opt.scrolloff = 8 -- forces 8 row buffer between cursor and screen bounds
vim.opt.mousescroll = 'ver:10' -- increase scroll event sensitivity 

Indenting and (relative) line numbers:

-- line numbers

vim.opt.number = true -- line numbers
vim.opt.relativenumber = true -- relative line numbers (i like both)

vim.opt.numberwidth = 2 -- 2 char width
vim.opt.signcolumn = "yes" -- adds padding to line numbers
vim.opt.statuscolumn = '%{v:relnum?v:relnum:v:lnum}' -- left aligns line numbers

vim.opt.cursorline = true -- highlights current line
vim.opt.cursorlineopt = 'number' -- only highlights number, not line

-- indenting

vim.opt.tabstop = 4 -- tab = 4 spaces (when writing a file)
vim.opt.softtabstop = 4 -- tab = 4 spaces (when performing editor commands)
vim.opt.shiftwidth = 2 -- width in spaces of shifting (<<, >>)
vim.opt.expandtab = true -- converts tabs to spaces

vim.opt.autoindent = true -- auto indent lines following an indented line
vim.opt.smartindent = true -- makes autodindent smarter (idk how lol)

-- line wrapping

vim.opt.wrap = true -- allows line wrapping
vim.opt.linebreak = true -- only breaks a line on a word boundary
vim.opt.showbreak = "" -- text inserted before broken lines

vim.opt.breakindent = true -- indented multi-lines retain indenting on broken text

Other text actions, like searching, completion menus (this won't work until we have a completion plugin), and spell checking:

-- searching

vim.opt.hlsearch = true -- highlight on search
vim.opt.incsearch = true -- live search highlighting updates

vim.opt.ignorecase = true -- case-insensitive searching (override with C)
vim.opt.smartcase = true -- also override with capitalised query

-- completion

vim.opt.completeopt = 'menuone,noselect' -- will show completion menu if there is at least one completion option, and will not select an option by default

-- spell check

vim.opt.spelllang = 'en_au' -- australian spelling (en_au, en_gb, en_us)
vim.opt.spell = false -- no spellcheck by default (use ':set spell' and ':set nospell' to toggle on the fly)

And finally some small aesthetic tweaks for cursors and text concealment:

-- aesthetics 

vim.opt.guicursor = "r-c-v-sm:block,i-ci-ve:ver25,n-cr-o:hor10"
vim.opt.conceallevel = 0 -- level of syntax replacement, for example math symbol macros being replaced with the actual symbol unicode in a .tex file

Again, we will add some more once we have plugins, but these are the ones that I like for core nvim functionality.

plugins

This is the exciting bit. Plugins are very powerful, and there are so many of them (like, an absurd amount. Just take a look at some ready-to-use neovim configs, like the popular NvChad). A large part of my wanting to write this page was that I didn't even know what some of my plugins did any more; I just added them, and they didn't do anything… bad? So I just kept them. I would like to categorically document which plugins I have, and why I have them.

Before we do that, though, we need a way of organising our plugins. We do this with a plugin manager. My preference is lazy.nvim, as it allows for lazy-loading plugins (only loading them when they are needed to reduce startup impact), automatic installation after adding plugins to our config, and it uses lua, which matches the rest of our config. From what I have heard and read, though, it doesn't really matter too much.

installing lazy.nvim

The process for installing lazy is pretty much the same as setting up the keymaps and options files: we make the lazy.lua file in the profile directory and require() the file in our init.lua:

require("core/lazy")

Following the documentation (like good software users), we add the following to the lazy.lua file:

-- installing lazy.nvim (plugin manager)

local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
if not (vim.uv or vim.loop).fs_stat(lazypath) then
  local lazyrepo = "https://github.com/folke/lazy.nvim.git"
  local out = vim.fn.system({ "git", "clone", "--filter=blob:none", "--branch=stable", lazyrepo, lazypath })
  if vim.v.shell_error ~= 0 then
    vim.api.nvim_echo({
      { "Failed to clone lazy.nvim:n", "ErrorMsg" },
      { out, "WarningMsg" },
      { "nPress any key to exit..." },
    }, true, {})
    vim.fn.getchar()
    os.exit(1)
  end
end

vim.opt.rtp:prepend(lazypath)

require("lazy").setup({
-- our plugins go here!
})

We can then add all of our plugin setup in the .setup() function, and the plugin manager will handle the rest! If we restart nvim, we should have access to the :Lazy command, which will bring up a buffer showing all of the currently loaded and installed plugins, of which there will be none just yet. Let's change that.

installing plugins

I must admit I am getting rather sick of the default neovim colors:

yuck!

It's high time we address this. I use the same theme, OneDark, for my entire system (including the website!) at the minute. Conveniently, there is a plugin for this. We can install it by starting a new block (scope) in the .setup() function using {} and writing the name of the plugin (as a git repo), as well as any configuration or dependencies it needs, like this:

require("lazy").setup({

  -- aesthetics

  {
    'navarasu/onedark.nvim',
    priority = 1000,
    config = function()
      vim.cmd.colorscheme 'onedark'
    end,
  },

})

We can resource the config by restarting neovim, and just like that:

yum

Gorgeous. Most plugins will have documentation that specifies how you should import them and what dependencies they require. If they don't, just adding the repo will probably work, like this:

require("lazy").setup({

  -- aesthetics

  {
    'navarasu/onedark.nvim',
    priority = 1000,
    config = function()
      vim.cmd.colorscheme 'onedark'
    end,
  },

  'nvim-tree/nvim-web-devicons', -- we just use the repo name here

})

Devicons adds support for system icons, like folders, files, images, etc. which will be handy when we add some file navigation plugins.

treesitter

On the topic of aesthetics, treesitter is a lovely addition; it creates incremental syntax trees for a text file, producing very accurate and readable code highlighting. Not strictly necessary, but we could make that argument ad absurdum and end up using Notepad instead.

We add treesitter to lazy.lua:

-- treesitter (syntax highlighter)
  {
    'nvim-treesitter/nvim-treesitter',
    build = ':TSUpdate',
  },

To pass options to the plugin, we can add a treesitter.lua to our core/ directory and require it in init.lua:

require("core/treesitter") 

-- treesitter.lua
vim.defer_fn(function()
  require('nvim-treesitter.configs').setup {

    -- languages treesitter should definitely have installed
    ensure_installed = {'c', 'cpp', 'cmake', 'lua', 'python', 'r', 'rust', 'vimdoc', 'vim', 'bash', 'java', 'latex', 'query', 'html', 'markdown'},

    auto_install = true, -- auto-install missing buffers (set false if tree-sitter is not locally installed)
    sync_install = false, -- install ensure_installed synchronously
    ignore_install = {}, -- parsers to ignore

    modules = {
      highlight = {
        enable = true,
        disable = {}, -- parsers to disable highlighting for
      },
      indent = { enable = true },

      -- syntax-tree based incremental scope selection
      incremental_selection = {
        enable = true,
        keymaps = {
          init_selection = '', -- self explanatory <3
          node_incremental = '', -- no longer self explanatory <3
          scope_incremental = '', -- (i don't know what these do yet)
          node_decremental = '',
        },
      },
    },

  }
end, 0)

lualine

Nvim's default status line is perfectly functional but we can make it much more useful (and pretty!) with the Lualine plugin. This one is fairly straightforward:

-- set lualine as statusline

{
    'nvim-lualine/lualine.nvim',
        opts = {
            options = {
                icons_enabled = false,
                theme = 'onedark',
                component_separators = '|',
                section_separators = '',
            },
        },
},

I'd be line if I said I didn't like
this…

Lovely stuff.

completion

I have a complicated relationship with completion plugins. On the one hand - fantastic! Completion suggestions make writing boilerplate code so much easier, and having all your variable names at the tip of your cursor is great for avoiding or catching silly syntax errors. On the other hand, why are you suggesting a function from a library I that is not imported in this buffer and then automatically replacing something I wrote with another, undesired completion? Stop that.

Most frustratingly, I found that autopairs (automatically inserting the closing pair to an opening character, like ({[<" ) simply did not work in a way that I felt was intuitive. These plugins are ultimately what made me want to write this entry, and this entry is what got me started on this whole website business in the first place, so getting this right is important to me.

I toyed briefly with the idea of writing snippets (a topic we'll get to in a moment) for each language, but this is essentially writing my own completion suggestions from scratch. This is what we might call a "bad idea".

There are a couple of things we need to get completion working: a completion engine that handles the display and interactivity of a completion menu, and completion sources, comprised of language servers (LSPs, we saw these a little bit with treesitter), snippets, and other bits of logic that tells the completion engine what to populate the menu with.

sequence break!

I had a whole section here about how I was going to reconstruct my setup to be minimal, use as few plugins as I could and write autopairing in luasnip. After doing more searching, it turns out that what I want from pairing behaviour is way overscoped for snippets. My actual options are to write my own plugin, or to do more research and actually read the documentation of the available plugins.

I tried the former. There's an interesting development cycle when writing code to help you write code, where your output is partially contingent on the output of your output. Trying to learn the language I was using to interact with surprisingly complex APIs (treesitter is a miracle), all to develop a tool to help with my development… I lost motivation fairly quickly.

Autopairs does actually seem like the best option for me. The thing that frustrated me about it was a lack of 'tabout' behaviour:

Before:     Input:     After:
(|)         <Tab>      ()|

Implementing this in lua was way above my confidence with the language, needing some way of marking locations (either writing my own or using a the treesitter API) and moving the cursor to them. Writing my own plugin would also mean either: implementing autopairing behaviour (a pretty big project), or writing my plugin as an extension to an existing autopair plugin and exclusively handles the tabout behaviour, requiring fully understanding how the autopairs plugin works under the hood. Luckily, someone has already written a this tabout plugin – called tabout.nvim, funnily enough – so we'll use that when we get there.

While this is an annoying realisation to make this deep into the project, I think it is a perfect example of why documentation should one thousand percent be the first point of call whenever you're having issues with a program. Regardless, I still think re-writing the config is worth it for the learning experience #teachablemoment. Continuing on…

lsp management

We'll set LSP up first, because the package manager, Mason, we will use needs to be loaded before its dependencies (ie. the rest of our completion setup). Language servers can be installed through CLI tools like npm, but I prefer a dedicated interface that can be used within NeoVim (hence Mason).

We first need to add the core LSP functionality to nvim, as it's not installed by default, and then we can load Mason and its bridge plugin to nvim. In lazy.lua:

-- lsp
  {
    'neovim/nvim-lspconfig', -- core lsp functionality
    dependencies = {
      'williamboman/mason.nvim', -- lsp package manager
      'williamboman/mason-lspconfig.nvim', -- bridge between mason and lspconfig
    },
  }

And then we can make the lsp.lua file, populate it, and require it:

require('mason').setup()
require('mason-lspconfig').setup()

-- setup all installed language servers

local lspconfig = require('lspconfig')

-- we need this for the html lsp. attention seeker.
local capabilities = vim.lsp.protocol.make_client_capabilities()
capabilities.textDocument.completion.completionItem.snippetSupport = true

lspconfig.lua_ls.setup {
  settings = {
    Lua = {
      diagnostics = {
        globals = { 'vim' }
      }
    }
  }
}

lspconfig.rust_analyzer.setup {}

lspconfig.html.setup {
  capabilities = capabilities
}

lspconfig.texlab.setup {}

We install any new LSPs by running :Mason and installing them from the TUI, and then setting them up in lsp.lua as we need them. Some have special requirements, like html needing the capabilities local variable, but most can be set up with lspconfig.<lsp_name>.setup {}. Use the lspconfig documentation to check!

We can't really verify these are working until we have a completion engine, so let's get that running.

completion engine

I've chosen to use nvim-cmp for the completion plugin. It has a good rep, is quick, provides a decent amount of control over which suggestions you want to be shown, and is written in Lua like rest of our config.

We can import and require nvim-cmp and its dependencies as usual in lazy.lua:

-- completion
{
    'hrsh7th/nvim-cmp', -- completion engine
        dependencies = {
            'hrsh7th/cmp-nvim-lsp', -- lsp source
                'hrsh7th/cmp-buffer', -- buffer completions
                'hrsh7th/cmp-path', -- file path completions
                'saadparwaiz1/cmp_luasnip', -- snippet completions (luasnip)
        },
}

Each of the dependencies is a bridge for each of the completion sources I use.

lsp gives us language specific suggestions.

buffer draws from anything already in the current buffer, which can be annoying on large files but is handy when writing prose where topical words crop up a lot (e.g. "heteroscedasticity").

path gives path suggestions from the local filetree.

Snippets are my favourite; they are code fragments that can automatically insert structures instead of just text, allowing you to define line breaks, paramaters and, dynamic cursor locations that can be jumped between. These can be phenomenally powerful, allowing shortcuts for common boilerplate structures in just a couple of keypresses. I'll go over setting up a snippet plugin, but might give writing them the deserved attention in a separate post!

We also need to construct the cmp.lua to configure the plugin. This file has some fairly complex keybinds that use fallback() functions to check a list of different potential key actions in order of priority. These are important for tabout behaviour, which we'll set up later. The big idea, though, is that fallback tells a key to enact its default action.

local cmp = require('cmp')
local luasnip = require('luasnip')

cmp.setup {

    -- allows completion engine to expand snippets from luasnip syntax
    snippet = {
        expand = function(args)
            luasnip.lsp_expand(args.body)
        end,
    },

    mapping = cmp.mapping.preset.insert {

      -- C-CR will always expand a completion, even without selection

      ['<C-CR>'] = cmp.mapping.confirm {
        behavior = cmp.ConfirmBehavior.Replace,
        select = true,
      },

      -- CR will expand a selected completion

      ['<CR>'] = cmp.mapping(function (fallback)
        if cmp.visible() and cmp.get_active_entry() then
          cmp.confirm({ 
            behavior = cmp.ConfirmBehavior.Replace,
            select = false 
          })
         else
           fallback()
        end
      end),

      -- tab will expand a selected completion if there is one
      -- else, it will jump forward in a snippet
      -- else, it will fallback

      ['<Tab>'] = function(fallback)
        if cmp.visible() and cmp.get_active_entry() then
          cmp.confirm({ 
            behavior = cmp.ConfirmBehavior.Replace, 
            select = false 
          })
         elseif luasnip.expand_or_locally_jumpable() then
           luasnip.expand_or_jump()
         else
           fallback()
        end
      end,

      -- shift tab will jump back through a snippet
      -- else, will fallback

      ['<S-Tab>'] = function(fallback)
        if luasnip.locally_jumpable(-1) then
          luasnip.jump(-1)
       else
          fallback()
        end
      end,

      -- C-j navigates down in cmp menu

      ['<C-j>'] = cmp.mapping(function (fallback)
        if cmp.visible() then
          cmp.select_next_item()
        else
          fallback()
        end
      end),

      -- C-k navigates up in cmp menu

      ['<C-k>'] = cmp.mapping(function (fallback)
        if cmp.visible() then
          cmp.select_prev_item()
        else
          fallback()
        end
      end),

    },

    sources = cmp.config.sources {
      { name = 'nvim_lsp' }, -- lsp suggestions. this is the big one
      { name = 'luasnip' }, -- luasnip snippet suggestions
      { name = 'buffer' }, -- suggestions based on content in the active buffer. comment this out to remove plaintext suggestions
      { name = 'path' }, -- suggestions when typing paths
    },
}

This file defines the keymap behaviours for interacting with the completion menu, and establishes snippet behaviour. At the bottom of this file is where we actually 'pipe' each of our completion sources into the completion engine, meaning the completion menu should now be populated:

Path reveal at
20k

Very nice!

luasnip

Snippet configuration is structured similarly to completion in that we need a snippet engine and snippet sources. The engine itself just provides the framework for snippet functionality, which we then populate with snippet code that can be interpreted.

There are two under-the-hood use cases for snippet execution. When used in conjunction with completion, the completion engine sees the snippet engine as one of its sources – a big list of things the user might be typing. If a snippet is selected on the completion menu, the completion engine cedes responsibility to the snippet engine to interpret and run the snippet. The other use case is autosnippets, which execute automatically if their trigger is input. In this case, the snippet engine is simply watching user input in the same way as the completion engine, but acts automatically instead of presenting the user with options.

First, though, we need a snippet engine! I use LuaSnip because it has pretty good documentation (especially third party), does exactly what we need, and is (say it with me) written in Lua. We install it in lazy.lua:

{
    'L3MON4D3/LuaSnip'
},

For this plugin, we're going to use a modular configuration that discriminates by filetype. This is really nice for avoiding bloat and keeping a tight, well defined set of just the snippets you need. The setup will look something like this:

core/
  |― lazy.lua
  |― luasnip.lua
  |― luasnip/
        |― all.lua
        |― rs.lua
        |― tex.lua
        |― html.lua

To start we just need luasnip.lua, the luasnip/ directory, and the all.lua snippets file. Configuration for LuaSnip is a bit different, in that most of our config requires the API as a local variable ls that we call functions from. Besides that, we add the usual preamble and set some uptions in luasnip.lua:

local ls = require("luasnip")

ls.config.set_config({
    enable_autosnippets = true
})

We also define the keymaps for interacting with snippets:

-- tab if we can activate or jump around a snippet, but normal tab behaviour otherwise
vim.keymap.set({"i", "s"}, "", function()
  if ls.expand_or_jumpable() then
    ls.expand_or_jump()
  else vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "n", false)
  end
end, {silent = true})

-- shift tab to navigate backwards
vim.keymap.set({"i", "s"}, "", function() ls.jump(-1) end, {silent = true})

This is a bit interesting, as there are a few different setups we could use depending on whether or not we want to use the same key for expansion (activating a snippet) and jumping (navigating cursor positions within snippets). I like to use tab for both expansion and jumping, as this means that snippet expansion takes priority over cmp. This is why we check if there is a snippet that can either be activated (expanded) or navigated within (jumped) with the expand_or_jumpable() check. If it returns true, then we enact it with expand_or_jump (expanding is prioritised, so that snippets can be activated inside of other snippets). The else check ensures that if there is no snippet interaction available, then tab should just add whitespace as normal (that line is courtesy of u/MunifTanjim).

As an example, say that both myself and the rust_analyzer LSP have defined a snippet for fn that inserts a template for a function definition. The keystrokes for activating my snippet would be fn<Tab>, while the LSP snippet would be fn<C-j><Tab>. It's not a huge difference (which is a good thing) but it's still easier to use something that I have defined than what the LSP provides, which addresses the issue I had with third party completion not behaving in the way I expected or wanted it to.

This isn't really the expected or intuitive way of setting this up, though. What would be more usual is to let cmp handle all of the snippet activation, and just define jumping within snippets in the LuaSnip config. This might look something like this:

-- luasnip.lua

-- tab to jump forward
vim.keymap.set({"i", "s"}, "<Tab>", function() ls.jump(1) end, {silent = true})
-- shift tab to jump backwards
vim.keymap.set({"i", "s"}, "<S-Tab>", function() ls.jump(-1) end, {silent = true})

-- cmp.lua

-- tab completes selected suggestion
['<Tab>'] = cmp.mapping.confirm {
  behavior = cmp.ConfirmBehavior.Replace,
  select = true, -- change to true for single-press completion
},

This would mean that all user-defined snippets are treated as a cmp source and just show up on the completion menu when their trigger is typed. It's worth spending some time thinking about which keybinds and behaviours make sense to you individually. (Regardless of which is used, autosnippets will always execute automatically if they're triggered).

We can then tell LuaSnip where to look for snippets by calling a load function in luasnip.lua:

require('luasnip.loaders.from_lua').lazy_load({paths = '~/.config/nvim/lua/core/luasnip/'})

I've used .lazy_load, which will only load files as needed by filetype. This is how we get filetype-filtered snippets. The other option is .load, which will just load all the snippet files regardless of filetype.

To test our setup we can define some simple snippets and try them out in suffixed files. To define a snippet in one of the luasnip/ files, we need to import the luasnip API and some functions from it. The most basic functions are s and t for snippet creation and text:

-- all.lua
local ls = require('luasnip')

local s = ls.snippet -- make a snippet
local t = ls.text_node -- write text

I'm planning on writing another entry to explain snippet syntax where I'll try to fully understand and document how they work, but for now we'll just make the most basic snippet possible:

-- all.lua
return {
    s("hi", t("Hello world!")),
}

With the return statement we can see that we're just passing a lua table of snippets to luasnip when it calls the file. Each snippet is brought into scope with s() which is a function that we've passed two arguments. The first, "hi" is the trigger for the snippet; what the user has to type for it to expand. This argument can be replaced with a table of options, but for a snippet this simple we can just pass a string. The second argument is another function, t() which defines a "text node" – when by itself this just defines what the snippet should expand to. We pass a string to this function, "Hello world!", so when we put it all together we have a snippet that is triggered on hi and expands to Hello world!

Trigger:        Input:        Result:
hi              <Tab>         Hello world!

These are a bit tricky to demonstrate without using it yourself or seeing a video, so this will have to do I'm afraid.

We can also define our filetype-specific snippets, for example in html.lua:

-- html.lua
local ls = require('luasnip')

local s = ls.snippet
local t = ls.text_node

return {

    s("ht", t("a html file")),

    s("hi", t("Hello html!"))

} 

These will only be loaded by luasnip for files with the correct extension, .html in this case (they're also loaded on buffer instantiation, so this will still work properly if you navigate to a different filetype within neovim!)

Both of these snippets are the same as the previous example, just with different text. Because of the filetype filtering, ht will only prompt a snippet expansion in html files. We've also overridden the hi snippet from all.lua. In a html file both of these will be shown as options, but the filetype specific snippet will be given higher priority by default.

That's pretty much it as far as basic snippet setup, but they can do a lot more than just expand text! I'll link to the snippets page here when I get around to writing it. I was going to use snippets for autopairing, but the behaviour I want was just too complex for them so I've reverted to using dedicated plugins for them, which we'll do now!

autopairs

oil

closing remarks

This took a lot of time to do but I think it was very much worth it. I have a much better understanding of neovim, lua, and my configuration, and have far more confidence to tweak my dotfiles as I see fit. Getting things like autopairing / tabout working properly was also really interesting, and it's satisfying to have my entire development pipeline work in a way that I find intuitive.

If there happens to be anyone following this for their own config, please note that writing this entry was not a smooth or continuous process. I was troubleshooting as I went and jumping back and forth between different sections to make sure all of the plugins worked nicely together. I'm certain there will be things I've missed or gotten wrong or forgotten to update after tweaking my files. For the most accurate and up-to-date version of this config, see my current nvim dotfiles directly on GitHub.

I'm also not planning on updating this page as I continue to modify my config; this was just a from-nothing to functional IDE with all my preferences as a way of documenting the process and forcing myself to understand the things I was doing.

Thank you for reading!