Skip to content

feat: let external completion UIs use the in-process LSP server - #4

Open
atusy wants to merge 8 commits into
kjuq:masterfrom
atusy:feat/lsp-external-completion
Open

atusy wants to merge 8 commits into
kjuq:masterfrom
atusy:feat/lsp-external-completion

Conversation

@atusy

@atusy atusy commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

What

Lets completion UIs other than Neovim's builtin menu use skkelua's candidates, through the in-process LSP server rather than a parallel Lua API. This is a rework of #1 following the maintainer's direction that SKK conversion logic should live in the LSP server as much as possible; #1 is left open for reference and can be closed once this one is accepted.

  • Candidates stay in the server. Candidate generation and the UI-independent CompletionList are internal to lsp/ (lsp/candidates.lua, lsp/list.lua); there is no public get().
  • Learning and registration are LSP commands. Each item carries command = skkelua.accept; the server declares it in executeCommandProvider and handles workspace/executeCommand. Clients run an item's command only when it is confirmed, so cancelling (<C-e>, <Esc>) needs no special casing. This replaces the CompleteDone autocmd, and there is no accept() fallback in Lua.
  • require("skkelua.lsp").start(bufnr) attaches a buffer to one shared client (kept across SKK enable/disable, builtin UI not enabled) that returns the UI-independent list.
  • require("skkelua.completion").set_adapter({selected, confirm}) is the only UI-specific hook left. The server cannot know which item a menu has selected or how to confirm it, so the UI reports them: selected() returns the selected LSP CompletionItem (or nil), and confirm() returns the keys that confirm it. pum.vim and nvim-cmp are still auto-detected through the same seam; an explicit adapter wins.
  • User skkelua-pre-edit-restored fires when the pre-edit is restored without typing (e.g. cancelling the registration prompt). Autotrigger does not run then, so a UI re-requests completion from this event; builtin completion is still re-requested by skkelua.

Try it with laser.nvim

