阅读视图

发现新文章,点击刷新页面。
🔲 ☆

GLM-5 流式 Tool Call 异常分析(基于 curl 原始输出)

问题背景

本文记录在使用 阿里云百炼平台 Coding Plan(GLM-5 模型) 时遇到的一个流式输出协议异常问题。请求客户端为 Neovim 插件 chat.nvim,通过 curl 命令异步调用阿里云的流式聊天补全接口。 在正常开发过程中,chat.nvim 需要调用大模型完成代码生成、问题解答等任务,并支持 Tool Call 功能以扩展模型能力。然而在实际使用中,发现当模型返回 Tool Call 响应时,SSE(Server-Sent Events)流中会夹杂未经封装的原始文本数据,导致客户端解析失败。

调用架构与实现

客户端请求实现

chat.nvim 底层使用 Neovim 的 job 系统异步执行 curl 命令,通过标准输入输出与服务端通信。核心请求函数如下:
function M.request(opt)
  local cmd = {
    'curl',
    '-s',
    'https://coding.dashscope.aliyuncs.com/v1/chat/completions',
    '-H',
    'Content-Type: application/json',
    '-H',
    'Authorization: Bearer ' .. config.config.api_key.aliyuncs_pc,
    '-X',
    'POST',
    '-d',
    '@-',
  }

  local body = vim.json.encode({
    model = sessions.get_session_model(opt.session),
    messages = opt.messages,
    enable_thinking = true,
    stream = true,
    stream_options = { include_usage = true },
    tools = require('chat.tools').available_tools(),
  })

  local jobid = job.start(cmd, {
    on_stdout = opt.on_stdout,
    on_stderr = opt.on_stderr,
    on_exit = opt.on_exit,
  })
  job.send(jobid, body)
  job.send(jobid, nil)
  sessions.set_session_jobid(opt.session, jobid)

  return jobid
end

关键请求参数

参数 配置值 说明
服务端点 https://coding.dashscope.aliyuncs.com/v1/chat/completions 阿里云百炼平台 API
模型 glm-5 GLM-5 模型(Coding Plan)
流式输出 stream = true 启用 SSE 流式响应
Tool Call tools = available_tools() 注册可用工具函数
思考模式 enable_thinking = true 启用模型思考过程
使用量统计 stream_options.include_usage = true 返回 token 使用统计
请求体通过 curl -d @- 从标准输入读取,job 系统通过 job.send() 将 JSON 编码的请求体发送给 curl 进程。响应数据通过 on_stdout 回调逐行接收,客户端需要实时解析 SSE 格式的数据流。

异常现象详述

正常的 SSE 流格式

符合 OpenAI 规范的 SSE 流式响应应当严格遵循以下格式,每一行都以 data: 前缀开头,后面跟随完整的 JSON 对象或 [DONE] 标记:
data: {"id":"chat-123","choices":[{"delta":{"content":"Hello"},"index":0}]}
data: {"id":"chat-123","choices":[{"delta":{"content":" world"},"index":0}]}
data: {"id":"chat-123","choices":[{"delta":{"content":"!"},"index":0}]}
data: {"id":"chat-123","choices":[{"finish_reason":"stop","index":0}]}
data: [DONE]
客户端解析器通过检测 data: 前缀识别有效数据行,使用 vim.json.decode() 解析 JSON 内容,遇到 [DONE] 标记时结束流式处理。

实际收到的异常数据

