582 lines
32 KiB
Markdown
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.
|