feat(edit): query-replace (M-% / C-M-%) — Arc 2

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) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-08 16:05:23 -04:00
parent 27aaf0ef30
commit 7e7b3f2dcd
7 changed files with 876 additions and 7 deletions

View File

@ -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.",

View File

@ -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

View File

@ -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.

View File

@ -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<regex::bytes::Regex>,
/// 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<crate::protocol::ByteRange>,
/// 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<BufferId>,
/// Live query-replace interactive session (Arc 2), or `None`. The
/// query-replace twin of `search`; drives the fifth dispatcher
/// shadow.
query_replace: Option<QueryReplaceSession>,
}
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<u8> {
@ -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"));
}
}

View File

@ -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(())
}

View File

@ -255,13 +255,7 @@ pub fn find_all_regex(haystack: &[u8], pattern: &str) -> Option<Vec<ByteRange>>
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<Vec<ByteRange>>
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<regex::bytes::Regex> {
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<ByteRange> {
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: &regex::bytes::Regex,
start: usize,
) -> Option<ByteRange> {
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();

View File

@ -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})"
);
}