Control flow

status = if age >= 18 { "adult" } else { "minor" }
unless ready { wait() }

for i in 0..10 { print(i) }
while condition { do_work() }

value = loop {
    n = n + 1
    if n * n > 50 { break n * n }
}

if is an expression

It evaluates to the taken branch, which is why it can be assigned. There is no ternary operator because there is no need for one.

Conditions

false, null, 0, 0.0, 0m, "", b"", [], {} and an empty range count as false. Every other value counts as true. and, or and not read their operands the same way and always return a Bool.

if count { ... } skips the body when the count is zero, and nothing on that line says whether zero was meant to. So ecko check warns (non-bool-condition) when a condition is plainly a number, string, list or map, and names the comparison to write:

count = len([1, 2])
# `if count > 0`, not `if count`
if count > 0 { print("{count} items") }

A value it cannot see the kind of - a parameter, a get, a function's result - is never warned about.

= in a condition is an error that names ==: if x = 2 { } was almost certainly meant as if x == 2 { }.

unless

if with the condition negated. An expression like if, with an optional else, yielding null when nothing runs. It reads best as a guard:

unless valid(x) { return err }

Ranges

0..10  # exclusive: 0 to 9
0..=10  # inclusive: 0 to 10

Ranges are lazy, so for i in 0..1000000 does not build a list. There is no size limit on iterating one - for i in 0..30000000 is fine, and costs the same 9 MB as any other loop.

Turning a range into an actual list is what costs memory, so that is what is capped (10M elements). list(0..30000000) raises and says to iterate instead; sum, count, first, min and max all stream a range without building it.

for

for x in items { }
for c in "hello" { }         # characters
for byte in b"\x01\x02" { }   # Ints
for (k, v) in config { }     # map pairs, sorted by key
for (i, x) in enumerate(xs) { }

Destructuring is strict: two or more parenthesized names bind each item's elements, and every item must be a list of exactly that length or the loop errors. Silent partial binding would be guessing. Use _ for positions you do not need:

for (_, v) in config { print(v) }

Map iteration yields [key, value] pairs in sorted key order, so a loop over a map is deterministic.

Each iteration has its own loop variable. A closure made in the body keeps the item it was made with:

mut handlers = []
for name in ["save", "load"] {
    handlers = push(handlers, fn() "ran {name}")
}
print(map(handlers, fn(h) h()))  # [ran save, ran load]

A variable declared outside the loop is still shared: every closure sees its latest value.

while and loop

while tests before each iteration. loop runs until break, and evaluates to the break value - which is the clean way to write "repeat until you find something".

for and while evaluate to null.

break and continue

Work in for, while and loop. Using one outside a loop is an error. break v supplies a value to a loop.

Iterating a channel

for job in jobs { handle(job) }

Drains a channel until it is closed. This is the idiomatic consumer, and unlike recv it distinguishes a sent null from end-of-channel.

Budgeting a loop

ECKO_LIMIT_STEPS sets an opt-in step budget, so an accidental infinite loop or an adversarial input degrades into a catchable error instead of hanging. Unset means unlimited. See Resource limits.