From e3e4d81ab037c9196f5857e489a773777dd88a35 Mon Sep 17 00:00:00 2001 From: Oscar Wallberg Date: Thu, 16 Apr 2026 02:24:04 +0200 Subject: [PATCH] refactor(lsp): turn completion popup and request into classes --- lua/lsp/completion/init.lua | 26 ++- lua/lsp/completion/popup.lua | 338 ++++++++++++++++----------------- lua/lsp/completion/request.lua | 214 ++++++++++++--------- 3 files changed, 305 insertions(+), 273 deletions(-) diff --git a/lua/lsp/completion/init.lua b/lua/lsp/completion/init.lua index 2f32156..efc132a 100644 --- a/lua/lsp/completion/init.lua +++ b/lua/lsp/completion/init.lua @@ -1,8 +1,10 @@ -local popup = require("lsp.completion.popup") +local Popup = require("lsp.completion.popup") local request = require("lsp.completion.request") local GROUP = vim.api.nvim_create_augroup("ow.lsp.completion", { clear = true }) +local popup = Popup.new() + local M = {} ---@param capabilities lsp.ClientCapabilities @@ -48,7 +50,9 @@ function M.setup() local client = client_id and vim.lsp.get_client_by_id(client_id) if not lsp_item or not client then - vim.schedule(popup.close) + vim.schedule(function() + popup:close() + end) return end @@ -67,7 +71,7 @@ function M.setup() vim.lsp.protocol.Methods.completionItem_resolve ) then - popup.dispatch_resolve( + popup:dispatch_resolve( client, lsp_item, ft, @@ -77,7 +81,7 @@ function M.setup() ) else vim.schedule(function() - popup.show_for(lsp_item, ft, pum) + popup:show(lsp_item, ft, pum) end) end end, @@ -85,14 +89,16 @@ function M.setup() vim.api.nvim_create_autocmd({ "CompleteDonePre", "InsertLeave" }, { group = GROUP, - callback = popup.close, + callback = function() + popup:close() + end, }) ---@param key string ---@param action fun() local function scroll_map(key, action) vim.keymap.set("i", key, function() - if popup.is_visible() then + if popup:is_visible() then vim.schedule(action) return "" end @@ -100,16 +106,16 @@ function M.setup() end, { expr = true, replace_keycodes = true }) end scroll_map("", function() - popup.scroll_half(vim.keycode("")) + popup:scroll_half(vim.keycode("")) end) scroll_map("", function() - popup.scroll_half(vim.keycode("")) + popup:scroll_half(vim.keycode("")) end) scroll_map("", function() - popup.scroll_line(vim.keycode("")) + popup:scroll_line(vim.keycode("")) end) scroll_map("", function() - popup.scroll_line(vim.keycode("")) + popup:scroll_line(vim.keycode("")) end) end diff --git a/lua/lsp/completion/popup.lua b/lua/lsp/completion/popup.lua index ab9d212..450cbd1 100644 --- a/lua/lsp/completion/popup.lua +++ b/lua/lsp/completion/popup.lua @@ -1,10 +1,9 @@ local MAX_WIDTH = 80 local MAX_HEIGHT = 20 +local HALF_HEIGHT = math.floor(MAX_HEIGHT / 2) local NS = vim.api.nvim_create_namespace("ow.lsp.completion.popup") -local M = {} - ---@class ow.lsp.completion.Pum ---@field row integer ---@field col integer @@ -16,18 +15,6 @@ local M = {} ---@field client vim.lsp.Client ---@field id integer ----@class ow.lsp.completion.Popup ----@field winid integer? ----@field bufnr integer? ----@field pending ow.lsp.completion.PendingResolve? - ----@type ow.lsp.completion.Popup -local popup = { - winid = nil, - bufnr = nil, - pending = nil, -} - ---@param ft string ---@param text string ---@return string @@ -41,139 +28,6 @@ local function signature_of(item) return item.detail or vim.tbl_get(item, "labelDetails", "description") or "" end -local function cancel_pending() - if popup.pending then - pcall( - popup.pending.client.cancel_request, - popup.pending.client, - popup.pending.id - ) - popup.pending = nil - end -end - ----@return integer bufnr -local function ensure_buffer() - if not popup.bufnr or not vim.api.nvim_buf_is_valid(popup.bufnr) then - popup.bufnr = vim.api.nvim_create_buf(false, true) - vim.bo[popup.bufnr].buftype = "nofile" - vim.bo[popup.bufnr].bufhidden = "hide" - vim.bo[popup.bufnr].swapfile = false - pcall(vim.treesitter.start, popup.bufnr, "markdown") - end - return popup.bufnr -end - -local function update_indicators() - if not popup.winid or not vim.api.nvim_win_is_valid(popup.winid) then - return - end - local visible = vim.api.nvim_win_get_height(popup.winid) - local topline, botline = - unpack(vim.api.nvim_win_call(popup.winid, function() - return { vim.fn.line("w0"), vim.fn.line("w$") } - end)) - local ok_total, info_total = - pcall(vim.api.nvim_win_text_height, popup.winid, {}) - local total = ok_total and info_total.all or visible - - local display_topline = 1 - if topline > 1 then - local ok, info = pcall(vim.api.nvim_win_text_height, popup.winid, { - end_row = topline - 2, - }) - if ok then - display_topline = (info.all or 0) + 1 - end - end - local display_bot = display_topline + visible - 1 - - local has_above = display_topline > 1 - local has_below = display_bot < total - and botline - < vim.api.nvim_buf_line_count(vim.api.nvim_win_get_buf(popup.winid)) - pcall(vim.api.nvim_win_set_config, popup.winid, { - title = has_above and { { "▲ ", "FloatBorder" } } or "", - title_pos = has_above and "right" or nil, - footer = has_below and { { "▼ ", "FloatBorder" } } or "", - footer_pos = has_below and "right" or nil, - }) -end - -function M.close() - cancel_pending() - if popup.winid and vim.api.nvim_win_is_valid(popup.winid) then - pcall(vim.api.nvim_win_close, popup.winid, true) - end - popup.winid = nil -end - ----@param content string ----@param pum ow.lsp.completion.Pum ----@param width integer -local function show(content, pum, width) - local bufnr = ensure_buffer() - local lines = vim.split(content, "\n", { plain = true }) - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) - - vim.api.nvim_buf_clear_namespace(bufnr, NS, 0, -1) - for i, line in ipairs(lines) do - if line ~= "" and line:gsub("─", "") == "" then - vim.api.nvim_buf_set_extmark(bufnr, NS, i - 1, 0, { - end_row = i - 1, - end_col = #line, - hl_group = "FloatBorder", - priority = 200, - }) - end - end - - width = math.min(width, MAX_WIDTH) - local height = math.min(#lines, MAX_HEIGHT) - - local right_edge = pum.col + pum.width + (pum.scrollbar and 1 or 0) - local right_space = vim.o.columns - right_edge - local col - if right_space >= width + 2 then - col = right_edge - else - col = math.max(pum.col - width - 2, 0) - end - - local cfg = { - relative = "editor", - row = pum.row, - col = col, - width = width, - height = height, - border = "rounded", - focusable = false, - style = "minimal", - } - - if popup.winid and vim.api.nvim_win_is_valid(popup.winid) then - pcall(vim.api.nvim_win_set_config, popup.winid, cfg) - else - cfg.noautocmd = true - popup.winid = vim.api.nvim_open_win(bufnr, false, cfg) - vim.wo[popup.winid].wrap = true - vim.wo[popup.winid].linebreak = true - vim.wo[popup.winid].conceallevel = 2 - end - pcall(vim.api.nvim_win_set_cursor, popup.winid, { 1, 0 }) - - local ok, info = pcall(vim.api.nvim_win_text_height, popup.winid, {}) - if ok and info.all and info.all ~= height then - pcall( - vim.api.nvim_win_set_height, - popup.winid, - math.min(info.all, MAX_HEIGHT) - ) - end - - update_indicators() -end - ---@param item lsp.CompletionItem ---@param ft string ---@return string? content @@ -220,35 +74,64 @@ local function build_content(item, ft) return table.concat(sections, sep), max_w end +---@class ow.lsp.completion.Popup +---@field private winid integer? +---@field private bufnr integer? +---@field private pending ow.lsp.completion.PendingResolve? +local Popup = {} +Popup.__index = Popup + +---@return ow.lsp.completion.Popup +function Popup.new() + return setmetatable({ + winid = nil, + bufnr = nil, + pending = nil, + }, Popup) +end + +---@return boolean +function Popup:is_visible() + return self.winid ~= nil and vim.api.nvim_win_is_valid(self.winid) +end + +function Popup:close() + self:cancel_pending() + if self:is_visible() then + vim.api.nvim_win_close(self.winid, true) + end + self.winid = nil +end + --- Build content for `item` and show, or close if there's nothing to render. ---@param item lsp.CompletionItem ---@param ft string ---@param pum ow.lsp.completion.Pum -function M.show_for(item, ft, pum) +function Popup:show(item, ft, pum) local content, width = build_content(item, ft) if content and width then - show(content, pum, width) + self:render(content, pum, width) else - M.close() + self:close() end end --- Cancel any in-flight resolve, dispatch a fresh one for `item`, and on --- response render the resolved popup. If the user moved the selection ---- before the response landed (`completed.word` mismatch), do nothing. +--- before the response landed (word mismatch), do nothing. ---@param client vim.lsp.Client ---@param item lsp.CompletionItem ---@param ft string ---@param pum ow.lsp.completion.Pum ---@param word string ---@param buf integer -function M.dispatch_resolve(client, item, ft, pum, word, buf) - cancel_pending() +function Popup:dispatch_resolve(client, item, ft, pum, word, buf) + self:cancel_pending() local _, request_id = client:request( vim.lsp.protocol.Methods.completionItem_resolve, item, function(err, result) - popup.pending = nil + self.pending = nil if err or not result then return end @@ -256,40 +139,153 @@ function M.dispatch_resolve(client, item, ft, pum, word, buf) if (vim.tbl_get(cur, "completed", "word") or "") ~= word then return end - M.show_for(result, ft, pum) + self:show(result, ft, pum) end, buf ) if request_id then - popup.pending = { client = client, id = request_id } + self.pending = { client = client, id = request_id } end end ----@return boolean -function M.is_visible() - return popup.winid ~= nil and vim.api.nvim_win_is_valid(popup.winid) +---@param direction string single-line scroll keycode ( or ) +function Popup:scroll_line(direction) + self:scroll(direction, 1) end ----@param direction string single-line scroll keycode ( or ) +---@param direction string +function Popup:scroll_half(direction) + self:scroll(direction, HALF_HEIGHT) +end + +---@param content string +---@param pum ow.lsp.completion.Pum +---@param width integer +function Popup:render(content, pum, width) + self:ensure_buffer() + local lines = vim.split(content, "\n", { plain = true }) + vim.api.nvim_buf_set_lines(self.bufnr, 0, -1, false, lines) + + vim.api.nvim_buf_clear_namespace(self.bufnr, NS, 0, -1) + for i, line in ipairs(lines) do + if line ~= "" and line:gsub("─", "") == "" then + vim.api.nvim_buf_set_extmark(self.bufnr, NS, i - 1, 0, { + end_row = i - 1, + end_col = #line, + hl_group = "FloatBorder", + priority = 200, + }) + end + end + + width = math.min(width, MAX_WIDTH) + local height = math.min(#lines, MAX_HEIGHT) + + local right_edge = pum.col + pum.width + (pum.scrollbar and 1 or 0) + local right_space = vim.o.columns - right_edge + local col + if right_space >= width + 2 then + col = right_edge + else + col = math.max(pum.col - width - 2, 0) + end + + local cfg = { + relative = "editor", + row = pum.row, + col = col, + width = width, + height = height, + border = "rounded", + focusable = false, + style = "minimal", + } + + if self:is_visible() then + vim.api.nvim_win_set_config(self.winid, cfg) + else + cfg.noautocmd = true + self.winid = vim.api.nvim_open_win(self.bufnr, false, cfg) + vim.wo[self.winid].wrap = true + vim.wo[self.winid].linebreak = true + vim.wo[self.winid].conceallevel = 2 + end + vim.api.nvim_win_set_cursor(self.winid, { 1, 0 }) + + local actual = vim.api.nvim_win_text_height(self.winid, { + max_height = MAX_HEIGHT, + }).all + if actual ~= height then + vim.api.nvim_win_set_height(self.winid, actual) + end + + self:update_indicators() +end + +---@param direction string ---@param count integer -local function scroll(direction, count) - if not M.is_visible() then +function Popup:scroll(direction, count) + if not self:is_visible() then return end - vim.api.nvim_win_call(popup.winid, function() + vim.api.nvim_win_call(self.winid, function() vim.cmd.normal({ args = { count .. direction }, bang = true }) end) - update_indicators() + self:update_indicators() end ----@param direction string single-line scroll keycode ( or ) -function M.scroll_line(direction) - scroll(direction, 1) +function Popup:cancel_pending() + if self.pending then + self.pending.client:cancel_request(self.pending.id) + self.pending = nil + end end ----@param direction string single-line scroll keycode ( or ) -function M.scroll_half(direction) - scroll(direction, math.floor(MAX_HEIGHT / 2)) +function Popup:ensure_buffer() + if self.bufnr and vim.api.nvim_buf_is_valid(self.bufnr) then + return + end + self.bufnr = vim.api.nvim_create_buf(false, true) + vim.bo[self.bufnr].buftype = "nofile" + vim.bo[self.bufnr].bufhidden = "hide" + vim.bo[self.bufnr].swapfile = false + -- Markdown parser may not be installed; fall back to no highlighting. + pcall(vim.treesitter.start, self.bufnr, "markdown") end -return M +function Popup:update_indicators() + if not self:is_visible() then + return + end + + local visible = vim.api.nvim_win_get_height(self.winid) + local topline, botline = unpack(vim.api.nvim_win_call(self.winid, function() + return { vim.fn.line("w0"), vim.fn.line("w$") } + end)) + + local display_topline = 1 + if topline > 1 then + display_topline = vim.api.nvim_win_text_height(self.winid, { + end_row = topline - 2, + }).all + 1 + end + local display_bot = display_topline + visible - 1 + + local total = vim.api.nvim_win_text_height(self.winid, { + max_height = display_bot + 1, + }).all + + local has_above = display_topline > 1 + local has_below = display_bot < total + and botline + < vim.api.nvim_buf_line_count(vim.api.nvim_win_get_buf(self.winid)) + + vim.api.nvim_win_set_config(self.winid, { + title = has_above and { { "▲ ", "FloatBorder" } } or "", + title_pos = has_above and "right" or nil, + footer = has_below and { { "▼ ", "FloatBorder" } } or "", + footer_pos = has_below and "right" or nil, + }) +end + +return Popup diff --git a/lua/lsp/completion/request.lua b/lua/lsp/completion/request.lua index 6ff615a..fb671c4 100644 --- a/lua/lsp/completion/request.lua +++ b/lua/lsp/completion/request.lua @@ -3,39 +3,6 @@ local util = require("util") local REQUEST_DEBOUNCE_MS = 100 -local M = {} - ----@class ow.lsp.completion.Request ----@field generation integer ----@field phase ('awaiting' | 'ready' | 'done')? ----@field result table? ----@field cancel function? - ----@type ow.lsp.completion.Request -local request = { - generation = 0, - phase = nil, - result = nil, - cancel = nil, -} - -local function discard_request() - if request.cancel then - pcall(request.cancel) - end - request.generation = request.generation + 1 - request.phase = nil - request.result = nil - request.cancel = nil -end - -local function buffer_has_completion_client() - return #vim.lsp.get_clients({ - bufnr = 0, - method = vim.lsp.protocol.Methods.textDocument_completion, - }) > 0 -end - --- Normalize an `itemDefaults.editRange` to a plain Range, since it may be --- either `Range` or `{ insert: Range, replace: Range }`. ---@param edit_range table? @@ -83,19 +50,6 @@ local function response_edit_start(response) return nil end ----@return integer byte_col 0-indexed byte column for completefunc findstart -local function resolve_start_col() - for _, response in pairs(request.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*$") -end - ---@param item lsp.CompletionItem ---@return string local function item_word(item) @@ -129,11 +83,14 @@ local function apply_defaults(items, defaults) end end +--- Transform raw LSP completion responses into Vim |complete()| items, +--- filtered by `base` and sorted by sortText/label. +---@param responses table map of client_id -> response from buf_request_all ---@param base string ---@return table[] -local function to_complete_items(base) +local function build_items(responses, base) local entries = {} - for client_id, response in pairs(request.result or {}) do + for client_id, response in pairs(responses) do if response.result and not (response.err or response.error) then local raw = response.result.items or response.result if type(raw) == "table" then @@ -187,39 +144,11 @@ local function to_complete_items(base) return result end -local function feed_user_completion() - if vim.fn.mode() ~= "i" or vim.fn.pumvisible() ~= 0 then - return - end - vim.api.nvim_feedkeys(vim.keycode(""), "n", false) -end - ----@param trigger_kind integer ----@param trigger_char string? -local function dispatch_request(trigger_kind, trigger_char) - discard_request() - request.phase = "awaiting" - local gen = request.generation - local buf = vim.api.nvim_get_current_buf() - local params = vim.lsp.util.make_position_params(0, "utf-16") - ---@cast params lsp.CompletionParams - params.context = { - triggerKind = trigger_kind, - triggerCharacter = trigger_char, - } - request.cancel = vim.lsp.buf_request_all( - buf, - vim.lsp.protocol.Methods.textDocument_completion, - params, - function(result) - if request.generation ~= gen or request.phase ~= "awaiting" then - return - end - request.phase = "ready" - request.result = result - feed_user_completion() - end - ) +local function buffer_has_completion_client() + return #vim.lsp.get_clients({ + bufnr = 0, + method = vim.lsp.protocol.Methods.textDocument_completion, + }) > 0 end ---@param char string @@ -243,13 +172,117 @@ local function is_trigger_char(char) return false end +local function feed_omnifunc() + if vim.fn.mode() ~= "i" or vim.fn.pumvisible() ~= 0 then + return + end + vim.api.nvim_feedkeys(vim.keycode(""), "n", false) +end + +---@class ow.lsp.completion.Request +---@field private generation integer +---@field private phase ('awaiting' | 'ready' | 'done')? +---@field private result table? +---@field private cancel function? +local Request = {} +Request.__index = Request + +---@return ow.lsp.completion.Request +function Request.new() + return setmetatable({ + generation = 0, + phase = nil, + result = nil, + cancel = nil, + }, Request) +end + +---@return boolean +function Request:is_awaiting() + return self.phase == "awaiting" +end + +---@return boolean +function Request:is_ready() + return self.phase == "ready" +end + +--- Cancel any in-flight LSP request and clear cached results. +function Request:discard() + if self.cancel then + pcall(self.cancel) + end + self.generation = self.generation + 1 + self.phase = nil + self.result = nil + self.cancel = nil +end + +---@param trigger_kind integer +---@param trigger_char string? +function Request:dispatch(trigger_kind, trigger_char) + self:discard() + self.phase = "awaiting" + local gen = self.generation + local buf = vim.api.nvim_get_current_buf() + local params = vim.lsp.util.make_position_params(0, "utf-16") + ---@cast params lsp.CompletionParams + params.context = { + triggerKind = trigger_kind, + triggerCharacter = trigger_char, + } + self.cancel = vim.lsp.buf_request_all( + buf, + vim.lsp.protocol.Methods.textDocument_completion, + params, + function(result) + if self.generation ~= gen or self.phase ~= "awaiting" then + return + end + self.phase = "ready" + self.result = result + feed_omnifunc() + end + ) +end + +--- Byte column where the cached LSP edits start, for |complete-functions| +--- `findstart == 1`. Falls back to the regex end of the word under cursor. +---@return integer +function Request: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*$") +end + +--- Build the Vim |complete()| items from the cached LSP results filtered by +--- `base`, and transition this request to the "done" phase. Subsequent +--- completion triggers will dispatch a fresh request. +---@param base string +---@return table[] +function Request:consume(base) + local items = build_items(self.result or {}, base) + self.phase = "done" + return items +end + +local request = Request.new() + local debounce = util.debounce(function(_, trigger_kind, char) if vim.fn.mode() ~= "i" then return end - dispatch_request(trigger_kind, char) + request:dispatch(trigger_kind, char) end, REQUEST_DEBOUNCE_MS) +local M = {} + function M.on_insert_char_pre() local char = vim.v.char if char == "" then @@ -273,6 +306,8 @@ function M.on_insert_char_pre() debounce:call(nil, kind_num, is_trigger and char or nil) end +-- Global entry point for Vim's 'omnifunc' option. Referenced via +-- `v:lua.ow_lsp_completion.omnifunc` from on_attach's `vim.bo.omnifunc`. _G.ow_lsp_completion = _G.ow_lsp_completion or {} --- Returns cached items without re-requesting, which is what lets Vim filter @@ -281,22 +316,17 @@ _G.ow_lsp_completion = _G.ow_lsp_completion or {} ---@param base string ---@return integer | table function _G.ow_lsp_completion.omnifunc(findstart, base) - if not buffer_has_completion_client() or request.phase == "awaiting" then + if not buffer_has_completion_client() or request:is_awaiting() then return findstart == 1 and -3 or {} end - - if request.phase ~= "ready" then - dispatch_request(vim.lsp.protocol.CompletionTriggerKind.Invoked) + if not request:is_ready() then + request:dispatch(vim.lsp.protocol.CompletionTriggerKind.Invoked) return findstart == 1 and -3 or {} end - if findstart == 1 then - return resolve_start_col() + return request:start_col() end - - local items = to_complete_items(base) - request.phase = "done" - return items + return request:consume(base) end return M