0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Neovim で skkeleton × blink.cmp を協調動作させる (4・最終回) 周辺の作り込み: スニペット統合とコマンドライン補完

0
Last updated at Posted at 2026-07-20

シリーズ最終回

Part 3 で skkeleton まわりの一番のハマりどころは片付きました。最終回は、それ以外の周辺機能——LuaSnip の統合とコマンドライン補完——で踏んだ罠を紹介します。

スニペット統合: 組み込み snippets = { preset = "luasnip" } を使わない理由

blink.cmp には LuaSnip 用の組み込みプリセットがあり、sources.defaultsnippets を入れて preset = "luasnip" を指定するだけで動くはずでした。しかし実際に使うと、dw(日付を入力して曜日ノードへジャンプする自作スニペット)で <Tab> を押した際に、次のエラーで落ちる不具合がありました。

E5108: Lua: .../luasnip/init.lua:625: E565: Not allowed to change text or change window

原因は組み込みソース(lua/blink/cmp/sources/snippets/luasnip.lua)のコード冒頭にコメントされています。

FIXME: Some annotations are based on an unmerged PR:
 https://github.com/L3MON4D3/LuaSnip/pull/1396

つまりこの組み込みソースは、LuaSnip 本家にまだマージされていない PR を前提にした実験的な実装で、確定時に自前で luasnip.snip_expand() を呼んで展開まで行っています。これが LuaSnip 内部のセッション追跡と噛み合わず、二重目のノードへ <Tab> でジャンプしようとした瞬間に textlock 中の nvim_buf_set_text 呼び出しとなって E565 が発生していました。

対処: 展開は <Tab> キーマップ側に一本化する

そこで、組み込みプリセットは使わず、次の2つに分けました。

1. luasnip_source.lua: トリガー文字列を出すだけのネイティブソース

function source:get_completions(_, callback)
  local ok, luasnip = pcall(require, "luasnip")
  if not ok then
    callback({ items = {}, is_incomplete_forward = false, is_incomplete_backward = false })
    return function() end
  end

  local kind_snippet = require("blink.cmp.types").CompletionItemKind.Snippet
  local items = {}
  local seen = {}

  for _, ft in ipairs(luasnip.get_snippet_filetypes()) do
    local snippets = luasnip.get_snippets(ft, { type = "snippets" }) or {}
    for _, snip in ipairs(snippets) do
      local trigger = snip.trigger
      if trigger and not seen[trigger] then
        seen[trigger] = true
        local description = snip.dscr and snip.dscr[1] and table.concat(snip.dscr, " ") or nil
        table.insert(items, {
          label = trigger,
          insertText = trigger,
          kind = kind_snippet,
          labelDetails = description and { description = description } or nil,
        })
      end
    end
  end

  callback({ items = items, is_incomplete_forward = false, is_incomplete_backward = false })
  return function() end
end

このソースは execute() を定義していません。blink.cmp は「ソースが execute を定義していなければ default_implementation(素の textEdit 適用)だけを行う」仕様なので、これだけで「トリガー文字列をプレーンテキストとして挿入するだけ」の動作になります。展開は一切行いません。

2. 展開・ジャンプは <Tab> キーマップの expand_or_jump() に任せる

["<Tab>"] = {
  function()
    if luasnip.expand_or_jumpable() then
      vim.schedule(function()
        luasnip.expand_or_jump()
        blink.hide() -- 日付の数字列に反応した calc 等のポップアップを閉じる
      end)
      return true
    end
  end,
  "fallback",
},

ここには2つ注意点があります。

  • 成功時は必ず return true する。 blink.cmp のキーマップは、関数が nil/false を返すと次のアクション(ここでは "fallback")に進む仕様です。return を書き忘れると、LuaSnip のジャンプが成功した直後に余計な <Tab> がもう一度実行されてしまいます。
  • 実際の nvim_buf_set_textvim.schedule で1ティック遅らせる。 blink.cmp のキーマップ実行コンテキストの中で直接バッファを書き換えようとすると、組み込みプリセットと同じ E565 が発生します。

