-- This file is part of the Roblox luau-polyfill repository and is licensed under MIT License; see LICENSE.txt for details -- #region Array -- Array related local Array = {} local Object = {} local Map = {} type Array = { [number]: T } type callbackFn = (element: V, key: K, map: Map) -> () type callbackFnWithThisArg = (thisArg: Object, value: V, key: K, map: Map) -> () type Map = { size: number, -- method definitions set: (self: Map, K, V) -> Map, get: (self: Map, K) -> V | nil, clear: (self: Map) -> (), delete: (self: Map, K) -> boolean, forEach: (self: Map, callback: callbackFn | callbackFnWithThisArg, thisArg: Object?) -> (), has: (self: Map, K) -> boolean, keys: (self: Map) -> Array, values: (self: Map) -> Array, entries: (self: Map) -> Array>, ipairs: (self: Map) -> any, [K]: V, _map: { [K]: V }, _array: { [number]: K }, } type mapFn = (element: T, index: number) -> U type mapFnWithThisArg = (thisArg: any, element: T, index: number) -> U type Object = { [string]: any } type Table = { [T]: V } type Tuple = Array local Set = {} -- #region Array function Array.isArray(value: any): boolean if typeof(value) ~= "table" then return false end if next(value) == nil then -- an empty table is an empty array return true end local length = #value if length == 0 then return false end local count = 0 local sum = 0 for key in pairs(value) do if typeof(key) ~= "number" then return false end if key % 1 ~= 0 or key < 1 then return false end count += 1 sum += key end return sum == (count * (count + 1) / 2) end function Array.from( value: string | Array | Object, mapFn: (mapFn | mapFnWithThisArg)?, thisArg: Object? ): Array if value == nil then error("cannot create array from a nil value") end local valueType = typeof(value) local array = {} if valueType == "table" and Array.isArray(value) then if mapFn then for i = 1, #(value :: Array) do if thisArg ~= nil then array[i] = (mapFn :: mapFnWithThisArg)(thisArg, (value :: Array)[i], i) else array[i] = (mapFn :: mapFn)((value :: Array)[i], i) end end else for i = 1, #(value :: Array) do array[i] = (value :: Array)[i] end end elseif instanceOf(value, Set) then if mapFn then for i, v in (value :: any):ipairs() do if thisArg ~= nil then array[i] = (mapFn :: mapFnWithThisArg)(thisArg, v, i) else array[i] = (mapFn :: mapFn)(v, i) end end else for i, v in (value :: any):ipairs() do array[i] = v end end elseif instanceOf(value, Map) then if mapFn then for i, v in (value :: any):ipairs() do if thisArg ~= nil then array[i] = (mapFn :: mapFnWithThisArg)(thisArg, v, i) else array[i] = (mapFn :: mapFn)(v, i) end end else for i, v in (value :: any):ipairs() do array[i] = v end end elseif valueType == "string" then if mapFn then for i = 1, (value :: string):len() do if thisArg ~= nil then array[i] = (mapFn :: mapFnWithThisArg)(thisArg, (value :: any):sub(i, i), i) else array[i] = (mapFn :: mapFn)((value :: any):sub(i, i), i) end end else for i = 1, (value :: string):len() do array[i] = (value :: any):sub(i, i) end end end return array end type callbackFnArrayMap = (element: T, index: number, array: Array) -> U type callbackFnWithThisArgArrayMap = (thisArg: V, element: T, index: number, array: Array) -> U -- Implements Javascript's `Array.prototype.map` as defined below -- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map function Array.map( t: Array, callback: callbackFnArrayMap | callbackFnWithThisArgArrayMap, thisArg: V? ): Array if typeof(t) ~= "table" then error(string.format("Array.map called on %s", typeof(t))) end if typeof(callback) ~= "function" then error("callback is not a function") end local len = #t local A = {} local k = 1 while k <= len do local kValue = t[k] if kValue ~= nil then local mappedValue if thisArg ~= nil then mappedValue = (callback :: callbackFnWithThisArgArrayMap)(thisArg, kValue, k, t) else mappedValue = (callback :: callbackFnArrayMap)(kValue, k, t) end A[k] = mappedValue end k += 1 end return A end type Function = (any, any, number, any) -> any -- Implements Javascript's `Array.prototype.reduce` as defined below -- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/reduce function Array.reduce(array: Array, callback: Function, initialValue: any?): any if typeof(array) ~= "table" then error(string.format("Array.reduce called on %s", typeof(array))) end if typeof(callback) ~= "function" then error("callback is not a function") end local length = #array local value local initial = 1 if initialValue ~= nil then value = initialValue else initial = 2 if length == 0 then error("reduce of empty array with no initial value") end value = array[1] end for i = initial, length do value = callback(value, array[i], i, array) end return value end type callbackFnArrayForEach = (element: T, index: number, array: Array) -> () type callbackFnWithThisArgArrayForEach = (thisArg: U, element: T, index: number, array: Array) -> () -- Implements Javascript's `Array.prototype.forEach` as defined below -- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach function Array.forEach( t: Array, callback: callbackFnArrayForEach | callbackFnWithThisArgArrayForEach, thisArg: U? ): () if typeof(t) ~= "table" then error(string.format("Array.forEach called on %s", typeof(t))) end if typeof(callback) ~= "function" then error("callback is not a function") end local len = #t local k = 1 while k <= len do local kValue = t[k] if thisArg ~= nil then (callback :: callbackFnWithThisArgArrayForEach)(thisArg, kValue, k, t) else (callback :: callbackFnArrayForEach)(kValue, k, t) end if #t < len then -- don't iterate on removed items, don't iterate more than original length len = #t end k += 1 end end -- #endregion -- #region Set Set.__index = Set type callbackFnSet = (value: T, key: T, set: Set) -> () type callbackFnWithThisArgSet = (thisArg: Object, value: T, key: T, set: Set) -> () export type Set = { size: number, -- method definitions add: (self: Set, T) -> Set, clear: (self: Set) -> (), delete: (self: Set, T) -> boolean, forEach: (self: Set, callback: callbackFnSet | callbackFnWithThisArgSet, thisArg: Object?) -> (), has: (self: Set, T) -> boolean, ipairs: (self: Set) -> any, } type Iterable = { ipairs: (any) -> any } function Set.new(iterable: Array | Set | Iterable | string | nil): Set local array = {} local map = {} if iterable ~= nil then local arrayIterable: Array -- ROBLOX TODO: remove type casting from (iterable :: any).ipairs in next release if typeof(iterable) == "table" then if Array.isArray(iterable) then arrayIterable = Array.from(iterable :: Array) elseif typeof((iterable :: Iterable).ipairs) == "function" then -- handle in loop below elseif _G.__DEV__ then error("cannot create array from an object-like table") end elseif typeof(iterable) == "string" then arrayIterable = Array.from(iterable :: string) else error(("cannot create array from value of type `%s`"):format(typeof(iterable))) end if arrayIterable then for _, element in ipairs(arrayIterable) do if not map[element] then map[element] = true table.insert(array, element) end end elseif typeof(iterable) == "table" and typeof((iterable :: Iterable).ipairs) == "function" then for _, element in (iterable :: Iterable):ipairs() do if not map[element] then map[element] = true table.insert(array, element) end end end end return (setmetatable({ size = #array, _map = map, _array = array, }, Set) :: any) :: Set end function Set:add(value) if not self._map[value] then -- Luau FIXME: analyze should know self is Set which includes size as a number self.size = self.size :: number + 1 self._map[value] = true table.insert(self._array, value) end return self end function Set:clear() self.size = 0 table.clear(self._map) table.clear(self._array) end function Set:delete(value): boolean if not self._map[value] then return false end -- Luau FIXME: analyze should know self is Map which includes size as a number self.size = self.size :: number - 1 self._map[value] = nil local index = table.find(self._array, value) if index then table.remove(self._array, index) end return true end -- Implements Javascript's `Map.prototype.forEach` as defined below -- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set/forEach function Set:forEach(callback: callbackFnSet | callbackFnWithThisArgSet, thisArg: Object?): () if typeof(callback) ~= "function" then error("callback is not a function") end return Array.forEach(self._array, function(value: T) if thisArg ~= nil then (callback :: callbackFnWithThisArgSet)(thisArg, value, value, self) else (callback :: callbackFnSet)(value, value, self) end end) end function Set:has(value): boolean return self._map[value] ~= nil end function Set:ipairs() return ipairs(self._array) end -- #endregion Set -- #region Object function Object.entries(value: string | Object | Array): Array assert(value :: any ~= nil, "cannot get entries from a nil value") local valueType = typeof(value) local entries: Array> = {} if valueType == "table" then for key, keyValue in pairs(value :: Object) do -- Luau FIXME: Luau should see entries as Array, given object is [string]: any, but it sees it as Array> despite all the manual annotation table.insert(entries, { key :: string, keyValue :: any }) end elseif valueType == "string" then for i = 1, string.len(value :: string) do entries[i] = { tostring(i), string.sub(value :: string, i, i) } end end return entries end -- #endregion -- #region instanceOf -- ROBLOX note: Typed tbl as any to work with strict type analyze -- polyfill for https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/instanceof function instanceOf(tbl: any, class) assert(typeof(class) == "table", "Received a non-table as the second argument for instanceof") if typeof(tbl) ~= "table" then return false end local ok, hasNew = pcall(function() return class.new ~= nil and tbl.new == class.new end) if ok and hasNew then return true end local seen = { tbl = true } while tbl and typeof(tbl) == "table" do tbl = getmetatable(tbl) if typeof(tbl) == "table" then tbl = tbl.__index if tbl == class then return true end end -- if we still have a valid table then check against seen if typeof(tbl) == "table" then if seen[tbl] then return false end seen[tbl] = true end end return false end -- #endregion function Map.new(iterable: Array>?): Map local array = {} local map = {} if iterable ~= nil then local arrayFromIterable local iterableType = typeof(iterable) if iterableType == "table" then if #iterable > 0 and typeof(iterable[1]) ~= "table" then error("cannot create Map from {K, V} form, it must be { {K, V}... }") end arrayFromIterable = Array.from(iterable) else error(("cannot create array from value of type `%s`"):format(iterableType)) end for _, entry in ipairs(arrayFromIterable) do local key = entry[1] if _G.__DEV__ then if key == nil then error("cannot create Map from a table that isn't an array.") end end local val = entry[2] -- only add to array if new if map[key] == nil then table.insert(array, key) end -- always assign map[key] = val end end return (setmetatable({ size = #array, _map = map, _array = array, }, Map) :: any) :: Map end function Map:set(key: K, value: V): Map -- preserve initial insertion order if self._map[key] == nil then -- Luau FIXME: analyze should know self is Map which includes size as a number self.size = self.size :: number + 1 table.insert(self._array, key) end -- always update value self._map[key] = value return self end function Map:get(key) return self._map[key] end function Map:clear() local table_: any = table self.size = 0 table_.clear(self._map) table_.clear(self._array) end function Map:delete(key): boolean if self._map[key] == nil then return false end -- Luau FIXME: analyze should know self is Map which includes size as a number self.size = self.size :: number - 1 self._map[key] = nil local index = table.find(self._array, key) if index then table.remove(self._array, index) end return true end -- Implements Javascript's `Map.prototype.forEach` as defined below -- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/forEach function Map:forEach(callback: callbackFn | callbackFnWithThisArg, thisArg: Object?): () if typeof(callback) ~= "function" then error("callback is not a function") end return Array.forEach(self._array, function(key: K) local value: V = self._map[key] :: V if thisArg ~= nil then (callback :: callbackFnWithThisArg)(thisArg, value, key, self) else (callback :: callbackFn)(value, key, self) end end) end function Map:has(key): boolean return self._map[key] ~= nil end function Map:keys() return self._array end function Map:values() return Array.map(self._array, function(key) return self._map[key] end) end function Map:entries() return Array.map(self._array, function(key) return { key, self._map[key] } end) end function Map:ipairs() return ipairs(self:entries()) end function Map.__index(self, key) local mapProp = rawget(Map, key) if mapProp ~= nil then return mapProp end return Map.get(self, key) end function Map.__newindex(table_, key, value) table_:set(key, value) end local function coerceToMap(mapLike: Map | Table): Map return instanceOf(mapLike, Map) and mapLike :: Map -- ROBLOX: order is preservered or Map.new(Object.entries(mapLike)) -- ROBLOX: order is not preserved end -- local function coerceToTable(mapLike: Map | Table): Table -- if not instanceOf(mapLike, Map) then -- return mapLike -- end -- -- create table from map -- return Array.reduce(mapLike:entries(), function(tbl, entry) -- tbl[entry[1]] = entry[2] -- return tbl -- end, {}) -- end -- #region Tests to verify it works as expected local function it(description: string, fn: () -> ()) local ok, result = pcall(fn) if not ok then error("Failed test: " .. description .. "\n" .. result) end end local AN_ITEM = "bar" local ANOTHER_ITEM = "baz" -- #region [Describe] "Map" -- #region [Child Describe] "constructors" it("creates an empty array", function() local foo = Map.new() assert(foo.size == 0) end) it("creates a Map from an array", function() local foo = Map.new({ { AN_ITEM, "foo" }, { ANOTHER_ITEM, "val" }, }) assert(foo.size == 2) assert(foo:has(AN_ITEM) == true) assert(foo:has(ANOTHER_ITEM) == true) end) it("creates a Map from an array with duplicate keys", function() local foo = Map.new({ { AN_ITEM, "foo1" }, { AN_ITEM, "foo2" }, }) assert(foo.size == 1) assert(foo:get(AN_ITEM) == "foo2") assert(#foo:keys() == 1 and foo:keys()[1] == AN_ITEM) assert(#foo:values() == 1 and foo:values()[1] == "foo2") assert(#foo:entries() == 1) assert(#foo:entries()[1] == 2) assert(foo:entries()[1][1] == AN_ITEM) assert(foo:entries()[1][2] == "foo2") end) it("preserves the order of keys first assignment", function() local foo = Map.new({ { AN_ITEM, "foo1" }, { ANOTHER_ITEM, "bar" }, { AN_ITEM, "foo2" }, }) assert(foo.size == 2) assert(foo:get(AN_ITEM) == "foo2") assert(foo:get(ANOTHER_ITEM) == "bar") assert(foo:keys()[1] == AN_ITEM) assert(foo:keys()[2] == ANOTHER_ITEM) assert(foo:values()[1] == "foo2") assert(foo:values()[2] == "bar") assert(foo:entries()[1][1] == AN_ITEM) assert(foo:entries()[1][2] == "foo2") assert(foo:entries()[2][1] == ANOTHER_ITEM) assert(foo:entries()[2][2] == "bar") end) -- #endregion -- #region [Child Describe] "type" it("instanceOf return true for an actual Map object", function() local foo = Map.new() assert(instanceOf(foo, Map) == true) end) it("instanceOf return false for an regular plain object", function() local foo = {} assert(instanceOf(foo, Map) == false) end) -- #endregion -- #region [Child Describe] "set" it("returns the Map object", function() local foo = Map.new() assert(foo:set(1, "baz") == foo) end) it("increments the size if the element is added for the first time", function() local foo = Map.new() foo:set(AN_ITEM, "foo") assert(foo.size == 1) end) it("does not increment the size the second time an element is added", function() local foo = Map.new() foo:set(AN_ITEM, "foo") foo:set(AN_ITEM, "val") assert(foo.size == 1) end) it("sets values correctly to true/false", function() -- Luau FIXME: Luau insists that arrays can't be mixed type local foo = Map.new({ { AN_ITEM, false :: any } }) foo:set(AN_ITEM, false) assert(foo.size == 1) assert(foo:get(AN_ITEM) == false) foo:set(AN_ITEM, true) assert(foo.size == 1) assert(foo:get(AN_ITEM) == true) foo:set(AN_ITEM, false) assert(foo.size == 1) assert(foo:get(AN_ITEM) == false) end) -- #endregion -- #region [Child Describe] "get" it("returns value of item from provided key", function() local foo = Map.new() foo:set(AN_ITEM, "foo") assert(foo:get(AN_ITEM) == "foo") end) it("returns nil if the item is not in the Map", function() local foo = Map.new() assert(foo:get(AN_ITEM) == nil) end) -- #endregion -- #region [Child Describe] "clear" it("sets the size to zero", function() local foo = Map.new() foo:set(AN_ITEM, "foo") foo:clear() assert(foo.size == 0) end) it("removes the items from the Map", function() local foo = Map.new() foo:set(AN_ITEM, "foo") foo:clear() assert(foo:has(AN_ITEM) == false) end) -- #endregion -- #region [Child Describe] "delete" it("removes the items from the Map", function() local foo = Map.new() foo:set(AN_ITEM, "foo") foo:delete(AN_ITEM) assert(foo:has(AN_ITEM) == false) end) it("returns true if the item was in the Map", function() local foo = Map.new() foo:set(AN_ITEM, "foo") assert(foo:delete(AN_ITEM) == true) end) it("returns false if the item was not in the Map", function() local foo = Map.new() assert(foo:delete(AN_ITEM) == false) end) it("decrements the size if the item was in the Map", function() local foo = Map.new() foo:set(AN_ITEM, "foo") foo:delete(AN_ITEM) assert(foo.size == 0) end) it("does not decrement the size if the item was not in the Map", function() local foo = Map.new() foo:set(AN_ITEM, "foo") foo:delete(ANOTHER_ITEM) assert(foo.size == 1) end) it("deletes value set to false", function() -- Luau FIXME: Luau insists arrays can't be mixed type local foo = Map.new({ { AN_ITEM, false :: any } }) foo:delete(AN_ITEM) assert(foo.size == 0) assert(foo:get(AN_ITEM) == nil) end) -- #endregion -- #region [Child Describe] "has" it("returns true if the item is in the Map", function() local foo = Map.new() foo:set(AN_ITEM, "foo") assert(foo:has(AN_ITEM) == true) end) it("returns false if the item is not in the Map", function() local foo = Map.new() assert(foo:has(AN_ITEM) == false) end) it("returns correctly with value set to false", function() -- Luau FIXME: Luau insists arrays can't be mixed type local foo = Map.new({ { AN_ITEM, false :: any } }) assert(foo:has(AN_ITEM) == true) end) -- #endregion -- #region [Child Describe] "keys / values / entries" it("returns array of elements", function() local myMap = Map.new() myMap:set(AN_ITEM, "foo") myMap:set(ANOTHER_ITEM, "val") assert(myMap:keys()[1] == AN_ITEM) assert(myMap:keys()[2] == ANOTHER_ITEM) assert(myMap:values()[1] == "foo") assert(myMap:values()[2] == "val") assert(myMap:entries()[1][1] == AN_ITEM) assert(myMap:entries()[1][2] == "foo") assert(myMap:entries()[2][1] == ANOTHER_ITEM) assert(myMap:entries()[2][2] == "val") end) -- #endregion -- #region [Child Describe] "__index" it("can access fields directly without using get", function() local typeName = "size" local foo = Map.new({ { AN_ITEM, "foo" }, { ANOTHER_ITEM, "val" }, { typeName, "buzz" }, }) assert(foo.size == 3) assert(foo[AN_ITEM] == "foo") assert(foo[ANOTHER_ITEM] == "val") assert(foo:get(typeName) == "buzz") end) -- #endregion -- #region [Child Describe] "__newindex" it("can set fields directly without using set", function() local foo = Map.new() assert(foo.size == 0) foo[AN_ITEM] = "foo" foo[ANOTHER_ITEM] = "val" foo.fizz = "buzz" assert(foo.size == 3) assert(foo:get(AN_ITEM) == "foo") assert(foo:get(ANOTHER_ITEM) == "val") assert(foo:get("fizz") == "buzz") end) -- #endregion -- #region [Child Describe] "ipairs" local function makeArray(...) local array = {} for _, item in ... do table.insert(array, item) end return array end it("iterates on the elements by their insertion order", function() local foo = Map.new() foo:set(AN_ITEM, "foo") foo:set(ANOTHER_ITEM, "val") assert(makeArray(foo:ipairs())[1][1] == AN_ITEM) assert(makeArray(foo:ipairs())[1][2] == "foo") assert(makeArray(foo:ipairs())[2][1] == ANOTHER_ITEM) assert(makeArray(foo:ipairs())[2][2] == "val") end) it("does not iterate on removed elements", function() local foo = Map.new() foo:set(AN_ITEM, "foo") foo:set(ANOTHER_ITEM, "val") foo:delete(AN_ITEM) assert(makeArray(foo:ipairs())[1][1] == ANOTHER_ITEM) assert(makeArray(foo:ipairs())[1][2] == "val") end) it("iterates on elements if the added back to the Map", function() local foo = Map.new() foo:set(AN_ITEM, "foo") foo:set(ANOTHER_ITEM, "val") foo:delete(AN_ITEM) foo:set(AN_ITEM, "food") assert(makeArray(foo:ipairs())[1][1] == ANOTHER_ITEM) assert(makeArray(foo:ipairs())[1][2] == "val") assert(makeArray(foo:ipairs())[2][1] == AN_ITEM) assert(makeArray(foo:ipairs())[2][2] == "food") end) -- #endregion -- #region [Child Describe] "Integration Tests" -- it("MDN Examples", function() -- local myMap = Map.new() :: Map -- local keyString = "a string" -- local keyObj = {} -- local keyFunc = function() end -- -- setting the values -- myMap:set(keyString, "value associated with 'a string'") -- myMap:set(keyObj, "value associated with keyObj") -- myMap:set(keyFunc, "value associated with keyFunc") -- assert(myMap.size == 3) -- -- getting the values -- assert(myMap:get(keyString) == "value associated with 'a string'") -- assert(myMap:get(keyObj) == "value associated with keyObj") -- assert(myMap:get(keyFunc) == "value associated with keyFunc") -- assert(myMap:get("a string") == "value associated with 'a string'") -- assert(myMap:get({}) == nil) -- nil, because keyObj !== {} -- assert(myMap:get(function() -- nil because keyFunc !== function () {} -- end) == nil) -- end) it("handles non-traditional keys", function() local myMap = Map.new() :: Map local falseKey = false local trueKey = true local negativeKey = -1 local emptyKey = "" myMap:set(falseKey, "apple") myMap:set(trueKey, "bear") myMap:set(negativeKey, "corgi") myMap:set(emptyKey, "doge") assert(myMap.size == 4) assert(myMap:get(falseKey) == "apple") assert(myMap:get(trueKey) == "bear") assert(myMap:get(negativeKey) == "corgi") assert(myMap:get(emptyKey) == "doge") myMap:delete(falseKey) myMap:delete(trueKey) myMap:delete(negativeKey) myMap:delete(emptyKey) assert(myMap.size == 0) end) -- #endregion -- #endregion [Describe] "Map" -- #region [Describe] "coerceToMap" it("returns the same object if instance of Map", function() local map = Map.new() assert(coerceToMap(map) == map) map = Map.new({}) assert(coerceToMap(map) == map) map = Map.new({ { AN_ITEM, "foo" } }) assert(coerceToMap(map) == map) end) -- #endregion [Describe] "coerceToMap" -- #endregion Tests to verify it works as expected