1. Install the compiler
Loon compiles on Linux x86-64. You need NASM (the Netwide Assembler) and a linker — both come standard on most distributions.
Linux (x86-64)
Install NASM, clone the repository, and build the bootstrap chain:
# Install the assembler
sudo apt install nasm
# Clone the repository
git clone https://github.com/mplsllc/loon.git
cd loon
# Build the bootstrap chain: stage0 (lexer) then stage1 (compiler)
cd stage0 && make && cd ../stage1 && make && cd ..
# Compile the stage2 compiler (written in Loon) using the bootstrap chain
./stage0/lexer stage2/compiler.loon | ./stage1/compiler > /tmp/loon.asm
nasm -f elf64 -o /tmp/loon.o /tmp/loon.asm
ld -o loon /tmp/loon.o
# Install system-wide
sudo mv loon /usr/local/bin/
macOS / Windows
Loon targets Linux x86-64 natively. For macOS and Windows, see the Building from Source documentation for cross-compilation options and WSL instructions.
2. Hello world
Create a file called hello.loon:
module main;
fn main() [IO] -> Unit {
do print("Hello, Loon!");
do exit(0);
}
Compile and run it:
loon hello.loon > hello.asm && nasm -f elf64 -o hello.o hello.asm && ld -o hello hello.o && ./hello
You should see Hello, Loon! printed to the terminal.
Three things to notice:
module main;— Every Loon file begins with a module declaration. There is one entry point: themainfunction in themainmodule.[IO]— This is an effect declaration. It tells the compiler (and every reader of this code) thatmainperforms I/O. Functions must declare their effects. If a function claims to be pure, the compiler enforces it.do— Thedokeyword marks effectful calls. You cannot callprintwithout it. This makes every side effect visible at the call site.
3. Functions and effects
Define a pure function — one that takes values and returns a value, with no side effects:
fn add(a: Int, b: Int) [] -> Int {
a + b
}
The empty brackets [] mean this function has no effects. It cannot print, it cannot read files, it cannot do anything other than compute a value from its inputs.
What happens if a pure function tries to call print?
fn add(a: Int, b: Int) [] -> Int {
do print("adding"); // try to sneak in some I/O
a + b
}
error[E0301]: effect violation in function `add`
--> math.loon:2:5
|
2 | do print("adding");
| ^^^^^^^^^^^^^^^^^^^ `print` requires [IO] effect
|
= note: function `add` declares effects [] but calls require [IO]
= help: add IO to the effect list: fn add(a: Int, b: Int) [IO] -> Int
The compiler rejects it. A function that claims to be pure must actually be pure. There is no way to sneak a side effect into a pure function.
4. Types and ADTs
Loon has algebraic data types (ADTs). Define a Shape type with three variants:
type Shape =
| Circle(Float)
| Rectangle(Float, Float)
| Point;
Use match to handle each variant:
fn describe(s: Shape) [] -> String {
match s {
Circle(r) -> "Circle with radius " + float_to_string(r),
Rectangle(w, h) -> "Rectangle " + float_to_string(w) + "x" + float_to_string(h),
Point -> "Point",
}
}
Every match in Loon must be exhaustive. If you forget a variant, the compiler tells you:
fn describe(s: Shape) [] -> String {
match s {
Circle(r) -> "Circle with radius " + float_to_string(r),
Rectangle(w, h) -> "Rectangle",
// Point is missing
}
}
error[E0401]: non-exhaustive match expression
--> shapes.loon:8:5
|
8 | match s {
| ^^^^^^^^ missing variant: `Point`
|
= note: all variants of `Shape` must be handled
= help: add the missing arm: Point -> ...
No variant falls through. No default case silently swallows a new variant added six months later. If the type changes, every match that handles it must be updated, and the compiler will tell you exactly which ones.
5. Privacy types
This is the moment. This is why Loon exists.
Create a file called auth.loon:
module auth;
fn main() [IO] -> Unit {
let username: Public<String> = "alice";
let password: Sensitive<String> = "hunter2";
do print("User: " + username); // OK
// do print("Pass: " + password); // COMPILE ERROR
do exit(0);
}
Uncomment the second print line. The compiler rejects it:
error[E0501]: privacy violation — Sensitive value cannot flow to public output
--> auth.loon:8:27
|
8 | do print("Pass: " + password);
| ^^^^^^^^ `password` has privacy level Sensitive
|
= note: `print` is a public output channel
= note: Sensitive data cannot be printed, logged, serialized, or returned from a public API
= help: use expose(password, "reason") with [Audit] effect to explicitly declassify
It is not just print. Try assigning the password to a plain variable:
let copy: String = password; // COMPILE ERROR
error[E0502]: privacy violation — cannot assign Sensitive<String> to String
--> auth.loon:9:24
|
9 | let copy: String = password;
| ^^^^^^^^ Sensitive<String> cannot be assigned to String
|
= note: this would strip the Sensitive wrapper, allowing the value to leak
Try passing it to a function that accepts a plain String:
fn log_value(v: String) [IO] -> Unit {
do print(v);
}
// In main:
do log_value(password); // COMPILE ERROR
error[E0503]: privacy violation — Sensitive<String> cannot flow to String parameter
--> auth.loon:14:19
|
14 | do log_value(password);
| ^^^^^^^^ expected String, found Sensitive<String>
|
= note: function `log_value` accepts String, which is a public type
= note: passing Sensitive data to a public parameter would allow it to leak
The compiler blocks every path. You cannot print it, assign it to an unwrapped variable, pass it to a function that does not expect sensitive data, or serialize it. The password is locked at the type level.
6. The escape hatch: expose()
Sometimes you genuinely need the underlying value — to hash it, compare it, or send it over an encrypted channel. Loon provides expose() for this, but it requires the Audit effect and a reason string:
fn hash_password(pw: Sensitive<String>) [Audit] -> String {
let raw: String = expose(pw, "hashing for storage");
hash_sha256(raw)
}
This compiles. Three things happen:
- The function must declare
[Audit]— Any function that callsexpose()must declare the Audit effect. This propagates up the call chain: the caller must also declare[Audit], and so on. Every function in the chain is visibly marked as one that handles sensitive data. - The reason string is mandatory —
"hashing for storage"is not optional. It documents why the sensitive value is being unwrapped. This is a compile-time requirement, not a convention. - The audit trail is compiler-enforced — You can search the codebase for every
expose()call and know exactly where sensitive data is being accessed and why. No grep forpasswordacross a million lines — the compiler has already narrowed it down.
7. ZeroOnDrop
For cryptographic keys, API secrets, and other values that should not linger in memory after use, Loon provides the ZeroOnDrop policy:
let api_key: Sensitive<String, ZeroOnDrop> = load_key();
// use api_key...
// when api_key goes out of scope, its memory is zeroed
ZeroOnDrop guarantees that the memory backing this value is overwritten with zeros when the value is no longer reachable. This is not a finalizer that might run eventually — it is a deterministic cleanup tied to scope exit. The value cannot survive in memory after the function returns.
Combined with privacy types, this means a sensitive value is protected at every stage: it cannot leak through code at compile time, and it cannot leak through memory at runtime.
What's next
You have seen the core of Loon: effects that make side effects visible, types that make data shapes exhaustive, and privacy types that make leaks impossible. Here is where to go from here:
- Language Reference — The complete specification: every keyword, every type, every compile-time rule.
- Privacy Types Guide — Deep dive into privacy levels, declassification policies, and real-world patterns.
- The Bootstrap Story — How Loon was built from bare metal x86-64 assembly, with no borrowed toolchain.