Eric's Blog 时光荏苒,岁月如梭

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

2026-03-10
Eric Wong

起因

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


版权声明:本文为原创文章,遵循 署名-非商业性使用-禁止演绎 4.0 国际 (CC BY-NC-ND 4.0)版权协议,转载请附上原文出处链接和本声明。


延生阅读

分享到:

评论