← All posts

Cure v0.28.0 :: Talk Back

by Aleksei Matiushkin

release type-checker parser tooling bless replay playground

v0.27.0 made running Cure applications observable. You could watch the supervision tree breathe, trace typed function calls, verify temporal properties over FSM graphs, and explore session-typed protocols between actors. What you could not yet do was hear back from the tools when you made a mistake -- the compiler stopped at the first parse error, suggestions were silent, and the formatter had no way to show you what it would change without touching disk.

v0.28.0 is the feedback-loop release. The theme is Talk Back.

The bug that started it all

Before any new features: a type-checker bug that has been quietly producing wrong results since the bidirectional checker landed.

cure(1)> :t Std.List.map(["1", "2", "3"], fn (x) -> x + 1)
List(U)

The correct answer is a type error: x is String, and + requires numeric operands. The checker should have said so. Instead it returned List(U) -- a half-resolved type variable, as if it had never looked inside the lambda at all.

Root cause

infer_and_unify_args is the two-pass engine that infers each argument of a polymorphic call, then unifies the inferred types against the declared signature to solve type variables. It looks roughly like this:

{arg_type, env} =
  case infer_arg_with_expected(env, arg, expected) do
    {:ok, t, e2} -> {t, e2}
    &lbrace;:error, _&rbrace;  -> &lbrace;:any, env&rbrace;   # <-- the bug
  end

When infer_arg_with_expected failed (because x + 1 is ill-typed for x: String), the error was silently replaced with :any. That :any unified trivially with everything, so U was never bound, and apply_subst returned {:list, {:type_var, "U"}} -- displayed as List(U).

The :any fallback is intentional for unification mismatches (a concrete argument that does not quite fit the parameter type but should be caught by the subtype fallback rather than aborted immediately). The problem is that the same fallback also swallowed genuine errors from inside argument expressions like lambda bodies.

Fix

Thread an error accumulator through infer_and_unify_args. When infer_arg_with_expected returns {:error, err}, record the error but still use :any locally -- so subsequent arguments still infer against updated substitutions, maximising diagnostics per call. After all arguments are processed, if the accumulator is non-empty, return the first error instead of the garbage return type.

Now the REPL tells the truth:

cure(1)> :t Std.List.map(["1", "2", "3"], fn (x) -> x + 1)
error: type mismatch
 --> nofile:1
  | operator '+' expects numeric operands, got String and Int

Two follow-on fixes came along for the ride. Type.numeric? previously returned false for named type aliases (type Rate = {r: Float | r > 0.0}) and for type variables (T, U). Both are now treated as potentially numeric -- the same permissive treatment :any receives -- so code like amount_f * rate in cure_moneta and fn(x: T) -> x + 1 in equality_laws no longer produce false positives.

Parser error recovery (E063)

The compiler stopped at the first parse error in a file. One misplaced token and every subsequent definition was invisible to the diagnostics pass. Developers had to fix errors one at a time, compile, fix the next one -- an exhausting loop on any non-trivial refactor.

v0.28.0 adds recovery checkpoints at statement boundaries. Both parse_block_body and parse_program now call synchronize_to_statement/1 after every error-producing expression:

state =
  if length(state.errors) > prev_errors,
    do: synchronize_to_statement(state),
    else: state

synchronize_to_statement skips tokens until it reaches a newline, a :dedent, :eof, or a keyword that can open a new definition (fn, mod, rec, type, use, sup, app, proto, impl). This prevents a broken statement from consuming tokens that belong to the next well-formed definition.

The new error code E063 Parse Error (recovered) documents the behaviour and explains that fixing the primary error will make E063 diagnostics disappear automatically.

"Did you mean?" everywhere

Levenshtein distance has been used for variable-name suggestions since v0.12.0. v0.28.0 extends it to every name-resolution failure in the toolchain:

  • Unbound variable -- the error message now includes the closest in-scope name. undefined variable 'lenght'; did you mean 'length'?
  • Record field pattern -- unknown fields in Point{x, z} suggest the closest declared field: did you mean 'y'?
  • CLI subcommand -- cure complie suggests compile.
  • REPL :use -- :use Std.Listt warns and suggests Std.List.
  • REPL unknown meta-command -- :typee foo suggests :type.

The Levenshtein helper lives in Cure.Compiler.Errors.suggest/2 and is threaded through each site with a single idiomatic pattern:

suffix =
  case Cure.Compiler.Errors.suggest(name, candidates) do
    nil        -> ""
    suggestion -> "; did you mean '#&lbrace;suggestion&rbrace;'?"
  end

cure fmt --diff

cure fmt lib/my_module.cure --dry-run

The new --dry-run flag (accepted as --diff in conversation) renders a colour-annotated unified diff using List.myers_difference/2 without touching disk:

--- lib/my_module.cure (original)
+++ lib/my_module.cure (formatted)
 fn add(a: Int, b: Int) -> Int = a + b
-fn   doubled(xs: List(Int))->List(Int) =
+fn doubled(xs: List(Int)) -> List(Int) =
   xs |> Std.List.map(fn (x) -> x * 2)

Red lines (-) are present in the original but absent from the formatted output; green lines (+) are the formatted replacements. Exit code is 0 when no files would change, 1 otherwise -- making it a natural CI gate:

# In CI: fail if any .cure file is unformatted
cure fmt --dry-run || exit 1

No external tooling required. The Myers diff runs in pure Elixir. ANSI output is suppressed automatically when NO_COLOR is set or stdout is not a tty.

cure bless -- a Socratic fix assistant

