From 7b5365cfbf2fcbaa967ce1152e8f417409138e03 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 10 Jul 2026 12:11:05 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01ATiKMwJ4864d82D39EvsU6 --- builtin/keymaps/default.lua | 2 +- builtin/runtime/indent.lua | 119 +++++++ docs/agent-handoff.md | 40 ++- pmacs-gpu/src/main.rs | 42 ++- src/editor.rs | 82 +++++ src/editor_core.rs | 185 +++++++++- src/search.rs | 25 +- tests/auto_indent_acceptance.rs | 484 +++++++++++++++++++++++++++ tests/auto_indent_crdt_acceptance.rs | 152 +++++++++ 9 files changed, 1080 insertions(+), 51 deletions(-) create mode 100644 builtin/runtime/indent.lua create mode 100644 tests/auto_indent_acceptance.rs create mode 100644 tests/auto_indent_crdt_acceptance.rs diff --git a/builtin/keymaps/default.lua b/builtin/keymaps/default.lua index e94c033..7dfb9db 100644 --- a/builtin/keymaps/default.lua +++ b/builtin/keymaps/default.lua @@ -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 --------------------------------------------------------- diff --git a/builtin/runtime/indent.lua b/builtin/runtime/indent.lua new file mode 100644 index 0000000..c8763ef --- /dev/null +++ b/builtin/runtime/indent.lua @@ -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, +} diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index f78cf8f..2bb2ed3 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -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 diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index d9955ff..8be9030 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -1068,9 +1068,10 @@ impl ApplicationHandler 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), diff --git a/src/editor.rs b/src/editor.rs index 98ae44b..0e07d4e 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -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""); diff --git a/src/editor_core.rs b/src/editor_core.rs index 4dcccf9..5633735 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -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) { 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 { 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"); diff --git a/src/search.rs b/src/search.rs index bda13db..fbe7f2b 100644 --- a/src/search.rs +++ b/src/search.rs @@ -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 { + 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"); + } } diff --git a/tests/auto_indent_acceptance.rs b/tests/auto_indent_acceptance.rs new file mode 100644 index 0000000..4241f21 --- /dev/null +++ b/tests/auto_indent_acceptance.rs @@ -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(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 = 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 \"\" end \ + return _G.list:slice(0, _G.list:len())", + ); + assert!( + list_after == "" || 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" + ); +} diff --git a/tests/auto_indent_crdt_acceptance.rs b/tests/auto_indent_crdt_acceptance.rs new file mode 100644 index 0000000..718f927 --- /dev/null +++ b/tests/auto_indent_crdt_acceptance.rs @@ -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) { + match pmacs::transport::read_message::(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( + 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::(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"); +}