indexmap

A hash map that preserves insertion order - MoonBit port of Rust's indexmap

hashmap
indexmap
ordered
data-structure
moon add aurasuisui/indexmap@0.4.0
Download zip
Version
0.4.0
License
Apache-2.0
Last updated
17 days ago
Downloads
31
README

#moonbit-indexmap

License CI

A hash map that preserves insertion order — MoonBit port of Rust's indexmap crate.

MoonBit's built-in Map[K, V] preserves insertion order but offers no way to address entries by position. IndexMap pairs that insertion-order guarantee with index-based access (get_index, get_index_of, first, last, pop, swap_remove_index), an Entry API, and order-sensitive Eq/Hash, making it ideal for configuration parsing, JSON serialization, LRU caches, and deterministic tests.

let map = @aurasuisui/indexmap.new()
map.insert("b", 2) |> ignore
map.insert("a", 1) |> ignore
map.insert("c", 3) |> ignore

// Iteration follows insertion order: b, a, c
let iter = map.iter()
while true {
match iter.next() {
Some((k, v)) => println("\{k}: \{v}")
None => break
}
}

#Features

  • Insertion-order iteration — entries yield in the order they were first inserted
  • O(1) average lookups — Robin Hood open-addressing hash table
  • Index-based accessget_index(i), first(), last(), pop()
  • Entry APIOccupiedEntry / VacantEntry for in-place manipulation
  • IndexSet — ordered hash set with is_disjoint, is_subset, is_superset
  • JSON supportToJson preserves key order; from_json / from_json_with deserialize back (order-preserving, so from_json(m.to_json()) == m is a lossless round-trip for String-keyed maps)
  • Standard traitsDebug, Default, Show, Hash, Eq, ToJson for both IndexMap and IndexSet
  • QuickCheck supportArbitrary trait for property-based testing

#Installation

Add to moon.mod:

{ "dependencies": { "aurasuisui/indexmap": "0.4.0" } }

Or clone directly:

git clone https://github.com/aurasuisui/moonbit-indexmap

#API Overview

#IndexMap[K, V]

CategoryMethods
Constructnew(), with_capacity(n), from_array(entries), default(), copy()
Querylen(), is_empty(), capacity(), load_factor(), max_probe()
Coreinsert(k, v) -> V?, get(k) -> V?, remove(k) -> V?, contains(k) -> Bool, clear(), get_mut(k, f)
Entryentry(k) -> EntryView (Occupied: get/insert/remove/key, Vacant: insert/key)
Indexget_index(i), get_full(k), get_index_of(k), first(), last(), pop(), swap_remove_index(i)
Capacityreserve(n), shrink_to_fit()
Iterateiter(), keys(), values(), for_each(f), into_iter(), into_array()
Bulkretain(f), sort_by_key(), sort_by(cmp), drain(), extend_from_array(entries)
TraitsDebug, Default, Show, Hash, Eq, ToJson

#IndexSet[K]

CategoryMethods
Constructnew(), with_capacity(n), from_array(elements), default(), copy()
Querylen(), is_empty(), capacity()
Coreinsert(v) -> Bool, contains(v) -> Bool, remove(v) -> Bool, clear()
Set opsis_disjoint(other), is_subset(other), is_superset(other)
Iterateiter(), into_array()
Bulkretain(f), drain(), extend_from_array(elements)
TraitsDebug, Default, Show, Hash, Eq, ToJson

#Design

Two parallel structures:

  1. Robin Hood hash table (Array[Entry[K, V]?]) — O(1) average lookup, reduced probe variance
  2. Order array (Array[K]) — tracks insertion order for deterministic iteration

Deletion uses backward-shift compaction: displaced entries move back until the next entry is at its home bucket or the cluster ends. This preserves probe reachability without retaining dead bucket entries. load_factor() therefore always reports live entries divided by capacity.

#Compared to built-in Map

PropertyMap[K, V]IndexMap[K, V]
LookupO(1) avgO(1) avg
Iteration orderInsertion order (linked map)Insertion order
Index access (get_index, first, pop, …)NoYes
Entry API (Occupied / Vacant)NoYes
Eq / Hash semanticsIndependent of insertion orderDependent on insertion order

