diff --git a/builtin/runtime/pair.lua b/builtin/runtime/pair.lua new file mode 100644 index 0000000..03856d9 --- /dev/null +++ b/builtin/runtime/pair.lua @@ -0,0 +1,251 @@ +-- pair.lua --- auto-pairing (Arc 2). +-- +-- Typing `(` gives `()` with the cursor between; typing `)` when the +-- next char is already `)` steps over it instead of doubling it. The +-- carrier is a `buffer.after-edit` reaction (Q#AP1): the opener stays +-- a genuine single-codepoint self-insert — the classification +-- signature help depends on — and this hook inserts (or swallows) the +-- closer as a second edit. Provenance is the exact one-shot typed-edit +-- record (`pmacs.editor.take_typed_edit()`, Q#AP9), not buffer-text +-- inference: pastes, programmatic edits, manual hook runs, and a stale +-- `this_command` have no record and never pair, and a transformed, +-- relocated, or context-switching source self-insert fails closed. +-- +-- This chunk loads BEFORE lsp.lua (Q#AP7): registration order is hook +-- execution order, 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. Everything under +-- `pmacs.lsp` is therefore looked up lazily at callback time. +-- +-- Framing: docs/auto-pairing-framing.md. + +pmacs.pair = pmacs.pair or {} + +local ed = pmacs.editor + +-- Language → array of pair strings (opener codepoint followed by +-- closer codepoint), plus the `default` entry used when the language +-- is unknown or has no entry — pairing is useful in scratch buffers +-- (Q#AP2). Public and user-extensible, like `pmacs.comment.strings`: +-- pmacs.pair.sets.rust = { "()", "[]", "{}", '""', "''" } +-- Conservative defaults: no `'` (prose apostrophes, Rust lifetimes, +-- char literals), no backtick, outside the languages that want them. +-- NOTE (Q#AP1): only the nine built-in chars `()[]{}"'` and backtick +-- are excluded from the frontends' optimistic classifiers. A +-- user-added pair char beyond those still pairs, but arrives +-- optimistically: its opener is a source-peer op and the closer a +-- daemon-peer op, so its undo is cross-peer-degraded (documented +-- limitation; the general fix is chronological cross-peer undo +-- arbitration, named substrate work). +pmacs.pair.sets = { + default = { "()", "[]", "{}", '""' }, + python = { "()", "[]", "{}", '""', "''" }, + lua = { "()", "[]", "{}", '""', "''" }, + javascript = { "()", "[]", "{}", '""', "''", "``" }, + typescript = { "()", "[]", "{}", '""', "''", "``" }, + javascriptreact = { "()", "[]", "{}", '""', "''", "``" }, + typescriptreact = { "()", "[]", "{}", '""', "''", "``" }, + markdown = { "()", "[]", "{}", '""', "``" }, + sh = { "()", "[]", "{}", '""', "''" }, + bash = { "()", "[]", "{}", '""', "''" }, +} + +-- 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. +local function char_at(buf, pos) + local len = buf:len() + if pos >= len then return nil end + 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 + 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). +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) +end + +-- The active buffer's pair set: language entry if the language is +-- known and configured, else `default`. `pmacs.lsp` is looked up +-- lazily and nil-guarded — this chunk loads before lsp.lua (Q#AP7), +-- and language detection is an LSP-runtime service. +local function active_set() + local lang + if pmacs.lsp and pmacs.lsp.active_buffer_language then + local ok, l = pcall(pmacs.lsp.active_buffer_language) + if ok then lang = l end + end + return (lang and pmacs.pair.sets[lang]) or pmacs.pair.sets.default +end + +-- opener → closer, and the set of closer codepoints. +local function maps_for(set) + local openers, closers = {}, {} + for _, entry in ipairs(set) do + local o, c = split_pair(entry) + if o then + openers[o] = c + closers[c] = true + end + end + return openers, closers +end + +-- Conservative insertion predicate (Q#AP3): pair only before +-- end-of-buffer, end-of-line, whitespace, or a closing char from the +-- active set — `foo|bar` + `(` gives `(bar`, never `()bar`. +local function should_pair(buf, cursor, closers) + local nxt = char_at(buf, cursor) + if nxt == nil then return true end + if nxt == "\n" or nxt == "\r" or nxt == " " or nxt == "\t" then return true end + return closers[nxt] == true +end + +-- Right-gravity translation of `pos` through the effective edit — +-- indent.lua's repair shape (Q#AP3/Q#AP4 transformed outcomes). +local function translate(pos, estart, estop, einserted) + if pos < estart then return pos end + if pos > estop then return pos - (estop - estart) + einserted end + return estart + einserted +end + +-- Context-guarded cursor repair after a TRANSFORMED reaction edit: +-- the intercept's positional result stands (kind and payload are +-- immutable; the edit has already landed), so translate the pre-edit +-- cursor through the effective edit and clamp via goto_byte — unless +-- the intercept switched window or buffer, in which case the new +-- context is not ours to touch. The clean path deliberately performs +-- NO cursor motion: a clean at-cursor closer insert must leave the +-- cursor *before* the closer, which translation would not. +local function repair_cursor(win0, buf0, cursor0, estart, estop, einserted) + if pmacs.window.current() ~= win0 or pmacs.window.buffer() ~= buf0 then + return + end + ed.goto_byte(translate(cursor0, estart, estop, einserted)) +end + +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 + -- 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 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 + + -- 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. + if not rec.clean then + ed.set_status("auto-pair skipped: source self-insert transformed") + return + 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. + if buf ~= rec.buffer + or pmacs.window.current() ~= rec.window + or ed.cursor() ~= rec.post_cursor then + ed.set_status("auto-pair skipped: source context changed") + return + end + -- Region guard (Q#AP3/Q#AP6): on the dispatch route type-over has + -- already consumed and cleared the region. A region surviving the + -- edit means the TUI's selection-blind optimistic gate let a custom + -- pair char through (named deferral) — reacting would pile a closer + -- 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 `(|)` + -- swallows the freshly typed duplicate, net `()` with the cursor + -- after — exactly Emacs's skip. The pair chars round-trip (Q#AP1), + -- so no frontend ever painted the transient duplicate. + if closers[ch] then + local dup_ok, dup = pcall(function() return buf:slice(cursor, cursor + #ch) end) + if dup_ok and dup == ch then + local win0 = pmacs.window.current() + local ok, estart, estop, einserted = pcall(function() + return buf:delete(cursor, cursor + #ch) + end) + if not ok then + -- The duplicate stays (e.g. `())`); report, no retry. + ed.set_status("auto-pair skip rejected by buffer intercept") + return + end + if estart ~= cursor or estop ~= cursor + #ch or einserted ~= 0 then + ed.set_status("auto-pair skip altered by buffer intercept") + repair_cursor(win0, buf, cursor, estart, estop, einserted) + end + return + end + end + + local closer = openers[ch] + if not closer then return end + if not should_pair(buf, cursor, closers) then return end + + local win0 = pmacs.window.current() + local ok, estart, estop, einserted = pcall(function() + return buf:insert(cursor, closer) + end) + if not ok then + -- Nothing landed; the opener stands alone. + ed.set_status("auto-pair closer rejected by buffer intercept") + return + end + if estart ~= cursor or estop ~= cursor or einserted ~= #closer then + ed.set_status("auto-pair closer altered by buffer intercept") + repair_cursor(win0, buf, cursor, estart, estop, einserted) + end + -- Clean path: no cursor motion — the insert landed at the cursor + -- and Lua mutators move no cursors, so it already sits between the + -- pair; the daemon's per-tick CursorByte re-grounds both frontends. +end) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 8be9030..d4b1956 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -39,6 +39,7 @@ use pmacs_protocol::{ InstanceSignal, Key as ProtocolKey, LineNumberMode, MenuPromptRow, Modifiers, PointerKind, SelectionSnapshot, StyleSegment, StyleSpan, cell::{Color as CellColor, Style as CellStyle}, + is_builtin_pair_char, }; use wgpu::MultisampleState; use winit::application::ApplicationHandler; @@ -1527,7 +1528,16 @@ fn optimistic_insert_text(key: ProtocolKey, mods: Modifiers, chbuf: &mut [u8; 4] return None; } match key { - ProtocolKey::Char(ch) if !ch.is_control() => Some(ch.encode_utf8(chbuf)), + // Auto-pairing Q#AP1: the built-in pair charset always + // round-trips so the typed opener and the pairing hook's + // closer land as adjacent daemon-peer undo units, and + // dispatch-path CUA type-over / skip-over-close apply. An + // optimistic pair char would put the opener on this + // frontend's peer with the closer on the daemon's — uncleanly + // undoable from either side. + ProtocolKey::Char(ch) if !ch.is_control() && !is_builtin_pair_char(ch) => { + Some(ch.encode_utf8(chbuf)) + } ProtocolKey::Tab if mods.is_empty() => Some("\t"), _ => None, } @@ -7112,6 +7122,30 @@ mod tests { ); } + #[test] + fn optimistic_insert_text_round_trips_builtin_pair_chars() { + // Auto-pairing Q#AP1: the nine built-in pair chars must reach + // daemon dispatch so the opener and the pairing hook's closer + // are adjacent daemon-peer undo units. Both modifier shapes + // real keyboards produce are pinned: `[`/`]`/`'`/`` ` `` + // arrive unshifted, `(`/`)`/`{`/`}`/`"` arrive with SHIFT — a + // gate that only caught `Modifiers::NONE` would leak every + // shifted pair char back onto the optimistic path. + let mut buf = [0u8; 4]; + for c in pmacs_protocol::BUILTIN_PAIR_CHARS { + assert_eq!( + optimistic_insert_text(ProtocolKey::Char(c), Modifiers::NONE, &mut buf), + None, + "unshifted {c:?} must round-trip" + ); + assert_eq!( + optimistic_insert_text(ProtocolKey::Char(c), Modifiers::SHIFT, &mut buf), + None, + "shifted {c:?} must round-trip" + ); + } + } + /// Q#R1 parity invariant: the per-line surgery's chunk source /// (`clipped_chunks_for_range` over one line's content range) /// must agree byte-for-byte — text AND color — with the full diff --git a/pmacs-protocol/src/lib.rs b/pmacs-protocol/src/lib.rs index bcb1000..01eb91c 100644 --- a/pmacs-protocol/src/lib.rs +++ b/pmacs-protocol/src/lib.rs @@ -46,12 +46,13 @@ pub use cell::{ pub use crdt::CrdtOp; pub use ids::{BufferId, ByteRange, FrontendId, Position}; pub use message::{ - AdornmentContent, AdornmentPlacement, AttachRequest, BlockAdornment, CompletionPopupRow, - CursorState, Decoration, DecorationKind, DecorationSegment, FrontendCapabilities, - FrontendEvent, GoodbyeReason, Hello, InlineAdornment, InstanceCapabilities, InstanceIdentity, - InstanceMessage, InstanceSignal, Key, KeyEvent, LineNumberMode, MenuPromptRow, Modifiers, - MouseButton, MouseEvent, MouseKind, NegotiatedCapabilities, PROTOCOL_VERSION, PointerKind, - ResourceBody, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, StyleSegment, StyleSpan, + AdornmentContent, AdornmentPlacement, AttachRequest, BUILTIN_PAIR_CHARS, BlockAdornment, + CompletionPopupRow, CursorState, Decoration, DecorationKind, DecorationSegment, + FrontendCapabilities, FrontendEvent, GoodbyeReason, Hello, InlineAdornment, + InstanceCapabilities, InstanceIdentity, InstanceMessage, InstanceSignal, Key, KeyEvent, + LineNumberMode, MenuPromptRow, Modifiers, MouseButton, MouseEvent, MouseKind, + NegotiatedCapabilities, PROTOCOL_VERSION, PointerKind, ResourceBody, + SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, StyleSegment, StyleSpan, is_builtin_pair_char, is_supported_protocol_version, negotiate_capabilities, }; pub use transport::{MAX_FRAME_BYTES, TransportError, read_message, write_message}; diff --git a/pmacs-protocol/src/message.rs b/pmacs-protocol/src/message.rs index 29499ca..57e8d4f 100644 --- a/pmacs-protocol/src/message.rs +++ b/pmacs-protocol/src/message.rs @@ -89,6 +89,22 @@ pub enum Key { Unknown(u32), } +/// The nine built-in auto-pair characters (docs/auto-pairing-framing.md +/// Q#AP1). Both frontends' optimistic classifiers exclude these so they +/// always round-trip through daemon dispatch: the typed opener and the +/// pairing hook's closer then land as adjacent daemon-peer undo units, +/// dispatch-path CUA type-over applies, and skip-over-close never +/// paints a transient duplicate. Shared here — not duplicated per +/// frontend — because a frontend that drifts from this set silently +/// re-degrades pair undo to the cross-peer mixed-history case. +pub const BUILTIN_PAIR_CHARS: [char; 9] = ['(', ')', '[', ']', '{', '}', '"', '\'', '`']; + +/// True when `c` is one of [`BUILTIN_PAIR_CHARS`]. +#[must_use] +pub fn is_builtin_pair_char(c: char) -> bool { + BUILTIN_PAIR_CHARS.contains(&c) +} + /// Modifier-key set. Bit-flag encoding for compact wire shape. /// /// `META` corresponds to the "logo" / "super" key on most keyboards. diff --git a/src/bin/pmacs_fake_lsp.rs b/src/bin/pmacs_fake_lsp.rs index d5b916b..8c11df8 100644 --- a/src/bin/pmacs_fake_lsp.rs +++ b/src/bin/pmacs_fake_lsp.rs @@ -49,6 +49,11 @@ //! advertises `signatureHelpProvider` with `(` / `,` triggers, so a //! test can drive the Arc 1d auto-trigger. Every other mode omits the //! capability and therefore never auto-triggers. +//! * If `PMACS_FAKE_LSP_CHANGE_SINK` names a file (any mode): appends +//! one `{"method", "text"}` JSON line per received didOpen / +//! didChange, so a test can replay the exact document-sync sequence +//! the server saw — the auto-pairing Q#AP7 ordering observable +//! ("the first didChange after `(` carries `()`"). use std::collections::HashMap; use std::io::{self, Read, Write}; @@ -415,22 +420,38 @@ fn main() { .and_then(|t| t.get("uri")) .cloned() .unwrap_or(serde_json::Value::Null); - if let Some(uri_s) = uri.as_str() { - let text = if method == "textDocument/didOpen" { - params - .get("textDocument") - .and_then(|t| t.get("text")) - .and_then(serde_json::Value::as_str) - } else { - params - .get("contentChanges") - .and_then(serde_json::Value::as_array) - .and_then(|a| a.first()) - .and_then(|c| c.get("text")) - .and_then(serde_json::Value::as_str) - }; - if let Some(text) = text { - open_docs.insert(uri_s.to_owned(), text.to_owned()); + let text = if method == "textDocument/didOpen" { + params + .get("textDocument") + .and_then(|t| t.get("text")) + .and_then(serde_json::Value::as_str) + } else { + params + .get("contentChanges") + .and_then(serde_json::Value::as_array) + .and_then(|a| a.first()) + .and_then(|c| c.get("text")) + .and_then(serde_json::Value::as_str) + }; + if let (Some(uri_s), Some(text)) = (uri.as_str(), text) { + open_docs.insert(uri_s.to_owned(), text.to_owned()); + } + // Auto-pairing Q#AP7: the ordering observable is "the + // FIRST didChange after `(` carries `()`" — provable + // only from what the server actually received, in + // order. Mirror of `PMACS_FAKE_LSP_ROOT_SINK`: append + // one JSON line per didOpen/didChange to the sink + // file so a test can replay the exact sequence. + if let (Ok(sink), Some(text)) = (std::env::var("PMACS_FAKE_LSP_CHANGE_SINK"), text) + { + use std::io::Write as _; + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&sink) + { + let line = serde_json::json!({ "method": method, "text": text }); + let _ = writeln!(f, "{line}"); } } let echo = serde_json::json!({ diff --git a/src/daemon.rs b/src/daemon.rs index dea3493..cc40cf9 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -2000,6 +2000,25 @@ fn is_single_codepoint_insert(edit: &crate::rope::Edit) -> bool { expected == len } +/// The exact codepoint a single-codepoint insert landed (auto-pairing +/// Q#AP9). Preconditions are [`is_single_codepoint_insert`]'s; the +/// inserted bytes live in the post-edit rope at `range.start`. `None` +/// on malformed UTF-8 (a classification the byte-length check above +/// already rejects, kept fail-closed rather than panicking). +#[cfg(feature = "crdt")] +fn decoded_single_codepoint(edit: &crate::rope::Edit) -> Option { + let len = usize::try_from(edit.inserted_len) + .ok() + .filter(|l| *l <= 4)?; + let mut buf = [0u8; 4]; + edit.new_rope.slice( + edit.range.start, + edit.range.start + edit.inserted_len, + &mut buf[..len], + ); + std::str::from_utf8(&buf[..len]).ok()?.chars().next() +} + /// T M10.10 (post-audit) — apply a *pre-validated* /// `FrontendEvent::CrdtOp`. Identity, capability, and scope checks /// happen upstream in `validate_remote_crdt_op`; this function trusts @@ -2082,9 +2101,18 @@ fn handle_remote_crdt_op( // must NOT classify as typing (review round 4 — it would // spuriously auto-trigger signature help). Exact provenance on // the wire op is the named deferred general fix. - if edit.range.start == edit.range.end && is_single_codepoint_insert(edit) { - core.rotate_command(source, "buffer.self-insert"); - } + let typed_codepoint = + if edit.range.start == edit.range.end && is_single_codepoint_insert(edit) { + core.rotate_command(source, "buffer.self-insert"); + // Auto-pairing Q#AP9: the optimistic arm is the second + // typed self-insert producer. The decoded codepoint plus + // this Edit build the same exact provenance record the + // dispatch fallback arms — remote CRDT imports run no + // intercepts, so requested == effective and clean == true. + decoded_single_codepoint(edit) + } else { + None + }; // Transient status messages clear on user input. The Key path // gets this from `dispatch_key`'s entry clear; the optimistic // path routes plain typing here instead, and since v15 ships @@ -2144,6 +2172,36 @@ fn handle_remote_crdt_op( } core.notify_buffer_edit(buffer_id, edit); + // Auto-pairing Q#AP9: arm the typed-edit record for the one + // after-edit fan-out below — but only when the source's + // active window actually displays the edited buffer, so + // `post_cursor` (set to the optimistic post-edit position in + // the window loop above) is that window's real cursor. A + // synthetic replica editing a background buffer gets no + // record: absence fails closed, silently. + if let Some(ch) = typed_codepoint + && let Some(wid) = source_active_window_id + && core + .windows + .get(&wid) + .is_some_and(|w| w.buffer_id == buffer_id) + { + core.typed_edit_set_armed( + source, + crate::editor_core::TypedEditRecord { + buffer: buffer_id, + window: wid, + codepoint: ch, + requested_start: edit.range.start, + requested_end: edit.range.end, + effective_start: edit.range.start, + effective_end: edit.range.end, + inserted_len: edit.inserted_len, + post_cursor: post_edit_cursor, + clean: true, + }, + ); + } // T M11.9 — temporarily switch active_frontend to source so // the `buffer.after-edit` hook's Lua observers (notably the // LSP `did_change` glue in `builtin/runtime/lsp.lua`) read @@ -2167,6 +2225,9 @@ fn handle_remote_crdt_op( editor .lua_host .run_hook("buffer.after-edit", mlua::MultiValue::new()); + // Q#AP9: drop any untaken record the moment the fan-out + // returns — the slot must never leak into a later hook run. + editor.core.borrow_mut().typed_edit_clear_armed(); } // Effect 4: queue for broadcast. The source frontend's mirror diff --git a/src/editor.rs b/src/editor.rs index 0e07d4e..40dde61 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -283,6 +283,21 @@ impl EditorState { include_str!("../builtin/runtime/listview.lua"), ) .expect("load listview builtin chunk"); + // Auto-pairing (Arc 2, Q#AP7) — ORDERING CONTRACT: pair.lua + // must load BEFORE lsp.lua. Hook callbacks run in registration + // order, and lsp.lua's `buffer.after-edit` callback flushes + // didChange synchronously on the signature-trigger path — the + // pairing closer must already be in the buffer when that + // callback runs, or the server receives opener-only text and + // the closer stays unsynchronized until the next edit (hook + // edits don't re-fire the hook). pair.lua's `pmacs.lsp.*` + // lookups are lazy and nil-guarded for the same reason. + lua_host + .eval( + Some("@pmacs/builtin/runtime/pair.lua"), + include_str!("../builtin/runtime/pair.lua"), + ) + .expect("load pair builtin chunk"); lua_host .eval( Some("@pmacs/builtin/runtime/lsp.lua"), @@ -753,6 +768,14 @@ impl EditorState { self.core .borrow_mut() .rotate_command(frontend_id, "buffer.self-insert"); + // Auto-pairing Q#AP9: this dispatch is the typed + // self-insert producer — arm the exact typed-edit + // record so the after-edit fan-out below can + // expose it. The insert primitive completes the + // record with the effective (post-intercept) edit; + // `typed_edit_finish` takes it back on every path + // out of this dispatch. + self.core.borrow_mut().typed_edit_arm(frontend_id, ch); let mut args = mlua::MultiValue::new(); args.push_back(mlua::Value::Integer(ch as i64)); if let Err(e) = self.lua_host.invoke_command("buffer.self-insert", args) { @@ -769,10 +792,25 @@ impl EditorState { } } + // Auto-pairing Q#AP9: take back the typed-edit arm on every + // path out of this dispatch — command error, rejected insert, + // and the no-revision-change case all land here with either a + // completed record or nothing. The record is armed for Lua + // only across the one after-edit fan-out below and cleared + // the moment it returns, so paste, later dispatches, and + // manual hook runs can never observe a stale record. + let typed_edit = self.core.borrow_mut().typed_edit_finish(frontend_id); + let post_revision = self.active_buffer_revision(); if pre_revision != post_revision { + if let Some(record) = typed_edit { + self.core + .borrow_mut() + .typed_edit_set_armed(frontend_id, record); + } self.lua_host .run_hook("buffer.after-edit", mlua::MultiValue::new()); + self.core.borrow_mut().typed_edit_clear_armed(); } // Q#C3 post-dispatch validation, deliberately AFTER the diff --git a/src/editor_core.rs b/src/editor_core.rs index 8a69c40..da6567c 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -142,6 +142,64 @@ pub struct CommandBoundary { pub last: Option, } +/// Exact provenance of one typed self-insert (auto-pairing Q#AP9). +/// +/// `this_command() == "buffer.self-insert"` proves only the *input +/// class*; it cannot say which character was typed, where the edit +/// actually landed after intercepts, or whether the command that ran +/// under that name performed the insert at all. This record carries +/// the exact facts for the one consumer contract that needs them (the +/// pairing hook): the decoded codepoint, the requested and effective +/// ranges, and the post-edit cursor, plus a `clean` verdict (effective +/// triple equals the request). It is ephemeral — armed by the two +/// self-insert producers (dispatch fallback, optimistic CRDT arm) for +/// exactly one `buffer.after-edit` fan-out, consumable once via +/// `pmacs.editor.take_typed_edit()`, and cleared when the fan-out +/// returns. Paste, programmatic mutation, manual hook runs, and a +/// stale `this_command` therefore observe nil, not a leftover record. +#[derive(Debug, Clone)] +pub struct TypedEditRecord { + /// Buffer the self-insert landed in. + pub buffer: BufferId, + /// Window that was active when the self-insert ran. + pub window: WindowId, + /// The exact typed codepoint (payload immutability makes this + /// authoritative even when an intercept relocated the edit). + pub codepoint: char, + /// Requested edit range: `start == end` for a plain insert; a CUA + /// type-over requests a `Replace` over the consumed region. + pub requested_start: u64, + /// End of the requested range (see `requested_start`). + pub requested_end: u64, + /// Effective (post-intercept) range start in the old rope. + pub effective_start: u64, + /// Effective (post-intercept) range end in the old rope. + pub effective_end: u64, + /// Bytes actually inserted at `effective_start`. + pub inserted_len: u64, + /// The window cursor immediately after the self-insert. + pub post_cursor: u64, + /// True iff the effective triple equals the request. + pub clean: bool, +} + +/// In-flight arm for a [`TypedEditRecord`] (auto-pairing Q#AP9): the +/// dispatch fallback declares "the next matching self-insert edit is +/// the typed one" before invoking `buffer.self-insert`; the insert +/// primitives complete the record when the edit lands. Private — +/// nothing outside the arm/complete/finish trio observes the pending +/// state. +#[derive(Debug)] +struct TypedEditPending { + /// Frontend whose dispatch armed this. + fid: FrontendId, + /// The codepoint the dispatcher decoded from the keystroke; a + /// completing edit must match it exactly. + codepoint: char, + /// Filled by the first matching insert primitive. + record: Option, +} + /// The world state mutated by editor commands. pub struct EditorCore { /// Shared buffer registry. The registry is the canonical owner @@ -270,6 +328,19 @@ pub struct EditorCore { /// query-replace twin of `search`; drives the fifth dispatcher /// shadow. query_replace: Option, + /// In-flight typed-edit arm (auto-pairing Q#AP9): set by the + /// dispatch fallback just before it invokes `buffer.self-insert`, + /// completed by the insert primitives, taken back by the + /// dispatcher via [`Self::typed_edit_finish`] in the same + /// dispatch. Never survives a dispatch cycle. + typed_edit_pending: Option, + /// The armed typed-edit record (auto-pairing Q#AP9), exposed to + /// Lua as `pmacs.editor.take_typed_edit()` for the duration of + /// exactly one `buffer.after-edit` fan-out. Keyed by frontend so + /// two attached frontends can never see or consume each other's + /// slot; the producer clears any untaken record when the fan-out + /// returns. + typed_edit_armed: Option<(FrontendId, TypedEditRecord)>, } impl EditorCore { @@ -313,6 +384,8 @@ impl EditorCore { completion_popup: crate::completion::make_shared_popup(), round_trip_buffers: std::collections::HashSet::new(), query_replace: None, + typed_edit_pending: None, + typed_edit_armed: None, } } @@ -1154,12 +1227,15 @@ impl EditorCore { // ---- editing primitives ------------------------------------------------ /// Apply `op` to the active buffer; notify every window - /// displaying that buffer. Returns the new buffer length. + /// displaying that buffer. Returns the effective [`Edit`] — the + /// post-intercept range and inserted length (auto-pairing Q#AP9 + /// needs the effective triple; every other caller reads + /// `new_rope.len()` or discards it). /// /// # Errors /// /// Returns a stringified error on buffer or view failure. - pub fn apply_active_edit(&mut self, op: EditOp<'_>) -> Result { + pub fn apply_active_edit(&mut self, op: EditOp<'_>) -> Result { let buffer_id = self.active_buffer_id(); // Scope the registry borrow: the origin translation below needs // `&mut self` after the views have been notified. @@ -1197,7 +1273,7 @@ impl EditorCore { // headline isearch bet — "stale-after-edit linger" — is // closed here. self.search_invalidate_for_edit(buffer_id, &edit); - Ok(edit.new_rope.len()) + Ok(edit) } /// Q#AI8 search invalidation for a landed edit: mark the buffer's @@ -1741,11 +1817,26 @@ impl EditorCore { let s = ch.encode_utf8(&mut buf); let bytes = s.as_bytes(); let pos = self.active_window().cursor; - if let Err(e) = self.apply_active_edit(EditOp::Insert { pos, bytes }) { - self.status = format!("insert failed: {e}"); - return false; - } + // Q#AP9: the buffer/window the request was made in, captured + // BEFORE the edit — a legal intercept may switch the active + // context mid-edit, and the record must name where the + // self-insert actually landed, not where the intercept went. + let (buffer_id, window_id) = (self.active_buffer_id(), self.active_window_id()); + let edit = match self.apply_active_edit(EditOp::Insert { pos, bytes }) { + Ok(edit) => edit, + Err(e) => { + self.status = format!("insert failed: {e}"); + return false; + } + }; self.active_window_mut().cursor += bytes.len() as u64; + self.typed_edit_complete( + ch, + (buffer_id, window_id), + Range::new(pos, pos), + bytes.len() as u64, + &edit, + ); true } @@ -1770,16 +1861,29 @@ impl EditorCore { self.active_window_mut().goal_col = None; let mut buf = [0u8; 4]; let bytes = ch.encode_utf8(&mut buf).as_bytes(); - if let Err(e) = self.apply_active_edit(EditOp::Replace { + // Q#AP9: capture the request's context before the edit (see + // the twin comment in [`Self::insert_char`]). + let (buffer_id, window_id) = (self.active_buffer_id(), self.active_window_id()); + let edit = match self.apply_active_edit(EditOp::Replace { range: Range { start: lo, end: hi }, bytes, }) { - self.status = format!("replace failed: {e}"); - return; - } + Ok(edit) => edit, + Err(e) => { + self.status = format!("replace failed: {e}"); + return; + } + }; let aw = self.active_window_mut(); aw.cursor = lo + bytes.len() as u64; aw.selection = None; + self.typed_edit_complete( + ch, + (buffer_id, window_id), + Range::new(lo, hi), + bytes.len() as u64, + &edit, + ); } /// Delete the codepoint immediately before the cursor. @@ -2095,9 +2199,12 @@ impl EditorCore { let Some((lo, hi)) = self.active_region() else { return Ok(self.active_buffer_len()); }; - let new_len = self.apply_active_edit(EditOp::Delete { - range: Range { start: lo, end: hi }, - })?; + let new_len = self + .apply_active_edit(EditOp::Delete { + range: Range { start: lo, end: hi }, + })? + .new_rope + .len(); let aw = self.active_window_mut(); aw.cursor = lo; aw.selection = None; @@ -2189,6 +2296,100 @@ impl EditorCore { .as_deref() } + // ---- typed-edit provenance (auto-pairing, Q#AP9) --------------------- + + /// Declare that `fid`'s dispatch is about to invoke + /// `buffer.self-insert` for `codepoint`: the next insert primitive + /// whose character matches completes the [`TypedEditRecord`]. + /// Called by the dispatch fallback only — programmatic + /// `pmacs.command.invoke("buffer.self-insert")` deliberately never + /// arms, so a hook run after it observes no record. + pub fn typed_edit_arm(&mut self, fid: FrontendId, codepoint: char) { + self.typed_edit_pending = Some(TypedEditPending { + fid, + codepoint, + record: None, + }); + } + + /// Complete the pending typed-edit record from the effective edit, + /// if one is armed for this character and hasn't completed yet. + /// First match wins: a command body that somehow self-inserts the + /// same character twice records the first landing (the one the + /// dispatcher's keystroke produced). `context` is the caller's + /// pre-edit `(buffer, window)` — the buffer the edit landed in + /// even when an intercept switched the active context mid-edit. + fn typed_edit_complete( + &mut self, + ch: char, + context: (BufferId, WindowId), + requested: Range, + requested_len: u64, + edit: &Edit, + ) { + let matches = self.typed_edit_pending.as_ref().is_some_and(|p| { + p.record.is_none() && p.codepoint == ch && p.fid == self.active_frontend + }); + if !matches { + return; + } + let clean = edit.range == requested && edit.inserted_len == requested_len; + let record = TypedEditRecord { + buffer: context.0, + window: context.1, + codepoint: ch, + requested_start: requested.start, + requested_end: requested.end, + effective_start: edit.range.start, + effective_end: edit.range.end, + inserted_len: edit.inserted_len, + post_cursor: self.active_window().cursor, + clean, + }; + if let Some(p) = self.typed_edit_pending.as_mut() { + p.record = Some(record); + } + } + + /// Take back the pending arm at the end of `fid`'s dispatch, + /// 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. + pub fn typed_edit_finish(&mut self, fid: FrontendId) -> Option { + let pending = self.typed_edit_pending.take()?; + if pending.fid != fid { + return None; + } + pending.record + } + + /// Arm `record` for consumption during the `buffer.after-edit` + /// fan-out the caller is about to run. The caller MUST clear the + /// slot when the fan-out returns ([`Self::typed_edit_clear_armed`]), + /// error paths included — the record must never outlive its hook. + pub fn typed_edit_set_armed(&mut self, fid: FrontendId, record: TypedEditRecord) { + self.typed_edit_armed = Some((fid, record)); + } + + /// Drop any untaken armed record. Producers call this immediately + /// after their `buffer.after-edit` fan-out returns. + pub fn typed_edit_clear_armed(&mut self) { + self.typed_edit_armed = None; + } + + /// One-shot consume of the armed typed-edit record, per frontend: + /// yields the record iff one is armed for the *active* frontend, + /// clearing the slot. Second and later takes — including from a + /// nested manual `pmacs.hook.run("buffer.after-edit")` — observe + /// `None`, as does any context where no producer armed a record + /// (paste, programmatic mutation, standalone manual hook runs). + pub fn take_typed_edit(&mut self) -> Option { + if self.typed_edit_armed.as_ref()?.0 != self.active_frontend { + return None; + } + self.typed_edit_armed.take().map(|(_, rec)| rec) + } + /// Copy the active region into the clipboard slot and queue an /// outbound OS-clipboard publish to the originating frontend. /// Returns `false` (a no-op) when there is no region. diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index dcdab4b..a72840f 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -11011,6 +11011,44 @@ fn install_session(editor: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result lua.create_function(move |_, ()| Ok(cc.borrow().this_command().map(str::to_owned)))?, )?; } + { + // take_typed_edit(): auto-pairing Q#AP9 — the one-shot exact + // provenance record of the self-insert that produced the + // current `buffer.after-edit` fan-out, or nil. Where + // `this_command()` names only the input class, this record + // carries the typed codepoint and the requested vs effective + // (post-intercept) edit, so a consumer can fail closed on a + // transformed, relocated, or context-switched source edit. + // Consuming clears the slot: later callbacks and nested manual + // hook runs see nil, and the producer clears any untaken + // record when the fan-out returns. Per-frontend — one + // frontend can never take another's record. `char` is the + // codepoint as a UTF-8 string (LuaJIT has no `utf8` library + // to convert `codepoint` Lua-side). + let cc = core.clone(); + editor.set( + "take_typed_edit", + lua.create_function(move |lua, ()| { + let Some(rec) = cc.borrow_mut().take_typed_edit() else { + return Ok(mlua::Value::Nil); + }; + let cvt = |v: u64| i64::try_from(v).map_err(mlua::Error::external); + let t = lua.create_table()?; + t.set("buffer", BufferIdLua(rec.buffer))?; + t.set("window", cvt(rec.window.raw())?)?; + t.set("codepoint", i64::from(u32::from(rec.codepoint)))?; + t.set("char", rec.codepoint.to_string())?; + t.set("requested_start", cvt(rec.requested_start)?)?; + t.set("requested_end", cvt(rec.requested_end)?)?; + t.set("effective_start", cvt(rec.effective_start)?)?; + t.set("effective_end", cvt(rec.effective_end)?)?; + t.set("inserted_len", cvt(rec.inserted_len)?)?; + t.set("post_cursor", cvt(rec.post_cursor)?)?; + t.set("clean", rec.clean)?; + Ok(mlua::Value::Table(t)) + })?, + )?; + } { // view_top(): the active window's first visible source line. // The saveplace getter (Arc 3) — pairs with set_view_top so a diff --git a/src/optimistic.rs b/src/optimistic.rs index 6b27d84..e8a30f7 100644 --- a/src/optimistic.rs +++ b/src/optimistic.rs @@ -34,7 +34,7 @@ use crate::buffer::BufferId; use crate::buffer_mirror::{BufferMirror, BufferMirrorError}; -use crate::protocol::{FrontendEvent, FrontendId, Key, KeyEvent, Modifiers}; +use crate::protocol::{FrontendEvent, FrontendId, Key, KeyEvent, Modifiers, is_builtin_pair_char}; use crate::rope::CrdtOp; use unicode_width::UnicodeWidthChar; @@ -135,7 +135,13 @@ pub fn classify_key(key: Key, mods: Modifiers) -> OptimisticAction { return OptimisticAction::RoundTrip; } match key { - Key::Char(c) if !c.is_control() => OptimisticAction::Insert(c), + // Auto-pairing Q#AP1: the built-in pair charset always + // round-trips so the opener and the pairing hook's closer are + // adjacent daemon-peer undo units (and dispatch-path CUA + // type-over applies). An optimistic pair char would be a + // source-peer op whose reaction closer lives on the daemon + // peer — uncleanly undoable from either frontend. + Key::Char(c) if !c.is_control() && !is_builtin_pair_char(c) => OptimisticAction::Insert(c), Key::Backspace => OptimisticAction::DeleteBack, Key::Delete => OptimisticAction::DeleteForward, _ => OptimisticAction::RoundTrip, @@ -460,6 +466,29 @@ mod tests { } } + #[test] + fn classify_builtin_pair_chars_round_trip() { + // Auto-pairing Q#AP1: the nine built-in pair chars must reach + // the daemon's dispatch so the opener and the hook's closer are + // adjacent daemon-peer undo units. Both modifier shapes real + // keyboards produce are pinned: `[`/`]`/`'`/`` ` `` arrive + // unshifted, `(`/`)`/`{`/`}`/`"` arrive with SHIFT set — a gate + // that only caught `Modifiers::NONE` would leak every shifted + // pair char back onto the optimistic path. + for c in crate::protocol::BUILTIN_PAIR_CHARS { + assert_eq!( + classify_key(Key::Char(c), Modifiers::NONE), + OptimisticAction::RoundTrip, + "unshifted {c:?} must round-trip" + ); + assert_eq!( + classify_key(Key::Char(c), Modifiers::SHIFT), + OptimisticAction::RoundTrip, + "shifted {c:?} must round-trip" + ); + } + } + #[test] fn classify_unicode_char_no_modifiers_is_insert() { // Non-ASCII printable — multi-byte UTF-8. diff --git a/tests/auto_pair_acceptance.rs b/tests/auto_pair_acceptance.rs new file mode 100644 index 0000000..5223947 --- /dev/null +++ b/tests/auto_pair_acceptance.rs @@ -0,0 +1,1019 @@ +//! Auto-pairing acceptance (Arc 2, docs/auto-pairing-framing.md). +//! +//! Dispatch-driven: pair chars round-trip through `dispatch_key` +//! (Q#AP1 removed them from both optimistic classifiers, so this IS +//! the production path for both frontends). Scratch-buffer tests cover +//! the default pair set; per-language tests visit file-backed buffers +//! with an emptied `pmacs.lsp.config` (language DETECTION must work, +//! server SPAWNING must not); the Q#AP7/Q#AP8 ordering tests drive the +//! real fake-LSP `sighelp` mode and replay the exact document-sync +//! sequence the server received via `PMACS_FAKE_LSP_CHANGE_SINK`. + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; +use pmacs::editor::EditorState; +use pmacs::lua_bindings::StateDir; +use pmacs::protocol::FrontendId; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + +fn fresh_state_dir() -> PathBuf { + static SEQ: AtomicUsize = AtomicUsize::new(0); + let dir = std::env::temp_dir().join(format!( + "pmacs-autopair-{}-{}", + std::process::id(), + SEQ.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn editor(state_dir: &std::path::Path) -> EditorState { + let s = EditorState::new(); + s.lua_host.lua().remove_app_data::(); + s.lua_host + .lua() + .set_app_data(StateDir(state_dir.to_path_buf())); + // Language DETECTION must work (filetypes/grammars); server + // SPAWNING must not (rust/python have default configs). + exec(&s, "pmacs.lsp.config = {}"); + s +} + +fn write_file(dir: &std::path::Path, name: &str, body: &str) -> String { + let p = dir.join(name); + std::fs::write(&p, body).unwrap(); + p.display().to_string() +} + +fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent { + KeyEvent { + code, + modifiers: mods, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } +} + +fn ctrl(s: &mut EditorState, c: char) { + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Char(c), KeyModifiers::CONTROL), + ); +} + +fn press(s: &mut EditorState, code: KeyCode) { + s.dispatch_key(FrontendId::LOCAL, key(code, KeyModifiers::NONE)); +} + +fn type_str(s: &mut EditorState, text: &str) { + for ch in text.chars() { + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Char(ch), KeyModifiers::NONE), + ); + } +} + +/// `C-x u` — the always-dispatched undo (daemon-peer history). +fn undo(s: &mut EditorState) { + ctrl(s, 'x'); + press(s, KeyCode::Char('u')); +} + +/// `C-x r` — redo. +fn redo(s: &mut EditorState) { + ctrl(s, 'x'); + press(s, KeyCode::Char('r')); +} + +fn exec(s: &EditorState, src: &str) { + s.lua_host.lua().load(src.to_string()).exec().unwrap(); +} + +fn eval(s: &EditorState, src: &str) -> T { + s.lua_host.lua().load(src.to_string()).eval().unwrap() +} + +fn buffer_text(s: &EditorState) -> String { + let b: mlua::String = eval( + s, + "local b = pmacs.window.buffer(); return b:slice(0, b:len())", + ); + String::from_utf8_lossy(&b.as_bytes()).into_owned() +} + +fn cursor(s: &EditorState) -> i64 { + eval(s, "return pmacs.editor.cursor()") +} + +fn status(s: &EditorState) -> String { + s.core.borrow().status.clone() +} + +/// Fresh scratch-buffer editor whose buffer holds `body`, cursor at 0. +/// No state dir / no files: scratch pairing uses the `default` set. +fn editor_with(body: &str) -> EditorState { + let s = EditorState::new(); + if !body.is_empty() { + exec(&s, &format!("pmacs.window.buffer():insert(0, {body:?})")); + } + exec(&s, "pmacs.editor.goto_byte(0)"); + s +} + +/// Fresh editor visiting `name` (created in a private tempdir) with +/// `body` on disk, cursor at 0, `pmacs.lsp.config` emptied. +fn editor_visiting(name: &str, body: &str) -> EditorState { + let dir = fresh_state_dir(); + let s = editor(&dir); + let f = write_file(&dir, name, body); + exec(&s, &format!("pmacs.buffer.find_or_open({f:?})")); + exec(&s, "pmacs.editor.goto_byte(0)"); + s +} + +// --------------------------------------------------------------------------- +// Insert-pair semantics (Q#AP3): the conservative predicate +// --------------------------------------------------------------------------- + +#[test] +fn opener_at_end_of_buffer_pairs_with_cursor_between() { + let mut s = editor_with(""); + type_str(&mut s, "("); + assert_eq!(buffer_text(&s), "()"); + assert_eq!(cursor(&s), 1, "cursor sits between the pair"); +} + +#[test] +fn shifted_opener_pairs_too() { + // Real keyboards produce `(` as Shift+9: the chord arrives as + // `Char('(')` with SHIFT set and must still self-insert + pair. + let mut s = editor_with(""); + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Char('('), KeyModifiers::SHIFT), + ); + assert_eq!(buffer_text(&s), "()"); + assert_eq!(cursor(&s), 1); +} + +#[test] +fn opener_at_end_of_line_pairs() { + let mut s = editor_with("x\ny"); + exec(&s, "pmacs.editor.goto_byte(1)"); + type_str(&mut s, "("); + assert_eq!(buffer_text(&s), "x()\ny"); + assert_eq!(cursor(&s), 2); +} + +#[test] +fn opener_before_whitespace_pairs() { + let mut s = editor_with("foo bar"); + exec(&s, "pmacs.editor.goto_byte(3)"); + type_str(&mut s, "("); + assert_eq!(buffer_text(&s), "foo() bar"); + assert_eq!(cursor(&s), 4); +} + +#[test] +fn opener_before_closing_bracket_pairs() { + let mut s = editor_with("()"); + exec(&s, "pmacs.editor.goto_byte(1)"); + type_str(&mut s, "["); + assert_eq!(buffer_text(&s), "([])"); + assert_eq!(cursor(&s), 2); +} + +#[test] +fn opener_before_word_char_does_not_pair() { + let mut s = editor_with("bar"); + type_str(&mut s, "("); + assert_eq!( + buffer_text(&s), + "(bar", + "`foo|bar` + `(` gives `(bar`, never `()bar`" + ); + assert_eq!(cursor(&s), 1); +} + +// --------------------------------------------------------------------------- +// Skip-over-close (Q#AP4) +// --------------------------------------------------------------------------- + +#[test] +fn closer_skips_over_existing_closer() { + let mut s = editor_with(""); + type_str(&mut s, "("); + assert_eq!(buffer_text(&s), "()"); + type_str(&mut s, ")"); + assert_eq!( + buffer_text(&s), + "()", + "the typed `)` steps over, not doubles" + ); + assert_eq!(cursor(&s), 2, "cursor lands after the closer"); +} + +#[test] +fn nested_closers_skip_outward() { + let mut s = editor_with(""); + type_str(&mut s, "(("); + assert_eq!(buffer_text(&s), "(())"); + assert_eq!(cursor(&s), 2); + type_str(&mut s, "))"); + assert_eq!(buffer_text(&s), "(())", "both closers skip"); + assert_eq!(cursor(&s), 4); +} + +#[test] +fn quote_pairs_then_second_quote_exits() { + let mut s = editor_with(""); + type_str(&mut s, "\""); + assert_eq!(buffer_text(&s), "\"\"", "symmetric pair inserts"); + assert_eq!(cursor(&s), 1); + type_str(&mut s, "\""); + assert_eq!( + buffer_text(&s), + "\"\"", + "second quote skips (exits the string)" + ); + assert_eq!(cursor(&s), 2); +} + +// --------------------------------------------------------------------------- +// Pair sets (Q#AP2): per-language, conservative default +// --------------------------------------------------------------------------- + +#[test] +fn single_quote_pairs_in_python_but_not_rust() { + let mut py = editor_visiting("a.py", ""); + type_str(&mut py, "'"); + assert_eq!(buffer_text(&py), "''", "python's set adds `''`"); + + let mut rs = editor_visiting("a.rs", ""); + type_str(&mut rs, "'"); + assert_eq!( + buffer_text(&rs), + "'", + "rust keeps the default set: `'` is a lifetime, not a pair" + ); +} + +#[test] +fn scratch_buffer_pairs_the_default_set() { + let mut s = editor_with(""); + type_str(&mut s, "{"); + assert_eq!( + buffer_text(&s), + "{}", + "language-less buffers pair the default set" + ); + let mut s2 = editor_with(""); + type_str(&mut s2, "'"); + assert_eq!( + buffer_text(&s2), + "'", + "no apostrophe pairing in the default set" + ); + let mut s3 = editor_with(""); + type_str(&mut s3, "`"); + assert_eq!( + buffer_text(&s3), + "`", + "no backtick pairing in the default set" + ); +} + +// --------------------------------------------------------------------------- +// Non-typed provenance (Q#AP9): no record, no reaction — with the +// after-edit callback actually exercised in every case. +// --------------------------------------------------------------------------- + +#[test] +fn paste_of_opener_does_not_pair() { + let mut s = editor_with(""); + // A prior self-insert, so a heuristic keyed only on buffer text or + // `char_before` would be primed to misfire. + type_str(&mut s, "a"); + // 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`). + s.core.borrow_mut().break_command_chain(FrontendId::LOCAL); + s.core.borrow_mut().paste_inbound(b"(").unwrap(); + s.lua_host + .run_hook("buffer.after-edit", mlua::MultiValue::new()); + assert_eq!(buffer_text(&s), "a(", "a pasted opener stays lone"); + let record_nil: bool = eval(&s, "return pmacs.pair._last_record == nil"); + assert!(record_nil, "paste must arm no typed-edit record"); +} + +#[test] +fn programmatic_insert_with_stale_this_command_does_not_pair() { + let mut s = editor_with(""); + // 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"); + let stale: String = eval(&s, "return pmacs.editor.this_command()"); + assert_eq!(stale, "buffer.self-insert"); + exec(&s, "pmacs.window.buffer():insert(1, \"(\")"); + exec(&s, "pmacs.hook.run(\"buffer.after-edit\")"); + assert_eq!( + buffer_text(&s), + "a(", + "programmatic insert + manual hook run must not pair, even with \ + this_command still reading buffer.self-insert" + ); + let record_nil: bool = eval(&s, "return pmacs.pair._last_record == nil"); + assert!(record_nil, "manual hook run must observe no record"); +} + +#[test] +fn command_invoke_self_insert_does_not_pair() { + let s = editor_with(""); + // 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)"); // '(' + exec(&s, "pmacs.hook.run(\"buffer.after-edit\")"); + assert_eq!(buffer_text(&s), "(", "invoked self-insert stays lone"); + let record_nil: bool = eval(&s, "return pmacs.pair._last_record == nil"); + assert!(record_nil); +} + +// --------------------------------------------------------------------------- +// Type-over composition (Q#AP6, dispatch route) +// --------------------------------------------------------------------------- + +#[test] +fn opener_over_region_type_overs_then_pairs() { + let mut s = editor_with("abc"); + // Shift+Right x3: region [0, 3). + for _ in 0..3 { + s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Right, KeyModifiers::SHIFT)); + } + type_str(&mut s, "("); + assert_eq!( + buffer_text(&s), + "()", + "the region is consumed by type-over, then the predicate pairs at EOB" + ); + assert_eq!(cursor(&s), 1); + let region_nil: bool = eval(&s, "return pmacs.editor.region() == nil"); + assert!(region_nil, "selection cleared"); +} + +// --------------------------------------------------------------------------- +// Undo grain (Q#AP5, daemon history in the non-replica harness) +// --------------------------------------------------------------------------- + +#[test] +fn pair_is_two_adjacent_undo_steps_and_two_redo_steps() { + let mut s = editor_with(""); + type_str(&mut s, "("); + assert_eq!(buffer_text(&s), "()"); + undo(&mut s); + assert_eq!( + buffer_text(&s), + "(", + "first undo removes the reaction closer" + ); + undo(&mut s); + assert_eq!(buffer_text(&s), "", "second undo removes the typed opener"); + redo(&mut s); + assert_eq!(buffer_text(&s), "(", "first redo restores the opener"); + redo(&mut s); + assert_eq!(buffer_text(&s), "()", "second redo restores the closer"); +} + +#[test] +fn skip_undo_restores_the_swallowed_duplicate() { + let mut s = editor_with(""); + type_str(&mut s, "()"); + assert_eq!(buffer_text(&s), "()"); + undo(&mut s); + assert_eq!( + buffer_text(&s), + "())", + "undoing the skip's delete restores the typed duplicate" + ); +} + +// --------------------------------------------------------------------------- +// Reaction intercept outcomes (Q#AP3/Q#AP4): rejected vs transformed +// --------------------------------------------------------------------------- + +#[test] +fn rejected_closer_leaves_opener_alone_and_reports() { + let mut s = editor_with(""); + exec( + &s, + r#" + pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op) + if op.kind == "insert" and op.bytes == ")" then + error("rejected by test intercept") + end + return nil + end) + "#, + ); + type_str(&mut s, "("); + assert_eq!( + buffer_text(&s), + "(", + "nothing landed; the opener stands alone" + ); + assert_eq!(cursor(&s), 1); + assert!( + status(&s).contains("auto-pair closer rejected"), + "got: {:?}", + status(&s) + ); +} + +#[test] +fn relocated_closer_lands_where_the_intercept_put_it_cursor_translated() { + let mut s = editor_with("ab"); + 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 == ")" then + return { kind = "insert", pos = 0, bytes = op.bytes } + end + return nil + end) + "#, + ); + type_str(&mut s, "("); + assert_eq!( + buffer_text(&s), + ")ab(", + "the intercept's positional result stands" + ); + assert!( + status(&s).contains("auto-pair closer altered"), + "got: {:?}", + status(&s) + ); + assert_eq!( + cursor(&s), + 4, + "pre-edit cursor 3 right-gravity-translated through the insert at 0 — \ + translated, not teleported to the relocated closer" + ); +} + +#[test] +fn rejected_skip_delete_keeps_the_duplicate() { + let mut s = editor_with(""); + type_str(&mut s, "("); + assert_eq!(buffer_text(&s), "()"); + exec( + &s, + r#" + pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op) + if op.kind == "delete" then error("rejected by test intercept") end + return nil + end) + "#, + ); + type_str(&mut s, ")"); + assert_eq!(buffer_text(&s), "())", "the typed duplicate stays"); + assert_eq!(cursor(&s), 2); + assert!( + status(&s).contains("auto-pair skip rejected"), + "got: {:?}", + status(&s) + ); +} + +#[test] +fn expanded_skip_delete_lands_reported_and_cursor_clamped() { + let mut s = editor_with(""); + type_str(&mut s, "("); + exec(&s, "pmacs.window.buffer():insert(2, \"x\")"); // "()x" + exec(&s, "pmacs.editor.goto_byte(1)"); + exec( + &s, + r#" + pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op) + if op.kind == "delete" then + return { kind = "delete", start = op.start, ["end"] = op["end"] + 1 } + end + return nil + end) + "#, + ); + type_str(&mut s, ")"); + assert_eq!( + buffer_text(&s), + "()", + "the expanded delete swallowed the duplicate AND the x — it stands" + ); + assert!( + status(&s).contains("auto-pair skip altered"), + "got: {:?}", + status(&s) + ); + assert_eq!(cursor(&s), 2, "translate-and-clamp repair"); +} + +// --------------------------------------------------------------------------- +// Source self-insert intercepts (Q#AP9): the reaction fails closed +// --------------------------------------------------------------------------- + +#[test] +fn relocated_opener_gets_no_pair_reaction() { + let mut s = editor_with("ab"); + 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 == "(" then + return { kind = "insert", pos = 0, bytes = op.bytes } + end + return nil + end) + "#, + ); + type_str(&mut s, "("); + assert_eq!( + buffer_text(&s), + "(ab", + "exactly the intercept's positional result — no closer anywhere" + ); + assert!( + status(&s).contains("auto-pair skipped: source self-insert transformed"), + "got: {:?}", + status(&s) + ); +} + +#[test] +fn transformed_type_over_gets_no_pair_reaction() { + let mut s = editor_with("abcd"); + for _ in 0..2 { + s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Right, KeyModifiers::SHIFT)); + } + exec( + &s, + r#" + pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op) + if op.kind == "replace" then + return { kind = "replace", start = op.start, + ["end"] = op["end"] + 1, bytes = op.bytes } + end + return nil + end) + "#, + ); + type_str(&mut s, "("); + assert_eq!( + buffer_text(&s), + "(d", + "the expanded type-over stands as the intercept produced it" + ); + assert!( + status(&s).contains("auto-pair skipped: source self-insert transformed"), + "got: {:?}", + status(&s) + ); +} + +#[test] +fn source_context_switch_fails_closed() { + // A context-switching INTERCEPT cannot exist on the dispatch + // self-insert path (the core borrow is held across it; the + // three-phase borrow-released discipline is the Lua-mutator + // path's). The legal producer of a context-switched source + // self-insert is a user-redefined `buffer.self-insert` command + // that switches after inserting — the record still completes for + // the exact typed codepoint, and the hook then runs in the new + // context, where pairing must fail closed. + 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)"); + // Bump the scratch revision past other.txt's so the active-buffer + // revision compare still fires the hook after the switch (the + // buffer-aware edit epoch is a named substrate deferral). + type_str(&mut s, "ab"); + 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, "("); + assert!( + status(&s).contains("auto-pair skipped: source context changed"), + "got: {:?}", + status(&s) + ); + let scratch_text: String = eval(&s, "return _G.scratch:slice(0, _G.scratch:len())"); + assert_eq!( + scratch_text, "ab(", + "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"); +} + +// --------------------------------------------------------------------------- +// Context-switching REACTION intercept: repair skipped, deferral pinned +// --------------------------------------------------------------------------- + +#[test] +fn context_switching_reaction_intercept_skips_repair_and_later_callbacks_observe_it() { + 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)"); + type_str(&mut s, "ab"); + // A probe registered AFTER pair.lua (and every builtin): it + // observes whatever context the fan-out is in when it runs — + // explicitly pinning, not concealing, the origin-context deferral. + exec( + &s, + r#" + _G.probe_buf = nil + pmacs.hook.add("buffer.after-edit", function() + _G.probe_buf = tostring(pmacs.window.buffer()) + end) + pmacs.buffer.add_intercept(_G.scratch, function(op) + if op.kind == "insert" and op.bytes == ")" then + pmacs.window.switch_buffer(_G.other) + return { kind = "insert", pos = 0, bytes = op.bytes } + end + return nil + end) + "#, + ); + type_str(&mut s, "("); + assert!( + status(&s).contains("auto-pair closer altered"), + "got: {:?}", + status(&s) + ); + let scratch_text: String = eval(&s, "return _G.scratch:slice(0, _G.scratch:len())"); + assert_eq!( + scratch_text, ")ab(", + "the relocated closer landed in the scratch buffer as the intercept wrote it" + ); + let other_text: String = eval(&s, "return _G.other:slice(0, _G.other:len())"); + assert_eq!( + other_text, "z", + "pair.lua never touched the new context's text" + ); + assert_eq!( + cursor(&s), + 0, + "no cursor repair in the switched-to context (switch_buffer's own \ + cursor reset stands untouched)" + ); + let probe_saw_other: bool = eval(&s, "return _G.probe_buf == tostring(_G.other)"); + assert!( + probe_saw_other, + "a later callback observes the switched context — the origin-pinned \ + fan-out deferral, pinned" + ); +} + +// --------------------------------------------------------------------------- +// Hook fan-out: one fire per keystroke, the reaction edit doesn't re-fire +// --------------------------------------------------------------------------- + +#[test] +fn after_edit_fires_once_per_pairing_keystroke() { + let mut s = editor_with(""); + exec( + &s, + r#" + _G.fires = 0 + pmacs.hook.add("buffer.after-edit", function() _G.fires = _G.fires + 1 end) + "#, + ); + type_str(&mut s, "("); + assert_eq!(buffer_text(&s), "()"); + let fires: i64 = eval(&s, "return _G.fires"); + assert_eq!( + fires, 1, + "the closer edit must not re-fire buffer.after-edit" + ); +} + +// --------------------------------------------------------------------------- +// Typed-edit record lifecycle (Q#AP9) +// --------------------------------------------------------------------------- + +#[test] +fn typed_edit_record_is_exact_and_one_shot() { + let mut s = editor_with(""); + exec( + &s, + r#" + _G.second_take = "unset" + pmacs.hook.add("buffer.after-edit", function() + _G.second_take = pmacs.editor.take_typed_edit() + end) + "#, + ); + type_str(&mut s, "("); + // pair.lua (first registrant) consumed the record and published it + // on the test seam: exact codepoint + effective triple. + let (cp, ch, clean, es, ee, il, pc): (i64, String, bool, i64, i64, i64, i64) = eval( + &s, + " + local r = pmacs.pair._last_record + return r.codepoint, r.char, r.clean, r.effective_start, + r.effective_end, r.inserted_len, r.post_cursor + ", + ); + assert_eq!(cp, 40, "exact codepoint for '('"); + assert_eq!(ch, "("); + assert!(clean); + assert_eq!( + (es, ee, il), + (0, 0, 1), + "effective triple of the opener insert" + ); + assert_eq!(pc, 1); + // A second take — from a later callback in the SAME fan-out — is nil. + let second_nil: bool = eval(&s, "return _G.second_take == nil"); + assert!(second_nil, "the record is consumable exactly once"); + // Outside any fan-out the slot is empty. + let outside_nil: bool = eval(&s, "return pmacs.editor.take_typed_edit() == nil"); + assert!(outside_nil, "no record outside the after-edit fan-out"); +} + +#[test] +fn nested_manual_after_edit_run_sees_no_record() { + let mut s = editor_with(""); + exec( + &s, + r#" + _G.outer = nil + _G.ran_nested = false + pmacs.hook.add("buffer.after-edit", function() + if _G.ran_nested then return end -- the nested run reaches this callback too + _G.ran_nested = true + _G.outer = pmacs.pair._last_record + pmacs.hook.run("buffer.after-edit") + end) + "#, + ); + type_str(&mut s, "("); + let outer_seen: bool = eval(&s, "return _G.outer ~= nil"); + assert!(outer_seen, "the outer fan-out carried a record"); + // The nested run re-entered pair.lua, which took nil and published + // nil on the seam — proving the nested run observed no record. + let nested_nil: bool = eval(&s, "return pmacs.pair._last_record == nil"); + assert!(nested_nil, "a nested manual re-run must see nil"); + assert_eq!(buffer_text(&s), "()", "and must insert no second closer"); +} + +#[test] +fn rejected_self_insert_leaves_no_record() { + let mut s = editor_with(""); + exec( + &s, + r#" + pmacs.buffer.add_intercept(pmacs.window.buffer(), function(_op) + error("rejected by test intercept") + end) + "#, + ); + type_str(&mut s, "("); + assert_eq!(buffer_text(&s), "", "nothing landed"); + let take_nil: bool = eval(&s, "return pmacs.editor.take_typed_edit() == nil"); + assert!(take_nil, "a rejecting edit must arm no record"); +} + +#[test] +fn frontends_cannot_consume_each_others_slot() { + let s = editor_with(""); + let (buffer, window) = { + let core = s.core.borrow(); + (core.active_buffer_id(), core.active_window_id()) + }; + let record = pmacs::editor_core::TypedEditRecord { + buffer, + window, + codepoint: '(', + requested_start: 0, + requested_end: 0, + effective_start: 0, + effective_end: 0, + inserted_len: 1, + post_cursor: 1, + clean: true, + }; + let a = FrontendId::LOCAL; + let b = FrontendId(a.0 + 1); + let mut core = s.core.borrow_mut(); + core.typed_edit_set_armed(a, record); + core.active_frontend = b; + assert!( + core.take_typed_edit().is_none(), + "frontend B must not see frontend A's record" + ); + core.active_frontend = a; + assert!( + core.take_typed_edit().is_some(), + "the slot survives a foreign take attempt for its owner" + ); + assert!( + core.take_typed_edit().is_none(), + "one-shot for the owner too" + ); +} + +// --------------------------------------------------------------------------- +// Signature help + first-didChange ordering (Q#AP7 / Q#AP8) +// --------------------------------------------------------------------------- + +fn fake_lsp_path() -> String { + env!("CARGO_BIN_EXE_pmacs_fake_lsp").to_owned() +} + +fn pump_lua_flag(state: &mut EditorState, flag: &str, secs: u64) -> bool { + let deadline = Instant::now() + Duration::from_secs(secs); + loop { + state.tick_processes(); + state.tick_lsp(); + state.tick_async(); + let done: bool = state + .lua_host + .lua() + .load(format!("return ({flag}) == true")) + .eval() + .unwrap_or(false); + if done { + return true; + } + if Instant::now() >= deadline { + return false; + } + std::thread::sleep(Duration::from_millis(10)); + } +} + +/// Editor visiting a `.rs` file attached to the fake LSP in `sighelp` +/// mode, with the document-sync sink at `sink`. Returns after the +/// server initialized. +fn sighelp_editor(dir: &std::path::Path, sink: &std::path::Path, body: &str) -> EditorState { + let mut s = editor(dir); + let fake = fake_lsp_path(); + let sink_disp = sink.display().to_string(); + exec( + &s, + &format!( + "pmacs.lsp.config.rust = {{ + command = '{fake}', + env = {{ + PMACS_FAKE_LSP_MODE = 'sighelp', + PMACS_FAKE_LSP_CHANGE_SINK = '{sink_disp}', + }}, + }}" + ), + ); + let f = write_file(dir, "a.rs", body); + exec(&s, &format!("pmacs.buffer.find_or_open({f:?})")); + exec(&s, "pmacs.editor.goto_byte(0)"); + let initialized = "(function() \ + for _,r in ipairs(pmacs.lsp.list()) do \ + if r.state and r.state.kind=='initialized' then return true end \ + end \ + return false \ + end)()"; + assert!(pump_lua_flag(&mut s, initialized, 5), "fake server init"); + s +} + +/// The `text` of every `textDocument/didChange` line in the sink, in +/// arrival order. +fn did_change_texts(sink: &std::path::Path) -> Vec { + let Ok(raw) = std::fs::read_to_string(sink) else { + return Vec::new(); + }; + raw.lines() + .filter_map(|l| serde_json::from_str::(l).ok()) + .filter(|v| v.get("method").and_then(|m| m.as_str()) == Some("textDocument/didChange")) + .filter_map(|v| v.get("text").and_then(|t| t.as_str()).map(str::to_owned)) + .collect() +} + +#[test] +fn first_did_change_after_opener_carries_the_pair() { + let dir = fresh_state_dir(); + let sink = dir.join("changes.jsonl"); + let mut s = sighelp_editor(&dir, &sink, "\n"); + + type_str(&mut s, "("); + assert_eq!( + buffer_text(&s), + "()\n", + "pairing is active in the attached buffer" + ); + + // The signature auto-trigger's synchronous flush sends the first + // didChange from inside the SAME fan-out; pump until the fake + // server has written it to the sink. + let deadline = Instant::now() + Duration::from_secs(5); + let changes = loop { + s.tick_processes(); + s.tick_lsp(); + s.tick_async(); + let c = did_change_texts(&sink); + if !c.is_empty() { + break c; + } + assert!( + Instant::now() < deadline, + "no didChange reached the fake server" + ); + std::thread::sleep(Duration::from_millis(10)); + }; + assert_eq!( + changes[0], "()\n", + "the FIRST didChange after `(` carries the closer — pair.lua ran \ + before lsp.lua's synchronous flush (Q#AP7 ordering observable)" + ); + + // And the auto-trigger itself still fired with pairing active + // (Q#AP8): the fake's signature label reaches the status line. + let deadline = Instant::now() + Duration::from_secs(5); + let mut saw = false; + while Instant::now() < deadline { + s.tick_processes(); + s.tick_lsp(); + s.tick_async(); + if status(&s).contains("fn echo(") { + saw = true; + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + assert!( + saw, + "signature help must still auto-trigger with pairing active" + ); +} + +#[test] +fn relocated_closer_first_did_change_carries_the_complete_effective_text() { + let dir = fresh_state_dir(); + let sink = dir.join("changes.jsonl"); + let mut s = sighelp_editor(&dir, &sink, "\n"); + // Context-preserving position transform: the closer lands at 0. + exec( + &s, + r#" + pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op) + if op.kind == "insert" and op.bytes == ")" then + return { kind = "insert", pos = 0, bytes = op.bytes } + end + return nil + end) + "#, + ); + type_str(&mut s, "("); + assert_eq!(buffer_text(&s), ")(\n"); + + let deadline = Instant::now() + Duration::from_secs(5); + let changes = loop { + s.tick_processes(); + s.tick_lsp(); + s.tick_async(); + let c = did_change_texts(&sink); + if !c.is_empty() { + break c; + } + assert!( + Instant::now() < deadline, + "no didChange reached the fake server" + ); + std::thread::sleep(Duration::from_millis(10)); + }; + assert_eq!( + changes[0], ")(\n", + "a position-only closer transform still sends the complete effective \ + text in the first didChange — never an opener-only intermediate" + ); +} diff --git a/tests/auto_pair_crdt_acceptance.rs b/tests/auto_pair_crdt_acceptance.rs new file mode 100644 index 0000000..46761b3 --- /dev/null +++ b/tests/auto_pair_crdt_acceptance.rs @@ -0,0 +1,470 @@ +// auto_pair_crdt_acceptance.rs --- auto-pairing over the wire. + +//! Auto-pairing two-replica acceptance (docs/auto-pairing-framing.md): +//! a synthetic source replica plus a synthetic observer replica against +//! a real daemon subprocess. +//! +//! Dispatch route (built-in pair chars, Q#AP1): the source sends +//! round-tripped `Key` events; the daemon pairs/skips and broadcasts +//! `DaemonKey` ops to both replicas. Undo grain (Q#AP5) is pinned for +//! both routing models — the TUI's single-key optimistic undo is the +//! source replica's own peer-bound undo, `C-x u` is a round-tripped +//! daemon undo — as assertions of the named cross-peer substrate +//! limit, NOT frontend-equivalence claims. +//! +//! Optimistic route (custom pair char via user config, Q#AP1 cost +//! paragraph): the opener arrives as a `FrontendEvent::CrdtOp`; the +//! daemon's hook-queued closer is broadcast BEFORE the opener's +//! rebroadcast (the ordering quirk named in the framing), and the +//! observer must still converge. The source mirror's undo removes the +//! opener and leaves the closer — the pinned degraded undo. + +#![cfg(feature = "crdt")] + +use std::time::Duration; + +use pmacs::crdt::CrdtState; +use pmacs::protocol::{FrontendEvent, FrontendId, Key, KeyEvent, Modifiers}; +use pmacs::rope::CrdtOp as RopeCrdtOp; +use pmacs::transport::write_message; + +mod common; +use common::daemon::{TestDaemon, attach_multi}; + +/// Read the daemon's initial `BufferSnapshot` for a freshly-attached +/// replica stream (the daemon always emits it first). +fn read_initial_snapshot( + stream: &mut std::os::unix::net::UnixStream, +) -> (pmacs::buffer::BufferId, Vec) { + match pmacs::transport::read_message::(stream) + .expect("read initial BufferSnapshot") + { + pmacs::protocol::InstanceMessage::BufferSnapshot { + buffer_id, + crdt_snapshot, + } => (buffer_id, crdt_snapshot), + other => panic!("expected initial BufferSnapshot, got {other:?}"), + } +} + +/// One attached synthetic replica: stream + mirror + identity. +struct Replica { + stream: std::os::unix::net::UnixStream, + state: CrdtState, + fid: FrontendId, + buffer_id: pmacs::buffer::BufferId, +} + +fn attach_replica(daemon: &TestDaemon) -> Replica { + let (hello, mut stream) = attach_multi(daemon); + let fid = hello.assigned_frontend_id; + let (buffer_id, snap) = read_initial_snapshot(&mut stream); + let state = CrdtState::new(fid.0).expect("CrdtState::new"); + state.import_snapshot(&snap).expect("import_snapshot"); + Replica { + stream, + state, + fid, + buffer_id, + } +} + +/// Mutate the local replica, export the delta, and ship it as an +/// optimistic `FrontendEvent::CrdtOp` (the `m10_11` idiom). +fn send_optimistic_op(replica: &mut Replica, mutate: F) +where + F: FnOnce(&CrdtState), +{ + let v = replica.state.version(); + mutate(&replica.state); + let op_bytes = replica + .state + .export_updates_since(&v) + .expect("export updates after local mutation"); + write_message( + &mut replica.stream, + &FrontendEvent::CrdtOp { + frontend_id: replica.fid, + buffer_id: replica.buffer_id, + op: RopeCrdtOp { + peer_id: replica.fid.0, + bytes: op_bytes, + }, + }, + ) + .expect("write CrdtOp"); +} + +fn send_key(replica: &mut Replica, key: Key, mods: Modifiers) { + write_message( + &mut replica.stream, + &FrontendEvent::Key(KeyEvent { + frontend_id: replica.fid, + key, + mods, + timestamp_ns: 0, + }), + ) + .expect("send Key"); +} + +/// `C-x u` — the always-dispatched daemon undo. +fn send_daemon_undo(replica: &mut Replica) { + send_key(replica, Key::Char('x'), Modifiers::CTRL); + send_key(replica, Key::Char('u'), Modifiers::NONE); +} + +/// `C-x r` — daemon redo. +fn send_daemon_redo(replica: &mut Replica) { + send_key(replica, Key::Char('x'), Modifiers::CTRL); + send_key(replica, Key::Char('r'), Modifiers::NONE); +} + +/// What a pump observed so far: materialized text, the daemon's last +/// `CursorByte` for the shared buffer, ops imported this call. +struct Observed { + text: String, + cursor: Option, + imported: usize, +} + +/// Pump broadcast messages into the replica until `pred` holds or the +/// deadline passes. Imports every `CrdtOp` for the shared buffer and +/// tracks the latest `CursorByte`. +fn pump_until bool>( + replica: &mut Replica, + timeout: Duration, + what: &str, + pred: P, +) -> Observed { + let deadline = std::time::Instant::now() + timeout; + let mut obs = Observed { + text: replica.state.materialize_string(), + cursor: None, + imported: 0, + }; + loop { + if pred(&obs) { + return obs; + } + assert!( + std::time::Instant::now() < deadline, + "pump timeout waiting for {what}; text={:?} cursor={:?} imported={}", + obs.text, + obs.cursor, + obs.imported + ); + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + replica + .stream + .set_read_timeout(Some(remaining.min(Duration::from_millis(100)))) + .ok(); + match pmacs::transport::read_message::( + &mut replica.stream, + ) { + Ok(pmacs::protocol::InstanceMessage::CrdtOp { buffer_id: b, op }) + if b == replica.buffer_id => + { + let _ = replica.state.import_updates(&op.bytes); + obs.imported += 1; + obs.text = replica.state.materialize_string(); + } + Ok(pmacs::protocol::InstanceMessage::CursorByte { + buffer_id: b, + byte_pos, + }) if b == replica.buffer_id => { + obs.cursor = Some(byte_pos); + } + Ok(_) | Err(_) => {} + } + } +} + +/// Pump for `window` expecting NO text change — the negative +/// assertion for "a further daemon undo cannot reach source-peer +/// history". Ops are still imported (there should be none that change +/// text); panics if the text leaves `expected`. +fn assert_text_stays(replica: &mut Replica, expected: &str, window: Duration) { + let deadline = std::time::Instant::now() + window; + while std::time::Instant::now() < deadline { + replica + .stream + .set_read_timeout(Some(Duration::from_millis(50))) + .ok(); + if let Ok(pmacs::protocol::InstanceMessage::CrdtOp { buffer_id: b, op }) = + pmacs::transport::read_message::(&mut replica.stream) + && b == replica.buffer_id + { + let _ = replica.state.import_updates(&op.bytes); + assert_eq!( + replica.state.materialize_string(), + expected, + "text must not change during the negative window" + ); + } + } + assert_eq!(replica.state.materialize_string(), expected); +} + +// --------------------------------------------------------------------------- +// Dispatch route (built-in chars) +// --------------------------------------------------------------------------- + +/// Round-tripped `(` pairs daemon-side and both replicas converge to +/// `()` with the daemon cursor between the pair; a round-tripped `)` +/// then skips (insert + swallow-delete, two more ops) and the daemon +/// cursor steps over the closer. +#[test] +fn dispatch_route_pair_and_skip_converge_on_both_replicas() { + let daemon = TestDaemon::spawn(); + let mut source = attach_replica(&daemon); + let mut observer = attach_replica(&daemon); + + send_key(&mut source, Key::Char('('), Modifiers::NONE); + pump_until(&mut observer, Duration::from_secs(5), "observer ()", |o| { + o.text == "()" + }); + pump_until( + &mut source, + Duration::from_secs(5), + "source () with cursor between", + |o| o.text == "()" && o.cursor == Some(1), + ); + + // Skip: the typed `)` inserts then swallows the duplicate — two + // ops that leave the text identical, so convergence is detected + // by the op count plus the daemon cursor stepping to 2. + send_key(&mut source, Key::Char(')'), Modifiers::NONE); + pump_until( + &mut source, + Duration::from_secs(5), + "source skip (two ops, cursor after the closer)", + |o| o.text == "()" && o.imported >= 2 && o.cursor == Some(2), + ); + pump_until( + &mut observer, + Duration::from_secs(5), + "observer skip (two ops, text still ())", + |o| o.text == "()" && o.imported >= 2, + ); +} + +/// Q#AP5 undo grain over the wire: the pair is two adjacent +/// daemon-peer units. Two `C-x u` restore `(` then empty on BOTH +/// replicas; two `C-x r` restore `(` then `()` in order. +#[test] +fn dispatch_route_daemon_undo_redo_walk_the_pair_on_both_replicas() { + let daemon = TestDaemon::spawn(); + let mut source = attach_replica(&daemon); + let mut observer = attach_replica(&daemon); + + send_key(&mut source, Key::Char('('), Modifiers::NONE); + pump_until(&mut source, Duration::from_secs(5), "source ()", |o| { + o.text == "()" + }); + pump_until(&mut observer, Duration::from_secs(5), "observer ()", |o| { + o.text == "()" + }); + + send_daemon_undo(&mut source); + pump_until(&mut source, Duration::from_secs(5), "source (", |o| { + o.text == "(" + }); + pump_until(&mut observer, Duration::from_secs(5), "observer (", |o| { + o.text == "(" + }); + + send_daemon_undo(&mut source); + pump_until(&mut source, Duration::from_secs(5), "source empty", |o| { + o.text.is_empty() + }); + pump_until( + &mut observer, + Duration::from_secs(5), + "observer empty", + |o| o.text.is_empty(), + ); + + send_daemon_redo(&mut source); + pump_until( + &mut source, + Duration::from_secs(5), + "source ( redone", + |o| o.text == "(", + ); + pump_until( + &mut observer, + Duration::from_secs(5), + "observer ( redone", + |o| o.text == "(", + ); + + send_daemon_redo(&mut source); + pump_until( + &mut source, + Duration::from_secs(5), + "source () redone", + |o| o.text == "()", + ); + pump_until( + &mut observer, + Duration::from_secs(5), + "observer () redone", + |o| o.text == "()", + ); +} + +// --------------------------------------------------------------------------- +// Mixed source/daemon history (the named substrate limit, pinned) +// --------------------------------------------------------------------------- + +/// TUI routing model: with optimistic `a` already in the source +/// mirror, the single-key optimistic undo (the mirror's own peer-bound +/// undo) removes `a` — NOT the daemon-peer closer — leaving `()`. +#[test] +fn mixed_history_source_mirror_undo_removes_the_optimistic_char_first() { + let daemon = TestDaemon::spawn(); + let mut source = attach_replica(&daemon); + let mut observer = attach_replica(&daemon); + + send_optimistic_op(&mut source, |r| { + r.insert(0, "a").expect("insert a"); + }); + send_key(&mut source, Key::Char('('), Modifiers::NONE); + pump_until(&mut source, Duration::from_secs(5), "source a()", |o| { + o.text == "a()" + }); + pump_until(&mut observer, Duration::from_secs(5), "observer a()", |o| { + o.text == "a()" + }); + + // The TUI's single-key undo: mirror-local, peer-bound. + send_optimistic_op(&mut source, |r| { + r.undo().expect("mirror undo"); + }); + assert_eq!( + source.state.materialize_string(), + "()", + "the mirror undo removed source-peer `a`, not the adjacent daemon closer" + ); + pump_until(&mut observer, Duration::from_secs(5), "observer ()", |o| { + o.text == "()" + }); +} + +/// `C-x u` routing model (and the GPU model, which reaches the daemon +/// the same way): daemon undos peel the pair — closer, then opener — +/// and a FURTHER daemon undo cannot reach the source-peer `a`. +#[test] +fn mixed_history_daemon_undo_peels_the_pair_but_cannot_reach_source_history() { + let daemon = TestDaemon::spawn(); + let mut source = attach_replica(&daemon); + let mut observer = attach_replica(&daemon); + + send_optimistic_op(&mut source, |r| { + r.insert(0, "a").expect("insert a"); + }); + send_key(&mut source, Key::Char('('), Modifiers::NONE); + pump_until(&mut source, Duration::from_secs(5), "source a()", |o| { + o.text == "a()" + }); + + send_daemon_undo(&mut source); + pump_until(&mut source, Duration::from_secs(5), "source a(", |o| { + o.text == "a(" + }); + pump_until(&mut observer, Duration::from_secs(5), "observer a(", |o| { + o.text == "a(" + }); + + send_daemon_undo(&mut source); + pump_until(&mut source, Duration::from_secs(5), "source a", |o| { + o.text == "a" + }); + + // The named limit: daemon undo is peer-bound too — source-peer + // `a` is beyond its reach. (Cross-peer chronological arbitration + // is deferred substrate work, not pair.lua's claim.) + send_daemon_undo(&mut source); + assert_text_stays(&mut source, "a", Duration::from_millis(800)); +} + +// --------------------------------------------------------------------------- +// Optimistic route (custom pair char from user config) +// --------------------------------------------------------------------------- + +const CUSTOM_PAIR_CONFIG: &str = "table.insert(pmacs.pair.sets.default, \"<>\")\n"; + +/// A user-extended pair char still arrives optimistically: the opener +/// is a source-peer op, the daemon's hook-queued `>` closer is +/// broadcast BEFORE the opener's rebroadcast (the framing's ordering +/// quirk — the observer receives the causally dependent closer +/// first), and both replicas must still converge. The skip route +/// converges likewise. +#[test] +fn optimistic_route_custom_char_pairs_and_skips_despite_closer_first_broadcast() { + let daemon = TestDaemon::spawn_with_config(CUSTOM_PAIR_CONFIG); + let mut source = attach_replica(&daemon); + let mut observer = attach_replica(&daemon); + + send_optimistic_op(&mut source, |r| { + r.insert(0, "<").expect("insert <"); + }); + pump_until(&mut observer, Duration::from_secs(5), "observer <>", |o| { + o.text == "<>" + }); + pump_until(&mut source, Duration::from_secs(5), "source <>", |o| { + o.text == "<>" + }); + + // Skip: the source optimistically types the closer before the + // existing `>`; the daemon swallows the duplicate. Text returns + // to `<>`; the extra daemon delete op must reach both replicas. + send_optimistic_op(&mut source, |r| { + r.insert(1, ">").expect("insert >"); + }); + pump_until( + &mut source, + Duration::from_secs(5), + "source skip converged", + |o| o.text == "<>" && o.imported >= 1, + ); + pump_until( + &mut observer, + Duration::from_secs(5), + "observer skip converged", + |o| o.text == "<>" && o.imported >= 2, + ); +} + +/// The pinned degraded undo for optimistic pair chars: the opener and +/// closer live on DIFFERENT peers, so the source mirror's undo removes +/// its own opener and leaves the daemon's closer behind. +#[test] +fn optimistic_route_mirror_undo_removes_the_opener_leaving_the_closer() { + let daemon = TestDaemon::spawn_with_config(CUSTOM_PAIR_CONFIG); + let mut source = attach_replica(&daemon); + let mut observer = attach_replica(&daemon); + + send_optimistic_op(&mut source, |r| { + r.insert(0, "<").expect("insert <"); + }); + pump_until(&mut source, Duration::from_secs(5), "source <>", |o| { + o.text == "<>" + }); + pump_until(&mut observer, Duration::from_secs(5), "observer <>", |o| { + o.text == "<>" + }); + + send_optimistic_op(&mut source, |r| { + r.undo().expect("mirror undo"); + }); + assert_eq!( + source.state.materialize_string(), + ">", + "peer-bound mirror undo removes the opener; the daemon-peer closer stays" + ); + pump_until(&mut observer, Duration::from_secs(5), "observer >", |o| { + o.text == ">" + }); +} diff --git a/tests/common/daemon.rs b/tests/common/daemon.rs index a0879cf..8a1aed2 100644 --- a/tests/common/daemon.rs +++ b/tests/common/daemon.rs @@ -48,12 +48,33 @@ impl TestDaemon { /// T M10.8 Day 4 — spawn with extra env-var overrides for /// instance-capability tests. pub fn spawn_with_env(env_vars: &[(&str, &str)]) -> Self { + Self::spawn_with_env_and_config(env_vars, None) + } + + /// Spawn with a user `init.lua` pre-written into the daemon's + /// isolated config home (the tempdir doubles as `HOME` / + /// `XDG_CONFIG_HOME`, so the chunk lands at + /// `/pmacs/init.lua` and loads through the real + /// `load_user_config` path). First consumer: the auto-pairing + /// CRDT suite, which extends `pmacs.pair.sets` from config to + /// exercise the optimistic (non-built-in) pair-char route. + #[allow(dead_code)] // consumed per-suite; not every test crate uses it + pub fn spawn_with_config(init_lua: &str) -> Self { + Self::spawn_with_env_and_config(&[], Some(init_lua)) + } + + fn spawn_with_env_and_config(env_vars: &[(&str, &str)], init_lua: Option<&str>) -> Self { let tempdir = TempDir::new().expect("tempdir"); // tempfile::TempDir creates 0755-mode directories; the daemon // requires a 0700-or-stricter parent for the socket. Tighten // the tempdir before spawning. fs::set_permissions(tempdir.path(), fs::Permissions::from_mode(0o700)) .expect("chmod tempdir 0700"); + if let Some(chunk) = init_lua { + let config_dir = tempdir.path().join("pmacs"); + fs::create_dir_all(&config_dir).expect("create pmacs config dir"); + fs::write(config_dir.join("init.lua"), chunk).expect("write init.lua"); + } let socket_path = tempdir.path().join("pmacs.sock"); let mut process = spawn_daemon_process_with_env(&socket_path, env_vars); wait_for_socket_or_exit(&socket_path, &mut process, Duration::from_secs(10))