Eskiueskiu v0.7.0

Eskiu Language Specification

Version: v0.6.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:

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

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 (az, AZ) or underscore (_), followed by zero or more letters, digits (09), 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
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 (09, af, AF):

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 doublefloat cast). Integer literals are int (i32), widening to int64 when they exceed 32 bits.

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
\0 NUL byte
\\ Backslash
\" Double quote
\' Single quote
\xNN Raw byte from one or two hex digits (e.g. \xC3 is byte 0xC3)

An unrecognized escape (\q) yields the character itself (q).

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

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;

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.

3.3 Array Types

Fixed-size arrays use the form T[N] where N is a compile-time integer constant: a decimal literal, an enum member, or a const int (see §4.6). 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]. A pointer to an array is written with a trailing star instead: T[N]*. For example *Node[7] is seven Node pointers, while Node[7]* points at a seven-Node array.

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. When a struct is passed as an interface, the compiler auto-boxes the value into a fat pointer {data_ptr, vtable_ptr}.

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

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

The one conversion that requires an explicit cast:

Two statically-known mistakes are also compile errors:

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.

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 all LLVM loads and stores that touch the declared pointer. 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
}

Array dimensions accept a decimal literal, an enum member, or a const int. 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.

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


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. Assigning through a dereferenced pointer parameter works correctly. *ptr = value stores through the pointer as expected.

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

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.

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

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)
let y = dup(&x);      // ok

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.

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.

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

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 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); }

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 and matching template instances), so a List<T>* parameter binds T from a List<int>* argument. 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.

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.

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

Implementation detail. The closure fat pointer {fn_ptr, env_ptr} maps directly to the (start_routine, arg) pair expected by pthread_create. No trampoline function is generated. On Linux, link the final binary with -lpthread.

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, return await, x = await E, async void, and every control-flow construct containing an await (if/while/C-style for/switch/for-in, with break/continue) are supported; a pending future is cancelled with future_drop.

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), 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, int, a pointer, etc.

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 matches the declared type, control transfers to that clause and the variable holds the thrown value.

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.

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, so link the final program with -lc++ on macOS or -lstdc++ on Linux (library flags go straight through to the linker):

eskiuc file.esk -o file -lc++      # macOS
eskiuc file.esk -o file -lstdc++   # Linux

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 or integer (non-zero is true). Braces are required around each branch body.

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:

eskiu for (i in 0..10) { // 0,1,…,9 printf("%d\n", i); }

A range desugars to for (int i = A; i < B; i = i + 1), so an empty range (A >= B) runs zero times.

eskiu int[4] xs; xs[0] = 10; xs[1] = 20; xs[2] = 30; xs[3] = 40; for (v in xs) { printf("%d\n", v); }

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 }

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

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

7.5 return

Returns a value from the current function. A void function uses return; with no operand.

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.

int process(string path) {
    *uint8 buf = alloc<uint8>(4096);
    defer free(buf);                 // runs however this function exits

    if (path[0] == 0) { return -1; } // buf freed
    int n = read_into(buf)?;         // buf freed if the `?` propagates an error
    return n;                        // buf freed
}

Rules:

defer complements try/finally (§10): finally is for catch-and-cleanup around a block, while defer colocates a one-off release with its acquisition. For cleanup that must also run when an exception unwinds past the scope, use try/finally.

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:

Connection open_ready(string host) {
    Connection c = connect(host)?;
    errdefer close(c);        // closed only if a later `?` fails; kept on success
    handshake(c)?;            // if this errors, close(c) runs and the Err propagates
    return c;                 // success: c survives, 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. Consecutive bitfields pack into storage words of their declared type; reads mask and shift out the field (signed fields sign-extend), and writes are read-modify-write. You cannot take the address of a bitfield.

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.

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

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. All fields must be provided.

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

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

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), 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. The type arguments of a generic variant 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:

enum Option<T>    { None, Some(T) }
enum Either<A, B> { Left(A), Right(B) }

