zezpaik

The language by example. Every rule with a program that shows it, from the tour.

The zezpaik language tour

A tour of the language as the compiler implements it today: version 0.28.0, Windows x86-64, through LLVM. It is written to be *run*, not read: every program below is a file in this repository, so nothing here is a fragment that might not compile. Every command was run and every line of output was copied from the run.

This tour stops at 0.28.0 and is kept as it was. The language has moved on to 0.37.0 (enum, match, exists/is_dir, conversions and the wrapping/saturating operators are not in this page). For the current, example-first course start at TUTORIAL.md; SPEC.md is the contract either way.

Two commands get you everywhere:

./build/zezpaik.exe --interpret <file.zk>      # run it from the tree, no LLVM
./build/zezpaik.exe <file.zk> -o out.exe       # compile it, then run out.exe

The interpreter is the reference implementation (SPEC.md section 11). A program must print the same bytes, report the same diagnostics and exit with the same status whether it is interpreted or compiled; tests/cli_runner.c enforces that for every sample in this tour and every program in examples/.

Contents:

  1. The smallest program
  2. Printing: printf and print
  3. Values and types
  4. Bindings: let, let mut, const
  5. Functions and calls
  6. Arithmetic: checked, wrapping, and saturating
  7. Comparisons, logic, and the short precedence ladder
  8. if, while, break, continue
  9. More than one result
  10. Records: struct
  11. Strings, escapes and UTF-8
  12. Sequences: [N]T, []T, for
  13. Files, and the program's own command line
  14. Text, numbers, and a data file into records
  15. Stopping on purpose: assert(condition)
  16. Modules: a file is a module
  17. What the compiler refuses, and why that is the point
  18. Where the language is going
  19. The corpus as documentation

1. The smallest program

examples/hello.zk is the program make run builds (SPEC.md section 9):

// zezpaik example - SPEC.md section 9.
extern def printf(fmt: *u8, ...) returns (i32);

def main() returns (i32)
{
    const Newline: *u8 = "\n";
    let number: i32 = 100;

    printf(fmt: "%d%s", number, Newline);
    ret 0;
}

Three things are already the whole design in miniature:

Run it both ways:

./build/zezpaik.exe --interpret examples/hello.zk     # 100
./build/zezpaik.exe examples/hello.zk -o hello.exe && ./hello.exe   # 100

Both print 100 and exit 0. A file with no main is refused - every program needs exactly one, in a file named on the command line:

Text is print, and a number in a format is printf; the next section shows both.

2. Printing: printf and print

Most printing goes through C's printf, declared at the top of the file:

extern def printf(fmt: *u8, ...) returns (i32);

When the format string is a literal, the compiler checks it against the arguments (SPEC.md section 8.4, D-25). That is not a nicety: printf reads its arguments by the format string, so a wrong conversion reads the wrong bytes. The set zezpaik accepts is:

ConversionTakes
%d %i %u %xan integer of 32 bits or fewer
%lld %lli %llu %llxa 64-bit integer
%sa *u8
%%nothing

Every conversion consumes exactly one argument and the count must match exactly. Anything the compiler cannot check - %f, %hd, %*d, flags, width, precision - is a compile error rather than a quiet difference between the two back ends. l is 32 bits on Windows, so a 64-bit value is %lld, not %ld.

A format string that is not a literal is left unchecked on purpose - the compiler cannot see it, and pretending otherwise would be worse than saying so:

examples/debug.zk shows the checked set in use, including %llu for a u64 at the top of its range.

Text, as opposed to a number in a format, is what print is for (0.16.0, SPEC.md section 8.4, D-37). It takes a []u8 and writes exactly as many bytes as the slice's length says:

let text: []u8 = "hello";
print(text);
print(" and goodbye\n");     /* a literal takes the []u8 context */

That is the thing %s cannot do: %s needs a *u8 and reads until it finds a NUL byte, and a slice is a length and a pointer with no promise of one anywhere. So a NUL *inside* a slice is an ordinary byte to print and a terminator to %s, and print("") writes nothing at all.

Two rules come with it, and both are easy to trip over:

