diff --git a/promise_tech.lua b/promise_tech.lua index 6236c3e..9693ecb 100644 --- a/promise_tech.lua +++ b/promise_tech.lua @@ -1,26 +1,37 @@ +-- ██████ ██████ ██████ ███ ███ ██ ███████ ███████ +-- ██ ██ ██ ██ ██ ██ ████ ████ ██ ██ ██ +-- ██████ ██████ ██ ██ ██ ████ ██ ██ ███████ █████ +-- ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ +-- ██ ██ ██ ██████ ██ ██ ██ ███████ ███████ + --- Javascript Promises, implemented in Lua -- In other words, a wrapper to manage asynchronous operations. -- Due to limitations of Lua, while this Promise API is similar it isn't exactly the same as in JS. --- --- Also, .then_() does not return a thenable value but the SAME ORIGINAL promise, as we stack up all functions and then execute them in order once you call .run(). This has the subtle implication that Promise.state is not set to "fulfilled" until ALL functions in the chain have been called. --- --- Additionally, every .then_(function(...) end) MAY return a promise themselves if they wish. These promises WILL be automatically executed when you call .run() on the PARENT promise, as they are considered required for the parent Promise function chain to run to completion. --- @class worldeditadditions_core.Promise + +--- @class worldeditadditions_core.Promise local Promise = {} +setmetatable(Promise, {__tostring = function(self) return "Promise" end}) Promise.__index = Promise Promise.__name = "Promise" -- A hack to allow identification in wea.inspect +Promise.__tostring = function(self) return "Promise: " .. self.state end --- Creates a new Promise instance. -- @param fn : The function to wrap into a promise. Promise.new = function(fn) -- resolve must be a function if type(fn) ~= "function" then - error("Error (Promise.new): First argument (fn) must be a function") + error("Error (Promise.new): Argument @position 1 (fn) must be a function") end local result = { + -- State can be "pending", "fulfilled", or "rejected" + -- Any state other than "pending" means the promise has been settled + -- and become "locked" enable to be acted on again state = "pending", + -- The force_reject flag is to be used to facilitate non error rejections + -- If set to true this flag will be passed to child promises force_reject = false, + -- The function to execute when the promise is settled fn = fn } setmetatable(result, Promise) @@ -29,80 +40,174 @@ Promise.new = function(fn) end + --[[ ************************* - Instance methods + Local helpers ************************* --]] --- A dummy function local f = function(val) end +-- Table tweaks (because this is for Minetest) +--- @class table +local table = table +if not table.unpack then table.unpack = unpack end +table.join = function(tbl, sep) + local function fn_iter(tbl,sep,i) + if i < #tbl then + return (tostring(tbl[i]) or "").. sep .. fn_iter(tbl,sep,i+1) + else return (tostring(tbl[i]) or "") end + end + return fn_iter(tbl,sep,1) +end + +--- Warning wrapper +local warn = warn +if warn then warn("@on") +else warn = minetest and function(...) + minetest.log("warning", table.concat(arg,"\t")) + end or print +end + +-- A handler for erors where a function is expected +-- @param called_from : The function of Promise that called the error +-- @param position : The position of the argument that caused the error (e.g. "First" or "Second") +-- @param arg_name : The name of the argument that caused the error (e.g. "onFulfilled") +-- @param must_be : The type of the argument that caused the error (e.g. "function" or "function or nil") +-- @param self_problem : Whether the error is a self-promblem or not (if called_from is supposed to be used with ":" instead of ".") +-- @return : The error/warning message +local function_type_warn = function(called_from, position, arg_name, must_be, self_problem) + local cat = self_problem and ":" or "." + local err_str = string.format( + "Error (Promise%s%s): Argument @position %s (%s) must be a %s", + cat, called_from, position, arg_name, must_be) + -- local err_str = "Error (Promise" .. cat .. called_from .. "): " .. position .. " argument (" .. arg_name .. ") must be " .. must_be + if self_problem then + err_str = string.format( + "%s\nAre you using .%s() instead of :%s()?", + err_str, called_from, called_from) + end + return err_str +end + +local type_enforce = function(called_from, args) + local err_str = nil + for i, arg in ipairs(args) do + local is_err = true + for _, should_be in ipairs(arg.should_be) do + -- First handle metatables + if type(arg.val) == "table" and type(should_be) == "table" and getmetatable(arg.val) == should_be then + is_err = false + elseif type(arg.val) == should_be then + is_err = false + end + end + if is_err then + err_str = function_type_warn(called_from, i, arg.name, table.join(arg.should_be, " or "), arg.name == "self" and true or false) + if arg.error then error(err_str) + else warn(err_str) end + end + end +end + + + +--[[ + ************************* + Instance methods + ************************* +--]] + --- Then function for promises --- @param onFulfilled : The function to call if the promise is fulfilled --- @param onRejected : The function to call if the promise is rejected +-- @param onFulfilled : The function to call if the promise is fulfilled +-- @param onRejected : The function to call if the promise is rejected -- @return A promise object containing a table of results Promise.then_ = function(self, onFulfilled, onRejected) + type_enforce("then_",{ + {name = "self", val = self, should_be = {Promise}, error = true}, + {name = "onFulfilled", val = onFulfilled, should_be = {"function", "nil"}, error = true}, + {name = "onRejected", val = onRejected, should_be = {"function", "nil"}, error = true}, + }) -- onFulfilled must be a function or nil - if onFulfilled == nil then onFulfilled = f - elseif type(onFulfilled) ~= "function" then - error("Error (Promise.then_): First argument (onFulfilled) must be a function or nil") - end + if onFulfilled == nil then onFulfilled = f end -- onRejected must be a function or nil - if onRejected == nil then onRejected = f - elseif type(onRejected) ~= "function" then - error("Error (Promise.then_): Second argument (onRejected) must be a function or nil") - end + if onRejected == nil then onRejected = f end -- If self.state is not "pending" then error if self.state ~= "pending" then return Promise.reject("Error (Promise.then_): Promise is already " .. self.state) end -- Make locals to collect the results of self.fn - local result, force_reject = {nil}, self.force_reject - - -- Local resolve and reject functions - local _resolve = function(value) result[1] = value end - local _reject = function(value) - result[1] = value - force_reject = true + local result = { + val = nil, + force_reject = self.force_reject, + success = true, + err = nil + } + result.update = function(val, rej) + if result.val == nil then + result.val = val + if rej == true then result.force_reject = true end + end end + -- Local resolve and reject functions + local _resolve = function(value) result.update(value) end + local _reject = function(value) result.update(value, true) end + -- Call self.fn - local success, err = pcall(self.fn, _resolve, _reject) + result.success, result.err = pcall(self.fn, _resolve, _reject) -- Return a new promise with the results - if success and not force_reject then - onFulfilled(result[1]) + if result.success and not result.force_reject then + onFulfilled(result.val) self.state = "fulfilled" - return Promise.resolve(result[1]) + return Promise.resolve(result.val) else onRejected(result[1]) self.state = "rejected" - return Promise.reject(success and result[1] or err) + return Promise.reject(result.success and result.val or result.err) end end +--[[ +tmp = Promise.new(function(resolve, reject) + -- In 10 seconds call resolve(20) + setTimeout(10, resolve, 20) +end) + +tmp:then_(function(value) print("Value", value) end, function(err) print("Error", err) end) +]] + --- Catch function for promises -- @param onRejected : The function to call if the promise is rejected -- @return A promise object Promise.catch = function(self, onRejected) -- onRejected must be a function if type(onRejected) ~= "function" then - error("Error (Promise.catch): First argument (onRejected) must be a function") + function_type_warn("catch", "First", "onRejected", "a function", true) end return Promise.then_(self, nil, onRejected) end --- Finally function for promises +-- Can be used to clone the current promise as it does not settle it -- @param onFinally : The function to call if the promise becomes settled --- @return A promise object +-- @return A promise object containing the function of the current promise Promise.finally = function(self, onFinally) + -- onFinally must be a function + if type(onFinally) ~= "function" then + function_type_warn("finally", "First", "onFinally", "a function", true) + end onFinally() return Promise.new(self.fn) end + + --[[ ************************* Static methods @@ -132,6 +237,45 @@ end -- TODO: Implement static methods (all, any, race etc.) + +--[[ + ************************* + Non JS methods + ************************* +--]] + +--- Poke a promise with a debug stick and see what happens +-- Also for those who want a table returned instead of a promise +-- @param promise : The promise to poke +-- @return boolean, table: true if no error, the settled state and value of the promise +Promise.poke = function(promise) + local result = {value=nil, state=nil} + -- Check that the argument is a promise + if not Promise.is_promise(promise) then + local _, err = pcall(function_type_warn, "poke", "First", "promise", "a Promise instance") + result.value = err + return false, result -- Stop execution and return the error + end + + local set_result_value = function(value) result.value = value end + -- Operate on the promise based on its state and force_reject flag + if promise.state ~= "pending" then + promise:catch(f):catch(set_result_value) + result.state = promise.state + return false, result + elseif promise.force_reject then + promise:catch(set_result_value) + result.state = promise.state + else + promise:then_(set_result_value, set_result_value) + result.state = promise.state + end + + return true, result +end + + + return Promise --- TESTS @@ -172,4 +316,38 @@ Vx2 = 0 test():then_(function(value) Vx2 = value end, function(value) print("caught rejection, value", value) end): then_(function(value) print("Sqrt is", math.sqrt(value)) end) if Vx2 ~= 0 then print("Vx2", Vx2) end -]] \ No newline at end of file + +-- Security test + +tmp = {val = nil, err = nil} +tmp_set = function(val) + tmp["val"] = val + print("DEBUG tmp_set val", val) +end +tmp_err = function(err) + tmp["err"] = err + print("DEBUG tmp_err err", err) +end + +tmp1 = Promise.resolve(3) + +tmp1:then_(tmp_set, tmp_err) +-- Prints "DEBUG tmp_set val 3" +print(tmp.val, tmp.err) +-- Prints "3 nil" + +tmp1:then_(tmp_set, tmp_err) +-- Prints nothing +print(tmp.val, tmp.err) +-- Still returns "3 nil" +-- But there was no DEBUG print + +tmp1:then_(tmp_set, tmp_err):catch(tmp_err) +-- Prints "DEBUG tmp_err err Error (Promise.then_): Promise is already fulfilled" +print(tmp.val, tmp.err) +-- Prints "3 Error (Promise.then_): Promise is already fulfilled" + +Now we get our error: "Error (Promise.then_): Promise is already fulfilled" +This functionality is a safeguard against executing the function of a promise more than once +The promise will simply "short-circuit" the return a new promise with the error without evaluating anything +--]] \ No newline at end of file