Type errors are not equal. A :type_mismatch on a function's return annotation has a clear mechanical fix (remove or widen the annotation). An :arity_mismatch is almost always a typo. A :non_exhaustive_match wants a wildcard arm. These are patterns, and cure bless knows them.

cure bless lib/my_module.cure

For each type error in the file, the assistant:

  1. Displays the error with the normal Cure diagnostic format.
  2. Explains what went wrong in one sentence.
  3. Proposes the top-ranked fix from Cure.Bless.Advisor.
  4. Prompts [y]es / [n]o / [s]kip.
  5. On y, applies the fix in-place and re-runs the checker to confirm the file is now clean.

Example session:

error: type mismatch
 --> lib/moneta.cure:45
  | function 'deposit' declared return type Money but body has type Result(Money, String)

  Suggestion (remove return type): Remove the return type annotation on
  line 45 and let the checker infer it.

  Apply? [y]es / [n]o / [s]kip: y
  Applied. Re-checking...
  No more errors.

Cure.Bless.Advisor covers five error patterns with machine-applicable rewrites: type mismatches (remove/widen annotation), constraint violations (suggest a when guard), unbound variables (insert a let binding, or use Cure.Types.Synth to find a well-typed replacement from context), arity mismatches (explain the expected count), and non-exhaustive matches (insert a wildcard arm).

cure bless is also available in the REPL as :bless path and as mix cure.bless path.

@record + cure replay

v0.27.0 gave you cure top to watch the system and cure trace to intercept typed calls. v0.28.0 adds rewind: journal every FSM transition as it happens, then replay the exact sequence of events against a fresh process.

Add @record to any fsm container:

@record
fsm Turnstile
  Locked --coin--> Unlocked
  Unlocked --push--> Locked

From that point, every call that successfully transitions the machine is recorded by Cure.Observe.Journal into an ETS table and periodically flushed to .cure-trace/<pid>.journal. The journal format is a list of {actor_id, state_before, event, state_after, timestamp_us} 5-tuples, serialised as Erlang terms.

To replay:

cure replay .cure-trace/abc123.journal --module Cure.FSM.Turnstile

Output:

Loaded 4 journal entries from .cure-trace/abc123.journal

Trace:
[  1] locked        --coin-------->  unlocked       (ok)
[  2] unlocked      --push-------->  locked         (ok)
[  3] locked        --coin-------->  unlocked       (ok)
[  4] unlocked      --push-------->  locked         (ok)

Replaying against Cure.FSM.Turnstile...
Replay complete: 4 ok, 0 warnings.

Add --step to single-step through the trace with [Enter to continue, q to quit] prompts. Cure.Observe.Replay.replay/3 can also be called from Elixir code and accepts an :on_event callback for custom instrumentation.

cure replay without --module still loads the trace and prints it as a table -- useful when the FSM module is not compiled into the current environment.

Playground v2: live type checking + sandboxed eval

The v0.27.0 Playground was a two-pane editor with live syntax highlighting. v0.28.0 turns it into a real development environment.

Live type-check panel

Every keystroke (debounced at 150 ms) now runs the full bidirectional type checker on the source and renders the result in a dedicated panel below the highlighted preview:

OK -- no type errors

or, when something is wrong:

error: type mismatch
 --> nofile:3
  | declared return type String but body has type Int

The checker runs server-side inside the LiveView process -- no WASM required, no round-trip latency other than the WebSocket hop.

Sandboxed evaluator

A "Run" button triggers CureSiteWeb.Eval, which spawns an isolated BEAM process using :erlang.spawn_opt/2 with a hard 64 MB heap limit and a 2-second kill timer. The child compiles the source, loads the resulting module, calls main/0 if it exists, and captures any stdout. Results appear inline below the Run button:

=> 3

If evaluation times out or the process runs out of memory, the error is surfaced cleanly:

Evaluation timed out after 2000 ms

The WASM path (AtomVM compile so the type checker runs entirely in the browser without a server round-trip) remains on the v0.29 queue.

examples/cure_atelier/ extended

The canonical showcase picks up two v0.28.0 exercises:

  • cure_src/exhibit.cure is a new @record-annotated Exhibit FSM. Closed --open--> Open --sold--> Sold. The test suite asserts that journal entries are recorded, flushed, and can be replayed against a fresh FSM instance with the same result sequence.
  • The README walks through a cure bless session step by step, showing how to introduce a deliberate type error, run the assistant, and apply the suggested fix.

Numbers

  • 1503 tests pass (3 doctests + 1500 tests), up from 1451.
  • 0 mix credo --strict issues across 252 source files, up from 238.
  • 7 new Elixir modules: Cure.Bless, Cure.Bless.Advisor, Cure.Observe.Journal, Cure.Observe.Replay, CureSiteWeb.Eval, and two new Mix tasks (mix cure.bless, mix cure.replay).
  • 2 new error codes: E063 Parse Error (recovered).

What's next

The WASM target -- compiling the pure Cure compiler to WASM via AtomVM so docs pages get truly in-browser executable snippets -- sits at the top of the v0.29 queue. Beyond that, the long-horizon items remain: algebraic effect handlers (handle expr with { Io.println(s) k -> ... }), typed hot code upgrades (cure release --upgrade-from 0.1.0), time-travel for actors as well as FSMs, and a first-class Cure notebook format (.cnb).

The repository lives at github.com/am-kantox/cure-lang. Read the new references on disk: docs/BLESS.md, docs/REPLAY.md, and the updated docs/PLAYGROUND.md. The full CHANGELOG has every detail.