How to write neovim plugins in Lua
Tags: vim, tutorial, programming, webdev
Published: 2020-02-05 10:08:02

---
title: How to write neovim plugins in Lua
cover_image: https://2n.pl/system/blog_posts/photos/000/000/131/original/whid.png
published: true
description: 
tags: vim, tutorial, programming, webdev
canonical_url: https://www.2n.pl/blog/how-to-write-neovim-plugins-in-lua
---
One of goals which neovim devs set for themselves, was making lua the first-class scripting language alternative to viml. Since version 0.4 its' interpreter along with 'stdlib' have been already built into the editor. Lua is quite simple to learn, very fast and widely used in gamedev community. In my opinion it also has much lower learning curve than viml, which may encourage new people to begin their journey with extending neovim capabilities or just making simple scripts for one time purposes. So how about we try it out? Let's write simple plugin to display the files we have recently worked on. How should we name it? Maybe... "What have I done?!". It will look something like that:

![](https://www.2n.pl/system/photos/imgs/000/000/009/original/whid.gif?1580740701)

## Plugin directories structure

Our plugin should have at least two directories: `plugin` where we put its main file and `lua` with whole codebase. Of course if we really want it, we can put everything in one file, but please, let's not be this guy (or gal). So `plugin/whid.vim` and `lua/whid.lua` will be fine. We can start from:

```vim
" in plugin/whid.vim
if exists('g:loaded_whid') | finish | endif " prevent loading file twice

let s:save_cpo = &cpo " save user coptions
set cpo&vim " reset them to defaults

" command to run our plugin
command! Whid lua require'whid'.whid()

let &cpo = s:save_cpo " and restore after
unlet s:save_cpo

let g:loaded_whid = 1
```

`let s:save_cpo = &cpo` is a common practice preventing custom `coptions` (sequence of single character flags) to interfere with the plugin. For our own purposes, the lack of this line would probably not hurt, but it is considered as good practice (at least according to the vim help files). There is also `command! Whid lua require'whid'.whid()` which requires plugin's lua module and calls its main function.

## Floating window

Okey let's start with something fun. We should create a place where we can display things. Thankfully neovim (now vim too) has neat feature called floating windows. It's a window that is displayed over top of other windows, like in OS.

```lua
-- in lua/whid.lua

local api = vim.api
local buf, win

local function open_window()
  buf = api.nvim_create_buf(false, true) -- create new emtpy buffer

  api.nvim_buf_set_option(buf, 'bufhidden', 'wipe')

  -- get dimensions
  local width = api.nvim_get_option("columns")
  local height = api.nvim_get_option("lines")

  -- calculate our floating window size
  local win_height = math.ceil(height * 0.8 - 4)
  local win_width = math.ceil(width * 0.8)

  -- and its starting position
  local row = math.ceil((height - win_height) / 2 - 1)
  local col = math.ceil((width - win_width) / 2)

  -- set some options
  local opts = {
    style = "minimal",
    relative = "editor",
    width = win_width,
    height = win_height,
    row = row,
    col = col
  }

  -- and finally create it with buffer attached
  win = api.nvim_open_win(buf, true, opts)
end
```

On top of the file we define `win` and `buf` variables in the highest scope, which will be often referenced by the other function. Empty, at this moment, the buffer will be the place where we put our results. It was created as not listed buffer (first argument) and "scratch-buffer" (second  argument; see `:h scratch-buffer`). Also we set it to be deleted when hidden `bufhidden = wipe`.

With `nvim_open_win(buf, true, opts)` we create new window with previously created buffer attached to it. Second argument makes the new window focused. `width` and `height` are pretty self explanatory. `row` and `col` are starting position of our window calculated from the upper left corner of editor `relative = "editor"`. `style = "minimal"` is handy option that configures appearance of window and here we disable many unwanted options, like line numbers or highlighting of spelling errors.

So now we have floating window, but we can make it look even better. Neovim currently doesn't support widgets like border, so we should create one by ourselves. It's quite simple. We need another floating window, slightly bigger than the first one and placed under it.

```lua
local border_opts = {
  style = "minimal",
  relative = "editor",
  width = win_width + 2,
  height = win_height + 2,
  row = row - 1,
  col = col - 1
}
```

We will fill it with "box-drawing" characters.

```lua
local border_buf = api.nvim_create_buf(false, true)

local border_lines = { '╔' .. string.rep('═', win_width) .. '╗' }
local middle_line = '║' .. string.rep(' ', win_width) .. '║'
for i=1, win_height do
  table.insert(border_lines, middle_line)
end
table.insert(border_lines, '╚' .. string.rep('═', win_width) .. '╝')

api.nvim_buf_set_lines(border_buf, 0, -1, false, border_lines)
-- set bufer's (border_buf) lines from first line (0) to last (-1)
-- ignoring out-of-bounds error (false) with lines (border_lines)
```

Of course we must open windows in proper order. And one more thing. Both windows should always close together. It will be quite odd if after closing first the border is still there. Currently viml autocomand is the best solution for this.

```lua
local border_win = api.nvim_open_win(border_buf, true, border_opts)
win = api.nvim_open_win(buf, true, opts)
api.nvim_command('au BufWipeout <buffer> exe "silent bwipeout! "'..border_buf)
```

## Get some data

Our plugin is designed to show latest files that we have worked on. We will use simple git command to do that. Something like:

```
git diff-tree --no-commit-id --name-only -r HEAD
```

Let's create function, that will put some data in our pretty window. We will call it frequently, so let's name it `update_view`.

```lua
local function update_view()
  -- we will use vim systemlist function which run shell
  -- command and return result as list
  local result = vim.fn.systemlist('git diff-tree --no-commit-id --name-only -r HEAD')

  -- with small indentation results will look better
  for k,v in pairs(result) do
    result[k] = '  '..result[k]
  end

  api.nvim_buf_set_lines(buf, 0, -1, false, result)
end
```

Hmm... it isn't very handy if we can look only for current files. Because we will directly call this function to update our view, we should accept param with information on whether we want show older or newer state.

```lua
local position = 0

local function update_view(direction)
  position = position + direction
  if position < 0 then position = 0 end -- HEAD~0 is the newest state

  local result = vim.fn.systemlist('git diff-tree --no-commit-id --name-only -r  HEAD~'..position)

  -- ... rest of the code 
end
```

Do you know what is still missing here? Our plugin's title. Centered! This function will help us put some text in the middle.

```lua
local function center(str)
  local width = api.nvim_win_get_width(0)
  local shift = math.floor(width / 2) - math.floor(string.len(str) / 2)
  return string.rep(' ', shift) .. str
end
```

```lua
  api.nvim_buf_set_lines(buf, 0, -1, false, {
      center('What have i done?'),
      center('HEAD~'..position),
      ''
    })
end
```

Some highlights will be nice. There are a few options we can chose: defining custom syntax file (you can match pattern based on line number) or use virtual text annotation instead of normal text (it is possible, but in that case our code would be more complicated) or... we can use position based highlighting `nvim_buf_add_highlight`. But first we must declare our highlights. We will link to existing default highlights group instead of setting color by ourselves. This way it will match user colorsheme.

```vim
" in plugin/whid.vim after set cpo&vim

hi def link WhidHeader      Number
hi def link WhidSubHeader   Identifier
```

Now let's add highlighting

```lua
api.nvim_buf_add_highlight(buf, -1, 'WhidHeader', 0, 0, -1)
api.nvim_buf_add_highlight(buf, -1, 'WhidSubHeader', 1, 0, -1)
```

We add highlights to buffer `buf` as ungrouped highlight (second argument `-1`). We can pass the namespace id here, which give us possibility to clear all highlights in group at once, but in our case we don't need that. Next is line number and last two params are start and end (byte-indexed) column range.

Whole function will look like this:

```lua
local function update_view(direction)
  -- Is nice to prevent user from editing interface, so
  -- we should enabled it before updating view and disabled after it.
  api.nvim_buf_set_option(buf, 'modifiable', true)

  position = position + direction
  if position < 0 then position = 0 end

  local result = vim.fn.systemlist('git diff-tree --no-commit-id --name-only -r  HEAD~'..position)
  for k,v in pairs(result) do
    result[k] = '  '..result[k]
  end

  api.nvim_buf_set_lines(buf, 0, -1, false, {
      center('What have i done?'),
      center('HEAD~'..position),
      ''
  })
  api.nvim_buf_set_lines(buf, 3, -1, false, result)

  api.nvim_buf_add_highlight(buf, -1, 'WhidHeader', 0, 0, -1)
  api.nvim_buf_add_highlight(buf, -1, 'whidSubHeader', 1, 0, -1)

  api.nvim_buf_set_option(buf, 'modifiable', false)
end
```

## User input

Now we should make our plugin interactive. Nothing too complicated, only simple features like changing currently previewed state or selecting and opening files. Our plugin will receive user input via mappings. Pressing a key will trigger certain action. Let's look how mappings are defined in lua api.

```lua
api.nvim_buf_set_keymap(buf, 'n', 'x', ':echo "wow!"<cr>', { nowait = true, noremap = true, silent = true })
```

First argument, as usual, is the buffer. These mappings will be scoped to it. Next is mode short-name. We define all ours mappings in normal mode `n`. Then is a "left" keys combination (I choose `x` as example) mapped to "right" keys combination (we tell neovim to enter command-line mode, typing some viml and push enter `<cr>`). At last there are some options. We want neovim to trigger mapping as soon as it matches a pattern, so we set `nowait` flag, we prevent it from triggering our mapping via others mappings `noremap` and don't show typing to user `silent`. It is quite a long line, so we use array to save some writing.

```lua
local function set_mappings()
  local mappings = {
    ['['] = 'update_view(-1)',
    [']'] = 'update_view(1)',
    ['<cr>'] = 'open_file()',
    h = 'update_view(-1)',
    l = 'update_view(1)',
    q = 'close_window()',
    k = 'move_cursor()'
  }

  for k,v in pairs(mappings) do
    api.nvim_buf_set_keymap(buf, 'n', k, ':lua require"whid".'..v..'<cr>', {
        nowait = true, noremap = true, silent = true
      })
  end
end
```

We can also disable not used keys (or not, whichever you like).

```lua
local other_chars = {
  'a', 'b', 'c', 'd', 'e', 'f', 'g', 'i', 'n', 'o', 'p', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'
}
for k,v in ipairs(other_chars) do
  api.nvim_buf_set_keymap(buf, 'n', v, '', { nowait = true, noremap = true, silent = true })
  api.nvim_buf_set_keymap(buf, 'n', v:upper(), '', { nowait = true, noremap = true, silent = true })
  api.nvim_buf_set_keymap(buf, 'n',  '<c-'..v..'>', '', { nowait = true, noremap = true, silent = true })
end
```

## Public function

Okey, but there are some new functions mentioned in the mapping. Let's look at them.

```lua
local function close_window()
  api.nvim_win_close(win, true)
end

-- Our file list start at line 4, so we can prevent reaching above it
-- from bottm the end of the buffer will limit movment
local function move_cursor()
  local new_pos = math.max(4, api.nvim_win_get_cursor(win)[1] - 1)
  api.nvim_win_set_cursor(win, {new_pos, 0})
end

-- Open file under cursor
local function open_file()
  local str = api.nvim_get_current_line()
  close_window()
  api.nvim_command('edit '..str)
end
```

Our file list is quite simple. We can just get line under the cursor and tell neovim to edit it. Of course we can built more  sophisticated mechanism. We can get line number (or even column) and then, based on it, trigger specific action. It will allow to separate view form logic. But for our purposes it is enough.

However non of these functions can be called if we don't export them first. At the bottom of the file we will return associative array with publicly available functions.

```lua
return {
  whid = whid,
  update_view = update_view,
  open_file = open_file,
  move_cursor = move_cursor,
  close_window = close_window
}
```

And of course the main function!

```lua
local function whid()
  position = 0 -- if you want to preserve last displayed state just omit this line
  open_window()
  set_mappings()
  update_view(0)
  api.nvim_win_set_cursor(win, {4, 0}) -- set cursor on first list entry
end
```

## The whole plugin...
... with small improvments (you can find them by looking for the comments).

```vim
" plugin/whid.vim
if exists('g:loaded_whid') | finish | endif

let s:save_cpo = &cpo
set cpo&vim

hi def link WhidHeader      Number
hi def link WhidSubHeader   Identifier

command! Whid lua require'whid'.whid()

let &cpo = s:save_cpo
unlet s:save_cpo

let g:loaded_whid = 1
```

```lua
-- lua/whid.lua
local api = vim.api
local buf, win
local position = 0

local function center(str)
  local width = api.nvim_win_get_width(0)
  local shift = math.floor(width / 2) - math.floor(string.len(str) / 2)
  return string.rep(' ', shift) .. str
end

local function open_window()
  buf = api.nvim_create_buf(false, true)
  local border_buf = api.nvim_create_buf(false, true)

  api.nvim_buf_set_option(buf, 'bufhidden', 'wipe')
  api.nvim_buf_set_option(buf, 'filetype', 'whid')

  local width = api.nvim_get_option("columns")
  local height = api.nvim_get_option("lines")

  local win_height = math.ceil(height * 0.8 - 4)
  local win_width = math.ceil(width * 0.8)
  local row = math.ceil((height - win_height) / 2 - 1)
  local col = math.ceil((width - win_width) / 2)

  local border_opts = {
    style = "minimal",
    relative = "editor",
    width = win_width + 2,
    height = win_height + 2,
    row = row - 1,
    col = col - 1
  }

  local opts = {
    style = "minimal",
    relative = "editor",
    width = win_width,
    height = win_height,
    row = row,
    col = col
  }

  local border_lines = { '╔' .. string.rep('═', win_width) .. '╗' }
  local middle_line = '║' .. string.rep(' ', win_width) .. '║'
  for i=1, win_height do
    table.insert(border_lines, middle_line)
  end
  table.insert(border_lines, '╚' .. string.rep('═', win_width) .. '╝')
  api.nvim_buf_set_lines(border_buf, 0, -1, false, border_lines)

  local border_win = api.nvim_open_win(border_buf, true, border_opts)
  win = api.nvim_open_win(buf, true, opts)
  api.nvim_command('au BufWipeout <buffer> exe "silent bwipeout! "'..border_buf)

  api.nvim_win_set_option(win, 'cursorline', true) -- it highlight line with the cursor on it

  -- we can add title already here, because first line will never change
  api.nvim_buf_set_lines(buf, 0, -1, false, { center('What have i done?'), '', ''})
  api.nvim_buf_add_highlight(buf, -1, 'WhidHeader', 0, 0, -1)
end

local function update_view(direction)
  api.nvim_buf_set_option(buf, 'modifiable', true)
  position = position + direction
  if position < 0 then position = 0 end

  local result = vim.fn.systemlist('git diff-tree --no-commit-id --name-only -r  HEAD~'..position)
  if #result == 0 then table.insert(result, '') end -- add  an empty line to preserve layout if there is no results
  for k,v in pairs(result) do
    result[k] = '  '..result[k]
  end

  api.nvim_buf_set_lines(buf, 1, 2, false, {center('HEAD~'..position)})
  api.nvim_buf_set_lines(buf, 3, -1, false, result)

  api.nvim_buf_add_highlight(buf, -1, 'whidSubHeader', 1, 0, -1)
  api.nvim_buf_set_option(buf, 'modifiable', false)
end

local function close_window()
  api.nvim_win_close(win, true)
end

local function open_file()
  local str = api.nvim_get_current_line()
  close_window()
  api.nvim_command('edit '..str)
end

local function move_cursor()
  local new_pos = math.max(4, api.nvim_win_get_cursor(win)[1] - 1)
  api.nvim_win_set_cursor(win, {new_pos, 0})
end

local function set_mappings()
  local mappings = {
    ['['] = 'update_view(-1)',
    [']'] = 'update_view(1)',
    ['<cr>'] = 'open_file()',
    h = 'update_view(-1)',
    l = 'update_view(1)',
    q = 'close_window()',
    k = 'move_cursor()'
  }

  for k,v in pairs(mappings) do
    api.nvim_buf_set_keymap(buf, 'n', k, ':lua require"whid".'..v..'<cr>', {
        nowait = true, noremap = true, silent = true
      })
  end
  local other_chars = {
    'a', 'b', 'c', 'd', 'e', 'f', 'g', 'i', 'n', 'o', 'p', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'
  }
  for k,v in ipairs(other_chars) do
    api.nvim_buf_set_keymap(buf, 'n', v, '', { nowait = true, noremap = true, silent = true })
    api.nvim_buf_set_keymap(buf, 'n', v:upper(), '', { nowait = true, noremap = true, silent = true })
    api.nvim_buf_set_keymap(buf, 'n',  '<c-'..v..'>', '', { nowait = true, noremap = true, silent = true })
  end
end

local function whid()
  position = 0
  open_window()
  set_mappings()
  update_view(0)
  api.nvim_win_set_cursor(win, {4, 0})
end

return {
  whid = whid,
  update_view = update_view,
  open_file = open_file,
  move_cursor = move_cursor,
  close_window = close_window
}
```

Now you should have a basic knowledge, enough to write simple TUI for your lua neovim scripts. Have fun!
The code can be found also here https://github.com/rafcamlet/nvim-whid