From e39bb02d6e2dd55ccfaddfb3d263322c43fe7bce Mon Sep 17 00:00:00 2001 From: atusy <30277794+atusy@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:47:35 +0900 Subject: [PATCH 1/2] feat: expose UI-independent completion APIs 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. --- README.md | 12 +- doc/skkelua.txt | 84 ++++- lua/skkelua/completion.lua | 325 ++++++++++++++++++ lua/skkelua/completion/builtin.lua | 64 ++++ lua/skkelua/guard.lua | 2 +- lua/skkelua/init.lua | 45 ++- lua/skkelua/lsp.lua | 514 ++++++++++------------------- tests/complete_key_spec.lua | 36 +- tests/completion_api_spec.lua | 284 ++++++++++++++++ tests/completion_builtin_spec.lua | 119 +++++++ tests/helper.lua | 1 + 11 files changed, 1119 insertions(+), 367 deletions(-) create mode 100644 lua/skkelua/completion.lua create mode 100644 lua/skkelua/completion/builtin.lua create mode 100644 tests/completion_api_spec.lua create mode 100644 tests/completion_builtin_spec.lua diff --git a/README.md b/README.md index 7b9d0b4..159c0fc 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,16 @@ require("skkelua").config({ この項目だけのメニューが開くので、変換キーを使わずに新しい単語を登録できます - skkelua の有効・無効に連動して補完も付いたり外れたりします +### 外部の補完 UI との連携 + +`skkelua.completion.get()` / `accept()` で候補生成と確定処理を共有できます。 +`skkelua.lsp.new_server()` は組み込み UI を有効にせず LSP 経由で同じ候補を提供し、 +`skkelua.completion.set_adapter()` で外部 UI の選択状態・確定キー・再補完を接続します。 +各 UI の候補ソースや確定後の学習は外部側で接続します。 +`completion.enabled` に関係なく、従来の cmp / pum.vim の自動検出と確定キー連携を使えます。 +明示的なアダプターは自動検出より優先されます。 +詳細は `:help skkelua-completion-extension` を参照してください。 + ## skkeleton との関係 skkelua は [vim-skk/skkeleton](https://github.com/vim-skk/skkeleton) の @@ -153,7 +163,7 @@ denops 版との主な違い: | 変換ごとの undo 区切り (`setUndoPoint`) | デフォルト有効 | デフォルト無効 | | SKK サーバー | 非同期 TCP | 同期 TCP (タイムアウト 1 秒) | | Google 日本語入力 | fetch | curl | -| ddc.vim ソース | 同梱 | 非同梱 (補完用 Lua API を提供) | +| ddc.vim ソース | 同梱 | 非同梱 (UI 非依存の候補・確定 API と LSP transport を提供) | ## Development diff --git a/doc/skkelua.txt b/doc/skkelua.txt index 2c03a2b..cc6f1d5 100644 --- a/doc/skkelua.txt +++ b/doc/skkelua.txt @@ -143,6 +143,8 @@ require("skkelua").get_default_mapped_keys() 補完プラグイン連携用 API ~ *skkelua-completion-api* +外部補完の接続は |skkelua-completion-extension| を参照してください。 + require("skkelua").get_pre_edit() 現在のプリエディット文字列を返します。 require("skkelua").get_pre_edit_length() @@ -185,7 +187,9 @@ config() に渡せる主なオプション (括弧内はデフォルト値): acceptIllegalResult (false) かなに変換できなかった入力をそのまま残すかどうか。 completion ({ enabled = false }) - 変換候補の builtin LSP 補完。|skkelua-completion| 参照。 + 変換候補の builtin LSP 補完を設定します。 + cmp / pum.vim の確定キー連携は、この設定に関係なく利用できます。 + |skkelua-completion| 参照。 completionRankFile ("") 補完候補ランクの保存先ファイル。設定すると補完候補の並び順 (確定が新しい候補ほど上、|skkelua-completion| 参照) が @@ -346,6 +350,84 @@ Note: |vim.lsp.completion.enable()| は最初にバッファの補完状態を 他の設定・プラグインが opts 無しで enable(true) を呼ぶと、skkelua の autotrigger が失われて候補が自動表示されなくなります。 +外部補完との接続 *skkelua-completion-extension* + +外部プラグインは候補生成・確定処理を再実装せず、以下の API を利用できます。 +UI の表示、絞り込み、キー設定、LSP client の起動・停止は外部側が所有します。 +組み込み UI と併用しない場合は completion.enabled = false にしてください。 +この設定は組み込み補完だけを無効にし、以下の API は利用できます。 +cmp / pum.vim の自動検出と確定連携は設定に関係なく有効です。 + +require("skkelua.completion").get({context}) + 現在の SKK 状態から LSP CompletionList を返します。context は + { line = 行の全文, row = 0 始まりの行番号, col = 0 始まりのバイト位置 } + です。文字位置は UTF-8 で、Insert バッファにもコマンドラインにも + 使えます。渡したカーソルの直前が現在の pre-edit と一致しない場合や + SKK 無効時は空の items を返します。要求対象が現在の SKK 入力先かは + 呼び出し側で判定してください (SKK 状態は文書ごとではありません)。 + 送りなし・送りあり・変換中・abbrev の候補、注釈、学習順、辞書登録項目 + を共通で生成します。'completeopt' や UI、カーソルには触れません。 + textEdit は pre-edit 全体を置換する PlainText です。候補の表示順には + sortText を使い、data を確定時まで保持してください。 + +require("skkelua.completion").accept({item}) + 実際に確定した元の LSP CompletionItem を一度だけ渡してください。 + 通常候補を学習し、abbrev の入力そのままの候補は学習から除外します。 + 辞書登録項目は次のイベントループで SKK の登録プロンプトを開きます。 + SKK の項目なら true、それ以外なら false を返します。 + textEdit の適用は UI 側の責務です。選択・キャンセル時は呼ばず、 + 確定イベントで呼びます。登録項目は pre-edit と入力モードを保持して + 確定してください。古い候補への遅延した確定通知は渡さないでください。 + +require("skkelua.lsp").new_server({get_context}?) + |vim.lsp.start()| の cmd に渡す in-process LSP transport を返します。 + UI は有効化せず、LSP の位置エンコーディングは UTF-8 です。 + 既定では要求 URI のロード済み名前付きバッファと params.position を使います。 + 匿名バッファ・仮想文書・コマンドラインでは get_context(params) から get() と同じ + context を返せます。対象外の要求には nil を返してください。 + 文書同期通知を蓄積するサーバーではありません。文書の内容は要求処理時に + バッファまたは get_context から読みます。 +>lua + require("skkelua").config({ completion = { enabled = false } }) + local client_id = vim.lsp.start({ + name = "skkelua-external", + cmd = require("skkelua.lsp").new_server(), + }, { bufnr = vim.api.nvim_get_current_buf() }) +< + 外部側で必要なバッファに attach し、SKK 無効時には補完要求を止めます。 + ライフサイクル連携には |skkelua-autocmds| を利用できます。確定時の + accept() 呼び出しは LSP を利用する場合も外部側で接続してください。 + +require("skkelua.completion").set_adapter({adapter}?) + 補完 UI への接続を一つ登録します。明示的な登録は自動検出より優先されます。 + nil で登録を解除し、cmp / pum.vim の自動検出に戻します。 + 候補取得だけなら不要ですが、SKK の確定キー・選択挿入・辞書登録後の + 再補完と協調する場合に必要です。adapter は以下の関数を持ちます。 + + state(): { visible: boolean, selected?: { word: string, item: table } } + 現在の UI 状態を返します。未選択なら selected を省略します。 + item は元の LSP CompletionItem (他ソースの項目でも可)、word は + 選択によって挿入済みの文字列です。未挿入なら空文字にします。 + この情報は確定キー、選択挿入中の削除・接尾辞・purge、補完中の + 移動キーの判定に使います。UI 非表示時は visible = false を返します。 + confirm(): string + 選択を確定するキー列を返します。その場でバッファを変更しないで + ください。戻り値は展開済みのキーコード (例: "\25") です。 + コマンドなら nvim_replace_termcodes() で ... を変換します。 + trigger(): nil (省略可) + 辞書登録のキャンセルなどで pre-edit が復元された際に補完を要求します。 + + アダプターの登録は SKK の有効・無効や completion.enabled と独立です。 + 同じ補完 UI を使い続ける場合は一度登録すれば十分です。nil を渡すのは + 明示的な連携を解除するときです。キャンセル処理や + キーの保存・復元は外部側で管理します。 + +設定の選び方: + completion.enabled は組み込み補完の有効・無効だけを制御します。 + cmp / pum.vim の自動検出と確定連携は設定に関係なく利用できます。 + 自動検出を置き換える場合は set_adapter() で明示的に登録します。低水準 API + (get_completion_result / complete_callback など) も引き続き使えます。 + ============================================================================== INDICATOR *skkelua-indicator* diff --git a/lua/skkelua/completion.lua b/lua/skkelua/completion.lua new file mode 100644 index 0000000..e4d33e2 --- /dev/null +++ b/lua/skkelua/completion.lua @@ -0,0 +1,325 @@ +-- UI-independent SKK completion. Positions are zero-based UTF-8 byte offsets. +local M = {} +local adapter + +---@class skkelua.CompletionSelection +---@field word string Text currently inserted by selection (empty if not inserted) +---@field item table Original LSP CompletionItem, including data +---@class skkelua.CompletionAdapter +---@field state fun(): {visible: boolean, selected?: skkelua.CompletionSelection} +---@field confirm fun(): string Keys to confirm selection; must not mutate text synchronously +---@field trigger? fun() Request completion after pre-edit restoration + +--- Install one UI adapter. Pass nil to restore automatic completion handling. +--- The adapter owns its UI, lifecycle, item conversion and acceptance events. +---@param value? skkelua.CompletionAdapter +function M.set_adapter(value) + adapter = value +end + +local function effective_adapter() + if adapter then + return adapter + end + return require("skkelua.completion.builtin").detect() +end + +---@return table? state nil means the native UI owns completion +function M.state() + local active = effective_adapter() + return active and active.state() or nil +end + +function M.confirm() + local active = effective_adapter() + return active and active.confirm() or nil +end + +---@return boolean handled +function M.trigger() + if not adapter then + return false + end + if adapter.trigger then + adapter.trigger() + end + return true +end + +function M.visible() + local state = M.state() + if state then + return state.visible == true + end + return vim.fn.pumvisible() == 1 +end + +function M.is_register_item(item) + local data = item and item.data + return type(data) == "table" and data.skkelua == true and data.register == true +end + +---@class skkelua.LspCandidate +---@field word string 辞書上の候補原文 (注釈付き) +---@field midasi string 辞書の見出し +---@field okuri string 送り仮名 (送りなしは "") +---@field type skkelua.HenkanType +---@field affix? skkelua.AffixType +---@field rank? number 並び順の決定に使うランク (大きいほど上) +---@field raw? boolean 辞書由来でない候補 (abbrev の半角スペース + 入力。登録・purge の対象外) + +--- 送りなし変換入力 (▽かんじ) の候補: 見出しの前方一致検索 +--- ユーザー辞書で確定済みの候補 (ランク持ち) を確定が新しい順に先頭へ置き、 +--- 残りは見出しの辞書順で並べる。 +--- abbrev モードでは入力の半角スペース前置形を末尾に足す +---@return skkelua.LspCandidate[] +local function okurinasi_candidates() + local skkelua = require("skkelua") + local completions = skkelua.get_completion_result() + table.sort(completions, function(a, b) + return a[1] < b[1] + end) + local ranks = {} + for _, e in ipairs(skkelua.get_ranks()) do + ranks[e[1]] = e[2] + end + -- ランクを持たない候補はランク持ちの末尾より配置する。 + -- 見出しの辞書順を保つよう先頭から順に負の方向にランクを振っていく + local global_rank = -1 + local result = {} + for _, entry in ipairs(completions) do + local midasi, words = entry[1], entry[2] + for _, word in ipairs(words) do + local rank = ranks[word] + if rank == nil then + rank = global_rank + global_rank = global_rank - 1 + end + result[#result + 1] = { word = word, midasi = midasi, okuri = "", type = "okurinasi", rank = rank } + end + end + table.sort(result, function(a, b) + return a.rank > b.rank + end) + local context = require("skkelua.store").get_context() + if context.mode == "abbrev" then + -- 入力したアルファベットの前に半角スペースを足したもの (英単語を + -- 和文の中に空けて入れる用)。入力そのものは の無変換確定で + -- 入るので候補には並べない + local feed = context.state.henkanFeed + result[#result + 1] = { word = " " .. feed, midasi = feed, okuri = "", type = "okurinasi", raw = true } + end + return result +end + +--- feed (送りのローマ字) から確定しうる送り仮名を列挙する +---@param kana_table skkelua.KanaTable +---@param feed string +---@return string[] +local function feed_kana_candidates(kana_table, feed) + local kanas = {} + local seen = {} + for _, e in ipairs(kana_table) do + -- feed に前方一致し、残余 feed を持たないエントリだけが送り仮名として完成する + if vim.startswith(e[1], feed) and type(e[2]) == "table" and e[2][2] == "" then + local kana = e[2][1] + if kana ~= "" and not seen[kana] then + seen[kana] = true + kanas[#kanas + 1] = kana + end + end + end + return kanas +end + +--- 候補選択中 (▼送る) の候補: 引いてある変換候補をそのまま並べる +---@return skkelua.LspCandidate[] +local function henkan_candidates() + local state = require("skkelua.store").get_context().state + if state.type ~= "henkan" then + return {} + end + local okuri = state.converter and state.converter(state.okuriFeed) or state.okuriFeed + local result = {} + for _, word in ipairs(state.candidates) do + result[#result + 1] = { + word = word, + midasi = state.word, + okuri = okuri, + type = state.mode, + affix = state.affix, + } + end + return result +end + +--- 送りあり変換入力 (▽おく*r) の候補: +--- 送りのローマ字からありうる送り仮名を列挙し、語幹 + 送り仮名の完成形を出す +---@return skkelua.LspCandidate[] +local function okuriari_candidates() + local state = require("skkelua.store").get_context().state + if state.type ~= "input" or state.previousFeed then + return {} + end + local lib = require("skkelua.store").get_library() + local get_okuri_str = require("skkelua.okuri").get_okuri_str + + local result = {} + local function collect(midasi, okuri) + for _, word in ipairs(lib:get_henkan_result("okuriari", midasi)) do + result[#result + 1] = { word = word, midasi = midasi, okuri = okuri, type = "okuriari" } + end + end + + if state.okuriFeed ~= "" then + -- 送り仮名の先頭が確定済み (immediatelyOkuriConvert=false の「っ」など)。 + -- 見出しは確定しているので、残り feed の展開だけ行う + local midasi = get_okuri_str(state.henkanFeed, state.okuriFeed) + if state.feed == "" then + collect(midasi, state.okuriFeed) + else + for _, kana in ipairs(feed_kana_candidates(state.table, state.feed)) do + collect(midasi, state.okuriFeed .. kana) + end + end + elseif state.feed ~= "" then + for _, kana in ipairs(feed_kana_candidates(state.table, state.feed)) do + collect(get_okuri_str(state.henkanFeed, kana), kana) + end + end + return result +end + +---@param context {line: string, row: integer, col: integer} +---@return table CompletionList +function M.get(context) + local empty = { isIncomplete = true, items = {} } + local skkelua = require("skkelua") + local phase = skkelua.phase() + local supported = phase == "input:okurinasi" or phase == "input:okuriari" or phase == "henkan" + if not skkelua.is_enabled() or not supported then + return empty + end + local pre_edit = skkelua.get_pre_edit() + -- 変換入力中はかなが無ければ出さない (henkan は候補が引けているので不要) + if pre_edit == "" or (phase ~= "henkan" and skkelua.get_prefix() == "") then + return empty + end + + local row, col, line = context.row, context.col, context.line + if row < 0 or col < 0 or col > #line or not vim.endswith(line:sub(1, col), pre_edit) then + return empty + end + local start_col = col - #pre_edit + local range = { + start = { line = row, character = start_col }, + ["end"] = { line = row, character = col }, + } + + local marker = require("skkelua.config").config.markerHenkan + local modify_candidate = require("skkelua.candidate").modify_candidate + + local candidates + if phase == "input:okurinasi" then + candidates = okurinasi_candidates() + elseif phase == "input:okuriari" then + candidates = okuriari_candidates() + else + candidates = henkan_candidates() + end + + local items = {} + local seen = {} + for _, c in ipairs(candidates) do + -- 送りありは語幹 + 送り仮名の完成形を挿入する + local display = (modify_candidate(c.word, c.affix) or c.word) .. c.okuri + if not seen[display] then + seen[display] = true + local annotation = c.word:match(";(.*)$") + local item = { + label = display, + labelDetails = annotation and { description = annotation } or nil, + detail = c.midasi, + kind = vim.lsp.protocol.CompletionItemKind.Text, + -- クライアントは sortText (無ければ label) で並べ替える。 + -- 辞書順 (ユーザー辞書 -> グローバル辞書のマージ順) を保つよう + -- 応答順の連番を振る + sortText = ("%05d"):format(#items + 1), + textEdit = { + range = range, + newText = display, + }, + -- okuri (送り仮名の生かな) は purgeCandidate が ▽henkanFeed*okuriFeed + -- を組み立て直すのに使う (midasi は語幹 + 送り仮名アルファベットの + -- 辞書見出し形式で、そのままでは送り仮名を分離できない) + data = { skkelua = true, midasi = c.midasi, word = c.word, type = c.type, okuri = c.okuri, raw = c.raw }, + } + item.insertTextFormat = vim.lsp.protocol.InsertTextFormat.PlainText + item.filterText = phase == "input:okurinasi" and (marker .. c.midasi) or pre_edit + items[#items + 1] = item + end + end + + -- 新しい読みを登録する項目を末尾に置く (候補が無い読みでも pum が開く)。 + -- 挿入テキストは pre-edit 自身にして、フォーカスや確定でバッファが + -- 変わらないようにする。確定時の accept() から登録プロンプトが開く + -- (登録プロンプトの中ではネストしたプロンプトが積まれる)。 + local state = require("skkelua.store").get_context().state + local registrable = true + local midasi + if phase == "henkan" then + midasi = state.word + elseif phase == "input:okuriari" then + -- 送り仮名が確定するまでは登録する読みが定まらない + registrable = registrable and state.feed == "" and state.okuriFeed ~= "" + midasi = registrable and require("skkelua.okuri").get_okuri_str(state.henkanFeed, state.okuriFeed) + else + midasi = state.henkanFeed + end + if registrable then + local item = { + label = "[辞書登録]", + detail = midasi, + kind = vim.lsp.protocol.CompletionItemKind.Text, + sortText = ("%05d"):format(#items + 1), + textEdit = { + range = range, + newText = pre_edit, + }, + data = { skkelua = true, register = true }, + } + item.insertTextFormat = vim.lsp.protocol.InsertTextFormat.PlainText + item.filterText = pre_edit + items[#items + 1] = item + end + + return { isIncomplete = true, items = items } +end + +--- Call once after accepting an item, never on selection or cancellation. +--- The consumer applies textEdit and retains the original item's data. +---@param item table LSP CompletionItem +---@return boolean handled +function M.accept(item) + local data = item and item.data + if not (data and data.skkelua) then + return false + end + if data.register then + -- [辞書登録] 項目: ins-completion の終了処理から抜けてから + -- 登録プロンプトを開く。挿入テキストが pre-edit のままなので + -- handle 側では変換入力の続きとして registerWord が実行される + vim.schedule(function() + require("skkelua").handle("handleKey", { ["function"] = "registerWord" }) + end) + return true + end + -- abbrev の raw 候補 (半角スペース + 入力) は辞書へ登録しない + if data.raw then + return true + end + require("skkelua").complete_callback(data.midasi, data.word, data.type) + return true +end + +return M diff --git a/lua/skkelua/completion/builtin.lua b/lua/skkelua/completion/builtin.lua new file mode 100644 index 0000000..a3447fc --- /dev/null +++ b/lua/skkelua/completion/builtin.lua @@ -0,0 +1,64 @@ +-- Optional compatibility with completion menus already configured by the user. +-- Candidate sources and acceptance events remain owned by those plugins. +local M = {} + +local function keys(command) + return vim.api.nvim_replace_termcodes("" .. command .. "", true, false, true) +end + +local function lsp_item(item) + local value = vim.tbl_get(item or {}, "user_data", "lspitem") + if type(value) == "string" then + local ok, decoded = pcall(vim.json.decode, value) + value = ok and decoded or nil + end + return type(value) == "table" and value or {} +end + +local pum_adapter = { + state = function() + local info = vim.fn["pum#complete_info"]({ "pum_visible", "selected", "inserted" }) + local selected = (info.selected or -1) >= 0 and vim.fn["pum#current_item"]() + return { + visible = info.pum_visible == true or info.pum_visible == 1, + selected = (info.selected or -1) >= 0 and { word = info.inserted or "", item = lsp_item(selected) } or nil, + } + end, + confirm = function() + return keys("call pum#map#confirm()") + end, +} + +local cmp_adapter = { + state = function() + local cmp = package.loaded.cmp + local entry = cmp.get_active_entry() + return { + visible = true, + -- cmp does not expose whether a selected entry has already been inserted. + selected = entry and { word = "", item = entry:get_completion_item() } or nil, + } + end, + confirm = function() + return keys("lua require('cmp').confirm({select = true})") + end, +} + +function M.detect() + if vim.fn.exists("*pum#visible") == 1 then + local visible = vim.fn["pum#visible"]() + if visible == true or visible == 1 then + return pum_adapter + end + end + local cmp = package.loaded.cmp + if cmp then + local ok, visible = pcall(cmp.visible) + if ok and visible then + return cmp_adapter + end + end + return nil +end + +return M diff --git a/lua/skkelua/guard.lua b/lua/skkelua/guard.lua index e751b33..b94572a 100644 --- a/lua/skkelua/guard.lua +++ b/lua/skkelua/guard.lua @@ -159,7 +159,7 @@ function M._on_key(key, _) if mode:sub(1, 1) == "i" and completion_keys()[key] then return end - if vim.fn.pumvisible() == 1 and pum_nav_keys()[key] then + if require("skkelua.completion").visible() and pum_nav_keys()[key] then return end -- pre-edit 状態のまま insert を抜けた場合 (stopinsert など) に diff --git a/lua/skkelua/init.lua b/lua/skkelua/init.lua index d1163b7..4b02f18 100644 --- a/lua/skkelua/init.lua +++ b/lua/skkelua/init.lua @@ -84,18 +84,14 @@ end ---@return string, table local function complete_info() - if vim.fn.exists("*pum#visible") == 1 and is_truthy(vim.fn["pum#visible"]()) then - return "pum.vim", vim.fn["pum#complete_info"]({ "pum_visible", "selected" }) - end - local cmp = package.loaded["cmp"] - if cmp then - local ok, visible = pcall(function() - return cmp.visible() - end) - if ok and visible then - local selected = cmp.get_active_entry() ~= nil - return "cmp", { pum_visible = true, selected = selected and 1 or -1 } - end + local state = require("skkelua.completion").state() + if state then + return "external", + { + pum_visible = state.visible, + selected = state.selected and 0 or -1, + completion_item = state.selected and state.selected.item, + } end -- items は選択中の [辞書登録] 項目の判定 (handle_impl) に使う return "native", vim.fn.complete_info({ "pum_visible", "selected", "items" }) @@ -141,10 +137,8 @@ end local function native_confirm_key(complete_type) if complete_type == "native" then return require("skkelua.notation").notation_to_key[""] - elseif complete_type == "pum.vim" then - return "call pum#map#confirm()" - elseif complete_type == "cmp" then - return "lua require('cmp').confirm({select = true})" + elseif complete_type == "external" then + return require("skkelua.completion").confirm() end return nil end @@ -195,11 +189,8 @@ local function handle_impl(opts, vim_status) selected = vim_status.completeInfo.selected, }) end - local handled = handle_complete_key( - vim_status.completeInfo.selected >= 0, - vim_status.completeType, - notation_str - ) + local handled = + handle_complete_key(vim_status.completeInfo.selected >= 0, vim_status.completeType, notation_str) if type(handled) == "string" then -- [辞書登録] 項目の確定はバッファを変えず、CompleteDone からの -- registerWord が変換入力の続きとして実行されるため状態を保つ @@ -208,7 +199,12 @@ local function handle_impl(opts, vim_status) if (info.selected or -1) >= 0 and type(info.items) == "table" then sel_item = info.items[info.selected + 1] end - if not require("skkelua.lsp").is_register_item(sel_item) then + if + not ( + require("skkelua.completion").is_register_item(info.completion_item) + or require("skkelua.lsp").is_register_item(sel_item) + ) + then require("skkelua.mode").initialize_state_with_abbrev(context, { "converter" }) context.preEdit:output("") end @@ -613,10 +609,7 @@ end --- deno_kv データベースは Lua 版では非対応 function M.update_database(_path, _encoding, _force) - vim.notify( - "skkelua: updateDatabase (deno_kv) is not supported by the lua version", - vim.log.levels.WARN - ) + vim.notify("skkelua: updateDatabase (deno_kv) is not supported by the lua version", vim.log.levels.WARN) end -------------------------------------------------------------------- diff --git a/lua/skkelua/lsp.lua b/lua/skkelua/lsp.lua index 6579185..51f08f0 100644 --- a/lua/skkelua/lsp.lua +++ b/lua/skkelua/lsp.lua @@ -65,138 +65,6 @@ local function trigger_characters() return chars end ----@class skkelua.LspCandidate ----@field word string 辞書上の候補原文 (注釈付き) ----@field midasi string 辞書の見出し ----@field okuri string 送り仮名 (送りなしは "") ----@field type skkelua.HenkanType ----@field affix? skkelua.AffixType ----@field rank? number 並び順の決定に使うランク (大きいほど上) ----@field raw? boolean 辞書由来でない候補 (abbrev の半角スペース + 入力。登録・purge の対象外) - ---- 送りなし変換入力 (▽かんじ) の候補: 見出しの前方一致検索 ---- (@ddc-sources/skkeleton の gather に相当)。 ---- ユーザー辞書で確定済みの候補 (ランク持ち) を確定が新しい順に先頭へ置き、 ---- 残りは見出しの辞書順で並べる。 ---- abbrev モードでは入力の半角スペース前置形を末尾に足す ----@return skkelua.LspCandidate[] -local function okurinasi_candidates() - local skkelua = require("skkelua") - local completions = skkelua.get_completion_result() - table.sort(completions, function(a, b) - return a[1] < b[1] - end) - local ranks = {} - for _, e in ipairs(skkelua.get_ranks()) do - ranks[e[1]] = e[2] - end - -- ランクを持たない候補はランク持ちの末尾より配置する。 - -- 見出しの辞書順を保つよう先頭から順に負の方向にランクを振っていく - local global_rank = -1 - local result = {} - for _, entry in ipairs(completions) do - local midasi, words = entry[1], entry[2] - for _, word in ipairs(words) do - local rank = ranks[word] - if rank == nil then - rank = global_rank - global_rank = global_rank - 1 - end - result[#result + 1] = { word = word, midasi = midasi, okuri = "", type = "okurinasi", rank = rank } - end - end - table.sort(result, function(a, b) - return a.rank > b.rank - end) - local context = require("skkelua.store").get_context() - if context.mode == "abbrev" then - -- 入力したアルファベットの前に半角スペースを足したもの (英単語を - -- 和文の中に空けて入れる用)。入力そのものは の無変換確定で - -- 入るので候補には並べない - local feed = context.state.henkanFeed - result[#result + 1] = { word = " " .. feed, midasi = feed, okuri = "", type = "okurinasi", raw = true } - end - return result -end - ---- feed (送りのローマ字) から確定しうる送り仮名を列挙する ----@param kana_table skkelua.KanaTable ----@param feed string ----@return string[] -local function feed_kana_candidates(kana_table, feed) - local kanas = {} - local seen = {} - for _, e in ipairs(kana_table) do - -- feed に前方一致し、残余 feed を持たないエントリだけが送り仮名として完成する - if vim.startswith(e[1], feed) and type(e[2]) == "table" and e[2][2] == "" then - local kana = e[2][1] - if kana ~= "" and not seen[kana] then - seen[kana] = true - kanas[#kanas + 1] = kana - end - end - end - return kanas -end - ---- 候補選択中 (▼送る) の候補: 引いてある変換候補をそのまま並べる ----@return skkelua.LspCandidate[] -local function henkan_candidates() - local state = require("skkelua.store").get_context().state - if state.type ~= "henkan" then - return {} - end - local okuri = state.converter and state.converter(state.okuriFeed) or state.okuriFeed - local result = {} - for _, word in ipairs(state.candidates) do - result[#result + 1] = { - word = word, - midasi = state.word, - okuri = okuri, - type = state.mode, - affix = state.affix, - } - end - return result -end - ---- 送りあり変換入力 (▽おく*r) の候補: ---- 送りのローマ字からありうる送り仮名を列挙し、語幹 + 送り仮名の完成形を出す ----@return skkelua.LspCandidate[] -local function okuriari_candidates() - local state = require("skkelua.store").get_context().state - if state.type ~= "input" or state.previousFeed then - return {} - end - local lib = require("skkelua.store").get_library() - local get_okuri_str = require("skkelua.okuri").get_okuri_str - - local result = {} - local function collect(midasi, okuri) - for _, word in ipairs(lib:get_henkan_result("okuriari", midasi)) do - result[#result + 1] = { word = word, midasi = midasi, okuri = okuri, type = "okuriari" } - end - end - - if state.okuriFeed ~= "" then - -- 送り仮名の先頭が確定済み (immediatelyOkuriConvert=false の「っ」など)。 - -- 見出しは確定しているので、残り feed の展開だけ行う - local midasi = get_okuri_str(state.henkanFeed, state.okuriFeed) - if state.feed == "" then - collect(midasi, state.okuriFeed) - else - for _, kana in ipairs(feed_kana_candidates(state.table, state.feed)) do - collect(midasi, state.okuriFeed .. kana) - end - end - elseif state.feed ~= "" then - for _, kana in ipairs(feed_kana_candidates(state.table, state.feed)) do - collect(get_okuri_str(state.henkanFeed, kana), kana) - end - end - return result -end - --- pum で選択中の自前候補の word がカーソル前に挿入されていればそれを返す。 --- insertOnSelect の選択挿入はバッファ上の pre-edit を候補 word で --- 置き換えるため、その間に届いた再リクエストは pre-edit を見つけられない @@ -204,6 +72,21 @@ end ---@return string? word ---@return table? data 候補の data (skkelua/midasi/word/type) local function selected_word(before_cursor) + local state = require("skkelua.completion").state() + if state then + local selected = state.visible and state.selected + local data = selected and selected.item and selected.item.data + if + selected + and selected.word ~= "" + and data + and data.skkelua + and vim.endswith(before_cursor, selected.word) + then + return selected.word, data + end + return nil + end if vim.fn.pumvisible() == 0 then return nil end @@ -233,6 +116,9 @@ end ---@return string? word ---@return table? data function M.selected_word() + if vim.fn.mode():sub(1, 1) == "c" then + return selected_word(vim.fn.getcmdline():sub(1, vim.fn.getcmdpos() - 1)) + end local pos = vim.api.nvim_win_get_cursor(0) local line = (vim.api.nvim_buf_get_lines(0, pos[1] - 1, pos[1], false) or {})[1] or "" return selected_word(line:sub(1, pos[2])) @@ -242,221 +128,178 @@ end --- nil を返した場合は応答自体を保留する (complete() を走らせない) ---@return table? CompletionList local function make_completion_list() - local empty = { isIncomplete = true, items = {} } local skkelua = require("skkelua") - local phase = skkelua.phase() - local supported = phase == "input:okurinasi" or phase == "input:okuriari" or phase == "henkan" - if not skkelua.is_enabled() or not supported then - return empty - end - local pre_edit = skkelua.get_pre_edit() - -- 変換入力中はかなが無ければ出さない (henkan は候補が引けているので不要) - if pre_edit == "" or (phase ~= "henkan" and skkelua.get_prefix() == "") then - return empty - end - - -- カーソル前のテキストが pre-edit (▽かんじ) で終わっていることを確認し、 - -- その開始位置を置換範囲にする。 - -- Note: params.position は使わない。pre-edit の再描画 (BS + 再挿入) は - -- InsertCharPre ごとにリクエストを積むので、後発の処理時点では - -- バッファが先へ進んでいることがある。クライアント (builtin) も - -- 応答を処理時点のカーソルで解釈するため、常に現在位置で組み立てる - local buf = vim.api.nvim_get_current_buf() local pos = vim.api.nvim_win_get_cursor(0) - local row = pos[1] - 1 - local col = pos[2] -- utf-8 (byte) - local line = (vim.api.nvim_buf_get_lines(buf, row, row + 1, false) or {})[1] or "" - local before_cursor = line:sub(1, col) - if not vim.endswith(before_cursor, pre_edit) then - -- insertOnSelect の選択挿入中 (バッファは pre-edit でなく候補 word) に - -- 届いた後発リクエスト。pre-edit の再描画は InsertCharPre ごとに - -- リクエストを積むため、選択挿入を起こした応答の後にも同じ状態への - -- リクエストが残っている。ここで空を返すと complete() が pum を閉じ、 - -- 同じ候補を返し直しても complete() の再実行で typed text が候補 word に - -- すり替わり / で pre-edit に戻れなくなる。 - -- 応答を保留して、表示中の pum と選択状態をそのまま保つ - if selected_word(before_cursor) then - return nil - end - return empty + local row, col = pos[1] - 1, pos[2] + local line = vim.api.nvim_get_current_line() + local pre_edit = skkelua.get_pre_edit() + if + skkelua.is_enabled() + and pre_edit ~= "" + and not vim.endswith(line:sub(1, col), pre_edit) + and selected_word(line:sub(1, col)) + then + return nil end - local start_col = col - #pre_edit - local range = { - start = { line = row, character = start_col }, - ["end"] = { line = row, character = col }, - } - - local marker = require("skkelua.config").config.markerHenkan - local modify_candidate = require("skkelua.candidate").modify_candidate - - local candidates - if phase == "input:okurinasi" then - candidates = okurinasi_candidates() - elseif phase == "input:okuriari" then - candidates = okuriari_candidates() - else - candidates = henkan_candidates() + local list = require("skkelua.completion").get({ line = line, row = row, col = col }) + if #list.items == 0 then + return list end - local instant_insert = completion_config().insertOnSelect - -- typed text (pre-edit) に ASCII 英数字が含まれるとクライアント側の - -- fuzzy フィルタが有効になり、素の label ではマッチしなくなる。 - -- その場合は label に pre-edit を前置してフィルタを通し、 - -- 表示は convert フック (enable 時に登録) で候補のみへ戻す - local prefixed_label = pre_edit:find("%w") ~= nil - - -- deferOkuri で送り仮名が確定した直後 (▽おく*る) は、第一候補を - -- 自動選択して即挿入する (completeopt の noselect をこの応答だけ外す) - local auto_select = false - if instant_insert and phase == "input:okuriari" then - local state = require("skkelua.store").get_context().state - auto_select = completion_config().deferOkuri and state.feed == "" and state.okuriFeed ~= "" - end - set_completeopt(buf, auto_select) - - local items = {} - local seen = {} - for _, c in ipairs(candidates) do - -- 送りありは語幹 + 送り仮名の完成形を挿入する - local display = (modify_candidate(c.word, c.affix) or c.word) .. c.okuri - if not seen[display] then - seen[display] = true - local annotation = c.word:match(";(.*)$") - local item = { - label = display, - labelDetails = annotation and { description = annotation } or nil, - detail = c.midasi, - kind = vim.lsp.protocol.CompletionItemKind.Text, - -- クライアント (builtin) は sortText (無ければ label) で並べ替える。 - -- 辞書順 (ユーザー辞書 -> グローバル辞書のマージ順) を保つよう - -- 応答順の連番を振る - sortText = ("%05d"):format(#items + 1), - textEdit = { - range = range, - newText = display, - }, - -- okuri (送り仮名の生かな) は purgeCandidate が ▽henkanFeed*okuriFeed - -- を組み立て直すのに使う (midasi は語幹 + 送り仮名アルファベットの - -- 辞書見出し形式で、そのままでは送り仮名を分離できない) - data = { skkelua = true, midasi = c.midasi, word = c.word, type = c.type, okuri = c.okuri, raw = c.raw }, - } - if instant_insert then - -- insertOnSelect: filterText を持たせないことで、クライアントの - -- 挿入 word が newText (候補そのもの) になり、 での - -- 選択と同時に pre-edit 全体が候補へ置き換わる - item.insertTextFormat = vim.lsp.protocol.InsertTextFormat.PlainText - if prefixed_label then - item.label = pre_edit .. display - item.data.display = display - end - else - -- クライアントは typed text と filterText を照合する。 - -- 送りなし入力中は続きのかな入力で絞り込めるよう marker + 見出し、 - -- それ以外 (送りあり入力・候補選択) は pre-edit そのもの - -- (絞り込みは isIncomplete の再リクエストが担う) - item.filterText = phase == "input:okurinasi" and (marker .. c.midasi) or pre_edit - -- Note: PlainText だと word が filterText に fallback した場合に - -- newText が適用されない (単なる再挿入になる)。 - -- Snippet format は確定時に挿入 word を削除して - -- newText を展開するため、pre-edit を候補で置換できる - item.insertTextFormat = vim.lsp.protocol.InsertTextFormat.Snippet - item.textEdit.newText = escape_snippet(display) - end - items[#items + 1] = item - end - end - - -- 新しい読みを登録する項目を末尾に置く (候補が無い読みでも pum が開く)。 - -- 挿入テキストは pre-edit 自身にして、フォーカスや確定でバッファが - -- 変わらないようにする。確定すると CompleteDone から登録プロンプトが開く - -- (登録プロンプトの中ではネストしたプロンプトが積まれる)。 local state = require("skkelua.store").get_context().state - local registrable = true - local midasi - if phase == "henkan" then - midasi = state.word - elseif phase == "input:okuriari" then - -- 送り仮名が確定するまでは登録する読みが定まらない - registrable = registrable and state.feed == "" and state.okuriFeed ~= "" - midasi = registrable and require("skkelua.okuri").get_okuri_str(state.henkanFeed, state.okuriFeed) - else - midasi = state.henkanFeed - end - if registrable then - local item = { - label = "[辞書登録]", - detail = midasi, - kind = vim.lsp.protocol.CompletionItemKind.Text, - sortText = ("%05d"):format(#items + 1), - textEdit = { - range = range, - newText = pre_edit, - }, - data = { skkelua = true, register = true }, - } + local auto_select = instant_insert + and skkelua.phase() == "input:okuriari" + and completion_config().deferOkuri + and state.feed == "" + and state.okuriFeed ~= "" + set_completeopt(0, auto_select) + for _, item in ipairs(list.items) do if instant_insert then - item.insertTextFormat = vim.lsp.protocol.InsertTextFormat.PlainText - if prefixed_label then + item.filterText = nil + if pre_edit:find("%w") then + item.data.display = item.label item.label = pre_edit .. item.label - item.data.display = "[辞書登録]" end else - item.filterText = pre_edit + -- Native completion needs snippet expansion to apply textEdit when + -- its insertion word falls back to filterText. item.insertTextFormat = vim.lsp.protocol.InsertTextFormat.Snippet - item.textEdit.newText = escape_snippet(pre_edit) + item.textEdit.newText = escape_snippet(item.textEdit.newText) end - items[#items + 1] = item end - - return { isIncomplete = true, items = items } + return list end -------------------------------------------------------------------- -- in-process server -------------------------------------------------------------------- ----@return fun(dispatchers: table): table -local function new_server() +local function create_server(complete) return function(dispatchers) - local closing = false + local closing, request_id = false, 0 + local pending = {} local srv = {} - srv.request = vim.schedule_wrap(function(method, params, handler) - if method == "initialize" then - handler(nil, { - capabilities = { - positionEncoding = "utf-8", - completionProvider = { - triggerCharacters = trigger_characters(), + local function finish(id, err, result) + local request = pending[id] + if not request then + return + end + pending[id] = nil + if request.replied then + request.replied(id) + end + -- Match vim.lsp.rpc: cancellation acknowledges tracking without invoking + -- the consumer callback, which may already belong to an obsolete UI. + if not (err and err.code == vim.lsp.protocol.ErrorCodes.RequestCancelled) then + request.handler(err, result, id) + end + end + function srv.request(method, params, handler, replied) + if closing then + return false + end + request_id = request_id + 1 + local id = request_id + pending[id] = { handler = handler, replied = replied } + vim.schedule(function() + if not pending[id] then + return + end + if method == "initialize" then + finish(id, nil, { + capabilities = { + positionEncoding = "utf-8", + completionProvider = { triggerCharacters = trigger_characters() }, }, - }, - }) - elseif method == "textDocument/completion" then - local list = make_completion_list() - -- nil は応答保留 (選択挿入中のバースト)。放置されたリクエストは - -- クライアントが次の trigger 時に cancel してくれる - if list then - table.insert(M._requests, { params = params, items = #list.items }) - handler(nil, list) + }) + elseif method == "textDocument/completion" then + local ok, list = pcall(complete, params) + if not ok then + finish(id, { code = -32603, message = tostring(list) }) + elseif list then + table.insert(M._requests, { params = params, items = #list.items }) + finish(id, nil, list) + end + -- Native selection insertion can hold a response until cancellation. + elseif method == "shutdown" then + finish(id, nil, nil) + else + finish(id, { code = -32601, message = "Method not found: " .. method }) end - elseif method == "shutdown" then - handler(nil, nil) + end) + return true, id + end + function srv.notify(method, params) + if closing then + return false end - end) - function srv.notify(method, _) - if method == "exit" then - dispatchers.on_exit(0, 15) + if method == "$/cancelRequest" then + vim.schedule(function() + finish( + params.id, + { code = vim.lsp.protocol.ErrorCodes.RequestCancelled, message = "Request cancelled" } + ) + end) + elseif method == "exit" then + srv.terminate() end + return true end function srv.is_closing() return closing end function srv.terminate() + if closing then + return + end closing = true + pending = {} + vim.schedule(function() + dispatchers.on_exit(0, 15) + end) end return srv end end +--- Create a transport for vim.lsp.start(). Never enables a completion UI. +--- get_context can resolve virtual documents; nil means no active input there. +---@param get_context? fun(params: table): {line: string, row: integer, col: integer}? +---@return fun(dispatchers: table): table +function M.new_server(get_context) + local function complete(params) + if get_context then + local context = get_context(params) + return context and require("skkelua.completion").get(context) or { isIncomplete = true, items = {} } + end + local buf + for _, candidate in ipairs(vim.api.nvim_list_bufs()) do + if + vim.api.nvim_buf_is_loaded(candidate) + and vim.api.nvim_buf_get_name(candidate) ~= "" + and vim.uri_from_bufnr(candidate) == params.textDocument.uri + then + buf = candidate + break + end + end + if not buf then + return { isIncomplete = true, items = {} } + end + local row = params.position.line + local line = vim.api.nvim_buf_get_lines(buf, row, row + 1, false)[1] + if not line then + return { isIncomplete = true, items = {} } + end + return require("skkelua.completion").get({ line = line, row = row, col = params.position.character }) + end + return create_server(complete) +end + +local function new_server() + return create_server(make_completion_list) +end + -------------------------------------------------------------------- -- attach / detach -------------------------------------------------------------------- @@ -472,31 +315,13 @@ function M._on_complete_done(reason, completed_item) return end local item = vim.tbl_get(completed_item or {}, "user_data", "nvim", "lsp", "completion_item") - local data = item and item.data - if not (data and data.skkelua) then - return - end - if data.register then - -- [辞書登録] 項目: ins-completion の終了処理から抜けてから - -- 登録プロンプトを開く。挿入テキストが pre-edit のままなので - -- handle 側では変換入力の続きとして registerWord が実行される - vim.schedule(function() - require("skkelua").handle("handleKey", { ["function"] = "registerWord" }) - end) - return - end - -- abbrev の raw 候補 (半角スペース + 入力) は辞書へ登録しない - if data.raw then - return - end - require("skkelua").complete_callback(data.midasi, data.word, data.type) + require("skkelua.completion").accept(item) end local function on_complete_done() M._on_complete_done(vim.tbl_get(vim.v.event, "reason"), vim.v.completed_item) end - --- buffer-local 'completeopt' をグローバル値に戻す ---@param buf integer local function restore_completeopt(buf) @@ -544,7 +369,15 @@ function M.attach() local client_id = vim.lsp.start({ name = CLIENT_NAME, cmd = new_server(), - }, { bufnr = buf }) + skkelua_builtin_completion = true, + }, { + bufnr = buf, + reuse_client = function(client) + return client.name == CLIENT_NAME + and client.config.skkelua_builtin_completion == true + and not client:is_stopped() + end, + }) if not client_id then return end @@ -557,15 +390,26 @@ function M.attach() end end +local function builtin_client(buf) + for _, client in ipairs(vim.lsp.get_clients({ name = CLIENT_NAME, bufnr = buf })) do + if client.config.skkelua_builtin_completion then + return client + end + end +end + --- 現在のバッファで補完を明示的にトリガーする。 --- autotrigger はトリガー文字のタイプでしか働かないため、辞書登録の --- キャンセルなどタイプを伴わずに pre-edit が復元された時に呼ぶ function M.trigger() + if require("skkelua.completion").trigger() then + return + end if not completion_config().enabled or vim.fn.mode() ~= "i" then return end local buf = vim.api.nvim_get_current_buf() - local client = vim.lsp.get_clients({ name = CLIENT_NAME, bufnr = buf })[1] + local client = builtin_client(buf) if not client then return end @@ -575,11 +419,11 @@ end --- 現在のバッファで補完を無効にする (skkelua-disable-post から呼ばれる) function M.detach() local buf = vim.api.nvim_get_current_buf() - local client = vim.lsp.get_clients({ name = CLIENT_NAME, bufnr = buf })[1] + local client = builtin_client(buf) if client then vim.lsp.completion.enable(false, client.id, buf) + restore_completeopt(buf) end - restore_completeopt(buf) end --- 有効化・無効化に連動する autocmd を登録する (plugin/skkelua.lua から呼ばれる) @@ -604,7 +448,7 @@ function M.setup_autocmds() group = group, callback = function(ev) local client = vim.lsp.get_client_by_id(ev.data.client_id) - if client and client.name == CLIENT_NAME then + if client and client.config.skkelua_builtin_completion then enable_completion(ev.data.client_id, ev.buf) end end, diff --git a/tests/complete_key_spec.lua b/tests/complete_key_spec.lua index 120d6a7..856c972 100644 --- a/tests/complete_key_spec.lua +++ b/tests/complete_key_spec.lua @@ -52,11 +52,41 @@ t.test(" with pum selected passes through to native confirm", function() t.assert_equals("", skkelua.get_pre_edit()) end) -t.test(" with cmp selection returns cmp confirm command", function() +t.test(" delegates confirmation to an external adapter", function() setup_henkan_input() + local completion = require("skkelua.completion") + completion.set_adapter({ + state = function() + return { visible = true, selected = { word = "漢字", item = { data = { skkelua = true } } } } + end, + confirm = function() + return "external-confirm" + end, + }) + local status = require("skkelua").vim_status() + local ret = handle_key("", status.completeInfo, status.completeType) + t.assert_equals("external-confirm", ret.result) + t.assert_equals("", require("skkelua").get_pre_edit()) +end) - local ret = handle_key("", { pum_visible = 1, selected = 1 }, "cmp") - t.assert_equals("lua require('cmp').confirm({select = true})", ret.result) +t.test("external registration selection preserves pre-edit", function() + setup_henkan_input() + local completion = require("skkelua.completion") + completion.set_adapter({ + state = function() + return { + visible = true, + selected = { word = "▽かんじ", item = { data = { skkelua = true, register = true } } }, + } + end, + confirm = function() + return "external-confirm" + end, + }) + local status = require("skkelua").vim_status() + local ret = handle_key("", status.completeInfo, status.completeType) + t.assert_equals("external-confirm", ret.result) + t.assert_equals("▽かんじ", require("skkelua").get_pre_edit()) end) t.test(" on the selected [辞書登録] item keeps the henkan input state", function() diff --git a/tests/completion_api_spec.lua b/tests/completion_api_spec.lua new file mode 100644 index 0000000..c75553d --- /dev/null +++ b/tests/completion_api_spec.lua @@ -0,0 +1,284 @@ +local t = require("tests.helper") + +local function setup_input(keys) + local skk = require("skkelua") + skk._handle_request("enable", {}, { mode = "", prevInput = "", completeInfo = {}, completeType = "" }) + for _, key in ipairs(keys or { "K", "a", "n", "j", "i" }) do + skk._handle_request("handleKey", { key = { key } }, { + mode = "", + prevInput = skk.get_pre_edit(), + completeInfo = {}, + completeType = "", + }) + end + return skk +end + +t.test("external completion uses explicit byte position without changing UI", function() + local skk = setup_input() + require("skkelua.store").get_library():register_henkan_result("okurinasi", "かんじ", "漢字;annotation") + local before = vim.bo.completeopt + local line = "😀 " .. skk.get_pre_edit() .. " suffix" + local list = require("skkelua.completion").get({ line = line, row = 3, col = #("😀 " .. skk.get_pre_edit()) }) + t.assert_equals("漢字", list.items[1].label) + t.assert_equals("漢字", list.items[1].textEdit.newText) + t.assert_equals(1, list.items[1].insertTextFormat) + t.assert_equals({ line = 3, character = #"😀 " }, list.items[1].textEdit.range.start) + t.assert_equals(before, vim.bo.completeopt) + t.assert_equals(0, #require("skkelua.completion").get({ line = "stale", row = 0, col = 5 }).items) +end) + +t.test("external acceptance learns original dictionary metadata", function() + local skk = setup_input() + local lib = require("skkelua.store").get_library() + lib:register_henkan_result("okurinasi", "かんじ", "漢字;annotation") + local completion = require("skkelua.completion") + local item = completion.get({ line = skk.get_pre_edit(), row = 0, col = #skk.get_pre_edit() }).items[1] + t.assert_equals(true, completion.accept(item)) + t.assert_equals("漢字;annotation", require("skkelua.store").get_context().lastCandidate.candidate) + t.assert_equals(false, completion.accept({ label = "unrelated" })) +end) + +t.test("external server returns candidates from the request document", function() + local skk = setup_input() + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(buf, vim.fn.tempname()) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { skk.get_pre_edit() }) + local server = require("skkelua.lsp").new_server()({ on_exit = function() end }) + local result + local ok, id = server.request("textDocument/completion", { + textDocument = { uri = vim.uri_from_bufnr(buf) }, + position = { line = 0, character = #skk.get_pre_edit() }, + }, function(err, value) + t.assert_equals(nil, err) + result = value + end) + t.assert_equals(true, ok) + t.assert_true(type(id) == "number") + t.assert_true(vim.wait(1000, function() + return result ~= nil + end)) + t.assert_equals(true, result.items[#result.items].data.register) + server.terminate() + vim.api.nvim_buf_delete(buf, { force = true }) +end) + +t.test("external LSP works with a real client without enabling native UI", function() + local skk = setup_input() + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(buf, vim.fn.tempname()) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { skk.get_pre_edit() }) + local before = vim.bo[buf].completeopt + local id = vim.lsp.start({ name = "skkelua", cmd = require("skkelua.lsp").new_server() }, { + bufnr = buf, + reuse_client = function() + return false + end, + }) + local client = vim.lsp.get_client_by_id(id) + t.assert_true(vim.wait(1000, function() + return client.initialized + end)) + local result + local ok, request_id = client:request("textDocument/completion", { + textDocument = { uri = vim.uri_from_bufnr(buf) }, + position = { line = 0, character = #skk.get_pre_edit() }, + }, function(err, value) + t.assert_equals(nil, err) + result = value + end, buf) + t.assert_equals(true, ok) + t.assert_true(vim.wait(1000, function() + return result ~= nil + end)) + t.assert_equals(nil, client.requests[request_id]) + t.assert_equals(before, vim.bo[buf].completeopt) + t.assert_equals(true, result.items[#result.items].data.register) + client:stop(true) + vim.api.nvim_buf_delete(buf, { force = true }) +end) + +t.test("adapter routes selected insertion and restoration trigger", function() + vim.cmd.enew({ bang = true }) + local buf = vim.api.nvim_get_current_buf() + local completion = require("skkelua.completion") + vim.api.nvim_buf_set_lines(0, 0, -1, false, { "prefix 漢字 tail" }) + vim.api.nvim_win_set_cursor(0, { 1, #"prefix 漢字" }) + local calls = 0 + completion.set_adapter({ + state = function() + return { + visible = true, + selected = { word = "漢字", item = { data = { skkelua = true, word = "漢字" } } }, + } + end, + confirm = function() + return "\25" + end, + trigger = function() + calls = calls + 1 + end, + }) + local word, data = require("skkelua.lsp").selected_word() + t.assert_equals("漢字", word) + t.assert_equals("漢字", data.word) + t.assert_equals(true, completion.visible()) + require("skkelua.lsp").trigger() + t.assert_equals(1, calls) + completion.set_adapter(nil) + t.assert_equals(nil, completion.state()) + vim.api.nvim_buf_delete(buf, { force = true }) +end) + +t.test("virtual document provider supplies cmdline byte ranges", function() + local skk = setup_input() + local line = "検索 " .. skk.get_pre_edit() + local server = require("skkelua.lsp").new_server(function(params) + if params.textDocument.uri ~= "test:cmdline" then + return nil + end + return { line = line, row = 0, col = #line } + end)({ on_exit = function() end }) + local results = {} + for _, uri in ipairs({ "test:cmdline", "test:other" }) do + server.request("textDocument/completion", { textDocument = { uri = uri } }, function(err, result) + t.assert_equals(nil, err) + results[uri] = result + end) + end + t.assert_true(vim.wait(1000, function() + return results["test:other"] ~= nil + end)) + t.assert_equals(#"検索 ", results["test:cmdline"].items[1].textEdit.range.start.character) + t.assert_equals(0, #results["test:other"].items) + server.terminate() +end) + +t.test("abbrev external items are literal text and raw acceptance does not learn", function() + local skk = setup_input({ "/", "$", "x" }) + local completion = require("skkelua.completion") + local list = completion.get({ line = skk.get_pre_edit(), row = 0, col = #skk.get_pre_edit() }) + t.assert_equals(" $x", list.items[1].label) + t.assert_equals(" $x", list.items[1].textEdit.newText) + t.assert_equals(1, list.items[1].insertTextFormat) + local before = vim.deepcopy(require("skkelua.store").get_context().lastCandidate) + t.assert_equals(true, completion.accept(list.items[1])) + t.assert_equals(before, require("skkelua.store").get_context().lastCandidate) +end) + +t.test("detaching builtin completion preserves an external client", function() + t.assert_true(vim.wait(1000, function() + return #vim.lsp.get_clients({ name = "skkelua", _uninitialized = true }) == 0 + end)) + local lsp = require("skkelua.lsp") + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_set_current_buf(buf) + local external_id = vim.lsp.start({ name = "skkelua", cmd = lsp.new_server() }, { bufnr = buf }) + local external = vim.lsp.get_client_by_id(external_id) + t.assert_true(vim.wait(1000, function() + return external.initialized + end)) + require("skkelua").config({ completion = { enabled = true } }) + lsp.attach() + local builtin + t.assert_true(vim.wait(1000, function() + for _, client in ipairs(vim.lsp.get_clients({ bufnr = buf })) do + if client.config.skkelua_builtin_completion then + builtin = client + return true + end + end + return false + end)) + local get_clients = vim.lsp.get_clients + vim.lsp.get_clients = function(opts) + local clients = get_clients(opts) + -- Client iteration order is unspecified; cover external-first selection. + table.sort(clients, function(a, b) + return a.id < b.id + end) + return clients + end + local enable = vim.lsp.completion.enable + local disabled = {} + vim.lsp.completion.enable = function(on, id, ...) + if not on then + disabled[#disabled + 1] = id + end + return enable(on, id, ...) + end + local ok, err = pcall(function() + lsp.detach() + t.assert_equals({ builtin.id }, disabled) + t.assert_true(not external:is_stopped()) + t.assert_true(vim.lsp.buf_is_attached(buf, external_id)) + lsp.attach() + disabled = {} + require("skkelua").config({ completion = { enabled = false } }) + lsp.detach() + t.assert_equals({ builtin.id }, disabled) + t.assert_equals(vim.go.completeopt, vim.bo[buf].completeopt) + end) + vim.lsp.completion.enable = enable + vim.lsp.get_clients = get_clients + external:stop(true) + builtin:stop(true) + vim.api.nvim_buf_delete(buf, { force = true }) + if not ok then + error(err) + end +end) + +t.test("cancelled native completion clears tracking without invoking its callback", function() + t.assert_true(vim.wait(1000, function() + return #vim.lsp.get_clients({ name = "skkelua", _uninitialized = true }) == 0 + end)) + setup_input() + local lsp = require("skkelua.lsp") + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_set_current_buf(buf) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { "漢字 " }) + vim.api.nvim_win_set_cursor(0, { 1, #"漢字" }) + local state_reads = 0 + require("skkelua.completion").set_adapter({ + state = function() + state_reads = state_reads + 1 + return { visible = true, selected = { word = "漢字", item = { data = { skkelua = true } } } } + end, + confirm = function() + return "" + end, + }) + require("skkelua").config({ completion = { enabled = true } }) + lsp.attach() + local client + t.assert_true(vim.wait(1000, function() + client = vim.lsp.get_clients({ bufnr = buf })[1] + return client ~= nil + end)) + local called = false + local ok, id = client:request("textDocument/completion", { + textDocument = { uri = vim.uri_from_bufnr(buf) }, + position = { line = 0, character = #"漢字" }, + }, function() + called = true + end, buf) + t.assert_equals(true, ok) + local success, err = pcall(function() + t.assert_true(vim.wait(1000, function() + return state_reads > 0 + end)) + t.assert_true(client.requests[id] ~= nil) + client:cancel_request(id) + t.assert_true(vim.wait(1000, function() + return client.requests[id] == nil + end)) + t.assert_equals(false, called) + end) + require("skkelua.completion").set_adapter(nil) + client:stop(true) + vim.api.nvim_buf_delete(buf, { force = true }) + if not success then + error(err) + end +end) diff --git a/tests/completion_builtin_spec.lua b/tests/completion_builtin_spec.lua new file mode 100644 index 0000000..e8bbb76 --- /dev/null +++ b/tests/completion_builtin_spec.lua @@ -0,0 +1,119 @@ +local t = require("tests.helper") + +local function with_cmp(fn) + local previous = package.loaded.cmp + package.loaded.cmp = { + visible = function() + return true + end, + get_active_entry = function() + return { + get_completion_item = function() + return { label = "漢字" } + end, + } + end, + } + local ok, err = pcall(fn) + package.loaded.cmp = previous + if not ok then + error(err) + end +end + +t.test("enabled completion restores automatic cmp confirmation", function() + with_cmp(function() + require("skkelua").config({ completion = { enabled = true } }) + local completion = require("skkelua.completion") + t.assert_equals(true, completion.state().visible) + t.assert_true(completion.state().selected ~= nil) + t.assert_equals( + vim.api.nvim_replace_termcodes("lua require('cmp').confirm({select = true})", true, false, true), + completion.confirm() + ) + end) +end) + +t.test("disabled builtin completion preserves automatic cmp confirmation", function() + with_cmp(function() + require("skkelua").config({ completion = { enabled = false } }) + local completion = require("skkelua.completion") + t.assert_equals(true, completion.state().visible) + t.assert_true(completion.state().selected ~= nil) + t.assert_equals( + vim.api.nvim_replace_termcodes("lua require('cmp').confirm({select = true})", true, false, true), + completion.confirm() + ) + end) +end) + +t.test("explicit adapter takes priority and unset restores automatic handling", function() + with_cmp(function() + local skk = require("skkelua") + local completion = require("skkelua.completion") + completion.set_adapter({ + state = function() + return { visible = false } + end, + confirm = function() + return "custom" + end, + }) + for _, enabled in ipairs({ true, false }) do + skk.config({ completion = { enabled = enabled } }) + t.assert_equals(false, completion.state().visible) + t.assert_equals("custom", completion.confirm()) + end + skk.config({ completion = { enabled = true } }) + completion.set_adapter(nil) + t.assert_equals(true, completion.state().visible) + end) +end) + +t.test("enabled completion prefers pum and preserves registration metadata", function() + local original = { + exists = vim.fn.exists, + visible = vim.fn["pum#visible"], + info = vim.fn["pum#complete_info"], + current = vim.fn["pum#current_item"], + } + vim.fn.exists = function(name) + return name == "*pum#visible" and 1 or original.exists(name) + end + vim.fn["pum#visible"] = function() + return true + end + vim.fn["pum#complete_info"] = function(fields) + t.assert_equals({ "pum_visible", "selected", "inserted" }, fields) + return { + pum_visible = true, + selected = 0, + inserted = "▽かんじ", + } + end + vim.fn["pum#current_item"] = function() + return { user_data = { lspitem = vim.json.encode({ data = { skkelua = true, register = true } }) } } + end + local ok, err = pcall(function() + with_cmp(function() + require("skkelua").config({ completion = { enabled = true } }) + local completion = require("skkelua.completion") + local state = completion.state() + t.assert_equals("▽かんじ", state.selected.word) + t.assert_equals(true, completion.is_register_item(state.selected.item)) + t.assert_equals( + vim.api.nvim_replace_termcodes("call pum#map#confirm()", true, false, true), + completion.confirm() + ) + require("skkelua").config({ completion = { enabled = false } }) + t.assert_equals("▽かんじ", completion.state().selected.word) + end) + end) + vim.fn.exists = original.exists + vim.fn["pum#visible"] = original.visible + vim.fn["pum#complete_info"] = original.info + vim.fn["pum#current_item"] = original.current + if not ok then + error(err) + end +end) diff --git a/tests/helper.lua b/tests/helper.lua index 519ffc3..eeb7492 100644 --- a/tests/helper.lua +++ b/tests/helper.lua @@ -44,6 +44,7 @@ local default_config = nil --- 各テストの前に skkelua の内部状態をリセットする function M.reset() + require("skkelua.completion").set_adapter(nil) local config_mod = require("skkelua.config") if not default_config then default_config = vim.deepcopy(config_mod.config) From 88c0f03900f0178a59e32848cdebc8c43e3be58c Mon Sep 17 00:00:00 2001 From: atusy <30277794+atusy@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:47:56 +0900 Subject: [PATCH 2/2] feat: share external completion clients 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. --- README.md | 10 +-- doc/skkelua.txt | 46 ++++++++++--- lua/skkelua/completion.lua | 2 + lua/skkelua/lsp.lua | 79 ++++++++++++++++------ tests/completion_api_spec.lua | 122 +++++++++++++++++++++++++++++++--- 5 files changed, 215 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 159c0fc..5d68597 100644 --- a/README.md +++ b/README.md @@ -135,11 +135,13 @@ require("skkelua").config({ ### 外部の補完 UI との連携 `skkelua.completion.get()` / `accept()` で候補生成と確定処理を共有できます。 -`skkelua.lsp.new_server()` は組み込み UI を有効にせず LSP 経由で同じ候補を提供し、 +`skkelua.lsp.start(bufnr)` は、既定の文書解決を使い、バッファ間で LSP クライアントを共有します。 +組み込み UI は有効化しません。要求 URI と入力先の対応や、その要求が現在の入力を対象とするかの判定は接続側が担当します。 +`skkelua.lsp.new_server()` で接続を自前で管理することもでき、 `skkelua.completion.set_adapter()` で外部 UI の選択状態・確定キー・再補完を接続します。 -各 UI の候補ソースや確定後の学習は外部側で接続します。 -`completion.enabled` に関係なく、従来の cmp / pum.vim の自動検出と確定キー連携を使えます。 -明示的なアダプターは自動検出より優先されます。 +`completion.enabled` に関係なく、従来の cmp / pum.vim の自動検出と確定キー連携も使えます。 +`false` では組み込み補完を無効にし、外部側で接続を設定できます。 +明示的なアダプターは自動検出より優先されます。各 UI の候補ソースや確定後の学習は外部側で接続します。 詳細は `:help skkelua-completion-extension` を参照してください。 ## skkeleton との関係 diff --git a/doc/skkelua.txt b/doc/skkelua.txt index cc6f1d5..25885ef 100644 --- a/doc/skkelua.txt +++ b/doc/skkelua.txt @@ -354,9 +354,13 @@ autotrigger が失われて候補が自動表示されなくなります。 外部プラグインは候補生成・確定処理を再実装せず、以下の API を利用できます。 UI の表示、絞り込み、キー設定、LSP client の起動・停止は外部側が所有します。 -組み込み UI と併用しない場合は completion.enabled = false にしてください。 -この設定は組み込み補完だけを無効にし、以下の API は利用できます。 -cmp / pum.vim の自動検出と確定連携は設定に関係なく有効です。 +completion.enabled に関係なく、表示中の cmp / pum.vim +を自動検出して確定キーを連携します (pum.vim、cmp、native の順)。既存の +補完 UI との確定連携であり、各 UI の候補ソースを自動設定するものではありません。 + +自前で接続を管理する場合は completion.enabled = false にしてください。 +組み込み補完を無効にし、以下の API は利用できます。 +cmp / pum.vim の自動検出は引き続き有効です。 require("skkelua.completion").get({context}) 現在の SKK 状態から LSP CompletionList を返します。context は @@ -367,7 +371,9 @@ require("skkelua.completion").get({context}) 呼び出し側で判定してください (SKK 状態は文書ごとではありません)。 送りなし・送りあり・変換中・abbrev の候補、注釈、学習順、辞書登録項目 を共通で生成します。'completeopt' や UI、カーソルには触れません。 - textEdit は pre-edit 全体を置換する PlainText です。候補の表示順には + textEdit は pre-edit 全体を置換する PlainText です。insertText にも同じ + 文字列を設定するため、表示用 label を挿入文字列に使う必要はありません。 + 候補の表示順には sortText を使い、data を確定時まで保持してください。 require("skkelua.completion").accept({item}) @@ -379,12 +385,31 @@ require("skkelua.completion").accept({item}) 確定イベントで呼びます。登録項目は pre-edit と入力モードを保持して 確定してください。古い候補への遅延した確定通知は渡さないでください。 +require("skkelua.lsp").get_context({params}, {bufnr}?) + 要求 URI に一致するロード済み名前付きバッファから、params.position の行と + UTF-8 バイト位置を読み、context とバッファ番号を返します。 + 名前付きバッファが見つからなければ、URI が一致する現在バッファを使います。 + 無名バッファ同士は URI で区別できないため、現在バッファだけを対象にします。 + 文書を新規作成・ロードせず、対象や行が存在しなければ nil を返します。 + bufnr を指定すると URI の解決を省略し、そのバッファを読みます。 + 無名バッファ・仮想文書・URI の別名に使えますが、URI の検証は呼び出し側の責務です。 + 現在の SKK 入力先かどうかの判定やモード制限は呼び出し側で行ってください。 + +require("skkelua.lsp").start({bufnr}?) + 指定バッファ (省略時は現在のバッファ) を外部補完クライアント + "skkelua" に接続し、client ID を返します。バッファ間でクライアントを + 再利用し、接続済みなら再 attach しません。文書解決には上記の get_context を使います。 + UI を有効化せず、SKK 無効化後も接続を維持します。 + 独自の文書解決が必要な場合は new_server(get_context) と vim.lsp.start() を使います。 + require("skkelua.lsp").new_server({get_context}?) |vim.lsp.start()| の cmd に渡す in-process LSP transport を返します。 UI は有効化せず、LSP の位置エンコーディングは UTF-8 です。 - 既定では要求 URI のロード済み名前付きバッファと params.position を使います。 - 匿名バッファ・仮想文書・コマンドラインでは get_context(params) から get() と同じ - context を返せます。対象外の要求には nil を返してください。 + 既定では上記の get_context で要求 URI と位置からバッファを読みます。 + 独自の文書解決には get_context(params) から get() と同じ context を + 返してください。対象外の要求には nil を返すと、空の items を返します。 + 要求 URI と入力先の対応、現在の SKK 入力先かどうかの判定は接続側の責務です。 + 入力モードや補完プラグイン固有のバッファ構成は判定しません。 文書同期通知を蓄積するサーバーではありません。文書の内容は要求処理時に バッファまたは get_context から読みます。 >lua @@ -394,8 +419,9 @@ require("skkelua.lsp").new_server({get_context}?) cmd = require("skkelua.lsp").new_server(), }, { bufnr = vim.api.nvim_get_current_buf() }) < - 外部側で必要なバッファに attach し、SKK 無効時には補完要求を止めます。 - ライフサイクル連携には |skkelua-autocmds| を利用できます。確定時の + 外部側で必要なバッファに attach し、SKK 無効時も接続を維持できます。 + 候補生成側が SKK 無効時に空の items を返します。UI の有効・無効には + |skkelua-autocmds| を利用できます。確定時の accept() 呼び出しは LSP を利用する場合も外部側で接続してください。 require("skkelua.completion").set_adapter({adapter}?) @@ -555,7 +581,7 @@ skkeleton (denops 版) との違い: - 辞書ロードは同期です。SKK-JISYO.L 規模で数百 ms かかるため、 気になる場合は起動後に initialize() を呼んで事前ロードしてください。 - YAML 辞書、Deno KV データベース (updateDatabase) は非対応です。 -- ddc.vim 用ソースは同梱しません。|skkelua-completion-api| を使って +- 外部補完用ソースは同梱しません。|skkelua-completion-api| を使って 補完ソースを実装できます。 - skkserv への問い合わせは同期実行で、応答が無い場合 1 秒でタイムアウト します。 diff --git a/lua/skkelua/completion.lua b/lua/skkelua/completion.lua index e4d33e2..b9e1f14 100644 --- a/lua/skkelua/completion.lua +++ b/lua/skkelua/completion.lua @@ -238,6 +238,7 @@ function M.get(context) local annotation = c.word:match(";(.*)$") local item = { label = display, + insertText = display, labelDetails = annotation and { description = annotation } or nil, detail = c.midasi, kind = vim.lsp.protocol.CompletionItemKind.Text, @@ -279,6 +280,7 @@ function M.get(context) if registrable then local item = { label = "[辞書登録]", + insertText = pre_edit, detail = midasi, kind = vim.lsp.protocol.CompletionItemKind.Text, sortText = ("%05d"):format(#items + 1), diff --git a/lua/skkelua/lsp.lua b/lua/skkelua/lsp.lua index 51f08f0..2066890 100644 --- a/lua/skkelua/lsp.lua +++ b/lua/skkelua/lsp.lua @@ -165,6 +165,7 @@ local function make_completion_list() -- its insertion word falls back to filterText. item.insertTextFormat = vim.lsp.protocol.InsertTextFormat.Snippet item.textEdit.newText = escape_snippet(item.textEdit.newText) + item.insertText = item.textEdit.newText end end return list @@ -262,38 +263,76 @@ local function create_server(complete) end end ---- Create a transport for vim.lsp.start(). Never enables a completion UI. ---- get_context can resolve virtual documents; nil means no active input there. ----@param get_context? fun(params: table): {line: string, row: integer, col: integer}? ----@return fun(dispatchers: table): table -function M.new_server(get_context) - local function complete(params) - if get_context then - local context = get_context(params) - return context and require("skkelua.completion").get(context) or { isIncomplete = true, items = {} } - end - local buf +--- Read a request position from a loaded buffer without opening documents. +--- When bufnr is supplied, the caller is responsible for validating the URI. +---@param params table LSP completion params; character is a UTF-8 byte offset. +---@param bufnr? integer Explicit backing buffer for a virtual document or URI alias. +---@return {line: string, row: integer, col: integer}? context +---@return integer? bufnr +function M.get_context(params, bufnr) + if bufnr == 0 then + bufnr = vim.api.nvim_get_current_buf() + end + if bufnr == nil then for _, candidate in ipairs(vim.api.nvim_list_bufs()) do if vim.api.nvim_buf_is_loaded(candidate) and vim.api.nvim_buf_get_name(candidate) ~= "" and vim.uri_from_bufnr(candidate) == params.textDocument.uri then - buf = candidate + bufnr = candidate break end end - if not buf then - return { isIncomplete = true, items = {} } + if bufnr == nil and vim.uri_from_bufnr(0) == params.textDocument.uri then + bufnr = vim.api.nvim_get_current_buf() end - local row = params.position.line - local line = vim.api.nvim_buf_get_lines(buf, row, row + 1, false)[1] - if not line then - return { isIncomplete = true, items = {} } + end + if not bufnr or not vim.api.nvim_buf_is_loaded(bufnr) then + return nil + end + local row = params.position.line + local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] + if line then + return { line = line, row = row, col = params.position.character }, bufnr + end +end + +--- Create a transport for vim.lsp.start(). Never enables a completion UI. +--- The caller validates the input document; nil means no applicable context. +---@param get_context? fun(params: table): {line: string, row: integer, col: integer}? +---@return fun(dispatchers: table): table +function M.new_server(get_context) + return create_server(function(params) + local context = (get_context or M.get_context)(params) + return context and require("skkelua.completion").get(context) or { isIncomplete = true, items = {} } + end) +end + +--- Attach a buffer to the shared external completion client, once per buffer. +--- Keeps the client alive across SKK enable/disable and never enables a UI. +---@param bufnr? integer Defaults to the current buffer. +---@return integer? client_id +function M.start(bufnr) + bufnr = bufnr or vim.api.nvim_get_current_buf() + if bufnr == 0 then + bufnr = vim.api.nvim_get_current_buf() + end + for _, client in ipairs(vim.lsp.get_clients({ bufnr = bufnr, _uninitialized = true })) do + if client.config.skkelua_external_completion and not client:is_stopped() then + return client.id end - return require("skkelua.completion").get({ line = line, row = row, col = params.position.character }) end - return create_server(complete) + return vim.lsp.start({ + name = "skkelua", + cmd = M.new_server(), + skkelua_external_completion = true, + }, { + bufnr = bufnr, + reuse_client = function(client) + return client.config.skkelua_external_completion == true and not client:is_stopped() + end, + }) end local function new_server() diff --git a/tests/completion_api_spec.lua b/tests/completion_api_spec.lua index c75553d..a02e8a0 100644 --- a/tests/completion_api_spec.lua +++ b/tests/completion_api_spec.lua @@ -22,6 +22,9 @@ t.test("external completion uses explicit byte position without changing UI", fu local list = require("skkelua.completion").get({ line = line, row = 3, col = #("😀 " .. skk.get_pre_edit()) }) t.assert_equals("漢字", list.items[1].label) t.assert_equals("漢字", list.items[1].textEdit.newText) + for _, item in ipairs(list.items) do + t.assert_equals(item.textEdit.newText, item.insertText) + end t.assert_equals(1, list.items[1].insertTextFormat) t.assert_equals({ line = 3, character = #"😀 " }, list.items[1].textEdit.range.start) t.assert_equals(before, vim.bo.completeopt) @@ -69,12 +72,9 @@ t.test("external LSP works with a real client without enabling native UI", funct vim.api.nvim_buf_set_name(buf, vim.fn.tempname()) vim.api.nvim_buf_set_lines(buf, 0, -1, false, { skk.get_pre_edit() }) local before = vim.bo[buf].completeopt - local id = vim.lsp.start({ name = "skkelua", cmd = require("skkelua.lsp").new_server() }, { - bufnr = buf, - reuse_client = function() - return false - end, - }) + local lsp = require("skkelua.lsp") + local id = lsp.start(buf) + t.assert_equals(id, lsp.start(buf)) local client = vim.lsp.get_client_by_id(id) t.assert_true(vim.wait(1000, function() return client.initialized @@ -130,17 +130,17 @@ t.test("adapter routes selected insertion and restoration trigger", function() vim.api.nvim_buf_delete(buf, { force = true }) end) -t.test("virtual document provider supplies cmdline byte ranges", function() +t.test("custom document provider supplies byte ranges", function() local skk = setup_input() local line = "検索 " .. skk.get_pre_edit() local server = require("skkelua.lsp").new_server(function(params) - if params.textDocument.uri ~= "test:cmdline" then + if params.textDocument.uri ~= "test:input" then return nil end return { line = line, row = 0, col = #line } end)({ on_exit = function() end }) local results = {} - for _, uri in ipairs({ "test:cmdline", "test:other" }) do + for _, uri in ipairs({ "test:input", "test:other" }) do server.request("textDocument/completion", { textDocument = { uri = uri } }, function(err, result) t.assert_equals(nil, err) results[uri] = result @@ -149,7 +149,7 @@ t.test("virtual document provider supplies cmdline byte ranges", function() t.assert_true(vim.wait(1000, function() return results["test:other"] ~= nil end)) - t.assert_equals(#"検索 ", results["test:cmdline"].items[1].textEdit.range.start.character) + t.assert_equals(#"検索 ", results["test:input"].items[1].textEdit.range.start.character) t.assert_equals(0, #results["test:other"].items) server.terminate() end) @@ -160,12 +160,86 @@ t.test("abbrev external items are literal text and raw acceptance does not learn local list = completion.get({ line = skk.get_pre_edit(), row = 0, col = #skk.get_pre_edit() }) t.assert_equals(" $x", list.items[1].label) t.assert_equals(" $x", list.items[1].textEdit.newText) + for _, item in ipairs(list.items) do + t.assert_equals(item.textEdit.newText, item.insertText) + end t.assert_equals(1, list.items[1].insertTextFormat) local before = vim.deepcopy(require("skkelua.store").get_context().lastCandidate) t.assert_equals(true, completion.accept(list.items[1])) t.assert_equals(before, require("skkelua.store").get_context().lastCandidate) end) +t.test("document context resolves loaded buffers without creating unknown documents", function() + local lsp = require("skkelua.lsp") + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(buf, "test://completion/context") + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { "first", "😀 か" }) + local params = + { textDocument = { uri = vim.uri_from_bufnr(buf) }, position = { line = 1, character = #"😀 か" } } + local context, resolved = lsp.get_context(params) + t.assert_equals({ line = "😀 か", row = 1, col = #"😀 か" }, context) + t.assert_equals(buf, resolved) + params.textDocument.uri = "test://completion/unknown" + local count = #vim.api.nvim_list_bufs() + t.assert_equals(nil, lsp.get_context(params)) + t.assert_equals(count, #vim.api.nvim_list_bufs()) + t.assert_equals(context, lsp.get_context(params, buf)) + vim.api.nvim_buf_set_name(buf, "") + vim.api.nvim_set_current_buf(buf) + params.textDocument.uri = vim.uri_from_bufnr(buf) + local fallback, fallback_buf = lsp.get_context(params) + t.assert_equals(context, fallback) + t.assert_equals(buf, fallback_buf) + t.assert_equals(context, lsp.get_context(params, buf)) + params.textDocument.uri = "test://completion/unknown" + t.assert_equals(nil, lsp.get_context(params)) + params.position.line = 9 + t.assert_equals(nil, lsp.get_context(params, buf)) + vim.api.nvim_buf_delete(buf, { force = true }) + t.assert_equals(nil, lsp.get_context(params, buf)) +end) + +t.test("shared client resolves each requested buffer", function() + local skk = setup_input() + local lsp = require("skkelua.lsp") + local first = vim.api.nvim_create_buf(false, true) + local second = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(first, "test://completion/first") + vim.api.nvim_buf_set_name(second, "test://completion/second") + local text = "prefix " .. skk.get_pre_edit() + vim.api.nvim_buf_set_lines(first, 0, -1, false, { text }) + vim.api.nvim_buf_set_lines(second, 0, -1, false, { "stale" }) + local id = lsp.start(first) + local client = vim.lsp.get_client_by_id(id) + local ok, err = pcall(function() + t.assert_equals(id, lsp.start(first)) + t.assert_equals(id, lsp.start(second)) + t.assert_true(vim.wait(1000, function() + return client.initialized + end)) + local function items(buf) + local response = client:request_sync("textDocument/completion", { + textDocument = { uri = vim.uri_from_bufnr(buf) }, + position = { line = 0, character = #text }, + }, 1000, buf) + t.assert_equals(nil, response.err) + return response.result.items + end + t.assert_equals({ line = 0, character = #"prefix " }, items(first)[1].textEdit.range.start) + t.assert_equals(0, #items(second)) + vim.api.nvim_buf_set_lines(second, 0, -1, false, { text }) + vim.api.nvim_buf_set_lines(first, 0, -1, false, { "stale" }) + t.assert_true(#items(second) > 0) + t.assert_equals(0, #items(first)) + end) + client:stop(true) + vim.api.nvim_buf_delete(first, { force = true }) + vim.api.nvim_buf_delete(second, { force = true }) + if not ok then + error(err) + end +end) + t.test("detaching builtin completion preserves an external client", function() t.assert_true(vim.wait(1000, function() return #vim.lsp.get_clients({ name = "skkelua", _uninitialized = true }) == 0 @@ -229,6 +303,34 @@ t.test("detaching builtin completion preserves an external client", function() end end) +t.test("shared completion restarts a client before its exit callback runs", function() + t.assert_true(vim.wait(1000, function() + return #vim.lsp.get_clients({ name = "skkelua", _uninitialized = true }) == 0 + end)) + local lsp = require("skkelua.lsp") + local buf = vim.api.nvim_create_buf(false, true) + local first_id = lsp.start(buf) + local first = vim.lsp.get_client_by_id(first_id) + t.assert_true(vim.wait(1000, function() + return first.initialized + end)) + first:stop(true) + local second_id = lsp.start(buf) + local second = vim.lsp.get_client_by_id(second_id) + local ok, err = pcall(function() + t.assert_true(second_id ~= first_id) + t.assert_true(not second:is_stopped()) + t.assert_true(vim.wait(1000, function() + return second.initialized + end)) + end) + second:stop(true) + vim.api.nvim_buf_delete(buf, { force = true }) + if not ok then + error(err) + end +end) + t.test("cancelled native completion clears tracking without invoking its callback", function() t.assert_true(vim.wait(1000, function() return #vim.lsp.get_clients({ name = "skkelua", _uninitialized = true }) == 0