Skip to content

feat: expose completion APIs for external integrations - #1

Closed
atusy wants to merge 2 commits into
kjuq:masterfrom
atusy:feat/completion-extension-api
Closed

atusy wants to merge 2 commits into
kjuq:masterfrom
atusy:feat/completion-extension-api

Conversation

@atusy

@atusy atusy commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

解決する問題

skkelua の候補を ddc.vim などの外部補完 UI で使うとき、既存の辞書検索・学習 API だけでは、組み込み補完と同じ候補をそのまま受け取れません。利用側で注釈を表示用テキストから分離し、候補順や pre-edit の置換範囲を組み立て、確定時には元の辞書情報を学習処理へ戻す必要があります。LSP source を使う場合は、その結果を返す transport も必要です。

これらを補完プラグインごとに実装すると、SKK の候補生成や確定処理の変更をそれぞれで追従することになります。この PR は、組み込み補完で使う処理を UI に依存しない Lua API として公開し、外部 UI でも同じ処理を利用できるようにします。候補生成・確定 API は直接呼び出せるため、LSP クライアントやサーバーの起動は不要です。 LSP 経由の接続も、その API を利用する方法の一つとして提供します。

変更内容と責務

候補生成・確定処理を共通の Lua API にまとめ、その上に任意で利用できる LSP 接続を用意します。

利用方法 候補の取得 確定時の処理 LSP の起動
補完 UI / source から Lua API を直接呼ぶ completion.get(context) completion.accept(item) 不要
Neovim の LSP source から利用する lsp.start() で接続し、補完要求を送る UI の確定イベントから completion.accept(item) 必要
  • completion.get(context) は明示的な入力位置から候補・置換範囲・確定に必要な metadata を返し、completion.accept(item) は確定した候補の学習や辞書登録を処理します。
  • LSP 経由の利用には lsp.start(bufnr) を提供します。補完 UI を有効化せず、外部補完用クライアントをバッファ間で共有します。独自の文書表現には lsp.new_server(get_context) と、ロード済みバッファを読む lsp.get_context() を使えます。
  • UI の選択状態・確定・再表示を独自に連携する場合は completion.set_adapter() を使えます。既存の cmp / pum.vim との連携は自動検出を維持します。

直接呼び出す場合も、返り値には LSP の CompletionList / CompletionItem のデータ形式を使います。これは候補・置換範囲・metadata を表す形式であり、LSP 通信やクライアントのライフサイクルを要求するものではありません。独自の補完 source でも、この値を UI の候補に変換して利用できます。

候補の表示・絞り込み・キー設定、確定時のテキスト置換、および入力位置が現在の SKK 入力に対応するかの判定は利用側が管理します。completion.accept() はテキストを挿入する API ではなく、確定後の学習や辞書登録を担当します。UI への変換後も元の CompletionItem を保持し、確定イベントから渡します。completion.set_adapter() による UI 連携も LSP なしで利用できます。

skkelua に ddc 固有の source や設定は追加しません。

既存の組み込み補完を維持し、completion.enabled は引き続き組み込み補完を制御します。外部 API と cmp / pum.vim の確定キー連携はこの設定に依存しません。外部・組み込みクライアントの併用に合わせて、後始末の対象を区別し、設定変更後の detach、停止済みクライアントの再利用防止、キャンセル時の古い UI コールバック抑止も含めています。

ddc + LSP の比較例で確認できること

以下は、2つの利用方法のうち 既存の ddc-source-nvim-lsp を使う経路の例です。ddc をこの source 経由で接続するため LSP を使いますが、本 PR の補完 API を利用するための必須条件ではありません。Lua API を直接呼べる補完 UI / source では、LSP transport を用意せずに同じ候補生成・確定処理を利用できます。

以下は、同じ ddc/pum の表示・キー操作で補完を成立させるために、利用側がどこまで実装する必要があるかを示す最小再現例です。PR 前後で UI と辞書を揃え、skkelua への接続部分を比較します。

処理 PR 前の例 PR 後の例
候補整形・重複排除・学習順・置換範囲 設定側で組み立てる skkelua の共通候補生成を利用する
LSP からの候補取得 設定側の transport から辞書検索処理を呼ぶ lsp.start() で接続する
確定時の学習 設定側で保持した辞書情報を complete_callback() に渡す 元の CompletionItem を completion.accept() に渡す
UI と操作 ddc / ddc-source-nvim-lsp / pum を設定する 同じ設定を使う

