ふりかえり
前回 は環境とディレクトリ構成の紹介でした。今回は本題、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-skkeleton を blink.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 由来の候補だけでなく buffer や rg などskkeleton と無関係なソースの候補でも再現したのです。
原因は skkeleton 本体側、具体的には「今どの補完エンジンが表示されているか」を skkeleton がどう判定しているか、という部分にありました。次回はこの根本原因と、その対処として書いた skkeleton_cmp_shim.lua を紹介します。
次回予告
Part 3 では、skkeleton 本体(autoload/skkeleton.vim)が pum.vim / nvim-cmp / Vim ネイティブ補完の3種類しか認識しないという仕様と、blink.cmp をどうやって「nvim-cmp が動いている」と誤認させたか —— このシリーズで一番のハマりどころを扱います。