mirror of
https://github.com/neovim/neovim.git
synced 2025-12-15 19:05:40 +00:00
feat(lsp): support textDocument/inlineCompletion
This commit is contained in:
435
runtime/lua/vim/lsp/inline_completion.lua
Normal file
435
runtime/lua/vim/lsp/inline_completion.lua
Normal file
@@ -0,0 +1,435 @@
|
||||
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 (private) vim.lsp.inline_completion.CurrentItem
|
||||
---@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.CurrentItem 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', 'CursorHoldI' }, {
|
||||
group = self.augroup,
|
||||
callback = function()
|
||||
self:automatic_request()
|
||||
end,
|
||||
})
|
||||
api.nvim_create_autocmd({ 'InsertLeave' }, {
|
||||
group = self.augroup,
|
||||
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:hide()
|
||||
self.current = nil
|
||||
end
|
||||
|
||||
--- Apply the current completion item to the buffer.
|
||||
---
|
||||
---@package
|
||||
function Completor:apply()
|
||||
local current = self.current
|
||||
self:abort()
|
||||
if not current then
|
||||
return
|
||||
end
|
||||
|
||||
local insert_text = current.insert_text
|
||||
if type(insert_text) == 'string' then
|
||||
local range = current.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 = current.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 current.command then
|
||||
local client = assert(vim.lsp.get_client_by_id(current.client_id))
|
||||
client:exec_cmd(current.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 or true
|
||||
|
||||
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
|
||||
|
||||
--- Apply the currently displayed completion candidate to the buffer.
|
||||
---
|
||||
--- It returns false when no candidate can be applied,
|
||||
--- 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,
|
||||
--- replace_keycodes = true,
|
||||
--- desc = 'Get 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 completor = Completor.active[bufnr]
|
||||
if completor and completor.current then
|
||||
-- Schedule apply to allow `get()` can be mapped with `<expr>`.
|
||||
vim.schedule(function()
|
||||
completor:apply()
|
||||
end)
|
||||
return true
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
return M
|
||||
Reference in New Issue
Block a user