The Book
of Eskiu

A complete introduction to systems programming

Eduardo Dorantes

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

Preface (Why this book exists)
Chapter 1: Hello, Eskiu (Your first program from scratch)
Chapter 2: Variables and Types (Naming values and describing their shape)
Chapter 3: Functions (Defining, calling, and composing behaviour)
Chapter 4: Control Flow (Decisions, loops, for-in, and early exits)
Chapter 5: Structs and Methods (Grouping data with the behaviour it needs)
Chapter 6: Pointers and Memory (Addresses, stack, heap, and the rules)
Chapter 7: Interfaces (Polymorphism without inheritance)
Chapter 8: Templates (Generic code at zero runtime cost)
Chapter 9: Closures and Lambdas (Functions as values, functions with memory)
Chapter 10: Error Handling (Result, the ? operator, and exceptions)
Chapter 11: Concurrency (Threads, closures, and shared state)
Chapter 12: Operators (Arithmetic, bitwise, sizeof, preprocessor)
Chapter 13: Multi-file Programs (import, multi-file CLI, and project structure)
Chapter 14: C Interop (Calling any C library from Eskiu)
Chapter 15: The Standard Library (result, list, string, math, io, mem, fs, net)
Chapter 16: Inline Assembly (Talking directly to hardware)
Chapter 17: Cross-compilation (Building for ARM, x86-64, Windows, and bare-metal)
Chapter 18: Bare-metal Programming (A kernel in Eskiu: no libc, no OS)
Chapter 19: Ownership and Resources (Designing safe code with alloc and free)
Chapter 20: Debugging (Segfaults, leaks, and diagnostic strategies)
Chapter 21: Testing (Assertions, unit tests, and test organisation)
Chapter 22: Common Bugs (The mistakes everyone makes and how to avoid them)
Chapter 23: Networking (TCP sockets, echo servers, and HTTP in Eskiu)
Chapter 24: Real World: INE QR Decoder (A cryptographic pipeline in production Eskiu)
Chapter 25: Async and Await (Concurrent I/O without threads)
Chapter 26: Sum Types and Match (Algebraic enums, Option, Either)
Chapter 27: Self-Hosting (The compiler written in Eskiu, the bootstrap fixpoint, feature-completeness)

Part II: Language Reference

Ref R1: Lexical Elements (Comments, identifiers, keywords, literals)
Ref R2: Types (Primitives, pointers, arrays, structs, templates)
Ref R3: Operators (Complete operator table with precedence)
Ref R4: Control Flow (if, for, for-in, while, switch, break, continue)
Ref R5: Functions (Regular, lambda, closure, template, thread primitives)
Ref R6: Memory (Stack, heap, alloc/free, volatile, freestanding)
Ref R7: Preprocessor (#define, #ifdef, #pragma pack, multi-line macros)
Ref R8: Standard Library (reference for the core, networking, and system modules)
Ref R9: CLI Reference (All compiler flags, linking, cross-compilation)
Ref R10: Undefined Behaviour (What the compiler does not guarantee)
Appendix A: Quick Reference (CLI flags, keywords, stdlib at a glance)
Appendix B: Glossary (Terms used throughout this book)
Preface

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.

Chapter 1

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.

Chapter 2

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

Chapter 3

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.

Chapter 4

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.

Chapter 5

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.

Chapter 6

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;
Chapter 7

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.

Chapter 8

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.
Chapter 9

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)
Chapter 10

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.

Chapter 11

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.
Chapter 12

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.
Chapter 13

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
Chapter 14

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.

Chapter 15

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
Chapter 16

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
Chapter 17

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 */ }
Chapter 18

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.

Chapter 19

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
Chapter 20

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).

Chapter 21

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
Chapter 22

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.

Chapter 23

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.

Chapter 24

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.

Chapter 25

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;
 }
Chapter 26

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.

Chapter 27

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.

Ref R1

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).

Ref R2

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.

Ref R3

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.

Ref R4

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.
Ref R5

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.

Ref R6

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)
Ref R7

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.

Ref R8

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>.

Ref R9

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.

Ref R10

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.

Appendix A

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
Appendix B

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