Reference for writing correct loft code. Apply whenever writing, editing, or reviewing .loft files. Covers types, syntax, known bugs, workarounds, naming rules, and error→fix table.
Installation
Mit Codex oder Claude installieren Kopieren Sie diesen Prompt, fügen Sie ihn in Codex, Claude oder einen anderen Assistant ein und lassen Sie die Skill-Seite prüfen und installieren.
Reference for writing correct loft code. Apply whenever writing, editing, or reviewing .loft files. Covers types, syntax, known bugs, workarounds, naming rules, and error→fix table.
user-invocable
false
Loft Language Writing Reference
Always consult this before writing or reviewing .loft files.
Before reimplementing stdlib-or-library functionality, check doc/claude/LIBRARIES.md and loft install <name> — a registered library may already provide it.
Naming conventions — enforced by the parser
Construct
Convention
Examples
Functions, variables
lower_case
my_fn, item_count
Types, structs, enums, variants
CamelCase
Point, Color, Red
Constants (file-scope)
UPPER_CASE
PI, MAX_SIZE
Operator definitions
OpXxx prefix
OpAdd, OpEqInt
The parser rejects code that violates these rules.
Primitive types
Type
Description
Null sentinel
integer
64-bit signed int (the base integer type; i64 at rest)
i64::MIN
float
64-bit float; literal must contain .: 1.0
NaN
single
32-bit float; literal suffix f: 1.0f
NaN (32-bit)
boolean
true / false (2-state by default under DN1; boolean? adds null → three-state, @PLN17)
boolean? null is byte 255 (test with b == null; !b is true for both null and false; null == false is false)
character
Single Unicode char; literal: 'a', '😊'; c as integer → codepoint
'\0'
text
UTF-8 string (primary string type)
internal null pointer
There is no long type and no l literal suffix — both were
removed. integer is already 64-bit (i64), so integer literals are
plain (86400000, not 86400000l), and now() / File.size return a
full-range integer. Writing long or 10l is a parse error
("Undefined type long" / "Expect token ;").
Narrow / sized integers are aliases of integer (defined in
default/01_code.loft), for byte-width-sensitive file / wire formats:
i8size(1), i16/u16size(2), i32/u32size(4). Cast with
x as i32 etc. Use plain integer for ordinary arithmetic — it has
the full 64-bit range.
Uncomputable arithmetic → null, and the program KEEPS RUNNING (C80, the
spreadsheet model): divide / modulo by zero, and integer overflow
(i64::MAX + 1), are uncomputable, so they yield null and execution CONTINUES
— no trap, no halt, identically in dev / test / production. Any arithmetic
landing exactly on i64::MIN also reads as null (the in-band sentinel).
Defend a site with ?? <fallback> to turn the null into a value; an unguarded
divide-by-zero additionally logs a Warn (overflow is silent — the null is the
signal). A function returning integer may return null (it yields i64::MIN,
detectable with !result).
Prefer x? when the fallback is just the type's zero (@PLN116). Postfix ?
discharges T? to T using the type's own default, so ?? 0 / ?? 0.0 /
?? "" / ?? Pixel{r:0,g:0,b:0} boilerplate collapses to one character. ??
supplies the default you give; ? supplies the default the type gives — that
pairing is the mnemonic.
name = row.label? // text? → "" (was: ?? "")
first = points[i]? // Point? → Point{} (every field defaulted)
count = (a / b)? // integer? → 0 (parens — see below)
Defaults: scalar → 0/0.0/false/'\0', text → "", collection → empty,
record → every field defaulted, nullable U? → null, bare enum → its
first-declared variant.
Three things to know before reaching for it:
? binds TIGHTEST, ?? binds LOOSEST — opposite ends. On a binary
result that flips the parens: a / b ?? 0 discharges the division, but
a / b? is a / (b?) and evaluates to null. Write (a / b)?. So ? is
a clear win on a primary (row.label?, v[i]?, x? — no parens) and a
wash on arithmetic.
On an enum it is positional.c? is the first-declared variant, so
reordering the enum silently changes it. Use ?? Colour.Red when the variant
matters.
Two types have no default, so x? is a COMPILE error (never a runtime one):
a bare reference, and a record with a bare enum field lacking = <variant>.
The error names the fixes; ?? or match is the fallback.
At a text parse, put ? after the CHECKED cast:
n = (s as integer?)? // integer — checked cast, then default ⇒ 0 on a bad parse
n = s as integer ?? 0 // integer — the assert-or-default form
n = s as integer? // integer? — the nullable CAST (that `?` belongs to the type)
n = (s as integer)? // COMPILE ERROR — a bare `as` on text is an assertion
A bare text as integer is an assertion the compiler refuses (a parse can't be
proven), so (s as integer)? fails on the inner cast — not because ? is
unavailable. Write the checked cast first.
Parsing text is fallible, so text as integer types integer? (as float →
float?, as single → single?). "42" as integer is a parse, not a numeric
cast — a non-numeric string yields null, so the compiler types the result nullable
and rejects storing it into a non-null slot. Discharge at the parse site:
n = s as integer ?? 0 (default), n = s as integer? (keep it nullable), or match.
This is the same reachable-fault rule as ÷0 / out-of-bounds indexing (@PLN25
(N-Parse)). A numericas (3.14 as integer, x as u8) is not a parse and does
not add ? on its own (a narrowing numeric cast that can miss uses as u8?).
Nullability defaults (@PLN25 DN1) — every scalar and vector is NON-NULL by default. A plain
integer / float / single / boolean / character / text (and vector<T>) field, local, or
return REJECTS null: storing null — or an undischarged τ? from a fit-failing op (parse, /,
v[i]) — into it is a compile error. To ALLOW null, append ? to the type (integer?, text?,
vector<T>?). A vector<T> field defaults to [] (empty, non-null). The old not null modifier
is a RETIRED accepted no-op — it now means what the default already is, so don't write it; write
? when (and only when) you want the slot to hold null.
text vs string: The canonical string type is text. Using string in struct fields causes errors.
Field modifiers (struct fields only)
struct Point {
const id: integer, // binding-const PREFIX: write-once at construction
const cells: vector<Cell>, // BUILDER: slot write-once, `cells += […]` still grows it
coord: const Coord, // value-const on the TYPE: frozen value (contents read-only)
x: float, // non-null by default (DN1) — no `not null` needed
r: integer limit(0, 255),
label: text = "default",
area: float virtual($.x * $.y),
note: text?, // append `?` to ALLOW null
}
Modifiers: limit(min, max), default(expr) / = expr, virtual(expr). (not null still parses
but is a retired no-op — see the DN1 defaults note above; use ? for the nullable case.)
Two const axes (@PLN40) — const before the NAME freezes the binding; const before the
TYPE freezes the value. They are opposites and compose:
declaration
rebind t.v=
append t.v+=
element t.v[i]=
use
v: T
✓
✓
✓
plain
const v: T
✗
✓
✓
builder (grow in place)
v: const T
✓
✗
✗
frozen value (rebindable slot)
const v: const T
✗
✗
✗
fully immutable
binding-const (const v: T, prefix): write-once slot, contents stay mutable — the builder
shape. Reach for it when a field is assembled once then grown/edited in place (const cells: vector<Cell> with cells += […]). const virtual(...) is rejected; works on enum-variant fields.
value-const (v: const T, on the type): the value is read-only — append / element / nested
writes (t.v+=, t.v[i]=, t.r.x= through it) are rejected; a whole-value rebind t.v = other
is allowed. Reach for it on a genuine value/record (shared config, a compound key, a par()-shared
value). Enforced for DIRECT writes; laundering via a local/return/generic is Phase 3 (not yet).
scalar collapse: a by-value scalar (integer/float/…) freezes fully under EITHER axis —
const n: integer and n: const integer both reject t.n= and t.n+=.
Owner policy (libraries): const every never-reassigned field. Use const v: T for builder fields
you still append to; v: const T for true value/record fields; whole-value keys/config → both.
Variable declarations
Variables declared by assignment — type is inferred from the initialiser.
x = 5;
s = "hello";
f = 3.14;
Explicit type annotations (sometimes required for empty collections):
v: vector<integer> = [];
n: integer = null as integer; // a null integer must be cast; bare `= null` is rejected
const variables
const x = 5; // immutable local — reassignment is a compile error
const t = "hello"; // works for any type
Constants
The const keyword is accepted at file scope as well, alongside the
bare-name form. Both produce the same file-scope immutable constant:
PI = 3.14159265358979; // bare-name form
const E = 2.71828182845905; // const-keyword form (P246, 2026-05-11)
pub const MAX_SIZE = 256; // pub + const combine for exported constants
Constants must be UPPER_CASE. Prefer the const form when you
want the immutability intent visible at the declaration site
(the bare-name form relies on the UPPER_CASE convention as the only
visual signal); both forms behave identically.
UPPER_CASE locals warn unless declared const. Inside a
function body, FOO = 5; (an assignment with an UPPER_CASE name and
no const keyword) emits a warning telling you to add const or
rename to lower_case. The convention "UPPER_CASE means immutable"
is enforced by this check at every scope.
const T — immutable (compile error to assign to it inside function)
&T — a live reference to the caller's argument: reads see the caller's current value,
writes write the caller, field/element mutation mutates the caller. Call WITHOUT & — the
reference comes from the param's TYPE: fn inc(n: &integer){ n = n + 1 } is called inc(x), not
inc(&x) (& is a binding marker, not a general operator). & to a temporary or in a general
expression is an error.
& that is never mutated is a compile error — drop it if the param is read-only
Omit modifier — pass by value/copy (heap values still alias: field/element mutation propagates)
A local reference is bound with a = &b (or a: &T = b); the operand must be addressable (a
variable / struct field / vector element). Full model: LOFT.md § References.
A function body ending in an expression (no ;) returns that value. Functions without -> return void.
Nested fn definitions are forbidden.fn declarations must be at file
scope. Code like
fn outer() {
fn inner() { ... } // PARSE ERROR
}
produces "'fn' definitions must be at file scope, not inside a function or
block". Move helper fns alongside the caller, not inside it. Lambdas
(|x| { ... } or fn(x: T) { ... }) are the only "function-shaped" thing
allowed inside a fn body.
Imports
use arguments; // wildcard: all pub names bare + `arguments::` qualifier
use arguments::*; // explicit wildcard (same as above)
use arguments::parse_args; // selective: just one name, bare
use arguments::(parse_args, Flag); // selective group — MULTIPLE names need parentheses
use arguments::Flag as Opt; // alias an imported name (bare `Opt`)
use arguments::(Flag as Opt, parse_args); // per-name aliases inside the group
use arguments as args; // library alias → `args::parse_args` (qualified-only,
// does NOT import names bare)
Multiple names MUST be parenthesised — use lib::a, b; (flat comma list) is a
hard error; write use lib::(a, b);. (@PLN22 Phase 4.)
use lib as m; gives a qualifier alias only (m::fn); it does not wildcard-import.
A qualified lib::fn() auto-loads the library — an explicit use is optional for
qualified access.
use declarations must appear before any other declarations in the file.
Finding a library's API (do this BEFORE writing use calls)
Libraries live OUTSIDE the project (~/.loft/registry/<name>-<version>/,
~/.loft/lib/<name>/), so the project tree alone does not show what they
export. Discovery surface, nearest first:
.loft/api/<name>.api in the project — generated public-API stubs
(signatures + doc comments) for every locked dependency. Written by
loft install / loft update / loft pin; read these first.
.loft/api/_available.api — the registry CATALOG: every package you
could loft install (name, latest version, one-line description), written
alongside the per-dep stubs. Read this to see what EXISTS, not just what's
installed.
loft api — list every library reachable from the cwd (project deps,
installed registry packages, user libraries) with their source paths.
loft api <name> — print one library's full public surface.
loft api --registry — print the whole installable catalog on demand
(the live form of _available.api). The catalog is cached ~1h; add
--refresh to force a re-fetch (e.g. after a package was just published
or its description changed).
loft search <query> / loft info <name> — query the registry
for libraries not installed yet; loft install <name> fetches one and
refreshes the stubs.
Never guess a library function's signature: check the stub or loft api
output, and read the real source at the path they name when you need the
implementation.
Field names may overlap across structs — lookups are type-scoped.
Tuples
Anonymous, fixed-arity, stack-allocated compound values. Use them to
return multiple values without naming a struct. Shipped in 0.8.3
(T1.1–T1.11); see doc/claude/TUPLES.md.
// Type notation — two or more element types (single-element tuples
// are not allowed; use the bare type instead).
t: (integer, text) = (3, "hi");
// Literal
pair = (1, "hello");
// Element access (zero-based integer literal — NOT a variable index).
a = pair.0; // integer
b = pair.1; // text
// Element assignment
pair.0 = 5;
pair.0 += 3;
// Destructuring
(lo, hi) = min_max(values);
// Function return
fn classify(t: (integer, text)) -> text {
match t {
(0, _) => "zero",
(n, msg) => "{n}: {msg}",
}
}
// Nested tuples — chain the `.N` accessors.
nested: ((integer, integer), boolean) = ((1, 2), true);
inner_first = nested.0.0; // 1
inner_second = nested.0.1; // 2
flag = nested.1; // true
Restrictions:
No single-element tuples — (integer) is just integer.
No named tuple fields — use a struct.
No tuple iteration / whole-tuple formatting — access elements one
by one.
Scalar elements are NON-NULL by default (@PLN25 DN1) — a tuple
integer / text / … element rejects null; append ? to the element
type ((integer?, text)) to allow it. (not null is a retired no-op.)
Compound assignment on tuple LHS is rejected — (a, b) += (1, 2)
is a compile error; rewrite as a += 1; b += 2; or rebuild the tuple.
Comparing a tuple-element character against a literal (t.0 == 'a')
works on both backends — the old P207 native E0308 was fixed 2026-05-04;
no cast or destructure workaround is needed.
Enums
Simple enum (value type, no fields):
enum Color { Red, Green, Blue }
c = Color.Red; // qualify when DEFINING a new variable
// ordering follows declaration order: Red < Green < Blue
Bare variants need a type context (@PLN22 Phase 1). A bare Red resolves only
against a known enum — a match subject, a typed target (c: Color = Red), a
typed reassignment / field, a parameter, a return type, an == LHS, or an is
subject. Defining a NEW untyped variable from a bare variant (c = Red) is a hard
error — qualify it (c = Color.Red) or annotate (c: Color = Red). Match arms and
the other context positions still use the bare form (match c { Red => … }). Two
enums MAY share a variant name (enum Color { Red } + enum Berry { Red }); the
bare Red resolves by its context enum.
Struct-enum (each variant has fields; polymorphic dispatch via methods):
enum Shape {
Circle { radius: float },
Rect { w: float, h: float },
}
fn area(self: Circle) -> float { 3.14159 * self.radius * self.radius }
fn area(self: Rect) -> float { self.w * self.h }
s = Circle { radius: 2.0 };
a = area(s); // dispatches to correct variant
Plain enums cannot have methods — use struct-enum variants for polymorphic dispatch.
Trailing commas in variant field lists are accepted: Circle { radius: float, }.
JSON round-trip: "{shape:j}" produces {"Circle":{"radius":3.14}}; Shape.parse(json) reconstructs the correct variant.
Vectors
empty: vector<integer> = [];
nums = [for i in 0..10 { i * 2 }]; // comprehension
items = [1, 2, 3];
v += [element]; // append
v += other_vec; // concatenate
len(v); // length
v[i]; // index read
Empty vectors need a type annotation so the compiler knows the element type.
Slices are iterators, materialised on assignment.sub = arr[lo..hi] (annotated or not) builds a fresh vector, and negative bounds count from the end (arr[2..-1], @P384). A slice still cannot be passed directly where a vector<T>argument is expected — assign to a local first, or pass the array with index bounds.
v[i] with a possibly-negative index does NOT null-guard. Scalar indexing counts from the end for negative i, exactly like slices: v[-1] is the LAST element, v[-len] the first — NOT null. Only i >= len (and i < -len) yield null. So if v[i] { … } and v[i] ?? d catch an over-range index but NOT a negative one — a -1 "not-found" sentinel or a a - b underflow silently reads a real element from the end. When i can go negative, test if i >= 0 FIRST (that >= 0 check is not redundant with a later null-guard).
Never swap vector<STRUCT> elements in-place via a temp (#338).tmp = v[j] is a VIEW of slot j (not a copy), but v[j] = v[k] COPIES into the slot — so tmp = v[j]; v[j] = v[k]; v[k] = tmp; silently loses j's record and duplicates k's. Swap scalar fields one by one, or build a fresh vector (selection instead of in-place insertion sort).
Hash collections
Hash must be a struct field — not a standalone local variable:
struct Entry { key: text, value: integer }
struct Table { data: hash<Entry[key]> }
t = Table { data: [] };
t.data += [Entry { key: "x", value: 1 }];
e = t.data["x"];
if e == null { /* not found */ }
else { e.value += 1; }
t.data["x"] = null; // remove entry
Hash cannot be iterated directly — track aggregates separately.
Never use key as a field name in a hash-value struct — it conflicts with hash iteration internals.
Structural satisfaction: if the methods exist, the type satisfies the interface.
No impl block needed. Built-in types satisfy Ordered, Equatable, Addable,
Numeric, Scalable, Printable automatically.
The both parameter name
Name the first parameter both instead of self to register a function as
both a method and a free function:
name = record.field ?? "default" // null-coalescing
name = record.field? // …or the TYPE's default ("" for text)
x as i32 // cast to a 4-byte sized integer
flags & ~32 // bitwise NOT — clears bit 5
? and ?? are at opposite ends of this table, so they parenthesise
oppositely on a binary result: a / b ?? 0 discharges the division, a / b? is
a / (b?) and yields null — write (a / b)?.
is variant check
if d is North { ... } // boolean check
assert(!(shape is Rect)); // negation
// field capture — binds variant fields as locals scoped to the if-body
if shape is Circle { radius } {
area = PI * radius * radius;
}
// multiple fields + else
if shape is Rect { width, height } {
area = width * height;
} else {
area = 0.0;
}
String / text literals
Double-quoted strings ("...")
Single-line. Supports {expr} interpolation and \n, \t, \\, \" escapes.
Use println() for line-oriented output and print() for output without a newline.
Control flow
if cond { } else if cond { } else { }
result = if x > 0 { x } else { -x } // if as expression
// null check
if !x { } // x is null (or false for boolean)
val = a ?? b // null-coalescing
while cond { }
return expr;
break;
break expr; // break with value (requires non-void function)
For loops
for i in 0..n { } // exclusive: 0 to n-1
for i in 0..=n { } // inclusive: 0 to n
for item in collection { }
for c in some_text { } // character iteration
for item in col if item.active { } // filtered iteration
for i in rev(0..n) { } // reverse range
Loop attributes
for v in collection {
v#index // 0-based position (vector/sorted); byte offset (text)
v#count // iterations completed so far (all types)
v#first // true for first element (all types)
v#next // byte offset after current char (text only)
v#remove // remove current element (filtered loops only)
}
#index is NOT supported on index collections — use #count there.
Match
match color {
Red => println("red"),
Green | Blue => println("cool"),
_ => {},
}
match shape {
Circle { radius } => println("r={radius}"),
Rect { w, h } if w == h => println("square"),
_ => {},
}
// Tuple match — destructure into element patterns.
match (3, 7) {
(0, _) => "zero",
(n, m) => "{n},{m}",
}
Match is an expression — all arms must produce the same type (or void).
The arm separator is =>, never ->.-> is the lambda /
function return-type arrow; using it in a match arm produces a clear
diagnostic ("match arm separator is =>, not ->") — but only because
the parser was hardened against it. Older drafts of TUPLES.md showed
-> for arms; that was always wrong. If a match arm in your code
uses ->, fix it before running anything.
Scalar-match arms need commas between them; enum and tuple match
also accept newline-separated arms. When in doubt, comma-separate —
it's universally accepted.
Higher-order functions
Two lambda syntaxes, each with a clear job:
Shorthand |x| — types inferred from the call-site context
(e.g. the map/filter signature). Use inside higher-order
calls where the types flow in.
Explicit fn(...) — full type annotations. Use when the
lambda is stored in a local variable, or anywhere types can't
be inferred. Omit -> for void-returning lambdas (-> void
is not valid syntax — there is no void type).
fn double(x: integer) -> integer { x * 2 }
doubled = map(nums, double); // named function ref — bare name, no `fn` prefix
positive = filter(nums, |x| { x > 0 }); // inferred lambda
total = reduce(nums, 0, |a, b| { a + b });
// Method form on vectors
doubled = nums.map(|x| { x * 2 });
evens = nums.filter(|x| { x % 2 == 0 });
// Typed lambda stored in a local: use the explicit fn(...) form.
emit = fn(x: integer, y: integer) { total += x + y; };
emit(1, 2);
Type annotations on |x| shorthand are rejected by design
(see doc/claude/DESIGN_DECISIONS.md § C62). If you need
types, switch to fn(name: <type>) { ... } — the shorthand
exists specifically because the types are inferred; adding
annotations collapses the distinction between the two forms.
Variable scoping — one type per name, per function
Variable names are per-function, and loft has no block scoping: a
for/if body does not open a fresh binding — every local (loop variables
included) lives in the enclosing function's scope for the whole function. So the
rule that matters is narrow: within one function, a name maps to a single
slot + type. The same name in a different function is completely free.
All of the violations below are clean compile-time errors with fix hints —
never a codegen panic or silent corruption, on either backend:
A name reused with a different type in one function → error, even across
disjoint blocks: if … { x = 1 } if … { x = "hi" } →
Variable 'x' cannot change type from integer to text. Re-assigning the same
type is fine (x = 1; x = 2).
A loop variable reused with a different element type (@P344) →
for i in [1,2,3] {…} then for i in ["a","b"] {…} in the same function →
loop variable 'i' has type text but was previously used as integer. Same
element type is fine ([1,2,3] then [4,5,6]); the same name in a different
function is fine. Two same-function loops over different types → distinct names
(for n in nums {…} for s in strs {…}).
A loop variable named like an existing local →
loop variable 'x' shadows a local named 'x' — rename the loop variable (e.g. loop_x). Rename it, or drop the dead outer local.
Loop variables are inference-only (@P345) — for i: integer in … does not
parse (loop variable 'i' is type-inferred from the iterable — remove the ': <type>' annotation). Drop the annotation.
This is accepted-as-intended (CAVEATS.md § P344); true per-loop scoping is a
deferred resolver change, not a bug to work around. The zero-cost habit:
descriptive, distinct names (fib_i, mb_x) — it sidesteps every case above
and reads better anyway. (You do not need names unique across the whole file;
the constraint is per-function.)
Unused variable = warning, not an error — the program still runs (exit 0).
Use _ for an unused loop variable to keep the build warning-clean.
Builtin names — shadowing rules (@PLN22 Phase 2)
Definitions CAN shadow a stdlib/library name.enum E, struct File,
pub PI = 3 are legal even though the stdlib has E/File/PI; your name wins
bare resolution and std::Name still reaches the original.
Built-in TYPE-KEYWORDS are reserved — you cannot define struct integer,
enum vector, etc. (it errors: "conflicts with a type"). Reserved: integer,
float, single, text, boolean, character, vector, hash, sorted,
index, radix, spatial, iterator, i8/i16/i32, u8/u16/u32,
reference. This holds for struct, enum, and type alike.
A few builtin names break as a local variable name — the literal null
(Not implemented operation = for type null), the special ticks
(Cannot redefine function 'ticks' as a variable), and the higher-order method
names map/filter/reduce (a local of that name derails the later
v.map(x => …) lambda parse → Expect token )). Most builtins are safe as
variable names — len, round, rev, and sorted (as a value) all work — but
distinct, descriptive names sidestep the question entirely.
Text pitfalls
character == text is a compile error — use "{c}" == t to compare as text.
Cannot reassign text parameter — copy to local first: local = param; local = ...
Prefer h += expr over h = h + expr for text building.
File I/O patterns
Path resolution — relative means program-relative
A relative path resolves against the program's own directory
(source_dir() — source dir under --interpret, exe dir under --native), not
the process cwd. So file("assets/x") loads the asset bundled beside the
program on every backend, regardless of launch directory. Absolute paths are
untouched. The file builtins (file, exists, read_file/write_file,
delete/move/mkdir, image loads) all resolve this way — so don't hand-roll
"{source_dir()}/{path}" joins in normal loft; just pass the relative path.
(Do the explicit join only when handing a path to a non-loft consumer, e.g. a
native asset loader that reads the filesystem directly.)
A program that must resolve a user-supplied relative path against the working
directory (CLI tools — loft tidy.loft data.csv) declares #cwd as the
file-top directive (before the first declaration). LOFT_PATHS=program|cwd
overrides per-invocation.
Text files (UTF-8)
f = file("path/to/file.txt");
content = f.content(); // full text content (UTF-8)
lines = f.lines(); // vector<text> of lines
out = file("output.txt");
out.write(content);
dir = file("some/directory");
for ef in dir.files() {
path = ef.path;
}
if f.exists() { }
size_bytes = f.size; // integer (i64) — works for any file
f.content() is UTF-8-only. It silently returns "" on a
binary file. For non-text data, use the binary idiom below.
Binary files (structured reads and writes)
Set f#format to LittleEndian or BigEndian, then use f#read
for reads and f += value for writes. #next seeks to an
absolute byte offset. All file-handle operations should live
inside a { ... } scope block so the handle flushes/closes at
block exit:
// --- Read a 12-byte GLB header ---
{
f = file("model.glb");
f#format = LittleEndian;
magic = f#read as i32; // 0x46546C67 = 'glTF'
version = f#read as i32;
total = f#read as i32; // declared file length
// Seek past the header + JSON data to a later chunk:
f#next = (20 + json_len) as integer;
bin_len = f#read as i32;
}
// --- Write a binary chunk-structured file ---
{
f = file("model.glb");
f#format = LittleEndian;
f += (0x46546C67 as i32); // 4 bytes: i32 magic
f += (2 as i32); // 4 bytes: i32 version
f += (32 as u8); // 1 byte (ASCII space)
f += "chunk of text"; // raw UTF-8 bytes
f += my_float_vector; // vector<single> → 4 bytes per element
}
Notes:
Prefer f#read as <type> (no parens) for fixed-width reads.
The byte count is inferred from the type — as i32 reads 4
bytes, as u8 reads 1, as u16 reads 2, as integer reads 8.
The legacy f#read(n) as T form still works but the (n) must
match the type's storage width exactly or the runtime panics in
src/database/io.rs:276. The inferred form makes that mismatch
impossible. as text still needs f#read(n) as text because
text has no fixed width.
s.field = f#read (no as T) infers width from the LHS field's
declared type — symmetric with f += s.field. For a struct
S { a: i32, b: u8, c: u16 }, both sides become:
f += s.a; f += s.b; f += s.c to write, s.a = f#read; s.b = f#read; s.c = f#read to read. Changing a field's declared type
(i32 → i64) automatically updates both sites at the next compile —
no manual cast edits needed.
Always cast scalar writes to the intended width. Bare
f += int_var writes 8 bytes (loft stores integers as i64). To
write 4 bytes use f += (int_var as i32); for 1 / 2 bytes use
as u8 / as u16. Strongly-typed struct fields (u8, u16, or a
range-limited integer limit(0, 255)) write at their declared width
automatically.
f += expr appends expr to the file, respecting the #format
endianness. text → raw bytes, vector<T> → each element in
sequence at its declared width.
f.size returns an integer (i64); compare with 0.
f#next = offset as integer seeks. Reading position advances
automatically after each f#read — don't manually advance it
between sequential reads.
No f.bytes() API — there's no "read all N bytes into a
vector" helper. If you need the whole buffer, call f#read(n)
in a loop or read into a typed record via OpReadFile.
Example binary reader/writer patterns live in
lib/graphics/src/glb.loft (writer) and
lib/graphics/tests/glb.loft (reader).
Warning-clean idioms (LOFT_DENY_WARNINGS=1)
When LOFT_DENY_WARNINGS=1 (set by the canonical chunk-repo
library-ci.yml), three idioms surface repeatedly during a clean-up
sweep. These are the patterns to reach for first. Sourced from the
loft-libs-core::arguments sweep, 2026-05-30.
Vector fields are non-null ([]) by default — no not null needed
Obsolete under @PLN25 DN1. A plain vector<T> field is already non-null
and defaults to [], and reading a non-null field never triggers the old
@PLN46 "read N times and never defended" warning (that warning only ever
concerned NULLABLE fields, and there are none by default now). So just
declare the field plainly:
Write vector<T>? only when the field must genuinely be able to hold null
(distinct from empty). The not null modifier still parses but is a retired
no-op — don't add it.
Capture-into-local before indexing (skip-pattern 5)
The parser's skip-pattern 5 recognises if idx < len(vec) { … vec[idx] … }
and silences the v[i] may-be-null warning inside the then-block.
The pattern only matches when vec is a bare local Var, not a
struct-field deref. So if i < len(self.results) { self.results[i] }
does NOT match. Capture into a local first:
// BAD — warning fires on self.results[i]:
if i < len(self.results) {
self.results[i] = "true";
}
// GOOD — capture self.results into `results`, then bound-guard:
results = self.results; // local Var; same backing vector
if i < len(results) {
results[i] = "true"; // skip-pattern 5 silences the warning
}
The local aliases the same underlying vector, so indexed writes
propagate. Field-level writes still go through self.field (the
capture's slot doesn't track appends).
When the value can legitimately be null and the consumer checks
if !val { … }, the warning text suggests x = v[i]; if x != null { … }
as one of the safe forms. Use this when ?? defaults would change
semantics (e.g. ?? "" collapses null and empty-string into the
same path):
// BAD — warning fires on self.results[mi]:
if !self.results[mi] {
self.err = "Required option missing";
return false
}
// GOOD — capture and null-check, preserves the "missing → null" contract:
results = self.results;
if mi < len(results) {
val = results[mi];
if val == null {
self.err = "Required option missing";
return false
}
}
The capture into a local is required (for skip-pattern 5 to recognise
the bound guard); the null check then satisfies the parser's "x = v[i];
if x != null" hint.
Why these idioms specifically
The warning text itself names three fixes for v[i]:
v[i] ?? <fallback>, if i < len(v) { v[i] }, or
x = v[i]; if x != null { ... }. The three idioms above are
when-to-reach-for-which:
?? fallback — use when a non-null sentinel makes sense
(e.g. ?? Pixel{r:0,g:0,b:0}). Changes runtime behaviour:
on OOB you get the sentinel back instead of null.
if i < len(v) bound — use when the access is index-bounded
in practice but the parser can't see it. Requires bare-Var vec
(capture into a local). Zero-cost — dead-code guard the optimizer
drops when the bound is provable.
capture-and-null-check — use when the value is genuinely
nullable and the consumer's contract is "missing → null".
Preserves the null semantics.
Known error messages → fixes
Error message
Fix
Too few parameters on n_<fn>
Per-function name collision — give the loop/local a name distinct from the function's params; avoid for in const vector<T> recursive fns
Variable <x> is never read
A warning (program still runs, exit 0) — use the variable, or name an unused loop var _
loop variable 'i' has type … but was previously used as …
Two same-function loops over different element types share one name — give them distinct names
Indexing a non vector
You indexed a scalar (x = 5; x[0]) — index a vector/collection, not a single value
Not implemented operation = for type null
A local named null (a literal keyword) — rename it
Cannot iterate a hash directly
Track aggregate separately
Undefined type string
Use text, not string
Allocating a used store
Field named key in hash-value struct — rename the field
<fn> is not found for say(...)
Use println()
Cannot pass a literal or expression to a '&' parameter
Assign to a named variable first, then pass it. v[i] and s.field work directly (P160).
match arm separator is \=>`, not `->``
Replace -> with => in the arm. (P206 — was a parser hang before the recovery helper landed.)
'fn' definitions must be at file scope, not inside a function or block
Move the helper fn out of the enclosing fn body. Lambdas (`
compound assignment is not supported for tuple destructuring — use (a, b) = expr instead
Rebuild the tuple: (a, b) = (a + 1, b + 2) — or update each element directly.
Native E0308 on t.0 == 'a' where t is (character, …)
Fixed (P207, 2026-05-04) — if seen on an old build, update loft; current builds compile this on both backends.