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:
parent
f4e3d9d85a
commit
7b5365cfbf
|
|
@ -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 ---------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
Loading…
Reference in New Issue