To support custom workflows, we need Vim plugins. But learning Vimscript is hard. One reason is because the Vim runtime has several unique concepts that we need to learn to interact with, e.g. buffers, terminals, and autocmds. To familiarize ourselves with these concepts, we will create a useful example module that illustrates some of these concepts: tracking active terminal buffers in Neovim.

Neovim terminal buffers

Neovim, a fork of Vim, introduced terminal buffers. Terminal buffers embed terminal emulators that we can interact with like shells and navigate around like regular buffers. Vim 8 also introduces terminal buffers, but we focus on API provided by Neovim for this post.

Using terminal buffers

To create a new terminal buffer in Neovim, we use the :term command. Because they are buffers, they will appear in the buffer list. For example, suppose that we start editing a text file, test.md, and create a new terminal buffer with the :term command. The buffer list will contain both buffers.

:ls
  1 #h   "test.md"                      line 1
  2 %a-  "term://.//18140:/usr/bin/zsh" line 1

Because terminal buffers are in the buffer list, buffer navigation will cycle through both regular buffers and terminal buffers. In other words, they are reachable using the navigation commands :bprev and :bnext.

For some users, this navigation behavior may be undesirable.

Custom workflows

Consider a workflow that includes two vertically split windows where the left window should only contain source code and the right window should only contain terminal buffers. Ideally, the buffer navigation on the left window should be restricted to source buffers and the buffer navigation on the right window should ber restricted to terminal buffers.

We can support custom workflows such as this by extending Vim with a plugin. The plugin may can expose buffer navigation commands for separately maintained source buffers and terminal buffers.

Creating Vim plugins

Despite being a scripting language, designing plugins with Vimscript suffers from the same problems as most engineering projects. One issue is that there are design choices to make. Consider a few possible design choices.

  • Should the lists be local to each window?
  • Which commands should we use to navigate the list?

Instead of designing the entire plugin upfront, we can limit the scope of our initial effort by specifying modules that are independent of the design choices above.

A module is an isolated unit of code and data with an interface that is intended to be used by clients. Modules may optionally maintain internal state and behavior.

For example, we can easily specify two reusable modules that are easier to tackle individually than to design the entire plugin upfront.

  1. A module that manages the list of active terminal buffers.
  2. A module that manages the list of active source buffers.

For this post, we will create the first module, which manages the list of active terminal buffers. Creating a module consists of three steps: defining the requirements, specifying the behavior, and implementing the behavior.

Define the requirements

As good engineers, we begin with the requirements of the module. For requirements, the terminal list module

  1. should keep track of the active terminal buffers,
  2. and should list the active terminals.

The first requirement is an implementation detail. The second requirement defines an interface for clients.

Specify the module

To satisfy the requirements, we specify the client interface concretely. We will expose a single global function, TermList, that will return the list of active terminal buffer numbers.

function! TermList()
  " The implementation.
endfunction

The interface above is intended to be a stub for our implementation later.

Implement the module

Create a new project directory, vim-term-list. We create a single file, term-list.vim, which will contain the source code for our terminal list module. The directory structure should appear as follows.

