vs Rust

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 keyword — use while or for + break

Loft

while !done() { step(); }  // while works

// Infinite loop workaround — use a long range:
for _ in 0l..9223372036854775807l {  // long: ~9.2×10^18
    if should_exit() { break; }
    step();
}

Rust

while !done() { step(); }

loop {           // truly infinite — no bound needed
    if should_exit() { break; }
    step();
}

Upside while condition { } works exactly as expected. Every for loop has an iteration variable, making it easy to add index tracking or a cycle limit without restructuring.

Downside There is no loop { } keyword for an unconditionally infinite loop. The workaround — a for loop over a long range — is verbose but practically unbounded: the 64-bit maximum (~9.2×10^18) exceeds any realistic event-loop iteration count.

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. Closures — same-scope capture works

Loft

// Capture works when called in the same scope:
offset = 10;
shift = fn(x: integer) -> integer { x + offset };
assert(shift(5) == 15, "shift");

// Non-capturing lambdas work with map/filter/reduce:
doubled = map([1, 2, 3], fn(x: integer) -> integer { x * 2 });
evens   = filter([1, 2, 3, 4], |x| { x % 2 == 0 });

// Cross-scope: returning a capturing lambda works:
fn make_adder(n: integer) -> fn(integer) -> integer {
    fn(x: integer) -> integer { x + n }
}

Rust

// Closure captures context freely, any scope:
let offset = 5;
let shift = |x| x + offset;
assert_eq!(shift(5), 10);

// Works as argument to higher-order functions:
let shifted: Vec<i32> = v.iter()
    .map(|x| x + offset)
    .collect();

// Returning a capturing closure:
fn make_adder(n: i32) -> impl Fn(i32) -> i32 {
    move |x| x + n
}

Upside Same-scope capture works for integers, text, and mutable variables — no capture modes (move vs borrow), no Fn/FnMut/FnOnce trait bounds, no lifetime annotations. Both long-form (fn(x: integer) -> integer { x * 2 }) and short-form (|x| { x * 2 }) lambdas are supported. Named function references (fn name) are compile-checked.

Downside Capture is always by value (like Rust move). Rust's Fn/FnMut/FnOnce traits give more flexibility: closures can borrow by reference, be stored in structs, and passed freely across scopes. Loft captures at definition time only — no borrow-based captures.

12. Generic functions — pass-through only, no trait bounds

Loft

// Pass-through generics work:
fn identity<T>(x: T) -> T { x }
identity(42)        // integer
identity("hello")  // text

// Trait-bounded generics are not supported:
// fn max<T: PartialOrd>(a: T, b: T) -> T { ... }  // not valid
// Must write a version per type:
fn max_int(a: integer, b: integer) -> integer {
    if a > b { a } else { b }
}

Rust

fn identity<T>(x: T) -> T { x }

fn max<T: PartialOrd>(a: T, b: T) -> T {
    if a > b { a } else { b }
}
// One function works for all comparable types.

Upside Basic generic functions (identity, pick_second, wrappers) work out of the box. Collections (vector<T>, hash<T>, sorted<T>) are generic at the engine level, covering the most common need. No dyn Trait, impl Trait, or associated types to learn.

Downside No trait bounds — a generic function cannot constrain T to be comparable, printable, or hashable. Any algorithm that requires an operation on T (comparison, addition, display) must be written once per concrete type. There is no way for user code to define "anything sortable" without duplicating the sort logic.

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, or zero-padding on a boolean) is a compile-time error.

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 Workers can return primitives (integer, long, float, boolean), text, and inline enums — but not struct references. 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.