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: a write to it is a compile error
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 is mutable. Variables are mutable by default, so refactoring is frictionless. Use const to say a value must not change — the compiler refuses every write it forbids.
Downside Immutability is opt-in, not the default. Rust makes variables immutable unless you write mut, so every binding is protected for free; in loft only the ones you mark const are.
2. Null instead of Option<T>
Loft
struct Point {
label: text?, // `?`: may be absent
x: integer, // never null — the default
}
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 The absence is in the type, as in Rust, but nothing forces you to discharge it: label can be read straight and answers null, where Rust's Option makes every call site say what it does about None. What loft gives you instead is ?? and ? at the point of use, plus a warning when a nullable value flows into a slot that cannot hold one. (Older code may carry not null on a field; it is deprecated and has no effect, because a type is non-null by default now.)
3. Parameter mutability — const and & instead of ownership
Loft
// a plain collection parameter is already shared — the append reaches the caller
fn push_two(v: vector<integer>) {
v += [1, 2]; // caller sees the change
}
// const: read-only (a write through it is a compile error)
fn count(v: const vector<integer>) -> integer {
len(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. A collection parameter is SHARED with no annotation at all — field writes, element writes, appends, removes and clears all reach the caller. & adds exactly one thing on top of that: whole-value replacement, v = [...], seen by the caller. A const parameter is read-only: a write through it, or passing it on to a parameter that is not const, is a compile error.
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 covers the name it marks and nothing more; aliasing is otherwise unchecked at compile time, and the engine's Rust runtime is what truly enforces memory safety.
4. ^ is XOR; ** or pow() for exponentiation
Loft
sq = 2 ** 10; // ** exponentiation — integers: 1024
area = PI * r ** 2.0; // floats too; pow(r, 2.0) also works
bits = a | b; // bitwise OR
mask = a & b; // bitwise AND
xor = a ^ b; // bitwise XOR
Rust
let sq = 2i32.pow(10); // 1024 — no ** operator in 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 Loft has a ** exponentiation operator that works on both integers (2 ** 10 == 1024) and floats (2.0 ** 3.0 == 8.0); pow(base, exp) is also available for float/single. ^ behaves exactly as Rust developers expect — it is bitwise XOR. Bitwise operator precedence matches Rust and C: | (loose) → ^ → & → shifts → arithmetic (tight).
Downside The ** operator is loft-specific — Rust has no exponentiation operator and reaches for methods instead (.pow(), .powi(), .powf()), so the muscle memory does not transfer. And ^ is XOR in both languages, never exponentiation — a trap for anyone reaching for it as a power operator out of habit.
5. No loop keyword — an infinite loop is while true
Loft
while !done() { step(); } // while works
while true { // infinite until a break
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: an unconditionally infinite loop is spelled while true { }, which reads the same and ends only at a break or a return.
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 offers map(v, f), filter(v, pred), and reduce(v, init, f) 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 exist only on a for loop's own variable.
8. Named loop break — x#break
Loft
for x in 1..5 {
for y in 1..5 {
if x * y >= 6 { x#break; }
pairs += 1;
}
}
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 {
len(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.
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 A variant needs its own implementation only once something calls the method on a value held as the enum — that call is refused until every variant has one or the enum has a fallback (fn area(self: Shape)) — so a missing one surfaces at the first such call, not where the variant is declared. An empty-body stub (fn area(self: NewShape) -> float { }) answers the type's default, 0.0, which a caller cannot tell from a computed one; Rust's match makes that decision visible at every site.
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");
// Capturing and non-capturing lambdas both work with map/filter/reduce:
shifted = map([1, 2, 3], shift); // carries `offset` with it
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 Capture works for every type — scalars, text, structs and every collection kind — with no capture modes (move vs borrow), no Fn/FnMut/FnOnce trait bounds, and no lifetime annotations. The compiler picks the mode from the type: a scalar or text is copied at definition time, a struct or collection is shared with the enclosing scope, and a scalar the closure writes to is shared as well, so an accumulator needs no declaration. Both long-form (fn(x: integer) -> integer { x * 2 }) and short-form (|x| { x * 2 }) lambdas are supported. Named function references (a bare name: map(v, double)) are compile-checked.
Downside You do not choose the capture mode, so a case Rust expresses by picking one has no spelling here. Three limits have no Rust counterpart: a capturing closure cannot be stored in a collection (a struct field holds one fine), a closure cannot write a captured &integer or &text parameter (it holds a copy), and a scalar the closure writes to may be captured by only one closure — the cure for the last two is a local the closure updates, or state held in a struct and captured.
12. Generic functions — inferred, with structural interface bounds
Loft
// Pass-through generics work:
fn identity<T>(x: T) -> T { x }
identity(42); // integer
identity("hello"); // text
// Interface bounds work, and are satisfied structurally:
fn largest<T: Ordered>(a: T, b: T) -> T { if a > b { a } else { b } }
largest(3, 9); // 9
largest("ada", "bob"); // "bob" — same function
// A per-type version is still what you write when the bound does not fit:
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 Interface bounds (<T: Ordered>, <T: Addable>, <T: Printable>) are structural and static-dispatch only — a type satisfies an interface implicitly by defining the required operators, and there are no trait objects (dyn Trait), associated types, or blanket impls. Rust's nominal traits and dynamic dispatch reach cases loft's static interfaces cannot.
13. String formatting — embedded expressions
Loft
msg = "Hi {name}, score: {score:6.2}"; // width.precision
rank = "seed {n:+}"; // sign shows on integers: "seed +42"
prec = "{value:.2}"; // precision only
hex = "{n:#x}";
list = "{for x in 1..4 {x*2}}"; // [2,4,6]
nest = "{\"hello\"}"; // nested string literal — works
Rust
let msg = format!("Hi {name}, score: {:6.2}", score);
let rank = format!("seed {:+}", n);
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. The sign (+) and zero-pad (0N) flags apply to floats too: {5.25:+} is +5.25 and {5.25:08} is 00005.25.
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 and d on an integer, and j/json on a struct, vector or enum — uppercase B or O produce an error, and there is no exponent form (e). Applying a numeric specifier to an incompatible type (such as :x or zero-padding on a text value) 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.twice(), 4) { sum += b; }
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 A worker is a named function, not a closure: context from the calling scope reaches it only as extra scalar arguments, because each worker runs on its own copy of the heap and cannot reach a struct or vector of the caller's. Nor may it touch shared state (no println, no file access) — a rule the compiler does not check. For multi-stage transformations, use map() / filter() / reduce() sequentially — each stage allocates a new intermediate vector.