fix(edit): PR #110 round 1 — revision postcondition, relevance gate, strict pair parsing

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
This commit is contained in:
Levi Neuwirth 2026-07-12 15:17:33 +01:00
parent 223e26420b
commit b0bbc86792
5 changed files with 328 additions and 57 deletions

View File

@ -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 `(|)`

View File

@ -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);

View File

@ -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,
},
);
}

View File

@ -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<TypedEditRecord> {
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`

View File

@ -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);