Cure v0.24.0 :: The REPL You Deserve
by Aleksei Matiushkin
v0.23.0 was the big one. Six new Cure.Project.* modules, a Rekor-
style transparency log, property-based shrinking, doctor / fix /
telemetry / coverage, and the cure_brainloop showcase example. The
ecosystem backlog that had been quietly deferred through four
releases finally landed in a single coherent cut.
That meant v0.24.0 had the luxury of picking exactly one thing and
finishing it. The pick, in retrospect, is obvious. The one piece of
day-to-day UX that still felt 2022 after v0.23.0 shipped was the
interactive REPL. cure repl had been backed by an IO.gets loop
since v0.15.0 and barely touched since. The arrow keys printed
^[[A. There was no history. There was no syntax highlighting. There
was no completion. You could not scroll up to your last input without
hitting Ctrl+P in a shell wrapping the REPL itself.
v0.24.0 rewrites the lot.
Raw mode
The ground floor is Cure.REPL.Terminal. It uses stty to put stdin
into cbreak/no-echo mode, and is careful to restore the previous
termios on every exit path: normal :quit, EOF on an empty buffer,
Ctrl+C on a non-empty buffer, uncaught exception, SIGINT / SIGTERM.
The saved state is captured eagerly so that a crash during enter_raw
still leaves a usable terminal behind.
On top of raw mode sits a small decoder that turns raw byte streams
into high-level events: arrow keys, Home / End / Delete /
PgUp / PgDn, every Ctrl+<letter> and Alt+<char> shortcut,
Ctrl+Arrow word-wise movement, F1-F12, and bracketed paste
(so multi-line pastes arrive as a single :paste event rather than
10,000 :key events). Non-tty stdin -- CI, pipes, | into
cure repl -- short-circuits to the legacy IO.gets loop, so test
automation is unaffected.
A pure-function line editor
The next floor up is Cure.REPL.LineEditor. It is a stateless
buffer: every key event produces a new %LineEditor{} struct. That
keeps raw_loop/2 a straight recursive match on events, and makes
the whole editor trivially unit-testable.
The feature surface is the full emacs-mode menu a readline user would expect:
- Cursor movement: one grapheme, one word (
Alt+B/Alt+F/Ctrl+Left/Ctrl+Right), start / end of line (Ctrl+A/Ctrl+EorHome/End). - Editing: backspace / delete, kill to end (
Ctrl+K), kill to start (Ctrl+U), kill previous word (Ctrl+W), kill next word (Alt+D), yank (Ctrl+Y), rotate yank (Alt+Y), transpose chars (Ctrl+T), transpose words (Alt+T), upcase / downcase / capitalise word (Alt+U/Alt+L/Alt+C). - Undo and redo (
Ctrl+_/Alt+_), backed by an append-only history of buffer snapshots. - A minimal vi normal mode (
:mode vi):h/j/k/l,w/b/e,0/^/$,i/a/I/A,x,D,C,u.Escenters normal mode;i/a/I/Ago back to insert mode.
Every key binding is a single LineEditor.handle/2 clause that
returns {:cont, ed} or a {:signal, ...} tuple. The REPL loop
treats :signal payloads (:submit, :abort, :cancel, :quit,
:redraw) as the only way to leave a keystroke, which keeps the
event flow tidy.
History that actually works
Cure.REPL.History tracks every submitted expression in memory and
persists to ~/.cure_history via atomic write-and-rename. Two
details worth surfacing:
- Consecutive duplicates are deduplicated:
foofollowed byfoostores one entry. Non-consecutive duplicates are kept, becausefoosurrounded by unrelated commands is a real signal. Up/Downstep through history while preserving the current draft, so scrolling up and back down returns you to the exact buffer and cursor position you had before you started exploring.
The file is capped at 10,000 entries. Rewriting is atomic: write to
~/.cure_history.tmp, then File.rename/2. A crash mid-session
cannot corrupt the history file.
Reverse search
Ctrl+R opens an inverse-video status line with the readline
semantics everyone already knows:
(reverse-i-search)`map': Std.List.map(xs, fn(x) -> x + 1)
Each keystroke narrows the match. Ctrl+R again steps to the next
older match. Ctrl+S flips direction to forward search. Enter
accepts the match and submits it. Tab / ArrowLeft / ArrowRight
accept-and-edit into the main buffer. Ctrl+G / Esc cancel and
restore the original.
No tracing-paper approximation of readline. Just readline.
Live syntax highlighting
Every buffer state is piped through Makeup.Lexers.CureLexer and
Marcli.Formatter, so expressions are re-rendered as ANSI-coloured
Cure source on every keystroke. The highlighter caches by buffer
hash, so holding down a key doesn't re-tokenise on every frame.
Cure.REPL.Theme ships three presets:
:dark-- default, optimised for dark backgrounds.:light-- inverted palette for light backgrounds.:mono-- no ANSI colour, forced automatically whenNO_COLORis set, when stdout is not a tty, or whenTERM=dumb.
:theme dark|light|mono toggles at runtime, and :color on|off
flips the highlighter on or off without re-reading the environment.
Tab completion
Cure.REPL.Completer handles four categories in one pass:
- Meta-commands. Typing
:and pressingTabcycles through every registered command. - File paths. Inside
:load,:save,:edit, completes against the filesystem. - Loaded modules. Inside
:useand:doc, completes against currently-loaded Cure modules. - Literal arguments and Cure keywords. Theme / mode / colour argument values, and the reserved-word list everywhere else.
A single Tab cycles through candidates; repeated Tab presses
rotate. When there is exactly one candidate, it is inserted directly.
Meta-commands
All pre-existing meta-commands -- :t, :type, :effects,
:load, :reload, :use, :holes, :env, :reset, :fmt,
:help, :quit -- are preserved. v0.24.0 adds a batch more:
:history [n]-- print the lastnentries (default 20).:search term-- non-interactive history grep.:clear-- clear the screen.:save path-- write the session transcript to a file.:edit-- open$EDITOR(or$VISUAL) on the current buffer.:time expr-- evaluate and report elapsed wall time.:bench expr [n]-- runexprntimes, report min / avg / p95 / max.:ast expr-- dump the parsed AST.:theme dark|light|mono,:mode emacs|vi,:color on|off.
:help is rendered through Marcli.render/2, so the key-bindings
table arrives as ANSI-styled Markdown with proper boldface and
alignment.
New dependencies
Three small, focused Hex packages:
{:marcli, "~> 0.3"}-- Markdown-to-ANSI renderer and Makeup token-to-ANSI formatter.{:makeup, "~> 1.2"}-- explicit soMakeup.Registryis available at runtime.{:makeup_cure, "~> 0.1"}-- Cure language lexer for Makeup, maintained alongside the compiler.
None of these pull in further transitive deps, and none are required at compile time. They live on the runtime load path alongside the compiler itself.
Design choices worth calling out
Split into many small modules. The REPL is ~2 KLOC of Elixir
split across Terminal, LineEditor, History, Search,
Highlight, Theme, Completer, Render, Docs, Markdown.
Every module is individually testable and has a single
responsibility. The Cure.REPL module itself is just the event
loop.
No in-buffer multi-line. Multi-line input is still detected by
bracket balancing and ;;; the raw-mode editor operates on a single
visual line at a time. Multi-line buffers print one line per
submitted chunk when re-rendered by history. This keeps the raw-mode
renderer's state space tiny: a buffer string, a cursor offset, and a
column.
Graphemes, not bytes. All cursor arithmetic is on Unicode
graphemes, via String.Grapheme and friends. A CJK character or an
emoji counts as one "cursor step", and the renderer accounts for
East Asian width when placing the cursor.
Logger quieted during raw mode. Logger output interleaves badly
with raw-mode redraws -- a stray [warning] ... line from a
dependency (MDEx NIF load, telemetry, etc.) can overwrite the
prompt. The REPL raises Logger.primary_config to :error for the
duration of the session and restores it on exit.
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.24.0
./cure repl # the real thing
Once you are in, :help renders the full key-bindings table and
meta-command reference; :theme light switches palettes;
:mode vi flips to the vi subset; Ctrl+R opens reverse search.
The repository is at github.com/am-kantox/cure-lang. v0.23.0 shipped the ecosystem; v0.24.0 ships the loop you actually use to explore it.
What's next (v0.25.0)
With the interactive cycle caught up, v0.25.0 returns to the type
pipeline: monomorphisation of polymorphic functions at concrete call
sites, profile-guided optimisation wiring between Cure.Profiler and
the inliner / pattern-aware SMT encoder, broader IDE reach (Helix,
Zed, upgraded VS Code extension), and REPL-level hot reload that
recompiles-and-rebinds on every file save for long-running sessions.