この比較で示したい効果は、外部 UI の利用者が保守する SKK 固有の処理を減らし、組み込み補完との候補生成・確定処理の共通化を進められることです。変更前の例は送りなし入力だけを実装しますが、変更後は skkelua 側が提供する辞書登録項目も受け取れます。

再現範囲

空行の先頭、または # のような空白で区切った位置で、Kanji → 候補選択 → 確定・学習を確認します。両方の例で簡単な keywordPattern を使います。既存のひらがなに続く入力の補完開始位置、送りあり、abbrev、キャンセル操作、コマンドラインはこの再現例では検証しません。候補生成 API 自体は送りあり・abbrev にも対応しています。

共通設定:同じ ddc/pum・辞書・キー操作で比較する

Neovim 0.11 以上、Deno 2、Git が必要です。確認環境は Neovim v0.13.0-nightly+338ce77 / Deno 2.9.6。初回は Denops の依存ダウンロードが完了するまで待ってください。以下は新しい一時ディレクトリで実行し、普段の設定・辞書は使いません。

example_dir=$(mktemp -d)
cd "$example_dir"
mkdir deps

pin_plugin() {
  git clone "https://github.com/$1.git" "deps/$2"
  git -C "deps/$2" checkout --detach "$3"
}
pin_plugin vim-denops/denops.vim denops.vim e82c5a94ca1154b05c9d74d6f6f58c9991b1424f
pin_plugin Shougo/ddc.vim ddc.vim b035d309a402c5c2e89ad7b0e451e9d8c9d7b1aa
pin_plugin Shougo/ddc-ui-pum ddc-ui-pum 0679df221520576d07add3cf4219d478db110801
pin_plugin Shougo/pum.vim pum.vim e26a0c74a9ac33036a8387aedc96aac106870966
pin_plugin atusy/ddc-source-nvim-lsp ddc-source-nvim-lsp f5bd08ef7a7e2a2b83b08171aac7725fe94be72f
pin_plugin kjuq/skkelua.nvim skkelua-before d3a1185ab97fc42d00a463e0fbd4616f429031ea
pin_plugin atusy/skkelua.nvim skkelua-after 88c0f03900f0178a59e32848cdebc8c43e3be58c

このディレクトリに以下を common.lua として保存します。両方の例で同じ ddc source、pum UI、キー設定を使います。辞書は かんじ /漢字/感じ/ の1項目だけを作成します。

local root = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":p:h")
for _, name in ipairs({ "denops.vim", "ddc.vim", "ddc-ui-pum", "pum.vim", "ddc-source-nvim-lsp" }) do
  vim.opt.runtimepath:append(root .. "/deps/" .. name)
end
vim.opt.runtimepath:append(root .. "/deps/skkelua-" .. vim.env.SKK_EXAMPLE)
vim.fn.writefile({ ";; okuri-ari entries.", ";; okuri-nasi entries.", "かんじ /漢字/感じ/" }, root .. "/jisyo")
local skk = require("skkelua")
skk.config({
  sources = { "skk_dictionary" },
  globalDictionaries = { { root .. "/jisyo", "utf-8" } },
  userDictionary = root .. "/user-" .. vim.env.SKK_EXAMPLE .. ".json",
  markerHenkan = "", markerHenkanSelect = "",
  completion = { enabled = false },
  mappedKeys = vim.tbl_filter(function(key)
    return not vim.tbl_contains({ "<tab>", "<s-tab>", "<c-y>" }, key:lower())
  end, skk.get_default_mapped_keys()),
})
vim.keymap.set("i", "<C-j>", "<Plug>(skkelua-toggle)")
vim.keymap.set("i", "<Tab>", "<Cmd>call pum#map#insert_relative(1)<CR>")
vim.keymap.set("i", "<S-Tab>", "<Cmd>call pum#map#insert_relative(-1)<CR>")
vim.keymap.set("i", "<C-y>", "<Cmd>call pum#map#confirm()<CR>")
vim.keymap.set("i", "<C-Space>", "<Cmd>call ddc#map#manual_complete()<CR>")
vim.fn["ddc#custom#patch_global"]({
  ui = "pum", sources = { "nvim-lsp" },
  autoCompleteEvents = { "TextChangedI", "TextChangedP" },
  sourceOptions = { ["nvim-lsp"] = {
    mark = "SKK", isVolatile = true, minAutoCompleteLength = 1,
    keywordPattern = "[ぁ-ゖー]+", matchers = {}, sorters = {}, converters = {},
  } },
  sourceParams = { ["nvim-lsp"] = { allowedServers = { "skkelua" }, confirmBehavior = "replace" } },
})
vim.fn["ddc#enable"]()
_G.skk_completed_item = function()
  if vim.g["pum#completed_event"] ~= "confirm" then return nil end
  local item = vim.g["pum#completed_item"] or {}
  if item.__sourceName ~= "nvim-lsp" then return nil end
  local value = vim.tbl_get(item, "user_data", "lspitem")
  return type(value) == "string" and vim.json.decode(value) or value
