---@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 log = require("log") local util = require("util") local REQUEST_DEBOUNCE_MS = 50 ---@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) end ---@param item lsp.CompletionItem ---@return lsp.Range? 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 ow.lsp.CompletionResponse ---@return lsp.Position? local function edit_start(response) if not response.result or response.err then return nil end local result = response.result local defaults = default_range(result.items and result.itemDefaults) if defaults then return defaults.start end local items = result.items or 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 end end return nil end ---@param items lsp.CompletionItem[] ---@param defaults? lsp.CompletionItemDefaults local function apply_defaults(items, defaults) if type(defaults) ~= "table" then return end local range = default_range(defaults) 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 responses table ---@param base string text between start col and cursor, used for fuzzy match ---@return vim.v.completed_item[] local function build_items(responses, base) ---@type vim.v.completed_item[] local items = {} for client_id, response in pairs(responses) do if response.result and not response.err then local raw_items = response.result.items or response.result if type(raw_items) == "table" then apply_defaults(raw_items, response.result.itemDefaults) for _, raw in ipairs(raw_items) do local item = Item.from_lsp(raw, client_id) local kind_icon, kind_hl = kind.get(raw.kind) items[#items + 1] = { word = item.word, abbr = item.abbr, kind = kind_icon, kind_hlgroup = kind_hl, icase = 1, dup = 1, empty = 1, user_data = { ow = { item = item, }, }, filter = raw.filterText or raw.label, } end end end end if base ~= "" then if vim.o.completeopt:find("fuzzy", 1, true) then items = vim.fn.matchfuzzy(items, base, { key = "filter" }) else items = vim.tbl_filter(function(item) ---@diagnostic disable-next-line: undefined-field return vim.startswith(item.filter, base) end, items) end end return items end local function buffer_has_completion_client() return #vim.lsp.get_clients({ bufnr = 0, method = "textDocument/completion", }) > 0 end ---@return integer start, integer cursor both 0-indexed byte columns local function word_bounds() local line = vim.api.nvim_get_current_line() local cursor = vim.fn.col(".") - 1 local start = vim.fn.match(line:sub(1, cursor), "\\k*$") return start, cursor end ---@param char string ---@return boolean local function is_trigger_char(char) local clients = vim.lsp.get_clients({ bufnr = 0, method = "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 ---@class ow.lsp.completion.Session ---@field private generation integer monotonic counter for discarding stale async callbacks ---@field private cancel function? ---@field private trigger_char string? ---@field private manual boolean ---@field is_incomplete boolean server flagged last response as truncated local Session = {} Session.__index = Session ---@return ow.lsp.completion.Session function Session.new() return setmetatable({ generation = 0, cancel = nil, trigger_char = nil, manual = false, is_incomplete = false, }, Session) end function Session:discard() if self.cancel then pcall(self.cancel) end self.generation = self.generation + 1 self.cancel = nil end ---@param trigger_kind integer ---@param trigger_char? string ---@param manual? boolean true when invoked by the user (via M.trigger) function Session:dispatch(trigger_kind, trigger_char, manual) self:discard() self.trigger_char = trigger_char self.manual = manual or false local gen = self.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, } self.cancel = vim.lsp.buf_request_all( buf, "textDocument/completion", params, function(result, ctx) if self.generation ~= gen then return end vim.schedule(function() -- Re-check: another dispatch may have fired between response -- and the scheduled callback running. if self.generation ~= gen then return end if vim.fn.mode() ~= "i" then return end local word_start, cursor = word_bounds() if not self.manual and not self.trigger_char and word_start == cursor then return end self.is_incomplete = false for client_id, response in pairs(result) do if response.err then log.warning( "client %d: %s failed: %s", client_id, ctx.method, response.err.message ) end local r = response.result if type(r) == "table" and r.isIncomplete then self.is_incomplete = true break end end local start = word_start for _, response in pairs(result) do local pos = edit_start(response) if pos then start = pos.character break end end local base = vim.api.nvim_get_current_line():sub(start + 1, cursor) vim.fn.complete(start + 1, build_items(result, base)) end) end ) end local session = Session.new() local dispatch = util.debounce(function(trigger_kind, char) if vim.fn.mode() ~= "i" then return end session:dispatch(trigger_kind, char) end, REQUEST_DEBOUNCE_MS) local M = {} function M.trigger() session:dispatch(vim.lsp.protocol.CompletionTriggerKind.Invoked, nil, true) end function M.on_insert_char_pre() local char = vim.v.char if char == "" then return end if not buffer_has_completion_client() 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 -- Word chars: let Vim's native in-place filtering narrow the already- -- visible pum. Re-dispatch only if the server flagged the last response -- as incomplete (meaning it expects to be re-queried on more input). if is_word and vim.fn.pumvisible() ~= 0 and not session.is_incomplete then return end local kind_num = is_trigger and vim.lsp.protocol.CompletionTriggerKind.TriggerCharacter or vim.lsp.protocol.CompletionTriggerKind.Invoked dispatch(kind_num, is_trigger and char or nil) end return M