fix(edit): PR #109 round 1 — shared search invalidation, daemon anchor clear, bounded indent scan

Finding 1: the empty-anchor optimistic residual was never GPU-only
(the TUI mirror tracks no selection state; its gate checks cursor
freshness/EOL only). The fix moves daemon-side: handle_remote_crdt_op
clears a selection whose anchor equals the pre-edit cursor (= empty)
before applying the source cursor update; nonempty selections stand.
Covers both frontends. The TUI gate's missing type-over check
(nonempty selection at EOL) is a named deferral.

Finding 2: Q#AI8 invalidation is one helper
(search_invalidate_for_edit) invoked from all four edit paths --
apply_active_edit, notify_buffer_edit, and now undo/redo, which
received precise Edits but invalidated nothing. rebuild_views_for is
named as a lower-frequency bypass (deferral).

Finding 3: acceptance matrix trued up -- added active-search
fail-closed + retype recovery, delete translation on both paths,
undo/redo staleness + origin tests; modal contexts narrowed to what
this suite pins (query-replace/menu/completion ride their own
suites).

Finding 4: indent extraction is a forward-chunked scan stopping at
the first non-whitespace byte -- Enter at the end of a giant
minified line no longer materializes the line. Functional pin at
64 KiB.

Both medium fixes are bite-verified (tests fail with the fix
disabled).

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 15:46:36 -04:00
parent 7b5365cfbf
commit 180343e6e3
5 changed files with 377 additions and 61 deletions

View File

