从零写一个 Neovim 插件:用 Lua 30 行实现“TODO 项目扫描器“,并教你怎么脱离 Neovim 单元测试
2026/9/18 5:21:32 网站建设 项目流程

一、为什么要自己写一个 Neovim 插件?

Neovim 的插件生态里,Lua 已经是事实标准——从 lazy.nvim 到 Telescope,几乎所有现代插件都用 Lua 写。但"会用插件"和"会写插件"之间,隔着两个心魔:

  1. API 恐惧症vim.api.nvim_create_user_commandvim.fn.readfile……看着就头大。

  2. 没法测试:插件依赖 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"这个骨架抽出来,就是一大类插件的通用模板:

扩展方向

把 scan_lines 换成扫什么

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 有多复杂,而在于你愿不愿意把"可测试性"当成设计的第一原则。把这一步做对了,剩下的就是体力活。


参考资料

  1. Neovim 官方文档:Writing Lua plugins(plugin structure / require). https://neovim.io/doc/user/lua/

  2. Neovim 官方文档:nvim_create_user_command / vim.fn.readfile / setqflist. https://neovim.io/doc/user/api/

  3. Lua 5.3 参考手册:Patterns 与 frontier pattern%f(明确说明无|交替). https://www.lua.org/manual/5.3/manual.html#6.4.1

  4. lazy.nvim 插件管理器文档(本地插件路径安装). GitHub - folke/lazy.nvim: 💤 A modern plugin manager for Neovim · GitHub

  5. 本文完整代码:见文章目录plugin/lua/todolens/init.luaplugin/test/selftest.lua(本机 Lua 5.3.6 实测 13 测试通过)

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询