Skip to content
Wantzel Studio
Wantzel documentation

Design

Why the language is the way it is, and how the compiler works underneath it.

What the language is belongs in language.md, which is binding. This page argues why: the yardstick every change is weighed against, the choices that follow from it, and — in the second half — how the compiler itself is built.

The case for the compiler, stated plainly by the maintainer: **it is strict, fast and verbose, has no dependencies, and gives errors an agent can act on.** Everything below explains that sentence. It does not argue it against other languages — no benchmarks, no essays — because the sentence is the argument.

The yardstick

**A compact, fast, strict language, fit to be used in iterations by AI when agents write

code.**

wordwhat it decides
compactthe whole specification is readable in one sitting; an agent does not spend its attention re-reading it
fasta build is short enough to run on every change, which is what makes iteration possible at all
strictwhat the compiler refuses is what nobody has to review by hand
in iterationsthe unit of work is a cycle that runs many times, and every signal is read by a machine before a person sees it

Weigh a proposal against those four. Where it does not decide, say so instead of guessing — a yardstick used to justify a guess is worse than none, because the guess then arrives with a citation.

The main choices, and their one-line reason

choicereason
No pointers, no heap, no garbage collectoran index into an array cannot dangle, is bounds-checked at every use, and survives being written to disk — every linked structure normally built with pointers has been built this way instead
Strict typing, no implicit conversionswhat the compiler refuses is a review question nobody has to ask; a wrong call is a compile error, not a silent bug that ships
One way per concepta second way is a second thing to learn, review, and get wrong; bool and no boolean, import for the library and include for a file and no module system, errors and no warnings
No dependenciesthe standard library is part of the compiler, changes with it, and travels inside the compiler file; there is no package manager, so there is no mechanism by which a dependency could be added
No optimiserthe generated code stays recognisable as the source you reviewed, and it is most of why compiling takes milliseconds
No warnings, only errorsa grey zone between "wrong" and "fine, but" does not survive contact with time — it piles up and gets ignored; exit 0 has to mean correct
Schemas are compiled, not configureda schema or tools block is the parser, the writer and the JSON Schema — nothing to keep in sync, and a wrong field is a compile error instead of a run-time surprise

tools deserves a separate mention. It is the language construct that turns a group of routines into an MCP tool table, argument parsing, dispatch, REST route and OpenAPI document — all from one declaration. See language.md §7b. It solves problem 3 below in its sharpest form: a contract that would otherwise be five things that can disagree becomes one thing that cannot.

Why a generator makes this stricter, not more permissive

A hundred recorded mistakes in generated Wantzel put library misuse (wrong routine, wrong argument order) at 30 of 100 — bigger than syntax and namespace mistakes combined. A permissive language answers a wrong call by doing something; a strict one refuses. For a generator running in a loop, a refusal is a message the next iteration can act on; a silent wrong answer is a bug that ships unnoticed. That asymmetry is why the language stays narrow rather than growing convenience features.

Three problems, answered once each

problemwhat happens without an answerwhat Wantzel does
Dependenciesa tree you did not plant, and "will this still build in five years" has no answerzero dependencies as an invariant; one static binary, nothing to install on the target
Stacks that grewa build becomes compiler + bundler + transpiler + package manager + linter, each with its own way of failingone binary, one pass, no AST, no intermediate representation; hello.wz compiles in ~3 ms
Errors that surface too latea mistake a compiler could catch instead ships to a user or a log nobody readsdeclarations mandatory, signatures checked, every array index and division checked at run time with file and line

Where the idea does not hold

No ecosystem — every library you need, you write. No debugger, no profiler, no IDE support beyond printf and tests. Almost nobody knows this language. No proven memory safety, only "no pointers" plus a test suite. Every compiler bug is your bug. A small language says no often, including to reasonable requests. That is a real price, and the honest answer for a lot of other work is to use something else.


How the compiler works

One pass, no AST, no intermediate representation: the compiler reads source once and emits machine code as it goes, backpatching forward jumps where they stand. It compiles itself in milliseconds.

One file: the standard library travels in the compiler

wantzel is one file. The standard library is part of it: import io; compiles the io of this compiler, read from the compiler's own file, and from nowhere else — no library directory on disk, no environment variable, no flag, no search order. The same source and the same compiler file give the same executable wherever either of them sits, and two compilers whose --version lines agree compile every import to the same text.

