<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. 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](https://crates.io/crates/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`](./docs/API.md#deciding-completeness).
```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.
- **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.
<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>