Minimal config (laser.nvim completing with skkelua's candidates)

Save as minimal.lua and run NVIM_APPNAME=skkelua-minimal nvim -u minimal.lua (needs Neovim with vim.pack). In insert mode, <C-j> enables SKK and Kanji lists the candidates; <C-n>/<C-p> select, <C-y> confirms and teaches skkelua, <C-e> closes.

-- Minimal reproduction: laser.nvim completing with skkelua's candidates.
-- Run: NVIM_APPNAME=skkelua-minimal nvim -u __ignored/minimal.lua
-- To refetch the plugins, remove ~/.config/skkelua-minimal (vim.pack's lockfile pins revisions)
-- and ~/.local/share/skkelua-minimal. (--clean does not put the plugin directory vim.pack installs into on 'packpath')
-- Then in insert mode: <C-j> (enable SKK), type "Kanji" -> laser lists 漢字, 感じ, ...
-- <C-n>/<C-p> select, <C-y> confirms (and teaches skkelua), <C-e> closes.
vim.g.mapleader = " "

vim.pack.add({
	"https://github.com/atusy/laser.nvim",
	{ src = "https://github.com/atusy/skkelua.nvim", version = "feat/lsp-external-completion" },
}, { confirm = false })

-- A tiny dictionary so that no download is needed.
local dict = vim.fn.tempname()
vim.fn.writefile({
	";; -*- coding: utf-8 -*-",
	";; okuri-ari entries.",
	";; okuri-nasi entries.",
	"かんじ /漢字/感じ/幹事/",
	"かな /仮名/",
}, dict)

local skkelua = require("skkelua")
skkelua.config({
	sources = { "skk_dictionary" },
	globalDictionaries = { dict },
	userDictionary = vim.fn.tempname(),
})
vim.keymap.set({ "i", "c" }, "<C-j>", "<Plug>(skkelua-enable)")

local laser = require("laser")

-- 1. skkelua's LSP client must be attached to the buffer for laser to ask it.
vim.api.nvim_create_autocmd("InsertEnter", {
	callback = function()
		require("skkelua.lsp").start()
	end,
})

-- 2. Tell skkelua which candidate laser has selected, and how to confirm it.
--    laser runs the candidate's command on confirm, which makes skkelua learn.
require("skkelua.completion").set_adapter({
	selected = function()
		if not laser.visible() then
			return nil
		end
		local ui = laser._engine().ui
		local candidate = ui.items()[ui.selected()]
		return candidate and vim.tbl_get(candidate, "user_data", "laser", "item") or nil
	end,
	confirm = function()
		return vim.keycode("<Cmd>lua require('laser').confirm()<CR>")
	end,
})

-- 3. Completion does not start by itself when the pre-edit is restored without typing.
vim.api.nvim_create_autocmd("User", {
	pattern = "skkelua-pre-edit-restored",
	callback = function()
		laser.complete()
	end,
})

-- laser itself: complete as you type, and map the menu keys.
vim.api.nvim_create_autocmd({ "InsertEnter", "TextChangedI", "LspAttach" }, {
	callback = function()
		laser.complete()
	end,
})
local function map(key, action)
	vim.keymap.set("i", key, function()
		if laser.visible() then
			return action()
		end
		return key
	end, { expr = true })
end
map("<C-n>", function()
	return "<Cmd>lua require('laser').select(1)<CR>"
end)
map("<C-p>", function()
	return "<Cmd>lua require('laser').select(-1)<CR>"
end)
map("<C-y>", function()
	return "<Cmd>lua require('laser').confirm()<CR>"
end)
map("<C-e>", function()
	return "<Cmd>lua require('laser').cancel()<CR>"
end)

Notes for review

  • Builtin menu behavior is unchanged: the list, ordering, insertOnSelect shaping and deferOkuri auto-select were compared against origin/master item by item. The one deliberate difference is that learning now happens one scheduled tick later than the old synchronous CompleteDone handler; <C-y>/<CR> on a dictionary item are still finalized by skkelua itself and learn synchronously.
  • A UI must run the item's command on confirm for learning and [辞書登録] to work; the help says so, including for the auto-detected pum.vim/nvim-cmp (nvim-cmp does).
  • completeType in vim_status() is now "external" for pum.vim/nvim-cmp (was "pum.vim"/"cmp"), and completeInfo.selected is 0/-1 for them.
  • All unnamed buffers share one URI, so the current buffer is taken as the requester; a stale request from another unnamed buffer finds no pre-edit and gets no candidates (documented).

Testing

nvim --clean --headless -l tests/run.lua passes. New coverage includes a real builtin menu (vim.lsp.completion) selecting and confirming/cancelling items, a real Client:exec_cmd round trip, shared-client reuse/separation in both attach orders, error replies for unsupported requests, pum.vim/nvim-cmp selection, and the skkelua-pre-edit-restored event.

🤖 Generated with Claude Code

@atusy
atusy force-pushed the feat/lsp-external-completion branch 3 times, most recently from 0f38ee8 to 62078f9 Compare October 20, 2026 15:32
@atusy
atusy marked this pull request as ready for review October 20, 2026 15:37
atusy and others added 8 commits October 21, 2026 01:36
lsp.lua mixed three concerns: generating candidates, shaping them for the
builtin completion UI, and the in-process transport. Moving the first into
lsp/candidates.lua lets later changes touch the transport or the item shape
without scrolling past the dictionary lookup.

No behavior change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…aping

The completion list mixed what every LSP client needs (items, ranges, sort
order, data) with what only Neovim's builtin menu needs (completeopt,
insertOnSelect label tricks, snippet escaping). Building the neutral list
in lsp/list.lua makes that boundary explicit, so a different client can
consume the same list without inheriting builtin workarounds.

No behavior change for the builtin menu.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…dule

init.lua carried per-UI branches for detecting a visible menu and for the
confirm key. Collecting them in completion.lua leaves one place to extend
when another completion UI needs the same treatment, instead of growing
init.lua's complete_info and native_confirm_key.

No behavior change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
pum.vim and nvim-cmp were the only non-builtin UIs skkelua could drive, and
only through hard-coded detection. A UI that owns its own menu now reports
the selected LSP item and the keys that confirm it, so <C-y> and <CR> follow
its selection instead of the builtin pum's.

The selected item lets a selected [辞書登録] item be recognized and keep the
pre-edit for every UI. The text a candidate inserts is read from the item's
data, so the UI does not have to say what it inserted. An explicit adapter
wins over automatic detection.

vim_status() asks the adapter on every keypress, so a stale menu object must
not break all of skkelua's input: a failing adapter counts as "no selection"
and warns once, and a confirm() that is not a string is ignored.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Learning a confirmed candidate and opening the registration prompt were
wired to the builtin menu's CompleteDone, so every other LSP client had no
way to trigger them. The server now advertises skkelua.accept, and each
item carries it as its command. Clients run an item's command only when it
is confirmed, so cancelling (<Esc>, <C-e>) needs no special handling here,
and the builtin menu relies on the same path instead of its own autocmd.

The registration prompt opens straight from the command, one tick sooner
than the extra schedule it used to wait for. Tests drive the real builtin
menu to pin that confirming learns exactly once, cancelling learns nothing,
and [辞書登録] opens the prompt once.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
lsp.start(bufnr) attaches a buffer to one client that is kept across SKK
enable/disable, does not enable the builtin menu, and returns the
UI-independent list. A UI can therefore ask the same server for candidates
without a Lua API of its own.

The in-process server now answers every request it receives (an error for
unsupported methods, a failure reply when building the list throws) and
returns true and a request id from request(), which clients use to tell
whether a request was sent and to cancel it; the builtin menu ignores the
return value, which is why only external clients depended on it. The
requested document is looked up in the current buffer first, since
requests arrive on every keystroke.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Cancelling the registration prompt restores the pre-edit without typing, so
no UI's autotrigger runs. skkelua still re-requests builtin completion, and
fires skkelua-pre-edit-restored so any other UI can re-request its own,
without every adapter having to carry a callback for it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The shared client, the accept command and the adapter hook are only useful
to someone who knows the contract: which request positions are honored,
that confirmation (not cancellation) must run the item's command, and what
state, confirm and trigger are expected to do.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@atusy
atusy force-pushed the feat/lsp-external-completion branch from 62078f9 to e66deb4 Compare October 20, 2026 16:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant