LLPL Documentation

The compiler, the standard library, and the examples

LLPL is a low-level language with Swift/JavaScript-flavored syntax that compiles to C — reference-counted classes, traits, macros, inline assembly, and a compile-time embed() builtin, aimed squarely at bare-metal and kernel-level work as much as ordinary hosted programs. This page is the full reference: the language itself, the standard library that ships with it, and a guided tour of every example in the repository, including a full GRUB-booted kernel with a windowing compositor and a DHCP-configured network stack.

Compiler & CLI

The compiler is a single executable, llpl, built from a small set of D sources under source/: lexer.d (source → tokens), parser.d (tokens → AST), ast.d (AST node definitions), codegen.d (AST → C), modules.d (import resolution), grammar.d (the inline grammar { } feature), errors.d (diagnostics), and lspquery.d (editor/LSP-facing hover and symbol queries).

build the compiler
dub build
# optional, if your code imports the standard library from outside the repo root:
export LLPL_HOME=$(pwd)
FlagWhat it does
-o, --outputOutput file path — a .c source file, or (with -b) a native binary.
-b, --binaryCompile straight to a native binary instead of emitting C — invokes a system C compiler (see --cc).
--ccC compiler to invoke in --binary mode. Defaults to $CC, falling back to cc.
--keep-cKeep the intermediate .c file in --binary mode even on success.
-v, --verboseVerbose output.
--safeEnable runtime safety checks — currently, bounds-checked fixed-size array indexing. Off by default. See Pointers & arrays.
--dceDead-code elimination. On by default.
--lsp-symbolsAnalyze a file and dump diagnostics/symbols/usages as JSON, for editor tooling.
-h, --helpHelp text.
two ways to run a program
# emit C, then compile it yourself
./llpl hello.llpl -o hello.c && gcc hello.c runtime/runtime.c -o hello && ./hello

# or straight to a native binary in one step (links runtime/runtime.c automatically)
./llpl hello.llpl -b -o hello
Scope of -b/--binary

-b/--binary only targets ordinary hosted programs. A freestanding/kernel target like baremetal_demo needs its own tools/llplbuild build instead — a custom linker script, hand-written boot assembly, and -ffreestanding flags that -b doesn't attempt to set up.

Language Reference core syntax

No semicolons, no parentheses around if/while conditions, and type annotations that are usually optional. The syntax reads like a cross between Swift and JavaScript; everything underneath compiles straight to plain C.

Variables

let x: i64 = 42              // mutable
const MAX: i64 = 100        // constant
let name: char* = "Bob"     // string literals are already char*, no cast needed
let x = 42                    // type inferred
volatile let ready: bool = false   // never cached in a register - for MMIO/shared state

Functions & overloading

func add(a: i64, b: i64) -> i64 {
    return a + b
}
func add(a: i64, b: i64, c: i64) -> i64 {   // overload by parameter shape
    return a + b + c
}
func greet(name: char*) {
    print(name)
}

Overloads are picked by exact argument type at each call site where one exists; failing that, a candidate reachable only through implicit numeric coercion (int-to-float, signed-to-unsigned, or a narrower-to-wider integer of the same signedness) is still eligible, and the lowest-total-coercion-cost candidate wins - a tie between two equally-costed candidates is an "Ambiguous call" error. Two declarations with identical parameter types is a compile error, and a call matching no overload is a compile error. Overloaded generic function templates aren't supported (only one func foo<T> per name), though a generic class's own methods can still be overloaded once monomorphized.

Named arguments and defaults

func greet(name: char*, greeting: char* = "Hello") {
    puts(greeting); puts(name)
}
greet("Alice", "Hi")                  // positional
greet("Bob")                          // trailing default omitted
greet(name: "Cara", greeting: "Yo")   // named, in order
greet(greeting: "Hey", name: "Dave")  // named, any order

Once a parameter has a default, every later parameter must too. Once any named argument appears, no further positional argument may follow. This resolves entirely at compile time at the call site — the callee's own signature is unchanged, so it works even against extern func declarations. Not supported for tagged-enum variant construction or macro invocations, which stay positional-only.

Control flow

// no parentheses around conditions
if x > 10 {
    print("Big")
} else {
    print("Small")
}

while count < 10 {
    count = count + 1
}

// init, condition, update - comma separated, no parens
for let i: i64 = 0, i < 10, i = i + 1 {
    print(".")
}

// range-based, counts up to (not including) end
for i in 0..10 {
    print_int("i", i)
}

break/continue affect only the lexically-innermost enclosing loop — nesting a match or if in between doesn't change the target, since match itself compiles to an if/else-if chain, not a C switch.

Custom iterators

Any class with iter_has_next() -> bool and iter_next() -> T (plus an optional iter_reset(), auto-called before the loop if present) works with for x in obj { } — either written inline or via impl Iterator<T> for X { ... }. The trait's own <T> is a documentation convenience; only method names/arities are actually checked against the impl.

if as an expression, and implicit returns

func classify(n: i64) -> char* {
    return if n < 0 { "negative" } else if n == 0 { "zero" } else { "positive" }
}
let y = if false { 1 } else { 2 }   // type inferred from both branches

// a function/method/lambda's trailing bare expression is its own value
func square(n: i64) -> i64 { n * n }             // same as { return n * n }

else is mandatory on an expression-if, and both branches' trailing expression types must match exactly. A bare, unparenthesized if as literally the last line of a body parses as an if-statement, not an expression value — wrap it in parens, (if ... else ...), to use it as a trailing value.

Type casting & sizeof

let addr: u64 = 0xB8000
let buffer: char* = addr as char*

x.sizeof            // no parens - infers the value's own type
sizeof(SomeType)     // explicit type reference

A class-typed value's .sizeof reflects the pointer's size (8 bytes), since classes are always heap-allocated and accessed by pointer.

A note on newline-sensitive unary parsing

A statement starting with *, -, or & right after a prior statement is read as continuing that statement only if it's on the same source line; on a new line it starts a fresh unary expression instead (the same rule Go and Kotlin use). So double-dereference assignment just works with no special syntax:

func set_via_pp(pp: i64**, v: i64) {
    **pp = v      // parses correctly as its own statement
}

Classes

class Rectangle {
    let width: i64
    let height: i64

    constructor(w: i64, h: i64) { self.width = w; self.height = h }
    destructor() { /* cleanup */ }

    func area() -> i64 { return self.width * self.height }
}
let rect: Rectangle = new Rectangle(10, 20)

A field can drop its letwidth: i64 in a class body means the same thing as let width: i64 (still mutable); const/volatile fields must keep their keyword, since that's the only way to distinguish them from a plain mutable field.

private

Restricts access to the declaring class's own body — any method, constructor, or a matching impl Trait for ThisClass {} block. It's class-scoped, not instance-scoped: one instance's method can read another instance's private field. Only fields and methods can be private, not constructors (they must stay reachable via new).

Single inheritance, super(), virtual/override