在实际调用 GLM-5 模型时,收到的响应流中出现了不符合 SSE 规范的裸文本数据:
data: {"id":"chat-456","choices":[{"delta":{"content":"请查看"},"index":0}]}
data: {"id":"chat-456","choices":[{"delta":{"tool_calls":[{"id":"call_1","function":{"name":"read_file","arguments":""},"index":0}]}]}
ts from you under
this License for any purpose whatsoever, with or without
data: {"id":"chat-456","choices":[{"delta":{"tool_calls":[{"function":{"arguments":"\"--filepath\""},"index":0}]}]}
data: {"id":"chat-456","choices":[{"finish_reason":"tool_calls","index":0}]}
data: [DONE]
可以看到在第 2 行和第 3 行之间,插入了两行没有 data: 前缀的纯文本内容。这些文本实际上是 Tool Call 的 arguments 参数值的一部分,应该是 GPL 许可证文本的片段。

异常数据特征分析

通过对多次请求的原始响应进行比对,总结出以下异常特征:
特征 表现 出现频率
裸文本行 data: 前缀的纯文本 每次 Tool Call 必现
内容来源 Tool Call arguments 字符串片段 100%
出现位置 tool_calls delta 输出过程中 100%
content 输出 正常 SSE 格式 不受影响
finish_reason 正确返回 tool_calls 不受影响
JSON 完整性 tool_call JSON 被截断 100%
这些特征表明问题具有高度一致性,并非网络传输错误或偶发异常,而是服务端实现层面的系统性问题。

根因分析

问题本质:协议层混流

该问题的本质是服务端在流式输出过程中,存在两条独立的输出通道被错误地混合写入同一个 HTTP 响应流:
输出类型 预期通道 实际通道 状态
普通内容 (content) SSE JSON 封装 SSE JSON 封装 ✅ 正常
Tool Call 参数 (arguments) SSE JSON 封装 原始 stdout 直写 ❌ 异常
正常情况下,所有输出内容都应当经过 SSE 协议封装层,统一格式化为 data: {...} 后写入 HTTP 响应体。但实际实现中,Tool Call 的 arguments 生成逻辑绕过了封装层,直接将 token 写入输出流。

推测的错误实现

根据输出特征,推测服务端可能存在类似以下的错误实现:
// ❌ 错误实现示意
func streamToolCallArguments(tokens []string, writer http.ResponseWriter) {
    for _, token := range tokens {
        // 错误:直接写入原始 token,未经过 SSE 封装
        writer.Write([]byte(token))
        writer.(http.Flusher).Flush()
    }
}

// ✅ 正确实现应当是
func streamToolCallArguments(tokens []string, writer http.ResponseWriter) {
    for _, token := range tokens {
        // 正确:构造完整的 SSE 消息
        message := SSEMessage{
            Choices: []Choice{
                {
                    Delta: Delta{
                        ToolCalls: []ToolCall{
                            {
                                Function: Function{
                                    Arguments: token,
                                },
                            },
                        },
                    },
                },
            },
        }
        fmt.Fprintf(writer, "data: %s\n\n", json.Marshal(message))
        writer.(http.Flusher).Flush()
    }
}

为什么会出现”半截字符串”

观察到的裸文本如 ts from you under 实际上是 arguments 字符串的中间片段。这是因为:
  1. Token 级流式输出:模型生成 arguments 时按 token 逐个输出,而非等待完整字符串生成后一次性输出
  2. 写入时机错误:每个 token 生成后立即被写入输出流,但未经过 JSON 转义和 SSE 封装
  3. 边界不匹配:token 切分点与 JSON 字符串边界不一致,导致输出的是字符串中间片段
例如,当 arguments 应该是 "GPL License text from you under this License" 时,实际输出可能是:
data: {"arguments":"GPL Licen"}   // 正常 SSE
se text from you under            // 裸文本(错误)
this License"                     // 裸文本(错误)
data: {"arguments":""}            // 正常 SSE

通道混用的可能原因

从软件工程角度分析,这种问题通常由以下原因导致:
  1. 代码路径分支:content 输出和 tool_call 输出走了不同的代码路径,其中一条路径遗漏了封装逻辑
  2. 历史遗留代码:可能在迭代过程中,tool_call 功能是后期添加的,复用了旧的输出接口
  3. 测试覆盖不足:缺少对原始 HTTP 响应流的协议一致性测试,仅测试了最终解析结果
  4. 多团队协作:流式输出模块和 Tool Call 模块可能由不同团队开发,接口约定不清晰

对客户端的影响

chat.nvim 解析器行为

chat.nvim 的 SSE 解析器期望每一行输入都是合法的 data: {...} 格式,当遇到裸文本时会出现以下问题:
-- 简化的解析逻辑
local function parse_sse_line(line)
  local data = line:sub(7)  -- 去掉 "data: " 前缀
  return vim.json.decode(data)  -- 裸文本会导致这里报错
end
当输入行为 ts from you under 时:
  1. line:sub(7) 返回 ` from you under`(或原样返回,如果行长度不足 7)
  2. vim.json.decode() 尝试解析非 JSON 字符串,抛出异常
  3. 异常可能导致整个流式处理中断,Tool Call 无法完成

实际影响范围

影响项 严重程度 说明
JSON 解析失败 vim.json.decode() 抛出异常
Tool Call 识别失败 arguments 内容不完整,无法调用工具
流式输出中断 可能触发错误处理逻辑,提前终止
用户体验下降 功能不可用或响应异常
错误日志污染 产生大量解析错误日志

临时容错方案

在无法修改服务端的情况下,客户端可以增加容错逻辑,过滤非法数据行:
local function safe_parse_sse(line)
  -- 严格过滤:只处理以 "data: " 开头的行
  if not line:match("^data: ") then
    vim.log.warn("跳过非法 SSE 行:" .. line)
    return nil
  end
  
  local ok, result = pcall(function()
    local content = line:sub(7)
    if content == "[DONE]" then
      return "DONE"
    end
    return vim.json.decode(content)
  end)
  
  if not ok then
    vim.log.warn("JSON 解析失败:" .. result)
    return nil
  end
  
  return result
end

-- 在 on_stdout 回调中使用
on_stdout = function(_, data)
  for line in data:gmatch("[^\n]+") do
    local parsed = safe_parse_sse(line)
    if parsed then
      handle_chunk(parsed)
    end
  end
end
该方案的核心思想是严格过滤 + 静默丢弃,遇到非法数据行时记录警告日志但不中断处理流程。这样可以保证在服务端修复问题之前,客户端仍能正常工作。

调试与验证方法

保存原始响应

使用 curl 直接请求并保存完整响应,便于离线分析:
curl -N \
  https://coding.dashscope.aliyuncs.com/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -d '{
    "model": "glm-5",
    "messages": [
      {"role": "user", "content": "请读取当前目录下的 LICENSE 文件"}
    ],
    "stream": true,
    "tools": [
      {
        "type": "function",
        "function": {
          "name": "read_file",
          "parameters": {
            "type": "object",
            "properties": {
              "filepath": {"type": "string"}
            }
          }
        }
      }
    ]
  }' \
  > raw_response.txt
参数说明:
  • -N:禁用缓冲,实时输出
  • raw_response.txt:保存完整原始响应

分析响应内容

# 统计总行数
wc -l raw_response.txt

# 查看非 data: 开头的行(非法行)
grep -v "^data: " raw_response.txt

# 统计非法行数量
grep -cv "^data: " raw_response.txt

# 查看非法行的上下文(前后各 2 行)
grep -v "^data: " -B 2 -A 2 raw_response.txt

# 提取所有 data: 行并验证 JSON 格式
grep "^data: " raw_response.txt | \
  sed 's/^data: //' | \
  while read line; do
    echo "$line" | jq . 2>/dev/null || echo "INVALID: $line"
  done

验证 Tool Call 完整性

# 提取所有 tool_calls 相关的行
grep "tool_calls" raw_response.txt

# 检查 arguments 字段是否完整
grep "arguments" raw_response.txt | \
  sed 's/^data: //' | \
  jq '.choices[0].delta.tool_calls[0].function.arguments'
如果 arguments 字段显示不完整或解析失败,说明存在数据污染。

结论与建议

问题定性

该问题属于服务端协议层实现缺陷,具体表现为:
  1. 非客户端问题:chat.nvim 的 SSE 解析逻辑符合 OpenAI 规范
  2. 非网络问题:裸文本稳定出现,非传输错误
  3. 非偶发问题:每次 Tool Call 必现,具有可复现性
  4. 协议级错误:违反 SSE 协议规范,混合输出不同格式数据

根本原因

Tool Call 的 arguments 在流式生成过程中未被正确封装进 SSE 消息格式,导致原始 token 直接泄漏到 HTTP 响应流中。这反映了服务端在流式输出模块和 Tool Call 模块的集成测试不足。

解决建议

对服务端(阿里云)
  1. 修复 arguments 输出路径,确保所有输出经过 SSE 封装层
  2. 增加协议一致性测试,验证响应流符合 SSE 规范
  3. 提供问题追踪和修复时间表
对客户端(chat.nvim)
  1. 增加 SSE 行过滤逻辑,容忍非法数据
  2. 记录详细的解析错误日志,便于问题诊断
  3. 考虑增加协议一致性检查,主动检测服务端问题

后续跟进

建议将原始响应日志提交给阿里云技术支持,附上:
  • 完整的 raw_response.txt 文件
  • 请求参数和调用时间
  • 问题复现步骤
  • 期望的正确输出格式

参考资料

🔲 ☆

job.nvim raw 参数导致 JSON 解析失败

起因

最近在使用 job.nvim 执行一些命令时,遇到了一个奇怪的问题。当我执行一个返回 JSON 数据的命令时,发现 JSON 数据无法被正确解析,总是报错 unexpected end of JSON。经过调试后发现,问题出在 job.nvim 的 raw 参数默认值上。

问题复现

假设我们有一个命令,它会输出一个较大的 JSON 数据:
local job = require('job')
local result = {}

local jobid = job.start({ 'some-command-that-outputs-json' }, {
  on_stdout = function(id, data)
    for _, line in ipairs(data) do
      table.insert(result, line)
    end
  end,
  on_exit = function(id, code, signal)
    local json_str = table.concat(result, '')
    local ok, decoded = pcall(vim.json.decode, json_str)
    if not ok then
      print('JSON decode failed: ' .. decoded)
    else
      print('JSON decoded successfully')
      vim.print(decoded)
    end
  end,
})
执行上述代码后,可能会看到类似以下的错误:
JSON decode failed: Expected object key string but found null at character 1024
或者在解析时直接报错:
JSON decode failed: Expected value but found T_END at character 1

问题分析

要理解这个问题,需要先了解 job.nvim 的 raw 参数的作用。查看 job.nvim 的源码,可以看到:
--- @class JobOpts
--- @field on_stderr? function
--- @field on_exit? fun(id: integer, code: integer, signin: integer)
--- @field on_stdout? function
--- @field cwd? string
--- @field detached? boolean
--- @field clear_env? boolean
--- @field env? table<string, string|number>
--- @field encoding? string
--- @field raw? boolean
raw 参数默认是 nil(即 false),这意味着使用行缓冲模式。在这种模式下,数据会通过 buffered_data 函数处理:
---@param eof string
---@param data string
local function buffered_data(eof, data)
  data = data:gsub('\r\n', '\n')
  local std_data = vim.split(data, '\n')
  if #std_data > 1 then
    std_data[1] = eof .. std_data[1]
    eof = std_data[#std_data] == '' and '' or std_data[#std_data]
    table.remove(std_data, #std_data)
  elseif #std_data == 1 then
    if std_data[1] == '' and eof ~= '' then
      std_data = { eof }
      eof = ''
    elseif std_data[1] == '' and eof == '' then
      std_data = {}
    elseif std_data[#std_data] ~= '' then
      eof = std_data[#std_data]
      std_data = {}
    end
  end
  return eof, std_data
end
这个函数的逻辑是:
  1. \r\n 替换为 \n
  2. 按换行符 \n 分割数据
  3. 处理不完整的行,保存到 eof 变量中等待下一次数据
问题在于:大多数 JSON 输出是不包含换行符的单行数据,或者是一个格式化的多行 JSON。当 JSON 数据较大时,可能会被系统分成多个数据块(chunk),但每个数据块内部可能没有换行符。 在这种情况下,buffered_data 函数会:
  1. 如果数据块中没有换行符,会将整个数据块视为一个”不完整的行”,保存到 eof
  2. 此时 std_data 是空的,不会触发 on_stdout 回调
  3. 只有当下一个数据块到来或者 EOF 时,才会将之前保存的数据输出
这样就导致了数据在缓冲区中累积,但 on_stdout 回调可能不会及时接收到数据。更糟糕的是,如果 JSON 数据本身就包含换行符(格式化的 JSON),会被错误地分割成多行。

对比两种模式

默认模式(raw = false)

-- Default: line-buffered mode
uv.read_start(stdout, function(_, data)
  if data then
    local stdout_data
    _jobs['jobid_' .. current_id].state.stdout_eof, stdout_data =
      buffered_data(
        _jobs['jobid_' .. current_id].state.stdout_eof,
        data
      )
    if #stdout_data > 0 then
      vim.schedule(function()
        if opts.encoding then
          stdout_data = vim.tbl_map(function(t)
            return vim.fn.iconv(t, opts.encoding, 'utf-8')
          end, stdout_data)
        end
        opts.on_stdout(current_id, stdout_data)
      end)
    end
    return
  end
  -- ... EOF handling
end)

Raw 模式(raw = true)

-- Raw mode: no buffering, pass raw data chunks directly
if opts.raw then
  uv.read_start(stdout, function(_, data)
    if data then
      vim.schedule(function()
        if opts.encoding then
          data = vim.fn.iconv(data, opts.encoding, 'utf-8')
        end
        if nparams == 2 then
          opts.on_stdout(current_id, { data })
        else
          opts.on_stdout(current_id, { data }, 'stdout')
        end
      end)
    else
      -- EOF
      if stdout and not stdout:is_closing() then
        stdout:close()
      end
    end
  end)
end
可以看到,raw = true 模式下:
  1. 不进行行缓冲处理
  2. 直接将原始数据块传递给回调函数
  3. 数据可能会被分成多个块,每个块都是不完整的

解决方案

针对 JSON 数据解析的场景,有以下几种解决方案:

方案一:使用 raw 模式并手动拼接数据

local job = require('job')
local result = {}

local jobid = job.start({ 'some-command-that-outputs-json' }, {
  raw = true,  -- 使用原始模式
  on_stdout = function(id, data)
    -- data 是一个包含原始数据块的列表
    for _, chunk in ipairs(data) do
      table.insert(result, chunk)
    end
  end,
  on_exit = function(id, code, signal)
    local json_str = table.concat(result, '')
    local ok, decoded = pcall(vim.json.decode, json_str)
    if not ok then
      print('JSON decode failed: ' .. decoded)
    else
      print('JSON decoded successfully')
      vim.print(decoded)
    end
  end,
})

方案二:在默认模式下正确处理数据

如果 JSON 数据是单行输出(minified JSON),需要在 on_exit 回调中处理缓冲区中剩余的数据:
local job = require('job')
local result = {}

local jobid = job.start({ 'some-command-that-outputs-json' }, {
  on_stdout = function(id, data)
    for _, line in ipairs(data) do
      -- 如果 JSON 是格式化的多行,需要保留换行符
      table.insert(result, line)
    end
  end,
  on_exit = function(id, code, signal)
    -- 拼接所有行,如果是 minified JSON,不需要换行符
    -- 如果是格式化 JSON,需要添加换行符
    local json_str = table.concat(result, '\n')
    local ok, decoded = pcall(vim.json.decode, json_str)
    if not ok then
      print('JSON decode failed: ' .. decoded)
    else
      print('JSON decoded successfully')
      vim.print(decoded)
    end
  end,
})

方案三:使用 luv 的 stream API

如果需要更精细的控制,可以直接使用 vim.loop (或 vim.uv) 的 stream API:
local uv = vim.uv or vim.loop

local stdout = uv.new_pipe()
local result = {}

local handle, pid = uv.spawn('some-command', {
  stdio = { nil, stdout, nil },
}, function(code, signal)
  stdout:close()
  handle:close()

  local json_str = table.concat(result, '')
  local ok, decoded = pcall(vim.json.decode, json_str)
  if not ok then
    print('JSON decode failed: ' .. decoded)
  else
    print('JSON decoded successfully')
    vim.print(decoded)
  end
end)

uv.read_start(stdout, function(err, data)
  if data then
    table.insert(result, data)
  end
end)

实际案例

在我开发的 chat.nvim 插件中,调用 AI API 时返回的就是 JSON 数据。最初使用默认模式时,遇到了数据解析失败的问题。 查看调试日志:
[ 14:23:42:123 ] [ Info  ] Received chunk 1: {"id":"chatcmpl-123","object":"chat.completion","created":1234567890
[ 14:23:42:125 ] [ Info  ] Received chunk 2: ,"model":"gpt-4","choices":[{"index":0,"message":{"role":"assistant"
[ 14:23:42:127 ] [ Info  ] Received chunk 3: ,"content":"Hello"},"finish_reason":"stop"}]}
可以看到,JSON 数据被分成了 3 个数据块,每个块都不完整。在默认模式下,这些数据会被缓冲,等待换行符,但 JSON 数据中没有换行符,导致数据一直在缓冲区中。 使用 raw = true 后,可以正确地接收并拼接这些数据块:
local job = require('job')
local chunks = {}

job.start(cmd, {
  raw = true,
  on_stdout = function(id, data)
    for _, chunk in ipairs(data) do
      table.insert(chunks, chunk)
    end
  end,
  on_exit = function(id, code, signal)
    local json_str = table.concat(chunks, '')
    -- 现在可以正确解析 JSON
    local response = vim.json.decode(json_str)
    -- 处理响应数据
  end,
})

总结

job.nvim 的 raw 参数默认为 false,使用行缓冲模式,适合处理以行为单位的文本输出(如日志、命令输出等)。但如果要处理二进制数据或单行的大型 JSON 数据,应该设置 raw = true,然后在回调函数中手动拼接数据块。 理解 raw 参数的作用对于正确处理外部命令的输出非常重要:
  • raw = false(默认):行缓冲模式,按换行符分割数据,确保每次回调传递完整的行
  • raw = true:原始模式,直接传递数据块,适合处理二进制数据或需要手动拼接的场景
在使用 job.nvim 处理 JSON 数据时,建议使用 raw = true 并在 on_exit 回调中拼接所有数据块后再进行解析。
🔲 ☆

为什么仍然在使用 job.nvim

起因

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 <wsdjeg@163.com>
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

使用 Lua 重写 job API

随着 Neovim 对 Lua 的支持越来越多,我后来使用 Lua 重写了 Job API,但仍然将其内置在 SpaceVim 里。重写之后,调用就可以直接使用 Lua 了。
commit 879129388ab22b64c5a5cf0df83799084cab96fc
Author: Eric Wong <wsdjeg@outlook.com>
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,
})

独立成 job.nvim

随着 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

我不太记得 vim.system 是什么时候加入到 Neovim 的了,其前后应该也功能迭代过几个版本。为什么我还在继续维护 job.nvim 而不切换到 vim.system 呢?
  1. 没有对 stdout 数据进行拼接处理,容易有截断数据,而且传给 callback 函数的数据是 string 而非像 jobstart 那样是 string 列表。当然,数据类型都是次要的事情,使用 split 函数很容易得到列表,但是未做数据拼接这点,在写 callback 函数时会增加很多额外的代码量。
  2. 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 相关知识,并着手开发 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)

从上面的请求消息内容可以看到,消息的角色(role)目前有四种:systemuserassistanttool。 各角色详解: user:这是用户输入的内容,对应聊天界面中输入框输入的信息。 assistant:服务器返回的内容,有两种形式:
  1. content:文本消息内容,直接显示在聊天界面
  2. tool_call:函数调用指令,告诉客户端需要执行什么功能
tool:当客户端收到 tool_call 后,执行相应功能并将结果以 tool 角色发送给服务器。 system:(这里需要重点澄清) 很多应用没有提供设置 system 角色的界面,但这个角色不是简单的“底层内容”,而是对话的“基础指令集”。它的主要作用是:
  1. 设定助手的行为准则:如”你是一个专业的编程助手”
  2. 定义回答风格:如”用简洁的语言回答,避免冗长”
  3. 设置安全边界:如”不回答涉及暴力、违法内容的问题”
  4. 提供上下文信息:如”当前项目使用 TypeScript 5.0”
一个标准的 system 消息示例:
{
  "role": "system",
  "content": "你是一个专业的软件开发助手。回答问题时请提供代码示例,并解释关键概念。如果用户的问题涉及不道德或违法内容,请礼貌拒绝。"
}

那些”新概念”文件到底是什么?

我开头所说的伪新概念,指的是目前在社区中流传的各种”配置文件”,比如:

实际存在的配置文件

AGENTS.md
  • 实际用途:为AI编码代理提供项目上下文和指令
  • 类比:项目的”README for agents”
  • 现状:已被6万多个开源项目使用,确实有实际价值
  • 示例内容:项目结构、构建命令、编码规范等
CLAUDE.md
  • 实际用途:Claude Code的配置文件,定义AI子代理的行为
  • 位置:通常位于 .claude/ 目录下
  • 功能:配置工具调用、代码风格偏好等

过度包装的概念

SOUL.md(灵魂文件)
  • 宣称功能:定义AI代理的”性格”、”价值观”、”沟通风格”
  • 实际本质:就是加了营销术语的 system 指令
  • 示例:原本的 system 消息换个文件名就成了”灵魂”
SKILLS.md(技能文件)
  • 宣称功能:列出AI能执行的所有”技能”
  • 实际本质:工具函数(tool_call)的文档化列表
  • 问题:与代码中的工具定义重复,维护成本高

如何看待这些概念

文件类型 实际价值 创新程度
AGENTS.md 中等(提供了标准化格式)
CLAUDE.md 低(平台特定配置)
SOUL.md 极低(换名不换药)
SKILLS.md 极低(文档化工具列表)

核心观点

  1. 技术本质没变:这些文件底层还是通过 system 消息或工具定义来实现
  2. 创新 vs 包装
    • 真创新:AGENTS.md 提供了标准化的项目上下文传递方式
    • 伪概念:SOUL.md 等只是给现有技术换了个好听的名字
  3. 开发建议
    • 使用 AGENTS.md 提升AI编码效率 ✓
    • 不要被”灵魂”、”技能”等营销术语迷惑 ✗
    • 理解底层的 systemtool_call 机制才是关键

实际开发中的选择

# 推荐的配置方式

## 1. 核心配置(必需)

- `system` 消息:定义助手基本行为
- 工具函数:实际可用的功能

## 2. 项目配置(推荐)

- AGENTS.md:项目级AI辅助配置

## 3. 避免过度设计

- 不需要 SOUL.md、SKILLS.md 等冗余文件
- 保持配置简洁,避免维护负担
真正的技术创新应该解决实际问题,而不是发明新名词。理解了消息传递的底层机制,就能一眼看穿哪些是真正有用的工具,哪些只是华丽的包装。
🔲 ☆

Neovim 智能对话伙伴 chat.nvim

很长一段时间,AI 一直是一个非常热门的话题。衍生出来的工具也非常的多,从我接触的顺序来看, 从最早的 tabline 补全到后来的 GitHub Copilot 补全。再到后来的 ChatGPT 以及之后来的各自类似的 Chat 工具。 前面我使用最多的还是网页版的 ChatGPT,使用过程中最大的一个问题就是请求结果渲染展示一直是有问题的。 尤其是让他展示 markdown 源码时。比如: markdown display error 上述图片 Usage 实际上也在代码块里面是,但是由于 markdown 内还有代码块,导致解析展示出问题。 正是由于这个原因,我制作了 Neovim AI 聊天插件 chat.nvim, 我需要以纯文本展示请求结果的完整内容。

安装及配置 chat.nvim

使用任意插件管理器,比如 nvim-plug:
local deepseek_api_key = 'xxxxxxxxxxx'
local free_chatgpt_api_key = 'xxxxxxxxxxxxxxx'
require('plug').add({
  {
    'wsdjeg/chat.nvim',
    depends = {
      {
        'wsdjeg/job.nvim', -- Required
        'wsdjeg/picker.nvim', -- Optional but recommended
      },
    },
  },
})

使用操作界面

chat.nvim 默认是上下分屏两个浮窗,分别为输入窗口和结果展示窗口。如图: chat.nvim

多 AI 提供商支持

chat.nvim 内置支持多种 AI 服务提供商: 同时还支持自定义提供商,你可以创建 ~/.config/nvim/lua/chat/providers/<provider_name>.lua 来添加新的 AI 服务。

picker.nvim 集成

chat.nvim 自带了一些 picker.nvim 插件的拓展,目前支持的拓展有:
  1. :Picker chat - 搜索历史对话 picker-chat
  2. :Picker chat_provider - 搜索并切换 provider picker-chat
  3. :Picker chat_model - 搜索并切换当前 provider 提供的模型 picker-chat

内置工具支持

chat.nvim 支持丰富的内置工具,让 AI 可以直接与编辑器交互:

文件操作工具

  • @read_file - 读取文件内容(支持指定行范围)
  • @find_files - 搜索文件
  • @search_text - 搜索文件内容(支持正则表达式、文件类型过滤等)
  • @git_diff - 查看 git diff 信息

网页工具

  • @web_search - 网络搜索(支持 Firecrawl、Google、SerpAPI)
  • @fetch_web - 获取网页内容

记忆系统

chat.nvim 实现了三层记忆系统,灵感来自认知心理学:
  • 工作记忆 (Working Memory) ⚡ - 当前会话的任务和决策(最高优先级)
  • 日常记忆 (Daily Memory) 📅 - 临时任务和短期目标(7-30天后自动过期)
  • 长期记忆 (Long-term Memory) 💾 - 永久知识存储(事实、偏好、技能)
相关工具:
  • @extract_memory - 提取并存储记忆
  • @recall_memory - 回忆相关信息

其他工具

  • @set_prompt - 设置系统提示词
  • @plan - 任务计划管理
  • @zettelkasten_create - 创建笔记(需要 zettelkasten.nvim)
  • @zettelkasten_get - 获取笔记(需要 zettelkasten.nvim)

会话管理

chat.nvim 提供了完整的会话管理功能:
  • :Chat - 打开当前会话
  • :Chat new - 创建新会话
  • :Chat prev / :Chat next - 切换会话
  • :Chat clear - 清空当前会话
  • :Chat delete - 删除当前会话
  • :Chat save <path> - 保存会话到文件
  • :Chat load <path> - 从文件或 URL 加载会话
  • :Chat share - 分享会话到 pastebin
  • :Chat preview - 在浏览器中预览会话
chat preview

并行会话

chat.nvim 支持同时运行多个独立的聊天会话:
  • 每个会话可以使用不同的 AI 模型
  • 会话之间保持独立的对话历史和设置
  • 使用 :Chat prev:Chat next 快速切换

IM 集成

chat.nvim 支持多种第三方聊天平台集成,可以将 Neovim 的 AI 对话能力扩展到各个平台:
平台 图标 双向通信 特点
Discord 💬 ✅ 是 完整的机器人功能,会话绑定
飞书 🐦 ✅ 是 飞书机器人,消息轮询
钉钉 📱 ✅ 是* Webhook(单向)或 API(双向)
企业微信 💼 ✅ 是* 企业微信 Webhook 或 API
Telegram ✈️ ✅ 是 Bot API,支持群组和私聊

Discord 集成

chat-nvim-discord 通过 Discord 集成,你可以:
  • 在 Discord 频道中与 AI 助手交互
  • 使用 :Chat bridge discord 绑定当前会话
  • 使用 /session 命令确认绑定
  • 通过提及机器人或回复消息来触发对话

飞书集成

chat-nvim-lark 飞书集成支持:
  • 双向消息通信
  • 自动轮询新消息
  • 长消息支持(最多 30,720 字符)

消息同步

通过这些集成,可以实现 Neovim 与各平台的消息同步: chat-nvim-integration-sync 这些集成功能让 chat.nvim 成为一个多平台的 AI 对话中心,不仅可以在 Neovim 内使用,还可以作为各种聊天平台的智能助手。

HTTP API

chat.nvim 内置了 HTTP 服务器功能,提供 RESTful API 接口:
http = {
  host = '127.0.0.1',
  port = 7777,
  api_key = 'your-secret-key',
}

API 端点

  • POST / - 发送消息到指定会话
  • GET /sessions - 获取所有活跃会话列表
  • GET /session - 获取会话的 HTML 预览(需要 id 参数)

使用示例

# 发送消息
curl -X POST http://127.0.0.1:7777/ \
  -H "X-API-Key: your-secret-key" \
  -H "Content-Type: application/json" \
  -d '{"session": "my-session", "content": "Hello from curl!"}'

# 获取会话列表
curl -H "X-API-Key: your-secret-key" http://127.0.0.1:7777/sessions
这使得外部应用可以轻松集成 chat.nvim 的对话能力,比如 CI/CD 流水线、监控系统等。

快捷键

在输入窗口中:
  • <Enter> - 发送消息
  • q - 关闭聊天窗口
  • <Tab> - 切换输入和结果窗口
  • Ctrl-C - 取消当前请求
  • Ctrl-N - 新建会话
  • r - 重试上次取消的请求
  • alt-h / alt-l - 上一个/下一个会话
在结果窗口中:
  • q - 关闭聊天窗口
  • <Tab> - 切换输入和结果窗口

总结

从最初为了解决 markdown 渲染问题而开发的聊天插件,到现在支持多种 AI 提供商、内置工具系统、三层记忆架构、多平台集成、HTTP API 等功能,chat.nvim 已经发展成为一个功能强大的 Neovim AI 助手。它不仅能帮助你在编辑器内高效地与 AI 对话,还能通过各种工具直接操作文件、搜索网络、管理记忆,真正成为了开发者的智能伙伴。 更多详细信息,请访问 chat.nvim GitHub 仓库
🔲 ☆

Neovim buffer 删除插件 bufdel.nvim

在使用 Neovim 的过程中,「删除 buffer 但不破坏窗口布局」一直是一个高频需求。 社区里已经有不少相关插件,比如 bufdelete.nvimnvim-bufdelmini.bufremove,以及 snacks.bufdelete。 但是在我自己长期使用过程中,总觉得缺少了我需要的功能。 于是,我写了一个新的插件:bufdel.nvim。 这篇文章简单聊聊它解决了什么问题、有哪些设计取舍,以及它和现有方案的区别。

为什么再写一个 bufdel 插件

先说结论:bufdel.nvim 设计初衷是为了删除 buffer 这个操作的每一步更加可控。 我写这个插件,主要有几个原因:
  1. bufdelete.nvim 已经 archived bufdelete.nvim 是一个非常优秀的插件,我也之前也使用过一段时间。但它目前已经被标记为 archived,不再维护。 我希望有一个持续维护、可扩展的替代方案,同时保留它最核心、最优雅的设计。
  2. 我需要更灵活的 buffer 选择方式 很多插件只支持按照 buffer number 删除,但是再实际使用中,我经常需要:
    • 按照 buffer name 删除
    • 按照正则表达式匹配到的 buffer 名称删除
    • 按照特定的条件函数删除(比如:未修改、已列出、非当前 buffer)
  3. 删除之后,切换到哪个 buffer,应该是可控的 前面提到的几个插件大多数在删除 buffer 后,选择切换到 bufnr('#')。但我更希望能明确指定下一个 buffer 是哪个或者通过函数,完全自定义切换逻辑。

bufdel.nvim 的核心设计

  1. 一个核心 API:delete(buffers, opt) bufdel.nvim 只暴露一个核心函数,但是它支持的参数非常灵活。不少其他的插件会设计两个 API 函数 deletewipeout,其实完全可以合并,通过 opt 内一个选项区分。
    require('bufdel').delete(buffers, opt)
    
  2. buffers 参数:你想怎么选 buffer 都行 buffers 支持多种形式:
    • 单个 buffer number
    • buffer 名称(字符串)
    • Vim 正则(匹配 buffer name)
    • table(混合 bufnr / bufname / regex)
    • 函数过滤器
    比如,删除所有已列出、已保存的非当前 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 })
    
    这类逻辑,在很多其他插件里是做不到的。
  3. 正则匹配 buffer 名称 如果你想清理一类文件,比如所有 .txt buffer:
    require('bufdel').delete('.txt$', { wipe = true })
    
    这在日常清理临时文件、日志文件时非常方便。

