refactor: async processes

This commit is contained in:
Folke Lemaitre 2024-06-28 16:08:26 +02:00
parent 4319846b8c
commit a36ebd2a75
No known key found for this signature in database
GPG Key ID: 41F8B1FBACAE2040
12 changed files with 394 additions and 379 deletions

View File

@ -1,78 +1,122 @@
---@class AsyncOpts
---@field on_done? fun()
---@field on_error? fun(err:string)
---@field on_yield? fun(res:any)
local M = {}
---@type Async[]
M._queue = {}
M._executor = assert(vim.loop.new_check())
M._running = false
---@type Async
M.current = nil
---@type table<thread, Async>
M._threads = setmetatable({}, { __mode = "k" })
---@alias AsyncEvent "done" | "error" | "yield" | "ok"
---@class Async
---@field co thread
---@field opts AsyncOpts
---@field sleeping? boolean
---@field _co thread
---@field _fn fun()
---@field _suspended? boolean
---@field _on table<AsyncEvent, fun(res:any, async:Async)[]>
local Async = {}
---@param fn async fun()
---@param opts? AsyncOpts
---@return Async
function Async.new(fn, opts)
function Async.new(fn)
local self = setmetatable({}, { __index = Async })
self.co = coroutine.create(fn)
self.opts = opts or {}
return self:init(fn)
end
---@param fn async fun()
---@return Async
function Async:init(fn)
self._fn = fn
self._on = {}
self._co = coroutine.create(function()
local ok, err = pcall(self._fn)
if not ok then
self:_emit("error", err)
end
self:_emit("done")
end)
M._threads[self._co] = self
return M.add(self)
end
function Async:restart()
assert(not self:running(), "Cannot restart a running async")
self:init(self._fn)
end
---@param event AsyncEvent
---@param cb async fun(res:any, async:Async)
function Async:on(event, cb)
self._on[event] = self._on[event] or {}
table.insert(self._on[event], cb)
return self
end
function Async:running()
return coroutine.status(self.co) ~= "dead"
---@private
---@param event AsyncEvent
---@param res any
function Async:_emit(event, res)
for _, cb in ipairs(self._on[event] or {}) do
cb(res, self)
end
end
function Async:running()
return coroutine.status(self._co) ~= "dead"
end
---@async
function Async:sleep(ms)
self.sleeping = true
self._suspended = true
vim.defer_fn(function()
self.sleeping = false
self._suspended = false
end, ms)
coroutine.yield()
end
---@async
function Async:suspend()
self.sleeping = true
self._suspended = true
if coroutine.running() == self._co then
coroutine.yield()
end
end
function Async:resume()
self.sleeping = false
self._suspended = false
end
function Async:wait()
local async = M.running()
if coroutine.running() == self._co then
error("Cannot wait on self")
end
while self:running() do
if async then
coroutine.yield()
else
vim.wait(10)
end
end
return self
end
function Async:step()
if self.sleeping then
if self._suspended then
return true
end
local status = coroutine.status(self.co)
local status = coroutine.status(self._co)
if status == "suspended" then
M.current = self
local ok, res = coroutine.resume(self.co)
M.current = nil
local ok, res = coroutine.resume(self._co)
if not ok then
if self.opts.on_error then
self.opts.on_error(tostring(res))
end
error(res)
elseif res then
if self.opts.on_yield then
self.opts.on_yield(res)
self:_emit("yield", res)
end
end
end
if self:running() then
return true
end
if self.opts.on_done then
self.opts.on_done()
end
return self:running()
end
function M.step()
@ -107,32 +151,24 @@ function M.add(async)
return async
end
---@param fn async fun()
---@param opts? AsyncOpts
function M.run(fn, opts)
return M.add(Async.new(fn, opts))
end
---@generic T: async fun()
---@param fn T
---@param opts? AsyncOpts
---@return T
function M.wrap(fn, opts)
return function(...)
local args = { ... }
---@async
local wrapped = function()
return fn(unpack(args))
end
return M.run(wrapped, opts)
function M.running()
local co = coroutine.running()
if co then
local async = M._threads[co]
assert(async, "In coroutine without async context")
return async
end
end
---@async
---@param ms number
function M.sleep(ms)
assert(M.current, "Not in an async context")
M.current:sleep(ms)
local async = M.running()
assert(async, "Not in an async context")
async:sleep(ms)
end
M.Async = Async
M.new = Async.new
return M

