普通视图

发现新文章,点击刷新页面。
昨天以前Eric's Blog

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

2026年3月21日 08:00

问题背景

本文记录在使用 阿里云百炼平台 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 解析失败

2026年3月10日 08:00

起因

最近在使用 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 回调中拼接所有数据块后再进行解析。

Neovim 智能对话伙伴 chat.nvim

2026年2月3日 08:00
很长一段时间,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

2026年1月28日 08:00
在使用 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 ⭐

Neovim 日历插件 calendar.nvim

2026年1月4日 08:00
早在写 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 模块重载

2025年12月28日 08:00

事情起因

时隔十年,再次被 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

2025年12月25日 08:00

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 目录

2025年12月23日 08:00
前面再阅读一些插件源码时,发现一个问题,很多插件的使用了 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

2025年12月4日 08:00
有这样一个需求,要给自己多个 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 仓库中幽灵文件

2025年11月29日 08:00
在使用 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

2025年11月27日 08:00
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 测试

2025年11月12日 08:00
最近在修改 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

2025年10月31日 08:00
模糊搜索插件(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 可变参数

2025年8月24日 08:00
在 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 中的代码块

2025年8月24日 08:00
在使用 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

2025年5月3日 08:00
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,支持录制声音、话筒、摄像头等等。

Neovim 录制按键及屏幕

2025年4月12日 08:00
以前偶尔也会录制一些动图,使用的是 LICEcap,这也是一个开源项目,但是不能录制成视频。 在网上搜到不少的录视频的软件,比较火热的是班迪录屏,但是我还是倾向于免费开源方案。

按键提示

Neovim 增加了一个 vim.on_key,这个函数在按键按下后会触发。借助这个函数及 Neovim 的悬浮窗口, 实现了一个按键弹窗提示的效果插件 record-key.nvim。 默认的着色是 Normal 高亮组,如果需要突出显示,可以设置为:
require('record-key').setup({
  timeout = 3000,
  max_count = 7,
  winhighlight = 'NormalFloat:Todo,FloatBorder:WinSeparator',
})

录屏插件

record-screen.nvim 是一个 Neovim 屏幕录制的插件, 借助 ffmpeg 这个命令和 Neovim 的异步机制。

硬件设备检查

前面的设置是只录制桌面图像,但是在日常视频录制的过程中,还是会涉及到其他的一些设备,比如麦克风、摄像头、音响等等。 首先使用 ffmpeg -list_devices true -f dshow -i dummy 命令获取设备列表:
ffmpeg version 7.1.1-full_build-www.gyan.dev Copyright (c) 2000-2025 the FFmpeg developers
  built with gcc 14.2.0 (Rev1, Built by MSYS2 project)
  configuration: --enable-gpl --enable-version3 --enable-static --disable-w32threads --disable-autodetect --enable-fontconfig --enable-iconv --enable-gnutls --enable-lcms2 --enable-libxml2 --enable-gmp --enable-bzlib --enable-lzma --enable-libsnappy --enable-zlib --enable-librist --enable-libsrt --enable-libssh --enable-libzmq --enable-avisynth --enable-libbluray --enable-libcaca --enable-libdvdnav --enable-libdvdread --enable-sdl2 --enable-libaribb24 --enable-libaribcaption --enable-libdav1d --enable-libdavs2 --enable-libopenjpeg --enable-libquirc --enable-libuavs3d --enable-libxevd --enable-libzvbi --enable-libqrencode --enable-librav1e --enable-libsvtav1 --enable-libvvenc --enable-libwebp --enable-libx264 --enable-libx265 --enable-libxavs2 --enable-libxeve --enable-libxvid --enable-libaom --enable-libjxl --enable-libvpx --enable-mediafoundation --enable-libass --enable-frei0r --enable-libfreetype --enable-libfribidi --enable-libharfbuzz --enable-liblensfun --enable-libvidstab --enable-libvmaf --enable-libzimg --enable-amf --enable-cuda-llvm --enable-cuvid --enable-dxva2 --enable-d3d11va --enable-d3d12va --enable-ffnvcodec --enable-libvpl --enable-nvdec --enable-nvenc --enable-vaapi --enable-libshaderc --enable-vulkan --enable-libplacebo --enable-opencl --enable-libcdio --enable-libgme --enable-libmodplug --enable-libopenmpt --enable-libopencore-amrwb --enable-libmp3lame --enable-libshine --enable-libtheora --enable-libtwolame --enable-libvo-amrwbenc --enable-libcodec2 --enable-libilbc --enable-libgsm --enable-liblc3 --enable-libopencore-amrnb --enable-libopus --enable-libspeex --enable-libvorbis --enable-ladspa --enable-libbs2b --enable-libflite --enable-libmysofa --enable-librubberband --enable-libsoxr --enable-chromaprint
  libavutil      59. 39.100 / 59. 39.100
  libavcodec     61. 19.101 / 61. 19.101
  libavformat    61.  7.100 / 61.  7.100
  libavdevice    61.  3.100 / 61.  3.100
  libavfilter    10.  4.100 / 10.  4.100
  libswscale      8.  3.100 /  8.  3.100
  libswresample   5.  3.100 /  5.  3.100
  libpostproc    58.  3.100 / 58.  3.100
[dshow @ 000001f024fc6d40] "Integrated Camera" (video)
[dshow @ 000001f024fc6d40]   Alternative name "@device_pnp_\\?\usb#vid_13d3&pid_5419&mi_00#7&17d116b8&1&0000#{65e8773d-8f56-11d0-a3b9-00a0c9223196}\global"
[dshow @ 000001f024fc6d40] "麦克风阵列 (Realtek(R) Audio)" (audio)
[dshow @ 000001f024fc6d40]   Alternative name "@device_cm_{33D9A762-90C8-11D0-BD43-00A0C911CE86}\wave_{253B9838-6E9C-47DB-A420-848E63B3931C}"
[in#0 @ 000001f024fad380] Error opening input: Immediate exit requested
Error opening input file dummy.
从上述的输出内容,不难看出,目前支持的设备仅仅有摄像头Integrated Camera和麦克风麦克风阵列 (Realtek(R) Audio),并没有扬声器,解决方法如下: 一、鼠标右键点击桌面右下角音量图标 二、打开更多音量设置 三、点击录制、右击立体声混声启用 这时候再执行 ffmpeg -list_devices true -f dshow -i dummy 输出为:
ffmpeg version 7.1.1-full_build-www.gyan.dev Copyright (c) 2000-2025 the FFmpeg developers
  built with gcc 14.2.0 (Rev1, Built by MSYS2 project)
  configuration: --enable-gpl --enable-version3 --enable-static --disable-w32threads --disable-autodetect --enable-fontconfig --enable-iconv --enable-gnutls --enable-lcms2 --enable-libxml2 --enable-gmp --enable-bzlib --enable-lzma --enable-libsnappy --enable-zlib --enable-librist --enable-libsrt --enable-libssh --enable-libzmq --enable-avisynth --enable-libbluray --enable-libcaca --enable-libdvdnav --enable-libdvdread --enable-sdl2 --enable-libaribb24 --enable-libaribcaption --enable-libdav1d --enable-libdavs2 --enable-libopenjpeg --enable-libquirc --enable-libuavs3d --enable-libxevd --enable-libzvbi --enable-libqrencode --enable-librav1e --enable-libsvtav1 --enable-libvvenc --enable-libwebp --enable-libx264 --enable-libx265 --enable-libxavs2 --enable-libxeve --enable-libxvid --enable-libaom --enable-libjxl --enable-libvpx --enable-mediafoundation --enable-libass --enable-frei0r --enable-libfreetype --enable-libfribidi --enable-libharfbuzz --enable-liblensfun --enable-libvidstab --enable-libvmaf --enable-libzimg --enable-amf --enable-cuda-llvm --enable-cuvid --enable-dxva2 --enable-d3d11va --enable-d3d12va --enable-ffnvcodec --enable-libvpl --enable-nvdec --enable-nvenc --enable-vaapi --enable-libshaderc --enable-vulkan --enable-libplacebo --enable-opencl --enable-libcdio --enable-libgme --enable-libmodplug --enable-libopenmpt --enable-libopencore-amrwb --enable-libmp3lame --enable-libshine --enable-libtheora --enable-libtwolame --enable-libvo-amrwbenc --enable-libcodec2 --enable-libilbc --enable-libgsm --enable-liblc3 --enable-libopencore-amrnb --enable-libopus --enable-libspeex --enable-libvorbis --enable-ladspa --enable-libbs2b --enable-libflite --enable-libmysofa --enable-librubberband --enable-libsoxr --enable-chromaprint
  libavutil      59. 39.100 / 59. 39.100
  libavcodec     61. 19.101 / 61. 19.101
  libavformat    61.  7.100 / 61.  7.100
  libavdevice    61.  3.100 / 61.  3.100
  libavfilter    10.  4.100 / 10.  4.100
  libswscale      8.  3.100 /  8.  3.100
  libswresample   5.  3.100 /  5.  3.100
  libpostproc    58.  3.100 / 58.  3.100
[dshow @ 0000020942ff6cc0] "Integrated Camera" (video)
[dshow @ 0000020942ff6cc0]   Alternative name "@device_pnp_\\?\usb#vid_13d3&pid_5419&mi_00#7&17d116b8&1&0000#{65e8773d-8f56-11d0-a3b9-00a0c9223196}\global"
[dshow @ 0000020942ff6cc0] "麦克风阵列 (Realtek(R) Audio)" (audio)
[dshow @ 0000020942ff6cc0]   Alternative name "@device_cm_{33D9A762-90C8-11D0-BD43-00A0C911CE86}\wave_{253B9838-6E9C-47DB-A420-848E63B3931C}"
[dshow @ 0000020942ff6cc0] "立体声混音 (Realtek(R) Audio)" (audio)
[dshow @ 0000020942ff6cc0]   Alternative name "@device_cm_{33D9A762-90C8-11D0-BD43-00A0C911CE86}\wave_{A04F0465-843E-4E64-891D-31A28192D215}"
[in#0 @ 0000020942fdd340] Error opening input: Immediate exit requested
Error opening input file dummy.
可以看到有三个设备:
  • Integrated Camera
  • 麦克风阵列 (Realtek(R) Audio)
  • 立体声混音 (Realtek(R) Audio)
当然,也可以安装开源项目 rdp/screen-capture-recorder-to-video-windows-free, 安装完成后,`` 命令输出如下:
ffmpeg version 7.1.1-full_build-www.gyan.dev Copyright (c) 2000-2025 the FFmpeg developers
  built with gcc 14.2.0 (Rev1, Built by MSYS2 project)
  configuration: --enable-gpl --enable-version3 --enable-static --disable-w32threads --disable-autodetect --enable-fontconfig --enable-iconv --enable-gnutls --enable-lcms2 --enable-libxml2 --enable-gmp --enable-bzlib --enable-lzma --enable-libsnappy --enable-zlib --enable-librist --enable-libsrt --enable-libssh --enable-libzmq --enable-avisynth --enable-libbluray --enable-libcaca --enable-libdvdnav --enable-libdvdread --enable-sdl2 --enable-libaribb24 --enable-libaribcaption --enable-libdav1d --enable-libdavs2 --enable-libopenjpeg --enable-libquirc --enable-libuavs3d --enable-libxevd --enable-libzvbi --enable-libqrencode --enable-librav1e --enable-libsvtav1 --enable-libvvenc --enable-libwebp --enable-libx264 --enable-libx265 --enable-libxavs2 --enable-libxeve --enable-libxvid --enable-libaom --enable-libjxl --enable-libvpx --enable-mediafoundation --enable-libass --enable-frei0r --enable-libfreetype --enable-libfribidi --enable-libharfbuzz --enable-liblensfun --enable-libvidstab --enable-libvmaf --enable-libzimg --enable-amf --enable-cuda-llvm --enable-cuvid --enable-dxva2 --enable-d3d11va --enable-d3d12va --enable-ffnvcodec --enable-libvpl --enable-nvdec --enable-nvenc --enable-vaapi --enable-libshaderc --enable-vulkan --enable-libplacebo --enable-opencl --enable-libcdio --enable-libgme --enable-libmodplug --enable-libopenmpt --enable-libopencore-amrwb --enable-libmp3lame --enable-libshine --enable-libtheora --enable-libtwolame --enable-libvo-amrwbenc --enable-libcodec2 --enable-libilbc --enable-libgsm --enable-liblc3 --enable-libopencore-amrnb --enable-libopus --enable-libspeex --enable-libvorbis --enable-ladspa --enable-libbs2b --enable-libflite --enable-libmysofa --enable-librubberband --enable-libsoxr --enable-chromaprint
  libavutil      59. 39.100 / 59. 39.100
  libavcodec     61. 19.101 / 61. 19.101
  libavformat    61.  7.100 / 61.  7.100
  libavdevice    61.  3.100 / 61.  3.100
  libavfilter    10.  4.100 / 10.  4.100
  libswscale      8.  3.100 /  8.  3.100
  libswresample   5.  3.100 /  5.  3.100
  libpostproc    58.  3.100 / 58.  3.100
[dshow @ 0000020d0d8f6d40] "Integrated Camera" (video)
[dshow @ 0000020d0d8f6d40]   Alternative name "@device_pnp_\\?\usb#vid_13d3&pid_5419&mi_00#7&17d116b8&1&0000#{65e8773d-8f56-11d0-a3b9-00a0c9223196}\global"
[dshow @ 0000020d0d8f6d40] "screen-capture-recorder" (video)
[dshow @ 0000020d0d8f6d40]   Alternative name "@device_sw_{860BB310-5D01-11D0-BD3B-00A0C911CE86}\{4EA69364-2C8A-4AE6-A561-56E4B5044439}"
[dshow @ 0000020d0d8f6d40] "立体声混音 (Realtek(R) Audio)" (audio)
[dshow @ 0000020d0d8f6d40]   Alternative name "@device_cm_{33D9A762-90C8-11D0-BD43-00A0C911CE86}\wave_{A04F0465-843E-4E64-891D-31A28192D215}"
[dshow @ 0000020d0d8f6d40] "virtual-audio-capturer" (audio)
[dshow @ 0000020d0d8f6d40]   Alternative name "@device_sw_{33D9A762-90C8-11D0-BD43-00A0C911CE86}\{8E146464-DB61-4309-AFA1-3578E927E935}"
[dshow @ 0000020d0d8f6d40] "麦克风阵列 (Realtek(R) Audio)" (audio)
[dshow @ 0000020d0d8f6d40]   Alternative name "@device_cm_{33D9A762-90C8-11D0-BD43-00A0C911CE86}\wave_{253B9838-6E9C-47DB-A420-848E63B3931C}"
[in#0 @ 0000020d0d8dd380] Error opening input: Immediate exit requested
Error opening input file dummy.
此时多出了两个设备:
  • screen-capture-recorder
  • virtual-audio-capturer

仅录制屏幕

如果仅仅需要录制桌面,而不需要录制摄像头和声音,可以使用如下配置:
require('record-screen').setup({
    command = 'ffmpeg',
    argvs = {
        '-f',
        'gdigrab',
        '-i',
        'desktop',
        '-pix_fmt',
        'yuv420p',
        '-f',
        'mp4',
    },
})

录制屏幕、麦克风、扬声器

require('record-screen').setup({
    cmd = 'ffmpeg',
    argvs = {
        '-f',
        'dshow',
        '-i',
        'audio=麦克风阵列 (Realtek(R) Audio)',
        '-f',
        'dshow',
        '-i',
        'audio=立体声混音 (Realtek(R) Audio)',
        '-filter_complex',
        'amix=inputs=2:duration=first:dropout_transition=2',
        '-f',
        'gdigrab',
        '-r',
        '60',
        '-draw_mouse',
        '1',
        '-i',
        'desktop',
        '-pix_fmt',
        'yuv420p',
        '-f',
        'mp4',
    },
})
录制视频时,偶尔会遇到类似于 real-time buffer frame dropped 这样的错误提示, 参考issue 136 提到的解决方案,增加参数 -rtbufsize 1500M

安装配置

使用 nvim-plug 插件管理器:
require('plug').add({
    {
        'wsdjeg/record-key.nvim',
        cmds = { 'RecordKeyToggle' },
        config_before = function()
            vim.keymap.set(
                'n',
                '<leader>rk',
                '<cmd>RecordKeyToggle<cr>',
                { silent = true }
            )
        end,
    },
    {
        'wsdjeg/record-screen.nvim',
        depends = {
            { 'wsdjeg/job.nvim' },
            { 'wsdjeg/notify.nvim' },
        },
        config = function()
            require('plugins.record-screen')
        end,
    },
})
插件详细配置参考文件 nvim-config/lua/plugins/record-screen.lua 通过这个配置文件,我新建了一个用户自定义命令 :RecordScreen,支持如下参数:
  • -camera: 录制摄像头
  • -speaker: 录制扬声器
  • --microphone: 录制麦克风
使用 :RecordScreen stop 停止录屏。

参考阅读

Neovim 自动切换至项目根目录

2025年3月22日 08:00
在使用 Neovim 打开某个文件时,我希望 Neovim 自动把当前目录切换到该文件所在的项目根目录。 其实,能实现这一功能的有不少的插件,我最早使用的是 vim-rooter,但是后来因为切换到了 Neovim, 因此使用 Lua 重写了该功能,这个功能早期是 SpaceVim 的内置的, 在 SpaceVim 项目停止维护后独立成单独的插件:rooter.nvim

安装 rooter.nvim

可以使用任意插件管理器进行安装,比如 nvim-plug
require('plug').add({
  {
    'wsdjeg/rooter.nvim',
    config = function()
      require('rooter').setup({})
    end,
  },
})

插件配置

以下是默认的配置:
require('rooter').setup({
  root_patterns = { '.git/' },
  outermost = true,
  enable_cache = true,
  project_non_root = '',  -- this can be '', 'home' or 'current'
  enable_logger = true,   -- enable runtime log via logger.nvim
})
  • project_non_root: 配置打开非项目文件时的行为
  • outermost: 若设为 true,那么通过 root_patterns 检索到的多个目录时,取最外层目录。

Telescope 拓展

rooter.nvim 自带 telescope.nvim 拓展,可以使用 :Telescope project 列出过往打开过的项目。 telescope project

插件运行日志

在插件运行过程中产生的日志信息,可以使用 logger.nvim 进行查看。 如果有这一需求,那么在安装 rooter.nvim 时,需要添加相应的依赖插件。
require('plug').add({
  {
    'wsdjeg/rooter.nvim',
    config = function()
      require('rooter').setup({})
    end,
    depends = {
      {
        'wsdjeg/logger.nvim',
      },
    },
  },
})

设置 callback 函数

通过 reg_callback 可以设置 callback 函数,该函数会在项目切换时被调用。
local function update_ctags_option()
  local project_root = vim.fn.getcwd()
  local dir = require('util').unify_path(require('tags').cache_dir) 
        .. require('util').path_to_fname(project_root)
  table.insert(tags, dir .. '/tags')
  vim.o.tags = table.concat(tags, ',')
end
require('rooter').reg_callback(update_gtags_option)
❌
❌