Initial commit

This commit is contained in:
aaron 2023-08-08 21:33:11 +01:00
commit cb002f4f27
50 changed files with 4666 additions and 0 deletions

254
test/benchmark.luau Normal file
View file

@ -0,0 +1,254 @@
------------------------------------------------------------------------------------------
-- benchmark.lua
------------------------------------------------------------------------------------------
local BENCH, START = require("test/testkit").benchmark()
local vide = require "src/init"
local N = 2^18 -- 262144
BENCH("Create state", function()
local cache = table.create(N)
local source = vide.source
for i = 1, START(N) do
cache[i] = source(1)
end
end)
BENCH("Get value", function()
local state = vide.source(1)
for i = 1, START(N) do
state()
end
end)
BENCH("Set value", function()
local state = vide.source(1)
for i = 1, START(N) do
state(i)
end
end)
BENCH("Derive 1 state", function()
local cache = table.create(N)
local state = vide.source(1)
local derive = vide.derive
for i = 1, START(N) do
cache[i] = derive(function()
return state()
end)
end
end)
BENCH("Derive 4 states", function()
local cache = table.create(N)
local state = vide.source(1)
local state2 = vide.source(2)
local state3 = vide.source(3)
local state4 = vide.source(4)
local derive = vide.derive
for i = 1, START(N) do
cache[i] = derive(function()
return state() + state2() + state3() + state4()
end)
end
end)
BENCH("Set derived value", function()
local state = vide.source(1)
local _derived = vide.derive(state)
for i = 1, START(N) do
state(i)
end
end)
BENCH("Apply 0 properties", function()
local apply = require "src/apply"
local instance = vide.create("Frame") {}
for i = 1, START(N) do
apply(instance, {})
end
end)
BENCH("Apply 8 properties", function()
local apply = require "src/apply"
local instance = vide.create("Frame") {}
for i = 1, START(N) do
apply(instance, {
Name = i,
Name2 = i,
Name3 = i,
Name4 = i,
Name5 = i,
Name6 = i,
Name7 = i,
Name8 = i,
})
end
end)
BENCH("Bind state", function()
local apply = require "src/apply"
local instance = vide.create("Frame") {}
local state = vide.source(1)
for i = 1, START(N) do
apply(instance, {
Name = state
})
end
end)
BENCH("Update binding", function()
local apply = require "src/apply"
local instance = vide.create("Frame") {}
local state = vide.source(1)
apply(instance, {
Name = state
})
for i = 1, START(N) do
state(i)
end
end)
BENCH("indexes() no change", function()
local data = {}
for i = 1, N do
data[i] = i
end
local state = vide.source(data)
local _list = vide.indexes(state, function(v, i)
return {}
end)
--state(state()) -- fill double buffer
START(N)
state(data)
end)
BENCH("indexes() all change", function()
local data = {}
for i = 1, N do
data[i] = i
end
local state = vide.source(data)
local _list = vide.indexes(state, function(v, i)
return {}
end)
--state(state()) -- fill double buffer
for i, v in data do
data[i] = v + 1
end
START(N)
state(data)
end)
BENCH("indexes() all remove", function()
local data = {}
for i = 1, N do
data[i] = i
end
local state = vide.source(data)
local _list = vide.indexes(state, function(v, i)
return {}
end)
table.clear(data)
START(N)
state(data)
end)
BENCH("values() no change", function()
local data = {}
for i = 1, N do
data[i] = {}
end
local state = vide.source(data)
local _list = vide.values(state, function(v, i)
return {}
end)
state(state()) -- fill double buffer
START(N)
state(data)
end)
BENCH("values() all change", function()
local data = {}
for i = 1, N do
data[i] = {}
end
local state = vide.source(data)
local _list = vide.values(state, function(v, i)
return {}
end)
state(state()) -- fill double buffer
for i = 1, N do
local r = math.random(1, #data)
data[i], data[r] = data[r], data[i]
end
START(N)
state(data)
end)
BENCH("values() all remove", function()
local data = {}
for i = 1, N do
data[i] = {}
end
local state = vide.source(data)
local _list = vide.values(state, function(v, i)
return {}
end)
table.clear(data)
START(N)
state(data)
end)
return nil

184
test/goodsignal.luau Normal file
View file

@ -0,0 +1,184 @@
--!nocheck
-- modified for use in pure luau
local task = { spawn = function(thread, ...)
local ok, err = coroutine.resume(thread, ...)
if not ok then error(err, 3) end
end }
export type Type = RBXScriptSignal & { Fire: (Type, ...any)-> () }
----------------------------------------------------------------------------------------------------
-- Batched Yield-Safe Signal Implementation --
-- This is a Signal class which has effectively identical behavior to a --
-- normal RBXScriptSignal, with the only difference being a couple extra --
-- stack frames at the bottom of the stack trace when an error is thrown. --
-- This implementation caches runner coroutines, so the ability to yield in --
-- the signal handlers comes at minimal extra cost over a naive signal --
-- implementation that either always or never spawns a thread. --
-- --
-- API: --
-- local Signal = require(THIS MODULE) --
-- local sig = Signal.new() --
-- local connection = sig:Connect(function(arg1, arg2, ...) ... end) --
-- sig:Fire(arg1, arg2, ...) --
-- connection:Disconnect() --
-- sig:DisconnectAll() --
-- local arg1, arg2, ... = sig:Wait() --
-- --
-- Licence: --
-- Licenced under the MIT licence. --
-- --
-- Authors: --
-- stravant - July 31st, 2021 - Created the file. --
----------------------------------------------------------------------------------------------------
-- The currently idle thread to run the next handler on
local freeRunnerThread = nil
-- Function which acquires the currently idle handler runner thread, runs the
-- function fn on it, and then releases the thread, returning it to being the
-- currently idle one.
-- If there was a currently idle runner thread already, that's okay, that old
-- one will just get thrown and eventually GCed.
local function acquireRunnerThreadAndCallEventHandler(fn, ...)
local acquiredRunnerThread = freeRunnerThread
freeRunnerThread = nil
fn(...)
-- The handler finished running, this runner thread is free again.
freeRunnerThread = acquiredRunnerThread
end
-- Coroutine runner that we create coroutines of. The coroutine can be
-- repeatedly resumed with functions to run followed by the argument to run
-- them with.
local function runEventHandlerInFreeThread()
-- Note: We cannot use the initial set of arguments passed to
-- runEventHandlerInFreeThread for a call to the handler, because those
-- arguments would stay on the stack for the duration of the thread's
-- existence, temporarily leaking references. Without access to raw bytecode
-- there's no way for us to clear the "..." references from the stack.
while true do
acquireRunnerThreadAndCallEventHandler(coroutine.yield())
end
end
-- Connection class
local Connection = {}
Connection.__index = Connection
function Connection.new(signal, fn)
return setmetatable({
_connected = true,
_signal = signal,
_fn = fn,
_next = false,
}, Connection)
end
function Connection:Disconnect()
self._connected = false
-- Unhook the node, but DON'T clear it. That way any fire calls that are
-- currently sitting on this node will be able to iterate forwards off of
-- it, but any subsequent fire calls will not hit it, and it will be GCed
-- when no more fire calls are sitting on it.
if self._signal._handlerListHead == self then
self._signal._handlerListHead = self._next
else
local prev = self._signal._handlerListHead
while prev and prev._next ~= self do
prev = prev._next
end
if prev then
prev._next = self._next
end
end
end
-- Make Connection strict
setmetatable(Connection, {
__index = function(tb, key)
error(("Attempt to get Connection::%s (not a valid member)"):format(tostring(key)), 2)
end,
__newindex = function(tb, key, value)
error(("Attempt to set Connection::%s (not a valid member)"):format(tostring(key)), 2)
end
})
-- Signal class
local Signal = {}
Signal.__index = Signal
Signal.__type = "RBXScriptSignal"
function Signal.new(): Type
return setmetatable({
_handlerListHead = false,
}, Signal) :: any
end
function Signal:Connect(fn)
if type(fn) ~= "function" then error(`attempt to connect non function (got { type(fn) })`, 2) end
local connection = Connection.new(self, fn)
if self._handlerListHead then
connection._next = self._handlerListHead
self._handlerListHead = connection
else
self._handlerListHead = connection
end
return connection
end
-- Disconnect all handlers. Since we use a linked list it suffices to clear the
-- reference to the head handler.
function Signal:DisconnectAll()
self._handlerListHead = false
end
-- Signal:Fire(...) implemented by running the handler functions on the
-- coRunnerThread, and any time the resulting thread yielded without returning
-- to us, that means that it yielded to the Roblox scheduler and has been taken
-- over by Roblox scheduling, meaning we have to make a new coroutine runner.
function Signal:Fire(...)
local item = self._handlerListHead
while item do
if item._connected then
if not freeRunnerThread then
freeRunnerThread = coroutine.create(runEventHandlerInFreeThread)
-- Get the freeRunnerThread to the first yield
coroutine.resume(freeRunnerThread)
end
task.spawn(freeRunnerThread, item._fn, ...)
end
item = item._next
end
end
-- Implement Signal:Wait() in terms of a temporary connection using
-- a Signal:Connect() which disconnects itself.
function Signal:Wait()
local waitingCoroutine = coroutine.running()
local cn;
cn = self:Connect(function(...)
cn:Disconnect()
task.spawn(waitingCoroutine, ...)
end)
return coroutine.yield()
end
-- Implement Signal:Once() in terms of a connection which disconnects
-- itself before running the handler.
function Signal:Once(fn)
local cn;
cn = self:Connect(function(...)
if cn._connected then
cn:Disconnect()
end
fn(...)
end)
return cn
end
return Signal

254
test/mock.luau Normal file
View file

@ -0,0 +1,254 @@
local Instance = {} do
local Signal = require "test/goodsignal"
type Signal = Signal.Type
type userdata = { __USERDATA: true }
--[[
attempt to mimic roblox engine's method of userdata proxy to actual instance data
proxy can gc independantly of actual instance data
proxy prevents gc of actual instance data
luau code never has direct access to actual instance data, only to proxy
proxy knows data
data does not know proxy
separate weak map kept data -> proxy
]]
type ProxyMT = {
proxy: userdata,
data: Data,
__index: any,
__newindex: any
}
type Data = {
name: string,
parent: Data?,
children: { Data },
changed: { [string]: Signal },
properties: { [string]: unknown },
destroying: Signal,
class: string,
type: "Instance"
}
local function deep_clone<T>(template: T & {}): T
local t = table.clone(template :: {}) :: {}
for i, v in next, t do
if type(v) == "table" then
t[i] = deep_clone(v)
end
end
return t :: T & {}
end
local proxies = {} :: { [Data]: userdata? }
setmetatable(proxies :: any, { __mode = "v" })
local function get_data(userdata: userdata): Data
local function f(userdata: userdata): ProxyMT
return getmetatable(userdata :: any)
end
return f(userdata).data
end
local function is_instance(value: unknown): boolean
local mt = getmetatable(value :: any)
return mt and mt.data and mt.data.type == "Instance"
end
local methods = {}
local function __index(userdata: userdata, property: string): ()
local data = get_data(userdata)
return if methods[property] then methods[property]
elseif property == "Name" then data.name
elseif property == "Parent" then data.parent
elseif property == "Destroying" then data.destroying
else data.properties[property]
end
local function __newindex(userdata: userdata, property: string, value: unknown)
local data = get_data(userdata)
if property == "Name" then
data.name = value :: string
elseif property == "Parent" then
assert(value == nil or is_instance(value), "attempt to set non-instance as parent")
local parent = data.parent
if parent then
data.parent = nil
table.remove(parent.children, table.find(parent.children, data))
end
if value then
data.parent = get_data(value :: userdata)
table.insert(get_data(value :: userdata).children, data)
end
else
data.properties[property] = value
end
if data.changed[property] then
data.changed[property]:Fire()
end
end
local function get_proxy(data: Data): userdata
return proxies[data] or (function()
local userdata = newproxy(true)
local proxy = getmetatable(userdata)
proxy.proxy = userdata
proxy.data = data
proxy.__index = __index
proxy.__newindex = __newindex
proxies[data] = userdata
return userdata
end)()
end
function Instance.new(class: string): Instance
local data = {
name = "UNNAMED",
parent = nil,
children = {},
changed = {},
properties = {},
class = class,
destroying = Signal.new() :: any,
type = "Instance" :: "Instance"
}
return get_proxy(data) :: any
end
function Instance.is_instance(value: unknown): boolean
return is_instance(value)
end
function methods.Clone(userdata: userdata): userdata
local data = get_data(userdata)
local clone_userdata = (Instance.new("") :: any) :: userdata
local clone_data = get_data(clone_userdata)
for i, v in next, deep_clone(data) do
clone_data[i] = v
end
return clone_userdata
end
function methods.FindFirstChild(userdata: userdata, target: string): userdata?
local data = get_data(userdata)
for _, child in data.children do
if child.name == target then
return get_proxy(child)
end
end
return nil
end
function methods.GetChildren(userdata: userdata): { userdata }
local children = get_data(userdata).children
local userdatas = table.create(#children)
for i, child in next, children do
userdatas[i] = get_proxy(child)
end
return userdatas
end
function methods.GetPropertyChangedSignal(userdata: userdata, property: string): RBXScriptSignal
local data = get_data(userdata)
if not data.changed[property] then
data.changed[property] = Signal.new() :: any
end
return data.changed[property]
end
function methods.Destroy(userdata: userdata)
local data = get_data(userdata);
data.destroying:Fire()
data.parent = nil
if data.changed["Parent"] then
data.changed["Parent"]:Fire()
end
end
end
local Color3 = {} do
function Color3.new(r, g, b): Color3
return setmetatable({ r = r, g = g, b = b}, Color3) :: any
end
function Color3.__eq(a, b)
return a.r == b.r and a.g == b.g and a.b == b.b
end
end
local Vector3 = {} do
function Vector3.new(x, y, z): Vector3
return setmetatable({ x = x, y = y, z = z}, Vector3) :: any
end
function Vector3.__eq(a, b)
return a.x == b.x and a.y == b.y and a.z == b.z
end
end
local Vector2 = {} do
function Vector2.new(x, y): Vector2
return setmetatable({ x = x, y = y }, Vector2) :: any
end
function Vector2.__eq(a, b)
return a.x == b.x and a.y == b.y
end
end
local UDim2 = {} do
function UDim2.fromScale(x, y): UDim2
return setmetatable({ x = { scale = x, offset = 0 }, y = { scale = y, offset = 0 } }, UDim2) :: any
end
function UDim2.__eq(a, b)
return a.x.scale == b.x.scale and
b.x.offset == b.x.offset and
a.y.scale == b.y.scale and
a.y.offset == b.y.offset
end
end
local Enum = {} :: any do
setmetatable(Enum, { __index = function(self, index)
local v = setmetatable({}, { __index = function(self, index)
self[index] = true
return true
end})
self[index] = v
return v
end})
end
local function typeof(v): string
return if Instance.is_instance(v) then "Instance"
elseif getmetatable(v) and getmetatable(v).__type then getmetatable(v).__type
else type(v)
end
return {
Instance = Instance,
Color3 = Color3,
Vector3 = Vector3,
Vector2 = Vector2,
UDim2 = UDim2,
Enum = Enum,
typeof = typeof
}

460
test/testkit.luau Normal file
View file

@ -0,0 +1,460 @@
--------------------------------------------------------------------------------
-- testkit.luau
-- v0.7.0
--------------------------------------------------------------------------------
local color = {
white_underline = function(s: string)
return `\27[1;4m{s}\27[0m`
end,
white = function(s: string)
return `\27[37;1m{s}\27[0m`
end,
green = function(s: string)
return `\27[32;1m{s}\27[0m`
end,
red = function(s: string)
return `\27[31;1m{s}\27[0m`
end,
yellow = function(s: string)
return `\27[33;1m{s}\27[0m`
end,
red_highlight = function(s: string)
return `\27[41;1;30m{s}\27[0m`
end,
green_highlight = function(s: string)
return `\27[42;1;30m{s}\27[0m`
end,
gray = function(s: string)
return `\27[30;1m{s}\27[0m`
end,
}
local function convert_units(unit: string, value: number): (number, string)
local prefix_colors = {
[3] = color.red,
[2] = color.yellow,
[1] = color.yellow,
[0] = color.green,
[-1] = color.red,
[-2] = color.yellow,
[-3] = color.green
}
local prefixes = {
[3] ="G",
[2] ="M",
[1] = "k",
[0] = " ",
[-1] = "m",
[-2] = "u",
[-3] = "n"
}
local order = 0
while value >= 1000 do
order += 1
value /= 1000
end
while value ~= 0 and value < 1 do
order -= 1
value *= 1000
end
if value >= 100 then
value = math.floor(value)
elseif value >= 10 then
value = math.floor(value * 1e1) / 1e1
elseif value >= 1 then
value = math.floor(value * 1e2) / 1e2
end
return value, prefix_colors[order](prefixes[order] .. unit)
end
local WALL = color.gray ""
--------------------------------------------------------------------------------
-- Testing
--------------------------------------------------------------------------------
type Test = {
name: string,
case: Case?,
cases: { Case },
duration: number,
error: {
message: string,
trace: string
}?
}
type Case = {
name: string,
result: number,
line: number?
}
local PASS, FAIL, NONE, ERROR = 1, 2, 3, 4
local skip: string?
local test: Test?
local tests: { Test } = {}
local function output_test_result(test: Test)
print(color.white(test.name))
for _, case in test.cases do
local status = ({
[PASS] = color.green "PASS",
[FAIL] = color.red "FAIL",
[NONE] = color.yellow "NONE",
[ERROR] = color.red "FAIL"
})[case.result]
local line = case.result == FAIL and color.red(`{case.line}:`) or ""
print(`{status}{WALL} {line}{color.gray(case.name)}`)
end
if test.error then
print(color.gray "error: " .. color.red(test.error.message))
print(color.gray "trace: " .. color.red(test.error.trace))
else
print()
end
end
local function CASE(name: string)
assert(test, "no active test")
local case = {
name = name,
result = NONE
}
test.case = case
table.insert(test.cases, case)
end
local function CHECK<T>(value: T, stack: number?): T
assert(test, "no active test")
local case = test.case
if not case then
CASE ""
case = test.case
end
assert(case, "no active case")
if case.result ~= FAIL then
case.result = value and PASS or FAIL
case.line = debug.info(stack and stack + 1 or 2, "l")
end
return value
end
local function TEST(name: string, fn: () -> ())
if skip and name ~= skip then return end
local active = test
assert(not active, "cannot start test while another test is in progress")
test = {
name = name,
cases = {},
duration = 0
}; assert(test)
table.insert(tests, test)
local start = os.clock()
local err
local success = xpcall(fn, function(m: string)
err = { message = m, trace = debug.traceback(nil, 2) }
end)
test.duration = os.clock() - start
if not test.case then CASE "" end
assert(test.case, "no active case")
if not success then
test.case.result = ERROR
test.error = err
end
test = nil
end
local function FINISH(): boolean
local success = true
local total_cases = 0
local passed_cases = 0
local duration = 0
for _, test in tests do
duration += test.duration
for _, case in test.cases do
total_cases += 1
if case.result == PASS or case.result == NONE then
passed_cases += 1
else
success = false
end
end
output_test_result(test)
end
print(color.gray(string.format(
`{passed_cases}/{total_cases} test cases passed in %.3f ms.`,
duration*1e3
)))
local fails = total_cases - passed_cases
print(
(
fails > 0
and color.red
or color.green
)(`{fails} {fails == 1 and "fail" or "fails"}`)
)
return success, table.clear(tests)
end
local function SKIP(name: string)
assert(not test, "cannot skip during test")
skip = name
end
--------------------------------------------------------------------------------
-- Benchmarking
--------------------------------------------------------------------------------
type Bench = {
time_start: number?,
memory_start: number?,
iterations: number?
}
local bench: Bench?
function START(iter: number?): number
local n = iter or 1
assert(n > 0, "iterations must be greater than 0")
assert(bench, "no active benchmark")
assert(not bench.time_start, "clock was already started")
bench.iterations = n
bench.memory_start = gcinfo()
bench.time_start = os.clock()
return n
end
local function BENCH(name: string, fn: () -> ())
local active = bench
assert(not active, "a benchmark is already in progress")
bench = {}; assert(bench)
;(collectgarbage :: any)("collect")
local mem_start = gcinfo()
local time_start = os.clock()
local err_msg: string?
local success = xpcall(fn, function(m: string)
err_msg = m .. debug.traceback(nil, 2)
end)
local time_stop = os.clock()
local mem_stop = gcinfo()
if not success then
print(`{WALL}{color.red("ERROR")}{WALL} {name}`)
print(color.gray(err_msg :: string))
else
time_start = bench.time_start or time_start
mem_start = bench.memory_start or mem_start
local n = bench.iterations or 1
local d, d_unit = convert_units("s", (time_stop - time_start) / n)
local a, a_unit = convert_units("B", math.floor((mem_stop - mem_start) / n * 1e3))
local function round(x: number): string
return x > 0 and x < 10 and (x - math.floor(x)) > 0
and string.format("%2.1f", x)
or string.format("%3.f", x)
end
print(string.format(
`%s %s %s %s{WALL} %s`,
color.gray(tostring(round(d))),
d_unit,
color.gray(tostring(round(a))),
a_unit,
color.gray(name)
))
end
bench = nil
end
--------------------------------------------------------------------------------
-- Printing
--------------------------------------------------------------------------------
local function print2(v: unknown)
type Buffer = { n: number, [number]: string }
type Cyclic = { [{}]: true }
-- overkill concatenationless string buffer
local function tos(value: any, stack: number, str: Buffer, cyclic: Cyclic)
local TAB = " "
local indent = table.concat(table.create(stack, TAB))
if type(value) == "string" then
local n = str.n
str[n + 1] = "\""
str[n + 2] = value
str[n + 3] = "\""
str.n = n + 3
elseif type(value) ~= "table" then
local n = str.n
str[n + 1] = value == nil and "nil" or tostring(value)
str.n = n + 1
elseif next(value) == nil then
local n = str.n
str[n + 1] = "{}"
str.n = n + 1
else -- is table
local tabbed_indent = indent .. TAB
str.n += 1
if cyclic[value] then
str[str.n] = color.gray "*cyclic reference*"
return
else
cyclic[value] = true
end
str[str.n] = "{\n"
local i, v = next(value, nil)
while v ~= nil do
local n = str.n
str[n + 1] = tabbed_indent
if type(i) ~= "string" then
str[n + 2] = "["
str[n + 3] = tostring(i)
str[n + 4] = "]"
n += 4
else
str[n + 2] = tostring(i)
n += 2
end
str[n + 1] = " = "
str.n = n + 1
tos(v, stack + 1, str, cyclic)
i, v = next(value, i)
n = str.n
str[n + 1] = v ~= nil and ",\n" or "\n"
str.n = n + 1
end
local n = str.n
str[n + 1] = indent
str[n + 2] = "}"
str.n = n + 2
end
end
local str = { n = 0 }
local cyclic = {}
tos(v, 0, str, cyclic)
print(table.concat(str))
end
--------------------------------------------------------------------------------
-- Equality
--------------------------------------------------------------------------------
local function shallow_eq(a: {}, b: {}): boolean
if #a ~= #b then return false end
for i, v in next, a do
if b[i] ~= v then
return false
end
end
for i, v in next, b do
if a[i] ~= v then
return false
end
end
return true
end
local function deep_eq(a: {}, b: {}): boolean
if #a ~= #b then return false end
for i, v in next, a do
if type(b[i]) == "table" and type(v) == "table" then
if deep_eq(b[i], v) == false then return false end
elseif b[i] ~= v then
return false
end
end
for i, v in next, b do
if type(a[i]) == "table" and type(v) == "table" then
if deep_eq(a[i], v) == false then return false end
elseif a[i] ~= v then
return false
end
end
return true
end
--------------------------------------------------------------------------------
-- Return
--------------------------------------------------------------------------------
return {
test = function()
return TEST, CASE, CHECK, FINISH, SKIP
end,
benchmark = function()
return BENCH, START
end,
print2 = print2,
seq = shallow_eq,
deq = deep_eq,
color = color
}

1302
test/tests.luau Normal file

File diff suppressed because it is too large Load diff

8
test/wrap-require.luau Normal file
View file

@ -0,0 +1,8 @@
local function dir(directory: string)
return setmetatable({} :: { [string]: any }, { __index = function(_, path) return directory .. path end })
end
local script = dir "src/"
script.Parent = dir "src/"
return script