View File

@ -236,7 +236,7 @@ function M.clear(plugins)
if plugin._.tasks then
---@param task LazyTask
plugin._.tasks = vim.tbl_filter(function(task)
return task:is_running() or task:has_errors()
return task:running() or task:has_errors()
end, plugin._.tasks)
end
end

View File

@ -1,67 +1,133 @@
local Async = require("lazy.async")
local Config = require("lazy.core.config")
local M = {}
---@type table<uv_process_t, true>
M.running = {}
M.signals = {
"HUP",
"INT",
"QUIT",
"ILL",
"TRAP",
"ABRT",
"BUS",
"FPE",
"KILL",
"USR1",
"SEGV",
"USR2",
"PIPE",
"ALRM",
"TERM",
"CHLD",
"CONT",
"STOP",
"TSTP",
"TTIN",
"TTOU",
"URG",
"XCPU",
"XFSZ",
"VTALRM",
"PROF",
"WINCH",
"IO",
"PWR",
"EMT",
"SYS",
"INFO",
}
---@diagnostic disable-next-line: no-unknown
local uv = vim.uv
---@class ProcessOpts
---@field args string[]
---@field cwd? string
---@field on_line? fun(string)
---@field on_line? fun(line:string)
---@field on_exit? fun(ok:boolean, output:string)
---@field on_data? fun(string)
---@field on_data? fun(data:string, is_stderr?:boolean)
---@field timeout? number
---@field env? table<string,string>
---@param opts? ProcessOpts
---@param cmd string
function M.spawn(cmd, opts)
opts = opts or {}
opts.timeout = opts.timeout or (Config.options.git and Config.options.git.timeout * 1000)
local M = {}
---@type table<uv_process_t, LazyProcess>
M.running = setmetatable({}, { __mode = "k" })
---@class LazyProcess: Async
---@field handle? uv_process_t
---@field pid? number
---@field cmd string
---@field opts ProcessOpts
---@field timeout? uv_timer_t
---@field timedout? boolean
---@field data string
---@field check? uv_check_t
---@field code? number
---@field signal? number
local Process = setmetatable({}, { __index = Async.Async })
---@param cmd string|string[]
---@param opts? ProcessOpts
function Process.new(cmd, opts)
local self = setmetatable({}, { __index = Process })
---@async
Process.init(self, function()
self:_run()
end)
opts = opts or {}
opts.args = opts.args or {}
if type(cmd) == "table" then
self.cmd = table.remove(cmd, 1)
vim.list_extend(opts.args, cmd)
else
self.cmd = cmd
end
opts.timeout = opts.timeout or (Config.options.git and Config.options.git.timeout * 1000)
-- make sure the cwd is valid
if not opts.cwd and type(uv.cwd()) ~= "string" then
opts.cwd = uv.os_homedir()
end
opts.on_line = opts.on_line and vim.schedule_wrap(opts.on_line) or nil
opts.on_data = opts.on_data and vim.schedule_wrap(opts.on_data) or nil
self.data = ""
self.opts = opts
self.code = 1
self.signal = 0
return self
end
---@async
function Process:_run()
self:guard()
local stdout = assert(uv.new_pipe())
local stderr = assert(uv.new_pipe())
self.handle = uv.spawn(self.cmd, {
stdio = { nil, stdout, stderr },
args = self.opts.args,
cwd = self.opts.cwd,
env = self:env(),
}, function(code, signal)
self.code = code
self.signal = signal
if self.timeout then
self.timeout:stop()
end
self.handle:close()
stdout:close()
stderr:close()
self:resume()
end)
if self.handle then
M.running[self.handle] = self
stdout:read_start(function(err, data)
self:on_data(err, data)
end)
stderr:read_start(function(err, data)
self:on_data(err, data, true)
end)
self:suspend()
while not (self.handle:is_closing() and stdout:is_closing() and stderr:is_closing()) do
coroutine.yield()
end
else
self.data = "Failed to spawn process " .. self.cmd .. " " .. vim.inspect(self.opts)
end
self:on_exit()
end
function Process:on_exit()
self.data = self.data:gsub("[^\r\n]+\r", "")
if self.timedout then
self.data = self.data .. "\n" .. "Process was killed because it reached the timeout"
elseif self.signal ~= 0 then
self.data = self.data .. "\n" .. "Process was killed with SIG" .. M.signals[self.signal]:upper()
end
if self.opts.on_exit then
self.opts.on_exit(self.code == 0 and self.signal == 0, self.data)
end
end
function Process:guard()
if self.opts.timeout then
self.timeout = assert(uv.new_timer())
self.timeout:start(self.opts.timeout, 0, function()
self.timedout = true
self:kill()
end)
end
end
function Process:env()
---@type table<string, string>
local env = vim.tbl_extend("force", {
GIT_SSH_COMMAND = "ssh -oBatchMode=yes",
}, uv.os_environ(), opts.env or {})
}, uv.os_environ(), self.opts.env or {})
env.GIT_DIR = nil
env.GIT_WORK_TREE = nil
env.GIT_TERMINAL_PROMPT = "0"
@ -72,147 +138,105 @@ function M.spawn(cmd, opts)
for k, v in pairs(env) do
env_flat[#env_flat + 1] = k .. "=" .. v
end
local stdout = assert(uv.new_pipe())
local stderr = assert(uv.new_pipe())
local output = ""
---@type uv_process_t?
local handle = nil
---@type uv_timer_t
local timeout
local killed = false
if opts.timeout then
timeout = assert(uv.new_timer())
timeout:start(opts.timeout, 0, function()
if M.kill(handle) then
killed = true
end
end)
return env_flat
end
-- make sure the cwd is valid
if not opts.cwd and type(uv.cwd()) ~= "string" then
opts.cwd = uv.os_homedir()
end
handle = uv.spawn(cmd, {
stdio = { nil, stdout, stderr },
args = opts.args,
cwd = opts.cwd,
env = env_flat,
}, function(exit_code, signal)
---@cast handle uv_process_t
M.running[handle] = nil
if timeout then
timeout:stop()
timeout:close()
end
handle:close()
stdout:close()
stderr:close()
local check = assert(uv.new_check())
check:start(function()
if not stdout:is_closing() or not stderr:is_closing() then
---@param signals uv.aliases.signals|uv.aliases.signals[]|nil
function Process:kill(signals)
if not self.handle or self.handle:is_closing() then
return
end
check:stop()
if opts.on_exit then
output = output:gsub("[^\r\n]+\r", "")
if killed then
output = output .. "\n" .. "Process was killed because it reached the timeout"
elseif signal ~= 0 then
output = output .. "\n" .. "Process was killed with SIG" .. M.signals[signal]
end
vim.schedule(function()
opts.on_exit(exit_code == 0 and signal == 0, output)
end)
signals = signals or { "sigterm", "sigkill" }
signals = type(signals) == "table" and signals or { signals }
---@cast signals uv.aliases.signals[]
local timer = assert(uv.new_timer())
timer:start(0, 1000, function()
if self.handle and not self.handle:is_closing() and #signals > 0 then
self.handle:kill(table.remove(signals, 1))
else
timer:stop()
end
end)
end)
if not handle then
if opts.on_exit then
opts.on_exit(false, "Failed to spawn process " .. cmd .. " " .. vim.inspect(opts))
end
return
end
M.running[handle] = true
---@param err? string
---@param data? string
local function on_output(err, data)
---@param is_stderr? boolean
function Process:on_data(err, data, is_stderr)
assert(not err, err)
if data then
if opts.on_data then
vim.schedule(function()
opts.on_data(data)
end)
if not data then
return
end
output = output .. data:gsub("\r\n", "\n")
local lines = vim.split(vim.trim(output:gsub("\r$", "")):gsub("[^\n\r]+\r", ""), "\n")
if opts.on_line then
vim.schedule(function()
opts.on_line(lines[#lines])
end)
if self.opts.on_data then
self.opts.on_data(data, is_stderr)
end
self.data = self.data .. data:gsub("\r\n", "\n")
local lines = vim.split(vim.trim(self.data:gsub("\r$", "")):gsub("[^\n\r]+\r", ""), "\n")
if self.opts.on_line then
self.opts.on_line(lines[#lines])
end
end
uv.read_start(stdout, on_output)
uv.read_start(stderr, on_output)
M.signals = {
"hup",
"int",
"quit",
"ill",
"trap",
"abrt",
"bus",
"fpe",
"kill",
"usr1",
"segv",
"usr2",
"pipe",
"alrm",
"term",
"chld",
"cont",
"stop",
"tstp",
"ttin",
"ttou",
"urg",
"xcpu",
"xfsz",
"vtalrm",
"prof",
"winch",
"io",
"pwr",
"emt",
"sys",
"info",
}
return handle
end
function M.kill(handle)
if handle and not handle:is_closing() then
M.running[handle] = nil
uv.process_kill(handle, "sigint")
return true
end
---@param cmd string|string[]
---@param opts? ProcessOpts
function M.spawn(cmd, opts)
return Process.new(cmd, opts)
end
function M.abort()
for handle in pairs(M.running) do
M.kill(handle)
for _, proc in pairs(M.running) do
proc:kill()
end
end
---@param cmd string[]
---@param opts? {cwd:string, env:table}
---@async
---@param cmd string|string[]
---@param opts? ProcessOpts
function M.exec(cmd, opts)
opts = opts or {}
---@type string[]
local lines
local job = vim.fn.jobstart(cmd, {
cwd = opts.cwd,
pty = false,
env = opts.env,
stdout_buffered = true,
on_stdout = function(_, _lines)
lines = _lines
end,
})
if job <= 0 then
error("Failed to start job: " .. vim.inspect(cmd))
local proc = M.spawn(cmd, opts)
proc:wait()
if proc.code ~= 0 then
error("Process failed with code " .. proc.code)
end
local Async = require("lazy.async")
local async = Async.current
if async then
while vim.fn.jobwait({ job }, 0)[1] == -1 do
async:sleep(10)
end
else
vim.fn.jobwait({ job })
end
return lines
return vim.split(proc.data, "\n")
end
return M

View File

@ -16,7 +16,6 @@ local Task = require("lazy.manage.task")
---@class Runner
---@field _plugins table<string,LazyPlugin>
---@field _pipeline PipelineStep[]
---@field _on_done fun()[]
---@field _opts RunnerOpts
---@field _running? Async
local Runner = {}
@ -38,7 +37,6 @@ function Runner.new(opts)
for _, plugin in ipairs(pp) do
self._plugins[plugin.name] = plugin
end
self._on_done = {}
---@param step string|(TaskOptions|{[1]:string})
self._pipeline = vim.tbl_map(function(step)
@ -61,15 +59,9 @@ end
function Runner:start()
---@async
self._running = Async.run(function()
self._running = Async.new(function()
self:_start()
end, {
on_done = function()
for _, cb in ipairs(self._on_done) do
cb()
end
end,
})
end)
end
---@async
@ -97,7 +89,7 @@ function Runner:_start()
for _, name in ipairs(names) do
state[name] = state[name] or { step = 0 }
local s = state[name]
local is_running = s.task and s.task:is_running()
local is_running = s.task and s.task:running()
local step = self._pipeline[s.step]
if is_running then
@ -185,14 +177,10 @@ function Runner:wait(cb)
end
return self
end
if cb then
table.insert(self._on_done, cb)
self._running:on("done", cb)
else
-- sync wait
while self:is_running() do
vim.wait(10)
end
self._running:wait()
end
return self
end

View File

@ -15,16 +15,15 @@ local colors = Config.options.headless.colors
---@field msg string
---@field level? number
---@class LazyTask
---@class LazyTask: Async
---@field plugin LazyPlugin
---@field name string
---@field private _log LazyMsg[]
---@field private _started? number
---@field private _started number
---@field private _ended? number
---@field private _opts TaskOptions
---@field private _running Async
---@field private _level number
local Task = {}
local Task = setmetatable({}, { __index = Async.Async })
---@class TaskOptions: {[string]:any}
---@field on_done? fun(task:LazyTask)
@ -35,17 +34,21 @@ local Task = {}
---@param task LazyTaskFn
function Task.new(plugin, name, task, opts)
local self = setmetatable({}, { __index = Task })
---@async
Task.init(self, function()
self:_run(task)
end)
self:set_level()
self._opts = opts or {}
self._log = {}
self:set_level()
self.plugin = plugin
self.name = name
self._started = vim.uv.hrtime()
---@param other LazyTask
plugin._.tasks = vim.tbl_filter(function(other)
return other.name ~= name or other:is_running()
return other.name ~= name or other:running()
end, plugin._.tasks or {})
table.insert(plugin._.tasks, self)
self:_start(task)
return self
end
@ -75,10 +78,6 @@ function Task:status()
return msg ~= "" and msg or nil
end
function Task:is_running()
return self._ended == nil
end
function Task:has_errors()
return self._level >= vim.log.levels.ERROR
end
@ -92,31 +91,24 @@ function Task:set_level(level)
self._level = level or vim.log.levels.TRACE
end
---@private
---@async
---@param task LazyTaskFn
function Task:_start(task)
assert(not self._started, "task already started")
assert(not self._ended, "task already done")
function Task:_run(task)
if Config.headless() and Config.options.headless.task then
self:log("Running task " .. self.name, vim.log.levels.INFO)
end
self._started = vim.uv.hrtime()
---@async
self._running = Async.run(function()
task(self, self._opts)
end, {
on_done = function()
self
:on("done", function()
self:_done()
end,
on_error = function(err)
end)
:on("error", function(err)
self:error(err)
end,
on_yield = function(res)
self:log(res)
end,
})
end)
:on("yield", function(msg)
self:log(msg)
end)
task(self, self._opts)
end
---@param msg string|string[]
@ -163,13 +155,6 @@ end
---@private
function Task:_done()
assert(self._started, "task not started")
assert(not self._ended, "task already done")
if self._running and self._running:running() then
return
end
if Config.headless() and Config.options.headless.task then
local ms = math.floor(self:time() + 0.5)
self:log("Finished task " .. self.name .. " in " .. ms .. "ms", vim.log.levels.INFO)
@ -186,13 +171,7 @@ function Task:_done()
end
function Task:time()
if not self._started then
return 0
end
if not self._ended then
return (vim.uv.hrtime() - self._started) / 1e6
end
return (self._ended - self._started) / 1e6
return ((self._ended or vim.uv.hrtime()) - self._started) / 1e6
end
---@async
@ -201,7 +180,6 @@ end
function Task:spawn(cmd, opts)
opts = opts or {}
local on_line = opts.on_line
local on_exit = opts.on_exit
local headless = Config.headless() and Config.options.headless.process
@ -214,35 +192,28 @@ function Task:spawn(cmd, opts)
end
end
self._running:suspend()
local running = true
local ret = { ok = true, output = "" }
---@param output string
function opts.on_exit(ok, output)
if not headless then
self:log(vim.trim(output), ok and vim.log.levels.DEBUG or vim.log.levels.ERROR)
end
ret = { ok = ok, output = output }
running = false
self._running:resume()
end
if headless then
opts.on_data = function(data)
-- prefix with plugin name
local prefix = self:prefix()
io.write(Terminal.prefix(data, prefix))
io.write(Terminal.prefix(data, self:prefix()))
end
end
Process.spawn(cmd, opts)
coroutine.yield()
assert(not running, "process still running?")
if on_exit then
pcall(on_exit, ret.ok, ret.output)
local proc = Process.spawn(cmd, opts)
proc:wait()
local ok = proc.code == 0 and proc.signal == 0
if not headless then
local msg = vim.trim(proc.data)
if #msg > 0 then
self:log(vim.trim(proc.data), ok and vim.log.levels.DEBUG or vim.log.levels.ERROR)
end
coroutine.yield()
return ret.ok
end
if opts.on_exit then
pcall(opts.on_exit, ok, proc.data)
end
return ok
end
function Task:prefix()
@ -253,10 +224,4 @@ function Task:prefix()
or plugin .. " " .. task .. " | "
end
function Task:wait()
while self:is_running() do
vim.wait(10)
end
end
return Task

View File

@ -76,6 +76,13 @@ function M.throttle(ms, fn)
local timer = vim.uv.new_timer()
local pending = false
---@type Async
local async
local function running()
return async and async:running()
end
return function()
if timer:is_active() then
pending = true
@ -85,7 +92,10 @@ function M.throttle(ms, fn)
0,
ms,
vim.schedule_wrap(function()
fn()
if running() then
return
end
async = require("lazy.async").new(fn)
if pending then
pending = false
else

View File

@ -51,7 +51,7 @@ function M:update()
if plugin._.tasks then
for _, task in ipairs(plugin._.tasks) do
self.progress.total = self.progress.total + 1
if not task:is_running() then
if not task:running() then
self.progress.done = self.progress.done + 1
end
end
@ -356,7 +356,7 @@ end
function M:diagnostics(plugin)
local skip = false
for _, task in ipairs(plugin._.tasks or {}) do
if task:is_running() then
if task:running() then
self:diagnostic({
severity = vim.diagnostic.severity.WARN,
message = task.name .. (task:status() and (": " .. task:status()) or ""),

View File

@ -28,7 +28,7 @@ return {
return true
end
return has_task(plugin, function(task)
return task:is_running()
return task:running()
end)
end,
title = "Working",

View File

@ -0,0 +1,20 @@
---@module 'luassert'
local Async = require("lazy.async")
local Process = require("lazy.manage.process")
describe("process", function()
it("runs sync", function()
local lines = Process.exec({ "echo", "-n", "hello" })
assert.are.same({ "hello" }, lines)
end)
it("runs sync from async context", function()
local lines ---@type string[]
local async = Async.new(function()
lines = Process.exec({ "echo", "-n", "hello" })
end)
async:wait()
assert.are.same({ "hello" }, lines)
end)
end)

View File

@ -21,9 +21,9 @@ describe("task", function()
it("simple function", function()
local task = Task.new(plugin, "test", function() end, opts)
assert(task:is_running())
assert(task:running())
task:wait()
assert(not task:is_running())
assert(not task:running())
assert(task_result.done)
end)
@ -31,9 +31,9 @@ describe("task", function()
local task = Task.new(plugin, "test", function()
error("test")
end, opts)
assert(task:is_running())
assert(task:running())
task:wait()
assert(not task:is_running())
assert(not task:running())
assert(task_result.done)
assert(task_result.error)
assert(task:has_errors() and task:output(vim.log.levels.ERROR):find("test"))
@ -46,12 +46,12 @@ describe("task", function()
coroutine.yield()
running = false
end, opts)
assert(task:is_running())
assert(task:running())
assert(running)
assert(task:is_running())
assert(task:running())
task:wait()
assert(not running)
assert(not task:is_running())
assert(not task:running())
assert(task_result.done)
assert(not task:has_errors())
end)
@ -60,19 +60,19 @@ describe("task", function()
local task = Task.new(plugin, "spawn_errors", function(task)
task:spawn("foobar")
end, opts)
assert(task:is_running())
assert(task:running())
task:wait()
assert(not task:is_running())
assert(not task:running())
assert(task_result.done)
assert(task:has_errors() and task:output(vim.log.levels.ERROR):find("Failed to spawn"), task.output)
assert(task:has_errors() and task:output(vim.log.levels.ERROR):find("Failed to spawn"), task:output())
end)
it("spawn", function()
local task = Task.new(plugin, "test", function(task)
task:spawn("echo", { args = { "foo" } })
end, opts)
assert(task:is_running())
assert(task:is_running())
assert(task:running())
assert(task:running())
task:wait()
assert.same(task:output(), "foo")
assert(task_result.done)
@ -84,8 +84,8 @@ describe("task", function()
task:spawn("echo", { args = { "foo" } })
task:spawn("echo", { args = { "bar" } })
end, opts)
assert(task:is_running())
assert(task:is_running())
assert(task:running())
assert(task:running())
task:wait()
assert(task:output() == "foo\nbar" or task:output() == "bar\nfoo", task:output())
assert(task_result.done)

View File

@ -1,3 +1,3 @@
#!/bin/sh
nvim -l tests/busted.lua tests -o utfTerminal
nvim -l tests/busted.lua tests -o utfTerminal "$@"

View File

@ -8,42 +8,14 @@ any = true
[jit]
any = true
[[describe.args]]
type = "string"
[[describe.args]]
type = "function"
[[it.args]]
type = "string"
[[it.args]]
type = "function"
[[before_each.args]]
type = "function"
[[after_each.args]]
type = "function"
[assert.is_not]
[assert]
any = true
[[assert.equals.args]]
type = "any"
[[assert.equals.args]]
type = "any"
[[assert.equals.args]]
type = "any"
required = false
[describe]
any = true
[[assert.same.args]]
type = "any"
[[assert.same.args]]
type = "any"
[it]
any = true
[[assert.truthy.args]]
type = "any"
[[assert.spy.args]]
type = "any"
[[assert.stub.args]]
type = "any"
[before_each.args]
any = true