A tiny set of async primitives for Neovim plugins. ~125 lines of source code, ~450 lines of tests.
In JavaScript, a promise is an object. In this plugin, I define a promise as a function that takes a resolve and (optional) reject callback:
--- @alias Resolve<T> fun(...: T): nil
--- @alias Reject fun(err: any): nil
--- @alias Promise<T> fun(resolve: Resolve<T>, reject?: Reject): nillocal promise = function(resolve, reject)
vim.defer_fn(function()
resolve("done")
end, 100)
endfrom_executor helps formalize the idea:
--- @generic T
--- @param executor fun(resolve: Resolve<T>, reject?: Reject): nil
--- @return Promise<T>
M.from_executor = function(executor)local promise = from_executor(function(resolve)
vim.defer_fn(function()
resolve("done")
end, 100)
end)In JavaScript, an async function has two properties important to us:
- It returns a promise object
- You can use the
awaitkeyword within it
In this plugin, I apply the same two properties to our async functions (the return value of make_async):
- It returns a promise (a function that takes in
resolveandreject) - You can use the
awaitfunction within it - more on that below
make_async takes a plain function and returns an async function — one that returns a promise.
--- @alias AsyncFn<T> fun(...: any): Promise<T>
--- @alias MakeAsync<T> fun(fn: fun(...: any): T): AsyncFn<T>
--- @generic T
--- @param fn fun(...: any): T
--- @return AsyncFn<T>
M.make_async = function(fn)local add = make_async(function(a, b)
return a + b
end)
local promise = add(3, 4)await takes a promise and returns its resolved value. It must run inside a coroutine — that's property #2 from above:
--- @generic T
--- @param promise Promise<T>
--- @return T
M.await = function(promise)local add = make_async(function(a, b)
return a + b
end)
local double = make_async(function(value)
return value * 2
end)
local compute = make_async(function()
local add_promise = add(3, 4)
local sum = await(add_promise)
local double_promise = double(sum)
return await(double_promise)
end)make_spawn also creates a coroutine so you can use await, but it runs the function immediately and discards the result:
--- @alias SpawnFn fun(...: any): nil
--- @alias MakeSpawn fun(fn: fun(...: any): any): SpawnFn
--- @type MakeSpawn
M.make_spawn = function(fn)local spawn = make_spawn(function()
vim.print(await(compute())) -- 14
end)
spawn()With just make_async, the same thing looks like this:
local async_fn = make_async(function()
vim.print(await(compute())) -- 14
end)
local promise = async_fn()
local resolve = function() end
promise(resolve)In other words: make_async gives you a promise to await, make_spawn is fire-and-forget.
Errors propagate through promises and await, so they can be caught with pcall in the same place you'd normally handle them.
A function wrapped in make_async runs in a coroutine. If it throws, its promise rejects:
local boom = make_async(function()
error("boom")
end)
local spawn = make_spawn(function()
local ok, err = pcall(await, boom())
vim.print(ok) -- false
vim.print(err) -- boom
end)
spawn()The same happens if the executor passed to from_executor throws:
local promise = from_executor(function(resolve)
error("executor boom")
end)
local spawn = make_spawn(function()
local ok, err = pcall(await, promise)
vim.print(ok) -- false
vim.print(err) -- executor boom
end)
spawn()If you call a promise directly without a reject handler, a rejection raises the error:
local promise = from_executor(function(resolve)
error("boom")
end)
local resolve = function() end
local ok, err = pcall(promise, resolve)
vim.print(ok) -- false
vim.print(err) -- boommake_spawn discards the result but not the error, so an uncaught error is raised the same way:
local spawn = make_spawn(function()
error("boom")
end)
local ok, err = pcall(spawn)
vim.print(ok) -- false
vim.print(err) -- boomFor processing large lists without blocking the UI, throttled_iterator iterates in batches and yields back to the main thread between batches:
--- @class ThrottledIteratorOpts<ControlVar>
--- @field threshold_ns? number The minimum time in nanoseconds between yields to the main loop. Defaults to 10ms.
--- @field should_cancel? fun():boolean Called before each iteration; return true to stop early. Defaults to always returning false.
--- @field on_iteration fun(control_var: ControlVar, ...):nil Called for each item with the control variable and the iterator values.
--- @generic InvariantState, ControlVar
--- @param iterator_factory fun(): ((fun(invariant_state: InvariantState, control_var: ControlVar):ControlVar), InvariantState?, ControlVar?)
--- @param opts ThrottledIteratorOpts<ControlVar>
--- @return Promise<nil>
M.throttled_iterator = function(iterator_factory, opts)Example:
local lines = { "first", "second", "third" }
local spawn = make_spawn(function()
vim.print("before")
await(throttled_iterator(ipairs(lines), {
on_iteration = function(i, line)
vim.print(i, line)
end,
}))
vim.print("after")
end)
spawn()