删除之后,切到哪个 buffer

这是 bufdel.nvim 的一个重点特性。
  1. 使用函数自定义切换逻辑(推荐)
    require('bufdel').delete(filter, {
      wipe = true,
      switch = function(deleted_buf)
        return vim.fn.bufnr('#') -- 切换到 alternate buffer
      end,
    })
    
    你可以在这里实现任何策略,只要返回一个有效的 buffer number。
  2. 内置几种常用策略 如果不想写函数,也可以直接用字符串:
    switch = 'alt'
    
    当前支持:
    • alt:alternate buffer(#)
    • current:保持当前 buffer
    • lastused:最近使用的 buffer
    • next / prev:下一个 / 上一个 buffer
  3. 直接指定 buffer number
    switch = 3
    

用户命令Bdelete/Bwipeout

bufdel.nvim 提供了两个命令:
:Bdelete
:Bwipeout
行为和 :bdelete / :bwipeout 一致,但不会改变窗口布局。 示例:
:Bdelete
:Bdelete 3
:Bdelete 2 5 7
:3,6Bdelete

一个 Vim 本身的限制说明

和原生 :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 并不是一个“什么都做”的插件,相反,我刻意让它保持:
  • 功能边界清晰
  • API 简单
  • 逻辑可控
如果你:
  • 经常清理 buffer
  • 在意窗口布局
  • 希望对 buffer 删除过程有更多掌控
那它可能正好适合你。 👉 GitHub:wsdjeg/budel.nvim 如果你觉得有用,欢迎 star ⭐
🔲 ☆

想对微博说 FUCK

今天下班后,回来本来想上微博看看最近有什么新鲜的事情。结果出现了这样一幕: fuck-weibo 瞬间感觉人都不好了。很多好友,历史消息都还没有任何备份。所以说,现在完全不能依赖这类所谓的社交软件。后台管理员有无限大的权限。
🔲 ☆

Neovim 日历插件 calendar.nvim

早在写 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',
  },
})

