From 9fe6d139eb0470c5570c5081e18175879f9873e8 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Mon, 20 Apr 2026 11:25:51 +0200 Subject: [PATCH 1/7] feat(data-structures/unstable): add MultiMap --- data_structures/deno.json | 3 +- data_structures/unstable_multimap.ts | 708 ++++++++++++++++++++++ data_structures/unstable_multimap_test.ts | 582 ++++++++++++++++++ 3 files changed, 1292 insertions(+), 1 deletion(-) create mode 100644 data_structures/unstable_multimap.ts create mode 100644 data_structures/unstable_multimap_test.ts diff --git a/data_structures/deno.json b/data_structures/deno.json index da895e9caadc..639d5f92a9a8 100644 --- a/data_structures/deno.json +++ b/data_structures/deno.json @@ -11,6 +11,7 @@ "./red-black-tree": "./red_black_tree.ts", "./unstable-2d-array": "./unstable_2d_array.ts", "./unstable-rolling-counter": "./unstable_rolling_counter.ts", - "./unstable-deque": "./unstable_deque.ts" + "./unstable-deque": "./unstable_deque.ts", + "./unstable-multimap": "./unstable_multimap.ts" } } diff --git a/data_structures/unstable_multimap.ts b/data_structures/unstable_multimap.ts new file mode 100644 index 000000000000..b9cea81f7d93 --- /dev/null +++ b/data_structures/unstable_multimap.ts @@ -0,0 +1,708 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +// This module is browser compatible. + +/** + * A map that associates each key with an ordered list of values. + * + * Unlike {@linkcode Map}, each key can hold multiple values. Values are stored + * in insertion order, and duplicate values under the same key are preserved. + * + * Iterator methods ({@linkcode MultiMap.prototype.entries}, + * {@linkcode MultiMap.prototype.groups}, + * {@linkcode MultiMap.prototype.keys}, + * {@linkcode MultiMap.prototype.values}) return in constant time and iterate + * lazily; fully draining them is linear in the total value count (or the + * number of distinct keys, for {@linkcode MultiMap.prototype.keys}). + * + * | Method | Per-call time complexity | + * | ----------------------------------------------------------- | ------------------------------- | + * | {@linkcode MultiMap.prototype.add} | Amortized constant | + * | {@linkcode MultiMap.prototype.get} | Linear in the bucket size | + * | {@linkcode MultiMap.prototype.has} | Constant | + * | {@linkcode MultiMap.prototype.hasEntry} | Linear in the bucket size | + * | {@linkcode MultiMap.prototype.delete} | Constant | + * | {@linkcode MultiMap.prototype.deleteEntry} | Linear in the bucket size | + * | {@linkcode MultiMap.prototype.clear} | Linear in the key count | + * | {@linkcode MultiMap.prototype.forEach} | Linear in the total value count | + * | {@linkcode MultiMap.prototype.toMap} | Linear in the total value count | + * | {@linkcode MultiMap.groupBy} | Linear in the number of items | + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @typeParam K The type of the keys in the map. + * @typeParam V The type of the values in the map. + * + * @example Usage + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]]); + * + * assertEquals(map.get("a"), [1, 2]); + * assertEquals(map.get("b"), [3]); + * ``` + * + * @example Preserves insertion order and duplicates + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap(); + * map.add("a", 2).add("a", 1).add("a", 2); + * + * assertEquals(map.get("a"), [2, 1, 2]); + * assertEquals(map.size, 1); + * ``` + */ +export class MultiMap implements Iterable<[K, V]> { + #map = new Map(); + + /** + * Creates a new instance. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param entries An iterable of key-value pairs for the initial entries. + * Duplicate values for the same key are preserved in insertion order. + * + * @example Creating an empty map + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap(); + * assertEquals(map.size, 0); + * ``` + * + * @example Creating a map from an iterable + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]]); + * assertEquals(map.get("a"), [1, 2]); + * ``` + */ + constructor(entries?: Iterable | null) { + if (entries) { + for (const [key, value] of entries) { + this.add(key, value); + } + } + } + + /** + * The number of distinct keys in the map. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @returns The number of distinct keys in the map. + * + * @example Usage + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]]); + * assertEquals(map.size, 2); + * ``` + */ + get size(): number { + return this.#map.size; + } + + /** + * Appends a value to the list stored under the given key. Duplicate values + * are preserved. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param key The key to add the value under. + * @param value The value to append. + * @returns The instance. + * + * @example Usage + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap(); + * map.add("a", 1).add("a", 2); + * + * assertEquals(map.get("a"), [1, 2]); + * ``` + */ + add(key: K, value: V): this { + let list = this.#map.get(key); + if (!list) { + list = []; + this.#map.set(key, list); + } + list.push(value); + return this; + } + + /** + * Returns a snapshot of the values associated with the given key, in + * insertion order, or `undefined` if the key does not exist. + * + * The returned array is a fresh copy; mutating it does not affect the map, + * and later mutations to the map are not reflected in it. For read-only + * traversal across all keys, prefer {@linkcode MultiMap.prototype.groups} + * to avoid the per-call copy. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param key The key to look up. + * @returns A fresh array of values in insertion order, or `undefined`. + * + * @example Usage + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap([["a", 1], ["a", 2]]); + * + * assertEquals(map.get("a"), [1, 2]); + * assertEquals(map.get("b"), undefined); + * ``` + */ + get(key: K): V[] | undefined { + const list = this.#map.get(key); + return list === undefined ? undefined : list.slice(); + } + + /** + * Returns `true` if the key exists with at least one value. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param key The key to check. + * @returns `true` if the key exists, `false` otherwise. + * + * @example Usage + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap([["a", 1]]); + * + * assertEquals(map.has("a"), true); + * assertEquals(map.has("b"), false); + * ``` + */ + has(key: K): boolean { + return this.#map.has(key); + } + + /** + * Returns `true` if the `[key, value]` entry exists in the map (i.e. the + * given value appears at least once under the given key). + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param key The key to look up. + * @param value The value to check. + * @returns `true` if the entry exists, `false` otherwise. + * + * @example Usage + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap([["a", 1], ["a", 2]]); + * + * assertEquals(map.hasEntry("a", 1), true); + * assertEquals(map.hasEntry("a", 3), false); + * assertEquals(map.hasEntry("b", 1), false); + * ``` + */ + hasEntry(key: K, value: V): boolean { + return this.#map.get(key)?.includes(value) ?? false; + } + + /** + * Removes all values for the given key. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param key The key to remove. + * @returns `true` if the key existed and was removed, `false` otherwise. + * + * @example Usage + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]]); + * assertEquals(map.delete("a"), true); + * assertEquals(map.delete("a"), false); + * + * assertEquals(map.has("a"), false); + * assertEquals(map.size, 1); + * ``` + */ + delete(key: K): boolean { + return this.#map.delete(key); + } + + /** + * Removes the first occurrence of the `[key, value]` entry from the map. + * If the key's list becomes empty, the key is also removed. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param key The key to look up. + * @param value The value to remove. + * @returns `true` if an entry was removed, `false` otherwise. + * + * @example Usage + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap([["a", 1], ["a", 2], ["a", 1]]); + * assertEquals(map.deleteEntry("a", 1), true); + * + * assertEquals(map.get("a"), [2, 1]); + * ``` + */ + deleteEntry(key: K, value: V): boolean { + const list = this.#map.get(key); + if (!list) return false; + // SameValueZero, matching `hasEntry()` / `Map` / `Set` semantics so that + // `NaN` values can be removed. `Array.prototype.indexOf` uses strict + // equality and would never match `NaN`. + let index = -1; + for (let i = 0; i < list.length; i++) { + const v = list[i]!; + if (v === value || (v !== v && value !== value)) { + index = i; + break; + } + } + if (index === -1) return false; + list.splice(index, 1); + if (list.length === 0) this.#map.delete(key); + return true; + } + + /** + * Removes all entries. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @example Usage + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap([["a", 1], ["b", 2]]); + * map.clear(); + * + * assertEquals(map.size, 0); + * ``` + */ + clear(): void { + this.#map.clear(); + } + + /** + * Executes a provided function once for each individual value in the map, + * in insertion order of keys and values. + * + * Within a bucket, the set of values to visit is fixed at the time the + * bucket is entered, so a callback that mutates the current key's list + * (via `add()` or `deleteEntry()`) will not extend or shift the visit. + * Cross-bucket mutations during iteration (adding or deleting other keys) + * are not supported and may skip or repeat keys. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @typeParam T The type of `this` when calling the callback. + * @param callbackfn The function to call for each value. + * @param thisArg Value to use as `this` when executing `callbackfn`. + * + * @example Usage + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]]); + * const result: [string, number][] = []; + * map.forEach((value, key) => result.push([key, value])); + * + * assertEquals(result, [["a", 1], ["a", 2], ["b", 3]]); + * ``` + * + * @example With `thisArg` + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap([["a", 1], ["b", 2]]); + * const context = { prefix: "x:" }; + * const result: string[] = []; + * map.forEach(function (value, key) { + * result.push(`${this.prefix}${key}=${value}`); + * }, context); + * + * assertEquals(result, ["x:a=1", "x:b=2"]); + * ``` + */ + forEach( + callbackfn: (this: T, value: V, key: K, map: this) => void, + thisArg?: T, + ): void { + if (typeof callbackfn !== "function") { + throw new TypeError( + `Cannot call MultiMap.prototype.forEach: "callbackfn" is not a function: received ${typeof callbackfn}`, + ); + } + // Split on thisArg to avoid paying the .call() binding cost per value in + // the common case where no thisArg is passed. The bucket is snapshotted + // before the inner loop so mutations to the current key's list (e.g. a + // callback calling `add()` or splicing via `deleteEntry()`) do not + // extend, truncate, or shift the visit. This mirrors the per-bucket + // contract of `Map.prototype.forEach`. + if (thisArg === undefined) { + const fn = callbackfn as (v: V, k: K, m: this) => void; + for (const [key, list] of this.#map) { + const snapshot = list.slice(); + for (let i = 0; i < snapshot.length; i++) { + fn(snapshot[i]!, key, this); + } + } + } else { + for (const [key, list] of this.#map) { + const snapshot = list.slice(); + for (let i = 0; i < snapshot.length; i++) { + callbackfn.call(thisArg, snapshot[i]!, key, this); + } + } + } + } + + /** + * Returns an iterator of all `[key, value]` pairs, with each value yielded + * individually across all keys, in insertion order. + * + * Mutating the map during iteration is not supported and may skip or repeat + * entries. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @returns An iterator of `[key, value]` pairs. + * + * @example Usage + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]]); + * + * assertEquals(Array.from(map.entries()), [["a", 1], ["a", 2], ["b", 3]]); + * ``` + */ + entries(): IterableIterator<[K, V]> { + // Hand-rolled iterator rather than a generator: avoids per-yield + // generator-frame overhead. ~5x faster on full iteration. The bucket is + // snapshotted on first entry so mutations to the current bucket during + // iteration (including `delete()` of the current key) do not extend, + // truncate, or shift the visit. + const outer = this.#map[Symbol.iterator](); + let currentKey!: K; + let currentList: V[] | null = null; + let innerIndex = 0; + const iter: IterableIterator<[K, V]> = { + next(): IteratorResult<[K, V]> { + while (true) { + if (currentList !== null && innerIndex < currentList.length) { + return { + value: [currentKey, currentList[innerIndex++]!], + done: false, + }; + } + const outerResult = outer.next(); + if (outerResult.done) { + currentList = null; + return { value: undefined, done: true }; + } + currentKey = outerResult.value[0]; + currentList = outerResult.value[1].slice(); + innerIndex = 0; + } + }, + [Symbol.iterator]() { + return this; + }, + }; + return iter; + } + + /** + * Returns an iterator of `[key, values]` pairs, where `values` is the list + * of values associated with that key in insertion order. + * + * Use this when you need both the key and its full value list, for example + * to filter by bucket size. For individual `[key, value]` pairs, use + * {@linkcode MultiMap.prototype.entries}. + * + * Each yielded array is a fresh snapshot; mutating it does not affect the + * map, and later mutations to the map are not reflected in it. Mutating the + * map during iteration is not supported and may skip or repeat buckets. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @returns An iterator of `[key, values]` pairs. + * + * @example Usage + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]]); + * + * assertEquals( + * Array.from(map.groups(), ([k, vs]) => [k, [...vs]]), + * [["a", [1, 2]], ["b", [3]]], + * ); + * ``` + * + * @example Filter by bucket size + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]]); + * const collisions: string[] = []; + * for (const [key, values] of map.groups()) { + * if (values.length > 1) collisions.push(key); + * } + * + * assertEquals(collisions, ["a"]); + * ``` + */ + *groups(): IterableIterator<[K, V[]]> { + for (const [key, list] of this.#map) { + yield [key, list.slice()]; + } + } + + /** + * Returns an iterator of all distinct keys in the map, in insertion order. + * + * Mutating the map during iteration is not supported and may skip or repeat + * keys. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @returns An iterator of keys. + * + * @example Usage + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]]); + * + * assertEquals(Array.from(map.keys()), ["a", "b"]); + * ``` + */ + keys(): MapIterator { + return this.#map.keys(); + } + + /** + * Returns an iterator of all individual values across all keys, in insertion + * order. + * + * Mutating the map during iteration is not supported and may skip or repeat + * values. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @returns An iterator of values. + * + * @example Usage + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]]); + * + * assertEquals(Array.from(map.values()), [1, 2, 3]); + * ``` + */ + values(): IterableIterator { + // Hand-rolled iterator rather than a generator: avoids per-yield + // generator-frame overhead and the yield* delegation cost. The bucket is + // snapshotted on first entry so mutations to the current bucket during + // iteration do not extend, truncate, or shift the visit. + const outer = this.#map.values(); + let currentList: V[] | null = null; + let innerIndex = 0; + const iter: IterableIterator = { + next(): IteratorResult { + while (true) { + if (currentList !== null && innerIndex < currentList.length) { + return { value: currentList[innerIndex++]!, done: false }; + } + const outerResult = outer.next(); + if (outerResult.done) { + currentList = null; + return { value: undefined, done: true }; + } + currentList = outerResult.value.slice(); + innerIndex = 0; + } + }, + [Symbol.iterator]() { + return this; + }, + }; + return iter; + } + + /** + * Returns a new {@linkcode Map} snapshot of the multimap, with each key + * mapped to a fresh array of its values in insertion order. + * + * The returned map and its value arrays are owned by the caller; mutating + * them does not affect the multimap, and later mutations to the multimap + * are not reflected in the snapshot. This is the natural inverse of + * {@linkcode MultiMap.groupBy} and the `Map.groupBy` builtin. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @returns A new `Map` snapshot. + * + * @example Usage + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]]); + * + * assertEquals(map.toMap(), new Map([["a", [1, 2]], ["b", [3]]])); + * ``` + */ + toMap(): Map { + const result = new Map(); + for (const [key, list] of this.#map) { + result.set(key, list.slice()); + } + return result; + } + + /** + * Returns an iterator of all `[key, value]` pairs, with each value yielded + * individually. The map is not modified. Same as + * {@linkcode MultiMap.prototype.entries}. + * + * Mutating the map while iterating is not supported and may skip or repeat + * entries. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @returns An iterator of `[key, value]` pairs. + * + * @example Usage + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]]); + * + * assertEquals(Array.from(map), [["a", 1], ["a", 2], ["b", 3]]); + * ``` + */ + [Symbol.iterator](): IterableIterator<[K, V]> { + return this.entries(); + } + + /** + * A string tag for the class, used by `Object.prototype.toString()`. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @example Usage + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap(); + * assertEquals(map[Symbol.toStringTag], "MultiMap"); + * ``` + */ + readonly [Symbol.toStringTag] = "MultiMap" as const; + + /** + * Groups items from an iterable by the result of `keyFn`, returning a new + * multimap. Mirrors the shape of + * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/groupBy | `Map.groupBy`}, + * but returns a {@linkcode MultiMap} so further mutation is supported. + * + * Item order is preserved within each bucket. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @typeParam K The type of the keys produced by `keyFn`. + * @typeParam T The type of the items in `items`. + * @param items The items to group. + * @param keyFn A function called for each item with the item and its + * zero-based index; its return value is the bucket key. + * @returns A new `MultiMap` of grouped items. + * + * @example Usage + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const users = [ + * { name: "Ada", role: "admin" }, + * { name: "Bo", role: "user" }, + * { name: "Cy", role: "admin" }, + * ]; + * + * const byRole = MultiMap.groupBy(users, (u) => u.role); + * + * assertEquals(byRole.get("admin"), [ + * { name: "Ada", role: "admin" }, + * { name: "Cy", role: "admin" }, + * ]); + * assertEquals(byRole.get("user"), [{ name: "Bo", role: "user" }]); + * ``` + * + * @example The key function receives the item index + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const grouped = MultiMap.groupBy( + * ["a", "b", "c", "d"], + * (_, i) => (i % 2 === 0 ? "even" : "odd"), + * ); + * + * assertEquals(grouped.get("even"), ["a", "c"]); + * assertEquals(grouped.get("odd"), ["b", "d"]); + * ``` + */ + static groupBy( + items: Iterable, + keyFn: (item: T, index: number) => K, + ): MultiMap { + if (typeof keyFn !== "function") { + throw new TypeError( + `Cannot call MultiMap.groupBy: "keyFn" is not a function: received ${typeof keyFn}`, + ); + } + const map = new MultiMap(); + let index = 0; + for (const item of items) { + map.add(keyFn(item, index++), item); + } + return map; + } +} diff --git a/data_structures/unstable_multimap_test.ts b/data_structures/unstable_multimap_test.ts new file mode 100644 index 000000000000..343f28b02a0f --- /dev/null +++ b/data_structures/unstable_multimap_test.ts @@ -0,0 +1,582 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +import { assertEquals, assertNotStrictEquals, assertThrows } from "@std/assert"; +import { MultiMap } from "./unstable_multimap.ts"; + +Deno.test("MultiMap.add() adds a value under a new key", () => { + const map = new MultiMap(); + map.add("a", 1); + + assertEquals(map.get("a"), [1]); + assertEquals(map.size, 1); +}); + +Deno.test("MultiMap.add() appends multiple values under the same key", () => { + const map = new MultiMap(); + map.add("a", 1).add("a", 2); + + assertEquals(map.get("a"), [1, 2]); + assertEquals(map.size, 1); +}); + +Deno.test("MultiMap.add() preserves duplicate values for the same key", () => { + const map = new MultiMap(); + map.add("a", 1).add("a", 1).add("a", 2); + + assertEquals(map.get("a"), [1, 1, 2]); +}); + +Deno.test("MultiMap.add() preserves insertion order across keys", () => { + const map = new MultiMap(); + map.add("b", 1).add("a", 2).add("b", 3).add("a", 4); + + assertEquals(Array.from(map.keys()), ["b", "a"]); + assertEquals(map.get("b"), [1, 3]); + assertEquals(map.get("a"), [2, 4]); +}); + +Deno.test("MultiMap.add() returns the instance for chaining", () => { + const map = new MultiMap(); + const result = map.add("a", 1); + + assertEquals(result, map); +}); + +Deno.test("MultiMap.get() returns the list of values for an existing key", () => { + const map = new MultiMap([["a", 1], ["a", 2]] as const); + + assertEquals(map.get("a"), [1, 2]); +}); + +Deno.test("MultiMap.get() returns undefined for a missing key", () => { + const map = new MultiMap(); + + assertEquals(map.get("a"), undefined); +}); + +Deno.test("MultiMap.get() returns a defensive snapshot", () => { + const map = new MultiMap([["a", 1], ["a", 2]]); + const first = map.get("a")!; + first.push(999); + + assertEquals(map.get("a"), [1, 2]); + + const second = map.get("a")!; + assertNotStrictEquals(first, second); + + map.add("a", 3); + assertEquals(second, [1, 2]); +}); + +Deno.test("MultiMap.has() returns true when key exists", () => { + const map = new MultiMap([["a", 1]] as const); + + assertEquals(map.has("a"), true); +}); + +Deno.test("MultiMap.has() returns false when key does not exist", () => { + const map = new MultiMap(); + + assertEquals(map.has("a"), false); +}); + +Deno.test("MultiMap.hasEntry() returns true when entry exists", () => { + const map = new MultiMap([["a", 1], ["a", 2]] as const); + + assertEquals(map.hasEntry("a", 1), true); + assertEquals(map.hasEntry("a", 2), true); +}); + +Deno.test( + "MultiMap.hasEntry() returns false when value is absent under key", + () => { + const map = new MultiMap([["a", 1]]); + + assertEquals(map.hasEntry("a", 2), false); + }, +); + +Deno.test( + "MultiMap.hasEntry() returns false when key does not exist", + () => { + const map = new MultiMap(); + + assertEquals(map.hasEntry("b", 1), false); + }, +); + +Deno.test("MultiMap.delete() removes all values for a key", () => { + const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]] as const); + assertEquals(map.delete("a"), true); + + assertEquals(map.has("a"), false); + assertEquals(map.size, 1); +}); + +Deno.test("MultiMap.delete() returns true when the key existed", () => { + const map = new MultiMap([["a", 1]] as const); + + assertEquals(map.delete("a"), true); +}); + +Deno.test("MultiMap.delete() returns false when the key does not exist", () => { + const map = new MultiMap(); + + assertEquals(map.delete("a"), false); +}); + +Deno.test( + "MultiMap.deleteEntry() removes a single entry from a key", + () => { + const map = new MultiMap([["a", 1], ["a", 2]] as const); + assertEquals(map.deleteEntry("a", 1), true); + + assertEquals(map.get("a"), [2]); + assertEquals(map.has("a"), true); + }, +); + +Deno.test( + "MultiMap.deleteEntry() removes only the first occurrence", + () => { + const map = new MultiMap([["a", 1], ["a", 2], ["a", 1]] as const); + assertEquals(map.deleteEntry("a", 1), true); + + assertEquals(map.get("a"), [2, 1]); + }, +); + +Deno.test( + "MultiMap.deleteEntry() removes the key when the last value is removed", + () => { + const map = new MultiMap([["a", 1]] as const); + assertEquals(map.deleteEntry("a", 1), true); + + assertEquals(map.has("a"), false); + assertEquals(map.size, 0); + }, +); + +Deno.test( + "MultiMap.deleteEntry() returns false when the entry does not exist", + () => { + const map = new MultiMap([["a", 1]]); + + assertEquals(map.deleteEntry("a", 2), false); + assertEquals(map.deleteEntry("b", 1), false); + }, +); + +Deno.test( + "MultiMap.deleteEntry() removes NaN using SameValueZero, matching hasEntry()", + () => { + const map = new MultiMap(); + map.add("a", NaN); + + assertEquals(map.hasEntry("a", NaN), true); + assertEquals(map.deleteEntry("a", NaN), true); + assertEquals(map.hasEntry("a", NaN), false); + assertEquals(map.has("a"), false); + }, +); + +Deno.test( + "MultiMap.deleteEntry() treats +0 and -0 as equal (SameValueZero)", + () => { + const map = new MultiMap(); + map.add("a", -0); + + assertEquals(map.hasEntry("a", +0), true); + assertEquals(map.deleteEntry("a", +0), true); + assertEquals(map.has("a"), false); + }, +); + +Deno.test("MultiMap.clear() removes all entries", () => { + const map = new MultiMap([["a", 1], ["b", 2]] as const); + map.clear(); + + assertEquals(map.size, 0); + assertEquals(map.has("a"), false); + assertEquals(map.has("b"), false); +}); + +Deno.test("MultiMap.size counts distinct keys, not total values", () => { + const map = new MultiMap([["a", 1], ["a", 2], ["a", 3], ["b", 4]] as const); + + assertEquals(map.size, 2); +}); + +Deno.test("MultiMap.keys() yields each key once in insertion order", () => { + const map = new MultiMap([["b", 1], ["a", 2], ["b", 3]] as const); + + assertEquals(Array.from(map.keys()), ["b", "a"]); +}); + +Deno.test( + "MultiMap.values() yields all individual values across all keys, including cross-key duplicates", + () => { + const map = new MultiMap([["a", 1], ["a", 2], ["b", 1]] as const); + + assertEquals(Array.from(map.values()), [1, 2, 1]); + }, +); + +Deno.test("MultiMap.entries() yields flattened [key, value] pairs", () => { + const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]] as const); + + assertEquals(Array.from(map.entries()), [["a", 1], ["a", 2], ["b", 3]]); +}); + +Deno.test( + "MultiMap.entries() does not observe mutations to the current bucket", + () => { + const map = new MultiMap([["a", 1]]); + const it = map.entries(); + const first = it.next(); + map.add("a", 42); + const second = it.next(); + + assertEquals(first.value, ["a", 1]); + assertEquals(second.done, true); + }, +); + +Deno.test( + "MultiMap.entries() keeps yielding values from the current bucket after delete()", + () => { + const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]]); + const it = map.entries(); + const first = it.next(); + map.delete("a"); + const rest: [string, number][] = []; + for (;;) { + const { value, done } = it.next(); + if (done) break; + rest.push(value as [string, number]); + } + + assertEquals(first.value, ["a", 1]); + assertEquals(rest, [["a", 2], ["b", 3]]); + assertEquals(map.has("a"), false); + }, +); + +Deno.test( + "MultiMap.values() does not observe mutations to the current bucket", + () => { + const map = new MultiMap([["a", 1]]); + const it = map.values(); + const first = it.next(); + map.add("a", 42); + const second = it.next(); + + assertEquals(first.value, 1); + assertEquals(second.done, true); + }, +); + +Deno.test("MultiMap.entries() is non-destructive", () => { + const map = new MultiMap([["a", 1], ["b", 2]] as const); + Array.from(map.entries()); + + assertEquals(map.size, 2); + assertEquals(Array.from(map.entries()), [["a", 1], ["b", 2]]); +}); + +Deno.test( + "MultiMap[Symbol.iterator]() yields the same pairs as entries()", + () => { + const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]] as const); + + assertEquals(Array.from(map[Symbol.iterator]()), [ + ["a", 1], + ["a", 2], + ["b", 3], + ]); + }, +); + +Deno.test( + "MultiMap is iterable via for..of", + () => { + const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]] as const); + const result: [string, number][] = []; + for (const pair of map) result.push(pair); + + assertEquals(result, [["a", 1], ["a", 2], ["b", 3]]); + }, +); + +Deno.test( + "MultiMap[Symbol.iterator]() is non-destructive", + () => { + const map = new MultiMap([["a", 1], ["b", 2]] as const); + Array.from(map); + + assertEquals(map.size, 2); + assertEquals(Array.from(map), [["a", 1], ["b", 2]]); + }, +); + +Deno.test("MultiMap.forEach() iterates individual (value, key, map) triples", () => { + const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]] as const); + const result: [string, number, MultiMap][] = []; + map.forEach((value, key, m) => result.push([key, value, m])); + + assertEquals(result, [ + ["a", 1, map], + ["a", 2, map], + ["b", 3, map], + ]); +}); + +Deno.test( + "MultiMap.forEach() throws TypeError when callbackfn is not a function", + () => { + const empty = new MultiMap(); + assertThrows( + // deno-lint-ignore no-explicit-any + () => empty.forEach(null as any), + TypeError, + `Cannot call MultiMap.prototype.forEach: "callbackfn" is not a function`, + ); + + const populated = new MultiMap([["a", 1]] as const); + assertThrows( + // deno-lint-ignore no-explicit-any + () => populated.forEach(null as any), + TypeError, + `Cannot call MultiMap.prototype.forEach: "callbackfn" is not a function`, + ); + }, +); + +Deno.test( + "MultiMap.forEach() does not visit values appended to the current bucket", + () => { + const map = new MultiMap([["a", 1]]); + const seen: number[] = []; + map.forEach((value, key) => { + seen.push(value); + if (value === 1) map.add(key, 99); + }); + + assertEquals(seen, [1]); + assertEquals(map.get("a"), [1, 99]); + }, +); + +Deno.test( + "MultiMap.forEach() does not shift the visit when the current bucket is spliced", + () => { + const map = new MultiMap([["a", 1], ["a", 2], ["a", 3]]); + const seen: number[] = []; + map.forEach((value, key) => { + seen.push(value); + if (value === 1) map.deleteEntry(key, 2); + }); + + assertEquals(seen, [1, 2, 3]); + assertEquals(map.get("a"), [1, 3]); + }, +); + +Deno.test("MultiMap.forEach() binds thisArg when provided", () => { + const map = new MultiMap([["a", 1], ["b", 2]] as const); + const context = { prefix: "x:" }; + const result: string[] = []; + map.forEach(function (value, key) { + result.push(`${this.prefix}${key}=${value}`); + }, context); + + assertEquals(result, ["x:a=1", "x:b=2"]); +}); + +Deno.test( + "MultiMap.groups() yields [key, values] pairs in insertion order", + () => { + const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]] as const); + + assertEquals( + Array.from(map.groups(), ([k, vs]) => [k, [...vs]]), + [["a", [1, 2]], ["b", [3]]], + ); + }, +); + +Deno.test( + "MultiMap.groups() exposes bucket length", + () => { + const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]] as const); + const collisions: string[] = []; + for (const [key, values] of map.groups()) { + if (values.length > 1) collisions.push(key); + } + + assertEquals(collisions, ["a"]); + }, +); + +Deno.test("MultiMap.groups() is non-destructive", () => { + const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]] as const); + Array.from(map.groups()); + + assertEquals(map.size, 2); + assertEquals(map.get("a"), [1, 2]); + assertEquals(map.get("b"), [3]); +}); + +Deno.test( + "MultiMap.groups() yields defensive snapshots that do not alias internal state", + () => { + const map = new MultiMap([["a", 1], ["a", 2]]); + for (const [, values] of map.groups()) { + values.length = 0; + values.push(-1); + } + + assertEquals(map.size, 1); + assertEquals(map.has("a"), true); + assertEquals(map.get("a"), [1, 2]); + assertEquals(map.hasEntry("a", 1), true); + }, +); + +Deno.test( + "MultiMap constructor accepts an iterable and preserves duplicates", + () => { + const map = new MultiMap([["a", 1], ["a", 1], ["a", 2], ["b", 3]] as const); + + assertEquals(map.size, 2); + assertEquals(map.get("a"), [1, 1, 2]); + assertEquals(map.get("b"), [3]); + }, +); + +Deno.test("MultiMap constructor accepts null", () => { + const map = new MultiMap(null); + + assertEquals(map.size, 0); +}); + +Deno.test("MultiMap.toMap() returns a Map of arrays in insertion order", () => { + const map = new MultiMap([["b", 1], ["a", 2], ["b", 3]] as const); + + assertEquals(map.toMap(), new Map([["b", [1, 3]], ["a", [2]]])); + assertEquals(Array.from(map.toMap().keys()), ["b", "a"]); +}); + +Deno.test( + "MultiMap.toMap() returns fresh arrays that do not alias internal state", + () => { + const map = new MultiMap([["a", 1]]); + const snapshot = map.toMap(); + snapshot.get("a")!.push(999); + snapshot.set("b", [2]); + + assertEquals(map.get("a"), [1]); + assertEquals(map.has("b"), false); + + map.add("a", 3); + assertEquals(snapshot.get("a"), [1, 999]); + }, +); + +Deno.test("MultiMap.toMap() on an empty multimap returns an empty Map", () => { + const map = new MultiMap(); + + assertEquals(map.toMap(), new Map()); +}); + +Deno.test("MultiMap.groupBy() buckets items by keyFn result", () => { + const users = [ + { name: "Ada", role: "admin" }, + { name: "Bo", role: "user" }, + { name: "Cy", role: "admin" }, + ]; + + const byRole = MultiMap.groupBy(users, (u) => u.role); + + assertEquals(byRole.size, 2); + assertEquals(byRole.get("admin"), [ + { name: "Ada", role: "admin" }, + { name: "Cy", role: "admin" }, + ]); + assertEquals(byRole.get("user"), [{ name: "Bo", role: "user" }]); +}); + +Deno.test("MultiMap.groupBy() preserves encounter order within buckets", () => { + const grouped = MultiMap.groupBy( + [3, 1, 4, 1, 5, 9, 2, 6], + (n) => (n % 2 === 0 ? "even" : "odd"), + ); + + assertEquals(grouped.get("even"), [4, 2, 6]); + assertEquals(grouped.get("odd"), [3, 1, 1, 5, 9]); +}); + +Deno.test("MultiMap.groupBy() passes the zero-based index to keyFn", () => { + const indices: number[] = []; + MultiMap.groupBy(["a", "b", "c"], (_, i) => { + indices.push(i); + return i; + }); + + assertEquals(indices, [0, 1, 2]); +}); + +Deno.test("MultiMap.groupBy() accepts an arbitrary iterable", () => { + function* gen() { + yield 1; + yield 2; + yield 3; + } + + const grouped = MultiMap.groupBy(gen(), (n) => n % 2 === 0 ? "even" : "odd"); + + assertEquals(grouped.get("even"), [2]); + assertEquals(grouped.get("odd"), [1, 3]); +}); + +Deno.test( + "MultiMap.groupBy() throws TypeError when keyFn is not a function", + () => { + assertThrows( + // deno-lint-ignore no-explicit-any + () => MultiMap.groupBy([], null as any), + TypeError, + `Cannot call MultiMap.groupBy: "keyFn" is not a function`, + ); + + assertThrows( + // deno-lint-ignore no-explicit-any + () => MultiMap.groupBy([1, 2, 3], null as any), + TypeError, + `Cannot call MultiMap.groupBy: "keyFn" is not a function`, + ); + }, +); + +Deno.test("MultiMap.groupBy() on an empty iterable returns an empty multimap", () => { + const grouped = MultiMap.groupBy([], () => "k"); + + assertEquals(grouped.size, 0); +}); + +Deno.test("MultiMap.groupBy() and toMap() round-trip", () => { + const items = [1, 2, 3, 4, 5, 6]; + const key = (n: number) => n % 3; + + const grouped = MultiMap.groupBy(items, key); + + assertEquals( + grouped.toMap(), + new Map([[1, [1, 4]], [2, [2, 5]], [0, [3, 6]]]), + ); +}); + +Deno.test("MultiMap[Symbol.toStringTag] is 'MultiMap'", () => { + const map = new MultiMap(); + + assertEquals(map[Symbol.toStringTag], "MultiMap"); +}); From 09dbf2acc3dec7f62252173e1ab5ca7543d9178a Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Mon, 20 Apr 2026 11:38:48 +0200 Subject: [PATCH 2/7] fix --- data_structures/unstable_multimap.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data_structures/unstable_multimap.ts b/data_structures/unstable_multimap.ts index b9cea81f7d93..a95351168122 100644 --- a/data_structures/unstable_multimap.ts +++ b/data_structures/unstable_multimap.ts @@ -510,7 +510,7 @@ export class MultiMap implements Iterable<[K, V]> { * assertEquals(Array.from(map.keys()), ["a", "b"]); * ``` */ - keys(): MapIterator { + keys(): IterableIterator { return this.#map.keys(); } From ba137bb8e3bb55b2ed67da0b0921d715ca84c227 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Mon, 20 Apr 2026 13:19:27 +0200 Subject: [PATCH 3/7] improve docs --- data_structures/unstable_multimap.ts | 29 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/data_structures/unstable_multimap.ts b/data_structures/unstable_multimap.ts index a95351168122..fb12d835bde4 100644 --- a/data_structures/unstable_multimap.ts +++ b/data_structures/unstable_multimap.ts @@ -263,9 +263,12 @@ export class MultiMap implements Iterable<[K, V]> { * import { assertEquals } from "@std/assert"; * * const map = new MultiMap([["a", 1], ["a", 2], ["a", 1]]); - * assertEquals(map.deleteEntry("a", 1), true); * + * assertEquals(map.deleteEntry("a", 1), true); * assertEquals(map.get("a"), [2, 1]); + * + * assertEquals(map.deleteEntry("a", 1), true); + * assertEquals(map.get("a"), [2]); * ``` */ deleteEntry(key: K, value: V): boolean { @@ -341,14 +344,13 @@ export class MultiMap implements Iterable<[K, V]> { * import { MultiMap } from "@std/data-structures/unstable-multimap"; * import { assertEquals } from "@std/assert"; * - * const map = new MultiMap([["a", 1], ["b", 2]]); - * const context = { prefix: "x:" }; - * const result: string[] = []; - * map.forEach(function (value, key) { - * result.push(`${this.prefix}${key}=${value}`); - * }, context); + * const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]]); + * const counter = { total: 0 }; + * map.forEach(function (value) { + * this.total += value; + * }, counter); * - * assertEquals(result, ["x:a=1", "x:b=2"]); + * assertEquals(counter.total, 6); * ``` */ forEach( @@ -464,10 +466,7 @@ export class MultiMap implements Iterable<[K, V]> { * * const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]]); * - * assertEquals( - * Array.from(map.groups(), ([k, vs]) => [k, [...vs]]), - * [["a", [1, 2]], ["b", [3]]], - * ); + * assertEquals(Array.from(map.groups()), [["a", [1, 2]], ["b", [3]]]); * ``` * * @example Filter by bucket size @@ -682,11 +681,11 @@ export class MultiMap implements Iterable<[K, V]> { * * const grouped = MultiMap.groupBy( * ["a", "b", "c", "d"], - * (_, i) => (i % 2 === 0 ? "even" : "odd"), + * (item, index) => index < 2 ? "first-half" : "second-half", * ); * - * assertEquals(grouped.get("even"), ["a", "c"]); - * assertEquals(grouped.get("odd"), ["b", "d"]); + * assertEquals(grouped.get("first-half"), ["a", "b"]); + * assertEquals(grouped.get("second-half"), ["c", "d"]); * ``` */ static groupBy( From d9be0ad8c0dca4e83eb6f1ed4e7efa742959459a Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Mon, 20 Apr 2026 11:25:51 +0200 Subject: [PATCH 4/7] feat(data-structures/unstable): add MultiMap --- data_structures/deno.json | 3 +- data_structures/unstable_multimap.ts | 708 ++++++++++++++++++++++ data_structures/unstable_multimap_test.ts | 582 ++++++++++++++++++ 3 files changed, 1292 insertions(+), 1 deletion(-) create mode 100644 data_structures/unstable_multimap.ts create mode 100644 data_structures/unstable_multimap_test.ts diff --git a/data_structures/deno.json b/data_structures/deno.json index 55a4f6c7968a..1bfba728e1d5 100644 --- a/data_structures/deno.json +++ b/data_structures/deno.json @@ -11,6 +11,7 @@ "./deque": "./deque.ts", "./red-black-tree": "./red_black_tree.ts", "./unstable-2d-array": "./unstable_2d_array.ts", - "./unstable-rolling-counter": "./unstable_rolling_counter.ts" + "./unstable-rolling-counter": "./unstable_rolling_counter.ts", + "./unstable-multimap": "./unstable_multimap.ts" } } diff --git a/data_structures/unstable_multimap.ts b/data_structures/unstable_multimap.ts new file mode 100644 index 000000000000..b9cea81f7d93 --- /dev/null +++ b/data_structures/unstable_multimap.ts @@ -0,0 +1,708 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +// This module is browser compatible. + +/** + * A map that associates each key with an ordered list of values. + * + * Unlike {@linkcode Map}, each key can hold multiple values. Values are stored + * in insertion order, and duplicate values under the same key are preserved. + * + * Iterator methods ({@linkcode MultiMap.prototype.entries}, + * {@linkcode MultiMap.prototype.groups}, + * {@linkcode MultiMap.prototype.keys}, + * {@linkcode MultiMap.prototype.values}) return in constant time and iterate + * lazily; fully draining them is linear in the total value count (or the + * number of distinct keys, for {@linkcode MultiMap.prototype.keys}). + * + * | Method | Per-call time complexity | + * | ----------------------------------------------------------- | ------------------------------- | + * | {@linkcode MultiMap.prototype.add} | Amortized constant | + * | {@linkcode MultiMap.prototype.get} | Linear in the bucket size | + * | {@linkcode MultiMap.prototype.has} | Constant | + * | {@linkcode MultiMap.prototype.hasEntry} | Linear in the bucket size | + * | {@linkcode MultiMap.prototype.delete} | Constant | + * | {@linkcode MultiMap.prototype.deleteEntry} | Linear in the bucket size | + * | {@linkcode MultiMap.prototype.clear} | Linear in the key count | + * | {@linkcode MultiMap.prototype.forEach} | Linear in the total value count | + * | {@linkcode MultiMap.prototype.toMap} | Linear in the total value count | + * | {@linkcode MultiMap.groupBy} | Linear in the number of items | + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @typeParam K The type of the keys in the map. + * @typeParam V The type of the values in the map. + * + * @example Usage + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]]); + * + * assertEquals(map.get("a"), [1, 2]); + * assertEquals(map.get("b"), [3]); + * ``` + * + * @example Preserves insertion order and duplicates + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap(); + * map.add("a", 2).add("a", 1).add("a", 2); + * + * assertEquals(map.get("a"), [2, 1, 2]); + * assertEquals(map.size, 1); + * ``` + */ +export class MultiMap implements Iterable<[K, V]> { + #map = new Map(); + + /** + * Creates a new instance. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param entries An iterable of key-value pairs for the initial entries. + * Duplicate values for the same key are preserved in insertion order. + * + * @example Creating an empty map + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap(); + * assertEquals(map.size, 0); + * ``` + * + * @example Creating a map from an iterable + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]]); + * assertEquals(map.get("a"), [1, 2]); + * ``` + */ + constructor(entries?: Iterable | null) { + if (entries) { + for (const [key, value] of entries) { + this.add(key, value); + } + } + } + + /** + * The number of distinct keys in the map. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @returns The number of distinct keys in the map. + * + * @example Usage + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]]); + * assertEquals(map.size, 2); + * ``` + */ + get size(): number { + return this.#map.size; + } + + /** + * Appends a value to the list stored under the given key. Duplicate values + * are preserved. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param key The key to add the value under. + * @param value The value to append. + * @returns The instance. + * + * @example Usage + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap(); + * map.add("a", 1).add("a", 2); + * + * assertEquals(map.get("a"), [1, 2]); + * ``` + */ + add(key: K, value: V): this { + let list = this.#map.get(key); + if (!list) { + list = []; + this.#map.set(key, list); + } + list.push(value); + return this; + } + + /** + * Returns a snapshot of the values associated with the given key, in + * insertion order, or `undefined` if the key does not exist. + * + * The returned array is a fresh copy; mutating it does not affect the map, + * and later mutations to the map are not reflected in it. For read-only + * traversal across all keys, prefer {@linkcode MultiMap.prototype.groups} + * to avoid the per-call copy. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param key The key to look up. + * @returns A fresh array of values in insertion order, or `undefined`. + * + * @example Usage + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap([["a", 1], ["a", 2]]); + * + * assertEquals(map.get("a"), [1, 2]); + * assertEquals(map.get("b"), undefined); + * ``` + */ + get(key: K): V[] | undefined { + const list = this.#map.get(key); + return list === undefined ? undefined : list.slice(); + } + + /** + * Returns `true` if the key exists with at least one value. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param key The key to check. + * @returns `true` if the key exists, `false` otherwise. + * + * @example Usage + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap([["a", 1]]); + * + * assertEquals(map.has("a"), true); + * assertEquals(map.has("b"), false); + * ``` + */ + has(key: K): boolean { + return this.#map.has(key); + } + + /** + * Returns `true` if the `[key, value]` entry exists in the map (i.e. the + * given value appears at least once under the given key). + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param key The key to look up. + * @param value The value to check. + * @returns `true` if the entry exists, `false` otherwise. + * + * @example Usage + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap([["a", 1], ["a", 2]]); + * + * assertEquals(map.hasEntry("a", 1), true); + * assertEquals(map.hasEntry("a", 3), false); + * assertEquals(map.hasEntry("b", 1), false); + * ``` + */ + hasEntry(key: K, value: V): boolean { + return this.#map.get(key)?.includes(value) ?? false; + } + + /** + * Removes all values for the given key. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param key The key to remove. + * @returns `true` if the key existed and was removed, `false` otherwise. + * + * @example Usage + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]]); + * assertEquals(map.delete("a"), true); + * assertEquals(map.delete("a"), false); + * + * assertEquals(map.has("a"), false); + * assertEquals(map.size, 1); + * ``` + */ + delete(key: K): boolean { + return this.#map.delete(key); + } + + /** + * Removes the first occurrence of the `[key, value]` entry from the map. + * If the key's list becomes empty, the key is also removed. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param key The key to look up. + * @param value The value to remove. + * @returns `true` if an entry was removed, `false` otherwise. + * + * @example Usage + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap([["a", 1], ["a", 2], ["a", 1]]); + * assertEquals(map.deleteEntry("a", 1), true); + * + * assertEquals(map.get("a"), [2, 1]); + * ``` + */ + deleteEntry(key: K, value: V): boolean { + const list = this.#map.get(key); + if (!list) return false; + // SameValueZero, matching `hasEntry()` / `Map` / `Set` semantics so that + // `NaN` values can be removed. `Array.prototype.indexOf` uses strict + // equality and would never match `NaN`. + let index = -1; + for (let i = 0; i < list.length; i++) { + const v = list[i]!; + if (v === value || (v !== v && value !== value)) { + index = i; + break; + } + } + if (index === -1) return false; + list.splice(index, 1); + if (list.length === 0) this.#map.delete(key); + return true; + } + + /** + * Removes all entries. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @example Usage + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap([["a", 1], ["b", 2]]); + * map.clear(); + * + * assertEquals(map.size, 0); + * ``` + */ + clear(): void { + this.#map.clear(); + } + + /** + * Executes a provided function once for each individual value in the map, + * in insertion order of keys and values. + * + * Within a bucket, the set of values to visit is fixed at the time the + * bucket is entered, so a callback that mutates the current key's list + * (via `add()` or `deleteEntry()`) will not extend or shift the visit. + * Cross-bucket mutations during iteration (adding or deleting other keys) + * are not supported and may skip or repeat keys. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @typeParam T The type of `this` when calling the callback. + * @param callbackfn The function to call for each value. + * @param thisArg Value to use as `this` when executing `callbackfn`. + * + * @example Usage + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]]); + * const result: [string, number][] = []; + * map.forEach((value, key) => result.push([key, value])); + * + * assertEquals(result, [["a", 1], ["a", 2], ["b", 3]]); + * ``` + * + * @example With `thisArg` + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap([["a", 1], ["b", 2]]); + * const context = { prefix: "x:" }; + * const result: string[] = []; + * map.forEach(function (value, key) { + * result.push(`${this.prefix}${key}=${value}`); + * }, context); + * + * assertEquals(result, ["x:a=1", "x:b=2"]); + * ``` + */ + forEach( + callbackfn: (this: T, value: V, key: K, map: this) => void, + thisArg?: T, + ): void { + if (typeof callbackfn !== "function") { + throw new TypeError( + `Cannot call MultiMap.prototype.forEach: "callbackfn" is not a function: received ${typeof callbackfn}`, + ); + } + // Split on thisArg to avoid paying the .call() binding cost per value in + // the common case where no thisArg is passed. The bucket is snapshotted + // before the inner loop so mutations to the current key's list (e.g. a + // callback calling `add()` or splicing via `deleteEntry()`) do not + // extend, truncate, or shift the visit. This mirrors the per-bucket + // contract of `Map.prototype.forEach`. + if (thisArg === undefined) { + const fn = callbackfn as (v: V, k: K, m: this) => void; + for (const [key, list] of this.#map) { + const snapshot = list.slice(); + for (let i = 0; i < snapshot.length; i++) { + fn(snapshot[i]!, key, this); + } + } + } else { + for (const [key, list] of this.#map) { + const snapshot = list.slice(); + for (let i = 0; i < snapshot.length; i++) { + callbackfn.call(thisArg, snapshot[i]!, key, this); + } + } + } + } + + /** + * Returns an iterator of all `[key, value]` pairs, with each value yielded + * individually across all keys, in insertion order. + * + * Mutating the map during iteration is not supported and may skip or repeat + * entries. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @returns An iterator of `[key, value]` pairs. + * + * @example Usage + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]]); + * + * assertEquals(Array.from(map.entries()), [["a", 1], ["a", 2], ["b", 3]]); + * ``` + */ + entries(): IterableIterator<[K, V]> { + // Hand-rolled iterator rather than a generator: avoids per-yield + // generator-frame overhead. ~5x faster on full iteration. The bucket is + // snapshotted on first entry so mutations to the current bucket during + // iteration (including `delete()` of the current key) do not extend, + // truncate, or shift the visit. + const outer = this.#map[Symbol.iterator](); + let currentKey!: K; + let currentList: V[] | null = null; + let innerIndex = 0; + const iter: IterableIterator<[K, V]> = { + next(): IteratorResult<[K, V]> { + while (true) { + if (currentList !== null && innerIndex < currentList.length) { + return { + value: [currentKey, currentList[innerIndex++]!], + done: false, + }; + } + const outerResult = outer.next(); + if (outerResult.done) { + currentList = null; + return { value: undefined, done: true }; + } + currentKey = outerResult.value[0]; + currentList = outerResult.value[1].slice(); + innerIndex = 0; + } + }, + [Symbol.iterator]() { + return this; + }, + }; + return iter; + } + + /** + * Returns an iterator of `[key, values]` pairs, where `values` is the list + * of values associated with that key in insertion order. + * + * Use this when you need both the key and its full value list, for example + * to filter by bucket size. For individual `[key, value]` pairs, use + * {@linkcode MultiMap.prototype.entries}. + * + * Each yielded array is a fresh snapshot; mutating it does not affect the + * map, and later mutations to the map are not reflected in it. Mutating the + * map during iteration is not supported and may skip or repeat buckets. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @returns An iterator of `[key, values]` pairs. + * + * @example Usage + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]]); + * + * assertEquals( + * Array.from(map.groups(), ([k, vs]) => [k, [...vs]]), + * [["a", [1, 2]], ["b", [3]]], + * ); + * ``` + * + * @example Filter by bucket size + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]]); + * const collisions: string[] = []; + * for (const [key, values] of map.groups()) { + * if (values.length > 1) collisions.push(key); + * } + * + * assertEquals(collisions, ["a"]); + * ``` + */ + *groups(): IterableIterator<[K, V[]]> { + for (const [key, list] of this.#map) { + yield [key, list.slice()]; + } + } + + /** + * Returns an iterator of all distinct keys in the map, in insertion order. + * + * Mutating the map during iteration is not supported and may skip or repeat + * keys. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @returns An iterator of keys. + * + * @example Usage + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]]); + * + * assertEquals(Array.from(map.keys()), ["a", "b"]); + * ``` + */ + keys(): MapIterator { + return this.#map.keys(); + } + + /** + * Returns an iterator of all individual values across all keys, in insertion + * order. + * + * Mutating the map during iteration is not supported and may skip or repeat + * values. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @returns An iterator of values. + * + * @example Usage + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]]); + * + * assertEquals(Array.from(map.values()), [1, 2, 3]); + * ``` + */ + values(): IterableIterator { + // Hand-rolled iterator rather than a generator: avoids per-yield + // generator-frame overhead and the yield* delegation cost. The bucket is + // snapshotted on first entry so mutations to the current bucket during + // iteration do not extend, truncate, or shift the visit. + const outer = this.#map.values(); + let currentList: V[] | null = null; + let innerIndex = 0; + const iter: IterableIterator = { + next(): IteratorResult { + while (true) { + if (currentList !== null && innerIndex < currentList.length) { + return { value: currentList[innerIndex++]!, done: false }; + } + const outerResult = outer.next(); + if (outerResult.done) { + currentList = null; + return { value: undefined, done: true }; + } + currentList = outerResult.value.slice(); + innerIndex = 0; + } + }, + [Symbol.iterator]() { + return this; + }, + }; + return iter; + } + + /** + * Returns a new {@linkcode Map} snapshot of the multimap, with each key + * mapped to a fresh array of its values in insertion order. + * + * The returned map and its value arrays are owned by the caller; mutating + * them does not affect the multimap, and later mutations to the multimap + * are not reflected in the snapshot. This is the natural inverse of + * {@linkcode MultiMap.groupBy} and the `Map.groupBy` builtin. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @returns A new `Map` snapshot. + * + * @example Usage + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]]); + * + * assertEquals(map.toMap(), new Map([["a", [1, 2]], ["b", [3]]])); + * ``` + */ + toMap(): Map { + const result = new Map(); + for (const [key, list] of this.#map) { + result.set(key, list.slice()); + } + return result; + } + + /** + * Returns an iterator of all `[key, value]` pairs, with each value yielded + * individually. The map is not modified. Same as + * {@linkcode MultiMap.prototype.entries}. + * + * Mutating the map while iterating is not supported and may skip or repeat + * entries. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @returns An iterator of `[key, value]` pairs. + * + * @example Usage + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]]); + * + * assertEquals(Array.from(map), [["a", 1], ["a", 2], ["b", 3]]); + * ``` + */ + [Symbol.iterator](): IterableIterator<[K, V]> { + return this.entries(); + } + + /** + * A string tag for the class, used by `Object.prototype.toString()`. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @example Usage + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap(); + * assertEquals(map[Symbol.toStringTag], "MultiMap"); + * ``` + */ + readonly [Symbol.toStringTag] = "MultiMap" as const; + + /** + * Groups items from an iterable by the result of `keyFn`, returning a new + * multimap. Mirrors the shape of + * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/groupBy | `Map.groupBy`}, + * but returns a {@linkcode MultiMap} so further mutation is supported. + * + * Item order is preserved within each bucket. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @typeParam K The type of the keys produced by `keyFn`. + * @typeParam T The type of the items in `items`. + * @param items The items to group. + * @param keyFn A function called for each item with the item and its + * zero-based index; its return value is the bucket key. + * @returns A new `MultiMap` of grouped items. + * + * @example Usage + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const users = [ + * { name: "Ada", role: "admin" }, + * { name: "Bo", role: "user" }, + * { name: "Cy", role: "admin" }, + * ]; + * + * const byRole = MultiMap.groupBy(users, (u) => u.role); + * + * assertEquals(byRole.get("admin"), [ + * { name: "Ada", role: "admin" }, + * { name: "Cy", role: "admin" }, + * ]); + * assertEquals(byRole.get("user"), [{ name: "Bo", role: "user" }]); + * ``` + * + * @example The key function receives the item index + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const grouped = MultiMap.groupBy( + * ["a", "b", "c", "d"], + * (_, i) => (i % 2 === 0 ? "even" : "odd"), + * ); + * + * assertEquals(grouped.get("even"), ["a", "c"]); + * assertEquals(grouped.get("odd"), ["b", "d"]); + * ``` + */ + static groupBy( + items: Iterable, + keyFn: (item: T, index: number) => K, + ): MultiMap { + if (typeof keyFn !== "function") { + throw new TypeError( + `Cannot call MultiMap.groupBy: "keyFn" is not a function: received ${typeof keyFn}`, + ); + } + const map = new MultiMap(); + let index = 0; + for (const item of items) { + map.add(keyFn(item, index++), item); + } + return map; + } +} diff --git a/data_structures/unstable_multimap_test.ts b/data_structures/unstable_multimap_test.ts new file mode 100644 index 000000000000..343f28b02a0f --- /dev/null +++ b/data_structures/unstable_multimap_test.ts @@ -0,0 +1,582 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +import { assertEquals, assertNotStrictEquals, assertThrows } from "@std/assert"; +import { MultiMap } from "./unstable_multimap.ts"; + +Deno.test("MultiMap.add() adds a value under a new key", () => { + const map = new MultiMap(); + map.add("a", 1); + + assertEquals(map.get("a"), [1]); + assertEquals(map.size, 1); +}); + +Deno.test("MultiMap.add() appends multiple values under the same key", () => { + const map = new MultiMap(); + map.add("a", 1).add("a", 2); + + assertEquals(map.get("a"), [1, 2]); + assertEquals(map.size, 1); +}); + +Deno.test("MultiMap.add() preserves duplicate values for the same key", () => { + const map = new MultiMap(); + map.add("a", 1).add("a", 1).add("a", 2); + + assertEquals(map.get("a"), [1, 1, 2]); +}); + +Deno.test("MultiMap.add() preserves insertion order across keys", () => { + const map = new MultiMap(); + map.add("b", 1).add("a", 2).add("b", 3).add("a", 4); + + assertEquals(Array.from(map.keys()), ["b", "a"]); + assertEquals(map.get("b"), [1, 3]); + assertEquals(map.get("a"), [2, 4]); +}); + +Deno.test("MultiMap.add() returns the instance for chaining", () => { + const map = new MultiMap(); + const result = map.add("a", 1); + + assertEquals(result, map); +}); + +Deno.test("MultiMap.get() returns the list of values for an existing key", () => { + const map = new MultiMap([["a", 1], ["a", 2]] as const); + + assertEquals(map.get("a"), [1, 2]); +}); + +Deno.test("MultiMap.get() returns undefined for a missing key", () => { + const map = new MultiMap(); + + assertEquals(map.get("a"), undefined); +}); + +Deno.test("MultiMap.get() returns a defensive snapshot", () => { + const map = new MultiMap([["a", 1], ["a", 2]]); + const first = map.get("a")!; + first.push(999); + + assertEquals(map.get("a"), [1, 2]); + + const second = map.get("a")!; + assertNotStrictEquals(first, second); + + map.add("a", 3); + assertEquals(second, [1, 2]); +}); + +Deno.test("MultiMap.has() returns true when key exists", () => { + const map = new MultiMap([["a", 1]] as const); + + assertEquals(map.has("a"), true); +}); + +Deno.test("MultiMap.has() returns false when key does not exist", () => { + const map = new MultiMap(); + + assertEquals(map.has("a"), false); +}); + +Deno.test("MultiMap.hasEntry() returns true when entry exists", () => { + const map = new MultiMap([["a", 1], ["a", 2]] as const); + + assertEquals(map.hasEntry("a", 1), true); + assertEquals(map.hasEntry("a", 2), true); +}); + +Deno.test( + "MultiMap.hasEntry() returns false when value is absent under key", + () => { + const map = new MultiMap([["a", 1]]); + + assertEquals(map.hasEntry("a", 2), false); + }, +); + +Deno.test( + "MultiMap.hasEntry() returns false when key does not exist", + () => { + const map = new MultiMap(); + + assertEquals(map.hasEntry("b", 1), false); + }, +); + +Deno.test("MultiMap.delete() removes all values for a key", () => { + const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]] as const); + assertEquals(map.delete("a"), true); + + assertEquals(map.has("a"), false); + assertEquals(map.size, 1); +}); + +Deno.test("MultiMap.delete() returns true when the key existed", () => { + const map = new MultiMap([["a", 1]] as const); + + assertEquals(map.delete("a"), true); +}); + +Deno.test("MultiMap.delete() returns false when the key does not exist", () => { + const map = new MultiMap(); + + assertEquals(map.delete("a"), false); +}); + +Deno.test( + "MultiMap.deleteEntry() removes a single entry from a key", + () => { + const map = new MultiMap([["a", 1], ["a", 2]] as const); + assertEquals(map.deleteEntry("a", 1), true); + + assertEquals(map.get("a"), [2]); + assertEquals(map.has("a"), true); + }, +); + +Deno.test( + "MultiMap.deleteEntry() removes only the first occurrence", + () => { + const map = new MultiMap([["a", 1], ["a", 2], ["a", 1]] as const); + assertEquals(map.deleteEntry("a", 1), true); + + assertEquals(map.get("a"), [2, 1]); + }, +); + +Deno.test( + "MultiMap.deleteEntry() removes the key when the last value is removed", + () => { + const map = new MultiMap([["a", 1]] as const); + assertEquals(map.deleteEntry("a", 1), true); + + assertEquals(map.has("a"), false); + assertEquals(map.size, 0); + }, +); + +Deno.test( + "MultiMap.deleteEntry() returns false when the entry does not exist", + () => { + const map = new MultiMap([["a", 1]]); + + assertEquals(map.deleteEntry("a", 2), false); + assertEquals(map.deleteEntry("b", 1), false); + }, +); + +Deno.test( + "MultiMap.deleteEntry() removes NaN using SameValueZero, matching hasEntry()", + () => { + const map = new MultiMap(); + map.add("a", NaN); + + assertEquals(map.hasEntry("a", NaN), true); + assertEquals(map.deleteEntry("a", NaN), true); + assertEquals(map.hasEntry("a", NaN), false); + assertEquals(map.has("a"), false); + }, +); + +Deno.test( + "MultiMap.deleteEntry() treats +0 and -0 as equal (SameValueZero)", + () => { + const map = new MultiMap(); + map.add("a", -0); + + assertEquals(map.hasEntry("a", +0), true); + assertEquals(map.deleteEntry("a", +0), true); + assertEquals(map.has("a"), false); + }, +); + +Deno.test("MultiMap.clear() removes all entries", () => { + const map = new MultiMap([["a", 1], ["b", 2]] as const); + map.clear(); + + assertEquals(map.size, 0); + assertEquals(map.has("a"), false); + assertEquals(map.has("b"), false); +}); + +Deno.test("MultiMap.size counts distinct keys, not total values", () => { + const map = new MultiMap([["a", 1], ["a", 2], ["a", 3], ["b", 4]] as const); + + assertEquals(map.size, 2); +}); + +Deno.test("MultiMap.keys() yields each key once in insertion order", () => { + const map = new MultiMap([["b", 1], ["a", 2], ["b", 3]] as const); + + assertEquals(Array.from(map.keys()), ["b", "a"]); +}); + +Deno.test( + "MultiMap.values() yields all individual values across all keys, including cross-key duplicates", + () => { + const map = new MultiMap([["a", 1], ["a", 2], ["b", 1]] as const); + + assertEquals(Array.from(map.values()), [1, 2, 1]); + }, +); + +Deno.test("MultiMap.entries() yields flattened [key, value] pairs", () => { + const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]] as const); + + assertEquals(Array.from(map.entries()), [["a", 1], ["a", 2], ["b", 3]]); +}); + +Deno.test( + "MultiMap.entries() does not observe mutations to the current bucket", + () => { + const map = new MultiMap([["a", 1]]); + const it = map.entries(); + const first = it.next(); + map.add("a", 42); + const second = it.next(); + + assertEquals(first.value, ["a", 1]); + assertEquals(second.done, true); + }, +); + +Deno.test( + "MultiMap.entries() keeps yielding values from the current bucket after delete()", + () => { + const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]]); + const it = map.entries(); + const first = it.next(); + map.delete("a"); + const rest: [string, number][] = []; + for (;;) { + const { value, done } = it.next(); + if (done) break; + rest.push(value as [string, number]); + } + + assertEquals(first.value, ["a", 1]); + assertEquals(rest, [["a", 2], ["b", 3]]); + assertEquals(map.has("a"), false); + }, +); + +Deno.test( + "MultiMap.values() does not observe mutations to the current bucket", + () => { + const map = new MultiMap([["a", 1]]); + const it = map.values(); + const first = it.next(); + map.add("a", 42); + const second = it.next(); + + assertEquals(first.value, 1); + assertEquals(second.done, true); + }, +); + +Deno.test("MultiMap.entries() is non-destructive", () => { + const map = new MultiMap([["a", 1], ["b", 2]] as const); + Array.from(map.entries()); + + assertEquals(map.size, 2); + assertEquals(Array.from(map.entries()), [["a", 1], ["b", 2]]); +}); + +Deno.test( + "MultiMap[Symbol.iterator]() yields the same pairs as entries()", + () => { + const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]] as const); + + assertEquals(Array.from(map[Symbol.iterator]()), [ + ["a", 1], + ["a", 2], + ["b", 3], + ]); + }, +); + +Deno.test( + "MultiMap is iterable via for..of", + () => { + const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]] as const); + const result: [string, number][] = []; + for (const pair of map) result.push(pair); + + assertEquals(result, [["a", 1], ["a", 2], ["b", 3]]); + }, +); + +Deno.test( + "MultiMap[Symbol.iterator]() is non-destructive", + () => { + const map = new MultiMap([["a", 1], ["b", 2]] as const); + Array.from(map); + + assertEquals(map.size, 2); + assertEquals(Array.from(map), [["a", 1], ["b", 2]]); + }, +); + +Deno.test("MultiMap.forEach() iterates individual (value, key, map) triples", () => { + const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]] as const); + const result: [string, number, MultiMap][] = []; + map.forEach((value, key, m) => result.push([key, value, m])); + + assertEquals(result, [ + ["a", 1, map], + ["a", 2, map], + ["b", 3, map], + ]); +}); + +Deno.test( + "MultiMap.forEach() throws TypeError when callbackfn is not a function", + () => { + const empty = new MultiMap(); + assertThrows( + // deno-lint-ignore no-explicit-any + () => empty.forEach(null as any), + TypeError, + `Cannot call MultiMap.prototype.forEach: "callbackfn" is not a function`, + ); + + const populated = new MultiMap([["a", 1]] as const); + assertThrows( + // deno-lint-ignore no-explicit-any + () => populated.forEach(null as any), + TypeError, + `Cannot call MultiMap.prototype.forEach: "callbackfn" is not a function`, + ); + }, +); + +Deno.test( + "MultiMap.forEach() does not visit values appended to the current bucket", + () => { + const map = new MultiMap([["a", 1]]); + const seen: number[] = []; + map.forEach((value, key) => { + seen.push(value); + if (value === 1) map.add(key, 99); + }); + + assertEquals(seen, [1]); + assertEquals(map.get("a"), [1, 99]); + }, +); + +Deno.test( + "MultiMap.forEach() does not shift the visit when the current bucket is spliced", + () => { + const map = new MultiMap([["a", 1], ["a", 2], ["a", 3]]); + const seen: number[] = []; + map.forEach((value, key) => { + seen.push(value); + if (value === 1) map.deleteEntry(key, 2); + }); + + assertEquals(seen, [1, 2, 3]); + assertEquals(map.get("a"), [1, 3]); + }, +); + +Deno.test("MultiMap.forEach() binds thisArg when provided", () => { + const map = new MultiMap([["a", 1], ["b", 2]] as const); + const context = { prefix: "x:" }; + const result: string[] = []; + map.forEach(function (value, key) { + result.push(`${this.prefix}${key}=${value}`); + }, context); + + assertEquals(result, ["x:a=1", "x:b=2"]); +}); + +Deno.test( + "MultiMap.groups() yields [key, values] pairs in insertion order", + () => { + const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]] as const); + + assertEquals( + Array.from(map.groups(), ([k, vs]) => [k, [...vs]]), + [["a", [1, 2]], ["b", [3]]], + ); + }, +); + +Deno.test( + "MultiMap.groups() exposes bucket length", + () => { + const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]] as const); + const collisions: string[] = []; + for (const [key, values] of map.groups()) { + if (values.length > 1) collisions.push(key); + } + + assertEquals(collisions, ["a"]); + }, +); + +Deno.test("MultiMap.groups() is non-destructive", () => { + const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]] as const); + Array.from(map.groups()); + + assertEquals(map.size, 2); + assertEquals(map.get("a"), [1, 2]); + assertEquals(map.get("b"), [3]); +}); + +Deno.test( + "MultiMap.groups() yields defensive snapshots that do not alias internal state", + () => { + const map = new MultiMap([["a", 1], ["a", 2]]); + for (const [, values] of map.groups()) { + values.length = 0; + values.push(-1); + } + + assertEquals(map.size, 1); + assertEquals(map.has("a"), true); + assertEquals(map.get("a"), [1, 2]); + assertEquals(map.hasEntry("a", 1), true); + }, +); + +Deno.test( + "MultiMap constructor accepts an iterable and preserves duplicates", + () => { + const map = new MultiMap([["a", 1], ["a", 1], ["a", 2], ["b", 3]] as const); + + assertEquals(map.size, 2); + assertEquals(map.get("a"), [1, 1, 2]); + assertEquals(map.get("b"), [3]); + }, +); + +Deno.test("MultiMap constructor accepts null", () => { + const map = new MultiMap(null); + + assertEquals(map.size, 0); +}); + +Deno.test("MultiMap.toMap() returns a Map of arrays in insertion order", () => { + const map = new MultiMap([["b", 1], ["a", 2], ["b", 3]] as const); + + assertEquals(map.toMap(), new Map([["b", [1, 3]], ["a", [2]]])); + assertEquals(Array.from(map.toMap().keys()), ["b", "a"]); +}); + +Deno.test( + "MultiMap.toMap() returns fresh arrays that do not alias internal state", + () => { + const map = new MultiMap([["a", 1]]); + const snapshot = map.toMap(); + snapshot.get("a")!.push(999); + snapshot.set("b", [2]); + + assertEquals(map.get("a"), [1]); + assertEquals(map.has("b"), false); + + map.add("a", 3); + assertEquals(snapshot.get("a"), [1, 999]); + }, +); + +Deno.test("MultiMap.toMap() on an empty multimap returns an empty Map", () => { + const map = new MultiMap(); + + assertEquals(map.toMap(), new Map()); +}); + +Deno.test("MultiMap.groupBy() buckets items by keyFn result", () => { + const users = [ + { name: "Ada", role: "admin" }, + { name: "Bo", role: "user" }, + { name: "Cy", role: "admin" }, + ]; + + const byRole = MultiMap.groupBy(users, (u) => u.role); + + assertEquals(byRole.size, 2); + assertEquals(byRole.get("admin"), [ + { name: "Ada", role: "admin" }, + { name: "Cy", role: "admin" }, + ]); + assertEquals(byRole.get("user"), [{ name: "Bo", role: "user" }]); +}); + +Deno.test("MultiMap.groupBy() preserves encounter order within buckets", () => { + const grouped = MultiMap.groupBy( + [3, 1, 4, 1, 5, 9, 2, 6], + (n) => (n % 2 === 0 ? "even" : "odd"), + ); + + assertEquals(grouped.get("even"), [4, 2, 6]); + assertEquals(grouped.get("odd"), [3, 1, 1, 5, 9]); +}); + +Deno.test("MultiMap.groupBy() passes the zero-based index to keyFn", () => { + const indices: number[] = []; + MultiMap.groupBy(["a", "b", "c"], (_, i) => { + indices.push(i); + return i; + }); + + assertEquals(indices, [0, 1, 2]); +}); + +Deno.test("MultiMap.groupBy() accepts an arbitrary iterable", () => { + function* gen() { + yield 1; + yield 2; + yield 3; + } + + const grouped = MultiMap.groupBy(gen(), (n) => n % 2 === 0 ? "even" : "odd"); + + assertEquals(grouped.get("even"), [2]); + assertEquals(grouped.get("odd"), [1, 3]); +}); + +Deno.test( + "MultiMap.groupBy() throws TypeError when keyFn is not a function", + () => { + assertThrows( + // deno-lint-ignore no-explicit-any + () => MultiMap.groupBy([], null as any), + TypeError, + `Cannot call MultiMap.groupBy: "keyFn" is not a function`, + ); + + assertThrows( + // deno-lint-ignore no-explicit-any + () => MultiMap.groupBy([1, 2, 3], null as any), + TypeError, + `Cannot call MultiMap.groupBy: "keyFn" is not a function`, + ); + }, +); + +Deno.test("MultiMap.groupBy() on an empty iterable returns an empty multimap", () => { + const grouped = MultiMap.groupBy([], () => "k"); + + assertEquals(grouped.size, 0); +}); + +Deno.test("MultiMap.groupBy() and toMap() round-trip", () => { + const items = [1, 2, 3, 4, 5, 6]; + const key = (n: number) => n % 3; + + const grouped = MultiMap.groupBy(items, key); + + assertEquals( + grouped.toMap(), + new Map([[1, [1, 4]], [2, [2, 5]], [0, [3, 6]]]), + ); +}); + +Deno.test("MultiMap[Symbol.toStringTag] is 'MultiMap'", () => { + const map = new MultiMap(); + + assertEquals(map[Symbol.toStringTag], "MultiMap"); +}); From 8c743eeb8159c8b1719b2d30c6905225aa818b7d Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Mon, 20 Apr 2026 11:38:48 +0200 Subject: [PATCH 5/7] fix --- data_structures/unstable_multimap.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data_structures/unstable_multimap.ts b/data_structures/unstable_multimap.ts index b9cea81f7d93..a95351168122 100644 --- a/data_structures/unstable_multimap.ts +++ b/data_structures/unstable_multimap.ts @@ -510,7 +510,7 @@ export class MultiMap implements Iterable<[K, V]> { * assertEquals(Array.from(map.keys()), ["a", "b"]); * ``` */ - keys(): MapIterator { + keys(): IterableIterator { return this.#map.keys(); } From 571f1010e919ce562ab08ec7ded0dbfbe43906a9 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Mon, 20 Apr 2026 13:19:27 +0200 Subject: [PATCH 6/7] improve docs --- data_structures/unstable_multimap.ts | 29 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/data_structures/unstable_multimap.ts b/data_structures/unstable_multimap.ts index a95351168122..fb12d835bde4 100644 --- a/data_structures/unstable_multimap.ts +++ b/data_structures/unstable_multimap.ts @@ -263,9 +263,12 @@ export class MultiMap implements Iterable<[K, V]> { * import { assertEquals } from "@std/assert"; * * const map = new MultiMap([["a", 1], ["a", 2], ["a", 1]]); - * assertEquals(map.deleteEntry("a", 1), true); * + * assertEquals(map.deleteEntry("a", 1), true); * assertEquals(map.get("a"), [2, 1]); + * + * assertEquals(map.deleteEntry("a", 1), true); + * assertEquals(map.get("a"), [2]); * ``` */ deleteEntry(key: K, value: V): boolean { @@ -341,14 +344,13 @@ export class MultiMap implements Iterable<[K, V]> { * import { MultiMap } from "@std/data-structures/unstable-multimap"; * import { assertEquals } from "@std/assert"; * - * const map = new MultiMap([["a", 1], ["b", 2]]); - * const context = { prefix: "x:" }; - * const result: string[] = []; - * map.forEach(function (value, key) { - * result.push(`${this.prefix}${key}=${value}`); - * }, context); + * const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]]); + * const counter = { total: 0 }; + * map.forEach(function (value) { + * this.total += value; + * }, counter); * - * assertEquals(result, ["x:a=1", "x:b=2"]); + * assertEquals(counter.total, 6); * ``` */ forEach( @@ -464,10 +466,7 @@ export class MultiMap implements Iterable<[K, V]> { * * const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]]); * - * assertEquals( - * Array.from(map.groups(), ([k, vs]) => [k, [...vs]]), - * [["a", [1, 2]], ["b", [3]]], - * ); + * assertEquals(Array.from(map.groups()), [["a", [1, 2]], ["b", [3]]]); * ``` * * @example Filter by bucket size @@ -682,11 +681,11 @@ export class MultiMap implements Iterable<[K, V]> { * * const grouped = MultiMap.groupBy( * ["a", "b", "c", "d"], - * (_, i) => (i % 2 === 0 ? "even" : "odd"), + * (item, index) => index < 2 ? "first-half" : "second-half", * ); * - * assertEquals(grouped.get("even"), ["a", "c"]); - * assertEquals(grouped.get("odd"), ["b", "d"]); + * assertEquals(grouped.get("first-half"), ["a", "b"]); + * assertEquals(grouped.get("second-half"), ["c", "d"]); * ``` */ static groupBy( From 955f2ddb6bba6d029e7d9cc5913c10273fee0202 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Wed, 22 Apr 2026 10:23:12 +0200 Subject: [PATCH 7/7] feedback --- data_structures/unstable_multimap.ts | 176 ++++++++++++++++------ data_structures/unstable_multimap_test.ts | 82 ++++++++++ 2 files changed, 212 insertions(+), 46 deletions(-) diff --git a/data_structures/unstable_multimap.ts b/data_structures/unstable_multimap.ts index fb12d835bde4..4c9ada9f3012 100644 --- a/data_structures/unstable_multimap.ts +++ b/data_structures/unstable_multimap.ts @@ -14,18 +14,20 @@ * lazily; fully draining them is linear in the total value count (or the * number of distinct keys, for {@linkcode MultiMap.prototype.keys}). * - * | Method | Per-call time complexity | - * | ----------------------------------------------------------- | ------------------------------- | - * | {@linkcode MultiMap.prototype.add} | Amortized constant | - * | {@linkcode MultiMap.prototype.get} | Linear in the bucket size | - * | {@linkcode MultiMap.prototype.has} | Constant | - * | {@linkcode MultiMap.prototype.hasEntry} | Linear in the bucket size | - * | {@linkcode MultiMap.prototype.delete} | Constant | - * | {@linkcode MultiMap.prototype.deleteEntry} | Linear in the bucket size | - * | {@linkcode MultiMap.prototype.clear} | Linear in the key count | - * | {@linkcode MultiMap.prototype.forEach} | Linear in the total value count | - * | {@linkcode MultiMap.prototype.toMap} | Linear in the total value count | - * | {@linkcode MultiMap.groupBy} | Linear in the number of items | + * | Method | Per-call time complexity | + * | ----------------------------------------------------------- | -------------------------------- | + * | {@linkcode MultiMap.prototype.size} | Constant | + * | {@linkcode MultiMap.prototype.valueCount} | Constant | + * | {@linkcode MultiMap.prototype.add} | Amortized constant | + * | {@linkcode MultiMap.prototype.get} | Linear in the bucket size (copy) | + * | {@linkcode MultiMap.prototype.has} | Constant | + * | {@linkcode MultiMap.prototype.hasEntry} | Linear in the bucket size | + * | {@linkcode MultiMap.prototype.delete} | Constant | + * | {@linkcode MultiMap.prototype.deleteEntry} | Linear in the bucket size | + * | {@linkcode MultiMap.prototype.clear} | Linear in the key count | + * | {@linkcode MultiMap.prototype.forEach} | Linear in the total value count | + * | {@linkcode MultiMap.prototype.toMap} | Linear in the total value count | + * | {@linkcode MultiMap.groupBy} | Linear in the number of items | * * @experimental **UNSTABLE**: New API, yet to be vetted. * @@ -57,6 +59,7 @@ */ export class MultiMap implements Iterable<[K, V]> { #map = new Map(); + #valueCount = 0; /** * Creates a new instance. @@ -112,6 +115,38 @@ export class MultiMap implements Iterable<[K, V]> { return this.#map.size; } + /** + * The total number of values stored across all keys, counting duplicates. + * + * In contrast to {@linkcode MultiMap.prototype.size} — which reports the + * number of distinct keys, matching the `Map` convention — this counts + * every individual value. Maintained in O(1): `add()` increments it, + * `delete()` and `deleteEntry()` decrement it, and `clear()` resets it. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @returns The total number of values across all keys. + * + * @example Usage + * ```ts + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * import { assertEquals } from "@std/assert"; + * + * const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]]); + * assertEquals(map.size, 2); + * assertEquals(map.valueCount, 3); + * + * map.add("a", 2); + * assertEquals(map.valueCount, 4); + * + * map.deleteEntry("a", 1); + * assertEquals(map.valueCount, 3); + * ``` + */ + get valueCount(): number { + return this.#valueCount; + } + /** * Appends a value to the list stored under the given key. Duplicate values * are preserved. @@ -140,6 +175,7 @@ export class MultiMap implements Iterable<[K, V]> { this.#map.set(key, list); } list.push(value); + this.#valueCount++; return this; } @@ -219,6 +255,9 @@ export class MultiMap implements Iterable<[K, V]> { * ``` */ hasEntry(key: K, value: V): boolean { + // `Array.prototype.includes` uses SameValueZero, which matches the + // hand-rolled loop in `deleteEntry()` (and the `Map`/`Set` contract) so + // `NaN` and `±0` behave consistently across the two methods. return this.#map.get(key)?.includes(value) ?? false; } @@ -244,7 +283,11 @@ export class MultiMap implements Iterable<[K, V]> { * ``` */ delete(key: K): boolean { - return this.#map.delete(key); + const list = this.#map.get(key); + if (list === undefined) return false; + this.#valueCount -= list.length; + this.#map.delete(key); + return true; } /** @@ -287,6 +330,7 @@ export class MultiMap implements Iterable<[K, V]> { } if (index === -1) return false; list.splice(index, 1); + this.#valueCount--; if (list.length === 0) this.#map.delete(key); return true; } @@ -309,6 +353,7 @@ export class MultiMap implements Iterable<[K, V]> { */ clear(): void { this.#map.clear(); + this.#valueCount = 0; } /** @@ -362,26 +407,14 @@ export class MultiMap implements Iterable<[K, V]> { `Cannot call MultiMap.prototype.forEach: "callbackfn" is not a function: received ${typeof callbackfn}`, ); } - // Split on thisArg to avoid paying the .call() binding cost per value in - // the common case where no thisArg is passed. The bucket is snapshotted - // before the inner loop so mutations to the current key's list (e.g. a - // callback calling `add()` or splicing via `deleteEntry()`) do not - // extend, truncate, or shift the visit. This mirrors the per-bucket - // contract of `Map.prototype.forEach`. - if (thisArg === undefined) { - const fn = callbackfn as (v: V, k: K, m: this) => void; - for (const [key, list] of this.#map) { - const snapshot = list.slice(); - for (let i = 0; i < snapshot.length; i++) { - fn(snapshot[i]!, key, this); - } - } - } else { - for (const [key, list] of this.#map) { - const snapshot = list.slice(); - for (let i = 0; i < snapshot.length; i++) { - callbackfn.call(thisArg, snapshot[i]!, key, this); - } + // The bucket is snapshotted before the inner loop so mutations to the + // current key's list (e.g. a callback calling `add()` or splicing via + // `deleteEntry()`) do not extend, truncate, or shift the visit. This + // mirrors the per-bucket contract of `Map.prototype.forEach`. + for (const [key, list] of this.#map) { + const snapshot = list.slice(); + for (let i = 0; i < snapshot.length; i++) { + callbackfn.call(thisArg as T, snapshot[i]!, key, this); } } } @@ -408,11 +441,12 @@ export class MultiMap implements Iterable<[K, V]> { * ``` */ entries(): IterableIterator<[K, V]> { - // Hand-rolled iterator rather than a generator: avoids per-yield - // generator-frame overhead. ~5x faster on full iteration. The bucket is - // snapshotted on first entry so mutations to the current bucket during - // iteration (including `delete()` of the current key) do not extend, - // truncate, or shift the visit. + // Hand-rolled iterator rather than a generator to avoid per-yield + // generator-frame overhead (measured 2–4× advantage on flattened + // iteration). `groups()` and `values()` follow the same pattern for + // consistency. The bucket is snapshotted on first entry so mutations + // to the current bucket during iteration (including `delete()` of the + // current key) do not extend, truncate, or shift the visit. const outer = this.#map[Symbol.iterator](); let currentKey!: K; let currentList: V[] | null = null; @@ -483,10 +517,24 @@ export class MultiMap implements Iterable<[K, V]> { * assertEquals(collisions, ["a"]); * ``` */ - *groups(): IterableIterator<[K, V[]]> { - for (const [key, list] of this.#map) { - yield [key, list.slice()]; - } + groups(): IterableIterator<[K, V[]]> { + // Hand-rolled for consistency with `entries()` / `values()`. See + // `entries()` for the rationale. One yield per bucket (no inner loop). + const outer = this.#map[Symbol.iterator](); + const iter: IterableIterator<[K, V[]]> = { + next(): IteratorResult<[K, V[]]> { + const outerResult = outer.next(); + if (outerResult.done) return { value: undefined, done: true }; + return { + value: [outerResult.value[0], outerResult.value[1].slice()], + done: false, + }; + }, + [Symbol.iterator]() { + return this; + }, + }; + return iter; } /** @@ -510,6 +558,9 @@ export class MultiMap implements Iterable<[K, V]> { * ``` */ keys(): IterableIterator { + // Delegates to the underlying `Map.prototype.keys()` (a native + // `MapIterator`); no per-bucket work is needed, so wrapping it in a + // hand-rolled iterator would be pure overhead. return this.#map.keys(); } @@ -535,10 +586,10 @@ export class MultiMap implements Iterable<[K, V]> { * ``` */ values(): IterableIterator { - // Hand-rolled iterator rather than a generator: avoids per-yield - // generator-frame overhead and the yield* delegation cost. The bucket is - // snapshotted on first entry so mutations to the current bucket during - // iteration do not extend, truncate, or shift the visit. + // Hand-rolled for consistency with `entries()` / `groups()`. See + // `entries()` for the rationale. The bucket is snapshotted on first + // entry so mutations to the current bucket during iteration do not + // extend, truncate, or shift the visit. const outer = this.#map.values(); let currentList: V[] | null = null; let innerIndex = 0; @@ -637,6 +688,39 @@ export class MultiMap implements Iterable<[K, V]> { */ readonly [Symbol.toStringTag] = "MultiMap" as const; + /** + * Custom output for {@linkcode Deno.inspect}, rendering the multimap as + * `MultiMap(size) { key => [values], ... }`. Each key and bucket is + * inspected via the provided `inspect` callback so caller-configured + * `options` (colors, depth, circular detection) are honored. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param inspect Internal inspect function. + * @param options Inspect options forwarded from the host. + * @returns The string representation of the multimap. + * + * @example Usage + * ```ts ignore + * import { MultiMap } from "@std/data-structures/unstable-multimap"; + * + * const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]]); + * console.log(map); + * ``` + */ + [Symbol.for("Deno.customInspect")]( + inspect: (value: unknown, options: unknown) => string, + options: unknown, + ): string { + const size = this.#map.size; + if (size === 0) return `MultiMap(0) {}`; + const parts: string[] = []; + for (const [key, list] of this.#map) { + parts.push(`${inspect(key, options)} => ${inspect(list, options)}`); + } + return `MultiMap(${size}) { ${parts.join(", ")} }`; + } + /** * Groups items from an iterable by the result of `keyFn`, returning a new * multimap. Mirrors the shape of diff --git a/data_structures/unstable_multimap_test.ts b/data_structures/unstable_multimap_test.ts index 343f28b02a0f..65311a47f41c 100644 --- a/data_structures/unstable_multimap_test.ts +++ b/data_structures/unstable_multimap_test.ts @@ -206,6 +206,63 @@ Deno.test("MultiMap.size counts distinct keys, not total values", () => { assertEquals(map.size, 2); }); +Deno.test("MultiMap.valueCount is 0 for an empty map", () => { + const map = new MultiMap(); + + assertEquals(map.valueCount, 0); +}); + +Deno.test("MultiMap.valueCount counts every value, including duplicates", () => { + const map = new MultiMap( + [ + ["a", 1], + ["a", 2], + ["a", 1], + ["b", 3], + ] as const, + ); + + assertEquals(map.size, 2); + assertEquals(map.valueCount, 4); +}); + +Deno.test("MultiMap.valueCount increments on add()", () => { + const map = new MultiMap(); + map.add("a", 1); + assertEquals(map.valueCount, 1); + map.add("a", 2); + assertEquals(map.valueCount, 2); + map.add("b", 3); + assertEquals(map.valueCount, 3); +}); + +Deno.test("MultiMap.valueCount decrements by bucket length on delete()", () => { + const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]]); + + assertEquals(map.delete("a"), true); + assertEquals(map.valueCount, 1); + + assertEquals(map.delete("missing"), false); + assertEquals(map.valueCount, 1); +}); + +Deno.test("MultiMap.valueCount decrements by one on successful deleteEntry()", () => { + const map = new MultiMap([["a", 1], ["a", 2], ["a", 1]]); + + assertEquals(map.deleteEntry("a", 1), true); + assertEquals(map.valueCount, 2); + + assertEquals(map.deleteEntry("a", 999), false); + assertEquals(map.valueCount, 2); +}); + +Deno.test("MultiMap.valueCount resets to 0 on clear()", () => { + const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]] as const); + map.clear(); + + assertEquals(map.valueCount, 0); +}); + Deno.test("MultiMap.keys() yields each key once in insertion order", () => { const map = new MultiMap([["b", 1], ["a", 2], ["b", 3]] as const); @@ -580,3 +637,28 @@ Deno.test("MultiMap[Symbol.toStringTag] is 'MultiMap'", () => { assertEquals(map[Symbol.toStringTag], "MultiMap"); }); + +Deno.test("MultiMap is inspected as 'MultiMap(size) { key => [values], ... }'", () => { + const map = new MultiMap([["a", 1], ["a", 2], ["b", 3]] as const); + + assertEquals( + Deno.inspect(map), + `MultiMap(2) { "a" => [ 1, 2 ], "b" => [ 3 ] }`, + ); +}); + +Deno.test("MultiMap is inspected as 'MultiMap(0) {}' when empty", () => { + const map = new MultiMap(); + + assertEquals(Deno.inspect(map), `MultiMap(0) {}`); +}); + +Deno.test("MultiMap inspect does not alias internal state", () => { + const map = new MultiMap([["a", 1], ["a", 2]]); + const before = Deno.inspect(map); + map.add("b", 3); + const after = Deno.inspect(map); + + assertEquals(before, `MultiMap(1) { "a" => [ 1, 2 ] }`); + assertEquals(after, `MultiMap(2) { "a" => [ 1, 2 ], "b" => [ 3 ] }`); +});