The model
Two independent halves, usable together or apart:
- Session state. A
Sessionaccumulates lines into an entry and calls your pipeline — a closure that lexes, parses, and evaluates — with anInputover the whole entry after each line. The pipeline answersStatus::Complete(value)orStatus::Incomplete; the session answers with aFeed, and on completion commits the entry and hands back anEntrywith the value. - Line editing. An
Editorholds the line, the cursor, a kill buffer, and a bounded history. Your key handler maps keys toEditcommands; the editor applies them and reports whether to redraw.
What each part owns, and what it leaves to you:
| repl-lang | Your code | |
|---|---|---|
| Completeness | Re-running the pipeline per line; is_incomplete over parser errors |
The parser |
| Positions | One global span space across all entries; Input::cursor starts at the right offset |
Tokens and syntax built from those spans |
| Diagnostics | Every entry stored as <repl:N> for rendering |
Deciding what is wrong |
| Editing | Line, cursor, word motion, kill/yank, history | Reading keys, drawing the screen |
| State | Entry numbering, pending text, size limit | Interpreter state, captured by the pipeline closure |
Installation
[]
= "1"
= "1" # rendering diagnostics against Session::sources
Or from the terminal:
MSRV: Rust 1.85 (Rust 2024 edition).
Quick start
A session over a fixed script with a pipeline that keeps reading until parentheses balance. In a real REPL the lines come from a terminal and the pipeline is your parser.
use ;
let mut session = new;
let mut transcript = Vecnew;
for line in
assert_eq!;
assert_eq!;
# Ok::
Using parser errors to detect continuation
Input::is_incomplete turns any parser that reports a missing token at the end of input into a continuation detector. Errors in the middle of the input mean the entry is wrong, not unfinished, so it completes and the errors are reported. Use it when the parser's positions are global (lexed with Input::cursor). A parser run on Input::text alone, such as a lang-forge language, reports positions from the start of the entry; use Input::is_incomplete_relative for it. The same rule applies to both; see docs/API.md.
use ;
use ;
// Stand-in for a parser: "expected expression" at the end when the text ends
// with an operator, "unexpected `)`" where a stray parenthesis appears.
let mut session = new;
let mut run = ;
assert_eq!; // error at the end: keep reading
assert!;
assert!; // error mid-line: report it
# Ok::
Rendering diagnostics across entries
Every committed entry lives in Session::sources, a SourceMap that diag_lang renders against. Spans the pipeline records are global from the start, so a diagnostic can point into any entry, including earlier ones.
use ;
use ;
let mut session = new;
let mut first_definition = None;
for line in
# Ok::
Line editing
The editor never touches a terminal. Map key events to Edit commands, redraw from line() and column(), and call submit() on Enter.
use ;
let mut editor = new;
editor.insert;
assert!; // Ctrl-W
editor.insert;
assert_eq!;
assert!; // Ctrl-A
assert!; // Alt-F: after "let"
assert_eq!;
assert_eq!;
assert!; // Up: recall it
assert_eq!;
// Columns, not bytes, for placing the terminal cursor.
let mut wide = new;
wide.insert;
assert_eq!;
assert_eq!;
Examples
Three runnable examples ship in examples/. Two share a small calculator language in examples/common: a lexer built on Input::cursor, a parser-lang Pratt grammar, Input::is_incomplete for continuation lines, and bindings that remember where they were defined. That module is the template for wiring repl-lang into a real language.
- Calculator REPL — an interactive loop over standard input with
letbindings, multi-line entries, rendered diagnostics, and a:historycommand. It also runs non-interactively.| - Notebook — a scripted session printed as
In [n]/Out[n]cells: multi-line entries, blank lines that never consume a number, an interrupt, a redefinition error that underlines the original definition in an earlier cell, and the entry-size limit. - Line editing — replays a keystroke script through the editor and draws each state with a caret under the cursor column: word motion, kill and yank, a wide-character line, a combining accent kept with its letter, and history browsing that restores the half-typed line.
Performance
Feeding a line appends to one reused buffer and calls the pipeline; committing an entry makes one exact-size copy of its text and its name into the source map. The editor keeps its line, kill buffer, and every history slot between uses — once the history is full, submitting a line recycles the oldest slot instead of allocating. Display widths and word classes take an ASCII fast path before consulting the Unicode tables.
Measured with the benchmarks in benches/ (x86_64, Rust stable, release profile):
| Benchmark | What it measures | Windows | Linux (WSL2) |
|---|---|---|---|
session/entries/100 |
100 single-line entries fed, checked, and committed. | ~6.5 µs | ~3.8 µs |
session/continuation/lines=8 |
One entry over 8 lines, re-checked after each. | ~0.47 µs | ~0.27 µs |
session/feed_incomplete/errors=4 |
Feed a line, run is_incomplete over 4 errors, cancel. |
~81 ns | ~48 ns |
editor/type/80 |
Typing an 80-column line key by key. | ~0.36 µs | ~0.23 µs |
editor/word_motion/line=800 |
Word-left to the start and word-right to the end of a long line. | ~7.4 µs | ~4.9 µs |
editor/submit/history_full |
Submitting into a full 100-entry history ring. | ~32 ns | ~23 ns |
editor/column/line=900 |
Cursor column at the end of a long mixed-width line. | ~1.7 µs | ~1.4 µs |
Per entry that is roughly 40–65 ns to feed, check, and commit a line, and 3–5 ns per keystroke.
Run them yourself:
Criterion writes per-benchmark reports to target/criterion/. Numbers vary by CPU; use the trend across runs, not a single absolute.
Design notes
- The parser is the oracle. Completeness is a property of the language, so the session asks the language. There is no built-in bracket counter to disagree with your grammar about strings, comments, or significant indentation; a pipeline that already parses gets continuation lines from
is_incompletein one call. - Check and work are the same call.
Status::Completecarries the pipeline's result, so a finished entry is parsed once, not once to test and again to run. - Spans are final when they are made. The pipeline learns its entry's global base before the entry is committed, and the session guarantees the entry lands exactly there. Nothing is rebased afterwards.
- Errors never echo input.
SessionErrorreports sizes, not text, so a refused line cannot leak into a log through the error value. - The terminal stays outside. Raw mode, key decoding, and drawing differ on every platform; editing logic does not. Keeping them apart makes the editor identical everywhere and testable without a terminal.
- Display units, not code points. The cursor moves over a visible character together with any combining marks attached to it, so it never separates a letter from its accent, and
column()counts wide characters as two terminal columns. - History keeps an entry's shape. A saved multi-line entry comes back with its newlines and tabs; every other control character, escape included, is removed before it can reach the line.
Testing
The suite runs on Windows, Linux (WSL2 Ubuntu), and macOS through the CI matrix, on stable and the 1.85 MSRV:
The property tests in tests/proptests.rs hold both halves to reference models. Arbitrary sequences of edits, pastes, submits, and history operations — over text mixing ASCII, accents, combining marks, joiners, wide characters, emoji, and control characters — must leave the editor in exactly the state of a model that works on a Vec<char>. Arbitrary lines with arbitrary terminators and size limits must leave the session agreeing with a plain string accumulator on every pending buffer, entry number, base offset, span, and refusal. Every rust example in this README and in docs/API.md is compiled and run as a doctest, so the published examples cannot drift from the API.
Cross-platform support
- Linux (x86_64, aarch64)
- macOS (x86_64, Apple Silicon)
- Windows (x86_64)
The crate uses no operating-system facilities and no platform-specific code; behaviour is identical on every target. Line terminators are normalised on input, so "\r\n" from a Windows console and "\n" from a Unix pipe produce the same entry.
Contributing
See REPS.md for the engineering standards every change is held to, and dev/ROADMAP.md for the plan to 1.0. Before a PR: cargo fmt --all, cargo clippy --all-targets --all-features -- -D warnings, and cargo test --all-features must be clean.