@ -33,14 +33,25 @@ local function line_start_before(buf, pos)
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
-- bytes[line_start .. min(first_non_ws, split)]. Forward chunked scan
-- from the line start, stopping at the first non-whitespace byte —
-- never materializing more of the line than the indent itself plus
-- one chunk (Enter at the end of a giant minified line must not copy
-- the whole line just to produce an empty indent). `[ \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]*")
local parts = {}
local p = start
while p < split do
local chunk_to = math.min(p + 4096, split)
local chunk = buf:slice(p, chunk_to)
local ws = chunk:match("^[ \t]*")
table.insert(parts, ws)
if #ws < #chunk then break end
p = chunk_to
end
return table.concat(parts)
end
-- Right-gravity translation of `pos` through the effective edit

View File

@ -7,12 +7,14 @@ language-agnostic, as one undoable edit. Second-to-last Arc 2 item;
auto-pairing follows as its own framing + PR.
Roadmap: `docs/roadmap-2026-07.md` Arc 2 ("auto-indent on newline").
Revision 5: Q#AI9 is success-gated (`insert_char` reports success;
clear only on Ok — a rejected edit must not mutate selection state),
and Q#AI2 names the one deliberate behavior change `buffer.newline`
inherits through the shared primitive. Earlier: live-origin
translation (Q#AI8), M6.4 same-kind intercept contract (Q#AI5),
empty-selection core fix with the GPU residual named (Q#AI9).
Revision 6 (PR #109 round 1): Q#AI8's invalidation is one helper
invoked from all four edit paths — dispatch, direct notification,
undo, redo — and the empty-anchor clear moved daemon-side, closing
the optimistic case for BOTH frontends (the residual was never
GPU-only: the TUI mirror tracks no selection state). The acceptance
matrix now matches what the suites actually pin. Earlier revisions:
success-gated Q#AI9, live-origin translation, M6.4 same-kind
intercept contract, modal-context corrections.
## Ground truth (as of `efa41cb`)
@ -310,14 +312,17 @@ anyway.
Three parts, all required:
1. **Mark**: `notify_buffer_edit` gains the same
`SearchStore::mark_stale` call `apply_active_edit` already makes.
One place, all three callers fixed: applied CRDT ops
(`src/daemon.rs:2133`), general Lua mutator edits
(`src/lua_bindings/mod.rs:1390-1395`), and the errors-buffer
append (`src/lua.rs:442` — normally a no-op, unless the *errors*
buffer itself carries accepted search state, in which case
marking it stale is exactly right).
1. **Mark**: one helper, `search_invalidate_for_edit` (mark stale +
translate the origin), invoked from **all four edit paths**:
`apply_active_edit` (dispatch), `notify_buffer_edit` (applied CRDT
ops `src/daemon.rs:2133`, general Lua mutator edits
`src/lua_bindings/mod.rs:1390-1395`, and the errors-buffer append
`src/lua.rs:442` — normally a no-op, unless the *errors* buffer
itself carries accepted search state, in which case marking it
stale is exactly right), and — round-1 finding — `undo` / `redo`,
which receive precise `Edit` values but previously invalidated
nothing. Generated-buffer rebuilds via `rebuild_views_for` remain
a named lower-frequency bypass (deferral).
2. **Honor (fail closed)**: `SearchStore::step` returns `None` while
stale — C-s / `search.next` stops navigating byte ranges that no
longer exist instead of teleporting the cursor to them — and
@ -363,14 +368,22 @@ input, on both frontends' round-trip paths, and makes
`insert_char_over_region`'s existing "any selection is cleared" doc
claim true.
**Out of scope, named**: the GPU optimistic path inherits the bug
independently — its eligibility gate reads Selection *decorations*,
which an empty selection never paints (ground truth), and the
daemon's CRDT-apply arm clears no anchors. Optimistic typing over an
empty anchor therefore still arms a surprise selection that the next
round-tripped key can consume. Fixing that means teaching the CRDT
arm about anchors — deferred alongside the substrate window-state
reconciliation work, explicitly, not silently.
**Also in scope (round 1: the residual was never GPU-only)**: the
TUI attach mirror tracks buffers and cursors but no selection state
(`src/buffer_mirror.rs:113`), and its optimistic gate checks cursor
freshness and EOL only (`src/optimistic.rs:250`) — so both
frontends' optimistic paths could re-arm the type-over. The fix
lives in the daemon's CRDT source arm (`handle_remote_crdt_op`):
before applying the source cursor update, a selection whose anchor
equals the pre-edit cursor — i.e. one that was EMPTY — is cleared.
Nonempty selections stand untouched.
**Out of scope, named**: the TUI optimistic gate performs no
type-over check at all — a NONEMPTY selection ending at EOL
optimistically inserts instead of consuming the region (the GPU
gates on Selection decorations, which nonempty selections do paint,
so it round-trips correctly there). That is a pre-existing TUI gate
gap, deferred with the substrate reconciliation work.
## Bets
@ -409,10 +422,12 @@ reconciliation work, explicitly, not silently.
per-command — including windows other than the acting one), and
aligning comment.lua's transformed-intercept fix-up with Q#AI5's
translate-and-clamp discipline.
- **GPU-optimistic empty-anchor residual** (Q#AI9): the daemon CRDT
arm clears no selection anchors, and an empty anchor paints no
Selection decoration to gate on — optimistic typing can still arm
a type-over one keystroke later.
- **TUI optimistic type-over gate gap** (Q#AI9 round 1): the TUI
attach gate consults no selection state, so a NONEMPTY selection
ending at EOL optimistically inserts instead of type-over. (The
empty-anchor half was fixed daemon-side in round 1.)
- **Generated-buffer rebuilds** (`rebuild_views_for`) bypass search
invalidation — a lower-frequency Q#AI8 gap, named not handled.
- Auto-recompute of a stale search from the stored query on step
(Q#AI8 fails closed instead).
- Unifying the five hardcoded tab-width sites (4× core `TAB_WIDTH=8`,
@ -459,26 +474,30 @@ reconciliation work, explicitly, not silently.
untouched — the original window's validity after such an intercept
belongs to the substrate-reconciliation deferral, and the test does
not claim it.
- **Search staleness** (Q#AI8):
- isearch, accept, RET → accepted highlights marked stale; a
direct `buf:insert` case pins the notify-path level.
- Direct/remote edit during **active** isearch → `C-s` is a no-op
(cursor unmoved, no jump to obsolete offsets) and the n/m summary
reads `(None, 0)`; typing the next pattern char refreshes and
stepping resumes.
- **Origin translation**: during a live search, an external insert
and a delete strictly before the origin → the next pattern
character focuses relative to the translated origin, and cancel
restores the cursor to the translated position — exercised via
both the notify path (Lua mutator/CRDT) and the other-frontend
dispatch path.
- Post-accept edit → `search.next` no-op instead of stale
navigation.
- **Search staleness** (Q#AI8; acceptance + lib split, per what each
seam can reach):
- Acceptance: isearch, accept, RET → post-accept `search_step`
no-op; the same via a direct `buf:insert` (notify path).
- Lib (editor_core): edit during **active** isearch → step no-op
and summary `(None, 0)`, then the next pattern keystroke
refreshes and stepping resumes; origin translation through
**inserts and deletes on both edit paths** (dispatch and
notify), pinning recompute focus and cancel restore; **undo and
redo** stale accepted matches and translate the live origin.
- Lib (search store): stale `step` fails closed; a fresh `set`
un-sticks it.
- Lib (daemon, `--features crdt`): the optimistic source arm
clears an EMPTY anchor and leaves a NONEMPTY selection alone.
- `after-edit` fires exactly once per RET (keybound and `M-x`).
- Kill-chain break: `C-k`, RET, `C-k` → two ring entries.
- Modal contexts unaffected: isearch accept, query-replace prompt,
context-menu invoke, minibuffer accept (the `m_x` helper exercises
it), completion-popup accept.
- Modal contexts: isearch accept and minibuffer accept pinned here
(plus buffer-list RET below). Query-replace, context-menu, and
completion-popup RET are pinned by their own suites
(`tests/query_replace_acceptance.rs`, the menu/completion
acceptance suites), which run in the gates — not re-pinned here.
- Giant minified line (64 KiB, unindented and indented): splits
correctly — pins the forward-chunked indent scan's behavior
(boundedness is by construction).
- Buffer-list RET still visits via dispatch (now also reachable from
the GPU frontend).
- `this_command()` after RET is `edit.newline-and-indent`.

View File

@ -2111,6 +2111,19 @@ fn handle_remote_crdt_op(
continue;
}
if Some(*wid) == source_active_window_id {
// Q#AI9 (PR #109 round 1): an empty anchor armed at
// the pre-edit cursor must not survive the cursor
// moving off it — otherwise the optimistic paths
// (GPU always; TUI mirror, which tracks no selection
// state) re-arm the type-over that
// `insert_char_over_region`'s no-region clear fixed
// on the dispatch path. Nonempty selections stand:
// the TUI gate's missing type-over check is a named
// deferral, and guessing here would destroy a real
// selection.
if win.selection.map(|sel| sel.anchor) == Some(win.cursor) {
win.selection = None;
}
// Source window: set directly to optimistic post-edit
// position (matches the source frontend's mirror
// cursor after advance/retreat).
@ -2551,6 +2564,114 @@ mod tests {
);
}
/// Q#AI9 (PR #109 round 1): the optimistic-apply arm clears an
/// EMPTY anchor on the source window — the GPU always takes this
/// path, and the TUI attach mirror tracks no selection state, so
/// neither frontend's gate stops an armed-empty-anchor sequence
/// from re-creating the type-over that
/// `insert_char_over_region`'s no-region clear fixed on the
/// dispatch path. A NONEMPTY selection must survive untouched
/// (the TUI gate's missing type-over check is a named deferral).
#[cfg(feature = "crdt")]
#[test]
fn handle_remote_crdt_op_clears_only_an_empty_source_anchor() {
use crate::editor::EditorState;
use crate::protocol::FrontendId;
use crate::window::Selection;
// Shared fixture: CRDT-backed active buffer + a peer doc that
// produces the optimistic op, sourced from LOCAL (which has a
// registered view, so the source-window arm runs).
fn apply_peer_insert(editor: &mut EditorState, buffer_id: crate::buffer::BufferId) {
let snapshot_bytes = {
let core = editor.core.borrow();
let reg = core.registry.borrow();
let buf = reg.get(buffer_id).expect("buffer");
buf.crdt_state()
.expect("crdt-backed")
.export_snapshot()
.expect("export snapshot")
};
let peer = loro::LoroDoc::new();
peer.set_peer_id(u64::from(FrontendId::LOCAL.0))
.expect("set peer id");
peer.import(&snapshot_bytes).expect("import snapshot");
let v_before = peer.oplog_vv();
peer.get_text("body").insert(0, "x").expect("peer insert");
let op_bytes = peer
.export(loro::ExportMode::updates(&v_before))
.expect("export op");
super::handle_remote_crdt_op(
editor,
FrontendId::LOCAL,
buffer_id,
crate::rope::CrdtOp {
peer_id: FrontendId::LOCAL.0,
bytes: op_bytes,
},
);
}
// Case 1: empty anchor at the cursor (S-Left-at-BOF shape) —
// cleared by the optimistic apply.
let mut editor = EditorState::new();
let buffer_id = editor.core.borrow().active_window().buffer_id;
{
let core = editor.core.borrow();
let mut reg = core.registry.borrow_mut();
reg.get_mut(buffer_id)
.expect("active buffer")
.upgrade_to_crdt(2)
.expect("upgrade to crdt");
}
{
let mut core = editor.core.borrow_mut();
let at = core.active_window().cursor;
core.active_window_mut().selection = Some(Selection { anchor: at });
}
apply_peer_insert(&mut editor, buffer_id);
{
let core = editor.core.borrow();
assert!(
core.active_window().selection.is_none(),
"an empty anchor must not survive an optimistic source edit"
);
assert_eq!(
core.active_window().cursor,
1,
"cursor at post-edit position"
);
}
// Case 2: nonempty selection — the arm must not touch it.
let mut editor = EditorState::new();
let buffer_id = editor.core.borrow().active_window().buffer_id;
editor.core.borrow_mut().insert_char('a');
editor.core.borrow_mut().insert_char('b');
{
let core = editor.core.borrow();
let mut reg = core.registry.borrow_mut();
reg.get_mut(buffer_id)
.expect("active buffer")
.upgrade_to_crdt(2)
.expect("upgrade to crdt");
}
{
let mut core = editor.core.borrow_mut();
core.active_window_mut().selection = Some(Selection { anchor: 0 });
// cursor is at 2 after the two inserts: nonempty region.
}
apply_peer_insert(&mut editor, buffer_id);
{
let core = editor.core.borrow();
assert_eq!(
core.active_window().selection,
Some(Selection { anchor: 0 }),
"a nonempty selection survives the optimistic source edit"
);
}
}
/// Kill ring Q#KR2 — GPU typing arrives here without touching
/// dispatch_key, so it must update the source frontend's command
/// boundary or `C-k x C-k` on the GPU would append across the typed

View File

@ -1192,17 +1192,28 @@ impl EditorCore {
.push((CrdtOpOrigin::DaemonKey, buffer_id, (**crdt_op).clone()));
}
// Search matches were computed against the pre-edit text, so
// their byte positions are now wrong. Mark the buffer's matches
// stale (M11.8): the producer / TUI overlay suppress them until
// a fresh search re-runs against the current content. No-op for
// a buffer with no search state. The headline isearch bet —
// "stale-after-edit linger" — is closed here.
// their byte positions are now wrong (M11.8): the producer /
// TUI overlay suppress them until a fresh search re-runs. The
// headline isearch bet — "stale-after-edit linger" — is
// closed here.
self.search_invalidate_for_edit(buffer_id, &edit);
Ok(edit.new_rope.len())
}
/// Q#AI8 search invalidation for a landed edit: mark the buffer's
/// matches stale (no-op without search state) and right-gravity-
/// translate the live session origin. ONE helper so every edit
/// path — dispatch ([`Self::apply_active_edit`]), direct
/// notification ([`Self::notify_buffer_edit`]), and history
/// ([`Self::undo`] / [`Self::redo`]) — invalidates identically;
/// a path that skips this leaves highlights, step targets, and
/// the n/m count pointing at pre-edit offsets.
fn search_invalidate_for_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);
Ok(edit.new_rope.len())
self.translate_search_origin(buffer_id, edit);
}
/// Right-gravity-translate the live search origin through an edit
@ -1247,11 +1258,7 @@ impl EditorCore {
/// 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);
self.search_invalidate_for_edit(buffer_id, edit);
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(buffer_id) else {
return;
@ -1897,6 +1904,9 @@ impl EditorCore {
}
}
drop(reg);
// Q#AI8 (PR #109 round 1): history edits move bytes
// like any other edit — invalidate search state.
self.search_invalidate_for_edit(buffer_id, &edit);
// Post-audit-round-5 F27: undo on a CRDT-backed
// buffer produces a crdt_op that must broadcast to
// every replica frontend (including the one whose
@ -1935,6 +1945,8 @@ impl EditorCore {
}
}
drop(reg);
// Q#AI8 — same as undo above.
self.search_invalidate_for_edit(buffer_id, &edit);
// Post-audit-round-5 F27 — same as undo above.
self.queue_daemon_origin_crdt_op(buffer_id, &edit);
}
@ -3826,6 +3838,138 @@ mod tests {
);
}
#[test]
fn undo_and_redo_stale_accepted_search_navigation() {
// Q#AI8 (PR #109 round 1): history edits move bytes like any
// other edit — undo/redo must invalidate search state.
let mut s = from_bytes(b"foo bar foo");
s.active_window_mut().cursor = 0;
assert!(s.insert_char('x')); // the history entry: "xfoo bar foo"
s.search_begin(true, false);
type_query(&mut s, "foo");
s.search_finish(true); // accept
assert_eq!(s.search_match_summary(), (Some(0), 2));
s.undo(); // back to "foo bar foo": offsets moved
assert_eq!(
s.search_match_summary(),
(None, 0),
"undo stales the accepted matches"
);
let before = s.cursor();
s.search_step(true);
assert_eq!(s.cursor(), before, "stale step is a no-op after undo");
// Redo the same way: refresh the matches first (a fresh set
// clears staleness), then redo must stale them again.
s.search_begin(true, false);
type_query(&mut s, "foo");
s.search_finish(true);
assert_eq!(s.search_match_summary().1, 2);
s.redo(); // forward to "xfoo bar foo" again
assert_eq!(
s.search_match_summary(),
(None, 0),
"redo stales the accepted matches"
);
}
#[test]
fn undo_translates_the_live_search_origin() {
let mut s = from_bytes(b"foo bar foo");
s.active_window_mut().cursor = 0;
assert!(s.insert_char('x')); // "xfoo bar foo"
s.active_window_mut().cursor = 5;
s.search_begin(true, false); // origin 5 ('b' of "bar")
type_query(&mut s, "foo");
s.undo(); // removes the 'x' at 0: origin must shift to 4
s.search_finish(false); // cancel
assert_eq!(
s.cursor(),
4,
"cancel lands on the origin translated through the undo"
);
}
#[test]
fn origin_translates_through_deletes_on_both_paths() {
// Dispatch path (apply_active_edit): backspace before the
// origin.
let mut s = from_bytes(b"foo bar foo");
s.active_window_mut().cursor = 5;
s.search_begin(true, false); // origin 5
type_query(&mut s, "foo");
s.active_window_mut().cursor = 2;
s.backspace(); // deletes byte 1: origin 5 -> 4
s.search_finish(false);
assert_eq!(s.cursor(), 4, "origin shifted left by the deleted byte");
// Direct-notification path: a delete edit through
// notify_buffer_edit.
let mut s = from_bytes(b"foo bar foo");
let bid = s.active_buffer_id();
s.active_window_mut().cursor = 5;
s.search_begin(true, false); // origin 5
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::Delete {
range: Range::new(0, 2),
})
.expect("direct delete")
};
s.notify_buffer_edit(bid, &edit);
s.search_finish(false);
assert_eq!(
s.cursor(),
3,
"origin shifted left by the two directly deleted bytes"
);
}
#[test]
fn active_search_fails_closed_while_stale_and_recovers_on_retype() {
// Q#AI8 during a LIVE session: an external edit mid-search
// makes step and summary fail closed; the next pattern
// keystroke recomputes (set clears staleness) and resumes.
let mut s = from_bytes(b"foo bar foo");
let bid = s.active_buffer_id();
s.active_window_mut().cursor = 0;
s.search_begin(true, false);
type_query(&mut s, "fo");
assert_eq!(s.search_match_summary().1, 2);
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_eq!(
s.search_match_summary(),
(None, 0),
"summary fails closed mid-search"
);
let before = s.cursor();
s.search_step(true);
assert_eq!(s.cursor(), before, "step fails closed mid-search");
// Growing the query recomputes against the current text.
type_query(&mut s, "o");
assert_eq!(
s.search_match_summary().1,
2,
"the next pattern keystroke refreshes the match set"
);
assert_eq!(s.cursor(), 2, "focus lands from the translated origin");
s.search_step(true);
assert_eq!(s.cursor(), 10, "stepping resumes after the refresh");
}
#[test]
fn search_backspace_widens_the_match_set() {
let mut s = from_bytes(b"fo foo food");

View File

@ -149,6 +149,27 @@ fn zero_indent_and_empty_buffer_match_plain_newline() {
assert_eq!(cursor(&s), 4);
}
#[test]
fn giant_minified_line_splits_correctly() {
// PR #109 round 1 finding 4: the indent scan is forward-chunked
// and stops at the first non-whitespace byte, so Enter at the end
// of a huge unindented line never materializes the line. This
// pins the behavior; boundedness is by construction.
let long = "x".repeat(64 * 1024);
let mut s = editor_with(&long);
exec(&s, &format!("pmacs.editor.goto_byte({})", long.len()));
press(&mut s, KeyCode::Enter);
assert_eq!(buffer_text(&s), format!("{long}\n"));
assert_eq!(cursor(&s) as usize, long.len() + 1);
// And an indented giant line still carries exactly its indent.
let body = format!(" {long}");
let mut s = editor_with(&body);
exec(&s, &format!("pmacs.editor.goto_byte({})", body.len()));
press(&mut s, KeyCode::Enter);
assert_eq!(buffer_text(&s), format!("{body}\n "));
}
#[test]
fn whitespace_only_line_copies_and_the_abandoned_line_keeps_its_whitespace() {
// Named non-goal (Q#AI3): no trailing-whitespace cleanup on the