pmacs/docs/auto-pairing-framing.md

582 lines
32 KiB
Markdown

# Auto-pairing — framing (Arc 2, editing table stakes)
Typing `(` should give `()` with the cursor between; typing `)` when
the next char is already `)` should step over it instead of doubling
it. Language-aware pair sets, conservative insertion predicate. Last
Arc 2 item; after it merges, Arc 2 closes and the compile-mode vs
themes decision discussion is due.
Roadmap: `docs/roadmap-2026-07.md` Arc 2 ("auto-pairing").
Revision 2: R1 findings — pair chars now route through dispatch
(peer-bound undo falsified the pure-reaction undo story), pair.lua
loads before lsp.lua (the sighelp path flushes didChange
synchronously mid-hook), rejected/transformed intercept outcomes
separated (the effective edit has already landed), the type-over
claim is path-qualified with a region guard, and the CRDT acceptance
gains a second replica plus undo cases for both routing models.
Revision 3: R2 findings — the dispatch route no longer claims global
chronological undo (an older source-peer edit still wins the TUI's
local undo arbitration), source self-inserts gain an exact ephemeral
typed-edit record so a relocated opener can never be inferred from
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.
Revision 5: PR #110 round 2 — set entries validate full UTF-8
well-formedness (Table 3-7: continuation bytes, overlong encodings,
surrogates, beyond-U+10FFFF all disqualify; lead-byte length alone
had accepted `"(\xC2x"`), and the predicate treats malformed buffer
bytes as word-like (no pairing before junk), never as EOL; relevance
and reporting resolve against the SOURCE buffer's language via the
new `pmacs.lsp.buffer_language(buf)` / `buf:path()` (a
context-switching command no longer attributes them to the
destination buffer); and non-table set containers degrade
language→default→empty instead of throwing from the callback.
Revision 6: PR #110 round 3 — coverage pins only, no code changes:
the predicate's raw-byte posture is pinned from the buffer side
(`(` typed before a lone `0xFF` inserts no closer; a regression to
nil-on-malformed would read junk as end-of-buffer), and the
top-level non-table `pmacs.pair.sets` container is pinned alongside
the per-entry cases (pairs nothing, clean `*errors*`).
## Ground truth (as of `7e127ab`)
- **Dispatch is keymap-first for printables** — `Char('(')` resolves
through the keymap stack before the self-insert fallback
(`src/editor.rs:713-717`, fallback `:748-761`); printable keys are
bindable (the buffer list binds `n`/`p` buffer-locally,
`builtin/commands/default.lua:407-423`).
- **But a key binding cannot carry pairing.** The GPU applies plain
printables optimistically **even mid-line** — classifier
`pmacs-gpu/src/main.rs:1525-1533`, eligibility `:2054-2079` — so a
`(`-binding would never run for GPU typing. The TUI applies
printables optimistically **at end of line** (the F19 paint
constraint: mid-line inserts round-trip, EOL appends do not —
`src/optimistic.rs:269-271`, contract `:168-200`). And a binding
that inserted the pair atomically would break classification
(below) even where it did run.
- **The typed-char substrate exists and has a working consumer.** The
daemon classifies single-codepoint optimistic inserts as
`buffer.self-insert` (exact byte decode,
`src/daemon.rs:1985-2002`, `:2085-2087`), explicitly "for
typed-char consumers (signature help; …)". `buffer.after-edit`
fires on BOTH paths — dispatch (`src/editor.rs:772-776`) and the
optimistic CRDT arm (`src/daemon.rs:2156-2169`) — with core
borrows released, so a hook may edit the buffer. Signature-help
auto-trigger (`builtin/runtime/lsp.lua:761-791`) is the in-tree
template: `this_command() == "buffer.self-insert"` plus
`char_before`; a paste can never trigger it.
- **An atomic 2-byte `"()"` insert breaks that classification**
(`is_single_codepoint_insert` decodes the actual bytes,
`src/daemon.rs:2079-2084` names signature help as the reason), and
**intercepts cannot rewrite `(` into `()`** (M6.4: kind and payload
immutable, `src/lua_bindings/mod.rs:1061-1073`, `:1657-1705`). Any
pairing design must keep the opener a genuine single-codepoint
self-insert.
- **Hook mechanics**: callbacks run in registration order
(`src/hook.rs:240`, snapshot `:255-259`; fan-out `:291-297`);
`buffer.after-edit` is **all-must-succeed**
(`builtin/hooks/default.lua:41-45`) — one callback's error or
return value cannot suppress the others. Hook-created edits do
**not** re-fire `buffer.after-edit` (fired once per dispatch cycle
/ per optimistic op).
- **The LSP hook flushes synchronously mid-fan-out.** The after-edit
callback in lsp.lua marks the buffer dirty, and — when the typed
char is a signature trigger — calls `signature_help_quiet`, which
calls `flush_did_change_for(rec)` **synchronously**
(`builtin/runtime/lsp.lua:739-743`: "The server must see the
character we just typed") before requesting. `flush_did_change`
reads `buffer_text` at flush time and clears the pending entry
(`:268-279`). Consequence: a closer inserted by a hook registered
**after** lsp.lua's would miss that flush AND stay unsynchronized
(no re-fire) until the next edit. Coalescing does not save this
path; registration order does.
- **Undo is peer-bound, per frontend.** Loro's `UndoManager` binds to
one peer at construction; each frontend's `BufferMirror` undoes
only its own peer's ops (`src/buffer_mirror.rs:541-555`), and the
daemon's `buffer.undo` covers only daemon-peer edits
(`src/buffer.rs:1360-1380`). The TUI's undo key tries the mirror
first and **round-trips to the daemon when the mirror has nothing
to undo** (`src/optimistic.rs:238-247`). Consequence: a pair whose
opener is a source-peer optimistic op and whose closer is a
daemon-peer Lua edit is **not undoable coherently by either
frontend** — the TUI removes the opener first (leaving `)`), the
daemon removes the closer then unrelated daemon edits. Edits that
route through dispatch are all daemon-peer.
- **The TUI's mirror-empty fallback is not chronological
arbitration.** If the mirror has *any* older source-peer edit, its
optimistic undo succeeds and the key never reaches the daemon
(`src/optimistic.rs:230-247`). Thus `a` (optimistic) then `()`
(daemon-routed) followed by the TUI's single-key undo removes `a`,
not the closer. `C-x u`, which always dispatches, removes the
daemon closer instead. Routing the pair's two edits to one peer
makes their daemon-local order coherent; it does not merge that
order with older source-peer history. Global chronological undo is
existing cross-peer substrate work, not something pairing can
truthfully claim to solve.
- **Mutator effects land before the caller sees them.** The effective
edit is applied and only then reported as the returned triple
(`src/lua_bindings/mod.rs:1184`, `:1246-1259`) — a transformed
(relocated/expanded) edit has already happened; it can be
*repaired around*, never *withheld*. Lua mutators move no cursors
and reconcile no window state (PR #109 ground truth); the
context-guarded right-gravity repair in
`builtin/runtime/indent.lua:60-64`, `:100-121` is the established
pattern.
- **The producing self-insert has the same intercept problem, and
`after-edit` currently carries no payload.** `insert_char` advances
the cursor from the requested position after `apply_active_edit`
(`src/editor_core.rs:1738-1749`), even if an insert intercept moved
the effective insertion. `this_command` proves only the input
class; `char_before(cursor)` does not prove which character was
typed or where it landed. Pairing therefore needs exact ephemeral
source-edit provenance, not the signature-help heuristic alone.
- **A hook-inserted closer lands correctly by construction**: insert
at the cursor, cursor stays before it (mutators move no cursors);
the daemon's `CursorByte` re-grounds both frontends; the GPU
applies incoming ops and rebases unconfirmed edits without moving
`own_cursor` (`pmacs-gpu/src/main.rs:2463-2564`).
- **Broadcast ordering quirk on the optimistic path**: the after-edit
hook fires (`src/daemon.rs:2167`) **before** the source opener op
is queued for broadcast (`:2178`), so a hook-queued DaemonKey
closer reaches **non-source replicas before the opener it depends
on**. Loro is expected to buffer and converge; nothing pins that
today.
- **Region-active typing**: CUA type-over consumes the region on the
dispatch path. The GPU round-trips when a selection decoration
exists; the TUI's optimistic gate consults no selection state (PR
#109 named deferral) — a nonempty TUI selection ending at EOL can
optimistically insert without consuming the region, and the daemon
arm deliberately preserves nonempty anchors.
- **A callback may switch context, and later callbacks observe the
switch.** The hook runner snapshots callbacks, not editor context;
lsp.lua reads `pmacs.window.buffer()` when its callback runs. If a
pair reaction's intercept switches from A to B, a local cursor guard
can avoid touching B, but it cannot make the later LSP callback see
A. Origin-pinned hook fan-out is named substrate work below.
- **Direct Lua mutation and plain `pmacs.command.invoke` do not fire
`buffer.after-edit`.** A non-typed regression that merely calls
either API is vacuous; the test must explicitly run the hook or use
a production path such as paste which fires it.
- **No pair knowledge exists anywhere**; `pmacs.comment.strings`
(`builtin/runtime/comment.lua:26-42`) is the per-language table
precedent. No node-at-byte API on `pmacs.parse` (manual descent
only; async/stale trees) — syntax-aware inhibit is not v1-viable.
- Rust uses `'` for lifetimes — pairing `'` per-language is a
correctness matter, not taste.
## Decisions
### Q#AP1 — Carrier: after-edit reaction, with pair chars routed through dispatch
Two coupled decisions, each grounded in the constraints above:
**The reaction carrier** (unchanged from R1): on
`this_command() == "buffer.self-insert"`, the pairing hook reads the
exact ephemeral typed-edit record from Q#AP9 and reacts with a second
edit. This keeps the opener a genuine single-codepoint self-insert —
the classification signature help depends on — without guessing its
identity or position from surrounding buffer text.
**The routing change** (new, R1 finding): the built-in pair charset
`( ) [ ] { } " ' `` ` is **removed from both optimistic
classifiers** — the GPU's `optimistic_insert_text` and the TUI's
`classify_key` — so those chars always round-trip through dispatch.
Without this, the opener is a source-peer op and the closer a
daemon-peer op, and peer-bound undo makes the pair uncleanly
undoable on every frontend (ground truth). With it, both edits are
adjacent daemon-peer undo units. A daemon-routed undo removes the
closer and then the opener; the TUI's single-key optimistic undo does
the same **only when its older source-peer stack is empty**. This is
pair-local coherence, not global chronological arbitration (Q#AP5).
The exclusion also restores CUA type-over for pair chars on the TUI
(a round-tripped `(` consumes the region the optimistic path would
have skipped) and removes transient pair/skip paint from both
frontends.
Costs, named: one daemon round-trip per built-in-charset keystroke —
on the TUI only EOL appends change (mid-line already round-trips); on
the GPU all nine chars do. `'` and `` ` `` round-trip even in languages
whose sets don't pair them — uniform routing beats per-language
classifier state the frontends don't have. **User-extended pair
chars beyond the built-in nine still arrive optimistically**: they
pair correctly (the reaction fires either way) but their undo is
cross-peer-degraded — documented limitation, pinned in acceptance,
full fix deferred with the pre-existing mixed-history problem
(cross-peer chronological undo arbitration).
`builtin/runtime/pair.lua`; a `pmacs.pair.*` namespace.
### Q#AP2 — Pair sets: per-language table, conservative default
`pmacs.pair.sets` — the `pmacs.comment.strings` shape: language →
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 **well-formed** UTF-8 codepoints — opener
then closer, multibyte allowed (`"«»"`); malformed entries are
skipped entirely, never partially honored (R4: a `"()x"` typo must
not turn `(` into `()x`; R5: well-formedness per Unicode Table 3-7,
so truncated sequences, overlong encodings, surrogates, and
beyond-U+10FFFF closers like `"(\xC2x"` also disqualify). A
non-table container anywhere — a typo like `pmacs.pair.sets.default
= "()"` — degrades language→default→empty rather than throwing from
the after-edit callback (R5). The set (and the language behind it)
always resolves against the buffer the typed-edit record names, via
`pmacs.lsp.buffer_language(buf)` (R5):
- `default = { "()", "[]", "{}", '""' }` — no `'` (prose
apostrophes), no backtick.
- `python`, `lua`, `javascript`/`typescript` (+react), `sh`/`bash`
add `''`; javascript/typescript/markdown add `` `` `` pairs.
- `rust`, `c`, `cpp`, `go`, `zig` = default (lifetimes, char
literals). Users opt in from init.lua: `pmacs.pair.sets.rust = …`.
### Q#AP3 — Insert-pair semantics (open char typed)
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
cursor. A relocated/expanded or context-switching source
self-insert stands as the intercept produced it and gets no pair
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. 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
TUI's selection-blind optimistic gate (custom chars only), where
reacting would pile a closer onto an unconsumed region;
- the record's exact typed codepoint is an opener in the SOURCE
buffer's pair set (language via `pmacs.lsp.buffer_language(buf)` on
the record's buffer, resolved **at callback time**, nil-guarded —
pair.lua loads before lsp.lua, Q#AP7; R5: the active buffer is the
wrong buffer whenever a context-switching command ran).
`char_before` is not input provenance;
- **conservative predicate**: the char at the cursor is EOL,
whitespace, or a closing bracket from the pair set — `foo|bar` +
`(` gives `(bar`, never `()bar`. Malformed bytes at the cursor are
word-like (no pairing before junk), not EOL-like (R5);
- for symmetric pairs (quotes), the skip check (Q#AP4) runs first.
Reaction: one pcall'd `buf:insert(cursor, closer)`. Outcomes,
separated (R1 finding):
- **Rejected** (intercept threw): nothing landed; the opener stands
alone; status *"auto-pair closer rejected by buffer intercept"*.
- **Transformed** (effective triple deviates): the edit has already
landed wherever the intercept put it — the positional result
stands. Report, then **context-guarded cursor repair**: with the
window+buffer snapshot taken before the mutator, right-gravity-
translate the pre-edit cursor through the effective edit and
`goto_byte` (clamps); skip all repair if the intercept switched
window or buffer. (The clean path needs no cursor motion at all —
the asymmetry is deliberate: repair only on deviation, because the
clean at-cursor insert must leave the cursor *before* the closer,
which translation would not.)
### Q#AP4 — Skip-over-close, reactive
When Q#AP9's exact typed char is a closer in the pair set AND the char
at the recorded post-insert cursor equals it: one pcall'd
`buf:delete(cursor, cursor+len)`. Net text and cursor are exactly
Emacs's skip: `(|)` + `)``()|`; nested closers skip likewise;
`"` at `"|"` exits the string. With pair chars on the dispatch route
there is no transient duplicate to paint — the frontends never
locally applied the typed closer.
Same outcome separation as Q#AP3: rejected delete → the duplicate
stays (`())`), status, no retry; transformed delete → already
landed, report, context-guarded translate-and-clamp repair.
### Q#AP5 — Undo grain
Built-in pairs are two adjacent daemon-peer edits. On the GPU, and on
the TUI when the mirror has no older source-peer unit (or when the
user invokes the always-dispatched `C-x u`), daemon undo removes the
closer and then the opener. Still two steps, not one; a skip's daemon
undo restores the swallowed duplicate.
This does **not** make undo globally chronological. With optimistic
`a` already in the TUI mirror, then daemon-routed `()`, the TUI's
single-key optimistic undo removes `a` first because the mirror has a
local unit and never falls back. The GPU can undo the two daemon pair
units but cannot subsequently reach the older source-peer `a` through
daemon undo. Custom optimistic pair chars additionally split the pair
itself across peers. All three behaviors are pinned explicitly; the
general fix is a cross-peer chronological undo arbiter, deferred as
existing collaboration substrate rather than charged to pair.lua.
### Q#AP6 — Type-over composes on the dispatch path; wrap is deferred
For built-in pair chars (always dispatch-routed), typing an opener
over an active region type-overs the region, then the reaction runs
under the Q#AP3 predicate — Emacs-with-delete-selection semantics,
guaranteed for a clean, context-preserving source edit. A transformed
source edit fails closed under Q#AP9. For custom optimistic chars the
TUI selection gap persists; the Q#AP3 region guard suppresses the
reaction there rather than compounding the gap. **Wrapping** the region
in the pair is deferred (needs a command carrier plus the TUI
selection-gate fix).
### Q#AP7 — Load order: pair.lua BEFORE lsp.lua
Registration order is execution order (ground truth), and lsp.lua's
after-edit callback synchronously flushes didChange on the signature
trigger path — the closer must already be in the buffer when that
callback runs, or the server receives `(`-only text and the closer
stays unsynchronized until the next edit (hook edits don't re-fire).
So pair.lua's loader entry in `src/editor.rs` goes **before**
lsp.lua's (`:288`), with a comment naming this ordering contract;
all `pmacs.lsp.*` lookups inside the callback are lazy and
nil-guarded (lsp.lua defines them later in the load sequence).
Acceptance asserts the ordering by its observable: the **first**
didChange after an ordinary `(` carries `()`; when the closer is
position-transformed without switching context, that first didChange
carries the complete effective post-reaction text rather than an
opener-only intermediate.
Scope: if the reaction intercept itself switches active window or
buffer, later callbacks observe that new context. The pair callback's
cursor guard cannot repair hook-wide context, so the first didChange
guarantee does not extend to that legal-but-pathological case; A may
remain pending while lsp.lua observes B. This is recorded under
Deferred as origin-pinned after-edit fan-out and is no longer hidden
behind a cursor-only acceptance claim.
### Q#AP8 — Interactions, verified
- **Signature help**: the opener still classifies as self-insert on
both routes; the closer insert moves no cursor, so `char_before`
still reads `(`; the synchronous flush ships `()` (Q#AP7). Pinned
with the `sighelp` fake-LSP mode. Same context-switch exception as
Q#AP7.
- **Completion popup**: `(` never belonged to the popup's key set;
`completion_popup_validate` runs after the hook fan-out
(`src/editor.rs:778-783`) and judges the post-pair buffer when
context is preserved. The Q#AP7 context-switch exception applies to
completion as well.
- **Kill ring / boundaries**: the closer is a plain Lua mutator —
stamps nothing, rotates nothing.
- **No recursion**: hook edits don't re-fire after-edit; the
autosave-driven manual `pmacs.hook.run("buffer.after-edit")`
(`builtin/runtime/autosave.lua:233`) is inert because Q#AP9 exposes
no typed-edit record outside the Rust-owned hook boundary, even if
`this_command` is stale.
- **Auto-indent**: RET inside `{|}` yields `{\n␣␣|}`; the electric
closer-on-own-line split stays deferred with language-aware
indent.
### Q#AP9 — Exact typed-edit provenance, ephemeral, one-shot, fail-closed
`this_command` remains the coarse input-origin signal used by existing
consumers, but pairing additionally requires a new
`pmacs.editor.take_typed_edit()` record. The record is per frontend,
is armed only while Rust is running the one `buffer.after-edit`
fan-out for that input, and can be consumed exactly once:
```text
{ buffer, window_id, codepoint, requested_start, requested_end,
effective_start, effective_end, inserted_len, post_cursor, clean }
```
The dispatch path arms the record from the self-insert codepoint and
the requested Insert/Replace, then completes it from the effective
`Edit` returned by `insert_char_over_region`. The optimistic CRDT arm
already has both the decoded single codepoint and effective `Edit`, so
it builds the same record before firing the hook. A small EditorCore
outcome/report refactor is required because `apply_active_edit`
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,
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 (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
1. **Nine round-tripped chars are imperceptible.** The TUI already
round-trips every mid-line char; the GPU pays one local-socket hop
on pair chars. The concrete return is real dispatch type-over, no
optimistic duplicate paint during skip, and adjacent daemon-local
pair units — not a false promise of global chronological undo.
2. **The conservative predicate kills the hate-mail cases** — no
pairing before words, no apostrophe pairing in the default set.
3. **Uniform routing beats language-aware classifiers**`'`
round-trips in Rust for nothing, and nobody notices.
4. **`default`-set pairing in language-less buffers is wanted**, not
surprising.
## Deferred (named)
- Wrap-region on opener with active selection (command carrier + the
TUI selection-gate fix).
- Pair-aware backspace (delete both of a fresh empty pair).
- RET inside a pair → closer on its own line (with language-aware /
electric indent).
- In-string/in-comment inhibit — needs a node-at-byte `pmacs.parse`
binding and freshness guarantees.
- Undo amalgamation (pair = one undo step); cross-peer undo grouping
and chronological arbitration (would un-degrade custom optimistic
pair chars and mixed source/daemon history generally).
- Origin-pinned `buffer.after-edit` context. Today a legal intercept
that switches window/buffer changes what all later callbacks see;
pair.lua can guard its own cursor repair but cannot keep LSP and
completion on the producing context without a hook-wide substrate.
- Balance-aware quote handling (odd/even counting).
- A per-buffer toggle (config-registry-blocked).
## Acceptance
`tests/auto_pair_acceptance.rs` (dispatch-driven):
- `(` at EOL → `()`, cursor between; mid-line before whitespace and
before `)`; before a word char → no pair.
- Skip: `(|)` + `)``()`, cursor after; nested; `"` at `"|"` exits.
- 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. Ill-formed UTF-8 closers (R5) —
truncated `"(\xC2x"`, overlong `"(\xC0\xAF"`, surrogate
`"(\xED\xA0\x80"`, beyond-U+10FFFF `"(\xF5\x80\x80\x80"` — all
pair nothing. Non-table containers (R5/R6): a string `default` and
a non-table `pmacs.pair.sets` itself pair nothing without erroring;
a junk language entry falls back to the default set. Malformed
BUFFER bytes (R6): `(` typed immediately before a lone `0xFF`
stays unpaired — junk is word-like, not EOL-like.
- Source-buffer relevance (R5): `'` typed in Rust with a
context-switching command landing in Python stays silent; the
inverse Python→Rust route still reports "source context changed".
- 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
`pmacs.hook.run("buffer.after-edit")` also leaves it lone even when
`this_command` was deliberately left as `buffer.self-insert`;
`pmacs.command.invoke`d self-insert plus the same explicit hook run
also has no record and no reaction.
- Type-over: region + `(` (dispatch route) → region consumed, then
the predicate decides; selection cleared.
- Daemon undo grain pinned in the non-replica harness: `(` then undo →
`(` alone; undo → empty; redo → `(`; redo → `()`; skip undo restores
the duplicate.
- Intercepts: rejected closer → opener stands, status; **relocated
closer** → landed at the intercept's position, reported, cursor
translated not teleported; rejected skip-delete → duplicate stays;
**expanded/relocated skip-delete** → landed, reported,
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 (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,
explicitly pinning (not concealing) the origin-context deferral; do
not combine this case with the Q#AP7 first-didChange guarantee.
- Signature help: fake-LSP `sighelp` mode — auto-trigger still fires
with pairing active, and the **first didChange after `(` contains
`()`** when the reaction preserves context (the Q#AP7 ordering
observable). A preserving-context relocated closer instead asserts
that the first didChange contains the complete effective text.
- Hook fan-out: one fire per keystroke; the closer edit does not
re-fire.
- Typed-edit lifecycle: `take_typed_edit()` yields the exact
codepoint/effective triple once during both dispatch and optimistic
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. 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);
TUI `classify_key`/orchestrator equivalents round-trip them (test
updated). Exercise both `Modifiers::NONE` and the `SHIFT` shapes real
keyboards use for `(){}"` so the test cannot pass only for synthetic
unshifted punctuation.
CRDT (`--features crdt`), **two replicas** (source + observer):
- Dispatch route: round-tripped `(` → both replicas converge to
`()`; daemon cursor between; `)` skip converges; **undo/redo**:
two daemon undos restore empty on both replicas, then two daemon
redos restore `(` and `()` in order on both.
- Mixed history, dispatch route: source optimistically inserts `a`,
then round-tripped `(` produces `a()`. On the TUI routing model the
single-key optimistic undo removes `a` first (leaving `()`). From a
fresh identical state, an always-dispatched `C-x u` removes the
closer first (leaving `a(`). On the GPU routing model two daemon
undos remove the pair but a further daemon undo cannot reach
source-peer `a`. These are assertions of the named substrate limit,
not frontend-equivalence claims.
- Optimistic route (custom pair char added to the set): source ships
the opener op; the hook's DaemonKey closer is queued **before**
the opener's broadcast — the observer receives the causally
dependent closer first and must still converge (pairing and skip
both). **Undo**: pinned degraded behavior — the source mirror's
undo removes the opener, leaving the closer.