Files
neovim/runtime/lua/vim/lsp/inline_completion.lua
2025-08-28 11:34:01 +02:00

436 lines
12 KiB
Lua

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<integer, vim.lsp.inline_completion.Completor?>
---@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<integer, vim.lsp.inline_completion.ClientState>
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 = 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', '<Tab>', function()
--- if not vim.lsp.inline_completion.get() then
--- return '<Tab>'
--- 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 `<expr>`.
vim.schedule(function()
completor:apply()
end)
return true
end
return false
end
return M