Why state machines are first-class
Recoil makes finite state machines
(fsm!) and streaming transducers
(fst!) into real types. Their states, events, guards, and payload
types are checked at compile time and lowered to plain C — no library, no
interpreter, no hand-rolled switch soup.
This is unusual: most languages leave state machines to a library or to error-prone manual encoding. In Recoil a machine is a declaration the compiler understands, so invalid transitions and payload mismatches are caught before you run anything. Combined with PARSE, FST gives you a type-safe pipeline from raw bytes to structured values.
FSM — core model
An fsm! type declares optional fields: (machine-local
storage), events: (named transitions with typed payloads), and one
block per state. The first state is the initial state.
TrafficLight!: make fsm! [
events: [
go ["Switch to green"]
slow ["Switch to yellow"]
stop ["Switch to red"]
]
red [go => green]
green [slow => yellow]
yellow [stop => red]
]
light: make [#mutable TrafficLight!] TrafficLight!/red
send light go []
send light slow []
send light stop []
Fields, events & payloads
fields: adds machine storage shared across transitions.
events: declares each event's doc string and typed payload
parameters; the payload is supplied in the block passed to send.
Event parameters are available by name inside the guards and action blocks of
the matching transition, and field names are in scope there too (in
Counter! below, n is the payload of inc).
Counter!: make fsm! [
fields: [count: i32!]
events: [
inc ["Increment" n [i32!]]
reset ["Reset"]
]
zero [
inc => one [count: count + n]
]
one [
inc => one [count: count + n]
reset => zero [count: 0]
]
]
c: make [#mutable Counter!] Counter!/zero
send c inc [5]
Transitions, guards & actions
A transition is event => target, optionally guarded with
when [cond], with a catch-all else => target. An
optional action block can mutate fields, assign from calls, or make bare void
calls.
Transition validity rules:
- a transition target must be a state of the same machine;
when [cond]is evaluated before the transition fires;elseis the fallback when no named transition for the event succeeds;- the action block runs before the machine enters the target state;
- action statements are limited to field assignment, local assignment from an
expression or call, and bare void calls. Anything else (a path-set such as
obj/field: x, a parenthesized expression, a nested block) is a compile-timeCODEGEN ERROR; control-flow keywords such asiforwhileare not supported in actions — keep them small.
Gated!: make fsm! [
fields: [ready: i32!]
events: [try [] arm []]
locked [
try when [ready > 0] => unlocked
arm => locked [ready: 1]
else => locked
]
unlocked []
]
send and state?
Dispatch an event with send machine event [payload]. Inspect the
current state with state? machine Type!/state-name, which returns a
logic!.
send mutates the machine in place, so the binding must be
#mutable (otherwise the borrow checker rejects it with
B3003); the event must be declared in events:, the
payload must match the parameter types, and the transition must exist for the
current state or be handled by else.
send light go []
print state? light TrafficLight!/green
FST — streaming transducers
An fst! consumes an input series and emits typed
values as it goes. It declares emits: (the required sum type every
emit produces), optional fields:, and one block per
state whose transitions are [parse-rule] => target [action]. The
first state is initial; a state with no transitions is terminal.
Token!: make sum! [
ident
num [value: i32!]
]
Lexer!: make fst! [
emits: Token!
fields: [count: i32!]
scanning [
["A"] => scanning [count: count + 1 emit Token!/num count]
[" "] => scanning
]
finished []
]
emit
Each transition's parse rule tests the current source head; a match consumes
those bytes and fires the transition. The action can emit values:
- atomic:
emit Token!/num count - 1-to-N (push several at once):
emit [Token!/a Token!/b]
emit is an expression: it pushes the constructed
value into the iterator's emission queue and also yields it as its result, so it
composes with control flow (if buffered? [emit Token!/flush]).
Every emit must produce a value of the declared emits:
sum. It is rejected inside loop forms (while, repeat,
foreach, ...) because emissions must be incremental and bounded, and
a 1-to-N emit takes only a fixed literal block, never a runtime-sized one.
Action blocks run after a successful match and may assign fields
(count: count + 1), emit, and make bare void calls.
Captures introduced by copy or set in the parse rule are
bound in the action scope and used like any other value:
[copy w some [#"a" | #"b"]] => scanning [emit Tok!/word w]
Transitions are tried in source order and the first matching rule wins. A
terminal state (no transitions) short-circuits take to
done.
transduce/lazy and take
Build a lazy iterator with transduce/lazy Type! source and pull
one value at a time with take, which returns an auto-registered
<Type>-take-result! sum you dispatch on with
match:
drive: func [] [
lex: transduce/lazy Lexer! "AAA"
result: take lex
match result [
ok [v] [print 1]
need-more [] [print 8]
done [] [print 9]
fail [err] [print 7]
]
]
The sum has variants ok [value: <emits>],
need-more [], done [], and
fail [err: error!].
The supported source kinds for transduce/lazy are a
c-string! literal, a string! variable, a
slice! [char!], and a vector! [u8!] (the
#flexible form is accepted too). vector! [char!] is
intentionally not a byte source, because char! is 4 bytes wide; use
u8! for a byte buffer.
take steps the machine like this: a queued emission is returned
as ok; a terminal state returns done (remaining input is
ignored on purpose); otherwise the current state's transitions are tried in order
and the action runs on a match; if nothing matches and input remains it returns
fail, and if the source is exhausted it returns done.
On fail, err/code is an ERR_PARSE_*
code (ERR_PARSE_NO_MATCH for an unmatched input, under
ERR_TYPE_PARSE), and runtime/error-parse-position err
gives the byte position. Rule and context accessors are not exposed at the
Recoil level yet.
Current limits
Avoid relying on these; they are deferred to future work:
- Ports as sources (the
need-morepath) — depends onparseoverport!. - FST chaining (
transduce/lazy Parser! tokens) — needs parse over arbitrary element types. - Vector sources of arbitrary element types (for example
vector! [Token!]) — the shipping vector source isu8!only. - Sub-FST invocation inside action blocks — out of scope for v1.
Unsupported source kinds raise a codegen diagnostic. See the FST guide and FSM guide.
Parse + FSM integration
Parse results drive control flow, and parse paren actions can send real FSM
events. state? can then choose the next parse rule — enough to
build a complete protocol loop (read → parse → send).
parse "GET /path" [
"GET" (send conn got-method [])
" "
thru end
]
either state? conn Conn!/reading-body [
parse "payload" [thru end (send conn got-body [])]
] [
print "still waiting for method"
]
See the repository's Parse + FSM integration doc for the full pattern catalogue.