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 CHANGEME
s (<c-v>ej
) and enter Insert Mode (c
). When pressing <esc>
to go back to Normal Mode, the CHANGEME
s 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?