Skip to content

Control flow

Pluvial uses C-style control flow: conditions go in parentheses, bodies are wrapped in mandatory braces, and there are no brace-less single-statement forms.

if (cond) {
...
} else if (cond2) {
...
} else {
...
}
while (cond) {
...
}
for item in arr {
...
}
  • Conditions must be of type bool — there is no truthiness conversion (if (5) is a compile error).
  • Braces are required.
  • A block { } is a new lexical scope; variables declared inside are not visible outside.
  • Shadowing is allowed: an inner block may redeclare a name from an outer scope as a new variable that covers the outer one until the block ends.
  • Redeclaring in the same scope is an error.

for-in walks a collection’s elements in order. The iterables are array<T> and map<K,V> (and strings, via for c in "hello").

array<int> a = [10, 20, 30]
for x in a {
println(x) # 10, 20, 30
}
for (i, x) in a { # enumerate: i is the 0-based index
println(to_string(i) + ":" + to_string(x))
} # 0:10, 1:20, 2:30
map<string, int> m = {"x": 10, "y": 20}
for k in m {
println(k) # keys only
}
for (k, v) in m {
if (v != null) { println(k + "=" + to_string(v)) }
}

Common rules:

  • The iterable type is checked at compile time; a non-iterable (an int, a Result, etc.) is a compile error.
  • Loop variables are new read-only locals whose types are inferred. Assigning to a loop variable is a compile error ("cannot assign to for-loop variable 'x'"). This applies to the enumerate index i too.
  • The iterable is evaluated exactly once, including any side effects.

For arrays, iteration runs from index 0 to arr.length - 1. The tuple form for (i, x) in arr is enumerate: i is a 0-based read-only int and x is the element.

For maps, iteration walks only live hash-table slots, in slot order (not insertion order). The loop snapshots m.capacity at the start, so inserting or deleting inside the body does not crash: deleted keys are skipped, and keys added beyond the snapshot range are not visited.

The tuple form (X, Y) is interpreted by the static type of the subject, not by the names: an array yields (index, element) and a map yields (key, value).

  • break immediately exits the innermost for / while.
  • continue jumps to the next iteration of the innermost loop.
  • Using break / continue outside a loop is a compile error.

A loop may carry a label, and break / continue may target it:

outer: while (cond) {
while (inner) {
break outer # exit the labeled loop
continue outer # continue the labeled loop
}
}

A break LABEL / continue LABEL that names no enclosing loop is a compile error.

match matches a value against patterns and runs the first matching arm. It is a statement, not an expression; arms do not fall through, and bodies must use braces. The subject is evaluated once.

match expr {
pattern => { body }
pattern => { body }
_ => { body } # wildcard, only as the last arm
}
  1. Literal — an int, float, bool, or string literal. The subject must have the same base type; strings compare by content. A duplicate literal is a compile error. OR patterns are supported: 1 | 2 => { ... }.
  2. ok(v) / err(e) — the subject must be a Result. For Result<T>, v : T and e : string; for Result<T, E>, v : T and e : E (a struct, so e.field and e.method() work).
  3. null or a bare identifier — the subject must be T?. null matches the absent case; a bare identifier is a binding pattern that fires when the value is present and binds the narrowed non-null T to a new read-only local.
  4. _ — wildcard, matches anything, must be the last arm.

Tuple patterns are also supported, including typed bindings and nesting:

match pair {
(1, _) => { ... }
(int x, string s) => { ... } # typed bindings
(1, (int a, int b)) => { ... } # nested
}

Enum variants are matched too:

match shape {
Shape.Circle(r) => { println(r) }
Shape.Rectangle(w, h) => { println(w * h) }
Shape.Point => { println("point") }
}

All bindings are read-only; reassigning one is a compile error.

The compiler enforces exhaustiveness at compile time:

SubjectRequired arms
Result<T> / Result<T,E>ok(_) AND err(_) (or _)
booltrue AND false (or _)
T? (nullable)null AND a catch-all (binding or _), or just _
int / float / string_ required (infinite domain)
enumevery variant (or _)
struct / array / map_ required (no destructuring of these yet)

A non-exhaustive match is a compile error (exit 65) — for example, forgetting the err arm of a Result or the false case of a bool.

A nullable value must be narrowed before it can be used as its base type. Pluvial recognises exactly two condition shapes:

  • if (x != null) { ... }x is narrowed to T inside the then branch.
  • if (x == null) { ... } else { ... }x is narrowed to T inside the else branch.
int? a = 5
if (a != null) {
int b = a + 1 # OK: a is int inside this branch
}
# outside the block, a is int? again

Result narrowing uses the same machinery, triggered by is ok / is err:

Result<int> r = parse(-3)
if (r is ok) {
int v = r.value # OK — narrowed to ok
} else {
string m = r.error # OK — else branch is the err state
}

Unlike nullable narrowing (which narrows only one branch), a Result is narrowed on both branches, since ok and err are the only two states. .value is accessible only under an ok-narrowing and .error only under an err-narrowing.

The recognised shapes are limited to x != null / x == null and r is ok / r is err with a single variable — compound conditions, negation, and early-return patterns do not narrow.

Assigning to a narrowed variable returns it to its declared (nullable) type from that point on. This is the soundness rule that keeps null safety honest:

int? a = 5
if (a != null) {
int c = a + 1 # OK: a is int
a = null
int d = a + 1 # compile error — a is int? again
}