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:
parent
223e26420b
commit
b0bbc86792
|
|
@ -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 `(|)`
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue