Skip to content

Latest commit

 

History

47 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

async.nvim

A tiny set of async primitives for Neovim plugins. ~125 lines of source code, ~450 lines of tests.

Promises as functions

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): nil
local promise = function(resolve, reject)
  vim.defer_fn(function()
    resolve("done")
  end, 100)
end

from_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)

Async functions

In JavaScript, an async function has two properties important to us:

  1. It returns a promise object
  2. You can use the await keyword within it

In this plugin, I apply the same two properties to our async functions (the return value of make_async):

  1. It returns a promise (a function that takes in resolve and reject)
  2. You can use the await function within it - more on that below

make_async

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

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

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.

Error handling

Errors propagate through promises and await, so they can be caught with pcall in the same place you'd normally handle them.

Async functions throw

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()

Promise executors throw

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()

Without await

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) -- boom

make_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) -- boom

throttled_iterator

For 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()

About

A tiny set of async primitives for Neovim plugins

Resources

Stars

1 star

Watchers

0 watching

Forks

Contributors

Languages