It’s snippets time!
Table of content
I write code for a living. But, do I really? I mean, sure, I write code, but that’s not what I spend most of my time doing. No, the real workflow looks more like this:
- I read and understand a ticket/issue,
- I dive into an existing codebase, explore some documentation,
- When I figure out what to do and where to do it, I write the code,
- Once done with this, I add some tests, commit, push, create a pull request and call it a day.
The experienced programmer knows: only the third point implies writing some actual code. The rest of the list is meetings, readings, and boilerplate. The boilerplate, the code that needs to be written but does not have inherent value. We can actually automate most of the effort using pre-built snippets of code.
Let’s see how to do this in neovim
with the luasnip
plugin. I’ll cover the plugins installation and configuration process and then build some real-life snippets to showcase some advanced features we can rely on.
Installation
Vanilla neovim
is not snippets-aware. It relies on plugins to bring new features into the game. And, to be fair, this snippets feature is not straightforward to setup. I really hope this post will come in handy for newcomers as I strongly believe snippets are a blessing for programmers.
Plugins manager
In order to make use of snippets, we’ll need a snippet engine (duh) to turn snippets into something usable, and a completion engine, to hook into neovim
’s API and provide a way to interact with the said snippet engine. I can already count two plugins but there will be dependencies.
Making all this work together is a job for a plugin manager. I’ll pick lazy
for this post as it’s the one I personally use. I copy-paste the following piece of code from lazy
’s repository into ~/.config/snippets/init.lua
.
local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
if not vim.loop.fs_stat(lazypath) then
vim.fn.system({
"git",
"clone",
"--filter=blob:none",
"https://github.com/folke/lazy.nvim.git",
"--branch=stable",
lazypath,
})
end
vim.opt.rtp:prepend(lazypath)
By default, neovim
loads its configuration from ~/.config/nvim/init.lua
but I wanted a clean slate for this post in order to build a working configuration from scratch. To make use of this without editing your existing configuration files, you can run NVIM_APPNAME=snippets nvim
or export NVIM_APPNAME=snippets
and then neovim
all you want. Remember to unset NVIM_APPNAME
to fall back to your default settings at the end.
Anyway, back to the code. The previous piece installs lazy
, the plugins manager, that’s it. Now, some plugins.
The engines
The de-facto standard for neovim
when it comes to code completion is nvim-cmp
. This plugin does not do much on its own but serves as a base to build complex features on. It explicitly requires a snippet engine in it’s configuration and I’ll give it luasnip
. Along the way, I’ll also add cmp_luasnip
, the missing piece allowing the two to work together.
This code goes below the lazy
installation code in the ~/.config/snippets/init.lua
file:
require("lazy").setup({
{
"hrsh7th/nvim-cmp",
dependencies = {
{ "L3MON4D3/LuaSnip", },
{ "saadparwaiz1/cmp_luasnip" },
},
},
})
If all goes according to plan, starting neovim
(while targeting the snippets
configuration with NVIM_APPNAME
) will trigger lazy
which will install the three other plugins.
Configuration
Completion engine
As said before, cmp
need to be fed with a snippet engine. And a pinch of other settings on the side to confirm we want to use luasnip
as a completion source.
{
"hrsh7th/nvim-cmp",
config = function()
local cmp = require("cmp")
cmp.setup({
snippet = {
expand = function()
require('luasnip').lsp_expand(args.body)
end
},
sources = {
{ name = 'luasnip' }
}
})
end,
dependencies = {
{ "L3MON4D3/LuaSnip", },
{ "saadparwaiz1/cmp_luasnip" },
},
},
Awesome! The completion engine knows about the snippet engine and the helper plugins makes it so they can communicate. Still no snippets at the moment but we’re getting there, soon, pinky promise.
Snippet engine
{
"L3MON4D3/LuaSnip",
config = function()
local luasnip = require("luasnip")
local s = luasnip.snippet
local t = luasnip.text_node
local foo = s("bar", t("baz"))
luasnip.add_snippets("all", { foo })
end
},
All right, we’re there already! Our fist snippet, isn’t it beautiful? Let’s see what’s going on here.
The luasnip
plugin takes snippets
as its inputs. A snippet is created using the luasnip.snippet
function and then passed to a luasnip
-wide map via the luasnip.add_snippets
function which accepts a filetype as its first parameter and an array of snippets as its second parameter. A snippet is made of a trigger which, in its most simple form is a string (here bar
) and some nodes (here a text_node
containing only baz
). I’ll talk in much more details about nodes later.
So, here, luasnip.add_snippets("all", { foo })
instructs luasnip
to add a snippet which replace bar
by baz
. Go on, restart neovim
and see for yourself: if you start write b
of ba
or bar
, you’ll see bar~ Snippet
. You can then press C-n
which is the built-in next (or C-p
for previous) when it comes to completion in neovim
. It selects the snippet, shows you what it’ll turn into and… That’s all. Nope, you can’t use the snippet yet (or at least I don’t know the default key binding for this).
Snippet expansion
To instruct luasnip
to expand a snippet there is the luasnip.expand()
function. Calling a function by hand each time we need a snippet is a no-go so we need a keymap. Also, snippets are meant to be interactive and explorable back and forth. Using the same bindings to expand and move forward makes perfect sense IMHO so I’ll go right for the all-in-one solution.
vim.keymap.set({ "i", "s" }, "<C-l>", function()
if luasnip.expand_or_jumpable() then
luasnip.expand_or_jump()
end
end, { desc = "Snippet next argument", silent = true })
Whether I’m in insert-mode
or select-mode
, pressing C-l
will check if a snippet is expandable or if I can jump to the next point of interest in an already expanded one. If possible, simply do it. This configuration block goes into the luasnip
config
function.
And, as said before, jumping back may also be desirable as some point. Here is the corresponding piece of configuration that mirrors the previous one.
vim.keymap.set({ "i", "s" }, "<C-h>", function()
if luasnip.jumpable(-1) then
luasnip.jump(-1)
end
end, { desc = "Snippet previous argument", silent = true })
There are a lot of neovim
users out there (I assure you, at least a dozen into the wild). And we all have our preferences when it comes to bindings.
I like to use C-l
to move forward and C-h
to move backward into a snippet. I mentally map them to next and previous as l
and h
are the horizontal one-displacement keys.
Any binding I’ll show in this post is my preference but it does not mean it’s the right choice for you. If you’re setting up luasnip
by reading those lines, please, do yourself a favor and pick keys that make sense to you!
Where are we at? Ho, yeah, press b
which shows bar~ Snippet
, press C-n
to select it and the press C-l
to expand the snippet. That’s a mere four or five key-strokes to write a three letters word. Do you feel the snippet’s power yet? No, me neither.
Completion bonus
There is this little helper I also like, straight from cmp
. You can add the following piece of settings along with the snippet
and sources
entry. It makes cmp
use the first item of the list without actually having to select it first (thanks to select = true
).
mapping = cmp.mapping.preset.insert({
["C-l"] = cmp.mapping.confirm({ select = true }),
}),
So, b
then C-l
to enter baz
. We’re even on the keystrokes.
Snippets
I don’t know about you but this foo
snippet turning bar
into baz
disappoints me. I think it’s time to create a more useful, more important snippet.
Insert nodes
And as we’re all advanced programmers here, we’ll of course pick the infamous Hello world example! While we’re at it, let’s not settle for the world as sky is not the actual limit here.
local s = luasnip.snippet
local t = luasnip.text_node
local i = luasnip.insert_node
local hello = s("hello", {
t("Hello "),
i(1, "world"),
})
See what happened here? The second parameter when constructing the snippet is now an array and its second element is an insert node with 1
as its index. Wow, crazy stuff we have there. This means, on expansion, luasnip
will write Hello world
and place the cursor at the beginning of the selected word world
. Here, two options: press C-l
again to move to the implicit 0
index at the end of the snippet or replace world
by simply typing other letters.
Autosnippet
Say, you’re a very polite person. Never in a bad mood, you always say Hello to everyone you write to. Why would bother typing all those letters? I have some good news for you, it’s possible to automagically trigger some snippets.
luasnip.config.set_config {
enable_autosnippets = true,
}
local hi = s(
{
trig = "hi",
snippetType = "autosnippet"
}, {
t("Hello "),
i(1, "world"),
})
Bam, two letters, nothing else to do, you’re the most polite person in writing, you get a raise, thanks to some snippets. Isn’t life sweet when automated?
Snippets in code
Up until now, the snippets were added to the "all"
category. This will quickly become a mess as no sane person would want some javascript
code popping into its python
script. Just kidding, no sane person would ever use any of these languages, but we all need to eat at the end of the day right? Anyway, let’s stay cordial to one another but in multiple programming languages.
luasnip.add_snippets("javascript", { s("hello", { t("console.log(\"Hello "), i(1, "world"), t("\")") }) })
luasnip.add_snippets("python", { s("hello", { t("print(\"Hello "), i(1, "world"), t("\")") }) })
Now, if you’re writing in a plain text format, hello
may turn into Hello world
. But if and only if you’re writing some python
script will neovim
ask you if you want to turn hello
into print("Hello world")
and never into console.log("Hello world")
. Note that both the code and the plain text options are available but the first is the language specific and the second is the plain text alternative. It’s possible to change the order by specifying a priority
as in s({trig = "...", priority = 100}, {...})
, 1000
being the default priority.
Hot reload
I’m kind of tired to edit the ~/.config/snippets/init.lua
file, save, close and reopen neovim
to see if my snippets code works as intended. Also, it’s nice to be able to split the snippets between languages but that’s still a mess because it mixes neovim
configuration luasnip
configuration.
It’s time to hit two birds with one stone with this awesome solution: require("luasnip.loaders.from_lua").lazy_load()
inside luasnip
configuration block. What does it do? Well, by default, it starts looking in the ~/.config/snippets/luasnippets
directory for files named following a filetype.lua
pattern. It monitors any changes in those files and reload snippets on the fly. So, foo
and hello
can go to ~/.config/snippets/luasnippets/all.lua
while language specific niceties go to ~/.config/snippets/luasnippets/python.lua
and ~/.config/snippets/luasnippets/javascript.lua
.
Here is the all.lua
file as an example:
local foo = s("bar", t("baz"))
local hello = s("hello", {
t("Hello "),
i(1, "world"),
})
return { foo, hello }
Notice something? Yeah, that’s right, all the abbreviations are implicitly defined by luasnip
for those monitored files. No more endless and error prone imports and redefinitions 🎉
Auto-autosnippet
Remember, a few paragraphs back, I introduced the snippetType = "autosnippet"
in the first argument of a snippet? Guess what? It works, but there is now a smarter way.
See this return {foo, hello}
at the end of the file? It’s a single array. But if you return not one but two arrays as in return { foo }, { hello }
, then hello
becomes an autosnippet.
Less code, great!
Real life example
I’ve written the most awesome feature today in python
. It’s obviously ready to ship in production right away as I’m not the kind of developer who write buggy botched code (you know, everybody else does but me). For some shady reason, my teammates insist on some testing so here I am, writing this code again and again.
def test_awesomness(self):
# Given
input_0 = "Hello"
input_1 = "world"
output = "Hello world"
# When
hello_world = my_feature(input0, input_1)
# Then
self.assertEqual(hello_world, output)
The structure is always the same:
- The test function starts with
test_
, - Some comments to split the test in logical blocks,
- The said blocks.
Hum, it looks like a job for… a snippet!
local test = s("test", {
t("def test_"),
i(1, "feature"),
t({ "(self):", " # Given", " " }),
i(2, "# Inputs go here"),
t({ "", " # When", " " }),
i(3, "# Computations go here"),
t({ "", " # Then", "" }),
i(4, "# Testing go here"),
})
Wait, what? What is this hell-sent horror? Can we really expect people to read, write on maintain that kind of snippet? No, I don’t think so. Luckily, there is the format
node can help here.
local test = s("test", fmt([[
def test_{}:
# Given
{}
# When
{}
# Then
{}
]], {
i(1, "feature"),
i(2, "Inputs go here"),
i(3, "Computation go here"),
i(4, "Testing go here")
}))
Ha, much clearer don’t you think? You guessed it, {}
acts as a placeholder. And it’s replaced by the nodes in the array passed as a second argument. It’s even possible to name them as I’ll show later when the code gets hairy.
In a format
node, {}
is a placeholder. If you actually need {}
in the snippet, double the curls: {{}}
.
Enhancement
That’s nice, we have a recurrent test_
structure. But we can do better. For example, most of the actual testing implies checking for equality or truthfulness.
I’ll introduce the choice
node in a bit. But first, let’s add one of the last piece of configuration code. We can go forward and backward with l
and h
respectively, it’s time to be able to pick between multiple options.
vim.keymap.set("i", "<C-j>", function()
if luasnip.choice_active() then
luasnip.change_choice(1)
end
end, { desc = "Snippet next choice", silent = true })
vim.keymap.set("i", "<C-k>", function()
if luasnip.choice_active() then
luasnip.change_choice(-1)
end
end, { desc = "Snippet previous choice", silent = true })
One again, in my mind, <C-j>
and <C-k>
acts as some vertical selector. You might want to pick other bindings to better fit your mental representation of what’s going on under the hood.
local test = s("test", fmt([[
def test_{}:
# Given
{}
# When
{}
# Then
{}
]], {
i(1, "feature"),
i(2, "Inputs go here"),
i(3, "Computation go here"),
c(4, {
{ t("self.assertEqual("), i(1), t(", "), i(2), t(")") },
{ t("self.assertTrue("), i(1), t(")") },
i(1, "Testing goes here")
})
}))
Okay, this is getting good. The fourth node is more than a simple input field. You can use C-j
and C-k
to circle through the equality test, the truthfulness test or simply write whatever you need freely. Press C-l
again when inside the parenthesis and you’re taken out of the choice
node.
Time machine
I know some people, not me of course, other people, who mistype. You know, they plan on getting the next item in a choice node and confidently press C-l
instead of C-k
.
If, for example, they do it on this test
example, it happens at the end of the snippet because the choice
node is last. The cursor moves to the implicit index 0 and they lose their snippet state.
Fixing this is easy as adding history = true
in the luasnip.config.set_config
map.
This is a powerful feature because you can even start typing in another part of the file and C-h
will bring you back where you left the snippet without loosing the edits in-between.
More nodes
Okay, I’m not gonna lie, I don’t know how to use the real life example anymore for now. I’ll get back to it later but I fist need to introduce some more nodes.
Function
node
A function
node, this should be no surprise, takes 🥁 a function 🥁. And this function must return a string
.
Say, I want to insert the current date in my document. The date changes approximately every 24h so it needs to be computed on the fly.
return { s("date", f(function() return { os.date("%Y-%m-%d") } end)) }
Snap, this date format seldom makes sense for French people.
return {
s("date", f(function() return { os.date("%Y-%m-%d") } end)),
s("date", f(function() return { os.date("%d/%m/%Y") } end))
}
I now have two snippets triggered by date
. While possible, it’s not a pleasant sight. Why not be more explicit about it?
s({ trig = "date(%a%a)", regTrig = true }, f(
function(_, snip)
local language = snip.captures[1]:lower()
if language == "fr" then
return { os.date("%d/%m/%Y") }
else
return { os.date("%Y-%m-%d") }
end
end
))
Whoooo, regexp! I added regTrig = true
in the snippet’s first argument which turns the trigger into a regular expression. Now, datefr
gives me a French date format, everything else gives me the other format. Obviously, it’d be better to have a list of valid entries and some error handling but this is out of this post’s scope. Let’s settle for the French specific case and the English default.
Wait, I’ve another idea! I’m sure you noticed there was an unused argument. Maybe… Yes, maybe it can be of some use.
s("today", {
c(1, {
t("Today is "),
t("Aujourd'hui nous sommes le ")
}),
f(
function(args, _)
if args[1][1] == "Today is " then
return { os.date("%Y-%m-%d") }
else
return { os.date("%d/%m/%Y") }
end
end,
1
)
})
All right. No need for error handling as there can be no error. The date format is 100% determined by the content of the first node which can only take two hard-coded values.
The 1
at the end means args
will receive data from the node with index 1
. It can also be an array so that the function
node uses multiple nodes as it’s inputs.
Starting from now, you’ll need to add updateevents = "TextChanged,TextChangedI"
to the luasnip
configuration block. This is because the next examples update themselves as you time and luasnip
must be instructed to do so. This is not the default behavior.
This is the final configuration block for this already long post:
luasnip.config.set_config {
history = true,
updateevents = "TextChanged,TextChangedI",
enable_autosnippets = true,
}
s({ trig = "(%d+)maths", regTrig = true }, {
i(1, "0"),
t(" + "),
i(2, "0"),
t(" - "),
f(function(_, snip) return snip.captures[1] end),
t(" = "),
f(function(args, snip)
return tostring(
tonumber(args[1][1]) +
tonumber(args[2][1]) -
tonumber(snip.captures[1])
)
end, { 1, 2 })
})
Granted this is not useful on a daily basis. But it comes in handy as a transition to the next section: what if I mistyped the number at the beginning of my snippet? I said it before, I don’t mistype, but, say, someone else does. It’d be better if changing the number afterward was possible. Well, nope, shame, undo and learn how to type.
Dynamic
node
Just kidding. Of course it’s possible. Thanks to the dynamic
node.
s({ trig = "(%d*)add", regTrig = true }, {
i(1, "0"),
t(" + "),
d(2, function(_, snip)
return sn(1, { i(1, snip.captures[1]) })
end, 1),
t(" = "),
f(function(args, _)
return tostring(tonumber(args[1][1]) + tonumber(args[2][1]))
end, { 1, 2 })
})
What do we have here? We’ve seen that a function
node takes a function
that return a string
. The said function
has two parameters allowing us to access some sort of snippet environment like the value of other nodes or the captures of a regex. In this example, d
stands for dynamic
node. Just like a function
node it takes a function
with the same parameters. But contrary to the function
node, it returns a snippet
node, not a string
. In other words, a dynamic
node allows for on-the-fly created nodes. Do you feel the power? The dynamic
node returns a snippet
node containing a single input
node initialized using the capture from the trigger. And its very value is passed to a function
node that will then compute the addition.
- Extract value from the regex,
- Use it to initialize an
input
node inside asnippet
node returned by adynamic
node, - Pass the potentially updated value to a
function
node for computation.
Pause
Okay. This is getting way too long. And I wanna go to sleep. So I’ll wrap this up for now.
First, the full configuration I used for this post until now:
local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
if not vim.loop.fs_stat(lazypath) then
vim.fn.system({
"git",
"clone",
"--filter=blob:none",
"https://github.com/folke/lazy.nvim.git",
"--branch=stable",
lazypath,
})
end
vim.opt.rtp:prepend(lazypath)
require("lazy").setup({
{
"hrsh7th/nvim-cmp",
config = function()
local cmp = require("cmp")
cmp.setup({
snippet = {
expand = function(args)
require("luasnip").lsp_expand(args.body)
end
},
mapping = cmp.mapping.preset.insert({
["<C-l>"] = cmp.mapping.confirm({ select = true }),
}),
sources = cmp.config.sources({
{ name = "luasnip" },
}),
})
end,
dependencies = {
{
"L3MON4D3/LuaSnip",
config = function()
local luasnip = require("luasnip")
require("luasnip.loaders.from_lua").lazy_load()
luasnip.config.set_config {
history = true,
updateevents = "TextChanged,TextChangedI",
enable_autosnippets = true,
}
vim.keymap.set({ "i", "s" }, "<C-l>", function()
if luasnip.expand_or_jumpable() then
luasnip.expand_or_jump()
end
end, { desc = "Snippet next argument", silent = true })
vim.keymap.set({ "i", "s" }, "<C-h>", function()
if luasnip.jumpable(-1) then
luasnip.jump(-1)
end
end, { desc = "Snippet previous argument", silent = true })
vim.keymap.set("i", "<C-j>", function()
if luasnip.choice_active() then
luasnip.change_choice(1)
end
end, { desc = "Snippet next choice", silent = true })
vim.keymap.set("i", "<C-k>", function()
if luasnip.choice_active() then
luasnip.change_choice(-1)
end
end, { desc = "Snippet previous choice", silent = true })
end
},
{ "saadparwaiz1/cmp_luasnip" },
},
},
})
Agreed, it’s a mouthful. It does install a plugins manager, that installs a completion engine that runs on top of a snippet engine. All with sensible settings IMHO.
Second, the nodes.
Text
nodes insert hard-coded text,Insert
nodes take optional default value and can be edited,Choice
nodes take a list of possible nodes,Function
nodes, take a function with two parameters. The first is filled with values from other nodes while the second gets its values from the noode’s environment. It must return astring
.Dynamic
nodes look like afunction
node but return asnippet
node. They allow us to create complex nodes on the fly.
I use C-l
and C-h
to jump from one position to the other, And C-j
and C-k
to circle through choice
nodes.
This is not the conclusion of this post. I’ll come back to it and turn the real life example into an actually useful piece of code. I might even split this post in multiple sub-parts, I don’t know yet. If you stumble on this content and have an opinion on what’s best to do, let me know.