Skip to content

Standard Library

Fels’s standard library is called std. It stays minimal, and it is allocation-free where it can be.

Here are the primary modules:

The I/O interfaces and utilities. Everything here is synchronous and allocation-free.

Interfaces:

  • Writer: write, flush
  • Reader: read, which gives at most buf.len bytes and 0 only at the end of the source. It may give fewer than are left, as a pipe or a socket does; std.bufio buffers a reader.

Implementations & Tools:

  • stdout(), stderr(): Return Stdout / Stderr structs that implement Writer.
  • stdin(): Returns Stdin, which implements Reader. At the end of the input (a closed pipe, Ctrl+Z or Ctrl+D at a console) read gives 0.
  • slice_reader(bytes): A Reader of bytes in memory (SliceReader), for a parser that takes a *Reader, or a test.
  • copy(dst, src, buf): Reads src to its end and writes it all to dst, through buf, and returns the bytes copied.
  • fixed_buf(): A 256-byte buffer on the stack that implements Writer (FixedBuf).
  • null_writer(): A writer that discards its output but counts the bytes (NullWriter).

Extension Methods (on *Writer):

  • write_all, write_line, write_char, write_repeat
  • write_bool, write_u64, write_i64, write_hex_u64

Printing: print, println, eprint and eprintln, and w.write_fmt(f) and w.write_fmt_line(f) on any *Writer, take a Format: a string literal with values in braces.

io::println("{name} has {n} items");
io::println("{p}"); // Point{ x: 1, y: 2 }
io::println("[{x:>8}] [{id:06}] {flags:x} {ratio:.2} {s:?}");
  • Any expression goes in the braces, and {{ and }} are braces of the text. The compiler reads the literal: the values are checked where the call is, and a mistake points at its place in the string. No string literal fits in the braces; put one in a local first.

  • A spec after a : is [[fill]align][0][width][.precision][kind]. Align is <, > or ^ (numbers go right by themselves, the rest left); 0 pads a number with zeros after its sign; the precision is the digits after a float’s point; the kind is x or X for hex, b binary, o octal, and ? for text in quotes. The width counts bytes.

  • Every type prints: numbers, bool, text, an enum as its variant, an error as its name, an optional as its value or null, slices and arrays as [1, 2], structs as Point{ x: 1, y: 2 } (text inside them in quotes), pointers as their address. A float shows up to 15 significant digits, without the zeros at the end (3.14159265, 0.1, 2).

  • A type with a method format :: fn(self, f: *Formatter) prints itself, also inside other values. f.text("…") writes text and f.value(x, "spec") a value:

    Money :: struct {
    cents: i64,
    pub format :: fn(self: Money, f: *Formatter) {
    f.value(self.cents / 100, "");
    f.text(",");
    f.value(self.cents % 100, "02");
    }
    }
  • A function of the program can take a Format too, and pass it on (println(f)) or write it (w.write_fmt(f)). Its values are read when it is written, so a log call that drops its message reads none.

  • Any other []const u8 passed as a Format prints as it is, braces and all, so text from data is never read as a format.

print_u64, print_i64, print_hex_u64, print_bool, eprint_u64 and eprint_i64 are deprecated: a format says the same (print("{n}")).

Buffered reading and writing over any Reader and Writer: a file, stdin, a socket, text in memory. The buffer is the caller’s, a stack array or a slice from an arena.

#import std.bufio as bufio;
f := fs::open("data.csv")?;
buf: [4096]u8 = .{};
r := bufio::reader(&f, buf[:]);
for line in r.lines() { … } // each line without "\n" or "\r\n"
if r.failed { … } // a read failed, or a line was longer than the buffer
out := io::stdout();
wbuf: [4096]u8 = .{};
w := bufio::writer(&out, wbuf[:]);
ww: *Writer = &w;
ww.write_all("many small writes, few calls to the OS\n")?;
ww.flush()?; // what waits in wbuf, then out's own flush
  • BufReader (reader(src, buf), or reader_in(&a, src, size) with a buffer from an arena): read_line() gives the next line, read_until(delim) the bytes before the next delim, read_byte() one byte; each is null at the end. A line is a slice of the buffer, good until the next read; one longer than the buffer fails with buffer_full. lines() is the same as a for loop, which a failed read ends, setting failed. BufReader is a Reader itself, and buffered() shows the bytes read and not yet taken.
  • BufWriter (writer(dst, buf), writer_in(&a, dst, size)) is a Writer. A write waits in the buffer until it is full; one as big as the buffer goes straight to dst. flush writes what waits and flushes dst; drain only writes it.
  • read_all(&a, src) reads a Reader to its end, into memory from an arena.

Formatting utilities built on top of std.io.Writer.

  • write_padded_left, write_padded_right, write_padded_center
  • write_u64_padded, write_hex_u64_padded
  • join_bytes(w: *Writer, items: []const []const u8, sep: []const u8)

It also reads numbers from text. Both fail with a ParseError (.empty, .invalid, .overflow):

fmt::parse_int(i32, "-42", 10) // -42
fmt::parse_int(u8, "0xff", 0) // 255: base 0 reads a 0x, 0o or 0b prefix
fmt::parse_int(u8, "256", 10) // fails .overflow
fmt::parse_int(usize, "1_000", 10) // 1000: `_` may stand between digits
fmt::parse_float("6.02e23") // also ".5", "5.", "inf", "nan"

parse_int takes any integer type and checks its limits as it reads; the base is 2 to 36. parse_float gives the closest f64 for up to 15 significant digits and an exponent within ±22, and may be off by one in the last bit beyond that. std.json and std.serde read their numbers with these.

Safe, generic helpers for typed memory.

  • eql($T: type, a: []const T, b: []const T) -> bool
  • starts_with, ends_with, contains, index_of
  • copy($T: type, dest: []T, src: []const T)
  • fill($T: type, slice: []T, value: T)
  • swap, reverse
  • index_of_slice, last_index_of, last_index_of_slice, count (of a sequence, not overlapping)
  • trim, trim_left, trim_right($T, s, set): without the elements of set at the ends

For bytes there are specialized aliases: eql_bytes, copy_bytes and zero. compare_bytes(a, b) -> i32 orders two texts byte by byte (-1, 0 or 1, a prefix first), and less_bytes(a, b) -> bool is that order as a comparison, for sorting text.

It also goes through the parts of a slice, as iterators a for takes:

for part in mem::split(u8, "a,,b", ",") { … } // "a", "", "b": empty parts too
for word in mem::tokenize(u8, " a b ", " ") { … } // "a", "b": runs of delimiters skipped
if mem::split_once(u8, "key=value", "=") |kv| { // kv.before "key", kv.after "value"
…
}

A growable StringBuilder in an arena, and functions on text. The builder also implements std.io.Writer.

sb := strings::builder(&a);
sb.append("x = ");
sb.append_i64(-42);
io::println(sb.str());
  • On text: str_eq, str_starts_with, str_ends_with, str_index_of, str_last_index_of, str_contains, str_count, str_count_byte, str_trim (_left, _right), str_eq_ignore_case
  • Into an arena: str_dup, str_concat, str_repeat, str_upper, str_lower, str_replace(&a, s, from, to), str_join(&a, parts, sep)
  • Bytes: is_digit, is_alpha, is_space and the like, to_upper_byte, to_lower_byte

And it goes through a text in parts:

for field in strings::split(line, ";") { … } // the parts between separators
for word in strings::fields(text) { … } // the words: between spaces, tabs, line breaks
for line in strings::lines(text) { … } // without "\n" or "\r\n"; no empty last line

This module reads and writes fixed-width integers at an explicit offset in a byte slice. It is meant for wire formats and file headers, where the integers sit at known offsets in a stated byte order.

