Neovim completion plugin building blocks
Table of content
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:
- The functionality to get completion candidates for an incomplete term and choose one of them to complete a word, expression or sentence.
- A functionality that automatically triggers this completion mechanism, without the user having to invoke it manually.
- 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_completionend
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:
- 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
, thea
would initially trigger the completion mechanism, andsdf
wouldn’t re-trigger it. - You start the completion only if there hasn’t been any successive
event for a given time period. The
f
inasdf
would trigger the completion mechanism, andasd
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.