<S-Tab>(逆ジャンプ)と <C-l>/<C-h>(choice_node の切り替え、dw スニペットの曜日表記切り替えなどに使用)も同じパターンです。

これは旧 nvim-cmp + saadparwaiz1/cmp_luasnip の組み合わせと本質的に同じ、安定版 API だけに依存する枯れた方式です。

<CR><Tab> の役割分担

この構成では、確定操作とスニペット展開が別のキーに分かれていることに注意が必要です。

  • <C-n>/<C-p> — 複数の候補から選ぶための操作
  • <CR> — 選んだ候補のトリガー文字列をプレーンテキストとして挿入するだけ(まだ展開されない)
  • <Tab> — バッファ上のトリガー文字列を実際にスニペットへ展開する操作

dw のようにトリガー文字列を過不足なく正確にタイプできていれば、候補一覧から明示的に選ばなくても、その場で <Tab> を押すだけで展開できます。<C-n>/<C-p><CR> が必要になるのは、複数候補から選びたい場合だけです。

コマンドライン補完の罠

: / ? でも blink.cmp の補完を有効にしていますが、cmdline プリセットに任せず、必要なキーをすべて明示的に定義しています。

cmdline = {
  enabled = true,
  keymap = {
    ["<Tab>"] = { "show", "select_next", "fallback" },
    ["<S-Tab>"] = { "show", "select_prev", "fallback" },
    ["<C-n>"] = { "show", "select_next", "fallback" },
    ["<C-p>"] = { "show", "select_prev", "fallback" },
    ["<CR>"] = { "accept_and_enter", "fallback" },
    ["<C-e>"] = { "cancel", "fallback" },
    ["<C-y>"] = { "select_and_accept" },
  },
  completion = {
    menu = { auto_show = true },
    list = { selection = { preselect = false, auto_insert = false } },
  },
  sources = function()
    local cmdtype = vim.fn.getcmdtype()
    if cmdtype == "/" or cmdtype == "?" then
      return { "buffer", "regex" }
    elseif cmdtype == ":" then
      return { "cmdline", "path", "buffer", "regex" }
    end
    return {}
  end,
}

ここで踏んだ罠は2つです。

罠1: 候補を選んで <CR> しても、選んでいない生の文字列で実行されてしまう

例えば :laz と入力して <C-n>Lazy を選び <CR> を押しても、E492: Not an editor command: laz になってしまう症状です。

原因は <CR> の割り当て方でした。accept 単体は候補をコマンドラインに挿入するだけで、実行はしません(wildmenu の Tab 補完と同じ挙動)。一発の <CR> で「挿入」と「実行」を両方行いたい場合は、accept_and_enter を明示的に割り当てる必要があります。

罠2: 何も選んでいないのに先頭候補が勝手にコマンドラインへ反映されてしまう

例えば :w と打っただけのつもりが :wq になってしまう、という症状です。旧 cmp-cmdline の見た目に寄せようとして list.selectionpreselect/auto_inserttrue にしていたのが原因でした。これらを true にすると、候補を明示的に選ぶ前から先頭候補が勝手にコマンドラインへ挿入されてしまいます。両方 false にし、<C-n>/<C-p> で明示的に選んだときだけ反映されるようにして解決しました。

シリーズを振り返って

4回に分けて、Neovim で skkeleton × blink.cmp を協調動作させるまでの過程を紹介しました。振り返ると、詰まった箇所の多くは「blink.cmp 側のexecuteの仕様」「skkeleton 側の補完エンジン判定ロジック」「LuaSnip 組み込みプリセットの実験的な実装」など、ドキュメント化されていない、ソースコードを読まないと分からない仕様が原因でした。

同じ組み合わせで困っている人(世界に何人いらっしゃるのだろう???)の参考になれば幸いです。設定ファイル一式は以下で公開しています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?