From 7e7b3f2dcde0cd7b755dd4484bba5b2b338ce034 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 8 Jul 2026 16:05:23 -0400 Subject: [PATCH] =?UTF-8?q?feat(edit):=20query-replace=20(M-%=20/=20C-M-%)?= =?UTF-8?q?=20=E2=80=94=20Arc=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emacs query-replace, built on isearch with zero protocol change (framing: docs/query-replace-framing.md). - search.rs: find_first_from (literal) + find_first_regex_from (cached engine, zero-width-skip) + compile_search_regex (shared smart-case compile). Q#QR2's forward-scan-past-replacement primitive. - QueryReplaceSession + core methods (editor_core.rs): begin (invalid regex refuses, Q#QR2), replace/skip/all/replace-and-quit/finish; matches run forward from next_from on the LIVE buffer, so offset shifts and never-re-matching-replacements (a->aa) fall out for free; current match highlighted via a single-element search_store set (SearchMatchActive, both frontends free) + cursor reveal; quit keeps replacements, only nothing-matched restores origin (Q#QR10). - Dispatcher shadow (editor.rs): QueryReplaceKey (y/SPC, n/DEL, !, ., q/RET/Esc/C-g) + dispatch_query_replace_key, the 5th modal shadow; added to dispatch_idle disjunction (GPU round-trips keys) and fires buffer.after-edit itself (Q#QR1 — a shadow returns before the normal post-command check; once per !-batch). - Lua: ed.query_replace_start/query_replace_active; query-replace / query-replace-regexp commands (chained minibuffer.read, separate from/to history buckets, empty-from reject / empty-to deletion); M-% / C-M-% bindings. - Per-match prompt via core.status → v15 StatusFacts.message band. Tests: 7 core unit + 11 dispatch_key acceptance (replace/skip/!/./quit, nothing-matched restore, empty-to deletion, a->aa non-loop, regex incl invalid, after-edit fires, dispatch_idle gate, explicit M-% AND C-M-% binding tests) + 5 search unit. Co-Authored-By: Claude Opus 4.8 (1M context) --- builtin/commands/default.lua | 34 ++++ builtin/keymaps/default.lua | 4 + src/editor.rs | 80 ++++++++ src/editor_core.rs | 322 ++++++++++++++++++++++++++++++ src/lua_bindings/mod.rs | 23 +++ src/search.rs | 138 ++++++++++++- tests/query_replace_acceptance.rs | 282 ++++++++++++++++++++++++++ 7 files changed, 876 insertions(+), 7 deletions(-) create mode 100644 tests/query_replace_acceptance.rs diff --git a/builtin/commands/default.lua b/builtin/commands/default.lua index 5f3678c..96049fe 100644 --- a/builtin/commands/default.lua +++ b/builtin/commands/default.lua @@ -140,6 +140,40 @@ cmd { name = "search.backward-regex", description = "Start an incremental regex search backward from the cursor.", fn = function() ed.search_start(false, true) end } +-- Query-replace (Arc 2). Two chained minibuffer prompts collect the +-- from/to strings (separate history buckets so search patterns and +-- replacement text don't mix), then ed.query_replace_start begins the +-- core interactive session (y/n/!/./q handled by a dispatcher shadow). +-- An empty FROM is rejected (nothing to search); an empty TO is valid +-- and means deletion (Q#QR3). +local function begin_query_replace(regex) + pmacs.minibuffer.read { + prompt = regex and "Query replace regexp: " or "Query replace: ", + history = "query-replace-from", + on_accept = function(from) + if from == nil or from == "" then + pmacs.editor.set_status("query-replace: empty search string") + return + end + pmacs.minibuffer.read { + prompt = string.format( + regex and "Query replace regexp %s with: " or "Query replace %s with: ", from), + history = "query-replace-to", + on_accept = function(to) + ed.query_replace_start(from, to or "", regex) + end, + } + end, + } +end + +cmd { name = "query-replace", + description = "Interactively replace a string from the cursor forward (M-%).", + fn = function() begin_query_replace(false) end } +cmd { name = "query-replace-regexp", + description = "Interactively replace a regexp from the cursor forward (C-M-%).", + fn = function() begin_query_replace(true) end } + -- History -------------------------------------------------------------------- cmd { name = "buffer.undo", description = "Undo the most recent edit.", diff --git a/builtin/keymaps/default.lua b/builtin/keymaps/default.lua index 0c9f612..e94c033 100644 --- a/builtin/keymaps/default.lua +++ b/builtin/keymaps/default.lua @@ -66,6 +66,10 @@ bind("C-r", "search.backward") bind("C-M-s", "search.forward-regex") bind("C-M-r", "search.backward-regex") +-- Query-replace (Arc 2): M-% literal, C-M-% regexp (Emacs bindings). +bind("M-%", "query-replace") +bind("C-M-%", "query-replace-regexp") + -- CUA-style word-level deletion (the same shortcuts users expect from -- IDEs, browsers, terminals on Linux/Windows). C-BS deletes back to -- the start of the previous word; C-DEL deletes forward through the diff --git a/src/editor.rs b/src/editor.rs index 5df243d..5d45d0a 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -524,6 +524,7 @@ impl EditorState { // optimistic local edit would do. !core.minibuffer.is_active() && !core.search_active() + && !core.query_replace_active() && !core.menu_is_open() && !core.active_buffer_round_trips() } @@ -578,6 +579,18 @@ impl EditorState { return; } + // Query-replace interception (Arc 2): the fifth modal shadow. + // While the interactive phase runs, every key drives it + // (y/n/!/./q), shadowing the global keymap like search. Both + // frontends reach this via the `FrontendEvent::Key` round-trip + // (`dispatch_idle` is false while it runs). The handler fires + // `buffer.after-edit` itself — a modal shadow returns before the + // normal post-command edit check below (Q#QR1). + if self.core.borrow().query_replace_active() { + self.dispatch_query_replace_key(chord); + return; + } + // Minibuffer interception: when a prompt is active, every key // routes through the minibuffer's hardcoded handler. The main // editor's keymap is bypassed; the user can still cancel with @@ -810,6 +823,32 @@ impl EditorState { } } + /// Drive an active query-replace from a keystroke (Arc 2, Q#QR6). + /// Fires `buffer.after-edit` itself when the key produced an edit + /// (Q#QR1): a modal shadow returns before `dispatch_key`'s normal + /// post-command edit check, so LSP `didChange` / syntax reparse + /// would otherwise never see the replaced text. `!` applies many + /// edits in one keypress; the single revision compare here fires + /// the hook once for the batch, which is what the debounced + /// `didChange` wants. + fn dispatch_query_replace_key(&mut self, chord: Chord) { + let pre_revision = self.active_buffer_revision(); + match QueryReplaceKey::from_chord(chord) { + QueryReplaceKey::Replace => self.core.borrow_mut().query_replace_replace(), + QueryReplaceKey::Skip => self.core.borrow_mut().query_replace_skip(), + QueryReplaceKey::All => self.core.borrow_mut().query_replace_all(), + QueryReplaceKey::ReplaceAndQuit => { + self.core.borrow_mut().query_replace_replace_and_quit(); + } + QueryReplaceKey::Quit => self.core.borrow_mut().query_replace_finish(), + QueryReplaceKey::Ignore => {} + } + if pre_revision != self.active_buffer_revision() { + self.lua_host + .run_hook("buffer.after-edit", mlua::MultiValue::new()); + } + } + /// Drive an open context menu from a keystroke (Q#CM1). fn dispatch_menu_key(&mut self, chord: Chord) { match MenuKey::from_chord(chord) { @@ -1629,6 +1668,47 @@ impl SearchKey { } } +/// Keys handled while a query-replace's interactive phase runs (Arc 2, +/// Q#QR6). A full modal shadow like [`SearchKey`]: an active +/// query-replace eats every key, and the same decode runs in both +/// frontends via the `FrontendEvent::Key` round-trip. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +enum QueryReplaceKey { + /// `y` / `SPC` — replace this match, advance. + Replace, + /// `n` / `DEL` — skip this match, advance. + Skip, + /// `!` — replace this and all remaining without prompting. + All, + /// `.` — replace this, then quit. + ReplaceAndQuit, + /// `q` / `RET` / `Esc` / `C-g` — quit (replacements are kept). + Quit, + /// Any other key — eaten (no-op), like an active isearch. + Ignore, +} + +impl QueryReplaceKey { + fn from_chord(chord: Chord) -> Self { + let ctrl = chord.modifiers.contains(KeyModifiers::CONTROL); + if ctrl { + // C-g quits; every other control chord is eaten. + return match chord.code { + KeyCode::Char('g') => Self::Quit, + _ => Self::Ignore, + }; + } + match chord.code { + KeyCode::Char('y' | ' ') => Self::Replace, + KeyCode::Char('n') | KeyCode::Backspace | KeyCode::Delete => Self::Skip, + KeyCode::Char('!') => Self::All, + KeyCode::Char('.') => Self::ReplaceAndQuit, + KeyCode::Char('q') | KeyCode::Enter | KeyCode::Esc => Self::Quit, + _ => Self::Ignore, + } + } +} + /// Keys handled while a context menu is open (Q#CM1). Like /// [`SearchKey`], this shadows the global keymap; the same decode runs /// in both frontends via the daemon's `FrontendEvent::Key` round-trip. diff --git a/src/editor_core.rs b/src/editor_core.rs index d1c4c59..35e163b 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -88,6 +88,41 @@ pub struct SearchSession { invalid: bool, } +/// Live state of an in-progress query-replace (Arc 2, Q#QR1). +/// +/// Present only while a query-replace's interactive phase is running +/// (`EditorCore::query_replace`); `None` otherwise. Unlike +/// [`SearchSession`], the buffer is usually already mutated by the +/// time this ends, so `origin` is used *only* for the nothing-matched +/// restore (Q#QR10); every other exit leaves point at the inspected +/// match. Matching runs forward from `next_from` on the *live* buffer +/// (Q#QR2), so offset shifts and never-re-matching-replacements fall +/// out for free. +#[derive(Clone, Debug)] +pub struct QueryReplaceSession { + /// The literal substring or regex source being replaced. + from: String, + /// The replacement text (may be empty — Q#QR3 deletion). + to: String, + /// Compiled regex engine when in regex mode (Q#QR9), cached for + /// the whole run so `!` stays linear; `None` = smart-case literal. + re: Option, + /// Buffer + cursor when the session began. Restored on cancel + /// *only* when nothing ever matched (Q#QR10). + origin: (BufferId, Position), + /// Byte offset the next forward search starts from — advanced past + /// each replacement so inserted text is never re-matched. + next_from: Position, + /// The match currently being prompted, or `None` before the first + /// advance / after finishing. + current: Option, + /// Number of replacements applied so far. + replaced: usize, + /// Whether any match was ever found (distinguishes "nothing + /// matched → restore origin" from "matched, then quit"). + found_any: bool, +} + /// The world state mutated by editor commands. pub struct EditorCore { /// Shared buffer registry. The registry is the canonical owner @@ -203,6 +238,10 @@ pub struct EditorCore { /// write would bypass the intercept chain entirely. Marked from /// Lua via `pmacs.buffer.set_round_trip_input`; pruned on kill. round_trip_buffers: std::collections::HashSet, + /// Live query-replace interactive session (Arc 2), or `None`. The + /// query-replace twin of `search`; drives the fifth dispatcher + /// shadow. + query_replace: Option, } impl EditorCore { @@ -244,6 +283,7 @@ impl EditorCore { menu: crate::menu::make_shared_menu(), completion_popup: crate::completion::make_shared_popup(), round_trip_buffers: std::collections::HashSet::new(), + query_replace: None, } } @@ -774,6 +814,206 @@ impl EditorCore { aw.goal_col = None; } + // ---- query-replace (Arc 2, Q#QR1-10) ----------------------------------- + + /// True while a query-replace interactive session is running (the + /// fifth dispatcher-shadow predicate; also drives `dispatch_idle` + /// and the modal-close guard). + #[must_use] + pub fn query_replace_active(&self) -> bool { + self.query_replace.is_some() + } + + /// Begin a query-replace from the cursor forward (Q#QR8). `regex` + /// selects `query-replace-regexp` (Q#QR9). An invalid regex refuses + /// to start (Q#QR2). Immediately advances to (and prompts on) the + /// first match, or finishes with "No matches" when there are none. + pub fn query_replace_begin(&mut self, from: String, to: String, regex: bool) { + if self.query_replace.is_some() || from.is_empty() { + return; + } + let re = if regex { + let Some(re) = crate::search::compile_search_regex(&from) else { + self.status = format!("Invalid regex: {from}"); + return; + }; + Some(re) + } else { + None + }; + let origin = (self.active_buffer_id(), self.cursor()); + // Reuse the isearch match-wash overlay to highlight the current + // match in the TUI; the GPU gets it via SearchMatch decorations. + self.ensure_search_overlay(); + self.query_replace = Some(QueryReplaceSession { + from, + to, + re, + origin, + next_from: origin.1, + current: None, + replaced: 0, + found_any: false, + }); + self.query_replace_advance(); + } + + /// Find the next match at/after `next_from` on the live buffer. On + /// a hit: highlight it, reveal it (cursor to its start, Q#QR2), and + /// prompt. On a miss: finish (natural end / nothing-matched). + fn query_replace_advance(&mut self) { + let Some(session) = self.query_replace.as_ref() else { + return; + }; + let bid = session.origin.0; + let start = session.next_from.min(self.active_buffer_len()) as usize; + let bytes = self.buffer_bytes(bid); + let found = match &session.re { + Some(re) => crate::search::find_first_regex_from(&bytes, re, start), + None => crate::search::find_first_from(&bytes, &session.from, start), + }; + let Some(range) = found else { + self.query_replace_finish(); + return; + }; + let from = session.from.clone(); + if let Some(session) = self.query_replace.as_mut() { + session.current = Some(range); + session.found_any = true; + } + // Highlight just this match: a single-element store set renders + // it as SearchMatchActive in both frontends (Q#QR5). + { + let mut guard = self + .search_store + .lock() + .expect("search store mutex poisoned"); + guard.set(bid, from, vec![range]); + } + self.search_place_cursor(range.start); + self.query_replace_set_prompt(); + } + + /// Set `core.status` to the per-match prompt (Q#QR4) — shown in + /// both frontends via the v15 `StatusFacts.message` band. + fn query_replace_set_prompt(&mut self) { + if let Some(session) = self.query_replace.as_ref() { + self.status = format!( + "Query replacing '{}' with '{}' (y/n, ! all, . last, q quit)", + session.from, session.to + ); + } + } + + /// Replace the current match with the to-string as a single edit + /// (Q#QR7), advancing `next_from` past the inserted text so it is + /// never re-matched (Q#QR2). Returns `true` when an edit was + /// applied. Does NOT advance to the next match — callers chain + /// `query_replace_advance` (or finish) as their flow needs. + fn query_replace_apply_current(&mut self) -> bool { + let Some(session) = self.query_replace.as_ref() else { + return false; + }; + let Some(range) = session.current else { + return false; + }; + let to = session.to.clone(); + if let Err(e) = self.apply_active_edit(EditOp::Replace { + range: Range { + start: range.start, + end: range.end, + }, + bytes: to.as_bytes(), + }) { + self.status = format!("query-replace: {e}"); + return false; + } + let new_next = range.start + to.len() as u64; + if let Some(session) = self.query_replace.as_mut() { + session.next_from = new_next; + session.current = None; + session.replaced += 1; + } + self.search_place_cursor(new_next); + true + } + + /// `y` / `SPC` — replace the current match, then advance to the next. + pub fn query_replace_replace(&mut self) { + if self.query_replace_apply_current() { + self.query_replace_advance(); + } + } + + /// `n` / `DEL` — leave the current match, advance past it to the next. + pub fn query_replace_skip(&mut self) { + if let Some(session) = self.query_replace.as_mut() + && let Some(range) = session.current + { + session.next_from = range.end; + session.current = None; + } + self.query_replace_advance(); + } + + /// `!` — replace the current match and all remaining without + /// prompting, then finish (Q#QR6). One `after-edit` hook fires for + /// the batch (the dispatcher compares revision across the handler). + pub fn query_replace_all(&mut self) { + while self.query_replace_apply_current() { + // Find the next match (mirrors advance's search, without the + // highlight/prompt work — we're not stopping to ask). + let Some(session) = self.query_replace.as_ref() else { + break; + }; + let bid = session.origin.0; + let start = session.next_from.min(self.active_buffer_len()) as usize; + let bytes = self.buffer_bytes(bid); + let found = match &session.re { + Some(re) => crate::search::find_first_regex_from(&bytes, re, start), + None => crate::search::find_first_from(&bytes, &session.from, start), + }; + match found { + Some(range) => { + if let Some(session) = self.query_replace.as_mut() { + session.current = Some(range); + } + } + None => break, + } + } + self.query_replace_finish(); + } + + /// `.` — replace the current match, then finish (Q#QR6). + pub fn query_replace_replace_and_quit(&mut self) { + self.query_replace_apply_current(); + self.query_replace_finish(); + } + + /// End the session (Q#QR10): clear the highlight, restore the origin + /// cursor *only* if nothing ever matched, and set the count status. + /// Every other exit leaves point where the last step put it. + pub fn query_replace_finish(&mut self) { + let Some(session) = self.query_replace.take() else { + return; + }; + let bid = session.origin.0; + self.search_store + .lock() + .expect("search store mutex poisoned") + .clear(bid); + if session.found_any { + let n = session.replaced; + self.status = format!("Replaced {n} occurrence{}", if n == 1 { "" } else { "s" }); + } else { + if self.active_buffer_id() == bid { + self.search_place_cursor(session.origin.1); + } + self.status = format!("No matches for '{}'", session.from); + } + } + /// Snapshot a buffer's full byte content (empty if the id is /// stale). O(1) rope snapshot + one copy; used to feed `find_all`. fn buffer_bytes(&self, buffer_id: BufferId) -> Vec { @@ -3441,4 +3681,86 @@ mod tests { "focus change closes the session even with the same buffer" ); } + + // ---- query-replace core (Arc 2) ---------------------------------------- + + #[test] + fn query_replace_all_replaces_and_counts() { + let mut s = from_bytes(b"foo foo foo\n"); + s.query_replace_begin("foo".into(), "bar".into(), false); + assert!(s.query_replace_active(), "session opens on the first match"); + s.query_replace_all(); + assert_eq!(text_of(&s), "bar bar bar\n"); + assert!(!s.query_replace_active(), "! finishes the session"); + assert_eq!(s.status, "Replaced 3 occurrences"); + } + + #[test] + fn query_replace_growing_replacement_does_not_loop() { + // The a→aa shape: replacing must not re-match the inserted text. + let mut s = from_bytes(b"a a a\n"); + s.query_replace_begin("a".into(), "aa".into(), false); + s.query_replace_all(); + assert_eq!(text_of(&s), "aa aa aa\n", "each 'a' replaced exactly once"); + assert_eq!(s.status, "Replaced 3 occurrences"); + } + + #[test] + fn query_replace_empty_to_deletes() { + let mut s = from_bytes(b"a-b-c\n"); + s.query_replace_begin("-".into(), String::new(), false); + s.query_replace_all(); + assert_eq!(text_of(&s), "abc\n", "empty replacement deletes matches"); + } + + #[test] + fn query_replace_skip_then_replace_is_selective() { + let mut s = from_bytes(b"x x x\n"); + s.query_replace_begin("x".into(), "y".into(), false); + s.query_replace_skip(); // leave the first x + s.query_replace_replace(); // replace the second x, advance to third + s.query_replace_replace_and_quit(); // replace the third, quit + assert_eq!(text_of(&s), "x y y\n", "first skipped, rest replaced"); + assert!(!s.query_replace_active()); + } + + #[test] + fn query_replace_nothing_matched_restores_origin() { + let mut s = from_bytes(b"hello world\n"); + s.active_window_mut().cursor = 6; // on "world" + s.query_replace_begin("zzz".into(), "q".into(), false); + assert!( + !s.query_replace_active(), + "no match → session never stays open" + ); + assert_eq!(text_of(&s), "hello world\n", "buffer untouched"); + assert_eq!(s.active_window().cursor, 6, "origin cursor restored"); + assert_eq!(s.status, "No matches for 'zzz'"); + } + + #[test] + fn query_replace_starts_from_cursor_forward() { + let mut s = from_bytes(b"k _ k\n"); + s.active_window_mut().cursor = 2; // between the two k's + s.query_replace_begin("k".into(), "K".into(), false); + s.query_replace_all(); + assert_eq!(text_of(&s), "k _ K\n", "only the match at/after point"); + } + + #[test] + fn query_replace_regex_replaces_and_invalid_refuses() { + let mut s = from_bytes(b"a1 b2 c3\n"); + s.query_replace_begin("[0-9]".into(), "#".into(), true); + s.query_replace_all(); + assert_eq!(text_of(&s), "a# b# c#\n", "regex matches digits"); + + // Invalid regex refuses to start and leaves a status. + let mut s2 = from_bytes(b"abc\n"); + s2.query_replace_begin("(unclosed".into(), "x".into(), true); + assert!( + !s2.query_replace_active(), + "invalid regex never opens a session" + ); + assert!(s2.status.starts_with("Invalid regex")); + } } diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 1f9f4a0..b6c97af 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -10601,6 +10601,29 @@ fn install_search(editor: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result< lua.create_function(move |_, ()| Ok(cc.borrow().search_active()))?, )?; } + { + // query_replace_start(from, to, regex): begin an interactive + // query-replace from the cursor forward (Arc 2). The Lua + // `query-replace` command collects `from`/`to` via chained + // minibuffer prompts, then calls this; the interactive y/n/!/./q + // phase is a core dispatcher shadow from here on. + let cc = core.clone(); + editor.set( + "query_replace_start", + lua.create_function(move |_, (from, to, regex): (String, String, bool)| { + cc.borrow_mut().query_replace_begin(from, to, regex); + Ok(()) + })?, + )?; + } + { + // query_replace_active(): true during the interactive phase. + let cc = core.clone(); + editor.set( + "query_replace_active", + lua.create_function(move |_, ()| Ok(cc.borrow().query_replace_active()))?, + )?; + } Ok(()) } diff --git a/src/search.rs b/src/search.rs index 1e486ec..bda13db 100644 --- a/src/search.rs +++ b/src/search.rs @@ -255,13 +255,7 @@ pub fn find_all_regex(haystack: &[u8], pattern: &str) -> Option> if pattern.is_empty() { return Some(Vec::new()); } - let case_insensitive = !pattern.chars().any(char::is_uppercase); - let re = if case_insensitive { - regex::bytes::Regex::new(&format!("(?i){pattern}")) - } else { - regex::bytes::Regex::new(pattern) - } - .ok()?; + let re = compile_search_regex(pattern)?; let matches = re .find_iter(haystack) .filter(|m| m.end() > m.start()) @@ -273,6 +267,80 @@ pub fn find_all_regex(haystack: &[u8], pattern: &str) -> Option> Some(matches) } +/// Compile `pattern` with the same smart-case rule the search paths use +/// (case-insensitive unless the pattern has an uppercase letter, via a +/// `(?i)` prefix), or `None` if it fails to compile. Shared by +/// [`find_all_regex`] and the query-replace session (which caches the +/// compiled engine for the whole run — Q#QR2). +#[must_use] +pub fn compile_search_regex(pattern: &str) -> Option { + let case_insensitive = !pattern.chars().any(char::is_uppercase); + if case_insensitive { + regex::bytes::Regex::new(&format!("(?i){pattern}")) + } else { + regex::bytes::Regex::new(pattern) + } + .ok() +} + +/// The first smart-case literal match of `query` in `haystack` at or +/// after byte `start` (Q#QR2: query-replace's forward step). Same +/// case-folding as [`find_all`]. An empty query, or `start` past the +/// last possible match, yields `None`. +#[must_use] +pub fn find_first_from(haystack: &[u8], query: &str, start: usize) -> Option { + let q = query.as_bytes(); + if q.is_empty() || start > haystack.len() || haystack.len() - start < q.len() { + return None; + } + let case_sensitive = query.chars().any(char::is_uppercase); + let mut i = start; + while i + q.len() <= haystack.len() { + let hit = haystack[i..i + q.len()].iter().zip(q).all(|(&h, &n)| { + if case_sensitive { + h == n + } else { + h.eq_ignore_ascii_case(&n) + } + }); + if hit { + return Some(ByteRange { + start: i as u64, + end: (i + q.len()) as u64, + }); + } + i += 1; + } + None +} + +/// The first non-zero-width match of the pre-compiled `re` in +/// `haystack` at or after byte `start` (Q#QR2). Uses `find_at` so the +/// engine keeps look-around context (`\b`, `^`) correct at the seam, +/// and skips zero-width matches (`a*`, anchors) by advancing one byte — +/// a zero-width hit never moves `next_from`, so it would otherwise +/// loop. +#[must_use] +pub fn find_first_regex_from( + haystack: &[u8], + re: ®ex::bytes::Regex, + start: usize, +) -> Option { + let mut pos = start; + while pos <= haystack.len() { + let m = re.find_at(haystack, pos)?; + if m.end() > m.start() { + return Some(ByteRange { + start: m.start() as u64, + end: m.end() as u64, + }); + } + // Zero-width match: step past it to guarantee progress. + pos = m.start() + 1; + } + None +} + // --------------------------------------------------------------------------- // TUI view // --------------------------------------------------------------------------- @@ -519,6 +587,62 @@ mod tests { assert_eq!(find_all_regex(b"abc", ""), Some(vec![])); } + // ---- find_first_from (query-replace forward step, Q#QR2) --------------- + + #[test] + fn find_first_from_scans_forward() { + assert_eq!(find_first_from(b"a.a.a", "a", 0), Some(r(0, 1))); + // Start past the first hit → the next one. + assert_eq!(find_first_from(b"a.a.a", "a", 1), Some(r(2, 3))); + assert_eq!(find_first_from(b"a.a.a", "a", 3), Some(r(4, 5))); + // No match at/after start. + assert_eq!(find_first_from(b"a.a.a", "a", 5), None); + assert_eq!(find_first_from(b"abc", "z", 0), None); + // Empty query never matches. + assert_eq!(find_first_from(b"abc", "", 0), None); + } + + #[test] + fn find_first_from_is_smart_case() { + // Lowercase query folds case; uppercase query is exact. + assert_eq!(find_first_from(b"xFoo", "foo", 0), Some(r(1, 4))); + assert_eq!(find_first_from(b"xFoo", "Foo", 0), Some(r(1, 4))); + assert_eq!(find_first_from(b"xfoo", "Foo", 0), None); + } + + #[test] + fn find_first_from_does_not_reloop_on_growing_replacement() { + // The a→aa shape: after replacing the 'a' at 0 with "aa", the + // next search must start PAST the replacement (byte 2), not + // re-match the inserted text. Simulated here by starting the + // scan at the replacement end. + assert_eq!(find_first_from(b"aa_a", "a", 2), Some(r(3, 4))); + } + + #[test] + fn find_first_regex_from_scans_and_skips_zero_width() { + let re = compile_search_regex("a+").unwrap(); + assert_eq!(find_first_regex_from(b"_aa_a", &re, 0), Some(r(1, 3))); + assert_eq!(find_first_regex_from(b"_aa_a", &re, 3), Some(r(4, 5))); + assert_eq!(find_first_regex_from(b"_aa_a", &re, 5), None); + // Zero-width pattern `x*` never yields a match (all filtered), + // and crucially terminates rather than looping. + let z = compile_search_regex("x*").unwrap(); + assert_eq!(find_first_regex_from(b"abc", &z, 0), None); + } + + #[test] + fn compile_search_regex_smart_case_and_invalid() { + // Lowercase → case-insensitive. + let re = compile_search_regex("foo").unwrap(); + assert!(re.is_match(b"FOO")); + // Uppercase → case-sensitive. + let re = compile_search_regex("Foo").unwrap(); + assert!(!re.is_match(b"foo")); + // Invalid pattern → None (the session refuses to start). + assert!(compile_search_regex("(unclosed").is_none()); + } + #[test] fn store_set_clamps_active_and_clears_on_empty() { let mut s = SearchStore::new(); diff --git a/tests/query_replace_acceptance.rs b/tests/query_replace_acceptance.rs new file mode 100644 index 0000000..e9e0660 --- /dev/null +++ b/tests/query_replace_acceptance.rs @@ -0,0 +1,282 @@ +//! 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})" + ); +}