refactor(completion): evolve Request into Session + surface-level cleanup

This commit is contained in:
2026-04-18 02:35:36 +02:00
parent 40a8f8cdd3
commit 405a176758
4 changed files with 147 additions and 77 deletions
+9 -2
View File
@@ -1,6 +1,6 @@
local Item = require("lsp.completion.item") local Item = require("lsp.completion.item")
local Popup = require("lsp.completion.popup") local Popup = require("lsp.completion.popup")
local request = require("lsp.completion.request") local session = require("lsp.completion.session")
local GROUP = vim.api.nvim_create_augroup("ow.lsp.completion", { clear = true }) local GROUP = vim.api.nvim_create_augroup("ow.lsp.completion", { clear = true })
@@ -23,7 +23,7 @@ local function on_attach(client, buf)
vim.api.nvim_create_autocmd("InsertCharPre", { vim.api.nvim_create_autocmd("InsertCharPre", {
buffer = buf, buffer = buf,
group = GROUP, group = GROUP,
callback = request.on_insert_char_pre, callback = session.on_insert_char_pre,
}) })
end end
@@ -207,6 +207,13 @@ function M.setup()
end end
return "<CR>" return "<CR>"
end, { expr = true, replace_keycodes = true }) end, { expr = true, replace_keycodes = true })
vim.keymap.set(
"i",
"<C-x><C-o>",
session.trigger_manual,
{ expr = true, remap = false }
)
end end
return M return M
+55 -35
View File
@@ -1,11 +1,5 @@
local SNIPPET = vim.lsp.protocol.InsertTextFormat.Snippet local SNIPPET_FORMAT = vim.lsp.protocol.InsertTextFormat.Snippet
local SNIPPET_KIND = vim.lsp.protocol.CompletionItemKind.Snippet
---@param raw lsp.CompletionItem
---@return string?
local function edit_text(raw)
local edit = raw.textEdit
return (type(edit) == "table" and edit.newText) or raw.insertText
end
---@param raw lsp.CompletionItem ---@param raw lsp.CompletionItem
---@return string ---@return string
@@ -14,26 +8,62 @@ local function label_stem(raw)
end end
---@param raw lsp.CompletionItem ---@param raw lsp.CompletionItem
---@return string? ---@return string
local function extract_doc(raw) local function edit_text(raw)
local doc = raw.documentation if raw.textEdit then
if type(doc) == "table" then return raw.textEdit.newText
return doc.value
end end
---@cast doc string?
return doc return raw.insertText or raw.label
end
---@param raw lsp.CompletionItem
---@return string
local function get_word(raw)
if raw.insertTextFormat == SNIPPET_FORMAT then
return raw.filterText or label_stem(raw)
end
return edit_text(raw)
end
---@param raw lsp.CompletionItem
---@return string
local function get_abbr(raw)
local abbr = label_stem(raw)
if raw.kind == SNIPPET_KIND then
abbr = abbr .. "~"
end
return abbr
end end
---@param raw lsp.CompletionItem ---@param raw lsp.CompletionItem
---@return string? ---@return string?
local function extract_detail(raw) local function get_detail(raw)
return raw.detail or vim.tbl_get(raw, "labelDetails", "description") return raw.detail or vim.tbl_get(raw, "labelDetails", "description")
end end
---@param raw lsp.CompletionItem
---@return string?
local function get_doc(raw)
local doc = raw.documentation
if not doc then
return nil
end
return doc.value or doc
end
---@param raw lsp.CompletionItem
---@return string?
local function get_snippet(raw)
if raw.insertTextFormat == SNIPPET_FORMAT then
return edit_text(raw)
end
return nil
end
---@class ow.lsp.completion.Item ---@class ow.lsp.completion.Item
---@field word string ---@field word string
---@field abbr string ---@field abbr string
---@field menu string
---@field detail string? ---@field detail string?
---@field doc string? ---@field doc string?
---@field snippet string? ---@field snippet string?
@@ -46,22 +76,12 @@ Item.__index = Item
---@param client_id integer ---@param client_id integer
---@return ow.lsp.completion.Item ---@return ow.lsp.completion.Item
function Item.from_lsp(raw, client_id) function Item.from_lsp(raw, client_id)
local is_snippet = raw.insertTextFormat == SNIPPET
local abbr = label_stem(raw)
local word
if is_snippet then
word = raw.filterText or abbr
abbr = abbr .. "~"
else
word = edit_text(raw) or raw.label
end
return setmetatable({ return setmetatable({
word = word, word = get_word(raw),
abbr = abbr, abbr = get_abbr(raw),
menu = vim.tbl_get(raw, "labelDetails", "detail") or "", detail = get_detail(raw),
detail = extract_detail(raw), doc = get_doc(raw),
doc = extract_doc(raw), snippet = get_snippet(raw),
snippet = is_snippet and (edit_text(raw) or raw.label) or nil,
client_id = client_id, client_id = client_id,
raw = raw, raw = raw,
}, Item) }, Item)
@@ -70,10 +90,10 @@ end
---@param resolved lsp.CompletionItem ---@param resolved lsp.CompletionItem
function Item:apply_resolved(resolved) function Item:apply_resolved(resolved)
self.raw = vim.tbl_deep_extend("force", self.raw, resolved) self.raw = vim.tbl_deep_extend("force", self.raw, resolved)
self.detail = extract_detail(self.raw) self.detail = get_detail(self.raw)
self.doc = extract_doc(self.raw) self.doc = get_doc(self.raw)
if self.snippet then if self.snippet then
self.snippet = edit_text(self.raw) or self.raw.label self.snippet = edit_text(self.raw)
end end
end end
+9 -7
View File
@@ -1,6 +1,7 @@
local MAX_WIDTH = 80 local MAX_WIDTH = 80
local MAX_HEIGHT = 20 local MAX_HEIGHT = 20
local HALF_HEIGHT = math.floor(MAX_HEIGHT / 2) local HALF_HEIGHT = math.floor(MAX_HEIGHT / 2)
local SNIPPET_KIND = vim.lsp.protocol.CompletionItemKind.Snippet
local NS = vim.api.nvim_create_namespace("ow.lsp.completion.popup") local NS = vim.api.nvim_create_namespace("ow.lsp.completion.popup")
@@ -28,14 +29,15 @@ end
---@return integer? width ---@return integer? width
local function build_content(item, ft) local function build_content(item, ft)
local sections = {} local sections = {}
if item.detail then if item.snippet and item.raw.kind == SNIPPET_KIND then
table.insert(sections, fence(ft, item.detail))
end
if item.snippet then
table.insert(sections, fence(ft, item.snippet)) table.insert(sections, fence(ft, item.snippet))
end else
if item.doc then if item.detail then
table.insert(sections, item.doc) table.insert(sections, fence(ft, item.detail))
end
if item.doc then
table.insert(sections, item.doc)
end
end end
if #sections == 0 then if #sections == 0 then
return nil, nil return nil, nil
@@ -5,7 +5,7 @@ local Item = require("lsp.completion.item")
local kind = require("lsp.kind") local kind = require("lsp.kind")
local util = require("util") local util = require("util")
local REQUEST_DEBOUNCE_MS = 100 local REQUEST_DEBOUNCE_MS = 50
---@param item_defaults? lsp.CompletionItemDefaults ---@param item_defaults? lsp.CompletionItemDefaults
---@return lsp.Range? ---@return lsp.Range?
@@ -99,11 +99,12 @@ local function build_items(responses, base)
end, entries) end, entries)
end end
table.sort(entries, function(a, b) -- for _, entry in ipairs(entries) do
local ka = a.item.sortText or a.item.label -- entry.sort_key = entry.item.sortText or entry.item.label
local kb = b.item.sortText or b.item.label -- end
return ka < kb -- table.sort(entries, function(a, b)
end) -- return a.sort_key < b.sort_key
-- end)
---@type vim.v.completed_item[] ---@type vim.v.completed_item[]
local result = {} local result = {}
@@ -113,11 +114,8 @@ local function build_items(responses, base)
result[#result + 1] = { result[#result + 1] = {
word = item.word, word = item.word,
abbr = item.abbr, abbr = item.abbr,
menu = item.menu,
kind = kind_icon, kind = kind_icon,
kind_hlgroup = kind_hl, kind_hlgroup = kind_hl,
-- non-empty so our CompleteChanged handler triggers resolve
info = " ",
icase = 1, icase = 1,
dup = 1, dup = 1,
empty = 1, empty = 1,
@@ -134,6 +132,14 @@ local function buffer_has_completion_client()
}) > 0 }) > 0
end end
---@return integer start, integer cursor both 0-indexed byte columns
local function word_bounds()
local line = vim.api.nvim_get_current_line()
local cursor = vim.fn.col(".") - 1
local start = vim.fn.match(line:sub(1, cursor), "\\k*$")
return start, cursor
end
---@param char string ---@param char string
---@return boolean ---@return boolean
local function is_trigger_char(char) local function is_trigger_char(char)
@@ -162,35 +168,37 @@ local function feed_omnifunc()
vim.api.nvim_feedkeys(vim.keycode("<C-x><C-o>"), "n", false) vim.api.nvim_feedkeys(vim.keycode("<C-x><C-o>"), "n", false)
end end
---@class ow.lsp.completion.Request ---@class ow.lsp.completion.Session
---@field private generation integer ---@field private generation integer
---@field private phase ('awaiting' | 'ready' | 'done')? ---@field private phase ('awaiting' | 'ready' | 'done')?
---@field private result table<integer, ow.lsp.CompletionResponse>? ---@field private result table<integer, ow.lsp.CompletionResponse>?
---@field private cancel function? ---@field private cancel function?
local Request = {} ---@field trigger_char string?
Request.__index = Request local Session = {}
Session.__index = Session
---@return ow.lsp.completion.Request ---@return ow.lsp.completion.Session
function Request.new() function Session.new()
return setmetatable({ return setmetatable({
generation = 0, generation = 0,
phase = nil, phase = nil,
result = nil, result = nil,
cancel = nil, cancel = nil,
}, Request) trigger_char = nil,
}, Session)
end end
---@return boolean ---@return boolean
function Request:is_awaiting() function Session:is_awaiting()
return self.phase == "awaiting" return self.phase == "awaiting"
end end
---@return boolean ---@return boolean
function Request:is_ready() function Session:is_ready()
return self.phase == "ready" return self.phase == "ready"
end end
function Request:discard() function Session:discard()
if self.cancel then if self.cancel then
pcall(self.cancel) pcall(self.cancel)
end end
@@ -202,9 +210,10 @@ end
---@param trigger_kind integer ---@param trigger_kind integer
---@param trigger_char? string ---@param trigger_char? string
function Request:dispatch(trigger_kind, trigger_char) function Session:dispatch(trigger_kind, trigger_char)
self:discard() self:discard()
self.phase = "awaiting" self.phase = "awaiting"
self.trigger_char = trigger_char
local gen = self.generation local gen = self.generation
local buf = vim.api.nvim_get_current_buf() local buf = vim.api.nvim_get_current_buf()
local params = vim.lsp.util.make_position_params(0, "utf-16") local params = vim.lsp.util.make_position_params(0, "utf-16")
@@ -229,42 +238,57 @@ function Request:dispatch(trigger_kind, trigger_char)
end end
---@return integer ---@return integer
function Request:start_col() function Session:start_col()
for _, response in pairs(self.result or {}) do for _, response in pairs(self.result or {}) do
local col = response_edit_start(response) local col = response_edit_start(response)
if col then if col then
return col return col
end end
end end
local line = vim.api.nvim_get_current_line() local start = word_bounds()
local cursor_col = vim.fn.col(".") - 1 return start
return vim.fn.match(line:sub(1, cursor_col), "\\k*$")
end end
---@param base string ---@param base string
---@return vim.v.completed_item[] ---@return vim.v.completed_item[]
function Request:consume(base) function Session:finalize(base)
local items = build_items(self.result or {}, base) local items = build_items(self.result or {}, base)
self.phase = "done" self.phase = "done"
return items return items
end end
local request = Request.new() ---@type ow.lsp.completion.Session?
local session = nil
local debounce = util.debounce(function(_, trigger_kind, char) local dispatcher = util.debounce(function(_, trigger_kind, char)
if vim.fn.mode() ~= "i" then if vim.fn.mode() ~= "i" then
return return
end end
request:dispatch(trigger_kind, char) if not session then
session = Session.new()
end
session:dispatch(trigger_kind, char)
end, REQUEST_DEBOUNCE_MS) end, REQUEST_DEBOUNCE_MS)
-- Set by `trigger_manual` when the user explicitly invokes completion via
-- <C-x><C-o>. Survives the dispatch+response round-trip so the omnifunc knows
-- to skip the "no word before cursor" guard. Reset on the next auto trigger.
local manual_pending = false
local M = {} local M = {}
---Expose for the <C-x><C-o> keymap wrapper.
function M.trigger_manual()
manual_pending = true
return vim.keycode("<C-x><C-o>")
end
function M.on_insert_char_pre() function M.on_insert_char_pre()
local char = vim.v.char local char = vim.v.char
if char == "" then if char == "" then
return return
end end
manual_pending = false
local is_word = char:match("[%w_]") ~= nil local is_word = char:match("[%w_]") ~= nil
local is_trigger = not is_word and is_trigger_char(char) local is_trigger = not is_word and is_trigger_char(char)
if not (is_word or is_trigger) then if not (is_word or is_trigger) then
@@ -280,7 +304,7 @@ function M.on_insert_char_pre()
local kind_num = is_trigger local kind_num = is_trigger
and vim.lsp.protocol.CompletionTriggerKind.TriggerCharacter and vim.lsp.protocol.CompletionTriggerKind.TriggerCharacter
or vim.lsp.protocol.CompletionTriggerKind.Invoked or vim.lsp.protocol.CompletionTriggerKind.Invoked
debounce:call(nil, kind_num, is_trigger and char or nil) dispatcher:call(nil, kind_num, is_trigger and char or nil)
end end
-- Global entry point for Vim's 'omnifunc' option. Referenced via -- Global entry point for Vim's 'omnifunc' option. Referenced via
@@ -291,17 +315,34 @@ _G.ow_lsp_completion = _G.ow_lsp_completion or {}
---@param base string ---@param base string
---@return integer | vim.v.completed_item[] ---@return integer | vim.v.completed_item[]
function _G.ow_lsp_completion.omnifunc(findstart, base) function _G.ow_lsp_completion.omnifunc(findstart, base)
if not buffer_has_completion_client() or request:is_awaiting() then if not session then
session = Session.new()
end
if not buffer_has_completion_client() or session:is_awaiting() then
return findstart == 1 and -3 or {} return findstart == 1 and -3 or {}
end end
if not request:is_ready() then if
request:dispatch(vim.lsp.protocol.CompletionTriggerKind.Invoked) findstart == 1
and session:is_ready()
and not manual_pending
and not session.trigger_char
then
local start, cursor = word_bounds()
if start == cursor then
return -3
end
end
if not session:is_ready() then
session:dispatch(vim.lsp.protocol.CompletionTriggerKind.Invoked)
return findstart == 1 and -3 or {} return findstart == 1 and -3 or {}
end end
if findstart == 1 then if findstart == 1 then
return request:start_col() return session:start_col()
end end
return request:consume(base) local items = session:finalize(base)
session = nil
manual_pending = false
return items
end end
return M return M