<h1 align="center">
<img width="99" alt="Rust logo" src="https://raw.githubusercontent.com/jamesgober/rust-collection/72baabd71f00e14aa9184efcb16fa3deddda3a0a/assets/rust-logo.svg">
<br>
<b>repl-lang</b>
<br>
<sub><sup>REPL SCAFFOLDING</sup></sub>
</h1>
<div align="center">
<a href="https://crates.io/crates/repl-lang"><img alt="Crates.io" src="https://img.shields.io/crates/v/repl-lang"></a>
<a href="https://crates.io/crates/repl-lang"><img alt="Downloads" src="https://img.shields.io/crates/d/repl-lang?color=%230099ff"></a>
<a href="https://docs.rs/repl-lang"><img alt="docs.rs" src="https://img.shields.io/docsrs/repl-lang"></a>
<a href="https://github.com/jamesgober/repl-lang/actions"><img alt="CI" src="https://github.com/jamesgober/repl-lang/actions/workflows/ci.yml/badge.svg"></a>
<a href="https://github.com/rust-lang/rfcs/blob/master/text/2495-min-rust-version.md"><img alt="MSRV" src="https://img.shields.io/badge/MSRV-1.85%2B-blue"></a>
</div>
<br>
<div align="left">
<p>
<strong>repl-lang</strong> is the loop state of a read-eval-print loop, with the language and the terminal left out. It decides when typed input forms a complete entry, keeps every entry of the session so diagnostics can point back into them, and edits the line being typed — the parts every language REPL needs and none should have to write twice.
</p>
<p>
The hard question in a REPL is <em>is the user finished?</em> After <code>let area = width * (3 +</code> the answer is plainly no, and the prompt should continue rather than report a syntax error. repl-lang answers it with the language's own parser: a <a href="./docs/API.md#session"><code>Session</code></a> runs your pipeline on the entry after every line, and <a href="./docs/API.md#inputis_incomplete"><code>Input::is_incomplete</code></a> recognises the case where every parse error sits at the very end of the input — the parser ran out of text, so ask for another line. Each finished entry becomes a source named <code><repl:N></code> in one shared source map, so an error in entry 7 can underline a definition from entry 2. A terminal-agnostic <a href="./docs/API.md#editor"><code>Editor</code></a> handles cursor motion, word motion, kill and yank, and history, driven by commands your key handler produces.
</p>
<br>
<hr>
<p>
<strong>MSRV is 1.85+</strong> (Rust 2024 edition). <code>no_std</code>-compatible (needs only <code>alloc</code>), <code>#![forbid(unsafe_code)]</code>, built on <a href="https://crates.io/crates/lexer-lang"><code>lexer-lang</code></a>, <a href="https://crates.io/crates/diag-lang"><code>diag-lang</code></a>, <a href="https://crates.io/crates/source-lang"><code>source-lang</code></a>, and <a href="https://crates.io/crates/unicode-lang"><code>unicode-lang</code></a>.
</p>
<blockquote>
<strong>1.0.0 is the API freeze.</strong> The public surface is stable and follows Semantic Versioning — no breaking changes before <code>2.0</code>. See <a href="./docs/API.md#stability"><code>docs/API.md</code></a> for the frozen-surface list and the SemVer promise, and <a href="./CHANGELOG.md"><code>CHANGELOG.md</code></a>.
</blockquote>
</div>
<hr>
<br>
## The model
Two independent halves, usable together or apart:
- **Session state.** A **[`Session`](./docs/API.md#session)** accumulates lines into an entry and calls your pipeline — a closure that lexes, parses, and evaluates — with an **[`Input`](./docs/API.md#input)** over the whole entry after each line. The pipeline answers **[`Status`](./docs/API.md#status)**`::Complete(value)` or `Status::Incomplete`; the session answers with a **[`Feed`](./docs/API.md#feed)**, and on completion commits the entry and hands back an **[`Entry`](./docs/API.md#entry)** with the value.
- **Line editing.** An **[`Editor`](./docs/API.md#editor)** holds the line, the cursor, a kill buffer, and a bounded history. Your key handler maps keys to **[`Edit`](./docs/API.md#edit)** commands; the editor applies them and reports whether to redraw.
<br>
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 |
<hr>
<br>
## Installation
```toml
[dependencies]
repl-lang = "1"
diag-lang = "1" # rendering diagnostics against Session::sources
```
Or from the terminal:
```bash
cargo add repl-lang diag-lang
```
MSRV: Rust 1.85 (Rust 2024 edition).
<hr>
<br>
## 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.
```rust
use repl_lang::{Feed, Session, Status};
let mut session = Session::new();
let mut transcript = Vec::new();
for line in ["(define (square x)", " (* x x))", "", "(square 12)"] {
let prompt = if session.is_pending() { "..." } else { ">>>" };
let feed = session.feed(line, |input| {
let depth: i32 = input.text().chars().map(|c| match c {
'(' => 1,
')' => -1,
_ => 0,
}).sum();
if depth > 0 { Status::Incomplete } else { Status::Complete(input.text().lines().count()) }
})?;
match feed {
Feed::Complete { entry, value } => {
transcript.push(format!("{prompt} {line} [entry {} spans {value} lines]", entry.number()));
}
Feed::Incomplete => transcript.push(format!("{prompt} {line}")),
Feed::Empty => transcript.push(prompt.to_owned()),
}
}
assert_eq!(transcript, [
">>> (define (square x)",
"... (* x x)) [entry 1 spans 2 lines]",
">>>",
">>> (square 12) [entry 2 spans 1 lines]",
]);
assert_eq!(session.entry(1).map(|e| e.name()), Some("<repl:1>"));
# Ok::<(), repl_lang::SessionError>(())
```
<br>
### 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.
```rust
use diag_lang::{Diagnostic, Label, Severity};
use repl_lang::{Feed, Session, Span, Status};
// Stand-in for a parser: "expected expression" at the end when the text ends
// with an operator, "unexpected `)`" where a stray parenthesis appears.
fn parse(text: &str, base: u32) -> Vec<Diagnostic> {
let trimmed = text.trim_end();
let error = |at: usize, msg: &str| {
let at = base + at as u32;
Diagnostic::new(Severity::Error, msg.to_owned(), Label::new(Span::new(at, at), "here"))
};
if let Some(at) = trimmed.find(')') {
vec![error(at, "unexpected `)`")]
} else if trimmed.ends_with(['+', '*', '=']) {
vec![error(trimmed.len(), "expected expression")]
} else {
Vec::new()
}
}
let mut session = Session::new();
let mut run = |line: &str| {
session.feed(line, |input| {
let errors = parse(input.text(), input.base().to_u32());
if input.is_incomplete(&errors) { Status::Incomplete } else { Status::Complete(errors.len()) }
})
};
assert_eq!(run("let x = 1 +")?, Feed::Incomplete); // error at the end: keep reading
assert!(matches!(run(" 2")?, Feed::Complete { value: 0, .. }));
assert!(matches!(run("1 + ) 2")?, Feed::Complete { value: 1, .. })); // error mid-line: report it
# Ok::<(), repl_lang::SessionError>(())
```
<br>
### 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.
```rust
use diag_lang::{Diagnostic, Label, Renderer, Severity};
use repl_lang::{Feed, Session, Span, Status};
let mut session = Session::new();
let mut first_definition = None;
for line in ["let limit = 10", "let limit = 20"] {
let feed = session.feed(line, |input| {
let name = Span::new(input.base().to_u32() + 4, input.base().to_u32() + 9);
match first_definition {
None => {
first_definition = Some(name);
Status::Complete(None)
}
Some(first) => Status::Complete(Some(
Diagnostic::new(Severity::Error, "`limit` is already defined", Label::new(name, "redefined"))
.with_secondary(Label::new(first, "first defined here")),
)),
}
})?;
if let Feed::Complete { value: Some(diagnostic), .. } = feed {
let text = Renderer::new().render(&diagnostic, session.sources());
assert!(text.contains("<repl:2>:1:5"));
assert!(text.contains("<repl:1>:1:5"));
assert!(text.contains("first defined here"));
}
}
# Ok::<(), Box<dyn std::error::Error>>(())
```
<br>
### Line editing
The editor never touches a terminal. Map key events to `Edit` commands, redraw from `line()` and `column()`, and call `submit()` on Enter.
```rust
use repl_lang::{Edit, Editor};
let mut editor = Editor::new();
editor.insert("let total = price");
assert!(editor.apply(Edit::KillWordLeft)); // Ctrl-W
editor.insert("cost * 2");
assert_eq!(editor.line(), "let total = cost * 2");
assert!(editor.apply(Edit::Home)); // Ctrl-A
assert!(editor.apply(Edit::WordRight)); // Alt-F: after "let"
assert_eq!(editor.cursor(), 3);
assert_eq!(editor.submit(), "let total = cost * 2");
assert!(editor.apply(Edit::HistoryPrev)); // Up: recall it
assert_eq!(editor.line(), "let total = cost * 2");
// Columns, not bytes, for placing the terminal cursor.
let mut wide = Editor::new();
wide.insert("名前 = 1");
assert_eq!(wide.cursor(), 10);
assert_eq!(wide.column(), 8);
```
<hr>
<br>
## Examples
Three runnable examples ship in [`examples/`](./examples). Two share a small calculator language in [`examples/common`](./examples/common/mod.rs): a lexer built on `Input::cursor`, a [`parser-lang`](https://crates.io/crates/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 `let` bindings, multi-line entries, rendered diagnostics, and a `:history` command. It also runs non-interactively.
```bash
cargo run --example calc
echo "let x = (1 +
2) * 7" | cargo run --example calc
```
- **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.
```bash
cargo run --example notebook
```
- **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.
```bash
cargo run --example line_editing
```
<hr>
<br>
## 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/`](./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:
```bash
cargo bench --bench bench
```
Criterion writes per-benchmark reports to `target/criterion/`. Numbers vary by CPU; use the trend across runs, not a single absolute.
<hr>
<br>
## 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_incomplete` in one call.
- **Check and work are the same call.** `Status::Complete` carries 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.** `SessionError` reports 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.
<hr>
<br>
## Testing
The suite runs on Windows, Linux (WSL2 Ubuntu), and macOS through the CI matrix, on stable and the 1.85 MSRV:
```bash
cargo test # unit + integration + property + doctests
cargo clippy --all-targets --all-features -- -D warnings
cargo bench --bench bench
```
The property tests in [`tests/proptests.rs`](./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`](./docs/API.md) is compiled and run as a doctest, so the published examples cannot drift from the API.
<hr>
<br>
## 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.
<hr>
<br>
## Contributing
See [`REPS.md`](./REPS.md) for the engineering standards every change is held to, and [`dev/ROADMAP.md`](./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.
<br>
<div id="license">
<h2>License</h2>
<p>Licensed under either of</p>
<ul>
<li><b>Apache License, Version 2.0</b> — <a href="./LICENSE-APACHE">LICENSE-APACHE</a></li>
<li><b>MIT License</b> — <a href="./LICENSE-MIT">LICENSE-MIT</a></li>
</ul>
<p>at your option.</p>
</div>
<div align="center">
<h2></h2>
<sup>COPYRIGHT <small>©</small> 2026 <strong>James Gober <me@jamesgober.com>.</strong></sup>
</div>