diff --git a/docs/regex-search-framing.md b/docs/regex-search-framing.md new file mode 100644 index 0000000..e4c00cb --- /dev/null +++ b/docs/regex-search-framing.md @@ -0,0 +1,138 @@ +# Regex in-buffer search — framing pass + +Date: 2026-06-27. Extends the merged incremental isearch (PR #70): +`C-s`/`C-r` drive a smart-case **substring** search over a per-buffer +`SearchStore`, highlighted live in both frontends. This adds **regex** +matching alongside the literal path. Decided up front (user): + +- **Both** a dedicated regex entry (`C-M-s` / `C-M-r`) **and** a + mid-search toggle (`M-r`, Emacs `isearch-toggle-regexp`). +- **Multi-line** matches (a pattern may span newlines). + +## Survey facts (anchors — what shipped in #70) + +- `find_all(haystack, query) -> Vec` (search.rs): smart-case + ASCII substring, non-overlapping. The one matcher today. +- `EditorCore::SearchSession { query, origin, forward }` + + `search_begin/input_char/backspace/step/finish/recompute`. Recompute + runs `find_all` over an `O(1)` rope snapshot, writes the store, + refocuses from origin, moves the cursor. +- Input is intercepted in the shared `dispatch_key` → + `dispatch_search_key` (`SearchKey::from_chord`), so TUI-local and + daemon (GUI round-trip) keystrokes drive the same core. +- TUI `SearchView` overlay washes matches; it assumes **single-line** + matches ("the query carries no newline") — the one place multi-line + breaks. +- GPU washes via `SearchMatch`/`SearchMatchActive` decorations through + `push_glyph_extent_rects`, which **already fans a byte range across + visual lines** (multi-line selections use the same path) — so the + GPU needs **no** rendering change for multi-line. +- `InstanceMessage::SearchPrompt { buffer_id, query, active, total }` + (protocol v9) carries the query + readout to the GUI status band. +- `regex` 1.12.3 is already in `Cargo.lock` (transitive); promote to a + direct dependency. + +## Q#RX1 — the matcher + +**Stance: a sibling `find_all_regex(haystack, pattern) -> Option>` +on `regex::bytes::Regex` over the whole buffer.** `Some(matches)` for a +valid pattern (possibly empty), `None` for an invalid one (so the +caller can show `[invalid]` rather than `[no match]`). `find_iter` +gives leftmost non-overlapping byte ranges directly; **zero-width +matches are filtered** (start == end — `a*`, `^`, anchors — wash +nothing and would spam). Multi-line falls out for free: the regex runs +over the whole byte slice, so `foo\n\s*bar` matches across the newline. +`.` keeps its default (no `\n`); the user opts into dotall with `(?s)`. + +## Q#RX2 — smart-case for regex + +**Stance: the same uppercase heuristic, via an `(?i)` prefix.** +Case-insensitive unless the *pattern string* contains an uppercase +ASCII letter, implemented by compiling `(?i){pattern}` vs `{pattern}`. +Accepted imprecision: an uppercase letter inside an escape/class (`\D`, +`[A-Z]`) trips case-sensitivity — same spirit as the literal path's +"any uppercase ⇒ exact", and a regex author writing `[A-Z]` plausibly +wants case to matter anyway. Documented, not solved, in v1. + +## Q#RX3 — session mode & toggle + +**Stance: a `regex: bool` on `SearchSession`; `recompute` dispatches.** +`search_begin(forward, regex)` records the mode; `recompute` calls +`find_all_regex` (regex) or `find_all` (literal). `M-r` → +`SearchKey::ToggleRegex` → `search_toggle_regex()` flips the flag and +re-runs recompute on the same query (re-anchored from origin). The +session tracks an `invalid` bool (last recompute's pattern failed to +compile) so the prompt can distinguish invalid from zero-match. + +## Q#RX4 — multi-line TUI wash + +**Stance: `SearchView` iterates the rows each match spans, mirroring +`paint_local_selection`.** Per match `[start, end)`: for each display +row from `line(start)` to `line(end)`, wash that row's clipped slice +(`paint_start = max(start, line_start)`, `paint_end = min(end, +line_end)`), mapping to display cols with the existing +`byte_range_to_display_cols`. Single-line matches (every literal match, +and most regex matches) hit exactly one row — no behavior change there. +The GPU is untouched (Q#RX-anchor: `push_glyph_extent_rects` already +multi-lines). + +## Q#RX5 — entry keys & both frontends + +**Stance: `C-M-s`/`C-M-r` start a regex search; `M-r` toggles within +any search.** Daemon keymap binds `C-M-s` → `search.forward-regex`, +`C-M-r` → `search.backward-regex` (Lua → `ed.search_start(forward, +regex=true)`); `M-r` is handled *inside* `dispatch_search_key` (a +`SearchKey`, not a global binding — it is only meaningful mid-search). +GUI: `is_search_entry_chord` also forwards `C-M-s`/`C-M-r` (Ctrl+Alt); +`M-r` already round-trips via the intercept path once a search is +running, so it needs no GUI change. The TUI/GUI prompt reads +**`Regex I-search:`** in regex mode and **`[invalid]`** when the +pattern won't compile. + +## Q#RX6 — the wire (GUI) + +**Stance: extend `SearchPrompt` with `regex: bool` + `invalid: bool`, +protocol v10.** `SearchPrompt` is new in v9 (this is the first +encoding change to it), so the additive-but-encoding-changing rule +bumps `PROTOCOL_VERSION` 9 → 10 and grows `SUPPORTED` to +`[6,7,8,9,10]`, daemon-gated per session exactly like v9. The producer +fills both from the active `SearchSession`; the GUI renders the regex +label + invalid indicator from them. + +## Predicted findings (categorical bets) + +1. **Multi-line TUI wash correctness** (headline): row-by-row clipping + at line boundaries — off-by-one on the trailing newline / a match + ending exactly at a line end / an empty middle line must wash + cleanly, not bleed a column or skip a row. +2. **Invalid-regex incremental states**: typing `foo(` passes through + invalid mid-keystroke constantly; recompute must degrade to "no + matches + `[invalid]`", never panic or surface a raw regex error, + and recover the instant the pattern compiles again. +3. **Smart-case heuristic × escapes/classes**: `\D`, `[A-Z]` flip + case-sensitivity under the simple "any uppercase" rule — acceptable, + noted. +4. **Zero-width / pathological matches**: `a*`, `^`, `$` produce empty + or per-position matches; filter empties, and lean on `find_iter`'s + non-overlapping advance so a catastrophic pattern can't loop. +5. **Regex compile per keystroke**: a fresh compile each character is + fine for interactive small patterns; no cache in v1 (note it). + +## Session plan + +Four green commits (mirrors the #70 arc: core → TUI → GUI): + +1. This framing doc. +2. `find_all_regex` (multi-line, smart-case, `Option` for invalid) + + `regex` direct dep + unit tests. +3. TUI: `SearchSession.regex` + `invalid` + `search_begin(fwd, regex)` + + `search_toggle_regex` + `SearchKey::ToggleRegex` (`M-r`) + + `search.forward-regex`/`backward-regex` commands + `C-M-s`/`C-M-r` + bindings + `Regex I-search:` / `[invalid]` prompt + multi-line + `SearchView` wash + tests. +4. GUI: protocol v10 (`SearchPrompt` + `regex`/`invalid`), producer + fill, GUI prompt label + indicator, `C-M-s`/`C-M-r` entry chords. + +Manual validation gate as usual (regex highlight live, multi-line +pattern washes across rows, `M-r` toggles, invalid pattern shows +`[invalid]`, both frontends).