diff --git a/lua/core/options.lua b/lua/core/options.lua index fda04ca..e4d7a6d 100644 --- a/lua/core/options.lua +++ b/lua/core/options.lua @@ -31,7 +31,6 @@ vim.opt.completeopt = { "menuone", "noinsert", "noselect", - "fuzzy", } vim.opt.complete = { "o" } -- set nowrap diff --git a/lua/lsp/completion/init.lua b/lua/lsp/completion/init.lua index dc3d05d..75a063c 100644 --- a/lua/lsp/completion/init.lua +++ b/lua/lsp/completion/init.lua @@ -17,8 +17,6 @@ local function on_attach(client, buf) return 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("InsertCharPre", { buffer = buf, @@ -208,12 +206,7 @@ function M.setup() return "" end, { expr = true, replace_keycodes = true }) - vim.keymap.set( - "i", - "", - session.trigger_manual, - { expr = true, remap = false } - ) + vim.keymap.set("i", "", session.trigger) end return M diff --git a/lua/lsp/completion/session.lua b/lua/lsp/completion/session.lua index 170ab2d..4979eef 100644 --- a/lua/lsp/completion/session.lua +++ b/lua/lsp/completion/session.lua @@ -28,15 +28,15 @@ local function item_range(item) end ---@param response ow.lsp.CompletionResponse ----@return integer? character 0-indexed UTF-16 character where edit starts -local function response_edit_start(response) +---@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.character + return defaults.start end local items = result.items or result if type(items) ~= "table" then @@ -45,7 +45,7 @@ local function response_edit_start(response) for _, item in ipairs(items) do local range = item_range(item) if range then - return range.start.character + return range.start end end return nil @@ -75,54 +75,48 @@ local function apply_defaults(items, defaults) end ---@param responses table ----@param base string +---@param base string text between start col and cursor, used for fuzzy match ---@return vim.v.completed_item[] local function build_items(responses, base) - local entries = {} + ---@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 = 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 } + 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 - entries = vim.tbl_filter(function(entry) - local key = entry.item.filterText or entry.item.label - return vim.startswith(key, base) - end, entries) + if vim.o.completeopt:find("fuzzy", 1, true) then + items = vim.fn.matchfuzzy(items, base, { key = "filter" }) + else + items = vim.tbl_filter(function(item) + return vim.startswith(item.filter, base) + end, items) + end end - - -- for _, entry in ipairs(entries) do - -- entry.sort_key = entry.item.sortText or entry.item.label - -- end - -- table.sort(entries, function(a, b) - -- return a.sort_key < b.sort_key - -- end) - - ---@type vim.v.completed_item[] - local result = {} - for _, entry in ipairs(entries) do - 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, - abbr = item.abbr, - kind = kind_icon, - kind_hlgroup = kind_hl, - icase = 1, - dup = 1, - empty = 1, - user_data = { ow = { item = item } }, - } - end - return result + return items end local function buffer_has_completion_client() @@ -161,19 +155,12 @@ local function is_trigger_char(char) return false end -local function feed_omnifunc() - if vim.fn.mode() ~= "i" or vim.fn.pumvisible() ~= 0 then - return - end - vim.api.nvim_feedkeys(vim.keycode(""), "n", false) -end - ---@class ow.lsp.completion.Session ----@field private generation integer ----@field private phase ('awaiting' | 'ready' | 'done')? ----@field private result table? +---@field private generation integer monotonic counter for discarding stale async callbacks ---@field private cancel function? ----@field trigger_char string? +---@field private trigger_char string? +---@field private manual boolean +---@field is_incomplete boolean server flagged last response as truncated local Session = {} Session.__index = Session @@ -181,39 +168,28 @@ Session.__index = Session function Session.new() return setmetatable({ generation = 0, - phase = nil, - result = nil, cancel = nil, trigger_char = nil, + manual = false, + is_incomplete = false, }, Session) end ----@return boolean -function Session:is_awaiting() - return self.phase == "awaiting" -end - ----@return boolean -function Session:is_ready() - return self.phase == "ready" -end - function Session:discard() if self.cancel then pcall(self.cancel) end self.generation = self.generation + 1 - self.phase = nil - self.result = nil self.cancel = nil end ---@param trigger_kind integer ---@param trigger_char? string -function Session:dispatch(trigger_kind, trigger_char) +---@param manual? boolean true when invoked by the user (via M.trigger) +function Session:dispatch(trigger_kind, trigger_char, manual) self:discard() - self.phase = "awaiting" 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") @@ -227,60 +203,63 @@ function Session:dispatch(trigger_kind, trigger_char) vim.lsp.protocol.Methods.textDocument_completion, params, function(result) - if self.generation ~= gen or self.phase ~= "awaiting" then + if self.generation ~= gen then return end - self.phase = "ready" - self.result = result - feed_omnifunc() + 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 _, response in pairs(result) do + 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 ----@return integer -function Session:start_col() - for _, response in pairs(self.result or {}) do - local col = response_edit_start(response) - if col then - return col - end - end - local start = word_bounds() - return start -end - ----@param base string ----@return vim.v.completed_item[] -function Session:finalize(base) - local items = build_items(self.result or {}, base) - self.phase = "done" - return items -end - ----@type ow.lsp.completion.Session? -local session = nil +local session = Session.new() local dispatcher = util.debounce(function(_, trigger_kind, char) if vim.fn.mode() ~= "i" then return end - if not session then - session = Session.new() - end session:dispatch(trigger_kind, char) end, REQUEST_DEBOUNCE_MS) --- Set by `trigger_manual` when the user explicitly invokes completion via --- . Survives the dispatch+response round-trip so the omnifunc knows --- to skip the "no word before cursor" guard. Reset on the next auto trigger. -local manual_pending = false - local M = {} ----Expose for the keymap wrapper. -function M.trigger_manual() - manual_pending = true - return vim.keycode("") +function M.trigger() + session:dispatch(vim.lsp.protocol.CompletionTriggerKind.Invoked, nil, true) end function M.on_insert_char_pre() @@ -288,61 +267,24 @@ function M.on_insert_char_pre() if char == "" then return end - manual_pending = false + 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 - -- 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 + -- 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 dispatcher:call(nil, kind_num, is_trigger and char or nil) end --- Global entry point for Vim's 'omnifunc' option. Referenced via --- `v:lua.ow_lsp_completion.omnifunc` from on_attach's `vim.bo.omnifunc`. -_G.ow_lsp_completion = _G.ow_lsp_completion or {} - ----@param findstart 0 | 1 ----@param base string ----@return integer | vim.v.completed_item[] -function _G.ow_lsp_completion.omnifunc(findstart, base) - if not session then - session = Session.new() - end - if not buffer_has_completion_client() or session:is_awaiting() then - return findstart == 1 and -3 or {} - end - if - findstart == 1 - and session:is_ready() - and not manual_pending - and not session.trigger_char - then - local start, cursor = word_bounds() - if start == cursor then - return -3 - end - end - if not session:is_ready() then - session:dispatch(vim.lsp.protocol.CompletionTriggerKind.Invoked) - return findstart == 1 and -3 or {} - end - if findstart == 1 then - return session:start_col() - end - local items = session:finalize(base) - session = nil - manual_pending = false - return items -end - return M