diff --git a/doc/skkelua.txt b/doc/skkelua.txt index 5f91084..92df28f 100644 --- a/doc/skkelua.txt +++ b/doc/skkelua.txt @@ -13,6 +13,7 @@ INTERFACE |skkelua-interface| AUTOCMDS |skkelua-autocmds| OPTIONS |skkelua-options| COMPLETION |skkelua-completion| + EXTENSION |skkelua-completion-extension| MACRO |skkelua-macro| INDICATOR |skkelua-indicator| SOURCES |skkelua-sources| @@ -175,6 +176,11 @@ AUTOCMDS *skkelua-autocmds* 参照できます。 skkelua-handled キー処理の完了後。 + skkelua-pre-edit-restored + 辞書登録のキャンセルなど、タイプを伴わずに pre-edit が復元された + 後。タイプを伴わないので補完 UI の自動表示は働きません。UI の補完を + 再要求するにはこのイベントで呼んでください (組み込みの補完は + skkelua が再要求します)。 skkelua-persistent-enable / skkelua-persistent-disable persistent mode の有効化・無効化時。 @@ -310,8 +316,10 @@ pureSpace (|skkelua-options|) も合わせて有効にすると快適です。 - 候補の選択挿入中に > を押すと、その候補で確定して接尾辞入力 (▽>) に 入ります (▼ 選択中の > と同じ動作)。接尾辞入力中 (▽>けい) も 補完メニューに候補が出ます -- 候補は |v:completed_item| 確定時 (CTRL-Y など) に pre-edit ごと置換され、 - ユーザー辞書へ登録されます (以後の候補順に反映されます) +- 候補は確定時 (CTRL-Y など) に pre-edit ごと置換され、ユーザー辞書へ + 登録されます (以後の候補順に反映されます)。登録は候補の command + (|skkelua-completion-extension|) として実行されるため、確定せず閉じた + (CTRL-E や ) 場合は登録されません - 候補を未選択のまま CTRL-Y を押すと、変換せずかなのまま確定します (デフォルトマップの kakuteiPassThrough、|skkelua-kanatable| 参照) - 送りなし入力の候補は、ユーザー辞書で確定した候補 (ランク持ち) が @@ -356,6 +364,67 @@ Note: |vim.lsp.completion.enable()| は最初にバッファの補完状態を 他の設定・プラグインが opts 無しで enable(true) を呼ぶと、skkelua の autotrigger が失われて候補が自動表示されなくなります。 +============================================================================== +EXTENSION *skkelua-completion-extension* + +外部補完との接続 ~ + +Neovim 組み込み以外の補完 UI でも、skkelua の候補を LSP 経由で受け取れます。 +候補の生成・辞書の学習・[辞書登録] は skkelua の in-process LSP サーバーが担い、 +UI 側は通常の LSP クライアントとして接続します。 + +require("skkelua.lsp").start({bufnr}?) *skkelua.lsp.start()* + {bufnr} (省略時は現在のバッファ) を共有 LSP クライアントへ attach し、 + client id を返します (起動できなければ nil)。クライアントはバッファ間で + 共有され、SKK の有効・無効に関係なく残ります。組み込みの補完 UI は + 有効化しません (|skkelua-completion| の completion.enabled とは + 独立です)。 + 候補は補完要求の位置 (UTF-8 のバイトオフセット) と、読み込み済み + バッファ名 (= URI) から引きます。名前の無いバッファは全て同じ URI に + なるため、現在のバッファが要求元として扱われます。仮想ドキュメントを + 使う場合は、バッファ名を要求の URI に合わせてください。 + 返す候補はスニペット化などの UI 固有の整形をしていません。 +>lua + vim.api.nvim_create_autocmd("InsertEnter", { + callback = function() + require("skkelua.lsp").start() + end, + }) +< +候補には command "skkelua.accept" が付いており、サーバーが +`executeCommandProvider` で宣言しています。クライアントが候補の確定時 +(キャンセル時ではなく) に |vim.lsp.Client:exec_cmd()| 相当で command を +実行すると、ユーザー辞書への学習と [辞書登録] の登録プロンプトが動きます。 +command を実行しない UI では、これらは動きません (pum.vim と nvim-cmp を +自動検出する場合も同じです)。 + +require("skkelua.completion").set_adapter({adapter}?) + *skkelua.completion.set_adapter()* + 補完メニューを持つ UI を skkelua のキー処理 (CTRL-Y, など) へ + 接続します。nil で自動検出に戻ります。{adapter} のフィールド: + - selected: `fun(): table?` + メニューで選択中の LSP CompletionItem (data を含む)。メニューが無い + か未選択なら nil を返します。item を持てない UI は空のテーブルで + 「選択中」だけを伝えられます。例外を投げた場合は未選択として扱います + - confirm: `fun(): string` + 選択中の候補を確定するキー列 (|nvim_replace_termcodes()| 済み)。 + 文字列が "" で始まる場合は、skkelua が を補って実行します。 + 同期的にテキストを変更しないでください +>lua + require("skkelua.completion").set_adapter({ + selected = function() + return my_menu.selected_item() + end, + confirm = function() + return vim.keycode("lua my_menu.confirm()") + end, + }) +< +明示的に設定したアダプターは自動検出より優先されます。アダプターを設定 +している間は、組み込みの補完メニューや pum.vim / nvim-cmp の表示状態は +参照されません。アダプターを設定しない場合は、表示中の pum.vim と nvim-cmp +を自動検出して CTRL-Y などの確定を連携します。 + ============================================================================== MACRO *skkelua-macro* diff --git a/lua/skkelua/completion.lua b/lua/skkelua/completion.lua new file mode 100644 index 0000000..1a8960d --- /dev/null +++ b/lua/skkelua/completion.lua @@ -0,0 +1,144 @@ +-- 組み込み以外の補完 UI (pum.vim / nvim-cmp / 外部 UI) の選択取得と確定。 +-- 補完候補そのものは lsp.lua の in-process サーバーが返し、 +-- ここは「いま何が選択されているか」と +-- 「選択中の候補を確定するキー」だけを UI ごとの差異として吸収する。 + +local M = {} + +---@class skkelua.CompletionAdapter +---@field selected fun(): table? メニューで選択中の LSP CompletionItem (data を含む)。メニューが無い・未選択なら nil。item を持てない UI は空のテーブルで「選択中」だけを伝える +---@field confirm fun(): string 選択中の候補を確定するキー列 (termcode 済み。"" で始まる文字列なら を補って実行する)。同期的にテキストを変更しない + +---@type skkelua.CompletionAdapter? +local adapter + +local function is_truthy(v) + return v == true or v == 1 +end + +--- pum.vim / cmp が持つ候補から LSP の CompletionItem を取り出す +---@param value any +---@return table +local function decode_item(value) + 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 = { + selected = function() + local info = vim.fn["pum#complete_info"]({ "selected" }) + if (info.selected or -1) < 0 then + return nil + end + return decode_item(vim.tbl_get(vim.fn["pum#current_item"]() or {}, "user_data", "lspitem")) + end, + confirm = function() + return "call pum#map#confirm()" + end, +} + +local cmp_adapter = { + selected = function() + local entry = package.loaded["cmp"].get_active_entry() + return entry and entry:get_completion_item() or nil + end, + confirm = function() + return "lua require('cmp').confirm({select = true})" + end, +} + +--- 補完 UI のアダプターを明示的に設定する。nil で自動検出に戻す。 +--- アダプターは UI 自身の寿命・候補の変換・確定イベントを担当する +---@param value? skkelua.CompletionAdapter +function M.set_adapter(value) + adapter = value +end + +--- 表示中のメニューを持つ UI を検出する。組み込みの補完メニューなら nil。 +--- 明示的に設定されたアダプターは自動検出より優先する +local function detect() + if adapter then + return adapter + end + if vim.fn.exists("*pum#visible") == 1 and is_truthy(vim.fn["pum#visible"]()) then + return pum_adapter + 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 + +--- アダプターの呼び出しで例外が出てもキー処理を止めない。 +--- state() は打鍵ごとに呼ばれるため、壊れたアダプターが全ての入力を巻き込む +---@param what string +---@param fn function +---@return boolean ok +---@return any result +local function call_adapter(what, fn) + local ok, result = pcall(fn) + if not ok then + vim.notify_once(("skkelua: completion adapter %s() failed: %s"):format(what, result), vim.log.levels.WARN) + end + return ok, result +end + +--- 選択中の候補。組み込みの補完メニューが担当するときは external が false +---@return boolean external 組み込み以外の UI が担当するか +---@return table? item 選択中の CompletionItem +function M.selection() + local active = detect() + if not active then + return false + end + local ok, item = call_adapter("selected", active.selected) + -- 状態が分からない間は、未選択として扱う + return true, ok and type(item) == "table" and item or nil +end + +--- 選択中の候補を確定するキー列 +---@return string? +function M.confirm() + local active = detect() + if not active then + return nil + end + local ok, keys = call_adapter("confirm", active.confirm) + return ok and type(keys) == "string" and keys or nil +end + +--- skkelua の候補が選択時にバッファへ挿入する文字列 +---@param item? table +---@return string? +function M.inserted_text(item) + local data = M.item_data(item) + return data and data.text or nil +end + +--- CompletionItem が skkelua の候補なら、その data を返す +---@param item? table +---@return table? +function M.item_data(item) + local data = item and item.data + if type(data) == "table" and data.skkelua == true then + return data + end + return nil +end + +--- CompletionItem が skkelua の [辞書登録] 項目かどうか +---@param item? table +---@return boolean +function M.is_register_item(item) + local data = M.item_data(item) + return data ~= nil and data.register == true +end + +return M diff --git a/lua/skkelua/function/dictionary.lua b/lua/skkelua/function/dictionary.lua index 45b26bb..86ba92e 100644 --- a/lua/skkelua/function/dictionary.lua +++ b/lua/skkelua/function/dictionary.lua @@ -182,11 +182,11 @@ local function register_word_float(context) replace_pre_edit(ctx:to_string()) ctx.preEdit:sync(ctx:to_string()) -- タイプを伴わない復元では補完の autotrigger が働かないため、 - -- キー処理が終わって insert に入ったところで明示的にトリガーする + -- キー処理が終わって insert に入ったところで補完 UI に知らせる vim.api.nvim_create_autocmd("SafeState", { once = true, callback = function() - require("skkelua.lsp").trigger() + require("skkelua.lsp").pre_edit_restored() end, }) end diff --git a/lua/skkelua/init.lua b/lua/skkelua/init.lua index 018ae69..031bbae 100644 --- a/lua/skkelua/init.lua +++ b/lua/skkelua/init.lua @@ -91,18 +91,11 @@ 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 external, item = require("skkelua.completion").selection() + if external then + -- 外部 UI は選択中の候補だけを伝える。未選択のメニューはキー処理に + -- 影響しないので、メニューが無いのと同じに扱う + return "external", { pum_visible = item ~= nil, selected = item and 0 or -1, completion_item = item } end -- items は選択中の [辞書登録] 項目の判定 (handle_impl) に使う return "native", vim.fn.complete_info({ "pum_visible", "selected", "items" }) @@ -148,10 +141,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 @@ -216,8 +207,8 @@ end --- 消して候補を入れるキー列を出力する。マクロには「pre-edit の削除 + 候補」 --- だけが記録され、再生時は補完に頼らず同じ文字列が入る ( は の --- 中で pum が見えている時だけ送るので、再生時には何もしない)。 ---- 確定した候補の辞書登録は native の確定 (CompleteDone) を通らないため ---- ここで行う +--- 確定した候補の辞書登録は、native の確定 (クライアントが item.command を +--- 実行する経路) を通らないためここで行う ---@param context skkelua.Context ---@param data table 候補の data (lsp.lua) ---@param word string pum の item.word (data.text が無い場合の挿入文字列) @@ -273,8 +264,12 @@ local function handle_impl(opts, vim_status) if completed and type(info.items) == "table" then sel_item = info.items[info.selected + 1] end - local lsp = require("skkelua.lsp") - local data = lsp.item_data(sel_item) + -- 選択中の候補を LSP の CompletionItem として扱う + -- (native は complete_info の items から、外部 UI はアダプターから取る) + local completion = require("skkelua.completion") + local completion_item = info.completion_item + or vim.tbl_get(sel_item or {}, "user_data", "nvim", "lsp", "completion_item") + local data = completion.item_data(completion_item) if vim_status.completeType == "native" and data @@ -289,9 +284,10 @@ local function handle_impl(opts, vim_status) else local handled = handle_complete_key(completed, vim_status.completeType, notation_str) if type(handled) == "string" then - -- [辞書登録] 項目の確定はバッファを変えず、CompleteDone からの - -- registerWord が変換入力の続きとして実行されるため状態を保つ - if not lsp.is_register_item(sel_item) then + -- [辞書登録] 項目の確定はバッファを変えず、候補の command + -- (skkelua.accept) からの registerWord が変換入力の続きとして + -- 実行されるため状態を保つ + if not completion.is_register_item(completion_item) then require("skkelua.mode").initialize_state_with_abbrev(context, { "converter" }) context.preEdit:output("") end diff --git a/lua/skkelua/lsp.lua b/lua/skkelua/lsp.lua index d3e718a..7a79920 100644 --- a/lua/skkelua/lsp.lua +++ b/lua/skkelua/lsp.lua @@ -1,13 +1,16 @@ --- 変換候補を Neovim builtin 補完へ流す in-process LSP サーバー +-- 変換候補を LSP (builtin 補完・外部の補完 UI) へ流す in-process サーバー -- -- 変換入力中 (▽かんじ) に、見出しを前方一致検索した変換候補を --- textDocument/completion の結果として返す。候補を確定すると --- CompleteDone でユーザー辞書へ登録される。 +-- textDocument/completion の結果として返す。候補を確定すると、 +-- クライアントが実行する item.command (skkelua.accept) で +-- ユーザー辞書へ登録される。 local M = {} local CLIENT_NAME = "skkelua" +local list_of = require("skkelua.lsp.list") + local function completion_config() return require("skkelua.config").config.completion end @@ -65,142 +68,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 の入力そのまま。completion.abbrevWithSpace で半角スペースを前置できる。登録・purge の対象外) - ---- 送りなし変換入力 (▽かんじ) の候補: 見出しの前方一致検索 ---- (@ddc-sources/skkeleton の gather に相当)。 ---- ユーザー辞書で確定済みの候補 (ランク持ち) を確定が新しい順に先頭へ置き、 ---- 残りは見出しの辞書順で並べる。 ---- abbrev モードでは入力の raw 候補を末尾に足す ---- (補完リストではスペース前置形は [辞書登録] の前、 ---- abbrevWithSpace = false の入力そのままは [辞書登録] の後ろに並ぶ) ----@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 - -- 入力そのものの raw 候補。completion.abbrevWithSpace (デフォルト true) だと - -- 前に半角スペースを足す (英単語を和文の中に空けて入れる用)。false なら - -- 入力そのまま (▽overall なら "overall") - local feed = context.state.henkanFeed - local with_space = require("skkelua.config").config.completion.abbrevWithSpace - local word = with_space and (" " .. feed) or feed - result[#result + 1] = { word = word, 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 を見つけられない @@ -208,6 +75,17 @@ end ---@return string? word ---@return table? data 候補の data (skkelua/midasi/word/type) local function selected_word(before_cursor) + local completion = require("skkelua.completion") + local external, item = completion.selection() + if external then + -- 選択挿入は pre-edit を候補の文字列で置き換える。UI が実際に挿入した + -- 文字列は分からないので、候補の挿入文字列がカーソル前にあるかで判断する + local word = completion.inserted_text(item) + if word and word ~= "" and vim.endswith(before_cursor, word) then + return word, completion.item_data(item) + end + return nil + end if vim.fn.pumvisible() == 0 then return nil end @@ -224,25 +102,6 @@ local function selected_word(before_cursor) return word, item.data end ---- complete_info() の item が skkelua の候補なら、その data を返す ----@param pum_item? table ----@return table? -function M.item_data(pum_item) - local data = vim.tbl_get(pum_item or {}, "user_data", "nvim", "lsp", "completion_item", "data") - if type(data) == "table" and data.skkelua == true then - return data - end - return nil -end - ---- complete_info() の item が skkelua の [辞書登録] 項目かどうか ----@param pum_item? table ----@return boolean -function M.is_register_item(pum_item) - local data = M.item_data(pum_item) - return data ~= nil and data.register == true -end - --- pum で選択中の自前候補の word が現在のカーソル前に挿入されていれば返す --- (deletePreEdit の削除対象判定や、選択挿入中の接尾辞開始に使う) ---@return string? word @@ -257,32 +116,15 @@ 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 + local row, col = pos[1] - 1, pos[2] -- utf-8 (byte) + local line = (vim.api.nvim_buf_get_lines(0, row, row + 1, false) or {})[1] or "" + local list, reason = require("skkelua.lsp.list").build({ line = line, row = row, col = col }) + if reason == "stale" then -- insertOnSelect の選択挿入中 (バッファは pre-edit でなく候補 word) に -- 届いた後発リクエスト。pre-edit の再描画は InsertCharPre ごとに -- リクエストを積むため、選択挿入を起こした応答の後にも同じ状態への @@ -290,30 +132,16 @@ local function make_completion_list() -- 同じ候補を返し直しても complete() の再実行で typed text が候補 word に -- すり替わり / で pre-edit に戻れなくなる。 -- 応答を保留して、表示中の pum と選択状態をそのまま保つ - if selected_word(before_cursor) then + if selected_word(line:sub(1, col)) then return nil end - 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() + return list + elseif reason then + return list end local instant_insert = completion_config().insertOnSelect + local pre_edit = require("skkelua").get_pre_edit() -- typed text (pre-edit) に ASCII 英数字が含まれるとクライアント側の -- fuzzy フィルタが有効になり、素の label ではマッチしなくなる。 -- その場合は label に pre-edit を前置してフィルタを通し、 @@ -323,140 +151,76 @@ local function make_completion_list() -- deferOkuri で送り仮名が確定した直後 (▽おく*る) は、第一候補を -- 自動選択して即挿入する (completeopt の noselect をこの応答だけ外す) local auto_select = false - if instant_insert and phase == "input:okuriari" then + if instant_insert and require("skkelua").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 = {} - -- abbrev の raw 候補のうち入力そのもの (abbrevWithSpace = false) は - -- 辞書候補ではないので [辞書登録] より後ろに回す。スペース前置形は - -- 英単語をそのまま入れる主用途なので [辞書登録] より前に置く - local raw_trailing = not completion_config().abbrevWithSpace - local trailing = {} - 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, - textEdit = { - range = range, - newText = display, - }, - -- okuri (送り仮名の生かな) は purgeCandidate が ▽henkanFeed*okuriFeed - -- を組み立て直すのに使う (midasi は語幹 + 送り仮名アルファベットの - -- 辞書見出し形式で、そのままでは送り仮名を分離できない)。 - -- text は確定時にバッファへ入れる文字列 (init.lua の finalize_completion) - data = { - skkelua = true, - midasi = c.midasi, - word = c.word, - type = c.type, - okuri = c.okuri, - raw = c.raw, - text = display, - }, - } - 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 - if c.raw and raw_trailing then - trailing[#trailing + 1] = item - else - items[#items + 1] = item - end - end - end + set_completeopt(0, auto_select) - -- 新しい読みを登録する項目を辞書候補の後ろに置く (候補が無い読みでも 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, - textEdit = { - range = range, - newText = pre_edit, - }, - data = { skkelua = true, register = true, text = pre_edit }, - } + for _, item in ipairs(list.items) do if instant_insert then - item.insertTextFormat = vim.lsp.protocol.InsertTextFormat.PlainText + -- insertOnSelect: filterText を持たせないことで、クライアントの + -- 挿入 word が newText (候補そのもの) になり、 での + -- 選択と同時に pre-edit 全体が候補へ置き換わる + item.filterText = nil if prefixed_label then + item.data.display = item.label item.label = pre_edit .. item.label - item.data.display = "[辞書登録]" end else - item.filterText = 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(pre_edit) + item.textEdit.newText = escape_snippet(item.textEdit.newText) end - items[#items + 1] = item end - vim.list_extend(items, trailing) + return list +end - -- クライアント (builtin) は sortText (無ければ label) で並べ替える。 - -- 辞書順 (ユーザー辞書 -> グローバル辞書のマージ順) と上の配置を保つよう - -- 応答順の連番を振る - for i, item in ipairs(items) do - item.sortText = ("%05d"):format(i) +--- 候補の確定 (workspace/executeCommand) を処理する。 +--- クライアントは確定したときだけ item.command を実行するので、 +--- / などで閉じた場合は何も起きない +---@param params {command: string, arguments?: table[]} +local function execute_command(params) + if params.command ~= list_of.ACCEPT_COMMAND then + -- level 0: クライアントへ返すエラーに位置情報を付けない + error("unknown command: " .. tostring(params.command), 0) end - - return { isIncomplete = true, items = items } + local data = params.arguments and params.arguments[1] + if not (data and data.skkelua) then + return + end + if data.register then + -- [辞書登録] 項目: 登録プロンプトを開く。サーバーはリクエストを + -- vim.schedule で処理するので、補完の終了処理は既に抜けている。 + -- 挿入テキストが pre-edit のままなので + -- handle 側では変換入力の続きとして registerWord が実行される + require("skkelua").handle("handleKey", { ["function"] = "registerWord" }) + return + end + -- abbrev の raw 候補 (入力そのもの) は辞書へ登録しない + if data.raw then + return + end + require("skkelua").complete_callback(data.midasi, data.word, data.type) end -------------------------------------------------------------------- -- in-process server -------------------------------------------------------------------- +--- in-process サーバーの transport (vim.lsp.start の cmd) +---@param complete fun(params: table): table? textDocument/completion の応答。 +--- nil は応答保留 (選択挿入中のバースト) ---@return fun(dispatchers: table): table -local function new_server() +local function new_server(complete) return function(dispatchers) local closing = false local srv = {} - srv.request = vim.schedule_wrap(function(method, params, handler) + local request_id = 0 + local function handle(method, params, handler) if method == "initialize" then handler(nil, { capabilities = { @@ -464,24 +228,44 @@ local function new_server() completionProvider = { triggerCharacters = trigger_characters(), }, + executeCommandProvider = { commands = { list_of.ACCEPT_COMMAND } }, }, }) elseif method == "textDocument/completion" then - local list = make_completion_list() - -- nil は応答保留 (選択挿入中のバースト)。放置されたリクエストは - -- クライアントが次の trigger 時に cancel してくれる - if list then + local ok, list = pcall(complete, params) + if not ok then + handler({ code = -32603, message = tostring(list) }, nil) + elseif list then + -- nil は応答保留 (選択挿入中のバースト)。放置されたリクエストは + -- クライアントが次の trigger 時に cancel してくれる table.insert(M._requests, { params = params, items = #list.items }) handler(nil, list) end + elseif method == "workspace/executeCommand" then + local ok, err = pcall(execute_command, params) + handler(not ok and { code = -32603, message = tostring(err) } or nil, nil) elseif method == "shutdown" then handler(nil, nil) + else + -- 応答が無いとクライアントのリクエストが残り続ける + handler( + { code = vim.lsp.protocol.ErrorCodes.MethodNotFound, message = "method not found: " .. method }, + nil + ) end - end) + end + function srv.request(method, params, handler) + request_id = request_id + 1 + vim.schedule(function() + handle(method, params, handler) + end) + return true, request_id + end function srv.notify(method, _) if method == "exit" then dispatchers.on_exit(0, 15) end + return true end function srv.is_closing() return closing @@ -497,42 +281,6 @@ end -- attach / detach -------------------------------------------------------------------- ---- 候補確定時にユーザー辞書へ登録する ---- (テスト用に reason と completed_item を注入できるよう分離している) ----@param reason? string v:event.reason ("accept"/"cancel"/"discard") ----@param completed_item? table v:completed_item -function M._on_complete_done(reason, completed_item) - -- / などで確定せず閉じた場合 (cancel/discard) は登録しない。 - -- reason が取れない環境では従来通り登録する - if reason ~= nil and reason ~= "accept" then - 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) -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) @@ -565,10 +313,6 @@ local function enable_completion(client_id, buf) vim.lsp.completion.enable(false, client_id, buf) vim.lsp.completion.enable(true, client_id, buf, { autotrigger = true, convert = convert_item }) set_completeopt(buf, false) - vim.api.nvim_create_autocmd("CompleteDone", { - group = vim.api.nvim_create_augroup("skkelua-lsp-complete-done", { clear = true }), - callback = on_complete_done, - }) end --- 現在のバッファで補完を有効にする (skkelua-enable-post から呼ばれる) @@ -579,8 +323,15 @@ function M.attach() local buf = vim.api.nvim_get_current_buf() local client_id = vim.lsp.start({ name = CLIENT_NAME, - cmd = new_server(), - }, { bufnr = buf }) + cmd = new_server(make_completion_list), + skkelua_builtin_completion = true, + }, { + bufnr = buf, + -- 外部 UI 向けの共有クライアント (start) を組み込みの補完に流用しない + reuse_client = function(client) + return client.config.skkelua_builtin_completion == true and not client:is_stopped() + end, + }) if not client_id then return end @@ -593,16 +344,26 @@ function M.attach() end end ---- 現在のバッファで補完を明示的にトリガーする。 ---- autotrigger はトリガー文字のタイプでしか働かないため、辞書登録の ---- キャンセルなどタイプを伴わずに pre-edit が復元された時に呼ぶ -function M.trigger() +---@param buf integer +---@return vim.lsp.Client? +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 + +--- タイプを伴わずに pre-edit が復元された (辞書登録のキャンセルなど) ことを知らせる。 +--- autotrigger はトリガー文字のタイプでしか働かないため、builtin の補完は +--- ここで明示的に再要求する。外部の補完 UI は User イベントで再要求する +function M.pre_edit_restored() + pcall(vim.api.nvim_exec_autocmds, "User", { pattern = "skkelua-pre-edit-restored", modeline = false }) 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] - if not client then + if not builtin_client(buf) then return end vim.lsp.completion.get() @@ -611,7 +372,7 @@ 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) end @@ -640,13 +401,94 @@ 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, }) end +-------------------------------------------------------------------- +-- 外部の補完 UI 向け +-------------------------------------------------------------------- + +--- 補完要求の位置から、読み込み済みバッファ上の入力行を引く。 +--- ドキュメントを開くことはしない。URI と入力先の対応づけは +--- この関数が担う範囲 (バッファ名 = URI) に限る +---@param params table textDocument/completion の params。character は UTF-8 のバイトオフセット +---@return {line: string, row: integer, col: integer}? +local function request_context(params) + local uri = vim.tbl_get(params, "textDocument", "uri") + local row = vim.tbl_get(params, "position", "line") + local col = vim.tbl_get(params, "position", "character") + if not (uri and row and col) then + return nil + end + local function matches(bufnr) + return vim.api.nvim_buf_is_loaded(bufnr) and vim.uri_from_bufnr(bufnr) == uri + end + -- 補完要求は打鍵ごとに届くので、多くの場合に当たる現在のバッファを先に調べ、 + -- 仮想ドキュメントなど現在のバッファでない場合だけ全バッファを走査する。 + -- 名前の無いバッファは全て同じ URI になるので、現在のバッファを先に調べる + -- ことがそのまま要求元の特定になる (他の名前無しバッファと取り違えるのは + -- 現在のバッファが要求元でない場合だけで、その行は pre-edit で終わらず + -- 候補は出ない) + local bufnr = vim.api.nvim_get_current_buf() + if not matches(bufnr) then + bufnr = nil + for _, candidate in ipairs(vim.api.nvim_list_bufs()) do + if matches(candidate) then + bufnr = candidate + break + end + end + end + local line = bufnr and vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] + if line then + return { line = line, row = row, col = col } + end +end + +--- 外部の補完 UI が使う LSP クライアントを、バッファに一度だけ attach する。 +--- クライアントはバッファ間で共有され、SKK の有効・無効に関係なく残る。 +--- 組み込みの補完 UI は有効化しない。候補は UI 非依存の形のまま返す +---@param bufnr? integer 省略時は現在のバッファ +---@return integer? client_id +function M.start(bufnr) + if bufnr == nil or 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 + end + return vim.lsp.start({ + name = CLIENT_NAME, + cmd = new_server(function(params) + local context = request_context(params) + return context and list_of.build(context) or { isIncomplete = true, items = {} } + end), + skkelua_external_completion = true, + }, { + bufnr = bufnr, + reuse_client = function(client) + return client.config.skkelua_external_completion == true and not client:is_stopped() + end, + }) +end + +--- テスト用: 確定コマンドを直接実行する +---@param params {command: string, arguments?: table[]} +function M._execute_command(params) + execute_command(params) +end + +--- テスト用: 任意のバッファへ attach できるトランスポート +function M._new_server() + return new_server(make_completion_list) +end + --- テスト用: completion list を直接組み立てる function M._make_completion_list() return make_completion_list() diff --git a/lua/skkelua/lsp/candidates.lua b/lua/skkelua/lsp/candidates.lua new file mode 100644 index 0000000..4f8e8bd --- /dev/null +++ b/lua/skkelua/lsp/candidates.lua @@ -0,0 +1,141 @@ +-- in-process LSP サーバーが返す変換候補の生成 (UI・transport に依存しない) + +local M = {} + +---@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 の入力そのまま。completion.abbrevWithSpace で半角スペースを前置できる。登録・purge の対象外) + +--- 送りなし変換入力 (▽かんじ) の候補: 見出しの前方一致検索 +--- (@ddc-sources/skkeleton の gather に相当)。 +--- ユーザー辞書で確定済みの候補 (ランク持ち) を確定が新しい順に先頭へ置き、 +--- 残りは見出しの辞書順で並べる。 +--- abbrev モードでは入力の raw 候補を末尾に足す +--- (補完リストではスペース前置形は [辞書登録] の前、 +--- abbrevWithSpace = false の入力そのままは [辞書登録] の後ろに並ぶ) +---@return skkelua.LspCandidate[] +function M.okurinasi() + 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 + -- 入力そのものの raw 候補。completion.abbrevWithSpace (デフォルト true) だと + -- 前に半角スペースを足す (英単語を和文の中に空けて入れる用)。false なら + -- 入力そのまま (▽overall なら "overall") + local feed = context.state.henkanFeed + local with_space = require("skkelua.config").config.completion.abbrevWithSpace + local word = with_space and (" " .. feed) or feed + result[#result + 1] = { word = word, 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[] +function M.henkan() + 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[] +function M.okuriari() + 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 + +return M diff --git a/lua/skkelua/lsp/list.lua b/lua/skkelua/lsp/list.lua new file mode 100644 index 0000000..b63e017 --- /dev/null +++ b/lua/skkelua/lsp/list.lua @@ -0,0 +1,165 @@ +-- 変換入力中の pre-edit から LSP の CompletionList を組み立てる。 +-- 補完 UI には依存せず、UI 固有の整形 (insertOnSelect やスニペット化) は +-- 呼び出し側 (lsp.lua) が行う。 + +local M = {} + +--- 候補の確定時にクライアントが実行するコマンド +M.ACCEPT_COMMAND = "skkelua.accept" + +local candidates_of = require("skkelua.lsp.candidates") + +local function completion_config() + return require("skkelua.config").config.completion +end + +--- 新しい読みを登録する項目の見出し。登録する読みが定まらない間は nil +---@param phase string +---@return string? +local function register_midasi(phase) + local state = require("skkelua.store").get_context().state + if phase == "henkan" then + return state.word + elseif phase == "input:okuriari" then + -- 送り仮名が確定するまでは登録する読みが定まらない + if state.feed == "" and state.okuriFeed ~= "" then + return require("skkelua.okuri").get_okuri_str(state.henkanFeed, state.okuriFeed) + end + return nil + end + return state.henkanFeed +end + +--- 確定時に辞書学習や登録プロンプトを起動するための command を付ける。 +--- クライアントは確定した時だけ実行するため、キャンセルでは何も起きない +---@param item table CompletionItem +local function with_command(item) + item.command = { title = "skkelua", command = M.ACCEPT_COMMAND, arguments = { item.data } } + return item +end + +--- 補完候補を組み立てる。位置は 0 始まりの UTF-8 バイトオフセット +---@param context {line: string, row: integer, col: integer} +---@return table list CompletionList +---@return "inactive"|"stale"? reason 変換入力中でなければ "inactive"、 +--- カーソル前のテキストが pre-edit で終わっていなければ "stale" +function M.build(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, "inactive" + end + local pre_edit = skkelua.get_pre_edit() + -- 変換入力中はかなが無ければ出さない (henkan は候補が引けているので不要) + if pre_edit == "" or (phase ~= "henkan" and skkelua.get_prefix() == "") then + return empty, "inactive" + end + + -- カーソル前のテキストが pre-edit (▽かんじ) で終わっていることを確認し、 + -- その開始位置を置換範囲にする + local row, col, line = context.row, context.col, context.line + if not vim.endswith(line:sub(1, col), pre_edit) then + return empty, "stale" + end + local range = { + start = { line = row, character = col - #pre_edit }, + ["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 = candidates_of.okurinasi() + elseif phase == "input:okuriari" then + candidates = candidates_of.okuriari() + else + candidates = candidates_of.henkan() + end + + local items = {} + -- abbrev の raw 候補のうち入力そのもの (abbrevWithSpace = false) は + -- 辞書候補ではないので [辞書登録] より後ろに回す。スペース前置形は + -- 英単語をそのまま入れる主用途なので [辞書登録] より前に置く + local raw_trailing = not completion_config().abbrevWithSpace + local trailing = {} + 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, + -- クライアントは typed text と filterText を照合する。 + -- 送りなし入力中は続きのかな入力で絞り込めるよう marker + 見出し、 + -- それ以外 (送りあり入力・候補選択) は pre-edit そのもの + -- (絞り込みは isIncomplete の再リクエストが担う) + filterText = phase == "input:okurinasi" and (marker .. c.midasi) or pre_edit, + insertTextFormat = vim.lsp.protocol.InsertTextFormat.PlainText, + textEdit = { + range = range, + newText = display, + }, + -- okuri (送り仮名の生かな) は purgeCandidate が ▽henkanFeed*okuriFeed + -- を組み立て直すのに使う (midasi は語幹 + 送り仮名アルファベットの + -- 辞書見出し形式で、そのままでは送り仮名を分離できない)。 + -- text は確定時にバッファへ入れる文字列 (init.lua の finalize_completion) + data = { + skkelua = true, + midasi = c.midasi, + word = c.word, + type = c.type, + okuri = c.okuri, + raw = c.raw, + text = display, + }, + } + with_command(item) + if c.raw and raw_trailing then + trailing[#trailing + 1] = item + else + items[#items + 1] = item + end + end + end + + -- 新しい読みを登録する項目を辞書候補の後ろに置く (候補が無い読みでも pum が開く)。 + -- 挿入テキストは pre-edit 自身にして、フォーカスや確定でバッファが + -- 変わらないようにする。確定すると登録プロンプトが開く + -- (登録プロンプトの中ではネストしたプロンプトが積まれる)。 + local midasi = register_midasi(phase) + if midasi then + items[#items + 1] = with_command({ + label = "[辞書登録]", + detail = midasi, + kind = vim.lsp.protocol.CompletionItemKind.Text, + filterText = pre_edit, + insertTextFormat = vim.lsp.protocol.InsertTextFormat.PlainText, + textEdit = { + range = range, + newText = pre_edit, + }, + data = { skkelua = true, register = true, text = pre_edit }, + }) + end + vim.list_extend(items, trailing) + + -- クライアントは sortText (無ければ label) で並べ替える。 + -- 辞書順 (ユーザー辞書 -> グローバル辞書のマージ順) と上の配置を保つよう + -- 応答順の連番を振る + for i, item in ipairs(items) do + item.sortText = ("%05d"):format(i) + end + + return { isIncomplete = true, items = items } +end + +return M diff --git a/tests/complete_key_spec.lua b/tests/complete_key_spec.lua index add14d8..613d198 100644 --- a/tests/complete_key_spec.lua +++ b/tests/complete_key_spec.lua @@ -87,7 +87,7 @@ t.test(" with a skkelua item selected commits it with skkelua's own output" t.assert_equals(REVERT_CMD .. "\b\b\b\b漢字", ret.result) t.assert_equals("input", ret.state.phase) t.assert_equals("", skkelua.get_pre_edit()) - -- 確定した候補はユーザー辞書へ登録される (CompleteDone を通らないため自前で行う) + -- 確定した候補はユーザー辞書へ登録される (native の確定を通らないため自前で行う) t.assert_equals("漢字", lib:get_henkan_result("okurinasi", "かんじ")[1]) t.assert_equals("漢字", require("skkelua.store").get_context().lastCandidate.candidate) end) @@ -158,7 +158,23 @@ end) t.test(" with cmp selection returns cmp confirm command", function() setup_henkan_input() - local ret = handle_key("", { pum_visible = 1, selected = 1 }, "cmp") + local saved = package.loaded["cmp"] + package.loaded["cmp"] = { + visible = function() + return true + end, + get_active_entry = function() + return { + get_completion_item = function() + return { data = { skkelua = true } } + end, + } + end, + } + local status = require("skkelua").vim_status() + local ok, ret = pcall(handle_key, "", status.completeInfo, status.completeType) + package.loaded["cmp"] = saved + assert(ok, ret) t.assert_equals("lua require('cmp').confirm({select = true})", ret.result) end) @@ -167,7 +183,8 @@ t.test(" on the selected [辞書登録] item keeps the henkan input state", setup_henkan_input() -- [辞書登録] の挿入テキストは pre-edit そのものでバッファは変わらず、 - -- CompleteDone からの registerWord が変換入力の続きとして実行される。 + -- 候補の command (skkelua.accept) からの registerWord が + -- 変換入力の続きとして実行される。 -- 確定キーへのパススルー時に状態をリセットしてはいけない local items = { { diff --git a/tests/completion_spec.lua b/tests/completion_spec.lua new file mode 100644 index 0000000..6d4048f --- /dev/null +++ b/tests/completion_spec.lua @@ -0,0 +1,266 @@ +-- 外部の補完 UI アダプター (completion.set_adapter) のテスト + +local t = require("tests.helper") + +local vim_status = { mode = "", prevInput = "", completeInfo = {}, completeType = "" } + +--- 現在の補完 UI の状態を渡して handleKey を呼ぶ +---@param key string +local function handle_key(key) + local skkelua = require("skkelua") + local status = skkelua.vim_status() + return skkelua._handle_request("handleKey", { key = { key } }, { + mode = "", + prevInput = require("skkelua.store").get_context():to_string(), + completeInfo = status.completeInfo, + completeType = status.completeType, + }) +end + +--- ▽かんじ の変換入力状態を作る +local function setup_henkan_input() + require("skkelua")._handle_request("enable", {}, vim_status) + for _, k in ipairs({ "K", "a", "n", "j", "i" }) do + handle_key(k) + end + t.assert_equals("▽かんじ", require("skkelua").get_pre_edit()) +end + +--- 選択中の候補だけを返すアダプター +---@param item table CompletionItem +local function adapter_selecting(item) + return { + selected = function() + return item + end, + confirm = function() + return "external-confirm" + end, + } +end + +t.test(" delegates confirmation to the adapter", function() + setup_henkan_input() + require("skkelua.completion").set_adapter(adapter_selecting({ data = { skkelua = true } })) + local ret = handle_key("") + t.assert_equals("external-confirm", ret.result) + t.assert_equals("", require("skkelua").get_pre_edit()) +end) + +t.test(" on a selected [辞書登録] item keeps the pre-edit", function() + setup_henkan_input() + require("skkelua.completion").set_adapter(adapter_selecting({ data = { skkelua = true, register = true } })) + local ret = handle_key("") + t.assert_equals("external-confirm", ret.result) + t.assert_equals("▽かんじ", require("skkelua").get_pre_edit()) +end) + +t.test(" without a selection is not handed to the adapter", function() + setup_henkan_input() + require("skkelua.completion").set_adapter({ + selected = function() + return nil + end, + confirm = function() + return "external-confirm" + end, + }) + local ret = handle_key("") + t.assert_true(ret.result ~= "external-confirm") +end) + +t.test("an explicit adapter takes precedence over detected UIs", function() + local saved = package.loaded["cmp"] + package.loaded["cmp"] = { + visible = function() + return true + end, + get_active_entry = function() + return {} + end, + } + require("skkelua.completion").set_adapter(adapter_selecting({})) + local confirm = require("skkelua.completion").confirm() + package.loaded["cmp"] = saved + t.assert_equals("external-confirm", confirm) +end) + +t.test("a restored pre-edit is announced to completion UIs through a User event", function() + local fired = 0 + local id = vim.api.nvim_create_autocmd("User", { + pattern = "skkelua-pre-edit-restored", + callback = function() + fired = fired + 1 + end, + }) + require("skkelua.lsp").pre_edit_restored() + vim.api.nvim_del_autocmd(id) + t.assert_equals(1, fired) +end) + +t.test("selected_word finds the selected candidate's text before the cursor", function() + 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, { "漢字 " }) + -- Normal モードでもカーソル前が "漢字" になるよう、後ろに文字を置く + vim.api.nvim_win_set_cursor(0, { 1, #"漢字" }) + local item = { data = { skkelua = true, word = "漢字", text = "漢字" } } + require("skkelua.completion").set_adapter({ + selected = function() + return item + end, + confirm = function() + return "" + end, + }) + local word, data = require("skkelua.lsp").selected_word() + vim.api.nvim_buf_delete(buf, { force = true }) + t.assert_equals("漢字", word) + t.assert_equals(item.data, data) +end) + +t.test("selected_word ignores a selection that is not inserted yet", function() + 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, #"▽かんじ" }) + require("skkelua.completion").set_adapter(adapter_selecting({ data = { skkelua = true, text = "漢字" } })) + local word = require("skkelua.lsp").selected_word() + vim.api.nvim_buf_delete(buf, { force = true }) + t.assert_equals(nil, word) +end) + +t.test("items from other sources are not treated as skkelua candidates", function() + local completion = require("skkelua.completion") + t.assert_equals(nil, completion.item_data({ data = { other = true } })) + t.assert_equals(false, completion.is_register_item({ data = { skkelua = true } })) + t.assert_equals(true, completion.is_register_item({ data = { skkelua = true, register = true } })) +end) + +t.test("an adapter that throws does not break key handling", function() + require("skkelua.store").get_library():register_henkan_result("okurinasi", "かんじ", "漢字") + setup_henkan_input() + require("skkelua.completion").set_adapter({ + selected = function() + error("boom") + end, + confirm = function() + error("boom") + end, + }) + local notify = vim.notify_once + vim.notify_once = function() end + local ok, err = pcall(function() + -- 状態が分からない間はメニュー無しとして扱い、skkelua 自身が処理する + t.assert_equals({ true, nil }, { require("skkelua.completion").selection() }) + t.assert_equals(nil, require("skkelua.completion").confirm()) + handle_key("") + end) + vim.notify_once = notify + if not ok then + error(err, 0) + end + t.assert_equals("henkan", require("skkelua.store").get_context().state.type) +end) + +t.test("adapter results are normalized", function() + local completion = require("skkelua.completion") + completion.set_adapter({ + selected = function() + return "not an item" + end, + confirm = function() + return 1 + end, + }) + t.assert_equals({ true, nil }, { completion.selection() }) + t.assert_equals(nil, completion.confirm()) + -- data を持てない UI は空の CompletionItem で「選択中」だけ伝えられる + completion.set_adapter({ + selected = function() + return {} + end, + confirm = function() + return "" + end, + }) + t.assert_equals({ true, {} }, { completion.selection() }) +end) + +--- pum.vim の関数を差し替えて fn を実行する +--- (autoload 関数は autoload/pum.vim にしか定義できないので一時ディレクトリに置く) +---@param info table pum#complete_info() の戻り値 +---@param current table pum#current_item() の戻り値 +---@param fn fun() +local function with_pum(info, current, fn) + local dir = vim.fn.tempname() + vim.fn.mkdir(dir .. "/autoload", "p") + vim.fn.writefile({ + "function! pum#visible() abort", + " return v:true", + "endfunction", + "function! pum#complete_info(...) abort", + " return g:skkelua_test_pum_info", + "endfunction", + "function! pum#current_item() abort", + " return g:skkelua_test_pum_current", + "endfunction", + }, dir .. "/autoload/pum.vim") + vim.g.skkelua_test_pum_info = info + vim.g.skkelua_test_pum_current = current + vim.opt.runtimepath:prepend(dir) + vim.cmd("runtime autoload/pum.vim") + local ok, err = pcall(fn) + vim.opt.runtimepath:remove(dir) + vim.cmd("delfunction pum#visible | delfunction pum#complete_info | delfunction pum#current_item") + vim.fn.delete(dir, "rf") + if not ok then + error(err, 0) + end +end + +t.test("pum.vim reports the selected LSP item", function() + local lspitem = vim.json.encode({ label = "漢字", data = { skkelua = true } }) + with_pum( + { pum_visible = true, selected = 1, inserted = "漢字" }, + { user_data = { lspitem = lspitem } }, + function() + local external, item = require("skkelua.completion").selection() + t.assert_equals(true, external) + t.assert_equals({ label = "漢字", data = { skkelua = true } }, item) + end + ) +end) + +t.test("pum.vim has no selection while nothing is selected", function() + with_pum({ pum_visible = true, selected = -1 }, {}, function() + t.assert_equals({ true, nil }, { require("skkelua.completion").selection() }) + end) +end) + +t.test("nvim-cmp reports the active entry's completion item", function() + local saved = package.loaded["cmp"] + local item = { label = "漢字", data = { skkelua = true, register = true } } + package.loaded["cmp"] = { + visible = function() + return true + end, + get_active_entry = function() + return { + get_completion_item = function() + return item + end, + } + end, + } + local ok, err = pcall(function() + local external, selected = require("skkelua.completion").selection() + t.assert_equals(true, external) + t.assert_equals(item, selected) + t.assert_equals(true, require("skkelua.completion").is_register_item(selected)) + end) + package.loaded["cmp"] = saved + if not ok then + error(err, 0) + end +end) diff --git a/tests/helper.lua b/tests/helper.lua index 519ffc3..6a3733a 100644 --- a/tests/helper.lua +++ b/tests/helper.lua @@ -55,6 +55,12 @@ function M.reset() config_mod.config[k] = v end require("skkelua.kana")._reset() + require("skkelua.completion").set_adapter(nil) + -- 前のテストが残した LSP クライアントを持ち越さない + -- (builtin/外部の取り違えが、残っていたクライアントで隠れてしまう) + for _, client in ipairs(vim.lsp.get_clients({ name = "skkelua" })) do + client:stop(true) + end require("skkelua")._reset_for_test() local store = require("skkelua.store") store.variables.lastMode = "hira" diff --git a/tests/integration_spec.lua b/tests/integration_spec.lua index 96c36e6..9b00602 100644 --- a/tests/integration_spec.lua +++ b/tests/integration_spec.lua @@ -504,3 +504,87 @@ t.test("< is converted immediately in zenkaku mode", function() t.assert_equals({ "<" }, vim.fn.getline(1, "$")) end) end) + +--- 実際の builtin 補完メニューを開いて label の項目を選び、keys を打つ。 +--- skkelua.complete_callback と registerWord の呼び出しを記録して返す +---@param label string 選択する項目の表示 +---@param keys string 選択後に打つキー +local function with_real_menu(label, keys) + local lib = require("skkelua.store").get_library() + lib:register_henkan_result("okurinasi", "かんじ", "感じ") + lib:register_henkan_result("okurinasi", "かんじ", "漢字") + require("skkelua").config({ completion = { enabled = true } }) + + local skkelua = require("skkelua") + local calls = { learned = {}, handled = {} } + local complete_callback, handle = skkelua.complete_callback, skkelua.handle + skkelua.complete_callback = function(...) + calls.learned[#calls.learned + 1] = { ... } + return complete_callback(...) + end + skkelua.handle = function(func, handle_opts) + if func == "handleKey" and handle_opts["function"] == "registerWord" then + calls.handled[#calls.handled + 1] = handle_opts["function"] + return + end + return handle(func, handle_opts) + end + local ok, err = pcall(with_buffer, function() + -- 実クライアントの初期化 (非同期) を待ってから補完を要求する + -- (マッピングの中なら insert のまま待てる) + vim.keymap.set("i", "", function() + t.assert_true(vim.wait(2000, function() + local client = vim.lsp.get_clients({ name = "skkelua", bufnr = 0 })[1] + return client ~= nil and client.initialized + end)) + vim.lsp.completion.get() + t.assert_true(vim.wait(2000, function() + return vim.fn.pumvisible() == 1 + end)) + end, { buffer = true }) + -- 初期の選択状態に依らず label の項目へ移動する + vim.keymap.set("i", "", function() + local info = vim.fn.complete_info({ "items", "selected" }) + local want + for i, item in ipairs(info.items) do + if (item.abbr ~= "" and item.abbr or item.word) == label then + want = i - 1 + end + end + t.assert_true(want ~= nil, label .. " is not in the menu") + local delta = want - info.selected + local move = delta > 0 and (""):rep(delta) or (""):rep(-delta) + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(move, true, true, true), "ni", false) + end, { buffer = true }) + feed("iJKanji" .. keys) + -- 遅れて届く重複実行も拾う + vim.wait(150, function() + return false + end) + end) + skkelua.complete_callback, skkelua.handle = complete_callback, handle + if not ok then + error(err, 0) + end + return calls +end + +t.test("confirming a builtin menu item learns it exactly once", function() + local calls = with_real_menu("漢字", "") + t.assert_equals({ { "かんじ", "漢字", "okurinasi" } }, calls.learned) + t.assert_equals({}, calls.handled) +end) + +t.test("closing the builtin menu without confirming learns nothing", function() + for _, keys in ipairs({ "", "" }) do + local calls = with_real_menu("漢字", keys) + t.assert_equals({}, calls.learned, keys) + t.assert_equals({}, calls.handled, keys) + end +end) + +t.test("confirming [辞書登録] in the builtin menu opens the prompt once through its command", function() + local calls = with_real_menu("[辞書登録]", "") + t.assert_equals({}, calls.learned) + t.assert_equals({ "registerWord" }, calls.handled) +end) diff --git a/tests/lsp_spec.lua b/tests/lsp_spec.lua index aac5758..f92917f 100644 --- a/tests/lsp_spec.lua +++ b/tests/lsp_spec.lua @@ -362,7 +362,7 @@ t.test("okuriari candidate registers with okuriari type", function() local skkelua = require("skkelua") skkelua.config({ completion = { enabled = true } }) skkelua._handle_request("enable", {}, vim_status) - -- CompleteDone 相当 (data.type = okuriari) + -- accept コマンドが行う学習 (data.type = okuriari) skkelua.complete_callback("はしr", "走", "okuriari") local lib = require("skkelua.store").get_library() t.assert_equals({ "走" }, lib:get_henkan_result("okuriari", "はしr")) @@ -528,6 +528,21 @@ t.test("register item is hidden while okuri is undetermined", function() vim.cmd.bwipeout({ bang = true }) end) +t.test("completeopt is still prepared when a response has no items", function() + local skkelua = require("skkelua") + skkelua.config({ completion = { enabled = true, insertOnSelect = true } }) + skkelua._handle_request("enable", {}, vim_status) + + -- 辞書に無い ▽おく*r は候補も [辞書登録] も無いが、以前の応答で + -- 外れた noselect を戻すため buffer-local の 'completeopt' は整える + setup_state({ "O", "k", "u", "R" }) + vim.bo.completeopt = "menu,popup" + local list = require("skkelua.lsp")._make_completion_list() + t.assert_equals(0, #list.items) + t.assert_true(vim.bo.completeopt:find("noselect", 1, true) ~= nil, vim.bo.completeopt) + vim.cmd.bwipeout({ bang = true }) +end) + t.test("register item appears inside the prompt buffer", function() local skkelua = require("skkelua") skkelua.config({ completion = { enabled = true } }) @@ -557,16 +572,9 @@ t.test("accepting register item opens the prompt via handle", function() called[#called + 1] = { func = func, opts = opts } end local ok, err = pcall(function() - lsp_mod._on_complete_done("accept", { - user_data = { - nvim = { - lsp = { - completion_item = { - data = { skkelua = true, register = true }, - }, - }, - }, - }, + lsp_mod._execute_command({ + command = "skkelua.accept", + arguments = { { skkelua = true, register = true } }, }) vim.wait(50, function() return #called > 0 @@ -584,39 +592,55 @@ t.test("accepting register item opens the prompt via handle", function() t.assert_equals({}, lib:get_henkan_result("okurinasi", "ぬぬ")) end) -t.test("CompleteDone registers only on accept", function() +t.test("accepting a candidate learns it through its command", function() + setup_library() local skkelua = require("skkelua") skkelua.config({ completion = { enabled = true } }) skkelua._handle_request("enable", {}, vim_status) + setup_henkan_state() local lib = require("skkelua.store").get_library() local lsp_mod = require("skkelua.lsp") - local function completed_item(word) - return { - user_data = { - nvim = { - lsp = { - completion_item = { - data = { skkelua = true, midasi = "むこう", word = word, type = "okurinasi" }, - }, - }, - }, - }, - } - end + local list = lsp_mod._make_completion_list() + local item = vim.iter(list.items):find(function(i) + return i.data.word == "漢字" + end) + t.assert_equals("skkelua.accept", item.command.command) + t.assert_equals(item.data, item.command.arguments[1]) - -- などによる discard / cancel では登録しない - lsp_mod._on_complete_done("discard", completed_item("無香")) - lsp_mod._on_complete_done("cancel", completed_item("無効")) - t.assert_equals({}, lib:get_henkan_result("okurinasi", "むこう")) + -- 確定されなかった候補は command が実行されないので学習されない + t.assert_equals("感じ", lib:get_henkan_result("okurinasi", "かんじ")[1]) + lsp_mod._execute_command(item.command) + t.assert_equals("漢字", lib:get_henkan_result("okurinasi", "かんじ")[1]) + vim.cmd.bwipeout({ bang = true }) +end) - -- accept ( 等) では登録する - lsp_mod._on_complete_done("accept", completed_item("向こう")) - t.assert_equals({ "向こう" }, lib:get_henkan_result("okurinasi", "むこう")) +t.test("the server runs the accept command sent by a client", function() + setup_library() + local skkelua = require("skkelua") + skkelua.config({ completion = { enabled = true } }) + skkelua._handle_request("enable", {}, vim_status) + setup_henkan_state() + local lib = require("skkelua.store").get_library() - -- reason が取れない環境では従来通り登録する - lsp_mod._on_complete_done(nil, completed_item("尨")) - t.assert_equals({ "尨", "向こう" }, lib:get_henkan_result("okurinasi", "むこう")) + local buf = vim.api.nvim_get_current_buf() + local client_id = vim.lsp.start({ name = "skkelua", cmd = require("skkelua.lsp")._new_server() }, { bufnr = buf }) + local client = assert(vim.lsp.get_client_by_id(client_id)) + t.assert_true(vim.wait(1000, function() + return client.initialized + end)) + t.assert_equals({ "skkelua.accept" }, client.server_capabilities.executeCommandProvider.commands) + + local item = vim.iter(require("skkelua.lsp")._make_completion_list().items):find(function(i) + return i.data.word == "漢字" + end) + client:exec_cmd(item.command, { bufnr = buf }) + local learned = vim.wait(1000, function() + return lib:get_henkan_result("okurinasi", "かんじ")[1] == "漢字" + end) + client:stop(true) + vim.cmd.bwipeout({ bang = true }) + t.assert_true(learned) end) t.test("complete_callback registers the selected candidate", function() @@ -626,7 +650,7 @@ t.test("complete_callback registers the selected candidate", function() skkelua._handle_request("enable", {}, vim_status) setup_henkan_state() - -- CompleteDone 相当の処理 (data 付き候補の確定) + -- accept コマンドが行う学習 (data 付き候補の確定) skkelua.complete_callback("かんじ", "漢字") local lib = require("skkelua.store").get_library() local candidates = lib:get_henkan_result("okurinasi", "かんじ") @@ -666,9 +690,7 @@ t.test("abbrev input adds a space-prefixed raw candidate above [辞書登録]", t.assert_equals(true, raw.data.raw) -- raw 候補の確定ではユーザー辞書に登録されない - require("skkelua.lsp")._on_complete_done("accept", { - user_data = { nvim = { lsp = { completion_item = { data = raw.data } } } }, - }) + require("skkelua.lsp")._execute_command(raw.command) t.assert_equals({ "全体" }, lib:get_henkan_result("okurinasi", "overall")) vim.cmd.bwipeout({ bang = true }) @@ -723,9 +745,7 @@ t.test("completion.abbrevWithSpace=false uses the raw input without a leading sp t.assert_equals(true, raw.data.raw) -- 入力そのものの候補を確定しても辞書には登録されない - require("skkelua.lsp")._on_complete_done("accept", { - user_data = { nvim = { lsp = { completion_item = { data = raw.data } } } }, - }) + require("skkelua.lsp")._execute_command(raw.command) t.assert_equals({ "全体" }, lib:get_henkan_result("okurinasi", "overall")) vim.cmd.bwipeout({ bang = true }) diff --git a/tests/lsp_start_spec.lua b/tests/lsp_start_spec.lua new file mode 100644 index 0000000..d02094a --- /dev/null +++ b/tests/lsp_start_spec.lua @@ -0,0 +1,264 @@ +-- 外部の補完 UI 向け共有クライアント (lsp.start) のテスト + +local t = require("tests.helper") + +local vim_status = { mode = "", prevInput = "", completeInfo = {}, completeType = "" } + +--- ▽かんじ を置いたバッファを作る。name が無ければ名前の無いバッファ +---@param name? string +---@return integer bufnr +local function setup_henkan_buffer(name) + local lib = require("skkelua.store").get_library() + lib:register_henkan_result("okurinasi", "かんじ", "漢字") + local skkelua = require("skkelua") + skkelua._handle_request("enable", {}, vim_status) + for _, k in ipairs({ "K", "a", "n", "j", "i" }) do + skkelua._handle_request("handleKey", { key = { k } }, { + mode = "", + prevInput = require("skkelua.store").get_context():to_string(), + completeInfo = {}, + completeType = "", + }) + end + t.assert_equals("▽かんじ", skkelua.get_pre_edit()) + vim.cmd.enew({ bang = true }) + local buf = vim.api.nvim_get_current_buf() + if name then + vim.api.nvim_buf_set_name(buf, vim.fn.tempname() .. "-" .. name) + end + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { "▽かんじ" }) + return buf +end + +---@param buf integer +---@return vim.lsp.Client +local function started_client(buf) + local id = require("skkelua.lsp").start(buf) + local client = assert(vim.lsp.get_client_by_id(id)) + t.assert_true(vim.wait(1000, function() + return client.initialized + end)) + return client +end + +--- 同期的に textDocument/completion を要求する +---@param client vim.lsp.Client +---@param buf integer +---@param character integer +local function request_completion(client, buf, character) + local result + client:request("textDocument/completion", { + textDocument = { uri = vim.uri_from_bufnr(buf) }, + position = { line = 0, character = character }, + }, function(_, res) + result = res or false + end, buf) + t.assert_true(vim.wait(1000, function() + return result ~= nil + end)) + return result +end + +local function cleanup(...) + for _, client in ipairs({ ... }) do + client:stop(true) + end + vim.cmd.bwipeout({ bang = true }) +end + +t.test("start shares one client across buffers and calls", function() + local first = setup_henkan_buffer("a") + local client = started_client(first) + t.assert_equals(client.id, require("skkelua.lsp").start(first)) + + vim.cmd.enew({ bang = true }) + local second = vim.api.nvim_get_current_buf() + t.assert_equals(client.id, require("skkelua.lsp").start(second)) + t.assert_true(vim.lsp.buf_is_attached(first, client.id)) + t.assert_true(vim.lsp.buf_is_attached(second, client.id)) + vim.cmd.bwipeout({ bang = true }) + cleanup(client) +end) + +t.test("start's client reports the request as sent with an id and can cancel it", function() + -- 戻り値で送信の成否を判断するクライアントは、id が無いと結果を捨てる。builtin の補完は見ないので別に確認する + local buf = setup_henkan_buffer("sent") + local client = started_client(buf) + local ok, id = client:request("textDocument/completion", { + textDocument = { uri = vim.uri_from_bufnr(buf) }, + position = { line = 0, character = #"▽かんじ" }, + }, function() end, buf) + t.assert_equals(true, ok) + t.assert_true(type(id) == "number") + t.assert_equals(true, client:cancel_request(id)) + cleanup(client) +end) + +t.test("start returns a UI-independent list for the requested position", function() + local buf = setup_henkan_buffer("b") + local client = started_client(buf) + local list = request_completion(client, buf, #"▽かんじ") + + t.assert_equals( + { "漢字", "[辞書登録]" }, + vim.tbl_map(function(item) + return item.label + end, list.items) + ) + -- 組み込みメニュー向けの整形 (スニペット化・filterText の除去) はしない + local item = list.items[1] + t.assert_equals(vim.lsp.protocol.InsertTextFormat.PlainText, item.insertTextFormat) + t.assert_equals("漢字", item.textEdit.newText) + t.assert_equals("▽かんじ", item.filterText) + t.assert_equals("skkelua.accept", item.command.command) + cleanup(client) +end) + +t.test("start serves the current buffer even when it has no name", function() + -- 名前の無いバッファは全て同じ URI になるため、現在のバッファを要求元とみなす + local buf = setup_henkan_buffer() + t.assert_equals("", vim.api.nvim_buf_get_name(buf)) + local client = started_client(buf) + local list = request_completion(client, buf, #"▽かんじ") + t.assert_equals( + { "漢字", "[辞書登録]" }, + vim.tbl_map(function(item) + return item.label + end, list.items) + ) + cleanup(client) +end) + +t.test("start returns nothing for unknown documents and stale positions", function() + local buf = setup_henkan_buffer("c") + local client = started_client(buf) + local unknown = {} + client:request("textDocument/completion", { + textDocument = { uri = "file:///no/such/document" }, + position = { line = 0, character = 0 }, + }, function(_, res) + unknown.result = res + end, buf) + t.assert_true(vim.wait(1000, function() + return unknown.result ~= nil + end)) + t.assert_equals(0, #unknown.result.items) + -- カーソル前のテキストが pre-edit で終わらない位置では候補を出さない + t.assert_equals(0, #request_completion(client, buf, #"▽か").items) + cleanup(client) +end) + +--- buf に attach されている builtin 用のクライアント +local function builtin_client(buf) + for _, client in ipairs(vim.lsp.get_clients({ bufnr = buf, name = "skkelua" })) do + if client.config.skkelua_builtin_completion then + return client + end + end +end + +t.test("start keeps its client apart from the builtin one in either order", function() + local buf = setup_henkan_buffer("d") + require("skkelua").config({ completion = { enabled = true } }) + -- 停止済み (終了通知待ち) のクライアントは数えない + t.assert_equals(0, #vim.tbl_filter(function(client) + return not client:is_stopped() + end, vim.lsp.get_clients({ name = "skkelua" }))) + + -- external -> builtin + local external = started_client(buf) + require("skkelua.lsp").attach() + t.assert_true(vim.wait(1000, function() + return builtin_client(buf) ~= nil + end)) + local builtin = assert(builtin_client(buf)) + t.assert_true(external.id ~= builtin.id) + t.assert_equals(nil, external.config.skkelua_builtin_completion) + t.assert_equals(external.id, require("skkelua.lsp").start(buf)) + cleanup(external, builtin) + + -- builtin -> external + buf = setup_henkan_buffer("d2") + require("skkelua.lsp").attach() + t.assert_true(vim.wait(1000, function() + return builtin_client(buf) ~= nil + end)) + builtin = assert(builtin_client(buf)) + external = started_client(buf) + t.assert_true(external.id ~= builtin.id) + t.assert_equals(true, external.config.skkelua_external_completion) + cleanup(external, builtin) +end) + +t.test("start uses the current buffer by default and restarts after the client stops", function() + local buf = setup_henkan_buffer("g") + local client = started_client(buf) + t.assert_equals(client.id, require("skkelua.lsp").start()) + t.assert_equals(client.id, require("skkelua.lsp").start(0)) + + client:stop(true) + t.assert_true(vim.wait(1000, function() + return client:is_stopped() + end)) + local restarted = started_client(buf) + t.assert_true(restarted.id ~= client.id) + cleanup(restarted) +end) + +t.test("start's client stays attached after SKK is disabled", function() + local buf = setup_henkan_buffer("h") + local client = started_client(buf) + require("skkelua")._handle_request("disable", {}, vim_status) + t.assert_true(vim.lsp.buf_is_attached(buf, client.id)) + t.assert_equals(false, client:is_stopped()) + cleanup(client) +end) + +--- 同期的にリクエストし、(err, result) を返す +---@param client vim.lsp.Client +---@param buf integer +---@param method string +---@param params table +local function request(client, buf, method, params) + local done, err, result = false, nil, nil + client:request(method, params, function(e, r) + done, err, result = true, e, r + end, buf) + t.assert_true( + vim.wait(1000, function() + return done + end), + method .. " was never answered" + ) + return err, result +end + +t.test("the server answers requests it cannot serve", function() + local buf = setup_henkan_buffer("e") + local client = started_client(buf) + + local err = request(client, buf, "completionItem/resolve", { label = "x" }) + t.assert_equals(vim.lsp.protocol.ErrorCodes.MethodNotFound, err.code) + + -- position の無い補完要求も応答を返す (候補は無い) + local _, list = request(client, buf, "textDocument/completion", { + textDocument = { uri = vim.uri_from_bufnr(buf) }, + }) + t.assert_equals(0, #list.items) + + err = request(client, buf, "workspace/executeCommand", { command = "no.such.command" }) + t.assert_equals(-32603, err.code) + t.assert_equals("unknown command: no.such.command", err.message) + cleanup(client) +end) + +t.test("start finds the requested document when it is not the current buffer", function() + local buf = setup_henkan_buffer("f") + local client = started_client(buf) + vim.cmd.enew({ bang = true }) + local list = request_completion(client, buf, #"▽かんじ") + t.assert_equals(2, #list.items) + vim.cmd.bwipeout({ bang = true }) + vim.api.nvim_buf_delete(buf, { force = true }) + client:stop(true) +end)