如何使用 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)创建一个新窗口,并将之前创建的缓冲区附加到该窗口。第二个参数使新窗口获得焦点。width和height的作用显而易见。row和col是窗口的起始位置,从编辑器的左上角计算得出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
