Snippet collections for Neovim's built-in snippet engine — the loading, not a second engine.
Why zsnip? | Quick start | Completion | API | Integrations
Neovim ships vim.snippet: it expands an LSP snippet body, runs the session,
and moves between tabstops. What it does not ship is everything around that —
finding the snippet packages on your runtimepath, reading the two formats they
come in, deciding which ones a filetype gets, and handing them to a completion
menu.
The established options answer that by bringing their own engine along:
LuaSnip,
vim-vsnip,
nvim-snippy and
mini.snippets each re-implement
expansion and session management. zsnip does not. It loads snippets and hands
the body to vim.snippet.expand(), so the engine running your snippets is the
one Neovim maintains.
Two projects already do that. Here is the honest comparison:
| Engine | VSCode packs | snipmate | Pack discovery | |
|---|---|---|---|---|
| nvim-snippets | vim.snippet |
yes | no | directories you list; friendly-snippets special-cased by directory name |
blink.cmp's built-in snippets source |
vim.snippet |
yes | no | runtimepath + configured paths, but only ever for blink |
| zsnip | vim.snippet |
yes | yes | every package.json and snippets/*.snippets on the runtimepath, re-checked per lookup |
nvim-snippets is the closest prior art and covers the VSCode-only case, but it
has had no commit since July 2024, reads no snipmate files at all, and builds
its pack list once during setup() from directories you maintain — so a plugin
that ships snippets and joins the runtimepath when its filetype opens is never
seen. blink's source is good and well maintained, but it is part of a
completion engine: it serves blink and nothing else.
What that leaves zsnip to do, it does completely:
-
Both formats. VSCode packages with a
package.jsonmanifest (rafamadriz/friendly-snippets and anything shaped like it) and snipmate.snippetsfiles, including theirextendslines. -
The variables Neovim doesn't resolve. Core knows the
TM_*set and turns every other variable into a tabstop holding its own name — which is why an unpatchedcopyrightsnippet inserts a literalCURRENT_YEAR. zsnip resolves the date, workspace, comment-marker, clipboard,UUIDandRANDOMfamilies before the body reaches the engine — in all four spellings,$VAR,${VAR},${VAR:default}and${VAR/regex/format/}— and escapes what it substitutes, so a clipboard holding$1or50%lands as text instead of turning into a tabstop or being eaten as a pattern. -
Bodies core cannot take. ~4% of friendly-snippets' bodies fail the LSP snippet grammar, and others parse but hit an assert inside
vim.snippet.expand()— a second$0, or two placeholders that disagree about a tabstop. Either way it raises after the completion engine has deleted the word you typed, so it takes the word with it. zsnip drops them on load instead. -
${0:text}. Core treats$0strictly as the exit point, so a placeholder sitting on it lands in the buffer unreachable — and a body whose only tabstop is$0gets no session at all. zsnip renumbers it past the last real tabstop. -
Triggers that are not words.
<div,#!,console.log. Every source tells the menu to replace the whole non-blank run before the cursor, so a trigger that starts with a symbol is offered at all and lands over what you typed rather than after it. -
Whichever menu you use — including none. Four ways to serve the same snippets, so the choice of completion engine is not also a choice of snippet plugin:
What it is Needs zsnip.blinkA native blink.cmp source blink.cmp zsnip.cmpA native nvim-cmp source nvim-cmp zsnip.lspAn in-process LSP server — answers textDocument/completionwithout ever leaving the process, so any menu that speaks LSP picks it up with no gluenothing zsnip.completeA 'complete'function source, so snippets rank next to buffer words in Neovim's own menu and'autocomplete'drives themnothing The last two are the point: neither needs a completion plugin, and
zsnip.completedoes not even need an LSP client. It is also the only one that has to expand the accepted snippet itself — the other three hand anlsp.CompletionItemto something that already knows whatinsertTextFormat = Snippetmeans.
- Neovim 0.12.0+
- A snippet collection, e.g. friendly-snippets
-- vim.pack
vim.pack.add({
'https://github.com/zuqini/zsnip.nvim',
'https://github.com/rafamadriz/friendly-snippets',
})
-- lazy.nvim / zpack.nvim
{
'zuqini/zsnip.nvim',
dependencies = { 'rafamadriz/friendly-snippets' },
config = function()
require('zsnip').setup()
require('zsnip.loaders.from_vscode').lazy_load()
require('zsnip.loaders.from_snipmate').lazy_load()
-- Then one of the four ways to offer them; see Wiring, below.
require('zsnip').start_lsp_server()
end,
}Registering a loader is what gives zsnip anything to find, so a spec with no
config installs a plugin that does nothing. It still reads nothing at
startup — lazy_load() only records where to look.
require('zsnip').setup()
require('zsnip.loaders.from_vscode').lazy_load()
require('zsnip.loaders.from_snipmate').lazy_load()Then pick one way to offer them — see wiring.
Nothing is read at startup. A package is scanned and decoded the first time a filetype it covers is opened, and the runtimepath is re-checked on every lookup, so a plugin that loads on a filetype and brings its own snippets is picked up the moment it arrives.
Pick one of the four. Two at once offers every snippet twice.
blink.cmp
require('blink.cmp').setup({
snippets = { preset = 'default' },
sources = {
default = { 'lsp', 'path', 'zsnip', 'buffer' },
providers = { zsnip = { name = 'zsnip', module = 'zsnip.blink' } },
},
})nvim-cmp
require('zsnip.cmp').register()
require('cmp').setup({
snippet = { expand = function(args) vim.snippet.expand(args.body) end },
sources = { { name = 'nvim_lsp' }, { name = 'zsnip' } },
})Neovim's own completion, no plugin and no LSP client — a 'complete'
function source (needs Neovim 0.12):
require('zsnip.complete').enable()
vim.o.autocomplete = true -- optional; CTRL-N reaches it either way
vim.o.completeopt = 'menu,popup,noinsert'Snippets then rank alongside buffer words in one menu, and 'complete' can cap
each source separately. If you set the option yourself, take the entry from
require('zsnip.complete').source() and pass complete = false so enable()
installs the CompleteDone handler without appending a second copy:
vim.o.complete = ".^5,w," .. require('zsnip.complete').source() .. '^10'
require('zsnip.complete').enable({ complete = false })Anything else — an in-process LSP server, which every LSP-speaking menu already knows how to consume:
-- Only needed for autotrigger: vim.lsp.completion asks unprompted on the
-- characters a server names, and a trigger can begin with any of them --
-- `2x1table` and `#!` are real friendly-snippets triggers -- so name every
-- printable one. blink and nvim-cmp ask on their own cadence and need none of
-- this, and neither does <C-x><C-o>.
local triggers = {}
for byte = 33, 126 do
triggers[#triggers + 1] = string.char(byte)
end
require('zsnip').start_lsp_server({
trigger_characters = triggers,
-- Wires each buffer up for vim.lsp.completion, for zsnip's client only.
-- Without it the items arrive but nothing expands them: an accepted
-- snippet would put a literal `${1:mod}` in the buffer.
completion = { autotrigger = true },
})See docs/integrations.md for the trade-offs between
these, and for building your own source with completion_items().
require('zsnip').add_snippets('lua', {
{ prefix = 'req', body = "local ${1:mod} = require '$1'", description = 'require' },
{ prefix = 'stamp', body = function() return os.date('%Y-%m-%d') end },
})
-- Available everywhere:
require('zsnip').add_snippets('all', {
{ prefix = 'todo', body = '$LINE_COMMENT TODO ($CURRENT_YEAR-$CURRENT_MONTH-$CURRENT_DATE): $0' },
})A function body is called each time the snippet is used, which covers a body
that depends on the buffer or the moment. It cannot react to what you type
into a tabstop — vim.snippet owns the session and offers no hook for that,
so LuaSnip's dynamic nodes have no equivalent here.
require('zsnip').setup({
extend = { typescriptreact = { 'typescript', 'javascript' } },
})
-- or later
require('zsnip').filetype_extend('svelte', { 'html' })Every filetype also inherits the all bucket, and snipmate's extends lines
are honoured as written.
zsnip installs none — every completion engine already has an opinion about
<Tab>. Bind what you want:
vim.keymap.set({ 'i', 's' }, '<C-k>', function() require('zsnip').expand_or_jump() end)
vim.keymap.set({ 'i', 's' }, '<C-j>', function() require('zsnip').jump(-1) end)require('zsnip').setup({
extend = {}, -- filetype -> filetypes it inherits from
global_filetype = 'all', -- bucket every filetype inherits; false to disable
max_items = 100, -- default cap for completion_items() and for
-- zsnip.complete; blink/cmp/lsp ask for an
-- uncapped list and let their engine filter it
documentation = true, -- attach the body and description to items
command = true, -- create :ZSnip
})An unknown key, or a known one of the wrong type, is reported with
vim.notify and otherwise ignored — the rest of the config still applies.
Both loaders also take paths, include and exclude; a VSCode paths entry
may be a package with a package.json or a directory of loose snippet files,
so ~/.config/Code/User/snippets works as-is — comments and trailing commas
included, which VSCode's own files are full of.
| Command | What it does |
|---|---|
:ZSnip |
Pick a snippet for the current filetype and expand it |
:ZSnip list |
Show every snippet the current filetype has, and where each came from |
:ZSnip reload |
Forget everything read from disk and rescan |
:checkhealth zsnip reports both versions, the registered loaders and the
paths each was given, how much was actually found, how many bodies were dropped
for being ones vim.snippet.expand() will not take, and whether anything is
serving them — a snippet that was found still needs one of the four wirings
above to reach a menu. It can see two of them (the LSP server and
zsnip.complete) and warns if both are on at once.
The loader and registry API is deliberately shaped like LuaSnip's, so most configs move over by renaming the module:
| LuaSnip | zsnip |
|---|---|
require('luasnip.loaders.from_vscode').lazy_load() |
require('zsnip.loaders.from_vscode').lazy_load() |
require('luasnip.loaders.from_snipmate').lazy_load() |
require('zsnip.loaders.from_snipmate').lazy_load() |
ls.add_snippets(ft, ...) |
zsnip.add_snippets(ft, ...) |
ls.filetype_extend(ft, ...) |
zsnip.filetype_extend(ft, ...) |
ls.expand_or_jump() / ls.jumpable() |
zsnip.expand_or_jump() / zsnip.jumpable() |
snippet node DSL (s, i, t, f, c, d) |
not supported — bodies are LSP snippet syntax, or a function returning it |
MIT