From 47f36adc21a838bbcdf0628082f2551068ab6db2 Mon Sep 17 00:00:00 2001 From: Oscar Wallberg Date: Fri, 17 Apr 2026 00:03:03 +0200 Subject: [PATCH] feat(lsp): add snippet support and refactor completion into Item class --- lua/core/keymap.lua | 13 +++++ lua/dap/hover/node.lua | 4 +- lua/lsp/codelens.lua | 8 +-- lua/lsp/completion/init.lua | 92 +++++++++++++++++----------------- lua/lsp/completion/item.lua | 90 +++++++++++++++++++++++++++++++++ lua/lsp/completion/popup.lua | 45 +++++++---------- lua/lsp/completion/request.lua | 89 ++++++++++++-------------------- lua/lsp/init.lua | 5 ++ lua/lsp/kind.lua | 11 ++-- 9 files changed, 213 insertions(+), 144 deletions(-) create mode 100644 lua/lsp/completion/item.lua diff --git a/lua/core/keymap.lua b/lua/core/keymap.lua index 131c9c0..b6545fc 100644 --- a/lua/core/keymap.lua +++ b/lua/core/keymap.lua @@ -162,6 +162,18 @@ end) vim.keymap.set({ "i", "s" }, "", function() vim.lsp.buf.signature_help({ max_width = 80 }) end) +---@param key string +---@param dir 1 | -1 +local function snippet_jump_map(key, dir) + vim.keymap.set({ "i", "s" }, key, function() + if vim.snippet.active({ direction = dir }) then + return string.format("lua vim.snippet.jump(%d)", dir) + end + return key + end, { expr = true, replace_keycodes = true }) +end +snippet_jump_map("", 1) +snippet_jump_map("", -1) vim.keymap.set({ "n", "i" }, "", vim.lsp.buf.document_highlight) vim.keymap.set({ "n", "x" }, "lf", vim.lsp.buf.format) vim.keymap.set("n", "grl", function() @@ -177,6 +189,7 @@ vim.keymap.set({ "n", "i" }, "", function() vim.lsp.buf.clear_references() vim.cmd.nohlsearch() vim.schedule(vim.cmd.diffupdate) + vim.snippet.stop() return "" end, { expr = true }) vim.keymap.set("n", "fd", vim.diagnostic.setloclist) diff --git a/lua/dap/hover/node.lua b/lua/dap/hover/node.lua index 991c03b..006bb66 100644 --- a/lua/dap/hover/node.lua +++ b/lua/dap/hover/node.lua @@ -12,7 +12,7 @@ local Node = {} Node.__index = Node ---@param item ow.dap.Item ----@param parent ow.dap.hover.Node? +---@param parent? ow.dap.hover.Node ---@param lang string ---@return ow.dap.hover.Node function Node.new(item, parent, lang) @@ -68,7 +68,7 @@ function Node:at(n) return search(self) end ----@param target ow.dap.hover.Node? if nil, returns index of self +---@param target? ow.dap.hover.Node if nil, returns index of self ---@return integer? function Node:index_of(target) target = target or self diff --git a/lua/lsp/codelens.lua b/lua/lsp/codelens.lua index 6057f09..3c15614 100644 --- a/lua/lsp/codelens.lua +++ b/lua/lsp/codelens.lua @@ -25,7 +25,7 @@ end, REFRESH_DEBOUNCE_MS) ---@class ow.lsp.codelens.Row ---@field row integer ---@field ready lsp.CodeLens[] ----@field pending integer count of unresolved lenses still in flight +---@field pending integer local Row = {} Row.__index = Row @@ -44,7 +44,7 @@ function Row:await() self.pending = self.pending + 1 end ----@param resolved lsp.CodeLens? +---@param resolved? lsp.CodeLens function Row:resolved(resolved) self.pending = self.pending - 1 if resolved then @@ -52,9 +52,6 @@ function Row:resolved(resolved) end end ---- Paint this row's extmark. Preserves the previous extmark while any lens ---- on the row is still resolving so the user doesn't see a partial row ---- (e.g. "refs" without "impls"). ---@param buf integer function Row:render(buf) if not vim.api.nvim_buf_is_valid(buf) then @@ -161,7 +158,6 @@ function State.new(buf) }, State) end ---- Paint every row and drop extmarks on rows that are no longer present. function State:render() if not vim.api.nvim_buf_is_valid(self.buf) then return diff --git a/lua/lsp/completion/init.lua b/lua/lsp/completion/init.lua index fda2031..3d8a6bd 100644 --- a/lua/lsp/completion/init.lua +++ b/lua/lsp/completion/init.lua @@ -1,3 +1,4 @@ +local Item = require("lsp.completion.item") local Popup = require("lsp.completion.popup") local request = require("lsp.completion.request") @@ -9,7 +10,7 @@ local M = {} ---@param capabilities lsp.ClientCapabilities function M.apply_capabilities(capabilities) - capabilities.textDocument.completion.completionItem.snippetSupport = false + capabilities.textDocument.completion.completionItem.snippetSupport = true end ---@param client vim.lsp.Client @@ -38,18 +39,10 @@ function M.setup() 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) + local item = Item.from_user_data(completed) + local client = item and vim.lsp.get_client_by_id(item.client_id) - if not lsp_item or not client then + if not item or not client then vim.schedule(function() popup:close() end) @@ -73,7 +66,7 @@ function M.setup() then popup:dispatch_resolve( client, - lsp_item, + item, ft, pum, completed.word, @@ -81,7 +74,7 @@ function M.setup() ) else vim.schedule(function() - popup:show(lsp_item, ft, pum) + popup:show(item, ft, pum) end) end end, @@ -94,52 +87,53 @@ 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 + local item = Item.from_user_data(completed) + if not item 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) + local client = vim.lsp.get_client_by_id(item.client_id) if not client then return end - local function apply(item) - if item.additionalTextEdits then + local function apply(target) + local raw = target.raw + if raw.additionalTextEdits then vim.lsp.util.apply_text_edits( - item.additionalTextEdits, + raw.additionalTextEdits, ev.buf, client.offset_encoding ) end - if item.command then + if raw.command then client:request("workspace/executeCommand", { - command = item.command.command, - arguments = item.command.arguments, + command = raw.command.command, + arguments = raw.command.arguments, }, nil, ev.buf) end + if target.snippet then + local word = completed.word or "" + local cursor = vim.api.nvim_win_get_cursor(0) + local row = cursor[1] - 1 + local col = cursor[2] + vim.api.nvim_buf_set_text( + ev.buf, + row, + col - #word, + row, + col, + {} + ) + vim.snippet.expand(target.snippet) + 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. + -- Prefer the resolved item when we have it. 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) @@ -147,22 +141,23 @@ function M.setup() client:supports_method( vim.lsp.protocol.Methods.completionItem_resolve ) - and not original.additionalTextEdits - and not original.command + and not item.raw.additionalTextEdits + and not item.raw.command then client:request( vim.lsp.protocol.Methods.completionItem_resolve, - original, + item.raw, function(err, resolved) if err or not resolved then return end - apply(resolved) + item:apply_resolved(resolved) + apply(item) end, ev.buf ) else - apply(original) + apply(item) end end, }) @@ -190,6 +185,13 @@ function M.setup() scroll_map("", function() popup:scroll_line(vim.keycode("")) end) + + vim.keymap.set("i", "", function() + if vim.fn.pumvisible() ~= 0 then + return "" + end + return "" + end, { expr = true, replace_keycodes = true }) end return M diff --git a/lua/lsp/completion/item.lua b/lua/lsp/completion/item.lua new file mode 100644 index 0000000..7076de8 --- /dev/null +++ b/lua/lsp/completion/item.lua @@ -0,0 +1,90 @@ +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 + +---@param raw lsp.CompletionItem +---@return string +local function label_stem(raw) + return raw.label:match("[^(]+") or raw.label +end + +---@param raw lsp.CompletionItem +---@return string? +local function extract_doc(raw) + local doc = raw.documentation + if type(doc) == "table" then + return doc.value + end + ---@cast doc string? + return doc +end + +---@param raw lsp.CompletionItem +---@return string? +local function extract_detail(raw) + return raw.detail or vim.tbl_get(raw, "labelDetails", "description") +end + +---@class ow.lsp.completion.Item +---@field word string +---@field abbr string +---@field menu string +---@field detail string? +---@field doc string? +---@field snippet string? +---@field client_id integer +---@field raw lsp.CompletionItem +local Item = {} +Item.__index = Item + +---@param raw lsp.CompletionItem +---@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, + client_id = client_id, + raw = raw, + }, Item) +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) + if self.snippet then + self.snippet = edit_text(self.raw) or self.raw.label + end +end + +---@param completed vim.v.completed_item +---@return ow.lsp.completion.Item? +function Item.from_user_data(completed) + local t = vim.tbl_get(completed, "user_data", "ow", "item") + if t then + return setmetatable(t, Item) + end + return nil +end + +return Item diff --git a/lua/lsp/completion/popup.lua b/lua/lsp/completion/popup.lua index 5510f52..227e312 100644 --- a/lua/lsp/completion/popup.lua +++ b/lua/lsp/completion/popup.lua @@ -22,24 +22,20 @@ local function fence(ft, text) return string.format("```%s\n%s\n```", ft, text) end ----@param item lsp.CompletionItem +---@param item ow.lsp.completion.Item ---@param ft string ---@return string? content ---@return integer? width local function build_content(item, ft) - local signature = item.detail - or vim.tbl_get(item, "labelDetails", "description") - local doc = item.documentation - if type(doc) == "table" then - doc = doc.value - end - local sections = {} - if signature then - table.insert(sections, fence(ft, signature)) + if item.detail then + table.insert(sections, fence(ft, item.detail)) end - if doc then - table.insert(sections, doc) + if item.snippet then + table.insert(sections, fence(ft, item.snippet)) + end + if item.doc then + table.insert(sections, item.doc) end if #sections == 0 then return nil, nil @@ -59,7 +55,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 }? +---@field private resolved { word: string, item: ow.lsp.completion.Item }? local Popup = {} Popup.__index = Popup @@ -73,12 +69,8 @@ function Popup.new() }, 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? +---@return ow.lsp.completion.Item? function Popup:resolved_for(word) if self.resolved and self.resolved.word == word then return self.resolved.item @@ -99,8 +91,7 @@ function Popup:close() self.winid = nil end ---- Build content for `item` and show, or close if there's nothing to render. ----@param item lsp.CompletionItem +---@param item ow.lsp.completion.Item ---@param ft string ---@param pum ow.lsp.completion.Pum function Popup:show(item, ft, pum) @@ -112,11 +103,8 @@ function Popup:show(item, ft, pum) 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 (word mismatch), do nothing. ---@param client vim.lsp.Client ----@param item lsp.CompletionItem +---@param item ow.lsp.completion.Item ---@param ft string ---@param pum ow.lsp.completion.Pum ---@param word string @@ -126,7 +114,7 @@ function Popup:dispatch_resolve(client, item, ft, pum, word, buf) self.resolved = nil local _, request_id = client:request( vim.lsp.protocol.Methods.completionItem_resolve, - item, + item.raw, function(err, result) self.pending = nil if err or not result then @@ -136,8 +124,9 @@ 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) + item:apply_resolved(result) + self.resolved = { word = word, item = item } + self:show(item, ft, pum) end, buf ) @@ -146,7 +135,7 @@ function Popup:dispatch_resolve(client, item, ft, pum, word, buf) end end ----@param direction string single-line scroll keycode ( or ) +---@param direction string single-line scroll keycode ( or ) function Popup:scroll_line(direction) self:scroll(direction, 1) end diff --git a/lua/lsp/completion/request.lua b/lua/lsp/completion/request.lua index f0ea55f..4501144 100644 --- a/lua/lsp/completion/request.lua +++ b/lua/lsp/completion/request.lua @@ -1,23 +1,24 @@ +---@class ow.lsp.CompletionResponse : ow.lsp.Response +---@field result lsp.CompletionItem[] | lsp.CompletionList + +local Item = require("lsp.completion.item") local kind = require("lsp.kind") local util = require("util") local REQUEST_DEBOUNCE_MS = 100 ---- 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) +---@param item_defaults? lsp.CompletionItemDefaults +---@return lsp.Range? +local function default_range(item_defaults) + local edit_range = item_defaults and item_defaults.editRange if type(edit_range) ~= "table" then return nil end - return edit_range.insert or (edit_range.start and edit_range) or nil + return edit_range.insert or (edit_range.start and edit_range) end ---- Normalize an item's `textEdit`, which may be `TextEdit` (has `range`) or ---- `InsertReplaceEdit` (has `insert`/`replace`). ---@param item lsp.CompletionItem ----@return table? +---@return lsp.Range? local function item_range(item) local te = item.textEdit if type(te) ~= "table" then @@ -26,18 +27,18 @@ local function item_range(item) 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 +---@param response ow.lsp.CompletionResponse +---@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 + if not response.result or response.err then return nil end - local defaults = - default_range(vim.tbl_get(response.result, "itemDefaults", "editRange")) + local result = response.result + local defaults = default_range(result.items and result.itemDefaults) if defaults then return defaults.start.character end - local items = response.result.items or response.result + local items = result.items or result if type(items) ~= "table" then return nil end @@ -50,23 +51,13 @@ local function response_edit_start(response) return nil 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 +---@param defaults? lsp.CompletionItemDefaults local function apply_defaults(items, defaults) if type(defaults) ~= "table" then return end - local range = default_range(defaults.editRange) + local range = default_range(defaults) for _, item in ipairs(items) do item.commitCharacters = item.commitCharacters or defaults.commitCharacters @@ -83,15 +74,13 @@ 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 responses table ---@param base string ----@return table[] +---@return vim.v.completed_item[] local function build_items(responses, base) local entries = {} for client_id, response in pairs(responses) do - if response.result and not (response.err or response.error) then + if response.result and not response.err then local raw = response.result.items or response.result if type(raw) == "table" then apply_defaults(raw, response.result.itemDefaults) @@ -116,14 +105,15 @@ local function build_items(responses, base) return ka < kb end) + ---@type vim.v.completed_item[] local result = {} for _, entry in ipairs(entries) do - local item = entry.item - local kind_icon, kind_hl = kind.get(item.kind) + local item = Item.from_lsp(entry.item, entry.client_id) + local kind_icon, kind_hl = kind.get(entry.item.kind) result[#result + 1] = { - word = item_word(item), - abbr = item.label:match("[^(]+") or item.label, - menu = vim.tbl_get(item, "labelDetails", "detail") or "", + word = item.word, + abbr = item.abbr, + menu = item.menu, kind = kind_icon, kind_hlgroup = kind_hl, -- non-empty so our CompleteChanged handler triggers resolve @@ -131,14 +121,7 @@ local function build_items(responses, base) icase = 1, dup = 1, empty = 1, - user_data = { - nvim = { - lsp = { - completion_item = item, - client_id = entry.client_id, - }, - }, - }, + user_data = { ow = { item = item } }, } end return result @@ -182,7 +165,7 @@ end ---@class ow.lsp.completion.Request ---@field private generation integer ---@field private phase ('awaiting' | 'ready' | 'done')? ----@field private result table? +---@field private result table? ---@field private cancel function? local Request = {} Request.__index = Request @@ -207,7 +190,6 @@ 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) @@ -219,7 +201,7 @@ function Request:discard() end ---@param trigger_kind integer ----@param trigger_char string? +---@param trigger_char? string function Request:dispatch(trigger_kind, trigger_char) self:discard() self.phase = "awaiting" @@ -246,8 +228,6 @@ function Request:dispatch(trigger_kind, trigger_char) ) 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 @@ -261,11 +241,8 @@ function Request:start_col() 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[] +---@return vim.v.completed_item[] function Request:consume(base) local items = build_items(self.result or {}, base) self.phase = "done" @@ -310,11 +287,9 @@ end -- `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 ---- the popup in-place on backspace. ---@param findstart 0 | 1 ---@param base string ----@return integer | table +---@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 return findstart == 1 and -3 or {} diff --git a/lua/lsp/init.lua b/lua/lsp/init.lua index a7a7b92..f7a3676 100644 --- a/lua/lsp/init.lua +++ b/lua/lsp/init.lua @@ -1,3 +1,8 @@ +---@class ow.lsp.Response +---@field err lsp.ResponseError? +---@field result any +---@field context lsp.HandlerContext + local codelens = require("lsp.codelens") local completion = require("lsp.completion") local diagnostic = require("lsp.diagnostic") diff --git a/lua/lsp/kind.lua b/lua/lsp/kind.lua index 89c6a10..bfa6016 100644 --- a/lua/lsp/kind.lua +++ b/lua/lsp/kind.lua @@ -56,17 +56,16 @@ local DEFAULT_HIGHLIGHTS = { Reference = "Special", } ---- Look up icon and highlight group for an LSP CompletionItemKind number. ----@param kind integer? ----@return string icon empty string if the kind is unknown ----@return string? hl nil if the kind is unknown +---@param kind? integer +---@return string? icon +---@return string? hl function M.get(kind) if not kind then - return "", nil + return nil, nil end local name = vim.lsp.protocol.CompletionItemKind[kind] if not name or not M.ICONS[name] then - return "", nil + return nil, nil end return M.ICONS[name], "OwLspKind" .. name end