diff --git a/src/gleam_stdlib.mjs b/src/gleam_stdlib.mjs index 60ab625b..e53596b6 100644 --- a/src/gleam_stdlib.mjs +++ b/src/gleam_stdlib.mjs @@ -15,10 +15,10 @@ import { } from "./gleam/regex.mjs"; import { DecodeError } from "./gleam/dynamic.mjs"; import { Some, None } from "./gleam/option.mjs"; - -const HASHCODE_CACHE = new WeakMap(); +import PMap from "./persistent-hash-map.mjs" const Nil = undefined; +const NOT_FOUND = {} export function identity(x) { return x; @@ -244,7 +244,7 @@ export function trim_right(string) { } export function bit_string_from_string(string) { - return new toBitString([stringBits(string)]); + return toBitString([stringBits(string)]); } export function bit_string_concat(bit_strings) { @@ -395,71 +395,8 @@ export function regex_scan(regex, string) { return List.fromArray(matches); } -class Map { - static #hashcode_cache = new WeakMap(); - - static hash(value) { - let existing = this.#hashcode_cache.get(value); - if (existing) { - return existing; - } else if (value instanceof Object) { - let hashcode = inspect(value); - HASHCODE_CACHE.set(value, hashcode); - return hashcode; - } else { - return value.toString(); - } - } - - constructor() { - this.entries = new globalThis.Map(); - } - - get size() { - return this.entries.size; - } - - inspect() { - let entries = [...this.entries.values()] - .map((pair) => inspect(pair)) - .join(", "); - return `map.from_list([${entries}])`; - } - - copy() { - let map = new Map(); - map.entries = new globalThis.Map(this.entries); - return map; - } - - toList() { - return List.fromArray([...this.entries.values()]); - } - - insert(k, v) { - let map = this.copy(); - map.entries.set(Map.hash(k), [k, v]); - return map; - } - - delete(k) { - let map = this.copy(); - map.entries.delete(Map.hash(k)); - return map; - } - - get(key) { - let code = Map.hash(key); - if (this.entries.has(code)) { - return new Ok(this.entries.get(code)[1]); - } else { - return new Error(Nil); - } - } -} - export function new_map() { - return new Map(); + return PMap.new(); } export function map_size(map) { @@ -467,19 +404,23 @@ export function map_size(map) { } export function map_to_list(map) { - return map.toList(); + return List.fromArray(map.entries()); } -export function map_remove(k, map) { - return map.delete(k); +export function map_remove(key, map) { + return map.delete(key); } export function map_get(map, key) { - return map.get(key); + const value = map.get(key, NOT_FOUND); + if (value === NOT_FOUND) { + return new Error(Nil); + } + return new Ok(value); } export function map_insert(key, value, map) { - return map.insert(key, value); + return map.set(key, value); } function unsafe_percent_decode(string) { @@ -610,7 +551,7 @@ export function classify_dynamic(data) { return `Tuple of ${data.length} elements`; } else if (BitString.isBitString(data)) { return "BitString"; - } else if (data instanceof Map) { + } else if (data instanceof PMap) { return "Map"; } else if (typeof data === "number") { return "Float"; @@ -680,13 +621,17 @@ export function decode_result(data) { } export function decode_map(data) { - if (data instanceof Map) { + if(data instanceof PMap) { return new Ok(data) } - if (typeof data === 'object' && data !== null && Object.getPrototypeOf(data) == Object.getPrototypeOf({})) { - return new Ok(new Map(Object.entries(data))) + if (data instanceof Map) { + return new Ok(PMap.fromMap(data)); + } + const proto = Object.getPrototypeOf(data); + if (proto === Object.prototype || proto === null) { + return new Ok(PMap.fromObject(data)); } - return decoder_error("Map", data); + return decoder_error("Map", data); } export function decode_option(data, decoder) { @@ -703,8 +648,8 @@ export function decode_option(data, decoder) { export function decode_field(value, name) { let error = () => decoder_error_no_classify("field", "nothing"); - if (value instanceof Map) { - let entry = value.get(name); + if (value instanceof PMap) { + let entry = map_get(value, name); return entry.isOk() ? entry : error(); } try { diff --git a/src/persistent-hash-map.mjs b/src/persistent-hash-map.mjs new file mode 100644 index 00000000..f454c7db --- /dev/null +++ b/src/persistent-hash-map.mjs @@ -0,0 +1,955 @@ +/** + * This file uses jsdoc to annotate types. + * These types can be checked using the typescript compiler with "checkjs" option. + */ + +import { isEqual } from "./gleam.mjs"; + +const referenceMap = new WeakMap(); +const tempDataView = new DataView(new ArrayBuffer(8)); +let referenceUID = 0; +/** + * hash the object by reference using a weak map and incrementing uid + * @param {any} o + * @returns {number} + */ +function hashByReference(o) { + const known = referenceMap.get(o); + if (known !== undefined) { + return known; + } + const hash = referenceUID++; + if (referenceUID === 0x7fffffff) { + referenceUID = 0; + } + referenceMap.set(o, hash); + return hash; +} +/** + * merge two hashes in an order sensitive way + * @param {number} a + * @param {number} b + * @returns {number} + */ +function hashMerge(a, b) { + return (a ^ (b + 0x9e3779b9 + (a << 6) + (a >> 2))) | 0; +} +/** + * standard string hash popularised by java + * @param {string} s + * @returns {number} + */ +function hashString(s) { + let hash = 0; + const len = s.length; + for (let i = 0; i < len; i++) { + hash = (Math.imul(31, hash) + s.charCodeAt(i)) | 0; + } + return hash; +} +/** + * hash a number by converting to two integers and do some jumbling + * @param {number} n + * @returns {number} + */ +function hashNumber(n) { + tempDataView.setFloat64(0, n); + const i = tempDataView.getInt32(0); + const j = tempDataView.getInt32(4); + return Math.imul(0x45d9f3b, (i >> 16) ^ i) ^ j; +} +/** + * hash a BigInt by converting it to a string and hashing that + * @param {BigInt} n + * @returns {number} + */ +function hashBigInt(n) { + return hashString(n.toString()); +} +/** + * hash any js object + * @param {any} o + * @returns {number} + */ +function hashObject(o) { + const proto = Object.getPrototypeOf(o); + if (proto !== null && typeof proto.hashCode === "function") { + try { + const code = o.hashCode(o); + if (typeof code === "number") { + return code + } + } catch {} + } + if (o instanceof Promise || o instanceof WeakSet || o instanceof WeakMap) { + return hashByReference(o); + } + if (o instanceof Date) { + return hashNumber(o.getTime()); + } + let h = 0; + if (o instanceof ArrayBuffer) { + o = new Uint8Array(o); + } + if (Array.isArray(o) || o instanceof Uint8Array) { + for (let i = 0; i < o.length; i++) { + h = (Math.imul(31, h) + getHash(o[i])) | 0; + } + } else if (o instanceof Set) { + o.forEach((v) => { + h = (h + getHash(v)) | 0; + }); + } else if (o instanceof Map) { + o.forEach((v, k) => { + h = (h + hashMerge(getHash(v), getHash(k))) | 0; + }); + } else { + const keys = Object.keys(o); + for (let i = 0; i < keys.length; i++) { + const k = keys[i]; + const v = o[k]; + h = (h + hashMerge(getHash(v), hashString(k))) | 0; + } + } + return h; +} +/** + * hash any js value + * @param {any} u + * @returns {number} + */ +export function getHash(u) { + if (u === null) return 0x42108422; + if (u === undefined) return 0x42108423; + if (u === true) return 0x42108421; + if (u === false) return 0x42108420; + switch (typeof u) { + case "number": + return hashNumber(u); + case "string": + return hashString(u); + case "bigint": + return hashBigInt(u); + case "object": + return hashObject(u); + case "symbol": + return hashByReference(u); + case "function": + return hashByReference(u); + default: + return 0; // should be unreachable + } +} +/** + * @template K,V + * @typedef {ArrayNode | IndexNode | CollisionNode} Node + */ +/** + * @template K,V + * @typedef {{ type: typeof ENTRY, k: K, v: V }} Entry + */ +/** + * @template K,V + * @typedef {{ type: typeof ARRAY_NODE, size: number, array: (undefined | Entry | Node)[] }} ArrayNode + */ +/** + * @template K,V + * @typedef {{ type: typeof INDEX_NODE, bitmap: number, array: (Entry | Node)[] }} IndexNode + */ +/** + * @template K,V + * @typedef {{ type: typeof COLLISION_NODE, hash: number, array: Entry[] }} CollisionNode + */ +/** + * @typedef {{ val: boolean }} Flag + */ +const SHIFT = 5; // number of bits you need to shift by to get the next bucket +const BUCKET_SIZE = Math.pow(2, SHIFT); +const MASK = BUCKET_SIZE - 1; // used to zero out all bits not in the bucket +const MAX_INDEX_NODE = BUCKET_SIZE / 2; // when does index node grow into array node +const MIN_ARRAY_NODE = BUCKET_SIZE / 4; // when does array node shrink to index node +const ENTRY = 0; +const ARRAY_NODE = 1; +const INDEX_NODE = 2; +const COLLISION_NODE = 3; +/** @type {IndexNode} */ +const EMPTY = { + type: INDEX_NODE, + bitmap: 0, + array: [], +}; +/** + * Mask the hash to get only the bucket corresponding to shift + * @param {number} hash + * @param {number} shift + * @returns {number} + */ +function mask(hash, shift) { + return (hash >>> shift) & MASK; +} +/** + * Set only the Nth bit where N is the masked hash + * @param {number} hash + * @param {number} shift + * @returns {number} + */ +function bitpos(hash, shift) { + return 1 << mask(hash, shift); +} +/** + * Count the number of 1 bits in a number + * @param {number} x + * @returns {number} + */ +function bitcount(x) { + x -= (x >> 1) & 0x55555555; + x = (x & 0x33333333) + ((x >> 2) & 0x33333333); + x = (x + (x >> 4)) & 0x0f0f0f0f; + x += x >> 8; + x += x >> 16; + return x & 0x7f; +} +/** + * Calculate the array index of an item in a bitmap index node + * @param {number} bitmap + * @param {number} bit + * @returns {number} + */ +function index(bitmap, bit) { + return bitcount(bitmap & (bit - 1)); +} +/** + * Efficiently copy an array and set one value at an index + * @template T + * @param {T[]} arr + * @param {number} at + * @param {T} val + * @returns {T[]} + */ +function cloneAndSet(arr, at, val) { + const len = arr.length; + const out = new Array(len); + for (let i = 0; i < len; ++i) { + out[i] = arr[i]; + } + out[at] = val; + return out; +} +/** + * Efficiently copy an array and insert one value at an index + * @template T + * @param {T[]} arr + * @param {number} at + * @param {T} val + * @returns {T[]} + */ +function spliceIn(arr, at, val) { + const len = arr.length; + const out = new Array(len + 1); + let i = 0; + let g = 0; + while (i < at) { + out[g++] = arr[i++]; + } + out[g++] = val; + while (i < len) { + out[g++] = arr[i++]; + } + return out; +} +/** + * Efficiently copy an array and remove one value at an index + * @template T + * @param {T[]} arr + * @param {number} at + * @returns {T[]} + */ +function spliceOut(arr, at) { + const len = arr.length; + const out = new Array(len - 1); + let i = 0; + let g = 0; + while (i < at) { + out[g++] = arr[i++]; + } + ++i; + while (i < len) { + out[g++] = arr[i++]; + } + return out; +} +/** + * Create a new node containing two entries + * @template K,V + * @param {number} shift + * @param {K} key1 + * @param {V} val1 + * @param {number} key2hash + * @param {K} key2 + * @param {V} val2 + * @returns {Node} + */ +function createNode(shift, key1, val1, key2hash, key2, val2) { + const key1hash = getHash(key1); + if (key1hash === key2hash) { + return { + type: COLLISION_NODE, + hash: key1hash, + array: [ + { type: ENTRY, k: key1, v: val1 }, + { type: ENTRY, k: key2, v: val2 }, + ], + }; + } + const addedLeaf = { val: false }; + return assoc( + assocIndex(EMPTY, shift, key1hash, key1, val1, addedLeaf), + shift, + key2hash, + key2, + val2, + addedLeaf + ); +} +/** + * @template T,K,V + * @callback AssocFunction + * @param {T} root + * @param {number} shift + * @param {number} hash + * @param {K} key + * @param {V} val + * @param {Flag} addedLeaf + * @returns {Node} + */ +/** + * Associate a node with a new entry, creating a new node + * @template T,K,V + * @type {AssocFunction,K,V>} + */ +function assoc(root, shift, hash, key, val, addedLeaf) { + switch (root.type) { + case ARRAY_NODE: + return assocArray(root, shift, hash, key, val, addedLeaf); + case INDEX_NODE: + return assocIndex(root, shift, hash, key, val, addedLeaf); + case COLLISION_NODE: + return assocCollision(root, shift, hash, key, val, addedLeaf); + } +} +/** + * @template T,K,V + * @type {AssocFunction,K,V>} + */ +function assocArray(root, shift, hash, key, val, addedLeaf) { + const idx = mask(hash, shift); + const node = root.array[idx]; + // if the corresponding index is empty set the index to a newly created node + if (node === undefined) { + addedLeaf.val = true; + return { + type: ARRAY_NODE, + size: root.size + 1, + array: cloneAndSet(root.array, idx, { type: ENTRY, k: key, v: val }), + }; + } + if (node.type === ENTRY) { + // if keys are equal replace the entry + if (isEqual(key, node.k)) { + if (val === node.v) { + return root; + } + return { + type: ARRAY_NODE, + size: root.size, + array: cloneAndSet(root.array, idx, { + type: ENTRY, + k: key, + v: val, + }), + }; + } + // otherwise upgrade the entry to a node and insert + addedLeaf.val = true; + return { + type: ARRAY_NODE, + size: root.size, + array: cloneAndSet( + root.array, + idx, + createNode(shift + SHIFT, node.k, node.v, hash, key, val) + ), + }; + } + // otherwise call assoc on the child node + const n = assoc(node, shift + SHIFT, hash, key, val, addedLeaf); + // if the child node hasn't changed just return the old root + if (n === node) { + return root; + } + // otherwise set the index to the new node + return { + type: ARRAY_NODE, + size: root.size, + array: cloneAndSet(root.array, idx, n), + }; +} +/** + * @template T,K,V + * @type {AssocFunction,K,V>} + */ +function assocIndex(root, shift, hash, key, val, addedLeaf) { + const bit = bitpos(hash, shift); + const idx = index(root.bitmap, bit); + // if there is already a item at this hash index.. + if ((root.bitmap & bit) !== 0) { + // if there is a node at the index (not an entry), call assoc on the child node + const node = root.array[idx]; + if (node.type !== ENTRY) { + const n = assoc(node, shift + SHIFT, hash, key, val, addedLeaf); + if (n === node) { + return root; + } + return { + type: INDEX_NODE, + bitmap: root.bitmap, + array: cloneAndSet(root.array, idx, n), + }; + } + // otherwise there is an entry at the index + // if the keys are equal replace the entry with the updated value + const nodeKey = node.k; + if (isEqual(key, nodeKey)) { + if (val === node.v) { + return root; + } + return { + type: INDEX_NODE, + bitmap: root.bitmap, + array: cloneAndSet(root.array, idx, { + type: ENTRY, + k: key, + v: val, + }), + }; + } + // if the keys are not equal, replace the entry with a new child node + addedLeaf.val = true; + return { + type: INDEX_NODE, + bitmap: root.bitmap, + array: cloneAndSet( + root.array, + idx, + createNode(shift + SHIFT, nodeKey, node.v, hash, key, val) + ), + }; + } else { + // else there is currently no item at the hash index + const n = root.array.length; + // if the number of nodes is at the maximum, expand this node into an array node + if (n >= MAX_INDEX_NODE) { + // create a 32 length array for the new array node (one for each bit in the hash) + const nodes = new Array(32); + // create and insert a node for the new entry + const jdx = mask(hash, shift); + nodes[jdx] = assocIndex(EMPTY, shift + SHIFT, hash, key, val, addedLeaf); + let j = 0; + let bitmap = root.bitmap; + // place each item in the index node into the correct spot in the array node + // loop through all 32 bits / array positions + for (let i = 0; i < 32; i++) { + if ((bitmap & 1) !== 0) { + const node = root.array[j++]; + nodes[i] = node; + } + // shift the bitmap to process the next bit + bitmap = bitmap >>> 1; + } + return { + type: ARRAY_NODE, + size: n + 1, + array: nodes, + }; + } else { + // else there is still space in this index node + // simply insert a new entry at the hash index + const newArray = spliceIn(root.array, idx, { + type: ENTRY, + k: key, + v: val, + }); + addedLeaf.val = true; + return { + type: INDEX_NODE, + bitmap: root.bitmap | bit, + array: newArray, + }; + } + } +} +/** + * @template T,K,V + * @type {AssocFunction,K,V>} + */ +function assocCollision(root, shift, hash, key, val, addedLeaf) { + // if there is a hash collision + if (hash === root.hash) { + const idx = collisionIndexOf(root, key); + // if this key already exists replace the entry with the new value + if (idx !== -1) { + const entry = root.array[idx]; + if (entry.v === val) { + return root; + } + return { + type: COLLISION_NODE, + hash: hash, + array: cloneAndSet(root.array, idx, { type: ENTRY, k: key, v: val }), + }; + } + // otherwise insert the entry at the end of the array + const size = root.array.length; + addedLeaf.val = true; + return { + type: COLLISION_NODE, + hash: hash, + array: cloneAndSet(root.array, size, { type: ENTRY, k: key, v: val }), + }; + } + // if there is no hash collision, upgrade to an index node + return assoc( + { + type: INDEX_NODE, + bitmap: bitpos(root.hash, shift), + array: [root], + }, + shift, + hash, + key, + val, + addedLeaf + ); +} +/** + * Find the index of a key in the collision node's array + * @template K,V + * @param {CollisionNode} root + * @param {K} key + * @returns {number} + */ +function collisionIndexOf(root, key) { + const size = root.array.length; + for (let i = 0; i < size; i++) { + if (isEqual(key, root.array[i].k)) { + return i; + } + } + return -1; +} +/** + * @template T,K,V + * @callback FindFunction + * @param {T} root + * @param {number} shift + * @param {number} hash + * @param {K} key + * @returns {undefined | Entry} + */ +/** + * Return the found entry or undefined if not present in the root + * @template K,V + * @type {FindFunction,K,V>} + */ +function find(root, shift, hash, key) { + switch (root.type) { + case ARRAY_NODE: + return findArray(root, shift, hash, key); + case INDEX_NODE: + return findIndex(root, shift, hash, key); + case COLLISION_NODE: + return findCollision(root, key); + } +} +/** + * @template K,V + * @type {FindFunction,K,V>} + */ +function findArray(root, shift, hash, key) { + const idx = mask(hash, shift); + const node = root.array[idx]; + if (node === undefined) { + return undefined; + } + if (node.type === ENTRY) { + return node; + } + return find(node, shift + SHIFT, hash, key); +} +/** + * @template K,V + * @type {FindFunction,K,V>} + */ +function findIndex(root, shift, hash, key) { + const bit = bitpos(hash, shift); + if ((root.bitmap & bit) === 0) { + return undefined; + } + const idx = index(root.bitmap, bit); + const node = root.array[idx]; + if (node.type !== ENTRY) { + return find(node, shift + SHIFT, hash, key); + } + if (isEqual(key, node.k)) { + return node; + } + return undefined; +} +/** + * @template K,V + * @param {CollisionNode} root + * @param {K} key + * @returns {undefined | Entry} + */ +function findCollision(root, key) { + const idx = collisionIndexOf(root, key); + if (idx < 0) { + return undefined; + } + return root.array[idx]; +} +/** + * @template T,K,V + * @callback WithoutFunction + * @param {T} root + * @param {number} shift + * @param {number} hash + * @param {K} key + * @returns {undefined | Node} + */ +/** + * Remove an entry from the root, returning the updated root. + * Returns undefined if the node should be removed from the parent. + * @template K,V + * @type {WithoutFunction,K,V>} + * */ +function without(root, shift, hash, key) { + switch (root.type) { + case ARRAY_NODE: + return withoutArray(root, shift, hash, key); + case INDEX_NODE: + return withoutIndex(root, shift, hash, key); + case COLLISION_NODE: + return withoutCollision(root, key); + } +} +/** + * @template K,V + * @type {WithoutFunction,K,V>} + */ +function withoutArray(root, shift, hash, key) { + const idx = mask(hash, shift); + const node = root.array[idx]; + if (node === undefined) { + return root; // already empty + } + let n = undefined; + // if node is an entry and the keys are not equal there is nothing to remove + // if node is not an entry do a recursive call + if (node.type === ENTRY) { + if (!isEqual(node.k, key)) { + return root; // no changes + } + } else { + n = without(node, shift + SHIFT, hash, key); + if (n === node) { + return root; // no changes + } + } + // if the recursive call returned undefined the node should be removed + if (n === undefined) { + // if the number of child nodes is at the minimum, pack into an index node + if (root.size <= MIN_ARRAY_NODE) { + const arr = root.array; + const out = new Array(root.size - 1); + let i = 0; + let j = 0; + let bitmap = 0; + while (i < idx) { + const nv = arr[i]; + if (nv !== undefined) { + out[j] = nv; + bitmap |= 1 << i; + ++j; + } + ++i; + } + ++i; // skip copying the removed node + while (i < arr.length) { + const nv = arr[i]; + if (nv !== undefined) { + out[j] = nv; + bitmap |= 1 << i; + ++j; + } + ++i; + } + return { + type: INDEX_NODE, + bitmap: bitmap, + array: out, + }; + } + return { + type: ARRAY_NODE, + size: root.size - 1, + array: cloneAndSet(root.array, idx, n), + }; + } + return { + type: ARRAY_NODE, + size: root.size, + array: cloneAndSet(root.array, idx, n), + }; +} +/** + * @template K,V + * @type {WithoutFunction,K,V>} + */ +function withoutIndex(root, shift, hash, key) { + const bit = bitpos(hash, shift); + if ((root.bitmap & bit) === 0) { + return root; // already empty + } + const idx = index(root.bitmap, bit); + const node = root.array[idx]; + // if the item is not an entry + if (node.type !== ENTRY) { + const n = without(node, shift + SHIFT, hash, key); + if (n === node) { + return root; // no changes + } + // if not undefined, the child node still has items, so update it + if (n !== undefined) { + return { + type: INDEX_NODE, + bitmap: root.bitmap, + array: cloneAndSet(root.array, idx, n), + }; + } + // otherwise the child node should be removed + // if it was the only child node, remove this node from the parent + if (root.bitmap === bit) { + return undefined; + } + // otherwise just remove the child node + return { + type: INDEX_NODE, + bitmap: root.bitmap ^ bit, + array: spliceOut(root.array, idx), + }; + } + // otherwise the item is an entry, remove it if the key matches + if (isEqual(key, node.k)) { + if (root.bitmap === bit) { + return undefined; + } + return { + type: INDEX_NODE, + bitmap: root.bitmap ^ bit, + array: spliceOut(root.array, idx), + }; + } + return root; +} +/** + * @template K,V + * @param {CollisionNode} root + * @param {K} key + * @returns {undefined | Node} + */ +function withoutCollision(root, key) { + const idx = collisionIndexOf(root, key); + // if the key not found, no changes + if (idx < 0) { + return root; + } + // otherwise the entry was found, remove it + // if it was the only entry in this node, remove the whole node + if (root.array.length === 1) { + return undefined; + } + // otherwise just remove the entry + return { + type: COLLISION_NODE, + hash: root.hash, + array: spliceOut(root.array, idx), + }; +} +/** + * @template K,V + * @param {undefined | Node} root + * @param {(value:V,key:K)=>void} fn + * @returns {void} + */ +function forEach(root, fn) { + if (root === undefined) { + return; + } + const items = root.array; + const size = items.length; + for (let i = 0; i < size; i++) { + const item = items[i]; + if (item === undefined) { + continue; + } + if (item.type === ENTRY) { + fn(item.v, item.k); + continue; + } + forEach(item, fn); + } +} +/** + * Extra wrapper to keep track of map size and clean up the API + * @template K,V + */ +export default class PMap { + /** + * @template V + * @param {Record} o + * @returns {PMap} + */ + static fromObject(o) { + const keys = Object.keys(o); + /** @type PMap */ + let m = PMap.new(); + for (let i = 0; i < keys.length; i++) { + const k = keys[i]; + m = m.set(k, o[k]); + } + return m; + } + /** + * @template K,V + * @param {Map} o + * @returns {PMap} + */ + static fromMap(o) { + /** @type PMap */ + let m = PMap.new(); + o.forEach((v, k) => { + m = m.set(k, v); + }); + return m; + } + static new() { + return new PMap(undefined, 0); + } + /** + * @param {undefined | Node} root + * @param {number} size + */ + constructor(root, size) { + this.root = root; + this.size = size; + } + /** + * @template NotFound + * @param {K} key + * @param {NotFound} notFound + * @returns {NotFound | V} + */ + get(key, notFound) { + if (this.root === undefined) { + return notFound; + } + const found = find(this.root, 0, getHash(key), key); + if (found === undefined) { + return notFound; + } + return found.v; + } + /** + * @param {K} key + * @param {V} val + * @returns {PMap} + */ + set(key, val) { + const addedLeaf = { val: false }; + const root = this.root === undefined ? EMPTY : this.root; + const newRoot = assoc(root, 0, getHash(key), key, val, addedLeaf); + if (newRoot === this.root) { + return this; + } + return new PMap(newRoot, addedLeaf.val ? this.size + 1 : this.size); + } + /** + * @param {K} key + * @returns {PMap} + */ + delete(key) { + if (this.root === undefined) { + return this; + } + const newRoot = without(this.root, 0, getHash(key), key); + if (newRoot === this.root) { + return this; + } + if (newRoot === undefined) { + return PMap.new(); + } + return new PMap(newRoot, this.size - 1); + } + /** + * @param {K} key + * @returns {boolean} + */ + has(key) { + if (this.root === undefined) { + return false; + } + return find(this.root, 0, getHash(key), key) !== undefined; + } + /** + * @returns {[K,V][]} + */ + entries() { + if (this.root === undefined) { + return []; + } + /** @type [K,V][] */ + const result = []; + this.forEach((v, k) => result.push([k, v])); + return result; + } + /** + * + * @param {(val:V,key:K)=>void} fn + */ + forEach(fn) { + forEach(this.root, fn); + } + hashCode() { + let h = 0; + this.forEach((v, k) => { + h = (h + hashMerge(getHash(v), getHash(k))) | 0; + }); + return h; + } + /** + * @param {unknown} o + * @returns {boolean} + */ + equals(o) { + if (!(o instanceof PMap)) { + return false; + } + let equal = true; + this.forEach((v, k) => { + equal = equal && isEqual(o.get(k, !v), v); + }); + return equal; + } +} + diff --git a/test/gleam/bit_string_test.gleam b/test/gleam/bit_string_test.gleam index c52dd854..581cc1c6 100644 --- a/test/gleam/bit_string_test.gleam +++ b/test/gleam/bit_string_test.gleam @@ -9,6 +9,11 @@ pub fn byte_size_test() { |> should.equal(0) } +pub fn not_equal_test() { + bit_string.from_string("test") + |> should.not_equal(bit_string.from_string("asdf")) +} + pub fn append_test() { bit_string.from_string("Test") |> bit_string.append(bit_string.from_string(" Me")) diff --git a/test/gleam/map_test.gleam b/test/gleam/map_test.gleam index 2666579f..43e17a01 100644 --- a/test/gleam/map_test.gleam +++ b/test/gleam/map_test.gleam @@ -2,6 +2,8 @@ import gleam/map import gleam/option.{None, Some} import gleam/should import gleam/string +import gleam/list +import gleam/int pub fn from_list_test() { [#(4, 0), #(1, 0)] @@ -82,6 +84,21 @@ pub fn get_test() { m |> map.get(C) |> should.equal(Error(Nil)) + + let proplist = [#(<<1, 2, 3>>, 0), #(<<3, 2, 1>>, 1)] + let m = map.from_list(proplist) + + m + |> map.get(<<1, 2, 3>>) + |> should.equal(Ok(0)) + + m + |> map.get(<<3, 2, 1>>) + |> should.equal(Ok(1)) + + m + |> map.get(<<1, 3, 2>>) + |> should.equal(Error(Nil)) } pub fn insert_test() { @@ -103,6 +120,7 @@ pub fn keys_test() { [#("a", 0), #("b", 1), #("c", 2)] |> map.from_list |> map.keys + |> list.sort(string.compare) |> should.equal(["a", "b", "c"]) } @@ -110,6 +128,7 @@ pub fn values_test() { [#("a", 0), #("b", 1), #("c", 2)] |> map.from_list |> map.values + |> list.sort(int.compare) |> should.equal([0, 1, 2]) } @@ -189,13 +208,131 @@ pub fn fold_test() { |> map.fold(0, add) |> should.equal(6) - let concat = fn(acc, k, _) { string.append(acc, k) } + let prepend = fn(acc, k, _) { list.prepend(acc, k) } dict - |> map.fold("", concat) - |> should.equal("abcd") + |> map.fold([], prepend) + |> list.sort(string.compare) + |> should.equal(["a", "b", "c", "d"]) map.from_list([]) |> map.fold(0, add) |> should.equal(0) } + +fn range(start, end, a) { + case end - start { + n if n < 1 -> a + _ -> range(start, end - 1, [end - 1, ..a]) + } +} + +fn list_to_map(list) { + list + |> list.map(fn(n) { #(n, n) }) + |> map.from_list +} + +fn grow_and_shrink_map(initial_size, final_size) { + range(0, initial_size, []) + |> list_to_map + |> list.fold( + range(final_size, initial_size, []), + _, + fn(map, item) { map.delete(map, item) }, + ) +} + +// maps should be equal even if the insert/removal order was different +pub fn insert_order_equality_test() { + grow_and_shrink_map(8, 2) + |> should.equal(grow_and_shrink_map(4, 2)) + grow_and_shrink_map(17, 10) + |> should.equal(grow_and_shrink_map(12, 10)) + grow_and_shrink_map(2000, 1000) + |> should.equal(grow_and_shrink_map(1000, 1000)) +} + +// ensure operations on a map don't mutate it +pub fn persistence_test() { + let a = list_to_map([0]) + map.insert(a, 0, 5) + map.insert(a, 1, 6) + map.delete(a, 0) + map.get(a, 0) + |> should.equal(Ok(0)) +} + +// using maps as keys should work (tests hash function) +pub fn map_as_key_test() { + let l = range(0, 1000, []) + let a = list_to_map(l) + let a2 = list_to_map(list.reverse(l)) + let a3 = grow_and_shrink_map(2000, 1000) + let b = grow_and_shrink_map(60, 50) + let c = grow_and_shrink_map(50, 20) + let d = grow_and_shrink_map(2, 2) + + let map1 = + map.new() + |> map.insert(a, "a") + |> map.insert(b, "b") + |> map.insert(c, "c") + |> map.insert(d, "d") + + map.get(map1, a) + |> should.equal(Ok("a")) + map.get(map1, a2) + |> should.equal(Ok("a")) + map.get(map1, a3) + |> should.equal(Ok("a")) + map.get(map1, b) + |> should.equal(Ok("b")) + map.get(map1, c) + |> should.equal(Ok("c")) + map.get(map1, d) + |> should.equal(Ok("d")) + map.insert(map1, a2, "a2") + |> map.get(a) + |> should.equal(Ok("a2")) + map.insert(map1, a3, "a3") + |> map.get(a) + |> should.equal(Ok("a3")) +} + +pub fn large_n_test() { + let n = 10_000 + let l = range(0, n, []) + + let m = list_to_map(l) + list.map(l, fn(i) { should.equal(map.get(m, i), Ok(i)) }) + + let m = grow_and_shrink_map(n, 0) + list.map(l, fn(i) { should.equal(map.get(m, i), Error(Nil)) }) +} + +pub fn size_test() { + let n = 1000 + let m = list_to_map(range(0, n, [])) + map.size(m) + |> should.equal(n) + + let m = grow_and_shrink_map(n, n / 2) + map.size(m) + |> should.equal(n / 2) + + let m = + grow_and_shrink_map(n, 0) + |> map.delete(0) + map.size(m) + |> should.equal(0) + + let m = list_to_map(range(0, 18, [])) + + map.insert(m, 1, 99) + |> map.size() + |> should.equal(18) + map.insert(m, 2, 99) + |> map.size() + |> should.equal(18) +} diff --git a/test/gleam/set_test.gleam b/test/gleam/set_test.gleam index aae5256d..123dc43d 100644 --- a/test/gleam/set_test.gleam +++ b/test/gleam/set_test.gleam @@ -70,6 +70,7 @@ pub fn filter_test() { |> set.from_list() |> set.filter(for: int.is_even) |> set.to_list + |> list.sort(int.compare) |> should.equal([4, 6, 44]) }