wolf

A compiled systems language. Pre-alpha; the surface still moves.

Memory lives in regions the compiler infers, so a program carries no lifetime annotations. Allocation goes through arenas by default. A region moves between tasks instead of being shared, which is what keeps concurrent access safe, and the aliasing that falls out of that is information the optimizer gets to use. Arithmetic is checked in every profile, including release.

fn fill(n: int) -> List[int] {
    var xs = List[int]()           // lands in the caller's current region
    for i in 0..n { (mut xs).push(i) }
    xs
}

fn main() -> !int {
    var total = 0
    region tmp {
        let xs = fill(100)         // allocated in tmp [mem.region.create.3]
        for x in xs { total += x }
    }                              // wholesale free [mem.region.intra.2]
    if total == 4950 { 0 } else { 1 }
}

That is corpus/memory/region_ambient_ok.lu, verbatim below its header. The callee allocates into the caller's region, so the list lives in tmp and dies with it, and the bracketed names in the comments are the specification clauses the program is pinned to. Run it in the browser, along with thirty-odd other programs from the same corpus.

What runs today

v0.2.29 is tagged, still under the codename wolfgang. wolf build and wolf run compile .lu source to native machine code through the compiler's own backend, with no LLVM in the loop. wolf build --release emits textual LLVM IR and hands it to the system clang — no LLVM libraries linked, no new dependencies. Both tiers produce the same verdict and the same output on every corpus program they both execute, and CI holds a floor under how many that is, so the comparison cannot quietly shrink to nothing. A binary never claims a release it is not: wolf --version built anywhere but the tag answers with the build's commit.

match is the switch you were reaching for. In statement position it is what a reader from any C-family language expects, and the arm that was missing is there:

match n {
    0 => ...,
    1 | 2 => ...,
    3..=9 => ...,
    n if n % 2 == 0 => ...,
    _ => ...,
}

lo..hi and lo..=hi, over integer and char literals, in any pattern position, composed with |, with guards and with @. Both ends are literals: no identifiers, no expressions, and no open ends, because ..hi and lo.. are how a slice is spelled — and a parser that refuses one now says the word "range" instead of leaving you to work out which of the two you had written. A range no value can match is E0815 before the program runs. Exhaustiveness computes no union, so a _ or a binder arm is still required after range arms, and when one is missing the diagnostic names the first value nothing covers. The guarded arm in that chain is new as well. Native lowering used to refuse n if n % 2 == 0 => standing before a _ — as conservatism, while the checked machine and the interpreter both ran it — and the refusal named the wrong arm. It lowers on both tiers now, and the switch above is byte-identical on both.

And the compiler stopped blaming itself for your program. s[l-1..l] = "{t}", which is a reasonable thing to try when you want to change the last character of a string, was answered cannot compile this yet and a pointer at the ledger of things the backend has not got to. That was backwards. A str never changes after it is built and a slice of one is a view, so there is no place on the left of that =, today or ever. [mem.str.imm] rules it and E0416 says it — cannot assign through a str slice: s is immutable — with the three spellings that do work in the note.

A send in a loop body is a warning, not a mismatch. chan.send is typed () ! {closed, cancelled} now, which is the row [conc.chan.close] had always described, and typing it raised a question: what does a fallible () mean where () is expected? [type.unit] answers it. The body of a loop, an else-less if and a unit function are unit contexts, and a !() tail in one is a warned discard — W0601 — never an error. ch.send(v)? hands the failure to the enclosing scope and else handles it on the spot; the corpus spells the first. A task closure's tail is the one different case, and the clause says why: the scope consumes it and re-raises the row when it exits.

Around those three, that release was the language reading what a signature already said. A body's tail is checked against the declared result ([type.fn.ret], E0401 at the tail rather than somewhere downstream of it), and a !T is two values that no operator reads ([type.row.operand], E0409 on either side). Nothing moved in the compiler for either one: both rules were being enforced and neither was written down, and lupin enforces both. Two more, for anyone who records a number: a module's interface hash is over the interface alone, so a toolchain bump no longer moves every hash in the file; and the commit in a development build's version string is seven characters by decision, from every clone, rather than whatever the object count makes git rev-parse --short guess.

