From 405a176758e16cf6aa4ed347ad54bc2db0acfea5 Mon Sep 17 00:00:00 2001 From: Oscar Wallberg Date: Sat, 18 Apr 2026 02:35:36 +0200 Subject: [PATCH] refactor(completion): evolve Request into Session + surface-level cleanup --- lua/lsp/completion/init.lua | 11 +- lua/lsp/completion/item.lua | 90 +++++++++------ lua/lsp/completion/popup.lua | 16 +-- .../completion/{request.lua => session.lua} | 107 ++++++++++++------ 4 files changed, 147 insertions(+), 77 deletions(-) rename lua/lsp/completion/{request.lua => session.lua} (76%) diff --git a/lua/lsp/completion/init.lua b/lua/lsp/completion/init.lua index e366a3d..dc3d05d 100644 --- a/lua/lsp/completion/init.lua +++ b/lua/lsp/completion/init.lua @@ -1,6 +1,6 @@ local Item = require("lsp.completion.item") 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 }) @@ -23,7 +23,7 @@ local function on_attach(client, buf) vim.api.nvim_create_autocmd("InsertCharPre", { buffer = buf, group = GROUP, - callback = request.on_insert_char_pre, + callback = session.on_insert_char_pre, }) end @@ -207,6 +207,13 @@ function M.setup() end return "" end, { expr = true, replace_keycodes = true }) + + vim.keymap.set( + "i", + "", + session.trigger_manual, + { expr = true, remap = false } + ) end return M diff --git a/lua/lsp/completion/item.lua b/lua/lsp/completion/item.lua index 7076de8..5a00bb9 100644 --- a/lua/lsp/completion/item.lua +++ b/lua/lsp/completion/item.lua @@ -1,11 +1,5 @@ -local SNIPPET = vim.lsp.protocol.InsertTextFormat.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 +local SNIPPET_FORMAT = vim.lsp.protocol.InsertTextFormat.Snippet +local SNIPPET_KIND = vim.lsp.protocol.CompletionItemKind.Snippet ---@param raw lsp.CompletionItem ---@return string @@ -14,26 +8,62 @@ local function label_stem(raw) end ---@param raw lsp.CompletionItem ----@return string? -local function extract_doc(raw) - local doc = raw.documentation - if type(doc) == "table" then - return doc.value +---@return string +local function edit_text(raw) + if raw.textEdit then + return raw.textEdit.newText 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 ---@param raw lsp.CompletionItem ---@return string? -local function extract_detail(raw) +local function get_detail(raw) return raw.detail or vim.tbl_get(raw, "labelDetails", "description") 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 ---@field word string ---@field abbr string ----@field menu string ---@field detail string? ---@field doc string? ---@field snippet string? @@ -46,22 +76,12 @@ Item.__index = Item ---@param client_id integer ---@return ow.lsp.completion.Item 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({ - word = word, - abbr = abbr, - menu = vim.tbl_get(raw, "labelDetails", "detail") or "", - detail = extract_detail(raw), - doc = extract_doc(raw), - snippet = is_snippet and (edit_text(raw) or raw.label) or nil, + word = get_word(raw), + abbr = get_abbr(raw), + detail = get_detail(raw), + doc = get_doc(raw), + snippet = get_snippet(raw), client_id = client_id, raw = raw, }, Item) @@ -70,10 +90,10 @@ end ---@param resolved lsp.CompletionItem function Item:apply_resolved(resolved) self.raw = vim.tbl_deep_extend("force", self.raw, resolved) - self.detail = extract_detail(self.raw) - self.doc = extract_doc(self.raw) + self.detail = get_detail(self.raw) + self.doc = get_doc(self.raw) if self.snippet then - self.snippet = edit_text(self.raw) or self.raw.label + self.snippet = edit_text(self.raw) end end diff --git a/lua/lsp/completion/popup.lua b/lua/lsp/completion/popup.lua index 686deae..ef5b3c1 100644 --- a/lua/lsp/completion/popup.lua +++ b/lua/lsp/completion/popup.lua @@ -1,6 +1,7 @@ local MAX_WIDTH = 80 local MAX_HEIGHT = 20 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") @@ -28,14 +29,15 @@ end ---@return integer? width local function build_content(item, ft) local sections = {} - if item.detail then - table.insert(sections, fence(ft, item.detail)) - end - if item.snippet then + if item.snippet and item.raw.kind == SNIPPET_KIND then table.insert(sections, fence(ft, item.snippet)) - end - if item.doc then - table.insert(sections, item.doc) + else + if item.detail then + table.insert(sections, fence(ft, item.detail)) + end + if item.doc then + table.insert(sections, item.doc) + end end if #sections == 0 then return nil, nil diff --git a/lua/lsp/completion/request.lua b/lua/lsp/completion/session.lua similarity index 76% rename from lua/lsp/completion/request.lua rename to lua/lsp/completion/session.lua index 4501144..170ab2d 100644 --- a/lua/lsp/completion/request.lua +++ b/lua/lsp/completion/session.lua @@ -5,7 +5,7 @@ local Item = require("lsp.completion.item") local kind = require("lsp.kind") local util = require("util") -local REQUEST_DEBOUNCE_MS = 100 +local REQUEST_DEBOUNCE_MS = 50 ---@param item_defaults? lsp.CompletionItemDefaults ---@return lsp.Range? @@ -99,11 +99,12 @@ local function build_items(responses, base) end, entries) end - table.sort(entries, function(a, b) - local ka = a.item.sortText or a.item.label - local kb = b.item.sortText or b.item.label - return ka < kb - end) + -- for _, entry in ipairs(entries) do + -- entry.sort_key = entry.item.sortText or entry.item.label + -- end + -- table.sort(entries, function(a, b) + -- return a.sort_key < b.sort_key + -- end) ---@type vim.v.completed_item[] local result = {} @@ -113,11 +114,8 @@ local function build_items(responses, base) result[#result + 1] = { word = item.word, abbr = item.abbr, - menu = item.menu, kind = kind_icon, kind_hlgroup = kind_hl, - -- non-empty so our CompleteChanged handler triggers resolve - info = " ", icase = 1, dup = 1, empty = 1, @@ -134,6 +132,14 @@ local function buffer_has_completion_client() }) > 0 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 ---@return boolean local function is_trigger_char(char) @@ -162,35 +168,37 @@ local function feed_omnifunc() vim.api.nvim_feedkeys(vim.keycode(""), "n", false) end ----@class ow.lsp.completion.Request +---@class ow.lsp.completion.Session ---@field private generation integer ---@field private phase ('awaiting' | 'ready' | 'done')? ---@field private result table? ---@field private cancel function? -local Request = {} -Request.__index = Request +---@field trigger_char string? +local Session = {} +Session.__index = Session ----@return ow.lsp.completion.Request -function Request.new() +---@return ow.lsp.completion.Session +function Session.new() return setmetatable({ generation = 0, phase = nil, result = nil, cancel = nil, - }, Request) + trigger_char = nil, + }, Session) end ---@return boolean -function Request:is_awaiting() +function Session:is_awaiting() return self.phase == "awaiting" end ---@return boolean -function Request:is_ready() +function Session:is_ready() return self.phase == "ready" end -function Request:discard() +function Session:discard() if self.cancel then pcall(self.cancel) end @@ -202,9 +210,10 @@ end ---@param trigger_kind integer ---@param trigger_char? string -function Request:dispatch(trigger_kind, trigger_char) +function Session:dispatch(trigger_kind, trigger_char) self:discard() self.phase = "awaiting" + self.trigger_char = trigger_char local gen = self.generation local buf = vim.api.nvim_get_current_buf() local params = vim.lsp.util.make_position_params(0, "utf-16") @@ -229,42 +238,57 @@ function Request:dispatch(trigger_kind, trigger_char) end ---@return integer -function Request:start_col() +function Session:start_col() for _, response in pairs(self.result or {}) do local col = response_edit_start(response) if col then return col end end - local line = vim.api.nvim_get_current_line() - local cursor_col = vim.fn.col(".") - 1 - return vim.fn.match(line:sub(1, cursor_col), "\\k*$") + local start = word_bounds() + return start end ---@param base string ---@return vim.v.completed_item[] -function Request:consume(base) +function Session:finalize(base) local items = build_items(self.result or {}, base) self.phase = "done" return items 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 return end - request:dispatch(trigger_kind, char) + if not session then + session = Session.new() + end + session:dispatch(trigger_kind, char) end, REQUEST_DEBOUNCE_MS) +-- Set by `trigger_manual` when the user explicitly invokes completion via +-- . 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 = {} +---Expose for the keymap wrapper. +function M.trigger_manual() + manual_pending = true + return vim.keycode("") +end + function M.on_insert_char_pre() local char = vim.v.char if char == "" then return end + manual_pending = false local is_word = char:match("[%w_]") ~= nil local is_trigger = not is_word and is_trigger_char(char) if not (is_word or is_trigger) then @@ -280,7 +304,7 @@ function M.on_insert_char_pre() local kind_num = is_trigger and vim.lsp.protocol.CompletionTriggerKind.TriggerCharacter 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 -- 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 ---@return integer | vim.v.completed_item[] 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 {} end - if not request:is_ready() then - request:dispatch(vim.lsp.protocol.CompletionTriggerKind.Invoked) + if + 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 {} end if findstart == 1 then - return request:start_col() + return session:start_col() end - return request:consume(base) + local items = session:finalize(base) + session = nil + manual_pending = false + return items end return M