Loft is inspired by Rust but designed for general-purpose scripting with a shorter learning curve and less ceremony. It trades some of Rust's safety guarantees and expressive power for a simpler mental model. This page documents the most important differences so that Rust programmers know exactly what they gain and what they give up.
1. Variables — no let or mut
Loft
x = 42
x += 1
const y = 42 // opt-in: locked in debug builds
Rust
let mut x = 42;
x += 1;
let y = 42; // immutable by default; compiler-enforced
Upside Less boilerplate. No need to decide upfront whether a variable will be mutated. Variables are mutable by default, so refactoring is frictionless. Use const to signal that a value should not change — in debug builds the runtime locks the store immediately after initialisation, catching accidental writes early.
Downside Immutability is opt-in, not the default. Rust makes variables immutable unless you write mut, catching accidents at compile time for free. Loft's const lock is only checked at runtime and only in debug builds, so it provides less of a safety net.
2. Null instead of Option<T>
Loft
struct Point {
label: text, // nullable
x: integer not null, // never null
}
p = Point { x: 1 };
assert(p.label == null, "absent");
Rust
struct Point {
label: Option<String>, // explicit absence
x: i32, // always present
}
let p = Point { label: None, x: 1 };
assert!(p.label.is_none());
Upside Natural for database records where fields are often absent. No Some(x)/None wrapping. Conditionals on null are concise: if v == null.
Downside Null is opt-out, not opt-in. A field is nullable unless explicitly marked not null, so forgetting the annotation leaves a potential null silently. In Rust, Option is visible in the type and forces handling at every call site.
3. Parameter mutability — const and & instead of ownership
Loft
// &: vector growth (append) is visible to caller
fn push_two(v: &vector<integer>) {
v += [1, 2]; // caller sees the change
}
// const: read-only (locked in debug builds)
fn count(v: const vector<integer>) -> integer {
length(v)
}
// &: explicit write-back for primitives
fn add_to(n: &integer, delta: integer) {
n += delta;
}
Rust
fn push_two(v: &mut Vec<i32>) {
v.push(1);
v.push(2);
}
fn count(v: &Vec<i32>) -> usize {
v.len()
}
fn add_to(n: &mut i32, delta: i32) {
*n += delta;
}
Upside No borrow-checker errors. No lifetime annotations. No ownership transfer to reason about. Struct field mutations through a parameter are always visible to the caller. Use & on a collection parameter to allow vector growth (append) to propagate back. const parameters signal read-only intent, and in debug builds the runtime locks the store for the duration of the call — enough to catch most accidents during development.
Downside The compile-time guarantees are much weaker. Rust's borrow checker statically proves no aliased mutations, no dangling references, and no data races — before the program ever runs. Loft's const is a debug-only runtime check. Aliasing is unchecked at compile time, and the engine's Rust runtime is what truly enforces memory safety.
4. ^ is XOR; exponentiation uses pow()
Loft
area = PI * pow(r, 2.0) // exponentiation via pow()
bits = a | b // bitwise OR
mask = a & b // bitwise AND
xor = a ^ b // bitwise XOR
Rust
let area = PI * r.powi(2); // or r.powf(2.0)
let bits = a | b; // bitwise OR
let mask = a & b; // bitwise AND
let xor = a ^ b; // bitwise XOR
Upside ^ behaves exactly as Rust developers expect — it is bitwise XOR. Bitwise operator precedence matches Rust and C: | (loose) → ^ → & → shifts → arithmetic (tight).
Downside Exponentiation requires a function call: pow(base, exp) for float/single — there is no ** or ^ exponentiation operator. Integer exponentiation is not in the standard library; compute it with a loop or cast to float first.
5. No loop or while
Loft
for _ in 0..2147483647 { // large upper bound
if done() { break; }
step();
}
Rust
loop {
if done() { break; }
step();
}
while !done() { step(); }
Upside Every loop has an iteration variable, making it easy to add index tracking or break conditions without restructuring. For a near-unbounded poll loop use for _ in 0..2147483647 — the break will fire long before the limit is reached in practice.
Downside while condition { } is universally understood and directly expresses intent. Its absence surprises Rust and C programmers. Event loops and polling patterns are more verbose with the open-ended range workaround.
6. Filtered loops — for ... if ...
Loft
for x in items if x > 0 {
total += x;
}
Rust
for x in items.iter().filter(|&&x| x > 0) {
total += x;
}
Upside Reads like natural language. No closure syntax, no double-reference in the filter predicate. v#remove inside a filtered loop also safely removes the current element while iterating — something that requires careful index management in Rust.
Downside The inline if filter is limited to the loop it lives in. For multi-stage pipelines, loft now offers map(v, fn f), filter(v, fn pred), and reduce(v, fn f, init) as composable higher-order functions — see section 11. Rust's lazy iterator chain (.filter().map().take_while()) avoids intermediate allocations; loft's higher-order functions allocate a new vector at each stage.
7. Loop attributes: #first, #count, #index
Loft
for x in 1..=5 {
if !x#first { b += ", "; }
b += "{x#count}:{x}";
}
Rust
for (i, x) in (1..=5).enumerate() {
if i != 0 { b.push_str(", "); }
b.push_str(&format!("{i}:{x}"));
}
Upside No tuple destructuring needed. x#first reads as "is this the first x", which is self-explanatory. Eliminates helper counter variables for the common pattern of comma-separated output.
Downside The #attribute syntax is unique to loft and unfamiliar to everyone else. Rust's .enumerate() composes freely with other adapters; loft's attributes are only available on the loop variable of the innermost loop.
8. Named loop break — x#break
Loft
for x in 1..5 {
for y in 1..5 {
if x * y >= 6 { x#break; }
}
}
Rust
'outer: for x in 1..5 {
for y in 1..5 {
if x * y >= 6 { break 'outer; }
}
}
Upside Reuses the existing loop variable name as the label. No separate 'label declaration at the loop head. The name x#break reads as "break out of the loop over x".
Downside Rust's lifetime-label syntax 'outer is explicit and visually separate from the loop body. x#break is easy to confuse with the loop attributes x#first and x#count, which have completely different semantics.
9. Methods by self name, not impl blocks
Loft
fn greet(self: Person) -> text {
"Hello, {self.name}!"
}
fn name_len(self: const Person) -> integer {
length(self.name) // const: read-only access guaranteed
}
p.greet() // dot syntax
greet(p) // free-function call — identical
Rust
impl Person {
fn greet(&self) -> String {
format!("Hello, {}!", self.name)
}
fn name_len(&self) -> usize {
self.name.len()
}
}
p.greet(); // only dot syntax
Upside No impl block ceremony. Methods and free functions are the same thing — just a naming convention. Functions can be added to any type from any file without modifying the original struct definition, similar to extension methods. Mark the first parameter const to guarantee the method never modifies the receiver — analogous to Rust's &self.
Downside No consuming self. Plain (non-const) methods behave like &mut self — the caller's value is always mutably aliased. Multiple functions with the same name but different non-variant self types are a compile error — no standard overloading.
10. Polymorphic enum dispatch
Loft
enum Shape {
Circle { r: float },
Rect { w: float, h: float }
}
fn area(self: Circle) -> float { PI * pow(self.r, 2.0) }
fn area(self: Rect) -> float { self.w * self.h }
s.area() // dispatches on the runtime variant
Rust
enum Shape {
Circle { r: f64 },
Rect { w: f64, h: f64 },
}
fn area(s: &Shape) -> f64 {
match s {
Shape::Circle { r } => PI * r * r,
Shape::Rect { w, h } => w * h,
}
}
Upside Each variant's behaviour lives in its own small function — easy to read and extend. Adding a new type of shape only requires a new fn area(self: NewShape) without modifying the dispatch site. Feels like OOP method overriding without the inheritance.
Downside Rust's match is exhaustive: the compiler forces you to handle every variant. In loft, leaving a variant without an implementation emits a compiler warning but does not stop compilation. To suppress the warning and produce a null return, write an explicit empty-body stub: fn area(self: NewShape) -> float { }. Exhaustiveness is enforced by discipline, not the type system.
11. No closures — but compile-checked function references
Loft
// fn <name> produces a compile-time-checked function reference
fn double(x: integer) -> integer { x * 2 }
fn is_even(x: integer) -> integer { x % 2 == 0 }
fn add(a: integer, b: integer) -> integer { a + b }
// Store, call, or pass a fn-ref like any other value:
f = fn double;
assert(f(5) == 10);
// Higher-order functions use fn-refs directly:
nums = [1, 2, 3, 4, 5];
doubled = map(nums, fn double);
evens = filter(nums, fn is_even);
total = reduce(nums, 0, fn add);
// Parallel dispatch — worker result collected per element:
for item in scores par(result=score_fn(item), 4) {
total += result;
}
Rust
fn double(r: &Score) -> i32 { r.value * 2 }
// Rayon parallel iterator:
let total: i32 = scores.par_iter().map(double).sum();
// Closure captures context freely:
let offset = 5;
let shifted: Vec<i32> = v.iter()
.map(|x| x + offset)
.collect();
Upside Simpler mental model. No capture modes (move vs borrow), no Fn/FnMut/FnOnce trait bounds, no lifetime constraints on captured variables. The fn <name> expression gives a compile-checked reference to any named function — the compiler verifies the name exists and has a matching signature before emitting any code.
Downside No closures or lambdas. A fn <name> reference cannot capture surrounding variables — context must be embedded in the data or passed as an explicit parameter. Indirect calls are supported (f(args) where f holds a fn-ref), but fn-refs cannot be compared, printed, or used in arithmetic. Rust's Fn/FnMut/FnOnce traits give far more flexibility for callbacks that need local state.
12. No generic functions or trait system
Loft
// No generic functions — must write a version per type
fn max_int(a: integer, b: integer) -> integer {
if a > b { a } else { b }
}
fn max_float(a: float, b: float) -> float {
if a > b { a } else { b }
}
Rust
fn max<T: PartialOrd>(a: T, b: T) -> T {
if a > b { a } else { b }
}
Upside Nothing to learn about type parameters, trait bounds, where clauses, dyn Trait, impl Trait, or associated types. Collections (vector<T>, hash<T>, sorted<T>) are generic at the engine level, covering the most common need.
Downside Code cannot be written once and reused across types. Every generic algorithm must be duplicated per type or pushed down into the standard library. There is no way for user code to define an "anything sortable" or "anything printable" abstraction.
13. String formatting — embedded expressions
Loft
msg = "Hi {name}, score: {score:+6.2}" // width.prec
prec = "{value:.2}" // precision only
hex = "{n:#x}"
list = "{for x in 1..4 {x*2}}" // "2,4,6"
= "{\"hello\":3}" // nested string literal — works
Rust
let msg = format!("Hi {name}, score: {:+6.2}", score);
let prec = format!("{:.2}", value);
let hex = format!("{:#x}", n);
// no in-string iteration; needs a separate collect step
// nested string literals in format args are fine in Rust
Upside Concise — no format!() call, no separate variable. Full format expressions (arithmetic, slices, for comprehensions) can appear directly inside {}. Specifiers mirror Rust: :width, :.precision, :width.precision, sign (+), radix (#x, #o, b), alignment (<, >, ^), and zero-padding (0N) all work.
Downside Unknown radix letters in specifiers are compile-time errors (e.g. :5z or :5B are both rejected). Radix letters are case-sensitive: valid ones are b, o, x/X, e, and j/json — uppercase B or O produce an error. Applying a numeric specifier to an incompatible type (such as :x on a text value) is silently ignored rather than flagged.
14. Built-in parallel for-loops — par(...)
Loft
fn double(r: const Score) -> integer { r.value * 2 }
sum = 0;
for item in scores par(b=double(item), 4) {
sum += b; // b is double(item), computed in parallel
} // results arrive in original order
// Method form:
for item in scores par(b=item.value(), 4) { ... }
Rust
use rayon::prelude::*;
fn double(r: &Score) -> i32 { r.value * 2 }
let sum: i32 = scores.par_iter()
.map(double)
.sum(); // order is not guaranteed without extra work
// rayon is an external crate; add to Cargo.toml first
Upside Built into the language — no external crate, no Cargo.toml edit. The compiler validates the worker function signature at the call site. Results are delivered in the original vector order. The thread count is set per call, making it easy to tune for the hardware at hand.
Downside The worker must return a primitive (integer, long, float, or boolean) — returning text or a struct reference is not yet supported. Workers cannot capture local variables: context must be embedded as fields alongside the data in the element struct. For multi-stage transformations, use map() / filter() / reduce() sequentially — each stage allocates a new intermediate vector.