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 を協調動作させる (2) skkeleton 用ネイティブ blink.cmp ソースの実装

0
Last updated at Posted at 2026-07-20

ふりかえり

前回 は環境とディレクトリ構成の紹介でした。今回は本題、lua/my/cmp/skkeleton_source.lua の中身を掘り下げます。

なぜ blink.compat 経由をやめたか

blink.cmp には calc / emoji / latex_symbols / spell / rg のように、blink.compat 経由で旧 nvim-cmp 用ソース(cmp-calc など)をそのまま動かしているものがあります。skkeleton も当初は同じ方針で uga-rosa/cmp-skkeletonblink.compat 経由で使おうとしました。

しかし、候補は表示されるのに <CR> で確定してもひらがなのまま反映されない、という不具合が最後まで解決できませんでした。原因の切り分けに時間がかかったのですが、結論から言うと「blink.compat にキーワードパターンの概念がない」といった compat 層固有の問題ではありませんでした(一時期そう疑っていたのですが、これは誤りでした)。

真因は blink.cmp の確定処理そのものの仕様にありました。次で説明します。

blink.cmp のソースが最低限実装すべきもの

blink.cmp のソースは Lua のテーブルとして、主に次の関数を実装します。

  • get_trigger_characters() — 補完を発火させる文字
  • get_completions(context, callback) — 候補一覧を返す
  • resolve(item, callback) — 候補選択時にドキュメントなどを追加で解決する(任意)
  • execute(context, item, callback, default_implementation) — 確定時の処理(任意。定義しなければ default_implementation 相当のテキスト挿入だけが行われる)

skkeleton 用のソースでは、この4つすべてを実装しています。

get_completions: denops 経由で候補を取得する

skkeleton 本体は denops (Deno) 上で動いているため、候補取得は denops#request を直接呼びます。cmp-skkeleton(nvim-cmp 用)のロジックをほぼそのまま移植した形です。

local function request(key, args)
  args = args or {}
  return vim.fn["denops#request"]("skkeleton", key, args)
end

local function skkeleton_enabled()
  return vim.fn.exists("*skkeleton#is_enabled") == 1 and vim.fn["skkeleton#is_enabled"]() == true
end

