From c91bd395b24093d04026f34c8d89818a5eec7f4c Mon Sep 17 00:00:00 2001 From: Oscar Wallberg Date: Wed, 15 Apr 2026 22:21:54 +0200 Subject: [PATCH] refactor(lsp): split completion module by concern --- lua/lsp/completion.lua | 663 --------------------------------- lua/lsp/completion/init.lua | 116 ++++++ lua/lsp/completion/popup.lua | 295 +++++++++++++++ lua/lsp/completion/request.lua | 302 +++++++++++++++ 4 files changed, 713 insertions(+), 663 deletions(-) delete mode 100644 lua/lsp/completion.lua create mode 100644 lua/lsp/completion/init.lua create mode 100644 lua/lsp/completion/popup.lua create mode 100644 lua/lsp/completion/request.lua diff --git a/lua/lsp/completion.lua b/lua/lsp/completion.lua deleted file mode 100644 index 33f1b4f..0000000 --- a/lua/lsp/completion.lua +++ /dev/null @@ -1,663 +0,0 @@ -local kind = require("lsp.kind") -local util = require("util") - -local MAX_WIDTH = 80 -local MAX_HEIGHT = 20 -local REQUEST_DEBOUNCE_MS = 100 - -local GROUP = vim.api.nvim_create_augroup("ow.lsp.completion", { clear = true }) -local NS = vim.api.nvim_create_namespace("ow.lsp.completion") - ----@class ow.lsp.completion.Pum ----@field row integer ----@field col integer ----@field width integer ----@field height integer ----@field scrollbar boolean - ----@class ow.lsp.completion.PendingResolve ----@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? - ----@param ft string ----@param text string ----@return string -local function fence(ft, text) - return string.format("```%s\n%s\n```", ft, text) -end - ----@param item lsp.CompletionItem ----@return string -local function signature_of(item) - return item.detail or vim.tbl_get(item, "labelDetails", "description") or "" -end - ----@type ow.lsp.completion.Popup -local popup = { - winid = nil, - bufnr = nil, - pending = nil, -} - -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 - -local function 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 ----@return integer? width -local function build_content(item, ft) - local signature = signature_of(item) - local doc = item.documentation - if type(doc) == "table" then - doc = doc.value - end - doc = doc or "" - - local code_parts = {} - if item.additionalTextEdits then - for _, edit in ipairs(item.additionalTextEdits) do - local text = (edit.newText or ""):gsub("%s+$", "") - if text ~= "" then - table.insert(code_parts, fence(ft, text)) - end - end - end - if signature ~= "" then - table.insert(code_parts, fence(ft, signature)) - end - - local sections = {} - if #code_parts > 0 then - table.insert(sections, table.concat(code_parts, "\n\n")) - end - if doc ~= "" then - table.insert(sections, doc) - end - if #sections == 0 then - return nil, nil - end - - local max_w = 0 - for _, s in ipairs(sections) do - for _, line in ipairs(vim.split(s, "\n", { plain = true })) do - max_w = math.max(max_w, vim.fn.strdisplaywidth(line)) - end - end - local sep = "\n" .. string.rep("─", math.min(max_w, MAX_WIDTH)) .. "\n" - return table.concat(sections, sep), max_w -end - ----@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? ----@return table? -local function default_range(edit_range) - if type(edit_range) ~= "table" then - return nil - end - return edit_range.insert or (edit_range.start and edit_range) or nil -end - ---- Normalize an item's `textEdit`, which may be `TextEdit` (has `range`) or ---- `InsertReplaceEdit` (has `insert`/`replace`). ----@param item lsp.CompletionItem ----@return table? -local function item_range(item) - local te = item.textEdit - if type(te) ~= "table" then - return nil - end - return te.range or te.insert -end - ----@param response table LSP response entry (as returned by buf_request_all) ----@return integer? character 0-indexed UTF-16 character where edit starts -local function response_edit_start(response) - if not response.result or response.err or response.error then - return nil - end - local defaults = - default_range(vim.tbl_get(response.result, "itemDefaults", "editRange")) - if defaults then - return defaults.start.character - end - local items = response.result.items or response.result - if type(items) ~= "table" then - return nil - end - for _, item in ipairs(items) do - local range = item_range(item) - if range then - return range.start.character - end - end - 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) - local edit = item.textEdit - if type(edit) == "table" and edit.newText then - return edit.newText - end - return item.insertText or item.label -end - ----@param items lsp.CompletionItem[] ----@param defaults? table -local function apply_defaults(items, defaults) - if type(defaults) ~= "table" then - return - end - local range = default_range(defaults.editRange) - for _, item in ipairs(items) do - item.commitCharacters = item.commitCharacters - or defaults.commitCharacters - item.data = item.data or defaults.data - item.insertTextFormat = item.insertTextFormat - or defaults.insertTextFormat - item.insertTextMode = item.insertTextMode or defaults.insertTextMode - if range and type(item.textEdit) ~= "table" then - item.textEdit = { - range = range, - newText = item.textEditText or item.insertText or item.label, - } - end - end -end - ----@param base string ----@return table[] -local function to_complete_items(base) - local entries = {} - for client_id, response in pairs(request.result or {}) 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 - apply_defaults(raw, response.result.itemDefaults) - for _, item in ipairs(raw) do - entries[#entries + 1] = - { item = item, client_id = client_id } - end - end - end - end - - if base ~= "" then - entries = vim.tbl_filter(function(entry) - local key = entry.item.filterText or entry.item.label - return vim.startswith(key, 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) - - local result = {} - for _, entry in ipairs(entries) do - local item = entry.item - local kind_icon, kind_hl = kind.get(item.kind) - result[#result + 1] = { - word = item_word(item), - abbr = item.label:match("[^(]+") or item.label, - menu = "", - kind = kind_icon, - kind_hlgroup = kind_hl, - -- non-empty so our CompleteChanged handler triggers resolve - info = " ", - icase = 1, - dup = 1, - empty = 1, - user_data = { - nvim = { - lsp = { - completion_item = item, - client_id = entry.client_id, - }, - }, - }, - } - end - 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 - ) -end - -_G.ow_lsp_completion = _G.ow_lsp_completion or {} - ---- Returns cached items without re-requesting, which is what lets Vim filter ---- the popup in-place on backspace. ----@param findstart 0 | 1 ----@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 - return findstart == 1 and -3 or {} - end - - if request.phase ~= "ready" then - dispatch_request(vim.lsp.protocol.CompletionTriggerKind.Invoked) - return findstart == 1 and -3 or {} - end - - if findstart == 1 then - return resolve_start_col() - end - - local items = to_complete_items(base) - request.phase = "done" - return items -end - ----@param char string ----@return boolean -local function is_trigger_char(char) - local clients = vim.lsp.get_clients({ - bufnr = 0, - method = vim.lsp.protocol.Methods.textDocument_completion, - }) - for _, client in ipairs(clients) do - local chars = vim.tbl_get( - client, - "server_capabilities", - "completionProvider", - "triggerCharacters" - ) - if chars and vim.list_contains(chars, char) then - return true - end - end - return false -end - -local debounce = util.debounce(function(_, trigger_kind, char) - if vim.fn.mode() ~= "i" then - return - end - dispatch_request(trigger_kind, char) -end, REQUEST_DEBOUNCE_MS) - -local function on_insert_char_pre() - local char = vim.v.char - if char == "" then - return - end - 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 - return - end - -- For word chars, let Vim's native in-place filtering handle things while - -- the pum is already showing. Trigger chars always schedule a request - -- because they change the completion context (e.g. member access). - if is_word and vim.fn.pumvisible() ~= 0 then - return - end - - 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) -end - -local M = {} - ----@param capabilities lsp.ClientCapabilities -function M.apply_capabilities(capabilities) - capabilities.textDocument.completion.completionItem.snippetSupport = false -end - ----@param client vim.lsp.Client ----@param buf integer -function M.on_attach(client, buf) - if - not client:supports_method( - vim.lsp.protocol.Methods.textDocument_completion - ) - then - return - end - - vim.bo[buf].omnifunc = "v:lua.ow_lsp_completion.omnifunc" - - vim.api.nvim_clear_autocmds({ group = GROUP, buffer = buf }) - vim.api.nvim_create_autocmd("InsertCharPre", { - buffer = buf, - group = GROUP, - callback = on_insert_char_pre, - }) -end - -function M.setup() - vim.api.nvim_create_autocmd("CompleteChanged", { - group = GROUP, - callback = function(ev) - local completed = vim.v.event.completed_item or {} - local lsp_item = vim.tbl_get( - completed, - "user_data", - "nvim", - "lsp", - "completion_item" - ) - local client_id = - vim.tbl_get(completed, "user_data", "nvim", "lsp", "client_id") - local client = client_id and vim.lsp.get_client_by_id(client_id) - - if not lsp_item or not client then - vim.schedule(close) - return - end - - local ft = vim.bo[ev.buf].filetype - ---@type ow.lsp.completion.Pum - local pum = { - row = vim.v.event.row, - col = vim.v.event.col, - width = vim.v.event.width, - height = vim.v.event.height, - scrollbar = vim.v.event.scrollbar, - } - - local will_resolve = client:supports_method( - vim.lsp.protocol.Methods.completionItem_resolve - ) - - if not will_resolve then - local initial, width = build_content(lsp_item, ft) - vim.schedule(function() - if initial and width then - show(initial, pum, width) - else - close() - end - end) - return - end - - cancel_pending() - local word = completed.word - local _, request_id = client:request( - vim.lsp.protocol.Methods.completionItem_resolve, - lsp_item, - function(err, result) - popup.pending = nil - if err or not result then - return - end - local cur = vim.fn.complete_info({ "completed" }) - if - (vim.tbl_get(cur, "completed", "word") or "") ~= word - then - return - end - local content, width = build_content(result, ft) - if content and width then - show(content, pum, width) - else - close() - end - end, - ev.buf - ) - if request_id then - popup.pending = { client = client, id = request_id } - end - end, - }) - - vim.api.nvim_create_autocmd({ "CompleteDonePre", "InsertLeave" }, { - group = GROUP, - callback = close, - }) - - ---@param key string - ---@param direction string single-line scroll keycode ( or ) - ---@param count integer - local function scroll_map(key, direction, count) - vim.keymap.set("i", key, function() - if popup.winid and vim.api.nvim_win_is_valid(popup.winid) then - vim.schedule(function() - if - popup.winid - and vim.api.nvim_win_is_valid(popup.winid) - then - vim.api.nvim_win_call(popup.winid, function() - vim.cmd.normal({ - args = { count .. direction }, - bang = true, - }) - end) - update_indicators() - end - end) - return "" - end - return key - end, { expr = true, replace_keycodes = true }) - end - local half = math.floor(MAX_HEIGHT / 2) - scroll_map("", vim.keycode(""), half) - scroll_map("", vim.keycode(""), half) - scroll_map("", vim.keycode(""), 1) - scroll_map("", vim.keycode(""), 1) -end - -return M diff --git a/lua/lsp/completion/init.lua b/lua/lsp/completion/init.lua new file mode 100644 index 0000000..2f32156 --- /dev/null +++ b/lua/lsp/completion/init.lua @@ -0,0 +1,116 @@ +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 M = {} + +---@param capabilities lsp.ClientCapabilities +function M.apply_capabilities(capabilities) + capabilities.textDocument.completion.completionItem.snippetSupport = false +end + +---@param client vim.lsp.Client +---@param buf integer +function M.on_attach(client, buf) + if + not client:supports_method( + vim.lsp.protocol.Methods.textDocument_completion + ) + then + return + end + + vim.bo[buf].omnifunc = "v:lua.ow_lsp_completion.omnifunc" + + vim.api.nvim_clear_autocmds({ group = GROUP, buffer = buf }) + vim.api.nvim_create_autocmd("InsertCharPre", { + buffer = buf, + group = GROUP, + callback = request.on_insert_char_pre, + }) +end + +function M.setup() + vim.api.nvim_create_autocmd("CompleteChanged", { + group = GROUP, + callback = function(ev) + local completed = vim.v.event.completed_item or {} + local lsp_item = vim.tbl_get( + completed, + "user_data", + "nvim", + "lsp", + "completion_item" + ) + local client_id = + vim.tbl_get(completed, "user_data", "nvim", "lsp", "client_id") + 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) + return + end + + local ft = vim.bo[ev.buf].filetype + ---@type ow.lsp.completion.Pum + local pum = { + row = vim.v.event.row, + col = vim.v.event.col, + width = vim.v.event.width, + height = vim.v.event.height, + scrollbar = vim.v.event.scrollbar, + } + + if + client:supports_method( + vim.lsp.protocol.Methods.completionItem_resolve + ) + then + popup.dispatch_resolve( + client, + lsp_item, + ft, + pum, + completed.word, + ev.buf + ) + else + vim.schedule(function() + popup.show_for(lsp_item, ft, pum) + end) + end + end, + }) + + vim.api.nvim_create_autocmd({ "CompleteDonePre", "InsertLeave" }, { + group = GROUP, + callback = popup.close, + }) + + ---@param key string + ---@param action fun() + local function scroll_map(key, action) + vim.keymap.set("i", key, function() + if popup.is_visible() then + vim.schedule(action) + return "" + end + return key + end, { expr = true, replace_keycodes = true }) + end + scroll_map("", function() + popup.scroll_half(vim.keycode("")) + end) + scroll_map("", function() + popup.scroll_half(vim.keycode("")) + end) + scroll_map("", function() + popup.scroll_line(vim.keycode("")) + end) + scroll_map("", function() + popup.scroll_line(vim.keycode("")) + end) +end + +return M diff --git a/lua/lsp/completion/popup.lua b/lua/lsp/completion/popup.lua new file mode 100644 index 0000000..ab9d212 --- /dev/null +++ b/lua/lsp/completion/popup.lua @@ -0,0 +1,295 @@ +local MAX_WIDTH = 80 +local MAX_HEIGHT = 20 + +local NS = vim.api.nvim_create_namespace("ow.lsp.completion.popup") + +local M = {} + +---@class ow.lsp.completion.Pum +---@field row integer +---@field col integer +---@field width integer +---@field height integer +---@field scrollbar boolean + +---@class ow.lsp.completion.PendingResolve +---@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 +local function fence(ft, text) + return string.format("```%s\n%s\n```", ft, text) +end + +---@param item lsp.CompletionItem +---@return string +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 +---@return integer? width +local function build_content(item, ft) + local signature = signature_of(item) + local doc = item.documentation + if type(doc) == "table" then + doc = doc.value + end + doc = doc or "" + + local code_parts = {} + if item.additionalTextEdits then + for _, edit in ipairs(item.additionalTextEdits) do + local text = (edit.newText or ""):gsub("%s+$", "") + if text ~= "" then + table.insert(code_parts, fence(ft, text)) + end + end + end + if signature ~= "" then + table.insert(code_parts, fence(ft, signature)) + end + + local sections = {} + if #code_parts > 0 then + table.insert(sections, table.concat(code_parts, "\n\n")) + end + if doc ~= "" then + table.insert(sections, doc) + end + if #sections == 0 then + return nil, nil + end + + local max_w = 0 + for _, s in ipairs(sections) do + for _, line in ipairs(vim.split(s, "\n", { plain = true })) do + max_w = math.max(max_w, vim.fn.strdisplaywidth(line)) + end + end + local sep = "\n" .. string.rep("─", math.min(max_w, MAX_WIDTH)) .. "\n" + return table.concat(sections, sep), max_w +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) + local content, width = build_content(item, ft) + if content and width then + show(content, pum, width) + else + M.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. +---@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() + local _, request_id = client:request( + vim.lsp.protocol.Methods.completionItem_resolve, + item, + function(err, result) + popup.pending = nil + if err or not result then + return + end + local cur = vim.fn.complete_info({ "completed" }) + if (vim.tbl_get(cur, "completed", "word") or "") ~= word then + return + end + M.show_for(result, ft, pum) + end, + buf + ) + if request_id then + popup.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) +end + +---@param direction string single-line scroll keycode ( or ) +---@param count integer +local function scroll(direction, count) + if not M.is_visible() then + return + end + vim.api.nvim_win_call(popup.winid, function() + vim.cmd.normal({ args = { count .. direction }, bang = true }) + end) + update_indicators() +end + +---@param direction string single-line scroll keycode ( or ) +function M.scroll_line(direction) + scroll(direction, 1) +end + +---@param direction string single-line scroll keycode ( or ) +function M.scroll_half(direction) + scroll(direction, math.floor(MAX_HEIGHT / 2)) +end + +return M diff --git a/lua/lsp/completion/request.lua b/lua/lsp/completion/request.lua new file mode 100644 index 0000000..6ff615a --- /dev/null +++ b/lua/lsp/completion/request.lua @@ -0,0 +1,302 @@ +local kind = require("lsp.kind") +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? +---@return table? +local function default_range(edit_range) + if type(edit_range) ~= "table" then + return nil + end + return edit_range.insert or (edit_range.start and edit_range) or nil +end + +--- Normalize an item's `textEdit`, which may be `TextEdit` (has `range`) or +--- `InsertReplaceEdit` (has `insert`/`replace`). +---@param item lsp.CompletionItem +---@return table? +local function item_range(item) + local te = item.textEdit + if type(te) ~= "table" then + return nil + end + return te.range or te.insert +end + +---@param response table LSP response entry (as returned by buf_request_all) +---@return integer? character 0-indexed UTF-16 character where edit starts +local function response_edit_start(response) + if not response.result or response.err or response.error then + return nil + end + local defaults = + default_range(vim.tbl_get(response.result, "itemDefaults", "editRange")) + if defaults then + return defaults.start.character + end + local items = response.result.items or response.result + if type(items) ~= "table" then + return nil + end + for _, item in ipairs(items) do + local range = item_range(item) + if range then + return range.start.character + end + end + 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) + local edit = item.textEdit + if type(edit) == "table" and edit.newText then + return edit.newText + end + return item.insertText or item.label +end + +---@param items lsp.CompletionItem[] +---@param defaults? table +local function apply_defaults(items, defaults) + if type(defaults) ~= "table" then + return + end + local range = default_range(defaults.editRange) + for _, item in ipairs(items) do + item.commitCharacters = item.commitCharacters + or defaults.commitCharacters + item.data = item.data or defaults.data + item.insertTextFormat = item.insertTextFormat + or defaults.insertTextFormat + item.insertTextMode = item.insertTextMode or defaults.insertTextMode + if range and type(item.textEdit) ~= "table" then + item.textEdit = { + range = range, + newText = item.textEditText or item.insertText or item.label, + } + end + end +end + +---@param base string +---@return table[] +local function to_complete_items(base) + local entries = {} + for client_id, response in pairs(request.result or {}) 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 + apply_defaults(raw, response.result.itemDefaults) + for _, item in ipairs(raw) do + entries[#entries + 1] = + { item = item, client_id = client_id } + end + end + end + end + + if base ~= "" then + entries = vim.tbl_filter(function(entry) + local key = entry.item.filterText or entry.item.label + return vim.startswith(key, 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) + + local result = {} + for _, entry in ipairs(entries) do + local item = entry.item + local kind_icon, kind_hl = kind.get(item.kind) + result[#result + 1] = { + word = item_word(item), + abbr = item.label:match("[^(]+") or item.label, + menu = "", + kind = kind_icon, + kind_hlgroup = kind_hl, + -- non-empty so our CompleteChanged handler triggers resolve + info = " ", + icase = 1, + dup = 1, + empty = 1, + user_data = { + nvim = { + lsp = { + completion_item = item, + client_id = entry.client_id, + }, + }, + }, + } + end + 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 + ) +end + +---@param char string +---@return boolean +local function is_trigger_char(char) + local clients = vim.lsp.get_clients({ + bufnr = 0, + method = vim.lsp.protocol.Methods.textDocument_completion, + }) + for _, client in ipairs(clients) do + local chars = vim.tbl_get( + client, + "server_capabilities", + "completionProvider", + "triggerCharacters" + ) + if chars and vim.list_contains(chars, char) then + return true + end + end + return false +end + +local debounce = util.debounce(function(_, trigger_kind, char) + if vim.fn.mode() ~= "i" then + return + end + dispatch_request(trigger_kind, char) +end, REQUEST_DEBOUNCE_MS) + +function M.on_insert_char_pre() + local char = vim.v.char + if char == "" then + return + end + 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 + return + end + -- For word chars, let Vim's native in-place filtering handle things while + -- the pum is already showing. Trigger chars always schedule a request + -- because they change the completion context (e.g. member access). + if is_word and vim.fn.pumvisible() ~= 0 then + return + end + + 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) +end + +_G.ow_lsp_completion = _G.ow_lsp_completion or {} + +--- Returns cached items without re-requesting, which is what lets Vim filter +--- the popup in-place on backspace. +---@param findstart 0 | 1 +---@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 + return findstart == 1 and -3 or {} + end + + if request.phase ~= "ready" then + dispatch_request(vim.lsp.protocol.CompletionTriggerKind.Invoked) + return findstart == 1 and -3 or {} + end + + if findstart == 1 then + return resolve_start_col() + end + + local items = to_complete_items(base) + request.phase = "done" + return items +end + +return M