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.