@perma/map
Version:
Immutable hash maps implemented as hash array papped tries
980 lines (913 loc) • 25.8 kB
JavaScript
import * as API from "./api.js"
export * from "./api.js"
import * as BitField from "./bitfield/Uint32.js"
import * as Path from "./path/Uint32.js"
export { API }
/**
* @template T
* @template {string} K
* @template {API.Config} C
* @implements {API.BitmapIndexedNode<T, K, C>}
*/
class BitmapIndexedNode {
/**
* @param {API.Edit|null} edit
* @param {ReturnType<C['BitField']['empty']>} datamap
* @param {ReturnType<C['BitField']['empty']>} nodemap
* @param {API.Children<T, K, C>} children
* @param {C} config
*/
constructor(edit, datamap, nodemap, children, config) {
this.edit = edit
this.config = config
this.datamap = datamap
this.nodemap = nodemap
this.children = children
}
get nodeArity() {
return this.config.BitField.popcount(this.nodemap)
}
get dataArity() {
return this.config.BitField.popcount(this.datamap)
}
/**
* @returns {API.BitmapIndexedNode<T, K, C>}
*/
/* c8 ignore next 3 */
empty() {
return create(this.config)
}
/**
* @template X
* @param {API.Uint32} depth
* @param {ReturnType<C['Path']['from']>} path
* @param {K} key
* @param {X} notFound
* @returns {T|X}
*/
lookup(depth, path, key, notFound) {
return lookup(this, depth, path, key, notFound)
}
/**
* @template {string} R
* @param {API.Edit|null} edit
* @param {API.Uint32} depth
* @param {ReturnType<C['Path']['from']>} path
* @param {K|R} key
* @param {T} value
* @param {{value:boolean}} addedLeaf
* @returns {API.BitmapIndexedNode<T, K | R, C>}
*/
associate(edit, depth, path, key, value, addedLeaf) {
return associate(this, edit, depth, path, key, value, addedLeaf)
}
/**
* @param {API.Edit|null} edit
* @param {API.Uint32} depth
* @param {ReturnType<C['Path']['from']>} path
* @param {K} key
* @param {{value:boolean}} removedLeaf
* @returns {API.BitmapIndexedNode<T, K, C>}
*/
dissociate(edit, depth, path, key, removedLeaf) {
return dissociate(this, edit, depth, path, key, removedLeaf)
}
/**
* @param {API.Edit|null} edit
* @returns {API.BitmapIndexedNode<T, K, C>}
*/
fork(edit = null) {
return fork(this, edit)
}
/**
* @returns {IterableIterator<[K, T]>}
*/
entries() {
return entries(this)
}
/**
* @returns {IterableIterator<K>}
*/
keys() {
return keys(this)
}
/**
* @returns {IterableIterator<T>}
*/
values() {
return values(this)
}
}
/**
* @template T
* @template {string} K
* @template {API.Config} C
* @implements {API.HashCollisionNode<T, K, C>}
*/
class HashCollisionNode {
/**
* @param {API.Edit|null} edit
* @param {number} count
* @param {API.CollisionEntries<T, K>} children
* @param {C} config
*/
/* c8 ignore next 12 */
constructor(edit, count, children, config) {
this.edit = edit
this.count = count
this.children = children
this.config = config
}
get nodeArity() {
return /** @type {0} */ (0)
}
get dataArity() {
return this.count
}
/**
* @template X
* @param {API.Uint32} _shift
* @param {unknown} _path
* @param {K} key
* @param {X} notFound
* @returns {T|X}
*/
/* c8 ignore next 3 */
lookup(_shift, _path, key, notFound) {
return lookupCollision(this, key, notFound)
}
/**
* @template {string} R
* @param {API.Edit|null} edit
* @param {API.Uint32} _shift
* @param {ReturnType<C['Path']['from']>} path
* @param {K|R} key
* @param {T} value
* @param {{value:boolean}} addedLeaf
* @returns {API.HashCollisionNode<T, K | R, C>}
*/
/* c8 ignore next 3 */
associate(edit, _shift, path, key, value, addedLeaf) {
return associateCollision(this, edit, path, key, value, addedLeaf)
}
/**
* @param {API.Edit|null} edit
* @param {API.Uint32} _shift
* @param {ReturnType<C['Path']['from']>} path
* @param {K} key
* @param {{value:boolean}} removedLeaf
* @returns {API.Node<T, K, C>}
*/
/* c8 ignore next 3 */
dissociate(edit, _shift, path, key, removedLeaf) {
return dissociateCollision(this, edit, path, key, removedLeaf)
}
/**
* @param {API.Edit|null} edit
* @returns {this}
*/
/* c8 ignore next 3 */
fork(edit = null) {
return /** @type {this} */ (forkCollision(this, edit))
}
/**
* @returns {IterableIterator<[K, T]>}
*/
/* c8 ignore next 3 */
entries() {
return entries(this)
}
/**
* @returns {IterableIterator<K>}
*/
/* c8 ignore next 3 */
keys() {
return keys(this)
}
/**
* @returns {IterableIterator<T>}
*/
/* c8 ignore next 3 */
values() {
return values(this)
}
}
/**
* @template T
* @template {string} K
* @template {API.Config} C
* @template X
* @param {API.HashCollisionNode<T, K, C>} node
* @param {K} name
* @param {X} notFound
* @returns {T|X}
*/
/* c8 ignore next 8 */
export const lookupCollision = (node, name, notFound) => {
const { children: entries, count } = node
// find where entry with this name belongs
const n = findHashCollisionNodeIndex(entries, count, name)
// if entry name at this index matches given name return the value otherwise
// return `notFound` as we have no such entry.
return entries[n] === name ? /** @type {T} */ (entries[n + 1]) : notFound
}
/**
* @template T
* @template {string} K
* @template {API.Config} C
* @template {string} R
* @param {API.HashCollisionNode<T, K, C>} node
* @param {API.Edit|null} edit
* @param {ReturnType<C['Path']['from']>} key
* @param {K|R} name
* @param {T} value
* @param {{value:boolean}} addedLeaf
* @returns {API.HashCollisionNode<T, K | R, C>}
*/
/* c8 ignore next 26 */
export const associateCollision = (node, edit, key, name, value, addedLeaf) => {
const { children, count } = node
const index = findHashCollisionNodeIndex(children, count, name)
// If entry at this index has a different name we fork the node and
// add a new entry.
if (children[index] !== name) {
const newNode = node.fork(edit)
addedLeaf.value = true
newNode.count += 1
newNode.children.splice(index, key, value)
return newNode
}
// If name is the same but value is not we fork the node and update
// the value
else if (children[index + 1] !== value) {
const newNode = node.fork(edit)
newNode.children[index + 1] = value
return newNode
}
// If we got this far entry with this exact name and value is already
// present making this a noop, so we return this node back.
else {
return node
}
}
/**
* @template T
* @template {string} K
* @template {API.Config} C
* @param {API.HashCollisionNode<T, K, C>} node
* @param {API.Edit|null} edit
* @param {ReturnType<C['Path']['from']>} hash
* @param {K} name
* @param {{value:boolean}} removedLeaf
* @returns {API.Node<T, K, C>}
*/
/* c8 ignore next 37 */
export const dissociateCollision = (node, edit, hash, name, removedLeaf) => {
const { children: entries, count, config } = node
const index = findHashCollisionNodeIndex(entries, count, name)
// If there is no entry with a the given name this is noop so we just
// return back this node.
if (entries[index] !== name) {
return node
} else {
removedLeaf.value = true
// If conflict contained only two entries removing one of them would
// leave us with no conflict which is why we create a new node with a
// an entry other than one that would correspond provided name
if (count === 2) {
const offset = index === 0 ? 2 : 0
return /** @type {API.BitmapIndexedNode<T, K, C>} */ (
associate(
create(config),
edit,
0,
hash,
/** @type {K} */ (entries[offset]),
/** @type {T} */ (entries[offset + 1]),
removedLeaf
)
)
}
// otherwise we got this far we have more than two colliding entries in
// which case we simply remove one corresponding to given `name`.
//
else {
const newNode = node.fork(edit)
newNode.children.splice(index, 2)
newNode.count -= 1
return newNode
}
}
}
/**
* @template T
* @template {string} K
* @template {API.Config} C
* @param {API.HashCollisionNode<T, K, C>} node
* @param {API.Edit|null} edit
* @returns {API.HashCollisionNode<T, K, C>}
*/
/* c8 ignore next 12 */
export const forkCollision = (node, edit = null) => {
if (canEdit(node.edit, edit)) {
return node
} else {
return new HashCollisionNode(
edit,
node.count,
/** @type {API.CollisionEntries<T, K>} */ (node.children.slice()),
node.config
)
}
}
/**
* Finds the index inside collision entries where given `key` belongs, which is
* index where `key <= entries[index]` is `true`. If no index satisfies this
* constraint index will be `entries.length` indicating that key belongs in the
* last position.
*
* @template T
* @template {string} K
* @param {API.CollisionEntries<T, K>} entries
* @param {number} count
* @param {K} key
*/
/* c8 ignore next 8 */
const findHashCollisionNodeIndex = (entries, count, key) => {
let index = 0
// increase index until we find a index where key <= entries[index]
while (index < count && entries[index] > key) {
index += 2
}
return index
}
const defaultConfig = { bitWidth: 32, BitField, Path }
/**
* @template T
* @template {string} K
* @template {API.Config} C
* @param {API.Edit|null} [edit]
* @param {C} config
* @returns {API.BitmapIndexedNode<T, K, C>}
*/
export const create = (config, edit = null) =>
new BitmapIndexedNode(
edit,
config.BitField.empty(Math.pow(2, config.bitWidth)),
config.BitField.empty(Math.pow(2, config.bitWidth)),
/** @type {API.Children<T, K, C>} */ ([]),
config
)
/**
* @template T, U
* @template {string} K
* @param {API.BitmapIndexedNode<T, K>} node
* @param {K} key
* @param {U} notFound
*/
export const get = (node, key, notFound) =>
lookup(node, 0, node.config.Path.from(key), key, notFound)
/**
* @template T, U
* @template {string} K
* @template Bits, BitMap
* @param {API.BitmapIndexedNode<T, K, API.Config<Bits, BitMap>>} node
* @param {API.Uint32} depth
* @param {Bits} path
* @param {K} key
* @param {U} notFound
* @returns {T|U}
*/
export const lookup = (node, depth, path, key, notFound) => {
const { datamap, nodemap, config } = node
const { Path, BitField } = config
const offset = Path.at(path, depth)
// If bit is set in the data bitmap we have some key, value under the
// matching hash segment.
if (BitField.get(datamap, offset)) {
const index = BitField.popcount(datamap, offset)
// If key matches actual key in the map we found the the value
// otherwise we did not.
if (keyAt(node, index) === key) {
return valueAt(node, index)
} else {
return notFound
}
}
// If bit is set in the node bitmapt we have a node under the
// matching hash segment.
else if (BitField.get(nodemap, offset)) {
// Resolve node and continue lookup within it.
const child = resolveNode(node, offset)
return child.lookup(depth + 1, path, key, notFound)
}
// If we have neither node nor key-pair for this hash segment
// we return notFound.
else {
return notFound
}
}
/**
* @template T, U
* @template {string} K
* @template {string} R
* @template {API.Config} C
* @param {API.BitmapIndexedNode<T, K, C>} node
* @param {API.Edit|null} edit
* @param {R} key
* @param {T} value
* @param {{ value: boolean }} addedLeaf
* @returns {API.BitmapIndexedNode<T, K|R, C>}
*/
export const set = (node, edit, key, value, addedLeaf) =>
associate(node, edit, 0, node.config.Path.from(key), key, value, addedLeaf)
/**
* @template T
* @template {string} K
* @template {string} R
* @template {API.Config} C
* @param {API.BitmapIndexedNode<T, K, C>} node
* @param {API.Edit|null} edit
* @param {API.Uint32} depth
* @param {ReturnType<C['Path']['from']>} path
* @param {K|R} key
* @param {T} value
* @param {{value:boolean}} addedLeaf
* @returns {API.BitmapIndexedNode<T, K | R, C>}
*/
export const associate = (node, edit, depth, path, key, value, addedLeaf) => {
const { datamap, nodemap, config } = node
const { Path, BitField } = config
const offset = Path.at(path, depth)
// If bit is set in the data bitmap we have some key, value under the
// matching hash segment.
if (BitField.get(datamap, offset)) {
const index = BitField.popcount(datamap, offset)
const found = keyAt(node, index)
// If we have entry with given name and value is the same return node
// as is, otherwise fork node and set the value.
if (key === found) {
return valueAt(node, index) === value
? node
: forkAndSet(node, edit, index, value)
}
// Otherwise we need to create a branch to contain current key, value and
// one been passed.
else {
const branch = mergeTwoLeaves(
config,
edit,
depth + 1,
Path.from(found),
found,
valueAt(node, index),
path,
key,
value
)
addedLeaf.value = true
return migrateLeafToBranch(node, edit, offset, branch)
}
}
// If bit is set in the node bitmap we have a branch under the current
// hash slice.
else if (BitField.get(nodemap, offset)) {
const child = resolveNode(node, offset)
const newChild = child.associate(
edit,
depth + 1,
path,
key,
value,
addedLeaf
)
if (child === newChild) {
return node
} else {
return copyAndSetChild(node, edit, offset, newChild)
}
}
// If we have neither node nor a key-value for this hash segment. We copy
// current children and add new key-value pair
else {
const index = BitField.popcount(datamap, offset)
addedLeaf.value = true
/** @type {API.BitmapIndexedNode<T, K|R, C>} */
const newNode = node.fork(edit)
// Capture new entry in the data bitmap
newNode.datamap = BitField.set(datamap, offset)
newNode.children.splice(keyPosition(index), 0, key, value)
return newNode
}
}
/**
* @template T
* @template {string} K
* @template {API.Config} C
* @param {API.BitmapIndexedNode<T, K, C>} node
* @param {API.Edit|null} edit
* @param {K} key
* @param {{ value: boolean }} removedLeaf
* @returns {API.BitmapIndexedNode<T, K, C>}
*/
const remove = (node, edit, key, removedLeaf) =>
dissociate(node, edit, 0, node.config.Path.from(key), key, removedLeaf)
export { remove as delete }
/**
* @template T
* @template {string} K
* @template {API.Config} C
* @param {API.BitmapIndexedNode<T, K, C>} source
* @param {API.Edit|null} edit
* @param {API.Uint32} depth
* @param {ReturnType<C['Path']['from']>} path
* @param {K} key
* @param {{value:boolean}} removedLeaf
* @returns {API.BitmapIndexedNode<T, K, C>}
*/
export const dissociate = (source, edit, depth, path, key, removedLeaf) => {
const { datamap, nodemap, config } = source
const { BitField, Path } = config
const offset = Path.at(path, depth)
// If bit is set in the data bitmap we have an entry under the
// matching hash segment.
if (BitField.get(datamap, offset)) {
const index = BitField.popcount(datamap, offset)
// If key at a given index matches given `name` we fork a node and remove
// the entry
if (key === keyAt(source, index)) {
removedLeaf.value = true
const node = fork(source, edit)
// Update the bitmap
node.datamap = BitField.unset(source.datamap, offset)
// remove the child
node.children.splice(keyPosition(index), 2)
return node
}
// otherwise we don't have such entry so we return node back as is.
else {
return source
}
}
// If bit is set in the node bitmapt we have a node under the
// matching hash segment.
else if (BitField.get(nodemap, offset)) {
const node = resolveNode(source, offset)
const child = node.dissociate(edit, depth + 1, path, key, removedLeaf)
// if child has a single element we need to canonicalize
if (hasSingleLeaf(child)) {
// if source has a single child, we collapse and return the child
// otherwise we inline the child.
return hasSingleNode(source)
? child
: inlineChild(source, edit, offset, child)
} else if (node === child) {
return source
} else {
return copyAndSetChild(source, edit, offset, child)
}
}
// If we have neither node nor a key-value for this hash segment this is a
// noop.
else {
return source
}
}
/**
* @template T
* @template {string} K
* @template {API.Config} C
* @param {API.Node<T, K, C>} node
* @returns {IterableIterator<[K, T]>}
*/
export const entries = function* ({ children }) {
let offset = 0
const count = children.length
while (offset < count) {
const key = children[offset]
if (typeof key === "string") {
offset += 1
const value = children[offset]
yield /** @type {[K, T]} */ ([key, value])
offset += 1
} else {
break
}
}
while (offset < count) {
const node = /** @type {API.BitmapIndexedNode<T, K, C>} */ (
children[offset]
)
yield* node.entries()
offset += 1
}
}
/**
* @template T
* @template {string} K
* @template {API.Config} C
* @param {API.BitmapIndexedNode<T, K, C>} node
* @param {API.Edit|null} edit
* @returns {API.BitmapIndexedNode<T, K, C>}
*/
export const fork = (node, edit) => {
if (canEdit(node.edit, edit)) {
return node
} else {
const newNode = new BitmapIndexedNode(
edit,
node.datamap,
node.nodemap,
node.children.slice(),
node.config
)
return newNode
}
}
/**
* @template T
* @template {string} K
* @template {API.Config} C
* @param {API.Node<T, K, C>} node
* @returns {IterableIterator<K>}
*/
export const keys = function* ({ children }) {
let offset = 0
const count = children.length
while (offset < count) {
const key = children[offset]
if (typeof key === "string") {
yield /** @type {K} */ (key)
offset += 2
} else {
break
}
}
while (offset < count) {
const node = /** @type {API.BitmapIndexedNode<T, K>} */ (children[offset])
yield* node.keys()
offset += 1
}
}
/**
* @template T
* @template {string} K
* @template {API.Config} C
* @param {API.Node<T, K, C>} node
* @returns {IterableIterator<T>}
*/
export const values = function* ({ children }) {
let offset = 0
const count = children.length
while (offset < count) {
const key = children[offset]
if (typeof key === "string") {
offset += 1
yield /** @type {T} */ (children[offset])
offset += 1
} else {
break
}
}
while (offset < count) {
const node = /** @type {API.BitmapIndexedNode<T, K>} */ (children[offset])
yield* node.values()
offset += 1
}
}
/**
* @template T
* @template {string} K
* @template {API.Config} C
* @param {API.BitmapIndexedNode<T, K, C>} node
* @param {API.Edit|null} edit
* @param {number} offset
* @param {T} value
*/
export const forkAndSet = (node, edit, offset, value) => {
const newNode = node.fork(edit)
newNode.children[valuePosition(offset)] = value
return newNode
}
/**
* @template T
* @template {string} K
* @template {API.Config} C
* @param {API.BitmapIndexedNode<T, K, C>} source
* @param {API.Edit|null} edit
* @param {number} offset
* @param {API.Node<T, K, C>} child
* @returns {API.BitmapIndexedNode<T, K, C>}
*/
export const inlineChild = (source, edit, offset, child) => {
const { datamap, nodemap, config } = source
const { BitField } = config
const node = fork(source, edit)
// remove the node that we are inlining
node.children.splice(nodePosition(source, offset), 1)
// add key-value pair where it wolud fall
node.children.splice(
keyPosition(BitField.popcount(datamap, offset)),
0,
child.children[0],
child.children[1]
)
node.datamap = BitField.set(datamap, offset)
node.nodemap = BitField.unset(nodemap, offset)
return node
}
/**
* @template T
* @template {string} K
* @template {API.Config} C
* @param {API.BitmapIndexedNode<T, K, C>} node
* @param {API.Edit|null} edit
* @param {number} offset
* @param {API.Node<T, K, C>} child
* @returns {API.BitmapIndexedNode<T, K, C>}
*/
export const copyAndSetChild = (node, edit, offset, child) => {
const newNode = fork(node, edit)
newNode.children[nodePosition(node, offset)] = child
return newNode
}
/**
* @template T
* @template {string} K
* @template {API.Config} C
* @param {API.BitmapIndexedNode<T, K, C>} source
* @param {API.Edit|null} edit
* @param {number} offset
* @param {API.Node<T, K, C>} branch
* @returns {API.BitmapIndexedNode<T, K, C>}
*/
export const migrateLeafToBranch = (source, edit, offset, branch) => {
const { nodemap, datamap, config } = source
const { BitField } = config
const index = BitField.popcount(datamap, offset)
// Previous id corresponds to the key position
const oldId = keyPosition(index)
const newId = nodePosition(source, offset)
const node = fork(source, edit)
// remove an old leaf
node.datamap = BitField.unset(datamap, offset)
node.children.splice(oldId, 2)
// add a new branch
node.nodemap = BitField.set(nodemap, offset)
node.children.splice(newId - 1, 0, branch)
return node
}
/**
* @template T
* @template {string} K
* @template {API.Config} C
* @param {C} config
* @param {API.Edit|null} edit
* @param {number} depth
* @param {ReturnType<C['Path']['from']>} oldPath
* @param {K} oldKey
* @param {T} oldValue
* @param {ReturnType<C['Path']['from']>} newPath
* @param {K} newKey
* @param {T} newValue
* @returns {API.Node<T, K, C>}
*/
export const mergeTwoLeaves = (
config,
edit,
depth,
oldPath,
oldKey,
oldValue,
newPath,
newKey,
newValue
) => {
const { BitField, Path } = config
// If we have reached end of the path we can no longer create another
// `BitmapIndexedNode`, instead we create a node containing (hash) colliding
// entries
/* c8 ignore next 7 */
if (Path.size < depth) {
return new HashCollisionNode(
edit,
2,
[oldKey, oldValue, newKey, newValue],
config
)
} else {
const oldOffset = Path.at(oldPath, depth)
const newOffset = Path.at(newPath, depth)
// If offsets still match create another intermediery node and merge these
// two nodes at next depth level.
if (oldOffset === newOffset) {
return new BitmapIndexedNode(
edit,
BitField.empty(Math.pow(2, config.bitWidth)),
BitField.from([oldOffset], Math.pow(2, config.bitWidth)),
[
mergeTwoLeaves(
config,
edit,
depth + 1,
oldPath,
oldKey,
oldValue,
newPath,
newKey,
newValue
),
],
config
)
}
// otherwise create new node with both key-value pairs as it's children
else {
return new BitmapIndexedNode(
edit,
BitField.from([oldOffset, newOffset], Math.pow(2, config.bitWidth)),
BitField.empty(Math.pow(2, config.bitWidth)),
/** @type {API.Children<T, K, C>} */
(
// We insert child with a lower index first so that we can derive it's
// index on access via popcount
oldOffset < newOffset
? [oldKey, oldValue, newKey, newValue]
: [newKey, newValue, oldKey, oldValue]
),
config
)
}
}
}
/**
* @template {string} K
* @param {API.BitmapIndexedNode<unknown, K>} node
* @param {number} index
*/
export const keyAt = ({ children }, index) =>
/** @type {K} */ (children[keyPosition(index)])
/**
* @param {number} index
*/
export const keyPosition = index => index * 2
/**
* @template T
* @param {API.BitmapIndexedNode<T>} node
* @param {number} index
*/
export const valueAt = ({ children }, index) =>
/** @type {T} */ (children[valuePosition(index)])
/**
* @param {number} index
*/
export const valuePosition = index => index * 2 + 1
/**
* @template T
* @template {string} K
* @template {API.Config} C
* @param {API.BitmapIndexedNode<T, K, C>} node
* @param {number} offset
* @returns {API.BitmapIndexedNode<T, K, C>|API.HashCollisionNode<T, K, C>}
*/
export const resolveNode = (node, offset) =>
/** @type {API.BitmapIndexedNode<T, K, C>|API.HashCollisionNode<T, K, C>} */ (
node.children[nodePosition(node, offset)]
)
/**
* @template T
* @template {string} K
* @template {API.Config} C
* @param {API.BitmapIndexedNode<T, K, C>} node
* @param {number} offset
*/
const nodePosition = ({ children, nodemap, config }, offset) =>
children.length - 1 - config.BitField.popcount(nodemap, offset)
/**
* @param {API.Edit|null} owner
* @param {API.Edit|null} editor
*/
const canEdit = (owner, editor) => owner != null && owner === editor
/**
* Returns `true` if node has a single entry. It also refines type to
* `BitmapIndexedNode` because `HashCollisionNode` is normalized to
* `BitmapIndexedNode` when it contains only a single entry.
*
* @template T
* @template {string} K
* @template {API.Config} C
* @param {API.Node<T, K, C>} node
* @returns {node is API.BitmapIndexedNode<T, K, C>}
*/
const hasSingleLeaf = node => node.nodeArity === 0 && node.dataArity === 1
/**
* Returns `true` if node has a single childe node and 0 child leaves.
*
* @template T
* @template {string} K
* @template {API.Config} C
* @param {API.BitmapIndexedNode<T, K, C>} node
* @returns {node is API.BitmapIndexedNode<T, K, C>}
*/
const hasSingleNode = ({ config: { BitField }, datamap, nodemap }) =>
BitField.popcount(datamap) === 0 && BitField.popcount(nodemap) === 1