feat(lsp): add snippet support and refactor completion into Item class

This commit is contained in:
2026-04-17 00:03:03 +02:00
parent 5ecec7cc6c
commit 47f36adc21
9 changed files with 213 additions and 144 deletions
+2 -6
View File
@@ -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
+47 -45
View File
@@ -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 (<C-y>) and
-- acceptance-by-continuing-to-type, since Vim keeps the inserted text
-- in both cases. Only <C-e>/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("<C-k>", function()
popup:scroll_line(vim.keycode("<C-y>"))
end)
vim.keymap.set("i", "<CR>", function()
if vim.fn.pumvisible() ~= 0 then
return "<C-y>"
end
return "<CR>"
end, { expr = true, replace_keycodes = true })
end
return M
+90
View File
@@ -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
+17 -28
View File
@@ -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 (<C-e> or <C-y>)
---@param direction string single-line scroll keycode (<C-e> or <C-y>)
function Popup:scroll_line(direction)
self:scroll(direction, 1)
end
+32 -57
View File
@@ -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<integer, ow.lsp.CompletionResponse>
---@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<integer, ow.lsp.CompletionResponse>?
---@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 {}
+5
View File
@@ -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")
+5 -6
View File
@@ -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