发布于 2026-01-06 0 阅读
0

如何在 Lua 中编写 neovim 插件 DEV 的全球展示与分享挑战赛,由 Mux 呈现:展示你的项目!

如何使用 Lua 编写 Neovim 插件

由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!

Neovim 开发者的目标之一,就是将 Lua 打造成 Viml 之外的一流脚本语言。自 0.4 版本起,Lua 的解释器及其标准库 (stdlib) 就已内置于编辑器中。Lua 学习起来相当简单,速度很快,并且在游戏开发社区中被广泛使用。在我看来,它的学习曲线也比 Viml 低得多,这或许能鼓励新手开始探索 Neovim 的功能扩展,或者仅仅是为了编写一些简单的脚本。那么,我们不妨来试试看?编写一个简单的插件来显示我们最近编辑过的文件。它应该叫什么名字呢?或许……“我做了什么?!”。它看起来大概是这样的:

插件目录结构

我们的插件至少应该有两个目录:plugin一个用来存放主文件,另一个lua用来存放整个代码库。当然,如果我们真的想这么做,也可以把所有内容都放在一个文件里,但拜托,我们还是别这么做。所以,plugin/whid.vim两个lua/whid.lua目录就足够了。我们可以从这里开始:

" 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这是一种常见的做法,可以防止自定义的coptions(单字符标志序列)干扰插件。就我们自身而言,缺少这一行可能不会造成什么影响,但这被认为是一种良好的实践(至少根据 vim 帮助文件是这么说的)。此外,还有command! Whid lua require'whid'.whid()一种方法需要插件的 lua 模块并调用其主函数。

浮动窗口

好,我们先来点有趣的。我们需要创建一个可以展示内容的地方。幸运的是,Neovim(现在 Vim 也一样)有一个很棒的功能叫做浮动窗口。它是一个可以显示在其他窗口之上的窗口,就像操作系统里的浮动窗口一样。

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

在文件顶部,我们定义了win最高buf作用域的变量,这些变量会被其他函数频繁引用。此时缓冲区为空,它将用于存放我们的结果。该缓冲区被创建为未列出的缓冲区(第一个参数)和“临时缓冲区”(第二个参数;参见:h scratch-buffer)。此外,我们还设置了在隐藏时删除该缓冲区bufhidden = wipe

我们nvim_open_win(buf, true, opts)创建一个新窗口,并将之前创建的缓冲区附加到该窗口。第二个参数使新窗口获得焦点。widthheight的作用显而易见。rowcol是窗口的起始位置,从编辑器的左上角计算得出relative = "editor"style = "minimal"是一个方便的选项,用于配置窗口的外观,在这里我们可以禁用许多不需要的选项,例如行号或拼写错误高亮显示。

现在我们有了浮动窗口,但还可以让它看起来更美观。Neovim 目前不支持边框之类的控件,所以我们需要自己创建一个。这很简单。我们需要另一个浮动窗口,比第一个窗口稍大一些,并放在它下面。

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

我们将用“方框画”字符填充它。

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)

当然,我们必须按正确的顺序打开窗口。还有一点,两个窗口应该始终同时关闭。如果第一个窗口关闭后边框仍然存在,那就很奇怪了。目前,viml 的自动命令是解决这个问题的最佳方案。

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)

获取一些数据

我们的插件旨在显示我们最近处理过的文件。我们将使用简单的 git 命令来实现这一点。例如:

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

让我们创建一个函数,它会将一些数据显示在漂亮的窗口中。我们会频繁调用它,所以就把它命名为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

嗯……如果只能查找当前文件,那就不太方便了。因为我们会直接调用这个函数来更新视图,所以应该接受一个参数,用来指示我们要显示旧版本还是新版本。

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

你知道这里还缺什么吗?我们插件的标题。居中!这个功能可以帮助我们把一些文字放在中间。

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

加一些高亮显示会很不错。我们可以选择几种方案:定义自定义语法文件(可以根据行号匹配模式),或者使用虚拟文本注释代替普通文本(虽然可行,但代码会更复杂),或者……我们可以使用基于位置的高亮显示nvim_buf_add_highlight。但首先,我们必须声明高亮显示。我们将链接到现有的默认高亮显示组,而不是自己设置颜色。这样就能与用户的颜色方案保持一致。

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

hi def link WhidHeader      Number
hi def link WhidSubHeader   Identifier

现在我们来添加高亮显示。

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

我们将高亮内容以非分组高亮的形式添加到缓冲区buf(第二个参数-1)。我们可以在这里传递命名空间 ID,这样就可以一次性清除组内的所有高亮内容,但在我们的例子中不需要这样做。接下来是行号,最后两个参数分别是起始和结束(字节索引)列范围。

整个函数如下所示:

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

用户输入

现在我们应该让插件具备交互功能。不需要太复杂,只需要一些简单的功能,例如更改当前预览状态或选择并打开文件。我们的插件将通过映射接收用户输入。按下某个键会触发相应的操作。让我们来看看 Lua API 中是如何定义映射的。

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

第一个参数,和往常一样,是缓冲区。这些映射的作用域将限定在这个缓冲区内。接下来是模式简称。我们定义所有普通模式下的映射n。然后是“左”键组合(我以x“左”键组合为例)映射到“右”键组合(我们告诉 Neovim 进入命令行模式,输入一些 Viml 代码并按下回车键<cr>)。最后是一些选项。我们希望 Neovim 在匹配到某个模式时立即触发映射,所以我们设置了nowait标志;我们阻止它通过其他映射触发我们的映射,noremap并且不向用户显示输入过程silent。这行代码比较长,所以我们使用数组来节省一些写入空间。

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

我们还可以禁用未使用的键(或者不禁用,随您喜欢)。

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

公共职能

好的,不过映射中提到了一些新功能。我们来看看。

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

我们的文件列表非常简单。我们可以直接获取光标所在行,然后让 Neovim 编辑它。当然,我们也可以构建更复杂的机制。我们可以获取行号(甚至列号),然后根据行号触发特定的操作。这样就可以将视图和逻辑分离。但就我们的需求而言,这已经足够了。

但是,如果我们不先导出这些函数,就无法调用它们。在文件末尾,我们将返回一个包含公开可用函数的关联数组。

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

当然还有主要功能!

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

整个插件……

……并进行了一些小的改进(您可以通过查看评论找到这些改进)。

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

现在你应该掌握了足够的基础知识,可以为你的 Lua Neovim 脚本编写简单的 TUI。祝你玩得开心!
代码也可以在这里找到:https://github.com/rafcamlet/nvim-whid

文章来源:https://dev.to/2nit/how-to-write-neovim-plugins-in-lua-5cca