From b0bbc867927f5089d8e2762068f1f1d59afbc159 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 12 Jul 2026 15:17:33 +0100 Subject: [PATCH] =?UTF-8?q?fix(edit):=20PR=20#110=20round=201=20=E2=80=94?= =?UTF-8?q?=20revision=20postcondition,=20relevance=20gate,=20strict=20pai?= =?UTF-8?q?r=20parsing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding 1 (medium): the typed-edit record now pins the edited buffer's revision after the completing edit; typed_edit_finish re-reads it at dispatch end and drops the record if the command edited again — a redefined buffer.self-insert that replaces the typed char (cursor unmoved) no longer leaves a stale-but-clean record, so `(`-then-replace-with-`[` yields `[`, not `[)`. Bite: post_insert_mutation_by_the_command_kills_the_record. Finding 2 (medium): pair-set relevance is established before the clean/context gates, so a transformed or relocated character outside the active set stays silent instead of drawing an auto-pair report. Bite: transformed_non_pair_char_stays_silent. Finding 3 (medium): split_pair parses EXACTLY two codepoints and rejects trailing bytes — a "()x" (or "«»x") entry is skipped entirely, never honored as `(` → `)x`; valid multibyte pairs ("«»") pair and skip at byte-correct cursors. Bites: malformed_pair_entries_are_skipped_not_partially_honored, multibyte_pair_entries_pair_and_skip. Finding 4 (low): the record-capture seam is gated behind the opt-in pmacs.pair._capture_records test facility, off by default — no consumed record is retained in production, restoring the Q#AP9 ephemerality the seam had defeated. Seam-reading tests opt in; record_capture_is_off_by_default pins the default. Finding 5 (low): the equal-revision source-context-switch twin is covered — the fan-out is skipped by the active-buffer revision compare, pairing fails closed silently, and no report is possible; the framing scopes the context-change report as best-effort until the buffer-aware edit epoch lands. Framing synced to revision 4 (Q#AP2 entry rule, Q#AP3 relevance-first + best-effort report scope, Q#AP9 revision postcondition + capture facility + the dispatch-path intercept borrow note, acceptance list). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB --- builtin/runtime/pair.lua | 94 ++++++++++--------- docs/auto-pairing-framing.md | 75 +++++++++++++--- src/daemon.rs | 11 +++ src/editor_core.rs | 41 ++++++++- tests/auto_pair_acceptance.rs | 164 +++++++++++++++++++++++++++++++++- 5 files changed, 328 insertions(+), 57 deletions(-) diff --git a/builtin/runtime/pair.lua b/builtin/runtime/pair.lua index 03856d9..231e23d 100644 --- a/builtin/runtime/pair.lua +++ b/builtin/runtime/pair.lua @@ -50,6 +50,16 @@ pmacs.pair.sets = { bash = { "()", "[]", "{}", '""', "''" }, } +-- UTF-8 sequence length from a leading byte; nil on a continuation +-- byte (not a codepoint boundary). +local function cp_len(b) + if b < 0x80 then return 1 end + if b < 0xC0 then return nil end + if b < 0xE0 then return 2 end + if b < 0xF0 then return 3 end + return 4 +end + -- The first full UTF-8 codepoint starting at byte `pos`, as a string, -- or nil at end-of-buffer / on a non-boundary byte. Forward twin of -- lsp.lua's `char_before`; reads at most 4 bytes. @@ -59,44 +69,23 @@ local function char_at(buf, pos) local to = math.min(pos + 4, len) local ok, s = pcall(function() return buf:slice(pos, to) end) if not ok or type(s) ~= "string" or #s == 0 then return nil end - local b = s:byte(1) - local n - if b < 0x80 then - n = 1 - elseif b < 0xC0 then - return nil -- continuation byte: pos is not a codepoint boundary - elseif b < 0xE0 then - n = 2 - elseif b < 0xF0 then - n = 3 - else - n = 4 - end - if n > #s then return nil end + local n = cp_len(s:byte(1)) + if not n or n > #s then return nil end return s:sub(1, n) end --- Split a pair entry into (opener, closer): the first codepoint and --- the rest. nil for entries that aren't two-or-more bytes of --- opener-then-closer (malformed user additions are skipped, not --- errors — the hook must never throw over a config typo). +-- Split a pair entry into (opener, closer): EXACTLY two codepoints, +-- no trailing bytes (PR #110 round 1, finding 3 — "()x" must be +-- skipped entirely, never honored as `(` → `)x`). nil for malformed +-- user additions: skipped, not errors — the hook must never throw +-- over a config typo. local function split_pair(s) if type(s) ~= "string" or #s < 2 then return nil end - local b = s:byte(1) - local n - if b < 0x80 then - n = 1 - elseif b < 0xC0 then - return nil - elseif b < 0xE0 then - n = 2 - elseif b < 0xF0 then - n = 3 - else - n = 4 - end - if n >= #s then return nil end - return s:sub(1, n), s:sub(n + 1) + local n1 = cp_len(s:byte(1)) + if not n1 or n1 >= #s then return nil end + local n2 = cp_len(s:byte(n1 + 1)) + if not n2 or n1 + n2 ~= #s then return nil end + return s:sub(1, n1), s:sub(n1 + 1) end -- The active buffer's pair set: language entry if the language is @@ -158,24 +147,38 @@ local function repair_cursor(win0, buf0, cursor0, estart, estop, einserted) ed.goto_byte(translate(cursor0, estart, estop, einserted)) end +-- Test facility (leading underscore = not stable API), OFF by +-- default: the one-shot record must stay ephemeral in production — +-- retaining every consumed record in a public field would defeat the +-- Q#AP9 contract the take API enforces (PR #110 round 1, finding 4). +-- Acceptance tests flip `_capture_records` on; each fan-out then +-- publishes the record it observed (or nil) to `_last_record`, which +-- is how tests read the exact codepoint / effective triple and prove +-- one-shot-ness (this callback registers first and consumes it). +pmacs.pair._capture_records = false + pmacs.hook.add("buffer.after-edit", function() -- One-shot provenance (Q#AP9). Absence — paste, programmatic edit, - -- manual hook run, rejected insert, stale `this_command` — is a - -- silent non-event; only a live record that then fails a gate + -- manual hook run, rejected insert, a post-insert mutation by the + -- command, stale `this_command` — is a silent non-event; only a + -- live record for a pair-set character that then fails a gate -- reports. local rec = ed.take_typed_edit and ed.take_typed_edit() - -- Test seam (leading underscore = not stable API, like - -- `pmacs.window._overlay_kinds`): the record this fan-out yielded, - -- or nil. This callback registers first and consumes the one-shot - -- record, so acceptance tests observe the exact codepoint / - -- effective triple here — and prove one-shot-ness by taking again. - pmacs.pair._last_record = rec + if pmacs.pair._capture_records then pmacs.pair._last_record = rec end if not rec then return end if not (ed.this_command and ed.this_command() == "buffer.self-insert") then return end local buf = pmacs.window.buffer() if not buf then return end + -- Relevance first (PR #110 round 1, finding 2): pairing has no + -- interest in characters outside the active set, so a transformed + -- or relocated ordinary `a` must stay silent — the reports below + -- are for pair characters only. + local ch = rec.char + local openers, closers = maps_for(active_set()) + if not (openers[ch] or closers[ch]) then return end + -- Fail closed on a transformed source self-insert (Q#AP3): the -- intercept's positional result stands as produced; pairing on top -- of a relocated or expanded opener would compound it. @@ -185,7 +188,12 @@ pmacs.hook.add("buffer.after-edit", function() end -- Fail closed when the source edit's context is no longer current: -- an intercept switched window/buffer, or something moved the - -- cursor off the post-insert position. + -- cursor off the post-insert position. Best-effort by construction: + -- the report needs this fan-out to run at all, and dispatch's + -- active-buffer revision compare (the named buffer-aware edit-epoch + -- deferral) skips the fan-out when a context-switching command + -- lands on a buffer with a coincidentally equal revision — pairing + -- still fails closed there, silently (the record dies un-armed). if buf ~= rec.buffer or pmacs.window.current() ~= rec.window or ed.cursor() ~= rec.post_cursor then @@ -199,9 +207,7 @@ pmacs.hook.add("buffer.after-edit", function() -- onto an unconsumed region. if ed.region() ~= nil then return end - local ch = rec.char local cursor = rec.post_cursor - local openers, closers = maps_for(active_set()) -- Skip-over-close (Q#AP4), checked before insertion so symmetric -- pairs (quotes) step over their own closer: typing `)` at `(|)` diff --git a/docs/auto-pairing-framing.md b/docs/auto-pairing-framing.md index 0479144..3e4a840 100644 --- a/docs/auto-pairing-framing.md +++ b/docs/auto-pairing-framing.md @@ -23,6 +23,19 @@ unrelated text, the context-switch/LSP limit is stated rather than hidden by the cursor guard, non-typed acceptance now drives callbacks that actually fire, and redo is exercised rather than merely named. +Revision 4: PR #110 round 1 — the typed-edit record pins the edited +buffer's revision after the completing edit and dies at dispatch end +if the command edited again (a redefined self-insert that replaces +the typed char can no longer leave a stale-but-clean record); +pair-set relevance is established before any provenance report +(transformed non-pair characters stay silent); pair entries parse as +exactly two codepoints (malformed entries are skipped entirely, never +partially honored); the record-capture seam is an opt-in test +facility, off in production; and the source-context-change *report* +is scoped as best-effort under the active-buffer edit-epoch limit — +an equal-revision context switch skips the fan-out and fails closed +silently. + ## Ground truth (as of `7e127ab`) - **Dispatch is keymap-first for printables** — `Char('(')` resolves @@ -194,9 +207,12 @@ full fix deferred with the pre-existing mixed-history problem ### Q#AP2 — Pair sets: per-language table, conservative default `pmacs.pair.sets` — the `pmacs.comment.strings` shape: language → -array of 2-byte pair strings, plus a `default` entry used when the -language is unknown or has no entry (pairing is useful in scratch -buffers): +array of pair strings, plus a `default` entry used when the language +is unknown or has no entry (pairing is useful in scratch buffers). +An entry is EXACTLY two codepoints — opener then closer, multibyte +allowed (`"«»"`); malformed entries (trailing bytes, non-boundary +first byte) are skipped entirely, never partially honored (R4: a +`"()x"` typo must not turn `(` into `()x`): - `default = { "()", "[]", "{}", '""' }` — no `'` (prose apostrophes), no backtick. @@ -212,6 +228,10 @@ React when ALL hold: - `this_command() == "buffer.self-insert"` **and** Q#AP9 returns a live typed-edit record for this callback (pastes, manual hook runs, and programmatic inserts have no record and never pair); +- **relevance first (R4)**: the record's exact typed codepoint is in + the active pair set at all (opener or closer). Characters outside + the set exit silently BEFORE any gate below can report — a + transformed ordinary `a` is not auto-pairing's business; - the record's buffer/window match the current context, its source edit is clean (effective triple equals the requested insert or replace), and the current cursor equals its recorded post-edit @@ -220,7 +240,13 @@ React when ALL hold: reaction. A non-clean triple reports *"auto-pair skipped: source self-insert transformed"*; a context/cursor mismatch reports *"auto-pair skipped: source context changed"*. This is the - fail-closed answer to R2 finding 2; + fail-closed answer to R2 finding 2. The context-change *report* is + best-effort (R4): it requires the after-edit fan-out to run, and + the dispatcher's active-buffer revision compare (the named + buffer-aware edit-epoch deferral) skips the fan-out when a + context-switching command lands on a buffer whose revision + coincidentally equals the origin's — the record dies un-armed and + pairing fails closed silently; - **no active region survives the edit** (`ed.region() == nil`) — on the dispatch path type-over has already consumed and cleared it; a surviving nonempty region means the edit arrived through the @@ -366,6 +392,19 @@ currently discards the effective range on its way back to `insert_char`; payload immutability means the codepoint itself remains authoritative. +The record additionally pins the edited buffer's revision immediately +after the completing edit — a producer-side postcondition, not +consumer surface (R4). At dispatch end, before arming, the revision +is re-read: if the command edited again after the self-insert (a +redefined `buffer.self-insert` that replaces or removes the typed +character while leaving the cursor in place), the record no longer +describes the buffer and dies un-armed. Note the switch-context case +is reachable only through such a redefined command: a +context-switching *intercept* cannot exist on the dispatch +self-insert path (the core borrow is held across it; the +borrow-released three-phase discipline belongs to the Lua-mutator +path the reaction uses). + The pair callback takes the record; later callbacks and a nested manual re-run of `buffer.after-edit` see nil. The dispatcher/daemon also clears any untaken slot immediately after the hook returns, @@ -373,10 +412,12 @@ including error paths and the no-revision-change path. Plain `pmacs.hook.run`, paste, programmatic mutation, and a stale `this_command == "buffer.self-insert"` therefore see nil. Pairing requires `clean == true`, matching buffer/window, and -`cursor == post_cursor`; otherwise it reports and does nothing. This -is deliberately narrower than teaching every command to expose its -edit: one producer class, one consumer contract, and no persistent -history. +`cursor == post_cursor`; otherwise it reports (pair-set characters +only, R4) and does nothing. This is deliberately narrower than +teaching every command to expose its edit: one producer class, one +consumer contract, and no persistent history. Record retention is +zero in production: the opt-in `pmacs.pair._capture_records` test +facility is the only way a consumed record outlives its fan-out (R4). ## Bets @@ -421,6 +462,9 @@ history. - Quotes pair under the predicate. - Per-language: `'` pairs in `.py`, not in `.rs`; scratch pairs the default set. +- Set-entry parsing (R4): a malformed `"()x"` (and an overlong + multibyte `"«»x"`) pairs nothing; a valid multibyte `"«»"` pairs + and skips at byte-correct cursors. - Non-typed provenance, with the callback actually exercised: production `FrontendEvent::Paste("(")` after a prior self-insert leaves a lone pasted opener; `buf:insert("(")` followed by explicit @@ -440,8 +484,14 @@ history. translate-and-clamp. The **source self-insert** gets separate cases: relocated opener and expanded/relocated type-over produce exactly the intercept's positional result, Q#AP9 reports/skips, and no - unrelated closer is inserted; a source context switch likewise - fails closed. + unrelated closer is inserted; a source context switch (via a + redefined `buffer.self-insert` — the only legal producer, see + Q#AP9) likewise fails closed, in BOTH revision shapes (R4): skewed + revisions report "source context changed"; equal revisions skip the + fan-out entirely and fail closed silently. A relocated **non-pair** + character draws no auto-pair report at all (R4), and a redefined + self-insert that edits again after the insert (replacing the typed + char, cursor unmoved) kills the record — no `[)` (R4). - Context-switching **reaction** intercept → pair cursor repair skipped, new context's text/cursor untouched by pair.lua. A probe callback registered after pair.lua observes the switched context, @@ -459,7 +509,10 @@ history. self-insert hooks; a second take (including a nested manual after-edit run) is nil. It is also nil before/after the fan-out, for paste, for standalone manual hook runs, and after a rejecting edit. - Two frontends cannot see or consume each other's slot. + Two frontends cannot see or consume each other's slot. Exact-record + observation goes through the opt-in `_capture_records` facility; + with it off (production), no consumed record is retained anywhere + (R4). Classifier flips (in-crate): GPU `optimistic_insert_text` returns `None` for the nine pair chars (test updated alongside Enter's); diff --git a/src/daemon.rs b/src/daemon.rs index cc40cf9..973188c 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -2186,6 +2186,16 @@ fn handle_remote_crdt_op( .get(&wid) .is_some_and(|w| w.buffer_id == buffer_id) { + // Revision postcondition anchor: this arm consumes the + // record in the same fan-out (no command body runs after + // the import), so the current revision is trivially the + // post-edit one. + let revision = core + .registry + .borrow() + .get(buffer_id) + .ok() + .map_or(0, crate::buffer::Buffer::revision); core.typed_edit_set_armed( source, crate::editor_core::TypedEditRecord { @@ -2199,6 +2209,7 @@ fn handle_remote_crdt_op( inserted_len: edit.inserted_len, post_cursor: post_edit_cursor, clean: true, + revision, }, ); } diff --git a/src/editor_core.rs b/src/editor_core.rs index da6567c..64b3211 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -181,6 +181,15 @@ pub struct TypedEditRecord { pub post_cursor: u64, /// True iff the effective triple equals the request. pub clean: bool, + /// The edited buffer's revision immediately after the completing + /// edit — a producer-side postcondition, not consumer surface (it + /// is not exposed on the Lua record). `typed_edit_finish` drops + /// the record when the buffer's revision has moved past this: a + /// redefined `buffer.self-insert` that edits again after the + /// insert (removing or replacing the typed character) must not + /// leave a stale-but-"clean" record for the pairing hook (PR #110 + /// round 1, finding 1). + pub revision: u64, } /// In-flight arm for a [`TypedEditRecord`] (auto-pairing Q#AP9): the @@ -2333,6 +2342,17 @@ impl EditorCore { if !matches { return; } + // The revision postcondition anchor: if the buffer vanished + // (killed mid-command), no record — absence fails closed. + let Some(revision) = self + .registry + .borrow() + .get(context.0) + .ok() + .map(Buffer::revision) + else { + return; + }; let clean = edit.range == requested && edit.inserted_len == requested_len; let record = TypedEditRecord { buffer: context.0, @@ -2345,6 +2365,7 @@ impl EditorCore { inserted_len: edit.inserted_len, post_cursor: self.active_window().cursor, clean, + revision, }; if let Some(p) = self.typed_edit_pending.as_mut() { p.record = Some(record); @@ -2355,12 +2376,30 @@ impl EditorCore { /// yielding the completed record (or `None` if the self-insert /// never landed — rejected edit, command error). Always clears the /// pending state: an arm never survives its dispatch cycle. + /// + /// Postcondition (PR #110 round 1, finding 1): the record is + /// yielded only if the edited buffer's revision still equals the + /// one captured at completion. A command body that edited again + /// after the self-insert — replacing or removing the typed + /// character while leaving the cursor in place — produced state + /// the record no longer describes; the record dies here, before + /// it can be armed for the hook. pub fn typed_edit_finish(&mut self, fid: FrontendId) -> Option { let pending = self.typed_edit_pending.take()?; if pending.fid != fid { return None; } - pending.record + let record = pending.record?; + let current = self + .registry + .borrow() + .get(record.buffer) + .ok() + .map(Buffer::revision); + if current != Some(record.revision) { + return None; + } + Some(record) } /// Arm `record` for consumption during the `buffer.after-edit` diff --git a/tests/auto_pair_acceptance.rs b/tests/auto_pair_acceptance.rs index 5223947..1998b04 100644 --- a/tests/auto_pair_acceptance.rs +++ b/tests/auto_pair_acceptance.rs @@ -260,6 +260,40 @@ fn single_quote_pairs_in_python_but_not_rust() { ); } +#[test] +fn malformed_pair_entries_are_skipped_not_partially_honored() { + // PR #110 round 1, finding 3: an entry is exactly two codepoints. + // "()x" must be ignored entirely — never "type `(`, get `)x`". + let mut s = editor_with(""); + exec(&s, "pmacs.pair.sets.default = { \"()x\" }"); + type_str(&mut s, "("); + assert_eq!(buffer_text(&s), "(", "a malformed entry pairs nothing"); + assert_eq!(cursor(&s), 1); + + // Overlong multibyte entry: same rule after a 2-byte opener. + let mut s2 = editor_with(""); + exec(&s2, "pmacs.pair.sets.default = { \"\u{ab}\u{bb}x\" }"); // "«»x" + type_str(&mut s2, "\u{ab}"); + assert_eq!( + buffer_text(&s2), + "\u{ab}", + "an overlong entry pairs nothing" + ); +} + +#[test] +fn multibyte_pair_entries_pair_and_skip() { + // Two-codepoint entries with multibyte members are valid: guillemets. + let mut s = editor_with(""); + exec(&s, "pmacs.pair.sets.default = { \"\u{ab}\u{bb}\" }"); // "«»" + type_str(&mut s, "\u{ab}"); + assert_eq!(buffer_text(&s), "\u{ab}\u{bb}"); + assert_eq!(cursor(&s), 2, "cursor between the pair (byte offset)"); + type_str(&mut s, "\u{bb}"); + assert_eq!(buffer_text(&s), "\u{ab}\u{bb}", "the closer skips"); + assert_eq!(cursor(&s), 4); +} + #[test] fn scratch_buffer_pairs_the_default_set() { let mut s = editor_with(""); @@ -293,9 +327,14 @@ fn scratch_buffer_pairs_the_default_set() { #[test] fn paste_of_opener_does_not_pair() { let mut s = editor_with(""); + exec(&s, "pmacs.pair._capture_records = true"); // A prior self-insert, so a heuristic keyed only on buffer text or - // `char_before` would be primed to misfire. + // `char_before` would be primed to misfire. (It also proves the + // capture seam live: the nil assertion below is a transition from + // this keystroke's captured record, not an unset field.) type_str(&mut s, "a"); + let primed: bool = eval(&s, "return pmacs.pair._last_record ~= nil"); + assert!(primed, "the typed `a` captured a record"); // The daemon's unified inbound-paste route, faithfully: break the // source's command chain, insert, fire the after-edit hook // (`handle_inbound_paste` + `with_after_edit_check`). @@ -311,6 +350,7 @@ fn paste_of_opener_does_not_pair() { #[test] fn programmatic_insert_with_stale_this_command_does_not_pair() { let mut s = editor_with(""); + exec(&s, "pmacs.pair._capture_records = true"); // Type 'a' so `this_command` is (and stays) "buffer.self-insert" — // the deliberately stale signal the provenance gate must ignore. type_str(&mut s, "a"); @@ -331,6 +371,7 @@ fn programmatic_insert_with_stale_this_command_does_not_pair() { #[test] fn command_invoke_self_insert_does_not_pair() { let s = editor_with(""); + exec(&s, "pmacs.pair._capture_records = true"); // Plain `pmacs.command.invoke` is the programmatic API: it stamps // no boundary and arms no record. exec(&s, "pmacs.command.invoke(\"buffer.self-insert\", 40)"); // '(' @@ -523,6 +564,68 @@ fn expanded_skip_delete_lands_reported_and_cursor_clamped() { // Source self-insert intercepts (Q#AP9): the reaction fails closed // --------------------------------------------------------------------------- +#[test] +fn post_insert_mutation_by_the_command_kills_the_record() { + // PR #110 round 1, finding 1: a redefined `buffer.self-insert` + // that inserts the char and then REPLACES it — leaving the cursor + // untouched — must not pair off the stale record. The record pins + // the buffer revision after the completing edit; any further edit + // by the same command kills it before the fan-out. + let mut s = editor_with(""); + exec( + &s, + r#" + pmacs.command.unregister("buffer.self-insert") + pmacs.command.define { + name = "buffer.self-insert", + description = "test override: insert, then replace the typed char", + fn = function(cp) + pmacs.editor.insert_char_over_region(cp) + pmacs.window.buffer():replace(0, 1, "[") + end, + } + "#, + ); + type_str(&mut s, "("); + assert_eq!( + buffer_text(&s), + "[", + "the typed `(` no longer exists; a `)` reaction would produce `[)`" + ); + assert_eq!(cursor(&s), 1); + assert!( + !status(&s).contains("auto-pair"), + "a dead record is a silent non-event; got: {:?}", + status(&s) + ); +} + +#[test] +fn transformed_non_pair_char_stays_silent() { + // PR #110 round 1, finding 2: pairing has no interest in `a`; an + // intercept relocating it must not draw an auto-pair report. + let mut s = editor_with("xy"); + exec(&s, "pmacs.editor.goto_byte(2)"); + exec( + &s, + r#" + pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op) + if op.kind == "insert" and op.bytes == "a" then + return { kind = "insert", pos = 0, bytes = op.bytes } + end + return nil + end) + "#, + ); + type_str(&mut s, "a"); + assert_eq!(buffer_text(&s), "axy"); + assert!( + !status(&s).contains("auto-pair"), + "chars outside the active pair set must stay silent; got: {:?}", + status(&s) + ); +} + #[test] fn relocated_opener_gets_no_pair_reaction() { let mut s = editor_with("ab"); @@ -632,6 +735,50 @@ fn source_context_switch_fails_closed() { assert_eq!(other_text, "z", "the switched-to buffer is untouched"); } +#[test] +fn source_context_switch_with_equal_revisions_fails_closed_silently() { + // PR #110 round 1, finding 5: the twin of the test above WITHOUT + // the revision bump. Dispatch's active-buffer revision compare + // (pre = scratch@0, post = other@0) sees no delta, so the + // after-edit fan-out never runs: no reaction anywhere, and no + // context-change report either — the record dies un-armed. The + // report is best-effort until the buffer-aware edit epoch lands + // (named substrate deferral); failing closed is unconditional. + let dir = fresh_state_dir(); + let mut s = editor(&dir); + let other = write_file(&dir, "other.txt", "z"); + exec(&s, "_G.scratch = pmacs.window.buffer()"); + exec(&s, &format!("pmacs.buffer.find_or_open({other:?})")); + exec(&s, "_G.other = pmacs.window.buffer()"); + exec(&s, "pmacs.window.switch_buffer(_G.scratch)"); + exec( + &s, + r#" + pmacs.command.unregister("buffer.self-insert") + pmacs.command.define { + name = "buffer.self-insert", + description = "test override: insert, then switch context", + fn = function(cp) + pmacs.editor.insert_char_over_region(cp) + pmacs.window.switch_buffer(_G.other) + end, + } + "#, + ); + type_str(&mut s, "("); + let scratch_text: String = eval(&s, "return _G.scratch:slice(0, _G.scratch:len())"); + assert_eq!(scratch_text, "(", "the opener landed in scratch, no closer"); + let other_text: String = eval(&s, "return _G.other:slice(0, _G.other:len())"); + assert_eq!(other_text, "z", "the switched-to buffer is untouched"); + assert!( + !status(&s).contains("auto-pair"), + "no fan-out ran, so no report is possible; got: {:?}", + status(&s) + ); + let take_nil: bool = eval(&s, "return pmacs.editor.take_typed_edit() == nil"); + assert!(take_nil, "the record was never armed"); +} + // --------------------------------------------------------------------------- // Context-switching REACTION intercept: repair skipped, deferral pinned // --------------------------------------------------------------------------- @@ -728,6 +875,7 @@ fn typed_edit_record_is_exact_and_one_shot() { exec( &s, r#" + pmacs.pair._capture_records = true _G.second_take = "unset" pmacs.hook.add("buffer.after-edit", function() _G.second_take = pmacs.editor.take_typed_edit() @@ -768,6 +916,7 @@ fn nested_manual_after_edit_run_sees_no_record() { exec( &s, r#" + pmacs.pair._capture_records = true _G.outer = nil _G.ran_nested = false pmacs.hook.add("buffer.after-edit", function() @@ -788,6 +937,18 @@ fn nested_manual_after_edit_run_sees_no_record() { assert_eq!(buffer_text(&s), "()", "and must insert no second closer"); } +#[test] +fn record_capture_is_off_by_default() { + // PR #110 round 1, finding 4: without the explicit test facility, + // no consumed record is retained anywhere — the one-shot take API + // is the only access, and it is empty after the fan-out. + let mut s = editor_with(""); + type_str(&mut s, "("); + assert_eq!(buffer_text(&s), "()"); + let leaked: bool = eval(&s, "return pmacs.pair._last_record ~= nil"); + assert!(!leaked, "production keystrokes must retain no record"); +} + #[test] fn rejected_self_insert_leaves_no_record() { let mut s = editor_with(""); @@ -823,6 +984,7 @@ fn frontends_cannot_consume_each_others_slot() { inserted_len: 1, post_cursor: 1, clean: true, + revision: 0, }; let a = FrontendId::LOCAL; let b = FrontendId(a.0 + 1);