TL;DR

LuaSnip is fast and doesn’t have to be complicated. Give it a try!

Even if that article shows how LuaSnip shines, I have great respect for the work that has gone into UltiSnips. It is still a reliable, reasonably fast plugin given the constraint it operates in (in particular, Vim compatibility requires a fair amount of Vimscript). I’ve written this article shortly after trying LuaSnip and I’m still very much evaluating it.

Introduction

Snippets are a convenient feature of some text editors to insert and adapt reusable pieces of code. For instance, snippets for for loops are common, to get the tedious bits of the syntax out of the way.

To get this feature in Vim back in the days, I started using UltiSnips. There are default snippets sets for it, and it’s easy to write custom snippets. These custom snippet can call Bash or Python scripts if you need more dynamic replacements. UltiSnips has been very powerful and has served me quite well over the past decade or so, and I have kept it when I migrated to NeoVim a few years ago.

Startup time

Every once in a while though, I run the excellent vim-startuptime command to assess the impact of various configurations and plugins on the startup time of Neovim. With UltiSnips and the corresponding completion plugins in my configuration, the first few lines are:

Extra options: []
Measured: 10 times

Total Average: 104.218300 msec
Total Max:     109.719000 msec
Total Min:     99.533000 msec

  AVERAGE       MAX       MIN
------------------------------
64.218600 70.419000 61.066000: ~/.config/nvim/init.lua
 6.255900  7.238000  5.764000: loading packages
 3.939000  5.338000  3.533000: ~/.local/share/nvim/site/pack/paqs/start/onedark.nvim/colors/onedark.lua
 3.651100  4.003000  3.381000: loading rtp plugins
 2.761600  3.957000  2.474000: expanding arguments
 2.683900  3.035000  2.566000: reading ShaDa
 2.418700  3.610000  2.131000: sourcing vimrc file(s)
 2.389600  2.751000  2.228000: /usr/share/nvim/runtime/filetype.lua
 1.954900  2.293000  1.702000: loading after plugins
 1.842500  2.015000  1.763000: BufEnter autocommands
 1.583350  3.532000  0.018000: ~/.local/share/nvim/site/pack/paqs/start/ultisnips/plugin/UltiSnips.vim
 1.027700  1.170000  0.831000: ~/.local/share/nvim/site/pack/paqs/start/nvim-treesitter/plugin/nvim-treesitter.lua
 0.968200  1.071000  0.860000: ~/.local/share/nvim/site/pack/paqs/start/cmp-nvim-ultisnips/after/plugin/cmp_nvim_ultisnips.lua
 0.859000  1.014000  0.702000: ~/.local/share/nvim/site/pack/paqs/start/nvim-treesitter-textobjects/plugin/nvim-treesitter-textobjects.vim
 0.590700  0.674000  0.524000: ~/.local/share/nvim/site/pack/paqs/start/indent-blankline.nvim/plugin/indent_blankline.vim
 0.557200  0.817000  0.493000: init highlight
 0.553200  0.898000  0.322000: opening buffers

The init.lua line covers a lot of different plugins and mappings set up in that file. Besides the onedark.nvim colorscheme1, the next biggest contributor is ~/…/ultisnips/plugin/UltiSnips.vim. The script to integrate UltiSnips with cmp, ~/…/cmp-nvim-ultisnips/after/plugin/cmp_nvim_ultisnips.lua, is not far behind. In total, we spend nearly 4 ms of the startup time for UltiSnips-related files, on top of the setup done in init.lua. That feels suboptimal, so I’ve been looking for a possible snippet plugin alternative.

Installing LuaSnip

LuaSnip aims to be a faster2 snippet engine, with support of treesitter in the snippets. It can also understand the LSP snippet “format”. Finally, it’s a pure Lua plugin, without any Python requirement, contrary to UltiSnips. That makes installation on various system much easier.

Its drawbacks for me are that it does not support the same snippet definition as UltiSnips and even in its own SnipMate-like syntax, backticks to execute code are not supported. As a result, I’ll have to migrate my (admittedly very small) snippet collection. Conversely, if I want to do more advanced things, I’ll have to learn the relatively complex “VS Studio Code” snippets in JSON or even the pure Lua snippets.

That’s said, I believe I can get the LuaSnip benefits without writing Lua snippets or JSON ones, at least to start. So let’s try and do that, using only the SnipMate-like syntax!

Overview of the Configuration Changes

I’ve made the following changes.

  1. Remove UltiSnips and its associated plugins, namely for completion with cmp and the telescope integration.
  2. Remove the UltiSnips mappings.
  3. Add LuaSnip (following the instructions in the Readme), its cmp integration, a set of snippets and a telescope integration along with some mappings (requires nvim 0.7+3):
    vim.keymap.set({ "i", "s" }, "<C-i>", function() require'luasnip'.jump(1) end, { desc = "LuaSnip forward jump" })
    vim.keymap.se({ "i", "s" }, "<M-i>", function() require'luasnip'.jump(-1) end, { desc = "LuaSnip backward jump" })
    
  4. Migrate my existing snippets, see below.
  5. Add code to load the snippet set and my own snippet collection:
    require("luasnip.loaders.from_vscode").lazy_load()
    require("luasnip.loaders.from_snipmate").lazy_load({ paths = {"./snippets"} })
    

