pmacs/docs/in-buffer-search-framing.md

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, RET accepts, Esc/C-g restores 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-buffer HashMap<BufferId, SearchState> (query + sorted Vec<ByteRange> matches + active index), shared Arc<Mutex>, mirroring diag::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/recompute drive it. recompute runs the matcher over an O(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_keydispatch_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 unless query has an uppercase char.
  • find_all_regex(haystack, pattern) -> Option<Vec<ByteRange>>regex::bytes::Regex over the whole buffer. Some for a valid pattern (possibly empty), None when 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. The regex crate'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). RET accepts (keeps cursor + highlights until the next edit); C-g / Esc cancels (restores the origin cursor, clears the store); BS shortens the query.
  • C-M-s / C-M-r — start a regex isearch (search.forward-regex / search.backward-regexed.search_start(forward, regex)).
  • M-r — toggle literal ↔ regex mid-search (a SearchKey decoded in dispatch_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 is C-x r), so isearch landed without disturbing the CUA / Emacs editing keys.

Frontend surfaces

  • TUI — a SearchView overlay attached to the active window on search_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 (mirrors paint_local_selection's per-row clip, newline excluded).
  • GUI (pmacs-gpu) — matches wash via SearchMatch / SearchMatchActive decorations through push_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 the SearchPrompt wire message. Key routing reuses the M11.6 DispatchIdle gate: daemon_intercepts_keys (a live SearchPrompt or !dispatch_idle) round-trips every key into the daemon's search while it runs, and is_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

  1. Entry binding: C-fC-s / C-r. The incremental framing penciled C-f (CUA "Find", rebinding cursor.right) "for veto." C-s / C-r shipped instead: both were unbound, Emacs-faithful, and need no cursor.right rebind. User-validated.
  2. Input model: minibuffer-hosted → dedicated core search mode. The framing (Q#SR5) proposed hosting the query in the minibuffer with a new on_changed hook. Shipped as a frontend-agnostic SearchSession on EditorCore driven by dispatch_search_key, because pmacs-gpu has no minibuffer — a shared core mode was the only way to make search work identically in both frontends.
  3. 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.
  4. 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_edit marking 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_idle flips during search; the optimistic path round-trips instead of editing the buffer.