Option<int> x = Some<int>(42);
Option<int> y = None<int>();
Either<int, string> e = Left<int, string>(7);

match x { Some(v) -> printf("%d\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. 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 and templates.

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. Packed layout (any N) composes with bitfields and is reflected by sizeof and by every field access.


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.

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

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

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

10.4 Using Result from stdlib

import <result>;

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?:

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 int ok field 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(*Self other);
}

// `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;
}

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: type 'int' does not satisfy constraint 'Ord' (required by a bounded type parameter)

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(Self) }. 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.

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. In hosted mode (the default) it calls malloc(N * sizeof(T)). Under --freestanding (see §11.5) it calls the user-provided esk_alloc instead; 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

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

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 (adjacent-region coalescing is a planned refinement)

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

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

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


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>)
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, abs
stdlib/io.esk extern declarations for printf, fprintf, sprintf, scanf, puts
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 (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
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, 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/_header, HttpResponse + HttpResponse_header/_set_body/_render, and a threaded worker pool http_serve(port, nworkers, handler) where handler is fn(HttpRequest*, HttpResponse*)->void. Plus a binary-safe full-body reader HttpReq + http_recv (loops until the Content-Length body arrives, into a *uint8 body), 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) (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, el_add_read, el_add_write, el_del, el_run, el_stop, el_free, plus a timer wheel (el_add_timer/el_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: 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, 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
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
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, and chan_recv (a *Future<T> that completes with the next item)
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, with the same fn(HttpRequest*, HttpResponse*)->void handler interface as <http>
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: drives the opening handshake then a frame-dispatch loop, multiplexes interleaved streams to per-stream slots, HPACK-decodes a completed request into an HttpRequest, and encodes the HttpResponse back as a HEADERS frame plus flow-controlled DATA frames: http2_serve_async
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

Result

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);
}

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

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

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

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)
net_send_str(fd, s) Send a C string (length via strlen)
net_close(fd) Close a socket

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.


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" :: inputs : clobbers);
asm("outb ${0:b}, $1" :: "a"(val), "Nd"(port) : "memory");

Sections are separated by :. Trailing sections may be omitted if empty.

15.3 Notes


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: unused vars/params/functions, assignment-in-condition
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
eskiuc file.esk -o prog -lpthread Link, passing library flags through to the linker
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. -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). 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. Compiler flags go before the script and program arguments after it (eskiuc run --asan file.esk -- 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, collapses consecutive blank lines, and ensures a final newline, but leaves each line's content (operator spacing, comments, string contents) exactly as written, so 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:

file.esk:8:22: undefined variable 'foo'
file.esk:14:5: type mismatch: expected int, got float

The format is file:line:col: message. Line and column numbers are 1-based.


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 #), and 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
#else / #endif Else branch / end of a conditional
#error message Abort compilation with message (only on an active #ifdef branch)
#pragma pack(...) Struct packing directive; see §8.9

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)

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, so a #define propagates into files pulled in by import and into the other inputs of a multi-file compile. A function-like macro invocation must fit on a single (post-continuation) line.

Unlike the other directives, #pragma is not consumed by the preprocessor. It is passed through to the compiler. Only #pragma pack is acted upon (§8.9); any other pragma is ignored.

Predefined macros

The compiler predefines these:

Macro Value Notes
__LINE__ current source line, an integer refreshed for every line; reflects the line of the use, after line-splicing
__FILE__ current file path, a string literal the path as passed to the compiler or resolved by import; distinct per file in a multi-file build
__APPLE__ 1 (macOS only) host-OS macro; exactly one of __APPLE__/__linux__ is defined
__linux__ 1 (Linux only)
__ESKIU_FREESTANDING__ 1 under --freestanding lets stdlib target esk_alloc/esk_free instead of libc

__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, so a __LINE__ inside a multi-line continuation still reports 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 treats it as an unrecognized directive 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 §17.