Why nothing overrides it. A copy of a module on disk that wins over the built-in one is a second place an import can be answered from, and then the question "which copy did this build use?" has no answer in the output. Experimenting with a module is still one line: copy it and include the copy as a file (include "./tls.wz";) — a file is always a file.

Why it is not precompiled. The library is kept as source, not as compiled tables: the compiler is one pass with no intermediate form, so "precompiled" would mean a snapshot of every symbol table, in a second format that has to change with every table. Measured, it would save at most 7% of a large program's compile time; parsing the library is not where the time goes.

Unused routines never reach the executable

Every module a program imports compiles in full, but only the routines it actually calls end up in the file: after parsing, the compiler builds the call graph from its own relocations and drops everything unreachable from the main block, before writing the ELF image.

How. A routine's code is one contiguous range, and every reference to it (a call, a jump to data, a jump to bss) is a relocation recorded at the offset it sits at. A relocation's kind already says whether it is a call to a routine (as opposed to a data or bss reference), so the call graph does not need a separate pass over the source: a routine's own relocations, read back, are exactly the routines it calls. The main block and the entry code are the two roots; everything not reachable from them is dead. Live routines are then copied down over the gaps the dead ones leave, and every surviving relocation is shifted by however much its own bytes moved.

Always on, no flag, because the output has to stay deterministic and a flag would be a second code path nothing exercises by default. Skipped under --debug: the debug sidecar's addresses are written into it as the code is generated, line by line, long before this pass knows what will move where. Rewriting the sidecar afterwards would be a second serialisation of every address; instead a --debug build keeps every routine, so its addresses are exactly the ones the sidecar recorded. It is therefore larger than a plain build, and not byte-identical to it.

Measured: hello.wz with an unused import tls; — nothing from tls is called, so none of it survives — drops from 348 KB to 56 KB. A program with most of its imported modules genuinely in use shrinks by a smaller fraction: examples/mcpfiles.wz goes from 847 KB with 0.4.0 to 707 KB; examples/serve.wz, which uses less of what it imports, from 500 KB to 257 KB.

One syscall layer

Library code names no operating-system function. lib/io.wz writes a file with one line:

return sys3(SYS.write, fd, a, n);

and sys3 emits the SYSCALL instruction, literally — the kernel, nothing in between. The call number is an ordinary constant in lib/io.wz, so the compiler knows no syscall by name and anything the kernel offers is reachable from source.

There is no import table: the ELF this compiler writes has no dynamic section, no PT_INTERP, nothing for ld.so to do. A shared library that was not there at build time cannot be called — calling it as a process (lib/proc.wz, fork+execve, ~300 µs) is the answer that needs no compiler change. Events arrive on a descriptor and the program reads them in its own loop; nothing ever calls into the program, so the language needs no function pointer and has none.

Memory: an index is the pointer

No heap, no pointers, no garbage collector. Instead:

  1. All memory is an array with an upper bound. A global array costs nothing until touched: pages are 4 KB each. Local arrays live on the stack and are zeroed.
  2. An index replaces a pointer. It cannot dangle, is bounds-checked at every use, and still means the same thing after being written to disk and read back.
  3. Anything larger than the program wants to pin down comes through mmap and view, which turns an address and a length into an ordinary, bounds-checked array — the capacity is disk or virtual memory, not RAM.
situationpattern
a growable listarray + counter, -1 past the bound
text of unknown lengthone arena array + (offset, length) slices
linked structures (list, tree, graph)index fields, -1 for none, a free list for reuse
key → valueopen-addressed hash table on two parallel arrays
bigger than RAM, or must survive a restartmmap on a file; the array is the file format

The one thing an index cannot replace is the address of a routine — addr(myroutine) is rejected, because that is a function pointer, and nothing checks one.

Debug info: the .wzdbg sidecar

--debug writes the executable -- with every routine kept, since unused routines are only dropped from a plain build (see above) -- plus a text sidecar, <output>.wzdbg, that a debugger reads to map addresses to source and memory to variables. Own format, not DWARF: the only reader is a debugger for this compiler, and DWARF needs a library on both ends that this project does not carry.

