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:
parent
27aaf0ef30
commit
7e7b3f2dcd
|
|
@ -140,6 +140,40 @@ cmd { name = "search.backward-regex",
|
||||||
description = "Start an incremental regex search backward from the cursor.",
|
description = "Start an incremental regex search backward from the cursor.",
|
||||||
fn = function() ed.search_start(false, true) end }
|
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 --------------------------------------------------------------------
|
-- History --------------------------------------------------------------------
|
||||||
|
|
||||||
cmd { name = "buffer.undo", description = "Undo the most recent edit.",
|
cmd { name = "buffer.undo", description = "Undo the most recent edit.",
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,10 @@ bind("C-r", "search.backward")
|
||||||
bind("C-M-s", "search.forward-regex")
|
bind("C-M-s", "search.forward-regex")
|
||||||
bind("C-M-r", "search.backward-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
|
-- CUA-style word-level deletion (the same shortcuts users expect from
|
||||||
-- IDEs, browsers, terminals on Linux/Windows). C-BS deletes back to
|
-- IDEs, browsers, terminals on Linux/Windows). C-BS deletes back to
|
||||||
-- the start of the previous word; C-DEL deletes forward through the
|
-- the start of the previous word; C-DEL deletes forward through the
|
||||||
|
|
|
||||||
|
|
@ -524,6 +524,7 @@ impl EditorState {
|
||||||
// optimistic local edit would do.
|
// optimistic local edit would do.
|
||||||
!core.minibuffer.is_active()
|
!core.minibuffer.is_active()
|
||||||
&& !core.search_active()
|
&& !core.search_active()
|
||||||
|
&& !core.query_replace_active()
|
||||||
&& !core.menu_is_open()
|
&& !core.menu_is_open()
|
||||||
&& !core.active_buffer_round_trips()
|
&& !core.active_buffer_round_trips()
|
||||||
}
|
}
|
||||||
|
|
@ -578,6 +579,18 @@ impl EditorState {
|
||||||
return;
|
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
|
// Minibuffer interception: when a prompt is active, every key
|
||||||
// routes through the minibuffer's hardcoded handler. The main
|
// routes through the minibuffer's hardcoded handler. The main
|
||||||
// editor's keymap is bypassed; the user can still cancel with
|
// 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).
|
/// Drive an open context menu from a keystroke (Q#CM1).
|
||||||
fn dispatch_menu_key(&mut self, chord: Chord) {
|
fn dispatch_menu_key(&mut self, chord: Chord) {
|
||||||
match MenuKey::from_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
|
/// Keys handled while a context menu is open (Q#CM1). Like
|
||||||
/// [`SearchKey`], this shadows the global keymap; the same decode runs
|
/// [`SearchKey`], this shadows the global keymap; the same decode runs
|
||||||
/// in both frontends via the daemon's `FrontendEvent::Key` round-trip.
|
/// in both frontends via the daemon's `FrontendEvent::Key` round-trip.
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,41 @@ pub struct SearchSession {
|
||||||
invalid: bool,
|
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.
|
/// The world state mutated by editor commands.
|
||||||
pub struct EditorCore {
|
pub struct EditorCore {
|
||||||
/// Shared buffer registry. The registry is the canonical owner
|
/// 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
|
/// write would bypass the intercept chain entirely. Marked from
|
||||||
/// Lua via `pmacs.buffer.set_round_trip_input`; pruned on kill.
|
/// Lua via `pmacs.buffer.set_round_trip_input`; pruned on kill.
|
||||||
round_trip_buffers: std::collections::HashSet<BufferId>,
|
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 {
|
impl EditorCore {
|
||||||
|
|
@ -244,6 +283,7 @@ impl EditorCore {
|
||||||
menu: crate::menu::make_shared_menu(),
|
menu: crate::menu::make_shared_menu(),
|
||||||
completion_popup: crate::completion::make_shared_popup(),
|
completion_popup: crate::completion::make_shared_popup(),
|
||||||
round_trip_buffers: std::collections::HashSet::new(),
|
round_trip_buffers: std::collections::HashSet::new(),
|
||||||
|
query_replace: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -774,6 +814,206 @@ impl EditorCore {
|
||||||
aw.goal_col = None;
|
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
|
/// Snapshot a buffer's full byte content (empty if the id is
|
||||||
/// stale). O(1) rope snapshot + one copy; used to feed `find_all`.
|
/// stale). O(1) rope snapshot + one copy; used to feed `find_all`.
|
||||||
fn buffer_bytes(&self, buffer_id: BufferId) -> Vec<u8> {
|
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"
|
"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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10601,6 +10601,29 @@ fn install_search(editor: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result<
|
||||||
lua.create_function(move |_, ()| Ok(cc.borrow().search_active()))?,
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
138
src/search.rs
138
src/search.rs
|
|
@ -255,13 +255,7 @@ pub fn find_all_regex(haystack: &[u8], pattern: &str) -> Option<Vec<ByteRange>>
|
||||||
if pattern.is_empty() {
|
if pattern.is_empty() {
|
||||||
return Some(Vec::new());
|
return Some(Vec::new());
|
||||||
}
|
}
|
||||||
let case_insensitive = !pattern.chars().any(char::is_uppercase);
|
let re = compile_search_regex(pattern)?;
|
||||||
let re = if case_insensitive {
|
|
||||||
regex::bytes::Regex::new(&format!("(?i){pattern}"))
|
|
||||||
} else {
|
|
||||||
regex::bytes::Regex::new(pattern)
|
|
||||||
}
|
|
||||||
.ok()?;
|
|
||||||
let matches = re
|
let matches = re
|
||||||
.find_iter(haystack)
|
.find_iter(haystack)
|
||||||
.filter(|m| m.end() > m.start())
|
.filter(|m| m.end() > m.start())
|
||||||
|
|
@ -273,6 +267,80 @@ pub fn find_all_regex(haystack: &[u8], pattern: &str) -> Option<Vec<ByteRange>>
|
||||||
Some(matches)
|
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: ®ex::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
|
// TUI view
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -519,6 +587,62 @@ mod tests {
|
||||||
assert_eq!(find_all_regex(b"abc", ""), Some(vec![]));
|
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]
|
#[test]
|
||||||
fn store_set_clamps_active_and_clears_on_empty() {
|
fn store_set_clamps_active_and_clears_on_empty() {
|
||||||
let mut s = SearchStore::new();
|
let mut s = SearchStore::new();
|
||||||
|
|
|
||||||
|
|
@ -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})"
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue