Neovimでスニペットを使う vim.snippetとLuaSnipの境目
Neovimでスニペットを使う方法を調べると、LuaSnipを入れて、friendly-snippetsを入れて、補完プラグインの設定にエンジンの名前を書く、という手順から始まる解説が多く見つかります。ところがNeovim 0.12.6の本体にはvim.snippetという展開の仕組みがあり、Tabでプレースホルダを移動する既定のマップまで入っています。プラグインを足す前に、本体だけでどこまで動くのかを手元で1つずつ打って確かめました。
先に結果を書くと、同じ番号のプレースホルダの同期、選択肢、TabとShift-Tabでの往復、$0での終了までは本体だけで動きました。扱えなかったのは$CURRENT_YEARのような日付の変数、文字とプレースホルダを混ぜた入れ子、入力を加工して別の場所へ写す変換です。friendly-snippetsに入っている6,197本のうち239本は、本体のパーサが受け付けませんでした。
検証環境:NVIM v0.12.6(macOS)。一時ディレクトリをHOMEにしてtmuxの中でNeovimを起動し、キーを送って画面を読み取りました。プラグインはvim.packで一時ディレクトリに入れ、blink.cmp v1.10.2、friendly-snippets(2026-10-27のコミット b2ea5c4)、LuaSnip(v2.5.0-14-g0abc8f3)を使っています。掲載した画面とエラーは実行結果です。セッションが終わる条件のうち、挿入モードを抜けたときと$0に着いたときは実機で、それ以外は$VIMRUNTIME/lua/vim/snippet.luaのソースで確かめました。Vim 9.1には同じ役割の機能が無いため、この記事はNeovimだけを扱います。
目次(9項目)
Neovimのスニペットは本体とプラグインの2層
「スニペット」と一口に言っても、中では3つの仕事が分かれています。テキストを差し込んでプレースホルダを移動できるようにする展開の仕事、どの語でどんな文を出すかという中身、それを候補として見せて確定させる呼び出し口です。本体のvim.snippetが受け持つのは最初の1つだけです。
| 仕事 | 中身 | 本体 | プラグインの例 |
|---|---|---|---|
| 展開 | プレースホルダを置き、Tabで移動させる | vim.snippet | LuaSnip |
| 中身 | トリガーの語と展開する本文 | 無し | friendly-snippets、自作のJSON |
| 呼び出し口 | 候補を出して確定する | LSPの補完の確定 | blink.cmp |
本体だけでスニペットが展開される場面は、いまのところLSPの補完候補を確定したときです。Neovimの標準LSP設定でサーバーをつなぎ、標準の自動補完で関数名を確定すると、引数の部分がプレースホルダとして展開されます。逆に言えば、自分で書いたスニペットをトリガーの語から呼ぶ仕組みは本体にありません。「スニペットエンジンはまだ要るのか」という問いは、実際には中身と呼び出し口を何で用意するかの問いになります。
vim.snippet.expandで展開してTabで移動する
展開するのはvim.snippet.expand()に文字列を渡すだけです。書式はVS CodeやLSPと同じで、${1:name}が初期値付きのプレースホルダ、$1が同じ番号の写し、$0が最後にカーソルを置く場所です。挿入モードのCtrl-jに割り当てて試しました。
vim.keymap.set('i', '<C-j>', function()
vim.snippet.expand('local ${1:name} = require("${1}")$0')
end)
空のバッファでiからCtrl-jを押した直後の画面です。
local name = require("name")
[No Name] [+] 1,7 All
-- SELECT -- 4
最初のnameが選択モード(-- SELECT --)で選ばれています。選択モードでは文字を打つと選択範囲がその文字に置き換わり、そのまま挿入モードに移ります。fを打つとlocal f = require("f")、続けてoを打つとlocal fo = require("fo")になりました。写しの$1は確定を待たずに1文字ごと追従します。ソースを読むと、TextChangedIのたびに同じ番号の範囲へ文字を書き写していました。
Tabを押すとカーソルは行末の$0へ飛び、その時点でvim.snippet.active()はfalseを返しました。$0に着いた瞬間にセッションは閉じます。プレースホルダが複数ある場合はShift-Tabで戻れます。for ${1:i} = 1, ${2:n} do\n\t$0\nendを展開してiをkに、Tabで移ったnをNに変え、Shift-Tabを押すとkが再び選択されました。
このTabは$VIMRUNTIME/lua/vim/_core/defaults.luaの237行目からのdoブロックで定義されています。
local function set_snippet_jump(direction, key)
vim.keymap.set({ 'i', 's' }, key, function()
if vim.snippet.active({ direction = direction }) then
return string.format('<Cmd>lua vim.snippet.jump(%d)<CR>', direction)
else
return key
end
end, {
desc = 'vim.snippet.jump if active, otherwise ' .. key,
expr = true,
silent = true,
})
end
set_snippet_jump(1, '<Tab>')
set_snippet_jump(-1, '<S-Tab>')
active({ direction = 1 })は、セッションがあって、しかもその方向に移る先があるときだけ真になります。それ以外のときはキーをそのまま返すので、普段のTabはタブ文字を入れるキーのままです。$0に着いたあとでもう一度Tabを押し、:set listで見ると、スニペットの本文にあった\tのうしろにタブがもう1つ増えていました。
for k = 1, N do
> > X
end
セッションが閉じる条件は$0だけではありません。f(${1:a}, ${2:b})を展開してxを打ち、Escで挿入モードを抜けてからaで戻ってTabを押すと、bへは飛ばずにタブが入りました。ソースでは、挿入モードや選択モードからノーマルモードへ移ったとき(ModeChanged)、カーソルがスニペットの範囲の外へ出たとき(CursorMovedI)、別のバッファへ移ったとき(BufLeave)にvim.snippet.stop()を呼んでいます。途中で直したい箇所を見つけてEscを押すと、その時点で残りのプレースホルダへは移れなくなります。
書ける構文と書けない構文(同期・選択肢・変数)
LSPの仕様にある書き方を一通り渡して、展開結果を見ました。空のバッファと、hello.luaを開いた状態の両方で試しています。
| 書き方 | 0.12.6での結果 |
|---|---|
| 同じ番号を2か所 | 入力に1文字ずつ追従する |
${1|debug,info,warn|} | 補完メニューで3つから選ぶ |
$TM_FILENAME | hello.lua。無名バッファでは空 |
${TM_FILENAME:untitled} | 無名バッファでuntitled |
$CURRENT_YEAR $UUID | 名前がそのまま入り、プレースホルダになる |
${1:a, ${2:b}} | snippet parsing failed |
${1/(.*)/${1:/upcase}/} | snippet parsing failed |
${TM_FILENAME/(.*)\..*/${1:/upcase}/} | エラーは出ないが変換されない |
${1:a} ${1:b} | Snippet has multiple placeholders for tabstop $1 |
選択肢のlog.${1|debug,info,warn|}($0)は、展開すると補完メニューが開き、先頭のdebugが入った状態になりました。Ctrl-nでinfoに替え、Ctrl-yで確定し、Tabで括弧の中へ移れます。選択肢は専用の画面ではなく、挿入モードの補完の仕組みを借りて出しています。
log.debug()
~ debug
~ info
~ warn
[No Name] [+] 1,10 All
-- INSERT --
変数で引っかかったのは$CURRENT_YEARです。hello.luaを開いて[$TM_FILENAME][$TM_FILENAME_BASE][$CURRENT_YEAR][$UUID]を展開すると、次のようになりました。
[hello.lua][hello][CURRENT_YEAR][UUID]
年が入るはずの場所にCURRENT_YEARという文字が選択状態で入り、Tabを押すと次のUUIDが選ばれました。snippet.luaのresolve_variable()が知っている名前はTM_SELECTED_TEXT、TM_CURRENT_LINE、TM_CURRENT_WORD、TM_LINE_INDEX、TM_LINE_NUMBER、TM_FILENAME、TM_FILENAME_BASE、TM_DIRECTORY、TM_FILEPATHの9つだけで、それ以外は「知らない変数」として名前をプレースホルダにする作りでした。VS Codeのスニペットから日付や$CLIPBOARDを使うものを持ってくると、ここで崩れます。
パースエラーになる2つは、どちらもVS Codeでは書ける形です。friendly-snippetsの全JSONを読んで本文を1本ずつ本体のパーサに通したところ、6,197本中239本がsnippet parsing failedになりました。多かったのはterraform.jsonの39本、fortran.jsonの28本、PowerShell.jsonの16本で、python/python.jsonにも8本あります。lua/lua.json、javascript/javascript.json、go.jsonは0本でした。たとえばPythonのtryefはexcept${2: ${3:Exception} as ${4:e}}:という入れ子を含むため、blink.cmpから確定しても何も展開されず、画面下にエラーだけが出ます。
プラグイン無しで自作スニペットを呼ぶ
本体に無いのはトリガーの語から本文を引く部分だけなので、そこを数行のLuaで足せば、プラグイン無しで自作スニペットが使えます。カーソルの直前の単語を見て、表にあればその単語を消してからvim.snippet.expand()を呼ぶ形です。
local snippets = {
lua = {
fn = 'local function ${1:name}(${2})\n\t$0\nend',
req = "local ${1:name} = require('${1}')",
},
}
vim.keymap.set('i', '<C-j>', function()
local row, col = unpack(vim.api.nvim_win_get_cursor(0))
local before = vim.api.nvim_get_current_line():sub(1, col)
local word = before:match('[%w_]+$')
local body = word and (snippets[vim.bo.filetype] or {})[word]
if not body then return end
vim.api.nvim_buf_set_text(0, row - 1, col - #word, row - 1, col, {})
vim.snippet.expand(body)
end, { desc = 'expand my snippet' })
t.luaで行頭に空白を2つ置いてfnと打ち、Ctrl-j、setup、Tab、opts、Tab、return 1と入力した結果です。
local function setup(opts)
return 1
end
2行目以降には、展開した行の先頭の空白がそのまま付きます。本文の\tはexpandtabが有効ならスペースに置き換わる作りです。表に無い語でCtrl-jを押した場合は何も起きません。数本の定型文を持ち歩くだけならこれで足ります。足りなくなるのは、候補の一覧を見ながら選びたくなったときと、他人が作ったスニペット集を使いたくなったときです。
blink.cmpでfriendly-snippetsと自作JSONを使う
blink.cmpは補完メニューのプラグインで、スニペットのソースを持っています。標準のプラグイン管理vim.packで入れる場合の最小の設定です。
vim.pack.add({
{ src = 'https://github.com/saghen/blink.cmp', version = vim.version.range('1.*') },
'https://github.com/rafamadriz/friendly-snippets',
})
require('blink.cmp').setup({
fuzzy = { implementation = 'lua' },
})
fuzzyの行は、候補の絞り込みをRust製のバイナリではなくLuaで行う指定です。検証ではバイナリの取得を挟みたくなかったので付けました。この状態でsources.defaultを表示すると{ "lsp", "path", "snippets", "buffer" }で、スニペットは最初から候補に混ざります。
blink.cmp 1.10.2のソースでは、スニペットを探す場所の既定値がvim.fn.stdpath('config') .. '/snippets'で、そこに加えてruntimepathの中から名前がfriendly-snippetsに合うディレクトリを足します。展開はconfig/snippets.luaのdefault = function(snippet) vim.snippet.expand(snippet) endで本体に任せています。自作のスニペットは、設定ディレクトリのsnippets/にVS Code形式のJSONを置きます。
// ~/.config/nvim/snippets/package.json
{
"name": "my-snippets",
"contributes": {
"snippets": [
{ "language": ["lua"], "path": "./lua.json" }
]
}
}
// ~/.config/nvim/snippets/lua.json
{
"require local": {
"prefix": "lreq",
"body": ["local ${1:name} = require('${1}')", "$0"],
"description": "local x = require('x')"
},
"header": {
"prefix": "hdr",
"body": ["-- $TM_FILENAME ($CURRENT_YEAR)", "$0"]
}
}
package.jsonが無い場合はディレクトリを走査してJSONを拾う処理がソースにありますが、手元で試したのはpackage.jsonを置いた形だけです。test.luaでlreqと打つと、候補にlreqが2行並びました。
lreq
lreq~
lreq~
friendly-snippetsのlua.jsonにも同じlreqがあるためです。上の行をCtrl-yで確定してutilと打つとlocal util = require('util')になり、シングルクォートの形なので自作のほうが選ばれていました。プレフィックスが重なっても上書きはされず、両方が候補に出ます。選択肢を含むlog.${1|debug,info,warn|}($0)も同じJSONに足して確かめました。確定すると本体と同じ補完メニューが開き、Ctrl-nでinfoに替えてからTabを押すと、log.info()の括弧の中へ移りました。
hdrの展開結果は-- test.lua (2026)でした。本体だけでは文字のまま残った$CURRENT_YEARが、ここでは年になっています。blink.cmpのbuiltin.luaがCURRENT_YEARやUUIDなどの変数を先に解決してから本体に渡しているためです。同じJSONでも、本体のvim.snippet.expand()に直接渡すか、blink.cmp経由で渡すかで結果が変わります。一方で前の節のtryefのような入れ子はblink.cmp経由でも展開できなかったので、パーサの制約は本体のものがそのまま効きます。
LuaSnipが要るのはどこからか
LuaSnipは自前の展開エンジンを持つプラグインで、ソースを検索してもvim.snippetは1か所も呼んでいません。スニペットをLuaのノードの組み合わせで書けるため、VS Code形式では表せないものが作れます。境目が分かりやすい3つを動かしました。
local ls = require('luasnip')
local s, t, i, f, c = ls.snippet, ls.text_node, ls.insert_node, ls.function_node, ls.choice_node
ls.add_snippets('lua', {
-- local bar = require('foo.bar') の bar を、入力したモジュール名から作る
s('req', {
t('local '),
f(function(args) return args[1][1]:match('[^.]+$') or '' end, { 1 }),
t(" = require('"), i(1, 'foo.bar'), t("')"),
}),
-- 選んだ候補によって、後ろの形ごと変わる
s('log', {
c(1, {
{ t('print('), i(1, 'msg'), t(')') },
{ t('vim.notify('), i(1, 'msg'), t(', vim.log.levels.WARN)') },
}),
}),
-- 正規表現トリガー: h3 と打つと、数字の4倍だけ - を並べる
s({ trig = 'h(%d)', trigEngine = 'pattern' }, {
f(function(_, snip) return string.rep('-', tonumber(snip.captures[1]) * 4) end),
}),
})
vim.keymap.set({ 'i', 's' }, '<C-k>', function() ls.expand_or_jump() end)
vim.keymap.set({ 'i', 's' }, '<C-l>', function()
if ls.choice_active() then ls.change_choice(1) end
end)
reqを展開してvim.lsp.utilと打つと、入力中の画面はlocal bar = require('vim.lsp.util')のままで、Escで抜けた時点でlocal util = require('vim.lsp.util')に変わりました。function nodeは、本体の同期のように1文字ずつ追従するのではなく、既定では挿入モードを抜けたときに計算し直します(default_config.luaのupdate_events = "InsertLeave")。入力をもとに別の文字列を組み立てる、という仕事は本体の写し($1)ではできません。
logは展開直後がprint(msg)で、Ctrl-lを押すとvim.notify(msg, vim.log.levels.WARN)に切り替わりました。本体の選択肢は1語を入れ替えるだけですが、choice nodeは括弧やうしろの引数まで含めた形ごと入れ替えます。h3と打ってCtrl-kを押すと、-が12個並びました。トリガーそのものを正規表現にして、捕まえた数字を本文に使っています。
| やりたいこと | 本体だけ | blink.cmp経由 | LuaSnip |
|---|---|---|---|
| 同じ番号の同期 | できる | できる | できる |
| 1語の選択肢 | できる | できる | できる |
| 年・UUIDの変数 | 文字のまま | 入る | 試していない |
| 入れ子・変換 | パースエラー | パースエラー | 試していない |
| 入力から別の文字列を作る | できない | できない | function node |
| 形ごと切り替える | できない | できない | choice node |
| 正規表現のトリガー | 自前で書く | 試していない | できる |
LuaSnipでVS Code形式のスニペットを読む機能や変数の扱いは、今回の検証の範囲外なので表では「試していない」としました。手元で線を引くなら、VS Code形式で書ける範囲のスニペットを使うだけなら本体とblink.cmpで足り、入力を計算して別の場所に書く必要が出たところからLuaSnipを入れる意味が出てきます。
展開されない・Tabで飛ばないときの確認
画面下にsnippet parsing failedが出る場合は、本文に文字とプレースホルダを混ぜた入れ子か、${1/…/…/}の変換が入っています。friendly-snippetsの一部はこれに当たるので、自分の設定の誤りとは限りません。Snippet has multiple placeholders for tabstop $1は、同じ番号に違う初期値を2回書いたときのエラーです。初期値は1か所だけに書き、ほかは$1にします。
Tabを押してもタブが入るだけのときは、セッションがもう閉じています。:lua print(vim.snippet.active())がfalseなら、Escで抜けたか、カーソルが範囲の外へ出たか、$0に着いたかのどれかです。trueなのに飛ばないときは、別のプラグインがTabを奪っていないかを:verbose imap <Tab>で見ます。nvim --cleanで打つと、既定のマップは次のように出ます。
i <Tab> * <Lua 26: vim/_core/defaults:241>
vim.snippet.jump if active, otherwise <Tab>
Last set from Lua (run Nvim with -V1 for more details)
ここに別のファイルの名前が出ていれば、そちらの設定が上書きしています。同じプレフィックスが候補に2行出る場合は、自作のJSONとfriendly-snippetsで名前が重なっています。どちらかのprefixを変えれば候補は1行になります。
VS Code向けに書かれたライセンス表記のスニペットを、そのままvim.snippet.expand()に渡して試しました。年の位置にはCURRENT_YEARという文字が選択状態で入り、見た目だけではエスケープの書き方を誤ったように見えます。snippet.luaを開くと、本体が解決する変数はTM_で始まる9つだけでした。同じスニペットをblink.cmpから確定すると年が入るので、呼び出し口を2つ持っていると、片方では年が入り片方では入らない状態になります。
まとめ
Neovim 0.12.6のvim.snippetは、プレースホルダの同期、選択肢、TabとShift-Tabでの移動、$0での終了まで本体だけで動きます。Tabはセッション中で移る先があるときだけ移動に使われ、それ以外はタブ文字のままです。セッションは$0に着いたときと、Escで挿入モードを抜けたときに閉じます。
本体に無いのはトリガーの語と本文の対応表で、数本なら自前のキーマップ、たくさん使うならblink.cmpとfriendly-snippetsで埋められます。日付の変数はblink.cmp経由なら入りますが、入れ子と変換のパースエラーはどちらでも残ります。入力から別の文字列を組み立てたい、形ごと切り替えたい、という段階に来たらLuaSnipの出番です。