diff --git a/packages/future/config.toml b/packages/future/config.toml new file mode 100644 index 0000000..18ebcb9 --- /dev/null +++ b/packages/future/config.toml @@ -0,0 +1,7 @@ +name = "future" +description = "Future type in Luau" +version = "1.0.0" + +types = ["Future"] +bundle = "Future" +dependencies = ["option", "threadpool"] diff --git a/packages/future/init.luau b/packages/future/init.luau new file mode 100644 index 0000000..5a7b945 --- /dev/null +++ b/packages/future/init.luau @@ -0,0 +1,196 @@ +local Option = require("../option") +local threadpool = require("../threadpool") + +local task = if _G.TARGET == "lune" then require("@lune/task") :: typeof(task) else task + +export type Future = typeof(setmetatable( + {} :: { + _f: boolean, + _v: T, + _w: { thread }, + _c: { (T) -> () }, + }, + {} :: FutureImpl +)) + +export type FutureImpl = { + __index: FutureImpl, + + new: (value: T) -> Future, + never: () -> Future, + spawn: (f: (U...) -> T, U...) -> Future, + fn: (f: (V...) -> T) -> (V...) -> Future, + + all: (futures: { Future }) -> Future<{ T }>, + race: (futures: { Future }) -> Future, + + isFinished: (self: Future) -> boolean, + isPending: (self: Future) -> boolean, + now: (self: Future) -> Option.Option, + await: (self: Future) -> T, + after: (self: Future, f: (T) -> ()) -> (), +} + +--[=[ + @class Future + + A future represents a value that does not exist yet, but will exist at some + point in the future. + + Futures allow you to more easily compose asynchronous operations. +]=] +local Future = {} :: FutureImpl +Future.__index = Future + +--[=[ + Creates a finished future with the given value. +]=] +function Future.new(value) + local self = setmetatable({ _f = true, _v = value, _w = {}, _c = {} }, Future) + return self +end + +--[=[ + Creates a future that will never finish. +]=] +function Future.never() + return setmetatable({ _f = false, _w = {}, _c = {} }, Future) +end + +local function finish(fut: Future, value: T) + fut._f = true + fut._v = value + + for _, thread in fut._w do + task.spawn(thread, value) + end + + for _, callback in fut._c do + callback(value) + end +end + +--[=[ + Creates a future and spawns the given function in a new thread, once the + function finishes, the future resolves with the result of the function. +]=] +function Future.spawn(f, ...) + local self = Future.never() + + -- selene: allow(shadowing) + local function inner(self: Future, f: (...any) -> any, ...) + finish(self, f(...)) + end + threadpool.spawn(inner, self, f, ...) + + return self +end + +--[=[ + Returns a function that, when called, will spawn the given function in a new + thread and returns a future that resolves with the result of the function. +]=] +function Future.fn(f) + return function(...) + return Future.spawn(f, ...) + end +end + +--[=[ + Takes an array of futures and returns a new future that will finish once all + of the given futures have finished. The future will resolve with an array + containing the results of the given futures. +]=] +function Future.all(futures) + -- selene: allow(shadowing) + local function inner(futures: { Future }) + local result = table.create(#futures) + for _, fut: any in futures do + table.insert(result, fut:await()) + end + return result + end + + return Future.spawn(inner, futures) :: any +end + +--[=[ + Takes an array of futures and returns a new future that will finish once any + of the given futures have finished. The future will resolve with the result + of the first future to finish. + + The result of the other futures will be discarded. +]=] +function Future.race(futures) + local self = Future.never() + local done = false + + for _, fut: any in futures do + fut:after(function(val) + if done then + return + end + + done = true + finish(self, val) + end) + end + + return self +end + +--[=[ + Returns `true` if the future has finished, `false` otherwise. +]=] +function Future.isFinished(self) + return self._f +end + +--[=[ + Returns `true` if the future is still pending, `false` otherwise. +]=] +function Future.isPending(self) + return not self._f +end + +--[=[ + Returns the value of the future, if it has finished. If the future is still + pending, the function will return `None`. +]=] +function Future.now(self) + if self._f then + return Option.Some(self._v :: any) + else + return Option.None + end +end + +--[=[ + Yields the current thread until the future has finished, then returns the + value of the future. + + If the future has already finished, the function will return immediately. +]=] +function Future.await(self) + if self._f then + return self._v + end + + local thread = coroutine.running() + table.insert(self._w, thread) + return coroutine.yield() +end + +--[=[ + Calls the given callback once the future has finished. If the future has + already finished, the callback will be called immediately. +]=] +function Future.after(self, f) + if self._f then + f(self._v) + else + table.insert(self._c, f) + end +end + +return Future diff --git a/packages/future/init.test.luau b/packages/future/init.test.luau new file mode 100644 index 0000000..c97d884 --- /dev/null +++ b/packages/future/init.test.luau @@ -0,0 +1,198 @@ +local assertEq = require("@testing/assertEq") +local describe = require("@testing/describe") +local test = require("@testing/test") + +local task = require("@lune/task") + +local Future = require(".") + +describe("new", function() + test("creates a finished future with the given value", function() + local fut = Future.new(5) + assertEq(fut._f, true) + assertEq(fut._v, 5) + end) +end) + +describe("never", function() + test("creates a pending future", function() + local fut = Future.never() + assertEq(fut._f, false) + assertEq(fut._v, nil) + end) +end) + +describe("spawn", function() + test("spawns the function and returns the result", function() + local thread = coroutine.running() + local spawned + + local fut = Future.spawn(function() + spawned = coroutine.running() + coroutine.yield() + return 5 + end) + + assertEq(fut._f, false) + assertEq(spawned ~= nil, true) + assertEq(thread ~= spawned, true) + task.spawn(spawned) + assertEq(fut._f, true) + assertEq(fut._v, 5) + end) +end) + +describe("fn", function() + test("creates a function that spawns a future", function() + local f = Future.fn(function(a: number, b: number) + return a + b + end) + + local fut = f(1, 2) + assertEq(fut._f, true) + assertEq(fut._v, 3) + end) +end) + +describe("all", function() + test("creates a future that resolves when all futures have resolved", function() + local spawned = {} + local function returnValue(value) + spawned[value] = coroutine.running() + coroutine.yield() + return value + end + + local futs = {} + for i = 1, 10 do + futs[i] = Future.spawn(returnValue, i) + end + + local fut = Future.all(futs) + for i = 1, 10 do + assertEq(fut._f, false) + task.spawn(spawned[i]) + + if i < 10 then + assertEq(fut._f, false) + else + assertEq(fut._f, true) + for j = 1, 10 do + assertEq(fut._v[j], j) + end + end + end + end) +end) + +describe("race", function() + test("resolves with the first future to resolve", function() + local spawned = {} + local function returnValue(value) + spawned[value] = coroutine.running() + coroutine.yield() + return value + end + + local random = math.random(1, 10) + local futs = {} + for i = 1, 10 do + futs[i] = Future.spawn(returnValue, i) + end + + local fut = Future.race(futs) + assertEq(fut._f, false) + task.spawn(spawned[random]) + assertEq(fut._f, true) + assertEq(fut._v, random) + + for i = 1, 10 do + if i ~= random then + task.spawn(spawned[i]) + assertEq(fut._f, true) + assertEq(fut._v, random) + end + end + end) +end) + +describe("isFinished", function() + test("returns whether a future is finished", function() + assertEq(Future.new(5):isFinished(), true) + assertEq(Future.never():isFinished(), false) + end) +end) + +describe("isPending", function() + test("returns whether a future is pending", function() + assertEq(Future.new(5):isPending(), false) + assertEq(Future.never():isPending(), true) + end) +end) + +describe("now", function() + test("returns Some if the future is finished", function() + assertEq(Future.new(5):now():unwrap(), 5) + end) + + test("returns None if the future is pending", function() + assertEq(Future.never():now():isNone(), true) + end) +end) + +describe("await", function() + test("returns the value if the future is finished", function() + assertEq(Future.new(5):await(), 5) + end) + + test("yields if the future is pending", function() + local done = false + local spawned + task.spawn(function() + local fut = Future.spawn(function() + spawned = coroutine.running() + coroutine.yield() + return 5 + end) + + assertEq(fut:await(), 5) + done = true + end) + + assertEq(done, false) + task.spawn(spawned) + assertEq(done, true) + end) +end) + +describe("after", function() + test("calls the callback immediately if finished", function() + local done = false + Future.new(5):after(function(v) + assertEq(v, 5) + done = true + end) + + assertEq(done, true) + end) + + test("calls the callback once the future is finished", function() + local done = false + local spawned + + local fut = Future.spawn(function() + spawned = coroutine.running() + coroutine.yield() + return 5 + end) + + fut:after(function(v) + assertEq(v, 5) + done = true + end) + + assertEq(done, false) + task.spawn(spawned) + assertEq(done, true) + end) +end)