记录一些坑

  1. nvim_buf_set_extmark 函数中 col 等参数指的并不是屏幕 column 列表,而是字符串的字节,
  2. 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)
最终的效果图如下: calendar-demo
🔲 ☆

文件路径大小写敏感导致 Lua 模块重载

事情起因

时隔十年,再次被 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 系统下路劲又是不敏感的, 会导致同一个模块被不同的大小写模块名称多次载入。
🔲 ☆

Neovim 悬浮滚动条 scrollbar.nvim

scrollbar.nvim 简介

好几年前,我使用 Vim Script 实现过一个悬浮侧栏插件 scrollbar.vim, 前段时间该插件使用 Lua 进行了重写并改名称为 scrollbar.nvim, 重写后的插件只支持 Neovim。 scrollbar.nvim 会在当前窗口的右侧使用浮窗绘制一个滚动条,其位置依据当前窗口显示的内容在整个文件中所在的行数, 并且随着鼠标移动、滚屏等操作上下移动。

安装 scrollbar.nvim

使用 nvim-plug 进行安装:
require('plug').add({
    {
        'wsdjeg/scrollbar.nvim'
    }
})
或者使用 luarocks 进行安装:
luarocks install scrollbar.nvim

插件的配置

require('scrollbar').setup({
  max_size = 10,
  min_size = 5,
  width = 1,
  right_offset = 1,
  excluded_filetypes = {
    'startify',
    'git-commit',
    'leaderf',
    'NvimTree',
    'tagbar',
    'defx',
    'neo-tree',
    'qf',
  },
  shape = {
    head = '▲',
    body = '█',
    tail = '▼',
  },
  highlight = {
    head = 'Normal',
    body = 'Normal',
    tail = 'Normal',
  },
  debug = false,
})
🔲 ☆

如何正确地使用 ftplugin 目录

前面再阅读一些插件源码时,发现一个问题,很多插件的使用了 ftplugin 这个目录,其内的脚本文件中直接使用了 setlocal xx=xx 这样的语法。 在早期的 Neovim 或者 Vim 版本中这样确实没有问题,但是随着 Neovim 功能特性增加。这样写就会容易出错。 实际上,直到目前为止 Neovim 和 Vim 的官方文档 :h ftplugin 内的示例还是:
" Only do this when not done yet for this buffer
if exists("b:did_ftplugin")
  finish
endif
let b:did_ftplugin = 1
setlocal textwidth=70
Neovim 插件的 ftplugin 目录是一个特殊的文件夹,其内的文件会在 FileType 事件触发是被载入。 看一下 Neovim 的源码,ftplugin 目录下的文件是如何被载入的。
augroup filetypeplugin
  au FileType * call s:LoadFTPlugin()

  func! s:LoadFTPlugin()
    if exists("b:undo_ftplugin")
      exe b:undo_ftplugin
      unlet! b:undo_ftplugin b:did_ftplugin
    endif

    let s = expand("<amatch>")
    if s != ""
      if &cpo =~# "S" && exists("b:did_ftplugin")
	" In compatible mode options are reset to the global values, need to
	" set the local values also when a plugin was already used.
	unlet b:did_ftplugin
      endif

      " When there is a dot it is used to separate filetype names.  Thus for
      " "aaa.bbb" load "aaa" and then "bbb".
      for name in split(s, '\.')
        " Load Lua ftplugins after Vim ftplugins _per directory_
        " TODO(clason): use nvim__get_runtime when supports globs and modeline
        " XXX: "[.]" in the first pattern makes it a wildcard on Windows
        exe $'runtime! ftplugin/{name}[.] ftplugin/{name}_*. ftplugin/{name}/*.'
      endfor
    endif
  endfunc
augroup END
以上内容不难看出,Neovim 实际上是监听了 FileType 这个事件,然后根据 expand('<amatch>') 的值来执行 :runtime 命令。 但是,随着 Neovim 和 Vim 增加了设置非当前 buffer 的 option 这一功能后。就会出现这样问题,当 FileType 事件触发时,触发的 buffer 并非是当前 buffer。 那么在 ftplugin 内如果使用了 setlocal 这样的命令,有可能会设置错了缓冲区。 test_ft.lua
local log = require("logger").derive("ft")
log.info("nvim_get_current_buf() is " .. vim.api.nvim_get_current_buf())
log.info("nvim_get_current_win() is " .. vim.api.nvim_get_current_win())
log.info("-----------------------------------------------------")
local real_current_win = vim.api.nvim_get_current_win()
local newbuf = vim.api.nvim_create_buf(true, false)

local events = {}

for _, v in ipairs(vim.fn.getcompletion("", "event")) do
	if not vim.endswith(v, "Cmd") then
		table.insert(events, v)
	end
end

local id = vim.api.nvim_create_autocmd(events, {
	group = vim.api.nvim_create_augroup("test_ft", { clear = true }),
	pattern = { "*" },
	callback = function(ev)
		log.info("-----------------------------------------------------")
		log.info("event is " .. ev.event)
		log.info("ev.buf is " .. ev.buf)
		log.info("nvim_get_current_buf() is " .. vim.api.nvim_get_current_buf())
		log.info("nvim_get_current_win() is " .. vim.api.nvim_get_current_win())
		log.info("real_current_win's buf is" .. vim.api.nvim_win_get_buf(real_current_win))
	end,
})

vim.api.nvim_open_win(newbuf, false, { split = "above" })
vim.api.nvim_set_option_value("filetype", "test123", { buf = newbuf })
vim.api.nvim_del_autocmd(id)
log.info("-----------------------------------------------------")
log.info("nvim_get_current_buf() is " .. vim.api.nvim_get_current_buf())
log.info("nvim_get_current_win() is " .. vim.api.nvim_get_current_win())
[ 23:50:19:932 ] [ Info  ] [     ft ] nvim_get_current_buf() is 7
[ 23:50:19:932 ] [ Info  ] [     ft ] nvim_get_current_win() is 1000
[ 23:50:19:932 ] [ Info  ] [     ft ] -----------------------------------------------------
[ 23:50:19:933 ] [ Info  ] [     ft ] -----------------------------------------------------
[ 23:50:19:933 ] [ Info  ] [     ft ] event is WinNew
[ 23:50:19:933 ] [ Info  ] [     ft ] ev.buf is 7
[ 23:50:19:933 ] [ Info  ] [     ft ] nvim_get_current_buf() is 7
[ 23:50:19:933 ] [ Info  ] [     ft ] nvim_get_current_win() is 1008
[ 23:50:19:933 ] [ Info  ] [     ft ] real_current_win's buf is7
[ 23:50:19:934 ] [ Info  ] [     ft ] -----------------------------------------------------
[ 23:50:19:934 ] [ Info  ] [     ft ] event is BufWinEnter
[ 23:50:19:934 ] [ Info  ] [     ft ] ev.buf is 9
[ 23:50:19:934 ] [ Info  ] [     ft ] nvim_get_current_buf() is 9
[ 23:50:19:934 ] [ Info  ] [     ft ] nvim_get_current_win() is 1008
[ 23:50:19:934 ] [ Info  ] [     ft ] real_current_win's buf is7
[ 23:50:19:953 ] [ Info  ] [     ft ] -----------------------------------------------------
[ 23:50:19:953 ] [ Info  ] [     ft ] event is Syntax
[ 23:50:19:953 ] [ Info  ] [     ft ] ev.buf is 9
[ 23:50:19:953 ] [ Info  ] [     ft ] nvim_get_current_buf() is 9
[ 23:50:19:953 ] [ Info  ] [     ft ] nvim_get_current_win() is 1008
[ 23:50:19:953 ] [ Info  ] [     ft ] real_current_win's buf is7
[ 23:50:19:954 ] [ Info  ] [     ft ] -----------------------------------------------------
[ 23:50:19:954 ] [ Info  ] [     ft ] event is FileType
[ 23:50:19:954 ] [ Info  ] [     ft ] ev.buf is 9
[ 23:50:19:954 ] [ Info  ] [     ft ] nvim_get_current_buf() is 9
[ 23:50:19:954 ] [ Info  ] [     ft ] nvim_get_current_win() is 1008
[ 23:50:19:954 ] [ Info  ] [     ft ] real_current_win's buf is7
[ 23:50:19:954 ] [ Info  ] [     ft ] -----------------------------------------------------
[ 23:50:19:954 ] [ Info  ] [     ft ] event is OptionSet
[ 23:50:19:954 ] [ Info  ] [     ft ] ev.buf is 0
[ 23:50:19:954 ] [ Info  ] [     ft ] nvim_get_current_buf() is 9
[ 23:50:19:954 ] [ Info  ] [     ft ] nvim_get_current_win() is 1008
[ 23:50:19:954 ] [ Info  ] [     ft ] real_current_win's buf is7
[ 23:50:19:954 ] [ Info  ] [     ft ] -----------------------------------------------------
[ 23:50:19:954 ] [ Info  ] [     ft ] nvim_get_current_buf() is 7
[ 23:50:19:954 ] [ Info  ] [     ft ] nvim_get_current_win() is 1000
可以看到,在 event 触发 callback 函数内 nvim_get_current_win 和 nvim_get_current_buf 都临时被修改了。 测试一下,不开窗口效果呢?
local log = require("logger").derive("ft")
log.info("nvim_get_current_buf() is " .. vim.api.nvim_get_current_buf())
log.info("nvim_get_current_win() is " .. vim.api.nvim_get_current_win())
log.info("-----------------------------------------------------")
local real_current_win = vim.api.nvim_get_current_win()
local newbuf = vim.api.nvim_create_buf(true, false)