Dispatch overhead is opt-in per method: an ordinary method call is always statically bound unless the method is declared virtual (establishing a new dispatchable vtable slot) or override (providing this class's implementation of a slot a base declared). override requires an exact signature match — no covariance; a mismatch is a compile error.

3-level hierarchy, from the bare-metal demo's UI widget library
class Widget {
    virtual func draw(buf: u32*, buf_w: i64, buf_h: i64) {}
    virtual func click(px: i64, py: i64) {}
}
class Button : Widget {
    constructor(x: i64, y: i64, w: i64, h: i64, label: char*) {
        super(x, y, w, h)
        self.label = label
    }
    override func draw(buf: u32*, buf_w: i64, buf_h: i64) { /* ... */ }
    override func click(px: i64, py: i64) {}
}
class ActionButton : Button {          // 3rd level - inherits draw() unchanged
    override func click(px: i64, py: i64) {
        self.target.value = self.target.value + 10
    }
}

The bare-metal demo's ui_demo.elf drives an entire UI through one Vector<Widget>, dispatching draw/click/drag purely through virtual calls instead of switching on a type tag by hand. Dotted base names are supported too: class Derived : Ns.Base { ... }.

static

static func on a class has no implicit self parameter and is called on the class rather than an instance.

Reference counting — the exact rules

  • new allocates and sets the reference count to 1.
  • A local class-typed variable gets rc_released automatically at scope exit, on reassignment, and at function return (RAII for class locals).
  • A class-typed field is released only when its owning object's destructor runs — cascading, not scope-based. There's no implicit release just because a field's owner happens to go out of scope somewhere else; it always waits for that owner's own destructor.
  • delete expr releases a reference explicitly — for an object that was never stored as anyone's field. If it was the last reference, the destructor runs and memory is freed; otherwise the object simply survives. delete on a struct or primitive is a compile error.
class Container {
    let item: MyClass
    constructor(item: MyClass) { self.item = item }
    destructor() {}  // self.item is released here, automatically
}
func example() {
    let obj: MyClass = new MyClass()
    let c: Container = new Container(obj)  // Container now owns obj
    delete c  // releases obj too, via Container's destructor
}

Weak<T>

A non-owning reference that never keeps its target alive, and safely reports whether it's still alive — use it to break a reference cycle between two classes that hold each other, so releasing one doesn't cascade back through the other.

class Child {
    let name: char*
    let parent: Weak<Parent>
    func link_parent(p: Parent) { self.parent = new Weak<Parent>(p) }
}
class Parent {
    let child: Child
    constructor(name: char*) { self.child = new Child(name); self.child.link_parent(self) }
}
let parent_ref: Parent = p.child.parent.upgrade()   // real retained ref if alive, else null
if parent_ref != null { delete parent_ref }
delete p  // Parent destroyed, then Child destroyed - no crash

The as_string/as_int/as_float/as_bool convention

Defining a no-arg method with one of these names is picked up automatically by (1) a let/assignment where the target type matches the kind, (2) value as string/i64/float/bool casts, and (3) .as_string property syntax and "\(x)" string interpolation. Only as_string has a fallback (the type's own name as a compile-time literal) for a class/struct/primitive that doesn't define one — as_int/as_float/as_bool have no fallback.

class Point3D {
    func as_string() -> string {
        if self.x == 0 && self.y == 0 && self.z == 0 { return "Point3D(origin)" }
        return "Point3D(non-origin)"
    }
}
puts(p.as_string)           // custom method, no call parens needed
puts(p as string)           // same, via cast
let s: string = p         // same, via plain assignment
puts("interpolated: \(p)") // implicit as_string inside interpolation

Traits & Bounded Generics

A trait is a compile-time-only contract — signatures, no bodies. impl TraitName for TargetType { ... } is how a primitive or struct gains a method body for it (classes can alternatively just define the method inline). Dispatch is entirely static (monomorphization) — there are no vtables or trait objects here, and no heterogeneous "any Comparable" collection through a shared pointer type.

trait Comparable {
    func compare(other: Self) -> i64
}
impl Comparable for i64 {
    func compare(other: i64) -> i64 {
        if self < other { return -1 }
        if self > other { return 1 }
        return 0
    }
}
func max_of<T: Comparable>(a: T, b: T) -> T {   // bounded generic type parameter
    if a.compare(b) >= 0 { return a }
    return b
}

Self in a trait signature (and the matching impl body) resolves by substitution to whatever concrete type the impl targets — it isn't a keyword in its own right. An impl is considered valid if it defines a same-named method for every one the trait declares. v1 limits: at most one bound per type parameter (no T: A + B), no default method bodies, and an impl's target must be concrete (impl X for Vector<i64> is rejected).

prelude.llpl ships Hashable (hash()/equals(other: Self) — implemented for i64/u64/u8/char*/String, the last two hashing/comparing by content via FNV-1a, not pointer identity), Comparable, StringOperators (content-based ==/</etc. for char*), and the operator traits Add/Sub/Neg/Mul — deliberately without impls for primitives, since plain +/-/* already works on i64/u64/u8/char and an impl for them would recurse.

Generics / monomorphization

func max_of<T>(a: T, b: T) -> T { if a > b { return a }; return b }
struct Pair<A, B> { let first: A; let second: B }
let m: i64 = max_of(3, 7)   // T inferred from the arguments - never written explicitly
let p: Pair<i64, i64>

A generic function's type parameters are always inferred from arguments (every parameter must appear in at least one argument's type); a generic class or struct is always instantiated explicitly. Standard generic containers ship in prelude.llpl with no import needed: Vector<T>, Optional<T>, LinkedList<T>, HashMap<K: Hashable, V>, Result<T,E>, Weak<T>, Slice<T>, Owned<T>, OwnedBuffer, Regex/RegexMatch, ParseNode, String.

Known limitation

Vector<T> cannot hold an explicit pointer element type (Vector<char*>/Vector<string>) — it needs a genuine C pointer-to-pointer type this system can't express yet. Wrap the pointer in a one-field struct instead.

Operator Overloading

func operator+(other: T) -> T is a special method-name form, not a distinct language construct — it's usable inline directly on a class, or (the only option for a struct or a primitive, which can't carry inline methods) via impl SomeTrait for TargetType. Both forms compile down to the same internal method name (e.g. op_add for +), so a generic function bounded by the right trait works identically either way.

KindOperators
Binary+ - * / % == != < > <= >= & | ^ << >>
Unary- ! ~ []

Inline on a class

A class defines an operator the same way it defines any other method — no trait or impl block needed, since the class already owns its own method table.

class Money {
    let cents: i64
    constructor(cents: i64) { self.cents = cents }

    func operator+(other: Money) -> Money {
        return new Money(self.cents + other.cents)
    }
    func operator==(other: Money) -> bool {
        return self.cents == other.cents
    }
    func operator-() -> Money {          // unary minus
        return new Money(-self.cents)
    }
}
let total: Money = new Money(150) + new Money(250)   // Money(400)
let refund: Money = -total

Via a trait impl, for a struct or primitive

A struct (or a primitive, when you actually need to override the built-in behavior) can't hold inline methods, so the operator body lives in an impl block instead — the trait itself just declares the signature.

struct Vec2 { let x: i64; let y: i64 }
trait Add { func operator+(other: Self) -> Self }
impl Add for Vec2 {
    func operator+(other: Vec2) -> Vec2 {
        let r: Vec2
        r.x = self.x + other.x
        r.y = self.y + other.y
        return r
    }
}
let c: Vec2 = a + b        // Vec2 { x: 11, y: 22 }

// indexing: struct/class operator[] takes the index and returns the element
impl ... for Vec2 {
    func operator[](i: i64) -> i64 {
        if i == 0 { return self.x }
        return self.y
    }
}
let first: i64 = a[0]   // a.operator[](0)

Validation is nominal (name-only), so an impl's parameter needn't literally be Self:

impl Mul for Vec2 {
    func operator*(scalar: i64) -> Vec2 {   // scaling by a plain i64, not another Vec2
        return Vec2 { x: self.x * scalar, y: self.y * scalar }
    }
}
let doubled: Vec2 = v * 2
Why prelude.llpl doesn't implement these for i64/u64/u8/char

prelude.llpl ships the operator traits Add/Sub/Neg/Mul themselves (so your own bounded generics can require them), but deliberately leaves them unimplemented for the primitives — plain +/-/* already works on i64/u64/u8/char via the compiler's built-in arithmetic, and an impl Add for i64 here would recurse, since operator lookup can't distinguish "the native operator" from "this exact impl".

Operators

The built-in operators, always available on primitives. To make any of these work on your own class/struct types, see Operator Overloading.

CategoryOperators
Arithmetic+ - * / %
Comparison== != < > <= >=
Logical&& (and), || (or), ! (not)
Bitwise& | ^ ~ << >>
Compound assignment+= -= *= /= %= &= |= ^= <<= >>=x += y desugars to x = x + y
Increment/decrement++ -- (postfix only) — i++ desugars to i = i + 1; only recognized as its own statement or parenthesized, never spliced mid-expression like i++ + 5
Member/index. member access, [] array indexing, -> function return type

Macros

Macros expand at compile time. quote { ... } produces statements; quote(expr) produces an expression. Identifiers inside quote are copied literally; unquote(arg) splices a macro argument into the generated syntax.

macro assignTwice(target, value) {
    quote {
        unquote(target) = unquote(value)
        unquote(target) = unquote(target) + 1
    }
}
macro square(value) {
    quote(unquote(value) * unquote(value))
}
func compute() -> i64 {
    let x: i64 = 0
    assignTwice!(x, 41)     // macro invocation: name!(args)
    return square!(x)
}

Macro parameter lists are positional-only, like tagged-enum variant construction — no named-argument support.

Result<T, E> and the ? operator

Result<T, E> (prelude.llpl) is a plain class, not a tagged enum — built that way specifically because a generic tagged enum can't infer both T and E from either variant constructor alone.

class Result<T, E> {
    func set_ok(v: T) { self.ok = true; self.value = v }
    func set_err(e: E) { self.ok = false; self.error = e }
    func get_ok() -> T { return self.value }
    func unwrap() -> T { return self.value }     // alias for get_ok()
    func get_err() -> E { return self.error }
    func get_trace() -> char* { return self.trace }
    func is_ok() -> bool { return self.ok }
    func is_err() -> bool { return !self.ok }
}

expr? unwraps an Optional<T> or Result<T,E> inline: it evaluates to the wrapped value on Some/Ok, or returns early out of the enclosing function with an equivalent None/Err otherwise — the enclosing function must itself return a compatible Optional/Result.

func safe_div(a: i64, b: i64) -> Result<i64, char*> {
    let r: Result<i64, char*> = new Result<i64, char*>()
    if b == 0 { r.set_err("division by zero"); return r }
    r.set_ok(a / b)
    return r
}
func sum_of_divisions(a: i64, b: i64, c: i64, d: i64) -> Result<i64, char*> {
    let first: i64 = safe_div(a, b)?    // early-return on failure, never reaching the addition
    let second: i64 = safe_div(c, d)?
    let result: Result<i64, char*> = new Result<i64, char*>()
    result.set_ok(first + second)
    return result
}

Trace chaining: each ? propagation step records the call-site file:line in the returned Result's trace field. If the error travels through multiple functions, the trace chains textually as "file:line -> file:line". Read it with r.get_trace() (returns char*, null if none was set).

throw / try / catch / finally

Backed by an SJLJ (setjmp/longjmp-style) exception runtime: the compiler emits an explicit handler stack and an x86_64 register save/restore jump buffer, so throw can cross LLPL function boundaries on hosted and bare-metal targets without libc or platform unwind tables.

func safe_div(a: i64, b: i64) -> Result<i64, i64> {
    if b == 0 { throw -1 }
    let r: Result<i64, i64> = new Result<i64, i64>()
    r.set_ok(a / b)
    return r
}
func main() -> i64 {
    try {
        let x: i64 = safe_div(10, 2)?
        print_int("x", x)
        throw 7
    } catch (e: i64) {
        print_int("caught", e)
    } finally {
        puts("finally always runs")
    }
    return 0
}

catch and finally are each independently optional, but at least one must be present — a bare try {} alone is a parse error. Parens around the caught variable are optional (catch e, catch (e: i64), ... all parse identically). finally runs on normal completion, after a caught error, before any return from inside try/catch, and before a throw crosses outward through that try.

Known limitations

One error type per try block. The SJLJ runtime is x86_64-only currently. Optional<T>'s None isn't catchable (a plain ?/is_none() still propagates normally). Cannot safely cross arbitrary external C callbacks that never return to LLPL-generated code.

Panics

extern func llpl_panic(msg: char*)
extern func llpl_set_panic_handler(handler: (char*) -> void)

func my_handler(msg: char*) {
    // log msg, clean up resources, etc.
}
func main() -> i64 {
    llpl_set_panic_handler(my_handler)
    llpl_panic("unrecoverable error")
    return 0
}

llpl_panic prints "PANIC: <msg>\n" and halts — on hosted targets, stderr + abort(); on freestanding targets, whatever a kernel wired up via llpl_set_panic_handler or the weak llpl_panic_putc/llpl_panic_halt hooks (the bare-metal demo overrides these so panics are never silent there). A custom handler runs before the default halt/abort. Used internally by Slice<T>/Vector<T>/OwnedBuffer bounds checks, Optional/Owned<T> access on null, and HashMap.iter_next() exhaustion.

Assert

assert(condition) aborts with a panic if condition is false; an optional second argument supplies a custom message. It's a real statement, not a macro or a function call — the compiler emits it directly as a guarded llpl_panic call:

Written asCompiles to
assert(cond)if (!(cond)) llpl_panic("assertion failed at <file>:<line>");
assert(cond, msg)if (!(cond)) llpl_panic(msg);
func main() -> i64 {
    assert(1 == 1)
    assert(2 > 1, "two is greater than one")
    // assert(1 == 2, "this would panic")
    return 0
}

With no message given, the generated panic text includes the assert's own source file and line ("assertion failed at <file>:<line>"), the same way an unhandled ? failure's trace does — so a failing assert is always locatable from its panic output alone, even without a custom message.

Defer

func process_file(path: char*) -> i64 {
    let fd: i64 = open_file(path)
    if fd < 0 { return -1 }
    defer close_file(fd)
    let buffer: char[1024]
    let bytes: i64 = read_file(fd, buffer as char*, 1024)
    return bytes   // close_file(fd) runs automatically here
}

Multiple defers run in reverse (LIFO) order. A common RAII-cleanup idiom: defer buf = null — reassigning a class-typed local to null triggers its release per the "RAII for class locals" rule (see Reference counting). OwnedBuffer/Owned<T> in prelude.llpl are defer-friendly wrappers with an idempotent .free().

Inline Assembly

func read_cr0() -> u64 {
    let value: u64 = 0
    asm("mov %%cr0, %0" : "=r"(value))
    return value
}
func atomic_inc(p: i64*) -> i64 {
    let one: i64 = 1
    let old: i64 = 0
    asm("lock; xaddq %0, %1"
        : "=r"(old), "+m"(*p)
        : "0"(one)
        : "cc", "memory")
    return old
}

Syntax: asm("template" : outputs : inputs : clobbers) — GCC-style extended asm. Outputs/inputs are comma-separated "constraint"(expression) pairs, exactly like GCC's own. Multiple consecutive string literals concatenate into one template, for multi-line asm.

Pipe operator & unless

Pipe: |>

x |> f desugars to f(x); x |> f(a, b) to f(x, a, b) — the piped value is always inserted as the callee's first argument. Chains read left to right, and it binds looser than every other operator.

func double_it(x: i64) -> i64 { return x * 2 }
func add(a: i64, b: i64) -> i64 { return a + b }

let a: i64 = 5 |> double_it              // double_it(5) = 10
let b: i64 = 5 |> double_it |> add(1)    // add(double_it(5), 1) = 11
let precedence: i64 = 2 + 3 |> double_it   // (2+3) |> double_it = 10, binds looser than +

Composes with Optional/T?:

let parsed: i64? = "123" |> parse_positive

unless

<statement> unless <condition> is a trailing modifier accepted after any statement, desugaring to if !(<condition>) { <statement> }. Checked after the whole statement parses, so it applies to an entire if/else chain if that's the statement it trails.

"123" |> puts() unless false

grammar { }

An ANTLR-like grammar DSL, compiled by the LLPL compiler itself into a real recursive-descent parser class — no runtime interpreter involved.

grammar Calc {
    expr   : expr '+' term
           | expr '-' term
           | term
           ;
    term   : term '*' factor
           | term '/' factor
           | factor
           ;
    factor : '(' expr ')'
           | NUMBER
           ;
    // ALL-CAPS rule name -> "lexer-style": no whitespace skipped between
    // its own elements. Lowercase rule names are "parser-style": whitespace
    // is skipped automatically before each element.
    NUMBER : [0-9]+ ;
}

func main() -> i64 {
    // Calc(text) is sugar for (new Calc(text)).parse_expr() - expr is
    // Calc's first-declared rule, so it's the implicit start rule.
    let tree: ParseNode = Calc(new String("1 + 2 * 3"))
    return 0
}
  • Terminals: quoted literals ('+', "hello") or bracket character classes ([0-9], negated [^\n]), combinable with postfix * + ?; (...) groups a sub-choice.
  • Parsing is fully predictive (lookahead-only, never backtracking) — FIRST/FOLLOW sets are computed at compile time; any ambiguity is a compile-time error, not a runtime misparse.
  • Direct left recursion (like expr : expr '+' term | ... ; above) is automatically rewritten into a standard precedence-climbing loop — alternatives listed first bind tighter, and every operator is left-associative.
  • Every rule becomes a walkable ParseNode (prelude.llpl): .name(), .text(), .child(i), .child_count().
  • An input that fails to match at a point already proven unambiguous reports via llpl_panic, matching this codebase's "loud failure for should-be-unreachable" convention rather than threading a Result through every generated call.

Used for real by stdlib/json/json_parser.llpl's own JSON grammar.

embed("path")

A compiler builtin that bakes a file's raw bytes into the compiled binary as a static array at compile time — no filesystem dependency, no on-target decoding.

func main() -> i64 {
    let asset = embed("embed_asset.txt")
    print_int("len", asset.len as i64)
    print_int("first", asset.data[0] as i64)
    return 0
}

Returns an EmbeddedFile (prelude.llpl):

struct EmbeddedFile {
    let data: char*
    let len: u64
}

The argument must be exactly one string-literal path (a non-literal argument is a compile error), resolved relative to the importing module's own directory. At compile time the compiler reads the file's raw bytes and emits a top-level static unsigned char __llpl_embed_N[len] = { ... }; array, and the embed(...) call site is replaced with a C compound literal pointing at it. A missing file is a compile error. Used by the bare-metal demo's windowing compositor to bake in the desktop wallpaper and cursor images with zero runtime decoding.

Modules

import graphics                 // resolves to graphics.llpl next to the importing file (or a search path)
import drivers.serial            // dotted -> subdirectory: drivers/serial.llpl
import "weird-path.llpl"         // quoted path, for names that aren't valid identifiers
import graphics as G             // alias, qualify via G.member
import { Point, draw as render } from graphics   // selective import, optional rename

Search order (exact):

  1. Relative to the importing file itself.
  2. $LLPL_HOME, if set — e.g. import stdlib.io.file checks $LLPL_HOME/stdlib/io/file.llpl.
  3. Current directory.
  4. lib/ directory.
  5. modules/ directory.

$LLPL_HOME is what lets stdlib modules write import stdlib.... instead of a relative path whose correctness would otherwise depend on the importing file's nesting depth. Compiling from inside the repo's own root works even without LLPL_HOME (current-directory search covers it); a project living elsewhere needs it set, including transitively — e.g. stdlib/yaml/yaml_parser.llpl imports stdlib/text/string_utils.llpl the same way.

Circular imports are fully supported: dependency resolution is a topological sort with cycle handling — when a circular import is detected mid-parse, the compiler notes it and continues, with C forward declarations for everything so mutually-referencing modules compile correctly. Each file is compiled exactly once regardless of how many times it's imported.

Modules are files, not logical namespacing units — use namespace Name { ... } for true symbol isolation independent of file layout:

namespace Graphics.Utils {      // sugar for namespace Graphics { namespace Utils { ... } }
    const VERSION = 2
    func describe() { puts("Graphics.Utils") }
}
Graphics.Utils.describe()

The prelude (prelude.llpl) is always implicitly compiled in ahead of everything else, regardless of whether the entry file or its imports ever mention it.

Type System

Primitive types

History: int / uint / char were removed, then both came back with new meanings

The bare, unsized int/uint and char were removed entirely for a while — using any of them was a hard compile error telling you to spell out i64/u64/u8 instead. All three are back now, but not as the old aliases:

  • u8 is a genuinely numeric unsigned byte (generates C uint8_t, like every other sized unsigned integer).
  • char is one byte of text (generates C char) — distinct from u8 even though both are 8 bits — and is what string is actually built from: alias string = char* in prelude.llpl.
  • int/uint are a third, separate integer family, not aliases of i64/ u64 — native machine-word-sized (C intptr_t/uintptr_t: 4 bytes on i386, 8 on x86_64). Since the actual width isn't known until the generated C is compiled for its target, they never implicitly widen to or from a fixed-width type — an explicit as cast is always required in both directions.

The other widths (i8/i16/i32, u16/u32) were never affected by any of this.

LLPLC type
int8/i8int64/i64int8_tint64_t
uint8/u8uint64/u64uint8_tuint64_t — genuinely numeric
charchar — one byte of text, distinct from u8 even though both are 8 bits
int/uintintptr_t/uintptr_t — native machine word (4 bytes on i386, 8 on x86_64), not aliases of i64/u64; no implicit widening to/from a fixed-width type
boolreal C99 bool (<stdbool.h>) — so extern func bindings to real C libraries returning actual bool (e.g. SDL3) don't conflict
voidvoid
float / doublefloat / double
stringchar* (alias — prelude.llpl: alias string = char*)

String is a distinct, full owned/heap-allocated class (prelude.llpl) — separate from the bare string/char* alias, which owns nothing.

let count: uint = 3
let word_bytes: uint = sizeof(int)   // 4 on i386, 8 on x86_64

// explicit cast required both ways - width isn't known until the C is compiled
let n: i64 = count as i64

int/uint still take part in the coercions that don't depend on width — an int flows to float/double like any other integer, and a signed int flows to any unsigned target (uint included) the same way i64 does.

Pointers & arrays

let ptr: i64* = &value
let arr: char[80]           // fixed-size array

Any depth of indirection is allowed (i64*, i64**, ...). T[] (no size) denotes a dynamic array field distinct from T* — used internally by Vector<T>.data/Slice<T>.ptr so a class type T correctly becomes e.g. String** rather than collapsing to String*.

Bounds checking with --safe

Fixed-size array indexing (T[N], like arr above) is unchecked by default — an out-of-range index is undefined behavior, same as raw C. Compiling with llpl --safe wraps every fixed-size index expression in a runtime bounds check instead: an out-of-range access panics with "index out of bounds" rather than reading or writing past the array. It only covers genuine fixed-size arrays — indexing through a plain pointer (T*) or a dynamic array (T[], Vector<T>, Slice<T>) isn't affected either way, since those already have (or lack) their own bounds behavior independent of this flag. Off by default — pass --safe explicitly to opt in.

Structs, packed structs & unions

struct Point { let x: i64; let y: i64 }
let p: Point = Point { x: 1, y: 2 }      // struct literal - all fields by name, any order

Generic structs work the same way as generic classes; type arguments for a struct literal are inferred from the enclosing declared type, never written in the literal itself.

packed struct emits __attribute__((packed)) on the generated C struct — for exact-layout hardware/wire structures. A real example from the bare-metal demo's GDT setup:

examples/baremetal_demo/gdt.llpl
namespace GDT {
    packed struct Entry {
        let limit_low: u16
        let base_low: u16
        let base_mid: u8
        let access: u8
        let granularity: u8
        let base_high: u8
    }
    packed struct Pointer {   // the 10-byte lgdt/sgdt operand - packed avoids
        let limit: u16      // padding a bare u16 followed by a u64 to an 8-byte boundary
        let base: u64
    }
}

union Name { ... } shares the same declaration shape as struct (fields + optional constructors) minus packed/generics/attributes — it exists mainly to bind an exact C library union.

Enums — plain and tagged

enum Color { RED, GREEN, BLUE = 10, YELLOW }   // YELLOW continues from 11
let c: i64 = Color.BLUE

Tagged (sum type — give any member a field list):

enum Shape {
    Circle(radius: i64),
    Rectangle(width: i64, height: i64),
    Triangle(base: i64, height: i64)
}
match s {
    case Shape.Circle(radius) => { return 3 * radius * radius }
    case Shape.Rectangle(width, height) => { return width * height }
}

A variant is always constructed by calling it (Shape.Circle(3), Shape.Triangle() for zero fields) — never as a bare value.

match — full pattern forms

match t {
    case (x, y) => { ... }               // tuple pattern
}
match p {
    case Point { x, y } => { ... }       // struct pattern
}
match ss {
    case "Hello, world!" => { puts("Matched!") }
    case _ => { puts("Did not match.") }  // wildcard catch-all
}

case pattern[, pattern...] => { ... } also allows comma-separated multiple patterns per case; default => { ... } is the keyword catch-all form, distinct from a wildcard case _ (both work).

Tuples

func split() -> (i64, i64) { return (10, 20) }
let (x, y) = split()                         // destructuring
let p = Point { x: 7, y: 8 }
let Point { px, py } = p                     // struct destructuring
let ((u, v), w) = ((100, 200), 300)          // nested destructuring

(T, U, ...) types and (a, b, ...) literals are value types, implemented internally as generic structs from arity 2 through 8, with positional fields _0, _1, etc.

T? (nullable sugar)

let maybe: i64? = 42       // Optional<i64>, set to 42
let nothing: i64? = null   // empty Optional<i64>

A T? global isn't supported (its initializer needs a real function call, invalid as a C static initializer) — declare it as a local instead.

Slice<T>

A bounds-checked, non-owning {ptr, len} view (plain value type, no constructor/destructor):

struct Slice<T> { let ptr: T[]; let len: u64 }
let view: Slice<i64> = Slice { ptr: arr as i64*, len: 2 }

Vector<T>.as_slice() returns a Slice<T> of exactly its live elements (invalidated by a subsequent reallocating push).

Bit-fields & global attributes

Class fields only (not globals/locals) can declare a bit width:

class PageEntry {
    let present: u32 : 1
    let writable: u32 : 1
    let frame: u32 : 20
}

@section("NAME"), @used, @align(N) are supported on global let/const/volatile declarations:

@section(".limine_requests")
@used
@align(16)
let request: u64[4] = [1, 2, 3, 4]

Closures

Type: (ParamType, ...) -> ReturnType. Literal: func[captures](params) -> ReturnType { ... } — an explicit capture list is required (a missing one is a compile error). Each capture is snapshotted by value at closure-creation time by default; prefix with & to capture by reference (a live pointer to the original variable — lifetime is your own responsibility).

func make_adder(n: i64) -> (i64) -> i64 {
    return func[n](x: i64) -> i64 { return x + n }
}
let add5: (i64) -> i64 = make_adder(5)
let result: i64 = add5(10)   // 15
let doubler: (i64) -> i64 = func(x: i64) -> i64 { return x * 2 }   // no captures: omit [...]

String Interpolation & Regex

String interpolation

"\(...)" — the interpolated expression is captured raw between \( and its matching ) (tracking paren depth, and copying nested string literals verbatim so an internal "/) doesn't end capture early), then re-parsed as an ordinary expression — nested interpolation inside a nested string works recursively. Escape sequences supported in string literals: \n \t \r \\ \" \0 \e (ESC, for ANSI codes), \xHH (exactly two hex digits).

Regular expressions

Regex literal syntax: /pattern/.

let r = /([a-z]+)-([0-9]+)/
let m = r.captures("id-42!")
m.is_match()      // true
m.group(0)        // "id-42" (whole match) - a String
m.group(1)        // "id"
m.group_start(2)  // byte offset of group 2 in the original text
for m in digits.find_all(text) { puts(m.group(0).c_str()) }

digits.replace(text, "#")       // first match only
digits.replace_all(text, "#")   // every match
pair.replace("id-42!", "$2:$1") // "42:id!" - $0/$1/... backreferences, $$ = literal $

Standard Library std.io

RAII-based file handling with automatic resource cleanup, plus free-function convenience wrappers. Import the whole standard library with import stdlib.stdlib, or just this module with import stdlib.io.file. ($LLPL_HOME must point at the repo root for stdlib/... imports to resolve outside the repo — see Modules.)

// Constants (namespace std.io)
O_RDONLY = 0     O_WRONLY = 1     O_RDWR = 2
O_CREAT  = 64    O_TRUNC  = 512   O_APPEND = 1024
SEEK_SET = 0     SEEK_CUR = 1     SEEK_END = 2

class std.io.File alias std.File

MemberSignature
constructor(path: char*, flags: i64) — mode defaults to 0o644
constructor(path: char*, flags: i64, mode: i64)
destructorcalls close(fd) if still open
is_valid() -> bool
read_bytes(buffer: char*, size: u64) -> Result<i64, char*>
write_bytes(buffer: char*, size: u64) -> Result<i64, char*>
read_string(max_size: u64) -> Result<String, char*>
write_string(s: String) -> Result<i64, char*>
seek(offset: i64, whence: i64) -> Result<i64, char*>
tell() -> Result<i64, char*> (self.seek(0, SEEK_CUR))
size() -> Result<i64, char*> (seeks to end, records length, restores position)
read_all() -> Result<String, char*>
flush() -> boolstubbed, always returns true; not a real fsync

Free functions in std.io

FunctionSignatureNotes
read_file(path: char*) -> Result<String, char*>opens O_RDONLY, calls read_all()
write_file(path: char*, content: String) -> Result<i64, char*>opens O_WRONLY | O_CREAT | O_TRUNC
append_file(path: char*, content: String) -> Result<i64, char*>opens O_WRONLY | O_CREAT | O_APPEND
file_exists(path: char*) -> booltries opening O_RDONLY
delete_file(path: char*) -> boolwraps unlink
rename_file(oldpath: char*, newpath: char*) -> boolwraps rename
quick start
let content: Result<String, char*> = std.io.read_file("test.txt")
if content.is_ok() {
    let text: String = content.unwrap()
}
std.io.write_file("output.txt", new String("Hello, World!"))
std.io.append_file("log.txt", new String("New log entry\n"))
the File class directly
let f: std.File = new std.File("data.txt", std.io.O_RDWR | std.io.O_CREAT)
if f.is_valid() {
    f.write_string(new String("Line 1\n"))
    f.seek(0, std.io.SEEK_SET)
    let result: Result<String, char*> = f.read_string(100)
    if result.is_ok() { let data: String = result.unwrap() }
}
// File automatically closed when f goes out of scope
Note

std.io.ERR_FILE_NOT_FOUND/ERR_PERMISSION_DENIED/ERR_IO_ERROR constants exist in source but are never actually returned by anything — the real error convention throughout is always a char* message inside a Result, not these codes.

std.net

TCP/UDP sockets — a raw BSD-socket-style Socket class, plus high-level TcpServer/TcpClient/UdpSocket wrappers. Import: import stdlib.net.socket.

class std.net.Socket alias std.Socket — low-level

MemberSignature
constructor(domain: i64, type: i64, protocol: i64)
is_valid() -> bool
bind_addr(addr: SockAddrIn) -> Result<bool, char*>
listen_backlog(backlog: i64) -> Result<bool, char*>
accept_connection() -> Result<Socket, char*>
connect_to(addr: SockAddrIn) -> Result<bool, char*>
send_data / recv_data(buffer: char*, size: u64) -> Result<i64, char*>
send_string / recv_string(s: String) / (max_size: u64) -> Result<..., char*>
set_reuse_addr(enable: bool) -> Result<bool, char*>
shutdown_socket(how: i64) -> Result<bool, char*>

class std.net.TcpServer alias std.TcpServer

constructor(port: u16)
func start_server(backlog: i64) -> Result<bool, char*>
func accept_client() -> Result<Socket, char*>
func is_valid() -> bool

class std.net.TcpClient alias std.TcpClient

constructor()
func connect_to_host(host_a: u8, host_b: u8, host_c: u8, host_d: u8, port: u16) -> Result<bool, char*>
func send_message(data: String) -> Result<i64, char*>
func recv_message(max_size: u64) -> Result<String, char*>
func is_valid() -> bool
func close_connection()
examples/stdlib/tcp_server.llpl
import stdlib.net.socket
using namespace std.net

func handle_client(client: Socket) {
    let recv_result: Result<String, char*> = client.recv_string(1024)
    if recv_result.is_ok() {
        let message: String = recv_result.unwrap()
        client.send_string(new String("Echo: ") + message.c_str())
    }
}

func main() -> i64 {
    let server: TcpServer = new TcpServer(8080)
    if !server.start_server(10).is_ok() { return 1 }

    while true {
        let client_result: Result<Socket, char*> = server.accept_client()
        if client_result.is_ok() {
            handle_client(client_result.unwrap())
            // Client socket automatically closed when it goes out of scope
        }
    }
    return 0
}
examples/stdlib/tcp_client.llpl
let client: std.net.TcpClient = new std.net.TcpClient()
client.connect_to_host(127 as u8, 0 as u8, 0 as u8, 1 as u8, 8080 as u16)
client.send_message(new String("Hello, Server!"))
let recv_result: Result<String, char*> = client.recv_message(1024)  // "Echo: Hello, Server!"
client.close_connection()

class std.net.UdpSocket alias std.UdpSocket

let udp: std.UdpSocket = new std.UdpSocket(9000)
if udp.bind_socket().is_ok() {
    let buffer: char[1024]
    let recv_result: Result<i64, char*> = udp.recv_from(buffer, 1024)
    if recv_result.is_ok() {
        let dest_addr: std.SockAddrIn = new std.SockAddrIn()
        dest_addr.set_port(9001)
        dest_addr.set_addr(192, 168, 1, 100)
        udp.send_to("response", 8, dest_addr)
    }
}

Constants (namespace std.net)

AF_INET=2  AF_INET6=10
SOCK_STREAM=1  SOCK_DGRAM=2  SOCK_RAW=3
IPPROTO_TCP=6  IPPROTO_UDP=17  IPPROTO_ICMP=1
SOL_SOCKET=1  SO_REUSEADDR=2  SO_KEEPALIVE=9
SHUT_RD=0  SHUT_WR=1  SHUT_RDWR=2

std.text

Trimming, case conversion, split/join, search, replace, padding, a StringBuilder for efficient concatenation, a {}-placeholder Format.format, and byte-level character classification via CharUtils. Import: import stdlib.text.string_utils.

class std.text.StringUtils alias std.StringUtils — all static

split(s: String, delimiter: char) -> Vector<String>
split_str(s: String, delimiter: String) -> Vector<String>
join(strings: Vector<String>, delimiter: String) -> String
trim(s) / trim_start(s) / trim_end(s) -> String
to_upper(s) / to_lower(s) -> String
starts_with(s, prefix) / ends_with(s, suffix) / contains(s, substr) -> bool
index_of(s, substr) / last_index_of(s, substr) -> i64
replace_all(s, old, new) / replace_first(s, old, new) -> String
pad_left(s, length, pad_char) / pad_right(s, length, pad_char) -> String
reverse(s) -> String
repeat(s, times) -> String
examples
let s: String = new String("  Hello, World!  ")
let trimmed: String = std.StringUtils.trim(s)              // "Hello, World!"
let parts: Vector<String> = std.StringUtils.split(trimmed, ',')
let joined: String = std.StringUtils.join(parts, new String(" | "))

let num: String = new String("42")
let padded: String = std.StringUtils.pad_left(num, 5, '0')    // "00042"
let repeated: String = std.StringUtils.repeat(new String("Ha"), 3)  // "HaHaHa"

class std.text.StringBuilder alias std.StringBuilder

let sb: std.StringBuilder = new std.StringBuilder()
sb.append(new String("Name: "))
sb.append(new String("Alice"))
sb.append_char('\n')
sb.append(new String("Age: "))
sb.append_int(30)
let result: String = sb.to_string()   // "Name: Alice\nAge: 30\n"
sb.clear()

Members: append(s), append_char(c), append_int(v), to_string(), clear(), length().

class std.text.Format alias std.Format

let args: Vector<String> = new Vector<String>()
args.push(new String("Alice")); args.push(new String("Engineer")); args.push(new String("42"))
let formatted: String = std.Format.format(new String("Name: {}, Role: {}, ID: {}"), args)
// "Name: Alice, Role: Engineer, ID: 42"

{} placeholders are matched literally by scanning for { immediately followed by } — no positional/named args, no escaping of a literal {}.

class std.text.CharUtils alias std.CharUtils — all static

is_alpha(c) / is_digit(c) / is_alphanumeric(c) / is_whitespace(c) -> bool
is_upper(c) / is_lower(c) -> bool
to_upper(c) / to_lower(c) -> char

std.collections

Linked lists, stack/queue/deque/circular buffer, a red-black tree, heaps/priority queue, hash map/set, a trie, and graphs (list and matrix representations). Import: import stdlib.collections.collections.

Linear — LinkedList<T>, DoublyLinkedList<T>, Stack<T>, Queue<T>, Deque<T>, CircularBuffer<T>

// LinkedList<T> / DoublyLinkedList<T> (the latter adds pop_back, reverse_iterate)
push_front(v) / push_back(v)
pop_front() -> Result<T, char*>
front() / back() / get(index) -> Result<T, char*>
insert(index, v) -> Result<bool, char*>
remove(index) -> Result<T, char*>
size() -> i64 / is_empty() -> bool / clear() / to_vector() -> Vector<T>

// Stack<T>: push(v), pop(), peek(), size(), is_empty(), clear()
// Queue<T>: enqueue(v), dequeue(), front(), back(), size(), is_empty(), clear()
// Deque<T>: push_front/push_back, pop_front/pop_back, front(), back()

// CircularBuffer<T>(capacity: i64)
is_full() / is_empty() -> bool
push(v) -> Result<bool, char*>    // fails if full
push_overwrite(v)             // overwrites oldest if full
pop() / peek() -> Result<T, char*>

RBTree<K, V>

insert(key: K, value: V)
find(key: K) -> Result<V, char*>
contains(key: K) -> bool
min_key() / max_key() -> Result<K, char*>
keys() -> Vector<K> / values() -> Vector<V>
height() -> i64 / clear()
Note

Delete is not yet implemented, despite the general complexity table elsewhere listing an O(log n) delete.

Heaps — BinaryHeap<T> (min-heap), MaxHeap<T>, PriorityQueue<T>

// BinaryHeap<T>: insert(v), extract_min(), peek_min(), heapify(values: Vector<T>)
// MaxHeap<T>: insert(v), extract_max(), peek_max()
// PriorityQueue<T>: enqueue(v, priority: i64), dequeue(), peek()  - lower priority number served first

EnhancedHashMap<K: Hashable, V>

constructor() / constructor(initial_capacity: i64)
insert(key: K, value: V)
get(key: K) -> Result<V, char*>
contains(key: K) -> bool
remove(key: K) -> Result<V, char*>
keys() / values() -> Vector<...>
get_load_factor() -> i64       // percentage; resizes automatically at 75%
get_collision_count() -> i64

HashSet<T: Hashable>

insert(v) / contains(v) -> bool / remove(v) -> bool
to_vector() -> Vector<T>
union_with(other: HashSet<T>) -> HashSet<T>
intersection(other: HashSet<T>) -> HashSet<T>
difference(other: HashSet<T>) -> HashSet<T>

Trie

insert(word: char*) / insert_with_value(word: char*, value: i64)
search(word: char*) -> bool / search_value(word) -> Result<i64, char*>
starts_with(prefix: char*) -> bool
get_words_with_prefix(prefix) -> Vector<String>
get_all_words() -> Vector<String>
count_prefix(prefix) -> i64
longest_common_prefix() -> String

Note: takes bare char* for word/prefix arguments, not String.

Graph (adjacency list) / GraphMatrix (adjacency matrix)

Graph(vertices: i64, directed: bool)
add_edge(from_v, to_v, weight) -> bool
has_edge(from_v, to_v) -> bool
get_neighbors(vertex) -> Vector<i64>
bfs(start) / dfs(start) -> Vector<i64>
is_connected() -> bool
topological_sort() -> Result<Vector<i64>, char*>   // DAG only
has_cycle_directed() -> bool

// GraphMatrix adds: get_weight(from_v, to_v), floyd_warshall() -> i64** (all-pairs shortest paths)
overview example
import stdlib.collections.collections

let rbtree: std.RBTree<i64, String> = new std.RBTree<i64, String>()
rbtree.insert(10, new String("value"))

let trie: std.Trie = new std.Trie()
trie.insert("hello")
let words: Vector<String> = trie.get_words_with_prefix("hel")

let graph: std.Graph = new std.Graph(10, false)  // 10 vertices, undirected
graph.add_edge(0, 1, 5)
let bfs_order: Vector<i64> = graph.bfs(0)
OperationLinkedListRBTreeHashMapBinaryHeapGraph (list)
InsertO(1)*O(log n)O(1)†O(log n)O(1)
SearchO(n)O(log n)O(1)†O(degree)
DeleteO(n)O(log n)O(1)†O(log n)O(degree)
SpaceO(n)O(n)O(n)O(n)O(V + E)

* at head/tail. † average case.

std.sdl

SDL3 bindings — window management, 2D rendering, textures, keyboard/mouse input, and audio playback. Import: import stdlib.sdl.sdl. Requires SDL3 installed and linking with -lSDL3.

namespace std.sdl.SDL

init(flags: u32) -> Result<bool, char*>
quit()
get_error() -> String
get_ticks() -> u64
delay(ms: u32)

class Window

constructor(title: String, w: i64, h: i64, flags: u64)
constructor(title: String, w: i64, h: i64)
is_valid() -> bool / get_size() -> (i64, i64) / set_size(w, h) -> bool
show() / hide() -> bool

class Renderer

constructor(win: Window)
clear() -> bool / present()
set_draw_color(r: u8, g: u8, b: u8, a: u8) -> bool
draw_point / draw_line / draw_rect / fill_rect / draw_frect / fill_frect -> bool
render_texture(texture, src: SDL_FRect*, dst: SDL_FRect*) -> bool
load_texture(filepath: char*) -> Result<Texture, char*>

class EventHandler

poll() -> bool / wait() -> bool / wait_timeout(ms: i32) -> bool
is_quit() / is_key_down() / is_key_up() -> bool
is_mouse_button_down() / is_mouse_button_up() / is_mouse_motion() -> bool
get_key_event() -> SDL_KeyboardEvent*
get_mouse_button_event() -> SDL_MouseButtonEvent*
quick start
import stdlib.sdl.sdl

func main() -> i64 {
    std.sdl.SDL.init(std.sdl.SDL_INIT_VIDEO)
    let window: std.sdl.Window = new std.sdl.Window(new String("My Game"), 800, 600)
    let renderer: std.sdl.Renderer = new std.sdl.Renderer(window)
    let events: std.sdl.EventHandler = new std.sdl.EventHandler()

    let running: bool = true
    for running {
        for events.poll() {
            if events.is_quit() { running = false }
        }
        renderer.set_draw_color(0, 0, 0, 255)
        renderer.clear()
        renderer.set_draw_color(255, 0, 0, 255)
        renderer.fill_rect(new std.sdl.SDL_Rect(100, 100, 200, 150))
        renderer.present()
        std.sdl.SDL.delay(16)
    }
    std.sdl.SDL.quit()
    return 0
}
keyboard state & mouse events
let keys: bool* = std.sdl.Input.get_keyboard_state()
if keys[std.sdl.SDLK_w as i64] != 0 { y = y - speed * dt }

if events.is_mouse_button_down() {
    let mouse: std.sdl.SDL_MouseButtonEvent* = events.get_mouse_button_event()
    let x: float = (*mouse).x
}

Audio: AudioDevice, AudioStream, WavFile, and free functions Audio.play_wav(filepath), Audio.get_num_playback_devices(). Color presets: std.sdl.Colors.black()/white()/red()/green()/blue()/.... Window flags: SDL_WINDOW_FULLSCREEN, RESIZABLE, BORDERLESS, HIDDEN, MAXIMIZED, MINIMIZED, HIGH_PIXEL_DENSITY — OR-able bit flags for the 4-arg Window constructor.

std.args

--name value / -n value flag parsing, positional arguments, required options, and a lone -- to force everything after it to be treated as positional. Import: import stdlib.args.args_parser.

Two supported main shapes: func main(args: string[]) -> i64 (preferred — pass args to parser.parse_args(args)), or the literal C signature func main(argc: i32, argv: char**) -> i64 (pass to parser.parse(argc, argv)).

class ArgParser {
    constructor(program_name: String)
    func add_flag(name, short_name, description: String)
    func add_option(name, short_name, description: String)
    func add_option_default(name, short_name, default_value, description: String)
    func add_required(name, short_name, description: String)
    func parse(argc: i32, argv: char**) -> bool
    func parse_args(args: string[]) -> bool
    func has_flag(name: String) -> bool
    func get_value(name: String) -> Optional<String>
    func get_value_or(name: String, fallback: String) -> String
    func get_positional() -> Vector<String>
    func get_errors() -> Vector<String> / has_errors() -> bool
    func print_errors() / print_help()
}
example
import stdlib.args.args_parser
using namespace std.args

func main(args: string[]) -> i64 {
    let parser: ArgParser = new ArgParser(new String("mytool"))
    parser.add_flag(new String("verbose"), new String("v"), new String("Enable verbose output"))
    parser.add_option_default(new String("output"), new String("o"), new String("a.out"), new String("Output file"))
    parser.add_required(new String("input"), new String("i"), new String("Input file"))

    if !parser.parse_args(args) {
        parser.print_errors(); parser.print_help()
        return 1
    }
    let verbose: bool = parser.has_flag(new String("verbose"))
    let output: String = parser.get_value_or(new String("output"), new String("a.out"))
    let rest: Vector<String> = parser.get_positional()
    return 0
}

Supports --name value, --name=value, -n value, bare boolean flags, positional arguments, and -- to force positional parsing.

std.yaml

A pragmatic, read-only subset of YAML 1.x for config files. Import: import stdlib.yaml.yaml_parser.

Supported: block-style mappings and sequences (including sequence-of-mappings), flow-style collections ([a, b, c], {k: v}), single/double-quoted scalars, plain scalars auto-typed as null/bool/i64/float/string, # comments. Not supported: anchors/aliases, multi-line block scalars, tags, multiple documents. Parsing never fails outright — malformed input degrades to a best-effort partial parse rather than an error.

class YamlValue {
    is_null() / is_bool() / is_int() / is_float() / is_string() / is_sequence() / is_mapping() -> bool
    as_bool() / as_int() / as_float() / as_string() / as_sequence() -> ...
    len() -> i64 / push(item) / get_index(i)
    get(key: String) -> Optional<YamlValue>
    get_or(key: String, fallback: YamlValue) -> YamlValue
    set(key: String, value: YamlValue) / keys() -> Vector<String>
    // static factories: make_null/make_bool/make_int/make_float/make_string/make_sequence/make_mapping
}
func parse(text: String) -> YamlValue   // std.yaml.parse
example
import stdlib.yaml.yaml_parser
import stdlib.io.file
using namespace std.yaml
using namespace std.io

func main() -> i64 {
    let content: Result<String, char*> = read_file("config.yaml")
    if !content.is_ok() { return 1 }
    let doc: YamlValue = parse(content.unwrap())

    let name: String = doc.get_or(new String("name"), YamlValue.make_string(new String(""))).as_string()
    let port: i64 = doc.get_or(new String("server"), YamlValue.make_mapping())
        .get_or(new String("port"), YamlValue.make_int(8080)).as_int()
    return 0
}

std.json

RFC 8259-compliant JSON — parse and stringify (bidirectional, unlike the read-only std.yaml). Import: import stdlib.json.json_parser.

Supports objects, arrays, strings (backslash escapes including \uXXXX), numbers (auto-typed i64 or float depending on whether the literal has a ./e/E), booleans, and null.

class JsonValue {
    // same shape as YamlValue above: is_*/as_*/len/push/get_index/get/get_or/set/keys
    // static factories: make_null/make_bool/make_int/make_float/make_string/make_array/make_object
}
func parse(text: String) -> JsonValue
func stringify(value: JsonValue) -> String    // serializes back to compact JSON
parse, mutate, and re-serialize
let doc: JsonValue = parse(content.unwrap())
let name: String = doc.get_or(new String("name"), JsonValue.make_string(new String(""))).as_string()

let payload: JsonValue = JsonValue.make_object()
payload.set(new String("ok"), JsonValue.make_bool(true))
let text: String = stringify(payload)  // {"ok":true}

Examples baremetal_demo

examples/baremetal_demo/ is the flagship demo: a full bare-metal x86-64 OS kernel written in LLPL, booted via GRUB2/Multiboot2 in QEMU. It's the project's largest single example — paging/VMM, a task scheduler, a persistent on-disk VFS, a dynamic ELF loader with a shared-library ABI (libsys.so), a windowing compositor, and a full user-space network stack with DHCP — all authored in LLPL, with only boot64.asm (the real-mode→long-mode trampoline) and runtime/runtime.c (the C support runtime every LLPL program links against) as non-LLPL glue.

build & run
cd examples/baremetal_demo
../../tools/llplbuild/llplbuild build            # final (optimized) build
../../tools/llplbuild/llplbuild build -c debug   # unoptimized, with -g
../../tools/llplbuild/llplbuild run              # builds, then launches QEMU
../../tools/llplbuild/llplbuild clean            # remove all generated artifacts
../../tools/llplbuild/llplbuild configs          # list configurations

# the compiler's own persistence integration test (boots twice against the same disk.img):
./test-persistence.sh

run expands to qemu-system-x86_64 -cdrom kernel.iso -drive file=disk.img,format=raw,if=ide -serial stdio -m 256. disk.img (64MB, the VFS backing store) is created once and persists across ordinary builds — only clean wipes it — which is what makes the VFS actually survive across reboots. Every user-space program is packaged as a GRUB Multiboot2 module inside kernel.iso and can be launched from the in-kernel shell with run /boot/<name>.elf [args...].

OS-level features

A page-table-based VMM with per-process virtual memory (vmm.llpl), a task/scheduler layer (task.llpl), a persistent on-disk VFS with indirect blocks (vfs.llpl) alongside a separate in-memory tmpfs.llpl for GRUB-loaded modules, a dynamic ELF loader plus shared-library ABI (ldso.llpl, libsys.so), a syscall layer (syscall.llpl), IPC (blocking send/reply and shared memory), a windowing compositor (wm.llpl), and a full user-space e1000 NIC driver with ARP/IPv4/ICMP/DHCP (netd.llpl).

User-space programs

wm.llpl → wm.elfWindowing

The compositor. Owns the real framebuffer and one shared-memory pixel arena sliced into fixed-size per-window regions; clients talk to it over small-message IPC (wm_protocol.llpl), never touching the framebuffer or each other's memory directly. Input delivery is pull-based (OP_POLL_INPUT).

editor.llpl → editor.elf

Windowed text editor over the persistent VFS. Loads argv[1] (or /untitled.txt), F2 saves, ESC closes. Usually launched from the file browser on ENTER; standalone: run /bin/editor.elf /some/path.

filebrowser.llpl → filebrowser.elf

Windowed, read-only VFS directory browser — arrow keys navigate, ENTER opens a file in the editor or descends a directory, ESC goes up.

terminal.llpl → terminal.elf

Windowed terminal — the only in-GUI way to launch a program with arguments or browse/cat a path without the taskbar's fixed buttons.

tetris.llpl → tetris.elfGame

Windowed Tetris, a real client of the wm compositor at its fixed 320×200 window size. Arrow keys move/rotate/soft-drop; shows a GAME OVER overlay and stays open on loss.

netd.llpl → netd.elfNetworking

User-space NIC driver for QEMU's emulated Intel e1000. Owns Ethernet TX/RX rings, ARP, a minimal IPv4 stack, ICMP echo, and a DHCP client (attempted first at boot; falls back to a hardcoded address if no server answers). Exposes one blocking OP_PING operation used by both ping and traceroute.

ping.llpl / traceroute.llpl → ping.elf / traceroute.elf

ping.llpl: fixed TTL, incrementing sequence — ping <ip> [count]. traceroute.llpl: fixed id/seq, incrementing TTL — traceroute <ip> [max_ttl]. Both default to 10.0.2.2, QEMU SLIRP's virtual gateway.

pingview.llpl → pingview.elfNetworking

Graphical, windowed ping monitor — the same OP_PING loop as ping.llpl, but renders a live scrolling bar graph of round-trip iteration count. run /bin/pingview.elf [ip].

atad.llpl → atad.elf

User-space ATA disk driver, registers as "atad", answers {op, lba} requests from the kernel's own ata.llpl client via a shared 512-byte-sector page.

ui_demo.llpl + ui_widgets.llpl → ui_demo.elf

Showcases the widget library: buttons, checkboxes, radio buttons, text boxes, sliders, progress bars — all driven through one Vector<Widget> and virtual dispatch, no manual type-switching (see Single inheritance).

Smaller smoke tests live under userapp/tests/: paired multi-window demos winball1.elf/winball2.elf (proving concurrent compositing and focus-gated input routing), named-service IPC (echod.elf/echoclient.elf), one-shot IPC (echoserver.elf/ipcdemo.elf), shared-memory proof (shmserver.elf/shmclient.elf), the dynamic heap allocator (memtest.elf), a VFS file reader (cat.elf), sys_spawn/sys_wait (spawnwait.elf), sys_exec (execchain.elf), a dynamically-linked LLPL program (dyn_userapp.elf), a minimal statically-linked program (llpl_userapp.elf), and one hand-written assembly ring-3 program (userapp.elf) proving the loader isn't LLPL-specific.

limine_baremetal_demo

A smaller companion to baremetal_demo that boots the same kind of 64-bit higher-half kernel through the Limine boot protocol instead of GRUB/Multiboot2 — Limine request markers, framebuffer/memory-map/HHDM (higher-half direct map) requests, COM1 serial output, direct framebuffer drawing, and a physical-memory probe through the HHDM offset.

Far smaller in scope than baremetal_demo — one kernel ELF, no userland/scheduler/VFS/windowing/networking. Its toolchain uses tcc instead of gcc, and packaging depends on external Limine tooling/assets rather than grub-mkrescue.

build & run
cd examples/limine_baremetal_demo
../../tools/llplbuild/llplbuild build
../../tools/llplbuild/llplbuild run
# if Limine assets aren't at the default /usr/share/limine:
../../tools/llplbuild/llplbuild build --var LIMINE_DIR=/path/to/limine

At boot, serial prints each memory-map entry (base..end len type), the HHDM offset, and a physical-memory probe result; if a framebuffer is available it also draws a memory-map bar (green=usable, grey=reserved, blue=bootloader-reclaimable, yellow=kernel/modules, pink=framebuffer) below a color gradient.

collections

examples/collections/collections_demo.llpl is a comprehensive demonstration of the std.collections standard library — linked lists, stack/queue, heaps, hash maps, red-black trees, tries, and graphs, each actually exercised.

Namespace gotcha worth knowing

The prelude has its own minimal LinkedList<T>, so this file must fully-qualify std.collections.LinkedList<i64> even under using namespace std.collections — the bare name would otherwise resolve to the prelude's own version.

compile & run
./llpl examples/collections/collections_demo.llpl -o /tmp/collections_demo.c
gcc /tmp/collections_demo.c runtime/runtime.c -o /tmp/collections_demo
/tmp/collections_demo

regex

examples/regex.llpl demonstrates regex literals (/[0-9]+/), find_all() iteration with .group(0)/.group_start(0), single and global replace()/replace_all(), capture-group backreferences in replacement strings ("$2:$1"), literal $ escaping ("$$100"), and empty-match handling.

compile & run
./llpl examples/regex.llpl -o /tmp/regex.c
gcc /tmp/regex.c runtime/runtime.c -o /tmp/regex
/tmp/regex

sdl

examples/sdl/ exercises the std.sdl bindings across five programs: simple_test.llpl (minimal init/quit smoke test), basic_window.llpl (window + renderer + shape drawing), interactive_demo.llpl (keyboard/mouse input driving a bouncing ball), pong_game.llpl (a complete two-paddle Pong), and tetris.llpl (a full Tetris with smooth falling-piece interpolation and a timed line-clear flash/compact sequence).

compile & run (requires SDL3)
./llpl examples/sdl/pong_game.llpl -o /tmp/pong_game.c
gcc /tmp/pong_game.c runtime/runtime.c -lSDL3 -o /tmp/pong_game
/tmp/pong_game

None of the five example programs exercise SDL audio, even though the bindings fully support it (AudioDevice, AudioStream, WavFile, WAV playback).

test/ suite

The compiler's own regression suite — /home/nix/Claude/LLPL/test/*.llpl, 51 files, run via:

./run_tests.sh

For each test file, the script compiles it, links against runtime/runtime.c, runs it, and — if a matching test/<name>.expected exists — diffs stdout against it; otherwise it just checks for a clean compile and exit. Reports [PASS]/[FAIL] per file and a final tally.

Representative coverage by area:

AreaSample files
Core languageif_expr_demo, implicit_return_demo, closures_demo, increment_decrement_demo, named_args_demo, overloading_demo, test_tuples, range_for_demo
Type systemgenerics_demo, traits_demo, tagged_enums_demo, pointer_to_pointer_demo, slice_demo, sizeof_demo, as_string_demo, utf8_string
Modules/namespacesimport_alias, import_selective, namespace_alias_demo, nested_namespace_demo
Memory/ownershipowned_helpers, weak_reference_demo, delete_demo, private_members_demo, test_allocator
Error handlingthrow_try_demo, try_catch_demo, test_panic, test_result_trace
Reflection/metaprogrammingreflection_demo, symbol_table_demo, method_address_demo, macro_quote
Pattern matching / regextest_match_destructuring, regex_demo, regex_replace_demo
Collectionshashmap_resize_iter, test_hashmap_string, iterator_trait_demo
embed()embed_demo + embed_asset.txt (see embed())

Web playground

playground/ is a local, single-user web playground — write LLPL in an in-browser editor and see both the generated C and the compiled program's real stdout/stderr/exit code side by side. Each "Run" click compiles the submitted source with the real llpl binary, compiles the generated C with gcc (linking runtime/runtime.c, exactly like test/ and examples/*.llpl are built), and runs the result with a 5-second timeout.

run it
dub build --force        # build the compiler first, if not already built
cd playground
node server.js            # then open http://localhost:8787
# PORT=3000 node server.js   to use a different port

Requires gcc on PATH and Node.js — no npm install needed.

Security

This executes arbitrary, unsandboxed native code submitted through the browser — no container, VM, seccomp, user, or network isolation, only process-level timeouts and output-size limits. It's meant for local personal experimentation only: don't expose it on a network, run it as a shared/multi-tenant service, or run it with elevated privileges without adding a real sandbox first.