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).
dub build
# optional, if your code imports the standard library from outside the repo root:
export LLPL_HOME=$(pwd)
| Flag | What it does |
|---|---|
-o, --output | Output file path — a .c source file, or (with -b) a native binary. |
-b, --binary | Compile straight to a native binary instead of emitting C — invokes a system C compiler (see --cc). |
--cc | C compiler to invoke in --binary mode. Defaults to $CC, falling back to cc. |
--keep-c | Keep the intermediate .c file in --binary mode even on success. |
-v, --verbose | Verbose output. |
--safe | Enable runtime safety checks — currently, bounds-checked fixed-size array indexing. Off by default. See Pointers & arrays. |
--dce | Dead-code elimination. On by default. |
--lsp-symbols | Analyze a file and dump diagnostics/symbols/usages as JSON, for editor tooling. |
-h, --help | Help text. |
# 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
-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 let — width: 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.
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
newallocates 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 exprreleases 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.deleteon 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.
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.
| Kind | Operators |
|---|---|
| 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
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.
| Category | Operators |
|---|---|
| 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.
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 as | Compiles 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 aResultthrough 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):
- Relative to the importing file itself.
$LLPL_HOME, if set — e.g.import stdlib.io.filechecks$LLPL_HOME/stdlib/io/file.llpl.- Current directory.
lib/directory.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
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:
u8is a genuinely numeric unsigned byte (generates Cuint8_t, like every other sized unsigned integer).charis one byte of text (generates Cchar) — distinct fromu8even though both are 8 bits — and is whatstringis actually built from:alias string = char*inprelude.llpl.int/uintare a third, separate integer family, not aliases ofi64/u64— native machine-word-sized (Cintptr_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 explicitascast is always required in both directions.
The other widths (i8/i16/i32, u16/u32)
were never affected by any of this.
| LLPL | C type |
|---|---|
int8/i8 … int64/i64 | int8_t … int64_t |
uint8/u8 … uint64/u64 | uint8_t … uint64_t — genuinely numeric |
char | char — one byte of text, distinct from u8 even though both are 8 bits |
int/uint | intptr_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 |
bool | real C99 bool (<stdbool.h>) — so extern func bindings to real C libraries returning actual bool (e.g. SDL3) don't conflict |
void | void |
float / double | float / double |
string | char* (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*.
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:
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
| Member | Signature |
|---|---|
| constructor | (path: char*, flags: i64) — mode defaults to 0o644 |
| constructor | (path: char*, flags: i64, mode: i64) |
| destructor | calls 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 | () -> bool — stubbed, always returns true; not a real fsync |
Free functions in std.io
| Function | Signature | Notes |
|---|---|---|
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*) -> bool | tries opening O_RDONLY |
delete_file | (path: char*) -> bool | wraps unlink |
rename_file | (oldpath: char*, newpath: char*) -> bool | wraps rename |
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"))
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
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
| Member | Signature |
|---|---|
| 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()
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 }
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
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()
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)
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)
| Operation | LinkedList | RBTree | HashMap | BinaryHeap | Graph (list) |
|---|---|---|---|---|---|
| Insert | O(1)* | O(log n) | O(1)† | O(log n) | O(1) |
| Search | O(n) | O(log n) | O(1)† | — | O(degree) |
| Delete | O(n) | O(log n) | O(1)† | O(log n) | O(degree) |
| Space | O(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*
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 }
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() }
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
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
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.
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.
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.
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.
./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.
./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).
./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:
| Area | Sample files |
|---|---|
| Core language | if_expr_demo, implicit_return_demo, closures_demo, increment_decrement_demo, named_args_demo, overloading_demo, test_tuples, range_for_demo |
| Type system | generics_demo, traits_demo, tagged_enums_demo, pointer_to_pointer_demo, slice_demo, sizeof_demo, as_string_demo, utf8_string |
| Modules/namespaces | import_alias, import_selective, namespace_alias_demo, nested_namespace_demo |
| Memory/ownership | owned_helpers, weak_reference_demo, delete_demo, private_members_demo, test_allocator |
| Error handling | throw_try_demo, try_catch_demo, test_panic, test_result_trace |
| Reflection/metaprogramming | reflection_demo, symbol_table_demo, method_address_demo, macro_quote |
| Pattern matching / regex | test_match_destructuring, regex_demo, regex_replace_demo |
| Collections | hashmap_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.
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.
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.