local util = require('vim.lsp.util') local log = require('vim.lsp.log') local protocol = require('vim.lsp.protocol') local ms = require('vim.lsp.protocol').Methods local grammar = require('vim.lsp._snippet_grammar') local api = vim.api local Capability = require('vim.lsp._capability') local M = {} local namespace = api.nvim_create_namespace('nvim.lsp.inline_completion') ---@class (private) vim.lsp.inline_completion.CurrentItem ---@field index integer The index among all items form all clients. ---@field client_id integer Client ID ---@field insert_text string|lsp.StringValue The text to be inserted, can be a snippet. ---@field filter_text? string ---@field range? vim.Range Which range it be applied. ---@field command? lsp.Command Corresponding server command. ---@class (private) vim.lsp.inline_completion.ClientState ---@field items? lsp.InlineCompletionItem[] ---@class (private) vim.lsp.inline_completion.Completor : vim.lsp.Capability ---@field active table ---@field timer? uv.uv_timer_t Timer for debouncing automatic requests ---@field current? vim.lsp.inline_completion.CurrentItem Currently selected item ---@field client_state table local Completor = { name = 'inline_completion', method = ms.textDocument_inlineCompletion, active = {}, } Completor.__index = Completor setmetatable(Completor, Capability) Capability.all[Completor.name] = Completor ---@package ---@param bufnr integer ---@return vim.lsp.inline_completion.Completor function Completor:new(bufnr) self = Capability.new(self, bufnr) self.client_state = {} api.nvim_create_autocmd({ 'InsertEnter', 'CursorMovedI', 'TextChangedP' }, { group = self.augroup, callback = function() self:automatic_request() end, }) api.nvim_create_autocmd({ 'InsertLeave' }, { group = self.augroup, callback = function() self:abort() end, }) return self end ---@package function Completor:destroy() api.nvim_buf_clear_namespace(self.bufnr, namespace, 0, -1) api.nvim_del_augroup_by_id(self.augroup) self.active[self.bufnr] = nil end --- Longest common prefix --- ---@param a string ---@param b string ---@return integer index where the common prefix ends, exclusive local function lcp(a, b) local i, la, lb = 1, #a, #b while i <= la and i <= lb and a:sub(i, i) == b:sub(i, i) do i = i + 1 end return i end --- `lsp.Handler` for `textDocument/inlineCompletion`. --- ---@package ---@param err? lsp.ResponseError ---@param result? lsp.InlineCompletionItem[]|lsp.InlineCompletionList ---@param ctx lsp.HandlerContext function Completor:handler(err, result, ctx) if err then log.error('inlinecompletion', err) return end if not result then return end local items = result.items or result self.client_state[ctx.client_id].items = items self:select(1) end ---@package function Completor:count_items() local n = 0 for _, state in pairs(self.client_state) do local items = state.items if items then n = n + #items end end return n end ---@package ---@param i integer ---@return integer?, lsp.InlineCompletionItem? function Completor:get_item(i) local n = self:count_items() i = i % (n + 1) ---@type integer[] local client_ids = vim.tbl_keys(self.client_state) table.sort(client_ids) for _, client_id in ipairs(client_ids) do local items = self.client_state[client_id].items if items then if i > #items then i = i - #items else return client_id, items[i] end end end end --- Select the {index}-th completion item. --- ---@package ---@param index integer ---@param show_index? boolean function Completor:select(index, show_index) self.current = nil local client_id, item = self:get_item(index) if not client_id or not item then self:hide() return end local client = assert(vim.lsp.get_client_by_id(client_id)) local range = item.range and vim.range.lsp(self.bufnr, item.range, client.offset_encoding) self.current = { index = index, client_id = client_id, insert_text = item.insertText, range = range, filter_text = item.filterText, command = item.command, } local hint = show_index and (' (%d/%d)'):format(index, self:count_items()) or nil self:show(hint) end --- Show or update the current completion item. --- ---@package ---@param hint? string function Completor:show(hint) self:hide() local current = self.current if not current then return end local insert_text = current.insert_text local text = type(insert_text) == 'string' and insert_text or tostring(grammar.parse(insert_text.value)) local lines = {} ---@type [string, string][][] for s in vim.gsplit(text, '\n', { plain = true }) do table.insert(lines, { { s, 'ComplHint' } }) end if hint then table.insert(lines[#lines], { hint, 'ComplHintMore' }) end -- The first line of the text to be inserted -- usually contains characters entered by the user, -- which should be skipped before displaying the virtual text. local pos = current.range and current.range.start:to_extmark() or vim.pos.cursor(api.nvim_win_get_cursor(vim.fn.bufwinid(self.bufnr))):to_extmark() local row, col = unpack(pos) local virt_text = lines[1] local skip = lcp(api.nvim_buf_get_lines(self.bufnr, row, row + 1, true)[1]:sub(col + 1), virt_text[1][1]) local winid = api.nvim_get_current_win() -- At least, characters before the cursor should be skipped. if api.nvim_win_get_buf(winid) == self.bufnr then local cursor_row, cursor_col = unpack(vim.pos.cursor(api.nvim_win_get_cursor(winid)):to_extmark()) if row == cursor_row then skip = math.max(skip, cursor_col - col + 1) end end virt_text[1][1] = virt_text[1][1]:sub(skip) col = col + skip - 1 local virt_lines = { unpack(lines, 2) } api.nvim_buf_set_extmark(self.bufnr, namespace, row, col, { virt_text = virt_text, virt_lines = virt_lines, virt_text_pos = current.range and 'overlay' or 'inline', hl_mode = 'combine', }) end --- Hide the current completion item. --- ---@package function Completor:hide() api.nvim_buf_clear_namespace(self.bufnr, namespace, 0, -1) end ---@package ---@param kind lsp.InlineCompletionTriggerKind function Completor:request(kind) for client_id in pairs(self.client_state) do local client = assert(vim.lsp.get_client_by_id(client_id)) ---@type lsp.InlineCompletionContext local context = { triggerKind = kind } if kind == protocol.InlineCompletionTriggerKind.Invoked and api.nvim_get_mode().mode:match('^v') then context.selectedCompletionInfo = { range = util.make_given_range_params(nil, nil, self.bufnr, client.offset_encoding).range, text = table.concat(vim.fn.getregion(vim.fn.getpos("'<"), vim.fn.getpos("'>")), '\n'), } end ---@type lsp.InlineCompletionParams local params = { textDocument = util.make_text_document_params(self.bufnr), position = util.make_position_params(0, client.offset_encoding).position, context = context, } client:request(ms.textDocument_inlineCompletion, params, function(...) self:handler(...) end) end end ---@private function Completor:reset_timer() local timer = self.timer if timer then self.timer = nil if not timer:is_closing() then timer:stop() timer:close() end end end --- Automatically request with debouncing, used as callbacks in autocmd events. --- ---@package function Completor:automatic_request() self:show() self:reset_timer() self.timer = vim.defer_fn(function() self:request(protocol.InlineCompletionTriggerKind.Automatic) end, 200) end --- Abort the current completion item and pending requests. --- ---@package function Completor:abort() util._cancel_requests({ bufnr = self.bufnr, method = ms.textDocument_inlineCompletion, type = 'pending', }) self:hide() self.current = nil end --- Apply the current completion item to the buffer. --- ---@package function Completor:apply() local current = self.current self:abort() if not current then return end local insert_text = current.insert_text if type(insert_text) == 'string' then local range = current.range if range then local lines = vim.split(insert_text, '\n') api.nvim_buf_set_text( self.bufnr, range.start.row, range.start.col, range.end_.row, range.end_.col, lines ) local pos = current.range.start:to_cursor() api.nvim_win_set_cursor(vim.fn.bufwinid(self.bufnr), { pos[1] + #lines - 1, (#lines == 1 and pos[2] or 0) + #lines[#lines], }) else api.nvim_paste(insert_text, false, 0) end elseif insert_text.kind == 'snippet' then vim.snippet.expand(insert_text.value) end -- Execute the command *after* inserting this completion. if current.command then local client = assert(vim.lsp.get_client_by_id(current.client_id)) client:exec_cmd(current.command, { bufnr = self.bufnr }) end end --- Query whether inline completion is enabled in the {filter}ed scope ---@param filter? vim.lsp.capability.enable.Filter function M.is_enabled(filter) return vim.lsp._capability.is_enabled('inline_completion', filter) end --- Enables or disables inline completion for the {filter}ed scope, --- inline completion will automatically be refreshed when you are in insert mode. --- --- To "toggle", pass the inverse of `is_enabled()`: --- --- ```lua --- vim.lsp.inline_completion.enable(not vim.lsp.inline_completion.is_enabled()) --- ``` --- ---@param enable? boolean true/nil to enable, false to disable ---@param filter? vim.lsp.capability.enable.Filter function M.enable(enable, filter) vim.lsp._capability.enable('inline_completion', enable, filter) end ---@class vim.lsp.inline_completion.select.Opts ---@inlinedoc --- --- (default: current buffer) ---@field bufnr? integer --- --- The number of candidates to move by. --- A positive integer moves forward by {count} candidates, --- while a negative integer moves backward by {count} candidates. --- (default: v:count1) ---@field count? integer --- --- Whether to loop around file or not. Similar to 'wrapscan'. --- (default: `true`) ---@field wrap? boolean --- Switch between available inline completion candidates. --- ---@param opts? vim.lsp.inline_completion.select.Opts function M.select(opts) vim.validate('opts', opts, 'table', true) opts = opts or {} local bufnr = vim._resolve_bufnr(opts.bufnr) local completor = Completor.active[bufnr] if not completor then return end local count = opts.count or vim.v.count1 local wrap = opts.wrap ~= false local current = completor.current if not current then return end local n = completor:count_items() local index = current.index + count if wrap then index = (index - 1) % n + 1 else index = math.max(1, math.min(index, n)) end completor:select(index, true) end ---@class vim.lsp.inline_completion.get.Opts ---@inlinedoc --- --- Buffer handle, or 0 for current. --- (default: 0) ---@field bufnr? integer --- Apply the currently displayed completion candidate to the buffer. --- --- It returns false when no candidate can be applied, --- so you can use the return value to implement a fallback: --- --- ```lua --- vim.keymap.set('i', '', function() --- if not vim.lsp.inline_completion.get() then --- return '' --- end --- end, { --- expr = true, --- replace_keycodes = true, --- desc = 'Get the current inline completion', --- }) --- ```` ---@param opts? vim.lsp.inline_completion.get.Opts ---@return boolean `true` if a completion was applied, else `false`. function M.get(opts) vim.validate('opts', opts, 'table', true) opts = opts or {} local bufnr = vim._resolve_bufnr(opts.bufnr) local completor = Completor.active[bufnr] if completor and completor.current then -- Schedule apply to allow `get()` can be mapped with ``. vim.schedule(function() completor:apply() end) return true end return false end return M