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:
std.io
Section titled “std.io”The I/O interfaces and utilities. Everything here is synchronous and allocation-free.
Interfaces:
Writer:write,flushReader:read, which gives at mostbuf.lenbytes and 0 only at the end of the source. It may give fewer than are left, as a pipe or a socket does;std.bufiobuffers a reader.
Implementations & Tools:
stdout(),stderr(): ReturnStdout/Stderrstructs that implementWriter.stdin(): ReturnsStdin, which implementsReader. At the end of the input (a closed pipe, Ctrl+Z or Ctrl+D at a console)readgives 0.slice_reader(bytes): AReaderof bytes in memory (SliceReader), for a parser that takes a*Reader, or a test.copy(dst, src, buf): Readssrcto its end and writes it all todst, throughbuf, and returns the bytes copied.fixed_buf(): A 256-byte buffer on the stack that implementsWriter(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_repeatwrite_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);0pads a number with zeros after its sign; the precision is the digits after a float’s point; the kind isxorXfor hex,bbinary,ooctal, 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 ornull, slices and arrays as[1, 2], structs asPoint{ 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 andf.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
Formattoo, 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 u8passed as aFormatprints 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}")).
std.bufio
Section titled “std.bufio”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 flushBufReader(reader(src, buf), orreader_in(&a, src, size)with a buffer from an arena):read_line()gives the next line,read_until(delim)the bytes before the nextdelim,read_byte()one byte; each isnullat the end. A line is a slice of the buffer, good until the next read; one longer than the buffer fails withbuffer_full.lines()is the same as aforloop, which a failed read ends, settingfailed.BufReaderis aReaderitself, andbuffered()shows the bytes read and not yet taken.BufWriter(writer(dst, buf),writer_in(&a, dst, size)) is aWriter. A write waits in the buffer until it is full; one as big as the buffer goes straight todst.flushwrites what waits and flushesdst;drainonly writes it.read_all(&a, src)reads aReaderto its end, into memory from an arena.
std.fmt
Section titled “std.fmt”Formatting utilities built on top of std.io.Writer.
write_padded_left,write_padded_right,write_padded_centerwrite_u64_padded,write_hex_u64_paddedjoin_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) // -42fmt::parse_int(u8, "0xff", 0) // 255: base 0 reads a 0x, 0o or 0b prefixfmt::parse_int(u8, "256", 10) // fails .overflowfmt::parse_int(usize, "1_000", 10) // 1000: `_` may stand between digitsfmt::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.
std.mem
Section titled “std.mem”Safe, generic helpers for typed memory.
eql($T: type, a: []const T, b: []const T) -> boolstarts_with,ends_with,contains,index_ofcopy($T: type, dest: []T, src: []const T)fill($T: type, slice: []T, value: T)swap,reverseindex_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 ofsetat 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 toofor word in mem::tokenize(u8, " a b ", " ") { … } // "a", "b": runs of delimiters skippedif mem::split_once(u8, "key=value", "=") |kv| { // kv.before "key", kv.after "value" …}std.strings
Section titled “std.strings”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_spaceand 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 separatorsfor word in strings::fields(text) { … } // the words: between spaces, tabs, line breaksfor line in strings::lines(text) { … } // without "\n" or "\r\n"; no empty last linestd.bytes
Section titled “std.bytes”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>andwrite_<width>_<order>, where the width isu16,u32oru64and the order isle(little-endian) orbe(big-endian). There are alsoread_u8andwrite_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 anyusize, evenusizemax, and the check cannot overflow.BytesErroris its own error set. If your parser returns a different one, handle it withcatch, 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.
std.utf8
Section titled “std.utf8”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 bytesn := utf8::count(text); // how many runesif utf8::valid(bytes) { … } // valid UTF-8 from start to endd := utf8::decode(text); // the first rune, d.rune, and its length, d.lenlen := 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, sorunesandcountgo through bad text without stopping or skipping the good text after it.validsays 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 whenoutis too short.strings::append_runewrites 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}'.
std.utf16
Section titled “std.utf16”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 u16of 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)andutf8_len(src16)give the exact size of the result. To convert into your own buffer,utf8_to_utf16(src, dst)andutf16_to_utf8(src, dst)return the number of units or bytes written. To allocate from an arena, usealloc_utf16(a, src),alloc_utf16z(a, src)oralloc_utf8(a, src). Withalloc_utf16z, one extra unit past the end is0, so.ptris a Win32LPCWSTR, and the returned length does not count that unit. - Errors (
Utf16Error). The errors are.invalid_utf8,.invalid_utf16,.buffer_too_smalland, for the arena functions,.out_of_memory. You inspect one withe == .invalid_utf8in acatch. - 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
dstis too small, it fails anddstis not modified. Whendstis longer than needed, its extra units stay as they were. The arena functions allocate exactly the result (plus the terminator foralloc_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.
std.fs
Section titled “std.fs”Minimal synchronous file I/O, on Windows and Linux.
open(path): Open an existing file for readingcreate(path): Create or truncate a file for writingappend(path): Open a file for appendingread_all(path, &a): Read a whole file into the arena;write_file(path, bytes)writes onecopy_file(from, to): Copy a file, and give the bytes copiedrename(from, to): Rename or move a file, replacing one atto(.rename_failedwhen it cannot)info(path): AnInfowith thesize, the last write asmodified_ns(nanoseconds since 1970, UTC) andis_dir;.not_foundwhen nothing is theredelete(path): Remove a fileexists(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, andremove_dir(path)removes an empty folder.open_dir(path): Walk a folder with theDirIterit 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()anddir_is_reparse()describe that entry, anddir_close()ends the walk. On Linux,dir_size()is always 0.walk(&a, root): AWalkerover everything below a folder, at any depth, each folder before what is in it:for e in w { ... }gives anEntrywith itspath(joined with/, in the arena),name,is_diranddepth(1 in the root). Links are given but not followed. A folder that cannot be opened is given, skipped, and setsfailed;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_foundwhen nothing is there,.delete_failedwhen something stayed (open, read-only, not yours).cwd(&a)gives the current folder, andset_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\xis on the current folder’s drive.temp_dir(&a): the folder for temporary files (TMP,TEMPorUSERPROFILEon Windows,TMPDIRor /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: aTempFilewithfileandpath.make_temp_dir(&a, dir, pattern)makes a new folder and gives its path.dir“” meanstemp_dir(). The last*ofpatternbecomes 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
Filestruct, which implements bothReaderandWriter. - 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
*Wfunctions take.dir_nextgives you names in UTF-8 too.
std.process
Section titled “std.process”Process management, on Windows and Linux.
spawn(cmd): Spawn a child process (returnsChild)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: anOutputwith itscodeandstdout(in the arena). Its stdin and stderr are this process’s.spawn_piped(cmd, pipes): Spawn with pipes for the streams aPipes { stdin, stdout, stderr }names.stdin_file(),stdout_file()andstderr_file()give the pipe ends asfs::Files (anio::Writerandio::Readers; a read gives 0 once the child closed its end), andclose_stdin()lets the child read the end of its input.releasecloses 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_envenv_vars(&a): every variable of the environment, each anEnvVarwithnameandvalue, withset_envandunset_envapplied. On Linux, these are what the program started with (up to the first 8 KB of them) plus whatset_envadded.exe_path(&a): the path of this program’s executable, or nullcommand_line: the command line as one textargs(&a): the arguments, with the program first. On Windows they are split from the command line the way the C runtime splits it, andsplit_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.
std.cli
Section titled “std.cli”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(...) toor := 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.-hand--helpsethelp(and skip the check for missing arguments). - What was read:
flag(name),count(name)(-vvvis 3),option(name)(the last value),all(&a, name)(every value, for-I a -I b),int_option(name),positional(i)andpositionals_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), andc.messagesays it for the user.c.help(&a)lists the usage, the arguments and the options.
std.ptr
Section titled “std.ptr”Pointer manipulation and conversions, which require unsafe in some contexts.
to_addr(p: *T) -> usizefrom_addr($T: type, addr: usize) -> *Tadd_bytes,sub_bytes,diff_bytesis_aligned,align_down,align_upis_null
std.bits
Section titled “std.bits”Bit-twiddling utilities.
count_ones_u32,count_ones_u64leading_zeros_u32,trailing_zeros_u64, etc.rotate_left_u32,rotate_right_u64, etc.is_power_of_two_u32bit_u32,set_bit_u32,clear_bit_u32,toggle_bit_u32
std.atomics
Section titled “std.atomics”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 valueif 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
Atomic(T): a typed atomic cell
Section titled “Atomic(T): a typed atomic cell”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 valueif counter.cas(1u32, 100u32) { ... } // compare-and-swapn := 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
+= 1would race. Seetests/fixtures/stdlib/atomics_thread_app.fels, andatomic_cell_app.felsfor theAtomic(T)method API.
std.thread
Section titled “std.thread”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.
Threads that end with a block: Scope
Section titled “Threads that end with a block: Scope”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 heres.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 pool for loops: Pool and parallel
Section titled “A pool for loops: Pool and parallel”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 hererange(start, end, f)callsf(i)once for eachiinstart..end, on several threads at once, and returns when every call has returned.each(T, items, f)callsfwith 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 onerangeon 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 onceShare 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.
One arena per thread
Section titled “One arena per thread”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.
Threads by hand
Section titled “Threads by hand”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 codeThread(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 withjoin().spawn(entry, arg) -> Thread: start a thread.entryiscore::fn_ptr(fn).- Helpers:
current_id(),sleep(ms),yield_now(),cpu_count(). ThreadGroup: structured fan-out.spawn(entry, arg)adds a thread, andjoin_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 allLocks, waiting, a blocking queue and channels are in
std.sync, while counters and flags need onlystd.atomics. Seetests/fixtures/stdlib/thread_scope_app.felsandtests/fixtures/stdlib/thread_app.fels.
std.sync
Section titled “std.sync”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 waiterlock.lock();while !ready { cv.wait(&lock); } // always in a loop: wait() can return with no signallock.unlock();
// a signallerlock.lock();ready = true;lock.unlock();cv.signal();Mutex:lock()sleeps until it holds the lock,try_lock() -> booltakes it only if it is free right now, andunlock()releases it. Pairlock()withdefer m.unlock();so the lock is released on every exit from a function.CondVar:wait(&m)releasesm, sleeps, and holdsmagain when it returns.wait_timeout(&m, ms) -> boolgives up aftermsmilliseconds (0xFFFFFFFF means never) and returnsfalsewhen the time ran out.signal()wakes one waiter andbroadcast()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 awhile !condition { … }loop, as above. Whenwait_timeoutreturnstrue, that means “woken”, not “the condition holds”. - Rules. Share a lock by address, and do not copy one that is in use. A
Mutexis 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 closedv := q.pop() ?? -1; // sleeps while it is empty; null once closed and drainedpush(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,pushandtry_pushreturnfalse. Items already queued can still be popped, and when they run out,popreturnsnull. So a pipeline stops like this: the producers finish, the owner callsclose(), the consumers drain the queue and seenull, the owner joins them, and only then does the owner release the storage. Callingclose()again does no harm. - Destruction. A
Queueholds no resource, so there is nodeinit. What must not happen is freeing or moving the storage, or theQueueitself, 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
Queueover empty storage can hold nothing, sopushfails andpopwaits forclose().
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.
std.com: COM and Windows Runtime types
Section titled “std.com: COM and Windows Runtime types”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 ofdata1: u32,data2: u16,data3: u16anddata4: [8]u8(an IID and a CLSID are GUIDs). It haseq(&other),is_null()andformat(buf) -> usize, which writes{XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}in upper case.bufneedsGUID_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”). Thewinerror.hcodes 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)andfrom_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 withComErrorotherwise. 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) addsget_iids,get_runtime_class_nameandget_trust_level.IActivationFactoryaddsactivate_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), andadd()andrelease()return the new count. Arelease()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 };
#entrymain :: 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 oneco_uninitialize()/ro_uninitialize()on the same thread, and that includes a success that returnedS_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)isCoCreateInstance. It gives the HRESULT back, and*outis an owned interface pointer.activation_factory(class_id, &iid)isRoGetActivationFactory. It takes the class name as an HSTRING handle (.h) and gives back an owned*IUnknownfor you to cast to the factory interface, or.class_not_registeredif there is no such class.activate_instance(class_id)isRoActivateInstance, andnew_guid()isCoCreateGuid.co_task_mem_free(p)frees what the OS allocated for you with the COM task allocator, such as the array thatIInspectable.get_iidsreturns. HString: an ownedHSTRING..his the one-pointer handle to pass to the OS, and the null handle is the empty string.hstring(units)copies UTF-16 in (WindowsCreateString), andhstring_from_utf8(text, scratch)converts through your[]u16scratch buffer first.release()deletes it (idempotent). There are alsoduplicate(),len(),is_empty(),units()(a borrowed view, valid untilrelease()),eq(&other)(case-sensitive) andto_utf8(out). An HSTRING may hold zeros and unpaired surrogates, andto_utf8refuses the latter with.invalid_utf16.HStringRef: a borrowedHSTRINGover 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 afterinit, 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;
#entrymain :: 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.
std.net: networking
Section titled “std.net: networking”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 { ... }; // falliblesa := 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; };
// clients := net::connect(addr::socket_addr(addr::localhost(), 8080u16)) catch e { return; };s.send_all("ping") catch e {}; // loops over short writesn := s.recv(buf[:]) catch e { return; }; // 0 = peer closeds.close();
// serverln := 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_hosts := 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.fromignored := srv.send_to("pong", d.from) catch e { return; }; // reply to senderTcpStream:send/send_all,recv/recv_exact,set_nodelay,set_read_timeout/set_write_timeout,local_addr,shutdown_write,close. It is anio.Reader(0 once the peer has closed) and anio.Writer, sostd.bufioandio::copywork 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::SocketAddrandnet::NetError. - The raw calls in
std.net.oskeep asys_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) andnet_udp_app.fels(UDP echo). HTTP is instd.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()andimage(): what the firmware passed toefi_main, for calls the module does not wrap.stall(us)waits,clear()clears the console,read_key() -> Keywaits for a key (char, or 0 and ascancode for a special key).framebuffer() -> ?Framebuffer: the screen of the graphics output protocol, withput(x, y, rgb)andfill(x, y, w, h, rgb); null without one.shutdown()andreboot().
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 = .{} };
#interrupton_breakpoint :: fn(frame: *const x86::InterruptFrame) { println("int3 at {frame.ip}");}
#interrupton_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.
Game & graphics modules
Section titled “Game & graphics modules”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.
std.math
Section titled “std.math”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. ConstantsPI,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,absandclamptake any number):gcd,lcmandipow(null when the result does not fit),isqrt,div_ceil,align_upandalign_down(to a power of two),log2_floor,next_power_of_two(null past 2^63), andmod_floorfori64, the remainder with the sign of the divisor (mod_floor(-7, 3)is 2, where%gives -1).
std.float
Section titled “std.float”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).
std.rand
Section titled “std.rand”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_f32andnext_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), andrand::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.
std.color
Section titled “std.color”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.
std.vec: Vec(T)
Section titled “std.vec: Vec(T)”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 removals := v.remove_swap(i); // O(1) unordered removalfor 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()).
std.map: AutoHashMap(K, V) and StrMap(V)
Section titled “std.map: AutoHashMap(K, V) and StrMap(V)”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 valuem.put(7, 100); m.put(7, 101); // overwrite → still one entryif m.get(7) |v| { ... } // v == 101 ; get returns ?Vm.contains(7); m.remove(7);
sm := map::strmap(i32, &a); // keys: []const u8, by contentsm.put("alpha", 1);sm.get("alpha"); // hashes + compares the string bytesAutoHashMap(K, V)keys a value type whose bytes are its value. It needs noHash/Eqimpl, 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 themap::ByteKeyconstraint, so a key that cannot be compared that way is a compile error that says why. Such a key is a float (-0.0and0.0are equal and differ in their bytes, and a NaN equals nothing), a slice (a string compares its address, not its contents, so key strings withStrMap), or a type with bytes no value sets (padding, an optional’s flag or an enum payload, seecore::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, andentries(), which aforgoes through:for e in m.entries() { … e.key … e.value … }, in no particular order. Aputorremoveduring the loop may skip or repeat entries. - Sets:
map::autoset(K, &a)gives anAutoSet(K)andmap::strset(&a)aStrSet, maps with nothing stored for a key.add(k)says whetherkwas new;contains,remove,clear,len, andeach()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_bytesfor text), ormap::sorted_with(K, V, C, &a, order)by an order type asstd.sorthas them (sort::Asc(K),sort::Desc(K),sort::By(K)). Besidesput,get,contains,remove,lenandclearit hasentries()in key order,from(k)from the first key that does not come beforek, andfirst()/last(). The keys and values are two sorted arrays: a lookup is a binary search, andputandremovemove what comes after, which suits up to some ten thousand entries.AutoHashMapandAutoSetdo not run at compile time: they hash a key’s bytes through a pointer, and a value on the comptime VM has none.StrMapandStrSetdo.
Fels’s approach to hashing: a struct that
#derive(Hash, Eq)s getshash()/eq()for free.AutoHashMapdoesn’t even need that, because byte-hashing covers every key whose bytes are its value with zero ceremony, and the compiler refuses the others.
std.pqueue: PriorityQueue(T, C)
Section titled “std.pqueue: PriorityQueue(T, C)”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 firstq.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) -> boolpush,push_all,pop(→?T),peek(→?T, left in),len,is_empty,clear, anditems()(in no particular order).- The order is a type
Cwithless(self, a, b):pqueue::Min(T),Max(T),By(T)(a function), or your own withpqueue::with(T, C, &a, order), for an order that carries data.
std.deque: Deque(T)
Section titled “std.deque: Deque(T)”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 queuefor x in d.each() { ... } // front to back, left in placepush_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.
std.bitset: BitSet
Section titled “std.bitset: BitSet”The numbers 0 to n-1, one bit each, 64 to a word.
#import std.bitset as bitset;
s := bitset::make(&a, 1000); // all clears.set(3); s.set(700);if s.has(3) { ... }for i in s.ones() { ... } // 3, 700: the set bits, in orders.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.
General-purpose modules
Section titled “General-purpose modules”std.sort
Section titled “std.sort”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 firstsort::by(Person, people, by_age); // `less(a, b)`: a goes before bsort::by([]const u8, names, mem::less_bytes); // text, byte by bytesort::stable_by(Row, rows, by_date, &scratch); // equal rows keep their ordersort::is_sorted(i32, xs) // truesort::lower_bound(i32, xs, 42) // the first index whose element is not < 42sort::upper_bound(i32, xs, 42) // the first index whose element is > 42sort::search(i32, xs, 42) // ?usize: the first index holding 42asc, 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.
std.path
Section titled “std.path”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 Windowspath::with_extension(&a, "c.txt", "md")// "c.md"path::normalize(&a, "a/./b/../c/") // "a\c" on Windows, "a/c" elsewhereOne 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.
std.time
Section titled “std.time”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 UTCms := time::unix_millis();dt := time::utc(secs); // DateTime { year, month, day, hour, minute, second, weekday }
start := time::now(); // monotonic Instanttime::sleep_ms(16u32);ns := time::since_nanos(start); // also since_millis / since_secondsboot := time::monotonic_millis(); // the same monotonic clock, in millisecondsutc 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 existp := d.parts(); // DateParts { year, month, day }w := d.weekday(); // 0=Sunday … 6=Saturdaye := d.add_months(1i64); // 2024-02-29: the day stays inside the monthf := d.add_days(-1i64); // also add_yearsn := d.days_until(e); // 29iso := time::parse_date("2024-03-15"); // ?Datebuf: [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 nanosecondsformat_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%%;offsetis what%zwrites. What does not fit inoutis cut.weekday_nameandmonth_namegive the English names.parse_rfc3339takesT,tor a space between date and time, fractions of a second, andZ,zor±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)andposix_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.
std.csv
Section titled “std.csv”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 tabr := 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 onceThe 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.
std.json
Section titled “std.json”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_textquote := 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(); // ?f64A 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) Valueo.end_object();o.finish()?; // fails when a write failed
text := json::stringify(&a, doc); // a Value as text, on one lineValues: 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.
std.decimal
Section titled “std.decimal”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.97share := decimal::div(total, decimal::from_int(7), 2, .half_even)?; // 8.57io::println("{total} / 7 = {share}");cents := decimal::round(x, 2, .half_up)?;- A
Decimalis a whole numbercoefand ascale, its valuecoef / 10^scale, with up to 18 places and about 18 digits in all. addandsubare exact, at the larger scale of the two.mulis exact while the result fits and keeps the places of both (1.25 * 2.5is3.125); past 18 places, or a coef past ani64, it drops the last places half to even.div(a, b, scale, mode)givesscaleplaces rounded once, bymode. 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,.floorand.ceiling.cmp,eq,lesscompare values whatever their scales (2.50is2.5);neg,abs,normalize(no zeros at the end),is_zero,is_negative,sum.parsereads-12.50,.5,1e3,2.5E-3, keeping the places written;to_textwrites them all (19.90), and so does printing one in a format.from_int,from_parts(coef, scale), andto_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.
std.base64 and std.hex
Section titled “std.base64 and std.hex”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 tokensn := 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::Codecis an alphabet and whether=pads the text to a multiple of 4:STANDARD(+and/, padded),STANDARD_NO_PAD,URL(-and_, padded) andURL_NO_PAD. Each hasencode,decode(into a buffer),encode_in,decode_in(into an arena),encoded_lenanddecoded_len;base64::encode_inanddecode_inareSTANDARD’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::encodeanddecodewrite into a buffer;hex::digit_value(c)is a digit’s value or null. Errors:.invalid_character,.odd_length,.buffer_too_small.crypto::hexishex::encode_in.
std.base32
Section titled “std.base32”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 toos := 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) andHEX_NO_PAD. Each hasencode,decode,encode_in,decode_in,encoded_lenanddecoded_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_lengthand.buffer_too_small.
std.leb128
Section titled “std.leb128”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 bytesd := leb128::decode_u64(buf[0..n])?; // d.value 624485, d.len 3m := leb128::encode_i64(buf[:], -123456)?; // c0 bb 78s := leb128::decode_i64(buf[0..m])?; // s.value -123456len_u64andlen_i64give 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 00for 0) reads, as most readers allow. - Errors (
LebError):.buffer_too_small(nothing written),.truncatedand.overflow(a number past 64 bits).
std.cbor
Section titled “std.cbor”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::Writere := 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, andbegin_array(n)andbegin_map(n), which the nextnitems (pairs) fill.begin_array_indefiniteandbegin_map_indefinitetake items up to anend, andrawwrites 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, andfinishreports it. nextgives anItem: itskind(.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) andindefinite.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.skipreads over the next item and all it holds, andskip_contents(it)over what an item just read holds.at_endsays 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(skippast 1000 levels).
std.log
Section titled “std.log”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 stderrlog::set_level(.debug); // from here on debug messages show too
net := log::to(&file, "net", .debug); // its own writer, scope and levelnet.debug("got {len} bytes"); // [debug] net: got 512 bytesif net.enabled(.debug) { ... } // to skip work a message would needA 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.
std.regex
Section titled “std.regex”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 \swith\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_allgives 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
reon 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
Regexkeeps the memory it matches in, so it is used by one thread at a time, and aMatchholds until the next search with itsRegex.compilefails with aRegexError:.unclosed_group,.unmatched_paren,.bad_class,.bad_escape,.nothing_to_repeat,.bad_count({n,m}past 1000, or backwards) or.too_big.
std.url
Section titled “std.url”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 encodedq := url::query_get(u.query, "q"); // ?[]const u8v := url::decode_form_in(&a, p.value)?; // "x y"e := url::encode_in(&a, "a b&c", .component); // "a%20b%26c"parsesplits and checks an absolute URL (scheme:…); it does not decode. Each part is a slice of the text, a missing one empty (portnull). An IPv6 host keeps its brackets ([::1]). Errors:.no_scheme,.bad_port(not a number up to 65535), and.bad_escapefrom decoding.encode/encode_inwrite%XXfor every byte but the onesKeepnames:.componentkeeps only letters, digits and- . _ ~(a query value, a path segment),.pathkeeps/too, and.formwrites a space as+(a form’s query).decode_ingives the bytes a part stands for (the part itself when it has no%), anddecode_form_inreads+as a space too.
std.http
Section titled “std.http”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 timel := 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();}requestsendsmethodwith the headers and body given, plusHost,User-Agent,Content-Length(for a body, and for any POST, PUT or PATCH),Accept-Encoding: gzipandConnection: 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
Responsehasstatus,reason,headers(as they came) andbody. ARequesthasmethod,target(“/search?q=x”),headersandbody, pluspath()andquery(). Both find a header withheader(name). respondwrites a whole response: the status line (reason_phrase),Content-Type,Content-LengthandConnection: close;respond_withadds headers of your own (aLocation, 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.
std.flate
Section titled “std.flate”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 contentpixels := flate::unzlib(&a, idat)?; // zlib, as PNG wraps its image dataplain := flate::inflate(&a, raw)?; // bare DEFLATE, as a zip entry holds it- The output goes into the arena.
gunzipchecks the CRC-32 and the length after the data, andunzlibthe Adler-32 (both fromstd.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)andzlib(&a, bytes, level), withlevel0 (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.
std.png
Section titled “std.png”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 bottomc := img.at(3, 5); // r | g << 8 | b << 16 | a << 24out := png::image(&a, 64, 48); // transparent black; set out.pixelsfs::write_file("out.png", png::encode(&a, out, 6))?;decodereads 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 fromtRNS. Each chunk’s CRC-32 is checked.encodewrites 8-bit RGB when every pixel is opaque, else RGBA, each row with the filter whose bytes sum smallest, compressed withstd.flateat the level given.- Errors (
PngError):.not_png,.corrupt,.unsupportedand.too_big(past 2^28 pixels).
std.zip
Section titled “std.zip”.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 compressw.add_at("log.txt", log, 6, time::unix_seconds());w.add_dir("img");fs::write_file("out.zip", w.finish())?;- An
Entryhas itsname(UTF-8),method(0 stored, 8 DEFLATE),size,compressed_size,crc,is_dir, andmodified()in seconds since 1970 (zip keeps a date and time with no zone, to two seconds).readchecks the CRC-32 and the size. addstores a file when compressing would not make it smaller. Files are dated 1980-01-01 unless added withadd_at.- Errors (
ZipError):.not_zip,.corrupt,.bad_checksum, and.unsupportedfor zip64 (past 4 GB or 65535 entries), encryption, or methods other than stored and DEFLATE.
std.hash
Section titled “std.hash”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 checksumf := hash::fnv1a_64("key"); // simple and quick for short keys (and fnv1a_32)
h := hash::crc32_start(); // in parts: the same result as one callh.update(part1);h.update(part2);c2 := h.final();
x := hash::xxhash64_start(0);n := hash::feed(&x, &file, buf[:])?; // all of a Reader, through bufadler32(data)is zlib’s checksum.fnv1a_64_start(),fnv1a_32_start(),crc32_start(),adler32_start()andxxhash64_start(seed)give the structs (Fnv1a64,Fnv1a32,Crc32,Adler32,XxHash64), each withupdate(bytes)andfinal().finalleaves the state as it was, so more bytes may follow.- Each struct is a
Hasher, and so iscrypto::Sha256:feed(h, src, buf)reads aReaderto 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.hashon the same input, intests/fixtures/stdlib/hash_tests.fels), natively and in#run. None of them resists input chosen to collide; usestd.cryptofor that.
std.crypto
Section titled “std.crypto”Hashing and checksums, as pure computation.
d := crypto::sha256("abc"); // FIPS 180-4 SHA-256 → Digesth := crypto::to_hex(&a, d); // "ba7816bf8f01cfea…"
buf: [32]u8 = .{}; // or write into your own buffercrypto::sha256_into("abc", buf[0..32usize]);
s := crypto::sha256_start(); // in parts: a file, a streamhash::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 toook := crypto::equal_secure(mac.bytes[:], given); // as long wherever they differtext := 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.
std.serde
Section titled “std.serde”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, not2.500000),bool []const u8/[]u8as JSON strings (with\",\\,\n,\tescaping/unescaping)- structs, recursively (nested structs, string fields)
- slices of scalars (
[]i32), structs ([]Point) and nested ones ([][]i32), as JSON arrays - optionals (
?T):nullfor 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.