← All posts

Cure v0.16.0 :: The FSM That Contains Itself

by Aleksei Matiushkin

release fsm finitomata callback-mode genserver

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 calling on_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/4 from 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

  • bang tokens.

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.