local events = {}

for _, v in ipairs(vim.fn.getcompletion("", "event")) do
	if not vim.endswith(v, "Cmd") then
		table.insert(events, v)
	end
end

local id = vim.api.nvim_create_autocmd(events, {
	group = vim.api.nvim_create_augroup("test_ft", { clear = true }),
	pattern = { "*" },
	callback = function(ev)
		log.info("-----------------------------------------------------")
		log.info("event is " .. ev.event)
		log.info("ev.buf is " .. ev.buf)
		log.info("nvim_get_current_buf() is " .. vim.api.nvim_get_current_buf())
		log.info("nvim_get_current_win() is " .. vim.api.nvim_get_current_win())
		log.info("real_current_win's buf is" .. vim.api.nvim_win_get_buf(real_current_win))
	end,
})

-- vim.api.nvim_open_win(newbuf, false, { split = "above" })
vim.api.nvim_set_option_value("filetype", "test123", { buf = newbuf })
vim.api.nvim_del_autocmd(id)
log.info("-----------------------------------------------------")
log.info("nvim_get_current_buf() is " .. vim.api.nvim_get_current_buf())
log.info("nvim_get_current_win() is " .. vim.api.nvim_get_current_win())
[ 23:53:49:058 ] [ Info  ] [     ft ] nvim_get_current_buf() is 10
[ 23:53:49:058 ] [ Info  ] [     ft ] nvim_get_current_win() is 1000
[ 23:53:49:058 ] [ Info  ] [     ft ] -----------------------------------------------------
[ 23:53:49:078 ] [ Info  ] [     ft ] -----------------------------------------------------
[ 23:53:49:078 ] [ Info  ] [     ft ] event is Syntax
[ 23:53:49:078 ] [ Info  ] [     ft ] ev.buf is 12
[ 23:53:49:078 ] [ Info  ] [     ft ] nvim_get_current_buf() is 12
[ 23:53:49:078 ] [ Info  ] [     ft ] nvim_get_current_win() is 1001
[ 23:53:49:078 ] [ Info  ] [     ft ] real_current_win's buf is10
[ 23:53:49:079 ] [ Info  ] [     ft ] -----------------------------------------------------
[ 23:53:49:079 ] [ Info  ] [     ft ] event is FileType
[ 23:53:49:079 ] [ Info  ] [     ft ] ev.buf is 12
[ 23:53:49:079 ] [ Info  ] [     ft ] nvim_get_current_buf() is 12
[ 23:53:49:079 ] [ Info  ] [     ft ] nvim_get_current_win() is 1001
[ 23:53:49:079 ] [ Info  ] [     ft ] real_current_win's buf is10
[ 23:53:49:079 ] [ Info  ] [     ft ] -----------------------------------------------------
[ 23:53:49:079 ] [ Info  ] [     ft ] event is OptionSet
[ 23:53:49:079 ] [ Info  ] [     ft ] ev.buf is 0
[ 23:53:49:079 ] [ Info  ] [     ft ] nvim_get_current_buf() is 12
[ 23:53:49:079 ] [ Info  ] [     ft ] nvim_get_current_win() is 1001
[ 23:53:49:079 ] [ Info  ] [     ft ] real_current_win's buf is10
[ 23:53:49:079 ] [ Info  ] [     ft ] -----------------------------------------------------
[ 23:53:49:079 ] [ Info  ] [     ft ] nvim_get_current_buf() is 10
[ 23:53:49:079 ] [ Info  ] [     ft ] nvim_get_current_win() is 1000
这窗口 1001 是什么鬼?临时隐藏窗口?
local log = require("logger").derive("ft")
log.info("nvim_get_current_buf() is " .. vim.api.nvim_get_current_buf())
log.info("nvim_get_current_win() is " .. vim.api.nvim_get_current_win())
log.info("-----------------------------------------------------")
local real_current_win = vim.api.nvim_get_current_win()
local newbuf = vim.api.nvim_create_buf(true, false)

local events = {}

for _, v in ipairs(vim.fn.getcompletion("", "event")) do
	if not vim.endswith(v, "Cmd") then
		table.insert(events, v)
	end
end

local id = vim.api.nvim_create_autocmd(events, {
	group = vim.api.nvim_create_augroup("test_ft", { clear = true }),
	pattern = { "*" },
	callback = function(ev)
		log.info("-----------------------------------------------------")
		log.info("event is " .. ev.event)
		log.info("ev.buf is " .. ev.buf)
		log.info("nvim_get_current_buf() is " .. vim.api.nvim_get_current_buf())
		log.info("nvim_get_current_win() is " .. vim.api.nvim_get_current_win())
        log.info('win count is ' .. vim.fn.winnr('$'))
        log.info('winconfig is ' .. vim.inspect(vim.api.nvim_win_get_config(vim.api.nvim_get_current_win())))
		log.info("real_current_win's buf is" .. vim.api.nvim_win_get_buf(real_current_win))
	end,
})

-- vim.api.nvim_open_win(newbuf, false, { split = "above" })
vim.api.nvim_set_option_value("filetype", "test123", { buf = newbuf })
vim.api.nvim_del_autocmd(id)
log.info("-----------------------------------------------------")
log.info("nvim_get_current_buf() is " .. vim.api.nvim_get_current_buf())
log.info("nvim_get_current_win() is " .. vim.api.nvim_get_current_win())
[ 23:57:49:249 ] [ Info  ] [     ft ] nvim_get_current_buf() is 9
[ 23:57:49:249 ] [ Info  ] [     ft ] nvim_get_current_win() is 1000
[ 23:57:49:249 ] [ Info  ] [     ft ] -----------------------------------------------------
[ 23:57:49:268 ] [ Info  ] [     ft ] -----------------------------------------------------
[ 23:57:49:268 ] [ Info  ] [     ft ] event is Syntax
[ 23:57:49:268 ] [ Info  ] [     ft ] ev.buf is 13
[ 23:57:49:268 ] [ Info  ] [     ft ] nvim_get_current_buf() is 13
[ 23:57:49:268 ] [ Info  ] [     ft ] nvim_get_current_win() is 1001
[ 23:57:49:268 ] [ Info  ] [     ft ] win count is 2
[ 23:57:49:268 ] [ Info  ] [     ft ] winconfig is {
  anchor = "NW",
  col = 0,
  external = false,
  focusable = false,
  height = 5,
  hide = false,
  mouse = false,
  relative = "editor",
  row = 0,
  width = 168,
  zindex = 50
}
[ 23:57:49:268 ] [ Info  ] [     ft ] real_current_win's buf is9
[ 23:57:49:269 ] [ Info  ] [     ft ] -----------------------------------------------------
[ 23:57:49:269 ] [ Info  ] [     ft ] event is FileType
[ 23:57:49:269 ] [ Info  ] [     ft ] ev.buf is 13
[ 23:57:49:269 ] [ Info  ] [     ft ] nvim_get_current_buf() is 13
[ 23:57:49:269 ] [ Info  ] [     ft ] nvim_get_current_win() is 1001
[ 23:57:49:269 ] [ Info  ] [     ft ] win count is 2
[ 23:57:49:270 ] [ Info  ] [     ft ] winconfig is {
  anchor = "NW",
  col = 0,
  external = false,
  focusable = false,
  height = 5,
  hide = false,
  mouse = false,
  relative = "editor",
  row = 0,
  width = 168,
  zindex = 50
}
[ 23:57:49:270 ] [ Info  ] [     ft ] real_current_win's buf is9
[ 23:57:49:270 ] [ Info  ] [     ft ] -----------------------------------------------------
[ 23:57:49:270 ] [ Info  ] [     ft ] event is OptionSet
[ 23:57:49:270 ] [ Info  ] [     ft ] ev.buf is 0
[ 23:57:49:270 ] [ Info  ] [     ft ] nvim_get_current_buf() is 13
[ 23:57:49:270 ] [ Info  ] [     ft ] nvim_get_current_win() is 1001
[ 23:57:49:270 ] [ Info  ] [     ft ] win count is 2
[ 23:57:49:270 ] [ Info  ] [     ft ] winconfig is {
  anchor = "NW",
  col = 0,
  external = false,
  focusable = false,
  height = 5,
  hide = false,
  mouse = false,
  relative = "editor",
  row = 0,
  width = 168,
  zindex = 50
}
[ 23:57:49:270 ] [ Info  ] [     ft ] real_current_win's buf is9
[ 23:57:49:270 ] [ Info  ] [     ft ] -----------------------------------------------------
[ 23:57:49:270 ] [ Info  ] [     ft ] nvim_get_current_buf() is 9
[ 23:57:49:270 ] [ Info  ] [     ft ] nvim_get_current_win() is 1000

这里说明一下,即便是 nvim_open_win 没有执行,Neovim 也会新建一个 autocmd windows,使用 win_gettype() 函数可以获取值为 “autocmd”。
🔲 ☆

本地化管理 Github secrets

有这样一个需求,要给自己多个 Github 仓库增加多个仓库 secrets,以便于 Action 中使用。 起初我是手动在每个仓库的设置页面逐一复制粘贴来添加的。但是这样效率明显太低,而且容易出错,此外后期若需要修改, 还得要重新操作。 于是我就想着是否可以使用脚本访问 Github API 来更改,查阅了 GitHub secrets API 文档,也找到了相关信息。 这篇文章就是我使用 Neovim 编辑并批量更新 Github 仓库 secrets 的完整方案。

安装 github.nvim

github.nvim 是一个 GitHub REST API 的 Lua 实现,用于在 Neovim 中访问 Github API。 早些年,我使用 Vim Script 写过类似的 Vim 插件 GitHub.vim, 而 github.nvim 算是 github.vim 的 Lua 重新实现版本,目前也仅仅实现了部分 API,会更具个人使用需要,陆续再实现其他的 API。 可以使用任意 Neovim 插件管理器安装,比如 nvim-plug,
require('plug').add({
  'wsdjeg/github.nvim',
})
当然了,你也可以使用 luarocks 安装:
luarocks install github.nvim

使用 github.nvim 访问仓库 secrts

由于 Github 的 secrts API 访问时,传输的数据都是要使用 libsodium 加密,因此需要安装 libsodium,以及对应的 Lua 模块。 这里记录一下 Windows 下安装这两个工具遇到的坑: 从官网下载 libsodium-1.0.20-stable-msvc.zip,解压后, 需要将 libsodium\x64\Release\v143\dynamic 目录加入到环境变量 PATH 内。
vim.env.PATH = vim.env.PATH
  .. ';'
  .. [[D:\Downloads\libsodium-1.0.20-stable-msvc\libsodium\x64\Release\v143\dynamic]]
此时再使用 luarocks 安装 luasodium,需要指定 libsodium 的安装目录:
luarocks install luasodium SODIUM_INCDIR=D:\Downloads\libsodium-1.0.20-stable-msvc\libsodium\include SODIUM_DIR=D:\Downloads\libsodium-1.0.20-stable-msvc\libsodium\x64\Release\v143\dynamic
如果提示安装成功后,在 Neovim 内如果使用以下 Lua 脚本,只有最后一个会报错。
local luasodium_ffi = require'luasodium' -- ok
local luasodium_ffi = require'luasodium.ffi' -- uses the FFI API (in a C module) ok
local luasodium_c = require'luasodium.core' -- uses the C API ok
local luasodium_pureffi = require'luasodium.pureffi' -- 失败,因为他使用 `require('ffi').load('sodium')`, 应该是 libsodium
最后一个失败的原因时因为 luasodium 源码文件 lib_loader.lua 中使用了 require(‘ffi’).load(‘sodium’) – 在luasodium 修复之前,可以临时如下操作 – 将 dynamic 目录里面的 libsodium.dll 改名为 sodium.dll 我也给 luasodium 提交了一个 PR 来解决这个问题。 确保上述 libsodium、luasodium、github.nvim 都安装好了之后,就可以使用以下脚本了:
local secrts = {
  {
    name = 'DOCKER_API_KEY',
    value = '12jdksjdiiwkdjsskkdj',
  },
  {
    name = 'LUAROCKS_API_KEY',
    value = 'ijnuhbygvtfcrdxesz',
  },
}
local repos = {
  'picker.nvim',
  'format.nvim',
  'tasks.nvim',
}
for _, repo in ipairs(repos) do
  for _, secrt in ipairs(secrts) do
    require('github.secrets').update_repository_secret('wsdjeg', repo, secrt)
  end
