一、为什么要自己写一个 Neovim 插件?
Neovim 的插件生态里,Lua 已经是事实标准——从 lazy.nvim 到 Telescope,几乎所有现代插件都用 Lua 写。但"会用插件"和"会写插件"之间,隔着两个心魔:
API 恐惧症:vim.api.nvim_create_user_command、vim.fn.readfile……看着就头大。
没法测试:插件依赖 Neovim 运行时,怎么在 CI 里、在没装 Neovim 的机器上验证逻辑对不对?
这篇文章的核心思路就是一招破两关:把插件拆成两层——
-
核心逻辑层(纯函数):只处理数据,不碰任何 Neovim API。输入"行数组",输出"命中项列表"。这一层在普通 Lua 里就能跑、就能测。
-
Neovim 集成层(薄壳):负责调用 vim.fn.readfile 读文件、vim.api 注册命令、填 quickfix。这一层才依赖 Neovim。
这么一拆,90% 的逻辑都变成了可独立测试的纯函数,Neovim 那层只剩薄薄几行胶水。

二、插件长什么样:目录结构
一个最小可用的 Lua 插件,目录结构是这样的(本文产物已附在文章目录 plugin/ 下):
todo-lens/
├── lua/
│ └── todolens/
│ └── init.lua # 核心模块(纯逻辑 + 集成层)
└── test/
└── selftest.lua # 脱离 Neovim 的单元测试
Neovim 的 runtime 会自动把 lua/ 目录加入 package.path,所以插件里 require('todolens') 就能找到 lua/todolens/init.lua。
三、核心逻辑:扫描 TODO
3.1 先踩一个 Lua 模式的坑
我一开始很自然地想这么写正则:
local pattern = '%f[%w](TODO|FIXME|HACK|NOTE|OPTIMIZE)%f[%W]' — ❌ 错误!
在 PCRE/JavaScript 里 | 是"或",但 Lua 的模式(pattern)根本不支持 | 交替!这行代码里的 | 会被当成字面量竖线字符去匹配,结果一个标签都匹配不到。我在本机实测——string.find 全部返回 nil。
正确做法:先匹配"一个全大写的词",再用白名单判断它是不是目标标签。
M.tags = { TODO = true, FIXME = true, HACK = true, NOTE = true, OPTIMIZE = true }
M.pattern = '%f[%u](%u+)%f[%W]' — 词边界 + 全大写词
%f[%u] 是 Lua 的 frontier(边界)模式,匹配"从非大写字母过渡到大写字目"的位置,这样能避免把单词 tomorrow 里的 TODO 子串误报成标签。
3.2 纯函数:scan_lines
这一层完全不碰 vim,输入输出都是普通 Lua 数据:
function M.scan_lines(filename, lines)
local results = {}
for i, line in ipairs(lines) do
local s, e, word = string.find(line, M.pattern)
if s and M.tags[word] then
table.insert(results, {
filename = filename, lnum = i, col = s,
tag = word,
text = line:gsub('^%s+', ''):sub(1, 80),
})
end
end
return results
end
再配一个聚合计数的小工具函数:
function M.count_by_tag(results)
local counts = {}
for _, r in ipairs(results) do
counts[r.tag] = (counts[r.tag] or 0) + 1
end
return counts
end
四、Neovim 集成层:薄壳胶水
集成层只做三件事:注册命令、读文件、填 quickfix。关键技巧是把"读文件"做成可注入——生产环境用 vim.fn.readfile,测试环境注入一个假函数。
function M.setup(opts)
opts = opts or {}
M.config = { signs = opts.signs or { TODO = 'TODO', FIXME = 'FIXME' } }
— 仅当真的跑在 Neovim 里才注册命令
if type(vim) == 'table' and vim.api then
vim.api.nvim_create_user_command('TodoLens', function() M.run() end,
{ desc = 'Scan project for TODO/FIXME and fill quickfix' })
end
end
function M.scan_file(filename, readfile)
local read = readfile or (type(vim) == 'table' and vim.fn and vim.fn.readfile)
if not read then return {} end
local ok, lines = pcall(read, filename)
if not ok or type(lines) ~= 'table' then return {} end
return M.scan_lines(filename, lines)
end
注意 type(vim) == 'table' 这个判断:纯 Lua 环境里 vim 全局不存在,setup 就会跳过命令注册而不报错——这正是"可脱离 Neovim 测试"的关键设计。
五、脱离 Neovim 单元测试(本文的硬核点)
这是很多教程跳过、但最实用的部分。因为核心逻辑是纯函数,我们根本不需要装 Neovim。

