Merge pull request #97 from levineuwirth/session-query-replace

feat(edit): query-replace (M-% / C-M-%) — Arc 2
This commit is contained in:
Levi Neuwirth 2026-07-08 17:16:08 -04:00 committed by GitHub
commit 9da82dcfe8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 1393 additions and 8 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

@ -0,0 +1,280 @@
# Query-replace — framing (Arc 2 interleave)
pmacs has incremental search, substring and regex, in both frontends —
and no replace at all. `search.rs` finds matches and never substitutes;
there is no `M-%`. This is the highest-value missing editing table-stake
(you reach for it hourly), and it sits right on top of isearch.
The happy discovery from scouting: **the whole feature is
zero-protocol-change.** It reuses three things already on the wire —
the `StatusFacts.message` band (added v15 for exactly this class of
transient prompt), the `SearchMatch`/`SearchMatchActive` decorations
(store-driven, both frontends), and the `dispatch_idle`-false gate that
already makes semantic frontends round-trip keys during a search. The
entire arc is core + Lua; no v16.
Roadmap context: `docs/roadmap-2026-07.md` Arc 2 (the editing
table-stakes interleave, promised after Arc 1's panels).
## What already exists (verified)
- **Match store** (`src/search.rs`): `SearchStore` is per-buffer,
keyed `BufferId → SearchState { query, matches: Vec<ByteRange>,
active }`; `SharedSearchStore = Arc<Mutex<…>>` on the core. `set`
replaces query+matches, `focus_from(byte)` points `active` at the
first match `≥ byte`, `step` advances with wrap. Matchers are free
functions over `&[u8]`: `find_all` (smart-case substring,
non-overlapping) and `find_all_regex` (`None` iff the pattern fails
to compile). **No replace API — greenfield.**
- **Highlights are store-driven, not session-driven**
(`semantic_render.rs`): the producer emits `SearchMatchActive` for
the match equal to `active_match()`, `SearchMatch` for the rest,
reading only the store. So writing a match into `search_store` and
pointing `active` at it renders in both frontends with **zero new
rendering code**.
- **The isearch shadow is the template** (`src/editor.rs`):
`dispatch_key` routes to `dispatch_search_key` while
`search_active()`; `SearchKey::from_chord` maps a fixed key
vocabulary; an active search eats every key. `dispatch_idle()`
returns false while `search_active()`, so the GPU round-trips keys
instead of optimistically self-inserting (test
`isearch_flips_dispatch_idle_so_gpu_round_trips`).
- **The transient prompt band** (`StatusFacts.message`, v15): setting
`core.status` shows an echo-area string in both frontends (TUI
bottom row, GPU band). `dispatch_key` clears `core.status` at entry,
so a handler that re-sets it at the end owns the band cleanly.
- **Region replace** is `EditOp::Replace { range, bytes }` via
`apply_active_edit` — one undo step (the `insert_char_over_region`
precedent). Every edit marks the search store stale; nothing
auto-recomputes matches (Q#QR2 owns this).
## Decisions
### Q#QR1 — A distinct `QueryReplaceSession` + a 5th dispatcher shadow
Not an overload of the isearch session — the lifecycles differ (isearch
is one string, cancel-restores-origin; query-replace is from+to plus an
interactive y/n phase). Mirror the structure instead:
`QueryReplaceSession` on `EditorCore`, `query_replace_active()`, and a
`QueryReplaceKey::from_chord` + `dispatch_query_replace_key` in
`editor.rs` — the fifth member of the shadow family (minibuffer,
search, menu, completion, **query-replace**). Add
`query_replace_active()` to the `dispatch_idle()` disjunction and the
completion-popup modal-close guard, exactly as the others.
**Pin to the origin buffer (as-built fix).** The session records its
origin buffer, but every edit and cursor move goes through the
*active* window/buffer — and focus can drift mid-session (a click into
another split, a key from another frontend, both of which change the
active buffer outside the shadow). Applying an origin-buffer match to
whatever became active is buffer corruption. Guard it:
`query_replace_on_origin()` checks the active buffer still equals the
origin buffer before every edit and **aborts the session without
editing** on mismatch (never corrupt an unrelated buffer). The
`buffer.after-edit` revision compare (above) targets the *origin*
buffer specifically, not the active one, so a focus-drift abort — which
edits nothing — never spuriously fires the hook.
**`buffer.after-edit` must fire from inside the shadow (P1).** A modal
shadow `return`s before `dispatch_key`'s normal post-command edit
check, so `apply_active_edit` from `dispatch_query_replace_key` would
not notify LSP `didChange` / syntax reparse / anything on the edit
chain — the replaced text would silently keep stale styling and
diagnostics. Mirror `dispatch_completion_key` (`editor.rs:797`
precedent): snapshot `active_buffer_revision()` before handling the
key, and `run_hook("buffer.after-edit", …)` if it changed. `!`
(replace-all) applies many edits in one keypress — fire the hook
**once** after the batch (revision compared across the whole handler),
not per replacement, so the debounced `didChange` coalesces naturally.
### Q#QR2 — Search-forward-after-each-replace (Emacs's algorithm), not precompute-all
The load-bearing correctness decision. Do **not** precompute the whole
match list and walk it — replacing `a`→`aa` (or `foo`→`foobar`) would
re-match the replacement text and loop, and precomputed offsets go
stale after the first edit. Instead, hold a `next_from` byte cursor;
each step finds the *next* match at/after `next_from` in the **current**
buffer:
- **replace** (`y`/`SPC`): `Replace` the match with the to-bytes; set
`next_from = match.start + to.len()` (past the replacement, so it's
never re-matched); advance.
- **skip** (`n`/`DEL`): set `next_from = match.end`; advance.
- **advance**: find the next match from `next_from`; none ⇒ finish.
Offset shift is handled by construction (every search is on the live
buffer from a byte past the last edit), and replacements are never
re-matched. Add `find_first_from(haystack, query, start)` (literal +
regex) to `search.rs` so each step is one bounded forward scan, not an
`O(buffer)` `find_all` filtered — keeps `!` (replace-all) linear.
**Cursor reveal (P2).** Highlighting a match that's off-screen is
useless — like isearch's `search_place_cursor` after each step, move
point so the frontend scrolls it into view: **advance** sets the cursor
to the current match's `start`; **replace** sets it to the *end of the
inserted replacement* (`match.start + to.len()`), which is also
`next_from`; **natural finish** (ran out of matches) leaves point
there. Quit semantics are Q#QR10.
**Regex specifics (P2).** Compile the pattern **once** at session start
and store the `regex::bytes::Regex` in the session; the regex
`find_first_from` scans from `next_from` using that cached engine
(`Regex::find_at`) — recompiling per step would make `!` quadratic and
defeat the "linear" claim. **Invalid pattern at start**: if the regex
fails to compile, don't begin the session — set a status
(`"Invalid regex: …"`) and return, the same clean refusal isearch's
`invalid` flag gives (there's no mid-session recompile since the
pattern is fixed once entered). **Zero-width matches**: the regex
first-match path filters them exactly as `find_all_regex` does (a
zero-width match would never advance `next_from` and would loop) — skip
forward past a zero-width hit.
### Q#QR3 — Two entry strings via chained `minibuffer.read`
The Lua command collects the from-string, then the to-string in its
`on_accept`, then calls `ed.query_replace_start(from, to, regex)` which
begins the core session. Both prompts ride `minibuffer.read`
(dual-frontend since v12); the minibuffer is closed by the time the
second `on_accept` starts the session, so the handoff into the
query-replace shadow is clean. Emacs's "Query replace: X Query replace
X with: Y" flow, faithfully.
**Separate history buckets (P3):** `history = "query-replace-from"` and
`history = "query-replace-to"` — one shared bucket would mix search
patterns and replacement text in both dropdowns.
**Empty-string rules (P3):** an empty *from* string is rejected (the
from-prompt's `on_accept` returns early with a status, like other
minibuffer flows) — there's nothing to search for. An empty *to* string
is **valid** and means deletion (replace each match with nothing); the
to-prompt must not copy the reject-empty pattern.
### Q#QR4 — Per-match prompt via `core.status` (StatusFacts.message)
No new wire message. Each prompt sets
`core.status = "Query replacing FROM with TO (SPC/y, n, !, ., q)"`
shown in both frontends via the v15 band. This is exactly the v15
rider's purpose (transient echo-area content), and it's the Emacs
behavior (query-replace prompts live in the echo area = the status
line). A running count (`… — 3 replaced`) can ride the same string.
### Q#QR5 — Current-match highlight reuses `SearchMatchActive`
Write just the current match into `search_store`
(`set(buffer, from, [current])`, active = 0); the producer renders it
as `SearchMatchActive` (amber) in both frontends. Clear the store on
finish (isearch's cancel discipline). Store contention with a lingering
isearch is moot: shadows are modal and mutually exclusive, and the
first write overwrites whatever isearch left. v1 highlights only the
current match (Emacs's default prompt highlight); lazy-highlighting all
remaining matches is deferred.
### Q#QR6 — Key vocabulary (v1)
`y` / `SPC` replace-and-advance; `n` / `DEL` skip-and-advance; `!`
replace this and all remaining without prompting; `.` replace this then
quit; `q` / `RET` / `Esc` / `C-g` quit. Unrecognized keys are eaten
(the isearch precedent). Deferred: `,` (replace-but-stay), `^` (back
up), `?` (help). Finish/quit semantics are Q#QR10.
### Q#QR7 — Undo granularity
Each replacement is one `EditOp::Replace` = one undo step, so an
N-match query-replace is N undo steps. Simple and correct; a single
undo-group for the whole run is deferred (it's the same
`begin/end_undo_group` mechanism the CUA type-over framing Q#U1 parked
— induct it when a second caller wants it).
### Q#QR8 — Scope
Forward, from point to buffer end (Emacs's default). Matches before the
cursor are not touched. Cursor placement is Q#QR2 (per-step) and Q#QR10
(on finish). Whole-buffer and backward query-replace are deferred.
### Q#QR9 — Regex replacement is literal in v1
`C-M-%` (`query-replace-regexp`) matches via `find_all_regex`/the regex
`find_first_from`, but the replacement string is inserted literally —
no `\1` capture-group references. Capture-group substitution is
deferred (it needs the regex engine's capture API threaded through the
replace step).
### Q#QR10 — Finish / quit semantics (NOT isearch's)
The load-bearing difference from isearch: query-replace has usually
**already mutated the buffer** by the time it ends, so "cancel" cannot
mean "restore." Precisely:
- **Quit** (`q`/`RET`/`Esc`/`C-g`, or `.` after its replace, or running
out of matches): does **not** roll back any replacement already made;
clears the highlight (`search_store.clear`); leaves point at the
current/last-inspected match (Q#QR2's cursor rule already put it
there); sets a status count (`"Replaced N occurrence(s)"`). `C-g`
behaves the same as `q` — Emacs's query-replace `C-g` exits and keeps
the replacements; it is *not* an undo.
- **Nothing matched** (the session never found a first match): a
distinct case — restore the origin cursor (isearch's cancel
discipline, since nothing was touched) and status `"No matches for
'FROM'"`. This is the only path that restores point.
So the session records `origin` (for the nothing-matched restore only)
and whether any replacement happened; every other exit leaves point at
the inspected match.
## Phasing
One implementation pass (the feature is small and cohesive), validated
in both frontends:
1. **Core + Lua + literal & regex.** `QueryReplaceSession` + methods +
the shadow (with the Q#QR1 after-edit hook) + `find_first_from`
(literal & regex, cached engine) + the status prompt + the
highlight-and-reveal; Lua `query-replace` / `query-replace-regexp`
commands (chained `minibuffer.read`, Q#QR3 empty-string + bucket
rules) bound `M-%` / `C-M-%`. Acceptance tests through `dispatch_key`
(hermetic, like `completion_popup_acceptance`): replace/skip/`!`/`.`,
the three quit paths + nothing-matched-restores-origin (Q#QR10),
empty-to deletion, offset-shift correctness (`a`→`aa` doesn't loop),
regex incl. invalid-at-start and zero-width, buffer.after-edit fires
(an LSP/syntax observer sees the replaced text), and the
`dispatch_idle`-false gate. **Explicit binding tests for both
`M-%` and `C-M-%`** — control-meta-shifted punctuation is exactly
the chord that can parse differently across the TUI and GPU key
paths (the `C-c H` lesson from Arc 1b), so assert `from_chord`
resolves each and that it fires through `dispatch_key`. GPU
validation scores bets #1/#3.
If the interactive-phase key handling or the offset bookkeeping proves
fiddlier than expected, phase 2 splits out regex + `!`/`.`; but the
plan is a single PR.
## Categorical bets (score at close)
1. **Zero protocol change holds.** Status band + `SearchMatchActive` +
`dispatch_idle` gate carry the whole feature to the GPU with no v16
and no GPU code. (The panels arc's bet-#3 lesson makes me watch the
GPU path specifically — "the mechanism exists" bit us there.)
2. **Search-forward-after-replace is correct where precompute-all
loops.** `a`→`aa`, `foo`→`foobar`, and a replacement that would form
a new downstream match all terminate correctly because each search
starts past the replacement on the live buffer.
3. **The 5th shadow drops in cleanly**`dispatch_idle`, modal-close
guard, and GPU round-trip all generalize like the completion popup
did (Arc 1a).
4. **A store-contention or status-clear edge** — some interaction where
a lingering isearch highlight, or `dispatch_key`'s entry
status-clear, briefly shows the wrong band/highlight during the
interactive phase.
## Deferred (named, not silently dropped)
- Capture-group references (`\1`) in regex replacements (Q#QR9).
- `,` (replace-but-stay), `^` (back up a match), `?` (help) keys.
- Backward and whole-buffer query-replace (Q#QR8).
- Single undo-group for a whole run (Q#QR7; shares CUA Q#U1's
`begin/end_undo_group`).
- Smart default from-string (word/region at point) + last-used default.
- Lazy-highlight of all remaining matches during the prompt (v1 shows
only the current match).
- `replace-string` / `replace-regexp` (non-interactive replace-all) —
trivial once the replace core exists; a thin non-prompting entry.

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()
}
@ -552,7 +553,10 @@ impl EditorState {
{
let mut core = self.core.borrow_mut();
if core.completion_popup_is_open()
&& (core.menu_is_open() || core.search_active() || core.minibuffer.is_active())
&& (core.menu_is_open()
|| core.search_active()
|| core.query_replace_active()
|| core.minibuffer.is_active())
{
core.completion_popup_close();
}
@ -578,6 +582,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
@ -679,6 +695,13 @@ impl EditorState {
/// mid-dispatch).
fn active_buffer_revision(&self) -> Option<u64> {
let id = self.core.borrow().active_buffer_id();
self.buffer_revision(id)
}
/// Edit revision of a specific buffer, or `None` if the registry no
/// longer knows it. Used by the query-replace shadow to compare the
/// *edited* (origin) buffer, not whichever is active.
fn buffer_revision(&self, id: crate::buffer::BufferId) -> Option<u64> {
let reg = self.lua_host.registry().borrow();
reg.get(id).ok().map(crate::buffer::Buffer::revision)
}
@ -810,6 +833,39 @@ 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) {
// Compare the *origin* buffer's revision (the one query-replace
// edits), not the active buffer's — they can differ if focus
// drifted, and the wrong-buffer guard may abort without editing.
let origin_buf = self.core.borrow().query_replace_origin_buffer();
let pre = origin_buf.and_then(|id| self.buffer_revision(id));
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 => {}
}
// `!` applies many edits under one keypress; the single compare
// fires `buffer.after-edit` once for the batch (Q#QR1).
let post = origin_buf.and_then(|id| self.buffer_revision(id));
if origin_buf.is_some() && pre != post {
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 +1685,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,249 @@ 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()
}
/// The buffer a running query-replace is pinned to, or `None`. The
/// dispatcher reads this so the `buffer.after-edit` revision compare
/// targets the *edited* buffer, not whichever is active.
#[must_use]
pub fn query_replace_origin_buffer(&self) -> Option<BufferId> {
self.query_replace.as_ref().map(|s| s.origin.0)
}
/// Query-replace's wrong-buffer guard. Every edit and cursor move it
/// makes goes through the *active* window/buffer, but the session is
/// pinned to the buffer it started in — and focus can drift
/// mid-session (a click into another split, a key from another
/// frontend). Before touching the buffer, verify the active buffer
/// is still the origin buffer; if not, **abort without editing** so
/// a match found in the origin buffer can never be applied to an
/// unrelated one. Returns `true` when it is safe to proceed.
fn query_replace_on_origin(&mut self) -> bool {
let Some(origin_bid) = self.query_replace.as_ref().map(|s| s.origin.0) else {
return false;
};
if self.active_buffer_id() == origin_bid {
return true;
}
// Focus moved off the origin buffer — abort, don't corrupt.
if let Some(session) = self.query_replace.take() {
self.search_store
.lock()
.expect("search store mutex poisoned")
.clear(session.origin.0);
}
self.status = "query-replace aborted: active buffer changed".into();
false
}
/// 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 bytes = self.buffer_bytes(bid);
let start = (session.next_from as usize).min(bytes.len());
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_on_origin() && 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 !self.query_replace_on_origin() {
return;
}
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) {
if !self.query_replace_on_origin() {
return;
}
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 bytes = self.buffer_bytes(bid);
let start = (session.next_from as usize).min(bytes.len());
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) {
if !self.query_replace_on_origin() {
return;
}
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 +3724,126 @@ 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_aborts_when_active_buffer_changes() {
// The wrong-buffer merge-blocker: a session started in buffer X
// must never apply its match to a buffer that became active
// mid-session. Focus drifts (a click / cross-frontend key), then
// the next replace key aborts safely instead of corrupting.
let mut s = from_bytes(b"foo foo\n");
let x = s.active_buffer_id();
s.query_replace_begin("foo".into(), "bar".into(), false);
assert!(s.query_replace_active());
// Switch the active buffer to an unrelated one (focus drift).
let y = s.registry.borrow_mut().create("*other*");
{
let reg = s.registry.borrow();
let buf = reg.get(y).unwrap();
let tv = crate::text_view::TextView::new(buf);
drop(reg);
let win = s.active_window_mut();
win.buffer_id = y;
win.text_view = tv;
win.cursor = 0;
}
assert_eq!(s.active_buffer_id(), y);
s.query_replace_replace(); // the y/replace key while drifted
assert!(!s.query_replace_active(), "drift aborts the session");
assert_eq!(s.status, "query-replace aborted: active buffer changed");
// Neither buffer was mutated by the aborted replace.
{
let reg = s.registry.borrow();
let bx = reg.get(x).unwrap();
let mut xb = vec![0u8; bx.len() as usize];
bx.snapshot_rope().slice(0, bx.len(), &mut xb);
assert_eq!(&xb, b"foo foo\n", "origin buffer X untouched");
let by = reg.get(y).unwrap();
assert_eq!(by.len(), 0, "unrelated buffer Y untouched");
}
}
#[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,418 @@
//! 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 full key vocabulary
//! (`y`/`SPC` replace, `n`/`DEL` skip, `!` all, `.` last); every quit
//! path (`q`, `RET`, `Esc`, `C-g`) keeping replacements, plus
//! nothing-matched-restores-origin; empty-to deletion; offset-shift
//! correctness (`a`→`aa` doesn't loop); regex; the `buffer.after-edit`
//! hook (once per `y`, and exactly once for an `!` batch); the
//! `dispatch_idle`-false gate; and the wrong-buffer/focus-drift abort.
//!
//! 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})"
);
}
#[test]
fn bang_fires_after_edit_hook_once_for_the_batch() {
// Q#QR1: `!` applies many replacements under one keypress, but the
// debounced didChange wants a single after-edit — the shadow
// compares revision once across the whole handler.
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 a");
goto_start(&s);
// Zero out the counter after the typing edits.
s.lua_host.lua().load("_G.EDITS = 0").exec().ok();
start_query_replace(&mut s, "a", "b", false);
s.lua_host.lua().load("_G.EDITS = 0").exec().ok();
press(&mut s, KeyCode::Char('!')); // four replacements in one keypress
let edits: i64 = s
.lua_host
.lua()
.load("return _G.EDITS")
.eval()
.expect("read counter");
let (text, _) = probe(&s);
assert_eq!(text, "b b b b", "! replaced all four");
assert_eq!(edits, 1, "after-edit fires exactly once for the ! batch");
}
#[test]
fn quit_via_ret_and_esc_keeps_replacements() {
// Q#QR10: RET and Esc both quit (keeping replacements), not just q.
for quit in [KeyCode::Enter, KeyCode::Esc] {
let mut s = EditorState::new();
type_str(&mut s, "a a a");
goto_start(&s);
start_query_replace(&mut s, "a", "b", false);
press(&mut s, KeyCode::Char('y')); // replace the first
press(&mut s, quit); // quit before the rest
let (text, active) = probe(&s);
assert_eq!(text, "b a a", "quit keeps the one replacement ({quit:?})");
assert!(!active, "{quit:?} ends the session");
}
}
#[test]
fn ctrl_g_quits_keeping_replacements() {
// Q#QR10: C-g exits and KEEPS replacements (unlike isearch's C-g).
let mut s = EditorState::new();
type_str(&mut s, "a a a");
goto_start(&s);
start_query_replace(&mut s, "a", "b", false);
press(&mut s, KeyCode::Char('y'));
s.dispatch_key(
FrontendId::LOCAL,
key(KeyCode::Char('g'), KeyModifiers::CONTROL),
);
let (text, active) = probe(&s);
assert_eq!(text, "b a a", "C-g keeps replacements (not an undo)");
assert!(!active);
}
#[test]
fn del_key_skips_like_n() {
let mut s = EditorState::new();
type_str(&mut s, "a a a");
goto_start(&s);
start_query_replace(&mut s, "a", "b", false);
press(&mut s, KeyCode::Backspace); // DEL/Backspace → skip first
press(&mut s, KeyCode::Char('y')); // replace second
press(&mut s, KeyCode::Char('q'));
let (text, _) = probe(&s);
assert_eq!(
text, "a b a",
"DEL skipped the first, y replaced the second"
);
}
#[test]
fn focus_drift_mid_session_aborts_without_touching_either_buffer() {
// The merge-blocker, end-to-end: a click into another buffer
// (simulated by switch_buffer, which the pointer path also uses)
// while query-replace is active. The next y must abort, not apply
// the origin-buffer match to the now-active unrelated buffer.
let mut s = EditorState::new();
type_str(&mut s, "foo foo");
goto_start(&s);
start_query_replace(&mut s, "foo", "bar", false);
assert!(probe(&s).1, "session active on the first match");
// Focus drifts to a fresh, unrelated buffer.
s.lua_host
.lua()
.load(
r"
_G.OTHER = pmacs.buffer.create('*drift*')
pmacs.window.switch_buffer(_G.OTHER)
",
)
.exec()
.expect("switch to another buffer");
press(&mut s, KeyCode::Char('y')); // the replace key, now drifted
assert!(!probe(&s).1, "drift aborts the session");
let (drift_text, _) = probe(&s); // active buffer is *drift*
assert_eq!(drift_text, "", "the unrelated buffer was not edited");
// The origin buffer is also intact — switch back and check.
s.lua_host
.lua()
.load(
r"
for _, id in ipairs(pmacs.buffer.list()) do
if pmacs.describe.buffer(id).name == '*scratch*' then
pmacs.window.switch_buffer(id)
end
end
",
)
.exec()
.ok();
assert_eq!(
probe(&s).0,
"foo foo",
"origin buffer untouched by the aborted replace"
);
}