//! Query-replace acceptance (Arc 2) — the interactive phase end-to-end //! through `dispatch_key`, exactly as a user (or a round-tripping GPU) //! drives it: the `M-%` / `C-M-%` bindings, the y/n/!/./q vocabulary, //! the three quit paths + nothing-matched-restores-origin, empty-to //! deletion, offset-shift correctness, regex, the `buffer.after-edit` //! hook firing on replaced text, and the `dispatch_idle`-false gate. //! //! Framing: docs/query-replace-framing.md. use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; use pmacs::editor::EditorState; use pmacs::protocol::FrontendId; fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent { KeyEvent { code, modifiers: mods, kind: KeyEventKind::Press, state: KeyEventState::empty(), } } 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() { press(s, KeyCode::Char(ch)); } } /// Move the active cursor to buffer start (no default binding for it; /// walk up then to line start, like lsp.lua's cursor-move). fn goto_start(s: &EditorState) { s.lua_host .lua() .load( r" while pmacs.editor.cursor_line() > 0 do pmacs.editor.move_up() end pmacs.editor.move_line_start() ", ) .exec() .expect("move cursor to buffer start"); } /// `(buffer text, active?, query-replace active?)` through the Lua /// surface. fn probe(s: &EditorState) -> (String, bool) { s.lua_host .lua() .load( r" local b = pmacs.window.buffer() return b:slice(0, b:len()), pmacs.editor.query_replace_active() ", ) .eval() .expect("probe query-replace state") } /// Drive the two minibuffer prompts a `query-replace` command opens: /// type `from`, RET, type `to`, RET. Leaves the session in its /// interactive phase (or finished, if `!`/no-match). fn start_query_replace(s: &mut EditorState, from: &str, to: &str, regex: bool) { let cmd = if regex { "query-replace-regexp" } else { "query-replace" }; s.lua_host .lua() .load(format!("pmacs.command.invoke('{cmd}')")) .exec() .expect("invoke query-replace command"); type_str(s, from); press(s, KeyCode::Enter); type_str(s, to); press(s, KeyCode::Enter); } #[test] fn replace_skip_and_quit_is_selective() { let mut s = EditorState::new(); type_str(&mut s, "x x x x"); // Cursor to buffer start so all four are ahead of point. goto_start(&s); start_query_replace(&mut s, "x", "y", false); let (_, active) = probe(&s); assert!( active, "session is in its interactive phase on the first match" ); press(&mut s, KeyCode::Char('y')); // replace 1st press(&mut s, KeyCode::Char('n')); // skip 2nd press(&mut s, KeyCode::Char('y')); // replace 3rd press(&mut s, KeyCode::Char('q')); // quit before the 4th let (text, active) = probe(&s); assert_eq!(text, "y x y x", "y/n/y then quit"); assert!(!active, "q ends the session"); } #[test] fn bang_replaces_all_remaining() { let mut s = EditorState::new(); type_str(&mut s, "a a a a"); goto_start(&s); start_query_replace(&mut s, "a", "b", false); press(&mut s, KeyCode::Char('!')); let (text, active) = probe(&s); assert_eq!(text, "b b b b"); assert!(!active, "! finishes the session"); } #[test] fn dot_replaces_current_then_quits() { let mut s = EditorState::new(); type_str(&mut s, "a a a"); goto_start(&s); start_query_replace(&mut s, "a", "z", false); press(&mut s, KeyCode::Char('.')); // replace first, then quit let (text, active) = probe(&s); assert_eq!(text, "z a a", "only the first is replaced"); assert!(!active); } #[test] fn growing_replacement_does_not_loop() { // a → aa must not re-match the inserted text (offset-shift + the // search-forward-past-replacement rule). let mut s = EditorState::new(); type_str(&mut s, "a a a"); goto_start(&s); start_query_replace(&mut s, "a", "aa", false); press(&mut s, KeyCode::Char('!')); let (text, _) = probe(&s); assert_eq!(text, "aa aa aa", "each 'a' replaced exactly once"); } #[test] fn empty_to_deletes() { let mut s = EditorState::new(); type_str(&mut s, "a-b-c"); goto_start(&s); start_query_replace(&mut s, "-", "", false); press(&mut s, KeyCode::Char('!')); let (text, _) = probe(&s); assert_eq!(text, "abc", "empty replacement deletes matches"); } #[test] fn regex_query_replace_via_binding() { let mut s = EditorState::new(); type_str(&mut s, "a1 b2 c3"); goto_start(&s); start_query_replace(&mut s, "[0-9]", "#", true); press(&mut s, KeyCode::Char('!')); let (text, _) = probe(&s); assert_eq!(text, "a# b# c#"); } #[test] fn m_percent_binding_starts_query_replace() { // The literal chord: M-% (Alt + Shift+5 → Char('%') with ALT). let mut s = EditorState::new(); type_str(&mut s, "cat cat"); goto_start(&s); s.dispatch_key( FrontendId::LOCAL, key(KeyCode::Char('%'), KeyModifiers::ALT), ); // The from-prompt minibuffer should now be active. let mb: bool = s .lua_host .lua() .load("return pmacs.minibuffer.is_active and pmacs.minibuffer.is_active() or false") .eval() .unwrap_or(false); assert!(mb, "M-% opened the query-replace from-prompt"); // Complete the flow and confirm it replaces. type_str(&mut s, "cat"); press(&mut s, KeyCode::Enter); type_str(&mut s, "dog"); press(&mut s, KeyCode::Enter); press(&mut s, KeyCode::Char('!')); let (text, _) = probe(&s); assert_eq!(text, "dog dog", "M-% drove a full query-replace"); } #[test] fn c_m_percent_binding_starts_regexp_query_replace() { // Control-meta-shifted punctuation — the chord most likely to parse // differently across key paths (the C-c H lesson). let mut s = EditorState::new(); type_str(&mut s, "x1 x2"); goto_start(&s); s.dispatch_key( FrontendId::LOCAL, key( KeyCode::Char('%'), KeyModifiers::CONTROL | KeyModifiers::ALT, ), ); let mb: bool = s .lua_host .lua() .load("return pmacs.minibuffer.is_active and pmacs.minibuffer.is_active() or false") .eval() .unwrap_or(false); assert!(mb, "C-M-% opened the query-replace-regexp from-prompt"); type_str(&mut s, "x[0-9]"); press(&mut s, KeyCode::Enter); type_str(&mut s, "Q"); press(&mut s, KeyCode::Enter); press(&mut s, KeyCode::Char('!')); let (text, _) = probe(&s); assert_eq!(text, "Q Q", "C-M-% drove a regexp query-replace"); } #[test] fn nothing_matched_leaves_buffer_untouched() { let mut s = EditorState::new(); type_str(&mut s, "hello"); start_query_replace(&mut s, "zzz", "q", false); let (text, active) = probe(&s); assert_eq!(text, "hello", "no match → buffer untouched"); assert!(!active, "no match → session never stays open"); } #[test] fn query_replace_flips_dispatch_idle_so_gpu_round_trips() { // While the interactive phase runs, dispatch_idle must be false so a // semantic frontend round-trips y/n/etc. instead of self-inserting. let mut s = EditorState::new(); type_str(&mut s, "a a"); goto_start(&s); start_query_replace(&mut s, "a", "b", false); assert!( !s.dispatch_idle(), "query-replace interactive phase forces key round-trip" ); press(&mut s, KeyCode::Char('!')); assert!(s.dispatch_idle(), "idle again after the session finishes"); } #[test] fn replace_fires_after_edit_hook() { // The Q#QR1 hook: an LSP/syntax observer must see replaced text. let mut s = EditorState::new(); s.lua_host .lua() .load( r" _G.EDITS = 0 pmacs.hook.add('buffer.after-edit', function() _G.EDITS = _G.EDITS + 1 end) ", ) .exec() .expect("install after-edit counter"); type_str(&mut s, "a a a"); goto_start(&s); let before: i64 = s .lua_host .lua() .load("return _G.EDITS") .eval() .expect("read counter"); start_query_replace(&mut s, "a", "b", false); press(&mut s, KeyCode::Char('y')); // one replacement let after: i64 = s .lua_host .lua() .load("return _G.EDITS") .eval() .expect("read counter"); assert!( after > before, "buffer.after-edit fired for the replacement (before {before}, after {after})" ); }