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:

  1. I read and understand a ticket/issue,
  2. I dive into an existing codebase, explore some documentation,
  3. When I figure out what to do and where to do it, I write the code,
  4. 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.

  1. Extract value from the regex,
  2. Use it to initialize an input node inside a snippet node returned by a dynamic node,
  3. 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 a string.
  • Dynamic nodes look like a function node but return a snippet 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.