Getting Started with Eskiu
A hands-on introduction to the Eskiu language. You will go from zero to writing and inspecting real compiled programs in about 30 minutes.
All code blocks in this document compile and run with Eskiu v0.6.2.
Installation
Prerequisites
| Tool | Minimum version | Notes |
|---|---|---|
| LLVM | 17+ | Headers and libraries required |
| CMake | 3.20+ | Build system |
| C++ compiler | C++17 | GCC 7+, Clang 5+, or Apple Clang |
| clang | any recent | Used to link the final binary |
macOS
brew install llvm cmake
export LLVM_DIR=$(brew --prefix llvm)/lib/cmake/llvm
Add the export line to ~/.zshrc to make it permanent. Then clone and build:
git clone https://github.com/doranteseduardo/eskiu.git
cd eskiu
cmake -S . -B build
cmake --build build -j$(sysctl -n hw.ncpu)
Linux (Ubuntu / Debian)
sudo apt-get install -y cmake llvm-17-dev clang-17 build-essential
cmake -S . -B build
cmake --build build -j$(nproc)
Verify
./build/eskiuc --version
# Eskiu 0.6.2 (LLVM 22.1.6) (exact LLVM version depends on your install)
Add ./build to your PATH so you can type eskiuc from any directory.
Your First Program
Create hello.esk:
extern int printf(string fmt, ...);
int add(int a, int b) {
return a + b;
}
int main() {
int result = add(5, 3);
printf("Hello from Eskiu!\n");
printf("Result: %d\n", result);
return 0;
}
Compile and run. When the -o name has no .o extension, eskiuc links the
program into an executable for you (it calls your system C toolchain under the
hood, exactly as rustc does):
eskiuc hello.esk -o hello
./hello
Prefer the two steps, for example to inspect or reuse the object file? Give the
output a .o name (or pass -c) and link it yourself:
eskiuc hello.esk -o hello.o # object only
clang hello.o -o hello # link
./hello
Expected output:
Hello from Eskiu!
Result: 8
Run it like a script
You don't have to compile and link by hand while iterating. eskiuc run compiles to a
temporary executable, runs it, and cleans up, forwarding any arguments after the script:
eskiuc run hello.esk
Add a shebang line and a .esk file becomes directly executable, the way a Python or Ruby
script is:
#!/usr/bin/env eskiuc run
extern int printf(string fmt, ...);
int main() { printf("hello\n"); return 0; }
chmod +x hello.esk
./hello.esk
For an optimized build, pass -O2 (or -O1/-O3), which runs the LLVM middle-end before
code generation. The default is -O0, which emits naive IR straight to the backend.
Peek at the generated IR
Before producing an object file you can ask the compiler to print the LLVM IR it would emit:
eskiuc hello.esk --test-codegen
Abridged output:
@0 = private unnamed_addr constant [19 x i8] c"Hello from Eskiu!\0A\00"
@1 = private unnamed_addr constant [12 x i8] c"Result: %d\0A\00"
declare i32 @printf(ptr, ...)
define i32 @add(i32 %a, i32 %b) {
entry:
%0 = add i32 %a, %b
ret i32 %0
}
define i32 @main() {
entry:
%result = alloca i32, align 4
%0 = call i32 @add(i32 5, i32 3)
store i32 %0, ptr %result, align 4
...
ret i32 0
}
String literals become private globals. Local variables are stack slots
(alloca). The add function compiles down to a single add i32 instruction.
Variables and Types
Declaring variables
Eskiu supports two equivalent declaration styles:
// C-style
int x = 42;
float pi = 3.14;
bool flag = true;
// let-style
let x: int = 42;
let pi: float = 3.14;
let flag: bool = true;
Both styles compile to the same IR. Use whichever reads more clearly in context.
Primitive types
| Type | Width | Notes |
|---|---|---|
bool |
1 bit | true / false |
char |
8-bit | Unsigned; single character |
int |
32-bit | Signed |
int8 |
8-bit | Signed |
int16 |
16-bit | Signed |
int32 |
32-bit | Alias for int |
int64 |
64-bit | Signed |
uint |
32-bit | Unsigned |
uint8 |
8-bit | Unsigned (byte) |
uint16 |
16-bit | Unsigned |
uint32 |
32-bit | Unsigned |
uint64 |
64-bit | Unsigned |
float |
32-bit | IEEE 754 single-precision (literals are double; assigning coerces down) |
double |
64-bit | IEEE 754 double-precision |
string |
pointer | Null-terminated C string |
void |
n/a | Used as function return type |
Integer and hex literals
int a = 255;
int b = 0xFF; // same value, hex prefix supported
uint8 mask = 0x0F;
int64 big = 1000000;
int neg = -42; // negative literals work anywhere, including global scope
float f = -3.14;
Pointer types
Prefix * to make a type a pointer:
let p: *int = null;
int x = 10;
p = &x; // address-of
int val = *p; // dereference
Operators
Arithmetic
int a = 10;
int b = 3;
int sum = a + b; // 13
int diff = a - b; // 7
int prod = a * b; // 30
int quot = a / b; // 3
int rem = a % b; // 1
Comparison
All comparison operators work on integers, floats, and pointers:
bool eq = (a == b);
bool neq = (a != b);
bool lt = (a < b);
bool gt = (a > b);
bool lte = (a <= b);
bool gte = (a >= b);
Logical
bool x = true;
bool y = false;
bool both = x && y; // false
bool either = x || y; // true
bool inv = !x; // false
Bitwise
int flags = 0xFF;
int low = flags & 0x0F; // AND → 15
int hi = flags | 0x100; // OR → 511
int xord = flags ^ 0x55; // XOR → 170
int shl = 1 << 4; // shift left → 16
int shr = flags >> 4; // shift right → 15
int inv2 = ~0; // bitwise NOT → -1
Compound assignment
int x = 10;
x += 5; // x = 15
x -= 3; // x = 12
x *= 2; // x = 24
x /= 4; // x = 6
x %= 4; // x = 2
x &= 0x3; // x = 2
x |= 0x1; // x = 3
x ^= 0x2; // x = 1
x <<= 3; // x = 8
x >>= 1; // x = 4
Address-of and dereference
int n = 42;
let ptr: *int = &n; // address-of
int copy = *ptr; // dereference → 42
Pointer arithmetic
import <mem>; // alloc<T> / free
extern int printf(string fmt, ...);
int main() {
let buf: *uint8 = alloc<uint8>(4);
*buf = 10;
*(buf + 1) = 20;
*(buf + 2) = 30;
printf("%d %d %d\n", *buf, *(buf + 1), *(buf + 2));
free(buf);
return 0;
}
ptr + n advances by n * sizeof(*ptr) bytes: typed arithmetic. Adding 1 to a *uint8 moves one byte; adding 1 to a *int moves four bytes. *void and *char are the exceptions: they always use byte-level stride.
Cast
float f = 3.99;
int i = (int)f; // truncates → 3
uint8 b = (uint8)255;
Control Flow
if / else
extern int printf(string fmt, ...);
int main() {
int x = 7;
if (x > 10) {
printf("big\n");
} else if (x > 4) {
printf("medium\n");
} else {
printf("small\n");
}
return 0;
}
Output: medium
while
extern int printf(string fmt, ...);
int main() {
int i = 1;
while (i <= 5) {
printf("%d\n", i);
i += 1;
}
return 0;
}
for with declaration init
The loop variable can be declared directly in the for header:
extern int printf(string fmt, ...);
int main() {
for (int i = 0; i < 5; i += 1) {
printf("i = %d\n", i);
}
return 0;
}
break and continue
extern int printf(string fmt, ...);
int main() {
for (int i = 0; i < 10; i += 1) {
if (i == 3) { continue; }
if (i == 6) { break; }
printf("%d\n", i);
}
return 0;
}
Output: 0 1 2 4 5 (one per line).
switch / case
extern int printf(string fmt, ...);
int main() {
int day = 3;
switch (day) {
case 1:
printf("Monday\n");
break;
case 2:
printf("Tuesday\n");
break;
case 3:
printf("Wednesday\n");
break;
default:
printf("Other\n");
break;
}
return 0;
}
Output: Wednesday
Functions
Defining and calling
Return type comes first, followed by the name and parameter list:
extern int printf(string fmt, ...);
int square(int n) {
return n * n;
}
bool isEven(int n) {
return n % 2 == 0;
}
int main() {
printf("4^2 = %d\n", square(4));
if (isEven(10)) {
printf("10 is even\n");
}
return 0;
}
extern for C interop
extern declares a function that lives in a C library. The compiler emits an
LLVM declare and the linker resolves it:
extern int printf(string fmt, ...);
extern int puts(string s);
extern double sqrt(double x);
The ... marks a variadic function, required for printf, scanf, etc.
extern also declares a global variable defined in another translation unit.
Drop the parentheses and end with a semicolon; the variable resolves at link time,
so Eskiu can read and write state shared with a C library:
extern int errno; // a C global; read and assign it like any variable
extern float g_volume;
void reset() {
errno = 0;
}
Declaration order
Definition order doesn't matter: a function can call another that's defined later in the file, and mutual recursion works out of the box. You can also write a body-less forward declaration if you prefer to state a signature up front:
int is_odd(int n); // forward declaration (optional)
int is_even(int n) {
if (n == 0) { return 1; }
return is_odd(n - 1); // defined below, works
}
int is_odd(int n) {
if (n == 0) { return 0; }
return is_even(n - 1);
}
Structs
Defining and using
extern int printf(string fmt, ...);
struct Point {
float x;
float y;
}
int main() {
let p: Point = Point { x: 1.5, y: 2.5 };
printf("x=%f y=%f\n", p.x, p.y);
p.x = 10.0;
printf("x=%f\n", p.x);
return 0;
}
Struct literals accept either named fields (Point { x: 1.0, y: 2.0 }) or
positional fields (Point { 1.0, 2.0 }).
Methods with self
Methods are defined inside the struct body and receive a pointer to the instance
as self:
extern int printf(string fmt, ...);
struct Rect {
float w;
float h;
float area() {
return self.w * self.h;
}
void print() {
printf("Rect(%f x %f)\n", self.w, self.h);
}
}
int main() {
let r: Rect = Rect { w: 4.0, h: 3.0 };
r.print();
printf("area = %f\n", r.area());
return 0;
}
The compiler lowers r.area() to Rect_area(ptr %r): the receiver is passed
as the first argument.
Fixed-size array fields
A struct field can be a fixed-size array:
struct Packet {
uint8[858] left;
uint8[858] right;
int len;
}
In LLVM IR this becomes [858 x i8], no heap allocation required.
Enums and match
A plain enum is a set of named integer constants, like C:
enum Color { Red, Green, Blue } // 0, 1, 2
enum Status { Ok = 0, Err = 2, Pending } // 0, 2, 3
let c: Color = Green; // c == 1
When one or more variants carry a payload, the enum becomes an algebraic data type, a tagged union. Variants are constructed by name and destructured with match:
extern int printf(string fmt, ...);
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 the rest
}
}
int main() {
Shape a = Circle(2.0);
printf("%f\n", area(a));
return 0;
}
A match must be exhaustive: every variant needs an arm, or there must be a _ default. Algebraic enums may also be generic (enum Option<T> { None, Some(T) }) and are monomorphized per instantiation. See spec §8.7 for the full rules.
Templates
Template structs
Use angle brackets to parameterize a struct over one or more types:
struct Pair<A, B> {
A first;
B second;
}
Instantiate by supplying concrete types:
extern int printf(string fmt, ...);
struct Pair<A, B> {
A first;
B second;
}
int main() {
let p: Pair<int, float> = Pair<int, float> { first: 7, second: 3.14 };
printf("first=%d second=%f\n", p.first, p.second);
return 0;
}
The compiler performs lazy monomorphic instantiation: Pair<int, float> becomes
%Pair_int_float in IR.
Template functions
extern int printf(string fmt, ...);
T max<T>(T a, T b) {
if (a > b) { return a; }
return b;
}
int main() {
printf("%d\n", max<int>(3, 5)); // explicit type argument
printf("%f\n", max<float>(1.2, 0.8));
printf("%d\n", max(7, 2)); // inferred: T = int
return 0;
}
The return type is T: the function returns the same type it is instantiated with. The type argument can be explicit (max<int>(3, 5)) or inferred from the arguments (max(7, 2)) when the type parameter appears directly as a parameter type.
Result from stdlib
<result> provides an error-as-value type ready to use:
import <result>;
extern int printf(string fmt, ...);
Result<int, string> divide(int a, int b) {
if (b == 0) {
return Err<int, string>("division by zero");
}
return Ok<int, string>(a / b);
}
int main() {
let r: Result<int, string> = divide(10, 2);
if (r.ok == 1) {
printf("result = %d\n", r.value);
} else {
printf("error: %s\n", r.error);
}
return 0;
}
Bounded generics (constraints)
A type parameter can require that its concrete type satisfy one or more interfaces, written after a colon. The constraint is checked at the instantiation site, not deep in codegen:
interface Ord {
int cmp(*Self other);
}
T max<T: Ord>(T a, T b) {
if (a.cmp(&b) > 0) { return a; }
return b;
}
Use + to require several interfaces at once (<K: Hashable + Eq, V>). A struct satisfies a constraint by defining the interface's methods. A primitive type 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 Ord. See spec §10.6 for the full rules.
Interfaces
Interfaces declare a set of method signatures. Any struct that provides matching
methods satisfies the interface. No implements keyword is needed.
Declaring an interface
interface Drawable {
void draw();
}
Implementing structurally
extern int printf(string fmt, ...);
interface Drawable {
void draw();
}
struct Circle {
float radius;
void draw() {
printf("Circle(r=%f)\n", self.radius);
}
}
struct Square {
float side;
void draw() {
printf("Square(s=%f)\n", self.side);
}
}
Circle and Square both satisfy Drawable automatically because they
implement a draw() method with a matching signature.
Passing a struct as an interface
Pass a pointer to the struct where an interface parameter is expected. The
compiler auto-boxes it into a fat pointer {data_ptr, vtable_ptr} and performs
the vtable dispatch at the call site:
void render(Drawable d) {
d.draw();
}
int main() {
let c: Circle = Circle { radius: 5.0 };
let s: Square = Square { side: 3.0 };
render(&c);
render(&s);
return 0;
}
Output:
Circle(r=5.000000)
Square(s=3.000000)
Memory
Stack allocation (default)
All local variables and struct instances live on the stack automatically:
int x = 42;
let p: Point = Point { x: 1.0, y: 2.0 };
Heap allocation with alloc / free
alloc<T>(n) allocates n items of type T on the heap and returns *T;
free(ptr) releases it. Both come from the <mem> stdlib (they are ordinary
generic functions, not keywords):
import <mem>;
extern int printf(string fmt, ...);
int main() {
let buf: *int = alloc<int>(8);
for (int i = 0; i < 8; i += 1) {
*(buf + i) = i * i;
}
for (int i = 0; i < 8; i += 1) {
printf("%d ", *(buf + i));
}
printf("\n");
free(buf);
return 0;
}
Output: 0 1 4 9 16 25 36 49
Null checks
extern int printf(string fmt, ...);
int main() {
let p: *int = null;
if (p == null) {
printf("pointer is null\n");
}
return 0;
}
Pointer comparisons use integer equality, not floating-point equality.
Dereference
int n = 100;
let ptr: *int = &n;
*ptr = 200; // write through pointer
int val = *ptr; // read through pointer, val == 200
Multi-file Projects
Eskiu has two import forms:
import <result>; // stdlib module, resolved by the compiler
import "utils.esk"; // local file, relative to the current file
import <name> looks up the module in the Eskiu installation's stdlib directory. No path required. import "path" is relative to the importing file, as before.
Each file is parsed only once regardless of how many times it is imported.
Example layout
project/
main.esk
utils.esk
utils.esk:
extern int printf(string fmt, ...);
void greet(string name) {
printf("Hello, %s!\n", name);
}
int clamp(int val, int lo, int hi) {
if (val < lo) { return lo; }
if (val > hi) { return hi; }
return val;
}
main.esk:
import "utils.esk";
int main() {
greet("Eskiu");
int v = clamp(150, 0, 100);
return 0;
}
Compile the entry point only; the compiler follows imports automatically:
eskiuc main.esk -o main
./main
Using stdlib modules
Import stdlib modules with angle brackets, no path needed:
import <result>;
import <io>;
import <math>;
Available modules:
| Module | Contents |
|---|---|
<result> |
Result<T,E>, Ok<T,E>(), Err<T,E>() |
<list> |
List<T>: List_init, push, get, len, free |
<map> |
Map<V>: string-keyed hash map (Map_init/_at/_get/_free); HashMap<K,V>: keyed on any type via hash/eq function pointers |
<bytes> |
Bytes: growable, binary-safe byte buffer (_init/_push/_append/_slice/_eq/_from_str, plus base64 round-trip) |
<string> |
String: init, from, append, concat, cstr, len, free, starts_with, ends_with, trim, split, next_token |
<math> |
sqrt, fabs, pow, floor, ceil, abs |
<io> |
printf, fprintf, sprintf, scanf, puts |
<mem> |
alloc<T>(n), free(p), memcpy, memset, memmove, memcmp, strlen |
<alloc> |
Bump, Arena, Pool, FirstFit: explicit allocators over a buffer you own (the Zig model; not a malloc replacement) |
<either> |
sum types: Option<T>, Either<A,B> + helpers (built on generic algebraic enums) |
<futureval> |
value-returning combinators: select2v -> Either, join2v -> Pair |
<http2> |
HTTP/2 (RFC 7540) wire layer: frame-header codec + constants, connection lifecycle (H2Conn), per-stream state machine + flow control (H2Stream), and async frame I/O |
<hpack> |
HPACK (RFC 7541) header compression: static + dynamic tables, prefix-integer / string coding, Huffman |
<http2_server> |
HTTP/2 (h2c, cleartext) server over the event loop: http2_serve_async, multiplexed streams, same handler interface as <http> |
<tls> |
h2 over TLS via OpenSSL with ALPN "h2": http2_tls_serve_conn (blocking) / http2_tls_serve_async |
<sysheap> |
Heap: a general heap that mmaps OS pages and runs FirstFit on them (no libc malloc) |
<fs> |
fs_open, fs_close, fs_read, fs_write, fs_puts, fs_seek, fs_tell, fs_size, fs_read_all, fs_write_all, fs_eof, fs_error |
<net> |
TCP sockets: net_tcp_listen, net_accept, net_tcp_connect, net_send, net_recv, net_close |
<http> |
HTTP/1.1: HttpRequest/HttpResponse, threaded http_serve(port, workers, handler) |
<json> |
Json builder + json_parse → JsonValue tree |
<multipart> |
extract a named part from a multipart/form-data body: multipart_boundary, multipart_part |
<base64> |
base64_encode / base64_decode over byte buffers |
<random> |
seedable PRNG (xoshiro256**): rng_seed, rng_next, rng_below, rng_range, rng_double, rng_bool, rng_fill |
<regex> |
linear-time regex (Thompson NFA): regex_match, regex_compile/regex_search with capture groups (match_group), \d \w \s, * + ? {m,n}, |, ( ), ^ $ |
<sort> |
generic in-place heapsort sort<T> + bsearch<T> over a *T array, via a cmp(&x, &y) function |
<url> |
RFC 3986 percent-encoding: url_encode, url_decode, and form-query lookup url_query_get |
<uuid> |
RFC 4122 v4 UUIDs: uuid_v4(&rng, &out) (built on <random>) |
<time> |
time_now_ms, time_now_s, time_monotonic_ms, sleep_ms; UTC civil calendar (DateTime, time_to_utc/time_from_utc, time_format_iso) |
<env> |
env_get, env_has, env_get_or, env_get_int |
<path> |
path_join, path_basename, path_dirname, path_extension, path_is_absolute |
<threading> |
Mutex, Cond, Sem over pthread (pairs with thread_create/thread_join) |
<eventloop> |
readiness reactor over kqueue/epoll: el_new/el_add_read/el_run/el_stop/el_add_timer |
<atomic> |
atomic int cell: atomic_load/atomic_store/atomic_swap/atomic_cas |
<future> |
the async/await runtime: Future<T>, future_poll/complete/drop, spawn/select2/join2 |
<executor> |
Executor: event loop + thread-safe ready-queue + self-pipe wakeup |
<net_async> |
async leaf futures: net_read_async, net_accept_async |
<timer> |
timer_after(lp, ms): a *Future<int> that completes after a delay (timeouts) |
<channel> |
async message channel: chan_new/chan_send/chan_recv (a *Future<T>) |
<http_async> |
non-blocking concurrent HTTP/1.1 server: http_serve_async |
Note: when using <math> link with -lm. Library flags are passed straight
through to the linker, so the one-command form works too:
eskiuc file.esk -o file -lm
Standard library highlights
A few of the newer modules. See the language spec §14 for the full reference.
<alloc>: allocators over a caller buffer. alloc_with(&a, T, n) carves a
typed *T out of any struct that exposes a _alloc method. Bump is the
simplest: individual frees are no-ops; one free of the backing buffer releases
everything.
import <mem>;
import <alloc>;
extern int printf(string fmt, ...);
int main() {
*uint8 backing = alloc<uint8>(4096);
let a: Bump; Bump_init(&a, backing, 4096);
*int xs = alloc_with(&a, int, 16); // 16 ints from the slab, no per-object malloc
xs[0] = 7; xs[15] = 9;
printf("%d %d\n", xs[0], xs[15]);
free(backing); // frees xs too
return 0;
}
<json>: build and parse. The builder inserts separators automatically;
json_parse returns a *JsonValue tree.
import <json>;
extern int printf(string fmt, ...);
int main() {
let j: Json; Json_init(&j);
Json_obj_begin(&j);
Json_key(&j, "year"); Json_int(&j, 2026);
Json_obj_end(&j);
printf("%s\n", Json_cstr(&j)); // {"year":2026}
*JsonValue v = json_parse(Json_cstr(&j));
printf("year=%lld\n", JsonValue_as_int(JsonValue_get(v, "year")));
JsonValue_free(v); Json_free(&j);
return 0;
}
<http>: a concurrent server in a few lines. The handler fills the
response; http_serve runs a pool of worker threads over <net> + <threading>.
import <http>;
void handle(HttpRequest* req, HttpResponse* res) {
HttpResponse_header(res, "Content-Type", "text/plain");
HttpResponse_set_body(res, "Hello from Eskiu\n");
}
int main() { http_serve(8080, 4, handle); return 0; } // 4 worker threads
Lambdas
Eskiu supports anonymous functions (lambdas) as first-class values. The syntax mirrors a regular function declaration without a name.
Function pointer types
Use the fn(T,...)->R syntax to declare a function pointer type:
let f: fn(int)->int;
let g: fn(int, int)->bool;
Writing a lambda
extern int printf(string fmt, ...);
int main() {
let double_it: fn(int)->int = int(int x) { return x * 2; };
printf("%d\n", double_it(5)); // 10
return 0;
}
Passing lambdas as arguments
extern int printf(string fmt, ...);
int apply(fn(int)->int f, int x) {
return f(x);
}
int main() {
let square: fn(int)->int = int(int n) { return n * n; };
printf("%d\n", apply(square, 6)); // 36
printf("%d\n", apply(int(int n) { return n + 1; }, 9)); // 10
return 0;
}
Closures: capturing from the enclosing scope
A lambda can reference variables declared in the surrounding scope. Those variables are captured by value at the point the lambda is created.
extern int printf(string fmt, ...);
int main() {
int base = 10;
let add: fn(int)->int = int(int x) { return x + base; };
printf("%d\n", add(5)); // 15
printf("%d\n", add(7)); // 17
return 0;
}
Closures can be passed to higher-order functions exactly like plain lambdas: the fn(T)->R type is the same in both cases.
int apply(fn(int)->int f, int x) { return f(x); }
int main() {
int offset = 100;
let shift: fn(int)->int = int(int x) { return x + offset; };
printf("%d\n", apply(shift, 5)); // 105
return 0;
}
Under the hood, fn(T)->R is a fat pointer {fn_ptr, env_ptr}. Non-capturing lambdas have env_ptr = null and behave identically to before.
Threads
thread_create and thread_join are language keywords. Pass any fn()->void value, including a closure, to spawn a thread.
Basic usage
extern int printf(string fmt, ...);
int main() {
*void t = thread_create(void() { printf("hello from thread\n"); });
thread_join(t);
return 0;
}
Capturing outer state
extern int printf(string fmt, ...);
int main() {
int id = 42;
let worker: fn()->void = void() { printf("thread id = %d\n", id); };
*void t = thread_create(worker);
thread_join(t);
return 0;
}
The closure fat pointer maps directly to pthread's (start_routine, arg) pair: no trampoline is generated. On Linux, link with -lpthread:
eskiuc threads.esk -o threads -lpthread
./threads
Exception Handling
Use try, catch, finally, and throw for structured exception handling.
Basic try/catch
extern int printf(string fmt, ...);
int divide(int a, int b) {
if (b == 0) {
throw "division by zero";
}
return a / b;
}
int main() {
try {
int r = divide(10, 0);
printf("result: %d\n", r);
} catch (string e) {
printf("caught: %s\n", e);
}
return 0;
}
Output: caught: division by zero
With finally
The finally block always executes, whether or not an exception was raised:
extern int printf(string fmt, ...);
int main() {
try {
throw "error";
} catch (string e) {
printf("caught: %s\n", e);
} finally {
printf("cleanup\n");
}
return 0;
}
Output:
caught: error
cleanup
Linking
Exception handling uses the Itanium C++ ABI personality function. Link with -lc++ on macOS or -lstdc++ on Linux:
eskiuc file.esk -o file -lc++ # macOS
eskiuc file.esk -o file -lstdc++ # Linux
./file
IDE Tooling (VS Code)
The editor/vscode/ directory contains a VS Code extension for Eskiu.
Install
ln -s $(pwd)/editor/vscode ~/.vscode/extensions/eskiu-language
Restart VS Code. .esk files will have syntax highlighting immediately.
Features
| Feature | How it works |
|---|---|
| Syntax highlighting | TextMate grammar (eskiu.tmLanguage.json) |
| Real-time error squiggles | Extension runs eskiuc --test-typechecker on save and parses file:line:col: message output |
| Hover type info | Extension calls eskiuc --hover-at LINE:COL and shows the result in a tooltip |
| Go-to-definition | Extension calls eskiuc --definition-at LINE:COL and jumps to the returned location |
CLI flags used by the extension
# Print the inferred type of the expression at line 10, column 5
eskiuc file.esk --hover-at 10:5
# Print the definition location of the symbol at line 10, column 5
eskiuc file.esk --definition-at 10:5
Both flags accept LINE:COL with 1-based line and column numbers and print a
single line of output. They can also be used directly on the command line for
scripting.
Using the Test Modes
The compiler exposes diagnostic flags that stop compilation after a specific phase and print what was produced. None of them produce an object file.
| Flag | Phase | Output | When to use |
|---|---|---|---|
--test-lexer |
Lexer | Token stream with line/col | Debugging unexpected parse errors |
--test-parser |
Parser | Indented AST | Checking whether syntax is parsed correctly |
--test-typechecker |
Type checker | Errors, then Type checking succeeded! / failed! |
Catching type mismatches before codegen |
--test-codegen |
Code gen | LLVM IR | Inspecting what IR the compiler produces |
--hover-at LINE:COL |
Type checker | Type at position | Hover type info (used by VS Code extension) |
--definition-at LINE:COL |
Type checker | Definition location | Go-to-definition (used by VS Code extension) |
--test-lexer
eskiuc hello.esk --test-lexer
Produces one token per line with its kind, text, and line:col position.
Useful when you see a parse error and want to check if the tokenizer is splitting
tokens correctly.
--test-parser
eskiuc hello.esk --test-parser
Prints the full AST as an indented tree. Each node shows its kind and the relevant identifiers or literals. Use this to verify that operator precedence, struct literals, and template arguments are parsed the way you expect.
--test-typechecker
eskiuc hello.esk --test-typechecker
Runs type inference and struct field validation. If a struct field name is wrong you see the error here, before any IR is generated:
hello.esk:5:14: struct 'Point' has no member 'z'
--test-codegen
eskiuc hello.esk --test-codegen
Prints the full LLVM IR to stdout. Useful for verifying that a cast, bitwise operation, or pointer dereference lowered the way you intended.
Inline Assembly and volatile
These features are primarily useful for bare-metal and kernel programming. Both are available in any Eskiu program.
volatile
Mark a pointer variable volatile to prevent the compiler from optimising away loads and stores through it. This is essential for memory-mapped I/O (MMIO) registers:
volatile let uart: *uint8 = (uint8*) 0x3F8;
// Both the read and the write are always emitted, never cached or removed
uint8 status = *uart;
*uart = 'A';
Without volatile, the compiler may eliminate or reorder accesses to addresses it considers side-effect-free.
Inline assembly: simple form
asm("cli"); // disable interrupts
asm("sti"); // enable interrupts
asm("nop");
The string is passed verbatim to the assembler. Use this form for instructions that take no operands.
Inline assembly: extended form
The extended form passes values in and out of the asm template using GCC-compatible constraints:
// Write a byte to an x86 I/O port
void outb(uint8 val, uint16 port) {
asm("outb ${0:b}, $1" :: "a"(val), "Nd"(port) : "memory");
}
Syntax: asm("template" : outputs : inputs : clobbers);
- Inputs and outputs are
"constraint"(expression)pairs. "memory"in the clobber list acts as a compiler barrier.- Common constraints:
"a"→ rax/eax,"Nd"→ 8-bit immediate or dx,"r"→ any register.
Freestanding mode
When targeting bare metal or a kernel, pass --freestanding to the compiler. This redirects the built-in alloc and free to user-provided esk_alloc / esk_free symbols instead of the libc malloc / free:
eskiuc kernel.esk --target x86_64-pc-linux-gnu --freestanding -o kernel.o
You must provide esk_alloc and esk_free in your own source or a C shim:
// kernel_alloc.esk, linked together with kernel.esk
*void esk_alloc(int size) { return bump_alloc(size); }
void esk_free(*void ptr) { bump_free(ptr); }
Safe mode
Pass --safe to insert runtime safety checks. Today it bounds-checks slice and
fixed-array indexing: an out-of-range index traps (aborts) instead of reading or writing
past the end. The checks are off by default, so release builds carry no overhead;
turn them on for development and test runs.
eskiuc app.esk --safe -o app # a[i] / s[i] out of range now aborts instead of corrupting memory
int[5] a = {1, 2, 3, 4, 5};
int[] s = a[1..4]; // length 3
int x = s[9]; // under --safe: aborts here; without it: reads garbage (as in C)
sizeof
sizeof(T) is a compile-time constant expression that returns the size of a type in bytes as an int64. It works for any Eskiu type, including structs.
extern int printf(string fmt, ...);
struct Grid {
float x;
float y;
float z;
}
int main() {
printf("sizeof(int) = %lld\n", sizeof(int)); // 4
printf("sizeof(int64) = %lld\n", sizeof(int64)); // 8
printf("sizeof(Grid) = %lld\n", sizeof(Grid)); // 12
return 0;
}
sizeof is resolved entirely at compile time and produces no runtime code.
Unions
A union is declared like a struct, but all fields share offset 0. The size of the union equals the size of its largest field. Reading a field reinterprets the stored bytes as that field's type.
extern int printf(string fmt, ...);
union Value {
int i;
float f;
}
int main() {
let v: Value;
v.i = 0x3F800000; // IEEE 754 bit pattern for 1.0
printf("%f\n", v.f); // 1.000000, same bytes, read as float
v.f = 3.14;
printf("%d\n", v.i); // raw integer bits of 3.14
return 0;
}
Typed Pointer Arithmetic
Pointer arithmetic scales by the pointed-to type's size, matching C behaviour. Adding 1 to a *int advances 4 bytes; adding 1 to a *Grid advances sizeof(Grid) bytes.
import <mem>;
extern int printf(string fmt, ...);
int main() {
let buf: *int = alloc<int>(4);
*buf = 10;
*(buf + 1) = 20; // +4 bytes (one int)
*(buf + 2) = 30; // +8 bytes
*(buf + 3) = 40; // +12 bytes
for (int i = 0; i < 4; i += 1) {
printf("%d\n", *(buf + i));
}
free(buf);
return 0;
}
*void and *char are exceptions: they always step one byte at a time.
What's Next
- Full language reference:
docs/lang/spec.md - Standard library source:
stdlib/ - Worked examples:
examples/ - Contributing guide:
docs/dev/
eskiu