📝

Neovimのvim.systemで起動したプロセスの完了を待たずに標準出力を1行ずつ処理する

2025/01/04に公開

vim.system()はNeovim Luaで外部プロセスを扱うのに便利。
しかし、タイトルのケースでは注意が必要なため対応例を示す。

https://neovim.io/doc/user/lua.html#vim.system()

プロセスの完了を待って処理する場合

vim.system()で外部プロセスを起動し完了後に標準出力を処理する場合は以下でいい。

vim.system({ "ls" }, {
  text = true,
}, function(o)
  if o.code ~= 0 then
    error(o.stderr)
  end
  vim.print(o.stdout)
end)

プロセスの完了を待たずに処理する場合

完了を待ちたくない、かつ、大きめの出力を行ごとに処理する場合には工夫が必要になる。
以下はパイプの容量を超える長さの行が分割されている。

vim.system({ "echo", "-n", "test1\n" .. ("a"):rep(65536) .. [[a
test2
test3]] }, {
  text = true,
  stdout = function(err, str)
    assert(not err, err)
    if not str then
      return
    end
    vim.print(vim.split(str, "\n", { plain = true }))
  end,
})

出力

{ "test1", "aaa...(省略)" }
{ "aaaaaaa", "test2", "test3" }

📝 参考: https://man7.org/linux/man-pages/man7/pipe.7.html のPipe capacity

参考: 容量を変更したパイプを使ってみる

vim.system()の内部ではvim.uv.spawn()を使っている。
以下のコードではvim.uv.spawn()で容量を変えたパイプを使ってみて変化を確認している。

local ffi = require("ffi")
ffi.cdef([[
int fcntl(int fd, int cmd, ...);
]])
local F_SETPIPE_SZ = 1031

local fds = assert(vim.uv.pipe({ nonblock = true }, { nonblock = true }))
ffi.C.fcntl(fds.read, F_SETPIPE_SZ, 4096)

local write_pipe = assert(vim.uv.new_pipe(false))
write_pipe:open(fds.write)

local read_pipe = assert(vim.uv.new_pipe(false))
read_pipe:open(fds.read)

local opts = {
  args = { ("a"):rep(4097) },
  stdio = { nil, write_pipe, nil },
}
local handle = vim.uv.spawn("echo", opts, function() end)

local count = 0
read_pipe:read_start(function(err, data)
  assert(not err, err)
  count = count + 1
  vim.print({
    count = count,
    data = data,
  })
end)

vim.wait(1000, function()
  return handle:is_active() == false
end, nil, true)

出力

{
  count = 1,
  data = "aaa...(省略)"
}
{
  count = 2,
  data = "a\n"
}

変更後のパイプの容量を超える出力のため1行が分割されている。

参考: パイプの容量を確認する
local ffi = require("ffi")
ffi.cdef([[
int fcntl(int fd, int cmd, ...);
]])
local F_GETPIPE_SZ = 1032

local fds = assert(vim.uv.pipe({ nonblock = true }, { nonblock = true }))
local size = ffi.C.fcntl(fds.read, F_GETPIPE_SZ)
print(size)

出力

65536

jobstart()に関する補足

Vim Scriptの関数のjobstart()にも似たような挙動が存在し、
以下のヘルプで対応方法が説明されている。
こちらはstringでなくstring[]として出力を受け取る。

https://neovim.io/doc/user/channel.html#channel-lines

行ごとに処理する実装例

出力を受け取るごとに改行で分割して、最後の要素は不完全な行の可能性があるため次の出力と結合する。
これを繰り返す。
末尾の空行をトリムしていい場合はvim.split()trimemptyオプションを使って少し簡略化できる。

local new_line_parser = function()
  local incomplete
  local finish = function()
    if incomplete == nil then
      return {}
    end
    return vim.split(incomplete, "\n", { plain = true })
  end
  return function(data)
    if not data then
      return finish()
    end

    local joined = (incomplete or "") .. data
    local lines = vim.split(joined, "\n", { plain = true })
    incomplete = table.remove(lines)
    return lines
  end
end

local to_lines = new_line_parser()
vim.system({ "echo", "-n", "test1\n" .. ("a"):rep(65536) .. [[a
test2
test3]] }, {
  text = true,
  stdout = function(err, data)
    assert(not err, err)
    vim.print(to_lines(data))
  end,
})
{ "test1" }
{ "aaa...(省略)", "test2" }
{ "test3" }

感想

パイプを使っていれば言語に依存しないよくある話なんだと思うが、
大きめの出力でしか起きないバグを生むので気をつけたい。

Discussion