repl-lang 1.1.1

Read-eval-print loop scaffolding: line editing, session state, and incremental input feed.
Documentation
# repl-lang v0.2.0 — The Session and the Editor

**The core, and the hard part of the roadmap.** v0.2.0 turns the scaffold into
working REPL scaffolding. A `Session` decides when typed input forms a complete
entry by asking the language's own pipeline, keeps every entry in one source
map so a diagnostic can point anywhere in the session, and hands each finished
entry's result straight back. An `Editor` handles the line being typed — motion,
words, kill and yank, history — without touching a terminal. The surface is
small: two engines, the values they exchange, and one error type.

## What is repl-lang?

The state of a read-eval-print loop, with the language and the terminal left
out. A language plugs in its pipeline — lex, parse, evaluate — as a closure;
a host plugs in its line source, whether a raw-mode terminal, `stdin`, or a
script. repl-lang supplies what sits between them: continuation-line detection,
entry numbering, a session-wide source map for diagnostics, and line editing.
It is one of the FEAT-tier crates of the `-lang` language-construction family,
built on `lexer-lang`, `diag-lang`, `source-lang`, and `unicode-lang`.

## What's new in 0.2.0

### `Session` — incremental feed into the pipeline

`Session::feed` appends a line to the pending entry and calls the pipeline with
the whole entry so far. The pipeline answers `Status::Complete(value)` or
`Status::Incomplete`; the session answers with a `Feed`. Blank lines with
nothing pending are skipped without calling the pipeline, line terminators are
normalised, and a finished entry is committed as `<repl:N>`.

```rust
use repl_lang::{Feed, Session, Status};

let mut session = Session::new();
let pipeline = |input: repl_lang::Input<'_>| {
    let open = input.text().matches('(').count();
    let close = input.text().matches(')').count();
    if open > close { Status::Incomplete } else { Status::Complete(input.text().lines().count()) }
};

assert_eq!(session.feed("(define (square x)", pipeline)?, Feed::Incomplete);
assert!(matches!(session.feed("  (* x x))", pipeline)?, Feed::Complete { value: 2, .. }));
assert_eq!(session.entry(1).map(|e| e.name()), Some("<repl:1>"));
# Ok::<(), repl_lang::SessionError>(())
```

Because `Status::Complete` carries the pipeline's result, the completeness
check and the real work are one call: a finished entry is parsed once.

### `Input::is_incomplete` — the parser decides

Only the language knows whether `let f = fn(x) {` is finished. A parser that
runs out of input reports the missing piece at the end of the input;
`Input::is_incomplete` recognises exactly that — at least one error, every one
at or after the last non-whitespace byte — and the pipeline answers
`Incomplete`. An error anywhere else means the input is wrong, so the entry
completes and the errors are shown. This matches how `parser-lang` reports
end-of-input errors and works with any parser that does the same.

```rust
use diag_lang::{Diagnostic, Label, Severity};
use repl_lang::{Session, Span, Status};

let at = |pos: u32| Diagnostic::new(Severity::Error, "expected expression", Label::new(Span::empty(pos), ""));
let mut session = Session::new();
session.feed("1 + (2 *", |input| {
    assert!(input.is_incomplete(&[at(8)]));          // at the end: keep reading
    assert!(!input.is_incomplete(&[at(2), at(8)]));  // mid-line: report it
    Status::Complete(())
})?;
# Ok::<(), repl_lang::SessionError>(())
```

### One source map for the whole session

Every committed entry is a source in `Session::sources`, laid out after the
entries before it. The pipeline learns its entry's global position *before*
commit — `Input::base`, `Input::span`, and `Input::cursor`, a `lexer-lang`
cursor already placed there — so every span it records is final when made. A
diagnostic in entry 6 can underline a definition from entry 1, and
`diag_lang::Renderer` names both:

```text
error: `rate` is already defined
 --> <repl:6>:1:5
  |
1 | let rate = 9
  |     ^^^^ defined again here
  |
 --> <repl:1>:1:5
  |
1 | let rate = 7  # percent
  |     ---- first defined here
  |
 = help: bindings are immutable; choose another name
```

### `Editor` and `Edit` — line editing without a terminal

The host maps key events to `Edit` commands; the editor applies them and
reports whether to redraw. Motion and deletion work in display units — a
visible character plus any combining marks attached to it — so the cursor never
separates a letter from its accent. Words are `XID_Continue` runs. `column()`
gives the terminal column, counting wide characters as two. The history skips
blanks and repeats, preserves the half-typed line while browsing, and can be
saved and restored with `history()` and `add_history()`.