print is one of the language's builtins, and a builtin name is one a program may not declare (section 12 shows len, section 15 assert).

3. Values and types

The type universe is small and exact (SPEC.md section 8.3). There are no floating-point types, no structs yet, and no classes.

i128 and u128 work in a compiled program but not in the interpreter, which holds values in 64 bits and says so rather than quietly truncating:

build/tour/w128.zk:5:21: error: i128 needs the native backend: the interpreter holds values in 64 bits

Because of that, the corpus stays inside 64 bits - every sample runs through both back ends, which is the point of the corpus. A 128-bit value also cannot be printed: printf has no conversion for one (see section 2).

There are no implicit conversions. A value of one type never quietly becomes another, in a binding, an assignment, an argument or a ret (SPEC.md section 8.4, D-20):

What *does* adapt is a literal: it takes the type its context asks for, since it has no type of its own yet (SPEC.md section 8.3). That is why let wide: i64 = 1 + 40000; is 64-bit arithmetic and prints 40001.

A name is written name: type - the name first - everywhere a type is stated.

A conversion is how one integer type becomes another (SPEC.md section 8.3, D-43), and it is the only conversion there is: T(x), written like the builtins are. It has three cases, and only one of them can fail:

Widening is exact, the same width reinterprets the bits (u8(-1) is 255, i8(200) is -56), and narrowing traps when the value does not fit - the sentence is value 300 does not fit in u8 and the status is 2, like an overflowing +. Nothing is ever truncated behind your back: a program that wants the low byte writes the mask that keeps it, and then the conversion checks what is left. i1 is not a conversion target - it is the boolean type, and x != 0 is how a value becomes one - and neither are the 128-bit types, because the interpreter holds 64-bit values. The eleven type names are reserved for this, so a program cannot declare an i32.

This is the wall that used to stop real programs: before it, a byte read out of a file could be printed and never computed with. Before that, i32 + i64 was a compile error and there was no way around it at all.

4. Bindings: let, let mut, const

Three spellings, and the difference is what each one promises the reader (SPEC.md section 8.4):

let number: i32 = 100;        // bound once, never reassigned
let mut total: i32 = 0;       // may be reassigned
const  nswer: i32 = 42;       // a compile-time value, no storage at all

const may sit at file scope, where it belongs to its module, and examples/debug.zk opens with five of them (Language, Newline, Base, Square, Offset):

const Base: i32 = 6;
const Square: i32 = Base * Base;      // arithmetic over constants folds at compile time

5. Functions and calls

definition states its name, its parameters, and its result:

def add(a: i32, b: i32) returns (i32)
{
    ret a + b;
}

Calls name their arguments for the declared parameters (SPEC.md section 8.4):

examples/named_arguments.zk is a whole program built on this rule, and both back ends print 23120 120 for it.

A mut parameter is a copy the body may reassign. The caller never sees the change, and the declaration says so (SPEC.md section 8.4):

def bump(mut n: i32) returns (i32)
{
    n = n + 1;
    ret n;
}

examples/debug.zk prints bump(41)=42 (start still 41).

returns () is a result like any other. A function that produces nothing is written ret; - not ret with a value, and not a fall-off-the-end:

def say(text: *u8) returns ()
{
    printf(fmt: "%s", text);
    ret;
}

6. Arithmetic: checked, wrapping, and saturating

+ - * / % and unary - are the arithmetic operators. Operands must have the same type, and every operation is checked (SPEC.md section 8.3, D-17).

Division truncates toward zero and the remainder follows the dividend, so -7 / 2 is -3 and -7 % 2 is -1 - not -4 and 1. Shifting right copies the sign bit on a signed type and fills with zeros on an unsigned one.

./build/zezpaik.exe --interpret examples/debug.zk | tail -1
-7/2 = -3  -7%2 = -1  wide(i64) = 40001

Arithmetic never wraps. Overflow, division by zero, a shift count at or past the width, and negating the most negative value all trap: the program stops, says what happened and where, and exits 2:

This is the whole reason the language has no undefined arithmetic behaviour to spend: the alternative to a defined stop is reading whatever the hardware left behind. Indexing will report the same way when it arrives (item 8).

