zsnip offers snippets four ways. Pick one — a native source for your engine, the LSP server, or Neovim's own completion. Running two offers every snippet twice.
| Use when | Trade-off | |
|---|---|---|
zsnip.blink |
You use blink.cmp | Snippets stay their own provider, so blink's score_offset, min_keyword_length and kind styling apply to them |
zsnip.cmp |
You use nvim-cmp | Same, for nvim-cmp's source config |
zsnip.complete |
You want no completion plugin at all | Nothing to install and nothing to start, and 'complete' caps each source separately — but zsnip has to match and expand for itself, so it is the one path with its own code on the accept |
zsnip.start_lsp_server() |
Anything else | One call covers every LSP-speaking menu, but the items arrive folded in with your language servers' |
The last two need no completion plugin, and zsnip.complete needs no LSP
client either.
require('blink.cmp').setup({
-- 'default' is blink's own vim.snippet wrapper -- the engine zsnip loads for.
snippets = { preset = 'default' },
sources = {
default = { 'lsp', 'path', 'zsnip', 'buffer' },
providers = {
zsnip = {
name = 'zsnip',
module = 'zsnip.blink',
-- opts are optional; all three are passed to completion_items()
opts = { documentation = true },
},
},
},
})Leave blink's own built-in snippets source out of default: it reads the
same VSCode packs, so listing both offers everything twice — and it cannot see
the snipmate ones at all.
The source requires nothing from blink. It is a table with new, enabled
and get_completions, returning plain LSP completion items, so there is no
blink internal for it to drift against.
require('zsnip.cmp').register({ documentation = true })
require('cmp').setup({
snippet = { expand = function(args) vim.snippet.expand(args.body) end },
sources = { { name = 'nvim_lsp' }, { name = 'zsnip' } },
})The snippet.expand line is what makes nvim-cmp use Neovim's engine rather
than asking for LuaSnip. register() is the only thing that requires nvim-cmp,
so the module loads fine on a config without it.
require('zsnip.complete').enable()
vim.o.autocomplete = true -- optional; CTRL-N reaches it either way
vim.o.completeopt = 'menu,popup,noinsert,fuzzy'A function source in 'complete', which 'autocomplete' drives as you type.
No completion plugin, and unlike the LSP server, no LSP client either —
enable() appends one entry to 'complete' and installs two handlers: a
CompleteDone expander, which only fires on an accepted item — <C-y>, or a
mapping that sends it; <CR>, <Space> and <Tab> are discard in Vim, same
as a typed character or <Esc>, and never expand — and a CompleteChanged
stylist for the preview.
Because 'complete' is buffer-local, enable() appends to the global default
and to every buffer already open, not just the current one, so lazy-loading
it on InsertEnter still reaches buffers that were open before that fired.
Snippets then rank next to buffer words in a single menu, and each source can
be capped on its own. Setting 'complete' yourself means enable() should not
also append to it, so pass complete = false and it installs only the
handlers:
vim.o.complete = ".^5,w," .. require('zsnip.complete').source() .. '^10'
require('zsnip.complete').enable({ complete = false })enabled() and disable() read the entry with or without its ^{count}, so a
capped setup is still recognised — by :checkhealth zsnip too.
enable() takes the same limit, documentation and filter as the other
sources. It deliberately does not touch 'autocomplete': whether the menu
opens by itself is your decision, not zsnip's. With it on, an empty base
returns nothing — the whole filetype is not a useful menu after every space,
and Vim's own . source shows nothing there either; a manual CTRL-N with
'autocomplete' off still lists the lot.
The preview shows exactly what vim.lsp.completion would render for the same
snippet — the description as prose, then the body syntax-highlighted — so the
menu row stays the trigger and its kind alone. The styling reaches the float
that popup in 'completeopt' opens (the preview split shows the markdown
raw), and comes back off when selection moves to another source's plain item,
since one menu reuses the same float — except onto an item served by
vim.lsp.completion, which core restyles on its own schedule and zsnip leaves
alone; it comes back off as usual on the next plain item. Pass
description_style = 'classic' to keep the description in the menu row
instead, visible without selecting, with a plain body in the preview.
Two things are specific to this path:
- zsnip does the matching. The other three hand the whole filetype over
and let the engine filter; here the completion function is given the text to
match and returns
{ refresh = 'always' }, so it is re-asked on every change and stays in control. That means the fuzzy match is zsnip's, and withoutfuzzyin'completeopt'Vim narrows the result again by its own plain-keyword rule — so a hit whosewordis not a prefix of the keyword under the cursor (clogforconsole.log,rqforreq) is filtered back out and never shown at all. - zsnip does the expanding. Nothing else on this path knows what a snippet
body is, so
enable()'sCompleteDonehandler replaces the accepted trigger and callsvim.snippet.expand(). The range it replaces is the whole non-blank run before the cursor, because a third of real triggers mix words and symbols —console.log,<div,#!/usr/bin/env. A trigger typed after a bracket still works:(reqkeeps the(and expandsreq.
require('zsnip').start_lsp_server()An in-process language server answering textDocument/completion with the
snippets of the requesting buffer's filetype. Nothing is spawned and nothing
leaves the process.
No completion plugin needed:
-- Only needed for autotrigger. vim.lsp.completion asks unprompted on the
-- characters a server names, and a snippet trigger can begin with any of
-- them: `2x1table` and `#!` are both real friendly-snippets triggers, so a
-- letters-only list would leave the first firing a keystroke late and the
-- second never firing at all. Which items are offered, and what each replaces,
-- is settled by the textEdit zsnip sends -- not by this list.
local triggers = {}
for byte = 33, 126 do
triggers[#triggers + 1] = string.char(byte)
end
require('zsnip').start_lsp_server({
trigger_characters = triggers,
completion = { autotrigger = true },
})completion is what makes an accepted item expand. Attaching a client is not
enough: the handler that reads insertTextFormat lives in
vim.lsp.completion and is installed only by its enable(), so without this
the items arrive and accepting one puts a literal ${1:mod} in your buffer.
Pass true for the defaults, or a table forwarded to
vim.lsp.completion.enable() — autotrigger, convert, cmp.
It is applied to zsnip's own client only. Enabling it for every client, which
a hand-written LspAttach hook does unless it checks, would take over
completion for your language servers as a side effect of asking for snippets:
-- Equivalent to `completion = { autotrigger = true }`, and the check is the
-- part that is easy to leave out.
vim.api.nvim_create_autocmd('LspAttach', {
callback = function(args)
local client = vim.lsp.get_client_by_id(args.data.client_id)
if client and client.name == 'zsnip' then
vim.lsp.completion.enable(true, client.id, args.buf, { autotrigger = true })
end
end,
})start_lsp_server() also takes limit, documentation and filter, which it
forwards to completion_items() — the same three the native sources accept.
vim.lsp.completion expands snippet items itself, and vim.snippet runs the
session — nothing else is involved. Without autotrigger, drop
trigger_characters and ask for the menu with <C-x><C-o>.
require('zsnip').stop_lsp_server() undoes all of it — the autocmd and the
clients it attached — which :checkhealth zsnip then reports accurately.
blink.cmp and nvim-cmp can consume the server too, through their existing LSP source, if you would rather not add a provider. The cost is that snippets are no longer separable from your language servers' items in that engine's config.
completion_items() returns plain lsp.CompletionItem[], already resolved and
marked as snippets:
local items = require('zsnip').completion_items({
prefix = keyword_under_cursor,
bufnr = bufnr,
limit = 20,
documentation = false, -- let the popup preview the expanded body instead
filter = function(snippet) return not already_offered[snippet.prefix] end,
})Feed them to vim.fn.complete(), to vim.lsp.completion's handler, or into
whatever menu you assemble yourself.
Passing prefix makes zsnip fuzzy-match and rank, and it then sets sortText
to pin the order. Omit it to get the whole filetype unranked and with no
sortText, which is what you want if your menu ranks for itself.
Bind the trigger expansion directly:
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)expand_or_jump() expands the trigger before the cursor when there is one and
moves to the next tabstop otherwise.
Anything on the runtimepath is found automatically:
- VSCode packs — a
package.jsonwithcontributes.snippets, e.g. friendly-snippets. - snipmate packs —
snippets/<filetype>.snippetsorsnippets/<filetype>/<name>.snippets, e.g. pkl-neovim and vim-snippets.
Your own snippets need no plugin — point a loader at a directory:
require('zsnip.loaders.from_vscode').lazy_load({ paths = vim.fn.stdpath('config') .. '/snippets' })A directory named this way needs no package.json. It is read the way VSCode
reads its own user-snippets folder, so you can point one at
~/.config/Code/User/snippets and get what is already there:
| File | Serves |
|---|---|
<language>.json |
the filetype the file is named after — python.json, lua.json |
*.code-snippets |
whichever languages each snippet's scope names, comma-separated; an entry with no scope goes to every filetype, through global_filetype |
package.json |
if one is present it still decides, and the files it names are not read a second time from the glob |
scope decides who a file serves in the *.code-snippets row alone — the
other two take their filetype from a filename and from the manifest, and a
scope key inside one of those is ignored rather than filtered on. Real packs
carry TextMate scopes (source.lua) there harmlessly, and filtering on them
drops most of what the pack serves.
Loose files are only looked for under a paths directory you configured.
Plugins on the runtimepath declare what they contribute in a package.json,
and globbing every plugin directory for stray JSON would turn up a great deal
that is not a snippet.
Either loader follows symlinks under a paths directory: a snippet file that
is one is read like any other, so a stow or dotfiles setup that routes every
file through a link is found rather than passed over. A symlinked directory is
descended into by the snipmate loader alone, whose per-filetype directories sit
below the one you configured; a VSCode loose file has to sit in the configured
directory itself, link or not. A link pointing at nothing is skipped.