commit c2c5a0297e0048a7d1b75e03e1ae7d4229052108
parent c6dad6e9dffec12556355a1a50cf4e74aaee7e81
Author: Riley Bruins <ribru17@hotmail.com>
Date: Sun, 9 Nov 2025 17:49:25 -0800
fix(lsp): don't overlay insertion-style inline completions (#36477)
* feat(lua): `Range:is_empty()` to check vim.range emptiness
* fix(lsp): don't overlay insertion-style inline completions
**Problem:** Some servers commonly respond with an empty inline
completion range which acts as a position where text should be inserted.
However, the inline completion module assumes that all responses with a
range are deletions + insertions that thus require an `overlay` display
style. This causes an incorrect preview, because the virtual text should
have the `inline` display style (to reflect that this is purely an
insertion).
**Solution:** Only use `overlay` for non-empty replacement ranges.
Diffstat:
5 files changed, 50 insertions(+), 1 deletion(-)
diff --git a/runtime/doc/lua.txt b/runtime/doc/lua.txt
@@ -4136,6 +4136,7 @@ Provides operations to compare, calculate, and convert ranges represented by
Fields: ~
• {start} (`vim.Pos`) Start position.
• {end_} (`vim.Pos`) End position, exclusive.
+ • {is_empty} (`fun(self: vim.Range): boolean`) See |Range:is_empty()|.
• {has} (`fun(outer: vim.Range, inner: vim.Range): boolean`) See
|Range:has()|.
• {intersect} (`fun(r1: vim.Range, r2: vim.Range): vim.Range?`) See
@@ -4167,6 +4168,12 @@ Range:intersect({r1}, {r2}) *Range:intersect()*
(`vim.Range?`) range that is present inside both `r1` and `r2`. `nil`
if such range does not exist. See |vim.Range|.
+Range:is_empty() *Range:is_empty()*
+ Checks whether the given range is empty; i.e., start >= end.
+
+ Return: ~
+ (`boolean`) `true` if the given range is empty
+
Range:lsp({buf}, {range}, {position_encoding}) *Range:lsp()*
Creates a new |vim.Range| from `lsp.Range`.
diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt
@@ -280,6 +280,7 @@ LUA
• Experimental `vim.pos` and `vim.range` for Position/Range abstraction.
• |vim.json.encode()| has an `indent` option for pretty-formatting.
• |vim.json.encode()| has an `sort_keys` option.
+• |Range:is_empty()| to check if a |vim.Range| is empty.
OPTIONS
diff --git a/runtime/lua/vim/lsp/inline_completion.lua b/runtime/lua/vim/lsp/inline_completion.lua
@@ -248,7 +248,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 'overlay' or 'inline',
+ virt_text_pos = (current.range and not current.range:is_empty() and 'overlay') or 'inline',
hl_mode = 'combine',
})
end
diff --git a/runtime/lua/vim/range.lua b/runtime/lua/vim/range.lua
@@ -99,6 +99,13 @@ function Range.__eq(r1, r2)
return r1.start == r2.start and r1.end_ == r2.end_
end
+--- Checks whether the given range is empty; i.e., start >= end.
+---
+---@return boolean `true` if the given range is empty
+function Range:is_empty()
+ return self.start >= self.end_
+end
+
--- Checks whether {outer} range contains {inner} range.
---
---@param outer vim.Range
diff --git a/test/functional/plugin/lsp/inline_completion_spec.lua b/test/functional/plugin/lsp/inline_completion_spec.lua
@@ -83,6 +83,27 @@ describe('vim.lsp.inline_completion', function()
},
handlers = {
['textDocument/inlineCompletion'] = function(_, _, callback)
+ if _G.empty then
+ callback(nil, {
+ items = {
+ {
+ insertText = 'foobar',
+ range = {
+ start = {
+ line = 0,
+ character = 19,
+ },
+ ['end'] = {
+ line = 0,
+ character = 19,
+ },
+ },
+ },
+ },
+ })
+ return
+ end
+
callback(nil, {
items = {
{
@@ -198,6 +219,19 @@ describe('vim.lsp.inline_completion', function()
screen:expect({ grid = grid_applied_candidates })
end)
+ it('correctly displays with absent/empty range', function()
+ exec_lua(function()
+ _G.empty = true
+ end)
+ feed('I')
+ screen:expect([[
+ function fibonacci({1:foobar}) |
+ ^ |
+ {1:~ }|*11
+ {3:-- INSERT --} |
+ ]])
+ end)
+
it('accepts on_accept callback', function()
feed('i')
screen:expect({ grid = grid_with_candidates })