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:
- 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
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
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.
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):
- 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.
Two 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;. - Division or remainder by a literal zero:
x / 0,x % 0.
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 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).
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:
- 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 (int i = A; i < B; i = i + 1), so an empty range
(A >= B) runs zero times.
- Fixed-size arrays (
T[N]), including array fields:
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].
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:
- 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.- 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 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?:
- 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 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");
- 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. - Outputs: not yet supported. The output section must be empty, so the extended form always begins with
::(the parser has no output-operand rule andAsmStmthas no output field). Return results through a clobbered register or memory instead. - Inputs: list of
"constraint"(expr)pairs. 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.- The compiler performs no validation of the assembly template or constraints beyond forwarding them 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: 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.
eskiu