Cure v0.16.0 :: The FSM That Contains Itself
by Aleksei Matiushkin
The turnstile was bothering me.
Not the concept—turnstiles are the canonical FSM example for a reason.
The implementation. In v0.15, defining a turnstile in Cure meant writing
the state machine graph in one file (turnstile.cure) and all the
interesting logic—what actually happens when a coin drops—in a
separate Elixir wrapper module. Two files. Two languages. One abstraction
split down the middle.
The FSM definition was a beautiful four-liner. The wrapper was 120 lines of
GenServer plumbing that had nothing to do with turnstiles and everything to
do with bridging the gap between a gen_statem process and the rest of the
application.
This is the release where we fixed that.
The Finitomata pattern
Finitomata is my Elixir library for finite
automata. It has been in production for years, and its central insight is
simple: the FSM graph and the transition handler belong together. You declare
your states and transitions in a mermaid-compatible string, then implement a
single on_transition/4 callback that decides what happens. One module. One
concern.
Finitomata also introduced two conventions that turned out to be indispensable:
-
Hard events (
event!): when the FSM enters a state where the only outgoing event ends with!, that event fires automatically. No external trigger needed. Initialization chains, cleanup sequences, guaranteed progression—all become trivial. -
Soft events (
event?): when a transition fails, instead of logging a warning and callingon_failure, the FSM silently stays put. Useful for optimistic polling, health checks, anything where failure is expected and uninteresting.
Cure v0.16 borrows all three ideas.
Callback mode
The new syntax looks like this:
fsm Turnstile with Integer
Locked --coin--> Unlocked
Unlocked --push--> Locked
Unlocked --coin--> Unlocked
Locked --push--> Locked
on_transition
(:locked, :coin, _payload, data) -> %[:ok, :unlocked, data + 1]
(:unlocked, :push, _payload, data) -> %[:ok, :locked, data]
(:unlocked, :coin, _payload, data) -> %[:ok, :unlocked, data + 1]
(_, _, _, data) -> %[:ok, :__same__, data]
The transition graph is the same four lines it always was. Below it, the
on_transition block contains pattern-matching clauses that receive
(current_state, event, event_payload, state_payload) and return
%[:ok, next_state, new_payload] or %[:error, reason].
When the compiler sees on_transition, it switches to callback mode:
instead of generating raw gen_statem Erlang abstract forms, it produces a
GenServer-based Elixir module with:
- An embedded transition table as a module attribute
- Transition validation before every event dispatch
- Compiled
do_on_transition/4from the user’s clauses - Hard event auto-fire via
{:continue, ...} - Soft event silent catch-all
- Optional lifecycle hooks:
on_enter,on_exit,on_failure,on_timer
The generated module exports the same API surface as simple mode --
start_link/0,1, send_event/2, get_state/1—plus introspection
functions: transitions/0, allowed?/2, responds?/2.
Simple mode lives on
FSMs without on_transition compile exactly as before: to OTP gen_statem
BEAM modules via Erlang abstract forms. The existing when guards and do
actions continue to work. What changed is that simple mode now also supports
!/? event suffixes and exports transitions/0 and allowed/2 for
introspection.
The compiler decides mode automatically. No configuration. No flags.
What the compiler does
The implementation touches every layer of the pipeline:
Lexer. A new fsm_transition_depth counter tracks whether we're inside
--...--> arrows. When positive, identifiers absorb trailing ! or ?
characters, producing tokens like "init!" instead of separate identifier
bangtokens.
Parser. FSM blocks now recognize on_transition, on_enter, on_exit,
on_failure, and on_timer as callback block starters. Each callback block
contains (pat1, pat2, ...) -> body clauses—a custom parser that reads
parenthesized comma-separated patterns, an optional when guard, then ->,
then body. Event names are classified into :normal, :hard, or :soft
based on their suffix. @timer <ms> is a new annotation.
Verifier. Hard events are validated: a !-suffixed event must be the
sole outgoing event from its source state (same rule as Finitomata). When
on_transition is present, the verifier emits coverage warnings for
ambiguous transitions—same event from same state leading to multiple
targets—that the callback must resolve.
Compiler. The core change. Callback mode generates Elixir source via
string interpolation and compiles it with Code.compile_string. This
sidesteps the complexity of generating GenServer callbacks as Erlang
abstract forms. A cure_ast_to_elixir translator converts the Cure AST
callback clauses to Elixir source. The pipeline in Cure.Compiler detects
the :callback_mode return and skips BeamWriter (the module is already
loaded).
Runtime. Cure.FSM.Runtime gains allowed?/3 and responds?/3 that
delegate to the FSM module’s compiled transition table.
LSP. FSM transitions appear as document symbols with event suffix badges.
Hovering over on_transition shows the callback signature. FSM callback
names (on_transition, on_enter, ...) appear as completions inside FSM
blocks.
MCP. The analyze_fsm tool now reports compilation mode, timer config,
event kinds with suffixes, and callback block clause counts. The
syntax_help("fsm") response was rewritten to cover both modes.
The turnstile, rewritten
The old two-file turnstile is now a single .cure file. The Elixir wrapper
still exists (for passage counting, which is application logic outside the
FSM’s concern), but it dropped from 120 lines to 50, and the terminate
callback went from :gen_statem.stop to GenServer.stop. The 12 example
tests all pass unchanged.
The numbers
714 tests. Zero compilation warnings. Zero credo issues. 55 Elixir source files. 18 stdlib modules. ~1,400 lines of new/modified code across 14 files.
Getting started
git clone https://github.com/am-kantox/cure-lang.git
cd cure
mix deps.get && mix test
mix escript.build
./cure version # Cure 0.16.0
The repository is at github.com/am-kantox/cure-lang.