Skip to content

Neovim Plugin Development

Developing a new Neovim plugin

The plugin repo has some examples in the tests directory.

Check org-checkbox to see a simple one ) Miguel Crespo has created a nice tutorial too

The anatomy of a Neovim plugin

For the repository name, plugins usually finish with the .nvim extension. I'm going to call mine org-misc.nvim.

Let’s start by seeing how the architecture of a common neovim plugin looks like, usually a neovim plugin is structured in the following way:

.
├── LICENSE
├── plugin
│  └── ...
├── lua
│   └── org-misc
│       └── init.lua
└── README.md

The plugin and lua folder are special cases and have the following meanings:

  • plugin directory: All files in this directory will get executed as soon as Neovim starts, this is useful if you want to set keymaps or autocommands regardless of the user requiring the plugin or not.

  • lua directory: Here is where your plugin’s code lives, this code will only be executed when the user explicitly requires your plugin.

The naming of the files is important and will usually be the same as the plugin, there are two ways to do it:

  • Having a single lua file named after the plugin, e.g: scratch-buffer.lua
  • Having a folder named after the plugin with an init.lua inside of it, e.g lua/scratch-buffer/init.lua.

Let's go with the second, add to your init.lua the next code:

print("Hello from our plugin")

How to load our extension

We can have the code of our extension wherever we want in our filesystem, but we need to tell Neovim where our plugin’s code is, so it can load the files correctly. Since I use lazy.nvim this is the way to load a plugin from a local folder:

{
  dir = "~/projects/org-misc", -- Your path
}

Now if you restart your neovim you won't see anything until you load it with :lua require "org-misc" you'll see the message Hello from our plugin in the command line.

To automatically load the plugin when you open nvim, use the next lazy config:

{
  dir = "~/projects/org-misc", -- Your path
  config = function()
    require "org-misc"
  end
}

The plugin file structure

Usually init.lua starts with:

local M = {}

M.setup = function ()
  -- nothing yet
end

return M

Where: - M stands for module, and we'll start adding it methods. - M.setup will be the method we use to configure the plugin.

Let's start with a basic functionality to print some slides:

local M = {}

M.setup = function()
    -- nothing yet
end

---@class present.Slides
---@fields slides string[]: The slides of the file

--- Takes some lines and parses them
--- @param lines string
--- @return present.Slides
local parse_slides = function(lines)
    local slides = { slides = {} }
    for _, line in ipairs(lines) do
        print(line)
    end
    return slides
end

print(parse_slides({
    "# Hello",
    "this is something else",
    "# world",
    "this is something else",
}))

return M

You can run the code in the current buffer with :%lua. For quick access, I've defined the next binding:

keymap.set("n", "<leader>X", ":%lua<cr>", {desc = "Run the lua code in the current buffer"})

