Erik's Blog - Zero-Plugin Neovim Snippets in 42 Lines of Lua - 2024-06-14

The Premise

So, here’s what we’re going to do today; we’re going write some Lua and implement snippet handling in Neovim including a nice little popover to select them. In total the implementation will be 42 lines, not counting empty lines.

We’re going to build this like we would a mini-plugin. If we didn’t do this, we could arguably reduce the lines of code even more, but 42 seemed like a reasonable number.

The Skeletons in my dotfiles

First things first, in (Neo)vim there is a concept called skeleton files (:help skeleton); these are intended to be used as templates that are read in an auto-command (:help autocmd).

For example, if you wanted to put a Created by comment at the beginning of every Swift file, like XCode does by default, you could create a file $HOME/vim/skeleton.swift like this:

// $HOME/.dotfiles/vim/skeleton.swift

//
//  main.swift
//  cool product
//
//  Created by Erik on 14.06.24.
//

And then set up an auto-command in your .vimrc (or init.vim) to read it into each new swift file:

autocmd BufNewFile  *.swift 0read $HOME/vim/skeleton.swift

See also :help skeleton to learn how to automatically set the current date in the comment.

This by itself is already useful in a variety of ways. If you wanted to spare yourself some typing effort in Java, you could have a skeleton.java that contains the typical class boilerplate.

// $HOME/.dotfiles/vim/skeletons/skeleton.java

package CHANGEME;

public class CHANGEME {
    public CHANGEME() {}
}

But we can take it one step further.

With this knowledge we can create a directory vim/skeletons - in my case it’s part of my dotfiles - to house all of our snippets. Here’s a simple one that I used for quite some time for C++ to avoid having to retype the include guards in header files:

// $HOME/.dotfiles/vim/skeletons/skeleton.hpp

#ifndef CHANGEME
#define CHANGEME

#endif

We can combine this with the admittedly over-engineered command:

nnoremap <leader>hpp :-1read $HOME/.dotfiles/vim/skeletons/skeleton.hpp<CR>2w<c-v>ejc

This will read the skeleton.hpp into the current buffer, move the cursor (2w), visually select the CHANGEMEs (<c-v>ej) and enter Insert Mode (c). When pressing <esc> to go back to Normal Mode, the CHANGEMEs will have been replaced with the inserted text.

Or use it to save yourself some typing when handling errors in Go.

// $HOME/.dotfiles/vim/skeletons/error.go

if err != nil {
    return err
}

We could stop here and simply define key maps to access all the various snippets that we might create, maybe even in a file type plugin if we wanted the same key map for different snippets in multiple languages (e.g., to insert a test function). But we can do better still.

Pick the Right Skeleton for the Occasion

Wouldn’t it be nice to not have to define key maps except maybe for our most-used snippets and still have access to all of them at the press of a button?

Let’s write some Lua! First we define our module M that will be returned when we call require'skeleton_picker'. We could also simply throw all of this into a file that’s sourced from our init.lua, so this is really not necessary, but a nice opportunity to introduce a common pattern in Lua plugins.

-- lua/skeleton_picker.lua

local M = {}
    -- the rest of our code goes here
return M

Next, we will need to know where to find the snippets (unless we want to hard-code it, which - again - is a perfectly valid option for your own config files), so we write a setup method for our “plugin”.

function M.setup(opts)
    M.path = opts.path
end

Finally, we need two more functions; one to show the snippet names on the popover and a callback function pick to read the content of the snippet into the current buffer.

local function pick()
    -- get the selected filename
    local line = vim.api.nvim_get_current_line()
    -- read the content of the skeleton file
    local path = M.path .. "/" .. line
    local lines = vim.fn.readfile(path)
    -- add an empty line at the end for visual separation
    table.insert(lines, #lines + 1, "")
    -- close the popover
    vim.api.nvim_win_close(0, false)
    -- insert read lines at current cursor position in current buffer
    local current = vim.api.nvim_win_get_cursor(0)
    vim.api.nvim_buf_set_text(0, current[1] - 1, 0, current[1] - 1, 0, lines)
end

function M.show()
    -- read all file names in the skeleton directory and filter them by the
    -- file type of the current buffer
    local lines = vim.fn.systemlist({ "ls", M.path })
    local filtered = {}
    for _, v in ipairs(lines) do
        if string.find(v, vim.bo.filetype) then
            table.insert(filtered, v)
        end
    end

    -- create a new unlisted scratch buffer
    local buf = vim.api.nvim_create_buf(false, true)
    -- read UI attributes
    local ui = vim.api.nvim_list_uis()[1]

    -- open a floating window with the scratch buffer and turn off most features
    -- like a statusline or autocmds
    vim.api.nvim_open_win(buf, true, {
        relative = 'editor',
        width = 50,
        height = 10,
        col = ui.width / 2 - 25,
        row = ui.height / 2 - 5,
        anchor = 'NW',
        style = 'minimal',
        border = 'rounded',
        noautocmd = true,
    })

    -- register pick function to Enter key or leave with Escape
    vim.api.nvim_buf_create_user_command(0, "Pick", pick, {})
    local opts = { silent = true, nowait = true, noremap = true }
    vim.api.nvim_buf_set_keymap(buf, 'n', "<Enter>", ':Pick<cr>', opts)
    vim.api.nvim_buf_set_keymap(buf, 'n', "<esc>", ':close<cr>', opts)

    -- set the list of file names into the scratch buffer
    vim.api.nvim_buf_set_text(buf, 0, 0, 0, 0, filtered)
    vim.bo.modifiable = false
end

And there we go, a nice little popover to select from our list of snippets (filtered by file type). Now, we just need to set this function on a key map in our init.lua, and we’ll always have it at our finger tips:

-- init.lua

require("skeleton_picker").setup({ path = vim.fn.expand("$HOME/.dotfiles/vim/skeletons") })
vim.keymap.set('n', '<leader>ss', require("skeleton_picker").show, {})

Final Thoughts

One could take this another step further and introduce a preview window that shows the contents of the file under the cursor; at that point, we might as well write a Telescope extension to round it all off. Maybe a project for another day.

Don’t get me wrong, plugins like LuaSnip and other snippet managers can do a lot of cool things, but expanding what are essentially abbreviations in insert mode always seemed like a stop-gap solution that non-modal editors needed, because there is only insert mode. Using key maps in normal mode has a similar level of arcane-ness, but at least separates the insertion of a snippet conceptually from producing text in insert mode. With our little piece of Lua, we take this down to a single key map (or however many one feels comfortable with).

And, of course, it’s always nice to know what your editor can do for you out of the box without needing a plugin, isn’t it?