The release this page advertises lets a server park a task for every connection, and gives a shell the signals it is made of. A task parked in a wait — net_read, net_accept, a channel recv, a when or a sleep — holds a thread, and the pool adds a worker beside it. That stopped at a fixed number of workers a core, so a program that parked more tasks at once stopped with them, the task that would have woken them queued behind them (wolf-lang#713). The pool has no cap now: the host's thread limit is the last word, and a thread the host refuses is one line on stderr, never an abort. A parked task costs its thread and no cpu, where every parked wait had woken many times a second to look for a cancellation; os_signal_wait still polls. Every signal POSIX names has a meaning in the signal set, the ones a program may listen for, ignore, wait for and poll, and KILL, STOP and the fault signals, which it may only send; a set holding a bit that is not a listenable meaning is io at every call that takes one, where a listen had dropped the bit and answered ok. os_signal_ignored reads which meanings are ignored, at start the ones the program was started ignoring; a poll no longer trails a delivery; os_pid and os_ppid are this process's id and its parent's, os_signal_send signals a process by its id, and os_signal_number is the one way to a host's number — what trap, kill, $$ and $PPID are made of. A published meaning keeps its bit in every later release ([os.signal.set.stable]), so a set a program holds today means the same signals under the next compiler. wolf lsp finds the standard library as wolf build does, where an editor had shown a false E0301 on every std method outside the builtins (wolf-lang#724). The std/ in the archive was written against the release before this one: its std.signal names the meanings that release had and wraps none of the new calls, so a program calls them as the prelude spells them and writes a newer meaning as the bit the specification gives it. The interpreter this release is tested against mirrors the calls it can serve without unsafe and refuses the send and the ignored set by name; it released without re-reading the specification, so the gap between the two widened once more, and the playground counts it.

v0.2.28, the release before it, closed two ways a program could read freed bytes through a call, and its checked machine ran concurrent code. A callee handed a mut argument may keep what else it is handed in it, and a callee handed a channel may send it on; when the bytes came from a string built in a region block, the block freed them while the caller's list or the receiver still held them, and native, release and the checked machine read what was left (wolf-lang#660, #665). Both shapes became E1010 at the call, read from the callee's signature and never from its body, and the message names the edit that is sound there: "{s}" inside the in block builds the bytes where the callee allocates. It is a compile-time refusal, so no accepted program changed and nothing costs time at run time; some programs that were correct are refused too, and the release notes say which shapes. The checked machine, wolf conform-run --checked, runs scope, spawn, procs, select, when, closures and xs.par(f) one task at a time under a deterministic scheduler, where it had refused them by name, so the machine that watches for undefined behaviour reaches concurrent programs. A program can change a file as well as read one — fs_set_mode, fs_symlink, fs_link, fs_truncate, fs_set_times, a creation's permission bits and os_umask, what chmod, ln, touch and mkdir -m are made of. A long-running server stopped growing in the runtime, and a List slice became one copy where it was a loop of pushes.

v0.2.27 shipped the standard library in the archive and fixed three integer answers. Every archive carries wolf-std as std/ beside wolf, and that copy is where use std.env resolves when nothing else is configured. Unsigned and wrapping integers print and compute as the values their types hold, where the compiled tiers had printed a wrapping[u64] as signed and refused or trapped its division (wolf-lang#551, #538, #553). A program can survive Ctrl-C, run a job in its own process group and own the terminal; become another program with os_exec and remove a variable; read the full stat record, a link and a typed directory listing. A repeated json name answers its last occurrence, a module named c links, and a C declaration needs the ffi capability as import c does. The interpreter it was tested against released beside it and beside v0.2.28 without re-reading the specification, so the gap between them widened at each.

v0.2.26 fixed one more answer the compiled tiers got wrong without a word, and handed a program its own plumbing. A call to a function returning a str left no record of the region it was made in, so region scratch { out = build(7) } handed out freed bytes on the checked, native and release tiers, where lupin trapped; that call became an allocation site, and holding its result past the region E1010. The way to keep a region block's result came with it: copy region { … } runs the block, copies its value out into the region it was entered from and frees the rest, so a loop keeps each turn's answer and nothing else ([mem.region.copyout]). A program's standard streams take bytes on descriptors 0, 1 and 2; os_spawn_fds, os_pipe and os_chdir build a pipeline, a redirection and a cd from the program's own argv, and os_isatty asks whether a descriptor is a terminal. -> never declares a function that never returns, so bad => die("…") fits an arm that wants an int; ! on an integer is its bitwise complement at the operand's width, so x & !0xfff is the mask it looks like; p[i].f = v stores one field through a raw pointer to a struct; and another module's pub const reads as m.K. A package's capabilities are what its code reaches now, a host builtin it calls as much as a module it imports, and wolf audit prints why each one is there. The interpreter it was tested against released beside it without re-reading the specification, so the two were two releases apart where they had been one.

v0.2.25 fixed three answers the compiled tiers got wrong without a word. A store to a module var, to extern "c" let storage or through any raw pointer was carried across a call to a function that wrote the same memory, so the caller read back its own stale value: the checked machine and lupin printed 99 where native and release printed 0. Loads had the same fault, a read after such a call reusing the value from before it. Every call now ends what the caller knew about that memory, which [mem.static.2] says on every machine; a list lent to the callee stays frozen for the call, as a read parameter promises, so its loads are still reused. And on release a branch on the top half of an unsigned shift, a >> 63 == 1, was deleted, because the range pass bounded the shift by the signed maximum. The interpreter it was tested against had re-read the specification at the tag before it, so the two were one release apart where they had been two.

v0.2.24 let a kernel share a word between cpus and allocate. On a *T whose pointee is a fixed-width integer, inside unsafe, atomic_load, atomic_store, atomic_swap, atomic_add, atomic_sub, atomic_and, atomic_or, atomic_xor and atomic_cas each take an order written at the call, Order.relaxed through Order.seq_cst, and the builtin fence(o) sits beside them. A pointee that is not an integer is E1308 and an order the operation does not admit is E1309. On the freestanding target List, Map, interpolation, closures and region compile against a wolf_alloc and wolf_free the program supplies. A raw access at an address that is not a multiple of its pointee's alignment is undefined, row L4, where the machines had disagreed about it. A module string holds the bytes the same literal holds inside a function; every compiler machine had printed its source, quotes and indentation included. And wolf prelude --json lists every name a program gets without an import, with the clause that defines it where one does.

v0.2.23 let a wolf kernel own its machine. Integers and pointers convert both ways — int as *T, *T as int, addr, with_addr, expose and is_null — and prefix *p reads and writes through a raw pointer on every tier, where each was unsupported. On the freestanding target an address no allocation owns is the platform's, so 0xb8000 as *u16 stores to 0xb8000. p.read_volatile() and p.write_volatile(v) are one access each, never elided, split or merged, and a pointee that is not one fixed-width integer or byte is E1307. #[repr(c, packed)] and #[repr(c, align(N))] lay a struct out as C does, and size_of, align_of and offset_of answer at compile time, held to gcc and clang in both directions. Module const, let and var compile as data in the image, evaluated at compile time so there is no initialization order to observe, and a var is raw-tier, read and written inside unsafe. #[section(".name")] places code and data, and extern "c" let names a symbol the link defines. An interrupt reaches an export fn through an assembly trampoline the manifest lists. And one wrong answer with no diagnostic is gone: an else-less if at a fallible function's tail handed its branch's error to the caller on every machine, unwarned, and it is the warned discard now, as the same if one line higher already was. Hosted programs that use none of this build byte-for-byte as before.

v0.2.22 built for a machine with nothing under it. wolf build --target x86_64-unknown-none --emit=obj emits a kernel object: no main shim, no runtime, one trap hook the program supplies, and everything that needs the hosted runtime or an allocator refused by name before any backend runs. Assembly sources listed in wolf.pkg are assembled and linked beside it. The C membrane opened both ways: an export fn is a C function, a hand-declared extern "c" fn is callable inside unsafe, a #[repr(c)] struct stored through a raw pointer has C's layout where the compiled tiers had packed it, and a *T may stand in a module-private signature or cross to C, never in a pub one. The attribute set is closed — an attribute wolf does not know, or does not implement yet, is E0817 by name where it used to compile and mean nothing, #[cfg(target = "…")] is evaluated rather than ignored, and an ABI string other than "c" is E0818. An integer cast keeps its value or traps overflow: native and release had reinterpreted the bits, so -1 as u64 printed -1, and the bits are spelled v as wrapping[u64] since. A for piece cannot outlive its region, where the compiled tiers printed it from freed bytes. And a program can seek, tell and read at an offset, with the process's standard streams on the descriptors C gives them, which is what a tail that does not read the whole file needs.

v0.2.21 let a match take a fallible value apart. match look(m, k) { none => -1, v => v } is the shape a reader reaches for when a function answers int ! {none}, and through v0.2.20 every tier of the compiler answered it unsupported while the interpreter ran it. [type.row.match] rules it now: an arm naming a tag of the scrutinee's row handles the error half, any other pattern handles the value half, the match must cover both, and a tag that is also a variant of the value's own type is E0816 rather than a guess. The same release closed three places where a program compiled and answered wrongly without a word. A ? inside a defer or an errdefer is refused, E0611 ([type.row.defer]): the checked machine dropped the error it raised and the native tiers overflowed the compiler's stack on it. A block's errdefer runs when the block's own value is an error, as a function's does, where the native tiers ran its defer alone. And a literal written at a binding with no annotation takes i32 there, so let big = 5000000000 is E0415 on every tier — a later int use used to widen it after the fact, which lupin never did. Release builds of one program by one compiler are byte-identical again.

v0.2.20 made arguments two-phase. Within one call a later argument may read what an earlier mut argument claims — grow(mut xs, xs.len) runs — and may not write, move, re-claim or lend it, a mut receiver included ([mem.tier0.excl.4]). Programs that wrote a claimed place inside a later argument had answered wrongly, some of them differently on different tiers, and are E1002 now. Two claims in one call reach two elements through an offset or a loop index the compiler can prove distinct, add2(mut xs[i], mut xs[i + 1]); a struct field's shorthand W { xs } is exactly its longhand and moves xs; an else handles its scrutinee's own row and nothing outside it ([type.row.else]); and a program that inherited a signal blocked hears the signals it arms.

v0.2.19 let two mut claims reach two elements of one list. swap(mut xs[0], mut xs[1]) is the shape a reader writes first, and through v0.2.18 every tier of the compiler refused it while the interpreter ran it: a claim on an element was a claim on the whole container. [mem.model.place.elem] already said which elements are distinct places for a move, and the rules that ask whether two claims meet — exclusivity, borrows and iteration — read it now too. Two integer-literal indices with different values hold two claims that cannot meet, whether the call is add2(mut xs[0], mut xs[1]), a grid's add2(mut g[0][1], mut g[1][0]), a field below each element, a loan of one element beside a store to the other, or a loop over one element that pushes into the other. A run-time index, the same literal twice and a path beside its own prefix are still one place, and still refused.

The same clause answers two questions a claim raised. An element and its container's length are different places under a claim as under a move, so bump(mut xs[0], xs.len) runs; and a container's header is read by len, count and is_empty and by nothing else, so each is allowed beside a moved or claimed element, while push, get, pop, a slice and a method's self read the whole container and stay refused beside a moved part. And that release fixed a defect v0.2.18 shipped: two mut fields off one mut parameter, add2(mut r.a, mut r.b), stopped its release tier with an internal compiler error once the call was inlined. The element pairs above reach the same shape, which is how it was found; such a call stays out of line now and prints the same bytes on every tier.

v0.2.18 was a point release, and it existed to refuse two programs v0.2.17 compiled and answered wrongly without a word. Both read a value that had already been moved away. In the first, an element is moved out of a list, a different element is stored into, and the moved one is read: v0.2.17 let the store to the sibling bring the moved element back, so the checked machine trapped at the read while the native tiers printed the moved list through the container — one buffer under two names. A store brings back only the element it names now. In the second, a function moves out of a mut parameter and returns without putting a value back, and the caller went on reading the buffer the callee had moved away and grown. A mut parameter is initialized at every return of the function now ([mem.tier0.mode.mut]), so storing a value back before each return keeps it legal, and both shapes are E1001 on every tier of the compiler. They were reading a moved value on every machine that ran them, so nothing that was right stops compiling.

The rule the first refusal stands on was new text at v0.2.18, [mem.model.place.elem]: which elements of a container are distinct places. Two integer-literal indices with different values are, and so are two literal Map keys and an element against its container's len — so move xs[0] leaves xs[1] readable, where every earlier release refused the read, and a diagnostic names xs[0] where it used to print xs[_]. A run-time index is still one place with every other index, with one exception, the shape a reader writes most: take a value out through m[k], work on it, store it back through the same k with k unwritten between, and it is yours again, for a key of any copyable type, a str included. And a store evaluates its place's own operands before its right-hand side — xs[idx()] = val() calls idx first — which the native tiers and the interpreter already did and the checked machine does now.

v0.2.17 gave back what v0.2.16 took away. v0.2.16 taught the compiler that a container element is a place a mut argument can claim, and the check that came with it refused programs every earlier release and the interpreter ran: call a function with mut lg in both arms of an if/else, call it again after the join, and the second call was refused, naming an arm that had already finished as though it were inside the call. It was the shape the exit convention prescribes, so it was everywhere — the web server and the coreutils this project is writing in wolf both carried workarounds for it rather than stay off that release. The rule was right and the walk was wrong: the check read block numbers as if they were program order, and a branch mints its join before its arms. It has read the call's own arguments since, the workarounds have come out, and the refusals the rule was written for are all still refused.

That release also fixed the defect v0.2.16 named on its way out. A function that takes a scope handle as a parameter and spawns into it never returned on the native tier: the task's captured state lived in the frame of a function that returns before the scope it spawned into joins. The runtime copies that state into the scope at the spawn now, and the program answers in under a second where it used to hang; a task spawned in a loop through such a handle is still refused by name rather than compiled. It survived every gate for a reason worth a reader's attention — the debug build of the compiler does not show it, and everything that runs on a developer's machine is a debug build.

Beside those two, v0.2.17 landed the rulings taken the week it was cut. An index store follows push: xs[i] = v and m[k] = v copy the value in and leave v yours, and xs[i] = take v is how you hand it over instead. A store works out its right-hand side before it decides where the value goes, so a right-hand side that grows the list it is storing into cannot leave the store aimed at memory the list has already left. And a diagnostic that points into a sibling module now says which file it is in, where the record used to give an offset into the wrong one without a word.

v0.2.16 was the second release in this project's history to change what a program you already wrote means. The first changed what a program printed; that one changed what the toolchain exits with. A program the compiler rejects exits 2, and one it refuses as outside what it implements exits 4, where both used to be 1 — and 1 is what a program that ran and returned an error out of main exits with, so a harness reading exit 1 could not tell a build failure from a program's own answer. [conf.exit] separates them. The same release fixed four programs v0.2.15 got wrong without a word — among them a move out of a copyable place that recorded no move at all, so reading the place afterwards printed the value that had just been moved away, on every tier. That one was found by a reader working through the book's chapter on ownership, which is the best argument this project has for writing the book at all.

v0.2.15 grew a surface, and it is the one the playground has the most to say about. A method call is defined as the function it is — xs.map(f) is list.map(xs, f), resolved in the receiver's own module — and the set of combinators is closed: ten functions, plus one builtin that is not a function at all. xs.par(f) maps over a list in parallel by opening a scope full of spawned tasks between its own parentheses, hands results back in input order, and its cost clause refuses to promise a speedup: a list shorter than the worker count, or an f cheaper than a spawn, makes it slower than the serial version, and both are conforming. The book's section on it measures its own word counter on eighteen cores and prints the row where parallelizing made the program slower first, which is the honest order. That release also made handing a non-Copy value to a builtin container copy the element in, where it used to leave the caller's handle alive inside the container with nothing spelled at the call; before it, v0.2.13 was three spellings the language could not write at all: the list literal, range[int] and range[char] as types, and error IoErrors = {none, parse, io} as a name for a row. What the tab can and cannot do with any of them is measured on the playground.

There is a second implementation. lupin is a reference interpreter written against the same specification, sharing no code with the compiler; the two are compared program by program through a published observation protocol. Neither is allowed to disagree with the other in silence. wolf --version names the lupin release the compiler was differentially tested against, and since v0.2.7 a CI job fetches that lupin by digest and runs the comparison on a runner, so the line cannot go stale somewhere only a laptop would notice. The playground on this site runs that interpreter, compiled to WebAssembly.

The performance goal the project set for itself, beating naive clang -O3 across a kernel suite, was declared met on 2026-08-24: three consecutive nightly runs of the benchmark ritual held the suite geometric mean above that baseline — 1.099, 1.066, 1.080 — with two documented exceptions renounced in writing. The suite once measured 0.191 of the same baseline, and the measurements that refuted the goal along the way still ship with the code, beside the protocol for reproducing all of them.

There are no macros; compile-time evaluation and reflection are the metaprogramming tier. Packages are declarative — reading a dependency graph never executes anything — and there is no hosted registry.

Getting it

git clone https://github.com/wolffe-lang/wolf-lang
cd wolf-lang
cargo build --release -p wolf_driver
./target/release/wolf run corpus/hello.lu

That prints hello, wolf. To put the toolchain on your path instead, cargo xtask install copies both artifacts to ~/.local/bin: the wolf binary and the runtime archive it links programs against. The binary cannot link anything without the archive beside it, which is why the install step exists. Neither step puts a standard library beside the binary, which the published archives do: a toolchain built from source answers use std.… only when --std-root, WOLF_STD or a wolf.pkg std dependency names one.

Or let a package manager do it (a Homebrew tap on macOS and linux, five packages on the AUR, all of them the project's own), or take a published archive instead of building one: installing wolf has both, and the per-platform story, including Windows, where the compiler builds and runs native programs once Visual Studio Build Tools is installed beside it — tasks, channels and signals included, with five named refusals left and each of the five measured rather than described. That page quotes what a machine without Build Tools sees, and every Windows sentence on it is asserted against the published archive by a job on a Windows runner.

The toolchain pin is Rust 1.97.1, recorded in rust-toolchain.toml, and rustup honors it on the first cargo invocation. There are no build scripts anywhere in the wolf ecosystem. CI builds and tests linux x86-64, macOS aarch64 and windows x86-64 on every change, which are also the three hosts native code generation serves; linux aarch64 is tier-1 in the matrix with no compiled tier behind it yet. At v0.2.29 its archive is built and gated on an arm runner when the release is tagged, and the gate asserts what that archive serves: wolf run and wolf run --release each refuse the host by name with exit code 2, and the checked tier runs the program (wolf conform-run --checked). So on linux aarch64 the toolchain checks and runs programs on the checked machine and compiles none (wolf-lang#614). Tagged builds upload an archive per tier-1 host to the releases page — four at v0.2.29, and a job in this site's own CI asks every one of those four URLs for a 200 on every change to this site. The packaging around them is a stub, so building from source is the path that is known to work.

Reading

The Wolf Book
The language text. Every sample in it is executed by CI against a pinned toolchain.
The playground
The reference interpreter in your browser, with corpus programs to load.
Diagnostics and warnings
Both catalogues, generated from the compiler's own registry.
The specification
Normative. Both implementations are readings of it.
The changelogs
What each release changed, per project, rendered from the same pinned checkouts this site is built from.
The source
All of it, including the issue tracker where the open problems and the known divergences live.

Bruckner revised the same symphony for twenty years and left several versions behind. This is version 0.2.29.