feat(lua): add vim.async

Problem: Nvim has many Lua APIs that start callback-driven work: timers,
jobs, libuv handles, and other event-loop tasks. Callers that need to
sequence or cancel that work have to build their own coroutine wrappers,
task bookkeeping, and cleanup rules. This makes async control flow hard
to share, test, and document.

Solution: Add `vim.async`, a structured-concurrency module vendored from
async.nvim. It provides task handles, await/pawait helpers,
sleep/timeout helpers, completion-order iteration, and semaphores on top
of Nvim's event loop.

The API follows the same broad model as Trio: async work has an owner,
tasks are awaited explicitly, and cancellation is cooperative. Include
generated vimdoc with an introductory overview and examples, a news
entry, and functional tests for the new module.

AI-assisted
This commit is contained in:
Lewis Russell
2026-08-25 17:37:27 +01:00
committed by Lewis Russell
parent da4355ab8f
commit ce8a897f98
17 changed files with 5315 additions and 7 deletions

View File

@@ -0,0 +1,133 @@
-- LuaLS cannot model the generic annotations used by this vendored implementation.
---@diagnostic disable: no-unknown, undefined-doc-name, luadoc-miss-symbol, missing-return, missing-return-value, param-type-mismatch, return-type-mismatch, redundant-return-value, undefined-field, need-check-nil, await-in-sync
local F = vim.F
local new_event = require('vim.async._event')
local pcall = pcall
do
local ok, coxpcall = pcall(require, 'coxpcall')
if ok and type(coxpcall) == 'table' and type(coxpcall.pcall) == 'function' then
pcall = coxpcall.pcall
end
end
local validate = vim.validate
--- A semaphore manages an internal permit counter. [Semaphore:acquire()]
--- consumes one permit and [Semaphore:release()] returns one permit. If no
--- permits are available, `acquire()` suspends the current task until another
--- task releases one.
---
--- The preferred way to use a Semaphore is with the `with()` method, which
--- automatically acquires and releases the semaphore around a function call.
--- This is useful for limiting sections that start external work and then
--- await it, such as file reads, requests, or subprocesses.
---
--- ```lua
--- local async = vim.async
---
--- async.run(function()
--- local limit = async.semaphore(4)
--- local tasks = {}
---
--- for _, path in ipairs(paths) do
--- table.insert(tasks, async.run(function()
--- return limit:with(function()
--- return read_file(path)
--- end)
--- end))
--- end
---
--- local next_task = async.iter(tasks)
--- while true do
--- local task = next_task()
--- if task == nil then
--- break
--- end
--- async.await(task)
--- end
--- end)
--- ```
--- @class vim.async.Semaphore
--- @field private _permits integer
--- @field private _max_permits integer
--- @field package _event vim.async.Event
local Semaphore = {}
Semaphore.__index = Semaphore
--- Executes a function while holding one semaphore permit.
---
--- This acquires the semaphore before running the function and releases it
--- after the function completes, even if it errors or the current task is
--- closed.
--- @async
--- @generic R
--- @param fn async fun(): R... # Function to execute within the semaphore's context.
--- @return R... # Result(s) of the executed function.
function Semaphore:with(fn)
self:acquire()
-- This pcall is only a try/finally guard for release(); all errors are
-- immediately rethrown so it is not an async recovery boundary.
local r = F.pack_len(pcall(fn))
self:release()
local stat = r[1]
if not stat then
local err = r[2]
error(err, 0)
end
return unpack(r, 2, r.n)
end
--- Acquire a semaphore permit.
---
--- If the internal counter is greater than zero, decrement it by `1` and
--- return immediately. If it is `0`, wait until [Semaphore:release()] is
--- called.
--- @async
function Semaphore:acquire()
self._event:wait()
self._permits = self._permits - 1
assert(self._permits >= 0, 'Semaphore value is negative')
if self._permits == 0 then
self._event:clear()
end
end
--- Release a semaphore permit.
---
--- Increments the internal counter by `1` and can wake a task waiting in
--- [Semaphore:acquire()].
---
--- Calling this more times than permits were acquired raises an error.
function Semaphore:release()
if self._permits >= self._max_permits then
error('Semaphore value is greater than max permits', 2)
end
self._permits = self._permits + 1
self._event:set(1)
end
--- Create an async semaphore that allows up to a given number of acquisitions.
---
--- Prefer [Semaphore:with()] for most uses so permits are released reliably.
--- Use [Semaphore:acquire()] and [Semaphore:release()] directly only when the
--- acquire and release points cannot be expressed as one function call.
--- @param permits? integer (default: 1)
--- @return vim.async.Semaphore
local function new_semaphore(permits)
validate('permits', permits, 'number', true)
permits = permits or 1
if permits < 1 or permits % 1 ~= 0 then
error('permits: expected positive integer', 2)
end
local obj = setmetatable({
_max_permits = permits,
_permits = permits,
_event = new_event(),
}, Semaphore)
obj._event:set()
return obj
end
return new_semaphore