Mettle provides structured control flow: conditionals, loops, and switches. All control structures use braces for the body.
Assignment uses =. The left side must be an lvalue (variable, struct field, array element, or dereferenced pointer). Assignment is a statement; it does not produce a value for use in larger expressions.
x = 42;
ptr->field = value;
arr[i] = x;
Compound assignment (+=, -=, *=, /=, %=, &=, |=, ^=, <<=, >>=) is syntactic sugar for target = target OP value, where OP is the corresponding binary operator. The left side must be the same kind of lvalue as for plain assignment. Compound assignment is a statement, not an expression—it does not produce a value for use in larger expressions. It is valid in for-loop initializers and increments.
count += 1;
arr[i] *= 2;
for (var i: int32 = 0; i < 10; i += 1) {
// ...
}
See Lexical Structure for the full operator list.
Type mismatches produce a compile error. Assigning a value of incompatible type (e.g. x = 3.14 where x is int32) is rejected; the compiler does not silently truncate.
The if statement evaluates a condition. If true, the then branch runs. The optional else branch runs when the condition is false. The condition must be a numeric type (integer or floating-point); zero is false, non-zero is true. Pointers are not valid as conditions—use an explicit comparison: if (ptr != 0) to check for non-null, not if (ptr).
if (x > 0) {
// ...
} else if (x < 0) {
// `else if` is parsed as part of the if statement
} else {
// ...
}
else if chaining is fully supported as a contiguous sequence of conditions, avoiding deep AST nesting. There is no separate elseif keyword.
The while loop evaluates the condition. If true, the body runs and the condition is evaluated again. The loop exits when the condition is false.
while (condition) {
// ...
}
Common patterns:
// Iterate over an array
var i: int32 = 0;
while (i < len) {
arr[i] = arr[i] * 2;
i = i + 1;
}
// Infinite loop (e.g. accept loop in a server)
while (1) {
// ...
}
An infinite loop is written while (1); the condition is always true.
The for loop has an initializer, condition, and increment. The initializer runs once. The condition is evaluated before each iteration; if false, the loop exits. The increment runs after each iteration. The initializer can declare a variable. Condition and increment are optional—for (;;) is a valid infinite loop.
for (var i: int32 = 0; i < 10; i = i + 1) {
// ...
}
Scope: A variable declared in the initializer (e.g. var i) is scoped to the loop. It is not accessible after the loop exits.
Infinite loop: Use for (;;) when all three parts are omitted. This is idiomatic in systems code.
for i in lo..hi { ... } iterates i over a half-open range. lo..hi is
exclusive of hi; lo..=hi is inclusive:
for i in 0..n { sum = sum + a[i]; } // i = 0, 1, ..., n-1
for i in 0..=n { /* i = 0, 1, ..., n */ }
for i: int64 in 0..count { /* loop variable typed explicitly */ }
The loop variable's type is inferred from the start bound, or you may annotate
it (for i: int64 in ...). A range-based for desugars at parse time into the
ordinary counted for above: the start bound is evaluated once, the end bound
is re-evaluated each iteration (so hoist a call-valued bound yourself if that
matters). Labels work as usual: outer: for i in 0..n { ... }.
The
../..=distinction here is exclusive/inclusive. Note that switch-case ranges (case lo..hi:) use..as inclusive, a historical inconsistency to be aware of.
A counted loop may carry a @simd attribute that asks the optimizer to
vectorize it. This only has effect under -O / --release (the auto-vectorizer
runs only when optimizing); plain debug builds print one note that the contracts
were not checked.
@simd for i in 0..n { c[i] = a[i] + b[i]; } // best-effort: warn if not vectorized
@simd! for i in 0..n { c[i] = a[i] + b[i]; } // contract: compile ERROR if not vectorized
@simdis a hint. If the loop vectorizes, nothing is printed; if it does not, the compiler emits a warning explaining why and keeps the scalar loop.@simd!is a hard contract. If the loop does not vectorize, compilation fails with an error and a precise reason; the performance guarantee cannot silently regress.
Both attributes also apply to while loops. The diagnostic names the cause when
it can determine it: a function call in the body, control flow (a nested loop or
data-dependent branch), an unsupported element width (16- or 64-bit integers
have no kernel), a loop-carried serial recurrence (a scalar computed from its
own previous value through a non-reassociable operation — *, /, a shift, or
a bitwise/xor op — so the iterations form a dependency chain, e.g. a hash, an
RNG, or an IIR filter), or, when none of those apply, that no vectorizer
recognized the loop's shape. The recurrence cause is found by backward
data-flow analysis, and +/- reductions are excluded from it — those
reassociate and vectorize.
@simd may also sit on a function, where it becomes the default contract
for every counted loop in the body that does not carry its own @simd:
@simd! fn sum(a: int32*, n: int64) -> int64 {
var s: int64 = 0;
var i: int64 = 0;
while (i < n) { s = s + (int64)a[i]; i = i + 1; } // inherits @simd! from the function
return s;
}
A per-loop attribute always wins over the function default, so you can place a
function-wide @simd and still relax (or tighten) an individual loop. Note that
@simd! on a function is a hard contract on all its counted loops — if the
body mixes vectorizable and non-vectorizable loops, annotate the loops
individually instead. See Function decorators.
Pass --simd-report (with -O/--release) to have the compiler report what
each @simd loop became:
kernels.mettle:10:10: note: @simd loop vectorized (simd_dot_i8)
kernels.mettle:21:9: warning: @simd loop was not vectorized: the loop body contains a function call
This makes the optimizer's decision legible instead of a black box: you can see exactly which kernel a loop lowered to, or why it stayed scalar.
The switch statement evaluates an expression and compares it to each case value. Case values must be compile-time constant integer expressions (including enum variants and true/false). When a case matches, its body runs. Use break to exit the switch. Use continue inside a loop that contains the switch to continue the loop. Only one default clause is allowed.
Range cases: A case may match an inclusive interval with case lo..hi:, where both bounds are compile-time constant integer expressions and lo <= hi. The case runs when the switch value is in [lo, hi]. Cases are tested top to bottom and the first match wins, so a single-value case listed before an overlapping range still takes precedence.
Fall-through: Unlike some languages, Mettle does not enforce break. If you omit it, execution falls through to the next case (C-style behavior). To avoid accidental bugs, always end each case with break explicitly unless you intend fall-through.
Exhaustiveness: switch over raw integers may omit matching cases and continue after the statement if no case matches. switch over enum or bool must be exhaustive unless a default clause is present.
switch (expr) {
case 1:
// ...
break;
case 2:
// ...
break;
case 3..9: // inclusive range: matches 3 through 9
// ...
break;
default:
// ...
}
The match statement branches on a tagged enum and optionally binds the payload of a variant. The subject expression must have a tagged-enum type.
match (value) {
case Some(v): {
return v;
}
case None: {
return 0;
}
}
Arms: Each case arm has a variant name and a block body. Use case VariantName(binding): when that variant carries a payload and you want to bind it to a local name. Use case VariantName: for payloadless variants.
Default arm: default: is allowed. Without default, the match must cover every variant of the tagged enum.
No fall-through: match arms do not fall through. Once an arm matches, its block runs and control continues after the match.
Statement-only: match is currently a statement, not an expression. Use assignments or return inside the arm bodies when you want to produce a value.
break exits the innermost loop or switch. continue skips to the next iteration of the innermost loop. Both are context-checked; they are valid only inside loops or switches. Using them elsewhere is a compile error.
Important: break and continue always target the innermost enclosing loop or switch. Inside nested loops, break exits only the inner loop. Inside a switch that is inside a loop, break exits the switch, not the loop—use continue to skip to the next loop iteration.
while (1) {
switch (cmd) {
case 0:
break; // exits switch only, loop continues
case 1:
continue; // skips to next loop iteration (exits switch and continues loop)
case 2:
break; // exits switch
}
// ...
}
A while or for loop may carry a label, written name: immediately before
the loop keyword. break name then exits that labeled loop, and
continue name jumps to the next iteration of that labeled loop, regardless of
how deeply nested the statement is:
outer: for (var i: int32 = 0; i < n; i = i + 1) {
for (var j: int32 = 0; j < m; j = j + 1) {
if (grid[i][j] == target) {
break outer; // exits BOTH loops
}
if (skip[j]) {
continue outer; // next i, abandoning the rest of the j loop
}
}
}
Rules and limits:
- Labels attach only to
whileandforloops. Writingname:before any other statement is a compile error. - The label in
break name/continue namemust match the label of an enclosing loop; an unknown label is a compile error ('break NAME' has no matching labeled loop). continue namerequires the target to be a loop (every labeled loop is, so this always holds for valid labels).- Unlabeled
break/continuestill target the innermost loop or switch as before. - Labels live in their own namespace and do not collide with variable or function names.
- Deferred statements are still emitted before the jump, the same as for
unlabeled
break/continue.
return exits the current function. A function with a return type must provide a value: return value. A void function uses return with no value.
return;
return value;
Logical operators && and || support short-circuit evaluation. For pointer checks like ptr != 0 && ptr->field > 0, a single condition is safe:
if (ptr != 0 && ptr->field > 0) {
// ...
}
defer schedules a statement to execute when the current scope exits, while errdefer schedules a statement to execute when returning a non-zero value from the current function. Both follow LIFO (Last In, First Out) ordering - the most recently deferred statement executes first.
Defer statements use the defer or errdefer keyword followed by a statement:
defer cleanup(); // Always runs on scope exit
errdefer rollback(); // Runs on non-zero return
The current compiler accepts function calls, assignments, and blocks:
defer puts("cleanup");
defer count = count + 1;
defer {
flush();
close(handle);
}
errdefer handle_error_recovery();
Argument capture: A deferred direct call captures its argument values at the defer point (by value). In a loop,
defer print_int(i)snapshotsias it is on that iteration, so the deferred calls see0, 1, 2, …, not the final value ofi. Method calls (defer obj.m(...)) and indirect/function-pointer calls are the exception: they re-evaluate their operands at scope exit, so snapshot into a local first (var current: int32 = i; defer obj.m(current);) if you need the defer-point value.
AST Representation:
deferstatements createAST_DEFER_STATEMENTnodeserrdeferstatements createAST_ERRDEFER_STATEMENTnodes- Both contain a single
statementfield pointing to the deferred statement
IR Lowering Process:
- Stack Management: Each scope has an
IRDeferStackthat tracks deferred statements - Scope Hierarchy:
IRDeferScopestructures form a linked list, allowing nested scopes - Push Operation: When encountering defer/errdefer, the compiler pushes the AST node onto the current scope's stack with an
is_errflag - Emission: At scope exit, the compiler emits deferred statements in reverse order (LIFO)
Data Structures:
typedef struct {
ASTNode *node; // The defer/errdefer AST node
int is_err; // 1 for errdefer, 0 for defer
} IRDeferEntry;
typedef struct {
IRDeferEntry *entries;
size_t count;
size_t capacity;
} IRDeferStack;
typedef struct {
IRDeferStack stack;
struct IRDeferScope *parent; // Link to outer scope
} IRDeferScope;Return Statement Handling: For functions with errdefer statements, the compiler generates two code paths:
- Error Path: Emits both defer and errdefer statements
- Success Path: Emits only defer statements
The return value is checked to determine which path to take, using generated labels like errdefer_ok_N and errdefer_end_N. This is convention-based: 0 means success and any non-zero return value is treated as an error, so return 42; also triggers errdefer.
Control Flow Integration:
- Blocks: Create new
IRDeferScopewith parent link to outer scope - If/Else: Each branch gets its own defer scope; deferred statements run when branch exits
- Loops: Each iteration creates a new scope; deferred statements run at iteration end
- Break/Continue: Trigger deferred statement emission before jumping
The same success/error split is used for explicit return and for implicit fall-through at the end of a function body.
Deferred statements execute in reverse order of declaration. This is crucial for resource management where cleanup must happen in reverse of acquisition:
func example() {
defer puts("first"); // Executes third
defer puts("second"); // Executes second
defer puts("third"); // Executes first
// Function body...
// Output: "third", "second", "first"
}
Mixed defer and errdefer:
func mixed_example() {
defer puts("always 1");
errdefer puts("error only");
defer puts("always 2");
if (error_condition) {
return err(); // Output: "always 2", "error only", "always 1"
}
return ok(); // Output: "always 2", "always 1"
}
Function scope: defer/errdefer execute when the function returns via any path (return, break from main loop, etc.)
Block scope: defer/errdefer execute when the block exits, including if/else branches, loop bodies, and switch cases:
func demo() {
defer puts("function exit");
if (condition) {
defer puts("if branch exit"); // Runs before function defer
// ... branch code ...
} else {
defer puts("else branch exit"); // Runs before function defer
// ... else code ...
}
// Output on condition=true: "if branch exit", "function exit"
// Output on condition=false: "else branch exit", "function exit"
}
Loops: Each iteration gets its own defer scope. Deferred statements run at the end of each iteration. A deferred direct call snapshots its arguments by value on each iteration (see the callout above); method and indirect calls re-evaluate at scope exit.
func loop_example() {
defer puts("function cleanup");
var i: int32 = 0;
while (i < 3) {
defer puts("iteration cleanup"); // Runs each iteration
puts("iteration start");
i = i + 1;
if (i == 2) {
break; // Runs iteration defer, then function defer
}
}
// Output: "iteration start", "iteration cleanup",
// "iteration start", "iteration cleanup",
// "function cleanup"
}
Switch statements: Each case that creates a block gets its own defer scope:
func switch_demo(value: int32) {
defer puts("function cleanup");
switch (value) {
case 1: {
defer puts("case 1 cleanup");
// ... case 1 code ...
}
case 2: {
defer puts("case 2 cleanup");
// ... case 2 code ...
}
default: {
defer puts("default cleanup");
// ... default code ...
}
}
// Only one case's defer runs, plus function defer
}
Because switch allows fall-through, cleanup order becomes harder to reason about if execution crosses multiple case bodies. Prefer explicit break when a case owns deferred cleanup.
Break and Continue: These statements trigger deferred statement emission before jumping:
func control_flow_demo() {
defer puts("function cleanup");
while (1) {
defer puts("iteration cleanup");
if (early_exit) {
break; // Runs "iteration cleanup", then "function cleanup"
}
if (skip_iteration) {
continue; // Runs "iteration cleanup", then next iteration
}
}
}
Resource cleanup with error recovery:
func process_file(filename: string) {
var file: File* = fopen(filename, "r");
if (file == 0) {
return err(); // No defer to run yet
}
defer fclose(file); // Always runs if file was opened
var buffer: uint8* = malloc(4096);
if (buffer == 0) {
return err(); // Runs defer: fclose(file)
}
errdefer free(buffer); // Only on error
var data: string = read_file_content(file, buffer, 4096);
if (data.length == 0) {
return err(); // Runs errdefer: free(buffer), then defer: fclose(file)
}
// Process successful data...
return ok(); // Runs only defer: fclose(file)
}
Nested error handling:
func nested_operations() {
defer puts("outer cleanup");
var resource1: Resource* = acquire_resource();
if (resource1 == 0) {
return err();
}
defer release_resource(resource1);
{
defer puts("inner cleanup");
var resource2: Resource* = acquire_resource();
if (resource2 == 0) {
return err(); // Runs "inner cleanup", "release_resource(resource1)", "outer cleanup"
}
defer release_resource(resource2);
if (processing_error) {
return err(); // Runs "release_resource(resource2)", "inner cleanup",
// "release_resource(resource1)", "outer cleanup"
}
// Success path...
return ok(); // Runs "release_resource(resource2)", "inner cleanup",
// "release_resource(resource1)", "outer cleanup"
}
}
Top-level defer: defer/errdefer can only be used inside functions:
// ERROR: defer outside function
defer puts("this fails");
func valid_function() {
defer puts("this works"); // OK
}
Supported deferred statements: defer and errdefer currently support function calls, assignments, and blocks:
func example() {
defer close_file(file); // OK
errdefer update_value(x); // OK
defer x = 1; // OK
errdefer {
x = x + 1;
update_value(x);
}
}
Argument capture: A deferred direct call snapshots its argument values at the defer point, so each iteration's deferred call sees that iteration's value:
while (i < 3) {
defer print_int(i); // snapshots i = 0, then 1, then 2
i = i + 1;
}
Method calls and indirect/function-pointer calls are the exception — they re-evaluate at scope exit — so copy into a local first if you need the defer-point value there.
Performance considerations: Each defer statement adds runtime overhead for stack management and conditional execution. In performance-critical code, consider manual cleanup for simple cases.
File handling with multiple resources:
func copy_file(src: string, dst: string) {
var src_file: File* = fopen(src, "r");
if (src_file == 0) {
return err();
}
defer fclose(src_file);
var dst_file: File* = fopen(dst, "w");
if (dst_file == 0) {
return err();
}
defer fclose(dst_file); // Runs first (LIFO)
var buffer: uint8* = malloc(4096);
if (buffer == 0) {
return err();
}
errdefer free(buffer);
// Copy loop...
while (!feof(src_file)) {
var bytes: int32 = fread(buffer, 1, 4096, src_file);
if (bytes <= 0) {
return err(); // Free buffer, close dst_file, close src_file
}
fwrite(buffer, 1, bytes, dst_file);
}
free(buffer); // Manual cleanup before success return
return ok(); // Close dst_file, close src_file
}
Socket management in servers:
func handle_client_connection(client_socket: int32) {
defer close_socket(client_socket);
// Set socket options
if (set_socket_options(client_socket) != 0) {
return err(); // Runs defer: close_socket(client_socket)
}
var buffer: uint8* = malloc(8192);
if (buffer == 0) {
return err();
}
errdefer free(buffer);
// Read request loop
while (1) {
var bytes: int32 = recv(client_socket, buffer, 8192, 0);
if (bytes <= 0) {
break; // Client disconnected or error
}
if (process_request(buffer, bytes) != 0) {
return err(); // Free buffer, close socket
}
}
return ok(); // Free buffer, close socket
}
Memory allocation chains:
func complex_allocation_chain() {
var resource1: Resource* = allocate_resource();
if (resource1 == 0) {
return err();
}
defer free_resource(resource1);
var resource2: Resource* = allocate_resource();
if (resource2 == 0) {
return err();
}
defer free_resource(resource2);
var temp_buffer: uint8* = malloc(1024);
if (temp_buffer == 0) {
return err();
}
errdefer free(temp_buffer); // Only on error
if (complex_processing(resource1, resource2, temp_buffer) != 0) {
return err(); // Free temp_buffer, resource2, resource1
}
// Success: manually clean up temp_buffer
free(temp_buffer);
return ok(); // Free resource2, resource1
}
The compiler emits a warning for unreachable statements that appear after an unconditional return, break, or continue in the same block.