end

<C-j> で SKK を有効化し、<Tab> / <S-Tab> で候補を選択、<C-y> で確定します。候補がまだ出ていなければ <C-Space> で再要求できます。

PR 前:外部 UI へ候補を渡すために、SKK の補完処理を設定側で組み立てる

同じディレクトリに before.lua として保存します。既存の get_completion_result() / get_ranks() / complete_callback() を使い、送りなし入力を ddc に接続する例です。注釈の分離、候補の重複排除・順位付け、pre-edit を置換する範囲、学習に渡す元の候補情報を設定側で組み立てます。さらに、これを ddc-source-nvim-lsp へ渡すための最小の LSP transport を用意します。

この例が実装するのは送りなし入力だけです。送りありや辞書登録項目まで外部 UI で扱うには、対応する処理も利用側で追加する必要があります。

local root = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":p:h")
vim.env.SKK_EXAMPLE = "before"
dofile(root .. "/common.lua")
local skk = require("skkelua")
-- 送りなし入力専用。候補整形・重複排除・学習順の反映を設定側で行う。
local function complete(params)
  local empty = { isIncomplete = true, items = {} }
  if not skk.is_enabled() or skk.phase() ~= "input:okurinasi" then return empty end
  if params.textDocument.uri ~= vim.uri_from_bufnr(0) then return empty end
  local row, col = params.position.line, params.position.character -- UTF-8 bytes
  local line = vim.api.nvim_buf_get_lines(0, row, row + 1, false)[1] or ""
  local preedit = skk.get_pre_edit()
  if preedit == "" or skk.get_prefix() == "" or not vim.endswith(line:sub(1, col), preedit) then
    return empty
  end
  local entries, ranks, items, seen = skk.get_completion_result(), {}, {}, {}
  table.sort(entries, function(a, b) return a[1] < b[1] end)
  for _, e in ipairs(skk.get_ranks()) do ranks[e[1]] = e[2] end
  for _, e in ipairs(entries) do
    for _, raw in ipairs(e[2]) do
      local word = raw:gsub(";.*", "")
      if not seen[word] then
        seen[word] = true
        items[#items + 1] = {
          label = word, insertText = word, detail = raw:match(";(.*)$"),
          sortText = #items + 1,
          textEdit = {
            range = { start = { line = row, character = col - #preedit }, ["end"] = params.position },
            newText = word,
          },
          data = { midasi = e[1], word = raw },
        }
      end
    end
  end
  table.sort(items, function(a, b)
    local ar, br = ranks[a.data.word] or 0, ranks[b.data.word] or 0
    return ar == br and a.sortText < b.sortText or ar > br
  end)
  for i, item in ipairs(items) do item.sortText = ("%06d"):format(i) end
  return { isIncomplete = true, items = items }
end
-- 再現範囲に必要な最小の in-process LSP transport。
local function transport(dispatchers)
  local closing, next_id = false, 0
  local pending = {}
  local rpc = {}
  function rpc.request(method, params, handler, replied)
    if closing then return false end
    next_id = next_id + 1
    local id = next_id
    pending[id] = true
    vim.schedule(function()
      if closing or not pending[id] then return end
      pending[id] = nil
      local err, result
      if method == "initialize" then
        result = { capabilities = { positionEncoding = "utf-8", completionProvider = {} } }
      elseif method == "textDocument/completion" then result = complete(params)
      elseif method ~= "shutdown" then err = { code = -32601, message = "Method not found" } end
      if replied then replied(id) end
      handler(err, result)
    end)
    return true, id
  end
  function rpc.notify(method, params)
    if method == "exit" then rpc.terminate() end
    -- この例の候補生成は次のイベントループで完了する。
    return not closing
  end
  function rpc.is_closing() return closing end
  function rpc.terminate()
    if closing then return end
    closing = true
    vim.schedule(function() dispatchers.on_exit(0, 0) end)
  end
  return rpc
end
vim.api.nvim_create_autocmd("User", {
  pattern = "skkelua-enable-post",
  callback = function(ev) vim.lsp.start({ name = "skkelua", cmd = transport }, { bufnr = ev.buf }) end,
})
vim.api.nvim_create_autocmd("User", {
  pattern = "PumCompleteDone",
  callback = function()
    local item = skk_completed_item()
    if item and item.data then skk.complete_callback(item.data.midasi, item.data.word, "okurinasi") end
  end,
})
nvim -n -i NONE -u ./before.lua ./example.txt

空のバッファで i → # → <C-j> → Kanji と入力します。# かんじ に対して「漢字」「感じ」が表示されます。<Tab> を2回押して「感じ」を選択し、<C-y> で確定すると、行は # 感じ になります。

PR 後:SKK の補完処理を共有し、設定側には接続と確定イベントの配線を残す

同じディレクトリに after.lua として保存します。common.lua はそのまま使い、SKK 有効化時に lsp.start() を呼び、pum の確定イベントから completion.accept() に元の CompletionItem を渡します。候補整形・置換範囲・学習用 metadata と LSP transport は skkelua が提供するため、設定側の実装から取り除けます。

この例の UI は引き続き ddc/pum が所有します。pum の選択状態と確定キーは自動検出されるため、明示的な set_adapter() は不要です。

local root = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":p:h")
vim.env.SKK_EXAMPLE = "after"
dofile(root .. "/common.lua")
vim.api.nvim_create_autocmd("User", {
  pattern = "skkelua-enable-post",
  callback = function(ev) require("skkelua.lsp").start(ev.buf) end,
})
vim.api.nvim_create_autocmd("User", {
  pattern = "PumCompleteDone",
  callback = function()
    local item = skk_completed_item()
    if item then require("skkelua.completion").accept(item) end
  end,
})
nvim -n -i NONE -u ./after.lua ./example.txt

変更前の Neovim は :qa! で終了し、ファイルを保存せずに切り替えます。同じ操作で # 感じ が確定します。PR 後は、共通の候補生成に由来する [辞書登録] 項目も表示されます(この例では選択しません)。

検証

  • 両構成を独立した Neovim で起動し、実際の Denops / ddc-source-nvim-lsp / LSP / pum を通した候補表示・選択・確定を確認しました。# かんじ から2番目の「感じ」を確定すると # 感じ となり、確定後の lastCandidate は { type = "okurinasi", word = "かんじ", candidate = "感じ" } になります。
  • 両コミットで env -u NVIM_LISTEN_ADDRESS nvim --clean --headless -l tests/run.lua が成功しています。
  • git diff --check が成功しています。
  • 辞書登録テストには既存の非同期 Invalid 'start_col': out of range エラー出力が残っています。変更前のコミットでも再現することを確認しています。

External completion integrations need SKK candidates and learning without copying builtin UI internals. Expose candidate generation, acceptance, explicit document transports and UI adapters while retaining native presentation.

Preserve automatic cmp and pum confirmation regardless of completion.enabled, with explicit adapters taking priority. Read only the selected pum item to avoid copying the entire menu on the input path.

Keep builtin lifecycle operations separate from external clients, including teardown after configuration changes and reuse after stopping. Acknowledge cancelled requests without invoking obsolete UI callbacks.
Let completion integrations reuse one UI-independent client across buffers. Resolve named request documents and the unnamed current buffer without requiring consumer-specific routing in skkelua.

Keep custom document resolution on new_server and provide explicit insertion metadata for candidate consumers. Exclude stopped clients from lookup and reuse so integrations can restart immediately, before a scheduled exit callback removes the old client.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant