job.nvim 发布了 1.5 版本,和往常一样,我在 Reddit 上发布了版本更新文章,也很感谢有不少正面的反馈。
但让我无法理解的是,有些人似乎很难沟通,他们一再强调 Neovim 有内置的 jobstart() 和 vim.system(),为什么还要创建一个新的插件?即使解释了原因,他们仍然无法理解。我想这可能是我说得不够清楚,因此在这里整理一下前因后果。
说起 Job 函数,要从最早期的 Neovim 版本说起。Neovim 增加了一个 jobstart() Vim Script 函数,那时候 Vim 还没有 Job 功能,后来 Vim 才增加了 job_start() 函数,但其调用方式与 Neovim 并不一致。早期的 Neovim 主要还是以使用 VimL 为主。
于是我给 SpaceVim 添加了一个 Job API,以兼容早期 Neovim 的 jobstart() 和 Vim 的 job_start() 函数。
commit 44ad1cb4fe6a8d9ccae49b71994e6182bbcaa968
Author: wsdjeg <[email protected]>
Date: Fri Mar 31 21:09:38 2017 +0800
Add job api for vim and neovim
这样,就可以使用相同的函数同时兼容 Vim 和 Neovim。
let s:JOB = SpaceVim#api#import('job')
let s:command = ['echo', 'hello world']
function! s:stdout(id, data, event)
" data 是一个字符串列表
for line in a:data
echo line
endfor
endfunction
call s:JOB.start(s:command, {
\ 'on_stdout' : function('s:stdout'),
\ }
\ )
在这个过程中,前后还遇到过很多兼容性问题。Neovim 的 jobstart() 函数的 stdout callback 在数据过大时会被截断,具体的 buffer size 我记不清了,有个 issue 讨论过这个问题。于是,我在这个 API 中增加了 data_eol 检测,以确保 callback 函数被调用时传入的是完整的数据。当然,后来的 Neovim 官方文档里也写了如何处理这种数据被截断的情况,详见 :h channel_buffered。
随着 Neovim 对 Lua 的支持越来越多,我后来使用 Lua 重写了 Job API,但仍然将其内置在 SpaceVim 里。重写之后,调用就可以直接使用 Lua 了。
commit 879129388ab22b64c5a5cf0df83799084cab96fc
Author: Eric Wong <[email protected]>
Date: Wed Jul 5 22:58:01 2023 +0800
feat(api): add lua job api
close https://github.com/neovim/neovim/issues/20856
调用方式变成了 Lua:
local job = require('spacevim.api.job')
local jobid = job.start(vim.g.test_ctags_cmd, {
on_stdout = function(id, data, event)
vim.print(id)
vim.print(data)
vim.print(event)
end,
on_stderr = function(id, data, event)
vim.print(id)
vim.print(data)
vim.print(event)
end,
on_exit = function(id, code, signal)
vim.print(id)
vim.print('exit code', code)
vim.print('exit signal', signal)
end,
})
随着 SpaceVim 项目停止维护,我把我常用的功能插件独立成了各个单独的 Neovim 插件,其中就包括了 job.nvim。
我自己写的很多需要异步执行命令的 Neovim 插件都依赖这个 job.nvim,这样就不需要在每个插件仓库里单独维护执行外部命令的模块了。使用起来也比原来的 SpaceVim 内置 Lua Job API 更简洁一些:
local job = require('job')
local function on_exit(id, code, signal)
print('job ' .. id .. ' exit code:' .. code .. ' signal:' .. signal)
end
local cmd = { 'echo', 'hello world' }
local jobid1 = job.start(cmd, {
on_stdout = function(id, data)
vim.print(data)
end,
on_exit = on_exit,
})
vim.print(string.format('jobid is %s', jobid1))
local jobid = job.start({ 'cat' }, {
on_stdout = function(id, data)
vim.print(data)
end,
on_exit = function(id, code, signal)
print('job ' .. id .. ' exit code:' .. code .. ' signal:' .. signal)
end,
})
job.send(jobid, { 'hello' })
job.chanclose(jobid, 'stdin')
我不太记得 vim.system 是什么时候加入到 Neovim 的了,其前后应该也功能迭代过几个版本。为什么我还在继续维护 job.nvim 而不切换到 vim.system 呢?
没有对 stdout 数据进行拼接处理,容易有截断数据,而且传给 callback 函数的数据是 string 而非像 jobstart 那样是 string 列表。当然,数据类型都是次要的事情,使用 split 函数很容易得到列表,但是未做数据拼接这点,在写 callback 函数时会增加很多额外的代码量。
callback 函数内无法确认到底是哪个 job 调用触发的这个 callback 函数,应该像 jobstart() 的 stdout callback 函数那样,传入一个 jobid 参数。
我创建 job.nvim 的主要原因是:
在旧的 Neovim 版本中,没有 vim.system。第一个版本是 job.vim,它使用 VimL,并以与 Neovim 的 jobstart 相同的 API 支持 Neovim 和 Vim。
我需要为不同的 job 的 stdout 使用相同的 callback 函数。例如,在我的插件管理器 https://github.com/wsdjeg/nvim-plug 中,当同时克隆 8 个插件时,我需要在 job 退出前显示每个 job 的进度。因此在 stdout callback 函数中,我需要知道是哪个 job 触发了这个 callback 函数。据我所知,即使现在的 vim.system 的 stdout callback 也不支持这个功能。
两种写法,哪种更简单方便,一目了然:
使用 vim.system:
local function on_stdout(err, data)
--- 首先,这里需要对 data 判断数据的完整性,然后参考以下鬼方法来拼接:
-- There are two ways to deal with this:
-- - 1. To wait for the entire output, use |channel-buffered| mode.
-- - 2. To read line-by-line, use the following code: >vim
-- let s:lines = ['']
-- func! s:on_event(job_id, data, event) dict
-- let eof = (a:data == [''])
-- " Complete the previous line.
-- let s:lines[-1] .= a:data[0]
-- " Append (last item may be a partial line, until EOF).
-- call extend(s:lines, a:data[1:])
-- endf
-- 然后,拼接完成后,再执行逐行提取
for _, line in ipairs(data) do
local progress = string.match(line, '%d*%%')
-- 然后在这个地方,你就会发现,无法判断这到底是哪个 Job 触发的 callback 函数了。
end
end
-- clone plugin A
vim.system({ 'git', 'clone', url_a }, { stdout = on_stdout })
-- clone plugin B
vim.system({ 'git', 'clone', url_b }, { stdout = on_stdout })
使用 job.nvim:
local job = require('job')
local jobs = {}
local function on_stdout(id, data)
for _, line in ipairs(data) do
print(
string.format(
'repo %s clone progress %s',
jobs[id],
string.match(line, '%d*%%')
)
)
end
end
-- clone plugin A
local id1 = job.start({ 'git', 'clone', url_a }, {
on_stdout = on_stdout,
})
jobs[id1] = 'A'
-- clone plugin B
local id2 = job.start({ 'git', 'clone', url_b }, {
on_stdout = on_stdout,
})
jobs[id2] = 'B'
也许,job.nvim 在许多年后会停止维护,那一定是我找到了更合适的内置替代方案。至少目前,vim.system 的实现还没有完全满足我的使用需求。
最后,我终于理解 avante.nvim 的作者为什么删掉 Reddit 账号了。吵架真的很烦。 https://www.reddit.com/r/neovim/comments/1rdgfxg/comment/o7bzfzc/
vim.system 的设计本身就有问题。我自己写了将近二三十个异步调用命令的插件,难道我遇到的问题还不够多吗?有些人一再给我强调可以在 exit_cb 里面区分 job,难道调用常驻命令时,要等它们执行完毕才能看到结果吗?
那就让我们看看以后的版本 vim.system 会不会增加这样的参数传入,或者会不会有类似的新的内置函数出现吧。
大约一年前,我开始关注 AI 相关的内容。从一开始的简单代码补全,到后来的文本对话,再到工具调用,新技术层出不穷,确实令人兴奋。 但最近我发现,有些人刻意制造新概念,试图将它们包装成新技术来吸引流量,这让我感到十分反感。
于是,我决定从零开始学习 AI 相关知识,并着手开发 AI 相关的 Neovim 插件。 整个过程踩了不少坑,但也逐渐理解了用户与 AI 之间的沟通逻辑。 因此,我想把这些内容整理成文字,帮助大家更好地理解用户与 AI 沟通时的底层逻辑, 同时也便于区分什么是真正的新技术,什么是人为包装的伪概念。
那就从简单的 DeepSeek 官网开发者文档 开始吧。
与大模型之间的沟通,通常是通过 API 请求完成的。比如向 DeepSeek 发送一句 Hello!,实际上执行的命令是:
curl https://api.deepseek.com/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${DEEPSEEK_API_KEY}" \
-d '{
"model": "deepseek-chat",
"messages": [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hello!"}
],
"stream": false
}'
得到的回复大致如下:
{
"id": "930c60df-bf64-41c9-a88e-3ec75f81e00e",
"choices": [
{
"finish_reason": "stop",
"index": 0,
"message": {
"content": "Hello! How can I help you today?",
"role": "assistant"
}
}
],
"created": 1705651092,
"model": "deepseek-chat",
"object": "chat.completion",
"usage": {
"completion_tokens": 10,
"prompt_tokens": 16,
"total_tokens": 26
}
}
为了方便展示,我移除了一些额外内容,只保留核心部分。实际上发送给服务器的消息数据是:
[
{ "role": "system", "content": "You are a helpful assistant." },
{ "role": "user", "content": "Hello!" }
]
而得到的回复也是一个单独的消息:
{
"role": "assistant",
"content": "Hello! How can I help you today?"
}
此时,如果我们想要保留会话历史并继续发送内容 What's the weather in SF?,就需要将服务器返回的消息追加到消息列表中,然后添加新的用户消息。于是,我们发送给服务器的消息变成这样:
[
{ "role": "system", "content": "You are a helpful assistant." },
{ "role": "user", "content": "Hello!" },
{ "role": "assistant", "content": "Hello! How can I help you today?" },
{ "role": "user", "content": "What's the weather in SF?" }
]
服务器除了返回文本内容外,有时还会返回 tool_call。这意味着返回的 JSON 中包含要调用的函数名称和参数,你收到的消息大致如下:
{
"id": "call_123abc",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"location\": \"San Francisco\", \"unit\": \"celsius\"}"
}
}
然后,你的助手软件实际上会做的是:根据这个 tool_call 内容调用对应的本地功能函数,
执行完成后,将结果连同上面的 tool_call 一起再次“自动”发送给服务器。
于是,发送给服务器的消息大致如下:
[
{ "role": "system", "content": "You are a helpful assistant." },
{ "role": "user", "content": "Hello!" },
{ "role": "assistant", "content": "Hello! How can I help you today?" },
{ "role": "user", "content": "What's the weather in SF?" },
{
"role": "assistant",
"tool_calls": [
{
"id": "call_123",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"location\": \"San Francisco\"}"
}
}
]
},
{
"role": "tool",
"tool_call_id": "call_123",
"content": "{\"temperature\": 18, \"conditions\": \"Sunny\", \"humidity\": 65}"
}
]
服务器给你返回的内容如果任然是 tool_call 那么就可以继续调用本地工具执行完成后返回给服务器。 这样的“自动”请求步骤循环下去,就形成了一个自动化的智能助手假象。
直到服务器返回的内容不再是 tool_call 而是 content,就可以将内容前台展示给用户了:
{
"role": "assistant",
"content": "The weather in San Francisco is currently 18°C and sunny with 65% humidity."
}
以上内容就是一个最基本的智能助手低层网络请求的逻辑。
从上面的请求消息内容可以看到,消息的角色(role)目前有四种:system、user、assistant、tool。
各角色详解:
user:这是用户输入的内容,对应聊天界面中输入框输入的信息。
assistant:服务器返回的内容,有两种形式:
content:文本消息内容,直接显示在聊天界面tool_call:函数调用指令,告诉客户端需要执行什么功能tool:当客户端收到 tool_call 后,执行相应功能并将结果以 tool 角色发送给服务器。
system:(这里需要重点澄清)
很多应用没有提供设置 system 角色的界面,但这个角色不是简单的“底层内容”,而是对话的“基础指令集”。它的主要作用是:
一个标准的 system 消息示例:
{
"role": "system",
"content": "你是一个专业的软件开发助手。回答问题时请提供代码示例,并解释关键概念。如果用户的问题涉及不道德或违法内容,请礼貌拒绝。"
}
我开头所说的伪新概念,指的是目前在社区中流传的各种”配置文件”,比如:
AGENTS.md
CLAUDE.md
.claude/ 目录下SOUL.md(灵魂文件)
system 指令system 消息换个文件名就成了”灵魂”SKILLS.md(技能文件)
tool_call)的文档化列表| 文件类型 | 实际价值 | 创新程度 |
|---|---|---|
| AGENTS.md | 高 | 中等(提供了标准化格式) |
| CLAUDE.md | 中 | 低(平台特定配置) |
| SOUL.md | 低 | 极低(换名不换药) |
| SKILLS.md | 低 | 极低(文档化工具列表) |
system 消息或工具定义来实现system 和 tool_call 机制才是关键# 推荐的配置方式
## 1. 核心配置(必需)
- `system` 消息:定义助手基本行为
- 工具函数:实际可用的功能
## 2. 项目配置(推荐)
- AGENTS.md:项目级AI辅助配置
## 3. 避免过度设计
- 不需要 SOUL.md、SKILLS.md 等冗余文件
- 保持配置简洁,避免维护负担
真正的技术创新应该解决实际问题,而不是发明新名词。理解了消息传递的底层机制,就能一眼看穿哪些是真正有用的工具,哪些只是华丽的包装。
很长一段时间,AI 一直是一个非常热门的话题。衍生出来的工具也非常的多,从我接触的顺序来看, 从最早的 tabline 补全到后来的 GitHub Copilot 补全。再到后来的 ChatGPT 以及之后来的各自类似的 Chat 工具。
前面我使用最多的还是网页版的 ChatGPT,使用过程中最大的一个问题就是请求结果渲染展示一直是有问题的。 尤其是让他展示 markdown 源码时。比如:

