feat(edit): auto-indent on newline (Arc 2)

RET now runs edit.newline-and-indent (builtin/runtime/indent.lua):
one insert/replace of "\n" plus the current line's leading whitespace,
copied verbatim and clipped at the split point (Q#AI3). Region RET
stays a single Replace (CUA type-over, one undo step, one CRDT op);
the selection clears after every successful edit (Q#AI4). Fix-up is
snapshot-guarded against context-switching intercepts and repairs the
cursor by right-gravity translation through the effective edit
(Q#AI5). buffer.newline remains the plain-newline escape hatch.

GPU (Q#AI1/Q#AI6): plain Enter is no longer optimistic-eligible --
its classifier arm's premise (byte-identical to a self-insert) died
with the new binding. Enter round-trips like the TUI, which also
makes global and buffer-local RET rebindings (buffer-list visit)
reachable from the GPU frontend.

Substrate fixes that RET would otherwise ship on top of:

- Q#AI8 search staleness: notify_buffer_edit now marks matches stale
  and right-gravity-translates the live session origin, matching
  apply_active_edit; SearchStore::step and search_match_summary fail
  closed while stale (a live search un-sticks on the next pattern
  keystroke, since set() clears staleness).
- Q#AI9 empty selections: insert_char reports success and the
  no-region arm of insert_char_over_region clears a lingering anchor
  only on Ok -- ordinary typing no longer type-overs its own previous
  keystroke after S-Left at BOF, and a rejected insert mutates no
  state.

Acceptance: tests/auto_indent_acceptance.rs (20 dispatch-driven
cases), tests/auto_indent_crdt_acceptance.rs (pending optimistic
input then round-tripped Enter converges on the source replica),
flipped GPU classifier test, and lib tests for the store, core, and
dispatch seams.

Framing: docs/auto-indent-framing.md (five review rounds).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATiKMwJ4864d82D39EvsU6
This commit is contained in:
Levi Neuwirth 2026-07-10 12:11:05 -04:00
parent f4e3d9d85a
commit 7b5365cfbf
9 changed files with 1080 additions and 51 deletions

View File

@ -49,7 +49,7 @@ bind("C-v", "cursor.page-down")
bind("BS", "buffer.delete-backward")
bind("DEL", "buffer.delete-forward")
bind("C-d", "buffer.delete-forward")
bind("RET", "buffer.newline")
bind("RET", "edit.newline-and-indent")
bind("TAB", "buffer.tab")
-- Incremental search ---------------------------------------------------------

119
builtin/runtime/indent.lua Normal file
View File

@ -0,0 +1,119 @@
-- indent.lua --- auto-indent on newline (Arc 2).
--
-- RET (`edit.newline-and-indent`) inserts a newline plus the current
-- line's leading whitespace, verbatim, clipped at the split point
-- (Q#AI3): copying bytes is the only policy that cannot be wrong about
-- tabs-vs-spaces, and the clip keeps a split inside the indent from
-- double-indenting the carried text. The whole thing is ONE edit — one
-- undo step, one CRDT op. With a region it is one `buf:replace` (CUA
-- type-over, Q#AI4); the selection is cleared after every successful
-- edit, region or not (a zero-length selection would otherwise go live
-- the moment the cursor moves off the anchor). `buffer.newline` stays
-- bound-free as the plain-newline escape hatch (Q#AI2).
--
-- Framing: docs/auto-indent-framing.md.
pmacs.indent = pmacs.indent or {}
local ed = pmacs.editor
-- Start of the line containing `pos`: chunked backward scan for the
-- last newline strictly before it (comment.lua's scan — there is no
-- line-access API on buffers; giant lines stay safe).
local function line_start_before(buf, pos)
local p = pos
while p > 0 do
local from = math.max(0, p - 4096)
local chunk = buf:slice(from, p)
local nl = chunk:match("()\n[^\n]*$")
if nl then return from + nl end
p = from
end
return 0
end
-- The indent to carry over a split at `split` (Q#AI3):
-- bytes[line_start .. min(first_non_ws, split)]. Slicing the line head
-- up to the split point and taking its leading `[ \t]*` run IS that
-- clip — the match cannot run past the slice's end. `[ \t]` rather
-- than `%s` so a CR on a CRLF line never counts as indent.
local function indent_before(buf, split)
local start = line_start_before(buf, split)
if split <= start then return "" end
return buf:slice(start, split):match("^[ \t]*")
end
-- Right-gravity translation of `pos` through the effective edit
-- (Q#AI5; the daemon optimistic-arm shape). `estop` is the PRE-edit
-- end of the replaced range; an insert has estart == estop.
local function translate(pos, estart, estop, einserted)
if pos < estart then return pos end
if pos > estop then return pos - (estop - estart) + einserted end
return estart + einserted
end
-- edit.newline-and-indent body.
function pmacs.indent.newline()
local buf = pmacs.window.buffer()
if not buf then
ed.set_status("no buffer")
return false
end
-- Snapshot the context BEFORE the edit (Q#AI5): intercepts run with
-- the registry borrow released and may switch window or buffer; the
-- fix-up below must never touch whatever is active afterwards.
local win0 = pmacs.window.current()
local cursor0 = ed.cursor()
local region = ed.region()
local has_region = region ~= nil and region["end"] > region.start
local rstart, rstop
if has_region then
rstart, rstop = region.start, region["end"]
else
rstart, rstop = cursor0, cursor0
end
local text = "\n" .. indent_before(buf, rstart)
-- One edit = one undo step, one CRDT op. Same intercept discipline
-- as killring/comment: a rejection reports rather than throws and
-- leaves no state behind.
local ok, estart, estop, einserted = pcall(function()
if has_region then
return buf:replace(rstart, rstop, text)
end
return buf:insert(rstart, text)
end)
if not ok then
ed.set_status("newline-and-indent rejected by buffer intercept")
return false
end
-- Context guard (Q#AI5): fix up only the window that made the edit.
if pmacs.window.current() ~= win0 or pmacs.window.buffer() ~= buf then
ed.set_status("newline-and-indent: context changed during edit")
return false
end
-- A deviating effective edit means an intercept rewrote it — the
-- interceptor's positional result stands (M6.4: kind and payload
-- are immutable). Cursor repair uses ONE formula for the clean and
-- transformed paths alike: translate the pre-edit cursor through
-- the effective edit, then goto_byte (which clamps). The clean
-- insert-at-cursor case lands at estart + einserted — right after
-- the carried indent.
local deviated = estart ~= rstart or estop ~= rstop or einserted ~= #text
if deviated then
ed.set_status("newline-and-indent altered by buffer intercept")
end
ed.goto_byte(translate(cursor0, estart, estop, einserted))
ed.clear_selection()
return not deviated
end
pmacs.command.define {
name = "edit.newline-and-indent",
description = "Insert a newline carrying the current line's indentation.",
fn = function() pmacs.indent.newline() end,
}

View File

@ -1,7 +1,7 @@
# Agent handoff — cross-machine continuity
**Last updated: 2026-07-10, on the desktop, by the session that shipped
PR #107.** This file is the bridge between development machines. If you
**Last updated: 2026-07-10, on the laptop, by the auto-indent
session.** This file is the bridge between development machines. If you
are an agent reading this on a fresh clone: this document plus the
`docs/*-framing.md` files ARE your memory. Read this fully before
taking on work, seed your persistent memory from it, and **update this
@ -10,20 +10,23 @@ next machine reads it the way you just did.
## 1. Where the project stands (2026-07-10)
- `main` @ `2dde4b8`, protocol **v15** (`SUPPORTED=[6..15]`).
- **PR #107 OPEN**: comment/uncomment toggle on `M-;` (Arc 2). Awaiting
the user's review findings. If it's merged by the time you read this,
Arc 2 has only auto-indent and auto-pairing left. Check
`gh pr list --state open` first thing.
- `main` @ `efa41cb`, protocol **v15** (`SUPPORTED=[6..15]`).
- **Auto-indent on newline (Arc 2) in flight on this branch**
framing `docs/auto-indent-framing.md` went through five review
rounds before approval. RET now binds `edit.newline-and-indent`;
plain Enter is no longer GPU-optimistic (round-trips like the TUI).
Rode along: Q#AI8 search-staleness substrate fix (mark stale in
`notify_buffer_edit`, fail-closed step/summary, live-origin
translation) and Q#AI9 empty-selection fix (`insert_char` reports
success; the no-region arm clears a lingering anchor only on Ok).
- Roadmap: `docs/roadmap-2026-07.md` (ranked arcs). Position:
- **Arc 1 (LSP utility surface) COMPLETE** — completion popup
(#92/#93), panels/references/outline/hover (#94#96), plus
hardening follow-ups (#102, #105, #106).
- **Arc 2 (editing table stakes) NEARLY COMPLETE** — query-replace
(#97), kill ring + `M-y` (#103/#105/#106), comment-toggle (#107).
**Remaining: auto-indent on newline, then auto-pairing.** These are
the agreed next work items, in that order, each as its own small
framing + PR.
- **Arc 2 (editing table stakes)** — query-replace (#97), kill ring
+ `M-y` (#103/#105/#106), comment-toggle (#107), auto-indent (this
branch). **Remaining after this merges: auto-pairing**, as its own
small framing + PR.
- **Arc 3 (persistence) COMPLETE** — saveplace/recentf (#98),
desktop-save (#99), autosave/crash-recovery (#100), save-clobber
fix (#101).
@ -70,12 +73,15 @@ cargo test --workspace -- --skip basedpyright # full sweep
git diff --check
```
Machine-specific caveats that were true on the DESKTOP — re-verify on
this machine before trusting them:
Machine-specific caveats — re-verify on a machine you haven't used
before trusting them:
- **basedpyright**: the desktop's local binary is broken and HANGS the
`m4_5_basedpyright` tests — hence the `--skip`. If this machine has a
working basedpyright, the skip may be droppable (verify once).
- **basedpyright**: the DESKTOP's local binary is broken and HANGS the
`m4_5_basedpyright` tests — hence the `--skip` there. The LAPTOP has
a working basedpyright 1.39.9 (verified 2026-07-10: the m4_5 test
passes in 0.18s), so the skip is droppable on the laptop.
- **GPU on the laptop**: AMD Radeon 780M (RADV) — native Vulkan,
`PMACS_REQUIRE_GPU=1` works without lavapipe.
- **m8 daemon tests are FLAKY** (timing). A lone m8 failure → rerun
before investigating.
- **GPU tests** need a Vulkan device. `PMACS_REQUIRE_GPU=1` makes

View File

@ -1068,9 +1068,10 @@ impl ApplicationHandler<AppEvent> for App {
if let Err(e) = client.send_crdt_op(op.buffer_id, op.op) {
eprintln!("pmacs-gpu: send_crdt_op failed: {e}");
}
// An optimistic Enter near the bottom edge can
// scroll; re-declare the scoped viewport so
// the producer styles the newly visible lines.
// An optimistic edit near the viewport edge can
// scroll (a wrap-inducing insert, a Backspace
// above the top); re-declare the scoped viewport
// so the producer styles the newly visible lines.
if let Some(vp) = op.viewport
&& let Err(e) =
client.send_viewport(vp.buffer_id, vp.visible, vp.generation)
@ -1508,22 +1509,25 @@ fn optimistic_delete_range(
/// The literal text `key` inserts when handled optimistically, or
/// `None` for keys that must round-trip through the daemon.
///
/// `Enter` and `Tab` qualify alongside printable chars because their
/// default bindings (`buffer.newline` / `buffer.tab`) reduce to plain
/// `insert_char(10)` / `insert_char(9)` — byte-identical to a
/// self-insert, so the local application cannot diverge from what the
/// daemon will do with the same op. Two caveats are the caller's job:
/// `Tab` qualifies alongside printable chars because its default
/// binding (`buffer.tab`) reduces to a plain `insert_char(9)` —
/// byte-identical to a self-insert, so the local application cannot
/// diverge from what the daemon will do with the same op. `Enter`
/// does NOT: since Q#AI1 (docs/auto-indent-framing.md) RET binds
/// `edit.newline-and-indent`, whose inserted text depends on the
/// current line's indentation — and round-tripping is also what makes
/// RET rebindings (e.g. the buffer list's visit binding) reachable
/// from this frontend at all. Two caveats are the caller's job:
/// `optimistic_crdt_insert` round-trips when an own-window selection
/// is active (the daemon commands consume the region first — CUA
/// type-over — which a raw op can't), and modified variants (`S-RET`,
/// `C-TAB`, …) return `None` here: a keymap may bind them to anything.
/// type-over — which a raw op can't), and modified variants (`C-TAB`,
/// …) return `None` here: a keymap may bind them to anything.
fn optimistic_insert_text(key: ProtocolKey, mods: Modifiers, chbuf: &mut [u8; 4]) -> Option<&str> {
if !is_plain_text_modifiers(mods) {
return None;
}
match key {
ProtocolKey::Char(ch) if !ch.is_control() => Some(ch.encode_utf8(chbuf)),
ProtocolKey::Enter if mods.is_empty() => Some("\n"),
ProtocolKey::Tab if mods.is_empty() => Some("\t"),
_ => None,
}
@ -2195,10 +2199,10 @@ impl State {
self.optimistic_cursor_floor = Some(predicted);
self.optimistic_floor_set_at = Some(std::time::Instant::now());
// Follow the caret NOW rather than when the daemon's
// `CursorByte` confirms — an optimistic Enter on the bottom
// visible line (or a Backspace pulling the caret above the
// top) moves it outside the slice, and waiting a round trip
// to scroll reads as a hitch.
// `CursorByte` confirms — an optimistic edit on the bottom
// visible line that wraps (or a Backspace pulling the caret
// above the top) moves it outside the slice, and waiting a
// round trip to scroll reads as a hitch.
let viewport = if self.scroll_to_cursor() {
self.rebuild_lines_reusing_scroll();
self.viewport_send_if_changed(predicted.buffer_id)
@ -7055,7 +7059,7 @@ mod tests {
}
#[test]
fn optimistic_insert_text_covers_plain_chars_enter_and_tab() {
fn optimistic_insert_text_covers_plain_chars_and_tab_but_not_enter() {
let mut buf = [0u8; 4];
let none = Modifiers::NONE;
let shift = Modifiers::SHIFT;
@ -7072,8 +7076,10 @@ mod tests {
);
assert_eq!(
optimistic_insert_text(ProtocolKey::Enter, none, &mut buf),
Some("\n"),
"RET is bound to buffer.newline = insert_char(10): identical to a self-insert"
None,
"RET binds edit.newline-and-indent (Q#AI1): the inserted text depends \
on the current line, so plain Enter must round-trip this is also \
what makes RET rebindings reachable from the GPU frontend"
);
assert_eq!(
optimistic_insert_text(ProtocolKey::Tab, none, &mut buf),

View File

@ -339,6 +339,12 @@ impl EditorState {
include_str!("../builtin/runtime/comment.lua"),
)
.expect("load comment builtin chunk");
lua_host
.eval(
Some("@pmacs/builtin/runtime/indent.lua"),
include_str!("../builtin/runtime/indent.lua"),
)
.expect("load indent builtin chunk");
// T M7.11 bundled-package bootstrap. Through M7.10 the REPL
// was loaded directly via `eval(include_str!(...))`; the
// M7.11 deliverable migrates it to the package system so it
@ -2856,6 +2862,82 @@ mod tests {
assert_eq!(s.core.borrow().active_buffer_len(), 1);
}
#[test]
fn empty_selection_is_cleared_by_a_landed_self_insert() {
// Q#AI9: an armed anchor at the cursor reports no region, so
// 'x' inserts plainly — but the insert moves the cursor off
// the anchor, and without the clear the region goes live and
// 'y' type-overs the 'x'.
let mut s = fresh_with(b"");
s.lua_host
.lua()
.load("pmacs.editor.begin_selection(0)")
.exec()
.unwrap();
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char('x'), KeyModifiers::NONE),
);
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char('y'), KeyModifiers::NONE),
);
let core = s.core.borrow();
assert_eq!(
core.active_buffer_len(),
2,
"'y' must append, not type-over the freshly inserted 'x'"
);
assert!(
core.active_window().selection.is_none(),
"a landed self-insert clears the lingering anchor"
);
}
#[test]
fn rejected_self_insert_leaves_the_empty_selection_anchor() {
// Q#AI9 failure regression: a rejecting intercept means NO
// state mutation — the armed anchor must survive.
let mut s = fresh_with(b"");
s.lua_host
.lua()
.load(
r#"
_G.reject_once = true
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(_op)
if _G.reject_once then
_G.reject_once = false
error("rejected by test intercept")
end
return nil
end)
pmacs.editor.begin_selection(0)
"#,
)
.exec()
.unwrap();
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char('x'), KeyModifiers::NONE),
);
{
let core = s.core.borrow();
assert_eq!(core.active_buffer_len(), 0, "the insert was rejected");
assert!(
core.active_window().selection.is_some(),
"a rejected insert must not clear the anchor"
);
}
// The next (allowed) insert lands and clears it.
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char('x'), KeyModifiers::NONE),
);
let core = s.core.borrow();
assert_eq!(core.active_buffer_len(), 1);
assert!(core.active_window().selection.is_none());
}
#[test]
fn backspace_deletes_previous_char() {
let mut s = fresh_with(b"");

View File

@ -714,7 +714,9 @@ impl EditorCore {
/// `(active_index, total)` for the active buffer's matches, for the
/// prompt's "n/m" readout. `active_index` is 0-based and `None`
/// when there are no matches.
/// when there are no matches. Stale matches read as absent (Q#AI8
/// fail-closed): the highlights they count are already suppressed,
/// so the prompt must not advertise them either.
#[must_use]
pub fn search_match_summary(&self) -> (Option<usize>, usize) {
let bid = self.active_buffer_id();
@ -722,6 +724,9 @@ impl EditorCore {
.search_store
.lock()
.expect("search store mutex poisoned");
if guard.is_stale(bid) {
return (None, 0);
}
guard
.for_buffer(bid)
.map_or((None, 0), |s| (s.active_index(), s.len()))
@ -1156,17 +1161,22 @@ impl EditorCore {
/// Returns a stringified error on buffer or view failure.
pub fn apply_active_edit(&mut self, op: EditOp<'_>) -> Result<u64, String> {
let buffer_id = self.active_buffer_id();
let mut reg = self.registry.borrow_mut();
let buffer = reg.get_mut(buffer_id).map_err(|e| e.to_string())?;
let edit = buffer.apply_edit(op).map_err(|e| e.to_string())?;
for win in self.windows.values_mut() {
if win.buffer_id == buffer_id {
let _ = win.text_view.on_edit(buffer, &edit);
for overlay in &mut win.overlays {
let _ = overlay.on_edit(buffer, &edit);
// Scope the registry borrow: the origin translation below needs
// `&mut self` after the views have been notified.
let edit = {
let mut reg = self.registry.borrow_mut();
let buffer = reg.get_mut(buffer_id).map_err(|e| e.to_string())?;
let edit = buffer.apply_edit(op).map_err(|e| e.to_string())?;
for win in self.windows.values_mut() {
if win.buffer_id == buffer_id {
let _ = win.text_view.on_edit(buffer, &edit);
for overlay in &mut win.overlays {
let _ = overlay.on_edit(buffer, &edit);
}
}
}
}
edit
};
// T M10.8 Day 4 — capture CRDT op (if the buffer was in
// CRDT mode and produced one) for the dispatcher to
// broadcast on the next tick.
@ -1191,9 +1201,35 @@ impl EditorCore {
.lock()
.expect("search store mutex poisoned")
.mark_stale(buffer_id);
self.translate_search_origin(buffer_id, &edit);
Ok(edit.new_rope.len())
}
/// Right-gravity-translate the live search origin through an edit
/// to `buffer_id` (Q#AI8; the `src/daemon.rs` optimistic-arm
/// shape). The origin is a raw byte offset captured at
/// [`Self::search_begin`]; without translation an insert/delete
/// before it skews every later recompute focus and the cancel
/// restore, even when the match set itself is fresh.
fn translate_search_origin(&mut self, buffer_id: BufferId, edit: &Edit) {
let Some(session) = self.search.as_mut() else {
return;
};
if session.origin.0 != buffer_id {
return;
}
let start = edit.range.start;
let end = edit.range.end;
let pos = session.origin.1;
session.origin.1 = if pos < start {
pos
} else if pos > end {
pos - (end - start) + edit.inserted_len
} else {
start + edit.inserted_len
};
}
/// Notify every window displaying `buffer_id` that the buffer was
/// just edited externally — used by code paths that mutate a buffer
/// without going through [`Self::apply_active_edit`] (the most
@ -1204,7 +1240,18 @@ impl EditorCore {
/// edited buffer would keep a stale [`crate::text_view::TextView`]
/// line cache, causing later cursor motions to land at offsets the
/// view cannot map back to display coordinates.
///
/// Q#AI8: direct edits must also invalidate search state exactly
/// like [`Self::apply_active_edit`] does — mark the matches stale
/// and translate the live origin — otherwise accepted-match
/// highlights and the session origin survive at pre-edit offsets
/// for every Lua mutator edit and applied CRDT op.
pub fn notify_buffer_edit(&mut self, buffer_id: BufferId, edit: &Edit) {
self.search_store
.lock()
.expect("search store mutex poisoned")
.mark_stale(buffer_id);
self.translate_search_origin(buffer_id, edit);
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(buffer_id) else {
return;
@ -1677,8 +1724,11 @@ impl EditorCore {
aw.goal_col = None;
}
/// Insert a single character at the cursor.
pub fn insert_char(&mut self, ch: char) {
/// Insert a single character at the cursor. Returns `true` iff the
/// edit landed: a rejecting buffer intercept reports via the status
/// line and returns `false`, and callers must not mutate dependent
/// state (e.g. selection anchors) on a failed insert (Q#AI9).
pub fn insert_char(&mut self, ch: char) -> bool {
self.active_window_mut().goal_col = None;
let mut buf = [0u8; 4];
let s = ch.encode_utf8(&mut buf);
@ -1686,9 +1736,10 @@ impl EditorCore {
let pos = self.active_window().cursor;
if let Err(e) = self.apply_active_edit(EditOp::Insert { pos, bytes }) {
self.status = format!("insert failed: {e}");
return;
return false;
}
self.active_window_mut().cursor += bytes.len() as u64;
true
}
/// CUA type-over: insert `ch`, replacing the active region if one
@ -1699,7 +1750,14 @@ impl EditorCore {
/// lands just past the inserted bytes and any selection is cleared.
pub fn insert_char_over_region(&mut self, ch: char) {
let Some((lo, hi)) = self.active_region() else {
self.insert_char(ch);
// Q#AI9: an empty selection (anchor == cursor) reports no
// region yet stays armed — the insert moves the cursor off
// the anchor and the very NEXT key type-overs the fresh
// text. Clear it, but only when the edit landed: a
// rejecting intercept must leave the anchor untouched.
if self.insert_char(ch) {
self.active_window_mut().selection = None;
}
return;
};
self.active_window_mut().goal_col = None;
@ -3669,6 +3727,105 @@ mod tests {
);
}
#[test]
fn stale_matches_fail_closed_for_step_and_summary() {
// Q#AI8: once an edit marks matches stale, the highlights are
// suppressed — stepping and the n/m prompt must fail closed
// with them instead of navigating/advertising dead offsets.
let mut s = from_bytes(b"foo bar foo");
s.active_window_mut().cursor = 0;
s.search_begin(true, false);
type_query(&mut s, "foo");
s.search_finish(true); // accept keeps the matches
assert_eq!(s.search_match_summary(), (Some(0), 2));
s.active_window_mut().cursor = 0;
assert!(s.insert_char('x'), "plain insert lands");
assert_eq!(
s.search_match_summary(),
(None, 0),
"stale counts must not reach the prompt"
);
let before = s.cursor();
s.search_step(true);
assert_eq!(s.cursor(), before, "stale step is a no-op");
}
#[test]
fn live_search_origin_translates_through_local_edits() {
// Q#AI8: the session origin is a raw byte offset; an edit
// before it must shift it (right-gravity) so cancel restores
// the same TEXT position, not the same number.
let mut s = from_bytes(b"foo bar foo");
s.active_window_mut().cursor = 5;
s.search_begin(true, false); // origin byte 5
type_query(&mut s, "foo");
assert_eq!(s.cursor(), 8, "focused the match after the origin");
s.active_window_mut().cursor = 0;
assert!(s.insert_char('x'));
assert!(s.insert_char('y'));
s.search_finish(false); // cancel
assert_eq!(
s.cursor(),
7,
"cancel restores the translated origin (5 + 2 inserted bytes)"
);
}
#[test]
fn live_search_recompute_focuses_from_the_translated_origin() {
let mut s = from_bytes(b"foo bar foo");
s.active_window_mut().cursor = 1;
s.search_begin(true, false); // origin byte 1
type_query(&mut s, "fo");
assert_eq!(s.cursor(), 8, "first match at/after the origin");
s.active_window_mut().cursor = 0;
assert!(s.insert_char('x'));
assert!(s.insert_char('y'));
assert!(s.insert_char('z'));
// "xyzfoo bar foo": origin 1 -> 4. Growing the query recomputes
// and must focus from the TRANSLATED origin: the match at 11,
// not the pre-edit offset 1's neighbor at 3.
type_query(&mut s, "o");
assert_eq!(
s.cursor(),
11,
"recompute focuses the first match at/after the translated origin"
);
}
#[test]
fn notify_buffer_edit_marks_stale_and_translates_the_origin() {
// Q#AI8 at the direct-edit seam (Lua mutators / applied CRDT
// ops): notify_buffer_edit must invalidate matches and shift
// the live origin exactly like apply_active_edit does.
let mut s = from_bytes(b"foo bar foo");
let bid = s.active_buffer_id();
s.active_window_mut().cursor = 1;
s.search_begin(true, false); // origin byte 1
type_query(&mut s, "foo");
let edit = {
let mut reg = s.registry.borrow_mut();
let buffer = reg.get_mut(bid).expect("buffer");
buffer
.apply_edit(crate::buffer::EditOp::Insert {
pos: 0,
bytes: b"zz",
})
.expect("direct insert")
};
s.notify_buffer_edit(bid, &edit);
assert!(
s.search_store.lock().expect("store").is_stale(bid),
"direct edits mark the matches stale"
);
s.search_finish(false); // cancel
assert_eq!(
s.cursor(),
3,
"cancel restores the origin translated through the direct edit"
);
}
#[test]
fn search_backspace_widens_the_match_set() {
let mut s = from_bytes(b"fo foo food");

View File

@ -145,8 +145,15 @@ impl SearchStore {
/// Step the active match forward or backward, wrapping. Returns
/// the new active match's range, or `None` when the buffer has no
/// matches.
/// matches — or when they are stale (Q#AI8 fail-closed): stale
/// ranges were computed against pre-edit text, and stepping
/// through them would teleport the cursor to offsets that no
/// longer exist. A live search un-sticks on the next pattern
/// keystroke ([`Self::set`] clears staleness).
pub fn step(&mut self, buffer_id: BufferId, forward: bool) -> Option<ByteRange> {
if self.stale.contains(&buffer_id) {
return None;
}
let s = self.by_buffer.get_mut(&buffer_id)?;
let n = s.matches.len();
if n == 0 {
@ -830,4 +837,20 @@ mod tests {
s.mark_stale(other);
assert!(!s.is_stale(other));
}
#[test]
fn step_fails_closed_while_stale() {
// Q#AI8: stale ranges were computed against pre-edit text;
// stepping through them would teleport the cursor to offsets
// that no longer exist.
let mut s = SearchStore::new();
let bid = BufferId::next();
s.set(bid, "x", vec![r(0, 1), r(4, 5)]);
assert!(s.step(bid, true).is_some(), "fresh matches step");
s.mark_stale(bid);
assert!(s.step(bid, true).is_none(), "stale matches do not");
// A re-run (`set`) clears staleness and stepping resumes.
s.set(bid, "x", vec![r(0, 1), r(4, 5)]);
assert!(s.step(bid, true).is_some(), "fresh set un-sticks stepping");
}
}

View File

@ -0,0 +1,484 @@
//! Auto-indent acceptance (Arc 2, docs/auto-indent-framing.md).
//!
//! Dispatch-driven: RET through `dispatch_key`, `M-x` through the real
//! minibuffer. Auto-indent is language-agnostic (Q#AI3 copies bytes),
//! so buffers are plain in-memory scratch buffers — no files, no
//! language detection, no `StateDir`.
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use pmacs::editor::EditorState;
use pmacs::protocol::FrontendId;
fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
KeyEvent {
code,
modifiers: mods,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
}
}
fn ctrl(s: &mut EditorState, c: char) {
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char(c), KeyModifiers::CONTROL),
);
}
fn alt(s: &mut EditorState, c: char) {
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Char(c), KeyModifiers::ALT));
}
fn press(s: &mut EditorState, code: KeyCode) {
s.dispatch_key(FrontendId::LOCAL, key(code, KeyModifiers::NONE));
}
fn type_str(s: &mut EditorState, text: &str) {
for ch in text.chars() {
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char(ch), KeyModifiers::NONE),
);
}
}
fn m_x(s: &mut EditorState, name: &str) {
alt(s, 'x');
type_str(s, name);
press(s, KeyCode::Enter);
}
fn exec(s: &EditorState, src: &str) {
s.lua_host.lua().load(src.to_string()).exec().unwrap();
}
fn eval<T: mlua::FromLuaMulti>(s: &EditorState, src: &str) -> T {
s.lua_host.lua().load(src.to_string()).eval().unwrap()
}
fn buffer_text(s: &EditorState) -> String {
let b: mlua::String = eval(
s,
"local b = pmacs.window.buffer(); return b:slice(0, b:len())",
);
String::from_utf8_lossy(&b.as_bytes()).into_owned()
}
fn cursor(s: &EditorState) -> i64 {
eval(s, "return pmacs.editor.cursor()")
}
fn status(s: &EditorState) -> String {
s.core.borrow().status.clone()
}
/// Fresh editor whose active scratch buffer holds `body`, cursor at 0.
fn editor_with(body: &str) -> EditorState {
let s = EditorState::new();
if !body.is_empty() {
exec(&s, &format!("pmacs.window.buffer():insert(0, {body:?})"));
}
exec(&s, "pmacs.editor.goto_byte(0)");
s
}
// ---------------------------------------------------------------------------
// The indent copy (Q#AI3)
// ---------------------------------------------------------------------------
#[test]
fn ret_at_eol_carries_the_space_indent() {
let mut s = editor_with(" foo\nbar\n");
exec(&s, "pmacs.editor.goto_byte(7)"); // end of " foo"
press(&mut s, KeyCode::Enter);
assert_eq!(buffer_text(&s), " foo\n \nbar\n");
assert_eq!(cursor(&s), 12, "cursor lands after the carried indent");
}
#[test]
fn tab_and_mixed_indents_round_trip_verbatim() {
let mut s = editor_with("\tfoo");
exec(&s, "pmacs.editor.goto_byte(4)");
press(&mut s, KeyCode::Enter);
assert_eq!(buffer_text(&s), "\tfoo\n\t", "a tab indent copies as a tab");
assert_eq!(cursor(&s), 6);
let mut s = editor_with("\t foo");
exec(&s, "pmacs.editor.goto_byte(6)");
press(&mut s, KeyCode::Enter);
assert_eq!(
buffer_text(&s),
"\t foo\n\t ",
"mixed tab+space indents copy byte-for-byte"
);
assert_eq!(cursor(&s), 10);
}
#[test]
fn mid_line_split_carries_the_tail_onto_the_indented_line() {
let mut s = editor_with(" foobar");
exec(&s, "pmacs.editor.goto_byte(7)"); // between "foo" and "bar"
press(&mut s, KeyCode::Enter);
assert_eq!(buffer_text(&s), " foo\n bar");
assert_eq!(cursor(&s), 12, "cursor sits before the carried tail");
}
#[test]
fn split_inside_the_leading_whitespace_does_not_double_indent() {
// Q#AI3 clip rule: `··|··foo` → `··` / `····foo` — the carried
// text keeps its TOTAL indentation (4), instead of gaining the
// full 4-wide indent on top of its remaining 2 spaces.
let mut s = editor_with(" foo");
exec(&s, "pmacs.editor.goto_byte(2)");
press(&mut s, KeyCode::Enter);
assert_eq!(buffer_text(&s), " \n foo");
assert_eq!(cursor(&s), 5, "cursor after the clipped indent");
}
#[test]
fn zero_indent_and_empty_buffer_match_plain_newline() {
let mut s = editor_with("");
press(&mut s, KeyCode::Enter);
assert_eq!(buffer_text(&s), "\n");
assert_eq!(cursor(&s), 1);
let mut s = editor_with("foo");
exec(&s, "pmacs.editor.goto_byte(3)");
press(&mut s, KeyCode::Enter);
assert_eq!(buffer_text(&s), "foo\n");
assert_eq!(cursor(&s), 4);
}
#[test]
fn whitespace_only_line_copies_and_the_abandoned_line_keeps_its_whitespace() {
// Named non-goal (Q#AI3): no trailing-whitespace cleanup on the
// line being left behind.
let mut s = editor_with(" ");
exec(&s, "pmacs.editor.goto_byte(4)");
press(&mut s, KeyCode::Enter);
assert_eq!(buffer_text(&s), " \n ");
assert_eq!(cursor(&s), 9);
}
// ---------------------------------------------------------------------------
// Region type-over + selections (Q#AI4)
// ---------------------------------------------------------------------------
#[test]
fn region_ret_is_one_replace_one_undo_step_and_clears_the_selection() {
let mut s = editor_with(" hello world");
exec(
&s,
"pmacs.editor.begin_selection(4); pmacs.editor.goto_byte(15)",
);
press(&mut s, KeyCode::Enter);
assert_eq!(
buffer_text(&s),
" \n ",
"the region is replaced by newline+indent in one edit"
);
assert_eq!(cursor(&s), 9);
let region_active: bool = eval(&s, "return pmacs.editor.region() ~= nil");
assert!(!region_active, "selection clears after a region RET");
ctrl(&mut s, '/'); // buffer.undo, exactly once
assert_eq!(
buffer_text(&s),
" hello world",
"one undo restores the whole type-over"
);
}
#[test]
fn plain_ret_is_one_undo_step() {
let mut s = editor_with(" ab");
exec(&s, "pmacs.editor.goto_byte(4)");
press(&mut s, KeyCode::Enter);
assert_eq!(buffer_text(&s), " ab\n ");
ctrl(&mut s, '/');
assert_eq!(buffer_text(&s), " ab");
}
#[test]
fn zero_length_selection_does_not_type_over_the_fresh_newline() {
// Q#AI4: an armed anchor at the cursor reports no region; the RET
// moves the cursor off it, so without the unconditional clear the
// next self-insert would replace the newline ("S-Left at BOF,
// RET, x" → "x").
let mut s = editor_with("");
exec(&s, "pmacs.editor.begin_selection(0)");
press(&mut s, KeyCode::Enter);
type_str(&mut s, "x");
assert_eq!(buffer_text(&s), "\nx", "the newline survives the 'x'");
// Q#AI9: the retained plain-newline escape hatch (through the
// fixed core arm) behaves the same.
let mut s = editor_with("");
exec(&s, "pmacs.editor.begin_selection(0)");
m_x(&mut s, "buffer.newline");
type_str(&mut s, "x");
assert_eq!(buffer_text(&s), "\nx");
}
// ---------------------------------------------------------------------------
// Intercept discipline (Q#AI5)
// ---------------------------------------------------------------------------
#[test]
fn rejecting_intercept_reports_without_throwing_or_mutating() {
let mut s = editor_with(" a");
exec(&s, "pmacs.editor.goto_byte(3)");
exec(
&s,
r#"
_G.reject_once = true
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(_op)
if _G.reject_once then
_G.reject_once = false
error("rejected by test intercept")
end
return nil
end)
"#,
);
press(&mut s, KeyCode::Enter);
assert!(status(&s).contains("rejected"), "got: {:?}", status(&s));
assert_eq!(buffer_text(&s), " a");
assert_eq!(cursor(&s), 3, "no cursor motion on a rejected RET");
press(&mut s, KeyCode::Enter); // allowed again: works
assert_eq!(buffer_text(&s), " a\n ");
}
#[test]
fn relocating_intercept_moves_the_payload_but_does_not_teleport_the_cursor() {
// The only transform an insert admits (M6.4): moving its `pos`.
// The payload lands where the intercept sent it; the cursor is
// translated through the edit, NOT jumped to the remote site.
let mut s = editor_with(" abc");
exec(&s, "pmacs.editor.goto_byte(5)");
exec(
&s,
r#"
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op)
if op.kind == "insert" then
return { kind = "insert", pos = 0, bytes = op.bytes }
end
return nil
end)
"#,
);
press(&mut s, KeyCode::Enter);
assert!(status(&s).contains("altered"), "got: {:?}", status(&s));
assert_eq!(
buffer_text(&s),
"\n abc",
"the newline+indent payload landed at the intercept's position"
);
assert_eq!(
cursor(&s),
8,
"cursor shifted right by the inserted length (5+3), not teleported to the edit"
);
}
#[test]
fn shrinking_intercept_leaves_a_valid_cursor_and_no_selection() {
// Only a replace can shrink the buffer (M6.4): the intercept
// expands the replaced range past the payload. The cursor must be
// right-gravity-translated into the shrunken buffer — validity,
// not immobility — and the selection cleared.
let mut s = editor_with(" hello world wide");
exec(
&s,
"pmacs.editor.begin_selection(2); pmacs.editor.goto_byte(7)",
);
exec(
&s,
r#"
pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op)
if op.kind == "replace" then
return {
kind = "replace",
start = op.start,
["end"] = op["end"] + 8,
bytes = op.bytes,
}
end
return nil
end)
"#,
);
press(&mut s, KeyCode::Enter);
assert!(status(&s).contains("altered"), "got: {:?}", status(&s));
assert_eq!(buffer_text(&s), " \n ide", "the expanded replace stands");
let len: i64 = eval(&s, "return pmacs.window.buffer():len()");
assert_eq!(cursor(&s), 5, "cursor translated to the edit's new end");
assert!(cursor(&s) <= len, "cursor within the shrunken buffer");
let region_active: bool = eval(&s, "return pmacs.editor.region() ~= nil");
assert!(!region_active, "selection cleared under the same guard");
}
#[test]
fn context_switching_intercept_skips_fixup_and_leaves_the_new_context_alone() {
// Q#AI5 guard: an intercept may switch the active window/buffer
// (the registry borrow is released). The fix-up must not touch
// whatever is active afterwards. This proves the NEW context is
// untouched; the original window's state after such an intercept
// is the substrate-reconciliation deferral's territory.
let mut s = editor_with(" a");
exec(&s, "pmacs.editor.goto_byte(3)");
exec(
&s,
r#"
_G.orig = pmacs.window.buffer()
_G.other = pmacs.buffer.create("*other*")
pmacs.buffer.add_intercept(_G.orig, function(_op)
pmacs.window.switch_buffer(_G.other)
return nil
end)
"#,
);
press(&mut s, KeyCode::Enter);
assert!(
status(&s).contains("context changed"),
"got: {:?}",
status(&s)
);
let name: String = eval(&s, "return pmacs.window.buffer():name()");
assert_eq!(name, "*other*", "the intercept's buffer switch stands");
assert_eq!(cursor(&s), 0, "the new context's cursor is untouched");
let orig: mlua::String = eval(&s, "return _G.orig:slice(0, _G.orig:len())");
assert_eq!(
String::from_utf8_lossy(&orig.as_bytes()),
" a\n ",
"the edit itself landed in the original buffer"
);
}
// ---------------------------------------------------------------------------
// Search staleness through RET (Q#AI8)
// ---------------------------------------------------------------------------
#[test]
fn accepted_search_navigation_fails_closed_after_ret() {
let mut s = editor_with("ind ind ind");
ctrl(&mut s, 's');
type_str(&mut s, "ind");
press(&mut s, KeyCode::Enter); // accept: matches stay until an edit
exec(&s, "pmacs.editor.goto_byte(11)");
press(&mut s, KeyCode::Enter); // auto-indent RET marks them stale
assert_eq!(buffer_text(&s), "ind ind ind\n");
let at = cursor(&s);
exec(&s, "pmacs.editor.search_step(true)");
assert_eq!(
cursor(&s),
at,
"post-accept navigation is a no-op once RET staled the matches"
);
}
#[test]
fn direct_lua_edit_stales_accepted_search_navigation() {
let mut s = editor_with("foo foo");
ctrl(&mut s, 's');
type_str(&mut s, "foo");
press(&mut s, KeyCode::Enter); // accept
exec(&s, "pmacs.window.buffer():insert(0, \"zz\")"); // notify path
let at = cursor(&s);
exec(&s, "pmacs.editor.search_step(true)");
assert_eq!(
cursor(&s),
at,
"a direct buf:insert must stale the matches like any edit"
);
}
// ---------------------------------------------------------------------------
// Substrate plumbing (Q#AI7)
// ---------------------------------------------------------------------------
#[test]
fn after_edit_fires_exactly_once_per_ret_keybound_and_m_x() {
let mut s = editor_with(" a");
exec(&s, "pmacs.editor.goto_byte(3)");
exec(
&s,
"_G.ae = 0; pmacs.hook.add('buffer.after-edit', function() _G.ae = _G.ae + 1 end)",
);
press(&mut s, KeyCode::Enter);
let n: i64 = eval(&s, "return _G.ae");
assert_eq!(n, 1, "keybound RET fires after-edit once");
m_x(&mut s, "edit.newline-and-indent");
assert_eq!(buffer_text(&s), " a\n \n ");
let n: i64 = eval(&s, "return _G.ae");
assert_eq!(n, 2, "M-x RET fires after-edit exactly once more");
}
#[test]
fn ret_between_kills_breaks_the_kill_chain() {
let mut s = editor_with("one\ntwo\nthree\n");
ctrl(&mut s, 'k'); // kills "one"; line now blank, cursor 0
press(&mut s, KeyCode::Enter); // rotates the command boundary
ctrl(&mut s, 'k'); // kills "\n" — must push fresh, not append
let ring: Vec<String> = eval(&s, "return pmacs.killring.list()");
assert_eq!(
ring,
vec!["\n", "one"],
"C-k, RET, C-k yields two ring entries (chain broken)"
);
}
#[test]
fn this_command_during_ret_is_the_new_command() {
let mut s = editor_with("");
exec(
&s,
"pmacs.hook.add('buffer.after-edit', function() _G.tc = pmacs.editor.this_command() end)",
);
press(&mut s, KeyCode::Enter);
let tc: String = eval(&s, "return _G.tc");
assert_eq!(tc, "edit.newline-and-indent");
}
// ---------------------------------------------------------------------------
// Contexts RET must not disturb (ground truth: consumed before the keymap)
// ---------------------------------------------------------------------------
#[test]
fn minibuffer_and_buffer_list_ret_are_unaffected() {
// The m_x helper itself proves minibuffer accept (used throughout
// this suite). The classic buffer list's RET is a buffer-local
// binding through normal dispatch (ground truth) — it must visit,
// not newline-and-indent into the list.
let mut s = editor_with("hello");
ctrl(&mut s, 'x');
ctrl(&mut s, 'b');
let name: String = eval(&s, "return pmacs.window.buffer():name()");
assert_eq!(name, "*buffer-list*");
let listed = buffer_text(&s);
exec(&s, "_G.list = pmacs.window.buffer()");
press(&mut s, KeyCode::Enter); // buffer-local RET: visit
let name: String = eval(&s, "return pmacs.window.buffer():name()");
assert_ne!(name, "*buffer-list*", "RET visits instead of inserting");
let list_after: String = eval(
&s,
"if not _G.list:is_valid() then return \"<gone>\" end \
return _G.list:slice(0, _G.list:len())",
);
assert!(
list_after == "<gone>" || list_after == listed,
"no newline landed in the buffer list"
);
}
#[test]
fn isearch_ret_accepts_instead_of_inserting() {
let mut s = editor_with("abc abc");
ctrl(&mut s, 's');
type_str(&mut s, "abc");
press(&mut s, KeyCode::Enter); // isearch accept, consumed pre-keymap
assert_eq!(
buffer_text(&s),
"abc abc",
"RET during isearch accepts; no newline is inserted"
);
}

View File

@ -0,0 +1,152 @@
// auto_indent_crdt_acceptance.rs --- RET's daemon CRDT round trip.
//! Auto-indent daemon-side wire acceptance (Q#AI6, the second of the
//! two named GPU seams in docs/auto-indent-framing.md): a synthetic
//! attached replica sends pending optimistic self-inserts followed by
//! a round-tripped Enter, and the daemon must dispatch
//! `edit.newline-and-indent` and broadcast the resulting multi-byte
//! CRDT op back to the source replica. This is the daemon side of the
//! wire path the GPU frontend takes now that plain Enter is no longer
//! optimistic-eligible; the in-crate classifier test in `pmacs-gpu`
//! covers the frontend side of the seam.
#![cfg(feature = "crdt")]
use std::time::Duration;
use pmacs::crdt::CrdtState;
use pmacs::protocol::{FrontendEvent, FrontendId, Key, KeyEvent, Modifiers};
use pmacs::rope::CrdtOp as RopeCrdtOp;
use pmacs::transport::write_message;
mod common;
use common::daemon::{TestDaemon, attach_multi};
/// Read the daemon's initial `BufferSnapshot` for a freshly-attached
/// replica stream (the daemon always emits it first).
fn read_initial_snapshot(
stream: &mut std::os::unix::net::UnixStream,
) -> (pmacs::buffer::BufferId, Vec<u8>) {
match pmacs::transport::read_message::<pmacs::protocol::InstanceMessage>(stream)
.expect("read initial BufferSnapshot")
{
pmacs::protocol::InstanceMessage::BufferSnapshot {
buffer_id,
crdt_snapshot,
} => (buffer_id, crdt_snapshot),
other => panic!("expected initial BufferSnapshot, got {other:?}"),
}
}
/// Mutate the local replica, export the delta, and ship it as an
/// optimistic `FrontendEvent::CrdtOp` (the m10_11 idiom).
fn send_optimistic_op_from<F>(
stream: &mut std::os::unix::net::UnixStream,
replica: &CrdtState,
frontend_id: FrontendId,
buffer_id: pmacs::buffer::BufferId,
mutate: F,
) where
F: FnOnce(&CrdtState),
{
let v = replica.version();
mutate(replica);
let op_bytes = replica
.export_updates_since(&v)
.expect("export updates after local mutation");
write_message(
stream,
&FrontendEvent::CrdtOp {
frontend_id,
buffer_id,
op: RopeCrdtOp {
peer_id: frontend_id.0,
bytes: op_bytes,
},
},
)
.expect("write CrdtOp");
}
/// Pump broadcast messages into the replica until it materializes
/// `expected` or the deadline passes.
fn pump_until(
stream: &mut std::os::unix::net::UnixStream,
replica: &CrdtState,
buffer_id: pmacs::buffer::BufferId,
expected: &str,
timeout: Duration,
) -> Result<(), String> {
let deadline = std::time::Instant::now() + timeout;
while std::time::Instant::now() < deadline {
if replica.materialize_string() == expected {
return Ok(());
}
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
stream
.set_read_timeout(Some(remaining.min(Duration::from_millis(100))))
.ok();
match pmacs::transport::read_message::<pmacs::protocol::InstanceMessage>(stream) {
Ok(pmacs::protocol::InstanceMessage::CrdtOp { buffer_id: b, op }) if b == buffer_id => {
let _ = replica.import_updates(&op.bytes);
}
Ok(_) | Err(_) => {}
}
}
Err(format!(
"expected materialize {expected:?}, got {observed:?} after {timeout:?}",
observed = replica.materialize_string()
))
}
/// Pending optimistic self-inserts (`"··x"`, one op per keystroke,
/// mirroring GPU typing), then Enter as a round-tripped Key. The
/// daemon's dispatch must run `edit.newline-and-indent` — carrying
/// the two-space indent — and the multi-byte op must come back to the
/// source replica. A plain-newline dispatch would converge to
/// `" x\n"` instead and fail the assertion.
#[test]
fn round_tripped_enter_after_pending_optimistic_input_auto_indents() {
let daemon = TestDaemon::spawn();
let (hello, mut stream) = attach_multi(&daemon);
let fid = hello.assigned_frontend_id;
let (buffer_id, snap) = read_initial_snapshot(&mut stream);
let replica = CrdtState::new(fid.0).expect("CrdtState::new");
replica.import_snapshot(&snap).expect("import_snapshot");
// Three pending optimistic self-inserts, ahead of the Enter.
send_optimistic_op_from(&mut stream, &replica, fid, buffer_id, |r| {
r.insert(0, " ").expect("insert space");
});
send_optimistic_op_from(&mut stream, &replica, fid, buffer_id, |r| {
r.insert(1, " ").expect("insert space");
});
send_optimistic_op_from(&mut stream, &replica, fid, buffer_id, |r| {
r.insert(2, "x").expect("insert x");
});
// Enter round-trips (never optimistic since Q#AI1): the daemon
// applies the pending ops first — its cursor for this frontend
// tracks the optimistic post-edit position — then dispatches the
// keymap's RET binding.
write_message(
&mut stream,
&FrontendEvent::Key(KeyEvent {
frontend_id: fid,
key: Key::Enter,
mods: Modifiers::NONE,
timestamp_ns: 0,
}),
)
.expect("send Enter");
pump_until(
&mut stream,
&replica,
buffer_id,
" x\n ",
Duration::from_secs(5),
)
.expect("replica converges to the auto-indented text");
}