Skip to content

Latest commit

 

History

History
4545 lines (3717 loc) · 239 KB

stdlib-reference.md

File metadata and controls

4545 lines (3717 loc) · 239 KB

Aether Standard Library Reference

Reference for Aether's standard library. The index lists every module that ships with the number of names it exports, and make check-docs fails when that table stops matching the tree, so a module added, removed or grown here cannot leave the index behind. The sections after it cover the most-used modules in depth; for the others the index links to the module source, whose header comment is the authoritative description.

Module index (88 modules)

Module Purpose Exports Detail
std.actors Process-global registry mapping names to actor references. 6 guide · source
std.alloc Allocator handles the containers accept, so a structure can allocate from an arena instead of the system. 6 guide · source
std.arena Bulk allocator: many allocations, released in one shot. 7 guide · source
std.audio Audio playback: WAV and PCM loading, device control, volume; pan, pitch, looping, groups, a queue source and a headless engine for the game-engine mixer. 46 guide · source
std.audit Query the sandbox audit trail. 4 guide · source
std.bignum Arbitrary-precision integers. 29 guide · source
std.bits Unsigned bit operations: rotates, shifts, popcount, leading zeros, unsigned divide. 17 guide · source
std.brotli Brotli encoding and capped decoding, streaming and one-shot, for Content-Encoding: br. 34 guide · source
std.bytes Mutable byte buffer with random access and overlap-safe copies. 32 guide · source
std.capsicum FreeBSD Capsicum capability-mode bindings. 28 guide · source
std.cas Content-addressed store keyed by the sha256 of file contents. 7 guide · source
std.casper FreeBSD Casper service delegation. 8 guide · source
std.cbor CBOR encoding and decoding (RFC 8949). 46 guide · source
std.clapae Command-line argument parser, modelled on clap. 32 guide · source
std.collections Dynamic list, hash map and packed int array, with the raw externs the alias modules re-export. 44 guide · source
std.config Process-global immutable string to string store. 6 guide · source
std.cryptography Cryptographic hashes, HMAC, and the Base64 codec. 34 full section
std.decimal Arbitrary-precision decimal fixed-point arithmetic with explicit rounding modes, for money math. 43 guide · source
std.deque Fixed-capacity double-ended queue over long values. 16 guide · source
std.dir Directory operations, re-exported from std.fs. 11 guide · source
std.dl Dynamic library loader over dlopen and LoadLibrary. 8 guide · source
std.encoding Hex, Base64, Base32 and CSV field codecs. 11 guide · source
std.file File operations, re-exported from std.fs. 14 guide · source
std.floatarr Fixed-size packed-double buffer. 15 guide · source
std.fs Files, directories, metadata, recursive walk, and change watching. 148 guide · source
std.hash Fast non-cryptographic hashes and checksums: FNV, MurmurHash3, SipHash, CRC-32. 6 guide · source
std.host Primitives for Aether scripts embedded in a host application. 14 guide · source
std.http HTTP client and server: the std.net surface plus Go-style wrappers. 132 guide · source
std.http1 Pure-Aether HTTP/1.1 response reader (RFC 9112). 14 guide · source
std.intarr Fixed-size packed-int buffer. 15 guide · source
std.intmap Hash map from long keys to long values, for integer-keyed counting and lookup. 12 guide · source
std.io Console output, whole-file reads and writes, file descriptors, environment variables. 44 full section
std.ipc Child-to-parent back-channel for processes started by std.os. 4 guide · source
std.json JSON parsing, building and serialisation. 45 full section
std.jsonpath RFC 9535 JSONPath queries over parsed JSON, with a reusable compiled path. 10 guide · source
std.ksuid KSUID: 160-bit lexicographically sortable identifier. 1 guide · source
std.language BCP 47 language tags and matching (RFC 5646, RFC 4647). 11 guide · source
std.lanes SIMD lanes: four or eight floats, or two doubles, per vector, with masks and select. 127 guide · source
std.list Dynamic array, re-exported from std.collections. 14 guide · source
std.log Levelled logging with timestamps, colours and counters. 9 full section
std.longarr Fixed-size packed-long buffer. 15 guide · source
std.lzf One-shot LZF compression and decompression. 9 guide · source
std.map Hash map, re-exported from std.collections, with readable key snapshots. 20 guide · source
std.math Arithmetic, trigonometry, rounding and floating-point helpers. 45 full section
std.mem Byte-level reads and writes over caller-allocated raw pointers. 98 guide · source
std.message ICU MessageFormat formatting and message catalogues. 8 guide · source
std.msgpack MessagePack serialisation and deserialisation. 39 guide · source
std.mutation Text-based mutation-testing driver for std.spec suites. 1 guide · source
std.nanoid NanoID: 21-character URL-safe identifier. 2 guide · source
std.net TCP sockets and the HTTP client and server externs. 64 guide · source
std.number Locale-aware number, percent and currency formatting. 15 guide · source
std.observe Observable struct models: run closures after every field store on a struct T @observable value. 9 guide · source
std.os Shell and process execution: run, capture, spawn, pipes, wait. 80 full section
std.path Lexical path manipulation, with no filesystem access. 10 guide · source
std.plural CLDR plural-rule categories. 2 guide · source
std.pqueue Priority queue over (priority, item) pairs, backed by a binary heap. 9 guide · source
std.reflect Read a struct's field table from @derive(schema): names, types, offsets, sizes, attributes. 46 guide · source
std.quote The tree a quote trailing block hands to a function: typed nodes, captures read through their cells, and a printer. 66 guide · source
std.regex Perl-compatible regular expressions, backed by PCRE2. 27 guide · source
std.resp RESP codec (Redis Serialization Protocol): RESP3-native, RESP2-compatible, resumable decoder. 42 guide · source
std.sandbox Grant lists, and blocks that run with only those grants in force. 14 guide · source
std.schema Declarative typed validation and coercion. 35 guide · source
std.set Unordered set of unique strings. 14 guide · source
std.signal POSIX signal-number constants. 11 guide · source
std.snapshot Copy-on-write snapshot cell for read-mostly shared data. 5 guide · source
std.sort In-place sort and binary search over packed numeric arrays and string arrays, with optional comparators. 12 guide · source
std.spec BDD test framework: describe and it, hooks, assertions, structured reports. 46 guide · source
std.strarr Growable string array whose backing is a string[], for sorting runtime-built lists. 15 guide · source
std.strbuilder Amortised-O(1) string building. 18 guide · source
std.string Managed strings: construction, search, slicing, case, split and join. 100 full section
std.sync Atomic 64-bit integer cell (load, store, add, sub, compare-and-swap) for refcounts and lock-free reclamation. 7 guide · source
std.tar Streaming POSIX ustar archives: reader and writer. 24 full section
std.tcp TCP sockets, re-exported from std.net. 36 guide · source
std.udp Datagram sockets for game networking: non-blocking bind, send_to, recv_from, poll, and address values. 40 guide · source
std.time Civil date and time over Unix epoch seconds (UTC). 22 guide · source
std.tracking Leak-detecting allocator wrapper. 5 guide · source
std.truetype TrueType fonts read from their bytes: glyph lookup, metrics, kerning, contours and flattened outlines. 24 guide · source
std.tsid TSID: 64-bit time-sortable identifier, Crockford base32. 1 guide · source
std.ulid ULID: 128-bit lexicographically sortable identifier. 1 guide · source
std.unicode Unicode normalization, case/accent folding and grapheme-aware length/substring (utf8proc). 8 guide · source
std.url Percent-encoding and query-string parsing (RFC 3986). 8 guide · source
std.uuid UUID v4 and v7 (RFC 9562). 2 guide · source
std.worker Run blocking work off the loop thread, deliver the result back on it. 10 guide · source
std.xml XML pull parsing and document writing. 25 full section
std.yaml YAML parsing and emitting. 16 guide · source
std.zip ZIP archive reader and writer over a byte buffer: stored/deflate, ZIP64, per-entry CRC-32. 25 guide · source
std.zlib One-shot zlib and gzip deflate and inflate. 28 full section
std.zstd Zstandard compression, streaming and one-shot, for archives and internal transports. 19 guide · source

Note: The standard library follows the canonical module pattern in stdlib-module-pattern.md, fallible operations expose a _raw extern plus a Go-style (value, err) Aether wrapper; pure/infallible operations stay raw without a suffix. See the error handling example for how the pattern is used from user code, and std/fs/module.ae for the reference implementation.

Platform support

