of Eskiu
A complete introduction to systems programming
v0.9.2, 2026
Close to the metal. Far from the noise.
This book is for everyone who will learn from it, and for those who made sure I was still standing when I wrote it: my mother, my grandparents, my husband, and three cats who never once doubted me.
Contents
Part I: Tutorial
Part II: Language Reference
Why this book exists
This book teaches Eskiu from zero. You do not need to know any other programming language. You need a computer, a terminal, and the willingness to type things and see what happens.
Programming is not a subject you read about and then understand. It is something you do. Every example in this book is short enough to type by hand. Type them. Run them. Change a number and see what breaks. That is how the knowledge becomes yours.
Eskiu is a systems language with the power of C and the immediacy of a scripting language. It compiles to native code through LLVM, giving you native performance, explicit memory, and direct access to any C library, so it does the work you would normally reach for C to do. But it spares you C's ceremony for small things: eskiuc run file.esk compiles and runs in a single step, and a #!/usr/bin/env eskiuc run shebang turns a .esk file into an executable script, the way Python or Ruby would. The whole toolchain is a single compiler driven by a single build command, and the language stays small enough to keep in your head.
The language has no garbage collector. Memory is yours to manage. You ask for it when you need it and give it back when you are done. This sounds more intimidating than it is. By the end of Chapter 6 you will do it without thinking.
Every chapter follows the same pattern. A concept is explained in plain language. Then shown in code. Each chapter ends with a summary and exercises. Do the exercises, even the easy ones, especially the easy ones.
By the end you will have read the complete language, managed memory explicitly, called C libraries directly, spawned threads, and read through a real cryptographic pipeline that runs 2.5× faster than the hand-written C it replaced.
Eskiu is named after a cat.
Tiny → Minusculiny (minúscula + tiny) → Kulini → Eskiulini → Eskiu.
As of v0.3.0, Eskiu is self-hosting: the whole compiler (lexer, preprocessor, parser, type checker, and code generator) is written in Eskiu itself (selfhost/), reaches a three-stage bootstrap fixpoint, and its code generator is feature-complete against the reference C++ compiler. Chapter 27 tells that story.
Hello, Eskiu
Every journey in programming starts with a single program that does almost nothing. It prints a line of text and exits. This is Hello World, a tradition as old as the C language itself. Its purpose is not the output. Its purpose is to prove that your tools work: the compiler is installed, you can type code, and the result runs on your machine. Everything else builds on that.
1.1 Installing the compiler
Go to eskiu-lang.org and download the binary for your platform. Unpack the archive and copy eskiuc to somewhere on your PATH. /usr/local/bin works on both macOS and Linux. You will also need Clang to link your programs:
# macOS xcode-select --install
# Ubuntu / Debian sudo apt install clang
# Verify eskiuc --version clang --version
1.2 Your first program
Create a file called hello.esk:
extern int printf(string fmt, ...);
int main() {
printf("Hello, Eskiu!\n");
return 0;
}
Compile and run:
eskiuc hello.esk -o hello # compile + link in one step ./hello
Output: Hello, Eskiu!
1.3 What just happened
The first command compiled and linked your program in one step. eskiuc invokes the LLVM backend to produce a native object file, then automatically calls the system C toolchain ($CC, then cc / clang / gcc) to link it into an executable, the same approach rustc and clang use. The second command runs the result.
To stop at the object file (for example to link multiple files yourself), give the output a .o suffix or pass -c:
eskiuc hello.esk -c -o hello.o # object file only, no linking
1.4 Line by line
The first line declares a function from the C standard library:
extern int printf(string fmt, ...);
extern means: this function exists somewhere else. I am just telling you its name and signature. The ... means it accepts any number of additional arguments after the format string.
The main function is where every Eskiu program begins. The OS calls it when you run the program. Returning 0 signals success.
Inside main, printf prints the format string. \n is a newline. It moves the cursor to the next line after printing.
1.5 Making a change
The best way to understand code is to change it. Try printing a number:
printf("The answer is %d\n", 42);
Recompile and run. The %d placeholder is replaced by 42.
Variables and Types
A variable is a named location in memory that holds a value. In Eskiu every variable has a type, and that type never changes. The compiler uses the type to know how much memory to reserve, how to interpret the bits stored there, and what operations are valid. Types are not bureaucracy; they are the compiler's way of catching mistakes before your program runs.
2.1 Declaring a variable
Two equivalent forms:
let x: int = 5; // let-style with type annotation int x = 5; // C-style
Both are identical to the compiler. The let form is useful when the annotation adds clarity. The C-style form is more compact.
2.2 The primitive types
Eskiu's built-in types:
Type Size Example Use it for
int 32 bits 42, -7 Everyday whole numbers
int64 64 bits 9999999999 Large values, file sizes, timestamps
uint8 8 bits 0–255 Bytes, raw binary data, pixel values
uint32 32 bits 0–4 billion Large unsigned counts, bit masks
float 32 bits 3.14 Decimal numbers
double 64 bits 3.14159265 High-precision decimal numbers
bool 1 bit true, false Yes/no conditions
char 8 bits 'A', '\n' Single byte, unsigned (0–255)
string pointer "hello" Immutable text literals
Eskiu also has int8, int16, int32, uint16, uint64. For most code, int and uint8 cover the majority of cases.
Integer literals can be written in hexadecimal with a 0x prefix, and a leading 0 means octal, as in C (017 is 15). A literal with a digit its base does not allow (08), a bare 0x, or a suffix of any kind (0b101, 1_000, 3.5f) is a compile error. Character and string literals support C's escapes: \n \t \r \f \v \a \b \\ \" \' \?, an octal escape \NNN of one to three digits ("\101" is A, "\012" a newline, \0 a NUL byte), and \xNN, which writes a raw byte from one or two hex digits (so "\xC3\x91" is Ñ in UTF-8). Any other escape (\q), an octal escape above \377 and a \x with no hex digit are compile errors located at the backslash. char is an unsigned 8-bit type (0–255); when widened to int it zero-extends, not sign-extends:
int flags = 0xFF; // hex literal = 255 uint32 mask = 0xDEADBEEF; // larger hex
char a = 'A'; // character literal char nl = '\n'; // newline escape char bs = '\\'; // backslash char sq = '\''; // single quote
A string can be indexed to read individual characters: s[0] is the first char. Adjacent string literals are joined automatically, which keeps long text readable:
string s = "Eskiu";
printf("%c%c\n", s[0], s[4]); // Eu
string msg = "Hello, " "world!"; // adjacent literals concatenate
printf("%s\n", msg); // Hello, world!
2.3 Explicit casting
Numeric conversions are mostly implicit: Eskiu widens and narrows between integer and float widths on its own. The one numeric conversion that needs an explicit cast is float to integer, which discards the fractional part. Pointer reinterpretation is always explicit too:
double x = 3.99; int n = (int)x; // 3: fractional part discarded uint8 b = (uint8)n; // fine if n fits in 0–255 float f = (float)n; // widen to float
Arithmetic follows C's integer promotions. An operand narrower than int (bool, char, int8, int16, uint8, uint16) becomes an int before the operation, so the result does not wrap at 8 or 16 bits and comparisons between signed and unsigned bytes behave as they do in C. The value only narrows again when you store it into a narrow variable:
uint8 a = (uint8)200; int s = a + (uint8)100; // 300: both operands became int int8 m = (int8)-1; bool gt = a > m; // true: 200 > -1
When the two operands still differ after promotion, C's usual arithmetic conversions pick the common type: the wider operand wins, and at equal width an unsigned operand makes the result unsigned. A shift takes the type of its left operand. An integer literal too large for int is an int64, so it widens the expression it appears in:
uint32 u = 5;
int32 i = -1;
printf("%u\n", i + u); // 4: int32 + uint32 is uint32
printf("%d\n", i < u); // 0: -1 converts to 4294967295
int64 big = 3000000000 * 2; // 6000000000: the literal is an int64
printf("%lld\n", big);
Output: 4 0 6000000000
Converting to bool tests against zero. (bool)2, (bool)0.5 and a non-null pointer are all true; only 0, 0.0 and null are false.
2.4 A complete example
extern int printf(string fmt, ...);
int main() {
int age = 28;
double height = 1.75;
bool active = true;
char grade = 'A';
string name = "Eduardo";
printf("Name: %s\n", name);
printf("Age: %d\n", age);
printf("Height: %.2f\n", height);
printf("Grade: %c\n", grade);
printf("Active: %d\n", active);
return 0;
}
Output: Name: Eduardo Age: 28 Height: 1.75 Grade: A Active: 1
2.5 Global variables
A variable declared outside any function is a global. It exists for the entire run of the program and is visible to every function in the file. Reach for globals sparingly, for configuration or shared state that genuinely must outlive a single call:
extern int printf(string fmt, ...);
int counter = 0; // global
void bump() { counter = counter + 1; }
int main() {
bump();
bump();
bump();
printf("%d\n", counter); // 3
return 0;
}
Output: 3
A global's initializer is computed at compile time, as in C, so it must be a constant expression: literals, arithmetic and casts on them, ternaries, sizeof, enum members, const globals, and the address of another global. A function call or a read of an ordinary (non-const) global is a compile error. Initialize such a global inside main instead:
const int BASE = 10; int limit = BASE * 4; // fine: folded to 40 int* where = &limit; // fine: address of a global // int start = compute(); // error: not a compile-time constant // int twice = limit * 2; // error: limit is not const
A global exists from its definition onward, as in C. A global initializer or a function body written above the definition cannot name it ("undefined variable"), so define shared globals near the top of the file, before the functions that use them. Functions are different: they can be called before they are defined (section 3.5).
2.6 const: immutable bindings
A const binding cannot be reassigned after its initial value. Unlike a plain variable, a const int can be used as an array size:
const int CAP = 4; int[CAP] xs; // const as array size
struct Ring { int[CAP] slots; } // const in struct field
const let base: int = 10; // const with let form // base = 11; // compile error: const cannot be reassigned
const applies to the binding: the variable cannot be reassigned. For pointer constness, see const T* (pointer to read-only data) and T* const (read-only pointer, writable data):
int[3] arr; arr[0] = 1; arr[1] = 2; arr[2] = 3; const int* r = &arr[0]; // cannot write through r // r[0] = 99; // compile error r = &arr[1]; // rebinding the pointer is fine
2.6b static: locals that persist
A static local variable has a single instance that lives for the whole program, not just one call, so it keeps its value between calls (C storage semantics). It is initialised once, and its initialiser must be a compile-time constant. A classic use is a counter that remembers how many times a function ran:
int next() {
static int c = 0; // initialised once, at load time
c += 1;
return c;
}
printf("%d %d %d\n", next(), next(), next()); // 1 2 3
Output: 1 2 3
A static local declared without an initializer starts at zero. A closure that uses a static local refers to that one variable, the same way it would refer to a global, so a change made through the closure is visible to later calls. Two static locals in different functions never share storage, even with the same name. static on a global is rejected, since a global already persists for the whole program.
2.7 enum: named integer constants
An enum assigns names to integer values. Values start at 0 and increment by 1 unless you set them explicitly. After an explicit value, numbering continues from there:
enum Color { Red, Green, Blue } // 0, 1, 2
enum Status { Ok = 0, Err = 2, Pending } // 0, 2, 3
An explicit value may be any integer constant expression over literals, earlier members, const integers and sizeof, folded like a global initializer. Each value must fit an int:
const int SHIFT = 2;
enum Flag { A = 1, B = A << SHIFT, C = B | A, D = sizeof(int64) } // 1, 4, 5, 8
The conditional and logical operators work there as well, ?:, &&, || and !, with C's short-circuit rules, both in the member value and in a const it names:
const int WIDE = 1;
enum Cfg { Bits = WIDE ? 64 : 32, Fast = WIDE && Bits > 32, Slow = !Fast || Bits < 16 } // 64, 1, 0
Enum members are global integer constants used directly by name:
Color c = Green;
printf("%d\n", c); // 1
switch (c) {
case Red: printf("red\n"); break;
case Green: printf("green\n"); break;
default: printf("other\n"); break;
}
Output: 1 green
You can also match an enum, which the compiler checks is exhaustive: every variant must be handled (or a _ default). Add a fourth colour later and every match that forgot it fails to compile, which a switch would let slide.
match c {
Red -> { printf("red\n"); }
Green -> { printf("green\n"); }
Blue -> { printf("blue\n"); }
}
An explicit value is an integer constant expression, folded with C's rules for int: 32-bit arithmetic that wraps, and unsigned operands compared and divided as unsigned. So (uint)3 - (uint)5 > 100 is 1, and 2147483647 + 1 is -2147483648, as in clang. Array dimensions and case labels fold the same way.
Two members may share a value, as in C (enum Level { Low = 1, Mid = 2, Medium = 2 }). A match works on the value, so one arm covers every member with that value, and a second arm for the same value is an error. Declaring the same member name twice, or reusing a member name for a function or global, is also an error.
2.8 type: aliases
A type alias gives an existing type a new name. The alias and the original are interchangeable. This is Eskiu's equivalent of C's typedef:
type u8 = uint8; type Bytes = *uint8; type Int = int;
Once defined, the alias can be used anywhere the original type can, including variable declarations, function parameters, return types, and alloc:
Int add(Int a, Int b) { return a + b; }
u8 b = 200;
printf("%d\n", b); // 200
Bytes buf = alloc<u8>(4);
buf[0] = 65; buf[1] = 66;
printf("%d %d\n", buf[0], buf[1]); // 65 66
free(buf);
printf("%d\n", add(3, 4)); // 7
Output: 200 65 66 7
An alias is its target also inside a larger type. Box<Int> is the same type as Box<int>, and an alias for fn(int)->Int is the same type as fn(int)->int, so values pass between the two spellings without a conversion and the compiler generates one copy of the generic code for both:
struct Box<T> { T v; }
int unbox(Box<int> b) { return b.v; }
type Op = fn(int)->Int;
int twice(int x) { return x * 2; }
Box<Int> bx = Box<Int>{ v: 20 };
printf("%d\n", unbox(bx)); // 20: Box<Int> is Box<int>
fn(int)->int f = twice;
Op g = f;
printf("%d\n", g(21)); // 42
Output: 20 42
Functions
A function is a named, reusable block of code. You define it once and call it as many times as you need. Functions are the primary tool for breaking a program into manageable pieces. A good function does one job and can be understood on its own.
3.1 Defining a function
Return type first, then name, then parameters, then body:
int add(int a, int b) {
return a + b;
}
int result = add(3, 4); // 7
3.2 Void functions
Functions with no return value use void:
void greet(string name) {
printf("Hello, %s!\n", name);
}
greet("world"); // Hello, world!
A call to a void function has no value, so it can only stand as a statement. Using it as an operand of &&, ||, ! or a comparison, as a condition, or as an argument (printf("%d", greet("x"))) is a compile error. Two forms are allowed because they produce no value either: return greet(name); inside another void function, and a ternary statement whose two arms are both void calls (ok ? greet("a") : greet("b");).
3.3 Parameters are copies
When you pass a value, the function gets its own copy. Changes to the copy stay inside the function. Pointers (Chapter 6) are how you let a function modify the caller's data:
void try_to_double(int x) {
x = x * 2;
printf("inside: %d\n", x);
}
int n = 5;
try_to_double(n);
printf("outside: %d\n", n); // still 5
Output: inside: 10 outside: 5
3.4 Recursion
A function can call itself. Useful when a problem is naturally defined in terms of a smaller version of itself:
int factorial(int n) {
if (n <= 1) return 1;
return n * factorial(n - 1);
}
printf("%d\n", factorial(5)); // 120
Warning Recursive calls consume stack space. For large inputs, a loop is safer.
3.5 Declaration order and forward declarations
Definition order does not matter. A function may call another that is defined later in the same file, and two functions may call each other. You can also write a body-less forward declaration (a signature ending in a semicolon) when you prefer to state the signature up front:
int is_odd(int n); // forward declaration (optional)
int is_even(int n) {
if (n == 0) return 1;
return is_odd(n - 1); // defined below, works
}
int is_odd(int n) {
if (n == 0) return 0;
return is_even(n - 1);
}
printf("%d %d\n", is_even(10), is_odd(10)); // 1 0
Output: 1 0
A forward declaration must match the definition exactly: the same return type and the same parameter types. A mismatch is a conflicting declaration error. Defining the same function twice, giving two parameters the same name, or using one name for both a function and a global variable is also rejected.
The freedom of order covers functions only. A global variable has to be defined before the code that reads it, as section 2.5 explains.
3.6 Command-line arguments
To read what the user typed on the command line, give main two parameters: argc, the number of arguments, and argv, an array of strings. argv[0] is always the program's own name, so the first real argument is argv[1]:
extern int printf(string fmt, ...);
int main(int argc, string* argv) {
printf("argc = %d\n", argc);
for (int i = 0; i < argc; i += 1) {
printf("argv[%d] = %s\n", i, argv[i]);
}
return 0;
}
$ ./prog hello 42 argc = 3 argv[0] = ./prog argv[1] = hello argv[2] = 42
3.7 Variadic functions
A function accepting a variable number of extra arguments uses ... as its last parameter. Read the extras with va_list, va_start, va_arg<T>, and va_end:
int sumv(int n, ...) {
va_list ap;
va_start(ap);
int total = 0;
for (i in 0..n) { total += va_arg<int>(ap); }
va_end(ap);
return total;
}
printf("%d\n", sumv(3, 10, 20, 30)); // 60
C default promotions apply: float args arrive as double. Read with va_arg<double>. At least one fixed parameter is required.
Control Flow
A program that executes every statement exactly once in order is not very useful. Real programs make decisions, repeat actions, and skip sections. Control flow is the set of tools that makes this possible.
4.1 if / else if / else
int score = 74;
if (score >= 90) {
printf("A\n");
} else if (score >= 80) {
printf("B\n");
} else if (score >= 70) {
printf("C\n");
} else {
printf("F\n");
}
Output: C
A condition can be a bool, a number or a pointer (a string included). A nonzero number and a non-null pointer count as true, as in C, and a floating-point condition tests != 0.0. A struct, an array or any other aggregate is not a condition; test one of its fields instead. The same rule applies to while, for, !, &&, || and the ternary.
4.2 while
Repeats while the condition is true. The condition is checked before each iteration:
int n = 1;
while (n <= 5) {
printf("%d ", n);
n += 1;
}
Output: 1 2 3 4 5
4.3 for
Packs initialiser, condition, and step into one line. The loop variable is scoped to the body:
for (int i = 0; i < 5; i += 1) {
printf("%d ", i);
}
Output: 0 1 2 3 4
+=, -=, *=, /=, %= are shorthand for x = x op y.
4.4 break and continue
// Find first multiple of both 3 and 7
for (int i = 1; i <= 100; i += 1) {
if (i % 3 == 0 && i % 7 == 0) {
printf("found: %d\n", i); // 21
break;
}
}
// Print only odd numbers
for (int i = 1; i <= 9; i += 1) {
if (i % 2 == 0) continue;
printf("%d ", i); // 1 3 5 7 9
}
A plain break or continue acts on the loop it sits in. When you nest loops and want to break out of an outer one, give that loop a label and name it. A label is an identifier and a colon written just before the loop:
// Stop as soon as we find the target in a grid
outer: for (int i = 0; i < rows; i += 1) {
for (int j = 0; j < cols; j += 1) {
if (grid[i][j] == target) {
printf("found at %d,%d\n", i, j);
break outer; // leaves both loops
}
}
}
continue outer works the same way, skipping to the next iteration of the named loop. Any defer you registered inside the inner loop still runs before control jumps, exactly as it does for an unlabeled break.
4.5 switch
Cleaner than a chain of else-if when dispatching on specific integer values. Always end each case with break:
int day = 3;
switch (day) {
case 1: printf("Monday\n"); break;
case 2: printf("Tuesday\n"); break;
case 3: printf("Wednesday\n"); break;
default: printf("Other\n"); break;
}
Output: Wednesday
A case label must be a compile-time constant: a literal, an enum member, a const integer, or an expression built from them (case BASE + 1:). A second default: in the same switch is an error.
A declaration may follow a case or default label directly, without braces. The whole switch body is one scope, as in C, so a variable declared under one case is visible in the cases after it, and two cases may not declare the same name. Give a case its own braces when its variables should stay private to it:
switch (day) {
case 1:
int hours = 8; // declared right after the label
printf("%d\n", hours);
break;
case 2:
{ int hours = 6; printf("%d\n", hours); } // braces: a separate scope
break;
default:
break;
}
4.6 for-in
The for-in loop iterates over a collection without managing an index. It works over fixed-size arrays and List<T> (or any struct with data and size fields):
// Fixed-size array
int[4] vals = {10, 20, 30, 40};
for (v in vals) {
printf("%d ", v);
}
// Output: 10 20 30 40
// List<T> import <list>; List<int> nums; List_init(&nums, 4); List_push(&nums, 1); List_push(&nums, 2); List_push(&nums, 3);
int total = 0;
for (n in nums) {
if (n == 2) { continue; } // continue correctly advances the index
total += n;
}
printf("%d\n", total); // 4 (1 + 3)
List_free(&nums);
Output: 10 20 30 40 4
For-in also works on half-open integer ranges A..B, iterating A, A+1, … B-1. The upper bound is exclusive. An empty range (A ≥ B) runs zero times:
// Range: 0, 1, 2, 3, 4
int s = 0;
for (i in 0..5) { s += i; }
printf("%d\n", s); // 10
// Variable bounds
int lo = 2; int hi = 6;
for (j in lo..hi) { printf("%d ", j); }
// Output: 2 3 4 5
A range's loop variable takes the common integer type of its two bounds, by the same promotion rules as C arithmetic: two int bounds give an int, an int64 bound makes it int64, and an unsigned bound of the widest width makes it unsigned. So for (i in 0..n) with n: int64 counts past 231 without wrapping. A float or pointer bound is a compile error.
The loop variable (v, n, or i above) is a copy. Modifying it does not affect the collection or the range. continue correctly advances the index. The upper bound of a range is evaluated once, before the first iteration: for (i in 0..count()) calls count() one time, and changing hi inside the loop does not change how many times it runs.
Over a two-dimensional array, for-in binds each row in turn, so a nested for-in walks the elements:
int[2][3] m = {{1, 2, 3}, {4, 5, 6}};
for (row in m) {
for (x in row) { printf("%d ", x); }
}
printf("\n"); // 1 2 3 4 5 6
4.7 do / while
Like while, but the condition is checked after the body, so the body always runs at least once. Use it when the first iteration must happen before there is anything to test:
int n = 0;
do {
printf("%d ", n);
n += 1;
} while (n < 3);
Output: 0 1 2
A plain while with a false condition runs zero times; the do/while above runs once even if you start it with n = 100.
Structs and Methods
A variable holds one value. A struct holds many, each with its own name and type, grouped under a single name. Methods attach behaviour to that data, so a struct carries both what it is and what it can do.
5.1 Your first struct
A struct declaration introduces a new type. Fields are declared inside the braces:
struct Point {
float x;
float y;
}
From this point on, Point is a type you can use anywhere you would use int.
5.2 Creating and using a struct
A struct literal initialises all fields at once. The named form uses field names explicitly; the positional form assigns fields in declaration order:
// Named
Point p = Point { x: 1.5, y: 2.5 };
printf("%0.1f %0.1f\n", p.x, p.y);
// Positional (declaration order)
Point q = Point { 3.0, 4.0 }; // same as { x: 3.0, y: 4.0 }
// Field-by-field Point r; r.x = 5.0; r.y = 6.0;
A field the literal leaves out is set to zero, as in C, so Point { y: 2.5 } has x == 0.0. Each value is checked against its field's type, so a string given to an int field, an integer literal that does not fit the field, more values than the struct has fields, or the same field named twice are all compile errors. A literal is either named or positional; mixing the two forms (Point { 1.5, y: 2.5 }) is an error. A trailing comma after the last field is allowed, as in an array literal, which keeps one-field-per-line literals easy to edit:
Point s = Point {
x: 5.0,
y: 6.0,
};
5.3 Methods
A method is a function inside a struct body. self refers to a pointer to the instance:
struct Rect {
float w;
float h;
float area() { return self.w * self.h; }
float perimeter() { return (self.w + self.h) * 2.0; }
void print() {
printf("Rect(%.1f x %.1f) area=%.1f perim=%.1f\n",
self.w, self.h, self.area(), self.perimeter());
}
}
Rect r = Rect { w: 4.0, h: 3.0 };
r.print();
Output: Rect(4.0 x 3.0) area=12.0 perim=14.0
A method written inside the struct receives self as a plain *Rect, which may modify the value. For that reason it cannot be called on a const value. A method that only reads can be written as a free function named Type_method whose receiver is const T* self; dot syntax finds it the same way, and it works on const and non-const values alike:
struct Counter { int n; }
int Counter_peek(const Counter* self) { return self.n; } // read-only receiver
const Counter c = Counter { n: 3 };
printf("%d\n", c.peek()); // 3
// a *Counter method on c is an error: declare it const Counter* self
A field and a method with the same name, or two fields with the same name, are rejected.
5.3a Operator overloading
For value types like vectors and matrices, the algebra reads better than nested calls. A struct can give an operator meaning by declaring operator, and then a + b resolves to it:
struct V3 { float x; float y; float z; }
V3 operator +(V3 a, V3 b) { let r: V3; r.x = a.x+b.x; r.y = a.y+b.y; r.z = a.z+b.z; return r; }
V3 operator *(V3 a, double s) { let r: V3; r.x = a.x*(float)s; r.y = a.y*(float)s; r.z = a.z*(float)s; return r; }
V3 p = (a + b) * 2.0; // uses operator + then operator *
You can overload the binary operators (+ - * / % == != < > <= >= & | ^ << >>), the unary - ! ~, and subscript []. Compound assignment (v += w) reuses your operator +. Overloads coexist by operand type, so V3 * V3 and V3 * double are different operators selected by the right-hand side. The short-circuit && and ||, pointer */&, assignment =, and member access . cannot be overloaded.
Structs, arrays, slices and closures have no built-in ==, < or truth value. (An interface value has only one: it compares with null and tests as a condition, see 7.4.) a == b on two V3 values is a compile error ("invalid operands for operator") until you declare bool operator ==(V3 a, V3 b), which then also works in an if. An overload must have at least one operand of a user-defined type, so the operators on the built-in types cannot be redefined.
Resolution is fully static: an operator is an ordinary function, and a op b compiles to a direct call to it. There is no dynamic dispatch and no boxing, so the cost is exactly that of writing the call by hand, which keeps it fit for hot numeric code.
5.4 Fixed-size array fields
struct Packet {
uint8[256] data;
int length;
}
Packet pkt; pkt.data[0] = 0xDE; pkt.data[1] = 0xAD; pkt.length = 2;
Arrays work the same as local variables, not just struct fields. A brace list initialises one in place; as in C, a short list zero-fills the rest and {} zeroes the whole array, while an over-long list is a compile error:
int[3] a = {10, 20, 30}; // 10, 20, 30
int[4] b = {7, 8}; // 7, 8, 0, 0 (rest zero-filled)
int[3] c = {}; // 0, 0, 0
Arrays can be multidimensional. T[N][M] is N arrays of M, in C order: the leftmost bracket is the outer dimension, so a[i] is a row and a[i][j] is an element. Each index is bounds-checked against its own dimension, and a nested brace list initialises it (zero-filling at every level):
int[2][3] grid = { {1, 2, 3}, {4, 5, 6} };
int[2][2] part = { {7, 8} }; // second row zero-filled
printf("%d %d %d\n", grid[0][0], grid[1][2], part[1][0]); // 1 6 0
Output: 1 6 0
5.4b Slices
A fixed array knows its size at compile time, but a function that takes one has to be told the length separately, the classic C footgun where the pointer and its count drift apart. A slice, written T[] (empty brackets), fixes that: it is a fat pointer carrying both the data pointer and the length, so the length always travels with the data.
You make a slice by slicing an array with a half-open range (the same .. used by for-in). You can slice a raw pointer too, so a heap buffer from alloc<T>(n) becomes a slice: ptr[lo..hi] is a T[] over that memory. Either way the slice is a view: it aliases the backing storage, so writing through it writes to the array or buffer.
int[6] a = {10, 20, 30, 40, 50, 60};
int[] mid = a[1..4]; // view of {20, 30, 40}
mid[1] = 99; // writes through: a[2] is now 99
printf("%lld %d\n", mid.len, a[2]); // 3 99
Output: 3 99
A slice supports indexing (s[i]), its length via s.len, iteration with for (x in s), and is passed by value (the pointer + length are copied; the backing storage is shared). A function that sums any run of ints needs no count argument:
int sum(int[] s) {
int total = 0;
for (x in s) { total = total + x; }
return total;
}
sum(a[0..6]); // the whole array as a slice
5.5 union
A union looks like a struct but all fields share the same memory location at offset 0. The union's size equals the size of its largest field. Writing one field and reading another reinterprets the underlying bits:
union Value {
int i;
float f;
*uint8 p;
}
let v: Value;
v.i = 0x3F800000; // bit pattern for 1.0f
printf("%f\n", v.f); // 1.000000
Output: 1.000000
A union has the size and alignment C gives it: the size of its largest field, rounded up to the strictest field alignment. union { uint8 tag; int64 wide; } is 8 bytes and 8-aligned, so it lays out the same as the matching C union when you pass it across extern.
5.6 Bitfields
A bitfield is a struct field with a width in bits written after a colon. Multiple bitfields pack into the same underlying integer word. This lets you represent hardware registers, protocol headers, or compact flags without manual bit manipulation:
struct Flags {
uint32 a : 1; // 1-bit field: values 0 or 1
uint32 b : 3; // 3-bit field: values 0-7
uint32 c : 4; // 4-bit field: values 0-15
int tag; // normal field: mixed freely
}
Assignment and access use the same dot syntax as normal fields. The compiler generates read-modify-write automatically, updating one field never disturbs its neighbours:
Flags f;
f.a = 1; f.b = 5; f.c = 9; f.tag = 42;
printf("%d %d %d %d\n", f.a, f.b, f.c, f.tag); // 1 5 9 42
f.b = 2; // a and c are untouched
printf("%d %d %d\n", f.a, f.b, f.c); // 1 2 9
Output: 1 5 9 42 1 2 9
Struct literals work with bitfields the same as with normal fields:
Flags g = Flags { a: 1, b: 7, c: 15 };
printf("%d %d %d\n", g.a, g.b, g.c); // 1 7 15
Increment, decrement and compound assignment work on a bitfield and wrap at its width, so after g.c++ the 4-bit field holds 0. A signed field (int8 d : 3) sign-extends when read, including through a type alias. A bitfield must have an integer type, and its width must fit in that type: a float or pointer bitfield, or uint32 x : 40, is an error, and you cannot take a bitfield's address. A named bitfield of width zero (int x : 0) and a sum-type bitfield are errors too.
A bitfield takes part in arithmetic the way clang and GCC treat it. When every value the field can hold fits an int, reading it gives an int, whatever its declared type. So with uint32 u : 3 holding 0, u - 1 is -1, not 4294967295, and a uint64 a : 20 promotes the same way. A postfix f.u++ keeps the declared type. An enum bitfield whose enum has no negative member reads back unsigned, so a 2-bit field holding the member value 2 reads as 2 (on Windows, whose layout rules keep it signed, it reads as -2).
The layout is the one the target's C compiler uses, so a struct with bitfields can be shared with C. On Linux, macOS and bare-metal ARM a bitfield shares bytes with its neighbours as long as it fits inside an aligned unit of its own declared type, even when the declared types differ. On Windows a new storage word starts whenever the declared type size changes. The same struct can therefore have different sizes on different targets:
struct Mixed {
uint8 a : 4;
uint32 w : 12;
}
Mixed m = Mixed { a: 9, w: 1000 };
printf("%lld %d %d\n", sizeof(Mixed), m.a, m.w); // 4 9 1000 (8 on Windows)
Output: 4 9 1000
5.7 packed struct
By default the compiler inserts padding between struct fields to satisfy alignment requirements. A uint8 followed by a uint32 occupies 8 bytes, not 5, because the compiler adds 3 padding bytes before the uint32. The packed qualifier removes all padding, giving you the exact on-wire layout. This is essential for hardware register maps, network protocols, and interop with C structs declared with __attribute__((packed)):
struct Natural { // default layout
uint8 tag; // 1 byte + 3 bytes padding
uint32 value; // 4 bytes
} // sizeof = 8
packed struct Wire { // packed keyword
uint8 tag; // 1 byte, no padding
uint32 value; // 4 bytes immediately after
} // sizeof = 5
printf("%lld\n", sizeof(Natural)); // 8
printf("%lld\n", sizeof(Wire)); // 5
Output: 8 5
You can also apply packing to a group of structs using #pragma pack, covered in Chapter 12.5. Any struct declared while pack(1) is active is packed, regardless of whether packed appears on the struct itself.
Pointers and Memory
A pointer is a variable that holds a memory address. Instead of containing a value directly, it points to where a value lives. Pointers are how Eskiu shares data between functions, manages heap memory, and interfaces with hardware. They are the most important concept in systems programming, and the one that trips up beginners most reliably. Take your time with this chapter.
6.1 Stack and heap
The stack is where local variables live. Fast, automatic, and limited in size. When a function returns, all its locals are reclaimed instantly.
The heap is a large pool of memory you manage yourself. Request a piece with alloc, use it as long as you need, return it with free. Heap allocations outlive the function that created them.
6.2 Address-of and dereference
int x = 42;
*int ptr = &x; // ptr holds the address of x
printf("%d\n", *ptr); // 42
*ptr = 100; // write through the pointer
printf("%d\n", x); // 100
Output: 42 100
& needs storage: a variable, a field or element of one, or a dereference. The result of a call is a temporary value with no storage, so &make_point().x, make_point().x = 5 and slicing an array returned by a call are compile errors. Store the result in a local first and work on the local.
6.3 Passing pointers to functions
This is the primary use of pointers in everyday code. A function that receives a pointer can modify the caller's variable:
void set_to_zero(*int p) { *p = 0; }
int x = 99;
set_to_zero(&x);
printf("%d\n", x); // 0
6.4 Heap allocation
Heap allocation is provided by the standard library module <mem>, not the language core. alloc<T>(n) allocates n elements of type T and returns a *T. free(p) releases it:
import <mem>;
*int arr = alloc<int>(8);
for (int i = 0; i < 8; i += 1) {
arr[i] = i * i;
}
printf("%d %d %d\n", arr[0], arr[1], arr[4]); // 0 1 16
free((*void)arr);
6.5 The golden rule
Every alloc must be paired with exactly one free, never zero and never twice.
Warning Forgetting free is a memory leak. Freeing twice is a double-free. Using memory after free is use-after-free. All three are undefined behaviour. Chapters 20 and 22 cover each in detail.
6.6 Typed pointer arithmetic
Adding an integer to a pointer advances it by n × sizeof(T) bytes, not n bytes. ptr + 1 moves to the next element, regardless of element size:
*int arr = alloc<int>(4); arr[0]=10; arr[1]=20; arr[2]=30; arr[3]=40;
*int p = arr + 2; // advances 2 × sizeof(int) = 8 bytes
printf("%d\n", *p); // 30
int64 gap = p - arr; // 2: the difference counts elements, not bytes
printf("%lld\n", gap);
free(arr);
Exception: *void and *char always use byte stride (1 byte per step), for C interop compatibility.
A pointer converts implicitly only where the conversion cannot mean something else. *void and null convert to and from any pointer, and the byte pointers (string, *char, *int8, *uint8) convert among themselves. A pointer to one type is not a pointer to an unrelated type: *Big b = p; with p an *int is a compile error ("cannot convert '*int' to '*Big'"). Write the cast when you mean the reinterpretation, *Big b = (*Big)p;, so it is visible in the source. The spellings int/int32 and uint/uint32 name the same type, so *int and *int32 match.
6.7 Null and checked nullable pointers
A plain *T may hold null, exactly as in C. Dereferencing a null pointer is undefined, so you guard it by hand:
*int p = null;
if (p != null) { printf("%d", *p); } // checked by hand
When you want the compiler to enforce that check, declare the pointer ?*T, a checked nullable pointer. You cannot dereference, index, or take a member of a ?*T until you have proven it non-null; inside an if (q != null) block the compiler narrows it to non-null and the dereference is allowed:
?*int q = maybe();
// *q; // compile error: q may be null; check it first
if (q != null) {
printf("%d", *q); // ok: narrowed to non-null here
}
Narrowing follows the flow of the code, so the usual C idioms all count as a check. The right side of q != null && *q > 0 and of q == null || *q > 0 is narrowed, as is the true arm of q != null ? *q : 0 and the body of if (!(q == null)). An early exit narrows everything after it, and a loop condition narrows the loop body:
int first(?*Node n) {
if (n == null) { return -1; } // early exit
return n.v; // n is non-null from here on
}
int sum(?*Node n) {
int t = 0;
while (n != null) { t = t + n.v; n = n.next; }
return t;
}
A narrowed pointer can be passed or assigned where a plain *T is expected. Narrowing ends when the variable is reassigned (the new value is unchecked again) or when an inner block declares a new variable with the same name. A variable whose address has been taken is never narrowed, since a write through that address could null it. A global ?*T stays narrowed only until the next call or await (a call to a user operator counts), because the code that runs there could change the global; check it again after the call, or copy it into a local first.
A non-null *T converts to ?*T freely (it is always safe to forget non-nullness); going the other way requires a null-check. ?*T has the same representation as a bare pointer, so the safety is entirely at compile time with no runtime cost. Use ?*T at the boundaries where null is a real possibility (a lookup that can miss, an optional field) and keep *T where the value is always present.
A pointer to a nullable pointer is spelled *?*T, and &p of a ?*int p has that type. It is how a function hands back a pointer that may be missing through an out-parameter. The outer pointer is plain, so *out = null is fine, but what it points to may be null, so **pp is rejected until you check *pp, and a *?*int does not convert to **int ("conversion drops the nullable pointee"). The other order, ?*T*, still means a nullable pointer to a *T:
int g = 7;
void lookup(bool hit, *?*int out) {
if (hit) { *out = &g; } else { *out = null; }
}
?*int p = null;
lookup(true, &p);
?*int r = p; // p had its address taken, so check a copy
if (r != null) { printf("%d\n", *r); } // 7
lookup(false, &p);
r = p;
if (r == null) { printf("miss\n"); } // miss
Output: 7 miss
6.8 Pointer constness
const T* is a pointer to read-only data: you can read through it and rebind it, but cannot write through it. T* const is a read-only pointer: you can write through it but cannot rebind it. Adding const is always safe; removing it is a compile error:
int x = 10; int y = 20; const int* r = &x; // pointer to const int // r[0] = 99; // compile error: write through const pointer r = &y; // ok: rebinding is allowed
int sum(const int* p, int n) { // caller's data is read-only
int s = 0;
for (i in 0..n) { s = s + p[i]; }
return s;
}
Use const T* parameters to communicate that a function will not modify the caller's data. It is a contract enforced by the compiler. The address of a const variable has type const T*, so passing &x of a const int x to a function taking *int is an error ("discards a const qualifier"). The same rule decides which methods a const struct can call (section 5.3).
6.9 volatile
The volatile qualifier prevents the compiler from optimising away, caching, or reordering loads and stores to a pointer. Required for memory-mapped hardware registers, whose value can change independently of your program:
volatile let uart_dr: *uint8 = (uint8*) 0x09000000; *uart_dr = 'A'; // always emitted: never optimised away
volatile let status: *uint8 = (uint8*) 0x09000018;
while ((*status & 0x20) == 0) {} // re-reads the register every iteration
volatile applies to every access reached from the variable, not only to reads of the variable itself: *p, p[i], p.field, ++/-- and compound assignment all emit volatile loads and stores, for locals and globals, and so does the variable's initializing store. A bitfield reached through a volatile variable is read and written with volatile accesses to its whole storage word, which is what a hardware register with packed fields needs:
struct Ctrl { uint32 mode : 3; uint32 enable : 1; }
volatile let ctrl: *Ctrl = (*Ctrl) 0x09000030;
ctrl.mode = 5; // volatile load + volatile store of the 32-bit word
ctrl.enable = 1;
Interfaces
An interface describes what a type can do, not what it is. It names a set of methods that any conforming type must provide. Code written against an interface works with any type that satisfies it: today's types and types not yet written. You can add a type years later and old code will accept it, unchanged.
7.1 Declaring an interface
extern int printf(string fmt, ...);
interface Shape {
float area();
void describe();
}
Any struct that provides both methods satisfies Shape. No explicit declaration needed. This is called structural typing.
7.2 Two structs, one interface
struct Circle {
float radius;
float area() { return self.radius * self.radius * 3.14159; }
void describe() {
printf("Circle r=%.2f area=%.2f\n", self.radius, self.area());
}
}
struct Rectangle {
float w;
float h;
float area() { return self.w * self.h; }
void describe() {
printf("Rect %.1fx%.1f area=%.2f\n", self.w, self.h, self.area());
}
}
7.3 Using the interface
void printShape(Shape s) { s.describe(); }
float totalArea(Shape a, Shape b) { return a.area() + b.area(); }
int main() {
Circle c = Circle { radius: 5.0 };
Rectangle r = Rectangle { w: 4.0, h: 6.0 };
printShape(&c);
printShape(&r);
printf("total: %.2f\n", totalArea(&c, &r));
return 0;
}
Output: Circle r=5.00 area=78.54 Rect 4.0x6.0 area=24.00 total: 102.54
Notice the &. An interface value is a pair of pointers, one to the struct and one to a table of its methods, so it always refers to a struct that lives somewhere else. You make one from the struct's address. Passing the struct itself (printShape(c)) is a compile error that asks for a pointer, because the interface would have nothing to point at once the copy was gone.
A struct satisfies an interface only when each method matches the interface's signature: the same return type and the same parameter types after self. A method with the right name but a different signature is reported as "does not satisfy interface".
7.4 Interface values
An interface value is an ordinary value. You can keep one in a local, store it in a struct field, return it from a function, and reassign it to point at a different struct. Each call dispatches through the method table of whatever it currently refers to:
struct Scene { Shape main; int layer; }
Shape pick(bool round, *Circle c, *Rectangle r) {
if (round) { return c; }
return r;
}
int main() {
Circle c = Circle { radius: 1.0 };
Rectangle r = Rectangle { w: 2.0, h: 3.0 };
Shape s = &c;
printf("%.2f\n", s.area()); // 3.14
s = &r;
printf("%.2f\n", s.area()); // 6.00
Scene sc = Scene { main: pick(true, &c, &r), layer: 1 };
sc.main.describe(); // Circle r=1.00 area=3.14
return 0;
}
Output: 3.14 6.00 Circle r=1.00 area=3.14
The interface value does not own the struct. Keep the struct alive for as long as any interface value refers to it, just as you would with a plain pointer.
null is an interface value too, one that refers to nothing. You can initialize, assign, return or pass null where an interface is expected, and put it in an interface field. An interface value compares with null (s == null, s != null) and works as a condition, so if (s), !s and s && ... test whether it refers to a struct:
Shape find(bool found, *Circle c) {
if (found) { return c; }
return null;
}
void show(Shape s) {
if (s == null) { printf("nothing to draw\n"); return; }
s.describe();
}
int main() {
Circle c = Circle { radius: 1.0 };
Shape s = null;
show(s); // nothing to draw
s = find(true, &c);
if (s) { show(s); } // Circle r=1.00 area=3.14
Scene sc = Scene { main: null, layer: 0 };
if (!sc.main) { printf("empty scene\n"); }
show(find(false, &c)); // nothing to draw
return 0;
}
Output: nothing to draw Circle r=1.00 area=3.14 empty scene nothing to draw
Calling a method on a null interface value is a null dereference, so test it first when it may be empty. Comparing an interface value with anything other than null, such as a struct pointer or another interface value, is still a compile error.
Templates
A template is a blueprint the compiler fills in with a concrete type when you use it. You write the logic once, parameterised by a type placeholder, and the compiler generates a separate, fully optimised version for each type you actually use. There is no runtime overhead: templates are entirely a compile-time mechanism.
8.1 A template function
Type parameters in angle brackets. The return type can also be T:
T identity<T>(T x) { return x; }
T max<T>(T a, T b) {
if (a > b) return a;
return b;
}
int same = identity<int>(42); // 42
int big = max<int>(10, 20); // 20
float fbig = max<float>(1.5, 2.5); // 2.5
printf("%d %d %.1f\n", same, big, fbig);
Output: 42 20 2.5
When a type parameter appears directly as a parameter type, the compiler can infer it from the arguments; the angle brackets become optional:
printf("%d\n", max(3, 5)); // 5 : T inferred as int
printf("%d\n", identity(42)); // 42
A generic call is checked like any other call: the number of arguments, the number of type arguments (identity<int, int> is an error), and each argument's type once T is known. When a type parameter cannot be inferred from the arguments, for example because it appears only in the return type, the compiler asks you to write it explicitly.
When several arguments bind the same type parameter, they have to agree. A composite parameter such as *T or List<T>* binds first. Two by-value arguments that deduce different integer types meet at their common type, the one C arithmetic would use: with int64 big, max(1, big) is max<int64>, so big is not truncated. Any other disagreement is an error that names both types:
int64 big = 5000000000;
printf("%lld\n", max(1, big)); // 5000000000: T is int64
// max(1.5, (float)2.5); // error: T is deduced as 'double' from
// // argument 1 and as 'float' here
A slice parameter binds its element type too, so a generic function over T[] can be called with a slice and no angle brackets:
T sum<T>(T[] s) {
T t = 0;
for (x in s) { t = t + x; }
return t;
}
int[4] a = {1, 2, 3, 4};
double[2] d = {0.5, 1.25};
printf("%d %.2f\n", sum(a[0..4]), sum(d[0..2])); // 10 1.75
The body is checked too, once for each set of type arguments you actually use. A template that only works for some types is fine until you instantiate it with one it does not support, and the error then points at the offending line and names the instance:
struct Box { int v; }
T twice<T>(T x) { return x + x; }
Box b = Box { v: 1 };
Box c = twice<Box>(b); // error: invalid operands for operator: struct:Box and
// struct:Box (in instantiation of twice<Box>)
8.2 A template struct
struct Pair<A, B> {
A first;
B second;
}
Pair<int, string> p = Pair<int, string> { first: 1, second: "one" };
printf("%d %s\n", p.first, p.second); // 1 one
A template struct can declare methods in its body, the same way as a plain struct. The compiler generates each method for every instance that calls it, so Stack<int> and Stack<double> each get their own push. A generic function written in the Type_method form, with the struct as its first parameter, is callable with dot syntax as well. Its type arguments are taken from the receiver, so s.peek() on a Stack<int> is Stack_peek<int>(&s):
struct Stack<T> {
T[8] items;
int n;
void push(T x) { self.items[self.n] = x; self.n++; }
T pop() { self.n--; return self.items[self.n]; }
}
T Stack_peek<T>(Stack<T>* self) { return self.items[self.n - 1]; }
Stack<int> s = Stack<int> { n: 0 };
s.push(3);
s.push(9);
printf("%d\n", s.peek()); // 9
int top = s.pop();
printf("%d %d\n", top, s.pop()); // 9 3
Stack<double> d = Stack<double> { n: 0 };
d.push(2.5);
printf("%.1f\n", d.peek()); // 2.5
Output: 9 9 3 2.5
This is how the standard library's containers are used: l.push(8) on a List<int> is List_push<int>(&l, 8) (see Chapter 15).
8.3 Result
The standard way to return a value or an error. Import from the standard library:
import <result>;
Result<int, string> divide(int a, int b) {
if (b == 0)
return Err<int, string>("division by zero");
return Ok<int, string>(a / b);
}
let r1: Result<int, string> = divide(10, 2);
if (r1.ok == 1) { printf("10 / 2 = %d\n", r1.value); }
let r2: Result<int, string> = divide(7, 0);
if (r2.ok == 0) { printf("error: %s\n", r2.error); }
Output: 10 / 2 = 5 error: division by zero
8.4 Bounded type parameters (constraints)
A type parameter may carry one or more interface constraints, written after a colon: T f<T: Iface>(...). The constraint requires that every concrete type argument satisfy the named interface. Multiple constraints use +: <T: A + B>. The check happens at the call site, not deep in codegen, so the error message names the constraint that failed, not an obscure method-missing error:
interface Ord { int cmp(Ord* o); }
T pick<T: Ord>(T* a, T* b) {
if (a.cmp(b) < 0) { return a[0]; }
return b[0];
}
struct Box<T: Ord> { T item; }
struct Num {
int v;
int cmp(Num* o) {
if (self.v < o.v) { return -1; }
return 1;
}
}
// Num satisfies Ord: it has cmp. int does not. let x: Num; x.v = 3; let y: Num; y.v = 7; let lo: Num = pick<Num>(&x, &y); // lo.v == 3 let b: Box<Num>; b.item.v = 42;
Multiple constraints separate with +:
interface Show { int show(); }
interface Eq { int eq(Eq* o); }
int dump<T: Show + Eq>(T* x) { return x.show(); }
Structs satisfy a constraint by defining its methods. Primitives such as int have no methods. Instead they satisfy a constraint via a free function named like the interface method, with the primitive as the first parameter. Inside the generic body, a constrained call t.cmp(x) on a primitive t lowers to cmp(t, x):
The sample below continues the same program, so it reuses the Ord interface declared above. The by-value generic gets its own name, smaller, because pick already exists:
// Free function makes int satisfy Ord:
int cmp(int a, int b) { if (a < b) { return -1; } return 1; }
T smaller<T: Ord>(T a, T b) {
if (a.cmp(b) < 0) { return a; } // lowers to cmp(a, b)
return b;
}
printf("%d\n", smaller<int>(3, 7)); // 3
printf("%d\n", pick<Num>(&y, &x).v); // 3: structs use the pointer form
Num.cmp takes a Num*, so a struct goes through pick, which passes pointers. Calling smaller<Num> with values is rejected.
The function-pointer HashMap<K,V> from <map> remains useful when you want to thread hash/eq explicitly without declaring an interface. Scalar primitives only; pointer types are not yet supported.
Closures and Lambdas
A lambda is a function without a name. You can store it in a variable, pass it to another function, or write it inline. A closure is a lambda that remembers variables from the scope where it was created: it closes over them, carrying its own small piece of state.
9.1 Your first lambda
let double_it: fn(int)->int = int(int x) { return x * 2; };
printf("%d\n", double_it(5)); // 10
printf("%d\n", double_it(21)); // 42
fn(int)->int means: a function that takes one integer and returns one integer. Here fn names a function-pointer type; it is not how you define a function. Functions are defined C-style, with the return type first (int add(int a, int b) { ... }), just like the lambda's own int(int x) body. Eskiu has no fn name(...) definition form, unlike Rust or Swift.
9.2 Higher-order functions
int apply(fn(int)->int f, int x) { return f(x); }
let square: fn(int)->int = int(int n) { return n * n; };
printf("%d\n", apply(square, 6)); // 36
printf("%d\n", apply(int(int x) { return x + 1; }, 9)); // 10
9.3 Closures
A lambda that references outer variables captures them by value at the moment of creation. Changing the original afterwards does not affect the closure:
int base = 10;
let add_base: fn(int)->int = int(int x) { return x + base; };
printf("%d\n", add_base(5)); // 15
printf("%d\n", add_base(32)); // 42
base = 999; // does not affect the closure
printf("%d\n", add_base(0)); // still 10
Output: 15 42 10
Because the closure holds a copy, it may not assign a captured variable: base = 1;, base += 1; or base++; inside the lambda is a compile error, since the write would change only the copy. To share state, write through a pointer (*p = v), or use a global or a static local, which are not captured. The lambda's own parameters and locals can be assigned freely.
The rule covers every way of writing into the copy. Assigning a field or an element of a captured struct or array (p.a = 5, arr[0] = 9) is an error, and so is taking the address of a captured variable or of one of its parts (&n, &p.a, &arr[0]) or slicing a captured array (arr[0..2]), because a write through that address would also land in the copy. Writing through a captured pointer, slice or string is fine, since the data it points to is shared:
int hits = 0;
*int ph = &hits;
let count: fn()->void = void() {
// hits += 1; // error: cannot assign to captured variable 'hits'
*ph = *ph + 1; // ok: writes the shared int
};
count();
count();
printf("%d\n", hits); // 2
Output: 2
9.4 Void lambdas
let greet: fn()->void = void() {
printf("hello from a lambda\n");
};
greet();
9.5 Function pointers as return types and struct fields
A function can return a fn(T)->R value and a struct can hold one as a field:
fn(int)->int adder(int base) {
return int(int x) { return x + base; }; // returns a lambda
}
let add10: fn(int)->int = adder(10);
printf("%d\n", add10(5)); // 15
// Struct with a fn field: call directly
struct Ops { fn(int,int)->int op; }
int add(int a, int b) { return a + b; }
let o: Ops; o.op = add;
printf("%d\n", o.op(3, 4)); // 7
A call through a fn value, including a struct field like o.op, is checked against the fn type: the wrong number of arguments or an argument of the wrong type is a compile error. A lambda body is a function of its own, so a break or continue inside it cannot target a loop outside it, and the enclosing function's defer statements do not run when the lambda returns.
9.6 Escaping closures
By default a closure environment lives on the stack. Zero allocation cost, but it cannot outlive the function that created it. When a closure must outlive its creator the parameter that receives it must be marked escaping:
void on_ready(int fd, escaping fn(int)->void cb) {
handlers[fd] = cb; // stored: parameter must be escaping
}
int x = 42;
on_ready(fd, void(int n) { printf("%d %d\n", x, n); });
// free_closure(cb) when done
The escaping qualifier states, and the compiler enforces, that the closure may outlive its creator. Pass a non-escaping closure to an escaping parameter and you get a compile error. The check follows captures too: a lambda that captures a non-escaping closure parameter and outlives the call (it is returned, stored, passed to an escaping parameter or given to thread_create) needs that parameter marked escaping.
9.7 Closures inside generic functions
A lambda declared inside a generic function body captures correctly across all instantiations. The closure environment is monomorphized alongside the outer template: a field typed by the type parameter gets the concrete type at each instantiation:
int call_it(escaping fn()->int op) { return op(); }
// box<T> captures a T-typed value and an int* in its closure
int box<T>(T v, int bump) {
*int hits = alloc<int>(1); *hits = 0;
let f: fn()->int = int() {
*hits = *hits + bump;
return (int)v + *hits;
};
int r1 = call_it(f); // v + bump
int r2 = call_it(f); // v + 2*bump
free_closure(f);
free((*void)hits);
return r1 + r2;
}
printf("%d\n", box<int>(10, 1)); // 23 (11+12)
Error Handling
Programs fail. Files do not exist. Numbers fall outside valid ranges. Network connections drop mid-request. Good error handling makes failures visible and recoverable rather than silent and catastrophic. Eskiu provides two tools: Result for expected failures, and try/catch for exceptional ones.
10.1 Result
import <result>;
Result<int, string> safeSqrt(int n) {
if (n < 0)
return Err<int, string>("negative input");
int i = 0;
while (i * i <= n) { i = i + 1; }
return Ok<int, string>(i - 1);
}
let r1: Result<int, string> = safeSqrt(25);
if (r1.ok == 1) { printf("sqrt(25) ~ %d\n", r1.value); }
let r2: Result<int, string> = safeSqrt(-4);
if (r2.ok == 0) { printf("error: %s\n", r2.error); }
Output: sqrt(25) ~ 5 error: negative input
10.2 try / catch / finally
int divide(int a, int b) {
if (b == 0) throw "division by zero";
return a / b;
}
try {
int r = divide(10, 0);
printf("result: %d\n", r); // never reached
} catch (string e) {
printf("caught: %s\n", e); // division by zero
} finally {
printf("done\n"); // always runs
}
Output: caught: division by zero done
finally runs on every way out of the try: falling through, an exception, and a return, break or continue from either the try body or a catch handler. A handler that returns early still prints done first:
int attempt() {
try {
return divide(1, 0);
} catch (string e) {
return -1; // finally runs before this return
} finally {
printf("done\n");
}
return 0;
}
A finally block cannot itself return or use ?. Either would discard the exit already in progress, including an exception on its way to a handler, so both are compile errors ("'return' is not allowed inside a finally block"). A break or continue inside a finally that sits in a loop still works, and a lambda written inside a finally is a separate function that may return normally.
No link flag is needed: when a program uses exceptions, eskiuc links the C++ runtime for the target (-lc++ on macOS, -lstdc++ on Linux and on Windows, where mingw also supplies the SEH unwinder).
10.3 The ? operator
Chaining multiple Result-returning calls with explicit if (r.ok == 0) return Err checks gets verbose quickly. The postfix ? operator shortens this: applied to a Result<T,E> expression, it either unwraps the value on success or immediately returns the Err to the caller:
import <result>;
Result<int, string> divide(int a, int b) {
if (b == 0) return Err<int, string>("division by zero");
return Ok<int, string>(a / b);
}
// Without ?, verbose
Result<int, string> compute_verbose(int a, int b, int c) {
let r1: Result<int, string> = divide(a, b);
if (r1.ok == 0) return Err<int, string>(r1.error);
let r2: Result<int, string> = divide(r1.value, c);
if (r2.ok == 0) return Err<int, string>(r2.error);
return Ok<int, string>(r2.value + 1);
}
// With ?, concise
Result<int, string> compute(int a, int b, int c) {
let x: int = divide(a, b)?; // unwrap or return Err
let y: int = divide(x, c)?;
return Ok<int, string>(y + 1);
}
The two functions are semantically identical. expr? on a Result<T,E> is syntactic sugar for the explicit check-and-return pattern. The type checker enforces that ? is only used inside a function that returns a compatible Result<T,E>.
This postfix ? is distinct from the ternary cond ? a : b (see 12.2b). The parser tells them apart by the :: a ? with a matching : ahead is a ternary, otherwise it is Result propagation. To propagate inside a ternary arm, parenthesise it: cond ? (divide(a, b)?) : fallback.
let good: Result<int, string> = compute(100, 5, 2);
if (good.ok == 1) { printf("ok=%d\n", good.value); } // ok=11
let bad: Result<int, string> = compute(100, 0, 2);
if (bad.ok == 0) { printf("err=%s\n", bad.error); } // err=division by zero
Output: ok=11 err=division by zero
10.4 When to use which
Use Result for expected, recoverable failures: file not found, invalid input, network timeout.
Use ? to propagate errors up the call stack without boilerplate.
Use throw for bugs and impossible states: an assertion failed, a data structure is internally inconsistent.
Concurrency
A thread is an independent sequence of execution. Your main function runs in one. Additional threads run in parallel, on separate CPU cores if available. Eskiu exposes threads as language primitives: thread_create and thread_join are keywords, not library calls.
11.1 thread_create and thread_join
extern int printf(string fmt, ...);
int main() {
*void t = thread_create(void() {
printf("hello from the thread\n");
});
printf("hello from main\n");
thread_join(t);
return 0;
}
The two lines may print in either order. thread_join blocks until the thread has finished. eskiuc links pthread by itself where the platform keeps it outside libc (Linux, and Windows, where threading runs on winpthreads).
11.2 Passing data via closures
Closures capture by value: each thread gets an independent copy, no shared state, no data race:
int id1 = 1; int id2 = 2;
let w1: fn()->void = void() { printf("thread %d\n", id1); };
let w2: fn()->void = void() { printf("thread %d\n", id2); };
*void t1 = thread_create(w1); *void t2 = thread_create(w2); thread_join(t1); thread_join(t2);
11.3 Shared state
When threads share heap-allocated data through a pointer, you must coordinate access. Without synchronisation, concurrent reads and writes produce corrupted results: a data race.
Warning Shared heap memory from multiple threads without synchronisation is undefined behaviour. Prefer passing data by value through closures.
11.4 Function decay
A named top-level function used as a value decays to a fn()->void pointer automatically. You can pass it directly to thread_create or any other higher-order function without wrapping it in a lambda:
void echo_server() {
// no captures needed
int fd = net_tcp_listen(54331);
// ...
}
// Pass function name directly, no void() { echo_server(); } wrapper
*void t = thread_create(echo_server);
thread_join(t);
The compiler synthesises a thin thunk that ignores the environment pointer and forwards to the named function. Function decay works with any fn(T,...)->R context, not just thread_create.
Function decay applies to named top-level functions only. Methods and local functions do not decay.
Operators
Eskiu has the operators you would expect from a systems language, and most will be familiar. The one that deserves attention is sizeof, a compile-time query that becomes essential when working with memory directly.
12.1 Arithmetic
int a = 17 + 5; // 22 int b = 17 - 5; // 12 int c = 17 * 5; // 85 int d = 17 / 5; // 3 : integer division truncates toward zero int e = 17 % 5; // 2 : remainder
12.1b Increment and decrement
The ++ and -- operators add or subtract one in place, on an integer or pointer variable. In prefix position the expression is the new value; in postfix position it is the old value. On a pointer they step by one element, like pointer arithmetic:
int i = 5;
int a = i++; // a = 5, then i = 6 (postfix: old value)
int b = ++i; // i = 7, then b = 7 (prefix: new value)
printf("%d %d %d\n", a, b, i); // 5 7 7
Output: 5 7 7
12.2 Bitwise
uint8 x = 0xB3; // 10110011 uint8 a = x & 0x0F; // 00000011 = 3 AND: keep lower nibble uint8 b = x | 0x0F; // 10111111 = 191 OR: set lower nibble uint8 c = x ^ 0xFF; // 01001100 = 76 XOR: flip all bits uint8 d = ~x; // 76 NOT uint8 e = x << 1; // 01100110 = 102 left shift uint8 f = x >> 2; // 00101100 = 44 right shift
Each bitwise operator has a compound-assignment form too, alongside the arithmetic += -= *= /= %= from Chapter 4:
int flags = 0; flags |= 0x04; // set bit 2 flags &= ~0x04; // clear bit 2 flags ^= 0x01; // toggle bit 0 flags <<= 1; // shift left flags >>= 2; // shift right
All six operators have compound assignment forms:
int x = 240; x >>= 2; // 60 x <<= 1; // 120 x &= 15; // 8 x |= 1; // 9 x ^= 3; // 10
12.2b Conditional (ternary)
The ternary cond ? a : b chooses between two values. It evaluates cond, then evaluates and yields exactly one of the arms, so side effects in the unused arm never run. The two arms take a common type (two numerics promote C-style). It is often the concise form of a small if/else:
int a = 5; int b = 9; int m = a > b ? a : b; // max: 9
It is right-associative, so a chain reads like an if/else ladder:
int score = 85;
char grade = score >= 90 ? 'A'
: score >= 80 ? 'B'
: score >= 70 ? 'C' : 'F'; // 'B'
Eskiu also uses ? as the postfix Result-propagation operator (Chapter 10). The two never collide: a ? with a matching : ahead is a ternary; otherwise it propagates. To propagate inside a ternary arm, wrap it in parentheses: cond ? (may_fail()?) : fallback.
12.3 sizeof
sizeof(T) returns the number of bytes a value of type T occupies in memory. The result is a compile-time int64 constant: no runtime code is generated. Print it with %lld:
printf("%lld\n", sizeof(int)); // 4
printf("%lld\n", sizeof(int64)); // 8
printf("%lld\n", sizeof(double)); // 8
struct Vec3 { float x; float y; float z; }
printf("%lld\n", sizeof(Vec3)); // 12
As in C, sizeof also accepts a variable, a parameter or a global, and gives the size of its type. A name that is neither a type nor a variable is an error:
Vec3 v;
double d = 1.0;
printf("%lld %lld\n", sizeof(v), sizeof(d)); // 12 8
More generally, sizeof(expr) gives the size of the expression's type without evaluating it, as in C. sizeof(*p) is the size of what p points to, sizeof(a[0]) the size of one element and sizeof(s.f) the size of a field, so a call inside the operand never runs:
int[5] a;
*Vec3 p = &v;
printf("%lld %lld %lld\n", sizeof(*p), sizeof(a[0]), sizeof(a)); // 12 4 20
A sizeof is a compile-time constant, so it can size an array (uint8[sizeof(Vec3)] raw;) or appear in a case label or an enum value.
12.4 Operator precedence
From lowest to highest. When in doubt, add parentheses:
= += -= *= /= %= assignment (right to left) ?: ternary (right to left) || && | (bitwise OR) ^ & (bitwise AND) == != < > <= >= << >> + - * / % ! - ~ & * ++ -- (TYPE) unary (right to left) () [] . ? call, index, member, propagate
An expression may be as long as you like: a generated chain of a hundred thousand + operands, or a long else if ladder, compiles without trouble. Nesting (parentheses, blocks, nested ifs and lambdas) may go 100000 levels deep, far beyond anything written by hand. Past that the compiler stops with a located nesting too deep error.
12.4b Range operator
The .. operator produces a half-open integer range for use in for-in loops. A..B iterates A, A+1, …, B-1. Both bounds are any integer expression, and the loop variable takes their common integer type; an empty range (A ≥ B) runs zero times:
for (i in 0..5) { /* 0 1 2 3 4 */ }
for (i in lo..hi){ /* lo to hi-1 */ }
for (i in 5..5) { /* never runs */ }
.. appears in a for-in head (for (x in A..B)) and in a slice (a[lo..hi], ptr[lo..hi]). It is not a standalone expression with a value of its own.
12.5 Preprocessor
Eskiu has a preprocessor that runs before the lexer. It supports object-like macros, function-like macros with parameter substitution, recursive expansion, and conditional compilation. Macros are shared across all files in a multi-file build.
Object-like macros substitute a name with a value everywhere it appears:
#define MAX 100 #define GREETING "hello" #define DEBUG
printf("%d\n", MAX); // 100
printf("%s\n", GREETING); // hello
printf("%d\n", MAX * 2); // 200
Output: 100 hello 200
Function-like macros take parameters. As in C, a call may span lines, and the ( may even sit on the line after the macro name:
#define SQ(x) ((x) * (x)) #define DOUBLE(n) ((n) + (n)) // LIMIT expands another macro (MAX): #define LIMIT (MAX + 1)
printf("%d\n", SQ(5)); // 25
printf("%d\n", DOUBLE(SQ(3))); // 18
printf("%d\n", LIMIT); // 101
Output: 25 18 101
Conditional compilation includes or excludes blocks at compile time:
#ifdef DEBUG
printf("debug\n");
#else
printf("release\n");
#endif
#undef DEBUG
#ifdef DEBUG
printf("still debug\n");
#else
printf("debug off\n");
#endif
#if and #elif take a C integer constant expression. Macros are expanded first, defined(NAME) (or defined NAME) tests whether a macro exists, and the usual arithmetic, comparison, bitwise and logical operators work, as do ?: and parentheses. A name that is still an identifier after expansion counts as 0:
#define LEVEL 3
#if LEVEL > 2 && defined(DEBUG)
printf("verbose debug\n");
#elif LEVEL > 1
printf("verbose\n");
#else
printf("quiet\n");
#endif
The preprocessor rejects what it does not understand instead of skipping it. An unknown directive is an error, and so is #include (Eskiu uses import), an #else, #elif or #endif with no matching #if, a conditional still open at the end of the file, and the C operators # and ## inside a macro body. Inside a branch that is being skipped, unknown directives are ignored, so a block of code for another platform can use whatever it likes. Arguments to a function-like macro keep string and character literals whole (a comma inside "a,b" does not split the argument) and are expanded before substitution, so SQ(SQ(2)) is 16. Files with Windows (CRLF) line endings work.
Line numbers are preserved through macro expansion: error messages always point to the original source location.
A macro body can span multiple physical lines using a backslash continuation. Each consumed newline is replaced by a blank line so error messages still point to the right source location:
#define POLY(x) \
((x) * (x) \
+ 2 * (x) \
+ 1)
printf("%d\n", POLY(3)); // 16
printf("%d\n", POLY(0)); // 1
Output: 16 1
#pragma pack controls struct padding for all structs declared while it is active. Use push and pop to save and restore the current packing:
#pragma pack(push, 1) // save current packing, switch to 1-byte
struct Wire {
uint8 tag; // no padding inserted
uint32 value;
} // sizeof = 5
#pragma pack(pop) // restore previous packing
struct Natural {
uint8 tag; // 3 bytes padding before value
uint32 value;
} // sizeof = 8
printf("%lld %lld\n", sizeof(Wire), sizeof(Natural)); // 5 8
Output: 5 8
A larger N caps the alignment instead of removing it. A struct declared under pack(N) aligns each field to the smaller of N and the field's own alignment, and the struct as a whole to the smaller of N and its largest field alignment, as C does. A union declared under pack(N) follows the same rule, so #pragma pack(2) union U { char[5] c; int x; } is 6 bytes, as in clang. That alignment also holds when the struct sits inside another struct, an array, a union or a generic type argument, so the outer layout matches clang's. The type checker knows these sizes, so a sizeof of such a struct can size an array or set an enum value:
#pragma pack(push, 2)
struct P2 { char a; int b; } // b at offset 2, sizeof 6, aligned to 2
#pragma pack(pop)
struct Outer { char c; P2 p; char e; } // p at offset 2, e at 8, sizeof 10
enum Sizes { SZ_P2 = sizeof(P2), SZ_OUTER = sizeof(Outer) }
uint8[sizeof(Outer)] raw;
printf("%d %d %lld\n", SZ_P2, SZ_OUTER, sizeof(raw)); // 6 10 10
Output: 6 10 10
The compiler predefines a macro for the host OS: __APPLE__ on macOS, __linux__ on Linux, and _WIN32 (plus _WIN64 on a 64-bit arch) on Windows. A bare-metal (none) target predefines none of them. An architecture macro follows the target the same way: __aarch64__ on 64-bit ARM, __x86_64__ on x86-64, and __arm__ on 32-bit ARM. Use them to write portable code that compiles correctly on both platforms:
#ifdef __APPLE__
printf("macOS\n");
#else
printf("Linux\n");
#endif
This is how <net> selects the correct sockaddr_in layout: macOS has an extra sin_len field that Linux does not.
__FILE__ expands to the current source file path; __LINE__ expands to the current line number. #error aborts compilation with a message, honoring #ifdef branches:
printf("%d\n", __LINE__); // prints the line number
printf("%s\n", __FILE__); // prints the file path
#ifdef DEBUG #error DEBUG builds are not supported in this file #endif
Supported directives: #define (object-like and function-like, including multi-line with \), #undef, #ifdef, #ifndef, #if, #elif, #else, #endif, #error, #pragma pack, #pragma link. Predefined: __APPLE__, __linux__, _WIN32, _WIN64, __aarch64__, __x86_64__, __arm__, __FILE__, __LINE__, __ESKIU_FREESTANDING__. pack(N) takes N = 1, 2, 4, 8 or 16; any other value is an error.
Multi-file Programs
Every program in this book so far has lived in a single file. Real programs grow. When a file exceeds a few hundred lines, it becomes hard to navigate. The import statement lets you split code across as many files as you like.
13.1 Local file imports
// utils.esk
extern double sqrt(double x);
double hypotenuse(double a, double b) { return sqrt(a*a + b*b); }
// main.esk import "utils.esk"; extern int printf(string fmt, ...);
int main() {
printf("%.4f\n", hypotenuse(3.0, 4.0)); // 5.0000
return 0;
}
13.2 Standard library imports
import <result>; // Result<T,E>, Ok, Err import <list>; // List<T> import <string>; // String (mutable) import <math>; // sqrt, pow, floor, ceil import <io>; // printf, scanf import <mem>; // memcpy, memset, strlen import <fs>; // file I/O
13.3 No include guards
Each file is parsed exactly once per compilation, regardless of how many files import it. Circular imports are detected and handled.
13.4 Project layout
project/
main.esk # entry point
types.esk # shared struct definitions
extern.esk # all extern declarations
utils.esk # helpers
Makefile
# Makefile build: ■eskiuc main.esk -o project
# With libraries: ■eskiuc main.esk -o project -lssl -lcrypto
C Interop
The entire C library ecosystem (OpenSSL, zlib, SQLite, libpng, the POSIX API) is available to Eskiu programs. Declare the function with extern, the compiler generates a standard C-ABI call, and the linker resolves it.
14.1 Declaring extern functions
extern int printf(string fmt, ...); extern int64 strlen(string s); extern *void malloc(int64 size); extern *void memcpy(*void dst, *void src, int64 n); extern void exit(int code);
14.1b Declaring extern variables
An extern declaration also reaches C global variables, not just functions. Write extern <type> <name>; to read and write a global that lives in another translation unit:
extern int shared_counter; // an int defined in a linked C file
shared_counter = shared_counter + 1;
printf("%d\n", shared_counter); // read and write the C global
The compiler emits an external-linkage declaration with no initializer; the linker resolves it against the real definition. This is how Eskiu shares mutable state with a C library, not only calls into it.
A struct or union can cross the boundary by value in either direction. The compiler lowers such a call to the platform's C calling convention (on AArch64, x86-64 Linux and macOS, Windows x64, 32-bit ARM and 32-bit x86), so it passes the struct in the same registers or memory that clang would use for the matching C prototype:
struct Vec2 { double x; double y; }
extern Vec2 vec2_add(Vec2 a, Vec2 b); // C: struct Vec2 vec2_add(struct Vec2, struct Vec2);
Vec2 a = Vec2 { x: 1.0, y: 2.0 };
Vec2 s = vec2_add(a, Vec2 { x: 0.5, y: 0.5 });
printf("%.1f %.1f\n", s.x, s.y); // 1.5 2.5
Keep the field types and order identical on both sides. Unions follow C layout too, so a union field inside such a struct lines up with its C counterpart.
On 32-bit x86 (cdecl) the rules differ by operating system, and the compiler follows clang's. A struct argument of at most 16 bytes made only of 32- and 64-bit scalars is passed as those scalars, and any other struct is copied onto the stack. Linux returns every struct through a hidden pointer, while macOS, Windows and the BSDs return a struct of 1, 2, 4 or 8 bytes in registers.
14.2 Calling OpenSSL: a real example
extern *void EVP_CIPHER_CTX_new();
extern void EVP_CIPHER_CTX_free(*void ctx);
extern *void EVP_aes_256_cbc();
extern int EVP_DecryptInit(*void ctx, *void cipher,
*uint8 key, *uint8 iv);
extern int EVP_DecryptUpdate(*void ctx, *uint8 out, *int outl,
*uint8 inp, int inl);
extern int EVP_DecryptFinal(*void ctx, *uint8 out, *int outl);
14.3 Linking
All library flags are passed directly to eskiuc; it forwards them to the system linker:
eskiuc main.esk -o program -lssl -lcrypto # OpenSSL eskiuc main.esk -o program -L/my/libs -lmylib # custom path
The libraries a program implies are added without a flag. The C++ exception runtime is linked when the program throws or catches (-lc++ on macOS, -lstdc++ on Linux and Windows), and pthread when it calls thread_create (Linux and Windows). A module can name its own library with #pragma link("name"), which links -lname. The stdlib uses it for libm, pthread and ws2_32, guarded by #ifdef so each platform links only what it needs:
#ifdef __linux__
#pragma link("m") // glibc keeps libm apart from libc
#endif
Each library is linked once, after the objects, and never when only an object file is produced. --no-default-libs turns all of this off for custom linking.
To compile to an object file and link separately:
eskiuc main.esk -c -o main.o clang main.o other.o -lssl -o program
14.4 Passing Eskiu functions as C callbacks
Many C APIs take a raw function pointer (qsort, signal, OpenSSL ALPN). Casting a top-level function to *void yields its bare C function-pointer address rather than the two-word closure fat pointer:
import <mem>; extern void qsort(*void base, int64 n, int64 size, *void compar);
int cmp(*void a, *void b) {
int x = *(*int)a; int y = *(*int)b;
if (x < y) return -1;
if (x > y) return 1;
return 0;
}
*int arr = alloc<int>(5); arr[0]=3; arr[1]=1; arr[2]=4; arr[3]=1; arr[4]=5; qsort((*void)arr, (int64)5, (int64)4, (*void)cmp); // (*void)cmp is the raw C pointer, not a fat closure free((*void)arr);
This works for top-level functions only. A closure or lambda still carries an environment and cannot be passed as a C callback.
The prototype can also spell the callback's type. A parameter of fn type in an extern is a C function pointer, so the call passes the function by name, with no cast:
extern void qsort(*void base, int64 n, int64 size, fn(*void, *void)->int cmp);
qsort((*void)arr, (int64)5, (int64)4, cmp);
for (i in 0..5) { printf("%d ", arr[i]); } // 1 1 3 4 5
Output: 1 1 3 4 5
Only a top-level function (or null) is accepted there. Passing a lambda or a fn-typed variable is a compile error ("is a C function pointer"), because its environment cannot cross into C.
A callback may take or return a struct by value. C then calls it through a small thunk the compiler generates with the target's C calling convention, so the struct arrives the way the C side passed it. Your own Eskiu calls to the function are unaffected.
The Standard Library
Eskiu's standard library ships as 42 plain .esk files with the compiler. There is no magic: they are Eskiu code, readable at lib/eskiu/stdlib/ in your installation. Import each module with an angle-bracket import.
15.1 result: errors as values
The most important module. Result<T,E> is a struct with three fields: ok (1 = success, 0 = failure), value (holds T on success), and error (holds E on failure). Two constructor functions create instances:
// result.esk: the complete interface
struct Result<T, E> {
int ok; // 1 = success, 0 = failure
T value;
E error;
}
Result<T, E> Ok<T, E>(T value) // wrap a success value Result<T, E> Err<T, E>(E error) // wrap an error value
import <result>;
Result<int, string> divide(int a, int b) {
if (b == 0) return Err<int, string>("division by zero");
return Ok<int, string>(a / b);
}
let r: Result<int, string> = divide(10, 2);
if (r.ok == 1) {
printf("result: %d\n", r.value);
} else {
printf("error: %s\n", r.error);
}
Output: result: 5
15.2 list: dynamic arrays
List<T> is a growable array. Capacity doubles when full. The struct layout is { *T data; int size; int cap; }. All fields are public.
// list.esk: the complete interface void List_init<T>(List<T>* self, int cap) // call before use void List_push<T>(List<T>* self, T item) // append an element T List_get<T>(List<T>* self, int i) // read element i void List_set<T>(List<T>* self, int i, T item) // overwrite element i void List_remove<T>(List<T>* self, int i) // remove element i, shift the tail down int List_len<T>(List<T>* self) // returns self->size void List_free<T>(List<T>* self) // free the buffer
The compiler infers T from the List<T>* argument; no explicit type argument needed:
import <list>;
List<int> nums; List_init(&nums, 4); // T inferred as int List_push(&nums, 10); List_push(&nums, 20); List_push(&nums, 30);
printf("len: %d\n", List_len(&nums)); // 3
// for-in iterates a List directly
for (x in nums) {
printf("%d ", x);
}
printf("\n");
// Classic index loop also works
for (int i = 0; i < nums.size; i += 1) {
printf("%d ", nums.data[i]); // direct field access
}
printf("\n");
List_free(&nums);
Output: len: 3 10 20 30 10 20 30
List_free releases the buffer and sets data to null (and the size and capacity to zero). A second List_free on the same list is then a harmless no-op, and a push after a free starts a fresh buffer instead of writing into freed memory. The same holds for a list freed through String_split_free.
15.3 string: mutable text
The built-in string type is an immutable C-string literal. For text you need to build or modify at runtime, use String, a struct with an owned, growable buffer. The standard library is broad. The complete String API:
// string.esk: the complete interface void String_init(String* self, int64 cap) // initialise with capacity void String_from(String* self, string s) // initialise from a literal void String_append(String* self, string s) // append a string literal void String_concat(String* self, String* other) // append another String void String_push(String* self, char c) // append a single character void String_clear(String* self) // reset to empty (keep buffer) void String_reverse(String* self) // reverse in place void String_free(String* self) // free the buffer
*char String_cstr(String* self) // null-terminated C string int64 String_len(String* self) // length in bytes char String_char_at(String* self, int64 i) // read character at index i void String_set(String* self, int64 i, char c) // overwrite character at i int64 String_index_of(String* self, char c) // first index of c, or -1 int String_eq(String* self, String* other) // 1 if equal, else 0 int String_eq_cstr(String* self, string s) // compare to C string literal void String_substring(String* self, String* out, int64 start, int64 count) void String_from_int(String* self, int n) // render integer as text int String_to_int(String* self) // parse leading integer int String_starts_with(String* self, string prefix) // 1 if prefix matches int String_ends_with(String* self, string suffix) // 1 if suffix matches void String_trim(String* self) // strip whitespace int String_next_token(String* self, char sep, *int pos, String* out) void String_split(String* self, char sep, List<String>* out) // split into list void String_split_free(List<String>* parts) // free parts from split
import <string>;
let s: String;
String_from(&s, "hello");
String_push(&s, '!'); // hello!
String_set(&s, 0, 'H'); // Hello!
printf("%s (len=%lld)\n", String_cstr(&s), String_len(&s));
printf("%c\n", String_char_at(&s, 0)); // H
printf("%lld\n", String_index_of(&s, 'l')); // 2
printf("%d\n", String_eq_cstr(&s, "Hello!")); // 1
let sub: String;
String_init(&sub, 8);
String_substring(&s, &sub, 1, 4); // ello
String_reverse(&sub);
printf("%s\n", String_cstr(&sub)); // olle
let num: String;
String_init(&num, 8);
String_from_int(&num, -2026);
printf("%s -> %d\n", String_cstr(&num), String_to_int(&num)); // -2026 -> -2026
String_free(&s); String_free(&sub); String_free(&num);
Output: Hello! (len=6) H 2 1 olle -2026 -> -2026
15.4 math: mathematical functions
Thin wrappers over libm (linked automatically). All functions take and return double except abs:
// math.esk: the complete interface double sqrt(double x) // square root double fabs(double x) // absolute value (float) int abs(int x) // absolute value (int) double pow(double base, double exp) // base raised to exp double floor(double x) // round down double ceil(double x) // round up double fmod(double x, double y) // floating-point remainder
import <math>;
printf("sqrt(2) = %.6f\n", sqrt(2.0));
printf("pow(2, 10) = %.0f\n", pow(2.0, 10.0));
printf("floor(3.7) = %.0f\n", floor(3.7));
printf("ceil(3.2) = %.0f\n", ceil(3.2));
printf("fabs(-5.5) = %.1f\n", fabs(-5.5));
Output: sqrt(2) = 1.414214 pow(2, 10) = 1024 floor(3.7) = 3 ceil(3.2) = 4 fabs(-5.5) = 5.5
15.5 io: standard input/output
Wrappers for libc stdio. printf is the most common; you have been using it since Chapter 1:
// io.esk: the complete interface int printf(string fmt, ...) // print to stdout int fprintf(string stream, string fmt, ...) // print to a FILE* handle (typed string) int sprintf(string buf, string fmt, ...) // print into a caller-owned buffer int scanf(string fmt, ...) // read from stdin int puts(string s) // print string + newline int getchar() // read one character int putchar(int c) // write one character
15.6 mem: memory operations
Wrappers for libc memory functions. Essential for working with raw byte buffers in systems code:
// mem.esk: the complete interface *void memcpy(*void dst, *void src, int64 n) // copy n bytes *void memset(*void dst, int c, int64 n) // fill n bytes with c *void memmove(*void dst, *void src, int64 n) // copy, handles overlap int memcmp(*void a, *void b, int64 n) // compare n bytes int64 strlen(string s) // length of C string *void memchr(*void s, int c, int64 n) // find byte in buffer
import <mem>;
*uint8 buf = alloc<uint8>(16);
memset(buf, 0, 16); // zero the buffer
memcpy(buf, "Hello", 5); // copy 5 bytes
printf("%d\n", memcmp(buf, "Hello", 5)); // 0 = equal
printf("%d\n", strlen("Eskiu")); // 5
free(buf);
Output: 0 5
15.7 fs: file I/O
fs wraps POSIX file operations. A file handle is a *void returned by fs_open. Always check for null before using the handle, and always close when done:
// fs.esk: the complete interface *void fs_open(string path, string mode) // "r", "w", "rb", "wb", "a" void fs_close(*void fp) // close the handle void fs_flush(*void fp) // flush write buffer
int64 fs_read(*void fp, *uint8 buf, int64 n) // read up to n bytes *uint8 fs_readline(*void fp, *uint8 buf, int n)// read one line int64 fs_write(*void fp, *uint8 buf, int64 n) // write n bytes int fs_puts(*void fp, string s) // write a string
int fs_seek(*void fp, int64 offset, int w) // w: 0=SET 1=CUR 2=END int64 fs_tell(*void fp) // current position int64 fs_size(*void fp) // file size in bytes
*uint8 fs_read_all(string path, *int64 out_len)// read entire file (caller frees) int fs_write_all(string path, *uint8 buf, int64 n) // write entire buffer
int fs_eof(*void fp) // 1 if at end of file int fs_error(*void fp) // 1 if error occurred
import <fs>;
// Write a file
*void f = fs_open("hello.txt", "w");
if (f != null) {
fs_puts(f, "Hello from Eskiu\n");
fs_close(f);
}
// Read entire file at once
int64 size = 0;
*uint8 data = fs_read_all("hello.txt", &size);
if (data != null) {
printf("read %lld bytes\n", size);
free(data);
}
Output: read 17 bytes
fs_read_all returns a heap-allocated buffer. You must free it when done.
15.8 map: string-keyed hash map
The <map> module provides Map<V>, a generic string-keyed hash map using open addressing with linear probing and 0.75 load-factor growth. Keys are strings; values are any type V:
import <map>;
let m: Map<int>; Map_init<int>(&m, 16); // initial capacity
// get-or-insert: returns *V slot, sets *created = 1 if new
int created = 0;
*int slot = Map_at<int>(&m, "hits", &created);
if (created == 1) { slot[0] = 0; }
slot[0] = slot[0] + 1;
// lookup only
int v = 0;
if (Map_get<int>(&m, "hits", &v) == 1) {
printf("%d\n", v);
}
Map_free<int>(&m);
Keys are copied on insert; the map owns them. Map_at returns a stable *V pointer into the value array, valid until the next insert that triggers a grow. For file uploads and binary payloads, pair with <multipart>:
The function below runs inside an HTTP handler, after http_recv has filled req (section 23.5 shows the server loop). It looks up the part named photo in a multipart/form-data body:
import <http>; import <multipart>; import <string>;
int find_photo(HttpReq* req, **uint8 out, *int64 out_len) {
int found = 0;
String ct; String_init(&ct, 64);
HttpReq_header(req, "Content-Type", &ct); // Content-Type header value
String boundary; String_init(&boundary, 64);
if (multipart_boundary(&ct, &boundary) == 1) {
*uint8 part; int64 plen;
if (multipart_part(req.body, req.body_len, &boundary,
"photo", &part, &plen) == 1) {
// part points into req.body (zero-copy slice), plen bytes
out[0] = part; out_len[0] = plen;
found = 1;
}
}
String_free(&boundary); String_free(&ct);
return found;
}
15.8b HashMap: generic key map
Map<V> keys on strings. HashMap<K,V> keys on any type: you pass hash and eq function pointers at init, the same pattern as C's qsort. Built-in helpers int_hash/int_eq cover integer keys:
import <map>;
let m: HashMap<int, int>; HashMap_init<int,int>(&m, 16, int_hash, int_eq);
int created = 0;
*int slot = HashMap_at<int,int>(&m, 42, &created);
if (created == 1) { slot[0] = 0; }
slot[0] += 1;
int v = 0;
if (HashMap_get<int,int>(&m, 42, &v) == 1) {
printf("%d\n", v);
}
HashMap_free<int,int>(&m);
15.9 bytes: binary-safe byte buffer
The <bytes> module provides Bytes: a growable binary-safe buffer backed by *uint8 and an explicit length. Unlike String, which is NUL-terminated, Bytes treats every byte as real data, essential for binary payloads and cryptographic output:
import <bytes>;
let b: Bytes; Bytes_init(&b, 64);
uint8[4] buf = {0xDE, 0xAD, 0x00, 0xBE}; // embedded NUL is fine
Bytes_append_raw(&b, &buf[0], 4); // an array does not decay: pass &buf[0]
Bytes view = Bytes_slice(&b, 0, 2); // non-owning, no alloc
Bytes enc = Bytes_to_base64(&b); Bytes_free(&enc); Bytes_free(&b);
Use HttpReq_body(&r;) to get a non-owning Bytes view of an HTTP request body without copying. A slice has cap = 0, so Bytes_free is safe to call on it.
15.10 random: seedable randomness
The <random> module is a seedable pseudo-random generator (xoshiro256**). All of its state lives in an Rng value, so there is no hidden global: a given seed always reproduces the same stream, which is exactly what you want for tests and repeatable runs. It is fast and passes the standard statistical tests, but it is not cryptographically secure, so do not use it for keys, tokens, or nonces.
import <random>;
let r: Rng; Rng_seed(&r, 12345); // deterministic
uint64 x = Rng_next(&r); // raw 64-bit int64 d = Rng_range(&r, 1, 7); // a die: uniform in [1, 7) double u = Rng_double(&r); // uniform in [0.0, 1.0) int b = Rng_bool(&r); // 0 or 1
Rng_range and Rng_below are unbiased: they redraw whenever they land on the handful of values a plain modulo would skew, so every outcome is equally likely. Rng_fill(&r, buf, n) fills a byte buffer. For a nondeterministic stream, seed from the clock: import <time>; Rng_seed(&r, (uint64)time_now_ms());
Because the functions follow the Type_method naming convention, dot syntax works too: r.next() is Rng_next(&r). The same holds for the generic containers. On a List<int> l, l.push(8) is List_push<int>(&l, 8) and l.get(0) is List_get<int>(&l, 0): the type arguments of a generic Type_method function are taken from the receiver, so ch.send(v) on a Chan<int>* and m.get(key, &out) on a Map<V> need no explicit <...>. Releases before 0.9.2 spelled these rng_seed, rng_next and so on. The old names still compile as deprecated wrappers, and the same applies to the other modules renamed in 0.9.2 (Regex_*/Match_*, Heap_*, EventLoop_*, Executor_*, Chan_*, HpackDecoder_*, H2Conn_*, DateTime_*). Functions that create a value keep their lowercase names: regex_compile, el_new, executor_new, chan_new.
15.11 regex: pattern matching
The <regex> module is a regular-expression engine built as a Thompson NFA, run as a Pike VM. It explores every path in lockstep, so matching is linear in the length of the input with no catastrophic backtracking: the pathological patterns that hang backtracking engines (the basis of a ReDoS attack) cannot occur here.
For a quick yes/no, regex_match compiles the pattern and searches the text for you:
import <regex>;
if (regex_match("\\d+-\\d+", "order 12-34") == 1) { /* found */ }
To reuse a pattern or read capture groups, compile once, then search and pull the groups out (group 0 is the whole match, group 1 the first parenthesized part, and so on):
Regex re = regex_compile("(\\w+)@(\\w+)");
let m: Match;
if (Regex_search(&re, "user@host", &m) == 1) {
let name: String;
String_init(&name, 16);
Match_group(&m, "user@host", 1, &name); // "user"
String_free(&name);
}
Match_free(&m);
Regex_free(&re);
The syntax covers literals, . (any character but newline), character classes [a-z] and [^...], the shorthands \d \w \s (and \D \W \S), the quantifiers * + ? {m} {m,} {m,n}, alternation |, capturing groups ( ), and the anchors ^ and $. Quantifiers are greedy by default; append a ? (as in .+?) for the lazy form that matches as little as possible.
Patterns and texts are UTF-8, and the semantics are those of RE2 (Go's regexp). ., a class and a literal each match a whole code point, so ^.$ matches "é" even though it is two bytes. Match offsets (m.cap) stay byte offsets into the text, and a byte that is not valid UTF-8 reads as U+FFFD.
Unicode classes are written \p{..}, and \P{..} is the negation. They name the general categories (\p{Lu}, \p{Nd}), their one-letter groups (\pL, \pN), the scripts (\p{Greek}, \p{Han}) and \p{Any}, from the Unicode 15 tables in <regex_unicode>, and they work inside brackets too ([\p{Greek}\d]). The flags are (?i) for case-insensitive matching (by simple case folding, so k, K and the Kelvin sign are one letter), (?m) for multi-line ^ and $, (?s) to let . match a newline, and (?U) to swap greedy and lazy. A flag applies to the rest of the enclosing group; (?i:...) scopes it to one group and (?-i) turns it off.
(?:...) groups without capturing. A named group, (?P<name>...) or (?<name>...), is numbered with the other groups, so you read it with Match_group by its number. \Q...\E matches the text between as written, so \Q1+1\E matches the three characters 1+1, and \x{..} names a code point up to \x{10FFFF}:
void show(string pat, string text) {
Regex re = regex_compile(pat);
let m: Match;
if (Regex_search(&re, text, &m) == 1) {
let s: String;
String_init(&s, 16);
Match_group(&m, text, 0, &s);
printf("%s -> %s (bytes %d..%d)\n", pat, String_cstr(&s), m.cap[0], m.cap[1]);
String_free(&s);
} else {
printf("%s -> no match\n", pat);
}
Match_free(&m);
Regex_free(&re);
}
show("^.$", "é");
show("\\p{Greek}+", "abc αβγ def");
show("(?i)straße", "STRAßE");
show("(?:ab)+(?P<n>\\d+)", "xabab42");
show("\\Q1+1\\E", "is 1+1=2");
show("\\x{1F600}", "hi 😀");
^.$ -> é (bytes 0..2)
\p{Greek}+ -> αβγ (bytes 4..10)
(?i)straße -> STRAßE (bytes 0..7)
(?:ab)+(?P<n>\d+) -> abab42 (bytes 1..7)
\Q1+1\E -> 1+1 (bytes 3..6)
\x{1F600} -> 😀 (bytes 3..7)
The byte offsets show the UTF-8 at work: é is two bytes, each Greek letter two, and the emoji four.
The rest of the syntax also follows RE2. Besides the shorthands above there are the ASCII word boundaries \b and \B, the text anchors \A and \z, the escapes \a \f \t \n \r \v, octal \012 and hex \x41 or \x{41}, and a backslash before any punctuation character for that character (\.). Any other escaped letter or digit (\q, \1) is a compile error rather than the literal letter. Inside brackets the POSIX classes work ([[:alpha:]], [[:^digit:]] and the other RE2 names), \s is [\t\n\f\r ], and an inverted range such as [z-a] is an error. A repeat count may be at most 1000, and a { that does not start a valid {n}, {n,} or {n,m} is a literal brace. A loop whose body can match the empty string behaves as in RE2 ((a?|b)* on "b" matches the empty string at 0), and nesting is limited to 512 levels so a hostile pattern fails cleanly. A pattern that does not compile leaves re.ok at 0, with the reason in re.err; an unknown class such as \p{Foo} is one of these. The engine was checked against Go's regexp on 60 000 generated patterns and texts, and every result agreed.
15.12 Additional stdlib: quick reference
// net: TCP sockets (see Chapter 23)
import <net>; net_tcp_listen net_accept net_tcp_connect
net_send net_recv net_close
// threading: Mutex / Cond / Sem over pthread import <threading>; Mutex Cond Sem (init/lock/unlock/wait/signal/destroy)
// http: HTTP/1.1 worker pool server
import <http>; http_serve(port, workers, handler)
http_serve_with(port, workers, handler, lim) // HttpLimits: timeouts, see 23.6
// json: builder + parser
import <json>; Json_init/obj_begin/key/str/int/obj_end/cstr/free
json_parse/JsonValue_get/JsonValue_as_double/JsonValue_free
// alloc: explicit allocators over a caller buffer (Zig model)
import <alloc>; Bump Arena Pool FirstFit
alloc_with(&allocator, T, n) -> *T
// sysheap: OS-backed heap (no libc malloc)
import <sysheap>; Heap_init(&h, size) bool Heap_alloc(&h, n) *void
Heap_free(&h, p) Heap_destroy(&h)
// time / env / base64 / path
import <time>; time_now_ms time_now_s time_monotonic_ms sleep_ms(ms)
DateTime time_to_utc/DateTime_to_epoch DateTime_format_iso // UTC civil calendar
import <env>; env_get env_has env_get_or env_get_int
import <base64>; base64_encode/decode(*uint8 src, int n, *uint8 out)
base64_encoded_len(n) base64_decoded_len(n)
// decode returns -1 on bad characters, misplaced '=' or a truncated input
import <path>; path_join path_basename path_dirname path_extension
// sort: generic heapsort + binary search over a *T array (O(n log n) worst case)
import <sort>; sort<T>(a, n, cmp) bsearch<T>(a, n, key, cmp)
// cmp(&x, &y) returns <0 / 0 / >0, like C qsort; works on a List's .data too
// url: RFC 3986 percent-encoding + form-query lookup
import <url>; url_encode(s, &out) url_decode(s, &out)
url_query_get(query, key, &out) 1/0 // "a=1&msg=hi+there" -> "hi there"
// uuid: RFC 4122 version-4, built on(not crypto-secure) import <uuid>; let r: Rng; Rng_seed(&r, seed); uuid_v4(&r, &out)
// async runtime import <atomic>; atomic_load atomic_store atomic_swap atomic_cas import <executor>; Executor (event loop + thread-safe queue) import <timer>; timer_after(lp, ms) -> *Future<int> import <futureval>; select2v -> Either<A,B> join2v -> Pair<A,B>
// HTTP/2 + TLS stack import <http2>; HTTP/2 framing + streams (RFC 7540) import <hpack>; HPACK header compression with Huffman (RFC 7541) import <http2_server>; http2_serve_async (same handler interface as <http>) import <tls>; OpenSSL TLS + ALPN h2 negotiation
// Data structures
import <map>; Map<V>: string-keyed hash map (open addressing, 0.75 load)
Map_init<V>(&m, cap) Map_at<V>(&m, key, &created) Map_get<V> Map_free<V>
// HTTP helpers
import <multipart>; multipart_boundary(ct, out) 1=ok
multipart_part(body, blen, boundary, name, &part_ptr, &part_len) -> int
Inline Assembly
Sometimes you need to speak to hardware directly: toggle a CPU flag, write to a memory-mapped register, execute a single privileged instruction. Eskiu's asm(...) statement embeds assembly inline, lowering directly to LLVM inline asm nodes.
16.1 Simple form
asm("nop"); // no operation
asm("wfi"); // ARM64: wait for interrupt
asm("cli"); // x86: disable interrupts
16.2 Extended form with inputs
GCC-compatible constraint syntax. "memory" as clobber prevents the compiler from reordering memory accesses across the instruction:
void uart_putc(int c) {
int64 UART_DR = 0x09000000;
asm("strb ${0:w}, [$1]" :: "r"(c), "r"(UART_DR) : "memory");
}
16.2b Output operands
An instruction that produces a value writes it to an output operand, listed before the inputs: asm("op" : outputs : inputs : clobbers). The constraints are the ones clang takes. =r writes a register that is then stored into the variable, =&r is the same with an early clobber (the register is written before all inputs are read, so it never shares one with an input), +r reads the variable and writes it back, and =m and +m work on the variable's memory directly. Operands are numbered as in clang, outputs first, so with one output $0 is the output and $1 the first input:
int64 a = 40;
int64 b = 2;
int64 sum;
int64 acc = 10;
int64 cell = 0;
#ifdef __aarch64__
asm("add $0, $1, $2" : "=r"(sum) : "r"(a), "r"(b));
asm("add $0, $0, $1" : "+r"(acc) : "r"(a));
asm("str $1, $0" : "=m"(cell) : "r"(b));
#else
asm("leaq ($1,$2), $0" : "=r"(sum) : "r"(a), "r"(b));
asm("addq $1, $0" : "+r"(acc) : "r"(a));
asm("movq $1, $0" : "=m"(cell) : "r"(b));
#endif
printf("%lld %lld %lld\n", sum, acc, cell); // 42 50 2
Output: 42 50 2
An asm may have several outputs. Each one must be something you could assign to (a variable, a field, an element or a dereference) of an integer type other than bool, a floating-point type or a pointer type, and it may not be a bitfield. A const or a literal there is a compile error, and so is an input constraint that starts with = or +, or an output constraint that does not.
16.3 volatile: a reminder
Hardware registers accessed via inline assembly almost always need volatile (covered fully in section 6.9). Without it, the compiler may eliminate a store it considers redundant:
volatile let uart_dr: *uint8 = (uint8*) 0x09000000; *uart_dr = 'A'; // always emitted
Cross-compilation
Cross-compilation means building a binary for a different machine than the one you are compiling on. You might develop on an x86-64 laptop but deploy to an ARM64 server, or write a kernel that runs on bare metal with no OS. Eskiu handles this with a target triple and a few tuning flags.
17.1 --target
eskiuc program.esk --target x86_64-pc-linux-gnu -o prog.o
eskiuc kernel.esk --target aarch64-unknown-none-elf \
--freestanding -o kernel.o
Triple Use it for
x86_64-pc-linux-gnu Linux on Intel/AMD 64-bit
aarch64-unknown-linux-gnu Linux on ARM64 (Raspberry Pi, Graviton)
aarch64-unknown-none-elf Bare-metal ARM64, no OS, no libc
x86_64-unknown-none Bare-metal x86-64, no OS, no libc
x86_64-pc-windows-gnu Windows on Intel/AMD 64-bit (COFF object)
armv6k-none-eabihf 32-bit ARM (Nintendo 3DS), hard-float
i686-pc-linux-gnu Linux on 32-bit x86
A 32-bit ARM triple reaches smaller and older hardware. Three flags tune the machine: --mcpu (for example mpcore for the 3DS's ARM11), --mattr (an LLVM feature string like +vfp2), and --reloc (static for a loader that applies fixed relocations). A triple ending in hf (like armv6k-none-eabihf) selects the hard-float ABI, so the object carries the Tag_ABI_VFP_args attribute and links against hard-float libraries. An object built this way links into a Nintendo 3DS .3dsx homebrew through the devkitARM toolchain, with its logic in Eskiu running on the ARM11.
Without --mcpu every build targets the baseline CPU of its target, a native build included, as clang does: apple-m1 on arm64 Apple targets (every Apple arm64 chip has its features, LSE atomics included) and generic elsewhere. The binary then runs on any machine of that architecture, including a VM or a host that hides some CPU features. To use the instructions of a particular processor, name it: --mcpu apple-m1, or --mcpu x86-64-v3 for recent x86-64.
Windows is a hosted target, not a bare-metal one. The compiler emits a COFF object with the Microsoft x64 calling convention, and the preprocessor predefines _WIN32 and _WIN64 so the standard library takes its Windows branches. Link the object with a mingw g++ and the whole stdlib runs there: exceptions unwind through the SEH personality, <sysheap> maps pages with VirtualAlloc, <net> talks to Winsock, and the async reactor in <eventloop> runs on WSAPoll. So both a blocking and an async server build and run on Windows the same as on Linux or macOS.
17.2 --freestanding
When targeting bare-metal there is no libc. --freestanding redirects alloc and free to your own esk_alloc and esk_free:
// alloc.esk: bump allocator int64 _heap_ptr = 0x40300000; int64 _heap_end = 0x40400000;
*uint8 esk_alloc(int64 size) {
if (_heap_ptr + size > _heap_end) return null;
int64 ptr = _heap_ptr;
_heap_ptr = _heap_ptr + size;
// esk_alloc must return zeroed memory (alloc<T>'s contract):
*uint8 p = (*uint8) ptr;
int64 i = 0;
while (i < size) { p[i] = 0; i = i + 1; }
return p;
}
void esk_free(*uint8 ptr) { /* bump: free is a no-op */ }
Bare-metal Programming
Bare-metal means the program runs directly on the hardware, with no operating system, libc, or runtime beneath it. The CPU starts at a known address and executes whatever is there. You set up the stack. You initialise the serial port. You write every byte of output yourself. This chapter walks through the Eskiu kernel that ships in the repository: a real ARM64 kernel that boots in QEMU, prints to the serial console, and allocates heap memory, without a single line of C.
18.1 Why bare-metal matters
Most systems languages claim to support bare-metal. Eskiu does not claim it; it ships a working kernel. The fact that the same language that decodes AES-256-CBC ciphertext also boots on raw ARM64 hardware is not a coincidence. It is a consequence of three design decisions: no implicit runtime, a redirectable alloc/free (from <mem>, switched by #ifdef __ESKIU_FREESTANDING__), and inline assembly as a first-class statement.
18.2 What changes in freestanding mode
When you compile with --freestanding, two things change:
First, alloc<T>(N) is now in <mem>. It no longer calls malloc from libc. Instead it calls esk_alloc, a function you provide. Similarly, free calls esk_free. The compiler inserts these calls; you write the implementations.
Second, the compiler targets a bare-metal ABI: no C runtime startup, no _start wrapper, no __libc_start_main. Your code is linked directly at the load address.
# Everything needed to cross-compile for bare-metal ARM64 eskiuc kernel.esk --target aarch64-unknown-none-elf --freestanding -o kernel.o
18.3 The memory map
QEMU's -M virt machine puts DRAM starting at 0x40000000. The linker script divides it into four regions:
/* linker.ld */ . = 0x40000000; /* kernel code loads here */
/* .text.boot: _start must be first */ /* .text / .rodata / .data / .bss */
. = 0x40200000; __stack_bottom = .; . += 0x10000; __stack_top = .; /* 64 KB stack */
/* 0x40300000: heap start (1 MB) */ /* 0x09000000: UART data register (MMIO, not in DRAM) */
The UART lives at 0x09000000, a memory-mapped I/O address separate from DRAM. Writing a byte there transmits it on the serial port.
18.4 The boot sequence
The CPU starts executing at _start in boot.s, the only assembly in the entire project. It does two things: sets up the stack pointer, then calls kernel_main. Everything after that is Eskiu:
// boot.s: ARM64 entry point
.global _start
.section .text.boot, "ax"
_start:
ldr x0, =__stack_top // load stack top address
mov sp, x0 // set stack pointer
bl kernel_main // jump to Eskiu
.hang:
b .hang // should never return
The linker script places .text.boot first, so _start lands at 0x40000000, exactly where QEMU starts executing.
18.5 The UART driver
With no OS and no libc, printing to the screen means writing bytes directly to the hardware register at 0x09000000. The PL011 UART on QEMU's virt machine transmits whatever byte you write there. This is the complete driver, 20 lines of Eskiu:
// uart.esk int64 UART_DR = 0x09000000;
void uart_putc(int c) {
asm("strb ${0:w}, [$1]" :: "r"(c), "r"(UART_DR) : "memory");
}
void uart_puts(string s) {
int i = 0;
while (s[i] != 0) {
uart_putc((int) s[i]);
i = i + 1;
}
}
void uart_puthex(int64 n) {
uart_puts("0x");
int shift = 60;
while (shift >= 0) {
int nibble = (int)((n >> shift) & 0xF);
if (nibble < 10) { uart_putc(nibble + 48); }
else { uart_putc(nibble - 10 + 65); }
shift = shift - 4;
}
}
uart_putc uses inline asm to execute strb (store byte) directly to the UART data register. The "memory" clobber prevents the compiler from reordering or eliminating the store. uart_puts walks the string one character at a time until it hits the null terminator. String indexing (s[i]) returns a char, the same feature used in Chapter 2.
18.6 The bump allocator
With --freestanding, every alloc<T>(N) call in your program becomes a call to esk_alloc. You provide the implementation. The kernel uses the simplest possible allocator, a bump allocator that advances a pointer through a 1 MB heap:
// alloc.esk int64 _heap_ptr = 0x40300000; // heap start int64 _heap_end = 0x40400000; // heap end (1 MB)
*uint8 esk_alloc(int64 size) {
if (_heap_ptr + size > _heap_end) { return null; }
int64 ptr = _heap_ptr;
_heap_ptr = _heap_ptr + size;
// esk_alloc must return zeroed memory (alloc<T>'s contract):
*uint8 p = (*uint8) ptr;
int64 i = 0;
while (i < size) { p[i] = 0; i = i + 1; }
return p;
}
void esk_free(*uint8 ptr) {
// Bump allocator, free is a no-op.
// A real kernel would use a slab or buddy allocator here.
}
Allocation is a pointer advance and a bounds check. Deallocation is a no-op: memory is never reclaimed. This is appropriate for a demo kernel. A production kernel would replace these two functions with a slab allocator or buddy system; the rest of the kernel code stays exactly the same.
18.7 kernel_main
With the UART and allocator in place, kernel_main can print, allocate, and halt, all in Eskiu:
// kernel.esk import "uart.esk"; import "alloc.esk";
void kernel_main() {
uart_puts("Eskiu v0.1 kernel\r\n");
uart_puts("Running on QEMU -M virt (ARM64)\r\n");
uart_puts("UART base: "); uart_puthex(0x09000000); uart_puts("\r\n");
uart_puts("Heap start: "); uart_puthex(0x40300000); uart_puts("\r\n");
*uint8 buf = alloc<uint8>(64);
if (buf == null) {
uart_puts("alloc failed\r\n");
} else {
uart_puts("alloc(64): "); uart_puthex((int64) buf); uart_puts(" ok\r\n");
free(buf);
}
uart_puts("\r\nKernel halted.\r\n");
while (1) { asm("wfi"); } // wait for interrupt, idle loop
}
The while(1) { asm("wfi"); } at the end is the idle loop. wfi (wait for interrupt) is an ARM64 instruction that suspends the CPU until an interrupt arrives. Without it, the CPU would spin at full speed consuming power. With it, QEMU's CPU simulation goes idle.
18.8 Building and running
The Makefile handles the full build pipeline:
# Prerequisites (macOS) brew install lld qemu
# Build cd kernel && make
# Run in QEMU make run
Under the hood, make runs three commands:
# 1. Compile Eskiu to ARM64 object (no libc) eskiuc kernel.esk --target aarch64-unknown-none-elf --freestanding -o kernel.o
# 2. Assemble the boot stub clang --target=aarch64-unknown-none-elf -c boot.s -o boot.o
# 3. Link into a bare-metal ELF, no standard libraries ld.lld -T linker.ld -nostdlib -static -o kernel.elf boot.o kernel.o
QEMU loads the ELF, sets the program counter to 0x40000000, and starts executing. Expected output on the serial console:
Eskiu v0.1 kernel Running on QEMU -M virt (ARM64)
UART base: 0x0000000009000000 Heap start: 0x0000000040300000 alloc(64): 0x0000000040300000 ok
Kernel halted.
Press Ctrl-A X to exit QEMU.
18.9 What this demonstrates
The kernel uses seven features from this book in combination: multi-file imports (Ch13), inline assembly (Ch16), cross-compilation (Ch17), volatile and MMIO (Ch6.9), pointer casting (Ch6.3), string indexing (Ch2.2), and the freestanding allocator hook. None of these features exist in isolation; bare-metal programming is where they compose.
The kernel is about 110 lines of Eskiu in three files (kernel.esk, uart.esk, alloc.esk) and a 12-line assembly stub. The assembly sets up the stack. Everything else (the UART driver, the allocator, the output, the idle loop) is plain Eskiu code that compiles to the same LLVM IR as any other Eskiu program.
Ownership and Resources
Eskiu has no garbage collector. No reference counter silently frees memory when you stop using it. You are responsible for every allocation, and that responsibility must be explicit in your code.
19.1 The ownership principle
Every heap allocation has exactly one owner. A function receiving a pointer does not own it. A function returning a heap pointer transfers ownership to the caller:
import <mem>;
// Receiver does not own
void process(*uint8 data, int len) {
// use data, do NOT free it here
}
// Caller must free the returned pointer
*uint8 build_packet(int size) {
return alloc<uint8>(size);
}
19.2 Struct ownership
When a struct holds a pointer field it owns what it points to. Pair every heap-owning struct with init and free functions:
import <mem>;
struct Buffer { *uint8 data; int len; int cap; }
void Buffer_init(Buffer* self, int cap) {
self.data = alloc<uint8>(cap);
self.len = 0; self.cap = cap;
}
void Buffer_free(Buffer* self) {
free((*void)self.data);
self.data = null; self.len = 0; self.cap = 0;
}
Setting self.data = null after free causes a null dereference on double-free instead of silent heap corruption.
19.3 Early return cleanup
Every early return must free everything allocated so far. Initialise all pointers to null, freeing null is always safe:
import <result>; import <mem>; extern int read_data(*uint8 buf); extern int transform(*uint8 src, *uint8 dst); extern void write_result(*uint8 out);
Result<int, string> process() {
*uint8 buf = null;
*uint8 out = null;
buf = alloc<uint8>(4096);
if (read_data(buf) != 0) {
free((*void)buf);
return Err<int, string>("read failed");
}
out = alloc<uint8>(4096);
if (transform(buf, out) != 0) {
free((*void)buf); free((*void)out);
return Err<int, string>("transform failed");
}
write_result(out);
free((*void)buf); free((*void)out);
return Ok<int, string>(1);
}
19.4 defer: automatic cleanup
Repeating every free on every exit path is exactly how leaks creep in: add a new early return and forget one. defer stmt; schedules a statement to run when the enclosing block is left (on fall-through, return, break, continue, or a propagated ? error). Write the cleanup once, next to the allocation, and it runs on every path out:
Result<int, string> process() {
*uint8 buf = alloc<uint8>(4096);
defer free((*void)buf); // freed however we leave
if (read_data(buf) != 0) { return Err<int, string>("read failed"); }
*uint8 out = alloc<uint8>(4096);
defer free((*void)out); // freed however we leave
if (transform(buf, out) != 0) { return Err<int, string>("transform failed"); }
write_result(out);
return Ok<int, string>(1);
}
The two versions do the same thing, but the second cannot leak: every exit, including a propagated ? error, runs both defers. Deferred statements run in LIFO order (here out is freed before buf), and a defer inside a loop body runs at the end of each iteration. A defer body may not return or break/continue out of itself.
A body without braces counts as its own block. In if (owned) defer free(p); the defer is registered only when the condition is true, and it runs as soon as that one-statement body ends, not at the end of the function. The same holds for an unbraced loop body, a case of a switch and a match arm. To tie a cleanup to the whole function, put the defer directly in the function body.
defer also runs when an exception unwinds out of the block toward a try that handles it, or through a try/finally. An exception that nothing catches ends the program without running cleanups, as in C++. Use defer for the common acquire-then-release pattern, and try/finally (10.2) when the same block also needs a catch.
errdefer is a variant that runs only on the error path: when the function exits through a propagated ? error, not on a normal return. It is exactly what you want for undoing half-finished work when a later step fails, while keeping it when everything succeeds:
Result<int, string> connect(string host);
Result<int, string> handshake(int fd);
extern int close(int fd);
Result<int, string> open_ready(string host) {
int fd = connect(host)?;
errdefer close(fd); // closed only if a later ? fails; kept on success
handshake(fd)?; // if this errors, close(fd) runs, then the Err propagates
return Ok<int, string>(fd); // success: fd stays open, errdefer does not run
}
Like every use of ?, this needs the function to return the same Result type as the calls it propagates from.
On the error path a defer and an errdefer both run, LIFO. On the success path only the defer runs.
19.5 must_use: results you cannot ignore
Some return values are dangerous to drop. The result of alloc is the only handle to a heap block: discard it and the memory leaks immediately. Marking a function must_use makes the compiler reject any call whose result is thrown away:
must_use *uint8 grab() { return alloc<uint8>(64); }
grab(); // compile error: result of a must_use function is discarded
*uint8 p = grab(); // ok: the handle is kept
defer free((*void)p);
alloc in the standard library is already must_use, so a forgotten allocation is caught at compile time rather than leaking at run time. Apply must_use to your own functions that hand back an owned resource, an error code that must be inspected, or any value whose whole point is to be used.
When you really do mean to drop a value, say so with a cast to void. (void)expr; evaluates the expression and discards the result, as in C, and it silences must_use, so the intent is visible to the next reader:
must_use int status() { return 3; }
(void)status(); // ok: discarded on purpose
Debugging
Systems programs fail in ways that higher-level languages protect you from. A null dereference crashes with a segfault. A buffer overrun silently corrupts memory three functions away from the bug. This chapter covers the tools that find these bugs quickly.
20.1 Reading a segfault
A segfault means the program accessed memory it was not allowed to. Run under the debugger to find the exact location:
eskiuc buggy.esk -o buggy
# macOS lldb ./buggy (lldb) run (lldb) bt # backtrace: call chain to the crash (lldb) frame 0 # inspect the crash frame, print locals
# Linux gdb ./buggy (gdb) run (gdb) bt (gdb) info locals
Frame 0 is where the crash happened. One of the locals will be null or pointing into freed memory.
20.2 AddressSanitizer
ASan catches use-after-free, buffer overruns, and double-frees with precise error messages and exact line numbers:
eskiuc buggy.esk --asan -o buggy_asan ./buggy_asan # ==ERROR: AddressSanitizer: heap-use-after-free # READ of size 4 at 0x602000000010 # #0 0x... in main buggy.esk:12
20.3 Memory leak detection
# Linux valgrind --leak-check=full ./prog
# macOS: LeakSanitizer is included in --asan eskiuc leaky.esk --asan -o prog_asan ./prog_asan
20.4 Inspecting generated IR
When output is wrong and the source looks correct, inspect what the compiler actually generated:
eskiuc suspect.esk --test-codegen # dumps LLVM IR to stdout # Verify that types, loads, and stores are what you expect
20.5 Bounds checking with --safe
By default array and slice indexing is unchecked, matching C: an out-of-range index reads or writes whatever is at that address. Compiling with --safe inserts a bounds check on every array and slice index, so a violation traps immediately instead of corrupting memory:
eskiuc prog.esk --safe -o prog ./prog # traps at the first out-of-bounds index
--safe is off by default, so release builds pay nothing for it. Turn it on during development and testing so silent memory corruption becomes a clean, immediate failure, then ship without it (or keep it on where a trap is preferable to undefined behaviour).
Testing
Eskiu has no built-in test framework. The language gives you functions, return values, and printf. That is all you need. The pattern described here is the one used by Eskiu's own test suite, which in 0.9.2 holds about a thousand programs (run tests with expected output, and error tests that must be rejected).
21.1 Test functions
extern int printf(string fmt, ...);
int check(string name, int got, int want) {
if (got == want) { printf(" PASS %s\n", name); return 0; }
printf(" FAIL %s: got %d want %d\n", name, got, want);
return 1;
}
int add(int a, int b) { return a + b; }
int test_add() {
int f = 0;
f += check("2+3", add(2, 3), 5);
f += check("0+0", add(0, 0), 0);
f += check("neg", add(-1, -2), -3);
return f;
}
int main() {
int failures = test_add();
if (failures == 0) printf("All passed.\n");
else printf("%d failed.\n", failures);
return failures;
}
Output: All passed.
21.2 The .esk + .expected pattern
Each test is a program that prints its output. A matching .expected file holds the correct output. A shell script compares them:
#!/bin/sh
PASS=0; FAIL=0
for esk in tests/*.esk; do
exp="${esk%.esk}.expected"
[ -f "$exp" ] || continue
actual=$(eskiuc run "$esk" 2>&1)
if diff -q <(echo "$actual") "$exp" >/dev/null 2>&1; then
PASS=$((PASS+1))
else
echo "FAIL $esk"
diff <(echo "$actual") "$exp"
FAIL=$((FAIL+1))
fi
done
echo "$PASS passed, $FAIL failed"
eskiuc run compiles to a temp binary, runs it, and deletes it. No Makefile step needed for running individual tests.
21.3 Testing on Linux from a Mac
Because eskiuc cross-compiles, a Mac can test a program on Linux without a Linux build of the compiler. Build an object for the Linux target and link and run it in a container that has a C compiler:
eskiuc prog.esk --target aarch64-unknown-linux-gnu -c -o prog.o docker run --rm -v "$PWD":/w -w /w gcc:14 sh -c 'gcc prog.o -o prog && ./prog'
Link the libraries the program needs yourself here (-lm, -lpthread, -lstdc++ for exceptions), since an object file carries no link step. The compiler's own repository does this at scale in tests/linux_docker.sh, its pre-release Linux smoke. It cross-compiles every run and smoke test and the self-hosted drivers for AArch64 Linux (or x86-64 with TRIPLE=x86_64-unknown-linux-gnu), links and runs them in ubuntu:24.04 with gcc, compares each run test with its .expected, runs the self-hosted type checker and code generator over the corpus, and checks the bootstrap fixpoint on Linux. It needs Docker, so it is a manual check before a release rather than a CI gate:
CLANG=$(brew --prefix llvm@22)/bin/clang ESKIUC=build/eskiuc tests/linux_docker.sh TESTS="regex http2_server" tests/linux_docker.sh # only these tests
Common Bugs
Systems programming bugs are in a class of their own. They do not always crash immediately. They corrupt memory silently, produce wrong results only under specific conditions, and sometimes disappear when you add a print statement. This chapter names the six most common bugs, shows what they look like in Eskiu, and tells you how to avoid them.
22.1 Use-after-free
You free memory and then access it. The memory may have been reused, so the read returns garbage or corrupts something else:
import <mem>;
*int p = alloc<int>(1);
*p = 42;
free((*void)p);
printf("%d\n", *p); // WRONG: p is no longer valid
Fix: Set the pointer to null after freeing. A null dereference crashes immediately and tells you where, instead of silently returning garbage:
free(p); p = null;
22.2 Double-free
You free the same pointer twice. The heap's internal structures are corrupted, and the program crashes somewhere unpredictable:
free(p); free(p); // WRONG
Fix: Same discipline, null after free. Freeing null is safe:
if (p != null) { free(p); p = null; }
22.3 Null dereference
You dereference a pointer that is null. This always crashes immediately with a segfault, one of the more honest bugs:
*int p = null;
printf("%d\n", *p); // WRONG: immediate crash
Fix: Check for null before any dereference:
void show(*int p) {
if (p == null) { printf("no value\n"); return; }
printf("%d\n", *p);
}
22.4 Buffer overrun
You read or write past the end of an array. The memory beyond belongs to something else: another variable, heap metadata, or nothing. Writing past the end is a serious security vulnerability:
import <mem>;
*int arr = alloc<int>(5);
for (int i = 0; i <= 5; i += 1) { // WRONG: i == 5 is out of bounds
arr[i] = i;
}
Warning Buffer overwrite is the mechanism behind most memory corruption attacks.
Fix: The condition is i < 5, not i <= 5. Keep the size next to the pointer:
int n = 5;
*int arr = alloc<int>(n);
for (int i = 0; i < n; i += 1) { arr[i] = i; }
free((*void)arr);
22.5 Integer overflow
A calculation produces a value that does not fit in the type. Signed integer overflow wraps silently. The result is wrong, no error is raised:
int x = 2147483647; // INT_MAX
int y = x + 1; // WRONG: wraps to -2147483648
printf("%d\n", y); // -2147483648
Fix: Use int64 for large values:
int64 x = 2147483647; int64 y = x + 1; // 2147483648, correct
22.6 Memory leak
You allocate memory and never free it. The program runs fine at first, but over time consumes all available memory:
void process() {
*uint8 buf = alloc<uint8>(1024);
doWork(buf);
// WRONG: forgot to free buf
}
Fix: Every allocation has a corresponding free. The function that allocates owns the memory until it explicitly frees or transfers it:
void process() {
*uint8 buf = alloc<uint8>(1024);
doWork(buf);
free(buf); // correct
}
22.7 Bugs the compiler now catches
The compiler rejects a growing class of mistakes before they reach codegen. The v0.2.2 fuzzer added checks for duplicate switch cases, negative array dimensions, and dead code:
// Duplicate switch case values, now a compile-time error:
// error: duplicate case value in switch
switch (x) { case 1: ...; case 1: ...; }
// Negative array dimension, now rejected clearly: // error: array size must be positive, got -1 int[-1] buf;
// Dead code after block terminator, silently dropped:
int f() { return 1; printf("unreachable"); }
// the printf is removed without error (dead code)
v0.4.0 turned a whole class of latent runtime bugs into compile-time errors, so unsafe code fails to build instead of misbehaving at runtime:
float f = 3.9; int n = f; // error: float to int needs an explicit cast
uint8 b = 300; // error: integer literal out of range for uint8
int q = x / 0; // error: division by literal zero
int[3] a; int v = a[5]; // error: constant index out of bounds
int x; printf("%d", x); // error: x read before it is assigned
*int p() { int y = 1; return &y; } // error: returns the address of a local
int g(int a) { } // error: missing return in a non-void function
void main() { } // error: main must return int
These checks come from the v0.2.2 compiler fuzzer (duplicate cases, dimensions) and the v0.4.0 correctness pass (the conversion, flow, and bounds checks above), both using the LLVM IR verifier and the sanitizers as oracles. Note that narrowing a variable still truncates silently (int to uint8); the error is reserved for a literal that cannot fit its type.
The uninitialized-read error covers a read that no path assigns. When only some paths assign the variable, -Wall reports it as a warning:
int pick(int c) {
int r;
if (c > 0) { r = 1; }
return r; // warning: variable 'r' may be used uninitialized
}
The analysis follows if/else, loops with break and continue, switch fall-through, match arms, try/catch/finally, defer, early returns and the variables a lambda captures. Passing &r to a function counts as an assignment. It tracks scalar locals (numbers, pointers, string, fn values), not structs or arrays set field by field, and it is off unless you pass -Wall. The self-hosted compiler accepts the flag but prints no warnings.
v0.9.2 added another set. Each of these used to compile, and most of them produced a silently wrong program:
int G = compute(); // error: initializer of global 'G' is not a compile-time constant
int x = 1; int x = 2; // error: redefinition of 'x' in the same scope
S s = S { v: "text" }; // error: field 'v': cannot convert 'string' to 'int'
render(c); // error: cannot convert struct 'Circle' to interface 'Shape' by value
const P p = P { x: 1 }; p.set(9); // error: cannot call method 'set' on a read-only value
?*int q = find(); q = other(); *q; // error: cannot dereference a possibly-null pointer
f(1, 2); // error: function 'f' expects 1 arguments, got 2
int n = 08; // error: invalid digit '8' in octal literal '08'
#include <stdio.h> // error: #include is not supported (use import)
The later 0.9.2 audit rounds closed more holes of the same kind. These are the checks most likely to reject code that an earlier release accepted:
void() { n += 1; } // error: cannot assign to captured variable 'n'
void() { *int q = &n; } // error: cannot take the address of captured variable 'n'
*Big b = p; // error: cannot convert '*int' to '*Big' (write (*Big)p)
int f() { return g; } int g = 5; // error: undefined variable 'g' (define g first)
if (s) { } // error: condition must be boolean, numeric, or a pointer
if (a == b) { } // error: invalid operands for operator: struct:P and struct:P
finally { return 2; } // error: 'return' is not allowed inside a finally block
max(1.5, (float)2.5); // error: 'T' is deduced as 'double' ... and as 'float' here
const int Z = 0; int q = 5 / Z; // error: division by zero
int64 w = 1 << sizeof(int64) * 8; // error: shift count 64 is out of range for a 32-bit operand
const double D = 1e10; int i = (int)D; // error: floating constant ... is out of range for 'int'
mk().x = 5; // error: cannot assign to this expression
printf("%d", v()); // error: argument 2 has type 'void'
string t = "a\qb"; // error: unknown escape sequence '\q'
The constant checks work on the folded value, not only on the literal spelling. A divisor, a shift count, an index or a cast operand that folds to a bad value through a const name, a sizeof, a cast or arithmetic is reported the same way as the literal form, instead of compiling to an undefined result.
22.8 Known issues in 0.9.2
A few gaps are still open in this release. None of them miscompiles a valid program:
await is rejected inside a finally or a defer (25.1), and in a generic async function in a match arm that binds a payload, after a side-effecting operand in the same expression, and inside a ?: arm; bind the awaited value to a local first. A method that mutates a captured value inside a lambda acts on the closure's copy; write through a pointer to share state. The type checker does not fold sizeof of a struct with an unnamed : 0 bitfield. The self-hosted compiler is slow (quadratic) on binary expressions nested thousands of levels deep.
The list is kept under "Known issues" in the 0.9.2 section of CHANGELOG.md.
Networking
Network programming in Eskiu is POSIX sockets via the stdlib module. No framework required for basic use. A TCP server is a file descriptor you accept connections on. A TCP client is a file descriptor you write bytes to.
23.1 TCP echo server
import <net>; import <mem>; extern int printf(string fmt, ...);
int main() {
int fd = net_tcp_listen(7000);
if (fd < 0) { printf("bind failed\n"); return 1; }
printf("listening on :7000\n");
*uint8 buf = alloc<uint8>(1024);
while (1) {
int c = net_accept(fd);
if (c < 0) { continue; }
int64 n = net_recv(c, (*void)buf, 1024);
if (n > 0) { net_send(c, (*void)buf, n); }
net_close(c);
}
free((*void)buf); net_close(fd);
return 0;
}
# Test with netcat nc 127.0.0.1 7000 hello # type this hello # echoed back
23.2 Minimal HTTP server
import <net>; import <mem>; extern int printf(string fmt, ...);
int main() {
int fd = net_tcp_listen(8080);
printf("http://127.0.0.1:8080\n");
string resp =
"HTTP/1.1 200 OK\r\n"
"Content-Type: text/plain\r\n"
"Connection: close\r\n\r\n"
"Hello from Eskiu.\n";
*uint8 req = alloc<uint8>(4096);
while (1) {
int c = net_accept(fd);
if (c < 0) { continue; }
net_recv(c, (*void)req, 4096);
net_send_str(c, resp);
net_close(c);
}
free((*void)req); net_close(fd);
return 0;
}
23.3 Concurrent connections
Spawn a thread per accepted fd. The fd is captured by value. Each thread has its own copy:
import <net>; import <mem>;
void handle(int c) {
*uint8 buf = alloc<uint8>(1024);
int64 n = net_recv(c, (*void)buf, 1024);
if (n > 0) { net_send(c, (*void)buf, n); }
net_close(c); free((*void)buf);
}
int main() {
int fd = net_tcp_listen(7001);
while (1) {
int c = net_accept(fd);
if (c < 0) { continue; }
thread_create(void() { handle(c); });
}
net_close(fd); return 0;
}
23.4 TCP client
import <net>; import <mem>; extern int printf(string fmt, ...);
int main() {
int fd = net_tcp_connect("127.0.0.1", 7000);
if (fd < 0) { printf("connect failed\n"); return 1; }
net_send_str(fd, "ping");
*uint8 buf = alloc<uint8>(256);
int64 n = net_recv(fd, (*void)buf, 256);
buf[n] = 0;
printf("got: %s\n", (string)buf);
net_close(fd); free((*void)buf);
return 0;
}
Output (echo server running): got: ping
23.5 net_accept_addr and binary uploads
When you need the client IP address, use net_accept_addr instead of net_accept. For binary file uploads, HttpReq reads the complete body into a *uint8 buffer so binary bytes survive intact:
import <net>; import <http>; import <mem>;
void upload_handler(int fd) {
let r: HttpReq; HttpReq_init(&r);
if (http_recv(fd, &r, 10 * 1024 * 1024) != 0) {
http_reply_error(fd, 413, "", "body too large");
} else {
// r.body is *uint8 with r.body_len bytes, binary safe
String ct; String_init(&ct, 64);
HttpReq_header(&r, "Content-Type", &ct);
String_free(&ct);
http_reply(fd, 200, "text/plain", "", null);
}
HttpReq_free(&r);
}
int main() {
int srv = net_tcp_listen(8081);
while (1) {
uint32 peer_ip = 0;
int c = net_accept_addr(srv, &peer_ip);
if (c < 0) { continue; }
upload_handler(c);
net_close(c);
}
net_close(srv); return 0;
}
23.6 Timeouts and limits
The servers above wait as long as a client likes. A client that connects and sends nothing, or sends one byte every few seconds, holds a connection (and in 23.2 the whole server) open for good. On the open internet that is an attack, so a real server needs deadlines.
For your own sockets, <net> bounds each blocking call. net_set_recv_timeout(fd, ms) and net_set_send_timeout(fd, ms) (or both at once with net_set_timeouts(fd, recv_ms, send_ms)) make a net_recv or net_send that runs out return -1, with net_would_block() returning 1; 0 means wait forever. A per-call timeout restarts with every read, so for a limit on the whole exchange use a deadline: net_deadline(ms) is an absolute time ms from now, and net_wait_ready(fd, 0, deadline) waits until fd is readable (mode 1: writable) or returns NET_TIMED_OUT once the deadline has passed:
int fd = net_tcp_connect("127.0.0.1", 7000);
net_set_timeouts(fd, 2000, 2000); // 2 s for each recv and each send
int64 dl = net_deadline(5000); // 5 s from now
if (net_wait_ready(fd, 0, dl) == NET_TIMED_OUT) {
printf("no reply within 5 s\n");
}
The async versions are in <net_async>: net_read_deadline_async(lp, fd, buf, cap, deadline) and net_write_deadline_async race the I/O against a timer and complete with NET_TIMED_OUT when the timer wins. A send to a peer that has already closed returns -1 instead of raising SIGPIPE and killing the process.
The stdlib HTTP servers (http_serve, http_serve_async, http2_serve_async and the TLS servers) apply deadlines of their own. The defaults are constants in <http>:
Limit Default Meaning
HTTP_HEADER_TIMEOUT_MS 10 s the whole request head (or HTTP/2 preface and SETTINGS)
HTTP_BODY_TIMEOUT_MS 60 s the body, once the head is in
HTTP_IDLE_TIMEOUT_MS 60 s an HTTP/2 connection with no open stream (then GOAWAY)
HTTP_WRITE_TIMEOUT_MS 30 s the client must keep taking the answer
HTTP_WRITE_TOTAL_MS 5 min a whole HTTP/2 response, however the peer paces it
HTTP_MAX_OPEN_CONNS 1024 connections an async server holds at once
A request cut off by the head or body deadline after some of it arrived is answered 408; a client that sent nothing is simply closed. To change them, fill an HttpLimits (http_limits() returns the defaults, HttpLimits_init resets one) and pass it to the _with variant of the server: http_serve_with, http_serve_async_with, http2_serve_async_with, http2_tls_serve_async_with or http2_tls_serve_conn_with. A field set to 0 turns that limit off:
HttpLimits lim = http_limits(); // start from the defaults lim.header_ms = 5000; // 5 s for the request head lim.max_open = 256; // at most 256 connections at once http_serve_with(8080, 4, hello, lim);
Sizes are bounded as well. An HTTP/1.1 request head over 64 KiB is answered 400 and a body over 1 MiB 413. The parser follows RFC 9112: a chunked body is decoded before the handler sees it, Transfer-Encoding together with Content-Length is 400, a transfer coding other than chunked is 501, and a missing, repeated or malformed Host header (it must be a host with an optional :port) is 400. Both HTTP/1.1 servers read until the request is complete, so a request split across several TCP segments reaches the handler whole. On the way out, HttpResponse_header refuses a value containing a CR or LF (it returns 0 and adds nothing), so a handler that echoes client input cannot be used to inject a header line.
The HTTP/2 engine buffers at most 1 MiB of body per request and 4 MiB across a connection (s.max_body and s.max_buffered on the H2Server); past either the request is answered 413 without calling the handler. A decoded header list over 64 KiB is 431. A malformed request (a missing :method or :path, a connection-specific header, a content-length the DATA frames do not add up to) is reset with RST_STREAM and never reaches the handler. The HTTP/1.1 Host rules apply here too: an invalid host or :authority is 400, two host fields or a host that disagrees with :authority reset the stream, and a request without a host field shows its :authority to the handler as the Host header.
Real World: INE QR Decoder
This chapter walks through the program that validated Eskiu: a production decoder for the cryptographic QR codes on Mexican voter ID cards. It uses nearly every feature covered in this book, and it runs 2.5× faster than the hand-written C reference it replaced.
24.1 Background
Every Mexican INE credential has two QR codes on the back. Together they encode the cardholder's biographical data and photo, encrypted with AES-256-CBC and RSA-8192. The encryption was reverse-engineered from a proprietary native ARM64 library (libPersonalCode.so, 4.6 MB, Chilkat 9.5 statically linked) and reimplemented entirely in Eskiu, calling OpenSSL via extern.
24.2 The pipeline
Stage 1 (qr_extract.c, 37 lines of C): zxing-cpp extracts two 858-byte payloads from the QR codes. The only C code in the project.
Stage 2 (crypto.esk, 724 lines): six AES-256-CBC decryptions and one RSA-8192 modular exponentiation, all via OpenSSL extern calls. Manages intermediate buffers with alloc/free.
Stage 3 (output.esk, 127 lines): parses the pipe-delimited plaintext into 18 biographical fields and extracts the embedded WebP photo. Pure Eskiu, no extern.
The three stages (stage_extract, stage_crypto, stage_decode) live in pipeline.esk and are wired together by main.esk, with the shared types in types.esk and the OpenSSL declarations in extern.esk.
24.3 Key data structures
// types.esk
struct QRPair {
uint8[858] left; // fixed-size, no heap allocation
uint8[858] right;
int ok;
char[256] err;
}
// types.esk: parsed output (both pointers heap-allocated; free each)
struct DecodeOutput {
*char json;
int json_len;
*uint8 webp; // the embedded photo
int webp_len;
}
// per-stage wall-clock timing, in milliseconds
struct PipelineTiming {
double qr_ms;
double crypto_ms;
double decode_ms;
double total_ms;
}
24.4 Performance
Stage Eskiu Reference C
QR extraction 71.7 ms 185.5 ms
Crypto pipeline 2.8 ms 2.9 ms
Output decode < 1 ms 0.5 ms
Total 74.4 ms 188.9 ms
2.5× faster than the reference C implementation on Apple Silicon. The crypto stage matches C within 0.1 ms. Both call the same OpenSSL functions. The speedup comes from the QR extraction stage, where LLVM's optimiser produces tighter code than the reference compiled without equivalent flags.
24.5 What it demonstrates
The decoder uses structs with fixed-size array fields, pointers and typed pointer arithmetic, heap allocation with explicit free, extern declarations for OpenSSL, Result for error propagation, multi-file imports, and string manipulation. Every feature in this book has a real use in that program.
The decoder ships in the Eskiu repository under ine_decoder/. Reading it is the best way to see how a real Eskiu program is structured.
Async and Await
Threads get concurrency by running code on multiple CPU cores at once. Async/await takes a different route: many tasks share a single thread, and each task suspends when it is waiting for I/O rather than blocking the thread. The result: a server that handles thousands of concurrent connections with the same memory a handful of threads would use.
25.1 The model
An async function returns a Future<T> instead of T directly. The future represents a value that does not exist yet. await suspends the current task until the future is ready, then unwraps the value:
import <future>; import <mem>; extern int printf(string fmt, ...);
Future<int>* produce() {
Future<int>* f = future_new<int>();
future_complete<int>(f, 41);
return f;
}
async int one() {
int n = await produce(); // suspends until produce()'s future is ready
return n + 1; // resumes with n = 41, returns 42
}
int main() {
Future<int>* r = one();
printf("%d", r.value); // 42
free_future<int>(r);
return 0;
}
The compiler transforms an async function into a state machine. Each await is a suspension point: the function can be paused there and resumed later without blocking any OS thread. Every control-flow form can contain an await: if, while, do/while, for, for-in, switch, match and try/catch. A defer in an async function runs when its block exits, even if the block awaited in between, and a lambda inside it can capture the function's locals. Labeled break/continue is not supported inside an async function.
An await can sit anywhere in an expression: a let initializer, a return, an assignment or compound assignment (r += await f();), a call argument, an operand (a + await f()), a condition (while (await more())), a switch or match subject, a for-in iterable and a range bound (for (i in 0..await f())). The order of evaluation is the one the synchronous expression would have: a call written before the await runs before it, the target of a[i()] += await f() is computed once, before the await, and an await on the right of && or ||, or in an arm of ?:, runs only when that operand does.
Inside try, an await may sit in the body and in a catch. An exception thrown before or after the suspension goes to the handler of the try it was thrown in, and the finally runs exactly once however the try is left: normally, by an exception, by return, break or continue, or because the future was cancelled. A future dropped while it waits runs the finally blocks and defers pending at its await before its memory is freed. Because that cleanup runs at once, it cannot wait: an await inside a finally or a defer is an error located at the await.
async int fetch(EventLoop* lp) {
try {
int n = await timer_after(lp, 10);
return n + check(await next_value(lp)); // check may throw
} catch (int code) {
return -code;
} finally {
printf("done\n"); // once, on every exit
}
}
An async function can be generic. It is instantiated per type argument like any template, and each instance returns its own Future:
async T twice<T>(T v) {
int n = await produce();
return v + v;
}
Future<int>* a = twice<int>(21); // a.value == 42
Future<double>* b = twice(1.25); // T inferred as double; b.value == 2.5
25.2 The event loop
Async tasks need something to run them. The event loop is a reactor that watches file descriptors for readiness and resumes the tasks waiting on them:
import <eventloop>; import <net_async>; import <net>; import <future>; import <mem>; extern int printf(string fmt, ...);
async int read_line(EventLoop* lp, int fd) {
*uint8 buf = alloc<uint8>(256);
int n = await net_read_async(lp, fd, buf, 255);
buf[n] = 0;
printf("%s", (string)buf);
free((*void)buf);
return n;
}
int main() {
EventLoop* lp = el_new(64);
int fd = net_tcp_connect("127.0.0.1", 7000);
Future<int>* f = read_line(lp, fd);
EventLoop_run(lp); // drives the event loop until all tasks complete
free_future<int>(f);
EventLoop_free(lp); net_close(fd);
return 0;
}
el_new(n) creates an event loop that watches up to n file descriptors. EventLoop_run(lp) blocks until all registered callbacks complete. Internally it uses kqueue on macOS, epoll on Linux, and WSAPoll on Windows.
25.3 Concurrent HTTP with http_serve_async
The simplest way to write a concurrent HTTP server (one event loop thread, many simultaneous connections):
import <http_async>; import <http>; import <eventloop>; import <net>; import <future>; import <mem>; extern int printf(string fmt, ...);
void hello(HttpRequest* req, HttpResponse* res) {
HttpResponse_header(res, "Content-Type", "text/plain");
HttpResponse_set_body(res, "Hello from Eskiu async\n");
}
int main() {
int fd = net_tcp_listen(8080);
EventLoop* lp = el_new(256);
Future<uint8>* f = http_serve_async(lp, fd, hello, 1000);
EventLoop_run(lp);
free_future<uint8>(f);
EventLoop_free(lp); net_close(fd);
return 0;
}
Each incoming connection is handled by a detached async task: a slow client never blocks others. The same handler function works with <http> (threaded) and <http_async> (event loop).
Each connection socket is non-blocking and the answer goes out through net_write_async, so a client that stops reading parks only its own connection. The server enforces the deadlines and the connection cap from section 23.6; http_serve_async_with(lp, fd, hello, 1000, lim) takes an HttpLimits of your own.
25.4 spawn, select2, and join2
Three combinators for composing concurrent tasks:
import <future>; import <mem>;
// spawn<T>: fire and forget. Returns nothing; the task frees itself when done. spawn(some_async_fn());
// select2<A,B>: whichever finishes first. Completes with the winner's
// index (0 if fa won, 1 if fb won); read the winner's value, then free it.
int w = await select2(fa, fb);
if (w == 0) { use(fa.value); free_future<int>(fa); }
else { use(fb.value); free_future<int>(fb); }
// join2<A,B>: wait for both, then read and free both await join2(fa, fb); int sum = fa.value + fb.value; free_future<int>(fa); free_future<int>(fb);
select2 cancels the loser future automatically: its on_drop cascade cleans up any resources the losing task held.
Dropping a combinator before it resolves cascades the same way. When a select2, join2, select2v or join2v future is itself cancelled (for example by an outer timeout that wins its own select2), it drops both of its inputs, so their producers are cancelled and their wakers unhooked. A late completion of an input can no longer write into the freed combinator.
The value-returning forms are in <futureval>: select2v completes with the winner's value as an Either<A,B> (Left if the first future won, Right if the second did) and consumes both inputs, and join2v completes with both values as a Pair<A,B>:
import <futureval>; Future<Either<int,int>>* race = select2v<int,int>(fa, fb); Future<Pair<int,int>>* both = join2v<int,int>(fc, fd);
25.5 Channels
A channel passes values between async tasks. Chan_send enqueues a value; Chan_recv returns a future that completes with the next value:
import <channel>; import <future>; import <mem>; extern int printf(string fmt, ...);
async int consume(Chan<int>* ch) {
int a = await Chan_recv<int>(ch);
int b = await Chan_recv<int>(ch);
return a + b;
}
int main() {
Chan<int>* ch = chan_new<int>(8);
Chan_send<int>(ch, 10);
Chan_send<int>(ch, 20);
Future<int>* f = consume(ch);
// drive on event loop or poll directly
printf("%d\n", f.value); // 30
free_future<int>(f);
Chan_free<int>(ch);
return 0;
}
Sum Types and Match
A sum type is a type that can be one of several variants, each carrying its own data. They are the type system's answer to the question: how do you represent a value that might be one of several different things? Eskiu has sum types as algebraic enums (integer enums extended with payload variants) and a match statement that dispatches on them exhaustively.
26.1 Algebraic enums
An enum variant can carry a payload: one or more values of any type. The tag identifies which variant; the payload carries its data:
enum Expr {
Lit(int), // carries one int
Add(int, int), // carries two ints
Neg(int),
Zero, // no payload, like a classic enum variant
}
Classic integer enums still work unchanged. The two styles coexist freely in the same codebase.
26.2 match
match dispatches on the variant and binds the payload fields to names:
int eval(Expr e) {
match e {
Lit(v) -> return v;
Add(a, b) -> return a + b;
Neg(n) -> return 0 - n;
Zero -> return 0;
}
return -1;
}
printf("%d\n", eval(Lit(7))); // 7
printf("%d\n", eval(Add(3, 4))); // 7
printf("%d\n", eval(Neg(9))); // -9
printf("%d\n", eval(Zero)); // 0
Output: 7 7 -9 0
The compiler checks that every variant is covered. A missing arm is a compile error. A _ arm catches anything not listed above it:
int kind(Expr e) {
int k = 0;
match e {
Zero -> k = 1;
Lit(v) -> k = 2;
_ -> k = 9; // Add or Neg
}
return k;
}
26.3 Generic enums: Option and Either
Enums can have type parameters. The standard library provides two:
import <either>;
// Option<T>: a value that may or may not exist Option<int> o = Some<int>(42); Option<int> n = None<int>();
match o {
Some(v) -> printf("got %d\n", v); // got 42
None -> printf("nothing\n");
}
// Either<A,B>: one of two types
Either<int, string> ok = Left<int,string>(100);
Either<int, string> err = Right<int,string>("fail");
match ok {
Left(v) -> printf("value: %d\n", v); // value: 100
Right(s) -> printf("error: %s\n", s);
}
The explicit type arguments can be left out wherever the type the variant must have is already known: in a declaration, an assignment, a return, and an argument to a non-generic function. The variant then takes that type's arguments, so Some(5) builds an Option<int64> when that is what the declaration asks for, and a bare None needs no parentheses or type at all:
Option<int64> half(int64 n) {
if (n % 2 != 0) { return None; }
return Some(n / 2);
}
void show(Option<int64> o) {
match o {
Some(v) -> printf("some %lld\n", v);
None -> printf("none\n");
}
}
Option<int64> a = Some(5); Option<int> n = None; Either<int, string> e = Left(4); // the payload alone could not tell string show(a); // some 5 show(half(10)); // some 5 show(half(7)); // none
Output: some 5 some 5 none
The payload is still checked against those type arguments: Option<int64> b = Some("x"); is an error ("variant 'Some' argument 1 type mismatch"). With no expected type, as in a call to a generic function or an expression statement, write the arguments out as before (Some<int>(42)).
Option<T> is the alternative to null pointer checks. Either<T,E> is an alternative to Result<T,E> when you want to handle both cases symmetrically in a match rather than checking .ok.
26.4 Generic enum definitions
You can define your own generic enums. The compiler monomorphizes them, one concrete type per set of type arguments:
enum Tree<T> {
Leaf(T),
Node(T, T),
Empty,
}
Tree<int> t = Node<int>(3, 7);
match t {
Leaf(v) -> printf("leaf %d\n", v);
Node(a, b) -> printf("node %d %d\n", a, b); // node 3 7
Empty -> printf("empty\n");
}
The chapters above are the tutorial. What follows is the reference: a complete specification of the syntax, types, operators, standard library, and CLI.
Self-Hosting
There is a moment in a language's life when it grows up: the day its compiler is written in itself. Until then the language depends on another: Eskiu's reference compiler, eskiuc, is written in C++. A self-hosted compiler is one the language can build on its own, and reaching it proves something concrete: the language is expressive enough, and the compiler correct enough, to take on a program as demanding as a compiler. As of v0.3.0, Eskiu is there.
The whole pipeline is reimplemented in Eskiu, under selfhost/: a lexer, a preprocessor, a parser, a type checker, and a code generator, each a faithful translation of its C++ counterpart. They chain together exactly as the reference compiler does:
lexer → preprocessor → parser → type checker → code generator → LLVM IR
The code generator is the interesting one. It does not link against the LLVM libraries. Instead it writes LLVM IR out as plain text, which clang then assembles and links. That choice keeps the self-hosted compiler dependency-free, and it is how most bootstrapping compilers do it.
27.1 Trust, but verify
A compiler that is subtly wrong is worse than no compiler at all, so every pass is checked against the reference. The method changes with what can be observed. For the lexer, parser, and preprocessor there is an exact answer to compare against: their output is diffed byte-for-byte against eskiuc's debug dumps. For the type checker, the ground truth is a verdict: does it accept the same programs and reject the same mistakes, with the same error messages? For the code generator, the generated IR can't be compared character by character (LLVM renumbers values and folds constants), so the test is behavioural: compile the program both ways, run both binaries, and check that they print the same thing and exit the same way. Every one of these comparisons runs in continuous integration; a divergence fails the build.
27.2 The fixpoint
Per-pass parity is necessary but not sufficient. The real test of self-hosting is whether the compiler can reproduce itself. So the self-hosted compiler is built three times: the C++ eskiuc builds it once (call the result cc0); cc0 builds it again (cc1); cc1 builds it a third time (cc2). If the project is sound, cc1 and cc2 must emit identical IR for the compiler's own source: a compiler built by itself reproducing its own output. That equality is the bootstrap fixpoint, and Eskiu reaches it.
27.3 Earning the word "complete"
There is a trap here worth naming, because it caught the author more than once. The bootstrap fixpoint only exercises the slice of the language the compiler's own source happens to use. It says nothing about features the compiler doesn't use: floating point, say, or unions, or the ? operator. It is tempting to declare the code generator complete once the fixpoint holds, but that would be wrong. The only way to know a feature works is to compile a program that uses it and run it. So feature-completeness is measured by pushing the entire reference test corpus through the behavioural oracle and demanding a clean sweep, with every program, compiled by the Eskiu-written code generator, behaving exactly as the C++ build does. v0.3.0 passes that sweep: floats, switch, sum types and match, closures, exceptions, atomics, generics with inference, async/await, unions, bitfields, interfaces, packed structs, variadics, and the ? operator all generate correct code.
The lesson generalises beyond compilers: a system that checks itself can only ever confirm the paths it walks. To claim coverage, you have to walk every path on purpose, and watch what happens.
Since v0.3.0 the self-hosting has been taken to completion. The Eskiu-written compiler is behaviorally equivalent to the C++ one across the whole corpus, gated on every commit, and dual-built as eskiuc-esk. It stays a co-equal build rather than the shipped artifact for one practical reason: the self-hosted driver links its output by shelling out to clang, so shipping it as the default would add a clang dependency at run time. The C++ binary remains the shipped compiler, and the parity gates keep the two equivalent.
Lexical Elements
Comments
// single-line comment /* block comment, may span lines, does not nest */
Identifiers
Start with a letter or underscore, followed by letters, digits, or underscores. Case-sensitive.
my_var _internal Count x1
Keywords
let const volatile int int8 int16 int32 int64 uint uint8 uint16 uint32 uint64 float double bool char string void
struct packed union enum type interface fn extern intrinsic import static must_use operator
if else for while do in switch case default return break continue match
alloc_with free_closure sizeof asm
thread_create thread_join async await escaping
try catch finally throw defer errdefer
true false null
A keyword cannot name a variable, parameter, function or type. Right after a ., though, a keyword is read as a member name, so a dot-call can reach a Type_method function whose method part is a keyword: j.int(5) calls Json_int(&j, 5).
Types
Primitive Types
Type LLVM IR Width Notes
int i32 32 bits Signed; alias for int32
int8 i8 8 bits Signed
int16 i16 16 bits Signed
int32 i32 32 bits Signed
int64 i64 64 bits Signed
uint i32 32 bits Unsigned; alias for uint32
uint8 i8 8 bits Unsigned
uint16 i16 16 bits Unsigned
uint32 i32 32 bits Unsigned
uint64 i64 64 bits Unsigned
float float 32 bits IEEE 754 single-precision
double double 64 bits IEEE 754 double-precision
bool i1 1 bit true or false
char i8 8 bits Unsigned; widens to int via zero-extend
string i8* pointer Immutable C-string literal
void void - No value; valid only as return type
Compound Types
Type Syntax Notes
Pointer *T Both *T and T* accepted; canonical form is *T
Fixed array T[N] N must be a compile-time integer constant
Function ptr fn(T)->R Two-word fat pointer {fn_ptr, env_ptr}
Template Name Monomorphic; one copy per unique argument set
inst.
Interface Name Fat pointer {data_ptr, vtable_ptr} at call site
Type Casting
(int)x // float to int: the one numeric cast the compiler requires (uint8)n // integer narrowing: implicit; cast only to document intent (double)i // widening: implicit; the cast is optional (*uint8)ptr // pointer reinterpretation: always explicit
Numeric widening and narrowing are implicit; only float-to-int and pointer reinterpretation need a cast. Signedness is tracked; signed and unsigned variants of the same width share the same LLVM integer type.
Operators
Precedence Operators Associativit Notes
y
1 (lowest) = += -= *= /= %= Right Assignment
(ternary) ?: Right cond ? a : b
2 || Left Logical OR (short-circuit)
3 && Left Logical AND (short-circuit)
4 | (bitwise) Left
5 ^ Left Bitwise XOR
6 & (bitwise) Left
7 == != Left
8 < > <= >= Left
9 << >> Left >> is arithmetic on signed
10 +- Left
11 */% Left Integer / truncates toward 0
12 (unary) ! - + ~ & * ++ -- (TYPE) Right ++ -- prefix and postfix
13 () [] . ? (postfix) Left postfix ? propagates a Result
(highest)
Pointer Arithmetic
p + n on *T advances n × sizeof(T) bytes. Exception: *void and *char use byte stride (1 byte per step).
sizeof sizeof(T) or sizeof(expr): compile-time int64 constant, the size of the type or of the expression's type. The operand is not evaluated and no runtime code is generated.
Control Flow
Statement Syntax Notes
if/else if (cond) { } else if (cond) { } else { } Condition: bool, number or pointer (non-zero / non-null = true).
Braces optional (an unbraced body is one statement).
for for (init; cond; step) { } Each part optional. Loop variable scoped to
body.
for-in for (x in iterable) { } or for (i in A..B) { } x is a copy. Iterables: T[N] arrays, List-like
structs, half-open ranges A..B.
while while (cond) { } Condition checked before each iteration.
switch switch (x) { case N: ... break; default: Integer dispatch. No break = fallthrough.
... }
match match e { Variant(x) -> ...; _ -> ...; } Dispatch on an algebraic enum variant, or a plain enum's values. Exhaustive.
Binds payload fields.
break break; or break label; Exits innermost for/while/switch/match, or the
loop named by label.
continue continue; or continue label; Skips to next iteration of the innermost loop, or
the loop named by label. In for-in, correctly
advances the index.
label outer: for (...) { ... break outer; } Names an enclosing while/do/for/for-in loop so
break/continue can target it. Runs the same
defers as an unlabeled jump. Not inside async.
return return expr; or return; return; for void functions.
try/catch try { } catch (T e) { } finally { } C++ EH. The driver links the C++
runtime.
throw throw expr; Any value type may be thrown.
Functions
Form Syntax Notes
Regular RetType name(T p, ...) { } Parameters by value. Declaration order irrelevant.
Void void name(T p) { } return; or fall off end.
Extern extern RetType name(T p, ...); ... only in extern. free is a <mem> function; do not
extern-declare it.
Lambda RetType(T p) { return expr; } Type is fn(T)->R. Closures capture by value at
creation.
Template T name(T a, T b) { } Monomorphic. Type args inferred from direct or
composite params.
Forward RetType name(T p); Optional: declaration order is already irrelevant.
decl
Function thread_create(myFunc) // Top-level named function decays to fn()->R.
decay myFunc: void myFunc() {} Compiler synthesises a thunk.
thread_crea thread_create(fn()->void f) -> Accepts closure. Fat ptr maps to pthread
te *void (start_routine, arg).
thread_join thread_join(*void h) -> void Blocks until thread h completes.
async async int f(...) { int x = await g(); Returns *Future. State machine transform. await
function return x; } only valid inside an async function.
escaping void store(escaping fn()->void cb) Marks closure param that outlives the call.
Non-escaping = stack env.
free_closur free_closure(f) Release escaping closure heap env.
e
variadic int f(int n, ...) { va_list ap; User-defined variadic. C promotions: float arrives as
va_start(ap); va_arg<T>(ap); double.
va_end(ap); }
constraint T f(...) or T f(...) or struct S {...} Structs: define the interface methods. Primitives:
provide a free fn named like the method. int
cmp(int,int) makes int satisfy Ord. t.cmp(x) lowers to
cmp(t,x) inside the body.
? operator expr? on Result: returns the Err unchanged if ok==0, otherwise evaluates to the unwrapped value of type T. Only valid inside a function returning the same Result type.
Memory
Operation Syntax / Description
Stack alloc All local variables. Reclaimed on function return.
Heap alloc alloc(N): import <mem>; returns *T. calloc hosted (zero-initialized), esk_alloc in freestanding.
Heap free free(ptr): calls free() or esk_free. Freeing null is safe.
Address-of &x: yields *T where x: T
Dereference *ptr: yields T where ptr: *T
Subscript ptr[i]: equivalent to *(ptr + i)
volatile volatile let p: *T = ...; every load/store of p and through it (*p, p[i], p.f,
++/--, compound assignment, bitfield words) emitted as volatile IR
Freestanding --freestanding: redirects alloc/free to user-provided esk_alloc/esk_free
Undefined Behaviour
Behaviour Example
Null dereference *null
Use-after-free free(p); *p = x;
Double-free free(p); free(p);
Buffer overrun arr[n] where arr has n elements (valid: arr[0]..arr[n-1])
Signed overflow INT_MAX + 1 wraps silently
Misaligned access reading a uint32 at a non-4-byte-aligned address (arch-dependent)
Preprocessor
Directive Effect
#define NAME value Object-like macro substitution
#define F(a,b) body Function-like macro. A call may span lines, as in C.
#define NAME Define NAME with empty value (for #ifdef checks)
#undef NAME Remove definition
#ifdef NAME Include block if NAME is defined
#ifndef NAME Include block if NAME is not defined
#if expr Include block if the integer constant expression is non-zero
#elif expr Alternative branch tested when every earlier branch was false
defined(NAME) 1 if NAME is a macro, else 0 (only inside #if / #elif)
#else Alternate branch for #if / #ifdef / #ifndef
#endif End conditional block
#pragma pack(1) Subsequent structs use 1-byte (packed) alignment
#pragma pack(push,1) Push current packing onto stack, switch to 1-byte
#pragma pack(pop) Restore previous packing from stack
#pragma pack() Restore default alignment (pack(N) takes N = 1, 2, 4, 8 or 16)
#pragma link("name") Link the executable with -lname (once, after the objects)
other #pragma Silently ignored
__APPLE__ Predefined by compiler on macOS
__linux__ Predefined by compiler on Linux
_WIN32 / _WIN64 Predefined by compiler on Windows (_WIN64 on 64-bit)
__aarch64__ / __x86_64__ / __arm__ Target architecture (from --target, else the host)
__FILE__ Current source file path (string literal)
__LINE__ Current source line number (integer)
__ESKIU_FREESTANDING__ Predefined when --freestanding is passed
#error message Abort compilation with message (only in an active branch)
any other directive Error (including #include; use import). Ignored inside a skipped branch
Macro expansion
Macro bodies may span multiple lines using \ continuation. Macros expand recursively; a macro is not re-expanded within its own expansion. Arguments are split at top-level commas (string and character literals stay whole) and expanded before substitution. The # and ## operators are not supported and are an error in a macro body. The macro table is shared across all files in a multi-file build.
Standard Library
net: import <net>
Symbol Signature Notes
net_tcp_listen int net_tcp_listen(uint16 port) bind+listen; returns fd or -1
net_accept int net_accept(int listen_fd) accept; returns fd or -1
net_tcp_connect int net_tcp_connect(string host, uint16 port) connect; returns fd or -1
net_send int64 net_send(int fd, *void buf, int64 n) returns bytes sent
net_recv int64 net_recv(int fd, *void buf, int64 n) returns bytes received;
0=closed, -1=error
net_send_str int64 net_send_str(int fd, string s) send string literal
net_close void net_close(int fd) close connection or server fd
net_set_timeouts void net_set_timeouts(int fd, int64 r, int64 s) bound each recv/send (ms);
also net_set_recv_timeout,
net_set_send_timeout
net_deadline int64 net_deadline(int64 ms) absolute deadline ms from now
net_wait_ready int net_wait_ready(int fd, int mode, int64 dl) 0 when ready, NET_TIMED_OUT
(-3) past the deadline
threading: import <threading>
Mutex: Mutex_init / Mutex_lock / Mutex_unlock / Mutex_destroy Cond: Cond_init / Cond_wait / Cond_signal / Cond_broadcast / Cond_destroy Sem: Sem_init(initial) / Sem_post / Sem_wait / Sem_destroy
http: import <http>
http_serve(port, workers, handler) // threaded worker pool http_serve_with(port, workers, handler, lim) // same, with HttpLimits lim (http_limits() = defaults) handler type: fn(HttpRequest*, HttpResponse*)->void HttpRequest: method / path / version / headers / body (String fields) HttpResponse: HttpResponse_header / HttpResponse_set_body / HttpResponse_render
// Binary-safe full-body reader (use for uploads and binary data)
struct HttpReq { String method; String path; String headers;
*uint8 body; int64 body_len; }
HttpReq_init / HttpReq_free
int HttpReq_header(HttpReq* r, string name, String* out) // 1 if found; value copied to out
int http_recv(int fd, HttpReq* r, int64 max_body) // loop recv full body
void http_reply(int fd, int status, string ctype, string extra, String* body)
void http_reply_error(int fd, int status, string extra, string msg)
json: import <json>
// Builder Json_init / Json_obj_begin / Json_obj_end / Json_arr_begin / Json_arr_end Json_key / Json_str / Json_int / Json_bool / Json_null / Json_raw_str Json_cstr / Json_free // Parser *JsonValue json_parse(string text) *JsonValue JsonValue_get(*JsonValue obj, string key) int JsonValue_kind / int JsonValue_len / *JsonValue JsonValue_at(v, i) int64 JsonValue_as_int / double JsonValue_as_double / int JsonValue_as_bool / *char JsonValue_as_cstr void JsonValue_free(*JsonValue) // json_parse is strict RFC 8259 and returns null on malformed input or trailing data
The builder functions follow the Type_method convention, so they dot-call on a Json, including the two whose names are keywords, Json_int and Json_bool:
let j: Json;
j.init();
j.obj_begin();
j.key("n"); j.int((int64)42);
j.key("ok"); j.bool(1);
j.obj_end();
printf("%s\n", j.cstr()); // {"n":42,"ok":true}
j.free();
Output: {"n":42,"ok":true}
alloc: import <alloc> (explicit allocators over a buffer you own)
// All allocators use: *void T_alloc(T* self, int64 n) -> plugs into alloc_with Bump: Bump_init(buf, cap) / Bump_alloc / Bump_reset Arena: Arena_init / Arena_alloc / Arena_save / Arena_restore / Arena_reset Pool: Pool_init(buf, cap, blockSize) / Pool_alloc / Pool_free FirstFit: FirstFit_init(buf, cap) / FirstFit_alloc / FirstFit_free sysheap: Heap_init / Heap_alloc / Heap_free / Heap_destroy
map: import <map>
Map<V>: string-keyed hash map (open addressing, 0.75 load factor) Map_init<V>(&m, cap) // initialise *V Map_at<V>(&m, key, *int created) // get-or-insert; *created=1 if new int Map_get<V>(&m, key, *V out) -> 1/0 // lookup only Map_free<V>(&m) // free keys and value array
multipart: import
multipart_boundary(String* ctype, String* out) -> int // 1 = boundary found multipart_part(body, blen, boundary, name, &ptr, &plen) -> int
bytes: import <bytes>
Bytes: binary-safe *uint8 + length buffer (unlike String: NUL-safe, length-authoritative) Bytes_init(&b, cap) Bytes_free(&b) Bytes_push(&b, u8) Bytes_append_raw(&b, *uint8 src, n) Bytes_append(&b, &other) Bytes_slice(&b, start, n) -> Bytes (cap=0, non-owning) Bytes_to_base64(&b) -> Bytes Bytes_from_base64(s) -> Bytes HttpReq_body(&r) -> Bytes (non-owning view of HTTP body)
HashMap: generic key (also in <map>)
HashMap<K,V>: any-key map; you supply hash + eq fn pointers (like qsort) HashMap_init<K,V>(&m, cap, hash_fn, eq_fn) built-in: int_hash / int_eq *V HashMap_at<K,V>(&m, key, *int created) // get-or-insert int HashMap_get<K,V>(&m, key, *V out) -> 1/0 // lookup HashMap_free<K,V>(&m)
time / env / base64 / path: quick reference
import <time>; time_now_ms time_now_s time_monotonic_ms sleep_ms(ms) import <env>; env_get env_has env_get_or env_get_int import <base64>; base64_encode/decode(*uint8 src, int n, *uint8 out) import <path>; path_join path_basename path_dirname path_extension
the rest of the library
The full standard library is 42 modules; the ones above are the most-referenced. Each of the rest is importable by name and readable at lib/eskiu/stdlib/: <result>, <list>, <string>, <math>, <io>, <mem>, <fs>, <random>, <regex> (with its Unicode tables in <regex_unicode>), <sort>, <url>, <uuid>, <ctype>, <json>, <http>, <http_async>, <http2>, <hpack>, <tls>, <net_async>, <eventloop>, <future>, <channel>, <executor>, <sysheap>, and <multipart>.
CLI Reference
Flag Effect
eskiuc file.esk -o prog Compile and link into executable
eskiuc a.esk b.esk -o prog Compile multiple files (declarations merged)
eskiuc file.esk -o prog -lssl Link with library (flags forwarded to linker)
eskiuc file.esk -o prog -L/path Add library search path
eskiuc file.esk -o prog --link-arg=X Pass extra argument to linker
eskiuc file.esk -c -o file.o Compile only: no linking
eskiuc file.esk -o file.o .o suffix implies -c
eskiuc file.esk -Wall Unused vars/params/fns, assignment-in-condition,
maybe-uninitialized locals
eskiuc file.esk -Wextra Signed/unsigned comparison warnings
eskiuc file.esk -O2 Optimization level (-O0 through -O3)
eskiuc file.esk --safe Runtime slice-bounds checks (traps on violation)
eskiuc file.esk --asan AddressSanitizer instrumentation
eskiuc file.esk --ubsan Trapping bounds checks
eskiuc run file.esk [args...] Compile to a temp exe, run it, delete it
eskiuc fmt file.esk [--check] Reformat in place, or --check formatting
eskiuc file.esk --target TRIPLE Cross-compile for target triple
eskiuc ... --mcpu CPU --mattr F --reloc R Tune the target (CPU, features, relocation)
eskiuc file.esk --freestanding No libc; alloc/free → esk_alloc/esk_free
eskiuc file.esk --test-lexer Dump token stream
eskiuc file.esk --test-parser Dump AST
eskiuc file.esk --test-typechecker Type-check only; print errors
eskiuc file.esk --test-codegen Dump LLVM IR
eskiuc file.esk --hover-at L:C Print inferred type at 1-based line:col
eskiuc file.esk --definition-at L:C Print definition location of symbol
eskiuc --version Print compiler and LLVM versions
Cross-compilation target triples
Triple Description
x86_64-pc-linux-gnu Linux x86-64
aarch64-unknown-linux-gnu Linux ARM64 (Raspberry Pi, Graviton)
aarch64-unknown-none-elf Bare-metal ARM64: no OS, no libc
x86_64-unknown-none Bare-metal x86-64: no OS, no libc
x86_64-pc-windows-gnu Windows x86-64 (COFF, mingw runtime)
armv6k-none-eabihf 32-bit ARM, hard-float (Nintendo 3DS)
i686-pc-linux-gnu Linux 32-bit x86
Linker detection order
eskiuc finds the system linker via: $CC → cc → clang → gcc. A C toolchain must be installed for linking. With --freestanding or a .o output, no linking occurs.
Undefined Behaviour
The following operations have undefined behaviour in Eskiu. The compiler makes no guarantees about what the generated code does when these occur. Bugs in these categories may crash, produce wrong results, corrupt memory, or appear to work correctly under some conditions and fail under others.
Category Description Detection
Null dereference Reading or writing through a null pointer. Crashes with SIGSEGV. ASan
reports immediately.
Use-after-free Accessing memory after free() has been ASan reports. Set pointer to
called on it. null after free to crash early.
Double-free Calling free() twice on the same pointer. ASan reports. Corrupts heap
metadata silently without ASan.
Buffer overrun Reading or writing past the end of an ASan reports. Corrupts
allocated buffer or fixed array. adjacent memory silently
without ASan.
Signed integer A signed arithmetic operation produces a Wraps silently. No detection
overflow value outside the representable range. without UBSan.
Uninitialized Reading a variable before assigning a value A read no path assigns is a
read to it. compile error; -Wall warns when
some path skips the assignment.
Valgrind reports the rest.
Misaligned access Reading a multi-byte type from a non-aligned Bus error on some ARM
(arch-dependent) address. Most common with packed struct targets. Silent on x86.
fields.
Data race Two threads access the same memory TSan (Thread Sanitizer)
concurrently with at least one write and no reports. Silent otherwise.
synchronisation.
Compile with AddressSanitizer to catch the first four categories: eskiuc file.esk --asan -o file_asan. The instrumentation is added by eskiuc itself, so linking a plain -c object with clang -fsanitize=address does not instrument the Eskiu code.
Quick Reference
A.1 CLI flags
eskiuc file.esk -o prog # compile + link eskiuc a.esk b.esk -o prog # multi-file eskiuc file.esk -c -o file.o # compile only eskiuc file.esk -o prog -lssl # link library eskiuc run file.esk [args...] # compile, run, delete eskiuc fmt file.esk # reformat in place eskiuc fmt --check file.esk # check formatting eskiuc file.esk -Wall # unused, assign-in-cond, maybe-uninitialized eskiuc file.esk -Wextra # signed/unsigned warnings eskiuc file.esk --asan -o prog # AddressSanitizer eskiuc file.esk --ubsan -o prog # trapping bounds checks eskiuc file.esk --safe -o prog # runtime slice-bounds checks (traps on violation) eskiuc file.esk -O2 -o prog # optimization level (-O0 through -O3) eskiuc file.esk --target TRIPLE # cross-compile eskiuc file.esk --target T --mcpu CPU --mattr FEAT --reloc static # tune the target eskiuc file.esk --freestanding # esk_alloc/esk_free, no libc eskiuc file.esk --test-lexer # dump token stream eskiuc file.esk --test-parser # dump AST eskiuc file.esk --test-codegen # dump LLVM IR eskiuc file.esk --hover-at LINE:COL # inferred type at position eskiuc file.esk --definition-at L:C # definition location eskiuc --version # compiler and LLVM version
A.2 Keywords
// Types int int8 int16 int32 int64 uint uint8 uint16 uint32 uint64 float double bool char string void
// Declarations let const volatile static must_use struct packed union enum type interface fn extern intrinsic import operator
// Control flow if else for while do in switch case default return break continue match
// Memory alloc_with free_closure sizeof asm
// Concurrency and async thread_create thread_join async await escaping
// Error handling try catch finally throw defer errdefer
// Literals true false null
A.3 Standard library: quick reference
// Core
import <result>; Result<T,E> Ok<T,E>(v) Err<T,E>(e)
import <list>; List<T> List_init List_push List_get List_len List_free
import <string>; String String_from String_append String_split String_trim
import <math>; sqrt pow floor ceil fabs abs
import <io>; printf fprintf sprintf scanf puts
import <mem>; alloc<T>(n) free(p) memcpy memset memmove memcmp strlen
import <fs>; fs_open fs_close fs_read_all fs_write_all fs_puts fs_size
import <net>; net_tcp_listen net_accept net_tcp_connect
net_send net_recv net_close
// Allocators import <alloc>; Bump Arena Pool FirstFit (over alloc_with) import <sysheap>; Heap (mmap-backed, no libc malloc)
// Utilities import <time>; time_now_ms time_now_s time_monotonic_ms sleep_ms import <env>; env_get env_has env_get_or env_get_int import <base64>; base64_encode base64_decode base64_encoded_len import <path>; path_join path_basename path_dirname path_extension
// Concurrency import <threading>; Mutex Cond Sem (pairs with thread_create/thread_join) import <atomic>; atomic_load atomic_store atomic_swap atomic_cas
// Async/await runtime import <future>; Future<T> future_new future_complete future_poll free_future import <eventloop>; el_new EventLoop_add_read EventLoop_add_timer EventLoop_run EventLoop_stop EventLoop_free import <executor>; Executor (thread-safe ready-queue + wakeup) import <net_async>; net_read_async net_accept_async net_write_async import <timer>; timer_after(lp, ms) -> *Future<int> import <channel>; Chan<T> chan_new Chan_send Chan_recv Chan_free
// HTTP import <http>; http_serve(port, workers, handler): threaded import <http_async>; http_serve_async(lp, fd, handler, n): event loop import <http2>; HTTP/2 framing + streams (RFC 7540) import <hpack>; HPACK header compression with Huffman (RFC 7541) import <http2_server>; http2_serve_async import <tls>; OpenSSL TLS + ALPN h2
// Sum types import <either>; Option<T> Either<A,B> opt_is_some opt_unwrap_or import <futureval>; select2v -> Either<A,B> join2v -> Pair<A,B>
// Data structures import <map>; Map<V> Map_init<V> Map_at<V> Map_get<V> Map_free<V>
// HTTP helpers import <multipart>; multipart_boundary multipart_part(body, len, ...)
A.4 Memory patterns
import <mem>;
// Stack: automatic, zero cost
int x = 5;
Point p = Point { x: 1.0, y: 2.0 };
// Heap: manual lifecycle *int arr = alloc<int>(100); // allocate arr[0] = 42; free((*void)arr); // release arr = null; // prevent use-after-free
// Pointers *int ptr = &x; // address-of *ptr = 100; // write through int n = *ptr; // read through
// Explicit allocator (Bump example) uint8[4096] backing; let b: Bump; Bump_init(&b, &backing[0], 4096); *int xs = alloc_with(&b, int, 16); // from the bump Bump_reset(&b); // free all at once
Glossary
ABI
Application Binary Interface. The binary-level contract between separately compiled code: how arguments are passed, where return values go, how structs are laid out. Eskiu uses the C ABI, which is why extern functions work transparently.
alloc<T>(N)
Heap-allocation stdlib function from <mem>, not a language keyword. Allocates N elements of type T on the heap and returns *T; calls calloc (zero-initialized) in hosted mode and esk_alloc in freestanding mode. Always paired with free.
AST
Abstract Syntax Tree. The tree-shaped data structure the parser builds from source code. Each node represents one language construct; the type checker and code generator both walk it.
async / await
Language keywords for non-blocking concurrency. An async function returns *Future; await E suspends the enclosing async function until the future is ready. The compiler lowers async functions to resumable state machines.
bump allocator
The simplest heap allocator. Keeps a pointer to the next free byte; allocation advances the pointer and free is a no-op. Used in the Eskiu kernel.
closure
A lambda that captures variables from its enclosing scope by value. Represented as a fat pointer {fn_ptr, env_ptr}, where env_ptr points to the captured variables. A non-escaping closure keeps its environment on the stack; an escaping one gets a heap-allocated copy.
constraint (bounded type parameter)
An interface requirement on a type parameter: <T: Ord> means the concrete type must provide every method declared by Ord. Multiple constraints are written <T: A + B>. Structs satisfy a constraint by defining the methods; primitives (int, float, …) satisfy it via a free function named like the method with the primitive as its first argument: int cmp(int, int) makes int satisfy Ord.
cross-compilation
Building a binary for a different machine than the one running the compiler. Controlled with --target TRIPLE.
data race
Two threads accessing the same memory concurrently, at least one writing, with no synchronisation: undefined behaviour. Closures avoid data races by capturing variables by value.
double-free
Calling free on the same pointer twice. Corrupts the heap. Prevented by setting pointers to null after free.
escaping closure
A closure whose environment outlives the function that created it, so it gets a heap-allocated env instead of a stack one. Mark the receiving parameter escaping; release it with free_closure(f).
event loop
A reactor that watches file descriptors for readiness (kqueue/epoll) and resumes parked futures when I/O is ready. Provided by <eventloop>: el_new / EventLoop_run / EventLoop_free.
extern
Declares a function from a C library. The compiler generates a C-ABI call and the linker resolves it; any C library is accessible this way.
fat pointer
A two-word value: {data_ptr, vtable_ptr} for interfaces, {fn_ptr, env_ptr} for closures.
freestanding
Compilation mode without libc. --freestanding redirects alloc and free to esk_alloc and esk_free, which the user provides.
Future
A handle to a value of type T that may not exist yet. Produced by async functions and leaf I/O primitives. Release with free_future(f). Import from <future>.
GEP
getelementptr. The LLVM IR instruction for pointer arithmetic, used to access struct fields and array elements without loading data.
heap
A large pool of memory managed with alloc and free. Allocations persist until freed and outlive the function that created them.
interface
A named set of method signatures. Any struct that provides all the required methods satisfies the interface (structural typing); the compiler generates a vtable automatically.
LLVM IR
The intermediate representation used internally: platform-independent, strongly typed, SSA form. Inspect it with --test-codegen.
memory leak
An allocation that is never freed. The program slowly consumes all available memory.
monomorphic template
A template that generates one concrete type or function per unique set of type arguments. No runtime overhead compared to hand-written type-specific code.
monomorphization
Generating a separate concrete copy of a template function or struct for each unique set of type arguments at compile time. Zero runtime overhead.
null
A pointer value that points to nothing. Dereferencing null crashes; check ptr != null before dereferencing.
pointer
A variable holding a memory address. *T is a pointer to T, &x gives the address of x, and *ptr reads or writes through the pointer.
Result
Template struct from stdlib: ok == 1 and value holds T on success; ok == 0 and error holds E on failure.
self
Implicit pointer to the struct instance inside a method body. Equivalent to this in C++ or Java.
stack
Memory automatically reclaimed as functions call and return. Local variables live here: fast, but limited in size.
struct
A composite type with named fields; can have methods. Created with a struct literal: Point { x: 1.0, y: 2.0 }.
structural typing
Interface-satisfaction rule: a struct satisfies an interface if it provides all the required methods, no explicit implements needed.
thread_create / thread_join
Language keywords. thread_create(fn) spawns a thread and returns a handle; thread_join(handle) blocks until the thread finishes. The driver links pthread where the platform needs it.
union
Like a struct, but all fields share offset 0; size equals the largest field. Used for type punning: treating the same bits as different types.
use-after-free
Reading or writing through a pointer after its memory was freed. Produces garbage or corrupts other allocations. Prevented by nulling after free.
volatile
Qualifier preventing the compiler from optimising away loads and stores to a pointer. Required for memory-mapped hardware registers.
vtable
Compiler-generated table of function pointers, one per interface method, for a specific struct. Part of the fat pointer used in interface dispatch.
The Book of Eskiu | Eduardo Dorantes | eskiu-lang.org | v0.9.2 | MIT License | A ReactVision project