The print(parse_slides.. part it's temporal code so that you can debug your code easily. Once it's ready you'll remove them

Vim plugin development snippets

Call a method of a module

To run the method of a module:

local M = {}

M.setup = function()
    -- nothing yet
end

return M

You can do require('org-misc').setup()

Set keymaps

Inside the code of the plugin

You can set keymaps into your plugins by using:

vim.keymap.set("n", "n", function()
  -- code
end)

The problem is that it will override the n key everywhere which is not a good idea, that's why we normally limit it to the current buffer.

You can get the current buffer with buffer = true

vim.keymap.set("n", "n", function()
  -- code
end, 
  {
    buffer = true
  }
)

Control an existing nvim instance

A number of different transports are supported, but the simplest way to get started is with the python REPL. First, start Nvim with a known address (or use the $NVIM_LISTEN_ADDRESS of a running instance):

$ NVIM_LISTEN_ADDRESS=/tmp/nvim nvim

In another terminal, connect a python REPL to Nvim (note that the API is similar to the one exposed by the python-vim bridge:

>>> from neovim import attach
# Create a python API session attached to unix domain socket created above:
>>> nvim = attach('socket', path='/tmp/nvim')
# Now do some work.
>>> buffer = nvim.current.buffer # Get the current buffer
>>> buffer[0] = 'replace first line'
>>> buffer[:] = ['replace whole buffer']
>>> nvim.command('vsplit')
>>> nvim.windows[1].width = 10
>>> nvim.vars['global_var'] = [1, 2, 3]
>>> nvim.eval('g:global_var')
[1, 2, 3]

Load buffer

buffer = nvim.current.buffer # Get the current buffer
buffer[0] = 'replace first line'
buffer[:] = ['replace whole buffer']

Get cursor position

nvim.current.window.cursor

Neovim plugin debug

If you use packer your plugins will be installed in ~/.local/share/nvim/site/pack/packer/start/. If you use lazy your plugins will be installed in ~/.local/share/nvim/lazy/pack/packer/start/.

You can manually edit those files to develop new feature or fix issues on the plugins.

To debug a plugin read it's source code and try to load in a lua shell the relevant code. If you are in a vim window you can run lua code with :lua your code here, for example :lua Files = require('orgmode.parser.files'), you can then do stuff with the Files object.

Debugging using Snacks

Utility functions you can use in your code.

Personally, I have the code below at the top of my init.lua:

_G.dd = function(...)
  Snacks.debug.inspect(...)
end
_G.bt = function()
  Snacks.debug.backtrace()
end
vim.print = _G.dd

What this does:

  • Add a global dd(...) you can use anywhere to quickly show a notification with a pretty printed dump of the object(s) with lua treesitter highlighting
  • Add a global bt() to show a notification with a pretty backtrace.
  • Override Neovim's vim.print, which is also used by := {something = 123}

You can't use debug instead of dd because nvim fails to start :(

image

Debugging with prints

Remember that if you need to print the contents of a table you can use vim.inspect.

Another useful tip for Lua newbies (like me) can be to use print statements to debug the state of the variables. If it doesn't show up in vim use error instead, although that will break the execution with an error.

To see the messages you can use :messages.

Debugging with DAP

You can debug Lua code running in a separate Neovim instance with jbyuki/one-small-step-for-vimkind.

The plugin uses the Debug Adapter Protocol. Connecting to a debug adapter requires a DAP client like mfussenegger/nvim-dap. Check how to configure here

Once you have all set up and assuming you're using the lazyvim keybindings for nvim-dap:

vim.api.nvim_set_keymap('n', '<leader>ds', [[:lua require"osv".launch({port = 8086})<CR>]], { noremap = true })
vim.api.nvim_set_keymap('n', '<leader>dq', [[:lua require"osv".stop()<CR>]], { noremap = true })

You will debug the plugin by:

  • Launch the server in the nvim instance where you're going to run the actions using <leader>ds.
  • Open another Neovim instance with the source file (the debugger).
  • Place breakpoint with <leader>db.
  • On the debugger connect to the DAP client with <leader>dc.
  • Optionally open the nvim-dap-ui with <leader>B in the debugger.
  • Run your script/plugin in the debuggee

Now you can interact with the debugger in the window below the code. You have the next commands:

  • help: Show all commands
  • <enter>: run the same action as the previous one. For example if you do .n and then <enter> it will run .n again.
  • .n or .next: next step
  • .b or .back: previous step (if the debugger supports it)
  • .c or .continue: Continue to the next breakpoint.

Continue till the end

If you want to stop capturing the traffic flow and go to the end ignoring all breakpoints, remove all breakpoints and do .c

Reload the plugin without exiting nvim

If you are using lazy.nvim, there is a feature that lazy.nvim provides for this purpose:

Lazy reload your_plugin your_plugin2

Neovim plugin testing

We're going to test it with plenary. We'll add a tests directory at the root of our repository.

Each of the test files need to end in _spec.lua, so if we want to test a parse_lines it will be called parse_lines_spec.lua.

Each test file has the following structure

local clockin = require('org-misc').clockin

describe("org-misc.clockin", function()
    it("should do clockin", function()
        assert.is.True(clock_in())
    end)
end)
These are all the tests for the clockin method,

Now you can run the test with :PlenaryBustedFile %

Configuring neotest to run the tests

Using :PlenaryBustedFile % is not comfortable, that's why we're going to use neotest

Configure it with:

return {
  {
    "nvim-neotest/neotest",
    dependencies = {
      "nvim-neotest/neotest-plenary",
    },
    config = function()
      require("neotest").setup({
        adapters = {
          require("neotest-plenary"),
        },
      })
    end,
  },
}

Now you can do:

  • <leader>tT to run all test files
  • <leader>tt to run the whole file
  • <leader>tl to run the last test
  • <leader>to to show the output
  • <leader>tr to run the nearest
  • <leader>ts to show the summary

Remove the Undefined global describe linter warnings

Add to the root of your repository a .luarc.json file with the next contents

{
  "$schema": "https://raw.githubusercontent.com/sumneko/vscode-lua/master/setting/schema.json",
  "diagnostics": {
    "globals": ["vim"]
  },
  "hint": {
    "enable": true
  },
  "runtime": {
    "path": ["?.lua", "?/init.lua"],
    "pathStrict": true,
    "version": "LuaJIT"
  },
  "telemetry": {
    "enable": false
  },
  "workspace": {
    "checkThirdParty": "Disable",
    "ignoreDir": [".git"],
    "library": [
      "./lua",
      "$VIMRUNTIME/lua",
      "${3rd}/luv/library",
      "./tests/.deps/plugins/plenary"
    ]
  }
}

Testing internal functions

If you have a function parse_lines in your module that you want to test, you can export it as an internal method

local parse_lines = function ()
  -- code
end

M._parse_lines = parse_lines

References