//! 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_with_roots(&crate::iso::roots()); 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_with_roots(&crate::iso::roots()); 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 malformed_pair_entries_are_skipped_not_partially_honored() { // PR #110 round 1, finding 3: an entry is exactly two codepoints. // "()x" must be ignored entirely — never "type `(`, get `)x`". let mut s = editor_with(""); exec(&s, "pmacs.pair.sets.default = { \"()x\" }"); type_str(&mut s, "("); assert_eq!(buffer_text(&s), "(", "a malformed entry pairs nothing"); assert_eq!(cursor(&s), 1); // Overlong multibyte entry: same rule after a 2-byte opener. let mut s2 = editor_with(""); exec(&s2, "pmacs.pair.sets.default = { \"\u{ab}\u{bb}x\" }"); // "«»x" type_str(&mut s2, "\u{ab}"); assert_eq!( buffer_text(&s2), "\u{ab}", "an overlong entry pairs nothing" ); } #[test] fn malformed_utf8_pair_entries_are_rejected() { // PR #110 round 2, finding 1: byte-length-from-lead-byte alone is // not validation. Every ill-formed closer shape from Unicode // Table 3-7 must disqualify the entry — never land in the buffer. let cases = [ // Truncated 2-byte sequence with a trailing ASCII byte: the // lead byte "promises" 2 bytes, so lead-length parsing counts // "\xC2x" as one codepoint. ("string.char(0xC2) .. \"x\"", "truncated sequence"), // Overlong encoding of `/` (C0 AF). ("string.char(0xC0, 0xAF)", "overlong encoding"), // UTF-16 surrogate D800 (ED A0 80). ("string.char(0xED, 0xA0, 0x80)", "surrogate encoding"), // Beyond U+10FFFF (F5 80 80 80). ("string.char(0xF5, 0x80, 0x80, 0x80)", "beyond U+10FFFF"), ]; for (closer, what) in cases { let mut s = editor_with(""); exec( &s, &format!("pmacs.pair.sets.default = {{ \"(\" .. {closer} }}"), ); type_str(&mut s, "("); assert_eq!(buffer_text(&s), "(", "a {what} closer must pair nothing"); } } #[test] fn multibyte_pair_entries_pair_and_skip() { // Two-codepoint entries with multibyte members are valid: guillemets. let mut s = editor_with(""); exec(&s, "pmacs.pair.sets.default = { \"\u{ab}\u{bb}\" }"); // "«»" type_str(&mut s, "\u{ab}"); assert_eq!(buffer_text(&s), "\u{ab}\u{bb}"); assert_eq!(cursor(&s), 2, "cursor between the pair (byte offset)"); type_str(&mut s, "\u{bb}"); assert_eq!(buffer_text(&s), "\u{ab}\u{bb}", "the closer skips"); assert_eq!(cursor(&s), 4); } #[test] fn opener_before_malformed_buffer_bytes_does_not_pair() { // PR #110 round 3, finding 1: the predicate's raw-byte posture, // pinned from the BUFFER side. A byte that begins no well-formed // sequence (0xFF) sits after the cursor; `char_at` must surface // it as a raw byte the predicate treats as word-like — a // regression back to nil would read it as end-of-buffer and pair // before the junk. let mut s = editor_with(""); exec(&s, "pmacs.window.buffer():insert(0, string.char(0xFF))"); exec(&s, "pmacs.editor.goto_byte(0)"); type_str(&mut s, "("); let len: i64 = eval(&s, "return pmacs.window.buffer():len()"); assert_eq!(len, 2, "opener + junk byte only — a closer would make 3"); let shape_ok: bool = eval( &s, "local b = pmacs.window.buffer() \ return b:slice(0, 1) == \"(\" and b:slice(1, 2):byte(1) == 0xFF", ); assert!( shape_ok, "the opener stands alone before the malformed byte" ); assert!( !status(&s).contains("auto-pair"), "declining the predicate is silent; got: {:?}", status(&s) ); } #[test] fn non_table_sets_container_fails_closed_without_erroring() { // PR #110 round 3, finding 2: the TOP-LEVEL container guard, // pinned. `set_for` degrades a non-table `pmacs.pair.sets` to the // empty set; the callback must not throw. let mut s = editor_with(""); exec(&s, "pmacs.pair.sets = 42"); type_str(&mut s, "("); assert_eq!(buffer_text(&s), "(", "a non-table container pairs nothing"); let log = s.lua_host.errors_buffer_text(); assert!( !log.contains("pair"), "the pairing callback must not error over a config typo; *errors*:\n{log}" ); } #[test] fn non_table_default_set_fails_closed_without_erroring() { // PR #110 round 2, finding 3: a config typo assigning a STRING // where the set table belongs must behave as an empty set — not // throw from the after-edit callback on every keystroke. let mut s = editor_with(""); exec(&s, "pmacs.pair.sets.default = \"()\""); type_str(&mut s, "("); assert_eq!(buffer_text(&s), "(", "a non-table set pairs nothing"); let log = s.lua_host.errors_buffer_text(); assert!( !log.contains("pair"), "the pairing callback must not error over a config typo; *errors*:\n{log}" ); } #[test] fn non_table_language_set_falls_back_to_default() { // The language entry being junk falls back to `default` (the // buffer still deserves pairing), and nothing throws. let mut s = editor_visiting("a.rs", ""); exec(&s, "pmacs.pair.sets.rust = 42"); type_str(&mut s, "("); assert_eq!( buffer_text(&s), "()", "a junk language entry falls back to the default set" ); let log = s.lua_host.errors_buffer_text(); assert!( !log.contains("pair"), "the pairing callback must not error over a config typo; *errors*:\n{log}" ); } #[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(""); exec(&s, "pmacs.pair._capture_records = true"); // A prior self-insert, so a heuristic keyed only on buffer text or // `char_before` would be primed to misfire. (It also proves the // capture seam live: the nil assertion below is a transition from // this keystroke's captured record, not an unset field.) type_str(&mut s, "a"); let primed: bool = eval(&s, "return pmacs.pair._last_record ~= nil"); assert!(primed, "the typed `a` captured a record"); // The daemon's unified inbound-paste route, faithfully: break the // source's command chain, insert, fire the after-edit hook // (`handle_inbound_paste` + `with_after_edit_check`). 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(""); exec(&s, "pmacs.pair._capture_records = true"); // Type 'a' so `this_command` is (and stays) "buffer.self-insert" — // the deliberately stale signal the provenance gate must ignore. type_str(&mut s, "a"); 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(""); exec(&s, "pmacs.pair._capture_records = true"); // Plain `pmacs.command.invoke` is the programmatic API: it stamps // no boundary and arms no record. exec(&s, "pmacs.command.invoke(\"buffer.self-insert\", 40)"); // '(' 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 post_insert_mutation_by_the_command_kills_the_record() { // PR #110 round 1, finding 1: a redefined `buffer.self-insert` // that inserts the char and then REPLACES it — leaving the cursor // untouched — must not pair off the stale record. The record pins // the buffer revision after the completing edit; any further edit // by the same command kills it before the fan-out. let mut s = editor_with(""); exec( &s, r#" pmacs.command.unregister("buffer.self-insert") pmacs.command.define { name = "buffer.self-insert", description = "test override: insert, then replace the typed char", fn = function(cp) pmacs.editor.insert_char_over_region(cp) pmacs.window.buffer():replace(0, 1, "[") end, } "#, ); type_str(&mut s, "("); assert_eq!( buffer_text(&s), "[", "the typed `(` no longer exists; a `)` reaction would produce `[)`" ); assert_eq!(cursor(&s), 1); assert!( !status(&s).contains("auto-pair"), "a dead record is a silent non-event; got: {:?}", status(&s) ); } #[test] fn transformed_non_pair_char_stays_silent() { // PR #110 round 1, finding 2: pairing has no interest in `a`; an // intercept relocating it must not draw an auto-pair report. let mut s = editor_with("xy"); exec(&s, "pmacs.editor.goto_byte(2)"); exec( &s, r#" pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op) if op.kind == "insert" and op.bytes == "a" then return { kind = "insert", pos = 0, bytes = op.bytes } end return nil end) "#, ); type_str(&mut s, "a"); assert_eq!(buffer_text(&s), "axy"); assert!( !status(&s).contains("auto-pair"), "chars outside the active pair set must stay silent; got: {:?}", status(&s) ); } #[test] fn relocated_opener_gets_no_pair_reaction() { let mut s = editor_with("ab"); 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"); } #[test] fn context_switch_relevance_is_the_source_buffers_rust_to_python_is_silent() { // PR #110 round 2, finding 2: `'` typed in Rust is not a pair // char THERE — that the context-switching command lands in a // Python buffer (where `''` pairs) must not conjure an irrelevant // "source context changed" report. Relevance and reporting are // attributed to the buffer the record names. let dir = fresh_state_dir(); let mut s = editor(&dir); let py = write_file(&dir, "b.py", ""); let rs = write_file(&dir, "a.rs", ""); exec(&s, &format!("pmacs.buffer.find_or_open({py:?})")); exec(&s, "_G.py = pmacs.window.buffer()"); exec(&s, &format!("pmacs.buffer.find_or_open({rs:?})")); exec(&s, "_G.rs = pmacs.window.buffer()"); // Revision skew so the fan-out runs after the switch (the // buffer-aware edit epoch is a named substrate deferral). type_str(&mut s, "xy"); 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.py) end, } "#, ); type_str(&mut s, "'"); assert!( !status(&s).contains("auto-pair"), "`'` is outside the SOURCE (rust) set; got: {:?}", status(&s) ); let rs_text: String = eval(&s, "return _G.rs:slice(0, _G.rs:len())"); assert_eq!( rs_text, "xy'", "the quote landed in the rust buffer, no pair" ); let py_text: String = eval(&s, "return _G.py:slice(0, _G.py:len())"); assert_eq!(py_text, "", "the python buffer is untouched"); } #[test] fn context_switch_relevance_is_the_source_buffers_python_to_rust_reports() { // The inverse route: `'` typed in Python IS a pair char there, so // the context-change report must fire even though the destination // (rust) set would have suppressed it under active-buffer lookup. let dir = fresh_state_dir(); let mut s = editor(&dir); let rs = write_file(&dir, "a.rs", ""); let py = write_file(&dir, "b.py", ""); exec(&s, &format!("pmacs.buffer.find_or_open({rs:?})")); exec(&s, "_G.rs = pmacs.window.buffer()"); exec(&s, &format!("pmacs.buffer.find_or_open({py:?})")); exec(&s, "_G.py = pmacs.window.buffer()"); type_str(&mut s, "xy"); 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.rs) end, } "#, ); type_str(&mut s, "'"); assert!( status(&s).contains("auto-pair skipped: source context changed"), "`'` is in the SOURCE (python) set, so the report fires; got: {:?}", status(&s) ); let py_text: String = eval(&s, "return _G.py:slice(0, _G.py:len())"); assert_eq!( py_text, "xy'", "the quote landed in the python buffer, no pair" ); let rs_text: String = eval(&s, "return _G.rs:slice(0, _G.rs:len())"); assert_eq!(rs_text, "", "the rust buffer is untouched"); } #[test] fn source_context_switch_with_equal_revisions_fails_closed_silently() { // PR #110 round 1, finding 5: the twin of the test above WITHOUT // the revision bump. Dispatch's active-buffer revision compare // (pre = scratch@0, post = other@0) sees no delta, so the // after-edit fan-out never runs: no reaction anywhere, and no // context-change report either — the record dies un-armed. The // report is best-effort until the buffer-aware edit epoch lands // (named substrate deferral); failing closed is unconditional. let dir = fresh_state_dir(); let mut s = editor(&dir); let other = write_file(&dir, "other.txt", "z"); exec(&s, "_G.scratch = pmacs.window.buffer()"); exec(&s, &format!("pmacs.buffer.find_or_open({other:?})")); exec(&s, "_G.other = pmacs.window.buffer()"); exec(&s, "pmacs.window.switch_buffer(_G.scratch)"); exec( &s, r#" pmacs.command.unregister("buffer.self-insert") pmacs.command.define { name = "buffer.self-insert", description = "test override: insert, then switch context", fn = function(cp) pmacs.editor.insert_char_over_region(cp) pmacs.window.switch_buffer(_G.other) end, } "#, ); type_str(&mut s, "("); let scratch_text: String = eval(&s, "return _G.scratch:slice(0, _G.scratch:len())"); assert_eq!(scratch_text, "(", "the opener landed in scratch, no closer"); let other_text: String = eval(&s, "return _G.other:slice(0, _G.other:len())"); assert_eq!(other_text, "z", "the switched-to buffer is untouched"); assert!( !status(&s).contains("auto-pair"), "no fan-out ran, so no report is possible; got: {:?}", status(&s) ); let take_nil: bool = eval(&s, "return pmacs.editor.take_typed_edit() == nil"); assert!(take_nil, "the record was never armed"); } // --------------------------------------------------------------------------- // Context-switching REACTION intercept: repair skipped, deferral pinned // --------------------------------------------------------------------------- #[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#" pmacs.pair._capture_records = true _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#" pmacs.pair._capture_records = true _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 record_capture_is_off_by_default() { // PR #110 round 1, finding 4: without the explicit test facility, // no consumed record is retained anywhere — the one-shot take API // is the only access, and it is empty after the fan-out. let mut s = editor_with(""); type_str(&mut s, "("); assert_eq!(buffer_text(&s), "()"); let leaked: bool = eval(&s, "return pmacs.pair._last_record ~= nil"); assert!(!leaked, "production keystrokes must retain no record"); } #[test] fn rejected_self_insert_leaves_no_record() { let mut s = editor_with(""); 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, revision: 0, }; 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" ); } // Isolated bootstrap storage roots (see the module docs): an // integration test is compiled without `cfg(test)`, so a raw // `EditorState::new()` would read the developer's real `init.lua` and // write into their real data root. #[path = "common/iso.rs"] mod iso;