测试脚本做了 6 组共 13 个断言:四类标签识别、行号正确性、tomorrow 不误报、按标签聚合计数、空表边界、注入假 readfile、无 Neovim 环境 setup 不崩。
本机真实运行输出
我在本机用 lua selftest.lua(Lua 5.3.6)真实运行,全部通过:
PASS 识别到 4 类标签
PASS 第1条是 TODO
PASS 行号正确(第2行)
PASS FIXME 在第3行
PASS "tomorrow" 不误报 TODO
PASS TODO 计数=1
PASS FIXME 计数=1
PASS HACK 计数=1
PASS NOTE 计数=1
PASS 空表返回空
PASS 注入 readfile 扫描出 1 项
PASS 不存在文件返回空
PASS 无 Neovim 环境 setup 不崩
== 结果: 13 通过, 0 失败 ==
exit code: 0
再跑一个"扫描示例项目"的真实演示:
=== TodoLens 扫描结果 (示例项目) ===
net.lua:2 [TODO] TODO: add retry on timeout
net.lua:4 [FIXME] FIXME: silently swallows error
net.lua:6 [HACK] — HACK: workaround for neovim 0.9
net.lua:7 [NOTE] NOTE: keep hot path allocation-free
=== 按标签统计 ===
NOTE: 1 TODO: 1 FIXME: 1 HACK: 1
验证命令本身也很简单,记下来:
# 语法检查(不执行)
lua -e "assert(loadfile('lua/todolens/init.lua')); print('syntax OK')"
# 跑单元测试
lua test/selftest.lua
六、在真正的 Neovim 里跑起来
把插件放到 runtimepath 后(比如用 lazy.nvim 安装本地路径),在 init.lua 里:
require('todolens').setup({})
然后命令行敲 :TodoLens,插件就会扫描并把所有 TODO/FIXME 填进 quickfix,用 :copen 打开就能像错误列表一样逐条跳转。在真实 Neovim 环境里,run() 函数会遍历项目文件、调用 vim.fn.readfile 读内容、再把 scan_lines 的结果转成 quickfix 条目格式({filename, lnum, col, text})——这部分就是把纯函数的输出接到 vim.fn.setqflist 上,是纯粹的胶水。
七、这个插件思路能扩展到哪?
把"扫描某种模式并填 quickfix"这个骨架抽出来,就是一大类插件的通用模板:
|
TodoLens(本文) |
TODO/FIXME 注释 |
|
日志查看器 |
错误日志里的 ERROR/WARN |
|
诊断聚合 |
LSP 诊断 + 自定义正则 |
|
搜索结果跳转 |
grep 输出解析 |
关键都是同一个原则:纯逻辑可测,API 调用集中在薄壳。这样写出来的插件,既能在没装 Neovim 的 CI 上跑测试,又能随时接入新功能——这就是专业插件和"一次性脚本"的区别。
八、总结
-
Lua 插件 ≠ 全是 API:把纯逻辑和 Neovim 集成层分开,核心 90% 变成可独立测试的普通 Lua。
-
Lua 模式不支持 |:多标签匹配用"全大写词 + 白名单"实现,别照搬 PCRE。
-
可注入依赖:readfile 做成参数注入,测试时塞假函数,生产时用 vim.fn.readfile。
-
脱离 Neovim 也能测:type(vim)=='table' 守卫让 setup 在纯 Lua 环境安全跳过命令注册。
-
代码真实跑过:本文 13 项测试全部通过,不是"理论上可行"。
写插件的门槛,其实不在 API 有多复杂,而在于你愿不愿意把"可测试性"当成设计的第一原则。把这一步做对了,剩下的就是体力活。
参考资料
Neovim 官方文档:Writing Lua plugins(plugin structure / require). https://neovim.io/doc/user/lua/
Neovim 官方文档:nvim_create_user_command / vim.fn.readfile / setqflist. https://neovim.io/doc/user/api/
Lua 5.3 参考手册:Patterns 与 frontier pattern %f(明确说明无 | 交替). https://www.lua.org/manual/5.3/manual.html#6.4.1
lazy.nvim 插件管理器文档(本地插件路径安装). GitHub – folke/lazy.nvim: 💤 A modern plugin manager for Neovim · GitHub
本文完整代码:见文章目录 plugin/lua/todolens/init.lua 与 plugin/test/selftest.lua(本机 Lua 5.3.6 实测 13 测试通过)




