Skip to content
stefanlogue.dev
GithubBluesky

Writing a Neovim Plugin

Neovim, Lua3 min read

neovim

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:

lua/meteor/health.lua
1local M = {}
2
3M.check = function()
4 vim.health.start("meteor.nvim report")
5
6 if vim.fn.executable("meteor") == 0 then
7 vim.health.issues("meteor is not installed or not in PATH")
8 return
9 end
10
11 vim.health.ok("meteor is installed")
12end
13
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 then
2 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:

lua/meteor/init.lua
1local M = {}
2
3M.show = function()
4end
5
6return M

And start writing a test! First let's get the test file set up:

tests/meteor/meteor_spec.lua
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 buffers
4 local buf_count = #vim.api.nvim_list_bufs()
5
6 -- calling our function
7 meteor.show()
8
9 -- Checking how many buffers exist after calling the function
10 local new_buf_count = #vim.api.nvim_list_bufs()
11
12 -- Asserting that a new buffer was created
13 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 screen
2local 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 the
6-- current buffer, and some options
7local 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 window
2vim.api.nvim_set_current_win(win)
3
4-- run meteor in the floating window
5vim.fn.termopen("meteor", {
6 on_exit = function(_, _, _)
7 if vim.api.nvim_win_is_valid(win) then
8 vim.api.nvim_win_close(win, true)
9 end
10 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 meteor
4---@param opts? {win?: integer}
5M.show = function(opts)
6 opts = opts or {}
7
8 -- create a throwaway buffer
9 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 buffer
14 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 window
27 vim.api.nvim_set_current_win(win)
28
29 -- run meteor in the floating window
30 vim.fn.termopen("meteor", {
31 on_exit = function(_, _, _)
32 if vim.api.nvim_win_is_valid(win) then
33 vim.api.nvim_win_close(win, true)
34 end
35 end,
36 })
37
38 -- start insert mode in the floating window
39 vim.cmd.startinsert()
40end
41
42return M

And that's pretty much it! All that's left is to define a command for the user to use:

plugin/meteor.lua
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.

© 2024 by stefanlogue.dev. All rights reserved.