起因
最近在使用 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
这个函数的逻辑是:
- 将
\r\n替换为\n - 按换行符
\n分割数据 - 处理不完整的行,保存到
eof变量中等待下一次数据
问题在于:大多数 JSON 输出是不包含换行符的单行数据,或者是一个格式化的多行 JSON。当 JSON 数据较大时,可能会被系统分成多个数据块(chunk),但每个数据块内部可能没有换行符。
在这种情况下,buffered_data 函数会:
- 如果数据块中没有换行符,会将整个数据块视为一个”不完整的行”,保存到
eof中 - 此时
std_data是空的,不会触发on_stdout回调 - 只有当下一个数据块到来或者 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 模式下:
- 不进行行缓冲处理
- 直接将原始数据块传递给回调函数
- 数据可能会被分成多个块,每个块都是不完整的
解决方案
针对 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 回调中拼接所有数据块后再进行解析。