欢迎光临
我们一直在努力

从零写一个 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。


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


    参考资料

  • 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 测试通过)

  • 赞(0)
    未经允许不得转载:171主机测评 » 从零写一个 Neovim 插件:用 Lua 30 行实现“TODO 项目扫描器“,并教你怎么脱离 Neovim 单元测试
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址