GuideThe Silo HandbookControl Flow

Chapter 2 Control Flow

Source

Control 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/:match must leave the stack in the same shape — the compiler enforces this.
  • :match is exhaustiveness-checked. Missing cases are a compile error.
  • :loop needs +Div when the break condition is runtime-dependent. Without +Div, Silo is total: your function is guaranteed to terminate on every input.
  • :ret finishes the enclosing word with whatever's on the stack. :try is :ret's specialised cousin for Result-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.