```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.submit(), "let total = cost * 2");
assert!(editor.apply(Edit::HistoryPrev));    // Up
assert_eq!(editor.line(), "let total = cost * 2");
```

The line, the kill buffer, and every history slot are reused; once the history
is full, a submit recycles the oldest slot, so steady-state editing does not
allocate. ASCII takes a fast path past the Unicode tables, which cut word
motion by 68% and column computation by 64% against the first measurement.

### Bounded and safe on hostile input

`Session::with_limit` (1 MiB by default) caps one entry; a line that would
exceed it returns `SessionError::TooLong`, the pending entry is dropped, and
the pipeline is not called. The editor refuses control characters on every
path into the line, including history restored from disk. Error values report
sizes, never the refused text.

### Examples, benchmarks, and reference models

Three runnable examples ship: `calc`, an interactive calculator REPL over
standard input; `notebook`, a scripted session printed as numbered cells,
including the cross-entry diagnostic above; and `line_editing`, a keystroke
script drawn with a caret under the cursor column. `calc` and `notebook` share a
calculator front end on `lexer-lang` and `parser-lang` that serves as the
template for wiring a real language.

The property tests drive the editor through arbitrary edits over text mixing
ASCII, accents, combining marks, joiners, wide characters, emoji, and control
characters, and require it to match a `Vec<char>` reference model after every
step. They found one real defect before release: history restored with
`add_history` could carry a control character into the line. It is fixed, and
the invariant is now pinned by a unit test. The session is held to a plain
string accumulator over arbitrary lines, terminators, and limits.

### Dependency wiring

`lexer-lang`, `diag-lang`, and `source-lang` are wired, plus `unicode-lang` for
the editor. `parser-lang` is deliberately a dev-dependency only: a REPL meets a
parser through its diagnostics, which are `diag-lang` types, so nothing in the
API names a parser type. The reasoning is recorded in `dev/ROADMAP.md`. The
scaffold's unused `serde` feature and `loom` dev-dependency were removed.

### Scaffold fixes

`Cargo.toml` had unquoted `keywords` and `categories`, so the manifest did not
parse; `clippy.toml` declared MSRV 1.87 against the crate's 1.85; two docs
carried byte-order marks; `deny.toml` named another project; and the README
linked a `dev/DIRECTIVES.md` that does not exist. All are fixed.

## Breaking changes

**None.** v0.1.0 exposed no public API; everything in 0.2.0 is new surface. The
removed `serde` feature had no code behind it.

## Verification

Run on Windows x86_64 and Linux (WSL2 Ubuntu), Rust stable and the 1.85 MSRV;
the same commands run in the configured CI matrix:

```bash
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
cargo clippy --all-targets --all-features -- -D warnings
cargo clippy --lib --no-default-features -- -D warnings
cargo test
cargo test --all-features
cargo test --no-default-features
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features
cargo build --examples
cargo +1.85 test --all-features
cargo +1.85 clippy --all-targets --all-features -- -D warnings
cargo +1.85 fmt --all -- --check
cargo deny check
cargo audit
```

All green. Counts at this tag:

- 62 unit + 19 integration + 4 property + 99 doctests. The doctests include
  every `rust` example in `README.md` and `docs/API.md`, wired into
  `cargo test` so the published examples cannot drift from the API.

Benchmark baselines (Windows x86_64, release profile):
`session/entries/100` ~6.8 µs (≈ 68 ns per entry, including the commit into the
source map); `session/continuation/lines=8` ~0.54 µs;
`session/feed_incomplete/errors=4` ~82 ns; `editor/type/80` ~0.30 µs;
`editor/word_motion/line=800` ~6.6 µs; `editor/submit/history_full` ~27 ns; and
`editor/column/line=900` ~1.7 µs.

## What's next

- **1.0.0 — API freeze.** Freeze the 0.2.0 surface as the 1.0 contract, mark
  `docs/API.md` stable with a SemVer promise, and confirm the full test and
  benchmark suite on all three platforms.

## Installation

```toml
[dependencies]
repl-lang = "0.2"
diag-lang = "1"
```

MSRV: Rust 1.85.

## Documentation

- [README]https://github.com/jamesgober/repl-lang/blob/main/README.md
- [API Reference]https://github.com/jamesgober/repl-lang/blob/main/docs/API.md
- [CHANGELOG]https://github.com/jamesgober/repl-lang/blob/main/CHANGELOG.md

---

**Full diff:** [`v0.1.0...v0.2.0`](https://github.com/jamesgober/repl-lang/compare/v0.1.0...v0.2.0).
**Changelog:** [`CHANGELOG.md`](https://github.com/jamesgober/repl-lang/blob/main/CHANGELOG.md#020---2026-07-08).