end
通过上述脚本,就给批量给自己的 Github 仓库设定 secrets,后期如果 API_KEY 修改了, 只需要修改脚本后再执行一次即可。
🔲 ☆

发现 Git 仓库中幽灵文件

在使用 Git 过程中,有时候会遇到这样的情况:某个以前删除的文件,不记得当时删除的 commit message 内容了, 也不记得完整的文件路径,只记得文件部分名称,想要找到是哪一个 commit 删除的,发现比较难。 实际上可以通过下方的命令来时间这一功能,首先使用下面的命令搜索删掉的文件路径包含 src 的文件列表:
git log --diff-filter=D --summary | rg delete | rg src
如果要使用 code-runner.nvim,执行并输出,需要设定 ps 的 runner:
require('code-runner').setup({
    runners = {
        lua = { exe = 'nvim', opt = { '-l', '-' }, usestdin = true },
        ps = { exe = 'powershell.exe', opt = { '-Command', '-' }, usestdin = true },
    },
})
快捷键调用 code-runner 输出:
[Running] powershell.exe -Command - STDIN

--------------------
 delete mode 100644 src/test/hello.c
 delete mode 100644 src/example/delete.c

[Done] exited with code=0, single=0 in 0.916431 seconds
获取到上述文件列表后,通过 git.nvim 执行以下命令,就可以打开 log 列表,列表内回车会分屏展示 commit diff。
:Git log -1 -- src/test/hello.c

使用 git-ghosts 拓展

最近,给 git.nvim 增加了一个 git-ghosts 的 picker.nvim 拓展。其实现的原理就是参考上述的命令执行顺序。 先获取已删除文件的列表,回车获取改文件的最后一次 commit 的哈希值,然后执行:
:Git show <commit_hash>

参考链接

  • https://www.linux88.com/restore-a-deleted-document-in-git/
🔲 ☆

Neovim 中使用 luarocks

luarocks 是 lua 常用的包管理器,类似于 python 的 pip。前面使用 Lua 实现了一个 Neovim 的插件管理器,而目前我自己正在维护的插件也都是使用 Lua 来实现的。 因此使用 luarocks 来管理插件,同时又让插件管理器 nvim-plug 支持 luarocks 就显得很有必要了。看了下目前主流的插件管理器 lazy.nvim、rocks.nvim 也是支持 luarcoks 的。

安装 luarocks

在 Windows 下可以使用 scoop 命令进行安装:
scoop install luarocks
这里需要注意一下,上述命令会自动安装 lua 5.4,但是 Neovim 内默认使用的是 luajit 兼容的 lua 5.1。因此为了避免下载的 rocks 不兼容。可以再执行以下命令:
scoop uninstall lua
scoop install lua51
安装完成后检查一下:
luarocks config | rg deploy
输出内容:
deploy_bin_dir = "D:\\Scoop\\apps\\luarocks\\current\\rocks\\bin"
deploy_lib_dir = "D:\\Scoop\\apps\\luarocks\\current\\rocks\\lib\\lua\\5.1"
deploy_lua_dir = "D:\\Scoop\\apps\\luarocks\\current\\rocks\\share\\lua\\5.1"

在 Neovim 内使用 luarocks

在 Neovim 内使用 :lua 命令或者使用 lua 开发 Neovim 插件时, 若想要使用 luarocks 安装的包,其原理就是将 luarocks 所安装的包位置加入到 package.pathpackage.cpath: nvim-plug 中实现这一步骤的逻辑如下: lua/plug/rocks/init.lua
function M.enable()
    if enabled then
        return
    end
    local ok, _ = pcall(function()
        local luarocks_config = vim.json.decode(
            vim.system({ 'luarocks', 'config', '--json' }):wait().stdout
        )
        package.path = package.path
            .. ';'
            .. luarocks_config.deploy_lua_dir
            .. [[\?.lua]]
            .. ';'
            .. luarocks_config.deploy_lua_dir
            .. [[\?\init.lua]]
            .. ';'
        package.cpath = package.cpath
            .. ';'
            .. luarocks_config.deploy_lib_dir
            .. '\\?.'
            .. luarocks_config.external_lib_extension
        -- 此处,还可以将 luarcoks bin 目录加入到 PATH
        vim.env.PATH = vim.env.PATH .. ';' .. luarocks_config.deploy_bin_dir
    end)
    if ok then
        enabled = true
    end
end

使用 nvim-plug 下载 rocks

可以在添加插件时,指定 type = 'rocks',比如: plugins/mru.lua
return {
    'wsdjeg/mru.nvim',
    events = { 'UIEnter' },
    opts = {
        enable_cache = true,
        ignore_path_regexs = {
            '/.git/',
            '/nvim/runtime/doc/',
            '.mp3$',
            '.mp4$',
            '.png$',
            '.jpg$',
            '.exe$',
            'nvim-mru.json$',
            'tags$',
        },
        enable_logger = true,
        sort_by = 'lastenter',
    },
    type = 'rocks',
    desc = 'mru(most recently used) files',
}
参考以上方式添加插件后,nvim-plug 在安装插件时会自动调用 luarocks install plugin_name 这一命令。

luarocks 的限制

在实现完上述功能后,才发现 luarocks 这个包管理器似乎还有一些限制。比如:
  1. 不支持同时安装多个插件。 因为 nvim-plug 是使用异步 job 调用外部命令的,因此支持多线程。 但是起初实现后发现,当同时执行多个 luarocks install 命令时, 只有第一个是成功的,后续的命令都有会报这一错误:Error: command ‘install’ requires exclusive write access。 解决的办法是为 luarocks 实现单独的 tasks 序列,逐一执行,这样的话插件的安装会非常慢。一个是单线程,一个是 16 线程 (max_processes = 16)
  2. 无法根据 plugSpec 获取的 rtp 目录位置 一个最简单 plugSpec 比如 { 'wsdjeg/mru.nvim' }, 默认 type 是 git,我是可以获取到该插件默认的 runtimepath 值为 plug.config.bundle_dir .. '/' .. 'wsdjeg/mru.nvim', 此时就可以根据这个目录是否存在来判断插件是否已安装。 但是,这样一个 plugSpec:
    return {
      'wsdjeg/mru.nvim',
      type = 'rocks',
    }
    
    将无法获取到默认的 runtimepath 目录位置,因为他的格式是
    D:/Scoop/apps/luarocks/current/rocks/lib/luarocks/rocks-5.1/mru.nvim/1.4.0-1
    
    最后面这个版本号,除非是 plugSpec 内指定,否则是无法判断到默认的值的。 最终的解决方案是分析 luarocks list 命令的输出内容,返回一个类似与这样的 lua table:
    return {
      ['mru.nvim'] = {
        rtp = 'D:/Scoop/apps/luarocks/current/rocks/lib/luarocks/rocks-5.1/mru.nvim/1.4.0-1',
      },
      ['rooter.nvim'] = {
        rtp = 'D:/Scoop/apps/luarocks/current/rocks/lib/luarocks/rocks-5.1/rooter.nvim/1.3.0-1',
      },
    }
    

将插件发布到 LuaRocks

这里主要使用到两个 Github actions:
  • googleapis/release-please-action
  • nvim-neorocks/luarocks-tag-release
使用 googleapis/release-please-action 来自动打 tag 并且新建 GitHub release,可以参考之前的文章《Github 仓库自动 release》。 使用 nvim-neorocks/luarocks-tag-release GitHub action 自动将 tag 上传到 luarocks.org。 在仓库根目录新建文件 .github/workflows/luarocks.yml:
name: Push to Luarocks

on:
  push:
    tags: # Will upload to luarocks.org when a tag is pushed
      - "*"
  pull_request: # Will test a local install without uploading to luarocks.org
  workflow_dispatch:

jobs:
  luarocks-upload:
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@v4
      - name: LuaRocks Upload
        uses: nvim-neorocks/luarocks-tag-release@v7
        env:
          LUAROCKS_API_KEY: $
存在一个问题,luarocks-tag-release 使用 copy_directories 来复制打包插件的其他文件, 但是 luarocks 一旦使用了 copy_directories 这一设置,就不会复制默认的 doc 文件了。 相关 issue : luarocks/issues/1266 代码逻辑 src/luarocks/build.lua#L344-L363

模块载入问题

neovim 中直接使用 rocks 似乎还有问题,dll 文件载入时会报错,估计跟 luarocks 的包编译方式有关
D:\wsdjeg\my-blog>luarocks list | rg file -A 2
luafilesystem
   1.8.0-1 (installed) - D:\Scoop\apps\luarocks\current\rocks\lib\luarocks\rocks-5.1
然后在 Neovim 内执行 :lua require('lfs') 时,报错:
E5108: Error executing lua error loading module 'lfs' from file 'D:\Scoop\apps\luarocks\current\rocks\lib\lua\5.1\lfs.dll':
        找不到指定的模块。

stack traceback:
        [C]: at 0x7ff83ac1bdb0
        [C]: in function 'require'
        [string ":lua"]:1: in main chunk
实际上这个 dll 文件是存在的:

D:\Scoop\apps\luarocks\current\rocks\lib\lua\5.1>ls
lfs.dll

排除问题

使用 scoop 安装 dependencies,
scoop install dependencies
打开 lfs.dll 文件,发现确实是依赖问题: lfs.dll 在 lua51 的安装目录里:
D:\Scoop\apps\lua51\current>ls
Microsoft.VC80.CRT  install.json  lua5.1.dll.manifest  lua51.dll           manifest.json
bin2c5.1.exe        liblua5.1.a   lua5.1.exe           lua51.dll.manifest  wlua5.1.exe
include             lua5.1.dll    lua5.1.exe.manifest  luac5.1.exe         wlua5.1.exe.manifest
而 Neovim 中 :lua 调用的是:
D:\Scoop\apps\neovim\current\bin>ls
dbghelp.dll  lua51.dll  nvim.exe  platforms  win32yank.exe  xxd.exe
使用 scoop 安装 luajit:
scoop install luajit
看下 luajit 的目录结构
D:\Scoop\apps\luajit/..
  2.1.1762795099-1
  current ➛ 2.1.1762795099-1
    bin
       lua51.dll
       luajit
       luajit-2.1.1762795099.exe
       luajit.exe
    include/luajit-2.1
       lauxlib.h
       lua.h
       lua.hpp
       luaconf.h
       luajit.h
       lualib.h
    lib
    share
     install.json
     manifest.json
修改 D:\Scoop\apps\luarocks\current\config.lua 为:
lua_interpreter = "D:/Scoop/apps/luajit/current/bin/luajit.exe"
lua_version = "5.1"
rocks_trees = {
   "D:/Scoop/apps/luarocks/current/rocks"
}
variables = {
   LUA = "D:/Scoop/apps/luajit/current/bin/luajit.exe",
   LUA_BINDIR = "D:/Scoop/apps/luajit/current/bin",
   LUA_INCDIR = "D:/Scoop/apps/luajit/current/include/luajit-2.1",
   LUA_DIR = "D:/Scoop/apps/luajit/current/bin"
}
重新安装 luafilesystem:
luarocks install luafilesystem --force
此时再使用 Dependencies 查看 lfs.dll: lfs-ok 此时在 Neovim 中执行 :=require('lfs') 就会看到:
{
  _COPYRIGHT = "Copyright (C) 2003-2017 Kepler Project",
  _DESCRIPTION = "LuaFileSystem is a Lua library developed to complement the set of functions related to file systems offered by the standard Lua distribution",
  _VERSION = "LuaFileSystem 1.8.0",
  attributes = <function 1>,
  chdir = <function 2>,
  currentdir = <function 3>,
  dir = <function 4>,
  link = <function 5>,
  lock = <function 6>,
  lock_dir = <function 7>,
  mkdir = <function 8>,
  rmdir = <function 9>,
  setmode = <function 10>,
  symlinkattributes = <function 11>,
  touch = <function 12>,
  unlock = <function 13>
}

