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 = { "()", "[]", "{}", '""', "''" }, 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, -- 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 -- or nil at end-of-buffer / on a non-boundary byte. Forward twin of
-- lsp.lua's `char_before`; reads at most 4 bytes. -- 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 to = math.min(pos + 4, len)
local ok, s = pcall(function() return buf:slice(pos, to) end) 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 if not ok or type(s) ~= "string" or #s == 0 then return nil end
local b = s:byte(1) local n = cp_len(s:byte(1))
local n if not n or n > #s then return nil end
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
return s:sub(1, n) return s:sub(1, n)
end end
-- Split a pair entry into (opener, closer): the first codepoint and -- Split a pair entry into (opener, closer): EXACTLY two codepoints,
-- the rest. nil for entries that aren't two-or-more bytes of -- no trailing bytes (PR #110 round 1, finding 3 — "()x" must be
-- opener-then-closer (malformed user additions are skipped, not -- skipped entirely, never honored as `(` → `)x`). nil for malformed
-- errors — the hook must never throw over a config typo). -- user additions: skipped, not errors — the hook must never throw
-- over a config typo.
local function split_pair(s) local function split_pair(s)
if type(s) ~= "string" or #s < 2 then return nil end if type(s) ~= "string" or #s < 2 then return nil end
local b = s:byte(1) local n1 = cp_len(s:byte(1))
local n if not n1 or n1 >= #s then return nil end
if b < 0x80 then local n2 = cp_len(s:byte(n1 + 1))
n = 1 if not n2 or n1 + n2 ~= #s then return nil end
elseif b < 0xC0 then return s:sub(1, n1), s:sub(n1 + 1)
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)
end end
-- The active buffer's pair set: language entry if the language is -- 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)) ed.goto_byte(translate(cursor0, estart, estop, einserted))
end 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() pmacs.hook.add("buffer.after-edit", function()
-- One-shot provenance (Q#AP9). Absence — paste, programmatic edit, -- One-shot provenance (Q#AP9). Absence — paste, programmatic edit,
-- manual hook run, rejected insert, stale `this_command` — is a -- manual hook run, rejected insert, a post-insert mutation by the
-- silent non-event; only a live record that then fails a gate -- command, stale `this_command` — is a silent non-event; only a
-- live record for a pair-set character that then fails a gate
-- reports. -- reports.
local rec = ed.take_typed_edit and ed.take_typed_edit() local rec = ed.take_typed_edit and ed.take_typed_edit()
-- Test seam (leading underscore = not stable API, like if pmacs.pair._capture_records then pmacs.pair._last_record = rec end
-- `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 not rec then return end if not rec then return end
if not (ed.this_command and ed.this_command() == "buffer.self-insert") then return end if not (ed.this_command and ed.this_command() == "buffer.self-insert") then return end
local buf = pmacs.window.buffer() local buf = pmacs.window.buffer()
if not buf then return end 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 -- Fail closed on a transformed source self-insert (Q#AP3): the
-- intercept's positional result stands as produced; pairing on top -- intercept's positional result stands as produced; pairing on top
-- of a relocated or expanded opener would compound it. -- of a relocated or expanded opener would compound it.
@ -185,7 +188,12 @@ pmacs.hook.add("buffer.after-edit", function()
end end
-- Fail closed when the source edit's context is no longer current: -- Fail closed when the source edit's context is no longer current:
-- an intercept switched window/buffer, or something moved the -- 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 if buf ~= rec.buffer
or pmacs.window.current() ~= rec.window or pmacs.window.current() ~= rec.window
or ed.cursor() ~= rec.post_cursor then or ed.cursor() ~= rec.post_cursor then
@ -199,9 +207,7 @@ pmacs.hook.add("buffer.after-edit", function()
-- onto an unconsumed region. -- onto an unconsumed region.
if ed.region() ~= nil then return end if ed.region() ~= nil then return end
local ch = rec.char
local cursor = rec.post_cursor local cursor = rec.post_cursor
local openers, closers = maps_for(active_set())
-- Skip-over-close (Q#AP4), checked before insertion so symmetric -- Skip-over-close (Q#AP4), checked before insertion so symmetric
-- pairs (quotes) step over their own closer: typing `)` at `(|)` -- 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 hidden by the cursor guard, non-typed acceptance now drives callbacks
that actually fire, and redo is exercised rather than merely named. 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`) ## Ground truth (as of `7e127ab`)
- **Dispatch is keymap-first for printables**`Char('(')` resolves - **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 ### Q#AP2 — Pair sets: per-language table, conservative default
`pmacs.pair.sets` — the `pmacs.comment.strings` shape: language → `pmacs.pair.sets` — the `pmacs.comment.strings` shape: language →
array of 2-byte pair strings, plus a `default` entry used when the array of pair strings, plus a `default` entry used when the language
language is unknown or has no entry (pairing is useful in scratch is unknown or has no entry (pairing is useful in scratch buffers).
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 - `default = { "()", "[]", "{}", '""' }` — no `'` (prose
apostrophes), no backtick. apostrophes), no backtick.
@ -212,6 +228,10 @@ React when ALL hold:
- `this_command() == "buffer.self-insert"` **and** Q#AP9 returns a - `this_command() == "buffer.self-insert"` **and** Q#AP9 returns a
live typed-edit record for this callback (pastes, manual hook runs, live typed-edit record for this callback (pastes, manual hook runs,
and programmatic inserts have no record and never pair); 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 - the record's buffer/window match the current context, its source
edit is clean (effective triple equals the requested insert or edit is clean (effective triple equals the requested insert or
replace), and the current cursor equals its recorded post-edit 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 reaction. A non-clean triple reports *"auto-pair skipped: source
self-insert transformed"*; a context/cursor mismatch reports self-insert transformed"*; a context/cursor mismatch reports
*"auto-pair skipped: source context changed"*. This is the *"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`) — - **no active region survives the edit** (`ed.region() == nil`) —
on the dispatch path type-over has already consumed and cleared on the dispatch path type-over has already consumed and cleared
it; a surviving nonempty region means the edit arrived through the 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 `insert_char`; payload immutability means the codepoint itself remains
authoritative. 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 The pair callback takes the record; later callbacks and a nested
manual re-run of `buffer.after-edit` see nil. The dispatcher/daemon manual re-run of `buffer.after-edit` see nil. The dispatcher/daemon
also clears any untaken slot immediately after the hook returns, 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 `pmacs.hook.run`, paste, programmatic mutation, and a stale
`this_command == "buffer.self-insert"` therefore see nil. Pairing `this_command == "buffer.self-insert"` therefore see nil. Pairing
requires `clean == true`, matching buffer/window, and requires `clean == true`, matching buffer/window, and
`cursor == post_cursor`; otherwise it reports and does nothing. This `cursor == post_cursor`; otherwise it reports (pair-set characters
is deliberately narrower than teaching every command to expose its only, R4) and does nothing. This is deliberately narrower than
edit: one producer class, one consumer contract, and no persistent teaching every command to expose its edit: one producer class, one
history. 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 ## Bets
@ -421,6 +462,9 @@ history.
- Quotes pair under the predicate. - Quotes pair under the predicate.
- Per-language: `'` pairs in `.py`, not in `.rs`; scratch pairs the - Per-language: `'` pairs in `.py`, not in `.rs`; scratch pairs the
default set. 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: - Non-typed provenance, with the callback actually exercised:
production `FrontendEvent::Paste("(")` after a prior self-insert production `FrontendEvent::Paste("(")` after a prior self-insert
leaves a lone pasted opener; `buf:insert("(")` followed by explicit 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: translate-and-clamp. The **source self-insert** gets separate cases:
relocated opener and expanded/relocated type-over produce exactly relocated opener and expanded/relocated type-over produce exactly
the intercept's positional result, Q#AP9 reports/skips, and no the intercept's positional result, Q#AP9 reports/skips, and no
unrelated closer is inserted; a source context switch likewise unrelated closer is inserted; a source context switch (via a
fails closed. 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 - Context-switching **reaction** intercept → pair cursor repair
skipped, new context's text/cursor untouched by pair.lua. A probe skipped, new context's text/cursor untouched by pair.lua. A probe
callback registered after pair.lua observes the switched context, callback registered after pair.lua observes the switched context,
@ -459,7 +509,10 @@ history.
self-insert hooks; a second take (including a nested manual 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 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. 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 Classifier flips (in-crate): GPU `optimistic_insert_text` returns
`None` for the nine pair chars (test updated alongside Enter's); `None` for the nine pair chars (test updated alongside Enter's);

View File

@ -2186,6 +2186,16 @@ fn handle_remote_crdt_op(
.get(&wid) .get(&wid)
.is_some_and(|w| w.buffer_id == buffer_id) .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( core.typed_edit_set_armed(
source, source,
crate::editor_core::TypedEditRecord { crate::editor_core::TypedEditRecord {
@ -2199,6 +2209,7 @@ fn handle_remote_crdt_op(
inserted_len: edit.inserted_len, inserted_len: edit.inserted_len,
post_cursor: post_edit_cursor, post_cursor: post_edit_cursor,
clean: true, clean: true,
revision,
}, },
); );
} }

View File

@ -181,6 +181,15 @@ pub struct TypedEditRecord {
pub post_cursor: u64, pub post_cursor: u64,
/// True iff the effective triple equals the request. /// True iff the effective triple equals the request.
pub clean: bool, 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 /// In-flight arm for a [`TypedEditRecord`] (auto-pairing Q#AP9): the
@ -2333,6 +2342,17 @@ impl EditorCore {
if !matches { if !matches {
return; 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 clean = edit.range == requested && edit.inserted_len == requested_len;
let record = TypedEditRecord { let record = TypedEditRecord {
buffer: context.0, buffer: context.0,
@ -2345,6 +2365,7 @@ impl EditorCore {
inserted_len: edit.inserted_len, inserted_len: edit.inserted_len,
post_cursor: self.active_window().cursor, post_cursor: self.active_window().cursor,
clean, clean,
revision,
}; };
if let Some(p) = self.typed_edit_pending.as_mut() { if let Some(p) = self.typed_edit_pending.as_mut() {
p.record = Some(record); p.record = Some(record);
@ -2355,12 +2376,30 @@ impl EditorCore {
/// yielding the completed record (or `None` if the self-insert /// yielding the completed record (or `None` if the self-insert
/// never landed — rejected edit, command error). Always clears the /// never landed — rejected edit, command error). Always clears the
/// pending state: an arm never survives its dispatch cycle. /// 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> { pub fn typed_edit_finish(&mut self, fid: FrontendId) -> Option<TypedEditRecord> {
let pending = self.typed_edit_pending.take()?; let pending = self.typed_edit_pending.take()?;
if pending.fid != fid { if pending.fid != fid {
return None; 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` /// 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] #[test]
fn scratch_buffer_pairs_the_default_set() { fn scratch_buffer_pairs_the_default_set() {
let mut s = editor_with(""); let mut s = editor_with("");
@ -293,9 +327,14 @@ fn scratch_buffer_pairs_the_default_set() {
#[test] #[test]
fn paste_of_opener_does_not_pair() { fn paste_of_opener_does_not_pair() {
let mut s = editor_with(""); 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 // 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"); 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 // The daemon's unified inbound-paste route, faithfully: break the
// source's command chain, insert, fire the after-edit hook // source's command chain, insert, fire the after-edit hook
// (`handle_inbound_paste` + `with_after_edit_check`). // (`handle_inbound_paste` + `with_after_edit_check`).
@ -311,6 +350,7 @@ fn paste_of_opener_does_not_pair() {
#[test] #[test]
fn programmatic_insert_with_stale_this_command_does_not_pair() { fn programmatic_insert_with_stale_this_command_does_not_pair() {
let mut s = editor_with(""); let mut s = editor_with("");
exec(&s, "pmacs.pair._capture_records = true");
// Type 'a' so `this_command` is (and stays) "buffer.self-insert" — // Type 'a' so `this_command` is (and stays) "buffer.self-insert" —
// the deliberately stale signal the provenance gate must ignore. // the deliberately stale signal the provenance gate must ignore.
type_str(&mut s, "a"); type_str(&mut s, "a");
@ -331,6 +371,7 @@ fn programmatic_insert_with_stale_this_command_does_not_pair() {
#[test] #[test]
fn command_invoke_self_insert_does_not_pair() { fn command_invoke_self_insert_does_not_pair() {
let s = editor_with(""); let s = editor_with("");
exec(&s, "pmacs.pair._capture_records = true");
// Plain `pmacs.command.invoke` is the programmatic API: it stamps // Plain `pmacs.command.invoke` is the programmatic API: it stamps
// no boundary and arms no record. // no boundary and arms no record.
exec(&s, "pmacs.command.invoke(\"buffer.self-insert\", 40)"); // '(' 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 // 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] #[test]
fn relocated_opener_gets_no_pair_reaction() { fn relocated_opener_gets_no_pair_reaction() {
let mut s = editor_with("ab"); 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"); 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 // Context-switching REACTION intercept: repair skipped, deferral pinned
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -728,6 +875,7 @@ fn typed_edit_record_is_exact_and_one_shot() {
exec( exec(
&s, &s,
r#" r#"
pmacs.pair._capture_records = true
_G.second_take = "unset" _G.second_take = "unset"
pmacs.hook.add("buffer.after-edit", function() pmacs.hook.add("buffer.after-edit", function()
_G.second_take = pmacs.editor.take_typed_edit() _G.second_take = pmacs.editor.take_typed_edit()
@ -768,6 +916,7 @@ fn nested_manual_after_edit_run_sees_no_record() {
exec( exec(
&s, &s,
r#" r#"
pmacs.pair._capture_records = true
_G.outer = nil _G.outer = nil
_G.ran_nested = false _G.ran_nested = false
pmacs.hook.add("buffer.after-edit", function() 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"); 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] #[test]
fn rejected_self_insert_leaves_no_record() { fn rejected_self_insert_leaves_no_record() {
let mut s = editor_with(""); let mut s = editor_with("");
@ -823,6 +984,7 @@ fn frontends_cannot_consume_each_others_slot() {
inserted_len: 1, inserted_len: 1,
post_cursor: 1, post_cursor: 1,
clean: true, clean: true,
revision: 0,
}; };
let a = FrontendId::LOCAL; let a = FrontendId::LOCAL;
let b = FrontendId(a.0 + 1); let b = FrontendId(a.0 + 1);