#Gotchas

Known design choices and limitations — see the independent test report for reproduction details.

  1. get_mut semantics: the callback's return value is authoritative (reworked in v0.3.3). get_mut(key, f) passes the current value to f (or None if the key is absent) and then re-applies the result through insert/remove: Some(v) stores v under key (inserting it if the callback removed it), and None removes key. Returning None therefore removes the key even if the callback re-inserted it — return Some(v) to keep a value. Because the result is re-applied via a fresh probe, the callback may safely mutate the map (including triggering a resize). Earlier versions wrote back to a stale bucket index, which could corrupt the table and silently broke plain deletion.

  2. Eq and Hash are insertion-order-sensitive. Two maps with identical key-value pairs but different insertion orders are not equal and produce different hashes. Avoid using an IndexMap or IndexSet as a key in another hash container unless you can guarantee consistent insertion order.

  3. swap_remove_index is actually O(n) shift-remove. Despite the name (kept for Rust indexmap API compatibility), it calls the order-preserving remove path — elements after the target are shifted one slot left. It does not swap with the last element in O(1). If you need actual O(1) order-breaking removal, you would need a dedicated method that directly swaps with the last element before popping — swap_remove_index does not do this.

  4. max_probe() is refreshed after sort_by / sort_by_key (fixed in v0.3.2). Sorting rebuilds order[] and positions[]; as of v0.3.2 the internal max_probe_distance is also recalculated after sorting, so max_probe() reports the current (post-sort) probe distribution. (Sorting does not move buckets, so previously the value happened to remain correct — it is now maintained explicitly.)

  5. Don't mutate the map while an iterator is active. Each iterator snapshots the map's mutation version at creation; if the map is structurally modified (insert, remove, clear, retain, sort_by*, reserve, shrink_to_fit, or an Entry / get_mut mutation) before the iterator is exhausted, the next next() aborts with IndexMap: map mutated during iteration — true fail-fast, added in v0.3.3. Earlier versions silently skipped entries and could crash with an out-of-bounds access. Finish all mutations first, then create a fresh iterator.

#Independent Test Report

An independent black-box test suite (indexmap-test-suite) covers every public API, stress up to 100k entries, property-based invariants, edge-case traps, plus (as of the latest reorganization) HashDoS / adversarial collision, fail-fast iterator aborts, real benchmarks + a regression gate, from_json round-trip, and Rust indexmap differential tests. The library itself keeps the white-box + library-specific tests in-repo — the model/oracle property test, fuzz harness, and IndexMap-vs-builtin-Map parity (see CLAUDE.md for the per-file breakdown and docs/RELEASE_CHECKLIST.md for the full Tier 0–4 status against the release checklist).

Released: the from_json API addition, the deletion-engine rewrite (backward-shift, tombstone-free) and the test-suite reorganization described here shipped in v0.4.0. See CHANGELOG.md [0.4.0].

#Examples

The example packages live in cmd/:

  • cmd/lru_cache — LRU eviction demo
  • cmd/config_parse — order-preserving config parser
  • cmd/json_orderToJson key ordering