And when wrapping is what you want, you ask for it (0.30.0, SPEC.md section 8.3, D-78). +%, -% and *% are the same three operations with the result taken modulo 2^bits, and they never trap - the only arithmetic in the language that cannot:

And when the limit is what you want, +|, -| and *| hold the result there (0.31.0, SPEC.md section 8.3, D-79). A saturation is the mathematical result clamped at the type's nearest end: upward for a positive overshoot, downward for a negative one, and never a trap - like the wrapping family, and unlike the checked one:

In a u8, 200 +| 100 is 255 and 5 -| 10 is 0 - not 251, which is what 5 -% 10 answers. An unsigned type saturates at 0 below and at its maximum above; a signed one saturates at its own two ends, so i8(100) +| i8(100) is 127 and i8(0 - 100) -| i8(100) is -128. Reaching the limit exactly is not a saturation: i8(100) +| i8(27) is 127, an ordinary sum. Where a count, a colour channel or a coordinate cannot leave its range, this is the operation that says so without stopping the program - and it is the reason a program can now spell all three answers to a result that does not fit:

200 +  100   in a u8   the program stops   (checked)
200 +% 100   in a u8   44                  (wrapping)
200 +| 100   in a u8   255                 (saturating)

7. Comparisons, logic, and the short precedence ladder

Comparisons (< <= > >= == !=) produce an i1, and comparison is the only thing that makes a boolean (SPEC.md sections 8.3 and 8.4):

let lt: i1 = a < b;
let eq: i1 = a == b;

&&, || and ! are for i1 only, and &&/|| short-circuit, left to right - which is what makes a guard like this safe rather than lucky:

let guarded: i1 = divisor != 0 && 100 / divisor > 1;    // no division when divisor is 0
(examples/debug.zk)

The precedence ladder is shorter than C's, on purpose (SPEC.md section 8.3, D-22):

LevelOperators
tighter* / % & << >>
looser+ - | ^

so a & b == c is (a & b) == c, and 1 << 2 + 3 is (1 << 2) + 3. Bit operations sit with multiplication because mixing the two without parentheses is almost always a mistake; there is no separate "bitwise" tier to memorize. Unary -, ! and ~ bind tightest. examples/arithmetic.zk prints the ladder in action.

8. if, while, break, continue

if is a statement, not an expression (SPEC.md section 8.4, D-23). It produces no value, there is no ?:, and braces are mandatory on both branches - there is no dangling-else question to answer:

if n < 0
{
    ret 0 - 1;
}
else if n == 0
{
    ret 0;
}
else
{
    ret 1;
}

let mut candidate: i32 = from;
while 1 == 1
{
    if candidate % step == 0
    {
        break;
    }
    candidate = candidate + 1;
}
ret candidate;

Runnable programs: examples/control_flow.zk (if/else chains, loops, break, continue), examples/number_table.zk (a table of digits, reversals and primes up to 20, with a while and a continue), examples/arithmetic.zk.

9. More than one result

function may return more than one value, and the result is a *tuple* (SPEC.md section 8.3):

def divmod(a: i32, b: i32) returns (i32, i32)
{
    ret (a / b, a % b);
}

let (q: i32, r: i32) = divmod(a: 17, b: 5);

Two rules keep that from spreading through the language:

The ret position is what makes forwarding possible - ret divmod(a: a, b: b); in examples/hello_recommendation.zk, a program that prints 17 3:

./build/zezpaik.exe --interpret examples/hello_recommendation.zk
divmod
17 3

10. Records: struct

A struct is a named aggregate of other values, declared at file scope (SPEC.md section 8.3):

pub struct Kanji
{
    nihongo: *u8,
    kunyomi: *u8,
    onyomi: *u8,
    eng: *u8,
}

A value of it is a record literal, and the literal takes its type from where it is written:

let card: Kanji = {
    nihongo: "kanji",
    kunyomi: "kannji",
    onyomi: "",
    eng: "character",
};