vim-term-list
`-- term-list.vim

For implementation details, our module will likely need the following state and behavior:

  • A data structure that tracks the active terminals.
  • A function that implements the client interface to list the terminals.
  • Update the data structure when terminals are opened and closed.

Fortunately, Vimscript has features that support all of these tasks. We will introduce them as we complete each task.

We may now begin writing the plugin code in term-list.vim.

The list data structure

We need a data structure to track the active terminal buffers. Vimscript has lists and dictionaries builtin. We choose the list data structure to keep track of the active terminal buffers because we only need the following behaviors:

  • Adding a new terminal buffer to the list when a terminal is opened.
  • Removing a new terminal buffer to the list when a terminal is closed.
  • Listing the terminal buffers when asked by a client.

We initialize the list as empty on startup.

" Initialize the list on startup.
if !exists('s:term_list')
  let s:term_list = []
end

This snippet introduces the script-local variable, s:term_list.

In Vimscript, scopes are predefined. We assign a scope to a variable by annotating it with a prefix. The s: prefix denotes that the variable is scoped the script, i.e. it is only accessible from within the script. A variable with a scope is known as an internal variable. See :help internal-variables.

The conditional guarantees that we only initialize the variable once.

Implementing the client interface

As a client of this module, we want to retrieve list the active terminals. So we implement global function, TermList, that returns the contents of the current list of active terminal buffers.

" List the terminal buffer numbers.
function! TermList()
  return copy(s:term_list)
endfunction

Note that we return a copy of the list as a defensive programming practice.

Listening for terminal events

This terminal list should be automatically updated when terminals events occur. Specifically, we want the following event-based behavior.

  • When a terminal is opened, we want to add its buffer number to the list.
  • When a terminal is closed, we want to add its buffer number to the list.

In Vim, this is the purpose of automatic commands (autocmds). An autocmd is code that executes when specific events occur occur. Vimscript has specialized syntax to define autocmds.

For some engineers, we call this an event bus. So the code that executes would be an event listener.

For our module, we are interested in the following events:

  • TermOpen. Occurs when a terminal is starts.
  • TermClose. Occurs when a terminal is ends.

We will have code that executes for each of these events, so we will wrap the logic in script-local functions: s:TermAdd and s:TermRemove. We will refer to these functions as event listeners.

Functions also have predefined scopes similar to internal-variables. However, there are only two scopes available to functions: global, g:, and script-local, s:. Script-local functions cannot be accessed from outside the script.

We stub the event listeners below and implement them later.

function! s:TermAdd()
  " Add a terminal to the list.
endfunction

function! s:TermRemove()
  " Remove a terminal from the list.
endfunction

Given the events and their corresponding event listeners, we can define autocmds.

" Listen for terminal open and close events.
augroup term_list_listeners
  autocmd!

  autocmd TermOpen * call <SID>TermAdd()
  autocmd TermClose * call <SID>TermRemove()
augroup END

This snippet registers the TermAdd and TermRemove event listeners to execute when the associated events occur. We wrap the autocmds in an augroup with the autocmd! command to guarantee that these functions are only registered once.

When we declare script-local functions with the s: prefix, we must call them with the <SID> key. The key is expanded to prefix for the mangled script-local function at runtime.

We are now ready to the implement the event listeners. They should update the terminal list accordingly.

" Add a terminal to the list.
function! s:TermAdd()
  let term_bufname = expand('<afile>')
  let term_bufnr = bufnr(term_bufname)
  call add(s:term_list, term_bufnr)
endfunction

" Remove a terminal from the list.
function! s:TermRemove()
  let term_bufname = expand('<afile>')
  let term_bufnr = bufnr(term_bufname)
  let term_index = index(s:term_list, term_bufnr)
  call remove(s:term_list, term_index)
endfunction

The first two lines of each function deserve an explanation. The special key, <afile>, represents the name of the file that is the target of the autocmd, i.e. the terminal buffer name. To retrieve the actual value of the special key, we use the expand function. Finally, to resolve the buffer number given the buffer name, so we use the bufnr function.

At this point, our module is ready and adheres to the specification.

Testing

To test our requirements, we can load our module into Neovim and interact with it.

  1. Open a new Neovim instance.
  2. Source the module, :source term-list.vim.
  3. Open a few terminals and maybe close a few.
  4. Inspect the list, :echo TermList().

The active terminal list should reflect changes when terminals are opened and closed.

Conclusion

We have shown how to create a practical module in Vimscript to support a custom workflow. In the module, we used a few concepts that are unique to the Vim runtime including buffers, terminals, and autocmds. When cast as familiar concepts such as scoping and event buses, these concepts were not as foreign.

Although we did not the create an entire plugin, the module was surprisingly complex. If we designed the entire plugin, there may have been more concepts to introduce, e.g. mappings and commands. To show how we can manage the complexity of plugin design, we focused on modular design. Vimscript supports modular design with its scoping rules that we have illustrated with variables and functions.

Learning new languages can always be daunting, but there are several common concepts across all languages that we can leverage to limit the initial complexity.