pmacs/docs/query-replace-framing.md

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): 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 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 aaa (or foofoobar) 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 (aaa 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. aaa, foofoobar, 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 cleanlydispatch_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.