Writing a Neovim Plugin

About a year ago, I released meteor, a CLI tool written in Go for helping to write conventional commit messages, with some extra features such as JIRA ticket numbers and "co-author" support.
Over time, it's become a bit of a pain point for me that I need to leave neovim in order to write my commit messages, so I've finally bit the bullet and decided to write a neovim plugin to wrap meteor, meteor.nvim.
I've had a little bit of experience with writing plugins, having released hydrate.nvim to remind me to drink water while I work, but I thought I'd document the process this time.
Let's get started!
Template
There's some initial setup I don't really want to do myself, so we'll be using this template to get us started.
Health
Let's start with the easy stuff.
It's a good practice for our plugin to have a health check. This is a way for neovim to check that anything we need to run our plugin successfully is installed and correctly configured.
Neovim provides us with some functions to help us do this, under the
vim.health
module:
1vim.health.start("meteor.nvim report")2vim.health.ok("meteor is installed")
We know we're going to at least need meteor
installed, so we need a way of
checking that it is installed and executable. We can use the built-in
vim.fn.executable
function for this:
1vim.fn.executable("meteor")
And putting it all together into a function that checks the health:
1local M = {}2
3M.check = function()4 vim.health.start("meteor.nvim report")5
6 if vim.fn.executable("meteor") == 0 then7 vim.health.issues("meteor is not installed or not in PATH")8 return9 end10
11 vim.health.ok("meteor is installed")12end13
14return M
Bonus
As a little bonus, let's also print out the currently installed meteor
version.
Neovim provides a really nice function vim.system()
that handles both
synchronous and asynchronous operations, so let's use that to get the version:
1local results = vim.system({ "meteor", "--version "}, { text = true }):wait()
We need to check if the command was successful, and to do that we can check the return code:
1if results.code ~= 0 then2 vim.health.issues("unable to get meteor version", results.stderr)3end
This leaves us with the version number on stdout
, which we can then print to
the user:
1vim.health.info("meteor version: " .. results.stdout)
The logic
Now we've got all of the prerequisites checked, we can work work on displaying
meteor
in Neovim.
Buffer
As meteor
is a CLI tool, we can display it in a terminal inside any type of
buffer we like. I like to have these tools in floating windows, so that's what
we'll do here.
I typically follow TDD when I work, so let's start by defining our main function:
1local M = {}2
3M.show = function()4end5
6return M
And start writing a test! First let's get the test file set up:
1local meteor = require("meteor")2
3describe("meteor", function()4
5end)
We know we'll want to display meteor
on the screen, so we're going to need to
create a new buffer. Let's start by checking how many buffers we currently have,
and then checking how many we have after we call our function:
1describe("show", function()2 it("creates a new buffer", function()3 -- Checking the original number of buffers4 local buf_count = #vim.api.nvim_list_bufs()5
6 -- calling our function7 meteor.show()8
9 -- Checking how many buffers exist after calling the function10 local new_buf_count = #vim.api.nvim_list_bufs()11
12 -- Asserting that a new buffer was created13 assert.equals(buf_count + 1, new_buf_count)14 end)15end)
Note that
#
in Lua is the length operator
If we go ahead and run this test now, it fails.
If you're new to TDD, it can feel a little weird running a test you know is going to fail, but it's all part of the red, green, refactor cycle.
Let's write the minimum amount of code to get this test passing:
1M.show = function()2 vim.api.nvim_create_buf(false, false)3end
We also know that we want the new buffer to be a scratch buffer.
We can achieve this through flipping the second argument of nvim_create_buf
to
true
, but how do we write a test that knows what arguments were passed to
a function?
luassert
provides a spy
module to allow us to inspect some properties of
functions we spy on. Let's see it in action.
We'll update the title of the test we've already written to include our new case:
1- it("creates a new buffer", function()2+ it("creates a new scratch buffer", function()
And then create a spy on the nvim_create_buf
function and assert it was called
with the correct arguments:
1local spy = require("luassert.spy")2
3describe("meteor", function()4 it("creates a new scratch buffer", function()5 local nvim_create_buf_spy = spy.on(vim.api, "nvim_create_buf")6 local buf_count = #vim.api.nvim_list_bufs()7
8 meteor.show()9
10 local new_buf_count = #vim.api.nvim_list_bufs()11
12 assert.equals(buf_count + 1, new_buf_count, "buffer count increased by 1")13 assert.spy(nvim_create_buf_spy).was_called_with(false, true)14 end)15end)
And to make this pass, we just need to change the arguments we passed to
nvim_create_buf
to match:
1M.show = function()2 vim.api.nvim_create_buf(false, true)3end
There are some options we'll want to set on our buffer. For example, we don't want the buffer to be modifiable, and we want the contents of the buffer to be destroyed when it's hidden.
Let's write another test where we specify the options we should set on the buffer:
1it('sets the buffer options correctly', function()2 meteor.show()3
4 local bufhidden_option = vim.api.nvim_get_option_info2("bufhidden", { buf = 2 })5 local bufhidden_option_value = vim.api.nvim_get_option_value("bufhidden", { buf = 2 })6 assert.is_true(bufhidden_option.was_set, "bufhidden was set")7 assert.equals("wipe", bufhidden_option_value, "bufhidden was set to wipe")8
9 local modifiable_option = vim.api.nvim_get_option_info2("modifiable", { buf = 2 })10 local modifiable_option_value = vim.api.nvim_get_option_value("modifiable", { buf = 2 })11 assert.is_true(modifiable_option.was_set, "modifiable was set")12 assert.is_false(modifiable_option_value, "modifiable was set to false")13end)
We'll be using the nvim_set_option_value
function from vim.api
to set the
options, and we need to specify which buffer the options apply to, so let's
store the buffer number returned from nvim_create_buf
and pass that to the
new function:
1local buf = vim.api.nvim_create_buf(false, true)2vim.api.nvim_set_option_value("bufhidden", "wipe", { buf = buf })3vim.api.nvim_set_option_value("modifiable", false, { buf = buf })
The rest of the owl
I'm not going to bore you by going through the rest of the code test by test, I think we get the idea behind TDD by now. Instead, I'll walk you through the code required to display our scratch buffer and run our CLI tool in it.
First, we need to open the buffer in a window for it to be visible:
1-- We define the height and width of the window to be about 50% of our screen2local height = math.ceil(vim.o.lines * 0.5)3local width = math.ceil(vim.o.columns * 0.5)4
5-- We create the window, passing our buffer, whether we want to make it the6-- current buffer, and some options7local win = vim.api.nvim_open_win(buf, true, {8 relative = "editor",9 width = width,10 height = height,11 row = math.ceil((vim.o.lines - height) / 2),12 col = math.ceil((vim.o.columns - width) / 2),13 style = "minimal",14 border = "single",15})
Next, we set the window to "current" and run meteor
in the buffer:
1-- set the current window to the floating window2vim.api.nvim_set_current_win(win)3
4-- run meteor in the floating window5vim.fn.termopen("meteor", {6 on_exit = function(_, _, _)7 if vim.api.nvim_win_is_valid(win) then8 vim.api.nvim_win_close(win, true)9 end10 end,11})
Finally, we start insert mode in the floating window:
1vim.cmd.startinsert()
So putting this all together, we should have something like the code below:
1local M = {}2
3---Open a floating window used to display meteor4---@param opts? {win?: integer}5M.show = function(opts)6 opts = opts or {}7
8 -- create a throwaway buffer9 local buf = vim.api.nvim_create_buf(false, true)10 vim.api.nvim_set_option_value("bufhidden", "wipe", { buf = buf })11 vim.api.nvim_set_option_value("modifiable", false, { buf = buf })12
13 -- create a floating window using the throwaway buffer14 local height = math.ceil(vim.o.lines * 0.5)15 local width = math.ceil(vim.o.columns * 0.5)16 local win = vim.api.nvim_open_win(buf, true, {17 relative = "editor",18 width = width,19 height = height,20 row = math.ceil((vim.o.lines - height) / 2),21 col = math.ceil((vim.o.columns - width) / 2),22 style = "minimal",23 border = "single",24 })25
26 -- set the current window to the floating window27 vim.api.nvim_set_current_win(win)28
29 -- run meteor in the floating window30 vim.fn.termopen("meteor", {31 on_exit = function(_, _, _)32 if vim.api.nvim_win_is_valid(win) then33 vim.api.nvim_win_close(win, true)34 end35 end,36 })37
38 -- start insert mode in the floating window39 vim.cmd.startinsert()40end41
42return M
And that's pretty much it! All that's left is to define a command for the user to use:
1vim.api.nvim_create_user_command("Meteor", function()2 require("meteor").show()3end, {4 desc = "Open a floating window used to display meteor",5})
And we're done! It's that easy to wrap your favourite CLI tool into a Neovim plugin.