Cure v0.28.0 :: Talk Back
by Aleksei Matiushkin
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}
{:error, _} -> {:any, env} # <-- 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 compliesuggestscompile. - REPL
:use--:use Std.Listtwarns and suggestsStd.List. - REPL unknown meta-command --
:typee foosuggests: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 '#{suggestion}'?"
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:
- Displays the error with the normal Cure diagnostic format.
- Explains what went wrong in one sentence.
- Proposes the top-ranked fix from
Cure.Bless.Advisor. - Prompts
[y]es / [n]o / [s]kip. - 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.cureis a new@record-annotatedExhibitFSM.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 blesssession 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 --strictissues 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.