起步
去年七月,开发工具转至 nvim,同时写下《(不)习惯:向nvim的迁徙》,讲述自己拆解 IDE 需求,逐个击破重点难点,最后将“大迁徙”方案落地。一年以来,我优化了原有的 NvChad 配置,新写一些实用插件,将许多繁琐的操作浓缩进几个命令。当同事们还在界面上点来点去,我已经完成文件内容替换;当他们还在逐级进入文件夹,我已经把代码上传到了内部服务器。
使用一款自己可控的开发工具十分重要。如果你能写 JetBrains 系或是 VsCode 插件,后面的内容可以不用读。如果你写不了,我十分建议你转投 nvim 或 emacs。因为基于它们给自己写插件尤其简单,而你需要付出的,仅仅是记住一些快捷键。
本文旨在从零开始,教你写出属于自己的第一个 nvim 插件。当你发现原来写插件不过如此而已后,你在开发工具上的灵感将源源不断地喷涌而出。然后你会意识到,读完这篇文章,竟然只是长期快乐的开始。
我们要写什么样的插件
即将实现的这款插件,灵感来自于 emacs 的 M-x find-file
(C-x C-f)。如果你是 emacs 用户,也许你会会心一笑,因为 find-file 是“神之编辑器”的入门功能。即使你只学过 15 分钟 emacs,你也会见到它。
执行 find-file,emacs 将展示当前打开文件所在目录下的所有内容。例如当前打开的文件路径是:~/.config/doom/customizations/init.el,则显示 ~/.config/doom/customizations/ 下的文件和目录。通过继续在 minibuffer 中键入字母,可以精确到某个文件,或是进入到指定子目录,再展示子目录下的内容。
我很喜欢这个功能。有了它,不用打开目录树就能搜索到附近的文件,然后迅速打开。我将这个能力移植到了 nvim,成为最常使用的命令之一。
如何优雅地开始
网上大多教程是把插件写进 ~/.config/nvim 目录下。如果你希望写的插件别人也能使用,我们可以选择更优雅的方案。在你的工作目录下创建 nvim-config 目录,然后创建 nvim-config/init.lua 文件。
@guan ~/P/project$ tree nvim-config
nvim-config
└── init.lua
init.lua 中的内容如下:
print("hello world")
指定配置启动 nvim:nvim -u nvim-config/init.lua
。你就能在左下角看到 “hello world”。
插件管理器(lazy)
我们需要一款插件管理器,用来加载自己写的插件。这里我选择 lazy.nvim,使用 Single File Setup 安装方式。
打开 nvim-config/init.lua,将配置粘贴进去,同时替换 data 和 config 目录。
-- 原:
-- local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
-- 修改后:
local data_dir = vim.fn.expand("~") .. "/Public/project/nvim-config-data/"
local config_dir = vim.fn.expand("~") .. "/Public/project/nvim-config/"
local lazypath = data_dir .. "/lazy/lazy.nvim"
-- ignore partions
require("lazy").setup({
root = data_dir .. "lazy",
lockfile = config_dir .. "lazy-lock.json",
spec = {
-- ignore partions
}
})
-- 关闭 insert mode 时的提示
vim.o.showmode = false
这样做的好处是,开发插件的配置 (nvim-config) 跟我们日常使用的 ~/.config/nvim 配置完全隔离,避免产生冲突。
创建插件
在 nvim-config 目录下创建插件目录 find-file.nvim,以及 init.lua 文件 (find-file.nvim/lua/find-file/init.lua)。此时你的目录结构应该是:
@guan ~/P/p/nvim-config$ tree find-file.nvim/
find-file.nvim/
└── lua
└── find-file
└── init.lua
find-file/init.lua:
local M = {}
function M.find_file_from_here()
print("hello world")
end
return M
然后在 nvim-config/init.lua 中使用 lazy.nvim 加载你的插件。同时创建 FindFile
命令。
-- Setup lazy.nvim
require("lazy").setup({
spec = {
-- import your plugins
-- { import = "plugins" },
-- 加载插件配置
{
dir = "~/Public/project/nvim-config/find-file.nvim",
config = function()
vim.api.nvim_create_user_command(
"FindFile",
'lua require("find-file").find_file_from_here()',
{ bang = true }
)
end,
},
},
-- Configure any other settings here. See the documentation for more details.
-- colorscheme that will be used when installing plugins.
install = { colorscheme = { "habamax" } },
-- automatically check for plugin updates
checker = { enabled = true },
})
退出 nvim,通过 nvim -u init.lua
重新打开 nvim,执行 :FindFile
,就能看到左下角的 “hello world”。
到此为止,我们已经完成了实现一款插件的前期准备工作,剩下的只不过是填充业务逻辑。
Rg与Telescope.nvim
在 linux 上搜索文件可供使用的命令很多,如原生的 find、grep;热门的 fzf、fd。这里我选择 ripgrep。NvChad 提供的文件搜索就是基于 ripgrep 实现,我选择 rg 也是为了方便抄代码。请记住,模仿是最快的学习方法。
直接上命令公式:
rg --files --color never -- [prompt] [path-to-directory]
rg 教程很多,不再累述。我们仅需要知道,如果我们想搜索 a 目录,那么 [path-to-directory] = ./a;如果想搜索 a/c 目录,[path-to-directory] = ./a/c。[prompt] 保持为空字符串,我们后面会用到。举个例:
rg --files --color never -- '' "./a/b"
有了搜索结果,还需要在 nvim 上展示出来,我是用 telescope.nvim。如仓库介绍,集 Find、Filter、Preview、Picker 为一身。我见过有大佬嫌 telescope 太慢,用 vim 自己的 Quickfix 展示结果。还有一个酷炫的 quickfix window 项目:nvim-bqf。总之等你熟练写插件以后,可以做自己的主,而当下,请先用 telescope.nvim。
lazy.nvim 可以帮我们管理插件依赖:
{
dir = "~/Public/project/nvim-config/find-file.nvim",
dependencies = { "nvim-telescope/telescope.nvim" }, -- add this line
}
保存配置再重新打开 nvim,插件会自动安装好。
实现业务逻辑
说回 find-file/init.lua 文件。在 lua 中,将一个模块里的函数供给其他模块调用,需要把函数 return 出去。所以每个 lua 文件都应该有这样的样板代码:
local M = {}
-- 定义函数
function M.your_function()
end
-- 导出函数
return M
我们先实现一个 hello world 函数:
local M = {}
function M.hello_world()
print("hello world")
end
return M
在插件管理器中绑定命令:
{
dir = "~/Public/project/nvim-config/find-file.nvim",
dependencies = { "nvim-telescope/telescope.nvim" },
config = function()
-- binding command
vim.api.nvim_create_user_command(
"HelloWorld",
'lua require("find-file").hello_world()',
{ bang = true }
)
end,
}
重新启动 nvim,执行 :HelloWorld
就能看到左下角的 “hello world”。
先定义 M.find_file_from_here()
函数。接下来就是如何展示搜索结果,以下这段代码也是从 NvChad 中摘出来的:
local pickers = require("telescope.pickers")
local finders = require("telescope.finders")
local make_entry = require("telescope.make_entry")
local sorters = require("telescope.sorters")
local conf = require("telescope.config").values
function M.find_file_from_here()
-- live_grepper 搜索回调函数
local live_grepper = finders.new_job(function(prompt)
local rg_command = ...
return rg_command
end, make_entry.gen_from_file())
-- 使用 picker 显示搜索结果
local opt = {}
pickers
.new(opt, {
prompt_title = "Find File From Here",
__locations_input = true,
finder = live_grepper,
previewer = conf.grep_previewer(opt),
sorter = sorters.get_fuzzy_file(opt),
default_text = ..., -- 首次搜索内容
})
:find()
end
逻辑很简单,只是需要了解 telescope api 的用法。我们这次关心的只有两个参数,其他照着抄下来就好。一个是搜索时的回调参数 finder,另一个是首次唤起 picker 时,使用的搜索参数 default_text。
我们想要 emacs 的效果,首次唤起 picker 展示当前目录下的文件,default_text 的入参就得是当前目录的路径:
local utils = require("find-file.utils")
function M.find_file_from_here()
local files_from_here = utils.get_cur_buf_dir()
local opt = {}
pickers
.new(opt, {
-- ignore others
default_text = files_from_here,
})
:find()
end
find-file/utils.lua 存放了公共函数,已经上传到 github 。今后你攒 nvim 配置途中也会收集许多有用函数,当下可以先从我的配置抄起来。
finder 入参对应 live_grepper,我们先写个最简版。
local live_grepper = finders.new_job(function(prompt)
prompt = vim.fn.trim(prompt)
local dir, prompt = prompt, ""
return utils.flatten({ { "rg", "--files", "--color", "never" }, "--", prompt, dir })
end, make_entry.gen_from_file())
telescope 中,输入框的内容通过 prompt 回传。我们根据前面提到的 rg 公式构建命令:rg --files --color never -- [prompt] [path-to-directory]
。find-file 是希望展示某目录下的所有文件,回传的 prompt 对应的是一个路径,跟我们命令中的 [prompt] 语义不同,跟 [path-to-directory] 语义相同,因此要把 prompt 的值赋给 dir,同时给 prompt 赋一个空字符。此时执行 :FindFile
,就能看到当前目录下的文件了。
现在的 find-file 有一个小 bug,输入框中是 “find-file.nvim/lua” 时,会展示 lua 目录下的文件;但如果输入框中是 “find-file.nvim/lu”,什么也不展示。此时对应的命令为:
rg --files --color never -- '' 'find-file.nvim/lu'
很明显,我们没有 find-file.nvim/lu 目录,什么都不展示理所当然,可用起来很不爽。解决的办法是,如果发现回传的 prompt 不是一个目录,就取 “/” 之前的内容作为目录([path-to-directory]),“/” 之后的内容作为 [prompt]。写成代码就是下面这样:
local live_grepper = finders.new_job(function(prompt)
prompt = vim.fn.trim(prompt)
local dir = nil
dir, prompt = prompt, ""
local is_dir = vim.fn.isdirectory(dir)
if is_dir == 0 then
local chunks = utils.split(dir, "/")
prompt = chunks[vim.fn.len(chunks)]
chunks[vim.fn.len(chunks)] = ""
dir = utils.join(chunks, "/")
end
return utils.flatten({ { "rg", "--files", "--color", "never" }, "--", prompt, dir })
end, make_entry.gen_from_file())
如果输入框是 “find-file.nvim/lu”,生成的命令就会是:
rg --files --color never -- 'lu' 'find-file.nvim'
我们使用了 telescope 的模糊搜索能力,因此 find-file.nvim/readme.md 也会作为搜索结果出现。个人觉得影响不大,不用修复这个问题。
优化体验
现在的 find-file 会展示指定目录下的所有文件(递归展示),遇到文件很多的目录,搜索结果会很多,使用体验变差。最好是增加二级搜索的能力──这个 idea 也是我从 emacs 周边学来的(笑)。
即,搜索框支持:[path-to-directory] [prompt]
。例如,搜索框中的内容为 “find-file.nvim init”,生成对应的 rg 命令:
rg --files --color never -- 'init' 'find-file.nvim'
实现起来也简单,检查回传的 prompt 中没有空格,有就 split 一下。具体代码如下:
local live_grepper = finders.new_job(function(prompt)
prompt = vim.fn.trim(prompt)
local dir = nil
-- 检查回传 prompt 中有没有空格
if string.match(prompt, "%s+") ~= nil then
local chunks = utils.splitn(prompt, " ", 2)
if vim.fn.len(chunks) >= 2 then
dir, prompt = vim.fn.trim(chunks[1]), vim.fn.trim(chunks[2])
else
dir, prompt = prompt, ""
end
else
dir, prompt = prompt, ""
end
local is_dir = vim.fn.isdirectory(dir)
if is_dir == 0 then
local chunks = utils.split(dir, "/")
prompt = chunks[vim.fn.len(chunks)]
chunks[vim.fn.len(chunks)] = ""
dir = utils.join(chunks, "/")
end
return utils.flatten({ { "rg", "--files", "--color", "never" }, "--", prompt, dir })
end, make_entry.gen_from_file())
创建快捷键
每次敲 :FindFile
挺麻烦的,我对这种常用功能更偏爱绑快捷键。
{
dir = "~/Public/project/nvim-config/find-file.nvim",
dependencies = { "nvim-telescope/telescope.nvim" },
config = function()
vim.api.nvim_create_user_command(
"FindFile",
'lua require("find-file").find_file_from_here()',
{ bang = true }
)
-- 绑定快捷键
vim.keymap.set("n", "<leader><space>", require("find-file").find_file_from_here)
end,
}
现在连敲两次空格就能弹出搜索窗了。
最后
插件写好以后,希望分享给别人使用,当然是上传 github 啦!这篇文章的完整代码你可以在 find-file.nvim 看到。在 lazy.nvim 使用 github 上的插件,只需要把 “dir = ...” 删掉,使用仓库路径即可。
{
"youguanxinqing/find-file.nvim", -- github 仓库路径
dependencies = { "nvim-telescope/telescope.nvim" },
config = function()
-- binding command `:FindFile`
vim.api.nvim_create_user_command(
"FindFile",
'lua require("find-file").find_file_from_here()',
{ bang = true }
)
-- binding shortcut
vim.keymap.set("n", "<leader><space>", require("find-file").find_file_from_here)
end,
}
在 nvim 中要怎么 debug 呢?使用万能的 print 函数就好。遇到 table 类型时,先 vim.inspect 一下。
print(vim.inspect(one_table))
感谢
- 参考 Writing Your First Telescope Extension (如果你对 telescope 的使用感到迷糊,可以先看这篇文章)
- 参考 NvChad (我从这个项目里抄了好多代码 !^_^)
还不快抢沙发