The zezpaik tutorial
From an empty folder to a working program, with every part of the language shown by a program that runs. This is the document a new user reads first. It is written against zezpaik 0.39.0 on 64-bit Windows, the version in SPEC.md and in the compiler.
Every complete program below was compiled and run while this page was written, and the output shown is copied from the run. Where a rule has a refusal, the exact message the compiler prints is shown too. Nothing here is a fragment that has never been through the compiler.
Contents:
- Get a compiler and run a program
- The shape of a program
- Values and types
- Bindings and constants
- Printing
- Operators and arithmetic
- Control flow
- Functions
- Sequences: arrays, slices and
for - Records:
struct - Enums
match- Modules
- Files, arguments and directories
- Errors, traps and exit codes
- The standard library
- Projects and the SDK:
zeko - Windows and the C back end
- Cookbook
- What is not in the language (yet)
- Where to read more
1. Get a compiler and run a program
Two ways, and they run the same compiler.
call C:\path\to\zezpaik-sdk-full-0.37.0-win64\setup.cmd
zeko doctor
zeko new myapp
cd myapp
zeko run
From this repository (after make):
build\zezpaik.exe --interpret examples/hello.zk
build\zezpaik.exe examples/hello.zk -o hello.exe
hello.exe
The three ways to run one file:
| command | what it does | needs |
|---|---|---|
zezpaik --interpret file.zk | runs the reference implementation | nothing but the compiler |
zezpaik file.zk -o app.exe | emits an object and links with gcc | a C linker on PATH |
zezpaik --emit-c file.zk | prints C to stdout instead | nothing; the C is the product |
The interpreter is the reference: a compiled program must print the same bytes, report the same traps and exit with the same status. The corpus enforces that for hundreds of programs (tests/cli_runner.c).
One troubleshooting note. A native build and --emit-c stage a temporary file; if TMP and TEMP point somewhere unwritable, the compiler says cannot create a temporary file and exits 1. Point them at a folder you own and it works.
2. The shape of a program
// A line comment.
/*
* A block comment.
*/
extern def printf(fmt: *u8, ...) returns (i32);
def main() returns (i32)
{
let number: i32 = 100;
let name: []u8 = "zezpaik";
printf(fmt: "%s says %d\n", name, number);
ret 0;
}
zezpaik says 100
mainis the entry point, exactly one per program, and itsi32result becomes the process exit status.ret 0;is success.- A definition never falls off its end. Every path through a function with a result must reach a
ret; areturns ()function ends withret;. extern defdeclares a symbol this program does not define - here C'sprintf. A declaration ends in;; a definition has a body.- Statements end in
;; blocks are braces.struct,enumanddefdeclarations have no trailing semicolon. - A file is a module (section 13).
importreads another file. - The language is case-sensitive, and the vocabulary is nineteen keywords: `break const continue def else enum extern for if import in let match mut pub ret returns struct while`.
3. Values and types
There are ten integer types plus i1, and nothing else. There is no bool, no float, no char, and no separate string type: text is a slice of bytes, []u8.
| type | bits | range |
|---|---|---|
i8 / u8 | 8 | -128..127 / 0..255 |
i16 / u16 | 16 | -32768..32767 / 0..65535 |
i32 / u32 | 32 | -2147483648..2147483647 / 0..4294967295 |
i64 / u64 | 64 | about +/- 9.2e18 / 0..1.8e19 |
i128 / u128 | 128 | native back end only (see below) |
i1 | 1 | 0 or 1; what a comparison produces |
extern def printf(fmt: *u8, ...) returns (i32);
def main() returns (i32)
{
let a: i8 = -128;
let b: i16 = -32768;
let c: i32 = -2147483648;
let d: i64 = -9223372036854775808;
let e: u8 = 255;
let f: u16 = 65535;
let g: u32 = 4000000000;
let h: u64 = 18000000000000000000;
let yes: i1 = 1 == 1;
let hex: i32 = 0xFF;
let bin: u8 = 0b1010_1010;
let big: i32 = 1_000_000;
printf(fmt: "i8 %d i16 %d i32 %d i64 %lld\n", a, b, c, d);
printf(fmt: "u8 %d u16 %d u32 %u u64 %llu\n", e, f, g, h);
printf(fmt: "i1 %d hex %d bin %d big %d\n", yes, hex, bin, big);
let widened: i32 = i32(a);
let narrowed: u8 = u8(200);
printf(fmt: "widened %d narrowed %d\n", widened, narrowed);
ret 0;
}
i8 -128 i16 -32768 i32 -2147483648 i64 -9223372036854775808
u8 255 u16 65535 u32 4000000000 u64 18000000000000000000
i1 1 hex 255 bin 170 big 1000000
widened -128 narrowed 200
- A number literal is decimal,
0xhexadecimal, or0bbinary, and_may separate digits (0.35.0). There is no sign in a literal --128is unary minus applied to128. - A literal takes the type its context asks for.
let wide: i64 = 1 + 40000;does the arithmetic ini64. - A conversion is the type name called:
i32(byte),u8(n). Widening is exact. The same width reinterprets the bits (u8(i8(-1))is 255). A narrowing conversion that does not fit traps:value 300 does not fit in u8. i128andu128are native-back-end only. The interpreter holds values in 64 bits and refuses them by name; there is no way to print one, so a program compares them.- There is no
trueorfalse:i1is written1/0or produced by a comparison.
4. Bindings and constants
extern def printf(fmt: *u8, ...) returns (i32);
const Language: *u8 = "zezpaik"; /* file scope: substituted at each use */
const Base: i32 = 6;
const Square: i32 = Base * Base; /* arithmetic over constants is allowed */
def main() returns (i32)
{
const Limit: i32 = 20; /* a local constant */
let name: []u8 = "immutable"; /* assigned once */
let mut count: i32 = 0; /* may be reassigned */
count = count + 1; /* `let mut` is required for this */
printf(fmt: "%s %d %d %d\n", Language, Square, Limit, count);
ret 0;
}
zezpaik 36 20 1
letis immutable,let mutis the only mutable binding. Assigning to aletis a compile error.consthas no storage: its expression is substituted at each use, so a constant that overflows traps where it is used rather than failing the build.- File-scope
constis legal and file-private. File-scopeletis a compile error: there is no global mutable state. - A block is a scope. A name lives from its declaration to the closing brace.
- A binding copies its value. Arrays and structs are values, so a copy is a real copy; a slice is a view and copies the view, not the storage (section 9).
5. Printing
Most output goes through C's printf, declared once per file:
extern def printf(fmt: *u8, ...) returns (i32);
When the format string is a literal, the compiler checks it against the arguments and refuses a mismatch at compile time. The accepted conversions are:
| conversion | argument |
|---|---|
%d %i %u %x | an integer of 32 bits or fewer |
%lld %lli %llu %llx | a 64-bit integer |
%s | a *u8 or a []u8 (0.35.0) |
%% | nothing |
Everything else - %f, %p, %c, %hd, %*d, a width or precision - is a compile error, because the compiler will not let a format string read the wrong bytes. The count of conversions must match the count of arguments exactly.
print(text) writes a []u8's bytes, exactly as many as its length says - including a NUL, which is where it differs from %s:
let greeting: []u8 = "hello";
print(greeting); /* hello */
printf(fmt: " (%d bytes)\n", len(greeting));
print and %s are the two printing tools: printf for numbers and format, print for text.
6. Operators and arithmetic
extern def printf(fmt: *u8, ...) returns (i32);
def main() returns (i32)
{
let sum: i32 = 7 + 3 * 2; /* 13 */
let quotient: i32 = -7 / 2; /* -3: truncates toward zero */
let remainder: i32 = -7 % 2; /* -1: follows the dividend */
let shifted: i32 = 1 << 4; /* 16 */
let anded: i32 = 12 & 10; /* 8 */
let ored: i32 = 12 | 10; /* 14 */
let xored: i32 = 12 ^ 10; /* 6 */
let wrapped: u8 = u8(255) +% u8(1); /* 0 */
let clamped: u8 = u8(200) +| u8(100); /* 255 */
let floor: u8 = u8(5) -| u8(10); /* 0 */
let less: i1 = 3 < 4;
let logic: i1 = (3 < 4) && !(2 == 3);
let either: i1 = (2 > 3) || (4 == 4);
printf(fmt: "sum %d div %d rem %d shift %d\n", sum, quotient, remainder, shifted);
printf(fmt: "and %d or %d xor %d\n", anded, ored, xored);
printf(fmt: "wrap %d sat %d floor %d\n", wrapped, clamped, floor);
printf(fmt: "less %d logic %d either %d\n", less, logic, either);
ret 0;
}
sum 13 div -3 rem -1 shift 16
and 8 or 14 xor 6
wrap 0 sat 255 floor 0
less 1 logic 1 either 1
Arithmetic has three answers to "the result does not fit", and the program writes which one it means:
+-*: trap - the program stops with exit 2.+%-%*%: wrap - the result modulo 2^bits (u8 255 +% 1is 0).+|-|*|: saturate - held at the type's nearest limit (u8 200 +| 100is 255).
/ and % truncate toward zero; the remainder follows the dividend. Division or remainder by zero traps, and so does T.min / -1. Shifts trap when the count is outside 0 <= count < bits. ^ is XOR, not exponentiation. There is no compound assignment and no ++/--.
Precedence, tightest first (the ladder is deliberately shorter than C's):
- unary
-!~ */%*%*|&<<>>+-+%-%+|-||^==!=<<=>>=&&||
So a & b == c is (a & b) == c and a << b + c is (a << b) + c, the opposite of C's famous surprises. Parentheses always group.
7. Control flow
extern def printf(fmt: *u8, ...) returns (i32);
def main() returns (i32)
{
let mut n: i32 = 1;
while n <= 15
{
if n % 15 == 0
{
printf(fmt: "FizzBuzz\n");
}
else if n % 3 == 0
{
printf(fmt: "Fizz\n");
}
else if n % 5 == 0
{
printf(fmt: "Buzz\n");
}
else
{
printf(fmt: "%d\n", n);
}
n = n + 1;
}
let mut i: i32 = 0;
let mut total: i32 = 0;
while 1 == 1
{
i = i + 1;
if i > 20 { break; }
if i % 2 == 0 { continue; }
total = total + i;
}
printf(fmt: "odd sum 1..20: %d\n", total);
ret 0;
}
The first loop prints 1 2 Fizz 4 Buzz ... FizzBuzz; the last line is:
odd sum 1..20: 100
if,else ifandelsetake a condition of typei1- a comparison or a logical expression, not an integer.whileis the only repetition keyword besidesfor; there is noloopand noforover a range or a counter.breakleaves the innermost loop;continuegoes to its next turn. Both are legal only inside a loop.- The all-paths rule: an
if/elsewhose every branch returns makes the rest of the function unnecessary. Awhilenever counts as returning, however its body ends.
8. Functions
extern def printf(fmt: *u8, ...) returns (i32);
def double(n: i32) returns (i32)
{
ret n * 2;
}
def add(a: i32, b: i32) returns (i32)
{
ret a + b;
}
def bump(mut n: i32) returns (i32)
{
n = n + 1; /* its own copy; the caller is untouched */
ret n;
}
def divmod(a: i32, b: i32) returns (i32, i32)
{
ret (a / b, a % b); /* two results */
}
def factorial(n: i32) returns (i32)
{
if n <= 1 { ret 1; }
ret n * factorial(n: n - 1);
}
def greet(name: []u8) returns ()
{
printf(fmt: "hello, %s\n", name);
ret;
}
def main() returns (i32)
{
let start: i32 = 41;
let after: i32 = bump(start);
let (q: i32, r: i32) = divmod(a: 17, b: 5);
printf(fmt: "double %d add %d bump %d (start %d)\n",
double(21), add(a: 2, b: 3), after, start);
printf(fmt: "17 / 5 = %d remainder %d\n", q, r);
printf(fmt: "5! = %d\n", factorial(n: 5));
greet("zezpaik");
ret 0;
}
double 42 add 5 bump 42 (start 41)
17 / 5 = 3 remainder 2
5! = 120
hello, zezpaik
- The call rule: a function with one parameter is called positionally (
double(21),greet("zezpaik")); a function with two or more names them (add(a: 2, b: 3)), in any order. There is no ambiguity to get wrong. muton a parameter means the body may reassign its own copy. For an integer or struct the caller never sees it; for a slice it writes through the view and the caller's storage does change (section 9).- Two or more results are written
returns (T, U), returned asret (a, b);, taken apart bylet (q: i32, r: i32) = ..., and forwarded whole byret f();. - Recursion is ordinary: a file is read once, so a function must be declared above its first caller; a forward
extern defdeclaration can introduce a name that is defined later. extern defis C only and may end in...for a varargs function such asprintf.- There are no closures, function pointers, methods, generics, or overloads. A function's types are written out.
9. Sequences: arrays, slices and for
extern def printf(fmt: *u8, ...) returns (i32);
def main() returns (i32)
{
let mut fixed: [5]i32 = [1, 2, 3, 4, 5];
fixed[0] = 10;
let mut page: [8]u8 = [65; 8]; /* 'A' eight times */
let mut text: []u8 = "hello";
text[0] = 72; /* "Hello" */
let mut scratch: []u8 = buffer(16); /* runtime-owned, zeroed */
scratch[0] = 111;
scratch[1] = 107;
scratch[2] = 10;
let words: [][]u8 = ["one", "two", "three"];
printf(fmt: "fixed %d %d %d len %d\n", fixed[0], fixed[1], fixed[4], len(fixed));
print(page[0..8]);
printf(fmt: " (page is %d bytes)\n", len(page[0..8]));
print(text);
printf(fmt: " (text is %d bytes)\n", len(text));
print(scratch[0..3]);
for word in words
{
print(word);
print(" ");
}
printf(fmt: "(%d words)\n", len(words));
let part: []i32 = fixed[1..3];
printf(fmt: "part %d %d, len %d\n", part[0], part[1], len(part));
ret 0;
}
fixed 10 2 5 len 5
AAAAAAAA (page is 8 bytes)
Hello (text is 5 bytes)
ok
one two three (3 words)
part 2 3, len 2
[N]Tis a fixed array: the length is in the type, and an array is a value - assigning it or passing it copies its elements.[]Tis a slice: a length and a pointer, a view of storage something else owns.[]u8is the string type.[][]u8is a list of strings, which is whatargs()andread_dir()answer.[a, b, c]is a literal whose length comes from context.[value; count]repeats a value into an array; the count is a compile-time whole number and must equal the array's length.buffer(count)givescountzeroed bytes owned by the runtime for the program's life. It is the one way to ask for storage at run time; there is noappendand no resizing.len(xs)is a builtin and givesi32. It is one of the names a program may not declare.xs[i]reads;xs[i] = v;writes when the binding ismut. An index outside the sequence traps and nothing is written.xs[a..b]is a half-open range:ais in,bis not. Both bounds are checked, the result is always a slice, and a range cannot be assigned to.for v in xsvisits each element once, in order;vis a new immutable binding each turn;breakandcontinuework as inwhile.- What a write is visible through follows the type. A slice shares its storage, so writing through it is visible through the original. An array is a value, so a slice of an array is a view of a copy; write and read through the slice, not the array.
view_i8(bytes)reads a[]u8as a[]i8without copying: the result has the same bytes and the same length, and a write through it is a write through the original. It is the builtin to reach for when a file of int8 weights is already in memory:
let mut raw: []u8 = buffer(4);
raw[0] = 255;
let mut lanes: []i8 = view_i8(raw);
lanes[0] = -1; /* raw[0] is 255 again */
It is a slice for the lifetime rule, so a view of a local array cannot be returned; a view of a parameter, buffer, string literal or read_file/args/read_dir bytes can. The C back end refuses []i8 until it has a slice (D-41).
- A slice of the function's own frame cannot be returned.
ret local[0..4];wherelocalis a local array is a compile error (this value is a view of storage that dies with the function), because the caller would read a dead frame. A slice of a parameter, abuffer(N), a string literal, or bytes fromread_file/args/read_diris fine: that storage outlives the call. - The longest array is 16,777,216 elements. The compiler sizes the stack from the largest frame (0.38.0): a local array past the driver's 2 MiB default gets a
-Wl,--stack,...link flag automatically, so a 4 MB buffer runs instead of crashing. A--emit-objectbuild (a C shim) has to pass that flag itself, because the compiler does not do its link.
10. Records: struct
extern def printf(fmt: *u8, ...) returns (i32);
struct Point
{
x: i32,
y: i32,
}
struct Segment
{
from: Point,
to: Point,
}
def length_squared(s: Segment) returns (i32)
{
let dx: i32 = s.to.x - s.from.x;
let dy: i32 = s.to.y - s.from.y;
ret dx * dx + dy * dy;
}
def main() returns (i32)
{
let mut s: Segment = { from: { x: 0, y: 0 }, to: { x: 3, y: 4 } };
printf(fmt: "before %d\n", length_squared(s: s));
s.to.x = 6;
printf(fmt: "after %d, to = %d,%d\n", length_squared(s: s), s.to.x, s.to.y);
let p: Point = { x: 1, y: 2 };
let q: Point = p;
printf(fmt: "copy %d,%d\n", q.x, q.y);
ret 0;
}
before 25
after 52, to = 6,4
copy 1,2
- A record declares named fields at file scope. A value is a literal; its type comes from context, and literals nest.
.reads a field, and assigns it when the binding ismut; a path may mix fields and elements (s.to.x, rows[i].total).- A struct is a value: passing it or assigning it copies it. A
mutstruct parameter works on the callee's copy. pub structexports a type from a module; use it qualified (shapesmod::Pair).- A struct has no methods: functions that take it are ordinary functions.
11. Enums
pub enum Scancode
{
A: i32 = 4,
P: i32 = 19,
V: i32 = 25,
W: i32 = 26,
}
- An enum is a named, closed set of values. A member is written
Scancode::P; the barePis not a name in scope. - The type after the colon is the representation:
u8,u16,u32,i32ori64, the same for every member. - Every member states a numeric literal, and no two members may share a name or a value.
- Nominal: two enums with the same members are two types. Only
==and!=work on enum values - no arithmetic, ordering or conversion. - An enum value becomes its representation implicitly at an
extern defboundary; a representation does not become an enum, and one enum does not become another. That refusal is the reason the type exists:
main.zk:5:12: error: an i32 is not a 'Scancode'; write a member of the enum
- An enum is not a struct: no fields, no literal, no methods.
12. match
extern def printf(fmt: *u8, ...) returns (i32);
pub enum Light
{
Red: i32 = 0,
Amber: i32 = 1,
Green: i32 = 2,
}
def next(light: Light) returns (Light)
{
match light
{
Light::Red { ret Light::Green; }
Light::Amber { ret Light::Red; }
Light::Green { ret Light::Amber; }
}
}
def say(light: Light) returns ()
{
match light
{
Light::Red { printf(fmt: "red\n"); }
Light::Amber { printf(fmt: "amber\n"); }
Light::Green { printf(fmt: "green\n"); }
}
ret;
}
def main() returns (i32)
{
let mut light: Light = Light::Red;
let mut step: i32 = 0;
while step < 5
{
say(light: light);
light = next(light: light);
step = step + 1;
}
ret 0;
}
red
green
amber
red
green
Two readings, and the matched value's type decides which:
| matched value | default | rule |
|---|---|---|
| an enum | forbidden | every member must appear in exactly one arm |
an i32 | required | an open set cannot be exhausted |
- An arm is a member list and a braced block, comma-separated:
Light::Red, Light::Amber { ... }or0, 1 { ... }. - No fallthrough, no
=>, no guards, ranges or payloads, and no match over a struct or string. - Over an enum, a missing member is a compile error that names it:
main.zk:21:5: error: match is not complete: Scancode::A and Scancode::V are not handled
- The matched value is evaluated once, even when several arms are tested.
defaultis a fallback wherever it is written, not "the first arm". - If every arm returns, the
matchreturns, satisfying the all-paths rule. If the arms only do something, thematchfalls through to the code after it. - A
breakdirectly in an arm is refused when the C back end is asked (crefusedcasematch_break): C'sbreakwould leave the switch where zezpaik's leaves the loop.
13. Modules
One file can be used from another. mathbox.zk:
pub const Answer: i32 = 42;
pub def double(n: i32) returns (i32)
{
ret n * 2;
}
def secret() returns (i32)
{
ret 7;
}
pub def revealed() returns (i32)
{
ret secret();
}
main.zk:
import mathbox;
extern def printf(fmt: *u8, ...) returns (i32);
def main() returns (i32)
{
printf(fmt: "Answer %d\n", mathbox::Answer);
printf(fmt: "double(21) %d\n", mathbox::double(n: 21));
printf(fmt: "revealed %d\n", mathbox::revealed());
ret 0;
}
Answer 42
double(21) 42
revealed 7
- A file is a module.
import mathbox;readsmathbox.zkbeside the importing file (or inlib/, which is where the standard library lives). pubis what another module can reach. Everything else is private to its file.- A name from another module is qualified:
mathbox::double,mathbox::Answer. There is no unqualified import and no alias. - A type from another module is qualified too:
shapesmod::Pair,shapesmod::Kind::First.importdoes not put the bare type name in scope. - A file is read once: a function must be declared above its first use. An
extern defforward declaration introduces a name defined later in the same file. extern defis C only; it does not import a.zkmodule.- The compiler follows imports itself:
zezpaik main.zk -o app.exeneeds no second operand. Several files on the command line are one program (no imports needed between them).
14. Files, arguments and directories
import io;
import args;
extern def printf(fmt: *u8, ...) returns (i32);
def main() returns (i32)
{
let path: []u8 = "tutorial.tmp";
io::write(path: path, text: "alpha\nbeta\n");
let text: []u8 = io::read(path);
print(text);
printf(fmt: "bytes %d lines %d\n", len(text), io::line_count(path));
printf(fmt: "exists %d is_dir %d missing %d\n",
exists(path), is_dir("."), exists("no-such-file.tmp"));
let names: [][]u8 = read_dir(".");
let mut found: i32 = 0;
for name in names
{
if args::same_text(a: name, b: path) { found = 1; }
}
printf(fmt: "entries %d wrote %d\n", len(names), found);
ret 0;
}
alpha
beta
bytes 11 lines 2
exists 1 is_dir 1 missing 0
entries 1 wrote 1
- A path is a
[]u8, the same text type as everything else. read_file(path)answers the whole file as a[]u8;write_file(path: p, text: t)writes it. Theiomodule is the library API over them:io::read,io::write,io::copy,io::line_count,io::first_line.read_dir(path)answers a[][]u8of names, in the file system's order (the order is not promised)..and..are never included.exists(path)andis_dir(path)answeri1and never trap: they are quiet questions.args()is the program's own operands as a[][]u8, not counting the program's name. For the interpreter, put them after--:zezpaik --interpret calc.zk -- add 2 3. It carries at most 64 operands, and more than that traps withtoo many operands: args() carries at most 64rather than quietly shortening the list.read_dircarries at most 512 names, and a folder past that traps withtoo many names: read_dir carries at most 512; a short listing would make its count a lie.- A file that cannot be opened stops the program with exit 2 and a message naming the path. There is no "returns empty" and no error value to check.
mkdir(path)makes one folder (0.37.0). It is one level, never a tree, and a folder that is already there is success - so a second run over the same folder is safe. A missing parent, or a file occupying the name, stops the program with exit 2 andcannot create '<path>':
mkdir("objects");
mkdir("objects/ab"); /* one level at a time: the parent came first */
mkdir("objects"); /* already there: success */
main.zk:3:5: error: cannot create 'objects/ab'
- No standard input builtin and no file metadata (size/time): see section 20.
15. Errors, traps and exit codes
There are two very different failures:
| when | output | exit | |
|---|---|---|---|
| compile error | before the program runs | <file>:<line>:<col>: error: ... on stderr | 1 |
| trap | while it runs | <file>:<line>:<col>: error: ... on stderr | 2 |
A trap is defined behaviour: the program stops at a known point with a sentence, instead of reading whatever memory happens to be there. These trap:
- signed or unsigned overflow in
+,-,*, unary-; /or%by zero, and the one minimum/-1 case;- a shift count outside the type's width;
- an index outside an array or slice, or a bad slice range;
- a narrowing conversion that does not fit (
value 300 does not fit in u8); assert(condition);when the condition is false;- a file that cannot be opened or written;
bufferwith a count that cannot be honoured.
assert quotes the condition exactly as it was written:
assert(count == 0);
main.zk:5:5: error: assertion failed: count == 0
There are no exceptions, no try/catch, no error values and no recovery: a trap ends the program. A library function that should be loud uses assert; one that should answer quietly returns a flag (num::parse_int answers (value, ok)).
16. The standard library
The library is written in zezpaik, in lib/, and the compiler finds it beside itself. It allocates nothing: its functions view the caller's bytes, parse them, format into a caller's buffer, or print.
import text;
import num;
import csv;
import date;
extern def printf(fmt: *u8, ...) returns (i32);
def main() returns (i32)
{
let (value: i32, ok: i1) = num::parse_int("-42");
let (bad: i32, bad_ok: i1) = num::parse_int("4x");
printf(fmt: "parse %d %d / %d %d\n", value, ok, bad, bad_ok);
let mut number: [12]u8 = [0; 12];
let mut buf: []u8 = number[0..12];
let used: i32 = num::format_int(value: 12345, buf: buf);
print(buf[0..used]);
printf(fmt: " (%d bytes)\n", used);
let line: []u8 = "sato,tanaka,,suzuki";
printf(fmt: "fields %d\n", text::field_count(line: line, separator: 44));
print(text::field(line: line, index: 1, separator: 44));
printf(fmt: "\n");
print(text::trim(" padded "));
printf(fmt: "\n");
let row: []u8 = "a,\"b,c\",d";
printf(fmt: "csv fields %d\n", csv::field_count(row: row));
print(csv::field_at(row: row, index: 1));
printf(fmt: "\n");
printf(fmt: "2024 leap %d Feb %d days 2023 leap %d\n",
date::is_leap(2024), date::month_length(year: 2024, month: 2),
date::is_leap(2023));
ret 0;
}
parse -42 1 / 0 0
12345 (5 bytes)
fields 4
tanaka
padded
csv fields 3
"b,c"
2024 leap 1 Feb 29 days 2023 leap 0
The core modules a new program is likely to want:
| module | what it is for | a function to start with |
|---|---|---|
io | whole files and storage | io::read, io::write, io::copy |
text | what bytes mean: trim, fields, lines | text::trim, text::field, text::line |
num | text <-> i32 | num::parse_int, num::format_int |
utf8 | walking UTF-8 text | utf8::length, utf8::at |
strings | building text in a buffer | strings::put, strings::find |
csv | RFC 4180 quoting in both directions | csv::field_count, csv::field_at |
sha256 | one hash into a 32-byte output | sha256::hash |
date | civil dates and the packed stamp | date::is_leap, date::days_from_civil |
field | an editable text field (caret, capacity) | field::load, field::insert |
bytes | hex, little/big-endian words, IEEE-754 as bits | bytes::hex_into, bytes::u32le |
html | building HTML and SVG | html::open, html::paragraph_text |
http | what a request means and what an answer says | http::parse_request, http::status_name |
mail | RFC 5322 headers and bodies | mail::parse_headers, mail::unfold |
nn | integer-only inference: int8 matrices, layers, a bump arena | nn::layer, nn::forward |
cnn | convolution and pooling over int8 lanes, integer-only | cnn::conv2d, cnn::max_pool2d |
zkm | the ZKM1 model file: a checked header, a tensor table, a digest | zkm::verify, zkm::find, zkm::body |
bmp | a 24/32-bit BMP as one grey byte per pixel, and a plane back as a BMP | bmp::info, bmp::gray, bmp::write_gray |
There are 88 modules in lib/, from ledger and worksheet to raytrace, mesh, ui, app, canvas, tone and wav. Every file starts with a comment that states its contract, and every public declaration is pub def/pub const/pub struct/pub enum. Read lib/ - the first thirty lines of a module are its manual.
The newest modules are cnn and zkm, the two halves of a picture model: cnn is the arithmetic - a filter that slides over a plane, a window reduced to its largest value or to its mean, all over int8 lanes - and zkm is the file that says which bytes are which. Neither knows what the other is; a program checks a model with zkm, views a tensor with view_i8 and runs it with cnn and nn:
import cnn;
import zkm;
if zkm::verify(data: model) != 0
{
print("model: not a ZKM1 file\n");
ret 1;
}
let w: []i8 = view_i8(zkm::body(data: model, index: zkm::find(data: model, want: "conv1.w")));
nn is the third: integer-only inference for a model converted to int8 outside the language. A Matrix is a row-major view of the model's own bytes, a Layer carries weights, bias and the requantise shift, Network runs two of them, and Arena bump-allocates activations in a pool the caller owns. There is no float and no allocator:
import nn;
let mut pool: []u8 = buffer(4096);
let mut arena: nn::Arena = nn::arena(pool);
let scratch: []u8 = nn::take(a: arena, n: 256);
The C back end refuses the slice paths (crefused); the interpreter and native build agree.
17. Projects and the SDK: zeko
A zezpaik project is a folder with a zeko.txt and a source file:
myapp/
zeko.txt
src/main.zk
zeko.txt:
name hello
source src/main.zk
src/main.zk:
extern def printf(fmt: *u8, ...) returns (i32);
def main() returns (i32)
{
printf(fmt: "hello from a zeko project\n");
ret 0;
}
cd myapp
zeko run
zeko: hello: building src/main.zk
hello from a zeko project
zeko always rebuilds, on purpose. The language has no way to ask a file's age, and a binary built by last week's compiler is the bug the tool exists to prevent.
| command | what it does |
|---|---|
zeko new myapp | writes a standard folder with src/main.zk and a .gitignore |
zeko run | builds, then runs the project's main program |
zeko build | builds it |
zeko check | builds, then runs every step and check line |
zeko clean | removes the object, the executable and the .actual files |
zeko doctor | asks every input a build needs and gives a verdict |
zeko kit <folder> | writes the portable folder (compiler, lib, shim) |
zeko <task> | builds, then runs a task line |
18. Windows and the C back end
A console program needs nothing but the compiler and a linker. A window program links the platform layer in shim/ and SDL3 statically, so the output is still one self-contained executable. A minimal window program (the SDK's samples/window) is:
extern def printf(fmt: *u8, ...) returns (i32);
extern def ui_open(title: *u8, width: i32, height: i32) returns (*u8);
extern def ui_close(handle: *u8) returns ();
extern def ui_color(handle: *u8, red: i32, green: i32, blue: i32) returns ();
extern def ui_clear(handle: *u8) returns ();
extern def ui_rect(handle: *u8, x: i32, y: i32, width: i32, height: i32) returns ();
extern def ui_text(handle: *u8, x: i32, y: i32, text: []u8) returns (i32);
extern def ui_present(handle: *u8) returns ();
extern def ui_pump(handle: *u8, millis: i32) returns (i32);
def main() returns (i32)
{
let win: *u8 = ui_open(title: "zezpaik", width: 640, height: 360);
let title: []u8 = "hello from a window";
ui_color(handle: win, red: 18, green: 18, blue: 22);
ui_clear(handle: win);
ui_color(handle: win, red: 235, green: 205, blue: 120);
ui_rect(handle: win, x: 40, y: 40, width: 560, height: 280);
ui_color(handle: win, red: 20, green: 20, blue: 20);
ui_text(handle: win, x: 64, y: 72, text: title);
ui_present(handle: win);
let mut frame: i32 = 0;
while frame < 3
{
ui_pump(handle: win, millis: 16);
frame = frame + 1;
}
ui_close(handle: win);
ret 0;
}
Its zeko.txt names the shim and the libraries:
name window
source src/main.zk
shim ..\..\shim\ui_sdl3.c ..\..\shim\screen.c
libs -Wl,-Bstatic -lSDL3 -lSDL3_image -Wl,-Bdynamic -lkernel32 -luser32 -lgdi32 -lwinmm -limm32 -lole32 -loleaut32 -lversion -luuid -ladvapi32 -lsetupapi -lshell32 -ldinput8 -mwindows
With SDL_VIDEODRIVER=dummy and SDL_RENDER_DRIVER=software, zeko run builds and exits 0 with no display, which is how the project checks frames.
--emit-c is the third implementation. It prints C99 over <stdint.h> for the scalar core, struct, conversions, assert, enum and match. A construct it cannot carry is a compile error naming it, never a silent difference:
main.zk:40:33: error: the C back end does not support the type '[]u8' yet:
use the native back end (-o) or --interpret
The emitted file compiles clean at -Wall -Wextra -Werror. --target linux-x86_64 emits an ELF object instead of a Windows one; a Linux cc links it.
19. Cookbook
wc - bytes, lines and words of a file
import io;
import text;
extern def printf(fmt: *u8, ...) returns (i32);
def main() returns (i32)
{
let operands: [][]u8 = args();
if len(operands) != 1
{
printf(fmt: "usage: wc <file>\n");
ret 1;
}
let path: []u8 = operands[0];
let body: []u8 = io::read(path);
let mut words: i32 = 0;
let mut inside: i1 = 0 == 1;
for byte in body
{
if text::is_space(byte)
{
inside = 0 == 1;
}
else
{
if inside == (0 == 1)
{
words = words + 1;
inside = 1 == 1;
}
}
}
printf(fmt: "%d bytes %d lines %d words %s\n",
len(body), io::line_count(path), words, path);
ret 0;
}
> zezpaik --interpret wc.zk -- examples/hello.zk
1072 bytes 29 lines 171 words examples/hello.zk
calc - arguments, parsing, and a dispatch
import num;
import args;
extern def printf(fmt: *u8, ...) returns (i32);
def main() returns (i32)
{
let a: [][]u8 = args();
if len(a) != 3
{
printf(fmt: "usage: calc <add|sub|mul> <a> <b>\n");
ret 1;
}
let (left: i32, left_ok: i1) = num::parse_int(a[1]);
let (right: i32, right_ok: i1) = num::parse_int(a[2]);
if left_ok == (0 == 1) || right_ok == (0 == 1)
{
printf(fmt: "calc: both operands must be integers\n");
ret 1;
}
let mut result: i32 = 0;
if args::same_text(a: a[0], b: "add") { result = left + right; }
else if args::same_text(a: a[0], b: "sub") { result = left - right; }
else if args::same_text(a: a[0], b: "mul") { result = left * right; }
else
{
printf(fmt: "calc: unknown operation '%s'\n", a[0]);
ret 1;
}
printf(fmt: "%d\n", result);
ret 0;
}
> zezpaik --interpret calc.zk -- add 2 3
5
> zezpaik --interpret calc.zk -- mul 6 7
42
A self-checking test
A test in zezpaik is a program: compare, count failures, exit 1 if any failed. No framework is needed.
extern def printf(fmt: *u8, ...) returns (i32);
pub enum Command { Add: i32 = 0, Remove: i32 = 1, Quit: i32 = 2 }
def code(command: Command) returns (i32)
{
match command
{
Command::Add { ret 10; }
Command::Remove { ret 11; }
Command::Quit { ret 13; }
}
}
def expect(got: i32, want: i32, id: i32) returns (i32)
{
if got != want
{
printf(fmt: "FAIL test %d: got %d, want %d\n", id, got, want);
ret 1;
}
printf(fmt: "ok test %d\n", id);
ret 0;
}
def main() returns (i32)
{
let mut failed: i32 = 0;
failed = failed + expect(got: code(Command::Add), want: 10, id: 1);
failed = failed + expect(got: code(Command::Remove), want: 11, id: 2);
failed = failed + expect(got: code(Command::Quit), want: 13, id: 3);
if failed != 0 { ret 1; }
printf(fmt: "all tests passed\n");
ret 0;
}
ok test 1
ok test 2
ok test 3
all tests passed
20. What is not in the language (yet)
The absences are decisions, not gaps:
- No garbage collector and no managed runtime, ever (D-52). A program is one self-contained executable.
- No allocation by a program: no
append, no resizing, no growable sequence. Storage is a fixed array,buffer(N), a file the runtime reads, the command line, or a directory listing. - No floating point, no
charliteral, nobool:i1is the boolean type and[]u8is text. - No classes, generics, closures, interfaces or function pointers; no address-of.
- No exceptions and no recovery from a trap; no
try/catch. - No
forover a range or a counter:for v in xswalks a sequence;whilecovers the rest. - No
null, no ternary?:, noswitch(usematch), no+=/++. - No strings as a distinct type: a string is
[]u8; useutf8to walk characters. - No standard input builtin and no file size/time builtin.
mkdiris one level, not a tree: the parent is a second call, and a missing parent traps.- The interpreter is 64-bit:
i128/u128need the native back end, and no 128-bit value can be printed. - The C back end does not carry slices, arrays,
for,print, the file builtins,args,read_diror multi-value results yet. It refuses them by name; use the native back end or--interpret.
21. Where to read more
The shortest path from here: copy the hello program, change the text, run it; then copy calc, add an operation, run it with arguments; then write a struct and a match over an enum. The corpus and the examples are the reference for every step in between.