14 KiB
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):SearchStoreis per-buffer, keyedBufferId → SearchState { query, matches: Vec<ByteRange>, active };SharedSearchStore = Arc<Mutex<…>>on the core.setreplaces query+matches,focus_from(byte)pointsactiveat the first match≥ byte,stepadvances with wrap. Matchers are free functions over&[u8]:find_all(smart-case substring, non-overlapping) andfind_all_regex(Noneiff the pattern fails to compile). No replace API — greenfield. - Highlights are store-driven, not session-driven
(
semantic_render.rs): the producer emitsSearchMatchActivefor the match equal toactive_match(),SearchMatchfor the rest, reading only the store. So writing a match intosearch_storeand pointingactiveat it renders in both frontends with zero new rendering code. - The isearch shadow is the template (
src/editor.rs):dispatch_keyroutes todispatch_search_keywhilesearch_active();SearchKey::from_chordmaps a fixed key vocabulary; an active search eats every key.dispatch_idle()returns false whilesearch_active(), so the GPU round-trips keys instead of optimistically self-inserting (testisearch_flips_dispatch_idle_so_gpu_round_trips). - The transient prompt band (
StatusFacts.message, v15): settingcore.statusshows an echo-area string in both frontends (TUI bottom row, GPU band).dispatch_keyclearscore.statusat entry, so a handler that re-sets it at the end owns the band cleanly. - Region replace is
EditOp::Replace { range, bytes }viaapply_active_edit— one undo step (theinsert_char_over_regionprecedent). 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 returns 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):Replacethe match with the to-bytes; setnext_from = match.start + to.len()(past the replacement, so it's never re-matched); advance. - skip (
n/DEL): setnext_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-gbehaves the same asq— Emacs's query-replaceC-gexits 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:
- 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; Luaquery-replace/query-replace-regexpcommands (chainedminibuffer.read, Q#QR3 empty-string + bucket rules) boundM-%/C-M-%. Acceptance tests throughdispatch_key(hermetic, likecompletion_popup_acceptance): replace/skip/!/., the three quit paths + nothing-matched-restores-origin (Q#QR10), empty-to deletion, offset-shift correctness (a→aadoesn't loop), regex incl. invalid-at-start and zero-width, buffer.after-edit fires (an LSP/syntax observer sees the replaced text), and thedispatch_idle-false gate. Explicit binding tests for bothM-%andC-M-%— control-meta-shifted punctuation is exactly the chord that can parse differently across the TUI and GPU key paths (theC-c Hlesson from Arc 1b), so assertfrom_chordresolves each and that it fires throughdispatch_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)
- Zero protocol change holds. Status band +
SearchMatchActive+dispatch_idlegate 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.) - 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. - The 5th shadow drops in cleanly —
dispatch_idle, modal-close guard, and GPU round-trip all generalize like the completion popup did (Arc 1a). - 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.