After

Let’s run vim-startuptime again. Here are the new top contributors:

Extra options: []
Measured: 10 times

Total Average: 98.251400 msec
Total Max:     100.159000 msec
Total Min:     96.519000 msec

  AVERAGE       MAX       MIN
------------------------------
60.469100 62.341000 59.619000: ~/.config/nvim/init.lua
 5.991100  6.219000  5.865000: loading packages
 4.088400  4.162000  3.942000: loading after plugins
 3.635700  3.752000  3.415000: loading rtp plugins
 3.561800  3.936000  3.220000: ~/.local/share/nvim/site/pack/paqs/start/onedark.nvim/colors/onedark.lua
 2.550500  2.628000  2.492000: reading ShaDa
 2.454200  2.525000  2.401000: expanding arguments
 2.363000  2.420000  2.270000: /usr/share/nvim/runtime/filetype.lua
 2.271100  2.337000  2.146000: sourcing vimrc file(s)
 1.701000  1.767000  1.635000: BufEnter autocommands
 1.676700  2.049000  1.366000: opening buffers
 1.034100  1.280000  0.782000: ~/.local/share/nvim/site/pack/paqs/start/nvim-treesitter/plugin/nvim-treesitter.lua
 0.901000  1.021000  0.799000: ~/.local/share/nvim/site/pack/paqs/start/nvim-treesitter-textobjects/plugin/nvim-treesitter-textobjects.vim
 0.590700  0.674000  0.524000: ~/.local/share/nvim/site/pack/paqs/start/indent-blankline.nvim/plugin/indent_blankline.vim
 0.549500  0.789000  0.514000: init highlight

The snippet infrastructure is now absent from that list! More importantly, the overall startup time is down, and we can be confident that the new calls to load the snippets in init.lua are not costlier than UltiSnips settings before, because the init.lua line is down as well.

As a bonus, the snippet expansion feels slightly snappier with LuaSnip, although it might be an illusion and I don’t have hard numbers to back this claim.

Migrate my Snippet Collection

Back to the topic of migrating existing UltiSnips snippets: LuaSnip will loudly complain when given UltiSnips snippets but it’s relatively easy to rewrite those snippets to the SnipMate-like format that LuaSnip understands.

Two Syntaxes

On the one hand, UltiSnips snippets roughly follow this syntax

snippet trigger "Comment" option
snippet content
endsnippet

And so, my markdown snippets would look like this:

priority 10

snippet bjtoday "“Bullet Journal”-styled date for today" b
# `date +'%F %A'`
endsnippet

On the other hand, LuaSnip uses a simplified version of SnipMate snippets:

# Comment
snippet toggle
  snippet content

Simple Snippets

Since I heavily rely on snippet sets, I have only about 30 snippets defined in my own snippet collection. Most of them are really simple, with only a few lines and almost no interactive text. So for most of those, the process has been to simply remove the priority … lines and then a simple substitution command4 did the trick.

Advanced Snippets With Environment Variables

However, there is also the case of the snippets calling external commands, like date in the example below:

snippet bjtoday "“Bullet Journal”-styled date for today" b
# `date +'%F %A'`
endsnippet

The problem is, to call arbitrary commands, one needs to define the snippets in Lua.

It turns out though that nearly all my snippets calling external command were actually inserting a date. And luckily LuaSnip defines “environment variables” holding just the values I need like $CURRENT_MONTH. So that snippet becomes:

# “Bullet Journal”-styled date for today
snippet bjtoday
  # ${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE} $CURRENT_DAY_NAME_SHORT

and we get to keep the simple SnipMate-like syntax!

You can find more of those environment variables in the sources.

What’s Next

I now have a slightly faster NeoVim and the snippet syntax that I use the most is simpler. However, there is more to explore!

So far, I’ve steered clear of writing more complicated JSON and Lua snippets. The latter would be necessary to unlock smart snippets using treesitter context. I’ll look into that next, in particular to generate go error handling code.

This is also a very simple configuration, the impact on startup time of LuaSnip might go up slightly as we use more advanced feature, but during my tests, even using all available features, it was much lighter than UltiSnips.

Resources


  1. That run is actually on a fork of onedark.nvim that contains a massive speed up. The changes of that fork are being up streamed in this PR↩︎

  2. According to that comment from the author for instance: https://github.com/L3MON4D3/LuaSnip/issues/60#issuecomment-873630664↩︎

  3. This code snippet uses new APIs introduced in Neovim 0.7 and the newly freed <C-i>, see https://gpanders.com/blog/whats-new-in-neovim-0-7/ for more details. ↩︎

  4. For instance: %s/^\(snippet \+\S\+\) "\(.*\)" \w\+\n\(\_.\{-}\)endsnippet/# \2^M\1^M \3^M↩︎