commit 36db6ff2c128864840e2820491a2172d6b1b7e62
parent 47ce93ad6d71625df7298b69b4c6ed74977889ab
Author: Harsh Kapse <harshkapse1234@gmail.com>
Date: Fri, 30 Jan 2026 20:04:42 +0530
fix(lsp): use LSP textEdit range for completion start boundary (#37491)
Previously, adjust_start_col returned nil when completion items had
different start position from lsp textEdit range
This caused the completion to fall back to \k*$ which ignores the
non-keyword characters
Changes:
- adjust_start_col: now returns the minimum start postion among all
items instead of nil
- _lsp_to_complete_items - normalizes the items by adding the gap between
current and minimum start
Fixes: https://github.com/neovim/neovim/issues/37441
Diffstat:
2 files changed, 75 insertions(+), 9 deletions(-)
diff --git a/runtime/lua/vim/lsp/completion.lua b/runtime/lua/vim/lsp/completion.lua
@@ -282,9 +282,21 @@ end
--- @param result vim.lsp.CompletionResult Result of `textDocument/completion`
--- @param prefix string prefix to filter the completion items
--- @param client_id integer? Client ID
+--- @param server_start_boundary integer? server start boundary
+--- @param line string? current line content
+--- @param lnum integer? 0-indexed line number
+--- @param encoding string? encoding
--- @return table[]
--- @see complete-items
-function M._lsp_to_complete_items(result, prefix, client_id)
+function M._lsp_to_complete_items(
+ result,
+ prefix,
+ client_id,
+ server_start_boundary,
+ line,
+ lnum,
+ encoding
+)
local items = get_items(result)
if vim.tbl_isempty(items) then
return {}
@@ -320,6 +332,25 @@ function M._lsp_to_complete_items(result, prefix, client_id)
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
+ end
+
local hl_group = ''
if
item.deprecated
@@ -393,16 +424,16 @@ 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
- if min_start_char and min_start_char ~= item.textEdit.range.start.character then
- return nil
- end
- min_start_char = item.textEdit.range.start.character
+ start_char = item.textEdit.range.start.character
elseif item.textEdit.insert and item.textEdit.insert.start.line == lnum then
- if min_start_char and min_start_char ~= item.textEdit.insert.start.character then
- return nil
+ 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
- min_start_char = item.textEdit.insert.start.character
end
end
end
@@ -456,7 +487,9 @@ 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)
+ local matches =
+ M._lsp_to_complete_items(result, prefix, client_id, server_start_boundary, line, lnum, encoding)
+
return matches, server_start_boundary
end
diff --git a/test/functional/plugin/lsp/completion_spec.lua b/test/functional/plugin/lsp/completion_spec.lua
@@ -1394,6 +1394,39 @@ describe('vim.lsp.completion: integration', function()
end)
)
end)
+
+ it('prepends prefix for items with different start positions', function()
+ local completion_list = {
+ isIncomplete = false,
+ items = {
+ {
+ label = 'div.foo',
+ insertTextFormat = 2,
+ textEdit = {
+ newText = '<div class="foo">$0</div>',
+ range = { start = { line = 0, character = 0 }, ['end'] = { line = 0, character = 7 } },
+ },
+ },
+ },
+ }
+ exec_lua(function()
+ vim.o.completeopt = 'menu,menuone,noinsert'
+ end)
+ create_server('dummy', completion_list)
+ feed('Adiv.foo<C-x><C-O>')
+ retry(nil, nil, function()
+ eq(
+ 1,
+ exec_lua(function()
+ return vim.fn.pumvisible()
+ end)
+ )
+ end)
+ feed('<C-Y>')
+ eq('<div class="foo"></div>', n.api.nvim_get_current_line())
+ eq({ 1, 17 }, n.api.nvim_win_get_cursor(0))
+ end)
+
it('sorts items when fuzzy is enabled and prefix not empty #33610', function()
local completion_list = {
isIncomplete = false,