← Vim研究所

Neovimでスニペットを使う vim.snippetとLuaSnipの境目

2026-11-02 公開 ・

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.snippetLuaSnip
中身トリガーの語と展開する本文無し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_FILENAMEhello.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の出番です。

関連記事

← Vim研究所 トップへ戻る