Friday, May 14, 2021

Neovim completion plugin building blocks

In this post I’ll show you the basic building blocks of a completion plugin for Neovim.

Some definitions

The term completion is unfortunately a bit overloaded, and people use it to refer to one or several distinct features:

  1. The functionality to get completion candidates for an incomplete term and choose one of them to complete a word, expression or sentence.
  2. A functionality that automatically triggers this completion mechanism, without the user having to invoke it manually.
  3. The ability to get “smart” completion candidates: They’re not just based on some pre-defined dictionary or on keywords in the buffer, but rather are based on the current context and the semantics of a programming language.

Neovim has a lot of built-in functionality that covers 1) and partially also 3). I recommend reading :help ins-completion to get an overview of all the options. Neovim also provides mechanism to create the functionality 2) and to extend the functionality of 3).

Completion plugins often provide 2) and extend both 1) and 3) in various ways.

complete()

The most basic building block is the complete() function. It is a vimL function which takes the startcol and matches arguments. Plugin developers can use it to show a popup which offers completion candidates to users.

This function can be used if the plugin has a custom database of completion candidates, or if it wants to offer completion candidates from multiple sources. Suppose you want to have a combination of filename completion (:help compl-filename) and keyword completion (:help compl-current). A plugin could scan the current buffer for keywords and scan the filesystem for completion candidates, and then use the complete() function to show the completion popup which offers the completion candidates as to the user.

InsertCharPre event

Neovim has an event system (:help autocmd) that allows to automatically execute commands on certain events. One of these events is the InsertCharPre event which is fired whenever a character is typed in insert mode, before the character is inserted.

Plugins use this event to provide the functionality that automatically triggers the completion mechanism.

An example of how this can look like in lua:

local function trigger_completion()
  -- vim.fn.complete() call, or some other logic to trigger the completion mechanism
end

function M._InsertCharPre()
  -- The characters on which the completion functionality should be triggered automatically.
  local triggers = {'.',}

  -- The char that is about to get inserted can be accessed via `nvim_get_vvar` or `vim.v.char`
  -- See `:help InsertCharPre` and `:help vim.v`
  local char = api.nvim_get_vvar('char')  
  if vim.tbl_contains(triggers, char) then
    trigger_completion()
  end
end


--- This function must be called for each buffer and registers the autocmd
function M.attach()
  vim.cmd("autocmd InsertCharPre <buffer> lua require('plugin_name')._InsertCharPre()")
end

Plugins often combine this with timers to provide some kind of debounce functionality. The term debounce is common in the JavaScript world and is used to refer to throttling or ignoring events. If you type asdf pretty quickly, the completion mechanism would be triggered four times without debounce. There are two ways to debounce the events:

  1. You trigger the completion on the first event, and ignore all subsequent events until some timeout has been reached, or until the first completion activity has completed. If you typed asdf, the a would initially trigger the completion mechanism, and sdf wouldn’t re-trigger it.
  2. You start the completion only if there hasn’t been any successive event for a given time period. The f in asdf would trigger the completion mechanism, and asd would be ignored, because the successive characters got inserted before the internal timeout was reached.

Both of these functionalities are usually implemented using an internal timer. Neovim bundles luv which contains a timer mechanism. See :help vim.loop

CompleteDone event

Another functionality that completion plugins often provide is snippet expansion. For snippet expansion to work, each completion candidate needs to have some snippet definition associated with it. Once a completion entry is selected, the CompleteDone event fires.

A plugin can listen to this event and access the selected completion item and expand the snippet. Many plugins delegate the actual snippet expansion logic to another plugin. The completion item can be accessed in v:completed_item (vim.v.completed_item in lua).

Completion items can either be a string, or a dictionary with the properties explained in :help complete-items. What’s most interesting for plugins is that the dictionary can have a user_data property, which can hold arbitrary custom data. This is what usually holds the snippet definition in some form.

There are a couple of challenges to take care of: If a user selects an item from the completion candidates, the text of the selected candidate will already be inserted. Before the snippet can be expanded it is necessary to remove the already inserted text again. Plugins can remove the text using vim.api.nvim_buf_set_text(...) but it can be a bit challenging finding the correct region to be cleared, depending on if the plugin also provides some fuzzy matching capabilities.

Users of the language server protocol client and servers have another use case for the CompleteDone event: Completion candidates retrieved via the language server protocol can contain a additionalTextEdits property. These are commonly used to add import statements if a completion candidate is selected. The CompleteDone event can access these additionalTextEdits from the user_data of the vim.v.completion_item and apply them using the vim.lsp.util.apply_text_edits function.

Wrap up

This isn’t meant to be an exhaustive completion system documentation, the included :help already covers everything you need to know. Instead I hope this gave a high level overview and helps you understand a bit better how completion plugins work under the hood.