inline_completion.lua (14859B)
1 --- @brief 2 --- This module provides the LSP "inline completion" feature, for completing multiline text (e.g., 3 --- whole methods) instead of just a word or line, which may result in "syntactically or 4 --- semantically incorrect" code. Unlike regular completion, this is typically presented as overlay 5 --- text instead of a menu of completion candidates. 6 --- 7 --- LSP spec: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.18/specification/#textDocument_inlineCompletion 8 --- 9 --- To try it out, here is a quickstart example using Copilot: [lsp-copilot]() 10 --- 11 --- 1. Install Copilot: 12 --- ```sh 13 --- npm install --global @github/copilot-language-server 14 --- ``` 15 --- 2. Define a config, (or copy `lsp/copilot.lua` from https://github.com/neovim/nvim-lspconfig): 16 --- ```lua 17 --- vim.lsp.config('copilot', { 18 --- cmd = { 'copilot-language-server', '--stdio', }, 19 --- root_markers = { '.git' }, 20 --- }) 21 --- ``` 22 --- 3. Activate the config: 23 --- ```lua 24 --- vim.lsp.enable('copilot') 25 --- ``` 26 --- 4. Sign in to Copilot, or use the `:LspCopilotSignIn` command from https://github.com/neovim/nvim-lspconfig 27 --- 5. Enable inline completion: 28 --- ```lua 29 --- vim.lsp.inline_completion.enable() 30 --- ``` 31 --- 6. Set a keymap for `vim.lsp.inline_completion.get()` and invoke the keymap. 32 33 local util = require('vim.lsp.util') 34 local log = require('vim.lsp.log') 35 local protocol = require('vim.lsp.protocol') 36 local grammar = require('vim.lsp._snippet_grammar') 37 local api = vim.api 38 39 local Capability = require('vim.lsp._capability') 40 41 local M = {} 42 43 local namespace = api.nvim_create_namespace('nvim.lsp.inline_completion') 44 45 ---@class vim.lsp.inline_completion.Item 46 ---@field _index integer The index among all items form all clients. 47 ---@field client_id integer Client ID 48 ---@field insert_text string|lsp.StringValue The text to be inserted, can be a snippet. 49 ---@field _filter_text? string 50 ---@field range? vim.Range Which range it be applied. 51 ---@field command? lsp.Command Corresponding server command. 52 53 ---@class (private) vim.lsp.inline_completion.ClientState 54 ---@field items? lsp.InlineCompletionItem[] 55 56 ---@class (private) vim.lsp.inline_completion.Completor : vim.lsp.Capability 57 ---@field active table<integer, vim.lsp.inline_completion.Completor?> 58 ---@field timer? uv.uv_timer_t Timer for debouncing automatic requests 59 ---@field current? vim.lsp.inline_completion.Item Currently selected item 60 ---@field client_state table<integer, vim.lsp.inline_completion.ClientState> 61 local Completor = { 62 name = 'inline_completion', 63 method = 'textDocument/inlineCompletion', 64 active = {}, 65 } 66 Completor.__index = Completor 67 setmetatable(Completor, Capability) 68 Capability.all[Completor.name] = Completor 69 70 ---@package 71 ---@param bufnr integer 72 ---@return vim.lsp.inline_completion.Completor 73 function Completor:new(bufnr) 74 self = Capability.new(self, bufnr) 75 self.client_state = {} 76 api.nvim_create_autocmd({ 'InsertEnter', 'CursorMovedI', 'TextChangedP' }, { 77 group = self.augroup, 78 buffer = bufnr, 79 callback = function() 80 self:automatic_request() 81 end, 82 }) 83 api.nvim_create_autocmd({ 'InsertLeave' }, { 84 group = self.augroup, 85 buffer = bufnr, 86 callback = function() 87 self:abort() 88 end, 89 }) 90 return self 91 end 92 93 ---@package 94 function Completor:destroy() 95 api.nvim_buf_clear_namespace(self.bufnr, namespace, 0, -1) 96 api.nvim_del_augroup_by_id(self.augroup) 97 self.active[self.bufnr] = nil 98 end 99 100 --- Longest common prefix 101 --- 102 ---@param a string 103 ---@param b string 104 ---@return integer index where the common prefix ends, exclusive 105 local function lcp(a, b) 106 local i, la, lb = 1, #a, #b 107 while i <= la and i <= lb and a:sub(i, i) == b:sub(i, i) do 108 i = i + 1 109 end 110 return i 111 end 112 113 --- `lsp.Handler` for `textDocument/inlineCompletion`. 114 --- 115 ---@package 116 ---@param err? lsp.ResponseError 117 ---@param result? lsp.InlineCompletionItem[]|lsp.InlineCompletionList 118 ---@param ctx lsp.HandlerContext 119 function Completor:handler(err, result, ctx) 120 if err then 121 log.error('inlinecompletion', err) 122 return 123 end 124 if not result or not vim.startswith(api.nvim_get_mode().mode, 'i') then 125 return 126 end 127 128 local items = result.items or result 129 self.client_state[ctx.client_id].items = items 130 self:select(1) 131 end 132 133 ---@package 134 function Completor:count_items() 135 local n = 0 136 for _, state in pairs(self.client_state) do 137 local items = state.items 138 if items then 139 n = n + #items 140 end 141 end 142 return n 143 end 144 145 ---@package 146 ---@param i integer 147 ---@return integer?, lsp.InlineCompletionItem? 148 function Completor:get_item(i) 149 local n = self:count_items() 150 i = i % (n + 1) 151 ---@type integer[] 152 local client_ids = vim.tbl_keys(self.client_state) 153 table.sort(client_ids) 154 for _, client_id in ipairs(client_ids) do 155 local items = self.client_state[client_id].items 156 if items then 157 if i > #items then 158 i = i - #items 159 else 160 return client_id, items[i] 161 end 162 end 163 end 164 end 165 166 --- Select the {index}-th completion item. 167 --- 168 ---@package 169 ---@param index integer 170 ---@param show_index? boolean 171 function Completor:select(index, show_index) 172 self.current = nil 173 local client_id, item = self:get_item(index) 174 if not client_id or not item then 175 self:hide() 176 return 177 end 178 179 local client = assert(vim.lsp.get_client_by_id(client_id)) 180 local range = item.range and vim.range.lsp(self.bufnr, item.range, client.offset_encoding) 181 self.current = { 182 _index = index, 183 client_id = client_id, 184 insert_text = item.insertText, 185 range = range, 186 _filter_text = item.filterText, 187 command = item.command, 188 } 189 190 local hint = show_index and (' (%d/%d)'):format(index, self:count_items()) or nil 191 self:show(hint) 192 end 193 194 --- Show or update the current completion item. 195 --- 196 ---@package 197 ---@param hint? string 198 function Completor:show(hint) 199 self:hide() 200 local current = self.current 201 if not current then 202 return 203 end 204 205 local insert_text = current.insert_text 206 local text = type(insert_text) == 'string' and insert_text 207 or tostring(grammar.parse(insert_text.value)) 208 local lines = {} ---@type [string, string][][] 209 for s in vim.gsplit(text, '\n', { plain = true }) do 210 table.insert(lines, { { s, 'ComplHint' } }) 211 end 212 if hint then 213 table.insert(lines[#lines], { hint, 'ComplHintMore' }) 214 end 215 216 local pos = current.range and current.range.start:to_extmark() 217 or vim.pos.cursor(api.nvim_win_get_cursor(vim.fn.bufwinid(self.bufnr))):to_extmark() 218 local row, col = unpack(pos) 219 220 -- To ensure that virtual text remains visible continuously (without flickering) 221 -- while the user is editing the buffer, we allow displaying expired virtual text. 222 -- Since the position of virtual text may become invalid after document changes, 223 -- out-of-range items are ignored. 224 local line_text = api.nvim_buf_get_lines(self.bufnr, row, row + 1, false)[1] 225 if not (line_text and #line_text >= col) then 226 self.current = nil 227 return 228 end 229 230 -- The first line of the text to be inserted 231 -- usually contains characters entered by the user, 232 -- which should be skipped before displaying the virtual text. 233 local virt_text = lines[1] 234 local skip = lcp(line_text:sub(col + 1), virt_text[1][1]) 235 local winid = api.nvim_get_current_win() 236 -- At least, characters before the cursor should be skipped. 237 if api.nvim_win_get_buf(winid) == self.bufnr then 238 local cursor_row, cursor_col = 239 unpack(vim.pos.cursor(api.nvim_win_get_cursor(winid)):to_extmark()) 240 if row == cursor_row then 241 skip = math.max(skip, cursor_col - col + 1) 242 end 243 end 244 virt_text[1][1] = virt_text[1][1]:sub(skip) 245 col = col + skip - 1 246 247 local virt_lines = { unpack(lines, 2) } 248 api.nvim_buf_set_extmark(self.bufnr, namespace, row, col, { 249 virt_text = virt_text, 250 virt_lines = virt_lines, 251 virt_text_pos = (current.range and not current.range:is_empty() and 'overlay') or 'inline', 252 hl_mode = 'combine', 253 }) 254 end 255 256 --- Hide the current completion item. 257 --- 258 ---@package 259 function Completor:hide() 260 api.nvim_buf_clear_namespace(self.bufnr, namespace, 0, -1) 261 end 262 263 ---@package 264 ---@param kind lsp.InlineCompletionTriggerKind 265 function Completor:request(kind) 266 for client_id in pairs(self.client_state) do 267 local client = assert(vim.lsp.get_client_by_id(client_id)) 268 ---@type lsp.InlineCompletionContext 269 local context = { triggerKind = kind } 270 if 271 kind == protocol.InlineCompletionTriggerKind.Invoked and api.nvim_get_mode().mode:match('^v') 272 then 273 context.selectedCompletionInfo = { 274 range = util.make_given_range_params(nil, nil, self.bufnr, client.offset_encoding).range, 275 text = table.concat(vim.fn.getregion(vim.fn.getpos("'<"), vim.fn.getpos("'>")), '\n'), 276 } 277 end 278 279 ---@type lsp.InlineCompletionParams 280 local params = { 281 textDocument = util.make_text_document_params(self.bufnr), 282 position = util.make_position_params(0, client.offset_encoding).position, 283 context = context, 284 } 285 client:request('textDocument/inlineCompletion', params, function(...) 286 self:handler(...) 287 end, self.bufnr) 288 end 289 end 290 291 ---@private 292 function Completor:reset_timer() 293 local timer = self.timer 294 if timer then 295 self.timer = nil 296 if not timer:is_closing() then 297 timer:stop() 298 timer:close() 299 end 300 end 301 end 302 303 --- Automatically request with debouncing, used as callbacks in autocmd events. 304 --- 305 ---@package 306 function Completor:automatic_request() 307 self:show() 308 self:reset_timer() 309 self.timer = vim.defer_fn(function() 310 self:request(protocol.InlineCompletionTriggerKind.Automatic) 311 end, 200) 312 end 313 314 --- Abort the current completion item and pending requests. 315 --- 316 ---@package 317 function Completor:abort() 318 util._cancel_requests({ 319 bufnr = self.bufnr, 320 method = 'textDocument/inlineCompletion', 321 type = 'pending', 322 }) 323 self:reset_timer() 324 self:hide() 325 self.current = nil 326 end 327 328 --- Accept the current completion item to the buffer. 329 --- 330 ---@package 331 ---@param item vim.lsp.inline_completion.Item 332 function Completor:accept(item) 333 local insert_text = item.insert_text 334 if type(insert_text) == 'string' then 335 local range = item.range 336 if range then 337 local lines = vim.split(insert_text, '\n') 338 api.nvim_buf_set_text( 339 self.bufnr, 340 range.start.row, 341 range.start.col, 342 range.end_.row, 343 range.end_.col, 344 lines 345 ) 346 local pos = item.range.start:to_cursor() 347 local win = api.nvim_get_current_win() 348 win = api.nvim_win_get_buf(win) == self.bufnr and win or vim.fn.bufwinid(self.bufnr) 349 api.nvim_win_set_cursor(win, { 350 pos[1] + #lines - 1, 351 (#lines == 1 and pos[2] or 0) + #lines[#lines], 352 }) 353 else 354 api.nvim_paste(insert_text, false, 0) 355 end 356 elseif insert_text.kind == 'snippet' then 357 vim.snippet.expand(insert_text.value) 358 end 359 360 -- Execute the command *after* inserting this completion. 361 if item.command then 362 local client = assert(vim.lsp.get_client_by_id(item.client_id)) 363 client:exec_cmd(item.command, { bufnr = self.bufnr }) 364 end 365 end 366 367 --- Query whether inline completion is enabled in the {filter}ed scope 368 ---@param filter? vim.lsp.capability.enable.Filter 369 function M.is_enabled(filter) 370 return vim.lsp._capability.is_enabled('inline_completion', filter) 371 end 372 373 --- Enables or disables inline completion for the {filter}ed scope, 374 --- inline completion will automatically be refreshed when you are in insert mode. 375 --- 376 --- To "toggle", pass the inverse of `is_enabled()`: 377 --- 378 --- ```lua 379 --- vim.lsp.inline_completion.enable(not vim.lsp.inline_completion.is_enabled()) 380 --- ``` 381 --- 382 ---@param enable? boolean true/nil to enable, false to disable 383 ---@param filter? vim.lsp.capability.enable.Filter 384 function M.enable(enable, filter) 385 vim.lsp._capability.enable('inline_completion', enable, filter) 386 end 387 388 ---@class vim.lsp.inline_completion.select.Opts 389 ---@inlinedoc 390 --- 391 --- (default: current buffer) 392 ---@field bufnr? integer 393 --- 394 --- The number of candidates to move by. 395 --- A positive integer moves forward by {count} candidates, 396 --- while a negative integer moves backward by {count} candidates. 397 --- (default: v:count1) 398 ---@field count? integer 399 --- 400 --- Whether to loop around file or not. Similar to 'wrapscan'. 401 --- (default: `true`) 402 ---@field wrap? boolean 403 404 --- Switch between available inline completion candidates. 405 --- 406 ---@param opts? vim.lsp.inline_completion.select.Opts 407 function M.select(opts) 408 vim.validate('opts', opts, 'table', true) 409 opts = opts or {} 410 local bufnr = vim._resolve_bufnr(opts.bufnr) 411 local completor = Completor.active[bufnr] 412 if not completor then 413 return 414 end 415 416 local count = opts.count or vim.v.count1 417 local wrap = opts.wrap ~= false 418 419 local current = completor.current 420 if not current then 421 return 422 end 423 424 local n = completor:count_items() 425 local index = current._index + count 426 if wrap then 427 index = (index - 1) % n + 1 428 else 429 index = math.max(1, math.min(index, n)) 430 end 431 completor:select(index, true) 432 end 433 434 ---@class vim.lsp.inline_completion.get.Opts 435 ---@inlinedoc 436 --- 437 --- Buffer handle, or 0 for current. 438 --- (default: 0) 439 ---@field bufnr? integer 440 --- 441 --- A callback triggered when a completion item is accepted. 442 --- You can use it to modify the completion item that is about to be accepted 443 --- and return it to apply the changes, 444 --- or return `nil` to prevent the changes from being applied to the buffer 445 --- so you can implement custom behavior. 446 ---@field on_accept? fun(item: vim.lsp.inline_completion.Item): vim.lsp.inline_completion.Item? 447 448 --- Accept the currently displayed completion candidate to the buffer. 449 --- 450 --- It returns false when no candidate can be accepted, 451 --- so you can use the return value to implement a fallback: 452 --- 453 --- ```lua 454 --- vim.keymap.set('i', '<Tab>', function() 455 --- if not vim.lsp.inline_completion.get() then 456 --- return '<Tab>' 457 --- end 458 --- end, { expr = true, desc = 'Accept the current inline completion' }) 459 --- ```` 460 ---@param opts? vim.lsp.inline_completion.get.Opts 461 ---@return boolean `true` if a completion was applied, else `false`. 462 function M.get(opts) 463 vim.validate('opts', opts, 'table', true) 464 opts = opts or {} 465 466 local bufnr = vim._resolve_bufnr(opts.bufnr) 467 local on_accept = opts.on_accept 468 469 local completor = Completor.active[bufnr] 470 if completor and completor.current then 471 -- Schedule apply to allow `get()` can be mapped with `<expr>`. 472 vim.schedule(function() 473 local item = completor.current 474 completor:abort() 475 if not item then 476 return 477 end 478 479 -- Note that we do not intend for `on_accept` 480 -- to take effect when there is no current item. 481 if on_accept then 482 item = on_accept(item) 483 if item then 484 completor:accept(item) 485 end 486 else 487 completor:accept(item) 488 end 489 end) 490 return true 491 end 492 493 return false 494 end 495 496 return M