--!nonstrict --[[ Constructs a new ForPairs object which maps pairs of a table using a `processor` function. Optionally, a `destructor` function can be specified for cleaning up values. If omitted, the default cleanup function will be used instead. Additionally, a `meta` table/value can optionally be returned to pass data created when running the processor to the destructor when the created object is cleaned up. ]] local PubTypes = require "../PubTypes" local Types = require "../Types" -- Logging local parseError = require "../Logging/parseError" local logErrorNonFatal = require "../Logging/logErrorNonFatal" local logError = require "../Logging/logError" local logWarn = require "../Logging/logWarn" -- Utility local cleanup = require "../Utility/cleanup" local needsDestruction = require "../Utility/needsDestruction" -- State local peek = require "../State/peek" local makeUseCallback = require "../State/makeUseCallback" local isState = require "../State/isState" local class = {} local CLASS_METATABLE = { __index = class } local WEAK_KEYS_METATABLE = { __mode = "k" } --[[ Called when the original table is changed. This will firstly find any keys meeting any of the following criteria: - they were not previously present - their associated value has changed - a dependency used during generation of this value has changed It will recalculate those key/value pairs, storing information about any dependencies used in the processor callback during value generation, and save the new key/value pair to the output array. If it is overwriting an older key/value pair, that older pair will be passed to the destructor for cleanup. Finally, this function will find keys that are no longer present, and remove their key/value pairs from the output table and pass them to the destructor. ]] function class:update(): boolean local inputIsState = self._inputIsState local newInputTable = peek(self._inputTable) local oldInputTable = self._oldInputTable local keyIOMap = self._keyIOMap local meta = self._meta local didChange = false -- clean out main dependency set for dependency in pairs(self.dependencySet) do dependency.dependentSet[self] = nil end self._oldDependencySet, self.dependencySet = self.dependencySet, self._oldDependencySet -- table.clear(self.dependencySet) for i, _ in pairs(self.dependencySet) do self.dependencySet[i] = nil end -- if the input table is a state object, add it as a dependency if inputIsState then self._inputTable.dependentSet[self] = true self.dependencySet[self._inputTable] = true end -- clean out output table self._oldOutputTable, self._outputTable = self._outputTable, self._oldOutputTable local oldOutputTable = self._oldOutputTable local newOutputTable = self._outputTable -- table.clear(newOutputTable) for i, _ in pairs(newOutputTable) do newOutputTable[i] = nil end -- Step 1: find key/value pairs that changed or were not previously present for newInKey, newInValue in pairs(newInputTable) do -- get or create key data local keyData = self._keyData[newInKey] if keyData == nil then keyData = { dependencySet = setmetatable({}, WEAK_KEYS_METATABLE), oldDependencySet = setmetatable({}, WEAK_KEYS_METATABLE), dependencyValues = setmetatable({}, WEAK_KEYS_METATABLE), } self._keyData[newInKey] = keyData end -- check if the pair is new or changed local shouldRecalculate = oldInputTable[newInKey] ~= newInValue -- check if the pair's dependencies have changed if shouldRecalculate == false then for dependency, oldValue in pairs(keyData.dependencyValues) do if oldValue ~= peek(dependency) then shouldRecalculate = true break end end end -- recalculate the output pair if necessary if shouldRecalculate then keyData.oldDependencySet, keyData.dependencySet = keyData.dependencySet, keyData.oldDependencySet -- table.clear(keyData.dependencySet) for i, _ in pairs(keyData.dependencySet) do keyData.dependencySet[i] = nil end local use = makeUseCallback(keyData.dependencySet) -- local processOK, newOutKey, newOutValue, newMetaValue = -- xpcall(self._processor, parseError, use, newInKey, newInValue) local processOK, newOutKey, newOutValue, newMetaValue = pcall(self._processor, use, newInKey, newInValue) if processOK then if self._destructor == nil and ( needsDestruction(newOutKey) or needsDestruction(newOutValue) or needsDestruction(newMetaValue) ) then logWarn "destructorNeededForPairs" end -- if this key was already written to on this run-through, throw a fatal error. if newOutputTable[newOutKey] ~= nil then -- figure out which key/value pair previously wrote to this key local previousNewKey, previousNewValue for inKey, outKey in pairs(keyIOMap) do if outKey == newOutKey then previousNewValue = newInputTable[inKey] if previousNewValue ~= nil then previousNewKey = inKey break end end end if previousNewKey ~= nil then logError( "forPairsKeyCollision", nil, tostring(newOutKey), tostring(previousNewKey), tostring(previousNewValue), tostring(newInKey), tostring(newInValue) ) end end local oldOutValue = oldOutputTable[newOutKey] if oldOutValue ~= newOutValue then local oldMetaValue = meta[newOutKey] if oldOutValue ~= nil then local destructOK, err = pcall( self._destructor or cleanup, newOutKey, oldOutValue, oldMetaValue ) if not destructOK then logErrorNonFatal( "forPairsDestructorError", parseError(err) ) end end oldOutputTable[newOutKey] = nil end -- update the stored data for this key/value pair oldInputTable[newInKey] = newInValue keyIOMap[newInKey] = newOutKey meta[newOutKey] = newMetaValue newOutputTable[newOutKey] = newOutValue -- if we had to recalculate the output, then we did change didChange = true else -- restore old dependencies, because the new dependencies may be corrupt keyData.oldDependencySet, keyData.dependencySet = keyData.dependencySet, keyData.oldDependencySet logErrorNonFatal( "forPairsProcessorError", parseError(newOutKey) ) end else local storedOutKey = keyIOMap[newInKey] -- check for key collision if newOutputTable[storedOutKey] ~= nil then -- figure out which key/value pair previously wrote to this key local previousNewKey, previousNewValue for inKey, outKey in pairs(keyIOMap) do if storedOutKey == outKey then previousNewValue = newInputTable[inKey] if previousNewValue ~= nil then previousNewKey = inKey break end end end if previousNewKey ~= nil then logError( "forPairsKeyCollision", nil, tostring(storedOutKey), tostring(previousNewKey), tostring(previousNewValue), tostring(newInKey), tostring(newInValue) ) end end -- copy the stored key/value pair into the new output table newOutputTable[storedOutKey] = oldOutputTable[storedOutKey] end -- save dependency values and add to main dependency set for dependency in pairs(keyData.dependencySet) do keyData.dependencyValues[dependency] = peek(dependency) self.dependencySet[dependency] = true dependency.dependentSet[self] = true end end -- STEP 2: find keys that were removed for oldOutKey, oldOutValue in pairs(oldOutputTable) do -- check if this key/value pair is in the new output table if newOutputTable[oldOutKey] ~= oldOutValue then -- clean up the old output pair local oldMetaValue = meta[oldOutKey] if oldOutValue ~= nil then local destructOK, err = pcall( self._destructor or cleanup, oldOutKey, oldOutValue, oldMetaValue ) if not destructOK then logErrorNonFatal("forPairsDestructorError", parseError(err)) end end -- check if the key was completely removed from the output table if newOutputTable[oldOutKey] == nil then meta[oldOutKey] = nil self._keyData[oldOutKey] = nil end didChange = true end end for key in pairs(oldInputTable) do if newInputTable[key] == nil then oldInputTable[key] = nil keyIOMap[key] = nil end end return didChange end --[[ Returns the interior value of this state object. ]] function class:_peek(): any return self._outputTable end function class:get() logError "stateGetWasRemoved" end local function ForPairs( inputTable: PubTypes.CanBeState<{ [KI]: VI }>, processor: (KI, VI) -> (KO, VO, M?), destructor: (KO, VO, M?) -> ()? ): Types.ForPairs local inputIsState = isState(inputTable) local self = setmetatable({ type = "State", kind = "ForPairs", dependencySet = {}, -- if we held strong references to the dependents, then they wouldn't be -- able to get garbage collected when they fall out of scope dependentSet = setmetatable({}, WEAK_KEYS_METATABLE), _oldDependencySet = {}, _processor = processor, _destructor = destructor, _inputIsState = inputIsState, _inputTable = inputTable, _oldInputTable = {}, _outputTable = {}, _oldOutputTable = {}, _keyIOMap = {}, _keyData = {}, _meta = {}, }, CLASS_METATABLE) self:update() return self end return ForPairs