Skip to content

Pipelines and State Machines

When each stage of a processing pipeline is a generator that consumes another generator, you get a pull-based pipeline: nothing is materialized in memory until the final consumer pulls a value. Compose as many stages as you like — the cost is constant per item.

Three-stage pipeline (source → keep_evens → square) and manual extraction of N items from an infinite sequence.

06-coroutine-pipelines.zolo
Playground
// Feature: pipelines with coroutines/generators

// Syntax: chain `fn*` consuming each other.

// When to use: stage-based processing (parsing, transformation,

// aggregation) without huge intermediate buffers.

//

// Each stage is a generator: pull-based, lazy, composable.


// Stage 1: produces integers 1..N.

fn* source(n: int) {
  var i = 1
  while i <= n {
    yield i
    i += 1
  }
}

// Stage 2: keeps even numbers (receives a generator handle).

fn* keep_evens(input: any) {
  var v = input()
  while v is not nil {
    if v % 2 == 0 {
      yield v
    }
    v = input()
  }
}

// Stage 3: maps to the square.

fn* square(input: any) {
  var v = input()
  while v is not nil {
    yield v * v
    v = input()
  }
}

// Composed pipeline.

let pipe = square(keep_evens(source(8)))
var x = pipe()
while x is not nil {
  print("out: {x}")
  x = pipe()
}

// expected: out: 4, out: 16, out: 36, out: 64


// Manual pull of N items.

fn take(input: any, n: int) -> [int] {
  let result: [int] = []
  var i = 0
  while i < n {
    let v = input()
    if v is nil { break }
    result.push(v)
    i += 1
  }
  return result
}

fn* naturals() {
  var i = 1
  loop {
    yield i
    i += 1
  }
}

let first10 = take(naturals(), 10)
print("first 10: {first10}")
// expected: first 10: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

Coroutines also model state machines without enum + switch: the execution position between two yields is the state. This is natural for phased parsers, dialogue trees, agents with steps, and game NPCs.

NPC with four states (idle/patrol/chase/attack), a mini-lexer, and a dialogue tree, all expressed via yield.

07-state-machine.zolo
Playground
// Feature: state machines with coroutines

// Syntax: each `yield` is a transition; state lives between yields.

// When to use: step-by-step parsers, agents with phases, game NPCs,

// flows with state rollback.

//

// The coroutine "remembers" the execution point. No need for enum +

// switch — the code flow itself is the machine.


// NPC with 4 states: idle -> patrol -> chase -> attack -> idle...

// Note: a real loop would use `while true { ... }`; here we unroll

// two cycles to keep the example short and avoid a known codegen

// edge case (multiple `fn*` with looping body in one file).

fn* npc_brain(name: str) {
  yield "{name}: idle"
  yield "{name}: patrol"
  yield "{name}: chase"
  yield "{name}: attack"
  yield "{name}: idle"
  yield "{name}: patrol"
}

let bot = npc_brain("orc-1")
for _ in 0..6 {
  print(bot())
}

// SKIP: state-based parser with nested while + yield inside if/else

// branches currently triggers a Lua codegen ambiguity in the runtime.

// The pattern is valid Zolo, but the lowered code becomes ambiguous.

// A simpler example below shows the same idea (state via `yield`)

// without the nested loops.

//

// Tiny lexer-like state machine: emits a token per call.

fn* tokens_demo() {
  yield "ID(abc)"
  yield "NUM(123)"
  yield "ID(def)"
  yield "NUM(45)"
}

let tokens = tokens_demo()
var t = tokens()
while t != nil {
  print(t)
  t = tokens()
}

// Dialogue with phases: each `yield` waits for the player to press "next".

fn* dialogue() {
  yield "Hello, traveler."
  yield "You look tired."
  yield "Want a potion?"
  yield "Good luck on the journey!"
}

let d = dialogue()
var line = d()
while line != nil {
  print("[npc] {line}")
  line = d()
}

When to use this pattern vs. machine X { ... }? Prefer coroutines when the state is a flow (parser, linear dialogue). Use the machine keyword when the state is a graph with explicit transitions (protocols, devices).

enespt-br