What a record cannot do yet: a field's type has to be a type the compiler already knows, so two structs that point at each other cannot be written (a type forward declaration is the honest fix, and it is not in this version), and a field whose type is a pointer can be declared but never *filled*, because the language has no way to spell a pointer value other than a string literal or a parameter. Both of those are the slice work below.

11. Strings, escapes and UTF-8

string literal is a *u8 - a pointer to bytes - and that is all it is today. There is no dereference, no indexing and no length, so a string can be passed whole to a function or to printf, and nothing else. Slices ([]u8, with a length and checked indexing) arrive with item 8; until then, *u8 is an opaque handle that C understands.

The single-byte escapes are \n, \t, \\, \" and \0, and 0.12.0 added two that name a byte or a character rather than a keystroke (SPEC.md section 8.1.7):

unknown escape sequence in string literal (expected \n, \t, \\, \", \0, \xHH or \u{...})

An escape is resolved before anything reads the string. That matters for a format string, because a % that an escape spelled is a conversion and not a per cent sign:

Source is UTF-8 and a string literal is bytes, so a program can print Japanese or an emoji with *u8 and no new feature - the bytes reach the program unchanged (SPEC.md section 8.1). Both spellings work: a raw character in the file, or \u{...} escapes. tests/cli_runner.c checks the outcome at the byte level by writing an ASCII sample that spells a 3-byte character, a 4-byte one and a combining mark with \u{...}, and requiring both back ends to print exactly the UTF-8 bytes.

This repository's own files are ASCII (AGENTS.md), which is the reason \u{...} exists: before 0.12.0 a .zk file here could not spell a character above U+7F at all. Your own programs may write the character directly if their encoding allows it.

12. Sequences: [N]T, []T, for

Two types for a sequence, and the difference is where the length lives (SPEC.md section 8.3, D-34):

let fixed: [4]i32 = [10, 20, 30, 40];   /* the length is in the type */
let text: []u8 = "hello";               /* the length travels with the value */
  let text: []u8 = "hello";

and the same literal still fits a *u8 where C needs one, so printf(fmt: "%s", text) keeps working.

  let mut scratch: []u8 = ".......";     /* a writable buffer, no allocation */
  scratch[0] = 45;
  let mut page: [4096]u8 = [0; 4096];         /* a scratch buffer */
  let mut sheet: [4194304]u8 = [0; SHEET];    /* 1024x1024 at four bytes a pixel */

The count is a whole number known at compile time - a literal, or a const, which the parser substitutes before it looks (D-16) - and it must equal the length in the context's array type, because the length is part of the type:

That is the same rule arithmetic already follows (section 6): the alternative to a defined outcome is reading whatever memory happens to be there. There is deliberately no unchecked form, and the message names both numbers, so a reader knows what went wrong without a debugger.

  for word in words
  {
      printf(fmt: "%d\n", len(word));
  }

  let line: []u8 = "sato,tanaka";
  print(line[0..4]);          /* sato */
  print(line[5..11]);         /* tanaka */
  let empty: []u8 = line[0..0];   /* length 0, and legal */

The result is always a slice, even when the operand is an array: let tail: []i32 = row[1..3]; is a []i32, which is the one place an array becomes a slice. What the view sees follows the operand's type, as everywhere else: a slice shares its storage, so writing through the view is visible through the original, while an array is a value, so the view is of that copy and a write through it does not change the array.

  let mut xs: [3]i32 = [1, 2, 3];
  xs[0] = 9;              /* fine: `xs` is let mut */
  xs[2] = xs[0] + 1;
  printf(fmt: "%d %d %d\n", xs[0], xs[1], xs[2]);    /* 9 2 10 */

  bump 8 view 8         /* the slice parameter changed the caller's storage */
  fill 99 copy 1        /* the array parameter changed only its own copy */

Not there yet: growing a sequence (append, resizing, allocation). And one hole named honestly in D-34, D-36 and D-38: a slice can outlive what it views - a slice of a local array returned from a function - because nothing in the language tracks lifetimes, and writing through such a slice writes into a frame that is gone.

13. Files, and the program's own command line

The standard library reads and writes whole files, and it is a module written in zezpaik (SPEC.md sections 8.4 and 8.5, 0.18.0, D-39):

import io;

def main() returns (i32)
{
    io::write(path: "names.tmp", text: "sato,tanaka\n");
    print(io::read("names.tmp"));
    printf(fmt: "\nlines %d\n", io::line_count("names.tmp"));
    ret 0;
}

./build/zezpaik.exe --interpret examples/file_io.zk

Four things about it are the language's design showing through - and a fifth arrived in 0.27.0, which is the one that turns a program into a tool:

An empty slice is what an *empty file* gives, so returning one for a missing file would be a lie a program could not detect, and a -1 status would be the sentinel this language does not use anywhere else.

  let operands: [][]u8 = args();
  let text: []u8 = read_file(operands[0]);      /* the file the user named */

What is in the module: io::read, io::write, io::copy, io::line_count and io::first_line. The two helpers a line parser wants are text, not io - text::field_count(line: l, separator: c) and text::index_of(text: t, byte: c), which live in lib/text.zk with trim, field and line. (This paragraph said io:: until 2026-10-25, and an agent writing a program with no knowledge of this tree spent a detour on it: the compiler answered 'io' has no public 'field_count', correctly.) All of them are zezpaik over two builtins, read_file(path) and write_file(path: p, text: t), which are the only code in the language that touches a file at all, and which use nothing but ISO C. That is deliberate: the one layer that knows about an operating system is small enough to replace, and nothing in the language's syntax or semantics knows what system it is on. What is *not* there: appending, streaming, reading part of a file, listing a directory (that is read_dir, a builtin), deleting, renaming, and asking whether a file exists - text::index_of returning -1 is the one sentinel the library has, and it is a library's decision rather than the language's.

The first real use is now writable: read a file, take a field out of a line (first_line, index_of, xs[a..b]), and print it. That is the deck parser - a data file turned into records by a program written in zezpaik, not JSON and not regex - and it needs nothing new from the compiler.

The program's own command line: args()

A program can read the operands it was run with (0.23.0, SPEC.md section 8.3, D-44). That is the other half of the paragraph above: args() is what hands a tool the name of the file it is supposed to open.

def main() returns (i32)
{
    let operands: [][]u8 = args();
    let mut index: i32 = 0;

    printf(fmt: "%d\n", len(operands));
    while index < len(operands)
    {
        printf(fmt: "%d:", index);
        print(operands[index]);
        printf(fmt: "\n");
        index = index + 1;
    }
    ret 0;
}

14. Text, numbers, and a data file into records

text is the other library module, and it is about what bytes mean rather than about storage (SPEC.md section 8.5, 0.19.0, D-40):

import text;

let line: []u8 = "sato,tanaka,,suzuki";
printf(fmt: "%d fields\n", text::field_count(line: line, separator: 44));
print(text::field(line: line, index: 0, separator: 44));      /* sato */
print(text::trim("  padded  "));                              /* padded */

Numbers are the other meaning bytes have, and the num module (0.25.0, SPEC.md section 8.4, D-46) is the two directions a number travels:

import num;

That is everything a data file needs. The last piece of item 9 is a program written in zezpaik that reads a file and turns it into records - one record per line, fields between commas - and it is two small files in this repository:

./build/zezpaik.exe --interpret examples/deck.zk
# deck.tmp holds 61 bytes and 4 lines
# 0. hi (hi) - day
# 1. mizu (mizu) - water
# ...

The data format here is the whole of it: one record per line, fields separated by one byte. No quoting, no escapes, no comment syntax and no number parsing - a field is the bytes between two separators. Those are decisions for text when a program needs them, not for the compiler, which is the shape item 9 was aiming at.

15. Stopping on purpose: assert(condition)

An assertion is a test the program believes cannot fail (0.24.0, SPEC.md section 8.4, D-45), and it is the third builtin that is a statement:

let mut count: i32 = 0;

The point of it is the thing section 14 had to leave out. A function written in zezpaik can now stop a program, including a function of the standard library, so a library can be loud about a caller's mistake instead of inventing a sentinel - and text::field's loud sibling is the first thing that will use it. It is still not a substitute for a sentinel: an assertion is for a condition the program believes is impossible, not for input a user can produce, and D-40's empty slice stays the answer for "the field is not there".

16. Modules: a file is a module

A file is a module, and its file name without the extension is its name (SPEC.md section 8.5, 0.11.0). A program is one or more .zk files named on the command line; import reads another one.

examples/modules/util.zk is a module:

extern def printf(fmt: *u8, ...) returns (i32);

pub def show(value: i32) returns ()
{
    printf(fmt: "%d\n", value);
    ret;
}

examples/modules/main.zk imports and calls it:

import util;

def main() returns (i32)
{
    util::show(value: 100);
    ret 0;
}

The rules are four lines long:

   ./build/zezpaik.exe --emit-llvm examples/modules/main.zk | grep '^define'
   define void @"util$show"(i32 %0) {
   define i32 @main() {

main and extern def keep their plain C names, because something outside the compiler calls them.

Two consequences worth knowing:

<file>:1:1: error: 'a' names both <path> and <path>, and a module name has to name one file (SPEC.md section 8.5)

Run the two-file example the way make run-modules does:

./build/zezpaik.exe --interpret examples/modules/main.zk     # 100
./build/zezpaik.exe examples/modules/main.zk -o modules.exe && ./modules.exe

Only main.zk is named: the import pulls the other file.

17. What the compiler refuses, and why that is the point

The exit statuses are the fastest signal (SPEC.md section 8.7):

ExitMeans
0success
1the compiler could not do its job - stderr says why and where
2the program compiled and then trapped while running

A diagnostic has one shape (SPEC.md section 10):

<file>:<line>:<col>: error: <message>

<file> is the path as it was written on the command line, line and column are 1-indexed, and the column counts bytes. The compiler stops at the first error: it does not recover and it does not report a second one, so the first message is the one to fix. Errors go to stderr and a failing compile writes nothing to stdout, so a build script can pipe stdout without filtering diagnostics out of it.

A trap's file is the one the operation was written in (SPEC.md section 8.3, D-47). A trap raised inside a library function names the library, not the program that called it - lib/num.zk:110:5: error: assertion failed: is_a_number is the assertion inside expect_int - which is the same rule a refusal follows. Before 0.25.0 the file was whichever file the compiler happened to read last, so a library assertion printed the calling program's name beside the library's line number.

printf 'def main() returns (i32)\n{\n    let x: i32 = missing;\n    ret 0;\n}\n' > e.zk
./build/zezpaik.exe --interpret e.zk
e.zk:3:18: error: 'missing' is not declared

That the language refuses so much is the design, not an accident: a wrong format string, a widening that was never written down, an integer used as a condition (in an if, a while or an assert) and a missing ret are all things a reader would have had to guess at. Each one has a message that says what to write instead.

18. Where the language is going

What item 9 deliberately left for later, each a decision for when a program wants it rather than a gap: appending to a file, asking whether one exists, reading standard input, and quoting and escaping inside a data format. All of those are lib/ work. The piece that needed a language decision landed in 0.24.0 - assert (section 15), which lets a library function stop the program instead of returning an empty slice - and the library that uses it landed in 0.25.0: num::parse_int, num::expect_int, num::format_int and text::expect_field (section 14, D-46). Item 9's planned work is done.

Next: item 10 is a transpiler to C, which gives embedded targets (item 11), WebAssembly, and a third implementation for the conformance suite to hold the other two to - the tool that would prove the language is a language rather than two back ends that agree with each other.

Everything in this tour still runs the way the sections above show it - from the AST or as a native program. The C route is a third opinion, not a replacement, and it is the one the corpus now holds to the same answers: a case is cconform when the C back end emits it and all three implementations agree, and crefused when it must refuse it. D-41 and D-42 have the reasoning and SPEC.md section 13 item 10 the coverage.

What that will change in this tour: the same programs, in a form a C compiler and a board can take, and a third opinion on every rule in here - section by section, as the slices land.

19. The corpus as documentation

Run one case on its own, and read what it expects: