Skip to content
CryoCryo home
LanguageControl and data

6Control Flow

6.1 If / Else

Conditions are parenthesised and bodies are always braced; there is no single-statement form.

if (x > 0) {
    println("positive");
} else if (x < 0) {
    println("negative");
} else {
    println("zero");
}

if may also be used as an expression that evaluates to the value of the chosen branch. When used this way, both arms are required and must produce the same type.

const is_even: boolean = if (n % 2 == 0) { true } else { false };

6.2 While Loops

mut i: int = 0;
while (i < 10) {
    printf("%d\n", i);
    i = i + 1;
}

6.3 For Loops

A C-style for with three components: declare-initialiser, condition, post-update. The loop variable is scoped to the loop body.

for (mut i: int = 0; i < 10; i++) {
    printf("%d\n", i);
}

for (x in <expr>) iterates a sequence. The expression is evaluated exactly once and bound to a hidden mutable local; the parser lowers the loop to a loop { match (iter.next()) { Some(x) => { ... } None => break; } }. break, continue, and return inside the body bind to the synthesised loop, and the iterator binding is dropped at the end of the enclosing block.

The scrutinee may be:

  • An Iterator directly - anything exposing next(mut &this) -> Option<T>, including the stdlib's Range<T> / RangeInclusive<T> and any type that implement trait Iterator<T>.
  • A range literal a..b (half-open) or a..=b (inclusive). These are sugar for Range::new(a, b) / RangeInclusive::new(a, b); see the precedence table in section 5.7.
  • An iterable that exposes iter() returning an iterator - Array<T> and Slice<T> (their iter() is gated where T: Copy). The lowering inserts the .iter() call.
  • A fixed-size array T[N]. The lowering views it as a Slice<T> over its N elements.
mut sum: i32 = 0;
for (i in 0..5) {            // range literal; 0,1,2,3,4
    sum = sum + i;
}
// sum == 10

const xs: i32[3] = [7, 8, 9];
for (x in xs) {              // fixed-size array
    sum = sum + x;
}

Range literals are ordinary expressions and may appear anywhere, not only in a for header: const r: Range<i32> = 2..7;. .. binds looser than the arithmetic operators, so a..b + 1 parses as a..(b + 1).

6.4 Loop

loop { ... } is an unconditional infinite loop. Prefer it over while (true); it communicates "this runs until something inside breaks out" without misdirection.

mut count: int = 0;
loop {
    if (count >= 5) { break; }
    printf("%d\n", count);
    count = count + 1;
}

6.5 Do-While

do {
    body();
} while (condition);

The body executes at least once, then the condition is checked.

6.6 Break and Continue

break exits the innermost loop. continue skips to the next iteration.

for (mut i: int = 0; i < 20; i++) {
    if (i % 2 == 0) { continue; }
    if (i > 10)     { break; }
    printf("%d\n", i);
}

6.7 Match: Statement Form

match is the language's primary discriminator. It branches on enum variants, integer values, and other patterns; the compiler enforces that every case is covered.

match (color) {
    Color::Red   => { println("red"); }
    Color::Green => { println("green"); }
    Color::Blue  => { println("blue"); }
}

There is no fallthrough between arms. See section 7 for the full pattern language.

6.8 Match: Expression Form

Used as an expression, match evaluates to the value of the matching arm. All arms must produce the same type.

const name: string = match (n) {
    1 => { "one" }
    2 => { "two" }
    _ => { "other" }
};

6.9 Switch / Case

A traditional switch is also available for integer, char, bool, and fieldless enum values - anything compared by value. There is no implicit fallthrough; each case is independent.

switch (value) {
    case 1: { println("one");   }
    case 2: { println("two");   }
    default: { println("other"); }
}

A switch on anything else is a compile error that points you at the right tool: enums whose variants carry payloads, strings, and other structured types require a match (which destructures and checks exhaustiveness), and floating-point values require explicit comparisons.

In idiomatic code, prefer match; it supports richer patterns and enforces exhaustiveness. switch is provided for familiarity and for low-level integer dispatch.

6.10 Ternary

const abs: int = x >= 0 ? x : -x;

The ternary is right-associative: a ? b : c ? d : e parses as a ? b : (c ? d : e).

6.11 Return

function add(a: int, b: int) -> int {
    return a + b;
}

return exits the current function. If the function has a non-void return type, a value is required.

6.12 Unsafe Blocks

unsafe { ... } is recognised at parse time and lowers identically to a plain block. It serves as a documentation marker: a visible signal that the enclosed code performs raw pointer arithmetic, calls extern functions, or otherwise sits at the edge of the language's safety story. The compiler does not currently impose any extra restriction outside an unsafe block, and does not relax any check inside one - every operation Cryo permits today is permitted everywhere.

unsafe {
    const raw: void* = malloc(64);
    // raw pointer manipulation here
}

This is the committed 1.0 behavior: unsafe is a documentation marker and nothing more. It is not reserved to silently become enforcing - 1.0 code will not break under a future release on account of unsafe. Should later versions add safety checks around raw pointer dereference, raw-to-pointer as-casts, or extern calls, they would arrive compatibly (as an opt-in lint/warning first), not as a breaking change to code that already compiles.

6.13 Inline Assembly

asm { ... } embeds target assembly directly, lowering to an LLVM inline-assembly call. The block body is raw assembly - written without string quoting - and Cryo values are bound into it through ${ ... } operand holes. Bare { and } are literal assembly text, so target syntax that uses braces (AVX-512 mask registers such as {k1}, for instance) passes straight through.

A mandatory ![arch(<arch>, <dialect>)] directive must appear immediately above the block. It names the target architecture (which gates the block - see below) and the assembly dialect (intel or att):

![arch(x86_64, intel)]
asm {
    mov ${=out}, ${in}
}

Operands. Each ${ ... } hole binds a Cryo variable. A prefix selects its direction and an optional : suffix pins a register or constraint class:

FormMeaning
${x}input - the value of x is read into a register
${=x}output - the result is written back to x
${+x}in-out - x is both read and written
${x:"rax"}pin the operand to a specific register
${x:m}memory operand - x is addressed in memory
${x:i}immediate - x must be a compile-time constant

Referencing the same variable more than once collapses to a single operand, and a variable used as both an input and an output is promoted to in-out. Operands must be register-sized scalars or pointers.

Clobbers. Registers, flags, or memory that the block overwrites but doesn't name as operands are declared with ![clobber(...)], so the compiler doesn't assume their values survive the block:

![arch(x86_64, intel)]
![clobber(rcx, r11, flags, memory)]
asm {
    mov rax, ${v}
    add rax, rax
    mov ${=out}, rax
}

Outputs and results. An asm block is a statement; values leave it through ${=x} / ${+x} operands, and a block may have any number of outputs.

Dialects. intel is destination-first and prefix-less (mov rax, 60); att is source-first with % registers and $ immediates (movq $60, %rax). The dialect is always stated explicitly in the ![arch(...)] directive.

Arch gating. <arch> is matched against the compile target: a block whose arch differs from the target is dropped, exactly like a ![linux] / ![windows] gate. This lets per-architecture blocks sit side by side, each written for its own target:

![arch(x86_64, intel)]
asm { syscall }

![arch(aarch64, att)]
asm { svc #0 }

Module-level assembly. An asm { ... } written at module scope (outside any function, with no operands) emits module-level inline assembly - for naked/global stubs, .globl symbols, or raw data.

A write system call on x86_64 Linux, pulling the buffer and length in as operands:

![arch(x86_64, att)]
![clobber(rcx, r11, memory)]
asm {
    movq $1, %rax        // SYS_write
    movq $1, %rdi        // fd = stdout
    movq ${buf}, %rsi    // buffer pointer
    movq ${len}, %rdx    // length
    syscall
}

Note. LLVM passes the assembly text through to the target assembler unchanged - Cryo does not parse it - so a typo in a mnemonic or register name surfaces as an assembler error at build time, not a Cryo diagnostic. Only ${ ... } introduces an operand; a literal $ (an AT&T immediate such as $60) and bare { / } are emitted verbatim.