#import std.bytes as bytes;
parse :: fn(packet: []const u8) -> u32 ! bytes::BytesError {
id := bytes::read_u32_le(packet, 4)?; // bytes 4..8, least significant first
return id;
}
build :: fn(out: []u8, length: u16) -> void ! bytes::BytesError {
bytes::write_u16_be(out, 0, length)?; // bytes 0..2, most significant first
}
  • Names: read_<width>_<order> and write_<width>_<order>, where the width is u16, u32 or u64 and the order is le (little-endian) or be (big-endian). There are also read_u8 and write_u8, which have no order. Reads have the shape (self: []const u8, off: usize) -> T ! BytesError, and writes have the shape (self: []u8, off: usize, v: T) -> void ! BytesError. For a signed value, cast the unsigned one: read_u16_le(b, 0)? as i16.
  • Bounds are always checked, in every build mode. (Plain slice indexing is checked too, unless the build is release_fast.) An integer that would not lie wholly inside the slice fails with .out_of_bounds, and in that case nothing was read or written. The offset can be any usize, even usize max, and the check cannot overflow. BytesError is its own error set. If your parser returns a different one, handle it with catch, because ? needs matching sets.
  • No alignment is assumed. The integers are assembled from single bytes, so an odd offset or a slice that starts mid-buffer is fine, and byte order does not depend on the machine.
  • Unchecked access goes through core::unaligned_read(T, ptr) (little-endian on the supported targets). It is meant for a bound you have already proven.
  • When you import a function by name (#import std.bytes.{ read_u32_le };), you can also call it as a method: packet.read_u32_le(4)?.

See tests/fixtures/stdlib/bytes_app.fels for the module at work.

Runes (Unicode code points, as u32) decoded from UTF-8 text and encoded into it.

#import std.utf8 as utf8;
for r in utf8::runes("grüß") { … } // 4 runes, from 6 bytes
n := utf8::count(text); // how many runes
if utf8::valid(bytes) { … } // valid UTF-8 from start to end
d := utf8::decode(text); // the first rune, d.rune, and its length, d.len
len := utf8::encode(buf[:], 0x20AC); // "€": 3 bytes at the start of buf
  • Decoding is strict: no overlong forms, no surrogates (U+D800 to U+DFFF), nothing past U+10FFFF. A byte that starts no valid sequence decodes as utf8::REPLACEMENT (U+FFFD) and is one byte long, so runes and count go through bad text without stopping or skipping the good text after it. valid says whether there is any.
  • Encoding: encoded_len(r) is 1 to 4, or 0 for a value that is no rune. encode(out, r) writes nothing and returns 0 for such a value, or when out is too short. strings::append_rune writes U+FFFD for it.
  • is_continuation(b) tells a byte that continues a sequence (10xxxxxx) from one that starts one.
  • In source, a string or character literal names a code point with \u{…}: "caf\u{e9}", '\u{20ac}'.

UTF-8 ⇄ UTF-16 conversion, written in plain Fels with no OS calls, so it behaves the same on every target. (Windows’ MultiByteToWideChar / WideCharToMultiByte do a related job. Nothing here depends on them, but the test suite checks that the two agree on every Unicode scalar value.)

#import std.utf16 as utf16;
#import std.heap as heap;
// UTF-8 text -> a NUL-terminated wide string for a Win32 "W" function.
to_wide :: fn(a: *heap::Arena, text: []const u8) -> []u16 ! utf16::Utf16Error {
return utf16::alloc_utf16z(a, text);
}
// Size first, then convert into a buffer you own.
into_buffer :: fn(text: []const u8, buf: []u16) -> usize ! utf16::Utf16Error {
need := utf16::utf16_len(text)?; // exact result length; fails on malformed input
if need > buf.len { fail .buffer_too_small; }
return utf16::utf8_to_utf16(text, buf);
}
  • Representations. UTF-8 is a []const u8. Nothing guarantees that a string is valid UTF-8, which is why every conversion validates. UTF-16 is a []const u16 of code units in the machine’s own order, with no byte-order mark and no serialisation. A character above U+FFFF is a surrogate pair.
  • Functions. For sizing, utf16_len(src8) and utf8_len(src16) give the exact size of the result. To convert into your own buffer, utf8_to_utf16(src, dst) and utf16_to_utf8(src, dst) return the number of units or bytes written. To allocate from an arena, use alloc_utf16(a, src), alloc_utf16z(a, src) or alloc_utf8(a, src). With alloc_utf16z, one extra unit past the end is 0, so .ptr is a Win32 LPCWSTR, and the returned length does not count that unit.
  • Errors (Utf16Error). The errors are .invalid_utf8, .invalid_utf16, .buffer_too_small and, for the arena functions, .out_of_memory. You inspect one with e == .invalid_utf8 in a catch.
  • Malformed input is an error and is never repaired, and there is no replacement character. UTF-8 is rejected for a stray continuation byte, C0/C1/F5–FF, a truncated sequence, a non-continuation where one is required, an overlong encoding, an encoded surrogate (ED A0–BF), and anything above U+10FFFF. UTF-16 is rejected for a high surrogate that is not followed by a low one, and for a low surrogate with no high one before it. U+0000 is an ordinary character.
  • Capacity is explicit. A conversion first validates and counts, then checks the destination, and only then writes. If the input is malformed or dst is too small, it fails and dst is not modified. When dst is longer than needed, its extra units stay as they were. The arena functions allocate exactly the result (plus the terminator for alloc_utf16z). The arena owns that memory, and nothing is freed.

See tests/fixtures/stdlib/utf16_app.fels for hand-picked and exhaustive cases, and utf16_win_app.fels for the agreement with the Windows APIs.

Minimal synchronous file I/O, on Windows and Linux.

  • open(path): Open an existing file for reading
  • create(path): Create or truncate a file for writing
  • append(path): Open a file for appending
  • read_all(path, &a): Read a whole file into the arena; write_file(path, bytes) writes one
  • copy_file(from, to): Copy a file, and give the bytes copied
  • rename(from, to): Rename or move a file, replacing one at to (.rename_failed when it cannot)
  • info(path): An Info with the size, the last write as modified_ns (nanoseconds since 1970, UTC) and is_dir; .not_found when nothing is there
  • delete(path): Remove a file
  • exists(path): Check whether a file or folder exists. is_dir(path) checks for a folder.
  • make_dir(path): Make a folder (one that is already there is fine). make_dirs(path) makes the folders above it too, and remove_dir(path) removes an empty folder.
  • open_dir(path): Walk a folder with the DirIter it returns. dir_next() gives the next entry’s name (. and .. included), or null at the end, and the name is valid until the next call. dir_is_dir(), dir_size() and dir_is_reparse() describe that entry, and dir_close() ends the walk. On Linux, dir_size() is always 0.
  • walk(&a, root): A Walker over everything below a folder, at any depth, each folder before what is in it: for e in w { ... } gives an Entry with its path (joined with /, in the arena), name, is_dir and depth (1 in the root). Links are given but not followed. A folder that cannot be opened is given, skipped, and sets failed; close() ends a walk early.
  • remove_tree(path): Delete a file, or a folder with everything in it. A link inside (a symlink, a junction) is deleted, not what it points at. .not_found when nothing is there, .delete_failed when something stayed (open, read-only, not yours).
  • cwd(&a) gives the current folder, and set_cwd(path) changes it for every thread. absolute(&a, path) makes a path absolute from the current folder and normalizes it (path::normalize). Nothing else is read from the disk, so the path need not exist. On Windows \x is on the current folder’s drive.
  • temp_dir(&a): the folder for temporary files (TMP, TEMP or USERPROFILE on Windows, TMPDIR or /tmp on Linux). home_dir(&a) gives the user’s home folder (USERPROFILE, HOME), or null.
  • create_temp(&a, dir, pattern) makes a new file and opens it to write: a TempFile with file and path. make_temp_dir(&a, dir, pattern) makes a new folder and gives its path. dir “” means temp_dir(). The last * of pattern becomes 16 random hex digits ("report-*.csv"), or they go at the end. A name that is taken is never reused, and on Linux a temp file is readable by its owner only.
d := fs::make_temp_dir(&a, "", "build-*")?;
fs::write_file(path::join(&a, d, "out.txt"), text)?;
...
fs::remove_tree(d)?;
  • You get back a File struct, which implements both Reader and Writer.
  • Paths are UTF-8, like all Fels text, and can be up to 1023 bytes long. On Windows the runtime turns them into the UTF-16 that the *W functions take. dir_next gives you names in UTF-8 too.

Process management, on Windows and Linux.

  • spawn(cmd): Spawn a child process (returns Child)
  • wait(child), try_wait(child), kill(child), release(child)
  • output(&a, cmd): Run a command to its end and keep what it writes to stdout: an Output with its code and stdout (in the arena). Its stdin and stderr are this process’s.
  • spawn_piped(cmd, pipes): Spawn with pipes for the streams a Pipes { stdin, stdout, stderr } names. stdin_file(), stdout_file() and stderr_file() give the pipe ends as fs::Files (an io::Writer and io::Readers; a read gives 0 once the child closed its end), and close_stdin() lets the child read the end of its input. release closes the pipes too. Read what the child writes while it runs, as a child with a full pipe waits; with stdout and stderr both piped, read one of them on another thread.
c := process::spawn_piped("sort", .{ true, true, false })?;
w := (&c).stdin_file();
... // write to w (an io::Writer)
(&c).close_stdin();
r := (&c).stdout_file();
sorted := bufio::read_all(&a, &r)?;
code := (&c).wait()?;
(&c).release();
  • current_pid()
  • get_env, set_env, unset_env
  • env_vars(&a): every variable of the environment, each an EnvVar with name and value, with set_env and unset_env applied. On Linux, these are what the program started with (up to the first 8 KB of them) plus what set_env added.
  • exe_path(&a): the path of this program’s executable, or null
  • command_line: the command line as one text
  • args(&a): the arguments, with the program first. On Windows they are split from the command line the way the C runtime splits it, and split_command_line(line, &a) does the same for any line (quotes keep spaces, and \" is a quote). On Linux they are exactly as the kernel passed them.

Command-line arguments: flags, options with values and positional arguments, and the help text, from one description of the command.

#import std.cli as cli;
c := cli::command(&a, "report", "writes a portfolio report");
c.flag("verbose", 'v', "say what happens");
c.option("out", 'o', "FILE", "where to write the report");
c.positional("input", "the file to read"); // needed; c.optional(...), c.rest(...) too
r := c.parse(process::args(&a)) catch err {
io::eprintln(c.message); // "unknown option --vrebose"
return 2;
};
if r.help { io::print(c.help(&a)); return 0; }
out := r.option("out") ?? "report.txt";
input := r.positional(0) ?? "";
  • The arguments are read by the usual rules: --out FILE, --out=FILE, -o FILE, -oFILE, short flags together (-vq), -- to end the options, and - alone as a positional argument. -h and --help set help (and skip the check for missing arguments).
  • What was read: flag(name), count(name) (-vvv is 3), option(name) (the last value), all(&a, name) (every value, for -I a -I b), int_option(name), positional(i) and positionals_all(). Names are the long ones, also for what was given in the short form.
  • A mistake fails with a CliError (.unknown_option, .missing_value, .unexpected_value, .missing_argument, .too_many_arguments, .invalid_number), and c.message says it for the user. c.help(&a) lists the usage, the arguments and the options.

Pointer manipulation and conversions, which require unsafe in some contexts.

  • to_addr(p: *T) -> usize
  • from_addr($T: type, addr: usize) -> *T
  • add_bytes, sub_bytes, diff_bytes
  • is_aligned, align_down, align_up
  • is_null

Bit-twiddling utilities.

  • count_ones_u32, count_ones_u64
  • leading_zeros_u32, trailing_zeros_u64, etc.
  • rotate_left_u32, rotate_right_u64, etc.
  • is_power_of_two_u32
  • bit_u32, set_bit_u32, clear_bit_u32, toggle_bit_u32

Atomic operations over shared memory, the foundation for lock-free code and for synchronising threads. Each operation acts on a pointer to the value and infers the element type, so you never write an explicit $T. Read-modify-write helpers return the previous value, as in C and LLVM. The helpers default to sequentially consistent ordering. For finer control, call the core::atomic_* builtins with an ordering constant (Relaxed/Acquire/Release/AcqRel/SeqCst).

#import std.atomics;
counter: u32 = 0u32;
old := atomics::fetch_add(&counter, 1u32); // returns the previous value
if atomics::compare_exchange(&counter, 1u32, 100u32) { /* swapped */ }

Compile-time type contracts. Each operation is guarded by a constraint (Native/Numeric/Integer) built on core::type_info reflection. That way, misuse is a clear compile error and not a backend failure. Here is what a bitwise op on a float gives you:

error: type `f64` does not satisfy `Integer`: expected an integer type
  • Load/store: load, store, load_acquire, store_release
  • Read-modify-write (these return the previous value): swap, fetch_add/fetch_sub (numeric, float-aware), fetch_mul/fetch_div (numeric, CAS loop), fetch_max/fetch_min (integer), fetch_and/fetch_or/fetch_xor/fetch_nand (integer), fetch_shl/fetch_shr (integer, CAS loop)
  • Compare-exchange: compare_exchange(p, expected, desired) -> bool, compare_exchange_value (returns the value actually seen)
  • Fences: fence(), fence_acquire(), fence_release()
  • Flags / spinlock: is_set, test_and_set, clear, wait_until_set, wait_until_eq, spin_lock, spin_unlock

The free functions act on a raw *T. Atomic(T) wraps the value instead, so the type itself documents that the value is shared, and the operations read as methods. The element type is recovered from the receiver, so you never write $T:

#import std.atomics;
#import std.atomics.{Atomic}; // bring the type name into scope
counter: Atomic(u32) = .{ 0u32 }; // or: counter := Atomic::make(0u32);
old := counter.add(1u32); // returns the previous value
if counter.cas(1u32, 100u32) { ... } // compare-and-swap
n := counter.get();

The methods are get/set, get_acquire/set_release, exchange, cas/cas_value, add/sub/mul/div (numeric), and max/min and and/or/xor/nand (integer). The cell itself only checks that T is Native. The methods do not carry the narrower contracts of their free-function counterparts, so and on an Atomic(f64) passes fels check and then fails in code generation. Use the free functions when you want the clear compile error.

Atomic increments across threads lose nothing under contention. That is the whole point, because a plain += 1 would race. See tests/fixtures/stdlib/atomics_thread_app.fels, and atomic_cell_app.fels for the Atomic(T) method API.

Native OS threads. Fels favours real threads over async/await. There is no function colouring, no hidden executor and no heap-allocated coroutine frame: a thread is a stack plus an entry point.

A zone of kind thread::Scope runs each closure given to spawn on a thread of its own, and joins them all on every way out of the block: its end, a return, a break or an error. The function the block is in is still there until then, so a closure may use the function’s locals. Each closure keeps its captures in the zone, so a closure spawned in a loop has its own i.

#import std.thread as thread;
#import std.atomics.{ Atomic };
total: Atomic(u64) = .{ 0 };
t := &total;
zone s: thread::Scope {
for i in 0..8 {
s.spawn(fn() { ign := t.add(i as u64); });
}
} // all eight threads have finished here

s.join() waits sooner, and s.count() tells how many closures were spawned. When no thread can be started, spawn runs the closure on the calling thread.

A zone of kind thread::Pool keeps worker threads for the loops in it, one per core but one, as the thread that runs the loop works too. They start at the first loop.

zone pool: thread::Pool {
pool.range(0, n, fn(i: usize) { ign := sum.add(score(i)); });
pool.each(f64, prices, fn(p: *f64) { *p = *p * rate; });
} // the workers stop here
  • range(start, end, f) calls f(i) once for each i in start..end, on several threads at once, and returns when every call has returned.
  • each(T, items, f) calls f with a pointer to each item. Each call has an item of its own, so it may write it.
  • threads() is how many threads a loop runs on.
  • thread::parallel(start, end, f) is one range on a pool of its own. A pool costs a thread start per core, so a program that runs many loops keeps one in a zone instead.

What a closure on another thread may capture

Section titled “What a closure on another thread may capture”

spawn, range, each and parallel are #spawns: the closures they take run on other threads while the caller goes on, so the compiler checks what each captures. A value, a *const T or []const T, a pointer to a #sync type (Atomic(T), sync::Mutex, sync::Channel(T), …) and a struct of such are fine. A *T or []T is an error, because two threads could write through it at once:

error: `spawn` runs this closure on another thread, and the `out` it captures, `[]u64`,
lets two threads write the same memory at once

Share a counter through an Atomic, results through a Channel, or write each item with Pool.each. When the threads really write places of their own, say so with unsafe:

o := out[:];
zone s: thread::Scope {
for i in 0..8 { unsafe { s.spawn(fn() { o[i] = compute(i); }); } }
}

A closure held in a variable cannot be checked where it is passed, so pass the fn literal itself, or a function’s name.

An Arena is not #sync: two threads that allocate from one at once break it. The check above refuses a *Arena in a closure that runs on another thread. Let each thread make its own (a := heap::make(); defer a.deinit();), and hand results back through a Channel or into an item of its own.

thread::spawn(entry, arg) starts an OS thread on a thin entry function, of the shape fn(*void) -> u32. It receives a single context pointer and returns an exit code.

An ordinary function value in Fels is a fat {fn, env} closure, which a C ABI can’t call. So you hand the entry over as core::fn_ptr(worker), the raw thin function pointer. The context pointer you pass must outlive the thread. Join the thread before its data leaves scope, or put the data on a long-lived arena.

#import std.thread;
#import std.atomics;
worker :: fn(p: *void) -> u32 {
c := unsafe (p as *u32);
atomics::fetch_add(c, 1u32);
return 0u32; // exit code
}
counter: u32 = 0u32;
t := thread::spawn(core::fn_ptr(worker), unsafe ((&counter) as *void));
code := t.join(); // blocks; returns the worker's exit code
  • Thread (methods): join() -> u32 (waits, returns the exit code, closes the handle), detach() (fire-and-forget), is_running() (non-blocking poll), ok(). On Linux an exit code keeps only its low 8 bits, so return 0 to 255 if you read it with join().
  • spawn(entry, arg) -> Thread: start a thread. entry is core::fn_ptr(fn).
  • Helpers: current_id(), sleep(ms), yield_now(), cpu_count().
  • ThreadGroup: structured fan-out. spawn(entry, arg) adds a thread, and join_all() blocks until the whole batch finishes (the batch can’t outlive the call site). thread::group() makes an empty one.
g := thread::group();
i: usize = 0usize;
while i < thread::cpu_count() as usize { g.spawn(core::fn_ptr(worker), cp); i += 1usize; }
g.join_all(); // wait for all

Locks, waiting, a blocking queue and channels are in std.sync, while counters and flags need only std.atomics. See tests/fixtures/stdlib/thread_scope_app.fels and tests/fixtures/stdlib/thread_app.fels.

Mutual exclusion and waiting between threads, built on the operating system’s own primitives. A thread that cannot proceed sleeps in the kernel instead of polling in a loop. On Windows, a Mutex is a slim reader/writer lock (SRWLOCK) used exclusively, and a CondVar is a CONDITION_VARIABLE. On Linux, they are built on futex(2). Both are one machine word kept in your own struct, so there is no allocation, no initialisation call and nothing to destroy. All zero bits is the unlocked state, which makes m: Mutex = .{}; correct as a local, as a struct field and as a global in static storage.

#import std.sync.{ Mutex, CondVar };
lock: Mutex = .{};
ready: bool = false; // guarded by `lock`
cv: CondVar = .{};
// a waiter
lock.lock();
while !ready { cv.wait(&lock); } // always in a loop: wait() can return with no signal
lock.unlock();
// a signaller
lock.lock();
ready = true;
lock.unlock();
cv.signal();
  • Mutex: lock() sleeps until it holds the lock, try_lock() -> bool takes it only if it is free right now, and unlock() releases it. Pair lock() with defer m.unlock(); so the lock is released on every exit from a function.
  • CondVar: wait(&m) releases m, sleeps, and holds m again when it returns. wait_timeout(&m, ms) -> bool gives up after ms milliseconds (0xFFFFFFFF means never) and returns false when the time ran out. signal() wakes one waiter and broadcast() wakes all of them. You may call either one with or without the lock held.
  • A wait can return early. A thread may be woken by nothing (a spurious wake-up, which both operating systems allow), and a signal() sent while nobody waits is forgotten. That is why the condition lives in data guarded by the mutex, and why every wait sits in a while !condition { … } loop, as above. When wait_timeout returns true, that means “woken”, not “the condition holds”.
  • Rules. Share a lock by address, and do not copy one that is in use. A Mutex is not recursive, so locking it again from the thread that holds it deadlocks. Unlocking one that the thread does not hold is undefined. Nothing is fair, and there is no ownership or poisoning check. Do not move or free a lock while a thread holds it or sleeps on it. Join the threads first.

Queue(T) is a bounded blocking FIFO for any number of producers and consumers. It is bounded by design. It owns no memory: you hand it the storage, and that fixes the capacity. So nothing is allocated behind your back, a producer that outruns its consumers waits instead of exhausting memory, and there is no allocator to share between threads.

#import std.sync;
buf: [64]i32 = .{};
q := sync::make_queue(i32, buf[:]); // capacity 64; `buf` must outlive every user
q.push(7); // sleeps while the queue is full; false once closed
v := q.pop() ?? -1; // sleeps while it is empty; null once closed and drained
  • push(item) -> bool, try_push(item) -> bool (false if full, closed or capacity 0), pop() -> ?T, try_pop() -> ?T (null if empty), len(), capacity(), is_closed().
  • Shutdown. close() ends the queue and wakes everyone blocked on it. After that, push and try_push return false. Items already queued can still be popped, and when they run out, pop returns null. So a pipeline stops like this: the producers finish, the owner calls close(), the consumers drain the queue and see null, the owner joins them, and only then does the owner release the storage. Calling close() again does no harm.
  • Destruction. A Queue holds no resource, so there is no deinit. What must not happen is freeing or moving the storage, or the Queue itself, while a thread is still inside a call. Hence the rule “join, then release”.
  • Items leave the queue in the order they went in. With several consumers, the order in which they finish is up to the scheduler. A Queue over empty storage can hold nothing, so push fails and pop waits for close().

Channel(T) is a Queue that keeps its own storage, taken from an arena, with the names of a channel: send(item) -> bool waits while it is full, recv() -> ?T waits while it is empty, and after close() a recv gives what is left and then null. try_send, try_recv, len, capacity and is_closed are there too.

#import std.sync as sync;
jobs := sync::channel(u64, &a, 64); // room for 64, from the arena `a`
j := &jobs;
zone s: thread::Scope {
for w in 0..4 { s.spawn(fn() { while j.recv() |n| { handle(n); } }); }
for n in inputs { ok := j.send(n); }
j.close(); // the workers drain it, then stop
}

Every type in std.sync is #sync (see attributes), so a closure that runs on another thread may capture a pointer to one.

More, on Mutex and CondVar. Each is all zero bits at the start (.{}), and is shared by address like a Mutex.

wg: WaitGroup = .{};
wg.add(n); // before the n tasks start; each ends with wg.done()
wg.wait(); // sleeps until all n are done
INIT: Once = .{};
INIT.call(load_tables); // the first caller runs it; every caller returns after it ended
table_lock: RwLock = .{};
table_lock.lock_shared(); // many readers at once ... table_lock.unlock_shared();
table_lock.lock(); // or one writer ... table_lock.unlock();
slots := sync::semaphore(4);
slots.acquire(); // at most 4 threads past here ... slots.release(); try_acquire()

An RwLock lets a waiting writer go first: readers that come after it wait too, so a stream of readers cannot keep it out. None of them is recursive. There is no barrier or unbounded channel yet. tests/fixtures/stdlib/sync_tests.fels runs the four on real threads. See tests/fixtures/stdlib/sync_app.fels for contention with exact totals, a two-thread hand-off that must alternate strictly, a broadcast, timed waits, and many producers against many consumers with every item delivered exactly once.

The data types of COM and the Windows Runtime. The module makes no operating-system call, so it compiles for every target. It has Guid, HRESULT, IUnknown and IInspectable with their vtables, and a reference counter for objects you implement. std.winrt is the part that calls Windows.

Every layout, GUID and constant was compared with the official Windows SDK headers (guiddef.h, winerror.h, unknwn.h, inspectable.h, activation.h). For that, the headers were compiled with clang for x86_64-pc-windows-msvc, printing sizeof, offsetof, each interface’s __uuidof and each constant. #asserts in the module pin the layouts, so a wrong offset is a build error.

  • Guid: 16 bytes, aligned to 4, made of data1: u32, data2: u16, data3: u16 and data4: [8]u8 (an IID and a CLSID are GUIDs). It has eq(&other), is_null() and format(buf) -> usize, which writes {XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX} in upper case. buf needs GUID_TEXT_LEN = 38 bytes, else the result is .buffer_too_small. parse_guid(text) takes exactly 8-4-4-4-12 hex digits in either case, with the braces or without, and anything else is .invalid_format. The well-known ids are typed constants (IID_IUnknown, IID_IInspectable, IID_IAgileObject, IID_IActivationFactory), and you pass them by address (&com::IID_IUnknown). They live in read-only data, so never write to one.

  • HRESULT is an i32 (Hresult). A negative value is a failure, and zero or more is a success (S_FALSE = 1 is a success that means “no”). The winerror.h codes you meet are constants (S_OK, E_FAIL, E_NOINTERFACE, E_INVALIDARG, RPC_E_CHANGED_MODE, REGDB_E_CLASSNOTREG, …). succeeded(hr), failed(hr), facility(hr), code(hr) and from_win32(err) (HRESULT_FROM_WIN32) take one apart or build one.

  • From an HRESULT to an error. check(hr) succeeds on any success code and fails with ComError otherwise. The common codes have their own names (.no_interface, .invalid_arg, .out_of_memory, .class_not_registered, .changed_mode, …), and any other failure is .other, which no longer says which code it was. If you need the code, keep the HRESULT.

  • From an error back to an HRESULT. The module has no function for this direction. You write it where the error is caught, or in a function of your own that takes the ComError, because an error is a value you can pass on:

    read_header(self) catch e {
    if e == .no_interface { return E_NOINTERFACE; }
    return E_FAIL;
    };
    return S_OK;
  • IUnknown { vtbl } comes with its vtable { query_interface, add_ref, release }, and the three methods are on the struct as well. IInspectable (the base of every Windows Runtime interface) adds get_iids, get_runtime_class_name and get_trust_level. IActivationFactory adds activate_instance. You call a COM method through the table, with the interface pointer first: p.vtbl.release(p). On x86-64 Windows there is one calling convention, so any Fels function with matching parameter and result types fits a table slot.

  • RefCount: one atomic 32-bit counter for an object you implement. new_refcount() starts at 1 (the creator’s reference), and add() and release() return the new count. A release() that returns 0 means the object is unreferenced and yours to free.

Ownership is by reference count and nothing else owns anything. An interface pointer that a function hands you (an out-parameter, or what query_interface returns) carries one reference, and that reference is yours to release exactly once. A pointer you are only lent is not yours to release, and you add_ref it if you want to keep it. Releasing the last reference destroys the object, and any later use is a crash. The counts that add_ref and release return are for diagnostics. Never branch on them.

Declaring an interface means listing its vtable in header order, with this first, and then asserting the offsets against the header. Implementing one takes a struct whose first field is the vtable pointer, plus a vtable that is static data. That vtable is a global initialised with the functions, and no table is built at run time:

#import std.com;
#import std.com.{ Guid, IUnknown, IUnknownVtbl, RefCount };
Counter :: struct { vtbl: *IUnknownVtbl, rc: RefCount }
c_query :: fn(self: *IUnknown, iid: *const Guid, out: **void) -> i32 {
c := unsafe (self as *Counter);
if iid.eq(&com::IID_IUnknown) {
*out = unsafe (self as *void);
n := c.rc.add(); // the pointer handed out is a new reference
return com::S_OK;
}
*out = unsafe (core::ptr_from_int(*void, 0usize));
return com::E_NOINTERFACE;
}
c_add_ref :: fn(self: *IUnknown) -> u32 { c := unsafe (self as *Counter); return c.rc.add(); }
c_release :: fn(self: *IUnknown) -> u32 { c := unsafe (self as *Counter); return c.rc.release(); }
COUNTER_VTBL: IUnknownVtbl = .{ c_query, c_add_ref, c_release };
#entry
main :: fn() -> i32 {
obj: Counter = .{ &COUNTER_VTBL, com::new_refcount() };
p := unsafe ((&obj) as *IUnknown);
n := p.add_ref(); // 2: the caller holds a reference of its own
n = p.release(); // 1
return 42;
}

std.winrt: calling COM and the Windows Runtime (Windows only)

Section titled “std.winrt: calling COM and the Windows Runtime (Windows only)”

This is the part of the COM and Windows Runtime story that calls Windows. It starts COM and the Runtime on a thread, creates objects, activates runtime classes, and handles HSTRING. It is thin on purpose. Each function that mirrors an OS call keeps the call’s shape and returns its HRESULT unchanged, for you to pass to com::check. The few that only return a status (co_initialize, ro_initialize) or have one natural result (activation_factory, new_guid) are fallible. Nothing runs on its own: there is no hidden initialisation and no thread-local state. A program that imports the module for another target fails to link, as it would for any missing Win32 function. The COM entry points come from ole32.dll and the Runtime ones from combase.dll. The Windows SDK has no combase.lib (it ships those entry points in runtimeobject.lib), so the linker driver hands LLD that library, and felsld imports the DLL directly.

  • Apartments. co_initialize(mode) / ro_initialize(kind) apply to the calling thread. Each success is paired with one co_uninitialize() / ro_uninitialize() on the same thread, and that includes a success that returned S_FALSE (“already initialised”). Initialising a thread the other way fails with .changed_mode. Use the multithreaded kinds (CO_MULTITHREADED, RO_MULTITHREADED) unless the thread pumps a message loop.
  • Creating objects. co_create_instance(&clsid, CLSCTX_INPROC_SERVER, &iid, &out) is CoCreateInstance. It gives the HRESULT back, and *out is an owned interface pointer. activation_factory(class_id, &iid) is RoGetActivationFactory. It takes the class name as an HSTRING handle (.h) and gives back an owned *IUnknown for you to cast to the factory interface, or .class_not_registered if there is no such class. activate_instance(class_id) is RoActivateInstance, and new_guid() is CoCreateGuid. co_task_mem_free(p) frees what the OS allocated for you with the COM task allocator, such as the array that IInspectable.get_iids returns.
  • HString: an owned HSTRING. .h is the one-pointer handle to pass to the OS, and the null handle is the empty string. hstring(units) copies UTF-16 in (WindowsCreateString), and hstring_from_utf8(text, scratch) converts through your []u16 scratch buffer first. release() deletes it (idempotent). There are also duplicate(), len(), is_empty(), units() (a borrowed view, valid until release()), eq(&other) (case-sensitive) and to_utf8(out). An HSTRING may hold zeros and unpaired surrogates, and to_utf8 refuses the latter with .invalid_utf16.
  • HStringRef: a borrowed HSTRING over a buffer you already hold, with no allocation and nothing to release. It is the cheap way to pass a class name. init(z) takes the UTF-16 with its terminating zero. The struct must not move after init, and the buffer must outlive every use of .h.
#import std.com;
#import std.com.{ Guid, IInspectable };
#import std.winrt;
#import std.winrt.{ HString, HStringRef };
#import std.utf16;
#entry
main :: fn() -> i32 {
winrt::ro_initialize(winrt::RO_MULTITHREADED) catch e { return 1; };
name_buf: [64]u16 = .{};
n := utf16::utf8_to_utf16("Windows.Foundation.Uri", name_buf[0..63]) catch e { return 2; };
name_buf[n] = 0u16;
class_name: HStringRef = .{ .{}, 0usize };
class_name.init(name_buf[0..n + 1usize]) catch e { return 3; };
// `IUriRuntimeClassFactory`, from windows.foundation.h
IID_UriFactory: Guid = .{ 0x44A9796Fu32, 0x723Eu16, 0x4FDFu16, .{ 0xA2u8, 0x18u8, 0x03u8, 0x3Eu8, 0x75u8, 0xB0u8, 0xC0u8, 0x84u8 } };
factory := winrt::activation_factory(class_name.h, &IID_UriFactory) catch e { return 4; };
ig := factory.release(); // every pointer we are handed is ours to release
winrt::ro_uninitialize();
return 42;
}

tests/fixtures/stdlib/winrt_app.fels goes further and runs against the real system. It compares GUID text and parsing with StringFromGUID2 / IIDFromString, and it takes an HSTRING through every operation. It calls the OS’s in-memory IStream through a vtable declared from objidl.h. It has a COM object written in Fels that the OS calls back (IStream::CopyTo writes into it). And it activates Windows.Foundation.Uri and reads it through its interfaces, with the reference counts checked.

Here is what the module does not do. There are no events, delegates or async. There is no generated projection of a runtime class, so you declare the interfaces you call. There is no smart pointer: a release is a call you write, with defer. And there is no marshalling across apartments.

TCP and UDP, over Winsock2 on Windows and over the socket system calls on Linux. The code is layered across a subdirectory module, so each piece stays small and focused. Here are the layers from the bottom up:

Module What it gives you
std.net.os The raw socket calls (sys_ functions over the runtime’s socket layer: Winsock2 on Windows, socket system calls on Linux), protocol constants, the sockaddr_in wire struct, the shared NetError, and addr ⇄ wire conversions. The foundation. Reach for it directly only for an option the upper layers don’t expose.
std.net.addr IpV4 / SocketAddr: parse, format, classify (is_loopback/is_unspecified), to_net/from_net, #derive(Eq).
std.net.socket Socket: the typed handle and the options shared by every protocol (set_recv_timeout/set_send_timeout, set_reuse_addr, set_broadcast, set_nonblocking, shutdown, bind_to, local_addr).
std.net.tcp TcpStream / TcpListener: streams plus send_all / recv_exact, set_nodelay, and accept returning the peer address.
std.net.udp UdpSocket: datagrams (send_to / recv_from) and the connected connect+send/recv shortcut.
std.net.dns The pieces of a lookup without a C library: an A query (build_query), the address in an answer (parse_answer), a name in a hosts file (from_hosts), the name servers of a resolv.conf (servers).
std.net A thin facade re-exporting the lifecycle (init / shutdown / last_error) and the constructors (connect / listen / udp_bind), plus names: resolve and connect_host.

It leans on the newer language features throughout: in-struct methods, #derive, and !-fallible returns. Every layer that works with sockets fails with the one shared os::NetError. Parsing an address fails with addr::AddrError.

Addresses (std.net.addr): IpV4 has #derive(Eq) and a hand-written format that renders a dotted quad, and SocketAddr has a derived Eq that recurses into IpV4.eq:

#import std.net.addr as addr;
ip := addr::parse("127.0.0.1") catch e { ... }; // fallible
sa := addr::socket_addr(addr::localhost(), 8080u16);
sa.format(&sb); // "127.0.0.1:8080"

TCP: call init() once, then connect(addr) / listen(addr):

#import std.net as net;
#import std.net.addr as addr;
net::init() catch e { return; };
// client
s := net::connect(addr::socket_addr(addr::localhost(), 8080u16)) catch e { return; };
s.send_all("ping") catch e {}; // loops over short writes
n := s.recv(buf[:]) catch e { return; }; // 0 = peer closed
s.close();
// server
ln := net::listen(sa) catch e { return; };
a := ln.accept() catch e { return; }; // a.stream + a.peer (blocks for a client)

Names: resolve gives the IPv4 address of a host, and connect_host connects to one:

ip := net::resolve(&a, "example.com") catch e { return; }; // NetError.unknown_host
s := net::connect_host(&a, "example.com", 80u16) catch e { return; };

A dotted address is taken as it is, and localhost is 127.0.0.1. Other names go to the system on Windows (getaddrinfo); on Linux /etc/hosts is read, then the name servers of /etc/resolv.conf are asked over UDP, two seconds each. The arena holds the files read.

UDP: connectionless datagrams, or connect to pin a default peer:

#import std.net.udp as udp;
srv := udp::bind(addr::socket_addr(addr::any(), 9000u16)) catch e { return; };
d := srv.recv_from(buf[:]) catch e { return; }; // d.len + d.from
ignored := srv.send_to("pong", d.from) catch e { return; }; // reply to sender
  • TcpStream: send/send_all, recv/recv_exact, set_nodelay, set_read_timeout/set_write_timeout, local_addr, shutdown_write, close. It is an io.Reader (0 once the peer has closed) and an io.Writer, so std.bufio and io::copy work on a connection.
  • TcpListener: accept() -> Accepted{stream, peer}, accept_stream(), local_addr, close.
  • UdpSocket: send_to/recv_from, connect+send/recv, set_broadcast, set_read_timeout, local_addr, close.
  • The facade re-exports the types of its submodules: net::TcpStream, net::TcpListener, net::Accepted, net::UdpSocket, net::Datagram, net::SocketAddr and net::NetError.
  • The raw calls in std.net.os keep a sys_ prefix and their Winsock names (sys_wsa_startup, sys_closesocket). Each one calls the runtime’s socket layer, so the same names work on Linux. The constants are the Winsock values on both systems, and the Linux runtime translates the ones that differ.

Loopback round-trips that combine net, thread and addr are in tests/fixtures/stdlib/net_echo_app.fels (TCP echo) and net_udp_app.fels (UDP echo). HTTP is in std.http. Names resolve to IPv4 addresses only, as the sockets are IPv4.

Without an operating system: std.uefi, std.freestanding, std.x86, std.aarch64

Section titled “Without an operating system: std.uefi, std.freestanding, std.x86, std.aarch64”

For programs built with --target uefi or --target freestanding, on x86-64 or aarch64 (18_targets_and_tiers §7). println, zones and the containers work there as anywhere; these modules give what the machine has instead of an operating system.

std.uefi (--target uefi) calls the firmware:

  • system_table() and image(): what the firmware passed to efi_main, for calls the module does not wrap.
  • stall(us) waits, clear() clears the console, read_key() -> Key waits for a key (char, or 0 and a scan code for a special key).
  • framebuffer() -> ?Framebuffer: the screen of the graphics output protocol, with put(x, y, rgb) and fill(x, y, w, h, rgb); null without one.
  • shutdown() and reboot().

std.freestanding (--target freestanding): set_output(write) names the function that println and a panic message go to, such as one that writes to a serial port; until then nothing is printed. halt() turns interrupts off and halts for good. add_memory(base, len) gives zones memory, such as a free range of the boot loader’s memory map, and free_memory() says how many bytes they can still take. Zones take memory from a free list and give it back when they end; until memory is added, and when it runs out, they take a 1 MiB static heap.

std.x86: the instructions a kernel needs, each a function over core::asm. Most work only in ring 0.

Ports outb/outw/outl(port, v), inb/inw/inl(port)
Interrupts cli, sti, hlt, pause, breakpoint (int3), lgdt(descriptor), lidt(descriptor) (10 bytes: the limit, then the base), read_cs
The interrupt table idt() -> Idt, Idt.set(vector, core::fn_ptr(handler)), Idt.load(), the InterruptFrame (ip, cs, flags, sp, ss)
Control registers read_cr0, read_cr2, read_cr3, write_cr3, read_cr4, invlpg(addr)
Model-specific registers rdmsr(msr) -> u64, wrmsr(msr, v)
The processor rdtsc() -> u64, cpuid(leaf, sub) -> Cpuid (eax, ebx, ecx, edx)
#import std.io.{ println };
#import std.freestanding;
#import std.x86;
serial_write :: fn(p: *const u8, n: usize) {
for i in 0usize..n { x86::outb(0x3F8u16, unsafe *core::ptr_from_int(*const u8, (p as usize) + i)); }
}
main :: fn() -> i32 {
freestanding::set_output(serial_write);
println("max cpuid leaf {x86::cpuid(0u32, 0u32).eax}");
return 0;
}

An interrupt handler is a function marked #interrupt (08): the CPU calls it with the frame it pushed, and with the error code for the exceptions that have one. It saves every register it uses and returns with iretq. Code can’t call it; its address goes in the table, which must stay where it is once loaded:

table: x86::Idt = .{ .gates = .{} };
#interrupt
on_breakpoint :: fn(frame: *const x86::InterruptFrame) {
println("int3 at {frame.ip}");
}
#interrupt
on_page_fault :: fn(frame: *const x86::InterruptFrame, code: u64) {
println("page fault at {x86::read_cr2()}, code {code}");
freestanding::halt();
}
main :: fn() -> i32 {
table.set(3u8, core::fn_ptr(on_breakpoint));
table.set(14u8, core::fn_ptr(on_page_fault));
table.load();
x86::breakpoint();
return 0;
}

std.aarch64 (--target aarch64-freestanding): the instructions an AArch64 kernel needs, most at EL1.

Waiting and barriers wfi, wfe, sev, isb, dsb, dmb
Interrupts mask_interrupts, unmask_interrupts (DAIF), breakpoint (brk #0)
System registers current_el, read_mpidr, read_cntpct, read_cntfrq, read_sctlr/write_sctlr, write_ttbr0, write_tcr, write_mair
Exceptions set_exception_handler(handler), ExceptionFrame (x, elr, spsr, esr, far), exception_class(esr), the vectors SYNC, IRQ, FIQ, SERROR

AArch64 has no interrupt calling convention: set_exception_handler points the CPU at a vector table of std.aarch64’s, which saves every register (the FP and SIMD ones too), calls the handler, an ordinary function, with them and the vector, and puts them back. The exception goes back to frame.elr, which the handler may move on:

on_exception :: fn(frame: *aarch64::ExceptionFrame, vector: u64) {
if vector == aarch64::SYNC && aarch64::exception_class(frame.esr) == 0x3Cu64 {
frame.elr += 4u64; // past the brk
return;
}
println("exception {vector}, class {aarch64::exception_class(frame.esr)}");
freestanding::halt();
}
main :: fn() -> i32 {
aarch64::set_exception_handler(on_exception);
aarch64::breakpoint();
return 0;
}

examples/uefi, examples/kernel and examples/kernel_aarch64 are whole programs for these targets.

Small, focused modules for 2D games and graphics. std.math and std.color are self-contained and need no libm, and std.rand is a deterministic PRNG.

Vectors (f32), rectangles, and scalar helpers. Component math wraps the core:: math builtins, while sin/cos/tan use a fast polynomial, so there is no libm dependency.

  • Scalars: lerp, approach, signf, deg2rad, rad2deg, wrapf, sin, cos, tan. Constants PI, TAU, DEG2RAD, RAD2DEG.
  • Vec2: v2, v2_zero, v2_splat, v2_add/sub/mul/scale/neg, v2_dot, v2_len, v2_len2, v2_dist, v2_dist2, v2_normalize, v2_lerp, v2_perp, v2_rotate, v2_from_angle.
  • Vec3: v3, v3_add/sub/scale, v3_dot, v3_cross, v3_len, v3_normalize.
  • Rect: rect, rect_contains, rect_overlaps (AABB), rect_center.
  • Integers (u64; core::min, max, abs and clamp take any number): gcd, lcm and ipow (null when the result does not fit), isqrt, div_ceil, align_up and align_down (to a power of two), log2_floor, next_power_of_two (null past 2^63), and mod_floor for i64, the remainder with the sign of the divisor (mod_floor(-7, 3) is 2, where % gives -1).

exp, ln and pow for f64, written in Fels and good to about 1e-15 relative. A program needs no math library for them, unlike core::pow, which is a call of the C library’s pow. For example: float::pow(1.07, 2.5), float::ln(10.0).

A deterministic xorshift64* PRNG. The same seed gives the same sequence, which suits replays and procedural generation.

  • seed(u64) -> Rng, next_u64, next_u32, next_f32 and next_f64 ([0,1)), range_i32(lo,hi), range_u64(lo,hi), range_f32(lo,hi), below(n) ([0,n)), chance(p), sign(), fill_bytes(out).
  • rand::shuffle(T, &r, xs) puts a slice in a random order (Fisher-Yates), and rand::choose(T, &r, xs) gives one element (null for an empty slice).
  • The integer ranges have no bias toward small values: a draw that would favor them is drawn again.

32-bit RGBA color.

  • rgb, rgba, with_alpha, gray, named colors (white/black/red/green/blue/yellow/orange/purple/clear), lerp, to_u32/from_u32 (0xRRGGBBAA).

Containers: std.vec, std.map, std.pqueue, std.deque, std.bitset

Section titled “Containers: std.vec, std.map, std.pqueue, std.deque, std.bitset”

The container library has one defining trait: every container is region-bound. It carries a borrowed *Arena and grows by bump-allocating from it, so there are no per-element frees and no destructors. When the region is reset/deinit-ed, every container that lived in it is gone at once. That’s the whole memory story, and it’s what makes Fels’s containers different from Rust’s owning collections or Zig’s allocator-injected ones.

A growable array with in-struct methods (UFCS):

#import std.heap as heap;
#import std.vec as vec;
a := heap::make();
v := vec::make(i32, &a); // or with_cap / from(T, &a, slice)
v.push(10); v.push(20);
v.insert(1, 15); // order-preserving (O(n))
g := v.remove(0); // order-preserving removal
s := v.remove_swap(i); // O(1) unordered removal
for x in v { ... } // over v.items(), a []T view until the next growth
b := heap::make();
w := v.clone_into(&b); // deep-copy into ANOTHER region (data escapes)
  • Access: get, set, at (→ *T), last, items. Size: len, cap, is_empty.
  • Mutation: push, pop, insert, remove, remove_swap, extend, reserve, clear. Region-moving: clone_into(into).

Managed vs unmanaged. Vec(T) is managed, which means it remembers its region. VecUnmanaged(T) is the unmanaged twin. It holds no region, so each growing call takes the *Arena explicitly (u.push(&a, x)). Use it when one vector is filled from several regions or stored in bulk. To copy unmanaged data into the managed API, use vec::from(T, a, u.items()).

Open-addressed hash maps with linear probing. They are region-bound too.

#import std.map as map;
m := map::auto(i32, i32, &a); // keys: a value type whose bytes are its value
m.put(7, 100); m.put(7, 101); // overwrite → still one entry
if m.get(7) |v| { ... } // v == 101 ; get returns ?V
m.contains(7); m.remove(7);
sm := map::strmap(i32, &a); // keys: []const u8, by content
sm.put("alpha", 1);
sm.get("alpha"); // hashes + compares the string bytes
  • AutoHashMap(K, V) keys a value type whose bytes are its value. It needs no Hash/Eq impl, no derive and no context object. The map hashes the key’s raw bytes and compares them byte-for-byte, which works for integers, plain enums, pointers, and structs and arrays of those without padding. The key type must satisfy the map::ByteKey constraint, so a key that cannot be compared that way is a compile error that says why. Such a key is a float (-0.0 and 0.0 are equal and differ in their bytes, and a NaN equals nothing), a slice (a string compares its address, not its contents, so key strings with StrMap), or a type with bytes no value sets (padding, an optional’s flag or an enum payload, see core::has_padding).
  • StrMap(V) keys []const u8, hashing and comparing the string contents. It copies each key into the region, so the map owns its keys.
  • Both: put, get (→ ?V), contains, remove, clear, len, cap, is_empty, and entries(), which a for goes through: for e in m.entries() { … e.key … e.value … }, in no particular order. A put or remove during the loop may skip or repeat entries.
  • Sets: map::autoset(K, &a) gives an AutoSet(K) and map::strset(&a) a StrSet, maps with nothing stored for a key. add(k) says whether k was new; contains, remove, clear, len, and each() for the keys.
  • SortedMap(K, V, C) keeps its keys in order: map::sorted(K, V, &a) by <, map::sorted_by(K, V, &a, less) by a function (mem::less_bytes for text), or map::sorted_with(K, V, C, &a, order) by an order type as std.sort has them (sort::Asc(K), sort::Desc(K), sort::By(K)). Besides put, get, contains, remove, len and clear it has entries() in key order, from(k) from the first key that does not come before k, and first()/last(). The keys and values are two sorted arrays: a lookup is a binary search, and put and remove move what comes after, which suits up to some ten thousand entries.
  • AutoHashMap and AutoSet do not run at compile time: they hash a key’s bytes through a pointer, and a value on the comptime VM has none. StrMap and StrSet do.

Fels’s approach to hashing: a struct that #derive(Hash, Eq)s gets hash()/eq() for free. AutoHashMap doesn’t even need that, because byte-hashing covers every key whose bytes are its value with zero ceremony, and the compiler refuses the others.

A binary heap: pop gives the element that comes first, push and pop take O(log n).

#import std.pqueue as pqueue;
q := pqueue::min(i32, &a); // smallest first; pqueue::max for largest first
q.push(5); q.push(1); q.push(3);
while q.pop() |x| { ... } // 1, 3, 5
jobs := pqueue::by(Job, &a, earlier); // earlier :: fn(a: Job, b: Job) -> bool
  • push, push_all, pop (→ ?T), peek (→ ?T, left in), len, is_empty, clear, and items() (in no particular order).
  • The order is a type C with less(self, a, b): pqueue::Min(T), Max(T), By(T) (a function), or your own with pqueue::with(T, C, &a, order), for an order that carries data.

A ring buffer: push and pop at both ends in O(1), and index from the front.

#import std.deque as deque;
d := deque::make(i32, &a); // or with_cap(i32, &a, n)
d.push_back(1); d.push_front(0);
while d.pop_front() |x| { ... } // a FIFO queue
for x in d.each() { ... } // front to back, left in place
  • push_back, push_front, pop_back, pop_front, front, back (each ?T), get(i), set(i, v), len, is_empty, clear. It grows by taking a block twice as big from its arena.

The numbers 0 to n-1, one bit each, 64 to a word.

#import std.bitset as bitset;
s := bitset::make(&a, 1000); // all clear
s.set(3); s.set(700);
if s.has(3) { ... }
for i in s.ones() { ... } // 3, 700: the set bits, in order
s.union_with(&other); // also intersect_with, subtract (the same length)
  • set, unset, toggle, put(i, on), has, count (the set bits), set_all, clear_all, len. An index past the end panics.

Sorting slices in place, and binary search in sorted ones. The element type comes first, as everywhere in std.

sort::asc(i32, xs); // smallest first, by `<`
sort::desc(f64, ys); // largest first
sort::by(Person, people, by_age); // `less(a, b)`: a goes before b
sort::by([]const u8, names, mem::less_bytes); // text, byte by byte
sort::stable_by(Row, rows, by_date, &scratch); // equal rows keep their order
sort::is_sorted(i32, xs) // true
sort::lower_bound(i32, xs, 42) // the first index whose element is not < 42
sort::upper_bound(i32, xs, 42) // the first index whose element is > 42
sort::search(i32, xs, 42) // ?usize: the first index holding 42

asc, desc and by are an introsort: O(n log n) in every case, in place, and not stable. A run of equal elements costs nothing extra. stable_asc(T, xs, &arena) and stable_by(T, xs, less, &arena) are a merge sort that keeps equal elements in their order; the arena gives them a second slice as long as xs. Every search has a _by form that takes the less the slice is sorted by (lower_bound_by, upper_bound_by, search_by, is_sorted_by). less may be any function value, a closure too.

Filesystem path strings, with pure manipulation and no I/O. Both / and \ are accepted as separators, and join and normalize write SEP, which is \ on Windows and / elsewhere. Query functions return sub-slices and allocate nothing, while builders take an *Arena.

path::basename("a/b/c.txt") // "c.txt"
path::dirname ("a/b/c.txt") // "a/b" ("." when no separator)
path::extension("c.txt") // ".txt" (with the dot; "" if none)
path::stem("c.txt") // "c"
path::is_absolute("/x") // true (also "C:\…" and "\unc")
path::has_extension("c.fels", "fels") // true (leading dot optional)
path::join(&a, "a/b", "c.txt") // "a/b\c.txt" on Windows
path::with_extension(&a, "c.txt", "md")// "c.md"
path::normalize(&a, "a/./b/../c/") // "a\c" on Windows, "a/c" elsewhere

One invariant holds: basename(p) == stem(p) ++ extension(p).

normalize drops . parts and doubled separators, and each .. takes out the part before it. A root (/, C:\, \\server\share\) stays, and a .. at the root is dropped. A relative path keeps its leading ..s, and an empty result is .. It reads nothing from the disk, so a .. after a link goes back over the link’s name; fs::absolute builds on it.

Wall-clock time, a monotonic clock, sleeping, and a UTC calendar breakdown, on Windows and Linux. The clocks come from the platform runtime, which calls kernel32 on Windows and clock_gettime and nanosleep on Linux.

secs := time::unix_seconds(); // i64 seconds since 1970-01-01 UTC
ms := time::unix_millis();
dt := time::utc(secs); // DateTime { year, month, day, hour, minute, second, weekday }
start := time::now(); // monotonic Instant
time::sleep_ms(16u32);
ns := time::since_nanos(start); // also since_millis / since_seconds
boot := time::monotonic_millis(); // the same monotonic clock, in milliseconds

utc is exact for any time ≥ the epoch, using Howard Hinnant’s civil-from-days. weekday runs from 0=Sunday to 6=Saturday.

Calendar dates. A Date is a day of the Gregorian calendar, kept as the number of days since 1970-01-01, so dates compare and subtract as numbers:

d := time::date(2024i64, 1i64, 31i64) ?? time::today_utc(); // null for a day that does not exist
p := d.parts(); // DateParts { year, month, day }
w := d.weekday(); // 0=Sunday … 6=Saturday
e := d.add_months(1i64); // 2024-02-29: the day stays inside the month
f := d.add_days(-1i64); // also add_years
n := d.days_until(e); // 29
iso := time::parse_date("2024-03-15"); // ?Date
buf: [10]u8 = .{};
text := time::format_date(e, buf[:]); // "2024-02-29"

is_leap_year(year) and days_in_month(year, month) answer the calendar questions.

Local time. local_offset(secs) is this machine’s offset east of UTC at that instant, in seconds, with daylight saving time as it was then (3600 for Berlin in winter, 7200 in summer). Windows says it; on Linux it comes from the zone file that TZ (Area/City, :Area/City or a path) or else /etc/localtime names, and the POSIX rule at its end for years past its last transition. local(secs) and now_local() give the DateTime on this machine’s clocks, and to_unix(dt) turns a DateTime back into seconds.

now := time::unix_seconds();
buf: [64]u8 = .{};
text := time::format_datetime(time::local(now), time::local_offset(now), "%a %e %b %Y, %H:%M %z", buf[:]);
// "Fri 1 Mar 2024, 15:07 +0100"
stamp := time::format_rfc3339(now, 0, buf[:]); // "2024-03-01T14:07:29Z"
if time::parse_rfc3339("2024-03-01T15:07:29+01:00") |s| { ... s.secs, s.nanos, s.offset ... }
d := time::format_duration(4250000000, buf[:]); // "4.25s"; also "1h 0m 12s", "350ms"
ns := time::parse_duration("1h30m") ?? 0; // in nanoseconds
  • format_datetime(dt, offset, pattern, out) knows %Y %y %m %d %e %j %H %I %p %M %S %a %A %b %B %z (+0100) %Z (+01:00) and %%; offset is what %z writes. What does not fit in out is cut. weekday_name and month_name give the English names.
  • parse_rfc3339 takes T, t or a space between date and time, fractions of a second, and Z, z or ±hh:mm; it gives null for anything else (also a day that does not exist).
  • Durations read and write the units d h m s ms us ns, with fractions (1.5s).
  • tzif_offset(file_bytes, secs) and posix_offset("CET-1CEST,M3.5.0,M10.5.0/3", secs) give the offset of any zone, from a TZif file (as in /usr/share/zoneinfo) or a POSIX rule.

This module reads CSV text: records of fields between separators, with fields in quotes as RFC 4180 has them. A quoted field may hold the separator, line breaks and doubled quotes.

#import std.csv as csv;
sep := csv::guess_separator(text); // ';' for "Datum;Kurs", ',' for "date,close", or a tab
r := csv::reader(text, sep);
while !r.done() {
row := r.next(&a) catch err { ... }; // CsvError.unterminated_quote, at line r.line
// row[0], row[1], ...
}
rows := csv::records(text, 59u8, &a) catch err { ... }; // every record at once

The fields are slices of the text. The one exception is a quoted field with doubled quotes, which is copied into the arena with single ones. Lines end in LF or CRLF. Empty lines are skipped, and so is a UTF-8 byte order mark at the start, as Excel writes one. r.line is the line the last record started on, which you can use in messages about a row.

This module reads JSON of any shape. The text becomes a tree of values in an arena, and you look into that tree. (For JSON that fits a struct of your own, std.serde maps it in one call.)

#import std.json as json;
doc := json::parse(text, &a) catch err { ... }; // JsonError: unexpected_end, unexpected_byte,
// bad_number, bad_string, too_deep, trailing_text
quote := doc.get("chart").get("result").at(0);
for t in quote.get("timestamp").items {
day := t.as_i64() ?? 0i64;
}
close := quote.get("indicators").get("quote").at(0).get("close").at(5).as_f64(); // ?f64

A Value has a kind (null_value, boolean, number, string, array, object). get(key) reads a member of an object, at(i) reads an element of an array, and len() counts them. items and keys are the elements and the members, in the order of the text. A lookup that finds nothing gives a null value, so a chain needs no checks on the way. In the same way, as_f64, as_i64, as_text and as_bool give null for a value of another kind. A number keeps the text it was written as next to its f64 (which is exact for up to 15 digits). That text is there for a decimal type, or for digits an f64 cannot hold. as_i64 is exact for whole numbers of up to 18 digits. Strings are unescaped, and a \u escape (also a surrogate pair) becomes UTF-8. A string without escapes is a slice of the text. A byte order mark at the start is skipped, and nesting deeper than 512 is refused. The comptime VM cannot build the values yet (they are structs in an arena).

Writing JSON. json::writer(w) gives an Out that writes JSON text to any *Writer (a file, stdout, a StringBuilder) as the calls come; json::pretty(w, 2) indents it. Commas, colons, quotes and escapes are its job.

o := json::pretty(&out, 2);
o.begin_object();
o.key("name"); o.string("Ada");
o.key("scores"); o.begin_array(); o.int(9); o.float(7.5); o.end_array();
o.key("tree"); o.value(doc); // a parsed (or built) Value
o.end_object();
o.finish()?; // fails when a write failed
text := json::stringify(&a, doc); // a Value as text, on one line

Values: string, int, uint, float (up to 15 significant digits; null for NaN or an infinity), boolean, null_value, and value(v) for a tree, its numbers as they were read. A write that fails makes the rest no-ops, and finish reports it.

Exact decimal numbers, for money and anything counted in tenths and hundredths: 0.1 + 0.2 is 0.3 here.

#import std.decimal as decimal;
price := decimal::parse("19.99")?;
total := decimal::mul(price, decimal::from_int(3))?; // 59.97
share := decimal::div(total, decimal::from_int(7), 2, .half_even)?; // 8.57
io::println("{total} / 7 = {share}");
cents := decimal::round(x, 2, .half_up)?;
  • A Decimal is a whole number coef and a scale, its value coef / 10^scale, with up to 18 places and about 18 digits in all.
  • add and sub are exact, at the larger scale of the two. mul is exact while the result fits and keeps the places of both (1.25 * 2.5 is 3.125); past 18 places, or a coef past an i64, it drops the last places half to even. div(a, b, scale, mode) gives scale places rounded once, by mode. Their work goes through 128- and 192-bit numbers, so nothing is lost on the way.
  • round(d, scale, mode); the modes are .half_even (banker’s), .half_up, .half_down, .down, .up, .floor and .ceiling.
  • cmp, eq, less compare values whatever their scales (2.50 is 2.5); neg, abs, normalize (no zeros at the end), is_zero, is_negative, sum.
  • parse reads -12.50, .5, 1e3, 2.5E-3, keeping the places written; to_text writes them all (19.90), and so does printing one in a format. from_int, from_parts(coef, scale), and to_f64 / from_f64(x, scale) for floats, which are binary and so not exact.
  • A result that does not fit fails with .overflow; text that is no number with .invalid. The tests check 384 results against Python’s decimal module.

Bytes as text, and back: base64 (RFC 4648) and hex digits.

#import std.base64 as base64;
#import std.hex as hex;
t := base64::encode_in(&a, "hello"); // "aGVsbG8="
b := base64::decode_in(&a, t)?; // "hello"
u := base64::URL_NO_PAD.encode_in(&a, token); // "-" and "_", no "=": JWT, URL tokens
n := base64::STANDARD.encode(out[:], bytes)?; // into your own buffer
h := hex::encode_in(&a, digest_bytes); // "ba7816bf…" (encode_upper_in: "BA78…")
d := hex::decode_in(&a, "DEADbeef")?; // either case reads
  • A base64::Codec is an alphabet and whether = pads the text to a multiple of 4: STANDARD (+ and /, padded), STANDARD_NO_PAD, URL (- and _, padded) and URL_NO_PAD. Each has encode, decode (into a buffer), encode_in, decode_in (into an arena), encoded_len and decoded_len; base64::encode_in and decode_in are STANDARD’s.
  • Decoding takes the codec’s own form only: a padded codec wants its padding, one without refuses it, and a character of the other alphabet, a space or a line break fails with .invalid_character. A text of a length no encoding has fails with .invalid_length, and a buffer that is too short with .buffer_too_small, before anything is written.
  • hex::encode and decode write into a buffer; hex::digit_value(c) is a digit’s value or null. Errors: .invalid_character, .odd_length, .buffer_too_small. crypto::hex is hex::encode_in.

Bytes as text in 32 characters (RFC 4648), as TOTP secrets and onion addresses write them. Its API is the same as std.base64’s.

#import std.base32 as base32;
t := base32::encode_in(&a, "foo"); // "MZXW6==="
b := base32::decode_in(&a, "mzxw6===")?; // "foo": lowercase reads too
s := base32::STANDARD_NO_PAD.encode_in(&a, key); // a TOTP secret
  • The codecs are STANDARD (A-Z and 2-7, padded with = to a multiple of 8), STANDARD_NO_PAD, HEX (0-9 and A-V, “base32hex”, whose text sorts as its bytes do) and HEX_NO_PAD. Each has encode, decode, encode_in, decode_in, encoded_len and decoded_len.
  • Decoding takes either case. A padded codec wants exactly the padding the length needs, and one without refuses =. The errors are base64’s: .invalid_character, .invalid_length and .buffer_too_small.

Integers in 7-bit groups, small ones in few bytes (LEB128), as WebAssembly, DWARF and Protocol Buffers’ varints write them.

#import std.leb128 as leb128;
buf: [leb128::MAX_LEN]u8 = .{};
n := leb128::encode_u64(buf[:], 624485)?; // e5 8e 26: 3 bytes
d := leb128::decode_u64(buf[0..n])?; // d.value 624485, d.len 3
m := leb128::encode_i64(buf[:], -123456)?; // c0 bb 78
s := leb128::decode_i64(buf[0..m])?; // s.value -123456
  • len_u64 and len_i64 give the bytes a number takes, 10 at most (MAX_LEN).
  • Decoding reads from the start of the bytes and gives the number with how many bytes it took (Unsigned, Signed). A longer form than needed (80 00 for 0) reads, as most readers allow.
  • Errors (LebError): .buffer_too_small (nothing written), .truncated and .overflow (a number past 64 bits).

CBOR (RFC 8949): JSON’s shapes in compact binary, plus byte strings and tags. COSE, WebAuthn and many devices use it. A writer writes items as the calls come, and a reader reads them one at a time without allocating.

#import std.cbor as cbor;
sb := strings::builder(&a); // or any io::Writer
e := cbor::writer(&sb);
e.begin_map(2);
e.text("name"); e.text("Ada");
e.text("ids"); e.begin_array(2); e.uint(1); e.int(-2);
e.finish()?;
bytes := strings::str(&sb);
r := cbor::reader(bytes);
m := r.next()?; // m.kind .map, m.value 2 (pairs)
k := r.next()?; // .text, k.data "name"
r.skip()?; // over the value, whatever it holds
  • The writer has uint, int, bytes, text, float, boolean, null_value, undefined, simple, tag, and begin_array(n) and begin_map(n), which the next n items (pairs) fill. begin_array_indefinite and begin_map_indefinite take items up to an end, and raw writes bytes that are CBOR already. As RFC 8949’s preferred serialization says, numbers take their shortest form, and a float takes the shortest of half, single and double precision that holds it exactly (NaN as half). After a write fails the rest are skipped, and finish reports it.
  • next gives an Item: its kind (.uint, .negint, .bytes, .text, .array, .map, .tag, .simple, .boolean, .null_value, .undefined, .float, or .end, the break after an indefinite item), value (the number, a count, or a tag), data (a string’s bytes, a slice of the input), number (a float) and indefinite. as_i64() gives an integer of either sign that fits. An array’s items, a map’s keys and values, and a tag’s item come next.
  • skip reads over the next item and all it holds, and skip_contents(it) over what an item just read holds. at_end says whether all bytes are read.
  • Errors (CborError): .truncated, .malformed (a header no item has, or a misplaced break), .bad_text (a text that is not UTF-8) and .too_deep (skip past 1000 levels).

Messages by level (debug, info, warn, error), each a format, written to stderr or to any io::Writer.

#import std.log as log;
log::info("listening on {port}"); // [info] listening on 8080, on stderr
log::set_level(.debug); // from here on debug messages show too
net := log::to(&file, "net", .debug); // its own writer, scope and level
net.debug("got {len} bytes"); // [debug] net: got 512 bytes
if net.enabled(.debug) { ... } // to skip work a message would need

A message below the level is dropped before its format is written, so its values are not read. A write that fails is dropped too: logging never stops the program. log::debug, info, warn and error write to stderr from info on, until set_level changes it.

Regular expressions, matched in time linear in the text (a Pike VM, as RE2 and Go do it): no pattern can make a match take exponential time.

#import std.regex as regex;
re := regex::compile(&a, "(?P<y>\\d{4})-(\\d{2})-(\\d{2})")?;
if re.find("due 2024-03-15!") |m| {
year := m.named("y") ?? ""; // also m.group(2), m.str(), m.start(), m.span(i)
}
for m in re.find_all(text) { ... }
fixed := re.replace_all(&a, text, "$3.$2.${y}");
parts := re.split(&a, text);
  • Syntax: literals; . (any rune but a line break); classes [a-z_], [^0-9], and \d \w \s with \D \W \S, all ASCII; anchors ^ $ (of the text; of each line with (?m)), \A \z, \b \B; groups ( ), (?: ), (?P<name> ) or (?<name> ); |; repeats * + ? {n} {n,} {n,m}, greedy or lazy (*?). (?i) (ASCII case), (?m) and (?s) (. takes line breaks too) at the start of the pattern. Escapes: \n \t \r \f \v \0, \xHH, \x{H...}, and a backslash before punctuation.
  • The text is UTF-8, read a rune at a time: . and a class take a whole rune; places are byte offsets. Matches are leftmost-first, as in Perl, Python and Go. find_all gives matches that do not overlap; an empty match may follow a match, two at one place may not (as Python).
  • The tests compare with Python’s re on hand-picked and 391 random patterns. Where a repeat’s body can match empty in more than one way ((\w??|x)*), the groups (and sometimes the matches) can differ from a backtracking engine’s, as RE2’s and Go’s do.
  • A Regex keeps the memory it matches in, so it is used by one thread at a time, and a Match holds until the next search with its Regex. compile fails with a RegexError: .unclosed_group, .unmatched_paren, .bad_class, .bad_escape, .nothing_to_repeat, .bad_count ({n,m} past 1000, or backwards) or .too_big.

URLs (RFC 3986): the parts of one, percent-encoding, and query strings.

#import std.url as url;
u := url::parse("https://user@example.com:8080/a/b?q=1&r=x%20y#top")?;
// u.scheme "https", u.user "user", u.host "example.com", u.port 8080 (?u16),
// u.path "/a/b", u.query "q=1&r=x%20y", u.fragment "top"
port := u.port_or(443);
for p in url::query_pairs(u.query) { ... p.key ... p.value ... } // still encoded
q := url::query_get(u.query, "q"); // ?[]const u8
v := url::decode_form_in(&a, p.value)?; // "x y"
e := url::encode_in(&a, "a b&c", .component); // "a%20b%26c"
  • parse splits and checks an absolute URL (scheme:…); it does not decode. Each part is a slice of the text, a missing one empty (port null). An IPv6 host keeps its brackets ([::1]). Errors: .no_scheme, .bad_port (not a number up to 65535), and .bad_escape from decoding.
  • encode/encode_in write %XX for every byte but the ones Keep names: .component keeps only letters, digits and - . _ ~ (a query value, a path segment), .path keeps / too, and .form writes a space as + (a form’s query).
  • decode_in gives the bytes a part stands for (the part itself when it has no %), and decode_form_in reads + as a space too.

HTTP/1.1 over std.net: a client, and the parts of a server. Plain HTTP only, as there is no TLS yet.

#import std.http as http;
net::init()?;
r := http::get(&a, "http://example.com/")?;
if r.status == 200 { io::print(r.body); }
kind := r.header("content-type") ?? ""; // any case
hs: [1]http::Header = .{ .{ "Content-Type", "application/json" } };
r2 := http::request(&a, "POST", "http://localhost:8080/api", hs[:], body)?;
// a server, one connection at a time
l := net::listen(addr::socket_addr(addr::any(), 8080u16))?;
while true {
c := l.accept_stream()?;
req := http::read_request(&a, &c)?; // req.method, req.target, req.body
if req.path() == "/" { http::respond(&c, 200, "text/plain", "hello")?; }
c.close();
}
  • request sends method with the headers and body given, plus Host, User-Agent, Content-Length (for a body, and for any POST, PUT or PATCH), Accept-Encoding: gzip and Connection: close (one request a connection). It reads the body by its length, in chunks, or to the end, and unpacks a gzip or deflate one. Redirects (301, 302, 303, 307, 308) are followed up to five times; 303, and 301 and 302 of a POST, go on as a GET without the body, as browsers do.
  • A Response has status, reason, headers (as they came) and body. A Request has method, target (“/search?q=x”), headers and body, plus path() and query(). Both find a header with header(name).
  • respond writes a whole response: the status line (reason_phrase), Content-Type, Content-Length and Connection: close; respond_with adds headers of your own (a Location, say).
  • Errors (HttpError): .bad_url, .tls_unsupported (https://), .unknown_host, .connect_failed, .send_failed, .recv_failed, .bad_response, .bad_request (what a server read) and .too_many_redirects.

DEFLATE decompression (RFC 1951), and the gzip (RFC 1952) and zlib (RFC 1950) formats around it: what .gz files, PNG images, zip entries and HTTP’s Content-Encoding: gzip hold.

#import std.flate as flate;
text := flate::gunzip(&a, gz_bytes)?; // a .gz file's content
pixels := flate::unzlib(&a, idat)?; // zlib, as PNG wraps its image data
plain := flate::inflate(&a, raw)?; // bare DEFLATE, as a zip entry holds it
  • The output goes into the arena. gunzip checks the CRC-32 and the length after the data, and unzlib the Adler-32 (both from std.hash).
  • Errors (FlateError): .truncated, .bad_block, .bad_code, .bad_distance, .bad_header (not gzip or zlib, or a zlib stream with a preset dictionary) and .bad_checksum.
  • Compressing: deflate(&a, bytes, level), gzip(&a, bytes, level) and zlib(&a, bytes, level), with level 0 (stored) to 9 (smallest); 6 is a good balance. Matches are found by hash chains as zlib finds them, and each block is written stored, with the fixed codes or with its own, whichever is smallest: the output is within about half a percent of zlib’s at the same level, and Python’s zlib reads it.
  • Only the first member of a gzip file of several is read.

PNG images, read into RGBA pixels and written from them.

#import std.png as png;
img := png::decode(&a, fs::read_all("icon.png", &a)?)?;
// img.width, img.height, img.pixels: 4 bytes a pixel (red, green, blue, alpha), rows top to bottom
c := img.at(3, 5); // r | g << 8 | b << 16 | a << 24
out := png::image(&a, 64, 48); // transparent black; set out.pixels
fs::write_file("out.png", png::encode(&a, out, 6))?;
  • decode reads every PNG: gray, gray with alpha, RGB, RGBA and palette images, at every bit depth (16-bit samples keep their high byte, smaller ones are scaled to 0..255), interlaced (Adam7) or not, with transparency from tRNS. Each chunk’s CRC-32 is checked.
  • encode writes 8-bit RGB when every pixel is opaque, else RGBA, each row with the filter whose bytes sum smallest, compressed with std.flate at the level given.
  • Errors (PngError): .not_png, .corrupt, .unsupported and .too_big (past 2^28 pixels).

.zip archives, read and written: also .docx, .xlsx, .jar and .epub, which are zip inside.

#import std.zip as zip;
ar := zip::open(&a, fs::read_all("book.zip", &a)?)?;
for e in ar.entries { if !e.is_dir { bytes := ar.read(&a, e)?; ... } }
if ar.find("word/document.xml") |e| { xml := ar.read(&a, e)?; }
w := zip::writer(&a);
w.add("hello.txt", "Hello!", 6); // level 0 stores, 1-9 compress
w.add_at("log.txt", log, 6, time::unix_seconds());
w.add_dir("img");
fs::write_file("out.zip", w.finish())?;
  • An Entry has its name (UTF-8), method (0 stored, 8 DEFLATE), size, compressed_size, crc, is_dir, and modified() in seconds since 1970 (zip keeps a date and time with no zone, to two seconds). read checks the CRC-32 and the size.
  • add stores a file when compressing would not make it smaller. Files are dated 1980-01-01 unless added with add_at.
  • Errors (ZipError): .not_zip, .corrupt, .bad_checksum, and .unsupported for zip64 (past 4 GB or 65535 entries), encryption, or methods other than stored and DEFLATE.

Non-cryptographic hashes of bytes: FNV-1a (32 and 64 bits), CRC-32 and XXH64. Each comes as one call, and as a struct that takes the bytes in parts.

#import std.hash as hash;
k := hash::xxhash64(0, "key"); // fast, 64 bits, for tables (seed 0)
c := hash::crc32("123456789"); // 0xcbf43926, the zip/png checksum
f := hash::fnv1a_64("key"); // simple and quick for short keys (and fnv1a_32)
h := hash::crc32_start(); // in parts: the same result as one call
h.update(part1);
h.update(part2);
c2 := h.final();
x := hash::xxhash64_start(0);
n := hash::feed(&x, &file, buf[:])?; // all of a Reader, through buf
  • adler32(data) is zlib’s checksum.
  • fnv1a_64_start(), fnv1a_32_start(), crc32_start(), adler32_start() and xxhash64_start(seed) give the structs (Fnv1a64, Fnv1a32, Crc32, Adler32, XxHash64), each with update(bytes) and final(). final leaves the state as it was, so more bytes may follow.
  • Each struct is a Hasher, and so is crypto::Sha256: feed(h, src, buf) reads a Reader to its end into any of them and returns the bytes it read.
  • mix64(x) scrambles one integer, every bit of it reaching every bit of the result: a hash for an integer key.
  • They match the reference values (Zig’s std.hash on the same input, in tests/fixtures/stdlib/hash_tests.fels), natively and in #run. None of them resists input chosen to collide; use std.crypto for that.

Hashing and checksums, as pure computation.

d := crypto::sha256("abc"); // FIPS 180-4 SHA-256 → Digest
h := crypto::to_hex(&a, d); // "ba7816bf8f01cfea…"
buf: [32]u8 = .{}; // or write into your own buffer
crypto::sha256_into("abc", buf[0..32usize]);
s := crypto::sha256_start(); // in parts: a file, a stream
hash::feed(&s, &file, rbuf[:])?;
d2 := s.final();

sha256, sha256_into and Sha256 match the standard test vectors, natively and in #run. It’s a software SHA-256. It is fine for integrity checks, checksums and content addressing, but it is not audited for adversarial use. crc32, fnv1a_64 and fnv1a_32 are std.hash’s.

The other hashes work the same way: sha512(data) (a Sha512Digest, 64 bytes) and sha512_start(), and sha1(data) (a Sha1Digest, 20 bytes) and sha1_start(), each in parts a hash::Hasher. SHA-1 is broken for collisions; it is there for what needs it (git object names, the WebSocket handshake), not for anything an attacker may choose.

mac := crypto::hmac_sha256(key, msg); // RFC 2104; hmac_sha512 too
ok := crypto::equal_secure(mac.bytes[:], given); // as long wherever they differ
text := crypto::hex(&a, crypto::sha512(data).bytes[:]);

hmac_sha256 and hmac_sha512 key a hash with a secret, a key longer than a block hashed first. Compare a MAC with equal_secure, whose time does not depend on where the two differ.

Reflection-driven JSON in both directions, with no per-type code. One generic serializer and one generic parser walk the type’s core::type_info for its shape and an core::any for the field values/slots. They recurse through nested structs and slices on their own.

#import std.heap as heap;
#import std.serde as serde;
Player :: struct { name: []const u8, pos: Point, hp: u8, alive: bool, scores: []i32 }
a := heap::make();
pl: Player = .{ "Mario", .{ 10, 20 }, 100u8, true, xs };
js := serde::to_json(Player, pl, &a);
// {"name":"Mario","pos":{"x":10,"y":20},"hp":100,"alive":true,"scores":[1,2,3]}
// …and back. `from_json` returns `?T` (null on a structural parse error):
if serde::from_json(Player, js, &a) |back| { /* back == pl, field for field */ }

Both to_json($T, v, arena) and from_json($T, text, arena) -> ?T handle:

  • ints of every width, floats (in minimal form: 2.5, not 2.500000), bool
  • []const u8/[]u8 as JSON strings (with \", \\, \n, \t escaping/unescaping)
  • structs, recursively (nested structs, string fields)
  • slices of scalars ([]i32), structs ([]Point) and nested ones ([][]i32), as JSON arrays
  • optionals (?T): null for none, else the payload (scalar / string / struct)
  • enums: the variant name as a JSON string ("Blue")
  • on parse, unknown object keys are skipped, and missing fields stay zero-initialized

Deserialization constructs the value reflectively. It zero-inits a result: T, wraps a mutable Any over its address, and writes each parsed field through the real field address that any_field_at hands back. Arrays and strings are allocated in the arena.

This is the “one source, two speeds” design from docs/12. The structure comes from core::type_info (a struct’s id sizes/tags slice and optional-payload elements, with the alignment derived recursively from the field types). The bytes come from Any. So adding a new type needs no changes to the serializer or the parser.

Two things are not covered. Enum payloads aren’t emitted, only the variant name, because TiVariant carries no payload type. And ?*T and maps aren’t modeled.