Language · Language spec
Eskiu Language Specification
Version: v0.9.2
1. Overview
Eskiu is a statically typed, compiled systems language. It lowers to native code through LLVM, manages memory explicitly (no garbage collector), and interoperates directly with C. A .esk file can also be run on the spot with eskiuc run (or a #!/usr/bin/env eskiuc run shebang), so the same source is both a compiled artifact and a runnable script. This document is the language reference: syntax, type system, semantics, and ABI.
Core properties:
- Statically typed with explicit type annotations
- Manual memory management, no garbage collector
- Compiles to native object files via LLVM (arm64 and x86-64)
- Structs with methods, monomorphic templates, and structural interfaces
- Lambdas and
fn(T)->Rfunction pointer types - Multi-file programs via
import - Error locations reported as
file.esk:line:col: message
The full compilation pipeline:
eskiuc file.esk -o file # compile and link into an executable
./file # run
eskiuc links the program for you by invoking the system C toolchain ($CC,
then cc/clang/gcc), the same approach rustc and clang use. To stop at
the object file instead, give the output a .o name or pass -c, then link
yourself:
eskiuc file.esk -o file.o # compile to a native object file only
clang file.o -o file # link it yourself
2. Lexical Elements
A source file is bytes; a leading UTF-8 byte order mark (EF BB BF) is skipped.
2.1 Comments
Single-line comments begin with // and extend to the end of the line. A trailing backslash does not continue the comment onto the next line (unlike C). Block comments are enclosed in /* ... */ and may span multiple lines. Comments do not nest.
// This is a single-line comment
/*
This is a
block comment.
*/
2.2 Identifiers
An identifier begins with a letter (a–z, A–Z) or underscore (_), followed by zero or more letters, digits (0–9), or underscores. Identifiers are case-sensitive: point and Point are distinct.
my_var _internal Count x1
2.3 Keywords
The following identifiers are reserved and may not be used as variable or function names:
let int int8 int16 int32 int64
uint uint8 uint16 uint32 uint64
float double bool char string void
struct packed interface fn extern intrinsic import
if else for while do in switch case default match
return break continue
true false null
alloc_with
const volatile static escaping must_use asm
thread_create thread_join
try catch finally throw defer errdefer
async await operator
sizeof free_closure union enum
2.4 Literals
Integer decimal literals are sequences of decimal digits:
0 42 1000
Integer hex literals are prefixed with 0x followed by hexadecimal digits (0–9, a–f, A–F):
0xFF 0x0F 0xDEAD 0xBEEF
Negative numeric literals are written with a leading -:
-1 -42 -100
-3.14 -0.5
Negative literals are first-class values and can be used in any expression context, including global variable initialisers and struct field initialisers.
Float literals contain a decimal point. They have type double (f64) by default;
assigning one to a float (f32) variable or field coerces it down (a double→float
cast). Integer literals are int (i32), widening to int64 when they exceed 32 bits.
A float literal may carry an exponent (1e10, 2.5e-3), which needs at least one
digit. An integer literal that does not fit in 64 bits is a lexical error.
3.14 2.0 0.5
String literals are sequences of characters enclosed in double quotes. The same escape sequences are recognized in string and character literals:
| Escape | Meaning |
|---|---|
\n |
Newline |
\t |
Horizontal tab |
\r |
Carriage return |
\f |
Form feed |
\v |
Vertical tab |
\a |
Alert (bell, byte 7) |
\b |
Backspace (byte 8) |
\\ |
Backslash |
\" |
Double quote |
\' |
Single quote |
\? |
Question mark |
\NNN |
Raw byte from one to three octal digits, at most \377 (e.g. \0 is NUL, \012 is a newline) |
\xNN |
Raw byte from one or two hex digits (e.g. \xC3 is byte 0xC3) |
Any other escape (\q), an octal escape above \377, and \x with no hex digit are
errors located at the backslash. As in C, an octal escape takes at most three digits
("\1234" is S followed by 4), and \0 followed by a non-octal character is NUL.
"Hello, world!\n"
"path\\to\\file"
"column\theader"
Character literals are a single character or escape sequence enclosed in single quotes:
'a' '\n' '\\'
Boolean literals are the keywords true and false.
Null literal is the keyword null, used for null pointer values.
3. Types
3.1 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; single byte |
string |
i8* |
pointer | Immutable C-string literal |
void |
void |
n/a | No value; valid only as return type |
Signedness is tracked by the compiler for correct arithmetic and comparison codegen. Signed and unsigned variants of the same width share the same LLVM integer type (e.g., int8 and uint8 are both i8).
Arithmetic follows C's integer promotions: an operand narrower than int (bool, char, int8, int16, uint8, uint16) is converted to int before an arithmetic, bitwise, shift, or comparison operator is applied. So (uint8)200 + (uint8)100 is 300, (uint8)200 > (int8)-1 is true, and true + true is 2. Storing the result back into a narrow variable truncates it, as in C. Converting any integer, floating, or pointer value to bool yields value != 0.
After promotion, two operands of different types meet under C's usual arithmetic conversions: the wider operand's type wins, and at equal width an unsigned operand makes the result unsigned, so (int32)-1 + (uint32)5 is the uint32 4 and (int32)-1 < (uint32)5 is false. A shift has the type of its promoted left operand, and unary - and ~ promote a narrow operand to int first. The two arms of a ternary meet the same way (two different narrow arms give an int). An integer literal that does not fit in int has type int64, so 3000000000 * 2 is 6000000000. Constant initializers fold with exactly these rules.
3.2 Pointer Types
A pointer type is written with a leading *:
*int // pointer to int
*uint8 // pointer to uint8
**char // pointer to pointer to char
Both leading-star (*T) and trailing-star (T*) syntax are accepted. The canonical form in this document is *T.
let ptr: *int = null;
let buf: *uint8 = null;
A pointer converts implicitly only to a pointer to the same type (adding const is
fine). null converts to any pointer, a *void converts to and from any pointer, and
the byte pointers string, *char, *int8 and *uint8 interconvert. Any other change
of pointee, such as *int to *Big or **int to *int, needs an explicit cast
((*Big)p). A *void points at no type, so it cannot be dereferenced (*p is a compile
error); cast it to a typed pointer first.
Checked nullable pointers (?*T)
A plain *T may hold null (as in C), and dereferencing a null one is undefined. For
opt-in null safety, write ?*T, a checked nullable pointer. The compiler will not let
you dereference, index, or take a member of a ?*T until you have proven it non-null with
a null-check; inside if (x != null) { ... } the pointer is treated as non-null:
?*int q = maybe();
*q; // error: q may be null; check it first
if (q != null) {
printf("%d\n", *q); // ok: narrowed to non-null here
}
A non-null *T converts to ?*T implicitly (widening); going the other way (?*T to
*T) drops the check and so requires narrowing (or an explicit cast). ?*T has the same
representation as *T (a bare pointer); the checking is entirely at compile time. The
? marks a pointer type only: ?int is an error. The ? applies to the whole type it starts, so ?*T* is a nullable pointer to *T. A ? after a leading * and before another one makes the inner pointer nullable: *?*T is a pointer to a nullable pointer to T (an out-parameter that may receive null). Dereferencing it once gives a ?*T, which must be checked before it is dereferenced again, and it does not convert to **T, which would drop that check (&p of a ?*T variable p is a *?*T).
A null-check narrows in these forms: the then-branch of if (x != null) (and the else of
if (x == null)), if (x), !(x == null), the right operand of x != null && ... and of
x == null || ..., either arm of a ?: on such a condition, the body of
while (x != null) or for (...; x != null; ...), and the rest of a block after an early
exit such as if (x == null) { return 0; } (unless the branch that falls through assigns
x). While narrowed, x can be passed or assigned
where a *T is expected. Narrowing applies to the variable itself: assigning to it (other
than storing an address &y), taking its address, or reassigning it anywhere in an
enclosing loop ends it, and a shadowing declaration of the same name is not narrowed. A
variable whose address has been taken (&x, before the check or anywhere in an enclosing
loop) is not narrowed, since a write through that pointer can store null behind the check;
for a global the address counts anywhere in the program. A global's narrowing also ends at
every call and await, and at alloc_with (a call to the alloc method), thread_create
and thread_join, since the code they run may assign it: a test of a global earlier in a
condition does not hold after a later operand that calls (gp != null && f() && gp.v),
nor in a branch or ternary arm guarded by such a condition, nor after an early-exit guard
whose condition or fall-through branch calls something. A user operator (a + b on a
struct with an operator +) is a call too. A lambda body never sees a global's narrowing
from where the lambda is written, since it runs later. A static local is one cell shared
by every call and closure, so it follows the rule for globals.
3.3 Array Types
Fixed-size arrays use the form T[N] where N is a positive integer constant expression, as in C: numbers, enum members and const ints (see §4.6), casts to an integer type, the integer operators including comparisons and ?: (int[K << 1], int[K > 2 ? 4 : 1]), and sizeof(T) (uint8[sizeof(Header)]), whose value is the target's layout size. An array of an array alias puts the new dimension outermost: with type Row = int[3], Row[2] is int[2][3]. Array types are supported both as struct fields and as local variables:
struct QRBuffer {
uint8[858] left;
uint8[858] right;
int length;
}
int main() {
int[16] scratch; // local fixed-size array
return 0;
}
When a leading * meets a trailing [N], the array binds outermost: *T[N] is an
array of N pointers (each element a *T), i.e. it reads as (*T)[N]. For example
*Node[7] is seven Node pointers, so *p of one is an error (index it first, *p[0]).
There is no source spelling for a pointer to a whole array (T[N]* does not parse, and
&arr is not a *T[N]); point at the first element instead, *Node p = &arr[0];, and
index through it (p[i]), or name the array with an alias: with type Row = int[3], a
*Row points at a whole int[3] ((*p)[2]).
The same rule applies to a function type: fn(int)->int[2] is an array of 2 function
values, not a function returning an array (a function cannot return an array), so
fs[1](41) calls the second element.
uint8[858] lowers to [858 x i8] in LLVM IR.
An array may be initialized with a brace list { e0, e1, ... }. As in C, listing fewer
elements than the size zero-fills the rest, and {} zero-fills the whole array; listing
more than the size is an error.
int[3] a = {10, 20, 30}; // a = 10, 20, 30
int[4] b = {7, 8}; // b = 7, 8, 0, 0
int[3] c = {}; // c = 0, 0, 0
Multidimensional arrays chain the suffix: T[N][M] is N arrays of M elements,
following C order, so the leftmost bracket is the outer dimension. Indexing peels one
dimension at a time (a[i] is a row of type T[M], a[i][j] is a T), and each index
is bounds-checked against its own dimension. A nested brace list initializes it, with the
same zero-fill rule at every level:
int[2][3] a = { {1, 2, 3}, {4, 5, 6} }; // 2 rows of 3
int[2][2] b = { {7, 8} }; // second row zero-filled: {7,8},{0,0}
a[1][2]; // 6 (row 1, column 2)
int[2][3] lowers to [2 x [3 x i32]] in LLVM IR.
3.3.1 Slice Types
A slice T[] (empty brackets) is a view into a contiguous run of T: a fat pointer
carrying both a data pointer and a length. Unlike a fixed array, a slice's length travels
with it, so a function that takes a T[] never needs a separate count argument, killing
the classic "pointer without its length" bug.
Create a slice by slicing a fixed array or a raw pointer with a half-open range
(reusing the .. operator): a[lo..hi] is a view of elements lo through hi-1.
Slicing a *T (for example a heap buffer from alloc<T>(n)) yields a T[] over that
memory, so slices are not limited to fixed arrays. The slice aliases the backing
storage; 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
A slice supports indexing (s[i], read and write), its length via s.len (an int64),
and iteration with for (x in s). Pass it by value: the fat pointer (data + length) is
copied, but it still refers to the same backing storage.
int sum(int[] s) {
int total = 0;
for (x in s) { total = total + x; } // length-driven, no count argument
return total;
}
sum(a[0..6]); // the whole array as a slice
int[] lowers to { ptr, i64 } in LLVM IR. By default an out-of-range slice or array
index is undefined, as in C; compile with --safe to bounds-check every index at runtime
(an out-of-range access aborts instead of corrupting memory). --safe is opt-in, so
release builds carry no overhead.
3.4 Struct Types
A struct type is named by its declaration (see §8). Variables of struct type are declared using the struct name as the type annotation:
let p: Point;
let r: Rect;
3.5 Template Types
Template structs and functions are parameterized by one or more type variables. Instantiation is lazy and monomorphic: the compiler generates one concrete definition per unique set of type arguments.
let r: Result<int, string>;
let items: List<float>;
Result<int, string> lowers to %Result_int_string in LLVM IR.
3.6 Interface Types
Interface types are structural: any struct that provides all required methods satisfies the interface without an explicit declaration. An interface value is a fat pointer {data_ptr, vtable_ptr}; the compiler builds one from a pointer to a conforming struct (&x) wherever an interface is expected (see §9.4). Passing the struct itself by value is an error. Each required method must match the interface's signature (return type and parameter types after the receiver; a type spelled with the interface's own name stands for the implementing type).
interface Drawable { void draw(); }
// any struct with a draw() method satisfies Drawable
3.7 Function Pointer Types
A function pointer type is written using the fn keyword:
fn(int)->int // function taking one int, returning int
fn(int, int)->bool // function taking two ints, returning bool
fn()->void // function taking no arguments, returning void
fnnames a type, never a definition. Unlike Rust, Swift, or Kotlin, Eskiu does not usefnto define functions. A function is defined C-style, with the return type before the name:int add(int a, int b) { ... }(see §6.1). Thefn(...)->Rform appears only where a type is expected: a variable's type, a struct field, or a parameter. There is nofn name(...)definition syntax.
Function pointer types can be used anywhere a type annotation is expected: variable declarations, struct fields, and function parameters.
let callback: fn(int)->int = int(int x) { return x * 2; };
int apply(fn(int)->int f, int x) { return f(x); }
A function value is assignable to an fn(...) type only when the signatures match
exactly: a function's parameter and return registers are fixed by its calling convention,
so there is no implicit conversion between function types. Assigning, say, an
fn(int)->int value to an fn(int)->float is a compile error. (A lambda literal is the
one exception: when its header return type differs from the target fn(...)->R, the
return type is taken from R and the body's value is coerced, so fn(int)->float =
int(int x){ ... } is accepted.)
3.8 Type Casting
An explicit cast is written as (TYPE)expr. The expression is converted to the named type at the point of the cast.
double x = 3.14;
int n = (int)x; // truncates to 3
uint8 b = (uint8)n;
float f = (float)n;
3.9 Numeric Conversions
Numeric conversions in assignment, initialization, return, and call arguments follow
C, with one exception. Implicit (no cast needed):
- Widening:
int8toint,inttoint64,inttofloat, and so on. - Integer-width narrowing:
int64toint,inttouint8. The value is truncated (as in C); write a cast when the truncation is not what you want. - Float-width narrowing:
doubletofloat.
The one conversion that requires an explicit cast:
- Floating-point to integer (
double/floatto any integer). It drops the fractional part, so it must be written out:int n = (int)3.9;gives3;int n = 3.9;is a compile error.
Statically-known mistakes are also compile errors:
- An integer literal that does not fit its target type:
int8 x = 300;(300 is outsideint8's range). A literal that fits is fine:uint8 c = 255;. - Integer division or remainder by zero, or of the most negative value by
-1, when the operands are constant: a literal (x / 0), aconstname, asizeof(§5.8), a cast (x / (int)0.5) or an expression of those.M / -1withconst int M = -2147483647 - 1does not fit anint. - A floating constant converted to an integer type that cannot hold it, written as a
literal or reached through
constnames and arithmetic:(int)1e10,(int)Dwithconst double D = 1e10,(uint32)(0.0 - 1.0).
A constant shift count out of range and a constant array index out of bounds are errors
by the same folding (a << sizeof(int64) * 8, a[sizeof(int)] on an int[4]).
int64 big = strlen(s); // ok: no cast needed for the length
int n = strlen(s); // ok: int64 to int truncates, as in C
int x = (int)3.9; // ok: explicit
int y = 3.9; // error: floating-point to integer needs a cast
int8 z = 300; // error: literal out of range for int8
4. Variables
4.1 let-style Declaration
let x: int = 5;
let name: string = "Eskiu";
let ptr: *int = null;
let pt: Point;
4.2 C-style Declaration
int x = 5;
string name = "Eskiu";
*int ptr = null;
Both forms are equivalent. The type annotation is required in both; type inference is not supported.
A variable is visible from its declaration to the end of the enclosing block. A declaration in a nested block may shadow an outer variable of the same name; the outer variable is unchanged and visible again after the inner block ends. Declaring the same name twice in one scope is an error, and so are these other duplicates: two parameters with one name, a local in a function's outermost block that reuses a parameter's name (the parameters and that block share one scope, as in C), two fields (or a field and a method) with one name in a struct, a repeated enum member, a second default: in a switch, a global and a function (or a struct and a function) with one name, and a function prototype whose signature differs from its definition. A prototype that matches its definition, and an extern declaration next to the variable's definition, are allowed.
4.3 Pointer Variables
let p: *int = null;
if (p != null) {
int val = *p;
}
4.4 Struct Variables
let pt: Point;
pt.x = 1.5;
pt.y = 2.5;
4.5 Volatile Variables
The volatile qualifier prevents the compiler (via LLVM) from optimising away loads and stores to a variable. It is required for memory-mapped I/O (MMIO) registers whose value may change or have side effects outside the program's control.
volatile let uart: *uint8 = (uint8*) 0x3F8;
*uart = 'A'; // store is always emitted, not eliminated by optimiser
volatile applies to every load and store of the variable itself and of any place reached from it by *, [] or . (*uart, uart[i], dev.ctrl), including ++/--, compound assignment and the storage word of a bitfield (dev.mode = 5 reads and writes the word with volatile accesses), for locals and globals. The initializing store of a volatile variable is volatile too. It has no effect on variables that are never accessed through a pointer, but the canonical use is MMIO pointer variables as shown above.
4.6 Constants (const)
The const qualifier declares an immutable, typed binding. It prefixes either declaration form, must be initialised, and may not be reassigned:
const int MAX = 100;
const let step: int = 5;
MAX = 200; // error: cannot assign to read-only location 'MAX'
A const integer can also be used as a fixed-size array dimension, in struct fields and in local variables:
const int CAP = 4;
struct Ring { int[CAP] slots; } // CAP resolves at compile time
int main() {
int[CAP] xs; // local array sized by a constant
xs[0] = 1;
return sizeof(Ring); // 16
}
An array dimension is an integer constant expression: literals, enum members and const ints, combined with the integer operators, ?:, parentheses and casts to an integer type, which truncate as in C (int[(uint8)258] has 2 elements, int[CAP * 2] 8). It must be positive. const bindings are block-scoped like any other variable.
const works on any type (string, struct, pointer, scalar). Immutability covers both rebinding the variable and mutating a field or element of a const value:
const string name = "Eskiu"; // any type may be const
const let p: Point = Point { x: 1.0, y: 2.0 };
p.x = 5.0; // error: cannot assign to read-only location 'p'
Pointer constness
For pointers, const distinguishes what is read-only, exactly as in C:
| Spelling | Meaning | Pointer rebindable? | Pointee writable? |
|---|---|---|---|
int* |
ordinary pointer | yes | yes |
const int* |
pointer to const int | yes | no |
int* const |
const pointer to int | no | yes |
const int* const |
const pointer to const int | no | no |
int sum(const int* p, int n) { // a read-only view of the caller's data
int s = 0;
for (i in 0..n) { s = s + p[i]; } // reads through the const pointer are fine
return s;
}
const int* r = &v;
r = &w; // allowed: the pointer is rebindable
*r = 10; // error: cannot assign to read-only location 'r' (pointee is const)
int* const c = &v;
c = &w; // error: cannot assign to read-only location 'c' (binding is const)
*c = 10; // allowed: the pointee is writable
Const-correctness is enforced on conversions: adding const (int* → const int*) is always allowed, but any conversion that would drop a const qualifier (in an initializer, assignment, call argument, or return) is a compile error. const has no ABI effect; it is stripped before code generation. It applies uniformly to locals, parameters, struct fields and return types.
A method call x.m() passes &x as self, so on a const value (or through a const T*) it is allowed only when the method declares a read-only receiver, int P_get(const P* self). A method whose self is a plain *P may write through it and is rejected there.
4.7 Static Locals (static)
The static qualifier gives a local variable a single instance that persists across calls, exactly as in C. Its storage lives for the whole program, not the enclosing call, so it retains its value between invocations:
int next() {
static int c = 0; // initialised once, at load time
c = c + 1;
return c;
}
next(); // 1
next(); // 2
next(); // 3
A static local's initializer must be a compile-time constant; a runtime expression is rejected. An uninitialised static local is zero-initialised. Two static locals in different functions never alias, even if they share a name. static on a global is rejected, since a global already has static storage. A closure that uses a static local refers to that one cell, like a global, rather than capturing a copy.
The same constant rule applies to a global variable's initializer, as in C: it may be a literal, an enum member, a top-level const, sizeof, the address of a global (&g), a non-capturing lambda, any unary, binary, ternary or cast expression over those (int C = 3 + 1;, double q = 1.0 / 4.0;), or an array or struct literal built from them. A top-level function name is a constant too (Op g = add;, as in C): it decays to a closure with no environment. A call or a read of a non-const variable is a compile error ("initializer of global 'X' is not a compile-time constant"), since no code runs before main to compute it.
5. Operators
5.1 Arithmetic
| Operator | Description |
|---|---|
a + b |
Addition |
a - b |
Subtraction |
a * b |
Multiplication |
a / b |
Division |
a % b |
Modulo (remainder) |
Integer division truncates toward zero. Float operands use IEEE 754 semantics.
5.2 Bitwise
| Operator | Description |
|---|---|
a & b |
Bitwise AND |
a \| b |
Bitwise OR |
a ^ b |
Bitwise XOR |
~a |
Bitwise NOT |
a << b |
Left shift |
a >> b |
Right shift |
Right shift on signed types is arithmetic (sign-extended).
5.3 Comparison
| Operator | Description |
|---|---|
a == b |
Equal |
a != b |
Not equal |
a < b |
Less than |
a > b |
Greater than |
a <= b |
Less than or equal |
a >= b |
Greater than or equal |
Comparison works on integer types, floating-point types, and pointer types. The result is always bool.
5.4 Logical
| Operator | Description |
|---|---|
a && b |
Logical AND |
a \|\| b |
Logical OR |
!a |
Logical NOT |
Short-circuit evaluation applies: in a && b, b is not evaluated if a is false; in a || b, b is not evaluated if a is true.
5.5 Assignment
| Operator | Description |
|---|---|
x = e |
Assign |
x += e |
Add and assign |
x -= e |
Subtract and assign |
x *= e |
Multiply and assign |
x /= e |
Divide and assign |
x %= e |
Modulo and assign |
x &= e |
Bitwise AND and assign |
x \|= e |
Bitwise OR and assign |
x ^= e |
Bitwise XOR and assign |
x <<= e |
Left-shift and assign |
x >>= e |
Right-shift and assign |
The compound bitwise/shift operators are desugared by the parser: x op= e is equivalent to x = x op e.
The left-hand side must be an lvalue: a named variable, a pointer dereference (*ptr = value), or a field access. A field or element of a temporary (mk().x, arr()[0]) is not an lvalue: it can be read, but not assigned, have its address taken, or (an array) be sliced. Assigning through a dereferenced pointer parameter works correctly. *ptr = value stores through the pointer as expected.
Evaluation order: the address of the left-hand side is computed first (its index, pointer and call subexpressions run), then the right-hand side, then the store. So a[f()] = g() calls f before g, and getp().x = g() calls getp first. A compound assignment x op= e evaluates the address of x once, in the same order.
5.5.1 Increment and Decrement
++ and -- add or subtract one from an integer or pointer lvalue, in place. Both prefix
and postfix forms are supported: ++x/--x yield the new value, x++/x-- yield the old
value. On a pointer, the step is one element (like pointer arithmetic).
for (int i = 0; i < n; i++) { ... } // postfix, value discarded
int a = i++; // a = old i, then i incremented
int b = ++j; // j incremented, then b = new j
The operand must be a modifiable lvalue (a const or a non-lvalue like 5++ is an error).
5.6 Address-of and Dereference
| Operator | Description |
|---|---|
&x |
Address of x; yields *T where x: T |
*ptr |
Dereference ptr; yields T where ptr: *T |
&x returns the alloca pointer for the stack variable x.
int val = 42;
*int ptr = &val;
*ptr = 100; // val is now 100
5.7 Pointer Arithmetic
Adding or subtracting an integer n from a pointer of type *T advances the pointer by n * sizeof(T) bytes (typed GEP). This matches C semantics: p + 1 moves to the next element, not the next byte.
*int pi = alloc<int>(8);
*int p2 = pi + 1; // 4 bytes forward, points to element 1
The exceptions are *void and *char, which always use byte-level stride (1 byte per step) to preserve C interop semantics:
*uint8 buf = alloc<uint8>(1024);
*uint8 mid = buf + 512; // 512 bytes into the buffer
*uint8 back = mid - 512; // back to start
Subtracting two pointers of the same type gives an int64 count of elements between them, as in C: (pi + 3) - pi is 3 for a *int, not 12.
5.8 sizeof Expression
sizeof(T) is a compile-time constant expression that evaluates to the size of type T in bytes as an int64. It works for all Eskiu types, including structs and unions.
sizeof(int) // 4
sizeof(int64) // 8
sizeof(float) // 4
sizeof(double) // 8
sizeof(Grid) // 12 (3 float fields)
sizeof is resolved entirely at compile time and produces no runtime code. As in C, sizeof(x) where x names a variable (or a parameter or global) gives the size of that variable's type; a name that is neither a type nor a variable is an error.
The operand may also be an expression: sizeof(*p), sizeof(a[0]), sizeof(s.field) give the size of the expression's type, and the expression is not evaluated (sizeof(f()) does not call f). The operand is read as a type when it is a bare name or a spelling built over a primitive or an already declared type (sizeof(*Node), sizeof(int[4])); otherwise it is an expression.
The type checker folds sizeof of a scalar, a pointer, a closure, an interface, a slice, a fixed array, a sum type, a generic instance, and a struct or union of those (bitfields and #pragma pack(N) included), using the target's layout (--target), so the constant checks below (a zero divisor, an array index out of bounds, a duplicate case) and enum member values see it. A union declared under #pragma pack(N) folds too.
5.9 Conditional (ternary)
cond ? a : b evaluates cond, then evaluates and yields exactly one of the two
arms (so side effects in the unused arm never run). The condition may be a bool,
integer, or pointer (non-zero / non-null is true). The two arms must share a common
type: identical types pass through, two numerics promote to the wider (C-style, e.g.
int and double yield double), and otherwise the arms must be mutually assignable.
When the value goes to an interface (a declaration, assignment, return or argument of
that type), each arm converts to it on its own, so the arms may point at different
structs that satisfy it: Shape s = round ? &c : &sq;.
The operator is right-associative, so a ? b : c ? d : e parses as a ? b : (c ? d : e).
int m = a > b ? a : b; // max
char g = s >= 90 ? 'A' : s >= 80 ? 'B' : 'C'; // right-associative chain
double d = flag ? 1 : 2.5; // arms promote to double
Eskiu also uses ? as the postfix Result-propagation operator (§10.5). The two are
disambiguated by the following :: a ? with a matching : at the same bracket
nesting is a ternary, otherwise it is propagation. To propagate inside a ternary arm,
parenthesize it: cond ? (may_fail()?) : fallback.
5.10 Operator Precedence
Listed from lowest precedence (loosest binding) to highest (tightest binding):
| Level | Operators | Associativity |
|---|---|---|
| 1 | = += -= *= /= %= &= \|= ^= <<= >>= |
Right to left |
| 2 | ?: (ternary) |
Right to left |
| 3 | \|\| |
Left to right |
| 4 | && |
Left to right |
| 5 | \| (bitwise) |
Left to right |
| 6 | ^ |
Left to right |
| 7 | & (bitwise) |
Left to right |
| 8 | == != |
Left to right |
| 9 | < > <= >= |
Left to right |
| 10 | << >> |
Left to right |
| 11 | + - |
Left to right |
| 12 | * / % |
Left to right |
| 13 | Unary ! - + ~ & * (TYPE) |
Right to left |
| 14 | () [] . ? (postfix) |
Left to right |
Use parentheses to override precedence explicitly.
6. Functions
6.1 Regular Functions
int add(int a, int b) {
return a + b;
}
int get_magic() {
return 42;
}
Parameters are passed by value. The return type is declared before the function name. This is the only function-definition form; Eskiu has no fn name(...) definition syntax (the fn keyword names a function-pointer type, see §3.7).
Declaration order is irrelevant. A function may call any other function regardless of where it appears in the file, so call-before-definition and mutual recursion both work without ceremony. A body-less forward declaration is also permitted (and optional):
int is_odd(int n); // forward declaration
int is_even(int n) {
if (n == 0) { return 1; }
return is_odd(n - 1); // defined below, fine
}
int is_odd(int n) {
if (n == 0) { return 0; }
return is_even(n - 1);
}
A non-void function must return a value on every path. Letting control fall
off the end of the body is a compile error (missing return in non-void
function). There is no implicit zero return, and the last expression in the body
is not treated as the result. A function whose body provably cannot fall through
satisfies the rule without a trailing return: for example one that ends in an
if/else where both branches return, an exhaustive switch/match where every
arm returns, or an infinite while (1) loop with no break.
int classify(int x) {
if (x < 0) { return 0; }
else { return 1; }
} // ok: every path returns
int bad(int x) {
if (x < 0) { return 0; }
} // error: missing return (x >= 0 falls through)
6.1.1 must_use
Prefixing a function with must_use makes discarding its result a compile error. It
catches the "called for a value, then dropped it" bug, most importantly a leaked
allocation. The standard library marks alloc this way:
must_use *T dup<T>(T* p) { ... }
dup(&x); // error: result of 'dup' must be used (it is marked must_use)
*int y = dup(&x); // ok (x is an int)
alloc<uint8>(64); // error: the allocation is leaked (alloc is must_use)
A call is "used" if its result is assigned, passed as an argument, returned, or otherwise consumed; only a bare call statement discards it.
6.2 Void Functions
void log_event(string msg) {
printf("%s\n", msg);
}
A void function may use return; with no operand or allow control to fall off the end of the body. It may also return f(); where f is itself void (the call runs, nothing is returned).
A call to a void function has no value: it can be a statement, the operand of return in a void function, or both arms of a ?: used as a statement (c ? f() : g();), but not an operand of an operator (&&, ||, a comparison, arithmetic), a condition, an initializer, or an argument (also not one passed through ..., as in printf("%d", f())).
The one function that may not be void is main: its return value is the process exit
code, so it must return int (int main() or int main(int argc, string* argv)). A
void main() is a compile error.
6.3 Variadic Functions
The ellipsis ... marks a variadic parameter list; at least one fixed parameter
must precede it. It is valid in both extern declarations and user functions:
extern int printf(string fmt, ...);
A user-defined variadic function reads its extra arguments with va_list and the
builtins va_start(ap), va_arg<T>(ap) (one argument of type T), and
va_end(ap):
int sum(int n, ...) {
va_list ap;
va_start(ap);
int total = 0;
for (i in 0..n) { total = total + va_arg<int>(ap); }
va_end(ap);
return total;
}
The C default argument promotions apply to variadic arguments: a float is passed
as double (read it with va_arg<double>), and integer types narrower than int
arrive as int. There is no automatic count of the arguments: pass it explicitly
(as n above) or use a sentinel.
va_start is only allowed in a function with a ... parameter. Each of the three
builtins takes exactly one va_list operand (a va_list may also be passed to another
function, which then reads it with va_arg). T in va_arg<T> must be a scalar: an
integer, a floating-point type or a pointer; a struct, union, sum type or array is a
compile error. So is a type the promotions widen (va_arg<float>, va_arg<char>,
va_arg<int8>, ...): read the promoted double or int and convert it.
6.4 Extern Declarations
extern declares a C function or global variable available to Eskiu code. See §13 for details.
6.4.1 Intrinsic Declarations
intrinsic declares a function whose implementation the compiler supplies directly, rather than one defined in Eskiu or linked from C. The form is a prototype with no body:
intrinsic int atomic_load(*int cell);
intrinsic void atomic_store(*int cell, int v);
A call to an intrinsic lowers to a specific instruction sequence chosen by codegen (the <atomic> intrinsics lower to LLVM atomic loads/stores/cmpxchg with fixed acquire/release ordering) instead of an ordinary call. Intrinsics are how the standard library exposes operations that have no portable C spelling; application code rarely declares its own.
6.5 Lambdas and Closures
An anonymous function (lambda) is written with a C-style function body and no name. The syntax is identical to a named function declaration without the name:
int(int x) { return x * 2; }
The type of a lambda is the corresponding function pointer type fn(T,...)->R. Lambdas are assigned to variables, passed as arguments, or used anywhere a function pointer value is expected.
// Assign to a variable
let double_it: fn(int)->int = int(int x) { return x * 2; };
int result = double_it(5); // result == 10
// Pass as an argument
int apply(fn(int)->int f, int x) {
return f(x);
}
int out = apply(double_it, 4); // out == 8
// Inline (pass directly)
int out2 = apply(int(int x) { return x + 1; }, 9); // out2 == 10
Closure capture. A lambda may reference variables from its enclosing scope. Such variables are captured by value at the point the lambda expression is evaluated.
int base = 10;
let add: fn(int)->int = int(int x) { return x + base; };
add(5); // 15: 'base' was captured by value
The closure holds its own copy of each captured variable, taken when the lambda expression is evaluated: a later change to base in the enclosing function is not seen by add. For the same reason a lambda may not assign to a captured variable (base = 1;, base += 1; or base++; inside the body is a compile error, "cannot assign to captured variable"; so is a write to a field or element of a captured struct or array value, p.a = 5; or arr[0] = 9;), since the write would change only the copy and be lost. Taking the address of a captured variable's storage (&base, &p.a, &arr[0]) or slicing a captured array (arr[0..2]) is an error for the same reason ("cannot take the address of captured variable"). To share state with the enclosing code, write through a pointer to it (*p = v, ptr.a = v), or use a global or a static local: those are not captured, the lambda reads and writes the one variable. The lambda's own parameters and locals are ordinary variables it may assign. A method called on a captured struct value (p.set(5), where set takes *P self) is allowed, but it operates on the closure's copy: the change is seen by later calls of the same closure and never by the enclosing function's variable. Capture a pointer (*P pp = &p; outside the lambda, pp.set(5) inside) to modify the original.
Under the hood, fn(T)->R is a two-word fat pointer {fn_ptr, env_ptr}. When a lambda captures one or more variables, the compiler packages them into an environment struct and stores its address in env_ptr. Lambdas that capture nothing have env_ptr = null and compile identically to plain function pointers. The representation is fully transparent to user code. The type annotation remains fn(T)->R in both cases.
Escape analysis and closure lifetime. Where the environment lives depends on whether the closure escapes its creating function:
- A non-escaping closure (one that is only called, or passed to a parameter that is not marked
escaping) has its environment allocated on the stack. This costs nothing and needs no cleanup (the commonmap/filter/callback-invoked-in-place case). - An escaping closure (one that is returned, stored into a struct field / global / through a pointer, or passed to an
escapingparameter) has its environment allocated on the heap, so it remains valid after the creating function returns. Release it withfree_closure(f)(a no-op for non-capturing closures, whose env is null;fmust be a closure value).
A parameter that retains the closure beyond the call (stores it, returns it, hands it to another escaping parameter) must be declared escaping:
// stores cb beyond the call -> the parameter is `escaping`
void on_ready(int fd, escaping fn(int)->void cb) { handlers[fd] = cb; }
// only calls f -> no annotation; closures passed here stay on the stack
int apply(fn(int)->int f, int x) { return f(x); }
Passing a non-escaping closure parameter straight on to another function's non-escaping parameter is also fine, since that callee can only call it too. A lambda that captures a non-escaping parameter escapes it with the lambda unless the lambda itself cannot outlive the call: it is passed straight to a non-escaping parameter, or bound to a local that is only called. Returning such a lambda, storing it, passing it to an escaping parameter or to thread_create needs the parameter marked escaping.
This is checked: using a non-escaping closure parameter beyond a direct call is a compile error pointing you at escaping, so a closure can never silently outlive its stack environment. escaping and free_closure are reserved words (§2.3).
Named functions as values. A top-level function used as a value (rather than called) decays to a fn(T,...)->R, so it can be assigned or passed directly, no lambda wrapper needed:
void worker() { /* ... */ }
int inc(int x) { return x + 1; }
*void t = thread_create(worker); // pass the function itself
int r = apply(inc, 41); // r == 42
The compiler synthesizes a tiny adapter so the function fits the {fn_ptr, env_ptr} calling convention (the function does not take an environment); this is transparent to your code.
6.6 Template Functions
Template functions are parameterized by one or more type variables declared in angle brackets after the function name:
T identity<T>(T x) {
return x;
}
T max<T>(T a, T b) {
if (a > b) return a;
return b;
}
Called with explicit type arguments:
int result = max<int>(3, 5);
float fmax = max<float>(1.5, 2.5);
int same = identity<int>(42);
The type arguments may also be inferred from the argument types. Inference works both when a type parameter appears directly as a parameter type and when it appears inside a composite parameter type, so type arguments can be omitted in either case:
int r = max(3, 5); // T inferred as int (direct parameter)
int i = identity(42); // T inferred as int
let nums: List<int>;
List_init(&nums, 4); // T inferred as int from the List<int>* argument
List_push(&nums, 7);
int first = List_get(&nums, 0);
Inference unifies each parameter type against the concrete argument type
structurally (peeling pointers, matching template instances and slice element types),
so a List<T>* parameter binds T from a List<int>* argument and a T[] parameter
binds T from an int[] slice (sum(a[0..4])). If a type parameter cannot be
inferred from any argument, pass the type arguments explicitly. Each unique set
of type arguments generates a separate monomorphic instantiation.
A binding from a composite parameter type (*T, List<T>*) takes precedence. When a
type parameter is bound only by parameters spelled as the bare type parameter (T a, T
b), every such argument must deduce the same type. Integer deductions that differ meet
at their common type by the usual arithmetic conversions (section 3.1), so
max(1, big) with int64 big is max<int64> and does not truncate; any other
disagreement (max(1.5, (float)2.5), pick(1, "s")) is a type error. Once the type
arguments are known, every argument is checked against the instantiated parameter type,
as for any call (swap(&i64, &i32) against *T is an error).
6.7 Thread Primitives
thread_create and thread_join are language keywords that spawn and await OS threads.
thread_create(fn()->void worker) -> *void
thread_join(*void handle) -> void
thread_create accepts any fn()->void value, including a closure, and returns an opaque *void thread handle. thread_join blocks the calling thread until the spawned thread completes. Any other worker type, or a thread_join operand that is not a *void handle, is a compile error.
extern int printf(string fmt, ...);
int main() {
*void t = thread_create(void() { printf("hello from thread\n"); });
thread_join(t);
return 0;
}
With a capturing closure:
int id = 1;
let worker: fn()->void = void() { printf("thread %d\n", id); };
*void t = thread_create(worker);
thread_join(t);
Ownership. A lambda written in the call (thread_create(void() { ... })) belongs to the new thread: its environment is freed when the thread body returns. A closure value passed in (thread_create(worker)) stays its owner's, since one closure may start several threads; release it with free_closure(worker) after the last thread_join.
Implementation detail. For a closure value, the fat pointer {fn_ptr, env_ptr} maps directly to the (start_routine, arg) pair expected by pthread_create. A lambda written in the call starts through a small trampoline that runs it and then frees its environment. When a program calls thread_create, the driver links -lpthread on Linux and Windows (mingw) by itself; macOS has pthread in libSystem.
6.8 Async Functions and await
An async function lowers to a resumable state machine and executes over the
<eventloop>/<executor> runtime. Single and multiple awaits, async void, and every
control-flow construct containing an await (if/while/do-while/C-style
for/switch/match/for-in, with break/continue, and try/catch) are
supported; a pending future is cancelled with future_drop.
An await may appear anywhere in an expression: a let initializer, a return value,
an assignment or compound assignment (x += 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 n())). Evaluation keeps the
order of the synchronous expression: an operand with a side effect written before the
await is evaluated before it, an assignment target (a[i()] += await f()) is evaluated
once, before the await, and an await in the right operand of &&/|| or in an arm of
?: runs only when that operand is evaluated. A switch subject, a for-in iterable and
a range bound are evaluated once, before the statement; a loop condition or step is
evaluated on every pass.
Inside try an await may sit in the body and in a catch handler. An exception thrown
before or after a suspension is caught by the handler of the try it is thrown in, and
the finally runs exactly once on every exit: normal completion, a caught or uncaught
exception, an early return, break or continue, and cancellation. A future dropped
while suspended runs the finally blocks and defers pending at its await once,
innermost first, before its frame is freed (a defer in a block split by an await runs
at every exit of that block, as in a plain function).
Rejected with a located error: an await inside a finally or a defer body (both run
when a cancelled future is dropped, which cannot suspend), labeled break/continue,
an await in a sizeof operand, an asm input or a thread_join, and in a generic
async function an await in a match arm that binds a payload, or an await after an
operand with a side effect or inside a ?: arm (their types differ per instance). Bind
the value first with let v = await ...;.
An async function is declared with the async modifier before the return type. Its
declared return type is the value it ultimately produces, but a call to it
yields a *Future<T>, a handle to the eventual result, rather than T directly:
import <future>;
import <net_async>;
async int read_len(EventLoop* lp, int fd, *uint8 buf) {
int n = await net_read_async(lp, fd, buf, 4096); // suspend until readable
return n;
}
// read_len(lp, fd, buf) has type *Future<int>
await E suspends the enclosing async function until the future E completes, then
evaluates to its result. E must have type *Future<T>, and await E has type T.
await is legal only inside an async function; at the top level you drive a
future explicitly (there is no top-level await). The future being awaited may come
from a leaf primitive (<net_async>) or from calling another async function.
Future<T>, the executor, and the leaf futures live in the standard library
(<future>, <executor>, <net_async>); the generic combinators spawn<T> (detach
a fire-and-forget task), select2<A,B> (first-of-two) and join2<A,B> (all-of-two)
take typed futures with no cast at the call site, <timer> timer_after(lp, ms) is a
leaf future for deadlines (so select2(read, timer_after(...)) is a real timeout; dropping
a combinator before it resolves drops its inputs with it), and
<http_async> is a non-blocking HTTP server built on the accept loop. See
docs/dev/async-design.md for the runtime contract and the lowering design.
6.9 Exception Handling
Eskiu supports structured exception handling via try, catch, finally, and throw.
Syntax
try {
// body: any function calls here are emitted as LLVM invoke
} catch (TYPE name) {
// handler: receives the thrown value as 'name'
} finally {
// cleanup: always executes
}
Multiple catch clauses may be chained. The finally clause is optional. Either catch or finally (or both) must follow try.
throw
throw expr throws the value of expr as an exception. Any Eskiu value type may be thrown: string, an integer of any width, float, double, a pointer, or a struct by value. The value is copied into the exception object with its own type, so a catch of the same type receives it unchanged. Inside a generic function the thrown type is the instance's type (throw x with x: T in f<double> is caught by catch (double d)).
int divide(int a, int b) {
if (b == 0) {
throw "division by zero";
}
return a / b;
}
Catching exceptions
Each catch clause names a type and a variable. If the thrown value's static type is the declared type (after aliases are resolved), control transfers to that clause and the variable holds the thrown value. There is no conversion: a thrown int is not caught by catch (int64 e).
try {
int r = divide(10, 0);
} catch (string e) {
printf("caught: %s\n", e);
}
finally
The finally block executes unconditionally after the try body and any catch clause, regardless of whether an exception was raised. It also runs when the body or a catch handler leaves early with return, break, continue or ?, and when a catch handler throws (directly or from a call); the new exception then propagates after the finally body.
A finally block may not leave the function: a return or a ? inside it is a compile error, since it would discard the pending exit (a return value, or an exception being unwound). A break or continue inside it is allowed, and a lambda written there is its own function.
try {
throw "error";
} catch (string e) {
printf("caught: %s\n", e);
} finally {
printf("cleanup\n");
}
Unhandled exceptions
If no catch clause matches the thrown value, the exception propagates up the call stack. If it reaches the top with no handler, the program terminates.
Linking
Exceptions use the platform C++ runtime. When a program contains throw or try, the driver links that runtime by itself: -lc++ on macOS and -lstdc++ on Linux and Windows (mingw). A bare-metal target gets nothing, and --no-default-libs turns this off (see the Linking paragraph of §16).
eskiuc file.esk -o file # no -l flag needed
7. Control Flow
7.1 if / else if / else
if (x > 0) {
printf("positive\n");
} else if (x < 0) {
printf("negative\n");
} else {
printf("zero\n");
}
The condition must evaluate to a bool, a number (non-zero is true; a float compares with 0.0, so NaN is true) or a pointer, string and ?*T included (non-null is true). An interface value is true when it is not null. A struct, array, slice or closure is not a condition. Braces around a branch body are optional: as in C, an unbraced body is a single statement (if (x > 0) n++; else n--;), and it is its own scope (see §7.8 for what that means for defer).
7.2 for
C-style three-part form:
for (int i = 0; i < 10; i += 1) {
printf("%d\n", i);
}
The init clause may declare a variable scoped to the loop. Each part is optional:
for ( ; running; ) {
// condition only
}
7.2.1 for ... in (for-each)
The for (x in iterable) form binds x to each element of iterable in turn.
The loop variable is a fresh copy each iteration; assigning to it does not modify
the underlying collection. break and continue work as in any loop.
Four kinds of iterable are supported:
- Half-open integer ranges
A..B: iterateA, A+1, …, B-1(the upper bound is exclusive).AandBare any integer expressions:
eskiu
for (i in 0..10) { // 0,1,…,9
printf("%d\n", i);
}
A range desugars to for (T i = A; i < B; i = i + 1), so an empty range
(A >= B) runs zero times. B is evaluated once, before the first iteration:
for (i in 0..n()) calls n() once, and changing a variable used in B inside
the body does not change the number of iterations.
Both bounds are read in the enclosing scope, before the loop variable exists. A
bound that names the loop variable means the outer variable of that name, as in C's
for (int i2 = a; i2 < b; ...): with an outer int i = 3, for (i in 0..i) runs
0, 1, 2. When B names the loop variable it is evaluated before A; otherwise
A is evaluated first.
The loop variable's type T is the common type of the two bounds under C's usual
arithmetic conversions: each bound is promoted to at least int, the wider one
wins, and at equal width an unsigned bound makes T unsigned. So 0..n with
n: int64 counts in int64, and two uint8 bounds count in int. A bare
integer literal bound takes the first of int, int64, uint64 that holds its
value. A bound that is not an integer (a float, a pointer) is a compile error.
- Fixed-size arrays (
T[N]), including array fields. Over a multidimensional arrayT[N][M]the loop variable is each row, aT[M]:
eskiu
int[4] xs;
xs[0] = 10; xs[1] = 20; xs[2] = 30; xs[3] = 40;
for (v in xs) {
printf("%d\n", v);
}
- Slices (
T[]): a fat pointer that carries its length, iterated element by element through its data pointer:
eskiu
int[4] xs;
xs[0] = 10; xs[1] = 20; xs[2] = 30; xs[3] = 40;
int[] s = xs[1..3]; // a view of xs[1], xs[2]
for (v in s) {
printf("%d\n", v); // 20, 30
}
- List-like structs: any struct with an
int sizefield and adatapointer field, which includesList<T>from the standard library:
eskiu
import <list>;
let nums: List<int>;
List_init(&nums, 4);
List_push(&nums, 1);
List_push(&nums, 2);
for (n in nums) {
printf("%d\n", n);
}
The form desugars to an index-counted loop: for an array the bound is its
compile-time length; for a slice the bound is its .len; for a List-like value
the bound is its size field, and each element is read through data[i].
The iterable is evaluated once, before the first iteration. A variable, or a field or constant-index element of one, is read in place, so the loop sees writes the body makes to it. Anything else (a call, an index by a variable) is held in a temporary: a List-like value that has an address by a pointer to it, an array, slice or pointer by value (an array is then copied).
7.3 while
while (condition) {
// body
}
The body executes repeatedly while condition is true.
7.3.1 do / while
do {
// body
} while (condition);
Like while, but the condition is tested after the body, so the body always runs at
least once. break and continue work as in the other loops (continue re-tests the
condition).
7.3.2 Labeled break / continue
A plain break or continue acts on the innermost enclosing loop. To act on an outer
loop from inside a nested one, give the outer loop a label and name it:
outer: for (int i = 0; i < rows; i = i + 1) {
for (int j = 0; j < cols; j = j + 1) {
if (grid[i][j] == target) {
found = 1;
break outer; // leave both loops
}
}
}
A label is an identifier followed by : directly before a while, do/while, for,
or for ... in loop. break label leaves that loop; continue label skips to its next
iteration (for a for, its step runs first, as with an unlabeled continue). The label
must name an enclosing loop, otherwise the program is rejected at compile time.
Labeled break and continue run the same defer/errdefer cleanups as the unlabeled
forms: every deferred statement between the jump and the target loop runs, innermost
first, before control leaves. A labeled jump may not escape a defer body, and labeled
break/continue is not supported inside an async fn.
7.4 switch / case / default / break
switch (x) {
case 1:
printf("one\n");
break;
case 2:
printf("two\n");
break;
default:
printf("other\n");
break;
}
switch dispatches on an integer value. break exits the enclosing switch. If break is omitted, control falls through to the next case. The type checker validates that each case value is compatible with the type of the switch subject expression.
A declaration may follow a case or default label directly, without braces. As in C, the whole switch body is one scope: the variable is visible from its declaration to the end of the switch, including the cases after it, and two cases may not declare the same name. A jump to a later case skips the initialization, so the variable is uninitialized there until assigned (this is not diagnosed).
7.5 return
Returns a value from the current function. A void function uses return; with no operand, or return f(); with a void call.
int sign(int x) {
if (x > 0) return 1;
if (x < 0) return -1;
return 0;
}
7.6 break
Exits the innermost enclosing for, while, or switch immediately.
while (true) {
if (done) break;
}
7.7 continue
Skips the remainder of the current loop iteration and proceeds to the next:
for (int i = 0; i < 10; i += 1) {
if (i % 2 == 0) continue;
printf("%d\n", i); // prints odd numbers only
}
7.8 defer
defer stmt; schedules stmt (a single statement or a { ... } block) to run when the
enclosing block is left. It is the ergonomic way to pair a resource acquisition with its
release: write the cleanup right next to the thing it cleans up, and it runs on every
path out of the block, so you never leak on an early return or a propagated error.
Result<int, string> read_into(*uint8 buf, string path);
Result<int, string> process(string path) {
*uint8 buf = alloc<uint8>(4096);
defer free(buf); // runs however this function exits
if (path[0] == 0) { return Err<int, string>("empty path"); } // buf freed
int n = read_into(buf, path)?; // buf freed if the `?` propagates an error
return Ok<int, string>(n); // buf freed
}
? needs the enclosing function to return a Result (§10.5), so the example returns
Result<int, string>.
Rules:
- Deferred statements run in LIFO order: the last one registered runs first.
deferruns on fall-through,return,break,continue, and?-propagation. It is block-scoped, so adeferin a loop body runs at the end of each iteration. An unbraced body (if (c) defer f();, a single-statement loop body, aswitchcase, amatcharm) is its own scope too: the defer runs when that body ends, and only if it was reached.- The deferred statement is evaluated when the block is left (it reads variables' values
at exit time), after any
returnvalue has been computed. - A defer body may not
return, orbreak/continueout of itself (that would jump out of the cleanup); doing so is a compile error.
defer also runs when an exception unwinds out of the block: a defer in a try body
runs before that try's catch handles the exception, and a defer in a function the
exception propagates through runs when an outer try catches it. (An exception no try
catches ends the program without running cleanups, as in C++.) An errdefer does not run
on an exception, only on ?-propagation.
defer complements try/finally (§6.9): finally is for catch-and-cleanup around a
block, while defer colocates a one-off release with its acquisition.
errdefer is a variant that runs its statement only on the error exit path,
namely when the function leaves through ?-propagation (an Err is returned early). It
does not run on a normal return, on fall-through, or on break/continue. This is the
tool for undoing partial work when a fallible step fails partway through, while keeping it
on success:
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 and the Err propagates
return Ok<int, string>(fd); // success: fd stays open, errdefer does not run
}
On the error path both kinds run in LIFO order (an errdefer registered after a defer
runs first). All the defer-body restrictions above apply to errdefer as well.
8. Structs
8.1 Declaration with Fields
struct Point {
float x;
float y;
}
struct Rect {
float x;
float y;
float width;
float height;
}
Field types may be any primitive type, pointer type, another struct type, or a fixed-size array type.
An integer field may declare a bit width with : N, making it a bitfield. Bitfields are laid out like C on the target: on SysV/AAPCS targets a bitfield takes the next free bits unless it would cross a boundary of an aligned storage unit of its declared type (so uint8 a : 4; uint32 w : 12; is 4 bytes), and in a packed struct bitfields pack back to back; on Windows targets consecutive bitfields share a storage word only while the declared type size stays the same. Reads mask and shift out the field (signed fields sign-extend); a bitfield whose values all fit an int (fewer than 32 bits, or at most 32 for a signed one) is read as an int, as C promotes it, so u - 1 of a uint32 u : 3 holding 0 is -1 (a postfix f++ keeps the declared type, as in clang). Writes (including compound assignment and ++/--, which wrap within the field's width) are read-modify-write. You cannot take the address of a bitfield. A bool bitfield uses a one-byte storage unit, as in C. An enum bitfield reads back unsigned when the enum has no negative member (SysV/AAPCS, as clang and GCC do; the Windows layout keeps it signed); a sum type is not a bitfield type. A named bitfield may not have zero width (int x : 0 is an error; C allows zero width only for an unnamed bitfield, which Eskiu does not have).
struct Flags {
uint32 ready : 1;
uint32 mode : 3;
uint32 weight : 4;
}
let f: Flags = Flags { ready: 1, mode: 5, weight: 9 };
f.mode = 2; // leaves ready and weight untouched
8.2 Methods with Implicit self
Methods are declared inside the struct body. Inside a method body, self refers to a pointer to the receiver struct. Because self is that implicit parameter, an inline method may not declare a parameter named self.
struct Counter {
int count;
void increment() {
self.count += 1;
}
int get() {
return self.count;
}
}
Methods are lowered to regular functions with a leading pointer parameter, e.g., Counter_increment(*Counter self). A method may also be written at top level in that lowered form, int Counter_get(*Counter self) { ... }, and it is called the same way (c.get()). To call a method on a const value, write the receiver as const T* self (see §4.6).
Dot syntax also reaches a generic free function on an instance of a generic struct. When x has type S<A..> (or *S<A..>) and there is a generic function S_m<T..> whose first parameter is the struct (S<T..>*, or S<T..> by value), then x.m(args) is the call S_m<A..>(&x, args), or S_m<A..>(x, args) when x is already a pointer. The type arguments come from the receiver: a type parameter named in the first parameter's type takes the receiver's argument at that position, and any other one is inferred from the call's arguments. The arguments are checked against the instantiated parameter types, as for any call. This is how the standard library's generic containers read:
import <list>;
List<int> l; l.init(4); // List_init<int>(&l, 4)
l.push(8); // List_push<int>(&l, 8)
int n = l.len(); // List_len<int>(&l)
int first = l.get(0); // List_get<int>(&l, 0)
l.free();
It works the same way inside a generic body, where the receiver's type arguments are the enclosing instance's (void Box_twice<T>(Box<T>* self, T x) { self.set(x); }). An inline method declared in the struct body takes precedence over a free function of the same name.
8.2a Operator Overloading
A type can give meaning to an operator by declaring operator <op>, so a + b reads as algebra instead of a nested call. This is aimed at value types like vectors and matrices, where the notation carries the meaning.
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 operator -(V3 a) { let r: V3; r.x = 0.0 - a.x; r.y = 0.0 - a.y; r.z = 0.0 - a.z; return r; }
V3 p = (q + t) * 2.0; // operator + and then operator *(V3, double)
V3 n = -q; // the unary operator -
Overloadable: the binary operators + - * / % == != < > <= >= & | ^ << >>, the unary operators - ! ~, and subscript []. A binary operator and [] take exactly two parameters, ! and ~ exactly one, and - one (negation) or two (subtraction); any other count is a compile error. Compound assignment (v += w) is defined as v = v + w, using the overloaded +. The short-circuit operators && / ||, the pointer operators * / &, and = / . are structural and cannot be overloaded.
Resolution is entirely static and structural:
- A type gets an operator simply by declaring one for it. There is no trait or
implblock to register; if anoperatorexists for the operand types, the operator resolves to it, otherwise the operands must be built-in. - Overloads coexist by operand type:
operator *(V3, V3)(component-wise) andoperator *(V3, double)(scale) are distinct, selected by the right operand. - An
operatordeclaration compiles to a normal function, anda op blowers to a direct call to it. There is no dynamic dispatch and no boxing; the cost is exactly that of writing the call by hand, so it is suitable for hot numeric code.
8.3 Struct Initialization
Named initialization:
Point p = Point { x: 1.5, y: 2.5 };
Positional initialization:
Point p = Point { 1.5, 2.5 };
Fields are assigned in declaration order in the positional form. A field the literal leaves out is zero-filled (as in C), in either form. Each value is checked against its field's type (an integer literal must fit the field), and naming a field twice, naming a field the struct does not have, or giving more positional values than there are fields is an error.
struct P { int x; int y; double z; }
P a = P { y: 5 }; // x = 0, y = 5, z = 0.0
P b = P { 1 }; // x = 1, y = 0, z = 0.0
8.4 Field Access and Mutation
let p: Point;
p.x = 1.0;
p.y = 2.0;
float sum = p.x + p.y;
Field assignment and load both use the . operator. Method calls use the same syntax:
counter.increment();
int val = counter.get();
8.5 Fixed-size Array Fields
struct QRFrame {
uint8[858] left;
uint8[858] right;
int size;
}
uint8[858] lowers to [858 x i8] in LLVM IR.
8.6 Union Types
A union declaration is identical in syntax to struct, but all fields share offset 0. The size of the union equals the size of its largest field, rounded up to the alignment of its most-aligned field, and the union is aligned like that field (the C layout). Accessing a field reinterprets the underlying bytes as the field's type. No explicit cast is needed.
union Value {
int i;
float f;
*uint8 p;
}
let v: Value;
v.i = 42;
printf("%f\n", v.f); // reinterprets the 4 bytes of v.i as a float
A union variable is declared exactly like a struct variable:
let u: Value;
u.i = 0x3F800000; // bit pattern for 1.0f
printf("%f\n", u.f); // prints 1.0
sizeof(Value) returns the size of the largest field: sizeof(*uint8) = 8 on a 64-bit target in this example.
A union literal initializes exactly one member, named or positional (Value{f: 1.5}, Value{7} sets i); the remaining bytes are zero. Naming two members is an error. A union literal is a constant initializer for a global or static when its member value is constant.
8.7 Enums
An enum declares a set of named integer constants. Members take consecutive values starting at 0; an explicit = N resets the running value, and the next member continues from there. N is an integer constant expression, as in C: literals, the members before it (of this or another enum), top-level const ints declared earlier, sizeof (§5.8), casts, the integer operators, !, &&, || and ?: (enum Flag { A = 1, B = A << 2, C } gives C == 5; D = K > 2 ? 4 : 1). The expression is evaluated with C's typed integer rules (§3.1: the usual arithmetic conversions, 32-bit wraparound, unsigned comparison and division, the arithmetic right shift of a signed value, sizeof as an unsigned size), as are array dimensions and case labels: ((uint)3 - (uint)5) / 2 > 100 is 1 and (1 << 31) >> 31 is -1. A value that is not constant is a compile error, and every value must fit an int. The enum type itself is an int (i32), so enum values work in arithmetic, comparisons, and switch.
enum Color { Red, Green, Blue } // 0, 1, 2
enum Status { Ok = 0, Err = 2, Pending } // 0, 2, 3
let c: Color = Green; // c == 1
if (c == Red) { /* ... */ }
Members are unscoped. Red is used directly, as in C. The enum name may be used anywhere a type is expected (it behaves as int).
A classic enum may also be consumed with match, which checks the dispatch is exhaustive (every variant covered, or a _ default), so adding a variant turns every unhandled match into a compile error. This is the same guarantee algebraic enums get; switch stays available for a non-exhaustive dispatch. Two members may share a value, as in C (enum E { A = 1, B = 1 }); since match dispatches on the value, one arm covers every member with that value, and two arms for equal values are an error.
int dx(Color c) {
match c {
Red -> { return -1; }
Green -> { return 0; }
Blue -> { return 1; }
}
return 0;
}
8.7.1 Algebraic enums (tagged unions)
When one or more variants carry a payload, the enum becomes an algebraic data type: a tagged union, not an integer. Each variant is constructed by name (with arguments for its payload; a payload-free variant is written bare, and Unit() is an error), and a value is destructured with match:
enum Shape {
Circle(float),
Rect(float, float),
Unit, // a payload-free variant
}
float area(Shape s) {
match s {
Circle(r) -> return 3.14 * r * r;
Rect(w, h) -> return w * h; // payload fields bind to w, h
_ -> return 0.0; // `_` matches any remaining variant
}
}
Shape a = Circle(2.0); // construct; payload-free variants are bare (`Unit`)
Algebraic enums may be generic and are monomorphized per instantiation, like
template structs. Where an instance of the variant's enum is expected (a declaration,
an assignment, a return, an argument of a function or of a generic function called
with explicit type arguments, a struct-literal field, an array-literal element, or the
payload of another variant), the variant takes that instance's type arguments, and its
payload is checked against them: Option<int64> a = Some(42) builds an
Option<int64>, Option<Option<int64>> b = Some(Some(5)) an Option<int64> inside,
and a bare None or an under-determining Left(7) is accepted there. Elsewhere the type
arguments are inferred from the payload arguments when they determine them (Some(42)
→ Option<int>); otherwise (a payload-free variant like None, or one that
under-determines the type like Either's Left) write them explicitly (None<int>(),
Left<int, string>(7)). Inside a generic function only a type as written is used (a
declared type, the declared return type): return None; in an Option<T> function is
None<T>(); an assignment or an argument there needs the type arguments written:
enum Option<T> { None, Some(T) }
enum Either<A, B> { Left(A), Right(B) }
Option<int64> x = Some(42);
Option<int> y = None;
Either<int, string> e = Left(7);
match x { Some(v) -> printf("%lld\n", v); None -> printf("none\n"); }
A match must be exhaustive: every variant must have an arm, or there must be
a _ default. Otherwise it is a compile error naming the missing variants. A
variant may not appear in two arms, and a match has at most one _ default. The arm body is any statement (often a block
or a return). The value is
laid out as { tag, payload }, where the payload area is sized to the largest
variant. (A match subject is parsed without a trailing struct literal, like the
condition of an if; wrap it in parens if you need one.)
8.8 Type Aliases
type Name = ExistingType; introduces a name for an existing type. The alias is fully interchangeable with its underlying type. It resolves before type checking and code generation. Aliases work for any type, including pointers, arrays, slices, fn types, interfaces, enums and templates: a value of an alias type derefs, indexes, calls, boxes, matches and dispatches methods and operators exactly as a value of the target type. An alias of an array or slice given more dimensions keeps them outer (type IS = int[]; IS[2] is two slices).
type u8 = uint8;
type Bytes = *uint8;
type IntList = List<int>;
let buf: Bytes = alloc<u8>(16);
type is contextual: it is only a keyword in the form type Name = ...;, so it remains usable as an ordinary identifier elsewhere.
8.9 Packed Structs
By default a struct is laid out with natural alignment: the compiler inserts padding so each field sits on its required boundary. A packed struct removes that padding: fields are placed back-to-back. This matters when a struct must match an exact on-the-wire or on-disk byte layout, or a C struct declared with #pragma pack / __attribute__((packed)).
Mark a struct packed with the packed qualifier:
struct Natural { // sizeof == 8 (1 byte tag + 3 padding + 4 byte value)
uint8 tag;
uint32 value;
}
packed struct Header { // sizeof == 5 (no padding; value starts at offset 1)
uint8 tag;
uint32 value;
}
For C source compatibility, #pragma pack is also honoured. It maintains an alignment stack and applies to every struct declared while in effect; pack(1) packs, pack() / pop restore:
#pragma pack(push, 1)
struct WireHeader { // packed (sizeof == 5)
uint8 kind;
uint32 length;
}
#pragma pack(pop) // subsequent structs use natural alignment again
#pragma pack(1) and packed struct are equivalent (fully packed, no padding). #pragma pack(N) for N > 1 caps each field's alignment at N: a field whose natural alignment exceeds N is aligned to N instead, and the struct's total size rounds up to its own alignment (min(max-field-alignment, N)). This matches the C #pragma pack(N) ABI. For example, under pack(4) a struct { char a; int64 b; int16 c; } lays out a@0, b@4, c@12 with sizeof == 16. N must be 1, 2, 4, 8 or 16 (as in C); any other alignment is a compile error at the pragma. Packed layout (any N) composes with bitfields and is reflected by sizeof and by every field access. A struct declared under pack(N) keeps its alignment min(max-field-alignment, N) wherever it is used, as in C: as a field or array element of another struct (declared under any packing that does not cap it lower) and as a union member. A union declared under pack(N) caps each member's alignment at N the same way: #pragma pack(2) union U { char[5] c; int x; } is 6 bytes and 2-aligned.
9. Interfaces
9.1 Declaration
An interface declares a set of method signatures. No implementation is provided.
interface Drawable {
void draw();
}
interface Greeter {
void greet();
string name();
}
9.2 Structural Satisfaction
There is no implements keyword. A struct satisfies an interface if it provides all required methods with matching signatures. The check is structural and performed at the call site.
struct Circle {
float radius;
void draw() {
printf("Drawing circle r=%.2f\n", self.radius);
}
}
// Circle satisfies Drawable because it has draw()
9.3 Calling Interface Methods
Interface methods are called with the . operator:
void render(Drawable d) {
d.draw();
}
Dispatch is performed via the vtable pointer in the fat pointer. The arguments are checked against the interface's declaration of the method (count and types), like a direct call.
9.4 Passing Structs as Interfaces
Passing a struct pointer to a function expecting an interface auto-boxes it into a fat pointer {data_ptr, vtable_ptr}:
Circle c;
c.radius = 5.0;
render(&c); // &c is auto-boxed into a Drawable fat pointer
The same boxing happens wherever an interface-typed slot receives a struct pointer: a local (let d: Drawable = &c;), an assignment, a struct field, and a return from a function declared to return the interface. The interface value is held by value, so it can be stored and returned freely; it keeps referring to the struct it was boxed from. Passing the struct itself (render(c)) is a compile error: an interface refers to a struct through a pointer, so write &c.
null converts to an interface, giving the empty value {null, null} (in a declaration, an assignment, a return, an argument or a field). An interface value compares with null (d == null, d != null) and is a condition that is true when it refers to a struct (if (d), !d, d && ...); it compares with nothing else. Calling a method through an empty interface value crashes, so test it first. A global or static interface is initialized with null or the address of a global struct (Drawable d = &gc;), a link-time constant.
9.5 Implementation Detail: Fat Pointer
Under the hood, an interface value is a two-word fat pointer:
{ i8* data_ptr, i8* vtable_ptr }
The vtable is a struct of function pointers, one per interface method, generated per concrete type. This is transparent to user code.
10. Templates
10.1 Template Structs
Template structs are declared with one or more type parameters in angle brackets:
struct Pair<A, B> {
A first;
B second;
}
struct Box<T> {
*T value;
int valid;
}
A template struct may declare methods in its body. Each method is generated per struct instance, on the first call: for Box<int> the method get becomes Box_int_get(*Box_int self). A method that only makes sense for some type arguments is fine as long as no other instance calls it.
struct Cell<T> {
T v;
T get() { return self.v; }
T twice() { return self.v + self.v; }
}
Cell<int> c = Cell<int>{ 21 };
int n = c.twice(); // 42
A generic function in the Type_method form (T Cell_peek<T>(Cell<T>* self)) is also callable as c.peek(), with its type arguments taken from the receiver (§8.2).
10.2 Template Functions
T identity<T>(T x) {
return x;
}
T max<T>(T a, T b) {
if (a > b) return a;
return b;
}
10.3 Instantiation
Template instantiation is lazy and monomorphic. The compiler generates one concrete struct or function definition per unique set of type arguments. The generated name uses underscores: Result<int, string> becomes %Result_int_string in LLVM IR.
let p: Pair<int, float>;
p.first = 1;
p.second = 3.14;
int big = max<int>(10, 20);
The body of a template is type-checked once per instance, with the concrete type arguments substituted, after the rest of the program. An error that exists only for some type arguments (an operator the concrete type lacks, a value passed where a method takes a pointer) is reported at the offending expression, with the instance named:
error: f.esk:3:28: invalid operands for operator: struct:Box and struct:Box (in instantiation of twice<Box>)
10.4 Using Result from stdlib
import <result>;
extern int printf(string fmt, ...);
int main() {
let r: Result<int, string> = Ok<int, string>(42);
if (r.ok) {
printf("value: %d\n", r.value);
} else {
printf("error: %s\n", r.error);
}
return 0;
}
10.5 The ? error-propagation operator
The postfix ? operator removes the boilerplate of checking a Result after
every fallible call. Applied to a Result<T, E> value, expr?:
- if the result is
Err, returns it unchanged from the enclosing function; - otherwise evaluates to the unwrapped success value of type
T.
It may only appear inside a function whose return type is the same Result<T, E>
type. The compiler rejects ? anywhere else, since there would be nothing to
propagate into.
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 `?`, each call would need its own `if (!r.ok) return r;`.
Result<int, string> compute(int a, int b, int c) {
let x: int = divide(a, b)?; // unwraps, or returns the Err
let y: int = divide(x, c)?;
return Ok<int, string>(y + 1);
}
A value is treated as Result-like if it has an ok field of an integer type or
bool (zero is the error) and a value field; the standard library's Result<T, E> satisfies this. Applying ? to an
expression of any other type is a compile error.
10.6 Bounded type parameters (constraints)
A type parameter may carry one or more interface constraints, written after a
colon. The constraint requires that every concrete type substituted for the
parameter satisfy the named interface(s), the same structural match used for
interface values (§9): the type must provide a method for each signature in the
interface.
interface Ord {
int cmp(*Ord other); // `Ord` here stands for the implementing type
}
// `T` must satisfy `Ord`, checked at the call site, not deep in codegen.
T max<T: Ord>(T a, T b) {
if (a.cmp(&b) > 0) return a;
return b;
}
// Multiple constraints with `+`.
struct Cache<K: Hashable + Eq, V> {
*K keys;
*V vals;
int len;
}
Inside an interface, a parameter type spelled with the interface's own name stands for
the implementing type (§9), so struct Num { int v; int cmp(*Num o) {...} } satisfies
Ord above. There is no Self keyword: *Self is an ordinary (unknown) type name and
no struct matches it.
The constraint is enforced when the template is instantiated. If the concrete type does not satisfy the interface, the compiler reports the error at the instantiation site:
error: main.esk:9:15: type 'int' does not satisfy constraint 'Ord' (required by a bounded type parameter): missing method 'cmp'
Constraints are checked for both explicit (max<Num>(...)) and inferred
(max(a, b)) instantiations, and for template structs the moment a concrete
Name<...> type is resolved. They do not affect name mangling; instances are
still keyed on the concrete type arguments (§10.3).
Primitives via free functions. A struct satisfies a constraint by defining
the interface's methods. A primitive type (int, float, …) has no methods, so
it satisfies a constraint through a free function named like the interface
method whose first parameter is that primitive, e.g. int cmp(int, int) makes
int satisfy interface Ord { int cmp(Ord other); }. Inside a generic body a
constrained call t.cmp(x) on such a t lowers to cmp(t, x). So both
max<T: Ord>(int…) and a constraint-bounded Map<K: Hashable, V> over int keys
type-check and compile. (The function-pointer HashMap<K, V> from the standard
library remains for cases where you want to thread hash/eq explicitly.)
11. Memory
11.1 Stack Allocation
All variables declared in a function body are allocated on the stack. Stack memory is reclaimed automatically when the enclosing function returns. There is no garbage collector.
struct Point { float x; float y; }
int main() {
int x = 10;
Point p;
p.x = 1.0;
return 0;
} // x and p are reclaimed here
11.2 Heap Allocation
Heap allocation lives in the standard library, not the language core: import <mem> brings in alloc<T> and free.
alloc<T>(N) allocates space for N elements of type T and returns a *T. The memory is zero-initialized: a freshly allocated *T is null, an int is 0, a List is a valid empty list, so reading a field before you assign it is defined behavior, not a garbage read (the same guarantee C++'s new T() gives). In hosted mode (the default) it calls calloc(N, sizeof(T)). Under --freestanding (see §11.5) it calls the user-provided esk_alloc instead, whose contract is likewise to return zeroed memory; the same source, selected at compile time via the __ESKIU_FREESTANDING__ macro.
free(ptr) releases a heap-allocated pointer (libc free hosted, esk_free freestanding). It takes a *void; any pointer type coerces.
import <mem>;
*uint8 buf = alloc<uint8>(1024);
// ... use buf ...
free(buf);
alloc<T>/free are ordinary generic stdlib functions. There is no alloc keyword. (alloc_with, the explicit-allocator primitive, is a built-in; see §11.5.) Every allocation must be paired with exactly one free. Double-free and use-after-free are undefined behaviour.
11.3 Pointer Arithmetic
Pointer arithmetic is typed: p + n on a *T pointer advances by n * sizeof(T) bytes. *void and *char are byte-level (stride 1).
*uint8 buf = alloc<uint8>(256);
*uint8 ptr = buf + 64; // 64 bytes from start
*uint8 back = ptr - 32; // 32 bytes back
*int pi = alloc<int>(8);
*int p2 = pi + 1; // 4 bytes forward, next int element
p - q between two pointers counts the elements between them (an int64). Both must
point to the same type, as in C (const, ? and aliases aside); *int - *char is a
compile error.
The subscript operator ptr[i] reads or writes the i-th element and is exactly equivalent to *(ptr + i) (typed by the pointee). It is the idiomatic way to index allocated buffers and array fields:
*int xs = alloc<int>(4);
xs[0] = 10;
xs[3] = xs[0] * 2; // same as *(xs + 3) = *(xs + 0) * 2
11.4 Null Checks
let p: *int = null;
if (p != null) {
int val = *p;
}
Dereferencing null is undefined behaviour. The compiler does not insert null checks.
11.5 Freestanding Mode
Passing --freestanding predefines the macro __ESKIU_FREESTANDING__, which <mem> uses to switch the allocation backend:
<mem> function |
Hosted (default) | Freestanding (--freestanding) |
|---|---|---|
alloc<T>(n) |
calloc |
esk_alloc (must zero) |
free(p) |
free |
esk_free |
In freestanding mode the user must provide esk_alloc and esk_free in their own code (typically in a kernel or bare-metal runtime); <mem> declares them extern and the linker resolves them from the user-supplied object file. To keep alloc<T>'s zero-initialization guarantee, esk_alloc must return zeroed memory (the in-repo kernel/alloc.esk bump allocator does this). Code that needs heap allocation still just writes import <mem> and calls alloc<T>/free. The same source compiles for both modes.
// user-provided in kernel.esk or a C shim
*void esk_alloc(int size) { return buddy_alloc(size); }
void esk_free(*void ptr) { buddy_free(ptr); }
Freestanding mode does not remove any other language features. The standard library modules (stdlib/result.esk, etc.) remain available but must not import libc functions that are absent from the target.
Custom allocators (alloc_with). alloc_with(&allocator, T, n) is the explicit-allocator form of alloc: instead of going to malloc/esk_alloc, it calls <Type>_alloc(&allocator, n * sizeof(T)) and returns a *T. Any struct that exposes a method *void <Type>_alloc(<Type>* self, int64 nbytes) is a valid allocator, so allocation strategy is a plain value, not a global. When n * sizeof(T) does not fit a signed 64-bit size (or the allocator's narrower size parameter), or n is negative, alloc_with yields null without calling the allocator. n must be an integer, the allocator must be a pointer to it (&a, or a *Type value), its type must have the alloc method (a free <Type>_alloc or an inline alloc) of that shape (the allocator, then an integer size, returning a pointer), and T must be a known type with a size (not void); otherwise it is a compile error.
import <alloc>;
*uint8 backing = alloc<uint8>(4096); // one slab from the host (or a static buffer in freestanding)
let a: Bump; Bump_init(&a, backing, 4096);
*int xs = alloc_with(&a, int, 16); // 16 ints carved from the slab, no per-object malloc
The <alloc> module ships four allocators, all built on caller-provided memory (so they work under --freestanding with no libc malloc):
| Allocator | Strategy | Reclaim |
|---|---|---|
Bump |
monotonic offset into the buffer | Bump_reset frees everything at once; individual frees are no-ops |
Arena |
bump with checkpoints | Arena_save/Arena_restore free back to a marker; Arena_reset frees all |
Pool |
fixed-size blocks, free list threaded through freed blocks | Pool_free returns a block for reuse |
FirstFit |
general-purpose, first-fit search with region splitting (after Thompson's original) | FirstFit_free returns a region and merges it with adjacent free regions (the free list is kept in address order); freeing null is a no-op |
11.6 MMIO and volatile
See §4.5 for the volatile qualifier. In freestanding/kernel contexts, hardware registers are accessed through volatile pointer variables:
volatile let uart_dr: *uint8 = (uint8*) 0x3F8; // UART data register
volatile let uart_sr: *uint8 = (uint8*) 0x3FD; // UART status register
void uart_putc(uint8 c) {
while ((*uart_sr & 0x20) == 0) {} // spin until TX ready
*uart_dr = c;
}
Every load and store through a volatile pointer is emitted as a volatile load / volatile store in LLVM IR, preventing the optimiser from caching, reordering, or eliminating the access.
12. Multi-file Programs
A project can be split across files two ways: with import (below), or by passing several files to the compiler at once, eskiuc a.esk b.esk -o prog, which merges the declarations of all inputs into one program. Declaration order across files does not matter.
12.1 import Statement
The import statement has two forms:
import <result>; // stdlib module, resolved by the compiler
import "stdlib/result.esk"; // local file, path relative to the importing file
import "../shared/types.esk";
import <name> resolves the module from the Eskiu installation's stdlib directory. The compiler locates the stdlib either via the ESKIU_ROOT environment variable (if set) or by auto-detecting it from the compiler binary's location. No path prefix is required.
import "path" resolves the path relative to the directory of the importing file, as before.
All declarations in the imported file (functions, structs, templates, externs) become available in the importing file.
12.2 Deduplication
Each file is parsed and processed at most once per compilation, regardless of how many files import it. Files are identified by their canonical path, so two different relative spellings of the same file (a diamond import) count as one. Circular imports are detected and do not cause infinite loops.
12.3 Example
// main.esk
import <io>;
import <result>;
int main() {
let r: Result<int, string> = Ok<int, string>(0);
printf("ok: %d\n", r.ok);
return 0;
}
13. extern / C Interop
13.1 Extern Function Declarations
An extern declaration makes a C function available to Eskiu code. The declaration must match the C function's ABI exactly.
extern int printf(string fmt, ...);
extern int64 strlen(string s);
extern *void memcpy(*void dst, *void src, int64 n);
extern int open(string path, int flags);
extern void exit(int code);
13.2 Extern Variables
An extern declaration with no parameter list (a type, a name, and a semicolon) names a global variable defined in another translation unit. It emits an external-linkage global with no initializer; reads and writes resolve at link time.
extern int errno;
extern float g_volume;
void clear_error() {
errno = 0; // assign a C global
}
int last_error() {
return errno; // read a C global
}
An extern variable lives at the top level and carries no initializer (its definition, and value, live in the other object). extern const <type> <name>; declares a read-only C global.
The definition may also be in the same program: an extern next to the variable's own definition (before or after it, or in another file compiled with it) names that one variable. In the other direction, every Eskiu global has external (C) linkage under its own name, so C code can reach it with extern int name;. A static local stays private to its function.
13.3 Calling Extern Functions
extern functions are called exactly like Eskiu functions:
extern int printf(string fmt, ...);
int main() {
printf("Hello, %s!\n", "world");
return 0;
}
13.4 C ABI Compatibility
extern declarations emit standard C-ABI-compatible LLVM IR call instructions. Any function exported from a C library, including system libraries, OpenSSL, zxing-cpp, or any other C-compatible library, may be called this way.
For functions that accept or return void*, use *void on the Eskiu side:
extern *void malloc(int64 size);
extern *void memcpy(*void dst, *void src, int64 n);
extern *void memset(*void ptr, int value, int64 n);
(For heap allocation, prefer import <mem> and alloc<T>/free over declaring malloc/free as extern yourself; see §11.2.)
A struct or union may be passed to or returned from an extern function by value. The compiler lowers such a call to the target's C calling convention (register classes, homogeneous float aggregates, hidden return pointer), matching what clang emits for the same C signature, on AArch64, x86-64 System V, Windows x64 and 32-bit ARM, in both compilers (the self-hosted one picks the convention from --target). See docs/dev/abi.md for the per-target rules.
struct Vec2 { double x; double y; }
extern Vec2 vec2_add(Vec2 a, Vec2 b); // a C function taking and returning structs
13.4 Passing an Eskiu function as a C callback
Many C APIs take a function pointer (qsort, signal, OpenSSL's ALPN selector,
…). Casting a top-level Eskiu function to a pointer type yields its bare C
function-pointer address, the raw symbol, not the {fn, env} closure fat
pointer a function name otherwise decays to:
extern void qsort(*void base, int64 n, int64 size, *void compar);
int cmp(*void a, *void b) { return *(*int)a - *(*int)b; }
qsort((*void)arr, (int64)5, (int64)4, (*void)cmp); // (*void)cmp is the raw C pointer
The callback's signature must match what the C side expects (C ABI). This works only for top-level functions (a closure or fn-pointer variable still carries an environment and is not C-callable). A callback that takes or returns a struct by value works too: C reaches it through a generated thunk with the C calling convention.
An extern parameter of fn type is a C function pointer, so the prototype can
spell the callback's signature and the call passes a top-level function by name
(or null), 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);
Passing anything else there (a lambda, a fn-typed variable) is a compile error.
14. Stdlib
Eskiu ships a set of standard library files in the stdlib/ directory. Import a module by name with import <name>; (the canonical form, resolved by the compiler); import "stdlib/name.esk"; with a relative path also works.
| File | Contents |
|---|---|
stdlib/result.esk |
Result<T,E> template struct; Ok<T,E>(value) and Err<T,E>(err) constructor functions |
stdlib/list.esk |
List<T> template struct; List_init, List_push, List_get, List_set, List_remove, List_len, List_free |
stdlib/string.esk |
String struct; String_init, String_from, String_append, String_concat, String_push, String_char_at, String_set, String_clear, String_index_of, String_eq, String_eq_cstr, String_reverse, String_substring, String_from_int, String_to_int, String_cstr, String_len, String_free, String_starts_with, String_ends_with, String_trim, String_next_token (streaming split), String_split/String_split_free (into a List<String>). String_to_int accepts a leading sign and saturates at the int range. String_is_space is deprecated; use is_space from <ctype> |
stdlib/ctype.esk |
Pure-Eskiu ASCII character classification (comparisons only, no libc, freestanding-safe): is_space, is_digit, is_hex, is_alpha, is_alnum, is_ident_start, is_ident_cont. Each takes and returns int (1/0) |
stdlib/math.esk |
extern declarations for sqrt, fabs, pow, floor, ceil, fmod, abs |
stdlib/io.esk |
extern declarations for printf, fprintf, sprintf, scanf, puts, getchar, putchar |
stdlib/mem.esk |
Heap allocation alloc<T>(n) / free(p) (libc, or esk_alloc/esk_free under --freestanding); plus extern memcpy, memset, memmove, memcmp, strlen |
stdlib/fs.esk |
File I/O: fs_open, fs_close, fs_flush, fs_read, fs_readline, fs_write, fs_puts, fs_seek, fs_tell, fs_size, fs_read_all, fs_write_all, fs_eof, fs_error |
stdlib/net.esk |
TCP sockets: net_tcp_listen, net_accept, net_accept_addr (accept + peer IPv4), net_tcp_connect, net_send, net_recv, net_send_str, net_close, socket timeouts (net_set_recv_timeout, net_set_send_timeout, net_set_timeouts, net_would_block; the struct timeval is two C longs, so 8 bytes on 32-bit ARM), deadlines (net_deadline, NET_TIMED_OUT) and a blocking readiness wait (net_wait_ready, over poll / WSAPoll) (plus the raw POSIX externs and a portable sockaddr_in) |
stdlib/alloc.esk |
Allocators over caller-provided memory for alloc_with (see §11.5): Bump, Arena, Pool, FirstFit, each with _init/_alloc (and _free/_reset/_save/_restore as applicable) |
stdlib/time.esk |
time_now_ms, time_now_s, time_monotonic_ms, sleep_ms; a UTC civil calendar: DateTime (year/month/day/hour/min/sec plus wday/yday), time_to_utc(t, &dt) (epoch seconds to fields), DateTime_to_epoch, and DateTime_format_iso (ISO 8601, e.g. 2026-07-13T18:30:00Z) |
stdlib/random.esk |
Rng, a seedable xoshiro256** generator (not cryptographic): Rng_seed, Rng_next (raw 64-bit), Rng_below (unbiased [0, n)), Rng_range ([lo, hi)), Rng_bool, Rng_double ([0.0, 1.0)), Rng_fill (random bytes) |
stdlib/regex.esk |
A Thompson-NFA regex engine run as a Pike VM (linear time, no catastrophic backtracking): regex_compile(pattern) -> Regex (check .ok / .err), Regex_search(&re, text, &m) for the leftmost match with capture groups, Match_group, Match_free, Regex_free, and the one-shot regex_match(pattern, text). RE2 syntax and semantics (as Go's regexp) over UTF-8: ., classes and literals match whole code points, match offsets are byte offsets, and a byte that is not valid UTF-8 reads as U+FFFD. Literals, ., classes [a-z]/[^...] with POSIX names inside ([[:alpha:]], [[:^space:]]), the ASCII shorthands \d \w \s (and negations; \s is [\t\n\f\r ]), Unicode classes \pL, \p{Lu}, \p{Greek}, \P{..}, \p{^..} (general categories, scripts, Any; the Unicode 15 tables are stdlib/regex_unicode.esk), * + ? {m} {m,} {m,n} (lazy with ?; any other { is a literal), |, groups, non-capturing (?:...), named (?P<name>...) / (?<name>...), the flags (?i) (simple case folding) (?m) (?s) (?U) for the rest of a group or scoped (?flags:...), ^ / $, \A / \z, the ASCII word boundaries \b / \B, \xHH / \x{10FFFF} and octal escapes, literal text \Q...\E (any other escaped letter or digit is an error) |
stdlib/sort.esk |
Generic in-place heapsort sort<T>(a, n, cmp) and binary search bsearch<T>(a, n, key, cmp) (index or -1) over a *T array; cmp is fn(*T, *T)->int (negative / zero / positive) |
stdlib/url.esk |
RFC 3986 percent-encoding: url_encode, url_decode (and url_decode_range), plus url_query_get(query, key, &out) for a=1&b=2 query strings (keys and values are decoded, + to a space) |
stdlib/uuid.esk |
uuid_v4(&rng, &out): an RFC 4122 version-4 UUID string (xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx) drawn from a <random> Rng |
stdlib/env.esk |
env_get, env_has, env_get_or, env_get_int (process environment; CLI args come from main's argc/argv) |
stdlib/base64.esk |
base64_encode / base64_decode over byte buffers (decoding rejects bad padding and truncated input), plus base64_encoded_len / base64_decoded_len and the base64_value / base64_digit primitives |
stdlib/bytes.esk |
Bytes, a growable, binary-safe byte buffer (*uint8 + length; embedded NULs survive, unlike String): Bytes_init/_free/_push/_append/_append_raw/_slice (non-owning view)/_eq/_from_str/_cstr, plus Bytes_from_base64/Bytes_to_base64 |
stdlib/path.esk |
Unix path manipulation: path_join, path_basename, path_dirname, path_extension, path_is_absolute |
stdlib/http.esk |
HTTP/1.1: HttpRequest + HttpRequest_parse/_parse_status/_header (RFC 9112 framing, the same as http_recv: _parse_status is 0 for a complete request, -1 when more bytes are needed, else the HTTP error status), HttpResponse + HttpResponse_header/_set_body/_render/_render_head (a HEAD response: Content-Length, no body; a 1xx, 204 or 304 has neither; HttpResponse_header returns 1, or 0 and adds nothing for a name that is not a token or a value with a CR or LF, which would inject a header line; a Content-Length the handler sets is not sent next to the automatic one, only a 1xx, 204 or 304 keeps it), and a threaded worker pool http_serve(port, nworkers, handler) where handler is fn(HttpRequest*, HttpResponse*)->void (http_serve_with(port, nworkers, handler, lim) takes an HttpLimits); it reads each request until complete (HttpConnBuf_feed, incremental: the head is parsed once and a chunked body decoded as it arrives), answering a bad one 400, 413, 501 or 505 without the handler (limits: a head over HTTP_SERVE_MAX_HEAD, 64 KiB, is 400, a body over HTTP_SERVE_MAX_BODY, 1 MiB, 413). HttpLimits (HttpLimits_init / http_limits() give the defaults) holds every server's timeouts in ms, 0 turning one off: header_ms (HTTP_HEADER_TIMEOUT_MS, 10 s: the whole head; trickled bytes do not extend it), body_ms (HTTP_BODY_TIMEOUT_MS, 60 s: the body once the head is in), idle_ms (HTTP_IDLE_TIMEOUT_MS, 60 s: an HTTP/2 connection with no open stream), write_ms (HTTP_WRITE_TIMEOUT_MS, 30 s: the client must take the answer; an HTTP/2 stream counts it from the last response data sent), write_total_ms (HTTP_WRITE_TOTAL_MS, 5 min: an HTTP/2 stream's whole response must be sent within it from when it is ready, however the peer paces its window updates; past it the stream is reset with CANCEL and its buffered response freed, the connection stays open), and max_open (HTTP_MAX_OPEN_CONNS, 1024: connections an async server holds at once; one past it is closed at once). A request cut by header_ms or body_ms after some of it arrived is answered 408; a peer that sent nothing is just closed. http_serve applies them with SO_RCVTIMEO (set to what is left of the deadline before each read) and SO_SNDTIMEO. http_chunked_step decodes a chunked body incrementally; a chunk-size or trailer line longer than HTTP_CHUNK_LINE_MAX (8192 bytes, CRLF included) is malformed however it was split into reads. The servers and http_recv drop the chunked input they have decoded, so their buffer holds the head and one unfinished chunk or line, and they reject a trailer section longer than HTTP_CHUNK_TRAILER_MAX (64 KiB) with 400. Plus a binary-safe full-body reader HttpReq + http_recv (loops until the Content-Length body arrives, or decodes a chunked body, into a *uint8 body; RFC 9112 framing: Transfer-Encoding with Content-Length, a missing, repeated or invalid Host (not uri-host [":" port]), whitespace before a header colon, an obsolete line fold or a bad version is 400, a coding other than chunked 501), HttpReq_header, http_reply, http_reply_error: for uploads a single-recv String body would corrupt binary bytes |
stdlib/multipart.esk |
Extract a named part from a multipart/form-data body over raw bytes: multipart_boundary(ct, out) and multipart_part(body, len, boundary, name, *out_ptr, *out_len) (the part whose Content-Disposition name parameter is name; returns a slice into the body) |
stdlib/map.esk |
Map<V>, a string-keyed hash map (open addressing, linear probing, grows at 0.75 load): Map_init, Map_at (get-or-insert → *V slot, sets *created), Map_get, Map_free. Plus HashMap<K,V>, keyed on any value type via hash/eq function pointers passed to HashMap_init (built-in int_hash/int_eq); same _at/_get/_free shape |
stdlib/threading.esk |
Synchronization over pthread: Mutex (_init/_lock/_unlock/_destroy), Cond (_init/_wait/_signal/_broadcast/_destroy), Sem (_init/_wait/_post/_destroy). Pairs with the thread_create/thread_join built-ins |
stdlib/eventloop.esk |
Readiness reactor over kqueue (macOS) / epoll (Linux): EventLoop, el_new, EventLoop_add_read, EventLoop_add_write, EventLoop_del, EventLoop_run, EventLoop_stop, EventLoop_free, plus a timer wheel (EventLoop_add_timer/EventLoop_del_timer). Callback is fn(EventLoop*, int)->void |
stdlib/atomic.esk |
Atomic intrinsics on an int cell: atomic_load/atomic_store/atomic_swap/atomic_cas, lowering to LLVM atomics with fixed acquire/release ordering. Declared with the intrinsic qualifier |
stdlib/json.esk |
JSON builder + parser. Builder: Json + Json_init/_free/_cstr, Json_obj_begin/_end, Json_arr_begin/_end, Json_key, Json_str, Json_int, Json_bool, Json_null (auto separators). Parser (strict RFC 8259: exact literals, no trailing data, \uXXXX escapes decoded; returns null on malformed input): json_parse(src) -> *JsonValue + JsonValue_kind/_len/_at/_get/_as_int/_as_double/_as_bool/_as_cstr/_free |
stdlib/sysheap.esk |
Heap, a general-purpose heap that mmaps OS pages and runs FirstFit over them, providing allocation with no libc malloc (suitable as a freestanding backend) |
stdlib/future.esk |
The async runtime's Future<T> (the locked compiler↔generated-code contract): the state/waker/on_drop handshake, future_new/future_complete/future_poll/future_drop/free_future/free_future_polled (the latter also frees the waker a hand-driven future_poll installed), and the generic combinators spawn<T>, select2<A,B> (first of two), join2<A,B> (all of two) |
stdlib/executor.esk |
Executor, a thread that owns an event loop plus a thread-safe ready-queue of wakers woken through a self-pipe, so a waker (a coroutine resume) always runs on the executor's own thread: executor_new, Executor_schedule, Executor_run, Executor_stop, Executor_free |
stdlib/net_async.esk |
Leaf futures for non-blocking network I/O over <eventloop>: net_set_nonblocking, net_read_async, net_write_async, net_accept_async, each registers an fd and completes its *Future<int> when ready; net_read_deadline_async, net_write_deadline_async and net_ready_deadline_async race the same against a timer_after (via select2) and complete with NET_TIMED_OUT once the deadline passes |
stdlib/timer.esk |
timer_after(lp, ms), a *Future<int> that completes once ms of monotonic time elapses, driven by the loop's timer wheel; combine with a read for a real timeout |
stdlib/channel.esk |
Chan<T>, an async message channel over the Future runtime: chan_new<T>(cap), Chan_send, Chan_recv (a *Future<T> that completes with the next item), and Chan_free |
stdlib/either.esk |
The standard sum types Option<T> and Either<A,B> (generic algebraic enums) plus helpers (opt_is_some/opt_unwrap_or, …) |
stdlib/futureval.esk |
Value-returning future combinators: select2v<A,B> resolves to the winner's value wrapped in Either<A,B>; join2v<A,B> resolves to a Pair<A,B> of both values |
stdlib/http_async.esk |
Non-blocking concurrent HTTP/1.1 server built on the event loop's accept loop: http_serve_async (and http_serve_async_with(lp, fd, handler, max_conns, lim) for other HttpLimits; the reads and the write race a timer, and at most lim.max_open connections are open at once), with the same fn(HttpRequest*, HttpResponse*)->void handler interface as <http>. Each connection socket is non-blocking and the answer is written with net_write_async, so a client that does not read parks only its own connection |
stdlib/http2.esk |
HTTP/2 (RFC 7540) wire layer: the 9-byte frame header codec, frame-type/flag constants, the connection lifecycle (H2Conn: SETTINGS exchange + ACK, PING/PONG, GOAWAY), the per-stream state machine and credit-based flow control (H2Stream), the HEADERS/DATA/WINDOW_UPDATE/RST_STREAM codecs, and async frame I/O |
stdlib/hpack.esk |
HPACK (RFC 7541) header compression: prefix-integer and string-literal coding, the 61-entry static table, a per-connection dynamic table, and the §6 field representations; the encoder picks the shorter of raw and Huffman |
stdlib/hpack_huffman.esk |
The HPACK Huffman code table (RFC 7541 Appendix B), generated from the RFC: hpack_huff_code(i) / hpack_huff_len(i), used by <hpack> |
stdlib/http2_server.esk |
HTTP/2 (h2c, cleartext) server over the async event loop, http2_serve_async (http2_serve_async_with / http2_serve_conn_async_with take an HttpLimits: a connection without the preface and SETTINGS by header_ms is closed, one with no open stream gets GOAWAY NO_ERROR after idle_ms (PING does not extend it), a stream must be received within body_ms and its response taken within write_ms of the last data sent and within write_total_ms in all (a stream past write_total_ms is reset with CANCEL, H2Server_expire); the transport reads with H2Server_deadline after H2Server_tick and on a timeout calls H2Server_timed_out_with). The protocol is the transport-agnostic H2Server engine (also used by <tls>): multiplexed streams, flow control in both directions, padding, CONTINUATION, and RFC 9113 connection/stream errors; a completed request is HPACK-decoded into an HttpRequest and the HttpResponse goes back as a HEADERS frame plus flow-controlled DATA frames. Request bodies are bounded: at most s.max_body bytes per stream (default HTTP_SERVE_MAX_BODY, 1 MiB) and s.max_buffered across a connection's streams (default H2_MAX_BUFFERED, 4 MiB), both settable after H2Server_init; a request past either, or with a content-length past max_body, is answered 413 without the handler and its stream reset with NO_ERROR. A request's decoded header list is bounded too: more than H2_MAX_HEADERS (64) fields, more than H2_MAX_HEADER_LIST bytes (64 KiB, counted as SETTINGS_MAX_HEADER_LIST_SIZE counts them: name + value + 32 per field, and advertised in the server's SETTINGS), or more than s.max_buffered leaves (the open streams' header lists count toward it with their bodies) is answered 431 without the handler. The HTTP/1.1 Host rules apply: a host field or :authority that is not uri-host [":" port] is 400, and two host fields or a host that differs from :authority (ignoring case) is malformed (RST_STREAM PROTOCOL_ERROR); without a host field the handler sees :authority as its Host header. A response header line with a non-token name or an LF or NUL in its value is not sent |
stdlib/tls.esk |
TLS for HTTP/2 over OpenSSL (libssl) with ALPN negotiating "h2": a server SSL_CTX that loads a cert/key and selects "h2", plus blocking (http2_tls_serve_conn) and async (http2_tls_serve_async) h2-over-TLS servers that run the <http2> protocol over the encrypted stream, with the h2c server's deadlines (http2_tls_serve_conn_with, http2_tls_serve_async_with take an HttpLimits; the handshake counts toward header_ms, and tls_accept bounds a blocking handshake too, tls_accept_with with other limits). The blocking calls (tls_accept_with, tls_read_full_deadline, tls_write_all_deadline, http2_tls_serve_conn_with) run the socket non-blocking and wait with poll on what is left of the deadline, so a peer that trickles bytes inside one TLS record cannot stretch it (a socket timeout restarts with every byte OpenSSL's record loop reads); tls_accept_with leaves the socket non-blocking |
Modules built around a struct name their operations Type_method, so they can also be called with dot syntax (rng.next() calls Rng_next(&rng)). A keyword after . is a member name, so j.int(5) and j.bool(1) call <json>'s Json_int and Json_bool. Functions that create a value keep a lowercase module name (el_new, executor_new, chan_new, regex_compile). Release 0.9.2 renamed the older lowercase forms: rng_* to Rng_*, regex_search/regex_free/match_* to Regex_*/Match_*, heap_* to Heap_*, el_* to EventLoop_*, executor_* to Executor_*, chan_* to Chan_*, hpack_decoder_init/hpack_decoder_free and hpack_huff_build/hpack_huff_free to HpackDecoder_init/HpackDecoder_free and HpackHuff_build/HpackHuff_free (the other hpack_huff_* functions, hpack_huff_code, hpack_huff_len, hpack_huff_decode, hpack_huff_encode and hpack_huff_encoded_len, keep their names), h2_conn_init/h2_stream_init/h2_can_send to H2Conn_init/H2Stream_init/H2Conn_can_send, and time_from_utc/time_format_iso to DateTime_to_epoch/DateTime_format_iso. The old names still work as deprecated wrappers and will be removed in a later release.
Result
import <result>;
extern int printf(string fmt, ...);
Result<int, string> divide(int a, int b) {
if (b == 0) return Err<int, string>("division by zero");
return Ok<int, string>(a / b);
}
int main() {
let r: Result<int, string> = divide(10, 2);
if (r.ok) {
printf("result: %d\n", r.value);
} else {
printf("error: %s\n", r.error);
}
return 0;
}
List
import <list>;
extern int printf(string fmt, ...);
int main() {
let items: List<int>;
List_init(&items, 4);
List_push(&items, 10);
List_push(&items, 20);
List_push(&items, 30);
printf("len: %d\n", List_len(&items));
printf("item[1]: %d\n", List_get(&items, 1));
List_free(&items);
return 0;
}
String
import <string>;
extern int printf(string fmt, ...);
int main() {
let s: String;
String_from(&s, "Hello");
String_append(&s, ", world!");
printf("%s\n", String_cstr(&s)); // Hello, world!
String_free(&s);
return 0;
}
String is a growable, owned, NUL-terminated buffer. Beyond append/concat it
offers per-character access and mutation (String_char_at, String_set,
String_push), comparison (String_eq, String_eq_cstr), search
(String_index_of), String_substring, String_reverse, String_clear, and
integer conversion (String_from_int, String_to_int):
let n: String;
String_init(&n, 8);
String_from_int(&n, -2026); // "-2026"
printf("%d\n", String_to_int(&n)); // -2026
String_free(&n);
Networking (<net>)
<net> wraps the POSIX BSD socket API for TCP. The net_* helpers cover the
common path; the raw externs (socket, bind, …) and a portable
sockaddr_in are also exported for anything more specialised. It is a pure
stdlib module. Sockets need no compiler support beyond the C FFI.
import <net>;
import <mem>;
extern int printf(string fmt, ...);
int main() {
int fd = net_tcp_listen(8080); // bind + listen on 0.0.0.0:8080
if (fd < 0) { return 1; }
printf("listening on :8080\n");
*uint8 req = alloc<uint8>(4096);
while (1) {
int c = net_accept(fd); // blocking accept
if (c < 0) { continue; }
net_recv(c, (*void)req, 4096);
net_send_str(c,
"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\nHello from Eskiu.\n");
net_close(c);
}
free(req);
return 0;
}
| Helper | Effect |
|---|---|
net_tcp_listen(port) -> int |
Create a socket, set SO_REUSEADDR, bind to 0.0.0.0:port, listen. Returns the fd or -1 |
net_accept(fd) -> int |
Accept the next connection; returns the connection fd or -1 |
net_tcp_connect(host, port) -> int |
Connect to a dotted-quad host (e.g. "127.0.0.1"); returns fd or -1 |
net_send(fd, buf, n) / net_recv(fd, buf, n) |
Send / receive raw bytes (int64 count). A send to a closed peer returns -1 (EPIPE); it never raises SIGPIPE |
net_send_str(fd, s) |
Send a C string (length via strlen) |
net_close(fd) |
Close a socket |
net_set_recv_timeout(fd, ms) / net_set_send_timeout(fd, ms) / net_set_timeouts(fd, recv_ms, send_ms) |
Bound a blocking recv / send (SO_RCVTIMEO / SO_SNDTIMEO; 0 waits forever). One that runs out returns -1 with net_would_block() 1 |
net_deadline(ms) -> int64 |
An absolute monotonic deadline ms from now (0 for none), for the *_deadline reads and writes, which return NET_TIMED_OUT (-3) once it passes |
net_wait_ready(fd, mode, deadline) -> int |
Wait until fd is readable (mode 0) or writable (1) by deadline (0 = none), with poll (WSAPoll on Windows): 0 when ready (an error or hang-up counts, for the next call to report), NET_TIMED_OUT once the deadline passed, -1 if the wait failed |
sockaddr_in differs between macOS and Linux; <net> selects the right layout
at compile time using the predefined __APPLE__ / __linux__ macro (see §18).
A concurrent server combines <net> with thread_create, handing each
accepted connection to a worker (see §6.5 for passing a function as a value).
Complete programs are in examples/http_server.esk and
examples/tcp_echo_server.esk.
The HTTP servers (http_serve, http_serve_async, http2_serve_async and the TLS
servers) bound request heads, bodies and header lists, and they disconnect slow clients
under <http>'s HttpLimits: a request head (or the HTTP/2 preface and SETTINGS) must
arrive within HTTP_HEADER_TIMEOUT_MS (10 s) and a body within HTTP_BODY_TIMEOUT_MS
(60 s), an HTTP/2 connection with no open stream is closed with GOAWAY after
HTTP_IDLE_TIMEOUT_MS (60 s), a client must take an answer within
HTTP_WRITE_TIMEOUT_MS (30 s), and the async servers hold at most
HTTP_MAX_OPEN_CONNS (1024) connections at once. The *_with variants take other
limits.
15. Inline Assembly
Eskiu supports inline assembly via the asm(...) statement, which lowers directly to an LLVM inline asm node.
15.1 Simple Form
asm("cli"); // disable interrupts (x86)
asm("sti"); // enable interrupts (x86)
asm("nop");
The string is passed verbatim to the assembler. No inputs, outputs, or clobbers are specified.
15.2 Extended Form
The extended form follows GCC-compatible inline assembly syntax:
asm("template" : outputs : inputs : clobbers);
asm("outb ${0:b}, $1" :: "a"(val), "Nd"(port) : "memory");
asm("add $0, $1, $2" : "=r"(sum) : "r"(a), "r"(b)); // AArch64
asm("addq $1, $0" : "+r"(acc) : "r"(b)); // x86-64
- Template: the assembly instruction string; operands are referenced LLVM-style by
$0,$1, … (with modifiers like${0:b}for a sub-register), not%0/%1. The outputs are numbered first, then the inputs, as in GCC and clang. - Outputs: list of
"constraint"(lvalue)pairs, written by the asm. A constraint starts with=(written only:"=r", the early-clobber"=&r", a specific register such as"=a", or memory"=m") or+(read and written:"+r","+m"). The operand must be a writable lvalue (a variable, field, element or dereference, not a bitfield or aconst) of an integer type other thanbool, a floating-point type or a pointer. A register output is a result of the asm stored into the lvalue afterwards; a memory output passes the lvalue's address. A+routput also feeds the lvalue's current value in through an input tied to it. - Inputs: list of
"constraint"(expr)pairs (a constraint may not start with=or+). Common constraints:"a"(eax/rax),"Nd"(8-bit immediate or dx),"r"(any register),"m"(memory). - Clobbers: comma-separated list of clobbered resources.
"memory"tells the compiler that the asm may read or write arbitrary memory (acts as a compiler barrier).
Sections are separated by :. Trailing sections may be omitted if empty.
15.3 Notes
- Inline assembly is only meaningful when targeting a platform whose assembler understands the instructions. Use
--targetto select the appropriate triple (see §16). asmis a statement, not an expression. It does not produce a value; results come back through output operands.- Beyond the output rules above, the compiler performs no validation of the assembly template or constraints; they are forwarded to LLVM.
16. CLI Flags
| Flag | Action |
|---|---|
eskiuc --version |
Print the compiler and LLVM version |
eskiuc file.esk -o prog |
Compile and link into the executable prog |
eskiuc a.esk b.esk -o prog |
Compile several files together (declarations are merged) |
eskiuc run file.esk [args...] |
Compile to a temp executable, run it (forwarding args), delete it; exit code propagated |
eskiuc run --asan file.esk |
Same, with a compiler flag (flags precede the script, program args follow) |
eskiuc fmt file.esk … |
Reformat files in place (indentation/whitespace; preserves content and comments) |
eskiuc fmt --check file.esk … |
Report files that are not formatted (exit non-zero); write nothing |
eskiuc file.esk --asan -o prog |
Instrument with AddressSanitizer (memory errors) and link its runtime |
eskiuc file.esk --ubsan -o prog |
Insert trapping bounds checks (traps on out-of-bounds; no runtime) |
eskiuc file.esk --safe -o prog |
Bounds-check every array and slice index at runtime (trap on out-of-range); off by default |
eskiuc file.esk -Wall -o prog |
Enable lint warnings in the program's own files (not in imported modules): unused vars/params/functions, assignment-in-condition, a local that may be used uninitialized |
eskiuc file.esk -Wextra -o prog |
Extra warnings on top of -Wall: signed/unsigned comparison mismatches |
eskiuc file.esk -O2 -o prog |
Optimize: run the LLVM middle-end (-O1/-O2/-O3). -O0 (default) emits naive IR straight to the backend. A level above 3 is rejected |
eskiuc file.esk -o prog -lfoo |
Link, passing library flags through to the linker |
eskiuc file.esk -o prog --no-default-libs |
Link only what -l names: skip #pragma link and the implied runtimes |
eskiuc file.esk -o file.o |
Compile to an object file only (no link) |
eskiuc file.esk -c -o name |
Compile to an object file only, any name |
eskiuc file.esk |
Compile to file.esk.o (object only) |
eskiuc file.esk --target TRIPLE |
Cross-compile for the given target triple |
eskiuc file.esk --freestanding |
Object only; use esk_alloc/esk_free instead of malloc/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 LINE:COL |
Print inferred type at position |
eskiuc file.esk --definition-at LINE:COL |
Print definition location of symbol |
Linking. When the -o output is not an object file (no .o suffix) and -c
is absent, eskiuc links the program into an executable by invoking the system
C toolchain ($CC, then cc/clang/gcc on the PATH) exactly as rustc
and clang do internally. $CC may include arguments (CC="clang --target=..."). -l<lib> and -L<path> flags, and any --link-arg=<arg>,
are forwarded to the linker. A C toolchain must therefore be installed (it is the
only build-time dependency besides LLVM).
The driver also adds the libraries the program itself implies, so the usual ones
never need a flag. It links each #pragma link("name") library the program and
its imports name (the stdlib uses this for libm on Linux, pthread on Linux and
Windows, and ws2_32 on Windows). It links the C++ exception runtime when the
program throws or catches (-lc++ on macOS, -lstdc++ on Linux and Windows) and
-lpthread when it calls thread_create (Linux and Windows). These come after
every object and --link-arg, once each, and a library already given with -l
is not repeated. --no-default-libs turns all of them off for custom linking.
Nothing is added when no executable is linked. With --freestanding (or a .o output)
no linking happens, so bare-metal targets are linked yourself (see the kernel's
ld.lld invocation).
Running directly. eskiuc run file.esk [args...] compiles to a temporary executable, runs it (forwarding args...), then deletes it, propagating the program's exit code, handy for quick iteration. If the program is killed by a signal, run exits with 128 + the signal number, as a shell does. Compiler flags (including ones that take a value, such as --target TRIPLE) go before the script and program arguments after it; a -- right after the script is dropped, so eskiuc run --asan file.esk -- input.txt passes only input.txt. Because a leading #! line is ignored, a script can also start with #!/usr/bin/env eskiuc run and, once chmod +x'd, be executed directly.
Formatting. eskiuc fmt file.esk … reformats files in place. It is deliberately conservative: it re-indents to four spaces per brace level, trims trailing whitespace (except after a trailing \, where trimming would create a line continuation), drops blank lines at the end of the file, and ensures a final newline, but leaves each line's content (operator spacing, comments, every byte of a string or character literal, including every line of a string that spans lines) exactly as written and keeps every other blank line, so line numbers (__LINE__, diagnostics) do not move. It never alters a program's behavior and is idempotent. --check makes it report (and exit non-zero on) files that would change, without writing. Useful in CI.
Sanitizers. --asan instruments the program with AddressSanitizer (detecting heap, stack and global memory errors) and links the matching LLVM runtime; --ubsan inserts trapping bounds checks (an out-of-bounds access aborts via a trap; no runtime is required). Both are real LLVM instrumentation passes and compose with eskiuc run.
--hover-at and --definition-at accept the format LINE:COL with 1-based line and column numbers. They are used by the VS Code extension to provide hover type information and go-to-definition navigation.
Cross-compilation
--target TRIPLE sets the LLVM target triple for the output object file. Both the AArch64 and X86 LLVM backends are included in the Eskiu build.
eskiuc kernel.esk --target x86_64-pc-linux-gnu --freestanding -o kernel.o
eskiuc kernel.esk --target aarch64-unknown-none --freestanding -o kernel.o
Common triples:
| Triple | Description |
|---|---|
x86_64-pc-linux-gnu |
ELF x86-64 (Linux) |
aarch64-unknown-linux-gnu |
ELF AArch64 (Linux) |
aarch64-unknown-none |
Bare-metal AArch64 |
x86_64-unknown-none |
Bare-metal x86-64 |
When --target is omitted the compiler defaults to the host machine's triple.
17. Error Reporting
The compiler emits diagnostics with full source location information:
error: file.esk:8:22: undefined variable 'foo'
error: file.esk:14:5: type mismatch: expected int, got float
The format is error: file:line:col: message. Line and column numbers are 1-based. Lexer, preprocessor, parser and type errors all use it, and an error inside an imported file names that file. -Wall warnings are printed as file:line:col: warning: message. Every mode, including the --test-* modes, exits with a non-zero status when it reports an error.
Reading a scalar local (a number, bool, char, pointer, string or fn value) declared without an initializer before any assignment to it is an error ("use of uninitialized variable") when the read is in the function's straight-line prefix, where no path assigns it. Elsewhere a read that some path reaches without an assignment is not an error, since the program's logic may guarantee it (as in C), but -Wall reports it as variable 'x' may be used uninitialized. The check follows every path through if/else, loops (a loop body may run zero times; the loop exits when its condition fails or at a break), switch fall-through, match arms, try/catch/finally (a handler or the finally may run before the body assigned anything), defer bodies (at each exit that runs them), early return/break/continue, and a lambda, which reads the variables it captures when it is created. Taking the address (&x) counts as an assignment, and sizeof(x) is not a read. Only whole-variable reads of scalar locals are tracked: a struct, union, array, slice or interface local set field by field or element by element never warns. static locals start at zero and are never reported, and the body of an async function is not checked.
18. Preprocessor
A small text pass runs before lexing. It supports object-like and function-like macros and conditional compilation. Directives occupy their own line (the first non-blank character is #); a line inside a string literal that spans lines is string text, never a directive, and no macro expands in it. Both directive lines and skipped lines are blanked out so reported line numbers match the original source.
| Directive | Effect |
|---|---|
#define NAME value |
Object-like macro; later occurrences of NAME are replaced by value |
#define NAME(a, b) body |
Function-like macro; NAME(x, y) substitutes the arguments into body |
#define NAME |
Define NAME with an empty value (useful for #ifdef) |
#undef NAME |
Remove a definition |
#ifdef NAME / #ifndef NAME |
Begin a block compiled only if NAME is / is not defined |
#if expr / #elif expr |
Begin a block / an alternative compiled only if the integer expression is non-zero |
#else / #endif |
Else branch / end of a conditional |
#error message |
Abort compilation with message (only on an active branch) |
#pragma pack(...) |
Struct packing directive; see §8.9 |
#pragma link("name") |
Link the executable with -lname (see below) |
An #if/#elif expression is a C-style integer constant expression: integer and
character literals, macros (expanded first), defined NAME / defined(NAME), the
unary ! ~ - +, the arithmetic, shift, relational, equality, bitwise and logical
operators, ?: and parentheses. An identifier left after expansion counts as 0.
It is evaluated on 64-bit signed integers with two's-complement wraparound (so
INT64_MIN / -1 is INT64_MIN), and a character literal decodes with the same
escapes as in code. &&, || and ?: do not evaluate the operand they skip, so
#if 1 || 1 / 0 is fine. A division by zero that is evaluated, a shift count
outside 0..63, and an integer literal that does not fit in 64 bits are errors.
Any other directive is an error, as is #include (use import), an #else,
#elif or #endif without its #if, a second #else, and a conditional left
open at the end of the file. Inside a skipped branch, unknown directives are
ignored. Macro bodies may not use # (stringification) or ## (token pasting).
Arguments to a function-like macro are split at top-level commas (string and
character literals stay whole) and are macro-expanded before substitution, so
SQ(SQ(2)) works as in C. An invocation must pass as many arguments as the macro
has parameters (a one-parameter macro accepts F() as one empty argument), and an
argument list left open at the end of the line is an error. A replacement that ends
in the name of a function-like macro picks up the (...) that follows it, so after
#define CALLF F, CALLF(2) expands F(2), also when the ( is on a later line. Comments count as whitespace: a //
or /* */ comment in a directive is not part of the macro body, and one inside an
argument list does not end the argument. Files with CRLF line endings are handled,
including \ continuations.
Two predefined macros expand in place: __LINE__ (the current source line, an
integer) and __FILE__ (the current file path, a string literal). Together with
#error they support assertions and build guards:
#ifndef CONFIG_OK
#error "build CONFIG_OK is required"
#endif
printf("%s:%d: reached\n", __FILE__, __LINE__);
A line ending in a backslash (\) is continued onto the next line, so a macro body may span several physical lines (the spliced lines stay counted, so line numbers are preserved):
#define MAX 100
#define SQ(x) ((x) * (x))
#define DEBUG
#define POLY(x) \
((x) * (x) \
+ 2 * (x) + 1)
extern int printf(string fmt, ...);
int main() {
int n = MAX; // 100
int s = SQ(n); // ((100) * (100))
int p = POLY(3); // ((3) * (3) + 2 * (3) + 1) = 16
#ifdef DEBUG
printf("debug build\n");
#endif
return 0;
}
Substitution is identifier-aware and leaves string and character literals untouched. Expansion is recursive: a macro whose body references other macros is expanded fully (a macro is never re-expanded within its own expansion). The macro table is shared across files and follows the text in order, like C's #include: an imported file sees the macros defined before its import line (not the ones defined after it), its own #defines reach the importing file's later lines, and each input of a multi-file compile sees the macros of the inputs before it. A function-like macro invocation may span lines: its argument list continues until the matching ) (a newline inside it reads as a space), and the ( itself may start a later line, past blank lines and comments (ADD then (1, 2) on the next line is a call, as in C). A function-like macro name with no ( after it is left as a plain name.
Unlike the other directives, #pragma is not consumed by the preprocessor. It is passed through to the compiler. #pragma pack (§8.9) and #pragma link are acted upon; any other pragma is ignored. A pragma may appear at the top level, inside a function body or inside a struct body; a pack inside a struct body applies to the structs declared after it (as in C), not to the enclosing one.
#pragma link("name") asks the driver to link the executable with -lname. The
name is what follows -l (letters, digits, _ . + -, not starting with -), in
double quotes; any other form is an error. A pragma in an imported module counts
like one in the main file, each library is linked once however many files name
it, and the order of first appearance is kept. Because the preprocessor has
already run, a pragma inside an inactive #ifdef branch has no effect, which is
how a module links a library on one platform only:
#ifdef __linux__
#pragma link("m") // glibc keeps libm apart from libc
#endif
The libraries are only used when eskiuc links an executable; -c, a .o
output and --freestanding ignore them, and --no-default-libs drops them.
Predefined macros
The compiler predefines these:
| Macro | Value | Notes |
|---|---|---|
__LINE__ |
current source line, an integer | refreshed for every logical line; on a \-continued line it is the line where that logical line starts |
__FILE__ |
current file path, a string literal | the path as passed to the compiler or resolved by import, with \ and " escaped; distinct per file in a multi-file build |
__APPLE__ |
1 (Apple targets) |
target-OS macro, from --target or the host |
__linux__ |
1 (Linux targets) |
|
_WIN32 |
1 (Windows targets) |
also defined for 64-bit Windows |
_WIN64 |
1 (64-bit Windows targets) |
|
__aarch64__ / __x86_64__ / __arm__ |
1 |
target-architecture macro, from --target or else the build host (at most one is defined) |
__ESKIU_FREESTANDING__ |
1 under --freestanding |
lets stdlib target esk_alloc/esk_free instead of libc |
At most one OS family is defined. A bare-metal triple (OS none, e.g. aarch64-none-elf) defines none of them.
__LINE__ and __FILE__ are ordinary object-like macros (so substitution
respects identifier boundaries and skips string/char literals) but their values
are maintained by the compiler. __LINE__ is re-set to the physical line number
of each logical line before that line is expanded. Line splicing happens first, so
a __LINE__ on the second physical line of a \-continued line reports the line
where the logical line starts, not the line where it textually appears.
__FILE__ is threaded from the file currently being compiled or imported, so in
a multi-file or import-driven build each file sees its own path. Together with
#error they support assertions, build guards, and file:line diagnostics:
printf("%s:%d: reached\n", __FILE__, __LINE__); // e.g. "src/main.esk:42: reached"
The host-OS macros let stdlib and user code branch on platform with #ifdef.
This is how <net> selects the correct sockaddr_in layout:
#ifdef __APPLE__
// macOS-specific layout / constants
#else
// Linux
#endif
Shebang lines
If the first line of a file begins with #! (e.g. #!/usr/bin/env eskiuc run),
the preprocessor ignores it and blanks it out, preserving line numbers. This lets a .esk file be marked executable
(chmod +x) and run directly as a script; see eskiuc run in §16.
eskiu