Files
neovim/runtime/lua/vim/lsp/inline_completion.lua
v1nh1shungry 70460d557c fix(lsp): scope inline_completion autocmds to buffer (#35965)
Problem:
Autocmds in inline_completion Completor are not scoped to specific
buffers. When multiple buffers have inline completion enabled, events
(InsertEnter, CursorMovedI, TextChangedP, InsertLeave) in any buffer
trigger callbacks for all Completor instances, causing incorrect
behavior across buffers.

Solution:
Add `buffer = bufnr` parameter to nvim_create_autocmd() calls to make
them buffer-local. Each Completor instance now only responds to events
in its own buffer.
2025-10-01 06:56:56 -07:00

479 lines
14 KiB
Lua

--- @brief
--- This module provides the LSP "inline completion" feature, for completing multiline text (e.g.,
--- whole methods) instead of just a word or line, which may result in "syntactically or
--- semantically incorrect" code. Unlike regular completion, this is typically presented as overlay
--- text instead of a menu of completion candidates.
---
--- LSP spec: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.18/specification/#textDocument_inlineCompletion
---
--- To try it out, here is a quickstart example using Copilot: [lsp-copilot]()
---
--- 1. Install Copilot:
--- ```sh
--- npm install --global @github/copilot-language-server
--- ```
--- 2. Define a config, (or copy `lsp/copilot.lua` from https://github.com/neovim/nvim-lspconfig):
--- ```lua
--- vim.lsp.config('copilot', {
--- cmd = { 'copilot-language-server', '--stdio', },
--- root_markers = { '.git' },
--- })
--- ```
--- 3. Activate the config:
--- ```lua
--- vim.lsp.enable('copilot')
--- ```
--- 4. Sign in to Copilot, or use the `:LspCopilotSignIn` command from https://github.com/neovim/nvim-lspconfig
--- 5. Enable inline completion:
--- ```lua
--- vim.lsp.inline_completion.enable()
--- ```
--- 6. Set a keymap for `vim.lsp.inline_completion.get()` and invoke the keymap.
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 vim.lsp.inline_completion.Item
---@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.Item 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,
buffer = bufnr,
callback = function()
self:automatic_request()
end,
})
api.nvim_create_autocmd({ 'InsertLeave' }, {
group = self.augroup,
buffer = bufnr,
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:reset_timer()
self:hide()
self.current = nil
end
--- Accept the current completion item to the buffer.
---
---@package
---@param item vim.lsp.inline_completion.Item
function Completor:accept(item)
local insert_text = item.insert_text
if type(insert_text) == 'string' then
local range = item.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 = item.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 item.command then
local client = assert(vim.lsp.get_client_by_id(item.client_id))
client:exec_cmd(item.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
---
--- Accept handler, called with the accepted item.
--- If not provided, the default handler is used,
--- which applies changes to the buffer based on the completion item.
---@field on_accept? fun(item: vim.lsp.inline_completion.Item)
--- Accept the currently displayed completion candidate to the buffer.
---
--- It returns false when no candidate can be accepted,
--- 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, desc = 'Accept 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 on_accept = opts.on_accept
local completor = Completor.active[bufnr]
if completor and completor.current then
-- Schedule apply to allow `get()` can be mapped with `<expr>`.
vim.schedule(function()
local item = completor.current
completor:abort()
if not item then
return
end
if on_accept then
on_accept(item)
else
completor:accept(item)
end
end)
return true
end
return false
end
return M