LEVIATHAN v0.1 · in development

Standard Library

Collections & Iteration

Array, Map, and Set are pure values — every "changing" method hands back a new collection. Pair, Range, the iterator protocol, and the lazy Seq pipeline round out how Leviathan moves data through a loop.

Pure value semantics

Array<T>, Map<K, V>, and Set<T> are pure values, not mutable containers. No method mutates the receiver in place — every method that looks like it "changes" the collection instead returns a new one, and you rebind the variable to keep the result.

Array<int> a = [1, 2, 3];
a = a.add(4);          // a is now [1, 2, 3, 4] — the old array is untouched
a[0] = 99;              // rebind-sugar: exactly a = a.with(0, 99)

arr[i] = v and m[k] = v are rebind-sugar, not mutation — they desugar to arr = arr.with(i, v) and m = m.with(k, v) respectively. If you hold onto a reference to the old array or map, it still shows the old contents. Efficiency for the common single-owner case is a planned copy-on-write optimization (in-place update when the refcount shows unique ownership) — the semantics stay pure regardless of how it's implemented under the hood.

Array<T>

Construct with a literal, empty, or sized-and-filled. A bare declaration with no initializer defaults to empty.

Array<int> lit    = [1, 2, 3];
Array<int> empty  = Array();
Array<int> filled = Array(5, 0);     // [0, 0, 0, 0, 0] — T inferred from the fill
Array<int> bare;                     // []
map/select/reduce are method-level generic

The result element type is inferred from the transform lambda's own return type, not forced to stay T:

Array<string> r = a.map((n) => n.toString());  // int -> string, and it type-checks

This holds through chained maps as well.

Basics & queries

categorymembers
core (native)length(), at(int), add(T)
indexerget ([])(int i)
basicsisEmpty(), first(), last(), firstOrNone()T?, lastOrNone()T?
querieswhere(pred) / filter(pred), any(pred), all(pred), count(pred), contains(T), indexOf(T), indexWhere(pred) (-1 on miss), find(pred)T?

Transforms

categorymembers
transformsmap<U>(fn) / select<U>(fn), reduce<A>(seed, fn), flatMap<U>(fn), forEach(fn), reverse(), take(int), skip(int), takeWhile(pred), skipWhile(pred), concat(Array<T>), unique() (dedup by ==), withIndex()Array<Pair<int,T>>, groupBy<K>(fn)Map<K, Array<T>>

unique() — not distinct, which is a reserved member-modifier keyword, unrelated to array dedup.

Pure updates & sorting

categorymembers
pure updatesinsertAt(int, T), removeAt(int), with(int i, T v) (index-set — a distinct overload from Map.with's key-set; same vocabulary, no clash), slice(int from, int len) (throws on out-of-bounds, unlike string.subStr's clamp) — all bounds errors throw RuntimeException
sortingsort((T,T)=>int cmp) (stable merge sort — equal-key relative order is preserved), sortBy<K>(fn) (duck-typed < on K; a K without < is an instantiation-time error), minBy<K>(fn)T?, maxBy<K>(fn)T?

Relational & iteration

categorymembers
relationaljoin<U>(Array<U>, (T,U)=>bool)Array<Pair<T,U>>; groupJoin<U>(...)Array<Pair<T,Array<U>>>; zip<U>(Array<U>)Array<Pair<T,U>> (length = shorter of the two)
stringsjoinToString(string sep), concatAll() (native, O(total)) — declared generically but only meaningful on Array<string>; it's the engine behind StringBuilder.toString()
iteration / lazyiterator()IIterator<T> (protocol uniformity; for..in over an array keeps its fast path), asSeq()Seq<T> (bridge into the lazy pipeline, see below)

Aggregatessum, min, max, average — are free functions in std, not methods: Array<T> has no specialization mechanism, so std::sum overloads by argument type (int vs float) instead.

int total    = std::sum([1, 2, 3]);
float avg    = std::average([1.0, 2.0, 3.0]);   // std::average always returns float
int? biggest = std::max([]);                     // None on empty

Map<K, V>

Insertion-ordered, pure, like Array: "changing" methods return a new map; m[k] = v is rebind-sugar. Since get/set are keywords, the vocabulary is at / with / without.

Map<string, int> m = Map();
m = m.with("a", 1);
m["b"] = 2;                 // sugar for m = m.with("b", 2)
int a = m.at("a");
int? z = m.atOrNone("z");    // None
categorymembers
core (native)length(), at(K), with(K, V)Map<K,V>, without(K)Map<K,V>, has(K), keys()Array<K>, values()Array<V>
indexerget ([])(K key) => at(key); m[k] = v rebinds
basicsisEmpty(), atOrNone(K)V?, atOr(K, V dflt)V
bulkentries()Array<Pair<K,V>>, withAll(Map<K,V>) (fold via bracket-sugar accumulate), mapValues<U>((V)=>U)Map<K,U>, whereEntries((K,V)=>bool)Map<K,V>
iterationfor (Pair e in m) — entries as Pair<K, V> (e.first, e.second); also iterator()IIterator<Pair<K,V>> for protocol uniformity, yielding the identical sequence in insertion order

console.writeln(m) prints {a: 1, b: 2}.

Key equality

Key comparison follows one contract across every collection built on it (Map and Set):

primitives
compare by value
struct keys
compare field-wise, recursively — a struct is its fields
class keys
compare by identity

