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 | 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
_rawextern 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.
| 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.
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
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 listlist.add(list, item)→string- Append item, return error stringlist.get(list, index)→(ptr, string)- The item atindex:(null, "index out of range")or(null, "null list")on failurelist.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, andget's errorslist.at(list, index)→ptr- The item at an index the caller has bounded (a loop over0 ..< list.size(l)); out of range, or a null list, is a panic naming the index and the size, as a slice index islist.set(list, index, item)- Set item at indexlist.remove(list, index)- Remove item at indexlist.size(list)- Get number of elementslist.clear(list)- Remove all elementslist.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.
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 liststring_list_add(list, s)→int- Append a copy; returns 1 on success, 0 on OOMstring_list_get(list, index)→string- The entry, which a place that keeps it takes a reference to; null on OOBstring_list_set(list, index, s)- Replace at index with a copy ofs; frees the copy it replacesstring_list_size(list)→int- -1 on nullstring_list_remove(list, index)- Remove the entry and free its copystring_list_clear(list)- Drop every entry, keep the backing allocstring_list_free(list)- Free every entry, then the liststring_list_sort_lex(list)- Stable ascending lexicographic (byte-wise) sort, in placestring_list_sort(list, cmp)- Stable in-place sort by a comparator closure|a: string, b: string| { ... }returning negative / 0 / positive (likestrcmp)
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
})
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 mapmap.put(map, key, value)→string- Insert or update, return error stringmap.get(map, key)→(ptr, string)- The value:(null, "")if the key is absent,(null, "null map")ifmapis nullmap.get_string(map, key)→(string, string)- Get a string value:(null, "")if missing,(null, "not a string")if the value is some other pointermap.lookup(map, key)→ptr- The value, or null when the key is absent or the map is null:getwithout its error slotmap.has(map, key)- Check if key existsmap.remove(map, key)- Remove key-value pairmap.size(map)- Get number of entriesmap.clear(map)- Remove all entriesmap.free(map)- Free map memory
Raw extern: map_put_raw (returns 1/0).
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 failedset.contains(set, item)→bool- Membership testset.remove(set, item)- Drop an item; absent items are ignoredset.size(set)→int- Number of unique itemsset.clear(set)- Drop every item, keeping the set usableset.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 withset.items_freeset.items_free(items)- Release a snapshot fromset.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.
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 failurepqueue.pop(pq)→ptr- Remove and return the lowest-priority item, null when emptypqueue.peek(pq)→ptr- Next item without removing it, null when emptypqueue.peek_priority(pq)→long- Priority of the itempeekwould return (0 when empty)pqueue.size(pq)→int- Number of queued entriespqueue.is_empty(pq)→bool- True when nothing is queuedpqueue.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.
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 ofcapacitylongsdeque.free(d)- Release the ringdeque.push_back(d, v)/deque.push_front(d, v)→Deque- Add at either end; a full ring drops the valuedeque.try_push_back(d, v)/deque.try_push_front(d, v)→(Deque, string)- The same, reporting"deque: full"instead of droppingdeque.pop_front(d)/deque.pop_back(d)→(long, Deque, string)- Remove from either end; the error is non-empty when the ring was emptydeque.peek_front(d)/deque.peek_back(d)→(long, string)- Read without removingdeque.len(d)/deque.cap(d)→int,deque.is_empty(d)/deque.is_full(d)→booldeque.clear(d)→Deque- Drop every value, keeping the capacity
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 arrayintarr.new_filled(size, init)→(ptr, string)- Allocate with every slot set toinitintarr.get(arr, i)→(int, string)- Bounds-checked readintarr.set(arr, i, value)→string- Bounds-checked writeintarr_size(arr)→int- Returns -1 for nullintarr_fill(arr, value)- Reset every slot tovalueintarr_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 reportintarr_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.
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 arrayfloatarr.new_filled(size, init)→(ptr, string)- Allocate with every slot set toinitfloatarr.get(arr, i)→(float, string)- Bounds-checked readfloatarr.set(arr, i, value)→string- Bounds-checked writefloatarr_size(arr)→int- Returns -1 for nullfloatarr_fill(arr, value)- Reset every slot tovaluefloatarr_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 reportfloatarr_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.
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 allocationlongarr.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-checkedlongarr.get_unchecked(a, i)/longarr_set_unchecked(a, i, v)- Unchecked, inlinedlongarr.array(a)→long[]- A typed view:v[i]reads and writes the array itselflongarr.size(a)→int,longarr.fill(a, v)
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 capacitybytes.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 successbytes.get(b, index)→int- Read byte at index as unsigned 0..255; -1 on OOB / NULL / negativebytes.set_le16(b, index, value)→int- Little-endian 16-bit write at index..index+1; grows; 1 on successbytes.get_le16(b, index)→int- Little-endian 16-bit read; -1 if range past current lengthbytes.set_le32(b, index, value)→int- Little-endian 32-bit write at index..index+3; grows; 1 on successbytes.get_le32(b, index)→int- Little-endian 32-bit read; -1 if range past current lengthbytes.set_le64(b, index, value: long)→int- Little-endian 64-bit write at index..index+7; grows; 1 on successbytes.get_le64(b, index)→long- Little-endian 64-bit read; -1 if range past current length; round-trips losslessly with set_le64bytes.set_be16(b, index, value)→int- Big-endian 16-bit write (MSB first); grows; 1 on successbytes.get_be16(b, index)→int- Big-endian 16-bit read; -1 if range past current lengthbytes.set_be32(b, index, value)→int- Big-endian 32-bit write (MSB first); grows; 1 on successbytes.get_be32(b, index)→int- Big-endian 32-bit read; -1 if range past current lengthbytes.set_be64(b, index, value: long)→int- Big-endian 64-bit write (MSB first); grows; 1 on successbytes.get_be64(b, index)→long- Big-endian 64-bit read; -1 if range past current length; round-trips losslessly with set_be64bytes.copy_from_string(b, dst, src, src_len)→int- Copy from a string into the buffer at offsetbytes.copy_from_bytes(dst, dst_off, src, src_off, length)→int- Copy between distinct buffers (memmove semantics); growsdstif 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 consumedbytes.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 WebAssemblyHEAPU8.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 leastlengthand may be more.bytes.set_length(b, n)→int- Publish how many bytes a direct write intodata()'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)
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 bytesort.ints_by(xs, cmp)/sort.longs_by(xs, cmp)/sort.floats_by(xs, cmp)/sort.strings_by(xs, cmp)- Sort with your own comparatorsort.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.
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.
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 stringstring.from_literal(cstr)- Create from string literal (alias fornew)string.from_cstr(cstr)- Create from a C string (alias fornew). Also accepts anAetherString*(e.g. a value read back fromlist.add_string_ownedvialist.get), copying its payload rather than its header bytes.string.empty()- Create empty string
Operations:
string.length(str)- Get lengthstring.concat(a, b)- Concatenate two strings (returns new string)string.char_at(str, index)- Get character at indexstring.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 ofoldwithnew. Returns a new managed string;stris untouched. Emptyoldreturns a copy ofstrunchanged (no infinite empty-match expansion). Binary-safe.string.replace_all(str, old, new)- Replace every non-overlapping occurrence ofoldwithnew, scanned left to right.newmay be empty (deletion) or longer thanold. Same lifetimes and empty-oldguard asreplace. Single exact-size allocation regardless of match count.string.substring(str, start, end)- Extract substringstring.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 whenstrarrived as astring-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_lenfalls through tostrlen, and binary content gets truncated at the first NUL.string.length_n(str, known_length)- Identity helper that documents intent. In code that receives astringparameter 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 forgottenstring.length(s)that would have truncated at NUL.string.bytes(s)→byte[](#2301) - A bounded, read-only view ofs's own bytes, for binary/ASCII scanning by indexing (v[i]) or sub-slicing instead of repeatedchar_at_ncalls. BORROWED: valid only as long assis; 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 resultstring.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 outlivesstring.array_free(arr)string.array_free(arr)- Free split result arraystring.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 outlivesarr: callstring.array_free(arr)right after this returns. Prefersplit_to_seqwhen you don't need array/index shape at all.string.split_to_seq(str, delimiter)- Split into a*StringSeqcons-cell list (Erlang/Elixir-shaped). Same split semantics asstring.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)- Ifsstarts withprefix, returns the remainder and 1. Otherwise returnssand 0. Cleaner than manualstarts_with+substringlength arithmetic.
Glob-pattern matching (string side, NOT filesystem):
string.glob_match(pattern, s)→int- Doespatternmatchs? 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 fromfs.globwhich 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 asglob_matchbut*and?do NOT cross a/separator. Use when matching path patterns:src/*.cmatchessrc/foo.cbut notsrc/sub/foo.c.
Sequences (*StringSeq Erlang/Elixir-shaped cons-cell list):
string.seq_empty()→*StringSeqempty list (NULL pointer)string.seq_cons(head, tail)→*StringSeqprepend; retains both head and tailstring.seq_head(s)→string""on emptystring.seq_tail(s)→*StringSeqempty seq on emptystring.seq_is_empty(s)→int1 if emptystring.seq_length(s)→intO(1) cachedstring.seq_retain(s)→*StringSeqbump refcount; pair withseq_freestring.seq_free(s)iterative spine walk; stops at shared cellsstring.seq_from_array(arr, count)→*StringSeqbuild from anAetherStringArray*(the shapestring.splitreturns)string.seq_to_array(s)→ptrmaterialise asAetherStringArray*for legacy callers; free withstring.array_freestring.seq_from_strings(items)→*StringSeq(#2301) build directly from a plainstring[], for a runtime slice that isn't a[...]literal, where the compiler's own literal-to-seq lowering doesn't apply.itemsitself is untouched; each element is independently retained into the new seq.string.seq_to_strings(s)→string[](#2301) the reverse: an ownedstring[]snapshot of a seq's elements (each independently copied), walking the seq directly rather than through theAetherStringArrayhandleseq_to_arrayreturns.string.seq_reverse(s)→*StringSeqO(n), fresh independent spinestring.seq_concat(a, b)→*StringSeqO(|a|),acopied,bshared via refcount bumpstring.seq_take(s, n)→*StringSeqfirstnelements (clamped to length, negative yields empty); fresh independent spinestring.seq_drop(s, n)→*StringSeqn-th tail retained (clamped to length, negative yieldssretained); pointer walk only, no allocationsstring.join(s, sep)→stringconcatenate every element withsepbetween adjacent pairs; the complement ofsplit_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 ofs. Equivalent tostring.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 anstd.listof 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 viastring.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 + stringis not defined. Use"${a}${b}"interpolation for literals orstring.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 andstring.concat(a, b)it does NOT silently emit broken pointer arithmetic.
Conversion:
string.to_cstr(str)- Get raw C string pointerstring.from_int(value)- Create string from integerstring.from_float(value)- Create string from floatstring.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 integerstring.to_long(s)→(long, string)- Parse 64-bit integerstring.to_int_radix(s, radix)→(long, string)- Parse base-N integer;radixin[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. Returnslongso 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 ofto_int_radix. Rendervaluein baseradix([2, 36]); empty string on invalid radix;'-'prefix for negatives. Pair withpad_startfor fixed-width hex bytes.string.pad_start(s, total_width, pad_char)→string- Prependpad_char(single-byte char code, e.g.48for'0',32for' ') untilsreachestotal_width. Returns a fresh copy ifsis already long enough (no truncation).string.pad_end(s, total_width, pad_char)→string- Append-side variant ofpad_start. Useful for columnar text output.string.to_float(s)→(float, string)- Parse floatstring.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 countstring.release(str)- Decrement reference count (frees when zero)string.free(str)- Alias forreleasestring.keep(s)→ptr- A reference of its own tos, as aptr, for a@keep ptrparameter or aptrfield that holds the string past the call (E0902)string.release_kept(p)- Gives back whatstring.keeptookstring.from_ptr(p)→(string, string)- The string aptrholds, typed as one:(null, "not a string")whenppoints at something else (a bareptrgiven to astringlocal is refused, E0202)
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.
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.
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 toregex.compile_flags(pattern, flags)→(ptr, string)- The same, with PCRE2 option bitsregex.free(re)- Release a compiled patternregex.matches(re, s)→int- 1 when the pattern matches anywhere insregex.find(re, s)→(int, int, string)- Byte span of the first match;start == -1for noneregex.captures(re, s)→(ptr, string)- Capture set for the first match;nullwith an empty error means no matchregex.capture(caps, i)→string- Groupi(0 = the whole match);""when out of range or unsetregex.capture_count(caps)→int,regex.capture_start(caps, i)/regex.capture_end(caps, i)→intregex.captures_free(caps)- Release a capture setregex.replace(re, s, repl)/regex.replace_all(re, s, repl)→(string, string)- Replace the first / every matchregex.last_error()→string,regex.clear_last_error()- The thread's last PCRE2 error
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 stringfile.open(path, mode)→(ptr, string)- Low-level open (caller mustfile.close)file.close(handle)- Close filefile.size(path)→(int, string)- Get size in bytesfile.delete(path)→string- Delete filefile.exists(path)- 1 if a regular file is atpath, 0 otherwise. Returns 0 for directories, even if they exist, seefs.existsfor the path-agnostic check.
Raw externs: file_open_raw, file_read_all_raw, file_write_raw, file_delete_raw, file_size_raw.
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 stringdir.delete(path)→string- Delete empty directory, return error stringdir.list(path)→(ptr, string)- List contents (caller mustdir.list_free)dir.list_count(list)→int- Number of entriesdir.list_get(list, index)→string- Entry name atindexdir.list_kind(list, index)→int- Entry's file kind from readdir'sd_type, avoiding astat(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 asfile_stat's kind. Kinds 5-7 are POSIX-only: Windows reports those nodes as 4. The named constantsfs.STAT_KIND_FILE,STAT_KIND_DIR,STAT_KIND_SYMLINK,STAT_KIND_OTHER,STAT_KIND_SOCKET,STAT_KIND_FIFOandSTAT_KIND_DEVICEare exported fromstd.fs, prefer them over the bare numbers.dir.exists(path)- 1 if a directory is atpath, 0 otherwise. Returns 0 for regular files, even if they exist, seefs.existsfor 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.
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)- Visitpath(depth 0) and every entry beneath it. Entry kinds come from readdir'sd_type, one sweep per directory, no per-entrystat(2). Symlinks are reported (kind 3) but never followed, so cycles are impossible.pathinside 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) whenpathcan't be read.fs.watch_open(path)→(ptr, string)- Watch one directory (or file), non-recursive, over the platform primitive: kqueueEVFILT_VNODE(macOS/BSD), inotify (Linux),FindFirstChangeNotification(Windows). The handle is single-threaded.fs.watch_wait(watch, timeout_ms)→int- Block up totimeout_ms(negative = forever): 1 = something changed (create/delete/modify/rename inside the watched directory), 0 = timeout, -1 = error. Changes made betweenwatch_openandwatch_waitare 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.
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 componentspath.dirname(path)- Get directory namepath.basename(path)- Get file namepath.extension(path)- Get file extension including dotpath.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 unlikefs.realpathit 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 aC:/ UNC volume prefix, and emits the platform separator.path.join_clean(a, b)→string-joinfollowed bycleanin one call. Use this rather thanjoinwheneverbis 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 withis_within_base.path.is_within_base(base, target)→int- 1 iftargetlies withinbaseafter both are cleaned, else 0. The lexical pre-opencheck 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 underbasepointing outside is an open-time concern.path.rel(base, target)→string- The relative path frombasetotarget, such that joining it ontobaseand cleaning yieldstarget. Returns empty when there is no such path (one absolute and one relative, different Windows volumes, or abasethat climbs above wheretargetcan be reached from, asrel("../a", "b")).path.separator()→string- The platform path separator,"/"on POSIX and"\\"on Windows. Use it instead of hardcoding a separator.
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 atpath(regular file, directory, symlink, fifo, ...), 0 otherwise. Distinct fromfile.exists(regular-file-only) anddir.exists(directory-only), those filter by type, this one doesn't. Useslstat(2)so a dangling symlink counts as existing, matches POSIXtest -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 astd.bytesbuffer). The tmp file is created withO_CREAT|O_EXCL|O_NOFOLLOWso 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 previousfopen("wb")would have.fs.write_binary(path, data: byte[])→string- Non-atomicfopen("wb")+fwrite+fclose. Binary-safe: the length travels with the slice. Cheaper thanwrite_atomicwhen a partial file on crash is acceptable (scratch writes, caches).fs.rename(from, to)→string- POSIXrename(2)wrapper. Atomic when source and target are on the same filesystem.fs.create_dir_with_mode(path, mode)→string- Likefs.create_dirbut takes an explicit POSIX mode (0777-masked). Use this for private dirs (e.g.0o700for keys), sets the bits at creation time, closing themkdir→chmodrace 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 olderfile_mtimeextern collapsed both into a single 0 sentinel and is kept only for back-compat.fs.file_stat(path)→(kind, size, mtime, err)- Onelstat(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 thefs.STAT_KIND_*constants rather than the literals.fs.is_socket(path)→int- 1 ifpathis a UNIX-domain socket, 0 otherwise (including when nothing is there). Unlikefs.is_symlinkthis follows symlinks, since a caller asking "is this a socket" wants the target. POSIX only; always 0 on Windows. Pair it withos.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.
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 insrcare followed (matches POSIXcpwithout-P);dstis overwritten if it exists;dstcannot be an existing directory (returnsKIND_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); macOSfcopyfile(COPYFILE_DATA)(APFS clone on same-volume); WindowsCopyFileExW(kernel block copy). An 8 MiB read/write loop is the portable fallback for filesystems that reject the kernel primitives. The byte count saturates atINT_MAXfor 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 fromsrctodst. Atomic when source and destination are on the same filesystem (POSIXrename(2)). OnEXDEV(cross-device) the call transparently falls back tofs.copy+unlinkcorrect, but no longer atomic. Cross-device directory moves surface asKIND_IS_DIR(the underlying copy refuses to recurse). Windows usesMoveFileExWwithMOVEFILE_REPLACE_EXISTING | MOVEFILE_COPY_ALLOWEDso 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. POSIXrealpath(3); on WindowsCreateFileW(FILE_FLAG_BACKUP_SEMANTICS)+GetFinalPathNameByHandleW(FILE_NAME_NORMALIZED), with the\\?\prefix stripped before returning. Common kinds:KIND_NOT_FOUNDfor a missing component,KIND_LOOPfor symlink cycles,KIND_NAME_TOO_LONGif the resolved form exceeds the OS limit. -
fs.chmod(path, mode)→(int, int, string)Change permission bits. POSIXchmod(2)follows symlinks (matches what shellchmoddoes);modeis masked with07777internally so set-uid/sgid/sticky high-bits are honoured. On Windows only the user-write bit (0o200) is meaningful, read-only is toggled viaSetFileAttributesW(FILE_ATTRIBUTE_READONLY); every other bit is silently ignored, matching Python'sos.chmoddocumented behaviour.
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.
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.soimport 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 hostA host or script on its own static runtime is refused at mount with KIND_IO and a message naming --shared-runtime.
script_gateway.mount(server, path_prefix, so_path)→(int, int, string)Mount the shared library atso_pathas the request handler for every URL whose path starts withpath_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 |
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).
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 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)tuplejson.parse_strict(json_str)→(ptr, int, string)- Structured-error variant ofparse: returns(value, KIND_OK, "")on success or(null, KIND_*, "<reason> at <line>:<col>")on failure.kinddiscriminates 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 matchstd.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 aparse/parse_strictreturned 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, not42.0or4e1) (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_MAXjson.get_bool(value)- Get boolean (1/0)json.get_string(value)→(string, string)- Get string value;(text, err)tuple, errors with"not a string"ifvalueis not aJSON_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 pairjson.object_has(obj, key)- Check if key exists (returns 1/0)json.object_size(obj)- Number of entries (0for empty,-1if 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 orderobject_setwas called). Mutatingobjduring 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 valuejson.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 / arrayjson.str(s)/json.num(f)/json.boolean(0|1)/json.null_value()scalarsjson.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 ofvaluejson.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
}
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, noT.GetType(), noreflect.TypeOfso 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")thenjson.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-jsonflag 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 tagsjson:"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), andto_json_schema()to emit a draft-07 JSON Schema. It does not consume external JSON Schema documents; validate against astd.schemarecord, or build that on top. - No arbitrary-precision numbers. Numbers are
intordouble; the parser falls through tostrtodfor correctly-rounded IEEE-754 on edge cases but there's noBigDecimal/decimal.Decimalequivalent for financial precision.
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_splitsplits one record on a separator you choose and trims a\r\nline 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.
aereadsaether.tomlwith 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 thatstring.spliton 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.
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- Scalarsmsgpack.arr()/msgpack.map()→ptr- Containersmsgpack.ext(type_id, data)→ptr,msgpack.get_ext_type(v)→int- An extension value and its application-defined type idmsgpack.array_add(a, v),msgpack.map_set(m, key, v)- Buildmsgpack.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)- Readmsgpack.get_type(v)→int, andget_bool/get_int/get_float/get_string/get_bin- Unwrapmsgpack.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 onemsgpack.pack(v)→string,msgpack.unpack(bytes)→(ptr, string)-unpackreads exactly one value; trailing bytes are an errormsgpack.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.
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- Scalarscbor.obj()/cbor.arr()→ptr,cbor.tag(n, child)→ptr- Containers and tagscbor.set(o, key, v)/object_set/map_set,cbor.push(a, v)/array_add- Buildcbor.object_get(o, key)→(ptr, string),cbor.map_get,cbor.object_size/array_size/object_entry- Readcbor.type(v)→int,cbor.is_null(v)/is_undefined(v), andget_bool/get_int/get_long/get_float/get_string/get_bytes/get_tag_val/get_tag_child- Unwrapcbor.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 themcbor.encode(v)→(string, string),cbor.parse(bytes)→(ptr, string)-parsereads 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 serializationcbor.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 serializationcbor.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.
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 withyaml.freeyaml.root(doc)→ptr- The document's root nodeyaml.type(node)→int-YAML_SCALAR,YAML_SEQUENCEorYAML_MAPPINGyaml.get_scalar(node)→(string, string)- A scalar's textyaml.sequence_size(node)→int,yaml.sequence_get(node, i)→ptryaml.mapping_size(node)→int,yaml.mapping_get_key(node, i)/mapping_get_value(node, i)→ptryaml.mapping_lookup(node, key)→ptr- Value for a key,nullwhen absentyaml.emit_node(node)/yaml.emit_document(doc)→(string, string)- Back to YAML textyaml.free(doc)- Release a document
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 (& < > " ') 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 <b>.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;, A) or names a
character XML 1.0 does not allow (�, a surrogate such as �,
anything past ); a & that starts no reference (a & b); a
reference to an entity other than & < > " ' ( ):
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)→ptrnew pull reader (free withxml.free)xml.next(p)→intadvance; returns anEVENT_*xml.name(p)→stringelement name for the current START/ENDxml.text(p)→stringentity-decoded character data for the current TEXTxml.attr(p, key)→stringattribute value on the current START (""if absent)xml.attr_count(p)/xml.attr_name(p, i)/xml.attr_value(p, i)iterate attributesxml.error(p)→stringmessage after anEVENT_ERRORxml.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()→ptrnew builder (namedwriterbecausebuilderis a keyword; free withxml.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 dataxml.element(b, name, content)<name>escaped</name>in one callxml.finish(b)→stringthe document (escaping already applied)xml.escape(s)→stringescape the five predefined entities (rarely needed directly)
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.algois"sha1","sha256", or any other name OpenSSL'sEVP_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-1if this build can computealgo,0otherwise. Always succeeds; never errors. Use at config time to validate user-supplied algorithm names before they hithash_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)-nrandom bytes as(bytes, length, error);bytespreserves embedded NULs.cryptography.random_hex(n)→(string, string)-nrandom bytes as a2*n-char lowercase-hex string. For opaque bearer-token / API-key minting.cryptography.random_base64(n)→(string, string)-nrandom 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 foralgo("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)- Feedlengthbytes;(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. FREESctx.cryptography.digest_final_bytes(ctx)→(string, int, string)- Finalize to raw digest bytes. FREESctx.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 ofdata, unpadded output.encoding.base64_encode_padded(data: byte[])→string- Encode the bytes ofdata, 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).erris 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;bytesis 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.equalsare 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.
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 hexencoding.hex_decode(s)→(string, string)- Bytes, or an error for an odd length or a non-hex digitencoding.base64_encode(data: byte[])→string- Unpadded Base64encoding.base64_encode_padded(data: byte[])→string- Padded Base64encoding.base64_decode(s)→(string, string)- Accepts padded or unpadded input; malformed padding or non-zero leftover bits are an errorencoding.base32_encode(data: byte[])→string,encoding.base32_decode(s)→(string, string)- RFC 4648 Base32encoding.csv_split(record, sep)→ptr- Split ONE record onsep; a trailing carriage return is trimmedencoding.csv_count(h)→int,encoding.csv_field(h, i)→string- Field count and fieldi, borrowed from the handleencoding.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.
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.
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 ofdataatlevel(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.
Note: Use
import std.httpfor thehttp.*prefix shown below.import std.netreaches the same HTTP externs, alongside TCP, under thenetprefix:net.http_get_raw(url). The short Go-style wrappers (get,post,put,delete) are defined instd.httpand are not part ofstd.net, so those need thehttpimport.
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 aDurationper-call timeout.0nskeepsget'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 POSThttp.put(url, body, content_type)→(string, string)- HTTP PUThttp.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 (aDuration). 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 read1, and10with 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 stringhttp.response_headers(response)- Read headers as stringhttp.response_error(response)- Read transport error, empty string on successhttp.response_ok(response)- 1 if request succeeded (no transport error, 2xx status), else 0http.response_free(response)- Free response
Server Lifecycle:
http.server_create(port)- Create server (never fails)http.server_set_host(server, host)- Set bind address beforeserver_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 stringhttp.server_start(server)→string- Start serving (blocking), return error stringhttp.server_stop(server)- Stop serverhttp.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 thesendfile(2)fast path (zero heap allocation for the body); falls back to a buffered read for TLS / HTTP/2 / Range requests / Windows.Content-Typeresolved viahttp.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 routehttp.server_post(server, path, handler, user_data)- Register POST routehttp.server_put(server, path, handler, user_data)- Register PUT routehttp.server_delete(server, path, handler, user_data)- Register DELETE routehttp.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 aDurationidle timeout (max_requests=0is 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=0uses 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 sharedstd.workerthread pool, sized toworker_count.worker_count > 0lets 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. Seedocs/http-server.mdfor 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 aDuration, exit. h2 sessions emit aGOAWAYframe 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.formatis"combined"or"json";output_pathis 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 ifreqis null.http.request_path(req)→string- URL path (no query string); empty ifreqis null.http.request_body(req)→string- Request body as a C-string. Truncates at the first embedded NUL when read viastring.length(...); pair withhttp.request_body_lengthfor 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 withrequest_body_readon 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 ifreqis 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 tohttp.request_body. On a streaming request this is the declaredContent-Lengthuntil 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 likerequest_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'trecvuntil 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: chunkedrequest bodies remain unsupported (noContent-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 headerhttp.get_query_param(req, name)- Get query parameterhttp.get_path_param(req, name)- Get URL path parameterhttp.request_free(req)- Free request
Response Building:
http.response_create()- Create responsehttp.response_set_status(res, code)- Set HTTP status codehttp.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 headerhttp.response_set_body(res, body)- Set response body. Usesstrdup+strleninternally, truncates at the first embedded NUL. Fine for text bodies; useresponse_set_body_nfor anything that may contain binary.http.response_set_body_bytes(res, body: byte[])- Binary-safe sibling ofresponse_set_body: sets the bytes ofbodyverbatim (embedded NULs survive); the server copies them.http.response_json(res, json)- Set JSON responsehttp.server_response_free(res)- Free response
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 emitsWWW-Authenticate: Bearer realm="…"witherror="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 (whenredirect_urlis 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 fromX-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 (defaultX-Forwarded-For), takes the leftmost non-empty IP, addsX-Real-IPto 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 viamiddleware.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 sendsAccept-Encoding: gzipand the body is at leastmin_sizebytes; 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 viamiddleware.error_pages_register(opts, status_code, body, content_type).
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)→stringproxy.upstream_remove(pool, base_url)→stringproxy.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 forcookie_hashalgo.proxy.rate_limit_set(pool, max_rps, burst)→string- per-upstream token-bucket rate limit.max_rps = 0disables.
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 = 0disables.
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()→ptrproxy.opts_set_strip_prefix(opts, prefix)→string- chops a path prefix before forwarding (e.g./api/users→/usersupstream).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)→stringproxy.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 underpath_prefix."/"forwards everything;"/api"forwards just the/apisubtree.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).
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- AppendName: valueto outgoing headersclient.set_body(req, body: byte[], content_type)→string- Set the request body to the bytes ofbody(binary-safe; pass a string asstring.bytes(s)). Empty content_type defaults toapplication/x-www-form-urlencoded.client.set_timeout(req, timeout)→string-Durationper-request timeout (0ns= block forever)client.set_follow_redirects(req, max_hops)→string- Follow up tomax_hopsredirects (0= don't follow, the default)client.send_request(req)→(ptr, string)- Fire the request; returns(resp, "")on transport success or(null, err)on failureclient.set_stream(req, on)→string- Enable streaming of the response body for this request;send_streamis the convenience formclient.send_stream(req)→(ptr, string)- Likesend_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-1skips TLS peer + hostname verification for this request only (curl -k/wget --no-check-certificate). Relaxed per-connection, never on the shared process-wideSSL_CTX, so other requests keep verifying. Default0. 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 atpathinstead of the system trust store, while keeping peer and hostname verification on. This is the "verify, but against THIS cert" knob, strictly stronger thanset_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-SSLX509_STORE; never touches the sharedSSL_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-1follows$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 uppercaseHTTP_PROXYis refused when$REQUEST_METHOD/$GATEWAY_INTERFACEis set (lowercasehttp_proxystays 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 aCONNECTtunnel 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 codeclient.response_body(resp)→string- Response body, binary-safe (buffered responses)client.response_is_stream(resp)→int- 1 if the body is streamed (fromsend_stream), 0 if bufferedclient.response_read(resp, max)→string- Pull the next decoded body window (up tomaxbytes) from a streaming response; empty result = end-of-body (streaming)client.response_header(resp, name)→string- Case-insensitive single-header lookup,""if absentclient.response_headers(resp)→string- Raw header blockclient.response_error(resp)→string- Transport error stringclient.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 statusclient.post_json(url, value)→(ptr, string)- Marshal a JSON value (std.json), setContent-Type+Accepttoapplication/json, sendclient.response_body_json(resp)→(ptr, string)- Wrapresponse_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.
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.
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'sQueryEscape): a space becomes+.url.encode_path: path segment (Go'sPathEscape):$&+:=@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 aboveurl.decode(s)→(string, string)- Decode; the error names a malformed escapeurl.parse_query(s)→(ptr, string)- Parse a query string (a leading?is tolerated) into astring_listurl.query_get(list, name)→string- The first value forname,""when absenturl.query_get_all(list, name)→ptr- Every value for a repeated key, as a freshstring_listurl.query_free(list)- Release a listparse_queryorquery_get_allreturned (null is a no-op)
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 ofdata(copied in);""once a response is complete,"incomplete"beforehttp1.read_response_conn(r, conn)→string- Read a whole response from atls13_clientconnectionhttp1.status_code(r)→int,status_reason(r)→ptr(a C string),header(r, name)→string(case-insensitive),header_count(r)→inthttp1.body(r)→byte[]- The decoded body as a borrowed view, valid untilresponse_free
Note:
sendandreceiveare reserved actor keywords in Aether, so the TCP byte-transfer wrappers are namedwrite/read. The raw externs retain theirsend_raw/receive_rawnames.
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; usetcp.write_nfor 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 asstring.bytes(s), a prefix asstring.bytes(s)[0..n].tcp.read(sock, max)→(string, string)- Read text-shaped data, return(data, err). Usetcp.read_nfor 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). Onesend(2): the count may be short, so loop ons[sent..]. A slice with no bound panics.tcp.read_into(sock, buf: byte[])→(int, string)- One receive of at mostbuf.lenbytes into a caller-owned slice, nothing allocated; the data isbuf[0..n](#2301). Errors asread_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 sockettcp.accept(server)→(ptr, string)- Accept connectiontcp.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.
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 exactlys.lenbytes.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 intobuf[0..n], with its sender (free withaddr_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.
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_iomust be called from inside an actor'sreceivehandler (not frommain()). The bridge reads the current actor from a TLS set at the top of every generated_step()function.- The fd must outlive the
await_ioregistration. If you close the fd before theIoReadyfires, 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.
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, withabout,arg(name) { ... }andsubcommand(name) { ... }inside- Inside
arg:long_(name),short(char),help(text),required(),positional(), and one kind --flag(),string_arg()(the default) orint_arg() clapae.parse(cmd)/parse_list(cmd, argv)→(int, ptr, string)- Result, matches, errorclapae.get_string(m, name)→string,get_int(m, name)→(int, string),get_flag(m, name)→intclapae.subcommand_name(m)→string- Which subcommand was chosen, or""clapae.subcommand_matches(m, name)→*ArgMatches- That subcommand's own matches, or null unlessnameis the one chosenclapae.print_help(cmd),free_command(cmd),free_matches(m)
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= DEBUG1= INFO2= WARN3= ERROR4= 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 logginglog.write(level, message)- Write a log message at the given levellog.set_level(level)- Set minimum levellog.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).
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 asio.setenvuseos.setenvwhen you've already importedstd.osforos.getenv.os.temp_dir()→string- The directory for scratch files, with no trailing separator. Windows resolves it throughGetTempPathW(which already does the documentedTMP→TEMP→USERPROFILE→ Windows-directory cascade); POSIX readsTMPDIRand 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 asio.unsetenv.os.getpid()→int- Process identifier of the current process. POSIXgetpid(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, macOStask_info, Linux/proc/self/statm, FreeBSDkinfo_proc).-1where 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 usemem.heap_in_use()(exactly the blocks held wheremem.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).-1where the platform does not report it (FreeBSD).os.user_id()→int- Effective user id of the calling process (POSIXgeteuid(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 (POSIXgettimeofday; WindowsGetSystemTimeAsFileTime). NTP-jumpable, pair withwall_microsfor 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 samestruct timevalaswall_seconds.os.now_monotonic_ms()→long- Monotonic clock, milliseconds since boot / process-start / arbitrary epoch. Value-domain is opaque; only deltas are meaningful. POSIXclock_gettime(CLOCK_MONOTONIC); WindowsQueryPerformanceCounter. Use for animation tick loops, frame-time budgets, microbenchmarks, anything that must survive a wall-clock jump.os.now_monotonic_ns()→long- Same source asnow_monotonic_ms, nanosecond precision. Useful for sub-millisecond timing.os.args_count()→int- Number of command-line argumentsos.args_get(index)→string- The i-th argument, a copy of its own;""when out of range, sealed or not initialisedos.argv0()→string- Path the OS launched the current process with (argv[0]), a copy of its own;""before argv is initialisedos.args_seal()- One-shot runtime seal of the argv accessors. After this returns,os.args_count()reports0,os.args_get(i)andos.argv0()return"", andos.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: oncemain()has parsed its CLI flags into config state, callos.args_seal()to prevent any later code (imported libraries, plugin callbacks, untrusted Aether modules) from reading the original argv. Complements the compile-timehide/seal exceptscope 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- Returns1ifargs_seal()has been called in this process,0otherwise. Cheap; useful for cooperative callers that want to check before they call.os_execv(prog, argv_list)→int- Replace the current process image withprog, passing an explicitlist<ptr>argv. Uses POSIXexecvp(3)soprogis looked up onPATHwhen 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-1and the current process continues. Windows cannot replace a running process, so there it runsprog, 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)
}
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.
argvis astd.listof the arguments after the program. The child still sees the program itself as itsargv[0].envis a list of"KEY=VALUE"strings, ornullto 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 inPATHEXTthat Windows can start (.com,.exe,.bat,.cmd). The current directory is not searched on any platform, so agit.exesomeone left where the program runs is never thegitit 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, erroros.run_full(prog, argv, env, stdin_data)→(string, string, int, string)- Feedstdin_datato 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 startos.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.killandos.wait_pid_timeoutreport it gone rather than reaching whatever process has that numberos.spawn_proc_to(prog, argv, env, out_path)→(int, string)- Start without waiting, the child's stdout and stderr written to the fileout_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, soos.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. Withos.wait_any_timeoutthis is a parallel runner in one thread: start N, wait for the first, kill whichever passes its deadline, read each one's fileos.wait(token)→(int, string)- Wait for one child: exit status, erroros.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 withmem.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 <= 0waits indefinitelyos.kill(pid, sig)→int- Send a signal (numbers fromstd.signal); 0 on success. A negative pid signals the whole process group, andsig == 0only checks that the process exists. Windows has no catchable signals: any non-zerosigterminates, and there is no group formos.wait_pid_timeout(pid, secs)→(int, int, string)- Wait at mostsecsfor one child: status,timed_out, error. A child that times out is left runningos.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 GNUtimeoutreports), 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 thestd.ipcback-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 aboveos.chdir(path)→string,os.getcwd()→string- The working directory;chdirreturns""or an erroros_which(name)→string- The first match fornameonPATH, or""
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 noneipc.write(fd, bytes)→string- Write;""or an erroripc.write_close(fd, bytes)→string- Write and close, for the common one-report-at-exit shape
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.
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 librarydl.symbol(handle, name)→(ptr, string)- Look up a symboldl.close(handle)→string- Unload;""on successdl.last_error()→string- The calling thread's last loader error
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 timetime.from_civil(y, m, d, hh, mm, ss)→DateTime- Build from civil fieldstime.from_unix(epoch)→DateTime,time.to_unix(dt)→long- Convert either waytime.weekday(dt)→int,time.day_of_year(dt)→int- Derived fieldstime.is_leap_year(y)→bool,time.days_in_month(y, m)→int- Calendar queriestime.add_seconds(dt, n)/add_minutes/add_hours/add_days→DateTime- Arithmetictime.diff_seconds(a, b)→long,time.is_before(a, b)/time.is_after(a, b)→bool- Comparisontime.to_iso8601(dt)→string,time.parse_iso8601(s)→(DateTime, string)- ISO-8601 round trip.parse_iso8601readsYYYY-MM-DDTHH:MM:SS, an optional fraction (dropped) and an optionalZ/z, and nothing else (an offset isparse_iso8601_offset's);to_iso8601writes a year outside 0..9999 in full, signed when negativetime.parse_iso8601_offset(s)→(DateTime, int, string)- ISO-8601 as commonly written (a date alone,Tor a space, optional seconds and fraction, aZ,UTCor+HH:MMzone): the instant and the offset it was written at, in seconds east of UTCtime.strftime(dt, fmt)/time.strftime_at(dt, offset, fmt)→string- Ruby'sTime#strftimeconversions 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.
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 shiftbits.rotr32/rotl32/rotr64/rotl64(x, n)→int- Rotatebits.popcount32(x)/bits.popcount64(x)→int- Number of set bitsbits.clz32(x)/bits.clz64(x)→int- Leading zero countbits.udiv32/urem32/udiv64/urem64(a, b)→int- Unsigned division and remainderbits.ucmp64(a, b)→int- Unsigned comparisonbits.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.
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 simplehash.murmur3_32(data: byte[], seed)→long- MurmurHash3, better distributionhash.siphash24(data: byte[], k0, k1)→long- SipHash-2-4 under a 128-bit keyhash.crc32(data: byte[])→long,hash.crc32_update(crc, data: byte[])→long- CRC-32 (IEEE 802.3), continued across pieces bycrc32_update
None of these is a cryptographic hash: for integrity or signatures use
std.cryptography.
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- Constructbignum.to_decimal(n)→string,bignum.to_hex(n)→string,bignum.to_bytes(n)/to_bytes_unsigned→string- Renderbignum.add/subtract/multiply/divide/remainder/mod(a, b)→ptr- Arithmeticbignum.negate(a)/bignum.abs(a)→ptr,bignum.shift_left(a, n)/shift_right(a, n)→ptrbignum.compare(a, b)→int,bignum.is_zero(a)/bignum.sign(a)/bignum.bit_length(a)→intbignum.test_bit(a, i)→int- Bitiof|a|(0 the least significant), read in place with no allocation: what a scalar multiplication walksbignum.mod_pow(base, exp, m)→ptr- Modular exponentiation, without formingbase^expbignum.gcd(a, b)/bignum.mod_inverse(a, m)→ptr,bignum.is_probable_prime(n, rounds)→intbignum.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.
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 valuemath.min_int(a, b)/math.min_float(a, b)- Minimummath.max_int(a, b)/math.max_float(a, b)- Maximummath.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 functionsmath.asin(x),math.acos(x),math.atan(x)- Inverse trigmath.atan2(y, x)- Two-argument arctangent
Power / Logarithms:
math.sqrt(x)- Square rootmath.pow(base, exp)- Powermath.log(x)- Natural logarithmmath.log10(x)- Base-10 logarithmmath.exp(x)- Exponential (e^x)
Rounding:
math.floor(x),math.ceil(x),math.round(x)
Random:
math.random_seed(seed)- Seed RNGmath.random_int(min, max)- Uniform random integer in[min, max], both ends included, for any pair of intsmath.random_float()- Uniform random float in[0, 1), never 1.0 (splitmix64; the same sequence for a seed on every platform)
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 digitsnumber.format_decimal(locale, value, opts)→string- Format afloatnumber.format_percent(locale, value, opts)→string- Format a ratio as a percentagenumber.format_currency(locale, currency, value)→string- Format with an ISO 4217 codenumber.format_decimal_string(locale, decimal, opts)→(string, string)- The exact-decimal form; the error names bad inputnumber.format_percent_string(locale, decimal, opts)/number.format_currency_string(locale, currency, decimal)→(string, string)- The same for the other twonumber.format_decimal_default(locale, value)/number.format_percent_default(locale, value)→string- Withdefault_options()
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 stringio.print_line(str)- Print string with newlineio.print_int(value)- Print integerio.print_float(value)- Print float
Unbuffered fd writes (crash-trace use case):
io.stderr_write(data)→int- Writedatato fd 2 directly (length computed internally withstring.length), bypassing stdio buffering. Returns the byte count actually written, or -1 on error. Loops on partial writes; retriesEINTRon POSIX. Reach for this when output must reach the terminal / pipe before the process aborts,printlnandio.printare 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 asstderr_writebut 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)- Openpathfor reading (POSIXO_RDONLY/ Win_O_RDONLY | _O_BINARY). Returns(fd, "")on success,(-1, error)on failure. -
io.fd_open_write(path)→(int, string)- Openpathfor writing, implicitO_CREAT | O_TRUNC(mode 0644 on POSIX,_O_BINARYon Windows). Returns(fd, "")/(-1, error). Pair withfd_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 tofd; 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 passstring.bytes(s), for astd.bytesbufferbytes.view(buf). -
io.fd_read_n(fd, n)→(ptr, int, string)- Read up tonbytes fromfd. Returns(bytes, count, err):bytesis a refcounted AetherString carrying the explicit byte count (binary-safe, embedded NULs survive),countis the number actually read (1..n on success, 0 on clean EOF or error),erris""on success or clean EOF, otherwise an error message. -
io.fd_read_line(fd)→(string, string)- Read one\n-delimited line fromfd. Trailing\nis stripped (a preceding\ris 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 haseof=false, even if it is the last line; no read-ahead is performed. Errors return("", false, error). Trailing CR/LF stripping matchesfd_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 likefd_read_nandfd_read_line. The existingfd_read_lineAPI retains its two-result shape and behavior.
File Operations (Go-style):
io.read_file(path)→(string, string)- Read entire fileio.write_file(path, content)→string- Write (overwrites), return error stringio.append_file(path, content)→string- Append to fileio.delete_file(path)→string- Delete fileio.file_info(path)→(ptr, string)- Get file metadataio.file_info_free(info)- Free file infoio.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 stringio.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 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()→boolaudio.load_wav(data, len)→ptr!,audio.load_pcm(data, len, rate, channels, format)→ptr!- Fallible: handle the error withorortry;formatis one ofFORMAT_U8,FORMAT_S16,FORMAT_S24,FORMAT_S32,FORMAT_F32audio.play(src)/pause/stop→bool,audio.is_playing(src)→bool-stopalso rewindsaudio.volume(src, v)→bool(clamped to 0-1),audio.get_volume(src)→floataudio.seek_ms(src, ms)→bool,position_ms(src)/duration_ms(src)→long,channels(src)/sample_rate(src)→intaudio.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)→floataudio.pitch(src, ratio)→bool(ratio > 0),audio.get_pitch(src)→floataudio.looping(src, on)→bool,audio.is_looping(src)→bool,audio.fade_ms(src, from, to, ms)→bool(fromof -1 means "from the current volume")audio.played_frames(src)→long- Output frames this source has produced, across loops and whatever the pitchaudio.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(nullroutes 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)→longaudio.open_headless(rate, channels)→bool,audio.is_headless()→bool,audio.render(frames)→int- No device, no mixer thread: nothing advances untilrenderpulls frames, so a test's numbers follow from its calls;audio.rendered_peak(channel)/audio.rendered_sample(frame, channel)→floatread the block back
What the sandbox decided, and the operating-system containment FreeBSD adds beneath it.
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 bufferaudit.entry(i)→(string, string, int)- Category, resource, and 1 if allowedaudit.denied_count()→intaudit.clear()- Empty the buffer, to scope a query to one operation
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()→intcapsicum.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 forfcntls_limit(F_GETFL,F_SETFL,F_GETOWN,F_SETOWN)
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"channelcasper.pwd_uid(pwd, user)→int,casper.pwd_home(pwd, user)→(string, string)- Over"system.pwd"casper.sysctl(chan, name)→(string, string)- Over"system.sysctl"
spawn(ActorName())- Create actor instancewait_for_idle()- Block until all actors finishsleep(milliseconds)- Pause executionrelease(s)- Release the reference astringlocal holds now and leave the local empty, asstring.release(s)does; without it the local releases the string when it is overwritten or its scope ends. The argument must bestring-typed (a*StringSeqhasstring.seq_free), and a function may not release a parameter it borrows (E0901).
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)- Runworkon a pool thread;donereceives itsptrresult 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 unlikepool_shutdownit 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: applyfto every element ofitemson the pool, collecting results into a new list that is index-aligned with the input (the Goerrgroup.SetLimitshape).fruns 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 ofitems.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.
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- Allocatebytes(8-byte aligned). Memory is uninitialized. NULL on OOM or null arena.arena.alloc_aligned(arena, bytes, alignment)→ptr- Same asallocwith 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().
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 theirset_twins - Native-endian scalarsmem.get_byte_sz/set_byte_sz-get_byte/set_bytewith asize_toffset, for an index that is already onemem.get_int/get_long/get_ptr/get_float32/get_float64, and theirset_twinsmem.get_u16_le/get_u16_be/get_u32_le/get_u32_be/get_u64_le/get_u64_be, and theirset_twins - Explicit byte order, for wire formatsmem.copy(dst, src, n)/move(dst, src, n)/compare(a, b, n)/set(dst, value, n)-memcpy,memmove,memcmp,memsetmem.copy_at/move_at/fill_at/compare_at- The same with a byte offset on each buffermem.bits_of_float/float_from_bits,mem.clz32/clz64,mem.udiv64_32- Bit-level helpersmem.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 slotmem.call_fn3_int/call_fn3_void/call_fn2_void- Call a C function pointer, for callbacks a C library hands over
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 allocatoralloc.of_arena(arena)→ptr- An allocator over astd.arenaarena; release it withalloc.arena_freealloc.raw(a, size)→ptr- Allocatesizebytesalloc.resize(a, block, old_size, new_size)→ptr- Grow or shrink, keeping the contentsalloc.release(a, block, size)- Free, with the size it was allocated at
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 overinner(the system allocator wheninneris null)tracking.count(t)→int,tracking.bytes(t)→long- Live blocks and bytes right nowtracking.report(t)→int- List live allocations on stderr; returns their counttracking.destroy(t)- Release the tracker itself (not the blocks it saw)
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 holdinginitialsnapshot.load(cell)→ptr- The current value, lock-freesnapshot.store(cell, value)→ptr- Publishvalue; returns the displaced onesnapshot.cas(cell, expected, value)→int- Publish only if the current value isexpected; 1 on successsnapshot.free(cell)- Free the cell, not the values
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 todigest. 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_CASif 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.
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 v4uuid.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.
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
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
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
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 failsnanoid.generate_n(n)→(string, string)- An id ofncharacters
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.
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
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;argsis astd.mapof name to valueparse(msg)→(ptr, string),format_pattern(locale, pattern, args)→string,pattern_free(pattern)- Parse once, format many timescatalog_new(locale)→ptr,catalog_add(cat, id, msg),catalog_format(cat, id, args)→string,catalog_free(cat)- A message catalog
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 onelanguage.language(tag)/script(tag)/region(tag)→string,language.base(tag)→Tag- Its parts (Tagis a distinctstring)language.matcher_create(supported)→*Matcher,language.matcher_free(m)- The tags an application supports, best firstlanguage.match_strings(m, preferred)→string- Best match for anAccept-Language-style list;match_tags(m, seq)for a sequence
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_dataslot 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
staticC 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.
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. Bothkeyandvalueare 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, otherwisedefault_value.config.has(key)→int- 1 ifkeyhas 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.
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)- Bindname→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 ifnamewasn'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."
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 forevent, 0 if nothost.caller_identity()→string,host.caller_attribute(key)→string,host.caller_deadline_ms()→long-""/ 0 outside a host callhost.describe,host.input,host.event,host.bindings, and the per-languagejava/python/ruby/gohelpers - The manifest DSL a namespace'smanifest.aeuses to describe its ABI
- Getting Started
- Tutorial
- Module System Design
- Standard Library Guide, where to start by task