ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从零写一个 Neovim 插件:用 Lua 30 行实现“TODO 项目扫描器“,并教你怎么脱离 Neovim 单元测试

从零写一个 Neovim 插件:用 Lua 30 行实现“TODO 项目扫描器“,并教你怎么脱离 Neovim 单元测试 一、为什么要自己写一个 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。三、核心逻辑扫描 TODO3.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.luaLua 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 有多复杂而在于你愿不愿意把可测试性当成设计的第一原则。把这一步做对了剩下的就是体力活。参考资料Neovim 官方文档Writing Lua pluginsplugin 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.1lazy.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 测试通过
返回列表