Neovim 终端中使用

为了能在 Neovim 内置终端中使用 lua,luajit,luarocks 等,给 nvim-plug 增加这样一个 patch:
diff --git a/lua/plug/rocks/init.lua b/lua/plug/rocks/init.lua
index e336791..58f391d 100644
--- a/lua/plug/rocks/init.lua
+++ b/lua/plug/rocks/init.lua
@@ -71,6 +71,8 @@ function M.enable()
             .. luarocks_config.deploy_lib_dir
             .. '\\?.'
             .. luarocks_config.external_lib_extension
+        vim.env.LUA_PATH = package.path
+        vim.env.LUA_CPATH = package.cpath
     end)
     if ok then
         enabled = true
这样在 Neovim 内置终端内使用 lua 命令,或者 luajit 命令,就会自动读取这两个变量值。
D:\wsdjeg\my-blog>lua
Lua 5.1.5  Copyright (C) 1994-2012 Lua.org, PUC-Rio
> print(require('lfs'))
table: 00000000004DCAC0
> 

D:\wsdjeg\my-blog>luajit
LuaJIT 2.1.1762795099 -- Copyright (C) 2005-2025 Mike Pall. https://luajit.org/
JIT: ON SSE3 SSE4.1 BMI2 fold cse dce fwd dse narrow loop abc sink fuse
> print(require('lfs'))
table: 0x01f72937bc70
> 
D:\wsdjeg\my-blog>echo print(require("lfs")) | nvim -l -  
table: 0x01dcba84a148
🔲 ☆

Neovim 窗口 API 参数 noautocmd 测试

最近在修改 notify.nvim 插件源码的时候有这么一段:
local win_config = {
  -- .....
}
if not vim.api.nvim_buf_is_valid(buf) then
  buf = vim.api.nvim_create_buf(false, true)
end
if not vim.api.nvim_win_is_valid(win) then
  win_config.noautocmd = true
  win = vim.api.nvim_open_win(buf, false, win_config)
else
  vim.api.nvim_win_set_config(win, win_config)
