fix(lsp): advance inline completion range over matching input #41434

Problem:
`Completor:accept()` applies the item against the range the server
answered with. Characters typed after the request but before accepting
lie inside neither that range nor the replacement, so they are left
behind by the insertion: typing `a` after `foob` and accepting `foobar`
within the 200 ms debounce yields `foobara`.

Solution:
Grow the item's range over the characters typed since the request, so
that `accept()` replaces them. Only while the candidate still matches,
decided by the longest common prefix `show()` already computes, so input
that contradicts it is left alone. Snippet items are unaffected, since
`accept()` ignores the range for them.
This commit is contained in:
Étienne Robert
2026-08-24 16:51:39 +02:00
committed by GitHub
parent 8c440c469b
commit d1b811b6c2
2 changed files with 49 additions and 1 deletions

View File

@@ -63,6 +63,7 @@ local namespace = api.nvim_create_namespace('nvim.lsp.inline_completion')
---@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 _overlay? boolean Whether `current` replaces buffer text, per its original range
---@field client_state table<integer, vim.lsp.inline_completion.ClientState>
local Completor = {
name = 'inline_completion',
@@ -184,6 +185,8 @@ function Completor:select(index, show_index)
_filter_text = item.filterText,
command = item.command,
}
-- Recorded here because `show()` may grow the range over typed characters.
self._overlay = range ~= nil and not range:is_empty()
local hint = show_index and (' (%d/%d)'):format(index, self:count_items()) or nil
self:show(hint)
@@ -240,6 +243,14 @@ function Completor:show(hint)
local cursor_row, cursor_col =
vim.pos.cursor(self.bufnr, api.nvim_win_get_cursor(winid)):to_extmark()
if row == cursor_row then
-- Characters typed since the request lie past the item's range, so `accept()`
-- would leave them behind. `skip` stops where the buffer stops matching.
if current.range and col + skip - 1 >= cursor_col then
local start_row, start_col, _, end_col = current.range:to_extmark()
if end_col < cursor_col then
current.range = vim.range.extmark(self.bufnr, start_row, start_col, row, cursor_col)
end
end
skip = math.max(skip, cursor_col - col + 1)
end
end
@@ -256,7 +267,7 @@ function Completor:show(hint)
api.nvim_buf_set_extmark(self.bufnr, namespace, row, col, {
virt_text = virt_text,
virt_lines = virt_lines,
virt_text_pos = (current.range and not current.range:is_empty() and 'overlay') or 'inline',
virt_text_pos = (self._overlay and 'overlay') or 'inline',
hl_mode = 'combine',
})
end

View File

@@ -84,6 +84,11 @@ describe('vim.lsp.inline_completion', function()
},
handlers = {
['textDocument/inlineCompletion'] = function(_, _, callback)
if _G.items then
callback(nil, { items = _G.items })
return
end
if _G.empty then
callback(nil, {
items = {
@@ -293,6 +298,38 @@ describe('vim.lsp.inline_completion', function()
feed('<Esc>')
screen:expect({ grid = grid_applied_candidates })
end)
it('does not leave behind text typed since the request', function()
exec_lua(function()
_G.items = {
{
insertText = 'foobar',
range = {
start = { line = 1, character = 0 },
['end'] = { line = 1, character = 1 },
},
},
}
end)
feed('ifo')
screen:expect([[
function fibonacci() |
fo{1:^obar} |
{1:~ }|*11
{3:-- INSERT --} |
]])
exec_lua(function()
vim.lsp.inline_completion.get()
end)
n.poke_eventloop()
feed('<Esc>')
screen:expect([[
function fibonacci() |
fooba^r |
{1:~ }|*11
|
]])
end)
end)
describe('select()', function()