6.8 KiB
In-buffer search — consolidated framing + as-built
Consolidates the two framing passes for in-buffer search:
incremental substring isearch (PR #70) and regex search (this
arc). Supersedes the separate incremental-search-framing.md and
regex-search-framing.md. Where the implementation diverged from a
framing stance, the "As-built" notes record what actually shipped and
why.
User-decided up front:
- Incremental isearch — highlight live as you type, the same key
steps to the next match,
RETaccepts,Esc/C-grestores origin. - Smart-case matching (case-insensitive unless the query has an uppercase letter), for both substring and regex.
- Regex with both dedicated entry keys (
C-M-s/C-M-r) and a mid-search toggle (M-r), matching multi-line.
Architecture (as-built)
search::SearchStore— per-bufferHashMap<BufferId, SearchState>(query + sortedVec<ByteRange>matches + active index), sharedArc<Mutex>, mirroringdiag::DiagnosticStore. The active index is navigation state on the store (two windows on one buffer share the active highlight — the diagnostics tradeoff). Edits mark the entry stale (M11.8) so matches at pre-edit byte positions never paint until a re-search.EditorCore::SearchSession { query, origin, forward, regex, invalid }— the live input state.search_begin/input_char/ backspace/step/finish/toggle_regex/recomputedrive it.recomputeruns the matcher over anO(1)rope snapshot, writes the store, refocuses from the origin cursor, moves the cursor to the active match.- Shared dispatch. Keys are intercepted in
EditorState:: dispatch_key→dispatch_search_key(SearchKey::from_chord). This is the same path the daemon runs for round-tripped GUI keystrokes, so isearch behaves identically in both frontends; only the prompt surface differs.
Matching
find_all(haystack, query)— smart-case ASCII substring, non-overlapping. Case-insensitive unlessqueryhas an uppercase char.find_all_regex(haystack, pattern) -> Option<Vec<ByteRange>>—regex::bytes::Regexover the whole buffer.Somefor a valid pattern (possibly empty),Nonewhen it won't compile, so the caller distinguishes invalid (show[invalid]) from zero matches. Smart-case via a(?i)prefix unless the pattern carries an uppercase letter. Multi-line is free — the regex runs over the whole byte slice, so an explicit\n(or(?s).) spans lines while.stays line-bound. Zero-width matches (a*,^,$) are filtered. Theregexcrate's linear-time engine makes a pathological pattern slow at worst, never catastrophic. An uppercase letter inside an escape/class (\D,[A-Z]) trips case-sensitivity — accepted, the same coarse rule as the literal path.
Input & bindings
C-s/C-r— start a literal isearch forward / backward; once running, the same keys step next / previous (intercepted in Rust, no binding).RETaccepts (keeps cursor + highlights until the next edit);C-g/Esccancels (restores the origin cursor, clears the store);BSshortens the query.C-M-s/C-M-r— start a regex isearch (search.forward-regex/search.backward-regex→ed.search_start(forward, regex)).M-r— toggle literal ↔ regex mid-search (aSearchKeydecoded indispatch_search_key, so it works the same in both frontends).- Both keys were free in the default map (save is
C-x C-s, redo isC-x r), so isearch landed without disturbing the CUA / Emacs editing keys.
Frontend surfaces
- TUI — a
SearchViewoverlay attached to the active window onsearch_begin(deduped; self-suppresses with no matches / when stale) washes matches; a bottom-row prompt reads[Regex] I-search: <query> (n/m)(or[no match]/[invalid]). The terminal cursor stays in the buffer at the active match. Multi-line matches wash each spanned row (mirrorspaint_local_selection's per-row clip, newline excluded). - GUI (pmacs-gpu) — matches wash via
SearchMatch/SearchMatchActivedecorations throughpush_glyph_extent_rects, which already fans a byte range across visual lines, so multi-line needed no GUI rendering change. The query reaches the band via theSearchPromptwire message. Key routing reuses the M11.6DispatchIdlegate:daemon_intercepts_keys(a liveSearchPromptor!dispatch_idle) round-trips every key into the daemon's search while it runs, andis_search_entry_chord(C-s/C-r/C-M-s/C-M-r) forwards the entry chords that are otherwise withheld. Escape cancels an active search instead of quitting the window.
Wire (InstanceMessage::SearchPrompt)
{ buffer_id, query: Option<String>, active: Option<u32>, total: u32, regex: bool, invalid: bool }. Emitted by the semantic producer
(cached-compare suppressed like StatusFacts); query: None clears
the band. Protocol v9 added the message (query/active/total);
v10 added regex / invalid (an encoding change to the variant),
so the daemon's per-session filter gates it at >= 10 — a v9 peer is
sent no SearchPrompt (decorations still highlight) rather than
mis-decoding the wider shape. SUPPORTED = [6, 7, 8, 9, 10].
As-built divergences from the framing passes
- Entry binding:
C-f→C-s/C-r. The incremental framing penciledC-f(CUA "Find", rebindingcursor.right) "for veto."C-s/C-rshipped instead: both were unbound, Emacs-faithful, and need nocursor.rightrebind. User-validated. - Input model: minibuffer-hosted → dedicated core search mode.
The framing (Q#SR5) proposed hosting the query in the minibuffer
with a new
on_changedhook. Shipped as a frontend-agnosticSearchSessiononEditorCoredriven bydispatch_search_key, because pmacs-gpu has no minibuffer — a shared core mode was the only way to make search work identically in both frontends. - Regex: deferred → shipped. Q#SR2 deferred regex ("literal-text
is the 95% case"); this arc added it as
find_all_regex+ a mode flag, keeping the literal path the default. - Multi-line: substring single-line → regex multi-line. Substring
matches never span lines; regex can. The TUI
SearchView(which assumed single-line) gained per-row washing; the GUI was already multi-line-capable.
Bets that held (validation gate)
- Stale-after-edit linger — closed by
apply_active_editmarking the store stale. - Invalid-regex incremental states —
foo(shows[invalid], never panics, recovers on completion. - Multi-line TUI wash — per-row clip at line boundaries, no phantom trailing cell.
- GUI key routing —
dispatch_idleflips during search; the optimistic path round-trips instead of editing the buffer.