Skip to content
Wantzel Studio
Wantzel documentation

Writing Wantzel

Common mistakes when writing Wantzel, and the idioms that avoid them.

For anyone writing Wantzel source, especially an AI agent generating it — most mistakes below produce a compiler message, and this page maps each one back to its fix. Nearly every mistake comes from one of four places: Pascal or C habits the language does not have, the shared case-insensitive namespace, a wrong assumption about a library routine, or a test expectation that was never checked. The specification is language.md; where the two differ, the specification wins and the difference is a finding.

Pitfalls

Each row: what's wrong, what's right, the message that identifies it. Found a new one? Add it under the heading it belongs to.

Namespace (one shared, case-insensitive namespace)

WrongRightMessage
const STORE.SET = 1; next to procedure store.setGive constants a name no routine has: STORE.OPSET, CURVE.NDAYS, HTTP.GETname already used by a variable or constant / duplicate global declaration
Parameter N next to global n; type Out next to variable outOne name per concept; local names that don't collide with globals (cnt, res)name already used by a type / duplicate declaration
A builtin as a name: procedure f(len: int), var len: intUse n, count, nbytes instead. Builtins: len addr view scan ord chr real trunc round sqrt pack32 unpack32 slen schar sadr band bor bxor bnot shl shr argc argch halt sys1..sys6that name is built in
tools as a namespace (tools.list)tool.list, tool.run — tools is a keywordvariable name expected
A type name is also an ordinary identifier: schema Point and var point: ... collidevar p: Point—
Assuming mcp.buf and mcp.Buf differ, or that a.b scopes to a moduleDotted names are cosmetic; there is no module scope—
Two files each claiming a run.* prefixGrep for a prefix across everything the program can include before claiming it; rename the newer fileduplicate global declaration, only where both files are included together
if argc < 2 (builtin without brackets)argc() — every builtin used as a value needs its ()missing ( after builtin

A prefix is claimed globally, so two files that each build a run.* namespace collide on the first variable of the second one — and only in a program that includes both, so each file compiles alone and the pair does not.

Types

WrongRightMessage
x / 2 on intsx div 2 — / is for real only/ divides reals; use div for integers
r := i with r: real, i: intr := real(i); back with trunc(r) (toward zero) or round(r)type error in assignment: expected real, found int
ord(b) on a boolif b then x := 1 else x := 0type error in ord: expected char
if n then (int as condition)if n <> 0 then — does not exist otherwise—
s = "abc"json.eq(b, at, last, "abc") (last exclusive), hash.same, or your own loopstrings cannot be compared with = or <>
Treating str as a general text typestr is only a literal's type. Text as a value is array of char plus a length; use array of char in new code — it accepts a literal, an array, a slice and a view—
Expecting a 32-bit real, byte, boolean, integerOnly int char bool real str and array exist—
writeln/writeio.puts(fd, ".."), io.putn(fd, v) for an int; a real via json.putreal into a bufferundeclared identifier on the whole line — check the outermost call first
1. or .5 as literals1.0, 0.5. A negative real literal in a const is fine (NEG = -2.5)—

A smoke test that prints nothing compiles fine and proves nothing about output.

Arrays, slices, view, records

WrongRightMessage
A slice as a value: x := a[1..3]Slices and view(addr, n) only where an array of T argument goesa slice can only be passed as an array argument
addr(s[0]) on an empty slice or viewGuard with if len(s) > 0 first; for an empty range pass addr(buf[at]) of the source bufferruntime array index out of range
Treating slices as exclusiveSlices are inclusive: a[lo..hi] has hi - lo + 1 elements, a[3..2] is empty. A (at, upto) exclusive pair becomes b[at..upto - 1]—
A record as a parameter: function f(p: Point)procedure f(p: array of Point), use p[0]; pass the record variable, items[i..i], or view(address, 1)this parameter needs an array / type expected
Copying a record field by field in a loopa := b copies the whole record (same type required)record types differ in assignment otherwise
Wanting array of array, a record in a const, or in an expressionNone of these exist—
An empty array of T literalPass an empty slice: a[0..-1] (or a[i..i-1])—

Other facts worth knowing: a builtin or keyword name is allowed inside a dotted name (addr.x, co.for, app.type are ordinary identifiers). len(a) works on array names and array of T parameters, not on str (that's slen) or a constant. array[lo..hi] indexing starts at lo, which may be nonzero; all arrays are fixed — "growing" means keeping a counter (language.md §10b). Large arrays belong at global scope; a local array of megabytes sits on the ~8 MB stack and crashes.

Routines

WrongRightMessage
store.set(...) as a statement, throwing away the resultok := store.set(...); or make it a procedurethe value of this function call is not used
A function missing return on some path (including after while true)Every path ends in returnruntime function without return
A procedure with return valuereturn; with no value onlyerror
Wanting a var parameterResults write back through an array of T argument (a view onto caller storage) or a global — there is no var parameter—
More than ten argument slots (an array counts as two)Count slots before writing the signature; pass a slice as the write target, or make repeated arguments state (q.clear, q.setX(...), then a no-arg call)wrong number of arguments / too many arguments in call
A missing callback: library declares procedure app.request; forward;, application never defines itDefine it; signature must match exactlyforward declared routine is never defined / parameter type differs from the forward declaration
Nested routines, function pointers, default arguments, overloadingNone exist—
return in the main programhalt(code) instead—

Counting argument slots is the second most common mistake overall.

Syntax

WrongRightMessage
; before elseif c then a else b;unexpected else (no ';' may precede it)
var after begin, or any block out of orderHeader ;, then const/var blocks, then begin ... end;missing begin in routine body
Anything after end.A program ends end. (period); a routine ends end;text after the end of the program
{ } or (* *) comments// only, to end of line. A { outside a string is refused—
A block comment spanning a JSON literal like {"a":1}Write multi-line comments as several // lines (block comments were removed 15-09-2026: they ended at the first })—
'\xHH' with the wrong digit countExactly two hex digits: '\x1b', "caf\xc3\xa9". "\x41BC" is three characters, unlike Cunknown escape sequence
$FF, 1_0000xFF only — no Pascal $, no digit separators—

Valid escapes: '\n' '\t' '\r' '\0' '\\' '\'', "\"", '\xHH'.

An else binds to the nearest unclosed if. In an if-chain, an arm whose body is itself an if without its own else swallows everything after it, silently — the result is valid code that means something else:

if k = 1 then
  if v > 0 then out := 1        // still open...
else if k = 2 then out := 2     // ...so this else belongs to the INNER if

Here k = 2 and k = 3 never run, and k = 1 with v <= 0 runs the last arm. Right: wrap the inner if in begin ... end. Check for this after rewriting a case into a chain — every shape needs its output checked. When a chain is written by a generator, each arm must close bare with only the last carrying ;, or the emitted code trips this same rule.

Imports and includes

**The rule in one sentence: import takes a bare module name — no quotes, no .wz — and reads that module from the compiler; include takes a file name in quotes, with .wz, relative to the file it stands in.** The two never overlap: an import never reads a file, an include never reads the library. wantzel --lib lists the modules.

WrongRight
include "io.wz"; for the libraryimport io; — include is always a file, so without an io.wz next to your file this is refused: io.wz is not a file next to this one; the library module is imported: import io;
import "io"; or import "io.wz";import io; — import takes a module name, without quotes or .wz: import io;
import io.wz;import io; — the same message
include io;include "model.wz"; for your own file, import io; for the library — include takes a file name in quotes: include "io.wz"; -- for the library module write: import io;
import "model.wz"; for your own fileinclude "model.wz"; — import takes a module name, without quotes or .wz; a file of your own is included: include "model.wz";
import jsn;import json; — no library module 'jsn' in this compiler; wantzel --lib lists them -- did you mean: import json;
include "lib/json.wz"; or include "../../lib/io.wz";import json; — a path into a lib/ directory is a file on disk, not the library; the compiler says the library module is imported: import json; when it is not there
io.puts with no importimport io; at the top — undeclared identifier: io.puts is declared in the library module io; add: import io;
A file of your own named after a module (store.wz beside your program, next to the module store)Give it a name no module has (orders.wz). import store; still reads the library and include "store.wz"; still reads your file, but every reader — and every tool that opens "store.wz" — has to know which one is meant. This repository refuses such names outside lib/ for that reason
A copy of a module in a lib/ directory, expecting the compiler to use itNothing overrides the library. To try a change, copy the module next to your program and include the copy as a file: include "./tls.wz"; in place of import tls;

A program with two imports and a file of its own, and that file in turn naming another file of the project — every include is relative to the file it stands in:

app/
  main.wz           import io;  import json;  include "orders.wz";
  orders.wz         include "util/money.wz";
  util/money.wz     no includes
// main.wz
import io;
import json;
include "orders.wz";            // app/orders.wz, next to this file

begin
  orders.show;
end.
// orders.wz
include "util/money.wz";        // app/util/money.wz: relative to orders.wz, not to main.wz

procedure orders.show;
begin
  io.puts(STDOUT, "total: ");
  io.putn(STDOUT, money.euros(1250));
  io.puts(STDOUT, " euro\n");
end;
// util/money.wz
function money.euros(cents: int): int;
begin
  return cents div 100;
end;

orders.wz uses io.* without importing it: main.wz imported io first, and an import is textual, so everything read after it sees it. Import in the file that uses a module when that file is also compiled on its own — a test of orders.wz, for instance. Two imports of the same module, or two includes of the same file, read it once.

Library (wrong assumptions)

WrongRight
sha256.hex(dst, src) on 16 bytesIt expects exactly 32; use your own hex routine (oauth.hex16) for 16
io.puts(fd, s: str) on a bufferio.puts takes only a literal; use io.out(fd, addr(b[0]), n) for a buffer
n := io.out(...)io.out is a procedure, not a function (a procedure has no value); call io.out(fd, addr(b[0]), n); as a statement, or sys3(SYS.write, ...) directly if you need the count
Assuming io.push/io.pushnum return a byte countThey return the new position
json.eq(b, i, i+16, "\"total_matched\"")The literal's length including its quotes is 15, so the end is i + 15 — count it (printf '%s' '"..."' | wc -c), don't estimate. Fails silently: no error, the branch just never runs
json.unescape(...) without checking len(dst)It does not check for you — guard the length yourself
Hand-computing a record size (ISIZE = 16 + 8 + ...)Measure it: addr(items[1]) - addr(items[0]), or addr(last_field) - addr(first_field) + size
CURVE.DAYSThe constants are CURVE.NDAYS, CURVE.NHOURS, CURVE.DAYLEN — CURVE.DAYS collides with curve.days
Putting unescaped bytes into a text field of an output schemaOut.write places a text view between quotes raw — fill it via json.escslice (escaped text) or json.putraw (only for already-valid JSON)
Forgetting import io;The compiler names the module: undeclared identifier: io.puts is declared in the library module io; add: import io;. A bare message means the name is in no module — check spelling
Assuming a str variable behaves like array of charOnly a literal converts implicitly; copy first: k := io.push(buf, 0, s); ... buf[0..k-1]
Calling parse on a schema and ignoring what it droppedAn undeclared key is skipped and counted in json.ignoredn, b[json.ignored0..json.ignored1) gives the first one. Report it — lib/tools.wz does (_meta.ignoredFields, X-Ignored-Field/X-Ignored-Count). A key the schema does declare is still strict: a bad value gives -1, json.badkey0..json.badkey1 names it
Copying a value out of kv.find/kv.first rawThe value keeps its quotes ("dryer" not dryer) and every comparison after fails silently. Use kv.text(b, dst, 0) — strips quotes, decodes escapes, returns -1 if not a string
http.header("Accept") expecting case sensitivityFixed 15-09-2026: comparison is case-insensitive both sides. On an older compiler, use lower case
Calling kv.isobject/kv.count/kv.match and assuming the cursor movesSince 15-09-2026 those three only ask a question and restore the cursor; kv.scanobj/pair/first/next/find still position it — save what you need first
arg.strings(arg.buf, 0, arg.n) after arg.take on a list-or-string argumentarg.take unwraps a bare string, so arg.buf then looks like plain text. Run arg.strings on arg.raw instead — it handles a list, a string containing a list, and a bare string
time.parseiso — checking the return value for failureFailure is reported through time.ok, not the return: -1 is ambiguous with one second before the epoch. See library.md for accepted shapes

Two contracts for "it does not fit." The lib/ appenders (io.push, io.pushnum, json.putraw, json.putstr, json.escslice, json.putreal) truncate at len(dst) and hand back a usable position — a shortened message is still a message. A generated <Schema>.write, json.putslice and json.putb refuse with -1 — a shortened JSON object is a syntax error, not a smaller object. Always test Out.write's result; never assume a chain of io.push wrote everything without comparing positions.

Names collide across the constant/routine namespace, case-insensitively: STORE.SET and store.set are the same name. Since 15-09-2026 the compiler says so (`duplicate declaration; names are case-insensitive, so STORE.SET is the same name as store.set`) — pick a prefix that isn't already a routine's rather than a spelling that happens to slip past.

The module tools imports http (for tool.rest), so a program importing it must define procedure app.request, or get forward declared routine is never defined on the last line. A stdio-only MCP server imports toolsmcp instead — the MCP half without HTTP, needing no app.request.

Working copies (one global record per entity)

  • Never pass a field of the working copy into a routine that reloads that working copy. uc.winner(dd.k.curve_ref) reloads dd.k while scanning: the slice ends up pointing at changed content, with no error and a partly-correct result. Copy the value into your own buffer first.
  • A profile.load(other) mid-edit overwrites your changes — directly, or indirectly through a scan helper (ctx.default, ctx.owns, ctx.selectall) that touches dd.*. Rule: new scan/search helpers use their own copy, never dd.*; a handler loads what it writes out itself rather than trusting what a helper left behind; handle other rows first, then load/change/save your own.

Syscalls and memory

WrongRight
sys3(SYS.open, addr(path[0]), flags, mode) without a terminatorThe path must be null-terminated: path[n] := chr(0)
Combining open flags with + or orbor(bor(O_WRONLY, O_CREAT), O_TRUNC)
Expecting peek8/peekrealThey don't exist — peek(a) reads one byte. Read an int or real via a typed one-element view: f(view(a + off, 1))
Ignoring mmap's returnNegative means failure (-errno); check p < 0
Treating addresses as needing alignmentOrdinary ints; view(p + offset, n) reads through them — a char view is bytes, an int view is 8 bytes/element

Everything is static: no new, no recursive structures through pointers — use array indices instead (language.md §10b).

A wrong result that looks like a choice

Most pitfalls above announce themselves: a compile error, a crash, a wrong number. The expensive ones don't — they produce output that's plausible, and a plausible result invites tuning instead of debugging.

  • **If you change an input and the output doesn't change, you're tuning the wrong variable.** Two rounds of this means stop adjusting and start measuring.
  • Measure the data, not the rendering. Print the actual values — maxima, counts, ranges — rather than judging the picture.
  • Know the healthy range before you look. State the expected bound first, then measure.
  • Suspect off-by-a-factor before off-by-a-pixel. A value at 1/8th or 1/64th of what it should be points at an early exit or a mismatched divisor, not a slightly-off formula.

A loop that counts and a divisor that disagree is the classic source:

// WRONG: break leaves the inner loop, so hits can never exceed n --
// yet the divisor is still n * n. Everything comes out at 1/n of its true value.
for i := 0 to n - 1 do
  for j := 0 to n - 1 do
    if inside(i, j) then begin hits := hits + 1; break; end;
value := hits * 255 div (n * n);

The result was usable, just uniformly wrong — nothing ever flagged it. Remove the early exit.

Spec versus compiler (findings)

  • and/or are short-circuit (the spec said otherwise at first; corrected).
  • Identifiers are case-insensitive (the spec first said case-sensitive; corrected).
  • After every routine, nloc must be 0; a compiler bug here produced stale locals in constant expressions (fixed).

Patterns

Everything below compiles with the current wantzel. Take the nearest pattern and adapt it; don't invent a variant of something listed here.

The argument order of the routines you use most

30 of 100 recorded mistakes are library use — a wrong name, or arguments in the wrong order. Two shapes cover almost all of it.

Appenders — (buffer, at, what), returning the new position:

io.push(b, at, s)a string literal, returns the new at
io.pushnum(b, at, v)a number
json.putstr(dst, at, s)a literal, quoted and escaped
json.putslice(dst, at, src, from, upto)bytes from another buffer, quoted and escaped
json.putraw(dst, at, src, from, upto)bytes verbatim, no quotes
json.putreal(dst, at, v)a real
json.putb(dst, at, c)one byte
kv.text(b, dst, at)the cursor's value, decoded — source first, then dst
n := io.push(buf, 0, "{\"name\":");
n := json.putstr(buf, n, "probe");
n := io.push(buf, n, "}");

Readers — (buffer, at, upto), upto exclusive:

json.ws(b, at, last)skip whitespace, first non-blank position
json.skip(b, at, last)past one whole value
json.string(b, at, last)past a string; text is b[json.sat..json.send)
json.eq(b, at, last, s)exact match — count the literal including its quotes
kv.find(b, at, upto, key)key as array of char, not str
kv.first(b, at, upto) / kv.next(b, cur, upto)walk an object

What each routine gives you on failure

kindon failurewhy
appenders in lib/ (io.push, json.put*)truncate at len(dst), return a usable positiona shortened message is still a message
generated <Schema>.write, json.putslice, json.putb-1, nothing usable writtenhalf a JSON object is a syntax error
parsers (json.skip, json.string, kv.pair)-1no position to continue from
questions (kv.find, json.eq, fs.stat)false
generated <Schema>.parse-1, offending key in json.badkey0..json.badkey1a missing/bad field; an unknown key is not a failure — see json.ignoredn
kv.text-1not a string, or doesn't fit

Always test the result of write and anything returning -1.

A buffer and its length, together

var
  buf: array[0..1023] of char;
  buf_n: int;                      // how much of buf is filled

buf_n := io.push(buf, 0, "text");
if buf_n >= len(buf) then ...      // it truncated: the position hit the end

Pass the filled part as a slice, use(buf[0..buf_n-1]) — passing buf hands over 1024 bytes of mostly zero, and the receiver can't tell where the text stops.

A whole command-line tool

The usage line, the argument copied out with its length guarded, the failing open reported, and a distinct exit code for "you used it wrong" (2) versus "it did not work" (1) — nothing here is spare.

import io;

var
  path: array[0..1023] of char;
  buf: array[0..65535] of char;
  fd, n, i, lines, bytes: int;

begin
  if argc() < 2 then
  begin
    io.puts(STDERR, "usage: wc <file>\n");
    halt(2);
  end;
  i := 0;
  while argch(1, i) <> chr(0) do
  begin
    if i >= len(path) - 1 then begin io.puts(STDERR, "wc: the path is too long\n"); halt(2); end;
    path[i] := argch(1, i);
    i := i + 1;
  end;
  path[i] := chr(0);                    // a syscall wants it NUL-terminated

  fd := sys3(SYS.open, addr(path[0]), O_RDONLY, 0);
  if fd < 0 then begin io.puts(STDERR, "wc: cannot open that file\n"); halt(1); end;

  lines := 0; bytes := 0;
  while true do
  begin
    n := io.read(fd, addr(buf[0]), len(buf));
    if n <= 0 then break;
    bytes := bytes + n;
    for i := 0 to n - 1 do
      if buf[i] = chr(10) then lines := lines + 1;
  end;
  sys1(SYS.close, fd);

  io.putn(STDOUT, lines); io.puts(STDOUT, " lines, ");
  io.putn(STDOUT, bytes); io.puts(STDOUT, " bytes\n");
end.

A whole MCP server

Do not build tools/list or a call reply by hand with mcp.add. The tools block (language.md §7b) generates the tool table, JSON Schemas, argument parsing, dispatch and result writers from one declared line per tool; only the handler is left to write. Two lines are easy to leave out and neither is optional:

import json;

type AddArgs = schema
  a: int "the left operand";
  b: int "the right operand";
end;

type AddResult = schema
  sum: int;
end;

tools
  add(AddArgs): AddResult "Add two whole numbers." readonly idempotent;
end;

import toolsmcp;                        // AFTER the tools block: it reads it

function tool.add(a: array of AddArgs; r: array of AddResult): int;
begin
  r[0].sum := a[0].a + a[0].b;
  return 0;
end;

begin
  mcp.stdio;
end.

That answers initialize, tools/list and tools/call on stdin; mcp.name/mcp.version are yours to set. To also serve the same table over HTTP, import tools (adds tool.rest, brings in http) and mcphttp, define procedure app.request, and call http.serve(port, 1); examples/mcptools.wz does both.

Program skeleton with an import

import io;                          // the library: a module name, no quotes, no path
const MAX = 10;
var
  i, n: int;
  buf: array[0..1023] of char;
procedure show(s: str; v: int);
begin
  io.puts(STDOUT, s); io.putn(STDOUT, v); io.puts(STDOUT, "\n");
end;
begin
  n := 0;
  for i := 1 to MAX do n := n + i;
  show("sum = ", n);
end.

Command-line arguments

var port, i: int; c: char; dir: array[0..255] of char; dirn: int;
...
  port := 0; i := 0;
  while true do
  begin
    c := argch(1, i);
    if c = chr(0) then break;
    port := port * 10 + (ord(c) - 48);
    i := i + 1;
  end;
  dirn := 0;
  while argch(2, dirn) <> chr(0) do begin dir[dirn] := argch(2, dirn); dirn := dirn + 1; end;
  store.opendir(dir[0..dirn - 1]) ...

Building text and writing it out

n := io.push(buf, 0, "item");         // returns the new position
n := io.pushnum(buf, n, 42);
buf[n] := chr(0);                     // only needed for syscalls (paths)
io.out(STDOUT, addr(buf[0]), n);      // buffer; io.puts is for literals only

Comparing text: json.eq(buf, at, upto, "literal") (upto exclusive). Passing text along: f(buf[0..n-1]); inside f(s: array of char), len(s) is the length.

Passing and storing records

type Item = record id: int; value: real; name: array[0..31] of char; name_n: int; end;
var it: Item; items: array[0..99] of Item; nitems: int;
procedure fill(dst: array of Item; id: int);   // dst[0] is "the" record
begin dst[0].id := id; dst[0].value := 1.5; end;
...
  fill(it, 1);                 // a record variable = a view of one element
  fill(items[3..3], 4);        // one element of an array
  items[nitems] := it; nitems := nitems + 1;      // a whole copy
  fill(view(store.rowaddr(tab, row), 1), 7);      // a row in a store table
  if not store.set(tab, row, view(store.rowaddr(tab, row), addr(items[1]) - addr(items[0]))) then ...

A list of unknown length (without a heap)

var xs: array[0..9999] of int; nxs: int;      // capacity + counter
function push(v: int): bool;
begin
  if nxs >= len(xs) then return false;
  xs[nxs] := v; nxs := nxs + 1; return true;
end;

Declare large buffers globally; use a view on mmap memory to grow beyond that (language.md §10b).

Linked structures without pointers: the index IS the pointer

There are no pointers, and for data you don't need them. Where C keeps an address, keep the index of the element instead, with -1 for "none". An index can't dangle, is bounds-checked at every use, and still means the same thing after a write-to-disk and read-back.

One array holds every node, a counter says how many are live, every link is an int index:

type Node = record val, next: int; end;       // `next` is an index, not an address
var nodes: array[0..1023] of Node; nn, head: int;

function alloc(v: int): int;                  // -1 when full: refuse, never truncate
begin
  if nn >= len(nodes) then return -1;
  nodes[nn].val := v; nodes[nn].next := -1;
  nn := nn + 1; return nn - 1;
end;

Doubly-linked list, with removal from the middle:

type Node = record val, prev, next: int; end;
var nodes: array[0..1023] of Node; nn, head: int;

procedure unlink(x: int);
begin
  if nodes[x].prev >= 0 then nodes[nodes[x].prev].next := nodes[x].next
  else head := nodes[x].next;                          // x was the head
  if nodes[x].next >= 0 then nodes[nodes[x].next].prev := nodes[x].prev;
end;

A tree, and a graph with cycles — same record with left/right, or an edge array. An index can't point at freed memory, so the "use after free" that makes cycles dangerous elsewhere can't happen; just guard the walk against revisiting:

procedure walk(x: int);
var i: int;
begin
  if seen[x] then return;                     // the only thing a cycle needs
  seen[x] := true;
  i := 0;
  while i < g[x].ne do begin walk(g[x].edge[i]); i := i + 1; end;
end;

Reuse: a free list, not an allocator. Removing a node doesn't free memory — nothing was allocated. Keep freed indexes in a list of their own (lib/store.wz does this with store.free).

It survives a restart: because a link is an index, the whole array is its own file format — write the raw bytes, read them back, every link still points where it did:

fd := sys3(SYS.open, addr(path[0]), 577, 420);        // O_WRONLY|O_CREAT|O_TRUNC
n  := sys3(SYS.write, fd, addr(nodes[0]), nn * 24);   // 24 = the record's size
sys1(SYS.close, fd);

What an index doesn't protect against: it can point at the wrong row. A zeroed link field means index 0, not "none" — initialise links to -1 explicitly, or a node becomes its own child and a walk recurses until the stack runs out. The compiler can't see this.

Reaching memory the program never declared

view(addr, n) turns any address plus a length into an ordinary, bounds-checked array — the escape hatch for OS memory, a shared segment, a mapped file:

base := sys6(SYS.mmap, 0, 4096, bor(PROT_READ, PROT_WRITE),
             bor(MAP_PRIVATE, MAP_ANONYMOUS), -1, 0);
fill(view(base, 4096));                       // an ordinary array of char
fill(view(base + 10, 100));                   // a window at an offset
sys2(SYS.munmap, base, 4096);

view and sys* are the unsafe primitives — nothing verifies the address and length are right. Everything inside the view is as safe as any other array.

List arguments (a small arg.* layer on top of json)

if not arg.take(a[0].keys_at, a[0].keys_end) then return tool.fail("keys: malformed");
n := arg.strings(arg.raw, 0, a[0].keys_end - a[0].keys_at);   // on the RAW view, not arg.buf

arg.take strips quotes off a bare string; arg.strings still needs to see quotes to recognise "abc" as one item. On an object already copied elsewhere (say ctx.pbuf), the value is still raw: arg.strings(ctx.pbuf, kv.vat, kv.vend) directly.

info filters (kv module): which side decides

kv.match(obj, .., filter, ..): every filter key must exist in obj; per key, the stored side decides the rule.

stored valuefilter valuerule
not a lista listmembership: stored value equals one element
a listanythingcontainment: stored list holds the filter value, or all of a filter list
not a listnot a listequality (kv.same)

So {"tags":"a"} matches a stored ["a","b"], and a stored list contains itself. An empty filter list against a non-list stored value matches nothing; against a stored list it matches everything (containment of nothing is vacuous). kv.contains/kv.haselem are the containment half standalone.

Ten argument slots: an array counts double

Three arrays (6 slots) leave room for four scalars. A routine with three buffers plus (from, upto) × 2 plus a write position doesn't fit — drop the write position, pass a slice: n := kv.merge(b, 0, bn, p, 0, pn, out[at..len(out)-1]).

Returning a scalar result without a var parameter

procedure co.schedule(...; n: array of int);   // n[0] becomes the count
var cnt: array[0..0] of int;
...
  co.schedule(..., cnt); k := cnt[0];

Several results: one record through array of R, or one array of int with fixed positions.

Writing a file and mapping it

n := io.push(path, 0, "data.bin"); path[n] := chr(0);
fd := sys3(SYS.open, addr(path[0]), bor(bor(O_WRONLY, O_CREAT), O_TRUNC), 420);
if fd < 0 then io.fatal("open");
if sys3(SYS.write, fd, addr(buf[0]), n) <> n then halt(2);
sys1(SYS.close, fd);
fd := sys3(SYS.open, addr(path[0]), O_RDONLY, 0);
p := sys6(SYS.mmap, 0, bytes, PROT_READ, MAP_SHARED, fd, 0);
if p < 0 then halt(3);
total := curve.sum(view(p, bytes), 0, 525600);
sys2(SYS.munmap, p, bytes);

HTTP handler (http module + router)

procedure app.request;
var uid: int;
begin
  if oauth.route then return;                       // OAuth endpoints first
  if http.pathis("/protected") then
  begin
    uid := oauth.verify;
    if uid < 0 then begin oauth.challenge; return; end;
    http.add("{\"user\":"); http.addn(uid); http.add("}");
    http.finish(200, "application/json");
    return;
  end;
  if router.get("/health") then begin router.text(200, "ok"); return; end;
  router.notfound;
end;
...
  http.serve(port, 1);

Schema + tools (MCP/REST without the handwork)

The tools block is the way to expose tools — never build tools/list or a call reply by hand with mcp.add. See language.md §7b for the full anatomy; "A whole MCP server" above has a complete, runnable program. Shortest form, for reference:

type AddArgs = schema a: int "left"; b: int "right"; end;
type AddResult = schema sum: int; end;
tools
  add(AddArgs): AddResult "Add two whole numbers." readonly idempotent;
end;
import tools;
function tool.add(a: array of AddArgs; r: array of AddResult): int;
begin r[0].sum := a[0].a + a[0].b; return 0; end;

A list argument (json?) in a tool: arg.strings on the raw view

arg.take(at, upto) already unwraps a JSON string: "child" becomes child in arg.buf, and arg.strings(arg.buf, 0, arg.n) then sees no " or [ and returns -1. Parse lists on arg.raw instead: arg.take(at, upto); n := arg.strings(arg.raw, 0, upto - at) — covers a list, a JSON string containing a list, and a single string.

Reading an int or a real at a raw address

peek(a) is the only read builtin and yields one byte; peek8/peekreal don't exist. Read eight bytes with a typed view of one element:

function db.geti(a: array of int): int; begin return a[0]; end;
function db.getr(a: array of real): real; begin return a[0]; end;
...
  v := db.geti(view(address + offset, 1));      // an int
  x := db.getr(view(address + offset, 1));      // a real

The parameter's type decides what the view reads, not the call site. Measure byte offsets in a record with addr(rec.field) - addr(rec.first), never by hand.


From an error message to its cause

The compiler's messages are terse and sometimes point at generated code instead of your own line. Fastest route from symptom to cause; add to it whenever you lose an hour to something not listed here.

The message points at a file you did not write

For example wantzel: <unitref>:45: undeclared identifier, or a line deep inside refdata.wz — that's generated code (a schema, the tools block).

SymptomCauseFix
undeclared identifier in <schemaname>the module the generator uses isn't importedimport json; before the schemas — a schema parser calls json.*
name already used by a type in generated codea global shares a name with a schema fieldrename your global; fields share the namespace inside generated routines
forward declared routine is never defined on the last linea library-required app.* hook is missingsearch library headers for forward (app.request, app.tools, app.authenticate)

Fast diagnosis: don't look at the line number, look at the name in angle brackets — that's the schema or tool. Work out which library the generator needs (usually stated in the file's header) and whether a global shares a name with one of its fields.

undeclared identifier on your own line

SymptomCauseFix
the name exists, further down the same filea routine must appear before its callermove it up, or declare it forward
the name is in another src/ fileinclude ordermove that file's include earlier
the name is in a library modulea missing or late importimport <module>; above the first use — the message names the module

Quickest check: grep -n "function <name>|procedure <name>" src/*.wz lib/*.wz. If it's there, the problem is order, not spelling.

Runtime, not compile time

SymptomCauseFix
array index out of range in generated dataa buffer sized too small for what's written into itthe generator computes the size — count in anything you added
an answer is half right (one group instead of two)aliasing: a working-copy field passed into a routine that reloads that same working copycopy the value into your own buffer first
a test passes alone but fails in the suitea port clash or shared stategive every test its own port/directory (from the process id) and clean up; otherwise look for a shared global or file two tests both write
empty slice, addr(s[0]) blows upn = 0guard with if n > 0 before addr

Before you compile

  1. Count the argument slots. Ten is the maximum, an array counts as two — four text buffers plus a scalar is already twelve. Make search keys state with setters (see ref.q.*) instead of parameters.
  2. Check whether a name already exists: grep -rn "\bname\b" lib/ src/ — the namespace is shared, case-insensitive, and includes schema fields.

The costliest mistakes are namespace and ordering errors, and cost time because the message points at generated code. "What name is in the angle brackets?" gets you there in one step.

Read next