Note: the cmd/* example packages are workspace members (listed in moon.work) and use pkgtype(kind: "executable") (migrated off the deprecated options("is-main")). Being in the workspace, they resolve aurasuisui/indexmap to the local source — so they're checked/formatted by the root moon check / moon fmt and run by the CI examples job without depending on the mooncakes registry (the historical reason they were excluded — the options("is-main") / version: latest conflict — is resolved by pkgtype). To run one locally: moon run cmd/<name> from the repo root.

#Development

moon check # Type check (0 warnings, 0 errors; --deny-warn clean) moon test # Run all in-package tests (white-box + library-specific) moon test --target <t># t = wasm-gc | wasm | js | native (CI tests all four) moon fmt # Format code

CI: check job (fmt / check --deny-warn / mbti drift) + a target × mode test matrix + an examples job. The black-box robustness battery (HashDoS, fail-fast, perf, Rust differential, JSON round-trip) lives in indexmap-test-suite. See CONTRIBUTING.md for project layout, roadmap, and contribution guidelines.

#License

Apache 2.0 — see LICENSE.

Built for the MoonBit Open Source Ecosystem Competition 2026.

#
Entry

type Entry[K, V] derive(
Debug
)

An entry stored in the hash table buckets. Uses TraitField pattern — key and hash are immutable to maintain probe-chain integrity, while distance is mutable for Robin Hood insertion.

#
EntryView

pub enum EntryView[K, V] {
Occupied(OccupiedEntry[K, V])
Vacant(VacantEntry[K, V])
}

Entry API — a view into a single entry in the map.

#
IndexMap

type IndexMap[K, V]

The main IndexMap type.
impl Default for IndexMap[K, V]
impl Eq for IndexMap[K, V]
impl Hash for IndexMap[K, V]
impl Show for IndexMap[K, V]
impl ToJson for IndexMap[K, V]
impl Debug for IndexMap[K, V]
impl Arbitrary for IndexMap[K, V]

#
IndexMap::capacity

fn[K, V] IndexMap::capacity(self : IndexMap[K, V]) -> Int

Return the current number of buckets in the underlying hash table.

#
IndexMap::clear

fn[K : Hash + Eq, V] IndexMap::clear(self : IndexMap[K, V]) -> Unit

Remove all entries from the map.

#
IndexMap::contains

fn[K : Hash + Eq, V] IndexMap::contains(self : IndexMap[K, V], key : K) -> Bool

Return true if the map contains key.

#
IndexMap::copy

fn[K : Hash + Eq, V] IndexMap::copy(self : IndexMap[K, V]) -> IndexMap[K, V]

Create a shallow copy of this IndexMap, preserving insertion order.

#
IndexMap::drain

fn[K : Hash + Eq, V] IndexMap::drain(self : IndexMap[K, V]) -> Array[(K, V)]

Drain all entries from the map, returning them in insertion order.

#
IndexMap::entry

fn[K : Hash + Eq, V] IndexMap::entry(self : IndexMap[K, V], key : K) -> EntryView[K, V]

Get the entry for key in the map for in-place manipulation.

#
IndexMap::extend_from_array

fn[K : Hash + Eq, V] IndexMap::extend_from_array(self : IndexMap[K, V], entries : Array[(K, V)]) -> Unit

Extend the map with entries from an array of (key, value) pairs.

#
IndexMap::first

fn[K : Hash + Eq, V] IndexMap::first(self : IndexMap[K, V]) -> (K, V)?

Get the first entry (earliest inserted).

#
IndexMap::for_each

fn[K : Hash + Eq, V] IndexMap::for_each(self : IndexMap[K, V], f : (K, V) -> Unit) -> Unit

Apply a function to each (key, value) pair in insertion order.

#
IndexMap::from_array

fn[K : Hash + Eq, V] IndexMap::from_array(entries : Array[(K, V)]) -> IndexMap[K, V]

Create an IndexMap from an array of (key, value) pairs.

#
IndexMap::from_json

Deserialize an IndexMap from a JSON object. Object keys become String keys; insertion order is preserved (core JSON objects are ordered), so for a String-keyed map from_json(m.to_json()) == m holds exactly — the strongest round-trip guarantee, since Eq is order-sensitive.

Non-String keys are rendered by to_json via Show and are not generally parseable back; use from_json_with to supply a key parser for those. Duplicate JSON keys follow core Map semantics (last wins).

#
IndexMap::from_json_with

fn[K : Hash + Eq, V :
FromJson
] IndexMap::from_json_with(json : Json, parse_key : (String) -> K) -> IndexMap[K, V] raise
JsonDecodeError

Like from_json, but parse each JSON object key from String into K (e.g. integer keys). Insertion order is preserved.

#
IndexMap::get

fn[K : Hash + Eq, V] IndexMap::get(self : IndexMap[K, V], key : K) -> V?

Get a value associated with key.

#
IndexMap::get_full

fn[K : Hash + Eq, V] IndexMap::get_full(self : IndexMap[K, V], key : K) -> (K, V)?

Get the full entry (key and value) for the given key.

#
IndexMap::get_index

fn[K : Hash + Eq, V] IndexMap::get_index(self : IndexMap[K, V], index : Int) -> (K, V)?

Get the entry at the given insertion-order index.

#
IndexMap::get_index_of

fn[K : Hash + Eq, V] IndexMap::get_index_of(self : IndexMap[K, V], key : K) -> Int?

Get the insertion-order index of the given key.

#
IndexMap::get_mut

fn[K : Hash + Eq, V] IndexMap::get_mut(self : IndexMap[K, V], key : K, f : (V?) -> V?) -> Unit

Update the value associated with key via a callback (an in-place upsert). The callback receives Some(value) if the key exists, or None if it does not. Its return value is authoritative:
  • Some(v) stores v under key, inserting the key if it was absent.
  • None removes key from the map.

The callback may mutate the map through its closure; the result is always re-applied via insert/remove (which re-probe and handle resizing), so resizing or removing key inside the callback is safe. Returning None removes key even if the callback re-inserted it — return Some(v) to keep a value.

#
IndexMap::insert

fn[K : Hash + Eq, V] IndexMap::insert(self : IndexMap[K, V], key : K, value : V) -> V?

Insert a key-value pair into the map.

#
IndexMap::into_array

fn[K : Hash + Eq, V] IndexMap::into_array(self : IndexMap[K, V]) -> Array[(K, V)]

Consume the map and return its entries as an array in insertion order.

#
IndexMap::into_iter

fn[K : Hash + Eq, V] IndexMap::into_iter(self : IndexMap[K, V]) -> IntoMapIter[K, V]

Consume the map and return an iterator over (key, value) pairs.

#
IndexMap::is_empty

fn[K, V] IndexMap::is_empty(self : IndexMap[K, V]) -> Bool

Return true if the map contains no entries.

#
IndexMap::iter

fn[K : Hash + Eq, V] IndexMap::iter(self : IndexMap[K, V]) -> Iter[(K, V)]

Return a lazy iterator over (key, value) pairs in insertion order. Supports for (k, v) in map { ... } syntax.

#
IndexMap::keys

fn[K : Hash + Eq, V] IndexMap::keys(self : IndexMap[K, V]) -> Iter[K]

Return a lazy iterator over keys in insertion order.

#
IndexMap::last

fn[K : Hash + Eq, V] IndexMap::last(self : IndexMap[K, V]) -> (K, V)?

Get the last entry (most recently inserted).

#
IndexMap::len

fn[K, V] IndexMap::len(self : IndexMap[K, V]) -> Int

Return the number of entries in the map.

#
IndexMap::load_factor

fn[K, V] IndexMap::load_factor(self : IndexMap[K, V]) -> Double

Return the current load factor (entries / capacity).

#
IndexMap::max_probe

fn[K, V] IndexMap::max_probe(self : IndexMap[K, V]) -> Int

Return the maximum probe distance observed.

#
IndexMap::new

fn[K : Hash + Eq, V] IndexMap::new() -> IndexMap[K, V]

Create a new, empty IndexMap with default capacity (16 buckets).

#
IndexMap::pop

fn[K : Hash + Eq, V] IndexMap::pop(self : IndexMap[K, V]) -> (K, V)?

Remove and return the last entry (most recently inserted).

#
IndexMap::remove

fn[K : Hash + Eq, V] IndexMap::remove(self : IndexMap[K, V], key : K) -> V?

Remove a key from the map, returning the value if it was present.

#
IndexMap::reserve

fn[K : Hash + Eq, V] IndexMap::reserve(self : IndexMap[K, V], additional : Int) -> Unit

Reserve capacity for at least additional more entries.

#
IndexMap::retain

fn[K : Hash + Eq, V] IndexMap::retain(self : IndexMap[K, V], f : (K, V) -> Bool) -> Unit

Retain only the entries for which the predicate returns true.

#
IndexMap::shrink_to_fit

fn[K : Hash + Eq, V] IndexMap::shrink_to_fit(self : IndexMap[K, V]) -> Unit

Shrink the capacity to fit the current number of entries.

#
IndexMap::sort_by

fn[K : Hash + Eq, V] IndexMap::sort_by(self : IndexMap[K, V], cmp : ((K, V), (K, V)) -> Int) -> Unit

Sort the map's entries using a custom comparison function (O(n log n)).

#
IndexMap::sort_by_key

fn[K : Hash + Eq + Compare, V] IndexMap::sort_by_key(self : IndexMap[K, V]) -> Unit

Sort the map's entries by key using MoonBit's built-in sort (O(n log n)).

#
IndexMap::swap_remove_index

fn[K : Hash + Eq, V] IndexMap::swap_remove_index(self : IndexMap[K, V], index : Int) -> (K, V)?

Swap-remove the entry at the given position.

#
IndexMap::values

fn[K : Hash + Eq, V] IndexMap::values(self : IndexMap[K, V]) -> Iter[V]

Return a lazy iterator over values in insertion order.

#
IndexMap::with_capacity

fn[K : Hash + Eq, V] IndexMap::with_capacity(cap : Int) -> IndexMap[K, V]

Create a new IndexMap with the given initial capacity.

#
IndexSet

type IndexSet[K]

A hash set that preserves insertion order. Wraps IndexMap[K, Unit] internally.
impl Default for IndexSet[K]
impl Eq for IndexSet[K]
impl Hash for IndexSet[K]
impl Show for IndexSet[K]
impl ToJson for IndexSet[K]
impl Debug for IndexSet[K]
impl Arbitrary for IndexSet[K]

#
IndexSet::capacity

fn[K] IndexSet::capacity(self : IndexSet[K]) -> Int

Return the current capacity of the underlying set.

#
IndexSet::clear

fn[K : Hash + Eq] IndexSet::clear(self : IndexSet[K]) -> Unit

Remove all elements from the set.

#
IndexSet::contains

fn[K : Hash + Eq] IndexSet::contains(self : IndexSet[K], value : K) -> Bool

Return true if the set contains value.

#
IndexSet::copy

fn[K : Hash + Eq] IndexSet::copy(self : IndexSet[K]) -> IndexSet[K]

Create a shallow copy of this IndexSet, preserving insertion order.

#
IndexSet::drain

fn[K : Hash + Eq] IndexSet::drain(self : IndexSet[K]) -> Array[K]

Drain all elements from the set, returning them in insertion order.

#
IndexSet::extend_from_array

fn[K : Hash + Eq] IndexSet::extend_from_array(self : IndexSet[K], elements : Array[K]) -> Unit

Extend the set with elements from an array.

#
IndexSet::from_array

fn[K : Hash + Eq] IndexSet::from_array(elements : Array[K]) -> IndexSet[K]

Create an IndexSet from an array of elements.

#
IndexSet::insert

fn[K : Hash + Eq] IndexSet::insert(self : IndexSet[K], value : K) -> Bool

Insert a value into the set. Returns true if the value was newly inserted, false if it already existed.

#
IndexSet::into_array

fn[K : Hash + Eq] IndexSet::into_array(self : IndexSet[K]) -> Array[K]

Consume the set and return its elements as an array in insertion order.

#
IndexSet::is_disjoint

fn[K : Hash + Eq] IndexSet::is_disjoint(self : IndexSet[K], other : IndexSet[K]) -> Bool

Returns true if self has no elements in common with other.

#
IndexSet::is_empty

fn[K] IndexSet::is_empty(self : IndexSet[K]) -> Bool

Return true if the set contains no elements.

#
IndexSet::is_subset

fn[K : Hash + Eq] IndexSet::is_subset(self : IndexSet[K], other : IndexSet[K]) -> Bool

Returns true if self is a subset of other.

#
IndexSet::is_superset

fn[K : Hash + Eq] IndexSet::is_superset(self : IndexSet[K], other : IndexSet[K]) -> Bool

Returns true if self is a superset of other.

#
IndexSet::iter

fn[K : Hash + Eq] IndexSet::iter(self : IndexSet[K]) -> Iter[K]

Return an iterator over elements in insertion order. Supports for elem in set { ... } syntax.

#
IndexSet::len

fn[K] IndexSet::len(self : IndexSet[K]) -> Int

Return the number of elements in the set.

#
IndexSet::new

fn[K : Hash + Eq] IndexSet::new() -> IndexSet[K]

Create a new, empty IndexSet.

#
IndexSet::remove

fn[K : Hash + Eq] IndexSet::remove(self : IndexSet[K], value : K) -> Bool

Remove a value from the set. Returns true if the value was present and removed.

#
IndexSet::retain

fn[K : Hash + Eq] IndexSet::retain(self : IndexSet[K], f : (K) -> Bool) -> Unit

Retain only the elements for which the predicate returns true.

#
IndexSet::with_capacity

fn[K : Hash + Eq] IndexSet::with_capacity(cap : Int) -> IndexSet[K]

Create a new IndexSet with the given initial capacity.

#
IntoMapIter

type IntoMapIter[K, V]

A consuming iterator over (key, value) pairs in insertion order.

#
IntoMapIter::collect

fn[K, V] IntoMapIter::collect(self : IntoMapIter[K, V]) -> Array[(K, V)]

Collect all remaining entries from the consuming iterator.

#
IntoMapIter::count_remaining

fn[K, V] IntoMapIter::count_remaining(self : IntoMapIter[K, V]) -> Int

Return the number of entries remaining in the consuming iterator.

#
IntoMapIter::next

fn[K, V] IntoMapIter::next(self : IntoMapIter[K, V]) -> (K, V)?

Advance the consuming iterator.

#
OccupiedEntry

pub struct OccupiedEntry[K, V] {
map : IndexMap[K, V]
key : K
hash : Int
}

A view into an occupied entry.

#
OccupiedEntry::get

fn[K : Eq, V] OccupiedEntry::get(self : OccupiedEntry[K, V]) -> V

Get the value stored in this occupied entry. Re-probes by key, so a stale handle (the map mutated after entry() was called) cannot return another key's value.

#
OccupiedEntry::insert

fn[K : Eq, V] OccupiedEntry::insert(self : OccupiedEntry[K, V], value : V) -> V

Replace the value in this occupied entry, returning the old value. Re-probes by key, so a stale handle (the map mutated after entry() was called) cannot overwrite another key's value.

#
OccupiedEntry::key

fn[K, V] OccupiedEntry::key(self : OccupiedEntry[K, V]) -> K

Get the key for this occupied entry.

#
OccupiedEntry::remove

fn[K : Hash + Eq, V] OccupiedEntry::remove(self : OccupiedEntry[K, V]) -> V

Remove this entry from the map, returning the value.

#
VacantEntry

pub struct VacantEntry[K, V] {
map : IndexMap[K, V]
key : K
hash : Int
}

A view into a vacant entry.

#
VacantEntry::insert

fn[K : Hash + Eq, V] VacantEntry::insert(self : VacantEntry[K, V], value : V) -> V

Insert a value into this vacant entry, returning the value inserted. Delegates to IndexMap::insert, which runs the resize gate and a fresh probe. Filling the map via the Entry API can therefore no longer skip expansion (which previously could drive insertion into an infinite loop), and a stale handle cannot corrupt the table.

#
VacantEntry::key

fn[K, V] VacantEntry::key(self : VacantEntry[K, V]) -> K

Get the key for this vacant entry.

#
LOAD_FACTOR_DENOMINATOR

let LOAD_FACTOR_DENOMINATOR : Int

#
LOAD_FACTOR_NUMERATOR

let LOAD_FACTOR_NUMERATOR : Int

The maximum load factor before the hash table resizes. 0.75 is a standard choice balancing memory and performance.

#
VERSION

let VERSION : String

Library version string.

#
from_json

Deserialize an IndexMap from a JSON object (String keys, order-preserving). See IndexMap::from_json.

#
from_json_with

fn[K : Hash + Eq, V :
FromJson
] from_json_with(json : Json, parse_key : (String) -> K) -> IndexMap[K, V] raise
JsonDecodeError

Deserialize an IndexMap from a JSON object, parsing each key from String. See IndexMap::from_json_with.

#
new

fn[K : Hash + Eq, V] new() -> IndexMap[K, V]

Create a new, empty IndexMap.

#
with_capacity

fn[K : Hash + Eq, V] with_capacity(cap : Int) -> IndexMap[K, V]

Create a new IndexMap with the given initial capacity.