recordfieldsholds
wzdbgversionformat version (this page describes 1)
target / base / text / data / bss / entryaddress(+length)where each image section lives — addresses in the file are process addresses directly, no relocation
fileid, pathsource file table; a path starting < has no source (generated runtime code)
lineaddress, file, line, kindmaps code to source; kind is s (statement start — the only safe breakpoint), p (prologue), or e (implicit return)
func / mainname, start, end, file, line, framesize, result typeone routine's code range and frame size, followed by its param/local records
param / local / globalname, location, type, array flag, boundswhere a variable lives and how to read it
record / fieldname, size, fieldsa record type's layout, for typed variables of that type

Frames are found through the rbp chain: [rbp] is the caller's saved rbp, [rbp+8] the return address. At a statement-start (s) line, rsp = rbp - framesize and every variable is in memory — nothing is kept in a register across statements, so that is always a safe stopping point.

Calling convention

whathow
up to 10 parametersrdi, rsi, rdx, rcx, r8, r9, r12, r13, r14, r15 — the first six are the System V order, so the syscall builtins share the code
array parametertwo registers: base pointer, then length
resultrax (a real result is its IEEE-754 bit pattern in rax)
frame set-uppush rbp; mov rbp, rsp; sub rsp, framesize, locals zeroed

Types, in memory

typesizelayout
int8two's-complement, little-endian
char1one byte
bool10 or 1
real8IEEE-754 double
str8address of the first character; length is the 8 bytes before it; NUL-terminated; 0 is the empty string
recordsum of fieldsdeclaration order; char/bool fields 1-aligned, everything else 8-aligned, size rounded up to a multiple of 8

What already works, beyond the standard library

sysN emits the raw syscall instruction and the compiler knows no call by name, so anything the kernel offers is reachable without a compiler change — including things once assumed impossible:

capabilitystatus
fork, copy-on-write, exit codes and signals, non-blocking reapworks, measured
shared memory and locking between processesworks, measured
several worker processes behind one listening socketworks (http.serve)
durable storage with crash recoveryworks (lib/store.wz: append-only log + snapshot)
multiple writers to one storenot done — a deliberate choice, not a limit; the primitives (MAP_SHARED, flock) are already present
TLSno — handled by a process in front; the one honest exception to "no dependencies"
threadsno, and stays no — separate processes do the same job without shared state

Measurements the design rests on

Same machine (Intel Core Ultra 7 258V, 6 cores, 16 GB), same job where stated: counting a pattern in a 2 GB file, every implementation giving the identical answer.

measurevaluewhat it means
compile speeda 189-line program: 3 ms. The compiler itself, 6,495 lines: ~9 ms (median of ten, 721,666 lines/s)one pass, no optimiser — compiling is not a step you wait for
binary size22,786 bytes, static, vs. 2,040,611 for the same program in Goa factor of 89.6 with no runtime hidden on either side
run time0.72 s (mmap + scan) vs. 0.82 s for the same approach in Gono optimiser does not cost speed for this class of program; the gap against Go's read()-based version (1.17 s) is larger but is an I/O-strategy difference, not code generation
generated-code mistakes30 of 100 recorded errors are library misuse, more than syntax and namespace combinedthe strictness and the whole-program examples in writing-wantzel.md answer this category specifically

These numbers are narrow by design: one machine, one class of program (a tight byte loop, exactly where an optimiser has least to find), and the run-time lead is mostly mmap, not code generation. They are not a claim against other languages in general — only evidence for the specific trade made above.

No pointers: what was tried before trusting it

Nine linked structures were built without pointers — list, tree, graph with cycles, a heterogeneous AST, a free list, zero-copy views, arbitrary OS memory as an array — and all nine work, with two actively better off: a cyclic graph needs no ownership tracking (nothing to free), and an index-based structure written to disk and read back has every link still valid with no fix-up pass. The one place this does not extend is a function address, and the language has none.


Influences

Two languages shaped this one. Turbo Pascal, for what a short compile-run loop feels like, and for syntax that reads close to pseudocode. Go, for what a deployable artifact should be: a static binary, fast builds, a capable standard library, a deliberately small language. Wantzel pushes both further than either did: dependencies are not merely few but structurally impossible, and an API contract is a language construct rather than a generator run as a build step.

What it comes down to

One binary you can copy instead of a stack you have to install. A compiler that answers in milliseconds and says plainly what cannot work. One declaration where there were five descriptions that could disagree. The language had to be new to get there; that was a consequence, not the goal.

Read next