fix(lsp): completion overwrites text before the word boundary #41463

Problem:
One item with an edit range before the word boundary changes the start
column for all items. For tsserver `str.`, completing `str.char` can
become `strcharAt` and break filtering.

Solution:
Prepend the missing text and filter on it.
This commit is contained in:
glepnir
2026-08-24 18:14:49 +08:00
committed by GitHub
parent 8875411613
commit 2a382ffb61
2 changed files with 60 additions and 40 deletions

View File

@@ -453,6 +453,20 @@ local function commit_chars_str(chars)
return table.concat(result)
end
--- @param item lsp.CompletionItem
--- @param lnum integer
--- @return integer?
local function edit_start_char(item, lnum)
local edit = item.textEdit
if not edit then
return nil
elseif edit.range and edit.range.start.line == lnum then
return edit.range.start.character
elseif edit.insert and edit.insert.start.line == lnum then
return edit.insert.start.character
end
end
--- Turns the result of a `textDocument/completion` request into vim-compatible
--- |complete-items|.
---
@@ -463,6 +477,7 @@ end
--- @param line string? current line content
--- @param lnum integer? 0-indexed line number
--- @param encoding string? encoding
--- @param default_start_byte integer? 0-indexed start byte for items without an edit range
--- @return table[]
--- @see complete-items
function M._lsp_to_complete_items(
@@ -472,25 +487,27 @@ function M._lsp_to_complete_items(
server_start_boundary,
line,
lnum,
encoding
encoding,
default_start_byte
)
local items = get_items(result)
if vim.tbl_isempty(items) then
return {}
end
---@type fun(item: lsp.CompletionItem):boolean
---@type fun(item: lsp.CompletionItem, item_prefix: string):boolean
local matches
if not prefix:find('%w') then
matches = function(_)
matches = function(_, _)
return true
end
else
---@param item lsp.CompletionItem
matches = function(item)
---@param item_prefix string text the item replaces, up to the cursor
matches = function(item, item_prefix)
local filter_text = nonempty(item.filterText)
if filter_text then
return match_item_by_value(filter_text, prefix)
return match_item_by_value(filter_text, item_prefix)
end
if item.textEdit and not item.textEdit.newText then
@@ -498,7 +515,7 @@ function M._lsp_to_complete_items(
return true
end
return match_item_by_value(item.label, prefix)
return match_item_by_value(item.label, item_prefix)
end
end
@@ -523,27 +540,20 @@ function M._lsp_to_complete_items(
local all_commit_str = all_commit_chars and commit_chars_str(all_commit_chars) or nil
for _, item in ipairs(items) do
local match, score = matches(item)
if match then
local word = get_completion_word(item, prefix, match_item_by_value)
if server_start_boundary and line and lnum and encoding and item.textEdit then
--- @type integer?
local item_start_char
if item.textEdit.range and item.textEdit.range.start.line == lnum then
item_start_char = item.textEdit.range.start.character
elseif item.textEdit.insert and item.textEdit.insert.start.line == lnum then
item_start_char = item.textEdit.insert.start.character
end
if item_start_char then
local item_start_byte = vim.str_byteindex(line, encoding, item_start_char, false)
if item_start_byte > server_start_boundary then
local missing_prefix = line:sub(server_start_boundary + 1, item_start_byte)
word = missing_prefix .. word
end
end
local pad, item_prefix = '', prefix
if server_start_boundary and line and lnum and encoding then
local start_char = edit_start_char(item, lnum)
local start_byte = start_char and vim.str_byteindex(line, encoding, start_char, false)
or default_start_byte
if start_byte and start_byte > server_start_boundary then
pad = line:sub(server_start_boundary + 1, start_byte)
item_prefix = prefix:sub(#pad + 1)
end
end
local match, score = matches(item, item_prefix)
if match then
local word = pad .. get_completion_word(item, item_prefix, match_item_by_value)
local hl_group = ''
if
@@ -633,18 +643,9 @@ end
local function adjust_start_col(lnum, line, items, encoding)
local min_start_char = nil
for _, item in pairs(items) do
if item.textEdit then
local start_char = nil
if item.textEdit.range and item.textEdit.range.start.line == lnum then
start_char = item.textEdit.range.start.character
elseif item.textEdit.insert and item.textEdit.insert.start.line == lnum then
start_char = item.textEdit.insert.start.character
end
if start_char then
if not min_start_char or start_char < min_start_char then
min_start_char = start_char
end
end
local start_char = edit_start_char(item, lnum)
if start_char and (not min_start_char or start_char < min_start_char) then
min_start_char = start_char
end
end
if min_start_char then
@@ -697,8 +698,17 @@ function M._convert_results(
server_start_boundary = client_start_boundary
end
local prefix = line:sub((server_start_boundary or client_start_boundary) + 1, cursor_col)
local matches =
M._lsp_to_complete_items(result, prefix, client_id, server_start_boundary, line, lnum, encoding)
local default_start_byte = curstartbyte and client_start_boundary or nil
local matches = M._lsp_to_complete_items(
result,
prefix,
client_id,
server_start_boundary,
line,
lnum,
encoding,
default_start_byte
)
return matches, server_start_boundary
end

View File

@@ -1430,6 +1430,10 @@ describe('vim.lsp.completion: integration', function()
range = { start = { line = 0, character = 0 }, ['end'] = { line = 0, character = 7 } },
},
},
{
label = 'foobar',
insertTextFormat = 1,
},
},
}
exec_lua(function()
@@ -1441,6 +1445,12 @@ describe('vim.lsp.completion: integration', function()
feed('<C-Y>')
eq('<div class="foo"></div>', n.api.nvim_get_current_line())
eq({ 1, 17 }, n.api.nvim_win_get_cursor(0))
feed('<Esc>ccdiv.foo<C-x><C-O>')
wait_for_pum()
feed('<C-N><C-Y>')
eq('div.foobar', n.api.nvim_get_current_line())
assert_cleanup_after_detach(client_id)
end)