This is landed in the tree-walk oracle, bytecode interpreter, emit-C++, and LLVM backends. The frozen ELF backend (--emit-elf) keeps the pre-contract identity-only comparison for struct/class keys permanently, by design — primitive keys are identical on every backend including ELF.

Engine caveat: named dispatch for with/without

.with()/.without() are missing from the LLVM and ELF backends' named-method dispatch entirely engines — use the m[k] = v bracket-sugar form there instead of calling them by name. The oracle, IR interpreter, and emit-C++ backends are unaffected.

Set<T>

Implemented in-language over Map<T, bool> — zero natives. Insertion-ordered, and the same purity model as Array/Map: every "changing" method returns a new Set.

Set<int> s = Set([1, 2, 3]);
s = s.with(4).without(1);       // {2, 3, 4}
Set<int> t = Set([3, 4, 5]);
Set<int> both = s.intersect(t); // {3, 4}
categorymembers
basicslength(), isEmpty(), has(T)
pure updateswith(T)Set<T>, without(T)Set<T>
set algebraunion(Set<T>), intersect(Set<T>), except(Set<T>) — all pure, all Set<T>
conversiontoArray()Array<T> (insertion order), toString()"{a, b, c}"

Key equality for Set<T> follows the same rule as Map — it's built over one. Set's own with/without rebuild via bracket-sugar internally, so they are unaffected by the Map engine caveat above and match on all five backends, including --emit-elf.

Pair & Range

Pair<A, B>

Fields first, second; construct with Pair::Of(a, b). This is the element type of relational array joins and of Map iteration.

Pair<string, int> p = Pair::Of("age", 30);
console.writeln(p.first);   // "age"
console.writeln(p.second);  // 30

Range

a..b — an inclusive integer range, with fields start and end. Iterable in for..in as a counted loop (see dispatch order), and also IIterable<int> with an iterator() for protocol uniformity. Printable, and it spreads inside array literals.

Range r = 1..5;
console.writeln(r.start);   // 1
console.writeln(r.end);     // 5
Array<int> spread = [1..3, 7];   // [1, 2, 3, 7]

The iterator protocol

Two prelude interfaces make any type iterable by for..in:

interface IIterator<T> { bool hasNext(); T next(); }
interface IIterable<T> { IIterator<T> iterator(); }

for (T x in e), when e's static type implements IIterable<T>, desugars to:

var __it = e.iterator();
while (__it.hasNext()) { T x = __it.next(); /* body */ }

for (var x in e) infers the loop variable from the IIterable<T> instantiation. break/continue behave exactly as in the hand-written while (continue re-checks hasNext()). A type that implements neither a built-in collection nor IIterable<T> is a compile error naming the protocol.

Dispatch order

The checker picks the loop's path statically — there is no runtime probing:

  1. a Range literal a..b → a counted loop (no object);
  2. an Array/Map/Range value → the IterLen/IterAt fast path;
  3. otherwise, the protocol (iterator()/hasNext()/next() via ordinary dynamic dispatch — no new IR op, zero backend work).

Built-ins never reroute through the protocol — the fast paths are why arrays are fast. Array<T>, Map<K,V>, and Range also implement IIterable (with ArrayIterator/MapIterator/RangeIterator) purely for uniformity — so one can be passed where an IIterable<T> is wanted — but a for..in over one still takes its fast path.

Past the end is unspecified by the protocol itself — hasNext() is what drives the loop — but the stdlib iterators throw RuntimeException (loud) if next() is called past the end anyway. Invalidation: stdlib iterators are over pure values (an Array snapshot) and can never be invalidated; a user iterator over a mutable collection is caveat-emptor, the same as any user code. Strings are not iterable in v1 — s.chars() returns an Array instead, to keep bytes vs. scalars explicit (see Strings, Chars & Regex).

Seq<T>: lazy pipelines

A pure library over the iterator protocol. Arrays are eager; Seq is the opt-in lazy form. array.asSeq() is the bridge in.

categorymembers
combinators (lazy)map<U>((T)=>U)Seq<U>, where((T)=>bool)Seq<T>, take(int)Seq<T>, takeWhile((T)=>bool)Seq<T>, skip(int)Seq<T>
terminals (pull)toArray()Array<T>, firstOrNone()T? (short-circuits), count()int, forEach((T)=>void), reduce<A>(A seed, (A,T)=>A)A

The laziness contract

Nothing runs until a terminal (toArray/firstOrNone/ count/forEach/reduce) pulls; map/where functions run at most once per pulled element; firstOrNone pulls exactly one matching element — the lazy payoff. Terminal operations require a finite source: take(n) is the bound for an unbounded one (there is no runtime guard, the same stance as while(true)).

Array<int> r = nums.asSeq()
    .map((x) => x * x)
    .where((x) => x % 2 == 1)
    .take(3)
    .toArray();          // squares evaluated only for the 3 kept elements
Implementation shape

Each combinator is an iterator-composition class, not a closure-holding-closure (MapSeq<T,U> : Seq<U> wraps the source plus the function; its iterator wraps the source's). Closures capture by snapshot, which is wrong for a stateful cursor, so the wrappers hold honest mutable cursor fields instead. Each Seq subclass pulls its source through the interface type (IIterable/IIterator), never the concrete class, so the source's own iterator()/next() override runs correctly.