function source:get_completions(_, callback)
  if not skkeleton_enabled() then
    callback({ items = {}, is_incomplete_forward = false, is_incomplete_backward = false })
    return function() end
  end

  local ok, candidates = pcall(request, "getCompletionResult")
  if not ok or not candidates or vim.tbl_isempty(candidates) then
    callback({ items = {}, is_incomplete_forward = true, is_incomplete_backward = true })
    return function() end
  end

  local pre_edit = request("getPreEdit") --  例: "▽かんじ"
  local ranks_ok, ranks = pcall(request, "getRanks")
  ranks = ranks_ok and ranks or {}

  -- 素のバイトオフセットで range を計算する(LSP の UTF-16 換算はしない)
  local win = vim.api.nvim_win_get_cursor(0)
  local row0 = win[1] - 1
  local end_col = win[2]
  local start_col = math.max(end_col - #pre_edit, 0)

  local range = {
    start = { line = row0, character = start_col },
    ["end"] = { line = row0, character = end_col },
  }

  local items = {}
  local global_rank = -1

  for _, cand in ipairs(candidates) do
    local kana = cand[1]
    for _, word in ipairs(cand[2]) do
      local label = word:gsub(";.*$", "")

      local rank = ranks[word]
      if not rank then
        rank = global_rank
        global_rank = global_rank - 1
      end

      table.insert(items, {
        label = label,
        filterText = pre_edit,
        sortText = string.format("%010d", -rank),
        kind = require("blink.cmp.types").CompletionItemKind.Text,
        textEdit = {
          range = range,
          newText = label,
        },
        data = { kana = kana, word = word },
      })
    end
  end

  callback({
    items = items,
    is_incomplete_forward = true,
    is_incomplete_backward = true,
  })

  return function() end
end

ポイントは2つあります。

1. range をバイトオフセットで計算している

cmp-skkeleton(nvim-cmp 用)は params.context.cursor.character を基準に textEdit.range を計算しますが、これは LSP 準拠の UTF-16 コードユニット単位です。nvim-cmp はこの値を内部で UTF-16 ⇔ バイトオフセットの変換をしたうえで nvim_buf_set_text に渡しています。

blink.cmp のネイティブソースは LSP のワイヤープロトコルを経由しないため、range は最初から nvim_win_get_cursor / nvim_buf_set_text と同じ「素のバイトオフセット」で計算すればよく、UTF-16 換算の変換処理が一切不要になります。getPreEdit() で得られる実際の文字列(例: ▽かんじ)のバイト長 #pre_edit をカーソル位置から引くだけで開始位置が求まります。

2. sortText で skkeleton 側のランク情報をそのまま活かす

getRanks() で得られる learn 済みの順位情報(よく変換する候補ほど上に来る、SKK おなじみの挙動)を sortText に反映しています。ランク情報がない候補は連番の負数を振って末尾に回しています。

resolve: セミコロン付き注釈をドキュメントとして出す

SKK 辞書には word;注釈 の形で注釈が入っていることがあります。これを補完メニューのドキュメント欄に出すだけの単純な実装です。

function source:resolve(item, callback)
  local word = item.data and item.data.word
  if word and word:find(";") then
    item.documentation = {
      kind = "plaintext",
      value = word:match(";%s*(.*)$"),
    }
  end
  callback(item)
end

execute: 「確定してもバッファに反映されない」の真因

ここが今回一番のハマりどころです。最初に実装した execute は、おおよそ次のような形でした(修正前)。

-- 修正前(バグあり)
function source:execute(_, item, callback)
  local data = item.data or {}
  if data.kana and data.word then
    pcall(request, "completeCallback", { data.kana, data.word })
  end
  callback()
end

skkeleton 側への通知(completeCallback)だけ行って callback() を呼んでいるので、一見問題なさそうに見えます。実際、blink.compat 経由で cmp-skkeleton を使っていたときと全く同じ症状 —— 確定はされるのに、ひらがなのまま何も置き換わらない —— が、ネイティブソースに書き直した直後にも再現しました。

blink.cmp 本体のソースコード(lua/blink/cmp/completion/accept/init.lua)を確認したところ、確定処理は各ソースの execute(ctx, item, callback, default_implementation) を呼び出すだけで、実際のバッファへの textEdit 適用は default_implementation を明示的に呼んで初めて実行される仕様でした。組み込みの LSP ソース(lua/blink/cmp/sources/lsp/init.lua)も、この default_implementation() を自前で呼び出しています。

つまり、execute の第4引数を受け取らず・呼び出してもいなかったせいで、skkeleton への通知だけは実行されるのに、肝心のテキスト置換だけが一切スキップされていた、というのが真因でした。修正後は次のようになります。

-- 確定後、実際のテキスト置換 (default_implementation) を行い、
-- そのうえで skkeleton 側に「この候補が選ばれた」ことを通知する。
function source:execute(_, item, callback, default_implementation)
  default_implementation()

  local data = item.data or {}
  if data.kana and data.word then
    pcall(request, "completeCallback", { data.kana, data.word })
  end

  callback()
end

default_implementation() を呼ぶ一行を足しただけですが、これでようやくテキストが正しく置き換わるようになりました。

それでも直らなかった

……と言いたいところですが、実はこの修正だけでは完全には解決しませんでした。同じ「<CR> で確定できない」症状が、skkeleton 由来の候補だけでなく bufferrg などskkeleton と無関係なソースの候補でも再現したのです。

原因は skkeleton 本体側、具体的には「今どの補完エンジンが表示されているか」を skkeleton がどう判定しているか、という部分にありました。次回はこの根本原因と、その対処として書いた skkeleton_cmp_shim.lua を紹介します。

次回予告

Part 3 では、skkeleton 本体(autoload/skkeleton.vim)が pum.vim / nvim-cmp / Vim ネイティブ補完の3種類しか認識しないという仕様と、blink.cmp をどうやって「nvim-cmp が動いている」と誤認させたか —— このシリーズで一番のハマりどころを扱います。

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?