上述图片 Usage 实际上也在代码块里面是,但是由于 markdown 内还有代码块,导致解析展示出问题。
正是由于这个原因,我制作了 Neovim AI 聊天插件 chat.nvim,
我需要以纯文本展示请求结果的完整内容。
使用任意插件管理器,比如 nvim-plug:
local deepseek_api_key = 'xxxxxxxxxxx'
local free_chatgpt_api_key = 'xxxxxxxxxxxxxxx'
require('plug').add({
{
'wsdjeg/chat.nvim',
opt = {
api_key = {
deepseek = deepseek_api_key,
free_chatgpt = free_chatgpt_api_key,
},
provider = 'free_chatgpt',
model = 'gpt-4o-mini',
border = 'double',
},
},
})
chat.nvim 默认是上下分屏两个浮窗,分别为输入窗口和结果展示窗口。如图:

chat.nvim 自带了一些 picker.nvim 插件的拓展,目前支持的拓展有:
:Picker chat - 搜索历史对话

:Picker chat_provider - 搜索并切换 provider

:Picker chat_model - 搜索并切换当前 provider 提供的模型

Bdelete/Bwipeout在使用 Neovim 的过程中,「删除 buffer 但不破坏窗口布局」一直是一个高频需求。
社区里已经有不少相关插件,比如 bufdelete.nvim、nvim-bufdel、mini.bufremove,以及 snacks.bufdelete。
但是在我自己长期使用过程中,总觉得缺少了我需要的功能。
于是,我写了一个新的插件:bufdel.nvim。
这篇文章简单聊聊它解决了什么问题、有哪些设计取舍,以及它和现有方案的区别。
先说结论:bufdel.nvim 设计初衷是为了删除 buffer 这个操作的每一步更加可控。
我写这个插件,主要有几个原因:
bufdelete.nvim 已经 archived
bufdelete.nvim 是一个非常优秀的插件,我也之前也使用过一段时间。但它目前已经被标记为 archived,不再维护。
我希望有一个持续维护、可扩展的替代方案,同时保留它最核心、最优雅的设计。
我需要更灵活的 buffer 选择方式
很多插件只支持按照 buffer number 删除,但是再实际使用中,我经常需要:
删除之后,切换到哪个 buffer,应该是可控的
前面提到的几个插件大多数在删除 buffer 后,选择切换到 bufnr('#')。但我更希望能明确指定下一个 buffer 是哪个或者通过函数,完全自定义切换逻辑。
一个核心 API:delete(buffers, opt)
bufdel.nvim 只暴露一个核心函数,但是它支持的参数非常灵活。不少其他的插件会设计两个 API 函数 delete 和 wipeout,其实完全可以合并,通过 opt 内一个选项区分。
require('bufdel').delete(buffers, opt)
buffers 参数:你想怎么选 buffer 都行
buffers 支持多种形式:
比如,删除所有已列出、已保存的非当前 buffer:
require('bufdel').delete(function(buf)
return not vim.bo[buf].modified
and vim.bo[buf].buflisted
and buf ~= vim.api.nvim_get_current_buf()
end, { wipe = true })
这类逻辑,在很多其他插件里是做不到的。
正则匹配 buffer 名称
如果你想清理一类文件,比如所有 .txt buffer:
require('bufdel').delete('.txt$', { wipe = true })
这在日常清理临时文件、日志文件时非常方便。
这是 bufdel.nvim 的一个重点特性。
使用函数自定义切换逻辑(推荐)
require('bufdel').delete(filter, {
wipe = true,
switch = function(deleted_buf)
return vim.fn.bufnr('#') -- 切换到 alternate buffer
end,
})
你可以在这里实现任何策略,只要返回一个有效的 buffer number。
内置几种常用策略
如果不想写函数,也可以直接用字符串:
switch = 'alt'
当前支持:
alt:alternate buffer(#)current:保持当前 bufferlastused:最近使用的 buffernext / prev:下一个 / 上一个 buffer直接指定 buffer number
switch = 3
Bdelete/Bwipeoutbufdel.nvim 提供了两个命令:
:Bdelete
:Bwipeout
行为和 :bdelete / :bwipeout 一致,但不会改变窗口布局。
示例:
:Bdelete
:Bdelete 3
:Bdelete 2 5 7
:3,6Bdelete
和原生 :bdelete 一样:
纯数字的 buffer 名称不能作为用户命令参数使用。
比如:
:e 123
:Bdelete 123
这时必须使用 bufnr,而不是 bufname。
bufdel.nvim 会在删除 buffer 前后触发两个事件:
User BufDelPre
User BufDelPost
示例:
vim.api.nvim_create_autocmd('User', {
pattern = 'BufDelPost',
callback = function(ev)
-- 被删除的 bufnr 在 ev.data.buf 中
end,
})
如果删除失败,BufDelPost 不会触发。
下面是基于我个人使用需求的一个对比表:
| Feature / Plugin | bufdel.nvim | bufdelete.nvim | nvim-bufdel | snacks.bufdelete | mini.bufremove |
|---|---|---|---|---|---|
| Preserve window layout | ✓ | ✓ | ✓ | ✓ | ✓ |
| Delete by bufnr | ✓ | ✓ | ✓ | ✓ | ✓ |
| Delete by bufname | ✓ | ✓ | ✓ | ✓ | ✗ |
| User Command | ✓ | ✓ | ✓ | ✗ | ✗ |
| Lua filter function | ✓ | ✗ | ✗ | ✓ | ✗ |
| Regex buffer matching | ✓ | ✗ | ✗ | ✗ | ✗ |
| Post-delete buffer switch | ✓ | ✓ | ✓ | ✗ | ✗ |
| User autocmd hooks | ✓ | ✓ | ✗ | ✗ | ✗ |
如果你发现表格里有不准确的地方,欢迎直接提 issue。
bufdel.nvim 并不是一个“什么都做”的插件,相反,我刻意让它保持:
如果你:
那它可能正好适合你。
👉 GitHub:wsdjeg/budel.nvim
如果你觉得有用,欢迎 star ⭐
早在写 zettelkasten.nvim 插件的时候,我就想做一个日历试图,用来查看笔记的日期。可能是因为需求不是那么的迫切, 所以一直拖着没有写这样功能。
趁着这次假日,抽空写了这样一个日历插件 calendar.nvim,功能目前还是非常简单的,只是一个简单的日历月视图。 这算是 2026 年我的第一个 Neovim 插件,这篇文字主要介绍 calendar.nvim 插件的安装使用以及制作这一插件遇到的一些问题。
calendar.nvim 是使用 Lua 实现的 Neovim 插件,零依赖,可以使用任意插件管理器直接安装,比如:nvim-plug
require('plug').add({
{
'wsdjeg/calendar.nvim',
},
})
插件的默认配置如下:
require('calendar').setup({
mark_icon = '•',
keymap = {
next_month = 'L', -- 下个月
previous_month = 'H', -- 上个月
next_day = 'l', -- 后一天
previous_day = 'h', -- 前一天
next_week = 'j', -- 下一周
previous_week = 'k', -- 前一周
today = 't', -- 跳到今天
},
highlights = {
current = 'Visual',
today = 'Todo',
mark = 'Todo',
},
})
nvim_buf_set_extmark 函数中 col 等参数指的并不是屏幕 column 列表,而是字符串的字节,
overlay virt_text 的高亮会清除掉当前位置的 extmark hl_group 高亮
最终解决逻辑是给每一个需要标记的位置按照如下逻辑添加 virt_text,其高亮参数传输一个高亮列表.
local hls = { highlights.mark }
if is_totay() then
table.insert(hls, highlights.today)
end
if is_current() then
table.insert(hls, highlights.current)
end
vim.api.nvim_buf_set_extmark(buf, ns, col, {
virt_text = { { mark_icon, hls } },
})
这里展示了一个添加了 zettelkasten 拓展的日历:
local zk_ext = {}
function zk_ext.get(year, month)
local notes = require('zettelkasten.browser').get_notes()
local marks = {}
for _, note in ipairs(notes) do
local t = vim.split(note.id, '-')
if tonumber(t[1]) == year and tonumber(t[2]) == month then
table.insert(
marks,
{
year = tonumber(t[1]),
month = tonumber(t[2]),
day = tonumber(t[3]),
}
)
end
end
return marks
end
require('calendar.extensions').register(zk_ext)
最终的效果图如下:

时隔十年,再次被 Windows 系统的路劲大小写问题坑了一把。记得上一次被坑是因为写 Vim Script 的 autoload 脚本时出现的问题。 最近使用 Lua 重新写了 ChineseLinter.vim 这个插件,最开始的文件结构:
文件:plugins/chineselinter.lua
return {
'wsdjeg/ChineseLinter.nvim',
dev = true,
opts = {
ignored_errors = { 'E015', 'E013', 'E020', 'E021' },
},
cmds = { 'CheckChinese' },
desc = 'Chinese Document Language Standards Checking Tool',
}
按照以上配置,无论如何 ignored_errors 配置都无法起效。
上述插件在载入时没有报错,说明被成功载入并且执行了 setup 函数。我试着用单独的脚本来测试,并且打入一些日志:
vim.opt.runtimepath:append("D:/wsdjeg/job.nvim")
vim.opt.runtimepath:append("D:/wsdjeg/logger.nvim")
vim.opt.runtimepath:append("D:/wsdjeg/nvim-plug")
require('plug').setup({
bundle_dir = 'D:/bundle_dir',
raw_plugin_dir = 'D:/bundle_dir/raw_plugin',
-- ui = 'notify',
http_proxy = 'http://127.0.0.1:7890',
https_proxy = 'http://127.0.0.1:7890',
enable_priority = false,
enable_luarocks = true,
max_processes = 16,
dev_path = 'D:/wsdjeg',
})
require("plug").add({
{
"wsdjeg/ChineseLinter.nvim",
dev = true,
opts = {
ignored_errors = { "E015", "E013", "E020", "E021" },
},
cmds = { "CheckChinese" },
desc = "Chinese Document Language Standards Checking Tool",
},
})
日志结果如下:
[ 23:35:32:449 ] [ Info ] [ cnlint ] module is loaded
[ 23:35:32:450 ] [ Info ] [ cnlint ] setup function is called
[ 23:35:32:450 ] [ Info ] [ plug ] load plug: ChineseLinter.nvim in 4.3624ms
[ 23:35:32:451 ] [ Info ] [ cnlint ] module is loaded
[ 23:35:32:451 ] [ Info ] [ cnlint ] check function is called
不难看出 ChineseLinter 模块被载入了两次,第一次载入及setup函数是 nvim-plug 在执行,执行后计算的载入时间,第二次是执行 CheckChinese 命令时,
而这一命令是在 plugin/ChineseLinter.lua 内定义的:
vim.api.nvim_create_user_command("CheckChinese", function(opt)
require("ChineseLinter").check()
end, { nargs = "*" })
问题就在这里,这个命令内 require('ChineseLinter') 不应该再次载入模块文件,因为前面 nvim-plug 已经执行过一次了,正常情况下 package.loaded 内会缓存模块。
看一下 nvim-plug 载入 Lua 插件的逻辑,它会给 plugSpec 自动设置一个模块名称,
以便于自动执行 require(plugSpec.module).setup(plugSpec.opts)。
问题就在于这个 module 名称生成函数原先是:
local function get_default_module(name)
return name
:lower()
:gsub('[%.%-]lua$', '')
:gsub('^n?vim-', '')
:gsub('[%.%-]n?vim', '')
end
也就是说,按照上述载入插件方式,nvim-plug 执行的是 require('chineselinter'),这在 Windows 系统下,
因为文件 lua/ChineseLinter/init.lua 已存在,那么上述 require 函数就会读取这个模块。
而 :CheckChinese 命令实际上调用的模块是 require('ChineseLinter')。
因为 Lua 的模块名称实际上是大小写敏感的,就会再次去寻找模块文件以载入。
我查阅了几个插件管理器,他们的获取模块名称的函数基本上逻辑类似,都使用了 lower() 函数:
---@param name string
---@return string
function M.normname(name)
local ret = name:lower():gsub("^n?vim%-", ""):gsub("%.n?vim$", ""):gsub("[%.%-]lua", ""):gsub("[^a-z]+", "")
return ret
end
实际上,最好是不要自动去将模块的名字全部小写,按照仓库的名称来最合适,去除掉前后缀,修改 nvim-plug 如下:
diff --git a/lua/plug/loader.lua b/lua/plug/loader.lua
index d0fc7b6..957fcb7 100644
--- a/lua/plug/loader.lua
+++ b/lua/plug/loader.lua
@@ -68,8 +68,7 @@ end
--- @param name string
--- @return string
local function get_default_module(name)
- return name:lower()
- :gsub('[%.%-]lua$', '')
+ return name:gsub('[%.%-]lua$', '')
:gsub('^n?vim-', '')
:gsub('[%.%-]n?vim', '')
end
@@ -94,6 +93,13 @@ function M.parser(plugSpec)
plugSpec.name = check_name(plugSpec)
if not plugSpec.module then
plugSpec.module = get_default_module(plugSpec.name)
+ log.info(
+ string.format(
+ 'set %s default module name to %s',
+ plugSpec.name,
+ plugSpec.module
+ )
+ )
end
if #plugSpec.name == 0 then
plugSpec.enabled = false
考虑到 Windows 系统的大小写敏感,以及 Shift 键这么难按,我将插件的名称以及其内模块的名称都改成了小写,修改后插件的安装方式:
return {
'wsdjeg/chineselinter.nvim',
dev = true,
opts = {
ignored_errors = { 'E015', 'E013', 'E020', 'E021' },
},
cmds = { 'CheckChinese' },
desc = 'Chinese Document Language Standards Checking Tool',
}
上述核心问题在于 Lua 的 require() 函数读取模块缓存时判断的是 package.load[key],这里的 key 是大小写敏感的。
而发现缓存不存在时,依照 key 去载入文件时,在 Windows 系统下路劲又是不敏感的,
会导致同一个模块被不同的大小写模块名称多次载入。