DEV Community

Cover image for How to write neovim plugins in Lua
Rafał Camlet for 2n IT

Posted on • Updated on • Originally published at 2n.pl

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:

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:

" 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.

-- 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.

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.

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.

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.

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.

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.

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
  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.

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

hi def link WhidHeader      Number
hi def link WhidSubHeader   Identifier

Now let's add highlighting

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:

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.

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.

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).

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.

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.

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!

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).

" 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/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

Top comments (2)

Collapse
 
harrydt profile image
Harry Tran

Shouldn't we use the global variable "api" instead of calling vim.api every time?

Collapse
 
dystroy profile image
Denys Séguret

Hey, thanks a lot for this article. I knew nothing about vim plugins or Lua and your repository helped me quick start my first plugin: github.com/Canop/nvim-bacon/