Luma is a statically typed, compiled programming language designed for systems programming. It combines the low-level control of C with a strong type system and modern safety features that eliminate many common runtime errors.
Luma is built on three core principles:
- Simplicity: Minimal syntax with consistent patterns
- Safety: Strong typing and memory safety features
- Performance: Zero-cost abstractions and predictable performance
Here's a complete Luma program that demonstrates the core language features:
@module "main"
const Point -> struct {
pub:
x: i64,
y: i64,
distance_to -> fn (other: Point) f32 {
let dx: i64 = other.x - self.x;
let dy: i64 = other.y - self.y;
return cast<f32>(sqrt(cast<f64>(dx * dx + dy * dy)));
},
};
const Status -> enum {
Active,
Inactive,
Pending,
};
pub const main -> fn (argc: i64, argv: **byte) i64 {
let origin: Point = Point { x: 0, y: 0 };
let destination: Point = Point { x: 3, y: 4 };
let current_status: Status = Status::Active;
outputln("Distance: ", origin.distance_to(destination));
switch (current_status) {
Status::Active -> outputln("System is running");
Status::Inactive -> outputln("System is stopped");
Status::Pending -> outputln("System is starting");
}
return 0;
}
This example shows:
- Module declaration with
@module - Struct definitions with methods
- Enum definitions
- Static access with
::for enum variants - Runtime access with
.for struct members - Function definitions and calls
- Switch statements with pattern matching
Luma provides a straightforward type system with both primitive and compound types.
i8 - Signed 8-bit integer
i16 - Signed 16-bit integer
i32 - Signed 32-bit integer (default type of integer literals)
i64 - Signed 64-bit integer
u8 - Unsigned 8-bit integer
u16 - Unsigned 16-bit integer
u32 - Unsigned 32-bit integer
u64 - Unsigned 64-bit integer
f32 - Single-precision floating point (32-bit, default type of float literals)
f64 - Double-precision floating point (64-bit)
bool - Boolean (1 byte)
byte - Single byte (1 byte)
*byte - Character pointer / C-style string
void - No value (used for function return types and generic pointers)
Note on String Types:
- String literals like
"hello"are of type*byte(null-terminated character arrays) - All string operations in the standard library use
*byte - There is no separate
strtype in Luma
*T - Pointer type (declares a pointer to type T)
[T; N] - Array type (fixed-size array of N elements of type T)
Pointer Operators:
*expr - Dereference operator (access value pointed to)
&expr - Address-of operator (get pointer to value)
Example:
let x: i64 = 42; // x is an i64
let ptr: *i64 = &x; // ptr is a pointer to i64, holds address of x
let value: i64 = *ptr; // value is 42 (dereferenced ptr)
Enums provide type-safe constants with underlying integer values:
const Direction -> enum {
North, // = 0
South, // = 1
East, // = 2
West // = 3
};
const current_direction: Direction = Direction::North;
// Can cast to i64 if needed
let dir_value: i64 = cast<i64>(Direction::North); // 0
Structures group related data with optional access control:
const Point -> struct {
x: i64,
y: i64
};
// With explicit access modifiers and methods
const Player -> struct {
pub:
name: *byte,
score: i64,
// Methods can be defined inside structs — fields are reached through
// `self`, never as bare names. Currently, a method must be declared in
// the `pub:` group to typecheck correctly — one declared after `priv:`
// doesn't get `self` resolved (a known compiler bug).
get_info -> fn () void {
outputln("Player: ", self.name, " Score: ", self.score);
},
priv:
internal_id: i64,
};
const origin: Point = Point { x: 0, y: 0 };
const player: Player = Player {
name: "Alice",
score: 100,
internal_id: 12345
};
// Access fields
outputln(origin.x); // 0
outputln(player.name); // Alice
// Call methods
player.get_info(); // Player: Alice Score: 100
Unlike C, there's no separate -> operator — . works the same way whether you have a struct value or a pointer to one, auto-dereferencing either way:
const move_point -> fn (p: *Point, dx: i64, dy: i64) void {
p.x = p.x + dx; // not p->x
p.y = p.y + dy;
}
pub const main -> fn (argc: i64, argv: **byte) i64 {
let p: Point = Point { x: 1, y: 2 };
let ptr: *Point = &p;
move_point(ptr, 10, 10);
outputln(p.x, " ", p.y); // 11 12
outputln(ptr.x, " ", ptr.y); // same values, through the pointer
return 0;
}
This is also why a method's self (always *Self under the hood) reads no differently from a plain struct field access — self.x inside a method and p.x on a local value use identical syntax.
A struct-typed field declared normally (without ...) is composition, not embedding — it's a nested value, and its fields are not promoted. You reach them through the field name, same as any other member:
const Line -> struct {
pub:
start: Point,
end: Point,
};
pub const main -> fn (argc: i64, argv: **byte) i64 {
let line: Line = Line {
start: Point { x: 0, y: 0 },
end: Point { x: 5, y: 5 },
};
outputln(line.start.x); // 0 — through `.start`, not promoted
outputln(line.end.y); // 5
return 0;
}
Compare this to Struct Embedding below, where ...Point, would make line.x and line.y valid directly. Use composition when the nested struct is conceptually a distinct part (a Line has a start and an end); use embedding when the outer struct is a kind of the inner one and should expose its interface directly (a Player is an Entity).
Structs don't support == — comparing two struct values field-by-field isn't generated automatically; compare the fields you care about individually instead.
A struct can embed another by value with ...Type, as a member. The embedded struct's fields and methods are promoted onto the outer struct — accessible and initializable as if they were declared directly on it, with no runtime indirection (the embedded value is laid out inline, not behind a pointer):
const Entity -> struct {
pub:
x: f32,
y: f32,
move -> fn (dx: f32, dy: f32) void {
self.x = self.x + dx;
self.y = self.y + dy;
},
};
const Player -> struct {
pub:
...Entity,
name: *byte,
// Defining `move` here shadows Entity's — a direct member always wins
// over a promoted one of the same name.
move -> fn (dx: f32, dy: f32) void {
self.x = self.x + (dx * 2.0);
self.y = self.y + (dy * 2.0);
},
};
pub const main -> fn (argc: i64, argv: **byte) i64 {
// `x`/`y` are promoted fields — the literal initializes them exactly
// like `name`, even though they live on the embedded `Entity`.
let player: Player = Player { x: 0.0, y: 0.0, name: "Connor" };
player.move(10.0, 5.0); // Player's own move — doubled deltas
outputln(player.x, " ", player.y);
return 0;
}
Promoted members resolve through as many embedding levels as needed, and work identically through a pointer (p.move(...) where p: *Player).
An override can still reach the shadowed base method explicitly, by naming the embedded type directly with . — Entity.move(dx, dy) inside Player::move calls Entity's own implementation on self's embedded Entity, bypassing the override it's written inside of:
const Player -> struct {
pub:
...Entity,
name: *byte,
move -> fn (dx: f32, dy: f32) void {
Entity.move(dx, dy); // delegates to Entity's own move, unmodified
},
};
This only resolves inside a method whose owning struct actually embeds the named type somewhere; Entity.move(...) written elsewhere doesn't mean anything.
A method declared static has no implicit self and is called on the type itself with ::, not on an instance with . — useful for constructors and other functions that logically belong to a type but don't operate on an existing value of it:
const Point -> struct {
pub:
x: f32,
y: f32,
static origin -> fn () Point {
return Point { x: 0.0, y: 0.0 };
},
static at -> fn (x: f32, y: f32) Point {
return Point { x: x, y: y };
},
move -> fn (dx: f32, dy: f32) void {
self.x = self.x + dx;
self.y = self.y + dy;
},
};
pub const main -> fn (argc: i64, argv: **byte) i64 {
let a: Point = Point::origin(); // static — no instance needed
let b: Point = Point::at(3.0, 4.0);
a.move(1.0, 1.0); // instance method — needs `a`
return 0;
}
Inside a static method's body, self isn't in scope — there's no instance to refer to. static is only valid on a method (name -> fn (...) T { ... }), not on a data field.
#returns_ownership/#takes_ownership can be combined with static in either order:
#returns_ownership
static create -> fn (...) *T { ... }
A static "constructor" returning a pointer, paired with an instance "destructor" method, is the idiomatic way to give a struct manual, class-like lifetime management — the same ownership rules from Memory Management apply, just wrapped in methods instead of loose functions:
const Person -> struct {
pub:
name: *byte, // owned
age: i64,
#returns_ownership
static create -> fn (name: *byte, age: i64) *Person {
let p: *Person = cast<*Person>(alloc(sizeof<Person>));
p.name = name;
p.age = age;
return p;
},
destroy -> fn () void {
free(self.name);
},
};
pub const main -> fn (argc: i64, argv: **byte) i64 {
let alice: *Person = Person::create(cast<*byte>(alloc(6)), 30);
defer { alice.destroy(); free(alice); }
outputln(alice.age);
return 0;
}
destroy only frees what the struct itself owns (name) — the struct's own allocation (alice the pointer) is a separate responsibility, freed by whoever called create, same as any other #returns_ownership pointer. Nothing in Luma calls destroy automatically; there's no destructor-on-scope-exit — pair it with defer explicitly, as shown.
// Same types
let x: i64 = 42;
let y: i64 = x; // OK
// Different types require explicit cast
let f: f32 = cast<f32>(x); // OK
let z: i64 = f; // ERROR: must use cast<i64>(f)
// Pointer type safety
let int_ptr: *i64 = &x;
let void_ptr: *void = cast<*void>(int_ptr); // Explicit cast required
See Type Casting System for full details on conversions.
Generic functions, generic structs (struct<T>) and generic enums
(enum<T>) are implemented. Generic structs and enums use type parameters
in angle brackets, such as struct<T> and enum<T>.
Generic functions are declared with type parameters in angle brackets <>
right after the fn keyword, same position as every other function
declaration's parameter list:
const add -> fn<T> (a: T, b: T) T {
return a + b;
}
const swap -> fn<T> (a: *T, b: *T) void {
let temp: T = *a;
*a = *b;
*b = temp;
}
const identity -> fn<T> (x: T) T {
return x;
}
Generic functions require explicit type arguments at the call site —
add<i64>(1, 2), not just add(1, 2):
pub const main -> fn (argc: i64, argv: **byte) i64 {
// Integer arithmetic
outputln("add(1, 2) = ", add<i64>(1, 2));
// Floating-point arithmetic
outputln("add(1.5, 2.5) = ", add<f32>(1.5, 2.5));
// Swapping integers
let x: i64 = 5;
let y: i64 = 10;
swap<i64>(&x, &y);
outputln("After swap: x = ", x, ", y = ", y);
// identity<*byte> and identity<i64> are two independent instantiations
let s: *byte = identity<*byte>("hello");
let n: i64 = identity<i64>(42);
return 0;
}
Why explicit, rather than inferring T from the arguments the way most
languages with generics do: add<i64>(...) and a < i64 > (...) (a chained
comparison) are genuinely ambiguous to parse — Luma's parser has no symbol
table, so it can't tell "add" apart from an ordinary variable at parse time.
It resolves this the same way C++ effectively does: on seeing ident <, it
speculatively tries to parse a type-argument list followed immediately by
( or {; if that doesn't parse cleanly, it rolls back with zero side
effects and falls through to ordinary < (less-than). This is also why the
type arguments are mandatory rather than optional — inference would remove
the very shape (<Type,...>() the parser depends on to disambiguate.
Luma uses monomorphization: the compiler generates a separate, concrete function for each distinct set of type arguments actually used, the first time it's used — not type erasure, and not a runtime dispatch of any kind.
const identity -> fn<T> (x: T) T { return x; }
// These calls generate two independent, separately-typechecked functions:
let a: i64 = identity<i64>(42); // -> identity__int
let c: *byte = identity<*byte>("hello"); // -> identity___byte
A generic function's body is duck-typed, like a C++ template: it's only
fully typechecked once per concrete instantiation, not at the generic
declaration itself. add<T>'s a + b isn't checked against every possible
T up front — add<*byte>(...) would fail with an ordinary Type Error
right at that call site (+ isn't defined for *byte), the same as if
you'd written a *byte-specific function with + in it directly.
Structs can carry generic fields, declared with type parameters in angle
brackets right after the struct keyword:
const Entry -> struct<K, V> {
key: K,
val: V,
};
const Map -> struct<K, V> {
size: i64,
first: *Entry<K, V>,
};
A concrete struct is named with explicit type arguments in type position,
the same Name<T, ...> shape generic calls use:
let e: Entry<i64, i64> = Entry<i64, i64> { key: 7, val: 9 }; // type + literal
Both the type reference and the generic struct literal
(Entry<i64, i64> { ... }) monomorphize the template to a concrete
Entry__int__int. Fields that reference generic types (*Entry<K, V> inside
Map) are substituted with the caller's concrete types during
instantiation, so nested generics work.
Methods declared inside the struct body inherit the struct's type parameters — no need to redeclare them on the method:
const Box -> struct<K, V> {
content: *Entry<K, V>,
cap: u64,
#returns_ownership
static make -> fn (key: K, value: V) *Box<K, V> {
let b: *Box<K, V> = cast<*Box<K, V>>(alloc(sizeof<Box<K, V>> * 1));
b.content = cast<*Entry<K, V>>(alloc(sizeof<Entry<K, V>>));
b.content.key = key;
b.content.value = value;
b.cap = sizeof<Entry<K, V>>;
return b;
},
get_key -> fn () K { return self.content.key; },
};
A static method is called with explicit type arguments, as a function would
be: Box::make<i64, *byte>(7, "seven"). Box<K, V> stays valid in type
position inside the template itself (sizeof<Box<K, V>>, *Box<K, V>),
substituted and mangled per instantiation.
Enums can take type parameters too, right after the enum keyword:
const State -> enum<T> {
Idle,
Running,
Done,
};
Because Luma enums are plain C-style name constants — they carry no
associated data — there is no field of type T to store. The type
parameter instead acts as a phantom/type tag: State<i64> and
State<*byte> are distinct nominal types, each with its own set of
(mangled) member constants, generated by the same monomorphization that
drives generic structs.
Members are written with the explicit type arguments on the left of the
::, matching the Name::Member syntax ordinary enums use:
let s: State<i64> = State<i64>::Idle;
switch (s) {
State<i64>::Idle -> { /* ... */ }
State<i64>::Running -> outputln("running");
State<i64>::Done -> return 0;
}
Type compatibility is per-instantiation: the phantom argument is part of
the enum's identity. A State<i64> value isn't assignable to a State<*byte>
variable without an explicit cast — they name different nominal types
(State__int vs State___byte, following the same mangling as generic
functions). This makes a generic enum a safe way to tag a value with which
concrete types it belongs to.
- Explicit type arguments are mandatory everywhere — functions, structs,
and enums — both because Luma's parser has no symbol table (it can't infer
from context at parse time) and because the explicit
<Type,...>shape is what lets<be disambiguated from a chained comparison. A bareState(no arguments) doesn't name any instantiation and is an error. - Right arity is required — naming more or fewer type arguments than the
template declares is a
Generics Errorat the reference site. - Cross-module works — an imported generic struct, enum, or function is
referenced as
ALIAS::Name<T,...>/ALIAS::Name::make<T>(...), and every concrete instantiation is codegen'd in the module that uses it (mangledMOD__Name__T1__T2, so the same template can be instantiated from several modules). - Generic methods on plain structs — a non-generic struct may declare a
method with its own type parameters (
static make -> fn<T> (...) T). These are instantiated per call site exactly like a generic function (Thing::make<*Thing>→Thing_make___Thing); the raw template is never emitted to C, and the type parameters it owns must not escape to module scope (that was the "unknown type name" bug).
Luma uses the const keyword as a unified declaration mechanism for all top-level bindings. Whether you're declaring variables, functions, types, or enums, const provides a consistent syntax that enforces immutability at the binding level.
const NUM: i64 = 42; // Immutable variable
const Direction -> enum { North, South, East, West }; // Enum definition
const Point -> struct { x: i64, y: i64 }; // Struct definition
const add -> fn (a: i64, b: i64) i64 { // Function definition
return a + b;
}
(fn<T>, struct<T> and enum<T> bindings work today — see Generics.)
Unified syntax: One parsing rule handles all top-level declarations, simplifying both the compiler and developer experience.
Semantic clarity: The binding itself is immutable—you cannot reassign or shadow a top-level const. This prevents accidental redefinition bugs.
Compiler optimization: Immutable bindings enable better optimization opportunities.
Future extensibility: This approach naturally supports compile-time metaprogramming and uniform import behavior.
const x: i64 = 5;
x = 10; // Error: `x` is immutable
const add -> fn (a: i64, b: i64) i64 { return a + b; }
add = something_else; // Error: cannot reassign function binding
Inside functions, use let to declare local variables:
const main -> fn (argc: i64, argv: **byte) i64 {
let x: i64 = 10; // Mutable local variable
x = 20; // Can be reassigned
let y: i64 = 5;
y = y + 1; // Can be modified
let counter: i64 = 0;
loop (counter < 10) {
counter = counter + 1; // Mutating in loop
}
return 0;
}
Key difference:
constat top-level = immutable binding (cannot reassign)letin functions = mutable variable (can reassign and modify)
Functions are first-class values in Luma.
// Basic function
const add -> fn (a: i64, b: i64) i64 {
return a + b;
}
// Function with no parameters
const greet -> fn () void {
outputln("Hello!");
}
// Function with no return value
const print_number -> fn (n: i64) void {
outputln("Number: ", n);
}
A function declaration's closing } never takes a trailing ; — that's only for struct/enum declarations and ordinary statements (see Top-Level Bindings with const).
main can declare zero, one, or two parameters — argc: i64 and argv: **byte, in that order. Its C-level signature is always int main(int, char**) regardless; declaring fewer just leaves the rest unnamed on the Luma side.
pub const main -> fn () i64 {
return 0;
}
pub const main -> fn (argc: i64) i64 {
return 0;
}
pub const main -> fn (argc: i64, argv: **byte) i64 {
return 0;
}
const main -> fn (argc: i64, argv: **byte) i64 {
let result: i64 = add(5, 3);
outputln("5 + 3 = ", result);
greet();
print_number(42);
return 0;
}
Parameters are passed by value by default:
const modify -> fn (x: i64) void {
x = 100; // Modifies local copy only
}
const main -> fn (argc: i64, argv: **byte) i64 {
let num: i64 = 10;
modify(num);
outputln(num); // Still 10
return 0;
}
To modify the caller's variable, use pointers:
const modify_ptr -> fn (x: *i64) void {
*x = 100; // Modifies original value
}
const main -> fn (argc: i64, argv: **byte) i64 {
let num: i64 = 10;
modify_ptr(&num); // Pass address
outputln(num); // Now 100
return 0;
}
// Single return value
const square -> fn (x: i64) i64 {
return x * x;
}
// Multiple return values via struct
const DivResult -> struct {
quotient: i64,
remainder: i64
};
const divide -> fn (a: i64, b: i64) DivResult {
return DivResult {
quotient: a / b,
remainder: a % b
};
}
const main -> fn (argc: i64, argv: **byte) i64 {
let result: DivResult = divide(17, 5);
outputln("17 / 5 = ", result.quotient, " R ", result.remainder);
return 0;
}
const find_positive -> fn (numbers: *i64, size: i64) i64 {
loop [i: i64 = 0](i < size) : (++i) {
if (numbers[i] > 0) {
return numbers[i]; // Early return
}
}
return -1; // Not found
}
Luma uses two distinct operators for name resolution to provide semantic clarity:
The :: operator is used for compile-time static access:
// Enum variants
const day: WeekDay = WeekDay::Monday;
// Module/namespace access
math::sqrt(16.0)
// Static methods — no implicit `self`, called on the type itself
Point::at(10, 20)
(See Static Methods for how Point::at is declared.)
The . operator is used for runtime member access:
// Struct field access
let point: Point = Point { x: 10, y: 20 };
outputln(point.x); // Access field at runtime
// Method calls on instances
let distance: f32 = origin.distance_to(destination);
- Semantic clarity:
::means "resolved at compile time",.means "accessed at runtime" - Easier parsing: The compiler immediately knows the access type
- Consistent with systems languages: Similar to C++ and Rust conventions
- Future-proof: Supports advanced features like associated functions
Luma provides clean, flexible control flow constructs that handle most programming patterns without unnecessary complexity.
Use if, elif, and else for branching logic:
const x: i64 = 7;
if (x > 10) {
outputln("Large number");
} elif (x > 5) {
outputln("Medium number"); // This will execute
} else {
outputln("Small number");
}
The loop keyword provides several iteration patterns:
// Basic for loop
loop [i: i64 = 0](i < 10) {
outputln("Iteration: ", i);
++i;
}
// For loop with post-increment
loop [i: i64 = 0](i < 10) : (++i) {
outputln("i = ", i);
}
Not currently working: multiple loop-init variables (loop [i: i64 = 0, j: i64 = 0](...) ...) parse but generate invalid C (for (long long i = 0, long long j = 0; ...), which repeats the type where C expects a bare j = 0) — the build fails at the C compile step. For now, declare the second counter with let above the loop instead:
let j: i64 = 0;
loop [i: i64 = 0](i < 10) : (++i) {
outputln("i = ", i, ", j = ", j);
++j;
}
// Condition-only loop
let counter: i64 = 0;
loop (counter < 5) {
outputln("Count: ", counter);
counter = counter + 1;
}
// While loop with post-action
let j: i64 = 0;
loop (j < 10) : (++j) {
outputln("Processing: ", j);
}
loop {
// Runs forever until `break` is encountered
if (should_exit()) {
break;
}
do_work();
}
// Break: exit loop early
loop [i: i64 = 0](i < 100) : (++i) {
if (i == 50) {
break; // Exit loop
}
process(i);
}
// Continue: skip to next iteration
loop [i: i64 = 0](i < 10) : (++i) {
if (i % 2 == 0) {
continue; // Skip even numbers
}
outputln("Odd: ", i);
}
Luma provides powerful pattern matching through switch statements that work with enums, integers, and other types. Switch statements must be exhaustive and all cases must be compile-time constants.
@module "main"
const WeekDay -> enum {
Sunday,
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday,
};
const classify_day -> fn (day: WeekDay) void {
switch (day) {
WeekDay::Monday, WeekDay::Tuesday, WeekDay::Wednesday,
WeekDay::Thursday, WeekDay::Friday ->
outputln("Weekday => ", day);
WeekDay::Saturday, WeekDay::Sunday ->
outputln("Weekend => ", day);
}
}
pub const main -> fn (argc: i64, argv: **byte) i64 {
classify_day(WeekDay::Monday); // Output: Weekday => 1
classify_day(WeekDay::Saturday); // Output: Weekend => 6
return 0;
}
When you need to handle unexpected values or want a catch-all case, use the default wildcard pattern _:
const handle_status_code -> fn (code: i64) void {
switch (code) {
200 -> outputln("OK");
404 -> outputln("Not Found");
500 -> outputln("Internal Server Error");
_ -> outputln("Unknown status code");
}
}
- Multiple values per case: Combine multiple values using commas
- Exhaustiveness: All possible values must be covered (or use
_for default) - Compile-time constants: All case values must be compile-time constants
- No fallthrough: Each case is automatically contained (no
breakneeded)
Fully-qualified case labels (WeekDay::Monday) get repetitive once an enum has more than a couple of members. switch using <Path::To::Enum> (expr) lets every case label in that switch be written as a bare member name instead:
const classify_day -> fn (day: WeekDay) void {
switch using WeekDay (day) {
Monday, Tuesday, Wednesday, Thursday, Friday -> {
outputln("Weekday => ", day);
}
Saturday, Sunday -> {
outputln("Weekend => ", day);
}
}
}
This is pure sugar, resolved entirely while parsing: Monday inside this switch expands to exactly the same WeekDay::Monday node a fully-qualified label would produce, so exhaustiveness checking, duplicate-case detection, and codegen all behave identically to the fully-qualified form. A label can still be written fully qualified (or reference a different enum entirely) inside a using switch — using only applies to bare identifiers, so an explicit Other::Value label is left untouched. The shorthand is scoped to the one switch block it's declared on; it never leaks into surrounding code.
Luma provides a simple module system for code organization and namespace management.
Every Luma source file must declare its module name:
@module "main"
// Your code here...
Use the @use directive to import other modules:
@module "main"
@use "std_libc" as c
@use "std_cstring" as string
pub const main -> fn (argc: i64, argv: **byte) i64 {
// Access imported functions with namespace
let result: f64 = c::sqrt(16.0);
let len: i64 = string::strlen("hello");
outputln("sqrt(16): ", result);
outputln("Length: ", len);
return 0;
}
@use only declares the dependency — it doesn't locate the file. Every module you @use also has to be passed to the compiler explicitly with -l:
luma main.lx -l std/libc.lx std/cstring.lx -name mainAll standard library modules use the std_ prefix:
std_math - Mathematical functions and constants
std_memory - Low-level memory operations
std_cstring - Null-terminated (*byte) string utilities: strlen, strcmp, copy, dup, ...
std_string - Growable String struct built on top of std_cstring
std_io - High-level I/O with formatted output
std_sys - POSIX system calls (Linux/macOS)
std_win32 - Windows Win32 API
std_termfx - Terminal colors and formatting
std_terminal - Terminal input/raw mode control
std_time - Time and timing operations
std_vector - Dynamic array
std_hashmap - Hash map
std_arena - Arena allocator
std_args - Command-line argument parsing
std_libc - C standard library bindings
std_thread - Threading- Explicit imports: All dependencies must be explicitly declared
- Namespace isolation: Imported modules are accessed through their aliases
- Static resolution: All module access is resolved at compile time using
:: - Clean syntax: Simple
@use "module" as aliaspattern
The @os block selects code based on the target operating system. This is how the standard library handles platform differences for things like syscall numbers and flags:
@os {
"linux" -> {
pub const SYS_WRITE: i64 = 1;
pub const O_CREAT: i64 = 64;
}
"macos" -> {
pub const SYS_WRITE: i64 = 4;
pub const O_CREAT: i64 = 512;
}
"windows" -> {
// Windows-specific declarations...
}
}
You can also use @os inline inside function bodies:
const write_out -> fn (s: *byte) i64 {
@os {
"linux" -> { return __syscall__(1, 1, cast<i64>(s), len); }
"macos" -> { return __syscall__(4, 1, cast<i64>(s), len); }
"windows" -> { /* use WriteFile */ }
}
}
Valid platform strings are "linux", "macos", and "windows".
Luma can call into native shared libraries and DLLs through two complementary directives.
@link declares that the entire module links against a shared library. Place it at the top of your module, before any function declarations:
@module "std_libc"
@link("libc.so.6")
pub const puts -> fn (s: *byte) i64;
pub const printf -> fn (fmt: *byte, val: *byte) i64;
pub const malloc -> fn (size: i64) *void;
// ...
The function body is omitted — the linker resolves it from the named library at link time. Only one @link per module is needed; it applies to all subsequent pub const declarations that have no body.
When individual functions within a module come from a different library than the module-level @link, use #lib_import as a per-declaration attribute:
@module "std_libc"
@link("libc.so.6")
// Most functions come from libc.so.6 via @link above:
pub const malloc -> fn (size: i64) *void;
pub const free -> fn (ptr: *void) void;
// Math functions need libm — override per-function:
#lib_import("libm.so")
pub const sqrt -> fn (x: f64) f64;
#lib_import("libm.so")
pub const pow -> fn (base: f64, exp: f64) f64;
#lib_import("libm.so")
pub const sin -> fn (x: f64) f64;
Place #lib_import(...) on the line immediately before the pub const it applies to.
On Windows, use #dll_import instead. It accepts the DLL name and an optional calling convention:
#dll_import("kernel32.dll", callconv: "stdcall")
pub const CreateFileA -> fn (
lpFileName: *byte,
dwDesiredAccess: i64,
dwShareMode: i64,
lpSecurityAttributes: *void,
dwCreationDisposition: i64,
dwFlagsAndAttributes: i64,
hTemplateFile: i64
) i64;
#dll_import("kernel32.dll", callconv: "stdcall")
pub const WriteFile -> fn (
hFile: i64,
lpBuffer: *void,
nNumberOfBytesToWrite: i64,
lpNumberOfBytesWritten: *i64,
lpOverlapped: *void
) i64;
#dll_import can also appear inside @os { "windows" -> { ... } } blocks so the same source file compiles cross-platform:
@os {
"windows" -> {
#dll_import("kernel32.dll", callconv: "stdcall")
pub const GetStdHandle -> fn (nStdHandle: i64) i64;
}
}
The std_libc module wraps the C standard library (stdio, stdlib, string, and math) and is the easiest way to call into libc from Luma:
@module "main"
@use "std_libc" as c
pub const main -> fn (argc: i64, argv: **byte) i64 {
c::puts("hello from libc");
let n: i64 = c::atoi("42");
let r: f64 = c::sqrt(144.0);
return 0;
}
Luma provides several built-in functions that are always available without imports.
output(...) // Print values without newline
outputln(...) // Print values with newline
Both functions are variadic - they accept any number of arguments of any type:
const main -> fn (argc: i64, argv: **byte) i64 {
output("Hello", " ", "World"); // Hello World
outputln("The answer is:", 42); // The answer is: 42\n
let x: i64 = 10;
let y: f32 = 3.14;
outputln("x = ", x, ", y = ", y); // x = 10, y = 3.14\n
return 0;
}
input<T>(prompt: *byte) -> T // Read typed input
input<T> parses and typechecks — the intended shape is generic, reading a value of the specified type:
pub const main -> fn (argc: i64, argv: **byte) i64 {
let name: *byte = input<*byte>("Enter your name: ");
let age: i64 = input<i64>("Enter your age: ");
let height: f64 = input<f64>("Enter height (meters): ");
outputln("Name: ", name);
outputln("Age: ", age);
outputln("Height: ", height);
return 0;
}
input<T> prints prompt (skipped if empty), then reads a line from stdin and
parses it as T. Supported T: i64, u64, f32, f64, bool,
byte, and *byte (string). byte is the exception to "a line" — it reads a
single raw byte (via read(2), not line-buffered stdio), which is what
std/terminal.lx's raw-mode key readers (getch, getch_raw, getpass, ...)
rely on. Any other T (structs, enums, ...) isn't supported yet and evaluates
to a zeroed value.
system(command: *byte) -> i64 // Execute system command
Execute shell commands from your program:
const main -> fn (argc: i64, argv: **byte) i64 {
system("clear"); // Clear terminal (Linux/Mac)
system("stty -icanon -echo"); // Configure terminal
return 0;
}
sizeof<T> -> i64 // Size of type in bytes
Get the size of any type at compile time:
const main -> fn (argc: i64, argv: **byte) i64 {
outputln("i64: ", sizeof<i64>); // 8
outputln("byte: ", sizeof<byte>); // 1
outputln("f64: ", sizeof<f64>); // 8
// Use in allocations
let buffer: *i64 = cast<*i64>(alloc(100 * sizeof<i64>));
defer free(buffer);
return 0;
}
Luma uses explicit casting with the cast<T>() function for all type conversions.
cast<TargetType>(expression)
const main -> fn (argc: i64, argv: **byte) i64 {
// Integer to f32
let i: i64 = 42;
let f: f32 = cast<f32>(i); // 42.0
// Float to integer (truncates)
let pi: f64 = 3.14159;
let rounded: i64 = cast<i64>(pi); // 3
// Between integer types
let small: byte = cast<byte>(65); // 'A'
let large: i64 = cast<i64>(small); // 65
return 0;
}
const main -> fn (argc: i64, argv: **byte) i64 {
// void* to typed pointer
let raw: *void = alloc(sizeof<i64>);
let typed: *i64 = cast<*i64>(raw);
*typed = 42;
free(raw);
// Between pointer types
let int_ptr: *i64 = cast<*i64>(alloc(sizeof<i64>));
let void_ptr: *void = cast<*void>(int_ptr);
free(int_ptr);
return 0;
}
const main -> fn (argc: i64, argv: **byte) i64 {
let ptr: *byte = cast<*byte>(alloc(10));
defer free(ptr);
// Pointer to integer
let addr: i64 = cast<i64>(ptr);
// Add offset (pointer arithmetic)
let offset_addr: i64 = addr + 5;
// Back to pointer
let offset_ptr: *byte = cast<*byte>(offset_addr);
return 0;
}
Luma supports fixed-size arrays with compile-time known sizes.
// Syntax: [Type; Size]
let numbers: [i64; 10]; // Array of 10 integers
let bytes: [byte; 256]; // Array of 256 bytes
let buffer: [f64; 100]; // Array of 100 doubles
// Constants can be arrays too
const PRIMES: [i64; 5] = [2, 3, 5, 7, 11];
const main -> fn (argc: i64, argv: **byte) i64 {
// Uninitialized (contains garbage)
let data: [i64; 5];
// Initialize with literal
let primes: [i64; 5] = [2, 3, 5, 7, 11];
// Initialize element by element
let scores: [i64; 3];
scores[0] = 95;
scores[1] = 87;
scores[2] = 92;
return 0;
}
const main -> fn (argc: i64, argv: **byte) i64 {
let numbers: [i64; 5] = [10, 20, 30, 40, 50];
// Read elements
let first: i64 = numbers[0]; // 10
let last: i64 = numbers[4]; // 50
// Write elements
numbers[2] = 99;
// Loop through array
loop [i: i64 = 0](i < 5) : (++i) {
outputln("numbers[", i, "] = ", numbers[i]);
}
return 0;
}
String literals are null-terminated byte arrays:
const main -> fn (argc: i64, argv: **byte) i64 {
// String literal - type is *byte
let message: *byte = "Hello, World!";
outputln(message);
return 0;
}
Single characters use single quotes:
const main -> fn (argc: i64, argv: **byte) i64 {
let letter: byte = 'A'; // Character literal
let newline: byte = '\n'; // Escape sequence
let tab: byte = '\t'; // Tab character
return 0;
}
'\n' // Newline
'\r' // Carriage return
'\t' // Horizontal tab
'\\' // Backslash
'\'' // Single quote
'\"' // Double quote
'\0' // Null character
\xHH (hexadecimal byte, e.g. "\x1b" for ESC) is supported in string literals but not in single-quoted character literals yet — let esc: byte = '\x1b'; fails to parse; use a string ("\x1b") and index into it, or write the decimal/cast<byte>(...) form instead.
Luma supports pointer arithmetic for low-level memory manipulation.
const main -> fn (argc: i64, argv: **byte) i64 {
let arr: *i64 = cast<*i64>(alloc(5 * sizeof<i64>));
defer free(arr);
// Initialize
loop [i: i64 = 0](i < 5) : (++i) {
arr[i] = i * 10;
}
// Pointer arithmetic: convert to i64, add offset, convert back
let addr: i64 = cast<i64>(arr);
let new_addr: i64 = addr + (2 * sizeof<i64>);
let new_ptr: *i64 = cast<*i64>(new_addr);
outputln(*new_ptr); // arr[2] = 20
return 0;
}
Use pub to export items from a module:
@module "math"
// Public - accessible from other modules
pub const PI: f64 = 3.14159265359;
pub const sqrt -> fn (x: f64) f64 {
return x;
}
// Private - only within this module
const INTERNAL_CONSTANT: i64 = 42;
const Person -> struct {
pub:
name: *byte,
age: i64,
priv:
ssn: *byte,
internal_id: i64
};
Luma provides explicit memory management with safety-oriented features.
alloc(size: i64) -> *void // Allocate memory
free(ptr: *void) // Deallocate memory
sizeof<T> -> i64 // Size of type
const main -> fn (argc: i64, argv: **byte) i64 {
// Allocate memory
let ptr: *i64 = cast<*i64>(alloc(sizeof<i64>));
// Use the memory
*ptr = 42;
outputln("Value: ", *ptr);
// Clean up
free(ptr);
return 0;
}
Ensure cleanup with defer statements that execute when leaving scope:
const process_data -> fn () void {
let buffer: *i64 = cast<*i64>(alloc(100 * sizeof<i64>));
defer free(buffer); // Guaranteed to run when function exits
let file: *File = open_file("data.txt");
defer close_file(file); // Will run even if early return
// Complex processing...
if (error_condition) {
return; // defer statements still execute
}
// More processing...
// defer statements execute here automatically
}
Multiple statements can be deferred:
defer {
close_file(file);
cleanup_resources();
log("Operation completed");
}
Key Benefits:
- Ensures cleanup code runs regardless of how the function exits
- Keeps allocation and deallocation code close together
- Prevents resource leaks from early returns
- Executes in reverse order (LIFO - Last In, First Out)
Luma provides function attributes that document and enforce ownership semantics.
Marks functions that allocate and return pointers:
#returns_ownership
const create_buffer -> fn (size: i64) *i64 {
let buffer: *i64 = cast<*i64>(alloc(size * sizeof<i64>));
return buffer; // Caller now owns this memory
}
const main -> fn (argc: i64, argv: **byte) i64 {
let data: *i64 = create_buffer(100);
defer free(data); // Caller must free
return 0;
}
Marks functions that take ownership of pointer arguments:
#takes_ownership
const consume_buffer -> fn (buffer: *i64) void {
outputln("Processing: ", *buffer);
free(buffer); // Function owns and frees the buffer
}
const main -> fn (argc: i64, argv: **byte) i64 {
let data: *i64 = cast<*i64>(alloc(sizeof<i64>));
*data = 42;
consume_buffer(data); // Ownership transferred
// Note: do not use `data` after this point
return 0;
}
Luma's compiler includes a static analyzer that tracks memory at compile time to prevent common memory management errors.
Verified at Compile Time:
- Memory Leaks: Detects
alloc()calls without correspondingfree() - Double-Free: Prevents freeing the same pointer twice
- Use-After-Free: Catches access to freed memory within the same function
- Ownership Transfer: Validates
#returns_ownershipand#takes_ownershipannotations - Defer Statement Cleanup: Ensures deferred frees execute properly
How It Works:
const good_memory_usage -> fn () void {
let ptr: *i64 = cast<*i64>(alloc(sizeof<i64>));
defer free(ptr); // Analyzer confirms cleanup
*ptr = 42;
} // No leak reported
const bad_memory_usage -> fn () void {
let ptr: *i64 = cast<*i64>(alloc(sizeof<i64>));
*ptr = 42;
// Compiler error: memory leak - ptr never freed
}
#returns_ownership
const create_buffer -> fn (size: i64) *i64 {
let buffer: *i64 = cast<*i64>(alloc(size));
return buffer; // Ownership transferred to caller
} // No leak reported - caller is responsible
const main -> fn (argc: i64, argv: **byte) i64 {
let data: *i64 = create_buffer(100);
defer free(data); // Caller properly handles ownership
return 0;
}
Ownership Tracking:
The analyzer understands three ownership patterns:
#returns_ownershipfunctions: Allocations inside are NOT tracked as leaks because ownership transfers to the caller#takes_ownershipfunctions: Parameters marked with this receive ownership and are responsible for cleanupdeferstatements: Deferred cleanup is tracked and validated at function exit
Transitive Ownership:
#returns_ownership
const create_arena_sized -> fn (size: i64) Arena {
let a: Arena;
a.buf = alloc(size); // Not tracked - inside #returns_ownership
return a;
}
const create_arena -> fn () Arena {
return create_arena_sized(1024);
// Warning: Should add #returns_ownership annotation
// for API clarity (ownership is being passed through)
}
Known Edge Cases:
The analyzer currently has limitations in these areas:
-
Struct Field Granularity: When tracking
a.buf = alloc(...), the analyzer tracks the entire structa, not the specific fielda.buf. This works for single-pointer structs but may cause issues with:const Container -> struct { data1: *i64, data2: *i64 }; let c: Container; c.data1 = alloc(10); // Tracked as "c" c.data2 = alloc(20); // Also tracked as "c" - potential confusion free(c.data1); // Marks "c" as freed, but c.data2 still allocated -
Conditional Allocations: The analyzer may report false positives for conditional paths:
let ptr: *i64; if (condition) { ptr = alloc(sizeof<i64>); } // May warn even if you don't need to free in else branch -
Allocations in Loops: Each loop iteration's allocations should be independent, but edge cases may exist:
loop [i: i64 = 0](i < 10) : (++i) { let temp: *i64 = alloc(4); // Use temp... free(temp); // Should work correctly } -
Early Returns with Defer: While generally working, complex control flow with multiple early returns may need testing:
const process -> fn () i64 { let a: *i64 = alloc(sizeof<i64>); defer free(a); if (error) { return -1; } // Defer should fire if (warning) { return 0; } // Defer should fire return 1; // Defer should fire } -
Stack vs Heap: The analyzer doesn't currently detect returning pointers to stack variables:
const dangerous -> fn () *i64 { let local: i64 = 42; return &local; // NOT DETECTED - returns dangling pointer } -
Arrays of Pointers: Complex allocation patterns may not be fully tracked:
let arr: [*i64; 5]; loop [i: i64 = 0](i < 5) : (++i) { arr[i] = alloc(sizeof<i64>); // Each needs individual free }
To work effectively with the analyzer:
- Use ownership annotations consistently: Mark all functions that allocate and return resources with
#returns_ownership - Use defer for cleanup: Always pair allocations with
defer free()for automatic cleanup - One allocation per variable: Avoid reassigning pointer variables after allocation without freeing
- Clear ownership semantics: Document which functions own their pointer parameters vs. borrowing them
- Test early returns: Ensure defer statements properly handle all exit paths
The analyzer is conservative - it may report false positives to prevent missed leaks. When in doubt, it will warn about potential issues rather than silently allowing them.
Understanding performance is crucial for systems programming.
Luma follows the "zero-cost abstraction" principle: abstractions should have no runtime overhead.
Generics are zero-cost (see Generics):
const add -> fn<T> (a: T, b: T) T {
return a + b;
}
// These calls compile to separate, optimized functions:
let x: i64 = add<i64>(1, 2); // Same as: x = 1 + 2
let y: f32 = add<f32>(1.0, 2.0); // Same as: y = 1.0 + 2.0
No runtime dispatch - generic instantiations are resolved at compile time through monomorphization.
Luma generates specialized code per type, the same way C++ templates do:
const max -> fn<T> (a: T, b: T) T {
if (a > b) { return a; }
return b;
}
// Compiler generates, on first use of each:
// max__int(a: long long, b: long long) -> long long { ... }
// max__float(a: f32, b: f32) -> f32 { ... }
Benefits:
- No runtime overhead
- Full optimization per type
- No vtables or dynamic dispatch
Trade-offs:
- Larger binary size (one copy per type)
- Longer compilation time
Struct layout is predictable:
const Point -> struct {
x: i64, // Offset 0, 8 bytes
y: i64 // Offset 8, 8 bytes
}; // Total: 16 bytes
Array layout is contiguous:
let arr: [i64; 10]; // 80 contiguous bytes
// arr[0] at offset 0, arr[1] at offset 8, arr[2] at offset 16...
Stack allocation is fast:
const fast_function -> fn () void {
let buffer: [i64; 1024]; // Stack allocated - instant
// Use buffer...
} // Automatically cleaned up
Heap allocation has overhead:
const slower_function -> fn () void {
let buffer: *i64 = cast<*i64>(alloc(1024 * sizeof<i64>));
defer free(buffer);
// Use buffer...
}
1. Prefer stack allocation when possible:
let temp: [i64; 100]; // Good for small, fixed-size data
2. Minimize pointer indirection:
// Better: direct access
let ptr: *i64;
let value: i64 = *ptr; // One memory load
// Best: value directly
let value2: i64 = 42; // No memory load
3. Batch operations:
// Good: one large allocation
let buffer: *i64 = cast<*i64>(alloc(1000 * sizeof<i64>));
loop [i: i64 = 0](i < 1000) : (++i) {
// Use buffer[i]...
}
free(buffer);
4. Avoid unnecessary copying:
// Bad: pass large struct by value
const process -> fn (data: LargeStruct) void { }
// Good: pass by pointer
const process_fast -> fn (data: *LargeStruct) void { }
| Operation | Cost | Notes |
|---|---|---|
| Stack variable | ~0 | Instant |
| Heap allocation | High | System call |
| Pointer dereference | Low | One memory access |
| Array index | Low | Bounds check + access |
| Function call | Low-Medium | Depends on size |
| Generic instantiation | 0 | Compile-time only |
| Struct field access | Low | Offset calculation |
| Enum comparison | ~0 | Integer comparison |
Luma provides several safety features to prevent common bugs:
- Tracks allocations and deallocations at compile time
- Detects memory leaks before runtime
- Prevents double-free errors
- Identifies use-after-free bugs
#returns_ownershipdocuments memory transfers#takes_ownershipclarifies responsibility- Compiler enforces ownership rules
- Guarantees cleanup code execution
- Prevents resource leaks
- Works with early returns and errors
- No implicit conversions (except safe ones)
- Explicit casting required
- Type-safe generics
- Enum exhaustiveness checking
- No hidden exceptions
- Clear error propagation
- Predictable control flow
const let if elif else
loop break continue return defer
struct enum pub priv cast
sizeof alloc free switch fn
using static input system as@module "name" // Declare module name
@use "name" as alias // Import module
@os { "linux" -> { } } // Platform-conditional code
@link("lib.so") // Link against shared library (module-level)
#returns_ownership // Function returns allocated memory (caller must free)
#takes_ownership // Function takes ownership of a pointer argument
#lib_import("lib.so") // Per-function library override (POSIX)
#dll_import("dll", callconv: "stdcall") // Per-function DLL import (Windows)
Arithmetic: + - * / % ++ --
Comparison: == != < > <= >=
Logical: && || !
Bitwise: & | ^ ~ << >>
Assignment: =
Access: . :: [] * &i8 i16 i32 i64 u8 u16 u32 u64
f32 f64 bool byte void *T [T; N]// Allocation with cleanup
let ptr: *T = cast<*T>(alloc(sizeof<T>));
defer free(ptr);
// Array iteration
loop [i: i64 = 0](i < size) : (++i) {
array[i] = value;
}
// Error checking
if (ptr == cast<*T>(0)) {
return ERROR_CODE;
}
// Module usage
@use "std_module" as m
let x: i64 = m::function();
// FFI — link a shared library (POSIX)
@link("libsomething.so")
pub const some_fn -> fn (x: i64) i64;
// FFI — per-function library override
#lib_import("libm.so")
pub const sqrt -> fn (x: f64) f64;
// FFI — Windows DLL
#dll_import("user32.dll", callconv: "stdcall")
pub const MessageBoxA -> fn (hwnd: i64, text: *byte, caption: *byte, utype: i64) i64;
// Platform-conditional code
@os {
"linux" -> { outputln("Linux"); }
"macos" -> { outputln("macOS"); }
"windows" -> { outputln("Windows"); }
}
Luma is a modern systems programming language that provides:
- Simplicity: Clean, consistent syntax
- Safety: Static analysis and ownership tracking
- Performance: Zero-cost abstractions and predictable behavior
- Control: Manual memory management with safety nets
- Interoperability: First-class FFI via
@link,#lib_import, and#dll_import - Portability:
@osblocks for clean cross-platform code
The language is designed for programmers who want the performance and control of C with modern safety features and ergonomics.
For more examples, see the standard library modules and test files included with the language distribution.