neovim

Neovim text editor
git clone https://git.dasho.dev/neovim.git
Log | Files | Refs | README

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