Target Filesystem Networking Threading Notes
Linux / macOS / BSD full POSIX full full Reference target.
Windows (MSYS2 / mingw-w64) partial full full Process exec is native: run, run_capture, spawn, wait, kill and supervision go through CreateProcessW and Job Objects. One thing stays POSIX-only and says so: the back-channel pipe (run_pipe spawns but hands back no pipe fd, run_pipe_drain_and_wait returns "unsupported on Windows"). symlink / readlink / is_symlink are native (CreateSymbolicLinkW, the link's reparse data); creating a link needs the privilege administrators hold and Developer Mode grants everyone, and without it symlink returns its error. hard_link is CreateHardLinkW (NTFS, same volume, files only).
WASI (wasi-sdk) per preopened paths none single-threaded wasi-libc provides POSIX-compatible fopen/fread/stat/etc., so the normal fs code path compiles. Paths must be under a WASI preopen.
Emscripten (browser WASM) off by default off cooperative Builds pass -DAETHER_NO_FILESYSTEM -DAETHER_NO_NETWORKING. File ops return (null, "cannot open file") via the Go-style wrappers, no silent failures. To enable, compile with -sFORCE_FILESYSTEM=1 and drop the define; untested in CI.
Bare embedded off off cooperative Same as Emscripten, stubs route all failures through the Go-style error tuples.

When a target lacks a capability, the stub implementations in each stdlib module return NULL / 0 so the Go-style wrapper produces a descriptive error string rather than crashing. A call like file.read("/etc/hosts") on a no-fs target returns ("", "cannot open file"), which the caller handles the same way as any other I/O error.

Using the Standard Library

Import a module and call its functions through its name: import std.file makes file.read, file.write and the rest of the module available. Three conventions hold across every module in this reference.

Errors are values. A call that can fail returns its result together with an error string, and "" means it worked. There is nothing to catch; the check is an if, next to the call.

The C layer is exported too. Most modules also export the C functions their wrappers sit on, named with a _raw suffix or an aether_ prefix, which return C's null or status int instead of an error string. They are there for handing a pointer straight to C; the wrapper is the API.

You release what you create. A list, a map, a buffer, a parsed document or an open handle has a free or close in its module, and defer keeps the release next to the allocation it pairs with.

import std.file
import std.list

main() {
    // A call that can fail returns its result AND an error string.
    // "" means it worked; anything else says what went wrong.
    text, err = file.read("no-such-file.txt")
    if err != "" {
        println("read failed: ${err}")
    } else {
        println(text)
    }

    // Whatever you create, you release. `defer` runs at scope exit,
    // so the release sits next to the allocation it pairs with.
    names = list.new()
    defer list.free(names)
    _a = list.add(names, "ada")
    _b = list.add(names, "grace")
    println("${list.size(names)} names")
}
read failed: cannot open file
2 names

Collections

List (std.list)

Dynamic array (ArrayList) implementation.

import std.list

main() {
    mylist = list.new()
    defer list.free(mylist)

    // list.add returns an error string: "" is success, anything else
    // a failure to grow the list.
    list.add(mylist, 10)
    list.add(mylist, 20)

    item, err = list.get(mylist, 0)
    println("${item != null} [${err}] ${list.size(mylist)}")

    names = list.new()
    defer list.free(names)
    list.add(names, "ada")
    first, err2 = list.get_string(names, 0)
    println("${first} [${err2}]")

    list.remove(mylist, 0)
    list.clear(mylist)
}
true [] 2
ada []

Functions:

  • list.new() - Create new list
  • list.add(list, item) → string - Append item, return error string
  • list.get(list, index) → (ptr, string) - The item at index: (null, "index out of range") or (null, "null list") on failure
  • list.get_string(list, index) → (string, string) - The item as a string, checked: (null, "not a string") when the element is some other pointer, (null, "") when it is null, and get's errors
  • list.at(list, index) → ptr - The item at an index the caller has bounded (a loop over 0 ..< list.size(l)); out of range, or a null list, is a panic naming the index and the size, as a slice index is
  • list.set(list, index, item) - Set item at index
  • list.remove(list, index) - Remove item at index
  • list.size(list) - Get number of elements
  • list.clear(list) - Remove all elements
  • list.free(list) - Free list memory

Raw externs: list_add_raw (returns 1/0), list_get_raw (no bounds check): the C bindings the functions above are built on, for std.list itself. Another module calls the functions (#3107).

String elements. list.add and list.set of a string take a reference to it, released when the element leaves (list.remove, list.clear, a store over it, list.free); a fresh string (a call's result, an interpolation) moves in, and a literal is stored as it is. The caller's own value is untouched, so the same string may be in several containers. Read a string element back with list.get_string, which checks that it is one; a string local given list.get's bare ptr is error E0202.

Two C entry points do the same by hand, for C code and ptr values: list_add_string_owned(l, s) gives the list a reference of its own (a counted string is retained, a plain char* copied), and list_add_string_adopted(l, s) takes over the caller's single reference without retaining, which only a caller that owns that reference may do. map_put_string_owned and map_put_string_adopted are the same pair for maps.

String list (string_list_*)

A list typed for strings, from std.collections: every entry is a copy the list owns, made when the string is added or set and freed when it is removed, cleared, overwritten or the list is freed.

import std.collections
import std.string

build() -> ptr {
    L = string_list_new()
    s = string.copy("ephemeral")
    string_list_add(L, s)          // the list stores a copy of its own
    return L
    // `s` is released here; the list's copy stays.
}

main() {
    L = build()
    println(string_list_get(L, 0))
    string_list_free(L)             // frees every entry
}
ephemeral

Functions:

  • string_list_new() → ptr - Allocate an empty list
  • string_list_add(list, s) → int - Append a copy; returns 1 on success, 0 on OOM
  • string_list_get(list, index) → string - The entry, which a place that keeps it takes a reference to; null on OOB
  • string_list_set(list, index, s) - Replace at index with a copy of s; frees the copy it replaces
  • string_list_size(list) → int - -1 on null
  • string_list_remove(list, index) - Remove the entry and free its copy
  • string_list_clear(list) - Drop every entry, keep the backing alloc
  • string_list_free(list) - Free every entry, then the list
  • string_list_sort_lex(list) - Stable ascending lexicographic (byte-wise) sort, in place
  • string_list_sort(list, cmp) - Stable in-place sort by a comparator closure |a: string, b: string| { ... } returning negative / 0 / positive (like strcmp)

Both sorts reorder the backing slots only: no element is copied, retained or released.

names = string_list_new()
string_list_add(names, "Charlie")
string_list_add(names, "alice")
string_list_add(names, "Bob")

string_list_sort_lex(names)                 // "Bob", "Charlie", "alice" (ASCII order)

string_list_sort(names, |a: string, b: string| {
    return string.length(a) - string.length(b)   // shortest first, stable
})

Map (std.map)

Hash map implementation.

import std.map
import std.string

main() {
    mymap = map.new()
    defer map.free(mymap)

    // map.put returns an error string; the map takes a reference to a string value.
    map.put(mymap, "name", string.concat("Aeth", "er"))
    name, err = map.get_string(mymap, "name")
    println("${name} [${err}] ${map.has(mymap, "name")}")

    map.remove(mymap, "name")
    println(map.size(mymap))
    map.clear(mymap)
}
Aether [] 1
0

Functions:

  • map.new() - Create new map
  • map.put(map, key, value) → string - Insert or update, return error string
  • map.get(map, key) → (ptr, string) - The value: (null, "") if the key is absent, (null, "null map") if map is null
  • map.get_string(map, key) → (string, string) - Get a string value: (null, "") if missing, (null, "not a string") if the value is some other pointer
  • map.lookup(map, key) → ptr - The value, or null when the key is absent or the map is null: get without its error slot
  • map.has(map, key) - Check if key exists
  • map.remove(map, key) - Remove key-value pair
  • map.size(map) - Get number of entries
  • map.clear(map) - Remove all entries
  • map.free(map) - Free map memory

Raw extern: map_put_raw (returns 1/0).

Set (std.set)

Unordered collection of unique strings, backed by the same hash table as std.map, so lookups are O(1) on average. Items are copied on insert, so the caller's string lifetime does not matter.

import std.set

main() {
    visited = set.new()
    defer set.free(visited)

    set.add(visited, "/index")          // true, newly added
    set.add(visited, "/index")          // false, already present
    set.add(visited, "/about")

    if set.contains(visited, "/about") {
        println("pages: ${set.size(visited)}")
    }

    set.remove(visited, "/about")
}

Functions:

  • set.new() → ptr - Create a new set (null on allocation failure)
  • set.add(set, item) → bool - True if added, false if already present or the insert failed
  • set.contains(set, item) → bool - Membership test
  • set.remove(set, item) - Drop an item; absent items are ignored
  • set.size(set) → int - Number of unique items
  • set.clear(set) - Drop every item, keeping the set usable
  • set.free(set) - Release the set (items were copied in, nothing else to free)
  • set.items(set) → (ptr, string) - Snapshot of the items in unspecified order; release with set.items_free
  • set.items_free(items) - Release a snapshot from set.items

Calls on a null set are safe: size reports 0, contains reports false.

Raw externs are the aether_set_* entry points, which return C-style ints.

Priority queue (std.pqueue)

Binary heap over (priority, item) pairs. The lowest priority value comes out first; negate the priority for highest-first. Push and pop are O(log n); peek and size are O(1).

The queue stores item pointers without taking ownership: it never frees them, so heap items you push remain yours to release.

import std.pqueue
import std.string

main() {
    jobs = pqueue.new()
    defer pqueue.free(jobs)

    pqueue.push(jobs, 30, "send newsletter")
    pqueue.push(jobs, 5,  "page on-call")
    pqueue.push(jobs, 20, "rebuild index")

    while pqueue.size(jobs) > 0 {
        priority = pqueue.peek_priority(jobs)
        // An item is a `ptr`; a literal pushed is its C text.
        job = string.from_cstr(pqueue.pop(jobs))
        println("[${priority}] ${job}")     // 5, 20, 30
    }
}

Functions:

  • pqueue.new() → ptr - Create a new queue (null on allocation failure)
  • pqueue.push(pq, priority, item) → bool - Enqueue; false on a null queue or allocation failure
  • pqueue.pop(pq) → ptr - Remove and return the lowest-priority item, null when empty
  • pqueue.peek(pq) → ptr - Next item without removing it, null when empty
  • pqueue.peek_priority(pq) → long - Priority of the item peek would return (0 when empty)
  • pqueue.size(pq) → int - Number of queued entries
  • pqueue.is_empty(pq) → bool - True when nothing is queued
  • pqueue.clear(pq) - Drop every entry, keeping the queue usable (items are not freed)
  • pqueue.free(pq) - Release the queue (items were never owned by it)

Calls on a null queue are safe: size reports 0, pop and peek return null.

Raw externs are the aether_pqueue_* entry points, which return C-style ints.

Double-ended queue (std.deque)

A fixed-capacity ring buffer of long values with O(1) push and pop at both ends: a work queue, a sliding window, an undo history. Deque is a value, so every mutation returns the updated deque and the result has to be rebound; a call whose result is dropped changes nothing. The capacity never grows: push_back on a full ring drops the value silently, while try_push_back reports it.

import std.deque

main() {
    // A fixed-capacity ring of longs. Every mutation RETURNS the deque:
    // the struct is a value, so the result must be rebound, and a call
    // whose result is dropped changes nothing.
    d = deque.new(4)
    d = deque.push_back(d, 10)
    d = deque.push_back(d, 20)
    d = deque.push_front(d, 5)

    front, _ferr = deque.peek_front(d)
    back, _berr = deque.peek_back(d)
    println("${front}..${back} over ${deque.len(d)} of ${deque.cap(d)}")

    v, d, _err = deque.pop_front(d)
    println("popped ${v}, empty ${deque.is_empty(d)}")

    // try_push_back reports a full ring instead of dropping the value.
    d = deque.push_back(d, 30)
    d = deque.push_back(d, 40)
    d, perr = deque.try_push_back(d, 50)
    println("full: ${deque.is_full(d)}, push said '${perr}'")

    deque.free(d)
}
5..20 over 3 of 4
popped 5, empty false
full: true, push said 'deque: full'

Functions:

  • deque.new(capacity) → Deque - Allocate a ring of capacity longs
  • deque.free(d) - Release the ring
  • deque.push_back(d, v) / deque.push_front(d, v) → Deque - Add at either end; a full ring drops the value
  • deque.try_push_back(d, v) / deque.try_push_front(d, v) → (Deque, string) - The same, reporting "deque: full" instead of dropping
  • deque.pop_front(d) / deque.pop_back(d) → (long, Deque, string) - Remove from either end; the error is non-empty when the ring was empty
  • deque.peek_front(d) / deque.peek_back(d) → (long, string) - Read without removing
  • deque.len(d) / deque.cap(d) → int, deque.is_empty(d) / deque.is_full(d) → bool
  • deque.clear(d) → Deque - Drop every value, keeping the capacity

Fixed-size int array (std.intarr)

Packed int buffer with O(1) random access. For DP tables, flat int-keyed lookup, and other hot paths where std.list's void*-boxed items cost an allocation per entry. Size is fixed at allocation, callers that need growth use std.list.

import std.intarr

main() {
    // Blame LCS DP table: M rows * N cols, flat buffer.
    rows = 100
    cols = 50
    dp, err = intarr.new(rows * cols)
    if err != "" { return }

    // Hot loop, _unchecked skips the bounds check (valid index required).
    r = 0
    while r < rows {
        c = 0
        while c < cols {
            intarr_set_unchecked(dp, r * cols + c, r + c)
            c = c + 1
        }
        r = r + 1
    }

    intarr_free(dp)
}

Functions:

  • intarr.new(size) → (ptr, string) - Allocate zero-initialised array
  • intarr.new_filled(size, init) → (ptr, string) - Allocate with every slot set to init
  • intarr.get(arr, i) → (int, string) - Bounds-checked read
  • intarr.set(arr, i, value) → string - Bounds-checked write
  • intarr_size(arr) → int - Returns -1 for null
  • intarr_fill(arr, value) - Reset every slot to value
  • intarr_free(arr) - Release

Hot-path (caller-validated) variants, no bounds check:

  • intarr_get_raw(arr, i) / intarr_set_raw(arr, i, v) - Safe on OOB (returns 0 / no-op), no error report
  • intarr_get_unchecked(arr, i) / intarr_set_unchecked(arr, i, v) - Undefined behaviour on OOB, for inner loops

Indexing with v[i] (#2041). a[i] on the handle itself cannot work: the handle is a bare ptr, so [] has no element type to dispatch on. A view is typed, and does:

a = intarr.new_raw(n)
v = intarr.array(a)             // an `int[]` over the same buffer
v[3] = 42
total = total + v[3]

The view is the buffer, not a copy, so writes through it are writes to the array and the accessors see them (and vice versa). v[i] lowers to the same load intarr_get_unchecked inlines to, so the readable spelling costs nothing. It borrows: valid until the handle is freed, and bounds are yours to respect, exactly as for the unchecked accessors.

Fixed-size float array (std.floatarr)

Packed double buffer, the float twin of std.intarr. Aether's float lowers to C double, so the element type is double end-to-end. Same fixed-size discipline, same bounds-check policy. Motivating use cases: SVG path-command argument storage, rasterizer edge tables, bbox accumulators, blur kernel coefficients, any number[]-shaped data where boxing each scalar into a *Pt struct would chase a pointer per element.

import std.floatarr

main() {
    // Gaussian kernel coefficients, length 2r+1 for radius r.
    radius = 3
    n = 2 * radius + 1
    kernel, err = floatarr.new(n)
    if err != "" { return }

    // ... fill from sigma ...
    floatarr_free(kernel)
}

Functions:

  • floatarr.new(size) → (ptr, string) - Allocate zero-initialised array
  • floatarr.new_filled(size, init) → (ptr, string) - Allocate with every slot set to init
  • floatarr.get(arr, i) → (float, string) - Bounds-checked read
  • floatarr.set(arr, i, value) → string - Bounds-checked write
  • floatarr_size(arr) → int - Returns -1 for null
  • floatarr_fill(arr, value) - Reset every slot to value
  • floatarr_free(arr) - Release

Hot-path (caller-validated) variants, no bounds check:

  • floatarr_get_raw(arr, i) / floatarr_set_raw(arr, i, v) - Safe on OOB (returns 0.0 / no-op), no error report
  • floatarr_get_unchecked(arr, i) / floatarr_set_unchecked(arr, i, v) - Undefined behaviour on OOB, for inner loops

Indexing with v[i] (#2041). a[i] on the handle itself cannot work: the handle is a bare ptr, so [] has no element type to dispatch on. A view is typed, and does:

a = floatarr.new_raw(n)
v = floatarr.array(a)               // a `float[]` over the same buffer
v[3] = 1.5
total = total + v[3]

The view is the buffer, not a copy, so writes through it are writes to the array and the accessors see them (and vice versa). v[i] lowers to the same load floatarr_get_unchecked inlines to, so the readable spelling costs nothing. It borrows: valid until the handle is freed, and bounds are yours to respect, exactly as for the unchecked accessors.

Fixed-size long array (std.longarr)

The 64-bit twin of std.intarr, for values that overflow 32 bits -- nanosecond timestamps, offsets into large files, 64-bit hashes. The checked get/set return an error for an index out of range; for a hot loop, longarr.array(a) is a typed view over the same buffer, where v[i] is a plain load.

import std.longarr

main() {
    // A fixed-size packed buffer of 64-bit integers: the long twin of
    // std.intarr, for values that overflow 32 bits -- nanosecond
    // timestamps, byte offsets in large files, 64-bit hashes.
    n = 4
    a = longarr.new_raw(n)

    // The checked accessors return an error for an index out of range.
    serr = longarr.set(a, 0, 9000000000)
    if serr != "" { println("set: ${serr}"); return }
    v, gerr = longarr.get(a, 0)
    println("checked: ${v} '${gerr}'")
    _v, oob = longarr.get(a, 99)
    println("out of range: ${oob}")

    // For a hot loop, a typed view: v[i] is a plain load.
    view = longarr.array(a)
    i = 1
    while i < n {
        view[i] = view[i - 1] * 2
        i = i + 1
    }
    println("doubling: ${view[1]} ${view[2]} ${view[3]}")

    longarr.free(a)
}
checked: 9000000000 ''
out of range: index out of range
doubling: 18000000000 36000000000 72000000000

Functions:

  • longarr.new(n) / new_filled(n, v) → (ptr, string) - The error names a negative size or a failed allocation
  • longarr.new_raw(n) / longarr_new_filled_raw(n, v) → ptr (null on failure), longarr_free(a)
  • longarr.get(a, i) → (long, string), longarr.set(a, i, v) → string - Bounds-checked
  • longarr.get_unchecked(a, i) / longarr_set_unchecked(a, i, v) - Unchecked, inlined
  • longarr.array(a) → long[] - A typed view: v[i] reads and writes the array itself
  • longarr.size(a) → int, longarr.fill(a, v)

Mutable byte buffer (std.bytes)

Mutable random-access byte buffer with overlap-safe forward copy_within. Aether's string is immutable; reach for std.bytes when you need to write bytes at arbitrary indices and read bytes the loop just wrote (binary codec output buffers, varint emit, frame layout, RLE-style overlap copy).

import std.bytes

main() {
    // Build "[abc]" by writing one byte at a time.
    b = bytes.new(8)
    bytes.set(b, 0, 91)                    // '['
    bytes.copy_from_string(b, 1, "abc", 3)
    bytes.set(b, 4, 93)                    // ']'

    // Hand off to a refcounted AetherString, buffer is consumed.
    s = bytes.finish(b, 5)                 // "[abc]"
}

The headline use case is the RLE-overlap pattern that immutable concat can't express:

// Pre-fill with [A, B], then expand to [A, B, A, B, A, B] in one call.
b = bytes.new(8)
bytes.set(b, 0, 65)
bytes.set(b, 1, 66)
bytes.copy_within(b, 2, 0, 4)             // dst=2, src=0, length=4
out = bytes.finish(b, 6)                  // "ABABAB"

copy_within is forward byte-by-byte (deliberately not memmove-style). When dst > src, each iteration i reads data[src + i] which earlier iterations of the same call may have just written. That's how runs of repeated bytes get encoded.

Functions:

  • bytes.new(initial_capacity) → ptr - Allocate empty buffer with reserved capacity
  • bytes.length(b) → int - Logical byte count (-1 if null)
  • bytes.set(b, index, byte) → int - Write byte at index; gaps zero-fill; returns 1 on success
  • bytes.get(b, index) → int - Read byte at index as unsigned 0..255; -1 on OOB / NULL / negative
  • bytes.set_le16(b, index, value) → int - Little-endian 16-bit write at index..index+1; grows; 1 on success
  • bytes.get_le16(b, index) → int - Little-endian 16-bit read; -1 if range past current length
  • bytes.set_le32(b, index, value) → int - Little-endian 32-bit write at index..index+3; grows; 1 on success
  • bytes.get_le32(b, index) → int - Little-endian 32-bit read; -1 if range past current length
  • bytes.set_le64(b, index, value: long) → int - Little-endian 64-bit write at index..index+7; grows; 1 on success
  • bytes.get_le64(b, index) → long - Little-endian 64-bit read; -1 if range past current length; round-trips losslessly with set_le64
  • bytes.set_be16(b, index, value) → int - Big-endian 16-bit write (MSB first); grows; 1 on success
  • bytes.get_be16(b, index) → int - Big-endian 16-bit read; -1 if range past current length
  • bytes.set_be32(b, index, value) → int - Big-endian 32-bit write (MSB first); grows; 1 on success
  • bytes.get_be32(b, index) → int - Big-endian 32-bit read; -1 if range past current length
  • bytes.set_be64(b, index, value: long) → int - Big-endian 64-bit write (MSB first); grows; 1 on success
  • bytes.get_be64(b, index) → long - Big-endian 64-bit read; -1 if range past current length; round-trips losslessly with set_be64
  • bytes.copy_from_string(b, dst, src, src_len) → int - Copy from a string into the buffer at offset
  • bytes.copy_from_bytes(dst, dst_off, src, src_off, length) → int - Copy between distinct buffers (memmove semantics); grows dst if needed. Use for two-pass algorithms like separable Gaussian blur.
  • bytes.copy_within(b, dst, src, length) → int - Self-copy, forward byte-by-byte (RLE-safe)
  • bytes.finish(b, length) → string - Hand off to refcounted AetherString; buffer is consumed
  • bytes.free(b) - Discard without finishing (idempotent on null)
  • bytes.data(b) → ptr - Borrowed pointer to the buffer's own memory, for handing the region to a foreign runtime (a WebAssembly HEAPU8.set, a C API) without copying it byte by byte. Valid until the next call that can grow the buffer, which may move the allocation and invalidate it.
  • bytes.capacity(b) → int - Bytes reserved, which is at least length and may be more.
  • bytes.set_length(b, n) → int - Publish how many bytes a direct write into data()'s region made live. Clamped to the capacity, and returns the length actually set.

Streaming binary reads without a per-block copy. A fixed-size block reader (walking 512-byte sectors of a device, say) can read straight into a reused std.bytes buffer instead of allocating a fresh string per block: fs.pread_into(file, buf, len, offset) reads up to len bytes at offset directly into buf (clamped to its capacity), sets buf's length to the count read, and returns (n, err) with the same EOF (n == 0) / short-read (0 < n < len) / I/O-error (err != "") distinction as fs.pread. The packed integers are then read in place with bytes.get_le64 etc., or walked with a cursor. std.bytes.cursor offers both byte orders, read_be_u16/32/64 and read_le_u16/32/64 (each returns -1 and leaves the cursor unchanged at end-of-buffer), so little-endian on-disk formats stream as cleanly as big-endian wire formats.

The build-then-walk pattern needed by binary-codec encoders (svndiff and similar) is what bytes.get / bytes.{set,get}_le32 are for, accumulate packed-int ops into the same buffer via set_le32 at known offsets, then walk and read each back at finish time:

b = bytes.new(0)
bytes.set_le32(b, 0,  action)    // op0
bytes.set_le32(b, 4,  length)
bytes.set_le32(b, 8,  offset)
// … later …
op0_action = bytes.get_le32(b, 0)
op0_length = bytes.get_le32(b, 4)

Sorting and searching (std.sort)

In-place sorting for the packed numeric arrays and for string[] views, plus the binary searches that go with them. The ordered forms take the array handle and read its own length; the _by forms take a view, a length and your comparator, negative when a sorts first, the sign convention C's qsort uses.

A search returns the index of the value when present, and otherwise the insertion point (the index where it would go to keep the array sorted) rather than -1, so one call answers both "is it here?" and "where would it go?".

import std.sort
import std.intarr
import std.strarr
import std.string

_by_length(a: string, b: string) -> int {
    return string.length(a) - string.length(b)
}

main() {
    // Every entry point takes a slice, which carries its length. A packed
    // array hands over its elements as one: `intarr.array(h)`.
    nums = intarr.new_raw(5)
    v = intarr.array(nums)
    for i in 0..v.len {
        v[i] = (i * 7) % 5
    }
    sort.ints(v)
    println("${v[0]}..${v[4]}")
    // A binary search over the sorted slice. A miss returns the insertion
    // point rather than -1, so it also answers "where would this go?".
    println("3 is at ${sort.int_search(v, 3)}")
    intarr.free(nums)

    // A prefix or any run sorts on its own: `xs[..n]`, `xs[lo..hi]`.
    words = ["pear", "fig", "apple", "kiwi"]
    sort.strings(words[..3])
    println("${words[0]} ${words[1]} ${words[2]} ${words[3]}")

    // The `_by` forms take your own comparator: negative if a sorts first,
    // as C's qsort expects.
    sa = strarr.new()
    _p = strarr.push_copy(sa, "medium")
    _p = strarr.push_copy(sa, "xs")
    _p = strarr.push_copy(sa, "largest")
    sort.strings_by(strarr.array(sa), _by_length)
    println(strarr.get(sa, 0))
    strarr.free(sa)
}
0..4
3 is at 3
apple fig pear kiwi
xs

Functions (every one takes a slice: a packed array's array() view, a T[N] array, a make buffer, or a sub-slice of any of them; a view with no bound, .len -1, panics):

  • sort.ints(xs) / sort.longs(xs) / sort.floats(xs) / sort.strings(xs) - Sort ascending, in place; strings lexicographically by byte
  • sort.ints_by(xs, cmp) / sort.longs_by(xs, cmp) / sort.floats_by(xs, cmp) / sort.strings_by(xs, cmp) - Sort with your own comparator
  • sort.int_search(xs, x) / sort.long_search(xs, x) / sort.float_search(xs, x) / sort.string_search(xs, x) → int - Binary search a sorted slice; returns the index or the insertion point

The ordering algorithm is a Shell sort over Ciura's gap sequence: no allocation, no recursion, and no worst-case input that turns it quadratic the way a naive quicksort pivot does.

Strings (std.string)

Strings are counted values

Every string is a counted AetherString that carries its length; a literal is a pinned one, never freed. A string a std.string function returns (string.concat, string.trim, string.to_upper, string.substring, string.from_int, ...) is a fresh counted string, and the place that takes it owns it: the compiler releases it when the place is overwritten or goes out of scope. No std.string call needs a matching free (String ownership, below).

So do string.new(s), string.from_cstr(s), string.empty(), string.new_with_length(data, n), the from_* conversions, pad_start, pad_end and format: each returns a string, never a ptr (#3018). string.to_cstr(s) returns the characters as C sees them.

Usage Examples

import std.string

main() {
    s = "Hello"
    combined = string.concat(s, " World")
    println("${string.length(combined)} ${string.to_upper(s)} [${string.trim("  x  ")}]")
    println("${string.contains(s, "ell")} ${string.index_of(s, "l")} ${string.starts_with(s, "He")} ${string.ends_with(s, "lo")}")
    println(string.substring(s, 0, 3))

    csv = "a,b,c"
    // (a) AetherStringArray: O(1) random access by index; a handle, freed by hand.
    parts = string.split(csv, ",")
    println("${string.array_size(parts)} ${string.array_get(parts, 0)}")
    string.array_free(parts)

    // (b) *StringSeq: O(1) head, tail, cons and length; matches [h | t].
    //     Reach for it when the result is walked recursively or sent to an
    //     actor in a message. See docs/sequences.md.
    parts_seq = string.split_to_seq(csv, ",")
    println("${string.seq_length(parts_seq)} ${string.seq_head(parts_seq)}")

    n = 42
    println("${string.from_int(n)} ${string.from_float(3.14)}")
}
11 HELLO [x]
1 2 1 1
Hel
3 a
3 a
42 3.14

Creation:

  • string.new(cstr) - Create from C string
  • string.from_literal(cstr) - Create from string literal (alias for new)
  • string.from_cstr(cstr) - Create from a C string (alias for new). Also accepts an AetherString* (e.g. a value read back from list.add_string_owned via list.get), copying its payload rather than its header bytes.
  • string.empty() - Create empty string

Operations:

  • string.length(str) - Get length
  • string.concat(a, b) - Concatenate two strings (returns new string)
  • string.char_at(str, index) - Get character at index
  • string.equals(a, b) - Check equality (returns 1/0)
  • string.compare(a, b) - Lexicographic compare (returns -1, 0, 1)

Searching:

  • string.starts_with(str, prefix) - Check prefix (returns 1/0)
  • string.ends_with(str, suffix) - Check suffix (returns 1/0)
  • string.contains(str, sub) - Check if substring exists (returns 1/0)
  • string.index_of(str, sub) - Find position of substring (returns -1 if not found)

Transformation:

  • string.replace(str, old, new) - Replace the first occurrence of old with new. Returns a new managed string; str is untouched. Empty old returns a copy of str unchanged (no infinite empty-match expansion). Binary-safe.
  • string.replace_all(str, old, new) - Replace every non-overlapping occurrence of old with new, scanned left to right. new may be empty (deletion) or longer than old. Same lifetimes and empty-old guard as replace. Single exact-size allocation regardless of match count.
  • string.substring(str, start, end) - Extract substring
  • string.substring_n(str, str_len_bytes, start, end) - Length-aware sibling. Caller threads the source length through; str_len(s) is not consulted internally. Reach for this when str arrived as a string-typed parameter at a function boundary AND the content may contain embedded NULs, see c-interop.md § Passing string values into C externs (auto-unwrap). Without it, the auto-unwrap strips the AetherString header at the call site, str_len falls through to strlen, and binary content gets truncated at the first NUL.
  • string.length_n(str, known_length) - Identity helper that documents intent. In code that receives a string parameter plus an explicit length, the explicit length IS the truth, don't consult the AetherString header. n = string.length_n(s, n) reads as "yes I know my length" instead of looking like a forgotten string.length(s) that would have truncated at NUL.
  • string.bytes(s) → byte[] (#2301) - A bounded, read-only view of s's own bytes, for binary/ASCII scanning by indexing (v[i]) or sub-slicing instead of repeated char_at_n calls. BORROWED: valid only as long as s is; never free it. Read-only: a store through it, or through a view made from it, panics with "a write through a read-only view of a string", and one the checker can see is refused (#3025).
  • string.to_upper(str) - Convert to uppercase (returns new string)
  • string.to_lower(str) - Convert to lowercase (returns new string)
  • string.trim(str) - Remove leading/trailing whitespace

Splitting:

  • string.split(str, delimiter) - Split string by delimiter (returns array)
  • string.array_size(arr) - Get number of parts in split result
  • string.array_get(arr, index) → string - Get string at index from split result. The array's own characters, borrowed: a place that keeps one copies it, so it outlives string.array_free(arr)
  • string.array_free(arr) - Free split result array
  • string.array_view(arr) → string[] (#2301) - A bounded, OWNED snapshot of a split result: every element is an independently copied, refcounted string, so the returned slice outlives arr: call string.array_free(arr) right after this returns. Prefer split_to_seq when you don't need array/index shape at all.
  • string.split_to_seq(str, delimiter) - Split into a *StringSeq cons-cell list (Erlang/Elixir-shaped). Same split semantics as string.split, but returns the result as an O(1) head/tail/cons/length linked list with refcount-aware structural sharing. Use this when the result will be pattern-matched, walked recursively, or sent across an actor boundary as a message field. See docs/sequences.md for the full surface.
  • string.strip_prefix(s, prefix) → (rest, stripped) - If s starts with prefix, returns the remainder and 1. Otherwise returns s and 0. Cleaner than manual starts_with + substring length arithmetic.

Glob-pattern matching (string side, NOT filesystem):

  • string.glob_match(pattern, s) → int - Does pattern match s? POSIX fnmatch(3) syntax: * zero-or-more, ? single-char, [abc] / [a-z] char classes, [!abc] negation, \* / \? literal escapes. Returns 1 on match, 0 on no-match, -1 on glob-syntax error. Distinct from fs.glob which enumerates matching files on disk, this is pure string matching (svn:ignore patterns, message routing, branch-spec matching).
  • string.glob_match_pathname(pattern, s) → int - Same as glob_match but * and ? do NOT cross a / separator. Use when matching path patterns: src/*.c matches src/foo.c but not src/sub/foo.c.

Sequences (*StringSeq Erlang/Elixir-shaped cons-cell list):

  • string.seq_empty() → *StringSeq empty list (NULL pointer)
  • string.seq_cons(head, tail) → *StringSeq prepend; retains both head and tail
  • string.seq_head(s) → string "" on empty
  • string.seq_tail(s) → *StringSeq empty seq on empty
  • string.seq_is_empty(s) → int 1 if empty
  • string.seq_length(s) → int O(1) cached
  • string.seq_retain(s) → *StringSeq bump refcount; pair with seq_free
  • string.seq_free(s) iterative spine walk; stops at shared cells
  • string.seq_from_array(arr, count) → *StringSeq build from an AetherStringArray* (the shape string.split returns)
  • string.seq_to_array(s) → ptr materialise as AetherStringArray* for legacy callers; free with string.array_free
  • string.seq_from_strings(items) → *StringSeq (#2301) build directly from a plain string[], for a runtime slice that isn't a [...] literal, where the compiler's own literal-to-seq lowering doesn't apply. items itself is untouched; each element is independently retained into the new seq.
  • string.seq_to_strings(s) → string[] (#2301) the reverse: an owned string[] snapshot of a seq's elements (each independently copied), walking the seq directly rather than through the AetherStringArray handle seq_to_array returns.
  • string.seq_reverse(s) → *StringSeq O(n), fresh independent spine
  • string.seq_concat(a, b) → *StringSeq O(|a|), a copied, b shared via refcount bump
  • string.seq_take(s, n) → *StringSeq first n elements (clamped to length, negative yields empty); fresh independent spine
  • string.seq_drop(s, n) → *StringSeq n-th tail retained (clamped to length, negative yields s retained); pointer walk only, no allocations
  • string.join(s, sep) → string concatenate every element with sep between adjacent pairs; the complement of split_to_seq. Linear cost: two passes (sum lengths, fill one exact-size buffer), one allocation regardless of element count. Empty seq yields "", a single element yields itself with no separator, and both elements and separator are binary-safe. Returns a fresh owned string.

Pattern-match [] and [h|t] arms work directly against *StringSeq matched expressions:

match s {
    []      -> { /* end of list */ }
    [h | t] -> { println(h); walk(t) }   // h: string, t: *StringSeq
}

Array literal [a, b, c] builds a cons chain when the target type is *StringSeq (in message-field initializers); see docs/sequences.md for the disambiguation rule and worked examples.

  • string.copy(s) - Return an independently-owned copy of s. Equivalent to string.concat(s, "") but with a discoverable name; callers use it to snapshot a borrowed TLS buffer before the next C call overwrites it.
  • string.format(fmt, args) - Format a string by substituting {} placeholders with entries from an std.list of strings. {{ and }} are literal braces. Use this for runtime-built strings of N parts where literal ${...} interpolation isn't an option (e.g. when the format string itself comes from a config file or message-template lookup). Non-string values must be converted via string.from_int(...) etc. before being added to the list.

For a split_once-style operation (find the first sep in s, return the halves), use string.index_of(s, sep) + two string.substring calls, two lines of code that avoid a tuple-unification foot-gun the typechecker currently has around three-string tuples.

Note: string + string is not defined. Use "${a}${b}" interpolation for literals or string.concat(a, b) for runtime-built strings; string.format(fmt, args) handles the N-part case. The typechecker rejects + between two string operands at compile time (E0200) with a hint naming "${a}${b}" interpolation and string.concat(a, b) it does NOT silently emit broken pointer arithmetic.

Conversion:

  • string.to_cstr(str) - Get raw C string pointer
  • string.from_int(value) - Create string from integer
  • string.from_float(value) - Create string from float
  • string.from_double(value) - Create lossless, round-trip-safe decimal string from float (binary64)

Parsing (Go-style):

  • string.to_int(s) → (int, string) - Parse base-10 integer
  • string.to_long(s) → (long, string) - Parse 64-bit integer
  • string.to_int_radix(s, radix) → (long, string) - Parse base-N integer; radix in [2, 36]. Accepts exactly an optional '-', one or more digits of the radix, and optional trailing whitespace: no "0x"/"0b" prefix, no '+', no leading whitespace. Returns long so 32-bit-wide hex (ARGB colors, file offsets) survives. Errors on invalid radix, invalid digit, empty input, overflow, or trailing garbage.
  • string.from_int_radix(value, radix) → string - Inverse of to_int_radix. Render value in base radix ([2, 36]); empty string on invalid radix; '-' prefix for negatives. Pair with pad_start for fixed-width hex bytes.
  • string.pad_start(s, total_width, pad_char) → string - Prepend pad_char (single-byte char code, e.g. 48 for '0', 32 for ' ') until s reaches total_width. Returns a fresh copy if s is already long enough (no truncation).
  • string.pad_end(s, total_width, pad_char) → string - Append-side variant of pad_start. Useful for columnar text output.
  • string.to_float(s) → (float, string) - Parse float
  • string.to_double(s) → (float, string) - Parse double

Each returns (value, "") on success or (0, "invalid ...") on parse failure. Except for to_int_radix (above), they handle leading whitespace, sign, trailing whitespace; reject trailing non-whitespace. to_float and to_double also read inf, infinity and nan in any case and with a sign, on every platform, so from_double's Infinity, -Infinity and NaN round-trip, and C99 hexadecimal constants such as 0x1.8p3, correctly rounded to the result type.

Raw out-parameter externs are preserved as string_to_int_raw, string_to_long_raw, string_to_float_raw, string_to_double_raw for callers who need to distinguish zero from parse failure without a tuple destructure.

Memory:

  • string.retain(str) - Increment reference count
  • string.release(str) - Decrement reference count (frees when zero)
  • string.free(str) - Alias for release
  • string.keep(s) → ptr - A reference of its own to s, as a ptr, for a @keep ptr parameter or a ptr field that holds the string past the call (E0902)
  • string.release_kept(p) - Gives back what string.keep took
  • string.from_ptr(p) → (string, string) - The string a ptr holds, typed as one: (null, "not a string") when p points at something else (a bare ptr given to a string local is refused, E0202)

Building strings incrementally (avoiding quadratic cost)

Self-append accumulation re-copies everything you have built so far on every iteration, so a loop that appends n pieces costs O(n²) in total bytes copied:

// SLOW: quadratic. Each iteration allocates a fresh buffer and copies
// the entire accumulation into it.
d = ""
while i < n {
    d = "${d}${piece}"           // same shape as string.concat(d, piece)
    i = i + 1
}

The trap is invisible at small n and catastrophic at large n: a real SVG path builder went from ~30 ms per call on a light font to seconds per call (wedging the host app's event loop) once a denser face pushed the outline-point count into the thousands.

Two linear-cost escapes, pick by shape.

Appending piece by piece: use std.strbuilder, which grows one buffer with amortized doubling:

import std.strbuilder

b = strbuilder.new(0)            // 0 = default capacity hint
i = 0
while i < n {
    strbuilder.append(b, piece)  // amortized O(len(piece))
    i = i + 1
}
d = strbuilder.finish(b)         // finalize; frees the builder

strbuilder also carries typed appends (append_int, append_long, append_hex, append_byte, append_codepoint) so number formatting doesn't route through an intermediate string.

Joining a sequence you already have: use string.join, which sizes one exact buffer in a first pass and fills it in a second:

d = string.join(parts, ", ")     // parts: *StringSeq

Neither escape changes the semantics of the naive form; they only change its cost. Reach for the builder when pieces arrive one at a time, join when the elements already exist as a *StringSeq.

String ownership

A place that holds a string (a local, a field, an element, a module var) owns one reference to it. A store takes the new string and releases the one it replaces, and leaving a scope releases what its locals hold, so you do not write defer string.free(s). A function returning a string hands its caller one reference whatever it returns: a fresh string, a literal, or a parameter it borrowed. A loop that rebinds a string therefore runs in constant memory:

import std.string

my_concat(a: string, b: string) -> string {
    return string.concat(a, b)        // a fresh string, moved to the caller
}

main() {
    s = ""
    i = 0
    while i < 1000 {
        s = my_concat(s, "x")         // the store releases the string it replaces
        i = i + 1
    }
    println(string.length(s))
}
1000

The rules, and how C strings cross the boundary, are in Memory Management.


Regular expressions (std.regex)

PCRE2-backed matching: compile a pattern once, then match, capture, find every occurrence, or replace. A compiled pattern is a handle the caller frees; compiling is the expensive half, so hoist it out of a loop.

compile returns (handle, error) (a malformed pattern is an ordinary error value, not a crash), and so do captures, replace and replace_all. find returns the span of the first match as (start, end, error), with start == -1 for no match.

import std.regex
import std.string

main() {
    re, err = regex.compile("([a-z]+)-([0-9]+)")
    if string.length(err) > 0 { println("bad pattern: ${err}"); return }
    defer regex.free(re)

    line = "build-427 ran after build-426"
    println("matches: ${regex.matches(re, line)}")

    // find gives the span of the first match, or start == -1 for none.
    start, end, _ferr = regex.find(re, line)
    println("first match spans ${start}..${end}")

    caps, cerr = regex.captures(re, line)
    if string.length(cerr) == 0 {
        println("name ${regex.capture(caps, 1)}, number ${regex.capture(caps, 2)}")
        regex.captures_free(caps)
    }

    out, _rerr = regex.replace_all(re, line, "job")
    println(out)
}
matches: 1
first match spans 0..9
name build, number 427
job ran after job

Functions:

  • regex.compile(pattern) → (ptr, string) - Compile; the error names what PCRE2 objected to
  • regex.compile_flags(pattern, flags) → (ptr, string) - The same, with PCRE2 option bits
  • regex.free(re) - Release a compiled pattern
  • regex.matches(re, s) → int - 1 when the pattern matches anywhere in s
  • regex.find(re, s) → (int, int, string) - Byte span of the first match; start == -1 for none
  • regex.captures(re, s) → (ptr, string) - Capture set for the first match; null with an empty error means no match
  • regex.capture(caps, i) → string - Group i (0 = the whole match); "" when out of range or unset
  • regex.capture_count(caps) → int, regex.capture_start(caps, i) / regex.capture_end(caps, i) → int
  • regex.captures_free(caps) - Release a capture set
  • regex.replace(re, s, repl) / regex.replace_all(re, s, repl) → (string, string) - Replace the first / every match
  • regex.last_error() → string, regex.clear_last_error() - The thread's last PCRE2 error

File System

Files (std.file)

Go-style tuple returns. Check the error string first, then use the value.

import std.file

main() {
    // Check existence
    if file.exists("data.txt") == 1 {
        size, err = file.size("data.txt")
        if err == "" { println("Size: ${size} bytes") }
    }

    // Read entire file (opens, reads, closes in one call)
    content, rerr = file.read("data.txt")
    if rerr != "" {
        println("read failed: ${rerr}")
        return
    }
    println(content)

    // Write
    werr = file.write("output.txt", "Hello")
    if werr != "" {
        println("write failed: ${werr}")
        return
    }

    // Delete
    derr = file.delete("temp.txt")
    _ = derr  // ignore if missing
}

Functions:

  • file.read(path) → (string, string) - Read entire file (opens, reads, closes)
  • file.write(path, content) → string - Overwrite file, return error string
  • file.open(path, mode) → (ptr, string) - Low-level open (caller must file.close)
  • file.close(handle) - Close file
  • file.size(path) → (int, string) - Get size in bytes
  • file.delete(path) → string - Delete file
  • file.exists(path) - 1 if a regular file is at path, 0 otherwise. Returns 0 for directories, even if they exist, see fs.exists for the path-agnostic check.

Raw externs: file_open_raw, file_read_all_raw, file_write_raw, file_delete_raw, file_size_raw.

Directories (std.dir)

import std.dir

main() {
    // Check and create
    if dir.exists("output") == 0 {
        err = dir.create("output")
        if err != "" { println("mkdir failed: ${err}") }
    }

    // List contents, with each entry's kind straight from readdir's d_type
    // (no per-entry stat needed).
    list, lerr = dir.list(".")
    if lerr == "" {
        n = dir.list_count(list)
        i = 0
        while i < n {
            name = dir.list_get(list, i)
            kind = dir.list_kind(list, i)      // 1 file / 2 dir / 3 symlink / 4 other / 0 unknown
            if kind == 2 { println("[dir]  ${name}") }
            i = i + 1
        }
        dir.list_free(list)
    }

    // Delete
    dir.delete("temp_dir")
}

Functions:

  • dir.create(path) → string - Create directory, return error string
  • dir.delete(path) → string - Delete empty directory, return error string
  • dir.list(path) → (ptr, string) - List contents (caller must dir.list_free)
  • dir.list_count(list) → int - Number of entries
  • dir.list_get(list, index) → string - Entry name at index
  • dir.list_kind(list, index) → int - Entry's file kind from readdir's d_type, avoiding a stat(2) per entry: 1 = file, 2 = directory, 3 = symlink (target not followed), 4 = other, 5 = socket, 6 = FIFO, 7 = device; 0 = unknown (the filesystem didn't report a type, stat that entry to resolve it). Same encoding as file_stat's kind. Kinds 5-7 are POSIX-only: Windows reports those nodes as 4. The named constants fs.STAT_KIND_FILE, STAT_KIND_DIR, STAT_KIND_SYMLINK, STAT_KIND_OTHER, STAT_KIND_SOCKET, STAT_KIND_FIFO and STAT_KIND_DEVICE are exported from std.fs, prefer them over the bare numbers.
  • dir.exists(path) - 1 if a directory is at path, 0 otherwise. Returns 0 for regular files, even if they exist, see fs.exists for the path-agnostic check.
  • dir.list_free(list) - Free directory listing

Raw externs: dir_create_raw, dir_delete_raw, dir_list_raw, dir_list_count, dir_list_get, dir_list_kind, dir_list_free.

Recursive walk and change notification (fs.walk, fs.watch_*)

The building blocks beyond one-level listing: visit a whole tree, and learn when a directory changes underneath you.

import std.fs

// Walk: the callback sees every entry with its kind and depth.
n, err = fs.walk(root, |path: string, kind: int, depth: int| {
    // kind: 1 file / 2 dir / 3 symlink / 4 other / 5 socket / 6 fifo / 7 device
    // (same encoding as file_stat; see fs.STAT_KIND_*)
    if kind == 2 && string.ends_with(path, "/node_modules") == 1 {
        return fs.WALK_SKIP_SUBTREE   // prune: don't descend into it
    }
    println("${depth} ${path}")
    return fs.WALK_CONTINUE           // WALK_CONTINUE 0 · WALK_SKIP_SUBTREE 1 · WALK_STOP 2
})

// Collecting paths: `path` is borrowed for the callback, and the list
// takes a string of its own, so it is stored as it is.
found = list.new()
n2, err2 = fs.walk(root, |path: string, kind: int, depth: int| {
    if kind == 1 && string.ends_with(path, ".md") == 1 {
        list.add(found, path)
    }
    return 0
})

// Watch: coarse change ping, re-list to see what changed.
w, werr = fs.watch_open(dir)
changed = fs.watch_wait(w, 1000)   // 1 changed / 0 timeout / -1 error
fs.watch_close(w)

Functions:

  • fs.walk(path, cb) → (int, string) - Visit path (depth 0) and every entry beneath it. Entry kinds come from readdir's d_type, one sweep per directory, no per-entry stat(2). Symlinks are reported (kind 3) but never followed, so cycles are impossible. path inside the callback is borrowed: it points into the buffer the walk rewrites for the next entry and is valid only until the callback returns. A place that keeps it takes a string of its own (list.add(paths, path), last = path), so storing it needs no copy. Traversal order within a directory is the platform's readdir order (unspecified). Returns (entries visited, ""), or (0, error) when path can't be read.
  • fs.watch_open(path) → (ptr, string) - Watch one directory (or file), non-recursive, over the platform primitive: kqueue EVFILT_VNODE (macOS/BSD), inotify (Linux), FindFirstChangeNotification (Windows). The handle is single-threaded.
  • fs.watch_wait(watch, timeout_ms) → int - Block up to timeout_ms (negative = forever): 1 = something changed (create/delete/modify/rename inside the watched directory), 0 = timeout, -1 = error. Changes made between watch_open and watch_wait are queued, not lost, and a burst of changes reports once (pending events are drained).
  • fs.watch_close(watch) - Release the handle. Safe on null.

The watch event is deliberately coarse, a "something changed here" ping without the file name (that is the only semantics all three platform primitives share; kqueue in particular reports no names). The idiomatic pattern is: wake on the ping, re-list with dir.list + dir.list_kind, diff against what you rendered. An actor can own the watch and poll with a short timeout in a self-send loop for live refresh.

Paths (std.path)

Path functions return heap-allocated plain strings (char*). Use defer free(result) if you want explicit cleanup.

import std.path

main() {
    joined = path.join("dir", "file.txt")
    println(joined)                            // "dir/file.txt"

    dirname = path.dirname("/a/b/file.txt")    // "/a/b"
    basename = path.basename("/a/b/file.txt")  // "file.txt"
    ext = path.extension("file.txt")           // ".txt" (includes dot)
    is_abs = path.is_absolute("/usr/bin")      // 1

    println("${dirname}/${basename}")
}

Functions:

  • path.join(a, b) - Join path components
  • path.dirname(path) - Get directory name
  • path.basename(path) - Get file name
  • path.extension(path) - Get file extension including dot
  • path.is_absolute(path) - Check if absolute path (returns 1/0)
  • path.clean(path) → string - Lexical normalize: collapses //, resolves . and .., drops a trailing separator. Purely textual, it never touches the filesystem, so unlike fs.realpath it works on paths that do not exist yet, which is the case when you are computing an output path before creating it. On Windows it understands both separators and a C: / UNC volume prefix, and emits the platform separator.
  • path.join_clean(a, b) → string - join followed by clean in one call. Use this rather than join whenever b is caller-supplied (an object key, an archive entry name) so a .. is resolved before the path reaches the filesystem: path.join_clean("bucket", "a/../b") is "bucket/b". Pair with is_within_base.
  • path.is_within_base(base, target) → int - 1 if target lies within base after both are cleaned, else 0. The lexical pre-open check for a blob store, static-file server or archive extractor: reject the request before you open it. Comparison follows platform rules, so on Windows it accepts either separator and is case-insensitive. Symlinks are not followed, a link under base pointing outside is an open-time concern.
  • path.rel(base, target) → string - The relative path from base to target, such that joining it onto base and cleaning yields target. Returns empty when there is no such path (one absolute and one relative, different Windows volumes, or a base that climbs above where target can be reached from, as rel("../a", "b")).
  • path.separator() → string - The platform path separator, "/" on POSIX and "\\" on Windows. Use it instead of hardcoding a separator.

Full-fat filesystem (std.fs)

std.file / std.dir / std.path cover most calls; std.fs re-exports them and adds the accessors that need a bit more plumbing, durable writes, atomic rename, one-shot stat, binary-safe read.

import std.fs

main() {
    // Durable write: staging + fsync + rename.
    err = fs.write_atomic("config.json", string.bytes(body))

    // Rename composes with write_atomic for stage-then-publish.
    err = fs.rename("config.json.new", "config.json")

    // One stat, four fields.
    kind, size, mtime, err = fs.file_stat("config.json")
    //   kind: 1=file, 2=dir, 3=symlink, 4=other

    // Binary-safe read with explicit length.
    data, n, err = fs.read_binary("payload.bin")
}

Functions (beyond those re-exported from std.file/std.dir/std.path):

  • fs.exists(path) → int - Path-agnostic existence check: 1 if anything is at path (regular file, directory, symlink, fifo, ...), 0 otherwise. Distinct from file.exists (regular-file-only) and dir.exists (directory-only), those filter by type, this one doesn't. Uses lstat(2) so a dangling symlink counts as existing, matches POSIX test -e. Reach for this in tooling that probes whether a path is bound without caring what's there (build-system runtime-path discovery, "did the user pass a real path?" CLI validation).
  • fs.write_atomic(path, data: byte[]) → string - Stage to <path>.tmp.<pid>.<n>, fsync, rename over destination. Binary-safe: the length travels with the slice (string.bytes(s) for a string, bytes.view(buf) for a std.bytes buffer). The tmp file is created with O_CREAT|O_EXCL|O_NOFOLLOW so an attacker who pre-plants a symlink at the predictable tmp path can't trick the write into following it; permissions track the process umask exactly as the previous fopen("wb") would have.
  • fs.write_binary(path, data: byte[]) → string - Non-atomic fopen("wb") + fwrite + fclose. Binary-safe: the length travels with the slice. Cheaper than write_atomic when a partial file on crash is acceptable (scratch writes, caches).
  • fs.rename(from, to) → string - POSIX rename(2) wrapper. Atomic when source and target are on the same filesystem.
  • fs.create_dir_with_mode(path, mode) → string - Like fs.create_dir but takes an explicit POSIX mode (0777-masked). Use this for private dirs (e.g. 0o700 for keys), sets the bits at creation time, closing the mkdir → chmod race window. Windows ignores the mode at the directory layer; the parameter is accepted for portability.
  • fs.mtime(path) → (int, string) - File's mtime as Unix epoch seconds, in the standard (value, err) shape. Distinguishes "stat failed" from "file's mtime is 0 (1970 epoch)", the older file_mtime extern collapsed both into a single 0 sentinel and is kept only for back-compat.
  • fs.file_stat(path) → (kind, size, mtime, err) - One lstat(2); symlinks report kind 3, target is not followed. Kind is 1 = file, 2 = directory, 3 = symlink, 4 = other, 5 = socket, 6 = FIFO, 7 = device (char and block devices share 7). Sockets, FIFOs and devices are distinguished on POSIX only; Windows reports them as 4. Use the fs.STAT_KIND_* constants rather than the literals.
  • fs.is_socket(path) → int - 1 if path is a UNIX-domain socket, 0 otherwise (including when nothing is there). Unlike fs.is_symlink this follows symlinks, since a caller asking "is this a socket" wants the target. POSIX only; always 0 on Windows. Pair it with os.user_id() to locate a per-user runtime socket such as /run/user/${os.user_id()}/podman/podman.sock.
  • fs.read_binary(path) → (content, length, err) - Length-aware read preserving embedded NULs.

Structured-error pilot

The four wrappers below return a three-element tuple (value, kind: int, message: string) instead of the usual (value, err) shape. kind is one of the KIND_* constants exported from std.fs; switch on it to discriminate failure modes programmatically without parsing English. kind == fs.KIND_OK (the integer 0) means success; the message stays empty.

import std.fs

bytes, kind, msg = fs.copy("a.bin", "b.bin")
if kind == fs.KIND_OK {
    println("copied ${bytes} bytes")
} else if kind == fs.KIND_NOT_FOUND {
    println("source missing")
} else if kind == fs.KIND_PERMISSION_DENIED {
    println("permission denied (${msg})")
} else {
    println("copy failed: ${msg}")
}

fs.copy, fs.move, fs.realpath, and fs.chmod all ship with this structured-error shape. Existing wrappers keep their (value, err) shape unchanged, the structured-error shape sits next to it, not in place of it.

Constants (exported from std.fs):

Constant Value Errno
KIND_OK 0 (none, success)
KIND_NOT_FOUND 1 ENOENT
KIND_PERMISSION_DENIED 2 EACCES / EPERM
KIND_EXISTS 3 EEXIST
KIND_CROSS_DEVICE 4 EXDEV
KIND_IO 5 EIO and unspecified I/O errors
KIND_INVALID 6 EINVAL (illegal argument; e.g. src == dst)
KIND_LOOP 7 ELOOP (symlink cycle)
KIND_NAME_TOO_LONG 8 ENAMETOOLONG
KIND_NO_SPACE 9 ENOSPC
KIND_IS_DIR 10 EISDIR
KIND_NOT_DIR 11 ENOTDIR
KIND_UNAVAILABLE 99 platform feature compiled out

Functions:

  • fs.copy(src, dst) → (int, int, string) Copy file contents; preserves source mode bits. Symlinks in src are followed (matches POSIX cp without -P); dst is overwritten if it exists; dst cannot be an existing directory (returns KIND_IS_DIR). On partial failure, the bytes count reflects how far the copy got.

    Performance: zero-copy via the platform's best primitive, Linux copy_file_range(2) (reflinks on btrfs/XFS) → sendfile(2); macOS fcopyfile(COPYFILE_DATA) (APFS clone on same-volume); Windows CopyFileExW (kernel block copy). An 8 MiB read/write loop is the portable fallback for filesystems that reject the kernel primitives. The byte count saturates at INT_MAX for files larger than 2³¹ bytes, the data is still copied correctly; only the reported count is truncated.

  • fs.move(src, dst) → (int, int, string) Move file from src to dst. Atomic when source and destination are on the same filesystem (POSIX rename(2)). On EXDEV (cross-device) the call transparently falls back to fs.copy + unlink correct, but no longer atomic. Cross-device directory moves surface as KIND_IS_DIR (the underlying copy refuses to recurse). Windows uses MoveFileExW with MOVEFILE_REPLACE_EXISTING | MOVEFILE_COPY_ALLOWED so the cross-fs case is handled internally.

  • fs.realpath(path) → (string, int, string) OS-canonicalise the path: every symlink is followed; . and .. components are folded out. POSIX realpath(3); on Windows CreateFileW(FILE_FLAG_BACKUP_SEMANTICS) + GetFinalPathNameByHandleW(FILE_NAME_NORMALIZED), with the \\?\ prefix stripped before returning. Common kinds: KIND_NOT_FOUND for a missing component, KIND_LOOP for symlink cycles, KIND_NAME_TOO_LONG if the resolved form exceeds the OS limit.

  • fs.chmod(path, mode) → (int, int, string) Change permission bits. POSIX chmod(2) follows symlinks (matches what shell chmod does); mode is masked with 07777 internally so set-uid/sgid/sticky high-bits are honoured. On Windows only the user-write bit (0o200) is meaningful, read-only is toggled via SetFileAttributesW(FILE_ATTRIBUTE_READONLY); every other bit is silently ignored, matching Python's os.chmod documented behaviour.


In-process script gateway (std.http.script_gateway)

CGI-style ergonomics, one .ae script file per route, without paying the per-request fork/exec cost. The script is pre-compiled with aetherc --emit=lib --with=net script.ae -o script.so to a shared library; the host server dlopen()s it once at mount time and dispatches matched requests via a direct indirect call. Empirically ~50× faster than the equivalent subprocess-spawn dispatch.

Script shape

The script must export an aether_script_handle symbol with the canonical HttpHandler signature, marked @c_callback so the emitted C uses the unmangled name:

// greeting.ae
import std.http

@c_callback aether_script_handle(req: ptr, res: ptr, ud: ptr) {
    http.response_set_status(res, 200)
    http.response_set_header(res, "Content-Type", "text/plain")
    http.response_set_body(res, "hello from greeting.ae\n")
}

Build it as a shared library, --with=net is required because std.http is the net capability and --emit=lib is capability-empty by default:

aetherc --emit=lib --with=net greeting.ae -o /var/aether/scripts/greeting.so

Mount in the host

import std.http
import std.http.script_gateway

main() {
    s = http.server_create(8080)
    ok, kind, msg = script_gateway.mount(
        s, "/greet", "/var/aether/scripts/greeting.so")
    if kind != script_gateway.KIND_OK {
        println("mount failed: ${msg}"); exit(1)
    }
    http.server_start(s)
}

Now GET /greet/anything runs aether_script_handle from greeting.so directly on the connection thread.

On Windows the script is a DLL loaded with LoadLibrary. A POSIX host links with -rdynamic, so a script's runtime calls bind to the host's own runtime; Windows has no equivalent, so the host and each script are built on the shared runtime (aether.dll) and run on it together:

ae build --emit=lib --shared-runtime --with=net greeting.ae -o greeting.dll
ae build --shared-runtime host.ae -o host

A host or script on its own static runtime is refused at mount with KIND_IO and a message naming --shared-runtime.

API

  • script_gateway.mount(server, path_prefix, so_path) → (int, int, string) Mount the shared library at so_path as the request handler for every URL whose path starts with path_prefix. Returns (1, KIND_OK, "") on successful mount; (0, KIND_*, msg) on failure. The dlopen handle is intentionally long-lived (process-lifetime); hot-reload is a separate feature.

Constants (subset of std.fs's KIND_*, values match so callers can mix the two surfaces):

Constant Value Meaning
KIND_OK 0 mount succeeded
KIND_NOT_FOUND 1 so_path is missing or unreadable
KIND_INVALID 6 null arg, or .so missing the aether_script_handle entrypoint
KIND_IO 5 dlopen / LoadLibrary failure (incompatible ABI, etc.); on Windows also a host or script not built with --shared-runtime
KIND_UNAVAILABLE 99 a platform with no dynamic loader; no supported platform returns it

Sandbox

mount() calls aether_sandbox_check("fs_read", so_path) before dlopen, so a sandboxed host cannot bring in arbitrary native code via untrusted .so paths. The script itself runs subject to whatever caps the host has armed via libaether.h (aether_set_memory_cap, aether_set_call_deadline).

Performance characteristic

Per-request hot path is one strncmp(path, prefix, prefix_len) plus one indirect call. On Linux/glibc the indirect call goes through the dlopened SO's PLT once and is then jit-bound; subsequent calls are direct. On macOS (lazy bind disabled by RTLD_NOW) the binding happens at mount time so per-request cost is one direct indirect call.


JSON (std.json)

JSON parsing, creation, and serialization. RFC 8259 conformant (318/318 JSONTestSuite cases, all mandatory y_* and n_* pass).

Implementation. Arena-allocated parser: every parsed value, string, and container backing array comes from a single per-document bump-pointer arena that json.free releases in one step. Character-class lookup tables drive the hot-path dispatch (whitespace, digit, structural, string-safe). UTF-8 validation uses Hoehrmann's public-domain DFA. Numbers follow a three-path design: pure integers go through an int64 accumulator; fractional/exponential values within double's exact range (≤15 significant digits, |exponent| ≤ 22) take a POW10 fast-double path with one multiply and one cast; anything past those bounds falls back to strtod for correct IEEE-754 rounding. Strings are decoded in two phases, a pre-scan locates the closing quote so the decode buffer is sized exactly once, and the inner fast-loop over safe printable-ASCII bytes dispatches to SSE2 on __SSE2__, NEON on __ARM_NEON && __aarch64__, or a scalar LUT fallback compiled in for WASM / embedded / anywhere else. The SIMD kernels are gated at compile time, not run-time detected, so there's no branch cost on the happy path and the scalar fallback is always linked. Compiles clean under -Wall -Wextra -Werror -pedantic on every target in the CI matrix. Design rationale in json-parser-design.md; measured throughput in benchmarks/json/baseline.md.

Security. ASan+UBSan clean on the full bench corpus including a 10 MB synthesized document. JSONTestSuite conformance: every y_* case accepted, every n_* case rejected, i_* outcomes recorded. Fixed nesting depth limit of 256 prevents stack-overflow DoS. First-error-wins diagnostics include <reason> at <line>:<col>.

import std.json

main() {
    // Parse JSON string, Go-style tuple return
    data, err = json.parse("{\"name\": \"Aether\", \"version\": 1}")
    if err != "" {
        println("parse failed: ${err}")
        return
    }
    name, _ = json.object_get(data, "name")
    text, _ = json.get_string(name)
    println(text)  // "Aether"

    // Create values
    obj = json.obj()
    json.set(obj, "key", json.str("value"))
    json.set(obj, "count", json.num(42.0))

    // Arrays
    arr = json.arr()
    json.push(arr, json.num(1.0))
    json.push(arr, json.num(2.0))
    size = json.array_size(arr)

    // Serialize to string, Go-style (output, err) tuple
    output, _ = json.encode(obj)
    println("JSON: ${output}")

    // Type checking
    type = json.type(json.num(3.0))  // 2 = JSON_NUMBER

    // Cleanup
    json.free(data)
    json.free(obj)
    json.free(arr)
}

JSON Type Constants:

  • 0 = NULL, 1 = BOOL, 2 = NUMBER, 3 = STRING, 4 = ARRAY, 5 = OBJECT

Parsing / Serialization:

  • json.parse(json_str) → (ptr, string) - Parse JSON, returns (value, err) tuple
  • json.parse_strict(json_str) → (ptr, int, string) - Structured-error variant of parse: returns (value, KIND_OK, "") on success or (null, KIND_*, "<reason> at <line>:<col>") on failure. kind discriminates between syntax errors (KIND_PARSE_ERROR), out-of-memory (KIND_OUT_OF_MEMORY), and invalid input (KIND_INVALID_INPUT) without parsing the human message. KIND values match std.fs's pilot so callers can mix the two surfaces in a single switch.
  • json.last_error_kind() / last_error_line() / last_error_col() → int - Programmatic accessors for the most recent parse failure on this thread. Read AFTER a parse / parse_strict returned a failure; undefined after a successful parse. Line/column are 1-based and match the values embedded in the error message.
  • json.encode(value) → (string, string) - Serialize to JSON string; (output, err) tuple (("", "stringify failed") on failure)
  • json.free(value) - Free a JSON value tree

Raw extern: json_parse_raw.

Structured-error kinds (exported from std.json):

Constant Value Meaning
KIND_OK 0 parse succeeded
KIND_PARSE_ERROR 1 malformed JSON (the message carries ... at line:col)
KIND_OUT_OF_MEMORY 2 arena allocation failed during parse
KIND_INVALID_INPUT 3 NULL / empty input handed to parse_strict
import std.json

main() {
    v, kind, msg = json.parse_strict(input)
    if kind == json.KIND_OK {
        // use v ...
        json.free(v)
    } else if kind == json.KIND_PARSE_ERROR {
        line = json.last_error_line()
        col  = json.last_error_col()
        println("syntax error at ${line}:${col}: ${msg}")
    } else if kind == json.KIND_OUT_OF_MEMORY {
        println("OOM during parse")
    } else {
        println("invalid input: ${msg}")
    }
}

The parse_strict shape is the std.fs structured-error pilot extended to a second module, same tuple, same KIND_* convention.

Type Checking:

  • json.type(value) - Get type constant (0-5)
  • json.is_null(value) - Check if null (returns 1/0)
  • json.is_integer(value) - Check if a number was written as an integer within int64 (42, not 42.0 or 4e1) (returns 1/0)

Value Getters:

  • json.get_number(value) - Get float value (lossy past 2^53)
  • json.get_int(value) - Get 32-bit integer (clamps to +/-2147483647 on overflow)
  • json.get_long(value) - Get the full int64 value exactly (IDs, byte-counts); a number outside the int64 range clamps to INT64_MIN / INT64_MAX
  • json.get_bool(value) - Get boolean (1/0)
  • json.get_string(value) → (string, string) - Get string value; (text, err) tuple, errors with "not a string" if value is not a JSON_STRING

Object Operations:

  • json.object_get(obj, key) → (ptr, string) - Get value by key; (child, err) tuple. Absent key returns (null, ""), distinct from the error case (null, "not an object")
  • json.set(obj, key, value) - Set key-value pair
  • json.object_has(obj, key) - Check if key exists (returns 1/0)
  • json.object_size(obj) - Number of entries (0 for empty, -1 if not an object)
  • json.object_entry(obj, i) - (key, value, err) for the i-th entry; keys are yielded in insertion order (same as parsed input; same as the order object_set was called). Mutating obj during iteration is not supported.

Array Operations:

  • json.array_get(arr, index) → (ptr, string) - Get value at index; (value, err) tuple. Out-of-range returns (null, "index out of range"), distinct from (null, "not an array")
  • json.push(arr, value) - Append value
  • json.array_size(arr) - Get array length

Value Creation:

  • json.null_value(), json.boolean(value), json.num(value)
  • json.str(value), json.arr(), json.obj()

Terse builder / encoder, thin aliases over the above for assembling a value tree and serializing it without hand-concatenating strings (escaping is handled by the encoder):

  • json.obj() / json.arr() new empty object / array
  • json.str(s) / json.num(f) / json.boolean(0|1) / json.null_value() scalars
  • json.set(obj, key, value) / json.push(arr, value) "" on success, error string on wrong-kind target (the orphaned value is reclaimed); the parent takes ownership of value
  • json.encode(value) → (string, string) (json, "") on success
import std.json

main() {
    root = json.obj()
    json.set(root, "name", json.str("aether"))
    json.set(root, "ok", json.boolean(1))
    tags = json.arr()
    json.push(tags, json.str("lang"))
    json.set(root, "tags", tags)

    out, err = json.encode(root)   // {"name":"aether","ok":true,"tags":["lang"]}
    println(out)
    json.free(root)                // frees the whole tree
}

What std.json doesn't do

Coming from Go's json.Unmarshal, Java's Jackson, Python's json.load + dataclasses, or C#'s JsonSerializer, expect to do more by hand:

  • No struct ↔ JSON mapping. Aether has no runtime reflection, no instanceof, no T.GetType(), no reflect.TypeOf so a library function that takes a struct type and a JSON tree and populates the struct fields can't exist as a stdlib API. Callers walk the tree by hand: json.object_get(v, "name") then json.get_string(...), repeated per field. For tree-shaped or dynamically-shaped JSON the Aether code looks similar to other languages; for struct-shaped JSON it's more verbose. A future codegen step (a --derive-json flag on struct definitions, or a build-step macro) could close this gap without runtime reflection, but isn't shipped today.
  • No annotations / struct tags. @JsonProperty("user_name"), Go struct tags json:"user_name,omitempty", etc. don't apply, there's nothing for them to attach to without struct-mapping in the first place.
  • No streaming parse. The whole document is buffered into the arena before the tree is walkable. For multi-gigabyte JSON, use a different tool. Documents into the tens of MB are fine.
  • No JSON5 / comments / trailing commas. Strict RFC 8259 only.
  • No pretty-print on stringify. Compact output only. Wrap with a separate prettier if you need one.
  • Declarative validation is std.schema: typed records, composable validators, parse -> (values, errors), and to_json_schema() to emit a draft-07 JSON Schema. It does not consume external JSON Schema documents; validate against a std.schema record, or build that on top.
  • No arbitrary-precision numbers. Numbers are int or double; the parser falls through to strtod for correctly-rounded IEEE-754 on edge cases but there's no BigDecimal / decimal.Decimal equivalent for financial precision.

Other structured-data formats

Each of these has its own section below:

  • MessagePack (std.msgpack) and CBOR (std.cbor), binary encodings of the same value model JSON has.
  • YAML (std.yaml), YAML 1.2 parsing and emitting.
  • XML (std.xml), a pull reader and an escaping builder. XSD, XPath, namespaces and DTD validation are outside it.
  • CSV (std.encoding), csv_split splits one record on a separator you choose and trims a \r\n line ending. It does not interpret quotes, so a field holding the separator inside quotes splits there.

What every one of them shares with std.json: values are built and walked by hand. Aether has no runtime reflection, so no codec maps a struct to an encoding and back automatically.

These have no stdlib module:

  • TOML. ae reads aether.toml with its own parser (tools/apkg/toml_parser.c), which is part of the build tool rather than something a program imports.
  • INI and Java .properties: line-oriented enough that string.split on newlines and then on = reads them.
  • Protocol Buffers, Avro, Thrift: schema-driven formats, which need code generated from the schema; nothing in the toolchain generates it.

MessagePack (std.msgpack)

A compact binary encoding of the JSON value model. Build a value, pack it to bytes, unpack bytes back into one. Values are handles: freeing a container frees what it holds, so one free at the top is enough.

import std.msgpack
import std.string

main() {
    // Build a value, pack it to bytes, read it back.
    m = msgpack.map()
    defer msgpack.free(m)
    _r = msgpack.map_set(m, "id", msgpack.from_int(42))
    _r = msgpack.map_set(m, "name", msgpack.str("widget"))

    packed = msgpack.pack(m)
    println("packed ${string.length(packed)} bytes")

    back, err = msgpack.unpack(packed)
    if string.length(err) > 0 { println("bad payload: ${err}"); return }
    defer msgpack.free(back)

    println("id ${msgpack.get_int(msgpack.map_get(back, "id"))}")
    println("name ${msgpack.get_string(msgpack.map_get(back, "name"))}")
    println("keys ${msgpack.map_size(back)}")
}
packed 17 bytes
id 42
name widget
keys 2

Functions:

  • msgpack.nil_value() / boolean(b) / from_int(n) / num(f) / str(s) / bin(s) → ptr - Scalars
  • msgpack.arr() / msgpack.map() → ptr - Containers
  • msgpack.ext(type_id, data) → ptr, msgpack.get_ext_type(v) → int - An extension value and its application-defined type id
  • msgpack.array_add(a, v), msgpack.map_set(m, key, v) - Build
  • msgpack.array_size(a) / array_get(a, i), msgpack.map_size(m) / map_get(m, key) / map_get_key(m, i) / map_get_value(m, i) - Read
  • msgpack.get_type(v) → int, and get_bool / get_int / get_float / get_string / get_bin - Unwrap
  • msgpack.get_int64(v) → (long, string) - The integer, or an error for a uint 64 of 2^63 or more; msgpack.get_uint(v) → (long, string) - A non-negative integer as the bit pattern of a long; msgpack.from_uint(bits) builds one
  • msgpack.pack(v) → string, msgpack.unpack(bytes) → (ptr, string) - unpack reads exactly one value; trailing bytes are an error
  • msgpack.free(v) - Release a value and everything under it

TYPE_NIL, TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STR, TYPE_BIN, TYPE_ARRAY, TYPE_MAP and TYPE_EXT are what get_type returns.

CBOR (std.cbor)

RFC 8949: the binary shape JSON has, with the same value model, a compact encoding, and tags for types JSON cannot express. It is what COSE, WebAuthn and a good deal of IoT protocol traffic are built on.

diagnose is the one to know about: it renders a value in RFC 8949 diagnostic notation, which is how to see what an encoding actually contains when it is not what you expected. (stringify is an alias for encode and returns bytes, not text.)

import std.cbor
import std.string

main() {
    // CBOR (RFC 8949) is the binary shape JSON has: the same value model,
    // a compact encoding, and a canonical form suitable for signing.
    o = cbor.obj()
    defer cbor.free(o)
    _e = cbor.set(o, "id", cbor.from_int(42))
    _e = cbor.set(o, "name", cbor.str("widget"))

    encoded, eerr = cbor.encode(o)
    if string.length(eerr) > 0 { println("encode: ${eerr}"); return }
    println("encoded ${string.length(encoded)} bytes")

    back, perr = cbor.parse(encoded)
    if string.length(perr) > 0 { println("parse: ${perr}"); return }
    defer cbor.free(back)

    id, _ierr = cbor.object_get(back, "id")
    println("id ${cbor.get_int(id)}")

    // diagnose() gives RFC 8949 diagnostic notation -- the readable form
    // to reach for when an encoding is not what you expected. (stringify()
    // is an alias for encode(), and returns the bytes.)
    d, _derr = cbor.diagnose(back)
    println("diag ${d}")
}
encoded 18 bytes
id 42
diag {"id": 42, "name": "widget"}

Functions:

  • cbor.from_int(n) / num(f) / str(s) / bytes(s, len) / boolean(b) / null_value() / undefined_value() → ptr - Scalars
  • cbor.obj() / cbor.arr() → ptr, cbor.tag(n, child) → ptr - Containers and tags
  • cbor.set(o, key, v) / object_set / map_set, cbor.push(a, v) / array_add - Build
  • cbor.object_get(o, key) → (ptr, string), cbor.map_get, cbor.object_size / array_size / object_entry - Read
  • cbor.type(v) → int, cbor.is_null(v) / is_undefined(v), and get_bool / get_int / get_long / get_float / get_string / get_bytes / get_tag_val / get_tag_child - Unwrap
  • cbor.get_int64(v) → (long, string) - The integer, or an error when it is outside the signed 64-bit range; cbor.get_uint(v) / cbor.get_nint(v) → (long, string) - A non-negative integer, or the argument n of a negative one (-1 - n), as the bit pattern of a long, so all of CBOR's 0..2^64-1 and -2^64..-1 is reachable; cbor.from_uint(bits) / cbor.from_nint(bits) build them
  • cbor.encode(v) → (string, string), cbor.parse(bytes) → (ptr, string) - parse reads exactly one well-formed data item: trailing bytes, anything RFC 8949 Appendix F lists as not well-formed, and invalid UTF-8 in text are errors. A parsed value encodes back byte for byte; a built one in preferred serialization
  • cbor.diagnose(v) → (string, string) - Diagnostic notation, exact: shortest round-trip floats, JSON text escapes, and the RFC 8949 §8.1 encoding indicators ([_ 1], 1.5_3) where a parsed item was not in preferred serialization
  • cbor.free(v) - Release a value and everything under it

CBOR_INT, CBOR_BYTES, CBOR_TEXT, CBOR_ARRAY, CBOR_MAP, CBOR_TAG, CBOR_SIMPLE and CBOR_FLOAT are what type returns.

YAML (std.yaml)

Read-only YAML parsing plus emission, over libfyaml. A document is a handle you free; nodes inside it are borrowed from the document and must not outlive it.

This module needs libfyaml at build time. Where the library is absent every entry point returns the error std.yaml unavailable: this build has no libfyaml, rather than failing to link, so a program that imports it still builds, and says so at runtime. That is also why the example below is compile-checked rather than run: its output depends on how the toolchain was built.

import std.yaml
import std.string

main() {
    doc, err = yaml.parse("name: widget\nports:\n  - 8080\n  - 8443\n")
    if string.length(err) > 0 { println("bad yaml: ${err}"); return }
    defer yaml.free(doc)

    r = yaml.root(doc)
    println("root is a mapping: ${yaml.type(r) == yaml.YAML_MAPPING}")

    name, _nerr = yaml.get_scalar(yaml.mapping_lookup(r, "name"))
    println("name ${name}")

    ports = yaml.mapping_lookup(r, "ports")
    println("ports ${yaml.sequence_size(ports)}")
    first, _ferr = yaml.get_scalar(yaml.sequence_get(ports, 0))
    println("first ${first}")
}

Functions:

  • yaml.parse(s) → (ptr, string) - Parse a document; free it with yaml.free
  • yaml.root(doc) → ptr - The document's root node
  • yaml.type(node) → int - YAML_SCALAR, YAML_SEQUENCE or YAML_MAPPING
  • yaml.get_scalar(node) → (string, string) - A scalar's text
  • yaml.sequence_size(node) → int, yaml.sequence_get(node, i) → ptr
  • yaml.mapping_size(node) → int, yaml.mapping_get_key(node, i) / mapping_get_value(node, i) → ptr
  • yaml.mapping_lookup(node, key) → ptr - Value for a key, null when absent
  • yaml.emit_node(node) / yaml.emit_document(doc) → (string, string) - Back to YAML text
  • yaml.free(doc) - Release a document

XML (std.xml)

A deliberately small XML surface: a pull/SAX reader and an escaping builder. Enough for S3 / SOAP-ish / config XML. Not in scope: XSD, XPath, namespaces, DTD validation, or custom entity definitions, the five predefined entities (&amp; &lt; &gt; &quot; &apos;) and numeric character references (&#NN; / &#xHH;) are decoded; CDATA is passed through raw; the prolog, comments, and processing instructions are skipped.

import std.xml

main() {
    // ---- Reader: pull one event at a time ----
    p = xml.parser("<Result><Key>a &lt;b&gt;.txt</Key><Empty/></Result>")
    done = 0
    while done == 0 {
        ev = xml.next(p)
        if ev == xml.EVENT_EOF { done = 1 }
        else if ev == xml.EVENT_ERROR { println(xml.error(p))  done = 1 }
        else if ev == xml.EVENT_START { println("start ${xml.name(p)}") }
        else if ev == xml.EVENT_TEXT  { println("text  ${xml.text(p)}") }  // entity-decoded
        else if ev == xml.EVENT_END   { println("end   ${xml.name(p)}") }
    }
    xml.free(p)

    // ---- Builder: escaping handled for you ----
    b = xml.writer()
    xml.declaration(b)
    xml.start(b, "Error")
    xml.attribute(b, "code", "NoSuchKey")
    xml.element(b, "Message", "the key \"x\" & <y> are gone")
    xml.end(b, "Error")
    out = xml.finish(b)        // <?xml ...?><Error code="NoSuchKey"><Message>...escaped...</Message></Error>
    xml.free_builder(b)
    println(out)
}

Event kinds (returned by xml.next): EVENT_START, EVENT_END, EVENT_TEXT, EVENT_EOF, EVENT_ERROR.

Well-formedness. The reader returns EVENT_ERROR at the first of these, rather than handing back events for a broken tree: an end tag that does not close the innermost open element (<a><b></a>), closes none (</x>) or has no name (</>); an element still open at the end of the document (<root><item>); no root element, a second one (<a/><b/>), or text or CDATA outside it (<a/>x); attributes not separated by whitespace (<a x="1"y="2"/>) or named twice in one tag (<a x="1" x="2"/>); a raw control character other than tab, LF and CR in text or an attribute value; a character reference that is malformed (&#x;, &#X41;) or names a character XML 1.0 does not allow (&#0;, a surrogate such as &#xD800;, anything past &#x10FFFF;); a & that starts no reference (a & b); a reference to an entity other than &amp; &lt; &gt; &quot; &apos; (&nbsp;): the reader reads no DTD, so no other entity is declared. Legal references decode to UTF-8, supplementary planes included. Whitespace, the prolog, comments and a UTF-8 byte order mark may surround the root element. xml.error(p) names the problem and ends in (line L, column C, byte N): the 1-based line, the 1-based column in bytes, and the byte offset.

Reader:

  • xml.parser(data) → ptr new pull reader (free with xml.free)
  • xml.next(p) → int advance; returns an EVENT_*
  • xml.name(p) → string element name for the current START/END
  • xml.text(p) → string entity-decoded character data for the current TEXT
  • xml.attr(p, key) → string attribute value on the current START ("" if absent)
  • xml.attr_count(p) / xml.attr_name(p, i) / xml.attr_value(p, i) iterate attributes
  • xml.error(p) → string message after an EVENT_ERROR
  • xml.free(p) release the reader

A self-closing <tag/> yields a START immediately followed by an END. The name/text/attr values are owned copies, safe to hold across next.

Builder:

  • xml.writer() → ptr new builder (named writer because builder is a keyword; free with xml.free_builder)
  • xml.declaration(b) emit <?xml version="1.0" encoding="UTF-8"?>
  • xml.start(b, name) / xml.attribute(b, name, value) / xml.end(b, name)
  • xml.text_node(b, content) append escaped character data
  • xml.element(b, name, content) <name>escaped</name> in one call
  • xml.finish(b) → string the document (escaping already applied)
  • xml.escape(s) → string escape the five predefined entities (rarely needed directly)

Cryptography (std.cryptography)

Hash digests + Base64 codec. Pure functions, bytes in, hex digest or Base64 string out, binary-safe via an explicit byte length (embedded NULs are fine; pass 0 to hash or encode an empty buffer).

Built on OpenSSL's EVP API, which is already linked for std.net's TLS support. When the Aether toolchain was built without OpenSSL, the wrappers return ("", "openssl unavailable") rather than crashing, callers should always check the error slot.

import std.cryptography
import std.encoding
import std.fs
import std.string

main() {
    // Text payload, length is explicit.
    digest, err = cryptography.sha256_hex(string.bytes("abc"))
    // digest == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"

    // Algorithm chosen at runtime (e.g. read from a config file).
    algo = "sha256"
    if cryptography.hash_supported(algo) == 1 {
        d, _ = cryptography.hash_hex(algo, string.bytes("abc"))
    }

    // Base64, round-trip a binary payload through JSON.
    b64      = encoding.base64_encode(string.bytes("\x01\x02\x03"))       // "AQID"
    raw, _   = encoding.base64_decode(b64)                     // 3 bytes
}

Hash functions:

  • cryptography.sha1_hex(string.bytes(data)[0..length]) → (string, string) - 40-char lowercase hex digest. Included for interop with legacy formats (Git, Subversion, HMAC-SHA1). Prefer SHA-256 for new work.
  • cryptography.sha256_hex(string.bytes(data)[0..length]) → (string, string) - 64-char lowercase hex digest.
  • cryptography.hash_hex(algo, string.bytes(data)[0..length]) → (string, string) - Algorithm-by-name dispatcher. algo is "sha1", "sha256", or any other name OpenSSL's EVP_get_digestbyname() recognizes ("sha384", "sha512", "sha3-256", ...). Returns ("", "unknown algorithm") for unrecognized names. Useful when the algorithm is config-driven rather than compile-time.
  • cryptography.hash_supported(algo) → int - 1 if this build can compute algo, 0 otherwise. Always succeeds; never errors. Use at config time to validate user-supplied algorithm names before they hit hash_hex.
  • cryptography.md4_hex(string.bytes(data)[0..length]) / md5_hex(data, length) → (string, string) - 32-char lowercase hex digest. Legacy interop only (Content-MD5, ETag, zsync, pre-SHA1 fixtures), NOT collision-resistant, do not use for security. ("", error) on failure.

Raw-bytes digests (same (bytes, length, error) tuple as base64_decode; bytes is an owned AetherString preserving embedded NULs, use when the wire format wants a fixed-width binary digest rather than hex):

  • cryptography.sha1_bytes(string.bytes(data)[0..length]) / sha256_bytes(data, length) → (string, int, string).
  • cryptography.md4_bytes(string.bytes(data)[0..length]) / md5_bytes(data, length) → (string, int, string).
  • cryptography.hash_bytes(algo, string.bytes(data)[0..length]) → (string, int, string) - Algorithm-by-name binary digest. ("", 0, "unknown algorithm") for unrecognized names.

HMAC-SHA256 (RFC 2104 / FIPS 198-1; RFC 4231 test vectors):

  • cryptography.hmac_sha256_hex(string.bytes(key)[0..key_len], string.bytes(msg)[0..msg_len]) → (string, string) - Hex digest. Natural shape for opaque-token signing and bearer-token derivation.
  • cryptography.hmac_sha256_bytes(string.bytes(key)[0..key_len], string.bytes(msg)[0..msg_len]) → (string, int, string) - Raw 32-byte digest. Use for chained key derivation (SigV4 / HKDF-shaped flows) where each round's output keys the next.

Cryptographically-secure random (OS CSPRNG, getrandom(2) / /dev/urandom on Linux, arc4random_buf(3) on macOS/BSD):

  • cryptography.random_bytes(n) → (string, int, string) - n random bytes as (bytes, length, error); bytes preserves embedded NULs.
  • cryptography.random_hex(n) → (string, string) - n random bytes as a 2*n-char lowercase-hex string. For opaque bearer-token / API-key minting.
  • cryptography.random_base64(n) → (string, string) - n random bytes as RFC 4648 §4 unpadded Base64 (~33% denser than hex).

Streaming (incremental) digest - Hash data that arrives in pieces without ever holding it whole (streaming an upload to disk in fixed windows, S3-style multipart ETags). The two final variants free the context; call digest_free only when bailing out before finalizing.

  • cryptography.digest_new(algo) → (ptr, string) - Open a context for algo ("md5", "sha256", "sha1", "md4", or any name OpenSSL recognizes). (null, "unknown algorithm") / (null, "openssl unavailable") on failure.
  • cryptography.digest_update(ctx, string.bytes(data)[0..length]) → (int, string) - Feed length bytes; (1, "") on success, (0, error) on failure. Binary-safe. Does NOT free the context.
  • cryptography.digest_final_hex(ctx) → (string, string) - Finalize to lowercase hex. FREES ctx.
  • cryptography.digest_final_bytes(ctx) → (string, int, string) - Finalize to raw digest bytes. FREES ctx.
  • cryptography.digest_free(ctx) - Abandon a context without finalizing (NULL-safe). Only for the bail-out-before-final case.

Base64 (RFC 4648 §4 standard alphabet):

  • encoding.base64_encode(data: byte[]) → string - Encode the bytes of data, unpadded output.
  • encoding.base64_encode_padded(data: byte[]) → string - Encode the bytes of data, with = padding to a multiple of 4. Reach for this when the wire format on the other end requires padding; most non-strict decoders accept either.
  • encoding.base64_decode(b64) → string! - Decode, destructured as (bytes, err). err is non-empty on malformed input: = anywhere but as trailing padding completing a multiple of 4, a 1-character final group, or non-zero leftover bits. Accepts both padded and unpadded input, and skips line breaks; bytes is an AetherString preserving embedded NULs.

Base64 lives in std.encoding, not std.cryptography: encoding is not a security primitive, and the split keeps that honest. std.cryptography keeps random_base64, which is crypto-random bytes rendered as base64.

What std.cryptography doesn't do:

Coming from Java's java.security, Python's cryptography, or Go's crypto/*, expect to reach for an external library if you need:

  • Public-key crypto (RSA, ECDSA, Ed25519, X25519), symmetric ciphers (AES, ChaCha20-Poly1305), and key derivation (KDFs). These live under std.cryptography (rsa, aes, chacha20poly1305, ed25519, x25519, p256, secp256k1, pem, asn1, ...), pure-Aether ports with no OpenSSL dependency. Each family is a separate sub-module you import explicitly.
  • URL-safe Base64 (RFC 4648 §5). Standard alphabet only; URL-safe (- / _ instead of + / /) is a separate variant the wrappers don't expose.
  • Constant-time comparison. Equality checks via string.equals are not constant-time; callers comparing hashes for security-sensitive cases need their own constant-time helper.

Raw externs: cryptography_sha1_hex_raw, cryptography_sha256_hex_raw return a fresh counted string the caller owns, or NULL on failure. The Go-style wrappers translate the NULL into ("", "openssl unavailable").

Public-key crypto, symmetric ciphers, and key derivation live under std.cryptography as explicitly-imported sub-modules (e.g. std.cryptography.rsa, std.cryptography.x25519, std.cryptography.aes), pure-Aether ports, no OpenSSL. The top-level std.cryptography module stays focused on the hash/HMAC/Base64/CSPRNG primitives with a single obvious shape.

The post-quantum KEM is std.cryptography.mlkem (ML-KEM, FIPS 203), with mlkem512_*, mlkem768_* and mlkem1024_* sets: keygen() -> (ek, dk, err) and encaps(ek) -> (ct, key, err) draw their randomness from the OS CSPRNG, and decaps(dk, ct) -> (key, err) returns the 32-byte shared secret. Each call runs the FIPS 203 input checks (key and ciphertext lengths, the encapsulation-key modulus check, the decapsulation-key hash check) and returns an error with null outputs when one fails. keygen_derand(d, z) and encaps_derand(ek, m) take the randomness from the caller and are for known-answer tests only. mlkem_ek_bytes(k), mlkem_dk_bytes(k) and mlkem_ct_bytes(k) give the sizes for k = 2, 3, 4 and 0 for any other k.


Encodings (std.encoding)

Hex, Base64, Base32 and one-record CSV splitting. The encoders take an explicit byte length rather than reading to a NUL, so they are binary-safe: a buffer with embedded zeros encodes correctly. The decoders return (value, error): malformed input is an error value, never a partial result.

Base64 output is unpadded by default, which is what URLs, JWTs and most modern APIs want; base64_encode_padded adds the = run for the protocols that require it. base64_decode accepts either.

import std.encoding
import std.string

main() {
    raw = "key=secret"

    // The encoders take an explicit length, so they are binary-safe: the
    // input may contain NUL bytes.
    println(encoding.hex_encode(string.bytes(raw)))
    println(encoding.base64_encode(string.bytes(raw)))
    println(encoding.base32_encode(string.bytes(raw)))

    back, err = encoding.base64_decode(encoding.base64_encode(string.bytes(raw)))
    println("round trip: ${back == raw}, err '${err}'")

    // One CSV record split on a separator. Deliberately simple: there is
    // no embedded-quote handling, so a field containing the separator is
    // not reassembled. Split multi-row input on a newline first; a
    // trailing carriage return is trimmed for you.

    rec = encoding.csv_split("id,name,3", ",")
    println("fields ${encoding.csv_count(rec)}: ${encoding.csv_field(rec, 1)}")
    encoding.csv_free(rec)
}
6b65793d736563726574
a2V5PXNlY3JldA
NNSXSPLTMVRXEZLU
round trip: true, err ''
fields 3: name

Functions:

  • encoding.hex_encode(data: byte[]) → string - Lowercase hex
  • encoding.hex_decode(s) → (string, string) - Bytes, or an error for an odd length or a non-hex digit
  • encoding.base64_encode(data: byte[]) → string - Unpadded Base64
  • encoding.base64_encode_padded(data: byte[]) → string - Padded Base64
  • encoding.base64_decode(s) → (string, string) - Accepts padded or unpadded input; malformed padding or non-zero leftover bits are an error
  • encoding.base32_encode(data: byte[]) → string, encoding.base32_decode(s) → (string, string) - RFC 4648 Base32
  • encoding.csv_split(record, sep) → ptr - Split ONE record on sep; a trailing carriage return is trimmed
  • encoding.csv_count(h) → int, encoding.csv_field(h, i) → string - Field count and field i, borrowed from the handle
  • encoding.csv_free(h) - Release a split record

csv_split is deliberately simple: there is no embedded-quote handling, so a field containing the separator is not reassembled. Split multi-row input on a newline first.

POSIX ustar archives (std.tar)

std.tar reads, writes, and safely extracts uncompressed POSIX ustar archives. It supports regular files, directories, symbolic links, permission modes, modification times, ustar prefix/name splitting, header checksums, payload padding, and the two-record end marker. Reader payloads and writer source files are transferred in bounded chunks; no entry-sized allocation is made.

import std.tar

main() {
    writer, err = tar.writer_create("release.tar")
    if err != "" { return }
    err = tar.writer_add_file(writer, "bin/app", "build/app")
    if err != "" {
        tar.writer_abort(writer)
        return
    }
    tar.writer_finish(writer)

    opts = tar.default_extract_options()
    err = tar.extract("release.tar", "unpacked", opts)
}

The low-level reader API is reader_open, reader_next, the entry_* metadata accessors, entry_read, entry_skip, and reader_close. entry_read(reader, max_bytes) returns a binary-safe (buffer, length, err); the caller releases every non-null buffer with string.release. Entry metadata is borrowed from the reader and remains valid only until the next reader_next call or reader_close. Calling reader_next skips any unread payload and its padding.

The writer API is writer_create, writer_add_file, writer_add_directory, writer_add_symlink, writer_finish, and writer_abort. Source files are streamed. writer_finish writes both zero records and consumes the writer on success; call writer_abort after an earlier add failure to close the handle and remove the incomplete output.

default_extract_options() disables symlinks and overwriting, does not restore mode or mtime, and applies conservative entry, per-entry byte, and total-byte limits. extract rejects absolute, drive-qualified, UNC, and root-escaping paths; checks and writes each entry at its cleaned name; refuses a parent that is a symlink or, on Windows, a directory junction; validates enabled symlink targets against the link's real depth, refusing a .. that follows a name; and delays directory metadata until children have been created. std.zip's extract makes the same parent checks and takes the same ExtractOptions, so the two modules import together. Reading needs filesystem-read capability; writing and extraction need filesystem-write capability under the normal --emit=lib sandbox checks.

This is deliberately POSIX ustar support, not general TAR support. The iterator reports hard links, devices, FIFOs, GNU/PAX records, sparse entries, base-256 numbers, and unknown type flags as KIND_OTHER; extraction rejects them rather than treating them as files. Invalid UTF-8 names are rejected. Gzip, zstd, xz, and bzip2 are separate formats/layers and are not inferred from suffixes. Symlink creation and metadata preservation remain platform-dependent; no ownership, setuid/setgid, device-node, or FIFO restoration is attempted.


Compression (std.zlib)

One-shot zlib deflate/inflate for in-memory byte buffers. Output is a length-aware AetherString plus explicit byte count (matching fs.read_binary's shape), so binary payloads with embedded NULs round-trip intact.

Under the hood deflate uses compress2 with compressBound sizing; inflate uses streaming inflate() with a geometric-grow output buffer so callers don't need to know the decompressed size in advance.

Auto-detects zlib via pkg-config (same pattern as OpenSSL). When absent, the wrappers return ("", 0, "zlib unavailable") rather than crashing.

import std.zlib
import std.fs
import std.string

main() {
    // Compress a text payload at the default level (-1).
    msg = "Hello, zlib. Repetition repetition repetition."
    compressed, nc, cerr = zlib.deflate(string.bytes(msg), -1)

    // Round-trip back to the original bytes. `inflate` doesn't need
    // to be told the decompressed size, it grows as needed.
    out, nu, uerr = zlib.inflate(string.bytes(compressed)[0..nc])

    // Binary payloads work the same way: fs.read_binary gives a
    // length-aware AetherString; the extern unwraps it before
    // feeding the bytes to zlib.
    data, nd, _ = fs.read_binary("payload.bin")
    blob, nb, _ = zlib.deflate(string.bytes(data)[0..nd], 9)  // level 9 = best
}

Functions:

  • zlib.deflate(data: byte[], level) → (string, int, string) - Compress the bytes of data at level (0..9, or -1 for default). Out-of-range levels are clamped to default. Returns (bytes, byte_count, "") on success, ("", 0, error) on failure.
  • zlib.inflate(data: byte[]) → (string, int, string) - Decompress a zlib stream (RFC 1950). Returns (bytes, byte_count, "") on success, ("", 0, error) on corruption, truncation, or empty input.

Gzip-framed helpers for HTTP Content-Encoding: gzip are also available: zlib.gzip_deflate(data: byte[], level) and zlib.gzip_inflate(data: byte[]). gzip_inflate decompresses every member of a multi-member file, as gzip -d does; bytes after the last member, or after a zlib stream, are an error. zlib.inflate_raw_max(data, max_bytes) caps a raw inflate at a stated size (std.zip inflates entries through it). A level outside -1..9 is an error. Streaming deflate is stream_new / stream_write / stream_flush / stream_finish / stream_free. See stdlib-vs-contrib.md for the "one obvious shape" criterion.


Networking

HTTP (std.http)

Note: Use import std.http for the http.* prefix shown below. import std.net reaches the same HTTP externs, alongside TCP, under the net prefix: net.http_get_raw(url). The short Go-style wrappers (get, post, put, delete) are defined in std.http and are not part of std.net, so those need the http import.

import std.http

main() {
    // HTTP Client, Go-style
    body, err = http.get("http://example.com")
    if err != "" {
        println("failed: ${err}")
        return
    }
    println("got: ${body}")

    // HTTP Server
    server = http.server_create(8080)
    berr = http.server_bind(server, "127.0.0.1", 8080)
    if berr != "" {
        println("bind failed: ${berr}")
        return
    }
    serr = http.server_start(server)
    if serr != "" { println("start failed: ${serr}") }
    http.server_free(server)
}

Client (Go-style):

  • http.get(url) → (string, string) - HTTP GET, returns (body, err)
  • http.get_with_timeout(url, timeout) → (string, string) - HTTP GET with a Duration per-call timeout. 0ns keeps get's "block forever" default; positive values are rounded up to whole seconds internally today. For any third-party URL, without a timeout, a hung site stalls the calling actor's whole message handler.
  • http.post(url, body, content_type) → (string, string) - HTTP POST
  • http.put(url, body, content_type) → (string, string) - HTTP PUT
  • http.delete(url) → (string, string) - HTTP DELETE

All wrappers auto-free the underlying response and return an error string for transport failures or non-2xx status codes. Raw externs: http_get_raw, http_get_with_timeout_raw, http_post_raw, http_put_raw, http_delete_raw.

Connection reuse. The client keeps a connection open after a response whose length was definite (Content-Length or chunked) and reuses it for the next request to the same origin, which is what HTTP/1.1 is for: a proxy or a polling client otherwise pays a TCP, and for HTTPS a TLS, handshake per request. It is on by default; a reverse proxy through std.http.proxy measured about 2x on the same box once it stopped dialling per request. Connections are keyed by origin, by the proxy actually dialled, by TLS, and by the verification the caller asked for, so a connection opened with a pinned CA or with verification off is never handed to a request that did not ask for that. A response with no definite length, or either side saying Connection: close, retires the connection instead of pooling it, and a streaming response (stream()) is never pooled because the caller may abandon it mid-body. A connection the peer closed while it sat idle looks live until it is used, so a pooled connection that returns nothing is redialled and the request sent once more, which is safe precisely because the server never saw it.

  • http.client_pool_configure(max_idle, max_per_host, idle) → string - Resize the pool: idle connections in total, idle connections to one origin, and how long an unused one is kept (a Duration). Defaults are (64, 8, 15s). Keep the idle time under the upstream's own keep-alive timeout, or connections are closed while they sit here and every reuse costs a retry.
  • http.client_pool_disable() - Turn reuse off and close what is held.
  • http.client_pool_clear() - Close every idle connection, keeping reuse on. Worth calling before a measurement, or after a change that makes held connections invalid (a new CA, a rotated proxy).
  • http.client_pool_idle_count() → int - How many idle connections are held right now.
  • http.client_rx_buffer_allocs() → long - How many response buffers the client has allocated from nothing, over the life of the process. A pooled connection keeps the buffer its last response was read into and lends it to the next, so a run of requests over one kept connection allocates once, not once per request: ten requests over one connection read 1, and 10 with pooling off. A buffer that grew past 64 KiB is not kept, so the idle pool cannot pin an outsized body. This is how to check the reuse rather than assume it.

Response accessors (used with raw externs):

  • http.response_status(response) - Read HTTP status code (0 on transport failure)
  • http.response_body(response) - Read body as string
  • http.response_headers(response) - Read headers as string
  • http.response_error(response) - Read transport error, empty string on success
  • http.response_ok(response) - 1 if request succeeded (no transport error, 2xx status), else 0
  • http.response_free(response) - Free response

Server Lifecycle:

  • http.server_create(port) - Create server (never fails)
  • http.server_set_host(server, host) - Set bind address before server_start. Default is "0.0.0.0". Pass "127.0.0.1" to bind loopback only, useful in tests because macOS / Windows firewalls don't prompt on loopback binds. A host name binds the IPv4 address it resolves to; one that does not resolve fails the bind.
  • http.server_bind(server, host, port) → string - Bind to address, return error string
  • http.server_start(server) → string - Start serving (blocking), return error string
  • http.server_stop(server) - Stop server
  • http.server_free(server) - Free server

Raw externs: http_server_bind_raw, http_server_start_raw.

Static file serving:

  • http.serve_file(res, filepath) - Serve a single file. Zero-copy: under HTTP/1.1 cleartext on Linux/macOS, takes the sendfile(2) fast path (zero heap allocation for the body); falls back to a buffered read for TLS / HTTP/2 / Range requests / Windows. Content-Type resolved via http.mime_type(filepath).
  • http.serve_static(req, res, base_dir) - Wildcard-route static-file dispatcher. Path traversal (.., %2e, etc.) is rejected with 403; missing files return 404.

Server Routing:

  • http.server_get(server, path, handler, user_data) - Register GET route
  • http.server_post(server, path, handler, user_data) - Register POST route
  • http.server_put(server, path, handler, user_data) - Register PUT route
  • http.server_delete(server, path, handler, user_data) - Register DELETE route
  • http.server_use_middleware(server, middleware, user_data) - Add middleware

Server Configuration:

  • http.server_set_tls(server, cert_path, key_path) → string - Enable HTTPS with PEM cert + key.
  • http.server_set_keepalive(server, enable, max_requests, idle_timeout) → string - HTTP/1.1 keep-alive with a Duration idle timeout (max_requests=0 is unlimited per connection). Keep-alive is on by default, so this is for changing the limits or turning it off. A response with no definite body length is never kept open; everything else is, and a connection with no request in flight waits in a poller rather than on a worker, so the number a server can hold open is a descriptor count and not a thread count. Measured on an 8-core box (16 workers), 3000 requests per cell: 78,700 rps at 8 concurrent clients, 74,400 at 50 and 70,000 at 200, against 22,000 with a close per response. See http-server.md for the handoff rule and the full table.
  • http.server_set_h2(server, max_concurrent_streams) → string - Enable HTTP/2 (h2 + h2c + ALPN). max_concurrent_streams=0 uses libnghttp2's default (100). Returns error string when the build is missing libnghttp2.
  • http.server_set_h2_concurrent_dispatch(server, worker_count) → string - Routes h2 stream handlers onto the shared std.worker thread pool, sized to worker_count. worker_count > 0 lets streams across all h2 connections execute their handlers in parallel; worker_count == 0 (default) keeps dispatch sequential on each connection thread. POSIX-only; on Windows the call is a silent no-op. See docs/http-server.md for the architecture rationale (pool threads vs actors, one shared pool vs per-connection).
  • http.server_shutdown_graceful(server, timeout) → string - Stop accepting new connections, drain in-flight requests for up to a Duration, exit. h2 sessions emit a GOAWAY frame so peers know not to start new streams while existing ones complete.
  • http.server_set_health_probes(server, live_path, ready_path, ready_check, ud) → string - Built-in /healthz (always 200) + /readyz (200 only when the readiness check returns 1).
  • http.server_set_access_log(server, format, output_path) → string - Built-in access logger. format is "combined" or "json"; output_path is a file path, "-" for stderr, or "" to disable.
  • http.server_set_metrics(server, endpoint) → string - Prometheus-compatible counters/histograms at the configured endpoint (default "/metrics").

Request Accessors:

  • http.request_method(req) → string - HTTP method (GET, POST, PUT, …); empty if req is null.
  • http.request_path(req) → string - URL path (no query string); empty if req is null.
  • http.request_body(req) → string - Request body as a C-string. Truncates at the first embedded NUL when read via string.length(...); pair with http.request_body_length for binary-safe access. On a large (streaming) request the first call materializes the body, it drains the remaining wire bytes into one buffer, preserving the v1 whole-body contract at the O(Content-Length) cost the caller asked for. Don't mix it with request_body_read on the same request (the consumed prefix is gone; the mixed call returns "").
  • http.request_body_length(req) → int - Byte count of the request body. Returns 0 if req is null or has no body. Reach for this whenever the body may contain NUL bytes (svn PUT, image uploads, gzipped JSON), the length-aware companion to http.request_body. On a streaming request this is the declared Content-Length until the body is materialized, then the actual received count.
  • http.request_body_bytes(req) → byte[] - The request body as a borrowed, bounded view (binary-safe; materialises a streaming body like request_body). Valid for the handler's duration.
  • http.request_body_read(req, offset, max) → (bytes, n, err) - Chunked body read. Bodies ≤ 16 KiB are pre-buffered (random-access offsets); larger bodies are streamed, the handler is dispatched at headers-complete and each read pulls the next window straight off the socket (sequential offsets only), so peak server RAM per upload is one window, not the object. Backpressure is TCP flow control itself: the server doesn't recv until the handler asks.
  • http.request_body_complete(req) → int - 1 once every declared body byte has arrived (streaming: pulled off the wire; buffered: always 1). The natural chunked-loop terminator. Transfer-Encoding: chunked request bodies remain unsupported (no Content-Length → length 0, no body), a deliberate v1 semantics decision.
  • http.request_query(req) → string - Raw query string; empty if absent.
  • http.get_header(req, name) - Get request header
  • http.get_query_param(req, name) - Get query parameter
  • http.get_path_param(req, name) - Get URL path parameter
  • http.request_free(req) - Free request

Response Building:

  • http.response_create() - Create response
  • http.response_set_status(res, code) - Set HTTP status code
  • http.response_get_status(res) → int - The status the response will be sent with (200 until a handler sets one)
  • http.response_set_header(res, name, value) - Set response header
  • http.response_set_body(res, body) - Set response body. Uses strdup + strlen internally, truncates at the first embedded NUL. Fine for text bodies; use response_set_body_n for anything that may contain binary.
  • http.response_set_body_bytes(res, body: byte[]) - Binary-safe sibling of response_set_body: sets the bytes of body verbatim (embedded NULs survive); the server copies them.
  • http.response_json(res, json) - Set JSON response
  • http.server_response_free(res) - Free response

HTTP Middleware (std.http.middleware)

Composable pre-handler middleware + response transformers. Each middleware is a C function pointer registered on the server's function-pointer chain, no Aether-side dispatch overhead in the hot path. Aether-side factory wrappers allocate the per-middleware config struct and register it.

import std.http
import std.http.middleware

main() {
    server = http.server_create(8080)

    // Order matters: real_ip first so downstream sees the client IP
    middleware.use_real_ip(server, "")                // default X-Forwarded-For

    middleware.use_cors(server, "*", "GET, POST", "Content-Type", 0, 600)

    middleware.use_rate_limit(server, 100, 60000)     // 100 req / 60s per IP

    middleware.use_bearer_auth(server, "api", verify_token, null)

    middleware.use_gzip(server, 256, 6)               // response transformer

    http.server_get(server, "/", handle_root, 0)
    http.server_start(server)
}

Pre-handler middleware (run before route dispatch; can short-circuit):

  • middleware.use_cors(server, allow_origin, allow_methods, allow_headers, allow_credentials, max_age_seconds) → string - CORS headers + preflight OPTIONS short-circuit.
  • middleware.use_basic_auth(server, realm, verify_cb, ud) → string - HTTP Basic auth (RFC 7617). Verifier receives decoded (username, password).
  • middleware.use_bearer_auth(server, realm, verify_cb, ud) → string - Bearer token auth (RFC 6750). Verifier receives the raw token; on failure emits WWW-Authenticate: Bearer realm="…" with error="invalid_token" for malformed credentials.
  • middleware.use_session_auth(server, cookie_name, redirect_url, verify_cb, ud) → string - Session-cookie auth. Reads a named cookie, hands the value to the verifier; on failure either 401s (when redirect_url is empty) or 302s to the configured login URL.
  • middleware.use_rate_limit(server, max_requests, window_ms) → string - Token-bucket per-client-IP rate limit. Client IP comes from X-Forwarded-For → X-Real-IP → "anonymous" resolution chain.
  • middleware.use_vhost(server, hosts_csv) → string - Host-header gate. Comma-separated allowed hosts; unknown hosts get 404.
  • middleware.use_real_ip(server, header_name) → string - Proxy-aware client IP detection. Reads the configured header (default X-Forwarded-For), takes the leftmost non-empty IP, adds X-Real-IP to the request. Idempotent. Trust model: only safe behind a proxy that strips client-supplied X-Forwarded-For.
  • middleware.use_static_files(server, url_prefix, root) → string - Mount a directory under a URL prefix; .. traversal blocked.
  • middleware.use_rewrite(server, opts) → string - Prefix-rewrite rules; build via middleware.rewrite_add_rule(opts, from, to).

Response transformers (run after the route handler emits the response):

  • middleware.use_gzip(server, min_size, level) → string - Gzip the response body when the client sends Accept-Encoding: gzip and the body is at least min_size bytes; level 1 (fastest) to 9 (best), 0 = default 6.
  • middleware.use_error_pages(server, opts) → string - Replace error-status response bodies with operator-supplied content; build via middleware.error_pages_register(opts, status_code, body, content_type).

HTTP Reverse Proxy (std.http.proxy)

nginx-class outbound HTTP forwarding. Forwards inbound requests to a pool of upstream HTTP servers with five load-balancing algorithms, active health checks, in-memory LRU response cache, per-upstream circuit breaker, idempotent retry with proxy_next_upstream semantics, per-upstream token-bucket rate limit, active drain, W3C Trace-Context propagation, Prometheus 0.0.4 metrics, and Hop-by-Hop header handling per RFC 7230.

import std.http
import std.http.proxy

// Convenience: single upstream, RR, default opts.
proxy.mount_simple(server, "/", "http://localhost:9000", 30)

// Production: pool + LB + health + cache + breaker + retry + rate-limit + metrics.
pool = proxy.upstream_pool_new("weighted_rr", 30, 0, 100)
proxy.upstream_add(pool, "http://10.0.0.1:8080", 3)
proxy.upstream_add(pool, "http://10.0.0.2:8080", 1)
proxy.health_checks_enable(pool, "/health", 200, 5000, 1000, 2, 3)
proxy.breaker_configure(pool, 5, 30000, 1)
proxy.rate_limit_set(pool, 200, 50)
cache = proxy.cache_new(1000, 65536, 60, "method_url_vary")
opts  = proxy.opts_new()
proxy.opts_bind_cache(opts, cache)
proxy.opts_set_retry_policy(opts, 3, 100)
proxy.opts_set_trace_inject(opts, 1)
proxy.mount(server, "/api", pool, opts)

Pool + LB:

  • proxy.upstream_pool_new(lb_algo, request_timeout_sec, dial_timeout_ms, max_inflight_per_up) → ptr - LB algos: "round_robin", "least_conn", "ip_hash", "weighted_rr", "cookie_hash".
  • proxy.upstream_pool_free(pool) - Decrements refcount; joins health-check thread on zero.
  • proxy.upstream_add(pool, base_url, weight) → string
  • proxy.upstream_remove(pool, base_url) → string
  • proxy.upstream_drain(pool, base_url) → string - skip in LB; in-flight finish.
  • proxy.upstream_undrain(pool, base_url) → string - re-admit to LB.
  • proxy.pool_set_cookie_name(pool, name) → string - cookie key for cookie_hash algo.
  • proxy.rate_limit_set(pool, max_rps, burst) → string - per-upstream token-bucket rate limit. max_rps = 0 disables.

Health checks (one pthread per pool):

  • proxy.health_checks_enable(pool, probe_path, expect_status, interval_ms, timeout_ms, healthy_threshold, unhealthy_threshold) → string

Circuit breaker (per-upstream state, per-pool config):

  • proxy.breaker_configure(pool, failure_threshold, open_duration_ms, half_open_max) → string - failure_threshold = 0 disables.

Cache (in-memory LRU + TTL):

  • proxy.cache_new(max_entries, max_body_bytes, default_ttl_sec, key_strategy) → ptr - Key strategy: "url", "method_url", "method_url_vary". RFC 7234 cacheability gates with Vary-aware lookup.
  • proxy.cache_free(cache)

Per-mount options:

  • proxy.opts_new() → ptr
  • proxy.opts_set_strip_prefix(opts, prefix) → string - chops a path prefix before forwarding (e.g. /api/users → /users upstream).
  • proxy.opts_set_preserve_host(opts, on) → string - 0 (default) rewrites Host: to upstream; 1 forwards client Host: verbatim.
  • proxy.opts_set_xforwarded(opts, xff, xfp, xfh) → string - toggles X-Forwarded-{For, Proto, Host} injection (defaults all on).
  • proxy.opts_bind_cache(opts, cache) → string
  • proxy.opts_set_body_cap(opts, max_body_bytes) → string - default 8 MiB.
  • proxy.opts_set_retry_policy(opts, max_retries, backoff_base_ms) → string - retry idempotent methods on 5xx + transport with exponential backoff + full jitter; re-picks per attempt.
  • proxy.opts_set_trace_inject(opts, on) → string - 0 (default) passthrough W3C traceparent; 1 generates a fresh trace when missing.
  • proxy.opts_free(opts)

Install:

  • proxy.mount(server, path_prefix, pool, opts) → string - mount the proxy under path_prefix. "/" forwards everything; "/api" forwards just the /api subtree.
  • proxy.mount_simple(server, path_prefix, upstream_url, request_timeout_sec) → string - one-upstream convenience.

Observability:

  • proxy.pool_metrics_text(pool) → string - Prometheus 0.0.4 exposition (per-upstream + per-pool counters / gauges).

See docs/http-reverse-proxy.md for the full reference (LB algorithms, retry semantics, error responses, performance budget, limitations).

HTTP Client Builder (std.http.client)

The http.get / http.post / http.put / http.delete one-liners above are good for "no auth, JSON in, 200 means good" calls. Reach for std.http.client when you need custom request headers, response-header capture, status discrimination, per-request timeouts, or methods other than the four common verbs (PROPFIND, PATCH, custom RPC verbs all work).

Non-2xx is not an error from send_request's perspective, the caller branches on response_status. Transport-level failures (DNS, connect, TLS handshake, timeout) populate the err slot.

import std.http
import std.http.client

main() {
    req = client.request("GET", "https://api.example.com/users/42")
    client.set_header(req, "Authorization", "Bearer abc123")
    client.set_header(req, "Accept",        "application/json")
    client.set_timeout(req, 30s)

    resp, err = client.send_request(req)
    client.request_free(req)
    if err != "" {
        println("transport: ${err}")
        return
    }

    status = client.response_status(resp)        // 200, 404, ...
    body   = client.response_body(resp)          // binary-safe AetherString
    etag   = client.response_header(resp, "ETag") // case-insensitive lookup
    client.response_free(resp)
}

Builder + send:

  • client.request(method, url) → ptr - Build a request handle (method as arbitrary string)
  • client.set_header(req, name, value) → string - Append Name: value to outgoing headers
  • client.set_body(req, body: byte[], content_type) → string - Set the request body to the bytes of body (binary-safe; pass a string as string.bytes(s)). Empty content_type defaults to application/x-www-form-urlencoded.
  • client.set_timeout(req, timeout) → string - Duration per-request timeout (0ns = block forever)
  • client.set_follow_redirects(req, max_hops) → string - Follow up to max_hops redirects (0 = don't follow, the default)
  • client.send_request(req) → (ptr, string) - Fire the request; returns (resp, "") on transport success or (null, err) on failure
  • client.set_stream(req, on) → string - Enable streaming of the response body for this request; send_stream is the convenience form
  • client.send_stream(req) → (ptr, string) - Like send_request, but the response body is streamed rather than buffered (see below)
  • client.request_free(req) - Free the request handle

TLS + forward proxy (per request): the client is hardened by default, TLS peer verification is on and env proxies are not followed unless you opt in. These knobs relax that per request; precedence for proxy is ignore > explicit > env > direct.

  • client.set_insecure(req, on) → string - 1 skips TLS peer + hostname verification for this request only (curl -k / wget --no-check-certificate). Relaxed per-connection, never on the shared process-wide SSL_CTX, so other requests keep verifying. Default 0. Use only against hosts trusted out-of-band (self-signed dev/staging/appliance certs), it removes MITM protection for that request.
  • client.set_cafile(req, path) → string - pin a custom CA for this request: verify the peer against the PEM bundle at path instead of the system trust store, while keeping peer and hostname verification on. This is the "verify, but against THIS cert" knob, strictly stronger than set_insecure, for machine-to-machine calls to a host with a private/self-signed CA couriered out-of-band (courier the CA once over SSH, then pin it instead of blind-trusting). Per-connection via a per-SSL X509_STORE; never touches the shared SSL_CTX. "" clears the pin (revert to the system store). A certificate the pinned CA doesn't cover fails the handshake, fails closed, never open.
  • client.use_env_proxy(req, on) → string - 1 follows $HTTP_PROXY/$HTTPS_PROXY/$NO_PROXY (Go-compatible). Off by default, the deliberate inverse of the default-follow that caused the httpoxy vulnerability class (CVE-2016-5385). It is a code-visible opt-in, never ambient, and carries two guards: the CGI-injectable uppercase HTTP_PROXY is refused when $REQUEST_METHOD/$GATEWAY_INTERFACE is set (lowercase http_proxy stays honoured), and a proxy resolving to a loopback/link-local IP literal (127/8, 169.254/16 IMDS, ::1, fc00::/7, fe80::/10) is rejected (SSRF).
  • client.use_http_proxy(req, "http://host:port") → string - Pin an explicit forward proxy; env is ignored entirely (empty url reverts to direct). A team-controlled proxy (recorder / toxiproxy) is thus immune to whatever the shell or CI has set. HTTP goes through the proxy with an absolute-form request line; HTTPS establishes a CONNECT tunnel with TLS end-to-end to the origin.
  • client.ignore_http_proxy(req) → string - Force a direct connection regardless of env or any proxy a higher layer set (the determinism escape hatch, e.g. a VCR that must record the origin, not a proxy's view).

Response accessors:

  • client.response_status(resp) → int - HTTP status code
  • client.response_body(resp) → string - Response body, binary-safe (buffered responses)
  • client.response_is_stream(resp) → int - 1 if the body is streamed (from send_stream), 0 if buffered
  • client.response_read(resp, max) → string - Pull the next decoded body window (up to max bytes) from a streaming response; empty result = end-of-body (streaming)
  • client.response_header(resp, name) → string - Case-insensitive single-header lookup, "" if absent
  • client.response_headers(resp) → string - Raw header block
  • client.response_error(resp) → string - Transport error string
  • client.response_free(resp) - Free the response (closes the connection for a streaming response)

Sugar wrappers (pure Aether on top of the builder, no new C externs):

  • client.get_with_headers(url, header_pairs) → (string, int, string) - GET with auth/whatever headers; returns (body, status, err)
  • client.post_with_status(url, body, content_type) → (string, int, string) - POST and inspect status
  • client.post_json(url, value) → (ptr, string) - Marshal a JSON value (std.json), set Content-Type + Accept to application/json, send
  • client.response_body_json(resp) → (ptr, string) - Wrap response_body + json.parse; returns (value, "") on success or (null, parse_error) on malformed JSON

Streaming large response bodies: send_request materialises the whole body into one AetherString, fine for JSON APIs, but for a multi-megabyte download that is O(Content-Length) memory. send_stream instead reads only the header block, keeps the connection open, and hands back a response you drain window-by-window with response_read, so peak memory is one window regardless of body size. Content-Length and Transfer-Encoding: chunked bodies are both decoded transparently (you always see payload bytes, never chunk framing). Redirects are still followed if enabled; only the final hop streams. Always response_free the response when done (it closes the connection), even if you stop reading early.

import std.http
import std.http.client

main() {
    req = client.request("GET", "https://example.com/big.iso")
    client.set_timeout(req, 60s)
    resp, err = client.send_stream(req)     // reads headers only; body stays on the wire
    client.request_free(req)
    if err != "" { println("transport: ${err}"); return }

    // Drive the body in windows; peak memory is one chunk, not the whole file.
    done = 0
    while done == 0 {
        chunk = client.response_read(resp, 65536)   // up to 64 KiB of decoded body
        if string.length(chunk) == 0 {
            done = 1                                 // end-of-body (or error, see below)
        } else {
            // ... write chunk to disk, hash it, forward it, ...
        }
    }
    // An empty chunk means EOF *or* a mid-stream failure; disambiguate here.
    serr = client.response_error(resp)
    client.response_free(resp)                       // closes the connection
    if serr != "" { println("stream error: ${serr}") }
}

Design choices: method is an arbitrary string, not a {GET,POST,PUT,DELETE} enum, so WebDAV / DeltaV / PATCH / project-specific verbs ride through without a stdlib release (the native client sends the method verbatim on the request line). A non-2xx status is not an error: send_request returns the response cleanly and the caller drives status interpretation, so 404/403/401 are distinguishable rather than collapsed to "http error". The builder is named send_request rather than send because send is reserved for actor messaging. tests/integration/test_http_client_v2.ae is the runnable example for the buffered API; tests/integration/http_client_stream/ and http_client_stream_chunked/ cover streaming.

HTTP record/replay (VCR), moved out of the stdlib

The Servirtium record/replay engine that used to ship as std.http.server.vcr has been lifted into its own repository, servirtium-vcr, now its authoritative home, alongside its language bindings. It is no longer part of the Aether stdlib (it had served its purpose: shaping Aether's HTTP server). See docs/http-vcr.md for the pointer and history.

URLs (std.url)

RFC 3986 percent-encoding and query-string parsing. Three encoders, because the standard keeps a different reserved set for each URL component and using the wrong one silently corrupts a request or, for a signed request, breaks the signature:

  • url.encode: query component (Go's QueryEscape): a space becomes +.
  • url.encode_path: path segment (Go's PathEscape): $&+:=@ are kept, a space is %20. + is KEPT here, because it is not a space in a path.
  • url.encode_strict: unreserved only, for SigV4-style canonical signing where any deviation invalidates the signature.

url.decode reverses any of them and reads + as a space. parse_query returns a string_list of decoded key=value entries that supports repeated keys, which query_get_all reads back.

import std.url
import std.collections
import std.string

main() {
    println(url.encode("a b&c=d"))
    println(url.encode_path("dir name/file+1.txt"))
    println(url.encode_strict("a b+c~d"))
    plain, derr = url.decode("a+b%26c")
    if string.length(derr) > 0 { println("bad escape: ${derr}"); return }
    println(plain)

    q, err = url.parse_query("tag=go&tag=rust&page=2")
    if string.length(err) > 0 { println("bad query: ${err}"); return }
    defer url.query_free(q)

    println(url.query_get(q, "page"))
    tags = url.query_get_all(q, "tag")
    defer url.query_free(tags)
    println("tags: ${string_list_size(tags)}, first ${string_list_get(tags, 0)}")
}
a+b%26c%3Dd
dir%20name%2Ffile+1.txt
a%20b%2Bc~d
a b&c
2
tags: 2, first go

Functions:

  • url.encode(s) / url.encode_path(s) / url.encode_strict(s) → string - The three encoders above
  • url.decode(s) → (string, string) - Decode; the error names a malformed escape
  • url.parse_query(s) → (ptr, string) - Parse a query string (a leading ? is tolerated) into a string_list
  • url.query_get(list, name) → string - The first value for name, "" when absent
  • url.query_get_all(list, name) → ptr - Every value for a repeated key, as a fresh string_list
  • url.query_free(list) - Release a list parse_query or query_get_all returned (null is a no-op)

HTTP/1.1 response reader (std.http1)

A pure-Aether RFC 9112 response reader. feed takes bytes from wherever they came from -- a socket, a file, a test -- in whatever chunks they arrive, and a header or chunk boundary can land anywhere in one; it accumulates them and parses once a complete response is there, returning "incomplete" until then. read_response_conn does the reading too, from a connection of the pure-Aether TLS client (std.cryptography.tls13_client). Content-Length, chunked, and close-delimited bodies are all framed.

import std.http1
import std.bytes
import std.string

// Raw bytes as a transport delivers them.
wire_bytes(s: string) -> ptr {
    n = string.length(s)
    b = bytes.new(n)
    _w = bytes.copy_from_string(b, 0, s, n)
    return b
}

main() {
    // A transport hands over bytes in whatever chunks it likes; a header or
    // body boundary can land anywhere in one. feed() accumulates them and
    // parses once the response is complete -- "incomplete" until then.
    r = http1.response_new()
    part1 = wire_bytes("HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Le")
    part2 = wire_bytes("ngth: 11\r\n\r\nhello world")
    println("after part 1: ${http1.feed(r, bytes.view(part1), 0)}")
    err = http1.feed(r, bytes.view(part2), 0)
    println("after part 2: '${err}'")

    println("status ${http1.status_code(r)}")
    println("content-type ${http1.header(r, "content-type")}")
    println("body ${bytes.string_from_slice(http1.body(r))}")
    http1.response_free(r)
    bytes.free(part1)
    bytes.free(part2)

    // Chunked transfer-encoding is decoded the same way.
    c = http1.response_new()
    ch = wire_bytes("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nWiki\r\n5\r\npedia\r\n0\r\n\r\n")
    _e = http1.feed(c, bytes.view(ch), 0)
    println("chunked body ${bytes.string_from_slice(http1.body(c))}")
    http1.response_free(c)
    bytes.free(ch)
}
after part 1: incomplete
after part 2: ''
status 200
content-type text/plain
body hello world
chunked body Wikipedia

Functions:

  • http1.response_new() → ptr, http1.response_free(r)
  • http1.feed(r, data: byte[], is_eof) → string - Add the bytes of data (copied in); "" once a response is complete, "incomplete" before
  • http1.read_response_conn(r, conn) → string - Read a whole response from a tls13_client connection
  • http1.status_code(r) → int, status_reason(r) → ptr (a C string), header(r, name) → string (case-insensitive), header_count(r) → int
  • http1.body(r) → byte[] - The decoded body as a borrowed view, valid until response_free

TCP (std.tcp)

Note: send and receive are reserved actor keywords in Aether, so the TCP byte-transfer wrappers are named write/read. The raw externs retain their send_raw/receive_raw names.

import std.tcp

main() {
    // Client, Go-style
    sock, cerr = tcp.connect("localhost", 8080)
    if cerr != "" { println("connect failed: ${cerr}"); return }

    _, werr = tcp.write(sock, "Hello")
    if werr != "" { println("write failed: ${werr}") }

    data, rerr = tcp.read(sock, 1024)
    if rerr == "" { println("got: ${data}") }
    tcp.close(sock)

    // Server
    server, lerr = tcp.listen(8080)
    if lerr != "" { return }
    client, aerr = tcp.accept(server)
    if aerr == "" {
        tcp.write(client, "Welcome")
        tcp.close(client)
    }
    tcp.server_close(server)
}

Functions (Go-style):

  • tcp.connect(host, port) → (ptr, string) - Connect, return (socket, err)
  • tcp.write(sock, data) → (int, string) - Write text-shaped data, return (bytes_sent, err). Uses the legacy strlen-shaped raw send; use tcp.write_n for binary payloads.
  • tcp.write_n(sock, data: byte[]) → (int, string) - Length-aware write, return (bytes_sent, err). Sends the slice's bytes, preserving embedded NUL bytes; send a string as string.bytes(s), a prefix as string.bytes(s)[0..n].
  • tcp.read(sock, max) → (string, string) - Read text-shaped data, return (data, err). Use tcp.read_n for binary payloads.
  • tcp.read_n(sock, max) → (string, int, string) - Binary-safe read, return (bytes, length, err). The returned length is authoritative for payloads with embedded NUL bytes.
  • tcp.write_slice(sock, s: byte[]) → (int, string) - Send a slice's bytes, binary-safe (#2301). One send(2): the count may be short, so loop on s[sent..]. A slice with no bound panics.
  • tcp.read_into(sock, buf: byte[]) → (int, string) - One receive of at most buf.len bytes into a caller-owned slice, nothing allocated; the data is buf[0..n] (#2301). Errors as read_n ("timeout" keeps the socket), plus "receive buffer is empty" for an empty slice; a slice with no bound panics.
  • tcp.listen(port) → (ptr, string) - Create server socket
  • tcp.accept(server) → (ptr, string) - Accept connection
  • tcp.close(sock) - Close socket (infallible)
  • tcp.server_close(server) - Close server socket

Raw externs: tcp_connect_raw, tcp_send_raw, tcp_send_n_raw, tcp_receive_raw, tcp_receive_n_raw, tcp_send_bytes_raw, tcp_receive_into_raw, tcp_listen_raw, tcp_accept_raw.

UDP slices (std.udp)

The datagram calls have byte[] forms (#2301), documented in full in the module guide:

  • udp.send_slice_to(sock, host, port, s: byte[]) → (int, string) - One datagram of exactly s.len bytes.
  • udp.send_slice_to_addr(sock, addr, s: byte[]) → (int, string) - The same to a resolved address.
  • udp.recv_slice_from(sock, buf: byte[]) → (int, ptr, string) - One datagram into buf[0..n], with its sender (free with addr_free); "would block" when idle.
  • udp.recv_slice_from_into(sock, buf: byte[], addr) → (int, string) - The same, filling a caller-owned address.

A receive never writes past buf.len. A datagram longer than the slice is truncated to buf.len bytes and the OS discards the rest on every platform, so size the slice one byte over the largest datagram accepted and treat n == buf.len as too long. An empty receive slice returns "receive buffer is empty" without consuming a datagram; a slice with no bound panics.

Reactor-pattern async I/O (await_io)

Aether's runtime already owns a per-core I/O reactor (epoll on Linux, kqueue on macOS/BSD, poll() elsewhere). net.await_io exposes that reactor to Aether code so an actor can suspend on a file descriptor without blocking any scheduler thread. When the fd becomes ready, the scheduler delivers an IoReady { fd, events } message to the actor's mailbox and resumes it on any available core.

import std.net

message IoReady { fd: int, events: int }
message Begin { fd: int }

actor Echo {
    state my_fd = 0
    receive {
        Begin(fd) -> {
            my_fd = fd
            err = net.await_io(fd)
            if err != "" {
                println("await_io failed: ${err}")
                exit(1)
            }
        }
        IoReady(fd, events) -> {
            // Resumed here, no OS thread was blocked while we waited.
            data, rerr = tcp.read(/*...*/)
            // ... process, then re-arm ...
            net.await_io(fd)
        }
    }
}

The IoReady message name is reserved. The runtime scheduler delivers I/O-readiness notifications under a fixed message ID; the Aether message registry assigns that same ID to any user message named IoReady so your handler sees the event as a normal receive arm.

Function Returns Description
net.await_io(fd) string Register fd with the current core's I/O poller and suspend the calling actor. Returns "" on success, error string on failure. One-shot: the fd is automatically unregistered after the next IoReady delivery.
net.ae_io_cancel(fd) none Abandon a pending await_io without waiting for the message. Rarely needed due to one-shot policy.

Constraints:

  • await_io must be called from inside an actor's receive handler (not from main()). The bridge reads the current actor from a TLS set at the top of every generated _step() function.
  • The fd must outlive the await_io registration. If you close the fd before the IoReady fires, behavior depends on the backend (epoll reports EPOLLHUP; kqueue silently drops the one-shot).

Performance: a C-level benchmark demonstrated the raw reactor pattern delivering substantially higher HTTP throughput than the blocking keep-alive worker it replaced. await_io is the Aether-language surface over the same runtime machinery, rerun the HTTP benchmark on your own target host before relying on historical numbers.


Command-line arguments (std.clapae)

A command-line parser in the shape of Rust's clap: a builder DSL, arguments typed and validated at the boundary, subcommands, and generated help. The library never exits for you -- parse returns RESULT_OK, RESULT_HELP or RESULT_ERROR, and the program decides what to print and whether to stop.

parse reads the process's own arguments; parse_list takes an explicit list, which is what a test wants.

import std.clapae
import std.list

main() {
    cmd = clapae.command("imgtool") {
        clapae.about("Resize images")
        clapae.arg("width") { clapae.long_("width"); clapae.short(119); clapae.int_arg(); clapae.help("target width") }
        clapae.arg("verbose") { clapae.long_("verbose"); clapae.short(118); clapae.flag() }
        clapae.arg("input") { clapae.positional(); clapae.required() }
    }

    // parse() reads the process's own argv; parse_list takes an explicit
    // one, which is what a test -- or this example -- wants.
    argv = list.new()
    list.add(argv, "photo.png")
    list.add(argv, "--width")
    list.add(argv, "800")
    list.add(argv, "-v")

    res, matches, err = clapae.parse_list(cmd, argv)
    if res != clapae.RESULT_OK { println("parse: ${err}"); return }

    // Typed at the boundary: --width was validated as an int while parsing.
    w, _werr = clapae.get_int(matches, "width")
    println("input ${clapae.get_string(matches, "input")}")
    println("width ${w}")
    println("verbose ${clapae.get_flag(matches, "verbose")}")

    // A value of the wrong kind is refused with a message, not coerced.
    bad = list.new()
    list.add(bad, "photo.png")
    list.add(bad, "--width")
    list.add(bad, "wide")
    res2, _m, _err2 = clapae.parse_list(cmd, bad)
    println("bad width refused: ${res2 == clapae.RESULT_ERROR}")

    clapae.free_matches(matches)
    clapae.free_command(cmd)
}
input photo.png
width 800
verbose 1
bad width refused: true

Functions:

  • clapae.command(name) { ... } → *Command, with about, arg(name) { ... } and subcommand(name) { ... } inside
  • Inside arg: long_(name), short(char), help(text), required(), positional(), and one kind -- flag(), string_arg() (the default) or int_arg()
  • clapae.parse(cmd) / parse_list(cmd, argv) → (int, ptr, string) - Result, matches, error
  • clapae.get_string(m, name) → string, get_int(m, name) → (int, string), get_flag(m, name) → int
  • clapae.subcommand_name(m) → string - Which subcommand was chosen, or ""
  • clapae.subcommand_matches(m, name) → *ArgMatches - That subcommand's own matches, or null unless name is the one chosen
  • clapae.print_help(cmd), free_command(cmd), free_matches(m)

Logging (std.log)

Structured logging with levels.

import std.log

main() {
    err = log.init("app.log", 0)  // 0 = LOG_DEBUG
    if err != "" {
        println("log file unavailable, falling back to stderr: ${err}")
    }

    log.write(0, "Debug message")
    log.write(1, "Info message")
    log.write(2, "Warning message")
    log.write(3, "Error message")

    log.print_stats()
    log.shutdown()
}

Log Levels:

  • 0 = DEBUG
  • 1 = INFO
  • 2 = WARN
  • 3 = ERROR
  • 4 = FATAL

Functions:

  • log.init(filename, level) → string - Initialize logging, return error string if the log file could not be opened (logging still works via stderr as a fallback)
  • log.shutdown() - Shutdown logging
  • log.write(level, message) - Write a log message at the given level
  • log.set_level(level) - Set minimum level
  • log.set_colors(enabled) - Enable/disable colored output (1/0)
  • log.set_timestamps(enabled) - Enable/disable timestamps (1/0)
  • log.print_stats() - Print logging statistics

Raw extern: log_init_raw (returns 1/0).


OS (std.os)

Shell execution, command output capture, and environment variables.

import std.os

main() {
    // Run a shell command, get exit code
    code = os.system("echo hello")
    println("Exit: ${code}")

    // Capture command output, Go-style tuple return
    output, err = os.exec("date")
    if err != "" {
        println("exec failed: ${err}")
        return
    }
    println("Date: ${output}")

    // Get environment variable
    home = os.getenv("HOME")
    if home != 0 {
        println("HOME = ${home}")
    }
}

Functions:

  • os.system(cmd) - Run shell command, returns exit code (0 = success, POSIX convention)
  • os.exec(cmd) → (string, string) - Run command and capture stdout, return (output, err)
  • os.getenv(name) - Get environment variable (returns string, or null if not set, infallible)
  • os.setenv(name, value) → string - Set environment variable, returns "" on success or an error string. Same C-side function as io.setenv use os.setenv when you've already imported std.os for os.getenv.
  • os.temp_dir() → string - The directory for scratch files, with no trailing separator. Windows resolves it through GetTempPathW (which already does the documented TMP → TEMP → USERPROFILE → Windows-directory cascade); POSIX reads TMPDIR and falls back to /tmp. Always returns a non-empty path, so "${os.temp_dir()}/name" needs no check. Prefer this to a hardcoded /tmp, which works on POSIX and under an MSYS2 shell but fails on a native Windows build, a bug that passes every local check.
  • os.unsetenv(name) → string - Unset environment variable, returns "" on success or an error string. Same C-side function as io.unsetenv.
  • os.getpid() → int - Process identifier of the current process. POSIX getpid(2); Windows _getpid(). Useful for tmpfile names (/tmp/myprog.${os.getpid()}.tmp), per-process locks, log prefixes, and stable tagging across forked children. Returns 0 on platforms compiled without filesystem support.
  • os.memory_resident() → long - The process's resident set in bytes, shared pages included (Windows working set, macOS task_info, Linux /proc/self/statm, FreeBSD kinfo_proc). -1 where the platform does not report it.
  • os.memory_private() → long - Bytes the process alone is charged for: Windows private usage, macOS physical footprint (Activity Monitor's Memory), Linux resident minus shared. Page-granular, and counts mappings the allocator does not see; for the bytes the program holds on the heap use mem.heap_in_use() (exactly the blocks held where mem.heap_in_use_exact() is true: Windows, not under Wine, and a sanitizer's allocator; elsewhere from the allocator's statistics, which also count cached freed blocks). -1 where the platform does not report it (FreeBSD).
  • os.user_id() → int - Effective user id of the calling process (POSIX geteuid(2)). Windows has no numeric uid model and returns -1, so treat any negative result as "unavailable" rather than as a uid. Mainly for building per-user runtime paths like /run/user/${os.user_id()}/.
  • os.now_utc_iso8601() → string - Current UTC time as ISO-8601 (YYYY-MM-DDThh:mm:ssZ). Returns "" (never null) on clock/format failure. Thread-safe.
  • os.wall_seconds() → long - Whole seconds since the Unix epoch (POSIX gettimeofday; Windows GetSystemTimeAsFileTime). NTP-jumpable, pair with wall_micros for sub-second precision, or use the monotonic accessors below for elapsed-time measurements.
  • os.wall_micros() → int - Sub-second microsecond fraction (0..999999) from the same struct timeval as wall_seconds.
  • os.now_monotonic_ms() → long - Monotonic clock, milliseconds since boot / process-start / arbitrary epoch. Value-domain is opaque; only deltas are meaningful. POSIX clock_gettime(CLOCK_MONOTONIC); Windows QueryPerformanceCounter. Use for animation tick loops, frame-time budgets, microbenchmarks, anything that must survive a wall-clock jump.
  • os.now_monotonic_ns() → long - Same source as now_monotonic_ms, nanosecond precision. Useful for sub-millisecond timing.
  • os.args_count() → int - Number of command-line arguments
  • os.args_get(index) → string - The i-th argument, a copy of its own; "" when out of range, sealed or not initialised
  • os.argv0() → string - Path the OS launched the current process with (argv[0]), a copy of its own; "" before argv is initialised
  • os.args_seal() - One-shot runtime seal of the argv accessors. After this returns, os.args_count() reports 0, os.args_get(i) and os.argv0() return "", and os.aether_argv_raw() returns null, as if argv had never been initialised. Idempotent (calling twice is a no-op); there is no unseal. Intended use: once main() has parsed its CLI flags into config state, call os.args_seal() to prevent any later code (imported libraries, plugin callbacks, untrusted Aether modules) from reading the original argv. Complements the compile-time hide / seal except scope directives, those deny lexical access, this denies runtime access. Caveat: this is a co-operative Aether-side gate, not a kernel boundary; the OS still has the original argv in process memory (Linux /proc/self/cmdline, macOS sysctl) and code that goes around the Aether accessors can still read it. Pair with the LD_PRELOAD libc sandbox if the threat model demands true inaccessibility.
  • os.args_sealed() → int - Returns 1 if args_seal() has been called in this process, 0 otherwise. Cheap; useful for cooperative callers that want to check before they call.
  • os_execv(prog, argv_list) → int - Replace the current process image with prog, passing an explicit list<ptr> argv. Uses POSIX execvp(3) so prog is looked up on PATH when it does not contain a slash. Flushes stdio before the exec so pre-exec output is not lost. On success this call never returns; on failure returns -1 and the current process continues. Windows cannot replace a running process, so there it runs prog, waits for it and exits with its status, which is what a caller of exec observes.

Raw extern: os_exec_raw.

Process replacement example:

import std.os
import std.list

main() {
    argv = list.new()
    _e1 = list.add(argv, "echo")
    _e2 = list.add(argv, "hello from")
    _e3 = list.add(argv, os.argv0())
    rc = os_execv("/bin/echo", argv)
    // Only reached if exec failed.
    println("exec failed: ${rc}")
    exit(rc)
}

Running programs

os.system and os.exec hand a command line to the shell (/bin/sh -c, cmd.exe /c on Windows), so quoting, globbing and $VAR expansion happen before the child sees it. The calls below skip the shell: on POSIX the program and its arguments go to the operating system as a list, so a path with spaces or an argument holding $ or ; arrives exactly as written.

  • argv is a std.list of the arguments after the program. The child still sees the program itself as its argv[0].
  • env is a list of "KEY=VALUE" strings, or null to inherit this process's environment.
  • A program named without a path separator is looked up on PATH. On Windows the program's own directory and the system directories come first, as they always have there, and a name without an extension is tried with the extensions in PATHEXT that Windows can start (.com, .exe, .bat, .cmd). The current directory is not searched on any platform, so a git.exe someone left where the program runs is never the git it meant.

A Windows process receives one command line, not a list. std.os quotes each argument by the rules the C runtime uses to split it back apart, so an ordinary program sees exactly the arguments passed. A .bat or .cmd is different: it runs under cmd.exe, which reads the line by its own rules. std.os launches it through the system cmd.exe with every argument quoted for cmd, so a & b arrives as the one argument "a & b". An argument holding a ", a % or a line break is refused: cmd still acts on those inside quotes, and no quoting makes them safe. The call returns the error argument cannot be passed to a batch file safely, and nothing runs.

A child that ran and failed and a child that never started are told apart. err is non-empty only when the child could not be started; grep finding nothing or diff finding a difference comes back with err == "" and its exit status. A child killed by a signal reports 128 plus the signal number, the shell's convention. One platform difference: on POSIX a program that is not there fails inside the child after the fork, so it reads as status 127 with no error, while on Windows it fails before any child exists and comes back as status -1 with err set to program not found.

Process execution is native on Windows (CreateProcessW and Job Objects), with two differences. Windows cannot replace a running process, so os_execv runs the program, waits for it and exits with its status, which is what a caller of exec observes. And the std.ipc back-channel is POSIX-only: os.run_pipe spawns the child without it, and os.run_pipe_drain_and_wait returns "unsupported on Windows".

import std.os
import std.list
import std.string

// The child is this same program, re-run with a flag, so the example
// needs no other binary and behaves the same on every OS.
main() {
    if os.args_count() > 1 && string.equals(os.args_get(1), "--child") == 1 {
        println("child says hi")
        exit(3)
    }
    if os.args_count() > 1 && string.equals(os.args_get(1), "--quiet") == 1 {
        exit(5)
    }

    // argv holds the arguments AFTER the program: the child sees
    // argv[0] = the program, argv[1] = "--child".
    argv = list.new()
    _a = list.add(argv, "--child")

    out, code, err = os.run_capture(os.argv0(), argv, null)
    if err != "" {
        println("could not start: ${err}")
        return
    }
    println("captured: ${string.trim(out)}")
    println("exit code: ${code}")

    // Non-blocking: start it, do other work, then reap the token.
    // This child writes nothing: it shares our stdout, so its output
    // would interleave with ours.
    quiet = list.new()
    _q = list.add(quiet, "--quiet")
    token, serr = os.spawn_proc(os.argv0(), quiet, null)
    if serr != "" {
        println("could not start: ${serr}")
        return
    }
    status, werr = os.wait(token)
    println("spawned child exited ${status}${werr}")
    list.free(argv)
    list.free(quiet)
}
captured: child says hi
exit code: 3
spawned child exited 5

Functions:

  • os.run_capture(prog, argv, env) → (string, int, string) - Run to completion: stdout, exit status, error
  • os.run_full(prog, argv, env, stdin_data) → (string, string, int, string) - Feed stdin_data to the child's stdin (binary-safe) and capture stdout and stderr separately: stdout, stderr, exit status, error. No pipe can fill and deadlock, whatever the sizes. "" gives the child an already-closed stdin. Safe to call from several threads at once: each child inherits only its own pipe ends, so a capture never waits for another thread's child (#2917)
  • os_run(prog, argv, env) → int - Run to completion with this process's stdio; the exit status, or -1 when it could not start
  • os.spawn_proc(prog, argv, env) → (int, string) - Start without waiting. The first value is a reap token: the pid on POSIX, a handle-table key on Windows, so pass it back to the calls below rather than treating it as a pid. On Windows a token is never a pid: once reaped, os.kill and os.wait_pid_timeout report it gone rather than reaching whatever process has that number
  • os.spawn_proc_to(prog, argv, env, out_path) → (int, string) - Start without waiting, the child's stdout and stderr written to the file out_path (created or truncated) and its stdin empty. The token works with every call below. The child runs in a Job Object of its own on Windows and leads a process group of its own on POSIX, so os.kill(token, sig) ends its whole tree, the compilers a build tool started included. The file is the only handle it inherits. What the child leaves running when it exits on its own is not ended. A child that crashes ends at once, with its exception code on Windows (its job keeps the error-reporting dialog, and the seconds Windows spends reporting, away whatever error mode the parent has) and 128 plus the signal on POSIX. With os.wait_any_timeout this is a parallel runner in one thread: start N, wait for the first, kill whichever passes its deadline, read each one's file
  • os.wait(token) → (int, string) - Wait for one child: exit status, error
  • os.wait_any(tokens) → (int, int, string) - Wait for whichever of a list finishes first: its token, exit status, error. Box each token into the list with mem.long_to_ptr(token)
  • os.wait_any_timeout(tokens, secs) → (int, int, int, string) - The same with a deadline: token, status, timed_out, error. On a timeout the children keep running; secs <= 0 waits indefinitely
  • os.kill(pid, sig) → int - Send a signal (numbers from std.signal); 0 on success. A negative pid signals the whole process group, and sig == 0 only checks that the process exists. Windows has no catchable signals: any non-zero sig terminates, and there is no group form
  • os.wait_pid_timeout(pid, secs) → (int, int, string) - Wait at most secs for one child: status, timed_out, error. A child that times out is left running
  • os.run_supervised(prog, argv, env, new_process_group, forward_signals, timeout_secs, reap_group) → (int, string) - The whole job-control pattern in one call: the child in its own process group, Ctrl-C forwarded to it, a deadline (exit status 124, as GNU timeout reports), and anything it leaked cleaned up afterwards. The flags are 1 or 0; returns the exit status and an outcome of "exited", "signalled", "timeout" or "error"
  • os.run_pipe(prog, argv, env) → (int, int, string), os.wait_pid(pid) → (int, string), os.run_pipe_drain_and_wait(prog, argv, env) → (string, int, string) - Spawn with the std.ipc back-channel (POSIX only)
  • os_execv(prog, argv_list) → int - Replace this process (on Windows: run it, then exit with its status); listed with the argv accessors above
  • os.chdir(path) → string, os.getcwd() → string - The working directory; chdir returns "" or an error
  • os_which(name) → string - The first match for name on PATH, or ""

Child-to-parent reports (std.ipc)

A child process started with os.run_pipe or os.run_pipe_drain_and_wait inherits a back-channel to its parent, and std.ipc writes a structured report to it -- "passed=44, failed=3" -- rather than the parent having to parse whatever the child printed.

POSIX only. On Windows parent_channel() returns -1 and the parent-side spawn reports unsupported on Windows, so this example is compile-checked rather than run: its output depends on the platform by design.

import std.ipc
import std.os
import std.list
import std.string

// Run as a child with `--report`, this writes a structured result to its
// parent over the inherited back-channel. Run with no arguments, it spawns
// itself that way and reads what the child sent.
main() {
    if os.args_count() > 1 && string.equals(os.args_get(1), "--report") == 1 {
        ch = ipc.parent_channel()
        if ch < 0 {
            println("not spawned with a back-channel")
            return
        }
        err = ipc.write_close(ch, "passed=44\nfailed=3\n")
        if string.length(err) > 0 { println("write failed: ${err}") }
        return
    }

    argv = list.new()
    list.add(argv, "--report")
    payload, code, err = os.run_pipe_drain_and_wait(os.argv0(), argv, null)
    if string.length(err) > 0 { println("spawn failed: ${err}"); return }
    println("child exited ${code} and reported:")
    println(payload)
}

Functions:

  • ipc.parent_channel() → int - The inherited back-channel descriptor, or -1 when there is none
  • ipc.write(fd, bytes) → string - Write; "" or an error
  • ipc.write_close(fd, bytes) → string - Write and close, for the common one-report-at-exit shape

Signal numbers (std.signal)

The POSIX signal numbers that are the same on every Unix, so a program names the signal it means instead of hard-coding 15. The job-control and real-time signals (SIGUSR1, SIGCHLD, SIGSTOP, ...) are deliberately absent: their numbers differ between Linux, macOS and the BSDs, and a constant that is right on one and wrong on another is worse than none.

import std.signal

main() {
    // The POSIX signal numbers that are the same on every Unix, so a
    // program can name the signal it means instead of hard-coding 15.
    println("SIGINT  ${signal.SIGINT()}")
    println("SIGTERM ${signal.SIGTERM()}")
    println("SIGKILL ${signal.SIGKILL()}")
    println("SIGHUP  ${signal.SIGHUP()}")

    // Pair them with std.os's process control, e.g.
    //     os.kill(pid, signal.SIGTERM())
}
SIGINT  2
SIGTERM 15
SIGKILL 9
SIGHUP  1

Functions (zero-argument, each returning the number): SIGHUP, SIGINT, SIGQUIT, SIGILL, SIGABRT, SIGFPE, SIGKILL, SIGSEGV, SIGPIPE, SIGALRM, SIGTERM.

Loading shared libraries (std.dl)

dlopen/dlsym on POSIX and LoadLibrary/GetProcAddress on Windows, behind one API. It does not guess a suffix: .so, .dylib or .dll is the caller's choice, made here at compile time with when target.os. A library or symbol that is not there is an ordinary error value.

import std.dl
import std.string

main() {
    // The C library, under the name each platform gives it: a library's
    // file name is the platform's business, which is why std.dl never
    // guesses one. These are macOS, Windows, FreeBSD and glibc Linux.
    lib = ""
    when target.os == "darwin" {
        lib = "/usr/lib/libSystem.B.dylib"
    } else when target.os == "windows" {
        lib = "msvcrt.dll"
    } else when target.os == "freebsd" {
        lib = "libc.so.7"
    } else {
        lib = "libc.so.6"
    }

    handle, err = dl.open(lib)
    if string.length(err) > 0 { println("could not open the C library: ${err}"); return }
    println("opened the C library")

    sym, serr = dl.symbol(handle, "strlen")
    println("found strlen: ${string.length(serr) == 0 && sym != null}")

    // A name that is not there is an ordinary error value, not a crash.
    _missing, merr = dl.symbol(handle, "no_such_function_anywhere")
    println("missing symbol reported: ${string.length(merr) > 0}")

    cerr = dl.close(handle)
    println("closed: ${string.length(cerr) == 0}")

    _h, oerr = dl.open("definitely-not-a-library-xyz")
    println("missing library reported: ${string.length(oerr) > 0}")
}
opened the C library
found strlen: true
missing symbol reported: true
closed: true
missing library reported: true

Functions:

  • dl.open(path) → (ptr, string) - Load a library
  • dl.symbol(handle, name) → (ptr, string) - Look up a symbol
  • dl.close(handle) → string - Unload; "" on success
  • dl.last_error() → string - The calling thread's last loader error

Dates and times (std.time)

Civil date and time over Unix epoch seconds, all UTC. The canonical value is the epoch second; the civil fields (year, month, day, hour, min, sec, plus weekday and day-of-year) are a decoded view of it, converted both ways by an exact proleptic-Gregorian algorithm with no dependency on libc timezone state. That is what makes the results deterministic and testable: the same input gives the same answer on every machine and in every environment.

import std.time

main() {
    launch = time.from_civil(2026, 9, 22, 14, 30, 0)
    println(time.to_iso8601(launch))
    println("weekday ${time.weekday(launch)} of week, day ${time.day_of_year(launch)} of year")

    deadline = time.add_days(launch, 30)
    println(time.to_iso8601(deadline))
    println("seconds between: ${time.diff_seconds(deadline, launch)}")
    println("2026 is a leap year: ${time.is_leap_year(2026)}")
}
2026-09-22T14:30:00Z
weekday 2 of week, day 265 of year
2026-10-22T14:30:00Z
seconds between: 2592000
2026 is a leap year: false

Functions:

  • time.now() → DateTime, time.now_ms() → long - The current UTC time
  • time.from_civil(y, m, d, hh, mm, ss) → DateTime - Build from civil fields
  • time.from_unix(epoch) → DateTime, time.to_unix(dt) → long - Convert either way
  • time.weekday(dt) → int, time.day_of_year(dt) → int - Derived fields
  • time.is_leap_year(y) → bool, time.days_in_month(y, m) → int - Calendar queries
  • time.add_seconds(dt, n) / add_minutes / add_hours / add_days → DateTime - Arithmetic
  • time.diff_seconds(a, b) → long, time.is_before(a, b) / time.is_after(a, b) → bool - Comparison
  • time.to_iso8601(dt) → string, time.parse_iso8601(s) → (DateTime, string) - ISO-8601 round trip. parse_iso8601 reads YYYY-MM-DDTHH:MM:SS, an optional fraction (dropped) and an optional Z / z, and nothing else (an offset is parse_iso8601_offset's); to_iso8601 writes a year outside 0..9999 in full, signed when negative
  • time.parse_iso8601_offset(s) → (DateTime, int, string) - ISO-8601 as commonly written (a date alone, T or a space, optional seconds and fraction, a Z, UTC or +HH:MM zone): the instant and the offset it was written at, in seconds east of UTC
  • time.strftime(dt, fmt) / time.strftime_at(dt, offset, fmt) → string - Ruby's Time#strftime conversions and flags, at UTC or at an offset

Duration is measured in whole seconds; sub-second precision is a later extension.

Every function but now and now_ms is also std.time.calendar, which reads no clock and so imports no std.os: code built with --emit=lib uses it without --with=os.

Unsigned bit operations (std.bits)

Aether's int is signed, so >> propagates the sign bit and / rounds toward zero on a negative value. std.bits is the unsigned view of the same bit patterns, for code that is manipulating bits rather than counting with them: hashes, codecs, checksums, anything ported from a language with a uint.

import std.bits

main() {
    // Aether's `int` is signed, so `>>` propagates the sign bit. These are
    // the UNSIGNED operations, for code that is working with bit patterns
    // rather than with numbers.
    x = -1
    println("lsr32 ${bits.lsr32(x, 28)}")
    println("rotl32 ${bits.rotl32(1, 1)} rotr32 ${bits.rotr32(1, 1)}")
    println("popcount ${bits.popcount32(255)} ${bits.popcount64(255)}")
    println("clz32 ${bits.clz32(1)}")

    // Unsigned division and comparison, where the same bits mean a large
    // positive value rather than a negative one.
    println("udiv32 ${bits.udiv32(-2, 3)}")
    println("wrapping ${bits.wrapping_add64(9223372036854775807, 1)}")
}
lsr32 15
rotl32 2 rotr32 -2147483648
popcount 8 8
clz32 31
udiv32 1431655764
wrapping -9223372036854775808

Functions:

  • bits.lsr32(x, n) / bits.lsr64(x, n) → int - Logical (zero-filling) right shift
  • bits.rotr32 / rotl32 / rotr64 / rotl64 (x, n) → int - Rotate
  • bits.popcount32(x) / bits.popcount64(x) → int - Number of set bits
  • bits.clz32(x) / bits.clz64(x) → int - Leading zero count
  • bits.udiv32 / urem32 / udiv64 / urem64 (a, b) → int - Unsigned division and remainder
  • bits.ucmp64(a, b) → int - Unsigned comparison
  • bits.wrapping_add64(a, b) / bits.wrapping_mul64(a, b) → int - Arithmetic that wraps instead of overflowing

The aether_bits_* raw externs are the same entry points under their C names.

Hashing (std.hash)

Non-cryptographic hashes for hash tables, checksums and sharding. All are deterministic across runs and platforms (the same bytes give the same number), and all take an explicit length, so they are binary-safe.

Reach for SipHash-2-4 whenever the input is attacker-controlled. The others have no secret, so an attacker who knows which one you use can pick keys that all land in one bucket and turn a hash table into a list; that is a denial-of-service, and it is why siphash24 takes a key.

import std.hash
import std.string

main() {
    key = "cache-key-42"

    // Non-cryptographic, for hash tables and checksums. Same input, same
    // output, every run and every platform.
    println("fnv32 ${hash.fnv32(string.bytes(key))}")
    println("fnv64 ${hash.fnv64(string.bytes(key))}")
    println("murmur3 ${hash.murmur3_32(string.bytes(key), 0)}")

    // SipHash-2-4 takes a 128-bit key, and is the one to reach for when
    // the input is attacker-controlled: without a secret key, an attacker
    // can pick inputs that all land in one bucket.
    println("siphash ${hash.siphash24(string.bytes(key), 0, 0)}")

    // CRC-32 is the checksum PNG, gzip and ZIP carry: for a format that
    // names it, not for a hash table.
    println("crc32 ${hash.crc32(string.bytes(key))}")
}
fnv32 193060292
fnv64 6601642739325170724
murmur3 306734394
siphash -3624809858559077013
crc32 955761749

Functions:

  • hash.fnv32(data: byte[]) → long, hash.fnv64(data: byte[]) → long - FNV-1a, fast and simple
  • hash.murmur3_32(data: byte[], seed) → long - MurmurHash3, better distribution
  • hash.siphash24(data: byte[], k0, k1) → long - SipHash-2-4 under a 128-bit key
  • hash.crc32(data: byte[]) → long, hash.crc32_update(crc, data: byte[]) → long - CRC-32 (IEEE 802.3), continued across pieces by crc32_update

None of these is a cryptographic hash: for integrity or signatures use std.cryptography.

Arbitrary-precision integers (std.bignum)

Integers with no width limit, as a handle you allocate and free. Every operation returns a new handle rather than mutating its operands, so each result needs its own free; defer bignum.free(x) at the point of creation is the habit that keeps that straight.

import std.bignum
import std.string

main() {
    // Arbitrary-precision integers, as a handle you own and free.
    a, err = bignum.from_decimal("170141183460469231731687303715884105727")
    if string.length(err) > 0 { println("bad number: ${err}"); return }
    defer bignum.free(a)

    b = bignum.from_int(1000003)
    defer bignum.free(b)

    sum = bignum.add(a, b)
    defer bignum.free(sum)
    println(bignum.to_decimal(sum))

    product = bignum.multiply(a, b)
    defer bignum.free(product)
    println("digits ${string.length(bignum.to_decimal(product))}")

    // Modular exponentiation, the operation public-key arithmetic is
    // built on: computed without ever forming base^exp.
    base = bignum.from_int(7)
    exp = bignum.from_int(1000)
    m = bignum.from_int(13)
    defer bignum.free(base)
    defer bignum.free(exp)
    defer bignum.free(m)
    r = bignum.mod_pow(base, exp, m)
    defer bignum.free(r)
    println("7^1000 mod 13 = ${bignum.to_decimal(r)}")

    println("compare ${bignum.compare(a, b)} sign ${bignum.sign(a)} bits ${bignum.bit_length(a)}")
}
170141183460469231731687303715885105730
digits 45
7^1000 mod 13 = 9
compare 1 sign 1 bits 127

Functions:

  • bignum.from_int(n) → ptr, bignum.from_decimal(s) → (ptr, string), bignum.from_bytes(s, len) / from_bytes_unsigned → ptr - Construct
  • bignum.to_decimal(n) → string, bignum.to_hex(n) → string, bignum.to_bytes(n) / to_bytes_unsigned → string - Render
  • bignum.add / subtract / multiply / divide / remainder / mod (a, b) → ptr - Arithmetic
  • bignum.negate(a) / bignum.abs(a) → ptr, bignum.shift_left(a, n) / shift_right(a, n) → ptr
  • bignum.compare(a, b) → int, bignum.is_zero(a) / bignum.sign(a) / bignum.bit_length(a) → int
  • bignum.test_bit(a, i) → int - Bit i of |a| (0 the least significant), read in place with no allocation: what a scalar multiplication walks
  • bignum.mod_pow(base, exp, m) → ptr - Modular exponentiation, without forming base^exp
  • bignum.gcd(a, b) / bignum.mod_inverse(a, m) → ptr, bignum.is_probable_prime(n, rounds) → int
  • bignum.free(n) - Release a handle

mod_pow, mod_inverse and is_probable_prime are the public-key arithmetic set. They are correct, not hardened: they are not written to be constant-time, so do not build a production cryptosystem on them; std.cryptography is where that belongs.

Math (std.math)

Mathematical functions. Note: abs, min, max, and clamp have separate int/float variants.

import std.math

main() {
    // Basic operations (type-specific variants)
    a = math.abs_int(-5)           // 5
    af = math.abs_float(-3.14)     // 3.14
    lo = math.min_int(3, 7)        // 3
    hi = math.max_int(3, 7)        // 7
    c = math.clamp_int(15, 0, 10)  // 10

    // Trigonometry
    s = math.sin(0.5)
    co = math.cos(0.5)
    t = math.tan(0.5)

    // Inverse trig
    asn = math.asin(0.5)
    ac = math.acos(0.5)
    at = math.atan2(1.0, 1.0)

    // Power, roots, logarithms
    sq = math.sqrt(16.0)    // 4.0
    p = math.pow(2.0, 3.0)  // 8.0
    l = math.log(2.718)     // ~1.0
    e = math.exp(1.0)       // ~2.718

    // Rounding
    fl = math.floor(3.7)    // 3.0
    ce = math.ceil(3.2)     // 4.0
    ro = math.round(3.5)    // 4.0

    // Random
    math.random_seed(12345)
    r = math.random_int(1, 100)
    f = math.random_float()
}

Basic (int/float variants):

  • math.abs_int(x) / math.abs_float(x) - Absolute value
  • math.min_int(a, b) / math.min_float(a, b) - Minimum
  • math.max_int(a, b) / math.max_float(a, b) - Maximum
  • math.clamp_int(x, min, max) / math.clamp_float(x, min, max) - Clamp to range

Trigonometry:

  • math.sin(x), math.cos(x), math.tan(x) - Trig functions
  • math.asin(x), math.acos(x), math.atan(x) - Inverse trig
  • math.atan2(y, x) - Two-argument arctangent

Power / Logarithms:

  • math.sqrt(x) - Square root
  • math.pow(base, exp) - Power
  • math.log(x) - Natural logarithm
  • math.log10(x) - Base-10 logarithm
  • math.exp(x) - Exponential (e^x)

Rounding:

  • math.floor(x), math.ceil(x), math.round(x)

Random:

  • math.random_seed(seed) - Seed RNG
  • math.random_int(min, max) - Uniform random integer in [min, max], both ends included, for any pair of ints
  • math.random_float() - Uniform random float in [0, 1), never 1.0 (splitmix64; the same sequence for a seed on every platform)

Number formatting (std.number)

Locale-aware decimal, percent and currency formatting. The separators, the digit grouping and where the currency symbol goes all come from the locale tag, so the call site does not encode one country's conventions.

FormatOptions controls the fraction digits and whether grouping is applied; default_options() is grouping on, up to three fraction digits and none forced. The _string variants take and return decimal strings rather than float, which is what a monetary value should be carried as: a binary float cannot represent 0.1 exactly, and rounding it at the last moment is how cents go missing.

import std.number

main() {
    // Locale-aware formatting: the separators and the currency placement
    // come from the locale tag, not from the call site.
    opts = number.default_options()
    opts.min_fraction_digits = 2
    opts.max_fraction_digits = 2

    println(number.format_decimal("en-US", 1234567.891, opts))
    println(number.format_decimal("de-DE", 1234567.891, opts))
    println(number.format_currency("en-US", "USD", 1299.5))
    println(number.format_percent("en-US", 0.4237, opts))

    // The *_default forms use default_options(): grouping on, up to three
    // fraction digits, none forced.
    println(number.format_decimal_default("en-US", 1234.5))
}
1,234,567.89
1.234.567,89
$1,299.50
42.37%
1,234.5

Functions:

  • number.default_options() → FormatOptions - Grouping on, 0 to 3 fraction digits
  • number.format_decimal(locale, value, opts) → string - Format a float
  • number.format_percent(locale, value, opts) → string - Format a ratio as a percentage
  • number.format_currency(locale, currency, value) → string - Format with an ISO 4217 code
  • number.format_decimal_string(locale, decimal, opts) → (string, string) - The exact-decimal form; the error names bad input
  • number.format_percent_string(locale, decimal, opts) / number.format_currency_string(locale, currency, decimal) → (string, string) - The same for the other two
  • number.format_decimal_default(locale, value) / number.format_percent_default(locale, value) → string - With default_options()

I/O (std.io)

Console output, file operations, and environment variable access.

import std.io

main() {
    io.print("Hello ")
    io.print_line("World")
    io.print_int(42)
    io.print_line("")

    // getenv is infallible, returns the value or null if unset
    home = io.getenv("HOME")
    if home != 0 {
        io.print_line(home)
    }

    // read_file is Go-style
    content, err = io.read_file("myfile.txt")
    if err != "" {
        println("read failed: ${err}")
    } else {
        io.print_line(content)
    }
}

Console Output (infallible):

  • io.print(str) - Print string
  • io.print_line(str) - Print string with newline
  • io.print_int(value) - Print integer
  • io.print_float(value) - Print float

Unbuffered fd writes (crash-trace use case):

  • io.stderr_write(data) → int - Write data to fd 2 directly (length computed internally with string.length), bypassing stdio buffering. Returns the byte count actually written, or -1 on error. Loops on partial writes; retries EINTR on POSIX. Reach for this when output must reach the terminal / pipe before the process aborts, println and io.print are line-buffered on tty and block-buffered when piped, so the last few lines reliably get lost during a crash.
  • io.stdout_write(data) → int - Same shape as stderr_write but writes to fd 1. Useful for shell-pipe-friendly tools that need each record flushed before the next stage reads.

File-descriptor lifecycle and bulk fd I/O:

  • io.fd_open_read(path) → (int, string) - Open path for reading (POSIX O_RDONLY / Win _O_RDONLY | _O_BINARY). Returns (fd, "") on success, (-1, error) on failure.

  • io.fd_open_write(path) → (int, string) - Open path for writing, implicit O_CREAT | O_TRUNC (mode 0644 on POSIX, _O_BINARY on Windows). Returns (fd, "") / (-1, error). Pair with fd_close. For O_APPEND or non-truncating opens, file an issue.

  • io.fd_close(fd) → string - "" on success, error string on failure. Single attempt, does not retry on EINTR (Linux requires not retrying; the descriptor is already gone).

  • io.fd_write(fd, data: byte[]) → int - Write every byte of the slice to fd; the length travels with it, so embedded NULs survive. Loops on partial writes; retries EINTR on POSIX. Returns 0 on success, -1 on error; an empty slice writes nothing and returns 0. For a string pass string.bytes(s), for a std.bytes buffer bytes.view(buf).

  • io.fd_read_n(fd, n) → (ptr, int, string) - Read up to n bytes from fd. Returns (bytes, count, err): bytes is a refcounted AetherString carrying the explicit byte count (binary-safe, embedded NULs survive), count is the number actually read (1..n on success, 0 on clean EOF or error), err is "" on success or clean EOF, otherwise an error message.

  • io.fd_read_line(fd) → (string, string) - Read one \n-delimited line from fd. Trailing \n is stripped (a preceding \r is also stripped, so CRLF input yields content with neither). Returns (line, "") on a normal line, ("", "") on clean EOF before any byte, (partial, "") on EOF mid-line (server-side dump streams sometimes omit a trailing newline), ("", error) on read error.

  • io.fd_read_line_status(fd) → (string, bool, string) - EOF-aware line input returning (line, eof, err). A blank line returns ("", false, ""); clean EOF returns ("", true, ""). A final unterminated line returns (content, true, ""): process its content before exiting. A newline-terminated line has eof=false, even if it is the last line; no read-ahead is performed. Errors return ("", false, error). Trailing CR/LF stripping matches fd_read_line, including stripping a final CR at EOF. The line is an owned, refcounted string, automatically released at scope exit and valid across later reads and descriptor closure, just like fd_read_n and fd_read_line. The existing fd_read_line API retains its two-result shape and behavior.

File Operations (Go-style):

  • io.read_file(path) → (string, string) - Read entire file
  • io.write_file(path, content) → string - Write (overwrites), return error string
  • io.append_file(path, content) → string - Append to file
  • io.delete_file(path) → string - Delete file
  • io.file_info(path) → (ptr, string) - Get file metadata
  • io.file_info_free(info) - Free file info
  • io.file_exists(path) - 1 if exists, 0 otherwise (infallible)

Environment:

  • io.getenv(name) - Get environment variable (returns string or null, infallible)
  • io.setenv(name, value) → string - Set env var, return error string
  • io.unsetenv(name) → string - Unset env var, return error string

Raw externs: io_read_file_raw, io_write_file_raw, io_append_file_raw, io_delete_file_raw, io_file_info_raw, io_setenv_raw, io_unsetenv_raw.


Audio (std.audio)

Audio playback in the shape of Go's beep: a source is the unit of playback, and play, pause, seek and volume operate on it. Version 1 decodes WAV and raw PCM over a null backend -- silent and deterministic -- so everything the transport does works headlessly and can be tested without a sound card; a real device backend slots in behind the same API. Loading is fallible, and positions are in milliseconds.

import std.audio
import std.bytes

main() {
    if !audio.open() { println("no audio: ${audio.last_error()}"); return }

    // Half a second of 16-bit stereo silence at 8 kHz: 4000 frames of
    // 2 channels x 2 bytes. A source can come from a WAV file (load_wav) or,
    // as here, from raw PCM the program already has.
    frames = 4000
    n = frames * 2 * 2
    pcm = bytes.new(n)
    data = bytes.finish(pcm, n)
    src, err = audio.load_pcm(data, n, 8000, 2, audio.FORMAT_S16)
    if err != "" { println("load: ${err}"); return }

    println("duration ${audio.duration_ms(src)} ms")
    println("channels ${audio.channels(src)}, rate ${audio.sample_rate(src)} Hz")

    _v = audio.volume(src, 1.5)                 // clamped to [0, 1]
    println("volume ${audio.get_volume(src)}")

    _p = audio.play(src)
    println("playing ${audio.is_playing(src)}")
    _s = audio.stop(src)                         // halts and rewinds
    println("after stop: playing ${audio.is_playing(src)}, at ${audio.position_ms(src)} ms")

    audio.unload(src)
    audio.close()
}
duration 500 ms
channels 2, rate 8000 Hz
volume 1
playing true
after stop: playing false, at 0 ms

Functions:

  • audio.open() → bool, audio.close(), audio.is_null_backend() → bool
  • audio.load_wav(data, len) → ptr!, audio.load_pcm(data, len, rate, channels, format) → ptr! - Fallible: handle the error with or or try; format is one of FORMAT_U8, FORMAT_S16, FORMAT_S24, FORMAT_S32, FORMAT_F32
  • audio.play(src) / pause / stop → bool, audio.is_playing(src) → bool - stop also rewinds
  • audio.volume(src, v) → bool (clamped to 0-1), audio.get_volume(src) → float
  • audio.seek_ms(src, ms) → bool, position_ms(src) / duration_ms(src) → long, channels(src) / sample_rate(src) → int
  • audio.unload(src), audio.last_error() → string

Mixer tier (#2205): per-source controls a game engine needs, groups, a queue source, and a headless engine that holds a mix to numbers:

  • audio.pan(src, p) → bool (clamped to -1..1, equal-power: the Web Audio StereoPannerNode law), audio.get_pan(src) → float
  • audio.pitch(src, ratio) → bool (ratio > 0), audio.get_pitch(src) → float
  • audio.looping(src, on) → bool, audio.is_looping(src) → bool, audio.fade_ms(src, from, to, ms) → bool (from of -1 means "from the current volume")
  • audio.played_frames(src) → long - Output frames this source has produced, across loops and whatever the pitch
  • audio.group_new() → ptr!, audio.group_free(g), audio.group_volume(g, v) → bool, audio.group_get_volume(g) → float, audio.set_group(src, g) → bool (null routes back to the output)
  • audio.stream_open(rate, channels, format, capacity_frames) → ptr! - A bounded ring the program pushes PCM into as it plays; audio.stream_push(src, data, len) → int! (frames accepted), stream_queued(src) / stream_capacity(src) → int, stream_underruns(src) → long
  • audio.open_headless(rate, channels) → bool, audio.is_headless() → bool, audio.render(frames) → int - No device, no mixer thread: nothing advances until render pulls frames, so a test's numbers follow from its calls; audio.rendered_peak(channel) / audio.rendered_sample(frame, channel) → float read the block back

Sandboxing

What the sandbox decided, and the operating-system containment FreeBSD adds beneath it.

Sandbox audit trail (std.audit)

Every permission check Aether's in-process sandbox makes -- allowed and denied -- goes into a ring buffer of the most recent 256, and std.audit reads it back: to count denials, assert an access pattern in a test, or explain why something was blocked. AETHER_SANDBOX_AUDIT=stderr prints the same checks live with no code at all.

import std.list
import std.os
import std.audit

run_sandboxed(perms: ptr, code: fn) {
    sandbox_push(perms)
    sandbox_install()
    call(code, perms)
    sandbox_uninstall()
    sandbox_pop()
}

main() {
    // Grant exactly one environment variable.
    perms = list.new()
    list.add(perms, "env")
    list.add(perms, "HOME")

    audit.clear()                      // scope the query to what follows
    worker = | _ctx: ptr | {
        os_getenv("HOME")              // granted
        os_getenv("AWS_SECRET_KEY")    // not granted
        os_getenv("DATABASE_URL")      // not granted
    }
    run_sandboxed(perms, worker)

    // Every check the sandbox made, allowed or denied, in order.
    n = audit.count()
    for (i = 0; i < n; i = i + 1) {
        cat, res, allowed = audit.entry(i)
        verdict = "deny "
        if allowed == 1 { verdict = "allow" }
        println("${verdict} ${cat} ${res}")
    }
    println("denied ${audit.denied_count()} of ${n}")
}
allow env HOME
deny  env AWS_SECRET_KEY
deny  env DATABASE_URL
denied 2 of 3

Functions:

  • audit.count() → int - Checks in the buffer
  • audit.entry(i) → (string, string, int) - Category, resource, and 1 if allowed
  • audit.denied_count() → int
  • audit.clear() - Empty the buffer, to scope a query to one operation

FreeBSD capability mode (std.capsicum)

Capsicum is FreeBSD's kernel-enforced sandbox. After enter() a process can reach no global namespace -- no new paths, no sockets bound, no PID lookups -- only the descriptors it already holds, each narrowed with rights_limit. Unlike Aether's in-process sandbox, it cannot be escaped from userspace. enter() is irreversible and inherited by children.

FreeBSD only. Everywhere else every call returns CAP_UNSUPPORTED and available() is 0, so portable code asks first -- and this example is compile-checked rather than run, because what it prints depends on the OS.

import std.capsicum
import std.fs

main() {
    // Portable code asks first: off FreeBSD every call is CAP_UNSUPPORTED.
    if capsicum.available() == 0 {
        println("capsicum: not on this platform; running unconfined")
        return
    }

    // Open what the program will need BEFORE entering capability mode:
    // afterwards the kernel refuses any new path, socket or PID lookup.
    log, err = fs.open("/var/log/myapp.log", "a")
    if err != "" { println("open: ${err}"); return }

    // Irreversible, and inherited by children. From here on the process
    // can act only on the descriptors it already holds.
    if capsicum.enter() != capsicum.CAP_OK {
        println("could not enter capability mode")
        return
    }
    println("in capability mode: ${capsicum.in_mode()}")
    fs.file_close(log)
}

Functions:

  • capsicum.available() → int, capsicum.enter() → int, capsicum.in_mode() → int
  • capsicum.rights_limit(fd, rights) / fcntls_limit(fd, mask) → int - Narrow a descriptor before entering
  • Return codes: CAP_OK, CAP_ERR, CAP_UNSUPPORTED
  • Rights bits for rights_limit (R_READ, R_WRITE, R_SEEK, R_FSTAT, R_MMAP, R_ACCEPT, R_CONNECT, R_RECV, R_SEND, ...) and fcntl bits for fcntls_limit (F_GETFL, F_SETFL, F_GETOWN, F_SETOWN)

FreeBSD service delegation (std.casper)

Inside capability mode a process can no longer resolve a hostname, read /etc/passwd or query a sysctl. Casper performs those for it, over service channels opened before the process locks itself down -- the ordering is the whole model. FreeBSD only, and compile-checked for the same reason as std.capsicum.

import std.capsicum
import std.casper
import std.string

main() {
    if casper.available() == 0 {
        println("casper: not on this platform")
        return
    }

    // 1. While still unconfined, open the service channels needed later.
    c = casper.init()
    net = casper.service(c, "system.net")
    casper.close(c)                    // keep `net`, drop the parent handle

    // 2. Lock the process down.
    if capsicum.enter() != capsicum.CAP_OK { println("enter failed"); return }

    // 3. DNS is a global namespace capability mode forbids -- the casper
    //    daemon performs it on the sandboxed process's behalf.
    ip, err = casper.dns_resolve(net, "example.com")
    if string.length(err) > 0 { println("resolve: ${err}"); return }
    println("example.com is ${ip}")
    casper.close(net)
}

Functions:

  • casper.available() → int, casper.init() → ptr, casper.service(c, name) → ptr, casper.close(chan)
  • casper.dns_resolve(net, host) → (string, string) - Over a "system.net" channel
  • casper.pwd_uid(pwd, user) → int, casper.pwd_home(pwd, user) → (string, string) - Over "system.pwd"
  • casper.sysctl(chan, name) → (string, string) - Over "system.sysctl"

Concurrency

Built-in Functions

  • spawn(ActorName()) - Create actor instance
  • wait_for_idle() - Block until all actors finish
  • sleep(milliseconds) - Pause execution
  • release(s) - Release the reference a string local holds now and leave the local empty, as string.release(s) does; without it the local releases the string when it is overwritten or its scope ends. The argument must be string-typed (a *StringSeq has string.seq_free), and a function may not release a parameter it borrows (E0901).

Blocking work off the loop (std.worker)

Runs blocking work on a bounded thread pool and delivers the result back on the thread that owns the event loop. The full contract (poster vs drain, cooperative builds, pool sizing) is documented at the top of std/worker/module.ae; the batch surface is:

  • worker.run(work, done) - Run work on a pool thread; done receives its ptr result on the loop thread. Concurrency is bounded by the pool, so job N+1 queues behind the N in flight.
  • worker.wait() → int - Block until every submitted job has completed and had its completion delivered, running those completions on the calling thread. Returns how many ran, or -1 if a main-thread poster is installed (there the host loop delivers, so waiting on that thread would block the very thread that has to deliver). This is the headless batch join: no poll loop, and unlike pool_shutdown it leaves the pool running, so a reusable helper can wait once per batch and be called again.
  • worker.map(items, f) → ptr - Bounded parallel map: apply f to every element of items on the pool, collecting results into a new list that is index-aligned with the input (the Go errgroup.SetLimit shape). f runs on a worker thread; the result slots are written on the calling thread during the wait, so the returned list needs no locking. The caller owns the returned list and keeps ownership of items.
  • worker.drain(max) / worker.pending() - Manual pump and outstanding count, for hosts driving their own loop.
lengths = worker.map(paths, |p: ptr| { return measure(p) })

When it pays. Dispatching and joining a batch costs a flat ~100 us (measured on 8 cores, #1297), independent of how much work the items do, so worker.map wins only when each item is worth more than roughly 20 us. Below that it is slower than a plain loop, by a lot at trivial work sizes. There is deliberately no automatic fallback: the threshold depends on the work per item, which the runtime cannot see. Numbers and method in docs/cross-references/bend.md.


Memory

Arena allocator (std.arena)

A bulk allocator for short-lived raw buffers. The arena hands out memory via arena.alloc() but cannot free individual allocations, call arena.reset() to drop everything in one shot, or arena.destroy() to return the underlying memory to the OS.

The headline use case is a polling loop or parsing pass that allocates many scratch buffers per iteration:

import std.arena

main() {
    a = arena.create(0)              // 0 = default 1 MiB block
    defer arena.destroy(a)

    iter = 0
    while iter < 1000 {
        arena.reset(a)               // O(1) bulk free of last iter
        scratch = arena.alloc(a, 4096)
        // ... use scratch for parsing, formatting, IO buffer, etc.
        iter = iter + 1
    }
}

Functions:

  • arena.create(size) → ptr - Allocate an arena with the given block size in bytes (0 = default 1 MiB). Overflow blocks chain on demand. NULL on OOM.
  • arena.alloc(arena, bytes) → ptr - Allocate bytes (8-byte aligned). Memory is uninitialized. NULL on OOM or null arena.
  • arena.alloc_aligned(arena, bytes, alignment) → ptr - Same as alloc with explicit alignment (must be a power of 2).
  • arena.reset(arena) - Free every allocation in one shot. Pointers become invalid.
  • arena.destroy(arena) - Free the arena and its memory.
  • arena.used(arena) → long - Bytes currently allocated (sum across overflow blocks).
  • arena.size(arena) → long - Total capacity (sum across overflow blocks).

Arenas don't track AetherString refcounts, strings allocated through the regular stdlib still need release() (or defer release(...)); the arena is for bulk raw allocations that the user controls themselves. Avoid handing arena-allocated pointers to functions that retain them past the next arena.reset() or arena.destroy().

Raw memory access (std.mem)

Reads and writes a buffer someone else allocated -- a C library's struct, a network packet, a memory-mapped file -- at byte offsets. std.bytes is for building up bytes Aether owns; this is for the pointer you were handed. There is no bounds check: the caller knows the size, exactly as with POSIX read/write. A null pointer is defended against; an out-of-range offset is the caller's to avoid. Native-endian scalar getters and setters also have _unchecked companions that omit the null check; callers must guarantee non-null storage with the same size and alignment as the checked forms. The compiler inlines those accessors, bits_of_float / float_from_bits, and clz32 / clz64 (which require nonzero inputs).

Offsets are in bytes, where std.lanes' loads take an element index -- the two conventions meet whenever a buffer is reached both ways.

import std.mem

extern malloc(n: int) -> ptr
extern free(p: ptr)

main() {
    // std.mem reads and writes a buffer someone else allocated -- a C
    // library's struct, a network packet, a memory-mapped file -- at BYTE
    // offsets. There is no bounds check: the caller knows the size.
    buf = malloc(16)
    mem.fill_at(buf, 0, 0, 16)

    mem.set_u32_le(buf, 0, 3405691582)     // 0xCAFEBABE, little-endian
    mem.set_u16_be(buf, 4, 258)            // 0x0102, big-endian
    mem.set_byte(buf, 6, 255)

    println("le32 ${mem.get_u32_le(buf, 0)}")
    println("be16 ${mem.get_u16_be(buf, 4)} first byte ${mem.get_uint8(buf, 4)}")
    println("byte ${mem.get_uint8(buf, 6)}")

    free(buf)
}
le32 3405691582
be16 258 first byte 1
byte 255

Functions (the get_ / set_ families take the pointer, then a byte offset):

  • mem.get_byte / set_byte, get_int8 / get_uint8 / get_int16 / get_uint16 / get_uint32, and their set_ twins - Native-endian scalars
  • mem.get_byte_sz / set_byte_sz - get_byte / set_byte with a size_t offset, for an index that is already one
  • mem.get_int / get_long / get_ptr / get_float32 / get_float64, and their set_ twins
  • mem.get_u16_le / get_u16_be / get_u32_le / get_u32_be / get_u64_le / get_u64_be, and their set_ twins - Explicit byte order, for wire formats
  • mem.copy(dst, src, n) / move(dst, src, n) / compare(a, b, n) / set(dst, value, n) - memcpy, memmove, memcmp, memset
  • mem.copy_at / move_at / fill_at / compare_at - The same with a byte offset on each buffer
  • mem.bits_of_float / float_from_bits, mem.clz32 / clz64, mem.udiv64_32 - Bit-level helpers
  • mem.ptr_to_long(p) / long_to_ptr(addr) - A pointer as a 64-bit address and back, for tagged-pointer arithmetic or storing an integer in a pointer slot
  • mem.call_fn3_int / call_fn3_void / call_fn2_void - Call a C function pointer, for callbacks a C library hands over

Allocators (std.alloc)

An allocator is a value you pass in, so a function that takes one does not decide where its memory comes from -- its caller does. The system allocator, an arena, and a tracking wrapper around either all fit the same shape, and a block is released with the size it was allocated at, which is what lets an arena or a size-class allocator skip a header per block.

import std.alloc
import std.tracking
import std.mem

main() {
    // An allocator is a value you pass in, so a function that takes one
    // does not decide where its memory comes from -- its caller does.
    sys = alloc.system()

    // Wrap it to count what is still live: the leak check for a test.
    t = tracking.wrap(sys)

    a = alloc.raw(t, 64)
    b = alloc.raw(t, 32)
    mem.set_byte(a, 0, 7)
    a = alloc.resize(t, a, 64, 128)
    println("live ${tracking.count(t)} blocks, ${tracking.bytes(t)} bytes")
    println("kept the byte across resize: ${mem.get_uint8(a, 0)}")

    alloc.release(t, a, 128)
    println("after one release: ${tracking.count(t)} live")
    alloc.release(t, b, 32)
    println("after both: ${tracking.count(t)} live, ${tracking.bytes(t)} bytes")

    tracking.destroy(t)
}
live 2 blocks, 160 bytes
kept the byte across resize: 7
after one release: 1 live
after both: 0 live, 0 bytes

Functions:

  • alloc.system() → ptr - The process allocator
  • alloc.of_arena(arena) → ptr - An allocator over a std.arena arena; release it with alloc.arena_free
  • alloc.raw(a, size) → ptr - Allocate size bytes
  • alloc.resize(a, block, old_size, new_size) → ptr - Grow or shrink, keeping the contents
  • alloc.release(a, block, size) - Free, with the size it was allocated at

Leak tracking (std.tracking)

Wraps any allocator and remembers every live block, so a test can say exactly what code under test left behind. Pass the wrapper wherever the code takes an allocator; at the end, report lists each live allocation on stderr and returns how many there are -- zero means nothing leaked.

import std.alloc
import std.tracking

// Code that takes an allocator can be handed a tracking one in a test, and
// the test then knows exactly what was left behind.
build_cache(a: ptr) -> ptr {
    header = alloc.raw(a, 16)
    scratch = alloc.raw(a, 256)
    alloc.release(a, scratch, 256)      // freed...
    return header                        // ...but the caller owns this one
}

main() {
    t = tracking.wrap(alloc.system())
    cache = build_cache(t)

    // report() lists every live allocation on stderr and returns how many
    // there are -- zero means nothing leaked.
    live = tracking.report(t)
    println("live after build: ${live} (${tracking.bytes(t)} bytes)")

    alloc.release(t, cache, 16)
    println("live after release: ${tracking.report(t)}")
    tracking.destroy(t)
}
live after build: 1 (16 bytes)
live after release: 0

Functions:

  • tracking.wrap(inner) → ptr - A tracking allocator over inner (the system allocator when inner is null)
  • tracking.count(t) → int, tracking.bytes(t) → long - Live blocks and bytes right now
  • tracking.report(t) → int - List live allocations on stderr; returns their count
  • tracking.destroy(t) - Release the tracker itself (not the blocks it saw)

Copy-on-write cells (std.snapshot)

One atomic pointer to an immutable value, for data read on every request and rebuilt rarely -- configuration, routing tables, feature flags. Readers take the current value with a lock-free load: no lock, no spinning, no coordination with the writer. A writer publishes a freshly built replacement.

The part that is easy to get wrong: the cell never owns what it holds. store and cas hand back the displaced value, and freeing it is the writer's job -- but only once no reader can still be looking at it. The example frees it at once because nothing else is reading; a server would wait for its readers to move on.

import std.snapshot
import std.mem

extern malloc(n: int) -> ptr
extern free(p: ptr)

// The published value is an immutable block; here, a routing-table version.
box(version: int) -> ptr {
    p = malloc(4)
    mem.set_int(p, 0, version)
    return p
}

main() {
    v1 = box(1)
    cell = snapshot.new(v1)

    // Readers take the current value with a lock-free load: no lock, no
    // spinning, no coordination with the writer.
    println("readers see version ${mem.get_int(snapshot.load(cell), 0)}")

    // The writer publishes a replacement and gets the displaced value back.
    // The cell never frees what it holds -- reclaiming the old value, once
    // no reader can still be looking at it, is the writer's job.
    v2 = box(2)
    old = snapshot.store(cell, v2)
    println("published version 2, displaced ${mem.get_int(old, 0)}")

    // Compare-and-swap publishes only if the value is still the expected one.
    v3 = box(3)
    println("cas against a stale value: ${snapshot.cas(cell, old, v3)}")
    println("cas against the current value: ${snapshot.cas(cell, v2, v3)}")
    free(old)
    free(v2)
    println("readers see version ${mem.get_int(snapshot.load(cell), 0)}")

    free(snapshot.load(cell))
    snapshot.free(cell)
}
readers see version 1
published version 2, displaced 1
cas against a stale value: 0
cas against the current value: 1
readers see version 3

Functions:

  • snapshot.new(initial) → ptr - A cell holding initial
  • snapshot.load(cell) → ptr - The current value, lock-free
  • snapshot.store(cell, value) → ptr - Publish value; returns the displaced one
  • snapshot.cas(cell, expected, value) → int - Publish only if the current value is expected; 1 on success
  • snapshot.free(cell) - Free the cell, not the values

Content-addressed store (std.cas)

A small content-addressed store keyed by the hex sha256 of file contents. Useful for sharing built artifacts (.so files from --emit=lib, signed configs, anything content-addressable) between machines and runs. Puts go through write-tmp + atomic rename so partial writes never appear under the final name; gets re-hash before delivering so a corrupted store entry can't quietly hand back wrong bytes.

import std.cas

main() {
    digest, err = cas.put("./build/myplugin.so")
    if err != "" { return }
    println("published: ${digest}")           // hex sha256

    if cas.has(digest) == 1 {
        gerr = cas.get(digest, "./fetched.so")
        // fetched.so's bytes hash to `digest`, verified on the way out.
    }
}

Functions:

  • cas.put(file_path) → (string, string) - Insert a file into the store. Returns (digest, "") on success or ("", err) on failure (file missing, OOM, mkdir failure, write failure). Idempotent: re-putting the same content returns the same digest without breaking the store.
  • cas.get(digest, dest_path) → string - Copy an entry out of the store, verifying its sha256 hashes back to digest. Returns "" on success or an error string on missing digest, read failure, dest write failure, or digest mismatch (corrupted entry).
  • cas.has(digest) → int - Existence check. NULL/empty-safe.
  • cas.path(digest) → string - Composes <root>/<digest> without touching the filesystem.
  • cas.root() → string - The CAS root: $AETHER_CAS if set, else $HOME/.aether/cas, else /tmp/aether-cas.

The store layout is intentionally flat (one file per digest). Grow to two-level fan-out (<digest[0:2]>/<digest[2:]>) only if entry counts ever push filesystem dirent limits in practice.


Identifiers (std.uuid)

UUID v4 and v7 (RFC 9562), both returned in the canonical 36-character 8-4-4-4-12 form.

v4 is 122 random bits: use it when the only requirements are unpredictability and uniqueness. v7 puts a 48-bit millisecond timestamp in front of 74 random bits, so ids sort by creation time. That is the better default for a primary key (v4 keys arrive in random index positions and fragment a B-tree, while v7 keys append) unless you specifically need the creation time hidden.

Both draw their entropy from std.cryptography.random_bytes and return (id, error); the error is non-empty only when the system entropy source fails.

import std.uuid
import std.string

main() {
    id, err = uuid.v4()
    if string.length(err) > 0 { println("no entropy: ${err}"); return }
    ordered, err7 = uuid.v7()
    if string.length(err7) > 0 { println("no entropy: ${err7}"); return }

    // Both are the canonical 8-4-4-4-12 form. The version nibble sits at
    // index 14, so a stored id says which generator made it, and a v7 id
    // sorts by creation time where a v4 one does not.
    println("length ${string.length(id)}")
    println("v4 version nibble: ${string.substring(id, 14, 15)}")
    println("v7 version nibble: ${string.substring(ordered, 14, 15)}")
}
length 36
v4 version nibble: 4
v7 version nibble: 7

Functions:

  • uuid.v4() → (string, string) - Random UUID v4
  • uuid.v7() → (string, string) - Time-ordered UUID v7

The version nibble at index 14 says which generator produced an id, so a stored value can always be told apart.

ULIDs (std.ulid)

26 characters of Crockford base32: a 48-bit millisecond timestamp, then 80 random bits. The timestamp comes first, so ids made later sort later -- as plain strings, with no parsing. Two made in the same millisecond differ only in their random bits and can compare either way, which is why the example waits a moment between them.

import std.ulid
import std.string

main() {
    a, err = ulid.generate()
    if string.length(err) > 0 { println("no entropy: ${err}"); return }
    // A different millisecond, so the order below is the timestamp's:
    // two ids made in the SAME millisecond differ only in random bits
    // and can compare either way.
    sleep(2)
    b, _e = ulid.generate()

    // 26 characters of Crockford base32: 48 bits of millisecond timestamp,
    // then 80 random bits. The timestamp comes first, so ids made later
    // sort later -- as strings, with no parsing.
    println("length ${string.length(a)}")
    println("later sorts later: ${string.compare(a, b) <= 0}")
}
length 26
later sorts later: true

Functions:

  • ulid.generate() → (string, string) - A new id; the error is non-empty only when the system entropy source fails

KSUIDs (std.ksuid)

27 base62 characters: a 32-bit timestamp in seconds, then 128 random bits. Sorts by creation second; within one second, ids are unique but unordered.

import std.ksuid
import std.string

main() {
    a, err = ksuid.generate()
    if string.length(err) > 0 { println("no entropy: ${err}"); return }
    b, _e = ksuid.generate()

    // 27 base62 characters: a 32-bit second-resolution timestamp, then 128
    // random bits. Sorts by creation second; within one second, ids are
    // unordered but still unique.
    println("length ${string.length(a)}")
    println("distinct: ${string.equals(a, b) == 0}")
}
length 27
distinct: true

Functions:

  • ksuid.generate() → (string, string) - A new id; the error is non-empty only when the system entropy source fails

TSIDs (std.tsid)

13 Crockford base32 characters encoding one 64-bit value -- 42 bits of milliseconds since 2020, then randomness -- so it fits a database BIGINT and sorts by creation time.

import std.tsid
import std.string

main() {
    a, err = tsid.generate()
    if string.length(err) > 0 { println("no entropy: ${err}"); return }
    // A different millisecond, so the order below is the timestamp's:
    // two ids made in the SAME millisecond differ only in random bits
    // and can compare either way.
    sleep(2)
    b, _e = tsid.generate()

    // 13 Crockford base32 characters encoding one 64-bit value: 42 bits of
    // milliseconds since 2020, then randomness. Fits a database BIGINT, and
    // sorts by creation time.
    println("length ${string.length(a)}")
    println("later sorts later: ${string.compare(a, b) <= 0}")
}
length 13
later sorts later: true

Functions:

  • tsid.generate() → (string, string) - A new id; the error is non-empty only when the system entropy source fails

NanoIDs (std.nanoid)

21 URL-safe characters (126 random bits): as collision-resistant as a UUID v4, shorter, and needing no escaping in a URL. No timestamp, so no ordering -- and nothing about when it was made leaks from it.

import std.nanoid
import std.string

main() {
    id, err = nanoid.generate()
    if string.length(err) > 0 { println("no entropy: ${err}"); return }

    // 21 URL-safe characters (A-Z a-z 0-9 _ -), 126 random bits: as
    // collision-resistant as a UUID v4, shorter, and needs no escaping in a
    // URL. No timestamp, so no ordering and nothing to leak.
    println("default length ${string.length(id)}")

    short, _e = nanoid.generate_n(10)
    println("custom length ${string.length(short)}")
}
default length 21
custom length 10

Functions:

  • nanoid.generate() → (string, string) - A new id; the error is non-empty only when the system entropy source fails
  • nanoid.generate_n(n) → (string, string) - An id of n characters

Internationalisation

Plural rules, message formatting and language-tag matching, following the Unicode CLDR and ICU conventions so an application's behaviour matches what translators and other platforms expect.

Plural categories (std.plural)

CLDR plural rules: which form of a word a number takes in a given language. English has two forms; Russian has four, and "21" takes the same form as "1" -- the rules are not guessable, which is why they are data here rather than an if n == 1.

import std.plural

main() {
    // CLDR plural categories: which form of a word a number takes. English
    // has two; many languages have more, and the rules are not guessable.
    println("en 1: ${plural.category("en", 1)}")
    println("en 2: ${plural.category("en", 2)}")
    println("ru 1: ${plural.category("ru", 1)}")
    println("ru 3: ${plural.category("ru", 3)}")
    println("ru 5: ${plural.category("ru", 5)}")
    println("ru 21: ${plural.category("ru", 21)}")
    println("ja 7: ${plural.category("ja", 7)}")
}
en 1: one
en 2: other
ru 1: one
ru 3: few
ru 5: many
ru 21: one
ja 7: other

Functions:

  • plural.category(lang, n) → string - "zero", "one", "two", "few", "many" or "other"
  • plural.category_decimal(lang, n, i, v, w, f, t) → string - The same for a decimal, given CLDR's operands

Message formatting (std.message)

ICU MessageFormat, with arguments by name and a plural that picks the form the locale's CLDR rules call for, plus a catalog for the messages an application ships. message is a keyword -- it names an actor message -- so the functions are imported by name rather than called through message..

// `message` is a keyword, so the module's functions are imported by name
// rather than called through `message.`.
import std.message(format)
import std.map

main() {
    args = map.new()
    map.put(args, "name", "Ana")
    map.put(args, "count", "3")

    // ICU MessageFormat: arguments by name, and a plural that picks the
    // form the locale's CLDR rules call for.
    msg = "{name} has {count, plural, one {# file} other {# files}}."
    println(format("en", msg, args))

    map.put(args, "count", "1")
    println(format("en", msg, args))

    map.free(args)
}
Ana has 3 files.
Ana has 1 file.

Functions:

  • format(locale, msg, args) → string - Format a message; args is a std.map of name to value
  • parse(msg) → (ptr, string), format_pattern(locale, pattern, args) → string, pattern_free(pattern) - Parse once, format many times
  • catalog_new(locale) → ptr, catalog_add(cat, id, msg), catalog_format(cat, id, args) → string, catalog_free(cat) - A message catalog

Language tags (std.language)

BCP 47 tags, parsed and normalised -- pt-br and PT-BR are the same tag -- and matched against what an application actually supports. A region the application lacks falls back to its base language; nothing usable falls back to the first supported tag.

import std.language
import std.string

main() {
    // BCP 47 tags, normalised: "pt-br" and "PT-BR" are the same tag.
    raw, err = language.parse("pt-br")
    if err != "" { println("bad tag: ${err}"); return }
    tag = raw as language.Tag
    println("normalised ${raw}")
    println("language ${language.language(tag)}, region ${language.region(tag)}")

    // Match what a user prefers -- an Accept-Language list, best first --
    // against what the application actually ships. A region the app lacks
    // falls back to the base language; nothing usable falls back to the
    // first supported tag.
    let supported: *StringSeq = ["en-US", "fr", "pt-BR"]
    m = language.matcher_create(supported)
    println("de, fr-CA -> ${language.match_strings(m, "de, fr-CA")}")
    println("pt-br     -> ${language.match_strings(m, "pt-br")}")
    println("ja        -> ${language.match_strings(m, "ja")}")
    language.matcher_free(m)
    string.seq_free(supported)
}
normalised pt-BR
language pt, region BR
de, fr-CA -> fr
pt-br     -> pt-BR
ja        -> en-US

Functions:

  • language.parse(s) → (string, string) - Normalise a tag; the error names an invalid one
  • language.language(tag) / script(tag) / region(tag) → string, language.base(tag) → Tag - Its parts (Tag is a distinct string)
  • language.matcher_create(supported) → *Matcher, language.matcher_free(m) - The tags an application supports, best first
  • language.match_strings(m, preferred) → string - Best match for an Accept-Language-style list; match_tags(m, seq) for a sequence

Process state

Aether deliberately rejects mutable assignment to module-level identifiers, the design philosophy is "if state is mutable, it lives inside an actor or a runtime registry." Two stdlib modules give you the set-during-init, read-everywhere shape that BEAM achieves with persistent_term and register/whereis, without spawning a long-lived actor or paying message round-trip on the read path.

These are the right tool when:

  • A handler is entered from a C-callback and can't take an Aether-typed parameter (the void* user_data slot doesn't carry typed values).
  • The state is genuinely process-wide (CLI flags, current-user identity, the long-lived registry of named actors).
  • You'd otherwise reach for a static C global.

Don't use these for per-request / per-tenant state, that should live in the actor or function that owns the work, plumbed through call parameters or actor messages.

std.config string→string KV

import std.config

main() {
    config.put("user", "alice")
    config.put("token", "xyz")
}

handle_request(req: ptr, res: ptr, ud: ptr) {
    user  = config.get("user")            // "alice"
    level = config.get_or("level", "info") // fallback default
}
  • config.put(key, value) - Insert / overwrite. Both key and value are duplicated internally; caller's string lifetimes don't matter.
  • config.get(key) → string - Returns "" if the key isn't set (matches Aether's Go-style "" = absent convention). Reads are concurrent (no lock contention with each other).
  • config.get_or(key, default_value) → string - Returns the registered value if set, otherwise default_value.
  • config.has(key) → int - 1 if key has been put, 0 otherwise.
  • config.size() → int - Number of keys currently registered.
  • config.clear() - Wipe all keys. Tests use this for isolation; production code rarely needs it.

Storage: a single process-global hashmap protected by a reader/writer lock. Implementation models BEAM's persistent_term. A string get returns is the caller's, with a reference of its own, so it stays valid after a later put or clear of the same key.

std.actors name → actor_ref registry

import std.actors

actor Auditor {
    receive {
        Analyze(payload) -> { /* ... */ }
    }
}

main() {
    a = spawn(Auditor())
    actors.register("auditor", a)
}

handle_request(req: ptr, res: ptr, ud: ptr) {
    a = actors.whereis("auditor")
    a ! Analyze { payload: ud }
}
  • actors.register(name, ref) - Bind name → ref. Overwrites any prior binding (no error). The name is duplicated; the actor_ref is stored as-is.
  • actors.whereis(name) → actor_ref - Look up by name. Returns the registered ref, or null if unregistered.
  • actors.unregister(name) → int - 1 if a binding was removed, 0 if name wasn't registered.
  • actors.is_registered(name) → int - 1 if bound, 0 otherwise.
  • actors.registry_size() → int - Number of currently-registered names.
  • actors.registry_clear() - Wipe all bindings.

Models BEAM's erlang:register / whereis. The registry doesn't track actor liveness, if the actor exits, whereis keeps returning the stale ref until something explicitly calls unregister. Match BEAM's behaviour for non-link'd registrations.

Why the module name is plural: actor (singular) is a reserved keyword in Aether and can't appear as a namespace prefix. actors.register(...) parses; actor.register(...) does not. The plural also reads correctly, "the actors registry."


Embedding in a host (std.host)

An Aether script built with ae build --emit=lib is a library another application loads, and every exported function is a C entry point it calls. std.host is the script's side of that seam: notify sends an event back to the host -- only a name and an id cross, the "claim check" pattern, and a host that wants detail calls back in through the exports -- and the caller_* functions read what the host said about the current call.

This block is a library, so it has no main(): the host application is the program.

import std.host

// Built with `ae build --emit=lib`, this is a library the HOST application
// loads; every exported function becomes a C entry point it can call.
export process_order(order_id: long) -> int {
    // The host describes each call: who is making it, and with what
    // attributes. A script can refuse work its caller may not ask for.
    if host.caller_attribute("role") != "fulfilment" {
        return -1
    }

    // ... validate and apply the order ...

    // Tell the host something happened. Only an event name and an id cross
    // the boundary -- the "claim check" pattern: a host that wants detail
    // calls back in through the exports. Returns 1 if a handler ran, 0 if
    // the host registered none for this event.
    return host.notify("order.processed", order_id)
}

Functions:

  • host.notify(event, id) → int - 1 if the host had a handler for event, 0 if not
  • host.caller_identity() → string, host.caller_attribute(key) → string, host.caller_deadline_ms() → long - "" / 0 outside a host call
  • host.describe, host.input, host.event, host.bindings, and the per-language java / python / ruby / go helpers - The manifest DSL a namespace's manifest.ae uses to describe its ABI

See Also