← All posts

Cure v0.24.0 :: The REPL You Deserve

by Aleksei Matiushkin

release repl readline makeup marcli terminal

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+E or Home / 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. Esc enters normal mode; i / a / I / A go 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: foo followed by foo stores one entry. Non-consecutive duplicates are kept, because foo surrounded by unrelated commands is a real signal.
  • Up / Down step 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 when NO_COLOR is set, when stdout is not a tty, or when TERM=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 pressing Tab cycles through every registered command.
  • File paths. Inside :load, :save, :edit, completes against the filesystem.
  • Loaded modules. Inside :use and :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 last n entries (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] -- run expr n times, 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 so Makeup.Registry is 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.