From 7162d00b4360f9764fdd41c0befba90557a94ea1 Mon Sep 17 00:00:00 2001 From: Oscar Wallberg Date: Thu, 16 Apr 2026 04:14:13 +0200 Subject: [PATCH] feat(lsp): apply additionalTextEdits on completion accept --- lua/core/keymap.lua | 5 ++- lua/lsp/completion/init.lua | 73 ++++++++++++++++++++++++++++++++++ lua/lsp/completion/popup.lua | 46 ++++++++++----------- lua/lsp/completion/request.lua | 2 +- 4 files changed, 100 insertions(+), 26 deletions(-) diff --git a/lua/core/keymap.lua b/lua/core/keymap.lua index a611e42..131c9c0 100644 --- a/lua/core/keymap.lua +++ b/lua/core/keymap.lua @@ -103,7 +103,10 @@ vim.cmd.aunmenu({ "PopUp.How-to\\ disable\\ mouse" }) vim.keymap.set("i", "", "") vim.keymap.set("i", "", "") vim.keymap.set("i", "", "^") -vim.keymap.set("i", "", "$") +vim.keymap.set("i", "", function() + -- Fall through to Vim's default (close popup + revert) during completion. + return vim.fn.pumvisible() ~= 0 and "" or "$" +end, { expr = true, replace_keycodes = true }) -- vim.keymap.set('i', '', 'x') -- Overrides de-indent vim.keymap.set("i", "", "w") vim.keymap.set("i", "", "b") diff --git a/lua/lsp/completion/init.lua b/lua/lsp/completion/init.lua index efc132a..fda2031 100644 --- a/lua/lsp/completion/init.lua +++ b/lua/lsp/completion/init.lua @@ -94,6 +94,79 @@ function M.setup() end, }) + -- Apply the LSP item's additionalTextEdits (auto-imports, etc.) and + -- run its post-accept command whenever an item's text was committed to + -- the buffer. This covers both explicit accept () and + -- acceptance-by-continuing-to-type, since Vim keeps the inserted text + -- in both cases. Only /discard leaves v:completed_item empty. + vim.api.nvim_create_autocmd("CompleteDone", { + group = GROUP, + callback = function(ev) + local completed = vim.v.completed_item or {} + local original = vim.tbl_get( + completed, + "user_data", + "nvim", + "lsp", + "completion_item" + ) + if not original then + return + end + 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 client then + return + end + + local function apply(item) + if item.additionalTextEdits then + vim.lsp.util.apply_text_edits( + item.additionalTextEdits, + ev.buf, + client.offset_encoding + ) + end + if item.command then + client:request("workspace/executeCommand", { + command = item.command.command, + arguments = item.command.arguments, + }, nil, ev.buf) + end + end + + -- Prefer the resolved item when we have it (servers like + -- rust-analyzer only provide additionalTextEdits in resolve + -- responses). If we haven't cached one yet and the original is + -- missing edits, resolve on the fly. + local cached = popup:resolved_for(completed.word) + if cached then + apply(cached) + elseif + client:supports_method( + vim.lsp.protocol.Methods.completionItem_resolve + ) + and not original.additionalTextEdits + and not original.command + then + client:request( + vim.lsp.protocol.Methods.completionItem_resolve, + original, + function(err, resolved) + if err or not resolved then + return + end + apply(resolved) + end, + ev.buf + ) + else + apply(original) + end + end, + }) + ---@param key string ---@param action fun() local function scroll_map(key, action) diff --git a/lua/lsp/completion/popup.lua b/lua/lsp/completion/popup.lua index 450cbd1..5510f52 100644 --- a/lua/lsp/completion/popup.lua +++ b/lua/lsp/completion/popup.lua @@ -22,42 +22,23 @@ 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 - ---@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 signature = item.detail + or vim.tbl_get(item, "labelDetails", "description") 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")) + if signature then + table.insert(sections, fence(ft, signature)) end - if doc ~= "" then + if doc then table.insert(sections, doc) end if #sections == 0 then @@ -78,6 +59,7 @@ end ---@field private winid integer? ---@field private bufnr integer? ---@field private pending ow.lsp.completion.PendingResolve? +---@field private resolved { word: string, item: lsp.CompletionItem }? local Popup = {} Popup.__index = Popup @@ -87,9 +69,23 @@ function Popup.new() winid = nil, bufnr = nil, pending = nil, + resolved = nil, }, Popup) end +--- Most recently resolved completion item for the given selection `word`, +--- or `nil` if we don't have one cached. Servers like rust-analyzer fill in +--- `additionalTextEdits` only in the resolve response, so on accept we want +--- the resolved version rather than the original one in `user_data`. +---@param word string +---@return lsp.CompletionItem? +function Popup:resolved_for(word) + if self.resolved and self.resolved.word == word then + return self.resolved.item + end + return nil +end + ---@return boolean function Popup:is_visible() return self.winid ~= nil and vim.api.nvim_win_is_valid(self.winid) @@ -127,6 +123,7 @@ end ---@param buf integer function Popup:dispatch_resolve(client, item, ft, pum, word, buf) self:cancel_pending() + self.resolved = nil local _, request_id = client:request( vim.lsp.protocol.Methods.completionItem_resolve, item, @@ -139,6 +136,7 @@ function Popup:dispatch_resolve(client, item, ft, pum, word, buf) if (vim.tbl_get(cur, "completed", "word") or "") ~= word then return end + self.resolved = { word = word, item = result } self:show(result, ft, pum) end, buf diff --git a/lua/lsp/completion/request.lua b/lua/lsp/completion/request.lua index fb671c4..f0ea55f 100644 --- a/lua/lsp/completion/request.lua +++ b/lua/lsp/completion/request.lua @@ -123,7 +123,7 @@ local function build_items(responses, base) result[#result + 1] = { word = item_word(item), abbr = item.label:match("[^(]+") or item.label, - menu = "", + menu = vim.tbl_get(item, "labelDetails", "detail") or "", kind = kind_icon, kind_hlgroup = kind_hl, -- non-empty so our CompleteChanged handler triggers resolve