end
我把 win_config 内容省略了,主要想表达的意思是, 本来我想通过一个固定的窗口配置来打开或者设置窗口的参数。 但是 Neovim 的 nvim_open_winnvim_win_set_config 两个函数所接受的 win_opt 选项是有区别的,已存在的窗口使用后者设置时不能够传入 noautocmd 选项(neovim#36409)。
'noautocmd' cannot be used with existing windows
于是只能分开写,在调用 nvim_open_win 时传入 noautocmd 参数。 那么这个 noautocmd 到底禁用了哪些事件,以及禁用的时机时什么呢? 以下为测试脚本:
local buf = vim.api.nvim_create_buf(true, false)

local log = require('logger').derive('t_no')

local aug = vim.api.nvim_create_augroup('test_noautocmd', { clear = true })

vim.api.nvim_create_autocmd(
  { 'WinEnter', 'BufWinEnter', 'BufEnter', 'WinLeave', 'TextChangedI' },
  {
    pattern = { '*' },
    group = aug,
    callback = function(ev)
      log.info(ev.event)
    end,
  }
)

vim.api.nvim_open_win(buf, true, { split = 'above', noautocmd = true })

-- [ 20:43:20:664 ] [ Info  ] [   t_no ] TextChangedI
-- [ 20:43:23:092 ] [ Info  ] [   t_no ] WinLeave
-- [ 20:43:23:093 ] [ Info  ] [   t_no ] WinEnter
-- [ 20:43:23:094 ] [ Info  ] [   t_no ] BufEnter
local buf = vim.api.nvim_create_buf(true, false)

local log = require('logger').derive('t_no')

local aug = vim.api.nvim_create_augroup('test_noautocmd', { clear = true })

vim.api.nvim_create_autocmd(
  { 'WinEnter', 'BufWinEnter', 'BufEnter', 'WinLeave', 'TextChangedI' },
  {
    pattern = { '*' },
    group = aug,
    callback = function(ev)
      log.info(ev.event)
    end,
  }
)

vim.api.nvim_open_win(buf, true, { split = 'above', noautocmd = false })

-- [ 20:44:50:454 ] [ Info  ] [   t_no ] WinLeave
-- [ 20:44:50:455 ] [ Info  ] [   t_no ] WinEnter
-- [ 20:44:50:456 ] [ Info  ] [   t_no ] BufEnter
-- [ 20:44:50:456 ] [ Info  ] [   t_no ] BufWinEnter
-- [ 20:44:51:279 ] [ Info  ] [   t_no ] TextChangedI
-- [ 20:44:52:045 ] [ Info  ] [   t_no ] WinLeave
-- [ 20:44:52:046 ] [ Info  ] [   t_no ] WinEnter
-- [ 20:44:52:048 ] [ Info  ] [   t_no ] BufEnter
如果去看 API 的源码,neovim 这段 nvim_open_win api 的源码。
Window nvim_open_win(Buffer buffer, Boolean enter, Dict(win_config) *config, Error *err)
  FUNC_API_SINCE(6) FUNC_API_TEXTLOCK_ALLOW_CMDWIN
{
#define HAS_KEY_X(d, key) HAS_KEY(d, win_config, key)
  buf_T *buf = find_buffer_by_handle(buffer, err);
  if (!buf) {
    return 0;
  }
  if ((cmdwin_type != 0 && enter) || buf == cmdwin_buf) {
    api_set_error(err, kErrorTypeException, "%s", e_cmdwin);
    return 0;
  }

  WinConfig fconfig = WIN_CONFIG_INIT;
  if (!parse_win_config(NULL, config, &fconfig, false, err)) {
    return 0;
  }

  bool is_split = HAS_KEY_X(config, split) || HAS_KEY_X(config, vertical);
  Window rv = 0;
  if (fconfig.noautocmd) {
    block_autocmds();
  }

  win_T *wp = NULL;
  tabpage_T *tp = curtab;
  assert(curwin != NULL);
  win_T *parent = config->win == 0 ? curwin : NULL;
  if (config->win > 0) {
    parent = find_window_by_handle(fconfig.window, err);
    if (!parent) {
      // find_window_by_handle has already set the error
      goto cleanup;
    } else if (is_split && parent->w_floating) {
      api_set_error(err, kErrorTypeException, "Cannot split a floating window");
      goto cleanup;
    }
    tp = win_find_tabpage(parent);
  }
  if (is_split) {
    if (!check_split_disallowed_err(parent ? parent : curwin, err)) {
      goto cleanup;  // error already set
    }

    if (HAS_KEY_X(config, vertical) && !HAS_KEY_X(config, split)) {
      if (config->vertical) {
        fconfig.split = p_spr ? kWinSplitRight : kWinSplitLeft;
      } else {
        fconfig.split = p_sb ? kWinSplitBelow : kWinSplitAbove;
      }
    }
    int flags = win_split_flags(fconfig.split, parent == NULL) | WSP_NOENTER;
    int size = (flags & WSP_VERT) ? fconfig.width : fconfig.height;

    TRY_WRAP(err, {
      if (parent == NULL || parent == curwin) {
        wp = win_split_ins(size, flags, NULL, 0, NULL);
      } else {
        switchwin_T switchwin;
        // `parent` is valid in `tp`, so switch_win should not fail.
        const int result = switch_win(&switchwin, parent, tp, true);
        assert(result == OK);
        (void)result;
        wp = win_split_ins(size, flags, NULL, 0, NULL);
        restore_win(&switchwin, true);
      }
    });
    if (wp) {
      wp->w_config = fconfig;
      if (size > 0) {
        // Without room for the requested size, window sizes may have been equalized instead.
        // If the size differs from what was requested, try to set it again now.
        if ((flags & WSP_VERT) && wp->w_width != size) {
          win_setwidth_win(size, wp);
        } else if (!(flags & WSP_VERT) && wp->w_height != size) {
          win_setheight_win(size, wp);
        }
      }
    }
  } else {
    if (!check_split_disallowed_err(curwin, err)) {
      goto cleanup;  // error already set
    }
    wp = win_new_float(NULL, false, fconfig, err);
  }
  if (!wp) {
    if (!ERROR_SET(err)) {
      api_set_error(err, kErrorTypeException, "Failed to create window");
    }
    goto cleanup;
  }

  if (fconfig._cmdline_offset < INT_MAX) {
    cmdline_win = wp;
  }

  // Autocommands may close `wp` or move it to another tabpage, so update and check `tp` after each
  // event. In each case, `wp` should already be valid in `tp`, so switch_win should not fail.
  // Also, autocommands may free the `buf` to switch to, so store a bufref to check.
  bufref_T bufref;
  set_bufref(&bufref, buf);
  if (!fconfig.noautocmd) {
    switchwin_T switchwin;
    const int result = switch_win_noblock(&switchwin, wp, tp, true);
    assert(result == OK);
    (void)result;
    if (apply_autocmds(EVENT_WINNEW, NULL, NULL, false, curbuf)) {
      tp = win_find_tabpage(wp);
    }
    restore_win_noblock(&switchwin, true);
  }
  if (tp && enter) {
    goto_tabpage_win(tp, wp);
    tp = win_find_tabpage(wp);
  }
  if (tp && bufref_valid(&bufref) && buf != wp->w_buffer) {
    // win_set_buf temporarily makes `wp` the curwin to set the buffer.
    // If not entering `wp`, block Enter and Leave events. (cringe)
    const bool au_no_enter_leave = curwin != wp && !fconfig.noautocmd;
    if (au_no_enter_leave) {
      autocmd_no_enter++;
      autocmd_no_leave++;
    }
    win_set_buf(wp, buf, err);
    if (!fconfig.noautocmd) {
      tp = win_find_tabpage(wp);
    }
    if (au_no_enter_leave) {
      autocmd_no_enter--;
      autocmd_no_leave--;
    }
  }
  if (!tp) {
    api_set_error(err, kErrorTypeException, "Window was closed immediately");
    goto cleanup;
  }

  if (fconfig.style == kWinStyleMinimal) {
    win_set_minimal_style(wp);
    didset_window_options(wp, true);
  }
  rv = wp->handle;

cleanup:
  if (fconfig.noautocmd) {
    unblock_autocmds();
  }
  return rv;
#undef HAS_KEY_X
}
从源码中不难看出,这个 noautocmd 选项只是在 nvim_open_win 这个函数调用内起作用,在最后的时候使用 unblock_autocmds 又恢复的事件的响应。
🔲 ☆

Neovim 模糊搜索插件 picker.nvim

模糊搜索插件(fuzzy finder)算是日常使用 Neovim 必不可少的插件之一。早期我在使用 Vim 时,最开始使用过 ctrlp.vim、unite.vim、leaderf、以及后来切换到的 denite.vim。 因为一直是在 Windows 系统下居多,因此没使用 fzf 系列的插件,最开始的时候 Windows 默认终端支持这些命令可不是那么的友好。因此我更倾向于使用 Vim 内置窗口实现的这类插件。 随着 Neovim 的浮窗功能完善,使用浮窗实现的 telescope.nvim 是我日常主要使用的工具了,我自己也实现了好一些 telescope extensions。 但是 telescope 似乎太复杂了,而且个人感觉维护也变得不是那么的活跃了,因此就自己写了一个 picker.nvim

安装和配置

picker.nvim 的安装很简单,默认是没有任何仓库依赖,可以使用任意插件管理器进行安装,比如 nvim-plug
require('plug').add({
  {
    'wsdjeg/picker.nvim',
    config = function()
      require('picker').setup({
        filter = {
          ignorecase = false, -- ignorecase (boolean): defaults to false
        },
        window = {
          width = 0.8, -- set picker screen width, default is 0.8 * vim.o.columns
          height = 0.8,
          col = 0.1,
          row = 0.1,
          current_icon = '>',
          current_icon_hl = 'CursorLine',
          enable_preview = false,
          preview_timeout = 500,
        },
        highlight = {
          matched = 'Tag',
        },
        prompt = {
          position = 'bottom', -- set prompt position, bottom or top
          icon = '>',
          icon_hl = 'Error',
          insert_timeout = 100,
          title = true, -- display/hide source name
        },
        mappings = {
          close = '<Esc>',
          next_item = '<Tab>',
          previous_item = '<S-Tab>',
          open_item = '<Enter>',
          toggle_preview = '<C-p>',
        },
      })
    end,
  },
})
以上是插件初始化时,默认的配置,也可以参考我的配置文件:nvim-config/plugins/picker.lua, 在我的配置里,我使用 picker.nvim 接管了 Neovim 默认的 vim.ui.select 函数。

基本使用

  1. :Picker 命令看看目前支持的源(sources)
  2. :Picker source_name 指定打开某个源进行匹配搜索。
  3. --input 指定默认初始化输入的内容
  4. --input=<cword> 指定以光标下的词作为默认输入内容。
默认只有四个快捷键,可以在 setup 时指定,默认是以下四个按键:
key binding description
Tab next item
S-Tab previous item
Enter default action
Esc close picker
其他的快捷键通过 source 的 action() 函数返回定义。

内置 source

source description
buffers listed buffers
buftags ctags outline for current buffer
cmd_history results from :history :
colorscheme all colorschemes
files files in current dir
help_tags neovim help tags source
highlights highlight group source
jumps jump list
lines lines in current buffer
loclist location list source
lsp_document_symbols document symbols result from lsp client
lsp_references lsp references
lsp_workspace_symbols workspace symbols
marks marks list
picker_config picker config source
qflist quickfix source
registers registers context

其他插件 source

source description
mru most recent used files, need mru.nvim
project project history, need rooter.nvim
bookmarks all bookmarks, need bookmarks.nvim
zettelkasten zettelkasten notes source from zettelkasten.nvim
zettelkasten_tags zettelkasten tags source from zettelkasten.nvim
git-branch git branch source from git.nvim
music-player music-player source form music-player.nvim
plug plugins source for nvim-plug
async_files async files source, require job.nvim

如何自定义拓展

picker.nvim 的拓展实际上就是一个 Lua table:
local source = {}

---@return PickerItem[]
function source.get() end
---@param entry PickerItem
function source.default_action(entry) end

--- 只有需要使用到预览窗口,才需要定义 preview 函数。
source.preview_win = true
function source.preview(entry, win, buf) end
🔲 ⭐

Lua 可变参数

在 Lua 中遇到需要使用可变参数时,通常这样处理:
local function add(...)
    local l = 0
    for k, v in ipairs({...}) do
        l = l + v
    end
    return l
end

print(add(1, 2, 3, 4))

-- 输出:
-- 10
但是,如果传入的参数中间有 nil 那么,结果就不是预期的了:
local function add(...)
    local l = 0
    for k, v in ipairs({...}) do
        l = l + v
    end
    return l
end

print(add(1, 2, nil, 4))
-- 输出:
-- 3
因此,在遍历参数时,推荐使用 select 函数:
local function add(...)
    local l = 0
    for i = 1, select('#', ...), 1 do
        l = l + (select(i, ...) or 0)
    end
    return l
end

print(add(1, 2, nil, 4))
-- 输出:
-- 7

参考文章

🔲 ☆

高效处理 Markdown 中的代码块

在使用 Markdown 写作时,通常会在文章中需要插入代码块。为了更高效的编辑和执行代码块,引入以下几个插件及配置。

执行代码

新建 ~/.confg/nvim/ftplugin/markdown.lua
vim.keymap.set('n', '<leader>lr', function()
    local cf = vim.fn['context_filetype#get']()

    if cf.filetype ~= 'markdown' then
        local runner = require('code-runner').get(cf.filetype)
        runner['usestdin'] = true
        runner['range'] = { cf['range'][1][1], cf['range'][2][1] }
        require('code-runner').open(runner)
    end
end, { silent = true, buffer = true })

格式化代码

vim.keymap.set('n', '<leader>bf', function()
    local cf = vim.fn['context_filetype#get']()

    if cf.filetype ~= 'markdown' then
        local command = vim.fn.printf(
            '%s,%sFormat! %s',
            cf.range[1][1],
            cf.range[2][1],
            cf.filetype
        )
        vim.cmd(command)
    end
end, { silent = true, buffer = true })
🔲 ☆

为什么停止维护 SpaceVim

SpaceVim 从 2016 年 12 月诞生,到 2025 年 2 月 21 日正式归档,前后将近九年。 九年的时间足以让一个想法从萌芽走向成熟,也足以让一个项目逐渐找到它最合适的落点。 在我看来,归档并不意味着结束,也是新的一段开始。

项目的初衷

早期,开发这个项目的时候,是想做一个开箱即用的模块化 Vim 配置,类似于 spacemacs 那样, 把不同语言功能所需要的配置及插件都放到一个模块里面。使用者不再需要自己配置各种插件, 只需要简单地启用对应语言或者功能的模块就可以快速搭建 Vim 的开发环境。

遇到的问题

随着功能越来越多,代码越来越臃肿,速度也变得越来越慢。如果查看 git log 的话,不难发现, 很大一部分内容是做速度的优化。 甚至,我也使用 Lua 重写的相当一部分的内置插件,包括状态栏、标签栏、任务管理、项目管理、多光标、快捷键导航等等。 但是即便是使用 Lua 进行了重写,但是因为要兼容老版本的 Neovim 以及兼容 Vim,因此整体总的框架使用的是 Vim Script, 调用 Lua 插件的时候还是使用 VimL 去调用 Lua 的代码,例如:
func! SpaceVim#test(...)
    lua require('task').start(require('spacevim').eval('a:000'))
endf
这样的话速度还是无法发挥到极致。

停止维护的原因

  1. 想使用纯 Lua 来写 Neovim 配置(nvim-config),不再兼容 Vim,因为我也很少再使用 Vim 了。
  2. 不想做 Neovim 老版本的兼容支持
  3. 单个功能插件独立维护,便于直接使用,我把原先 spacevim 中内置的很多功能,

归档即是新的开始

归档并不是结束,而是完成一个阶段性作品后的放手。正如上述原因里所描述,我希望使用 Lua 并且不再兼容老版本以及 Vim,使得代码能够更简洁。 相较于再制作一个 Lua 开发的 SpaceVim,我更乐意把 SpaceVim 中日常使用较多的功能独立成单独的插件。这样也方便他人根据实际的需求选择最小化的配置。

插件列表

目前已经切换到纯 Lua 配置的 Neovim 环境了,并且原先 SpaceVim 中常用功能已单独使用 Lua 重写。 个人 Neovim 配置仓库地址:nvim-config。 以下是我自己制作并且日常使用的 Neovim 插件,欢迎尝试。

插件管理器 nvim-plug

nvim-plug 仓库地址:wsdjeg/nvim-plug 这是我参考 dein.nvim 以及 Vundle.vim 写的一个异步的插件管理器,已经实现了日常我对于插件管理器的要求:
  1. clone、pull、build 等操作都是多线程异步执行;
  2. 支持通过 autocmd、command、function 等方式触发延迟加载;
  3. 支持依赖管理、import 目录载入插件;
  4. 支持 luarocks;
对于插件管理器的界面,其实我需要的只是像 Vundle.vim, 以前使用 dein.nvim 时候,dein.nvim 是默认没有 UI 界面的,当时我写了一个 dein-ui.vim 用来展示插件安装的进度。

项目管理 rooter.nvim

仓库地址:rooter.nvim 在使用 Vim 编辑代码文件时,比较常用的一个功能是根据当前文件的路径自动将当前目录切换到项目的根目录。 这个功能以前我使用的是 vim-rooter 这个插件。 而 rooter.nvim 正是使用 Lua 重新实现该功能的 Neovim 插件, 不同的是,该插件额外提供了一些模糊搜索插件拓展,可以模糊搜索历史打开过的项目。 此外,这个插件还支持设置 callback 函数,当切换项目时自动执行。

异步代码检索 flygrep.nvim

仓库地址:flygrep.nvim 日常阅读代码过程中还是经常需要搜索代码的,flygrep。插件会异步调用rg,并实时展示搜索结果。

历史文件管理 mru.nvim

仓库地址:mru.nvim 类似于 v:oldfiles,解决的问题是Windows系统下路径格式问题,同时支持各种筛选选项。此外也提供了一个 telescope 拓展。

代码格式化 format.nvim

仓库地址:format.nvim 很早以前,我使用的代码格式化插件是 neoformat,这是使用 VimScript 开发的插件,为了更快的速度体验,我使用 Lua 重新写了一个格式化插件,插件的基本逻辑参考了 neoformat。也支持 markdown 内代码块的格式化。

版本控制 git.nvim

仓库地址:git.nvim 可以这么说,这应该是我日常使用最多的插件,比如 :Git add,:Git commit,等等。

代码运行 code-runner.nvim

仓库地址:code-runner.nvim vscode 里面有一个非常著名的插件,叫做CodeRunner,就是快速运行当前文件,并在下面分屏显示运行结果。code-runner.nvim 实现了想类似的功能,不仅仅可以运行当前文件,还可以快速运行 markdown 文件内当前代码块。

书签管理 bookmarks.nvim

仓库地址:bookmarks.nvim 可以标记当前行进入书签列表,支持添加备注内容,支持使用quickfix列表展示书签,也提供了一个 telescope 拓展以便于模糊搜索书签。

tags 管理 ctags.nvim

仓库地址:ctags.nvim 自动为当前项目生成ctags数据库,同时更新 &tags 选项,以便于使用ctrl-]跳转到光标tags定义位置。

滚动侧栏 scrollbar.nvim

仓库地址:scrollbar.nvim 自动在当前窗口右侧绘制一个滚动条,提示当前文档滚动页大致的位置。

笔记插件 zettelkasten.nvim

仓库地址:zettelkasten.nvim 这是一个做笔记的插件,支持标签内链,相互索引。

日历插件 calendar.nvim

calendar.nvim 仓库地址: calendar.nvim 记笔或者待办事项的时候,很多时候需要一个日历视图,来快速看下哪一天有记录。而 calendar.nvim 就是这样一个支持拓展的简易日历视图,可以方便的查阅日历,配合拓展还可以在特定日期执行某个拓展操作。 我目前最常用的是配合 zettelkasten.nvim 插件来记录和查阅每日笔记。

异步job管理 job.nvim

仓库地址:job.nvim 这是模拟 jobstart 的工具,基于libuv,可以用于异步执行命令。我的很多插件都是依赖这个job.nvim,比如插件管理器等等,需要异步执行外部命令的。

模糊搜索插件 picker.nvim

仓库地址:picker.nvim 早期在使用 Vim 时,使用过 ctrl-p、unite.vim、denite.nvim 等等插件。从 Neovim 浮窗功能出来后,逐步切换到了 telescope.nvim。 但是近期发现这个插件似乎不怎么在维护了,再加上自己也有想法写一个模糊搜索插件,于是就有了 picker.nvim。

项目文件跳转 altf.nvim

仓库地址:altf.nvim 这个插件提供了一个命令 :A,可以使用这个命令在头文件和C文件、或者源文件和测试文件等之间快速跳转。

按键弹窗 record-key.nvim

仓库地址:record-key.nvim 有时候制作录屏的时候,需要展示自己按键的顺序,这个插件以弹窗的形式,在右下方展示按键的顺序。

录屏 record-screen.nvim

仓库地址:record-screen.nvim 屏幕录制的软件其实挺多的,这个插件通过后台调用 ffmpeg,支持录制声音、话筒、摄像头等等。
❌