Chapter 2 Control Flow
SourceControl flow in Silo is built out of five keywords and two library words. Each of them has a clean stack contract: it consumes a known shape from the stack and leaves a known shape behind. The compiler checks that every branch of a conditional produces the same output shape — so you can't accidentally have an :if arm that leaves one value on the stack and an :else arm that leaves two.
If you've used Rust, the analogues are close enough to read at sight:
| Silo | Rust |
|---|---|
:if … :else … |
if … { … } else { … } |
:match |
match |
:if-let |
if let PAT = … { … } else { … } |
:let |
let PAT = … else { … }; (bindings escape into scope) |
:loop / :break |
loop { … break; … } |
:ret |
return |
:try |
? (the try operator) |
while |
while (as a library fn) |
times |
for _ in 0..n { … } (as a library fn) |
:if and :else
:if pops a Bool off the stack. If it's true, the body runs; if false, the :else arm runs — or nothing happens if there's no :else.
:fn abs ( Int -> Int )
dup 0 < :if neg :end
:end
Reading left to right: duplicate the top of the stack (so we still have it after the test), push 0, compare with <, which consumes both numbers and pushes a Bool. :if pops that Bool; if it was true, neg runs. Either way the function leaves one Int on the stack.
With an :else branch, you compute different values on each side:
:fn max ( Int Int -> Int )
pop-> b pop-> a
a b > :if a :else b :end
:end
The compiler requires both arms to leave the stack in the same shape. If one arm pushed a Str and the other pushed an Int, the program would not compile.
:match
:match pops the value on top of the stack and dispatches on its shape. Patterns cover the full range of the type — matching on an Int you can use literals, ranges, and a wildcard; matching on a union you destructure each variant.
:fn describe ( Int -> Str )
:match
| 0 => "zero"
| 1 => "one"
| 2 .. 10 => "a few"
| _ => "many"
:end
:end
Patterns are checked for exhaustiveness: forgetting the _ case above would be a compile error because integers outside the listed ranges would be unhandled. The full pattern grammar — unions, records, literals, ranges, wildcards, nested — has its own chapter (Pattern Matching).
:if-let and :let
When you only care about one pattern — typically unwrapping an Option or a Result — a full :match is noisy. Silo borrows two constructs from Rust for these cases.
:if-let is exactly Rust's if let … else: try a pattern, run one body on success, another on failure. The scrutinee sits on the stack beforehand.
42 Some :if -let Some v =>
v 1 +
:else
0
:end
# Leaves 43 on the stack.
Both arms must produce matching stack shapes, same as :if. The construct desugars at parse time to a two-arm :match with a wildcard fallback, so the exhaustiveness story carries over automatically.
:let is Rust's let … else: unwrap or diverge. On success, the pattern's bindings stay in scope after the :end. On failure, the :else branch must diverge — it has to call :ret, :break, panic, or otherwise not return normally — because there's no value to continue with.
:fn incr-or-bail ( ( Option Int ) -> Int )
:let Some v :else
:ret # bail out; v never gets bound
:end
v 1 + # v is in scope here
:end
A more interesting case: unwrap or re-raise as Err:
:fn require-name ( ( Option Str ) -> ( Result Str Str ) )
:let Some name :else
"missing name" Err :ret
:end
name Ok
:end
:let is how you write code that says "I expect this to succeed, and if it doesn't I have a clear exit strategy." It keeps the happy path unindented instead of burying the whole rest of the word inside a :match arm.
:loop and :break
:loop is an unconditional loop. You exit it with :break:
:fn countdown ( Int -> ) + Console + Div
:loop
dup 0 <= :if drop :break :end
dup "{}" format print
1 -
:end
:end
The +Div annotation is the interesting bit. Silo is total by default: every function must be proved to terminate on every input, and the compiler does the proving. A structural recursion where the argument shrinks is trivially total. But :loop with a runtime break condition can't be proved to terminate statically, so it carries the +Div (divergence) effect. Callers of countdown see +Div in its signature and know it might loop forever.
+Div is covered in detail in the effects chapter; for now, think of it as a compiler-visible note saying "I cannot guarantee this terminates".
:ret — early return
Whatever is on the stack when the word finishes is its return value. :ret lets you finish earlier, from inside a nested block:
:fn find-first ( ( Vec Int ) Int -> ( Option Int ) )
pop-> target pop-> vec
0 .. vec .len [ pop-> i
i vec .get unwrap target = :if
i Some :ret
:end
] .for-each
None
:end
When the predicate fires, i Some :ret pushes Some(i) and returns. If the loop finishes without firing, control falls through to None, which becomes the return value. The stack shape at the point of :ret must match the word's declared output — that's how the compiler keeps the effect signature honest.
:try — unwrap or short-circuit
:try is the Silo analogue of Rust's ? operator. It unwraps an (Ok val) and continues with val on the stack, or short-circuits the enclosing word by returning the (Err e) unchanged:
:fn process ( Str -> ( Result Int Str ) )
parse-int :try # if Err, returns Err immediately
2 * Ok # if Ok, continues with unwrapped value
:end
:try only works inside a word whose declared return type is itself a (Result _ _) with a compatible error type. The compiler checks this. If you reach for :try in a word that returns a plain value, it won't compile — you'll need :match to handle the Err case explicitly.
while and times — library words, not keywords
Silo keeps the language surface small by moving most looping into the standard library. while and times are ordinary words that take quotations:
:fn gcd ( Int Int -> Int ) + Div
[ dup 0 ≠ ] [ swap over mod ] while drop
:end
5 [ "hello" print ] times
while takes a condition quotation and a body quotation, both of type [ -> Bool ] and [ -> ] plus whatever effects. times takes a count and a body, calling the body that many times. They compose with the rest of the language exactly because they're not special — nothing stops you from defining your own.
A much richer family of higher-order iteration primitives — .map, .filter, .fold, .for-each, .reduce, and friends — belongs to the Foldable and Iterator traits. They're covered in the collections chapter.
Key points
- Every control-flow construct pops its condition or scrutinee from the stack. Nothing is passed by implicit variable.
- Every branch of an
:if/:else/:matchmust leave the stack in the same shape — the compiler enforces this. :matchis exhaustiveness-checked. Missing cases are a compile error.:loopneeds+Divwhen the break condition is runtime-dependent. Without+Div, Silo is total: your function is guaranteed to terminate on every input.:retfinishes the enclosing word with whatever's on the stack.:tryis:ret's specialised cousin forResult-returning words.while,times,.map,.fold, and so on are ordinary library words, not keywords.
The next chapter picks up from the [ … ] syntax used above — quotations and higher-order functions.