From f40063f44b03b5548114193017af53cdf6eeb913 Mon Sep 17 00:00:00 2001 From: Oscar Wallberg Date: Wed, 15 Apr 2026 05:31:20 +0200 Subject: [PATCH] feat(lsp): custom omnifunc --- lua/core/options.lua | 2 +- lua/lsp/completion.lua | 377 +++++++++++++++++++++++++++++++++++------ lua/lsp/kind.lua | 90 ++++++++++ nvim-pack-lock.json | 5 - plugins/onedark.lua | 34 ++++ 5 files changed, 449 insertions(+), 59 deletions(-) create mode 100644 lua/lsp/kind.lua diff --git a/lua/core/options.lua b/lua/core/options.lua index 821ee68..4dfccb8 100644 --- a/lua/core/options.lua +++ b/lua/core/options.lua @@ -67,7 +67,7 @@ vim.opt.hlsearch = true vim.opt.laststatus = 2 vim.opt.textwidth = 0 vim.opt.colorcolumn = "81" -vim.opt.shortmess:append("a") +vim.opt.shortmess:append("acC") vim.opt.autoread = true -- vim.opt.cmdheight = 0 -- To hide cmdline when not used. Disabled due to -- causing "Press ENTER to continue" messages for small messages. diff --git a/lua/lsp/completion.lua b/lua/lsp/completion.lua index 2ab0423..33f1b4f 100644 --- a/lua/lsp/completion.lua +++ b/lua/lsp/completion.lua @@ -1,16 +1,28 @@ -local WORD_CHARS = - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_" +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") ----@type table -local word_chars_extended = {} +---@class ow.lsp.completion.Pum +---@field row integer +---@field col integer +---@field width integer +---@field height integer +---@field scrollbar boolean ----@alias lsp.completion.Pum { row: integer, col: integer, width: integer, height: integer, 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 @@ -25,7 +37,7 @@ local function signature_of(item) return item.detail or vim.tbl_get(item, "labelDetails", "description") or "" end ----@type { winid: integer?, bufnr: integer?, pending: { client: vim.lsp.Client, id: integer }? } +---@type ow.lsp.completion.Popup local popup = { winid = nil, bufnr = nil, @@ -60,9 +72,10 @@ local function update_indicators() 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 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 @@ -79,9 +92,9 @@ local function update_indicators() 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) - ) + 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, @@ -99,7 +112,7 @@ local function close() end ---@param content string ----@param pum lsp.completion.Pum +---@param pum ow.lsp.completion.Pum ---@param width integer local function show(content, pum, width) local bufnr = ensure_buffer() @@ -210,6 +223,300 @@ local function build_content(item, ft) 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 @@ -228,49 +535,13 @@ function M.on_attach(client, buf) return end - local provider = client.server_capabilities.completionProvider - if not provider then - return - end - provider.triggerCharacters = provider.triggerCharacters or {} - if not word_chars_extended[client.id] then - word_chars_extended[client.id] = true - for c in WORD_CHARS:gmatch(".") do - table.insert(provider.triggerCharacters, c) - end - 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("TextChangedI", { + vim.api.nvim_create_autocmd("InsertCharPre", { buffer = buf, group = GROUP, - callback = function() - if vim.fn.pumvisible() ~= 0 then - return - end - local col = vim.fn.col(".") - 1 - if col <= 0 then - return - end - local char = vim.api.nvim_get_current_line():sub(col, col) - if char:match("[%w_]") then - vim.lsp.completion.get() - end - end, - }) - - vim.lsp.completion.enable(true, client.id, buf, { - autotrigger = true, - convert = function(item) - return { - abbr = item.label:match("[^(]+") or item.label, - menu = "", - kind = "", - -- non-empty info blocks vim.lsp.completion's built-in - -- resolve so our own CompleteChanged handler can run it - info = " ", - } - end, + callback = on_insert_char_pre, }) end @@ -296,7 +567,7 @@ function M.setup() end local ft = vim.bo[ev.buf].filetype - ---@type lsp.completion.Pum + ---@type ow.lsp.completion.Pum local pum = { row = vim.v.event.row, col = vim.v.event.col, diff --git a/lua/lsp/kind.lua b/lua/lsp/kind.lua new file mode 100644 index 0000000..89c6a10 --- /dev/null +++ b/lua/lsp/kind.lua @@ -0,0 +1,90 @@ +local M = {} + +M.ICONS = { + Text = "󰉿", + Method = "󰊕", + Function = "󰊕", + Constructor = "󰒓", + Field = "󰜢", + Variable = "󰆦", + Property = "󰖷", + Class = "󱡠", + Interface = "󱡠", + Struct = "󱡠", + Module = "󰅩", + Unit = "󰪚", + Value = "󰦨", + Enum = "󰦨", + EnumMember = "󰦨", + Keyword = "󰻾", + Constant = "󰏿", + Snippet = "󱄽", + Color = "󰏘", + File = "󰈔", + Reference = "󰬲", + Folder = "󰉋", + Event = "󱐋", + Operator = "󰪚", + TypeParameter = "󰬛", +} + +local DEFAULT_HIGHLIGHTS = { + Method = "Function", + Function = "Function", + Constructor = "Function", + Field = "Identifier", + Variable = "Identifier", + Property = "Identifier", + Class = "Type", + Interface = "Type", + Struct = "Type", + Enum = "Type", + TypeParameter = "Type", + Keyword = "Keyword", + Operator = "Keyword", + Event = "Keyword", + Constant = "Constant", + EnumMember = "Constant", + Value = "Constant", + Unit = "Constant", + Text = "String", + Snippet = "String", + Color = "String", + Module = "Directory", + File = "Directory", + Folder = "Directory", + 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 +function M.get(kind) + if not kind then + return "", nil + end + local name = vim.lsp.protocol.CompletionItemKind[kind] + if not name or not M.ICONS[name] then + return "", nil + end + return M.ICONS[name], "OwLspKind" .. name +end + +local function register_highlights() + for name in pairs(M.ICONS) do + vim.api.nvim_set_hl(0, "OwLspKind" .. name, { + link = DEFAULT_HIGHLIGHTS[name] or "Pmenu", + default = true, + }) + end +end + +register_highlights() + +vim.api.nvim_create_autocmd("ColorScheme", { + group = vim.api.nvim_create_augroup("ow.lsp.kind", { clear = true }), + callback = register_highlights, +}) + +return M diff --git a/nvim-pack-lock.json b/nvim-pack-lock.json index ffd1f3b..9668002 100644 --- a/nvim-pack-lock.json +++ b/nvim-pack-lock.json @@ -4,11 +4,6 @@ "rev": "e30b7f2008e52442154b66f7c519bfd2f1e32acb", "src": "https://github.com/numToStr/Comment.nvim" }, - "blink.cmp": { - "rev": "78336bc89ee5365633bcf754d93df01678b5c08f", - "src": "https://github.com/saghen/blink.cmp", - "version": "1.0.0 - 2.0.0" - }, "fzf-lua": { "rev": "1e866cce085cc6df3d3e4fb59f00105fa84cef6c", "src": "https://github.com/ibhagwan/fzf-lua" diff --git a/plugins/onedark.lua b/plugins/onedark.lua index 120b7ab..a1b2cb1 100644 --- a/plugins/onedark.lua +++ b/plugins/onedark.lua @@ -1,6 +1,35 @@ require("onedark").setup({ style = "darker" }) local c = require("onedark.colors") + +local completion_kind_colors = { + Text = c.light_grey, + Method = c.blue, + Function = c.blue, + Constructor = c.blue, + Field = c.purple, + Variable = c.purple, + Property = c.cyan, + Class = c.yellow, + Interface = c.green, + Struct = c.purple, + Module = c.orange, + Unit = c.green, + Value = c.orange, + Enum = c.purple, + EnumMember = c.yellow, + Keyword = c.cyan, + Constant = c.orange, + Snippet = c.red, + Color = c.green, + File = c.blue, + Reference = c.orange, + Folder = c.orange, + Event = c.yellow, + Operator = c.red, + TypeParameter = c.red, +} + local highlights = { ["@string.special.url"] = { fg = "NONE", fmt = "NONE" }, ["@lsp.type.macro.gotmpl"] = { fg = "NONE", fmt = "NONE" }, @@ -25,5 +54,10 @@ local highlights = { GitDirty = { fg = c.yellow }, GitNew = { fg = c.green }, } +for kind, color in pairs(completion_kind_colors) do + highlights["OwLspKind" .. kind] = { fg = color } +end require("onedark").set_options("highlights", highlights) require("onedark").load() + +vim.api.nvim_set_hl(0, "PmenuSel", { link = "Visual" })