Loft catches most mistakes at compile time. The ones that get through are surprises rather than crashes: a value that reads as null, a count in the wrong unit, a write that lands somewhere else than you meant. This page collects the ones that catch people out, and most sections prove themselves with a live example you can run. Helper used in the ?? double-evaluation example below. Increments the call counter and returns the given value unchanged.
fn counted_call(calls: &integer, value: integer) -> integer {
calls += 1;
value
}
Used to obtain a null text value (an uninitialized field is the NUL sentinel).
struct NullTextHolder {
s: text,
}
struct OptTextHolder {
s: text?,
}
A boolean? holds all three states; a plain boolean holds two.
struct OptBoolHolder {
b: boolean?,
}
Used by the parameter section below: what a callee does to a collection it is GIVEN, versus what it does to a collection it BOUND.
fn appended_by_callee(v: vector<integer>) { v += [4]; }
fn replaced_by_callee(v: vector<integer>) { v = [7, 7]; }
fn replaced_through_ref(v: &vector<integer>) { v = [7, 7]; }
One table, two indexes on it — see the hash section.
struct Item { key: text, count: integer }
struct Catalogue {
all: vector<Item>,
by_key: hash<Item[key]>,
}
A field with a declared default, beside one without.
struct Tile { name: text, palette_pick: integer = -1 }
struct PlainTile { name: text, palette_pick: integer }
fn main() {
Null values — hidden reserved values
Every type reserves one special value to mean "nothing here" (null). Be aware of what it is for each type:
integer— the most negative 64-bit integer (-9 223 372 036 854 775 808) is nullfloat/single—NaN(Not a Number) is nullcharacter— the NUL character (code point 0) is nulltext— a single NUL character ('\0') is null; the empty string""is NOT nullreference— record 0 is nullboolean— a third byte state, which is neitherfalsenortrue- plain
enum— both the 0 byte and the 255 byte are reserved, so an enum holds at most 254 variants
For the first four, the reserved value is a value you can write, so there is one value per type that you cannot tell apart from null. For integers, that is -9 223 372 036 854 775 808. When defended with ?? null (or a following null-check), division by zero produces this same value, so both paths look the same to your code:
zero = 0;
n = 1 / zero ?? null;
assert(!n, "div-by-zero defended with `?? null` is null");
assert(n != 0, "null is not zero; it is -9 223 372 036 854 775 808");
assert(n < 0, "i64::MIN is the most negative 64-bit integer");
Arithmetic on null propagates: null plus anything is null.
assert(! (n + 1), "null + 1 is still null");
Text null is the NUL character (\0), not the empty string. A text? local or field can hold it; a plain text cannot, and a parse respects that.
holder = NullTextHolder.parse(`{{}}`);
assert(holder.s == "", "a missing `text` field is empty, because it cannot be null");
opt = OptTextHolder.parse(`{{}}`);
assert(!opt.s, "a missing `text?` field IS null — the declaration decides");
empty = "";
assert(empty, "empty string is NOT null — this surprises most newcomers");
boolean is the exception, and the useful one: its null is a third byte state that no boolean expression can produce, so false is a value like any other and a boolean? tells all three apart.
assert(!(false == null), "`false` is a value, not the boolean null");
maybe = OptBoolHolder.parse(`{{}}`);
assert(maybe.b == null, "a missing `boolean?` field IS null");
assert(maybe.b ?? true, "…so `??` reaches its default");
stored = OptBoolHolder { b: false };
assert(!(stored.b == null), "…while a stored `false` is not null");
assert(!(stored.b ?? true), "…and `??` keeps it");
Parsing text to a number needs a fallback
An unchecked text as integer is a COMPILE error: parsing can fail, and loft refuses to let that failure slip through as a silent null. Ask for a checked cast with integer? (yields the number or null), or supply a default with ?? <value>. The same rule applies to float.
parsed = "42" as integer?;
assert(parsed == 42, "checked cast `as integer?` yields the number");
fallback = "oops" as integer? ?? -1;
assert(fallback == -1, "unparseable text falls back to the default");
Integer overflow yields null
A calculation that overflows the integer range is uncomputable, so loft yields null and keeps running — it never wraps to a silently-wrong number (the way C, or a Rust release build, would) and it never crashes. One bad value degrades locally; the rest of the program still computes. huge = 9223372036854775807; huge + 1 → null (not a wrapped negative) Unlike a divide by zero, an overflow writes no log entry and prints no warning at any setting: the null IS the report, and there is nothing to switch on to get a second one. So check the result for null, or keep intermediate values within range.
huge = 9223372036854775807;
assert(! (huge + 1), "overflow yields null, not a wrapped value");
Bitwise operators with zero
All bitwise operators (AND, OR, XOR, shift) work correctly with zero. Zero is the identity element for OR, XOR, and shift; zero for AND.
assert(0b1010&0 == 0, "AND with 0: zero");
assert(0b1010|0 == 0b1010, "OR with 0: identity");
assert(0b1010^0 == 0b1010, "XOR with 0: identity");
assert(5<<0 == 5, "shift left by 0: identity");
assert(5>>0 == 5, "shift right by 0: identity");
Float null compares uniformly with every other scalar
Floats store null as NaN internally, but null COMPARISON is uniform with every other scalar type: null == null is TRUE, null is not equal to any real value, and null orders as the low extreme. Detect a null float with f == null (or !f / f ?? default) — NOT the f != f trick from other languages, which never fires here because null == null is true. Both the defended and the undefended division yield null and the program continues; the undefended site also writes a warning to the log.
bad = 0.0 / 0.0 ?? null;
assert(!bad, "a null float is falsy");
assert(bad == null, "detect a null float with `== null`");
assert(bad == bad, "null == null is true (uniform null model)");
assert(! (bad != null), "a null float `!= null` is false");
assert(bad != 0.0, "null != 0.0 is true");
Use f == null, !f, or f ?? default to check for null floats.
Text length counts characters; size counts bytes
len() on text returns the number of characters (Unicode code points) — the human count. size() returns the number of UTF-8 bytes; multi-byte characters (accented letters, emoji, CJK) each occupy 2-4 bytes. The two values differ whenever the text contains any multi-byte character.
emoji = "Hi 😊!";
assert(len(emoji) == 5, "5 characters (H, i, space, 😊, !)");
assert(size(emoji) == 8, "8 UTF-8 bytes (the emoji is 4): {size(emoji)}");
count = 0;
for _ in emoji { count += 1; }
assert(count == 5, "for-loop iterates by character, not byte");
Indexing and slicing take BYTE offsets, and an offset landing inside a multi-byte character is not an error: it rounds OUTWARD to whole characters. So a slice can be longer than the byte range you asked for, and two different ranges can hand back the same text.
mixed = "aébc"; // bytes: a, then 'é' across two, then b, c
assert(mixed[2] == 'é', "byte 2 is the second half of 'é', and reads as 'é'");
assert(mixed[0..2] == "aé", "the end rounds forward: three bytes come back, not two");
assert(mixed[1..2] == mixed[1..3], "two byte ranges, one character, one answer");
Mitigation: Use for c in text to iterate by character. Use c#index and c#next to get the byte boundaries of each character.
#index means different things on text and vectors
On a text loop, c#index is the byte offset of the current character. On a vector loop, v#index is the 0-based element position. Both are called #index but represent different units.
v = [10, 20, 30];
idx = 0;
for x in v { idx = x#index; }
assert(idx == 2, "vector #index: element position (0-based)");
Text #index is a byte offset, not a character count:
t = "aé";
byte_pos = 0;
for c in t { byte_pos = c#index; }
assert(byte_pos == 1, "text #index: byte offset of 'é' (byte 1, not char 1)");
?? evaluates the left side exactly once
The ?? operator means "use this value, or if it is null, use the right side instead". The left-hand expression is evaluated exactly once regardless of whether the result is null. The example below uses counted_call (defined above) to verify this.
calls = 0;
qq_result = counted_call(calls, 7) ?? 99;
assert(calls == 1, "?? evaluated left side exactly once: {calls}");
assert(qq_result == 7, "result is the value from that single evaluation: {qq_result}");
This means result = expensive_call() ?? default is safe: the function is called once. If it returns null, default is used. There is no double call.
Text indexing and slicing return different types
txt[i] returns a character (a single Unicode scalar value). txt[i..j] returns text (a UTF-8 string). These are different types.
txt = "hello";
ch = txt[0];
slice = txt[0..1];
assert(ch == 'h', "indexing returns a character");
assert(slice == "h", "slicing returns text");
A character appends to text directly:
result = "";
for c in "abc" { result += c; }
assert(result == "abc", "`+=` appends a character to text");
Format strings: braces are always interpreted
Every string literal in loft is a format string. Literal braces must be escaped as {{ and }}:
n = 42;
assert("{n}" == "42", "single braces: format expression");
assert(len("{{}}") == 2, "double braces produce literal brace chars");
Forgetting to escape braces in expected output is a common mistake in assertions and comparisons.
A hash iterates, and collections over one record type index one table
for e in my_hash { } works and visits the records in ascending key order — the same loop shape as sorted and index, which yield records too. A hash key runs one way only: the - prefix that reverses a sorted key is refused on a hash, so reach for sorted when the order has to run the other way. The one loop attribute a hash does not carry is e#remove, because the walk is over a sorted snapshot — remove through the key instead, with my_hash[key] = null.
loft's store is an in-memory database, and collections over the same record type in one struct are INDEXES ON ONE TABLE — as ordinary here as several indexes on a table are in any database, and declared for the same reason. A vector beside a hash gives you insertion order and keyed lookup over the same rows; add a sorted and you have a third way in. The rows live in the table, so filling any one index fills them all, a write through one is visible through the others, and a removal through one removes the row itself.
What that asks of you is only at construction: hand the rows to ONE member and write [] for the others. That [] declares an index; it does not declare an empty collection to be filled separately. Hand rows to two members and the table gets both sets, so a vector you wrote two rows into holds four. loft advises on that literal (advice[linked-group-double-fill]).
cat = Catalogue { all: [Item{key:"zebra", count:1}, Item{key:"apple", count:5}], by_key: [] };
insertion = "";
for e in cat.all { insertion += "{e.key} "; }
assert(insertion == "zebra apple ", "the vector keeps insertion order: {insertion}");
ordered = "";
for e in cat.by_key { ordered += "{e.key} "; }
assert(ordered == "apple zebra ", "the hash walks ascending key order: {ordered}");
assert(len(cat.by_key) == 2, "…over the records the vector was given: {len(cat.by_key)}");
Mutation guard blocks appending during iteration
The compiler prevents v += [x] inside for e in v. This protects against infinite loops. The guard also catches field access: for e in db.items { db.items += [x]; } is blocked too. Removing is the one mutation a loop may make: e#remove drops the current element, in a plain loop or in a filtered one.
shrinking = [1, 2, 3];
for e in shrinking if e > 1 { e#remove; }
assert(len(shrinking) == 1, "`e#remove` drops the elements the filter selected");
If-expression requires else when used as a value
Using if as a value expression without an else clause is a compile error. This prevents accidental null values from missing branches. If-statements (where the body has no value) do not need else. For example, x = if cond { 1 } is an error; write x = if cond { 1 } else { 0 }.
Match guards do not prove a variant is handled
A guarded arm like Red if cond => ... does not count as handling the Red variant because the guard can fail at runtime. Even if every variant has a guarded arm, you still need a wildcard _ or an unguarded arm so the compiler knows every case is covered.
A parameter shares; a bind copies
A struct or collection PARAMETER is a shared view of the caller's value — nothing is copied when you call. So everything the function does to the contents is visible to the caller once it returns: writing an element, appending, removing, clearing. None of that needs &.
& buys exactly one thing: replacing the WHOLE value. v = [7, 7] inside a plain parameter gives that function a different vector and leaves the caller's alone; through a &vector<T> the caller sees the new vector. So & means "I may replace this", not "I may add to it".
A plain BIND is the opposite, and the pair is what catches people: d = self.data COPIES, so d += [x] leaves self.data at its old length. Identical-looking lines, opposite outcomes, decided by whether the name came from a parameter or from an assignment.
shared = [1, 2, 3];
appended_by_callee(shared);
assert(len(shared) == 4, "a plain parameter grew the CALLER's vector: {len(shared)}");
kept = [1, 2, 3];
replaced_by_callee(kept);
assert(len(kept) == 3, "…while replacing the whole value stayed local: {len(kept)}");
swapped = [1, 2, 3];
replaced_through_ref(swapped);
assert(swapped[0] == 7, "…and `&` is what makes a replacement reach back");
bound = [1, 2, 3];
alias = bound;
alias += [4];
assert(len(bound) == 3, "a BIND copies — this append reached nothing: {len(bound)}");
Text file reading assumes UTF-8
lines() and content() decode the file as UTF-8. A file that is not valid UTF-8 — a binary file, or text in another encoding such as Latin-1 — neither crashes nor stops the program: the read warns, answers null, and lines() therefore hands back an EMPTY vector. A loop over it runs zero times and the program carries on, so a text read of the wrong file looks exactly like a read of an empty one. Read such a file as bytes instead: read_bytes(path) for the whole file, or set a binary format on the handle — f#format = LittleEndian (or BigEndian) — and read fields one at a time (see the File page).
A struct literal zeroes the fields it leaves out
Naming some fields and omitting others is legal, and each omitted field takes its type's zero — 0, "", false. Nothing in the declaration chose that value, and where zero is a meaningful member of the field's domain (a palette index whose 0 is a real colour) it is a wrong value rather than an absent one. loft advises on the partial literal (advice[omitted-field-zero]). The cure is a declared default on the field, after which the omission has an answer the declaration chose:
plain_tile = PlainTile { name: "grass" };
assert(plain_tile.palette_pick == 0, "an omitted field takes the type's zero");
tile = Tile { name: "grass" };
assert(tile.palette_pick == -1, "a declared default answers instead: {tile.palette_pick}");
A field only some variants declare answers null on the others
Enum payloads are named fields you read straight — shape.radius. Where every variant declares the same name and type it is ONE slot and each variant reads its own value. Where only SOME declare it, a value of any other variant has no such field: the read answers null, although the field's type does not say it can, and a write to it is silently dropped. loft warns (warning[variant-field-unchecked]); bind the field in a match arm or an is test, which is per-variant and cannot reach the wrong one.
A yield inside an expression runs eagerly on --native
A generator is suspended at each yield on both backends when the yield is a statement — in any loop, if, match, nested loop or beside a continue, for any yielded type. A yield inside an expression is different when an operand before it could change while the generator is suspended, as in s = s + { yield x; "b" } with s a text. On --native that loop runs to its end before the first value is handed out. The values agree, the side effects do not, and an endless loop of that shape never hands out a value on --native: it runs until memory runs out. Make the yield a statement of its own: yield x; s = s + "b";.
XOR is ^, not exponentiation
Unlike some languages where ^ means "power", in loft ^ is bitwise XOR. For exponentiation use the ** operator (2 ** 10 == 1024, 2.0 ** 3.0 == 8.0) or the pow() function. Watch one precedence footgun: a leading - binds TIGHTER than **, so -x ** y raises the NEGATED BASE. loft warns on the bare spelling; write the parentheses for whichever of the two you mean.
assert((0b1010^0b1100) == 0b0110, "^ is XOR");
assert(2**10 == 1024, "** is the power operator");
assert(pow(2.0, 3.0) == 8.0, "pow() also computes powers");
base = 2;
assert((-base) ** 2 == 4, "`-x ** y` is this one: the sign binds to the base");
assert(-(base ** 2) == -4, "…and this one needs the parentheses");
}
Run it yourself
This page is a loft program. It runs here, in your browser — press Run, then ask it something.
Stop on a line
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// @NAME: Safety
// @TITLE: Language traps and how to avoid them
// Loft catches most mistakes at compile time. The ones that get through are
// surprises rather than crashes: a value that reads as null, a count in the
// wrong unit, a write that lands somewhere else than you meant. This page
// collects the ones that catch people out, and most sections prove themselves
// with a live example you can run.
// Helper used in the '??' double-evaluation example below.
// Increments the call counter and returns the given value unchanged.
fn counted_call(calls: &integer, value: integer) -> integer {
calls += 1;
value
}
// Used to obtain a null text value (an uninitialized field is the NUL sentinel).
struct NullTextHolder {
s: text,
}
struct OptTextHolder {
s: text?,
}
// A `boolean?` holds all three states; a plain `boolean` holds two.
struct OptBoolHolder {
b: boolean?,
}
// Used by the parameter section below: what a callee does to a collection it
// is GIVEN, versus what it does to a collection it BOUND.
fn appended_by_callee(v: vector<integer>) { v += [4]; }
fn replaced_by_callee(v: vector<integer>) { v = [7, 7]; }
fn replaced_through_ref(v: &vector<integer>) { v = [7, 7]; }
// One table, two indexes on it — see the hash section.
struct Item { key: text, count: integer }
struct Catalogue {
all: vector<Item>,
by_key: hash<Item[key]>,
}
// A field with a declared default, beside one without.
struct Tile { name: text, palette_pick: integer = -1 }
struct PlainTile { name: text, palette_pick: integer }
fn main() {
// ## Null values — hidden reserved values
// Every type reserves one special value to mean "nothing here" (null).
// Be aware of what it is for each type:
// - `integer` — the most negative 64-bit integer (-9 223 372 036 854 775 808) is null
// - `float`/`single` — `NaN` (Not a Number) is null
// - `character` — the NUL character (code point 0) is null
// - `text` — a single NUL character (`'\0'`) is null; the empty string `""` is NOT null
// - `reference` — record 0 is null
// - `boolean` — a third byte state, which is neither `false` nor `true`
// - plain `enum` — both the 0 byte and the 255 byte are reserved, so an
// enum holds at most 254 variants
//
// For the first four, the reserved value is a value you can write, so there
// is one value per type that you cannot tell apart from null. For integers,
// that is -9 223 372 036 854 775 808. When defended with `?? null` (or a
// following null-check), division by zero produces this same value, so both
// paths look the same to your code:
zero = 0;
n = 1 / zero ?? null;
assert(!n, "div-by-zero defended with `?? null` is null");
assert(n != 0, "null is not zero; it is -9 223 372 036 854 775 808");
assert(n < 0, "i64::MIN is the most negative 64-bit integer");
// Arithmetic on null propagates: null plus anything is null.
assert(! (n + 1), "null + 1 is still null");
// Text null is the NUL character ('\0'), not the empty string. A `text?` local
// or field can hold it; a plain `text` cannot, and a parse respects that.
holder = NullTextHolder.parse(`{{}}`);
assert(holder.s == "", "a missing `text` field is empty, because it cannot be null");
opt = OptTextHolder.parse(`{{}}`);
assert(!opt.s, "a missing `text?` field IS null — the declaration decides");
empty = "";
assert(empty, "empty string is NOT null — this surprises most newcomers");
// `boolean` is the exception, and the useful one: its null is a third byte
// state that no boolean expression can produce, so `false` is a value like
// any other and a `boolean?` tells all three apart.
assert(!(false == null), "`false` is a value, not the boolean null");
maybe = OptBoolHolder.parse(`{{}}`);
assert(maybe.b == null, "a missing `boolean?` field IS null");
assert(maybe.b ?? true, "…so `??` reaches its default");
stored = OptBoolHolder { b: false };
assert(!(stored.b == null), "…while a stored `false` is not null");
assert(!(stored.b ?? true), "…and `??` keeps it");
// ## Parsing text to a number needs a fallback
// An unchecked `text as integer` is a COMPILE error: parsing can fail, and
// loft refuses to let that failure slip through as a silent null. Ask for a
// checked cast with `integer?` (yields the number or null), or supply a
// default with `?? <value>`. The same rule applies to `float`.
parsed = "42" as integer?;
assert(parsed == 42, "checked cast `as integer?` yields the number");
fallback = "oops" as integer? ?? -1;
assert(fallback == -1, "unparseable text falls back to the default");
// ## Integer overflow yields null
// A calculation that overflows the integer range is uncomputable, so loft
// yields null and keeps running — it never wraps to a silently-wrong number
// (the way C, or a Rust release build, would) and it never crashes. One bad
// value degrades locally; the rest of the program still computes.
// huge = 9223372036854775807; huge + 1 → null (not a wrapped negative)
// Unlike a divide by zero, an overflow writes no log entry and prints no
// warning at any setting: the null IS the report, and there is nothing to
// switch on to get a second one. So check the result for null, or keep
// intermediate values within range.
huge = 9223372036854775807;
assert(! (huge + 1), "overflow yields null, not a wrapped value");
// ## Bitwise operators with zero
// All bitwise operators (AND, OR, XOR, shift) work correctly with zero.
// Zero is the identity element for OR, XOR, and shift; zero for AND.
assert(0b1010&0 == 0, "AND with 0: zero");
assert(0b1010|0 == 0b1010, "OR with 0: identity");
assert(0b1010^0 == 0b1010, "XOR with 0: identity");
assert(5<<0 == 5, "shift left by 0: identity");
assert(5>>0 == 5, "shift right by 0: identity");
// ## Float null compares uniformly with every other scalar
// Floats store null as `NaN` internally, but null COMPARISON is uniform with
// every other scalar type: `null == null` is TRUE, null is
// not equal to any real value, and null orders as the low extreme. Detect a
// null float with `f == null` (or `!f` / `f ?? default`) — NOT the `f != f`
// trick from other languages, which never fires here because `null == null`
// is true.
// Both the defended and the undefended division yield null and the program
// continues; the undefended site also writes a warning to the log.
bad = 0.0 / 0.0 ?? null;
assert(!bad, "a null float is falsy");
assert(bad == null, "detect a null float with `== null`");
assert(bad == bad, "null == null is true (uniform null model)");
assert(! (bad != null), "a null float `!= null` is false");
assert(bad != 0.0, "null != 0.0 is true");
// Use `f == null`, `!f`, or `f ?? default` to check for null floats.
// ## Text length counts characters; size counts bytes
// `len()` on text returns the number of characters (Unicode code points) — the
// human count. `size()` returns the number of UTF-8 bytes; multi-byte characters
// (accented letters, emoji, CJK) each occupy 2-4 bytes. The two values differ
// whenever the text contains any multi-byte character.
emoji = "Hi 😊!";
assert(len(emoji) == 5, "5 characters (H, i, space, 😊, !)");
assert(size(emoji) == 8, "8 UTF-8 bytes (the emoji is 4): {size(emoji)}");
count = 0;
for _ in emoji { count += 1; }
assert(count == 5, "for-loop iterates by character, not byte");
// Indexing and slicing take BYTE offsets, and an offset landing inside a
// multi-byte character is not an error: it rounds OUTWARD to whole
// characters. So a slice can be longer than the byte range you asked for,
// and two different ranges can hand back the same text.
mixed = "aébc"; // bytes: a, then 'é' across two, then b, c
assert(mixed[2] == 'é', "byte 2 is the second half of 'é', and reads as 'é'");
assert(mixed[0..2] == "aé", "the end rounds forward: three bytes come back, not two");
assert(mixed[1..2] == mixed[1..3], "two byte ranges, one character, one answer");
// Mitigation: Use `for c in text` to iterate by character. Use
// `c#index` and `c#next` to get the byte boundaries of each character.
// ## `#index` means different things on text and vectors
// On a text loop, `c#index` is the byte offset of the current character.
// On a vector loop, `v#index` is the 0-based element position.
// Both are called `#index` but represent different units.
v = [10, 20, 30];
idx = 0;
for x in v { idx = x#index; }
assert(idx == 2, "vector #index: element position (0-based)");
// Text #index is a byte offset, not a character count:
t = "aé";
byte_pos = 0;
for c in t { byte_pos = c#index; }
assert(byte_pos == 1, "text #index: byte offset of 'é' (byte 1, not char 1)");
// ## `??` evaluates the left side exactly once
// The `??` operator means "use this value, or if it is null, use the right side instead".
// The left-hand expression is evaluated exactly once regardless of whether the result is
// null. The example below uses `counted_call` (defined above) to verify this.
calls = 0;
qq_result = counted_call(calls, 7) ?? 99;
assert(calls == 1, "?? evaluated left side exactly once: {calls}");
assert(qq_result == 7, "result is the value from that single evaluation: {qq_result}");
// This means `result = expensive_call() ?? default` is safe: the function
// is called once. If it returns null, `default` is used. There is no double call.
// ## Text indexing and slicing return different types
// `txt[i]` returns a `character` (a single Unicode scalar value).
// `txt[i..j]` returns `text` (a UTF-8 string). These are different types.
txt = "hello";
ch = txt[0];
slice = txt[0..1];
assert(ch == 'h', "indexing returns a character");
assert(slice == "h", "slicing returns text");
// A character appends to text directly:
result = "";
for c in "abc" { result += c; }
assert(result == "abc", "`+=` appends a character to text");
// ## Format strings: braces are always interpreted
// Every string literal in loft is a format string. Literal braces must
// be escaped as `{{` and `}}`:
n = 42;
assert("{n}" == "42", "single braces: format expression");
assert(len("{{}}") == 2, "double braces produce literal brace chars");
// Forgetting to escape braces in expected output is a common mistake in
// assertions and comparisons.
// ## A hash iterates, and collections over one record type index one table
// `for e in my_hash { }` works and visits the records in ascending key order —
// the same loop shape as `sorted` and `index`, which yield records too. A hash
// key runs one way only: the `-` prefix that reverses a `sorted` key is refused
// on a hash, so reach for `sorted` when the order has to run the other way.
// The one loop attribute a hash does not carry is `e#remove`, because the walk
// is over a sorted snapshot — remove through the key instead, with
// `my_hash[key] = null`.
//
// loft's store is an in-memory database, and collections over the same record
// type in one struct are INDEXES ON ONE TABLE — as ordinary here as several
// indexes on a table are in any database, and declared for the same reason. A
// vector beside a hash gives you insertion order and keyed lookup over the same
// rows; add a `sorted` and you have a third way in. The rows live in the table,
// so filling any one index fills them all, a write through one is visible
// through the others, and a removal through one removes the row itself.
//
// What that asks of you is only at construction: hand the rows to ONE member
// and write `[]` for the others. That `[]` declares an index; it does not
// declare an empty collection to be filled separately. Hand rows to two members
// and the table gets both sets, so a vector you wrote two rows into holds four.
// loft advises on that literal (`advice[linked-group-double-fill]`).
cat = Catalogue { all: [Item{key:"zebra", count:1}, Item{key:"apple", count:5}], by_key: [] };
insertion = "";
for e in cat.all { insertion += "{e.key} "; }
assert(insertion == "zebra apple ", "the vector keeps insertion order: {insertion}");
ordered = "";
for e in cat.by_key { ordered += "{e.key} "; }
assert(ordered == "apple zebra ", "the hash walks ascending key order: {ordered}");
assert(len(cat.by_key) == 2, "…over the records the vector was given: {len(cat.by_key)}");
// ## Mutation guard blocks appending during iteration
// The compiler prevents `v += [x]` inside `for e in v`. This protects
// against infinite loops. The guard also catches field access:
// `for e in db.items { db.items += [x]; }` is blocked too.
// Removing is the one mutation a loop may make: `e#remove` drops the current
// element, in a plain loop or in a filtered one.
shrinking = [1, 2, 3];
for e in shrinking if e > 1 { e#remove; }
assert(len(shrinking) == 1, "`e#remove` drops the elements the filter selected");
// ## If-expression requires else when used as a value
// Using `if` as a value expression without an `else` clause is a compile
// error. This prevents accidental null values from missing branches.
// If-statements (where the body has no value) do not need else.
// For example, `x = if cond { 1 }` is an error; write `x = if cond { 1 } else { 0 }`.
// ## Match guards do not prove a variant is handled
// A guarded arm like `Red if cond => ...` does not count as handling
// the `Red` variant because the guard can fail at runtime. Even if
// every variant has a guarded arm, you still need a wildcard `_` or an
// unguarded arm so the compiler knows every case is covered.
// ## A parameter shares; a bind copies
// A struct or collection PARAMETER is a shared view of the caller's value —
// nothing is copied when you call. So everything the function does to the
// contents is visible to the caller once it returns: writing an element,
// appending, removing, clearing. None of that needs `&`.
//
// `&` buys exactly one thing: replacing the WHOLE value. `v = [7, 7]` inside
// a plain parameter gives that function a different vector and leaves the
// caller's alone; through a `&vector<T>` the caller sees the new vector.
// So `&` means "I may replace this", not "I may add to it".
//
// A plain BIND is the opposite, and the pair is what catches people:
// `d = self.data` COPIES, so `d += [x]` leaves `self.data` at its old
// length. Identical-looking lines, opposite outcomes, decided by whether the
// name came from a parameter or from an assignment.
shared = [1, 2, 3];
appended_by_callee(shared);
assert(len(shared) == 4, "a plain parameter grew the CALLER's vector: {len(shared)}");
kept = [1, 2, 3];
replaced_by_callee(kept);
assert(len(kept) == 3, "…while replacing the whole value stayed local: {len(kept)}");
swapped = [1, 2, 3];
replaced_through_ref(swapped);
assert(swapped[0] == 7, "…and `&` is what makes a replacement reach back");
bound = [1, 2, 3];
alias = bound;
alias += [4];
assert(len(bound) == 3, "a BIND copies — this append reached nothing: {len(bound)}");
// ## Text file reading assumes UTF-8
// `lines()` and `content()` decode the file as UTF-8. A file that is not
// valid UTF-8 — a binary file, or text in another encoding such as Latin-1 —
// neither crashes nor stops the program: the read warns, answers null, and
// `lines()` therefore hands back an EMPTY vector. A loop over it runs zero
// times and the program carries on, so a text read of the wrong file looks
// exactly like a read of an empty one.
// Read such a file as bytes instead: `read_bytes(path)` for the whole file,
// or set a binary format on the handle — `f#format = LittleEndian` (or
// `BigEndian`) — and read fields one at a time (see the File page).
// ## A struct literal zeroes the fields it leaves out
// Naming some fields and omitting others is legal, and each omitted field
// takes its type's zero — 0, `""`, `false`. Nothing in the declaration chose
// that value, and where zero is a meaningful member of the field's domain (a
// palette index whose 0 is a real colour) it is a wrong value rather than an
// absent one. loft advises on the partial literal
// (`advice[omitted-field-zero]`). The cure is a declared default on the
// field, after which the omission has an answer the declaration chose:
plain_tile = PlainTile { name: "grass" };
assert(plain_tile.palette_pick == 0, "an omitted field takes the type's zero");
tile = Tile { name: "grass" };
assert(tile.palette_pick == -1, "a declared default answers instead: {tile.palette_pick}");
// ## A field only some variants declare answers null on the others
// Enum payloads are named fields you read straight — `shape.radius`. Where
// every variant declares the same name and type it is ONE slot and each
// variant reads its own value. Where only SOME declare it, a value of any
// other variant has no such field: the read answers null, although the
// field's type does not say it can, and a write to it is silently dropped.
// loft warns (`warning[variant-field-unchecked]`); bind the field in a
// `match` arm or an `is` test, which is per-variant and cannot reach the
// wrong one.
// ## A yield inside an expression runs eagerly on --native
// A generator is suspended at each `yield` on both backends when the `yield`
// is a statement — in any loop, `if`, `match`, nested loop or beside a
// `continue`, for any yielded type. A `yield` inside an expression is
// different when an operand before it could change while the generator is
// suspended, as in `s = s + { yield x; "b" }` with `s` a text. On --native
// that loop runs to its end before the first value is handed out. The
// values agree, the side effects do not, and an endless loop of that shape
// never hands out a value on --native: it runs until memory runs out. Make
// the `yield` a statement of its own: `yield x; s = s + "b";`.
// ## XOR is `^`, not exponentiation
// Unlike some languages where `^` means "power", in loft `^` is bitwise
// XOR. For exponentiation use the `**` operator (`2 ** 10 == 1024`,
// `2.0 ** 3.0 == 8.0`) or the `pow()` function. Watch one precedence
// footgun: a leading `-` binds TIGHTER than `**`, so `-x ** y` raises the
// NEGATED BASE. loft warns on the bare spelling; write the parentheses for
// whichever of the two you mean.
assert((0b1010^0b1100) == 0b0110, "^ is XOR");
assert(2**10 == 1024, "** is the power operator");
assert(pow(2.0, 3.0) == 8.0, "pow() also computes powers");
base = 2;
assert((-base) ** 2 == 4, "`-x ** y` is this one: the sign binds to the base");
assert(-(base ** 2) == -4, "…and this one needs the parentheses");
}