From e03f5620c9eaf7926313f924bfba647f76bd9a59 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 27 Jun 2026 14:20:47 -0400 Subject: [PATCH 1/5] framing: regex in-buffer search (multi-line, C-M-s + M-r toggle) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the merged isearch (#70) with regex matching: both dedicated entry keys (C-M-s / C-M-r) and a mid-search toggle (M-r), with multi-line matches. Q#RX1–RX6 stances + bets. Key survey finding recorded: the GPU's push_glyph_extent_rects already fans a byte range across visual lines (multi-line selections use it), so multi-line needs no GPU rendering change — only the TUI SearchView (which assumed single-line) mirrors paint_local_selection's per-row wash. SearchPrompt gains regex/invalid → protocol v10. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/regex-search-framing.md | 138 +++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 docs/regex-search-framing.md 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). From 5c34b3c37a542cd419ffa74cfaf326835348e8fb Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 27 Jun 2026 14:22:51 -0400 Subject: [PATCH 2/5] regex-search: smart-case multi-line find_all_regex (Q#RX1/RX2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The regex sibling of find_all, on regex::bytes::Regex over the whole buffer. Returns Option>: Some for a valid pattern (possibly empty), None when it fails to compile — so the caller can tell an invalid pattern (show [invalid]) from a valid zero-match search. Smart-case mirrors the literal path: case-insensitive 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 `.` keeps its default. Zero-width matches (a*, ^, $) are filtered. The regex crate (already transitive in the lockfile) is promoted to a direct dependency; its linear-time engine makes a pathological pattern slow at worst, never catastrophic. Tests: pattern match, smart-case both ways, \n-spanning + dotall + default-no-cross, invalid→None vs valid-zero→Some(empty), zero-width filter. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 1 + Cargo.toml | 4 +++ src/search.rs | 98 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 5cb1c26..14013ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2434,6 +2434,7 @@ dependencies = [ "proptest", "rand 0.8.6", "rand_distr", + "regex", "rmp-serde", "semver", "serde", diff --git a/Cargo.toml b/Cargo.toml index 037e9b5..5acb93e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,6 +78,10 @@ crdt = ["dep:loro", "pmacs-protocol/crdt"] crossterm = "0.28" thiserror = { workspace = true } unicode-width = "0.2" +# Regex engine for in-buffer regex search (Q#RX1). `regex::bytes::Regex` +# matches over rope-snapshot bytes and yields byte offsets directly. +# Already in the lockfile transitively; promoted to a direct dependency. +regex = "1" # Work-stealing deque, MPMC channels, and parking primitives for the # M3 worker pool (spec §6.3). The umbrella crate re-exports # `crossbeam_deque`, `crossbeam_channel`, and `crossbeam_utils`. diff --git a/src/search.rs b/src/search.rs index eacb5be..5e974bc 100644 --- a/src/search.rs +++ b/src/search.rs @@ -228,6 +228,51 @@ pub fn find_all(haystack: &[u8], query: &str) -> Vec { out } +/// Smart-case regex search over `haystack` bytes for `pattern` (Q#RX1). +/// +/// Returns `Some(matches)` — leftmost, non-overlapping, ascending byte +/// ranges — for a valid pattern (possibly empty), or `None` when +/// `pattern` fails to compile. The `Option` lets the caller tell an +/// *invalid* pattern (show `[invalid]`) apart from a valid one with no +/// matches (a failing search), which a flat `Vec` could not. +/// +/// **Smart-case (Q#RX2).** Case-insensitive unless `pattern` contains +/// an uppercase letter, applied by compiling `(?i)` ahead of the +/// pattern. An uppercase letter inside an escape or class (`\D`, +/// `[A-Z]`) trips case-sensitivity — the same coarse rule the literal +/// path uses. +/// +/// **Multi-line (Q#RX1)** falls out for free: the regex runs over the +/// whole byte slice, so an explicit `\n` (or `(?s).`) spans lines. `.` +/// keeps its default of not matching `\n`. +/// +/// **Zero-width matches** (`a*`, `^`, `$`, anchors) are filtered — they +/// wash nothing and would otherwise flood the match list. The `regex` +/// crate's linear-time engine makes a pathological pattern slow at +/// worst, never catastrophic. +#[must_use] +pub fn find_all_regex(haystack: &[u8], pattern: &str) -> Option> { + if pattern.is_empty() { + return Some(Vec::new()); + } + let case_insensitive = !pattern.chars().any(char::is_uppercase); + let re = if case_insensitive { + regex::bytes::Regex::new(&format!("(?i){pattern}")) + } else { + regex::bytes::Regex::new(pattern) + } + .ok()?; + let matches = re + .find_iter(haystack) + .filter(|m| m.end() > m.start()) + .map(|m| ByteRange { + start: m.start() as u64, + end: m.end() as u64, + }) + .collect(); + Some(matches) +} + // --------------------------------------------------------------------------- // TUI view // --------------------------------------------------------------------------- @@ -401,6 +446,59 @@ mod tests { assert!(find_all(b"", "x").is_empty()); } + // ---- regex matcher (Q#RX1) ----------------------------------------- + + #[test] + fn find_all_regex_matches_pattern() { + // \d+ over "a1 bb 23 c" → "1" and "23". + assert_eq!( + find_all_regex(b"a1 bb 23 c", r"\d+"), + Some(vec![r(1, 2), r(6, 8)]) + ); + } + + #[test] + fn find_all_regex_is_smart_case() { + // Lowercase pattern folds case (matches "Foo", "foo", "FOO"). + assert_eq!( + find_all_regex(b"Foo foo FOO", "foo"), + Some(vec![r(0, 3), r(4, 7), r(8, 11)]) + ); + // An uppercase letter in the pattern makes it case-sensitive. + assert_eq!(find_all_regex(b"Foo foo FOO", "Foo"), Some(vec![r(0, 3)])); + } + + #[test] + fn find_all_regex_spans_newlines() { + // An explicit \n in the pattern matches across the line break. + assert_eq!( + find_all_regex(b"foo\n bar", r"foo\n\s*bar"), + Some(vec![r(0, 9)]) + ); + // Plain `.` does NOT cross the newline (default, not dotall). + assert_eq!(find_all_regex(b"a\nb", "a.b"), Some(vec![])); + // ...but `(?s)` opts into dotall. + assert_eq!(find_all_regex(b"a\nb", "(?s)a.b"), Some(vec![r(0, 3)])); + } + + #[test] + fn find_all_regex_invalid_pattern_is_none() { + // Unbalanced group — the incremental-typing case (`foo(`). + assert_eq!(find_all_regex(b"foo(", "foo("), None); + // A valid pattern with zero matches is Some(empty), distinct + // from invalid. + assert_eq!(find_all_regex(b"abc", "zzz"), Some(vec![])); + } + + #[test] + fn find_all_regex_filters_zero_width_and_empty() { + // `a*` matches empty at non-'a' positions; only the non-empty + // runs survive the zero-width filter. + assert_eq!(find_all_regex(b"baab", "a*"), Some(vec![r(1, 3)])); + // An empty pattern yields no matches (not one-per-position). + assert_eq!(find_all_regex(b"abc", ""), Some(vec![])); + } + #[test] fn store_set_clamps_active_and_clears_on_empty() { let mut s = SearchStore::new(); From 7723f51f12ab27bfd2d632d0b7b14d50762ddb6f Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 27 Jun 2026 14:46:33 -0400 Subject: [PATCH 3/5] regex-search: TUI regex mode + multi-line wash (Q#RX3/RX4/RX5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires regex matching into the search session and the terminal frontend. SearchSession gains `regex` and `invalid` flags. search_begin takes a `regex` argument; recompute dispatches find_all_regex (regex) vs find_all (literal), recording `invalid` when the pattern won't compile (an invalid pattern clears the matches and shows [invalid] rather than a stale count). search_toggle_regex flips the mode and re-runs the current query. Input: C-M-s / C-M-r start a regex search (search.forward-regex / search.backward-regex commands → ed.search_start(forward, regex)). M-r toggles literal <-> regex mid-search — a new SearchKey decoded in dispatch_search_key, so it works the same in both frontends (the GUI already round-trips every key while searching). The TUI prompt reads "Regex I-search:" in regex mode and "[invalid]" when the pattern won't compile. Multi-line: SearchView now washes each row a match spans, mirroring paint_local_selection's per-row clip (newline excluded so a spanning match doesn't paint a phantom trailing cell). Single-line matches — every literal match — touch exactly one row, unchanged. The GPU already fans multi-line ranges per-line, so it needs no change here. Tests: regex match / smart-case / invalid-flags-and-recovers / toggle-reinterprets-query (core); C-M-s starts regex + M-r toggles mid-search (dispatch); multi-line per-row wash (SearchView render). Co-Authored-By: Claude Opus 4.8 (1M context) --- builtin/commands/default.lua | 18 +++-- builtin/keymaps/default.lua | 4 + src/editor.rs | 66 ++++++++++++++-- src/editor_core.rs | 126 +++++++++++++++++++++++++---- src/lua_bindings.rs | 9 ++- src/search.rs | 148 +++++++++++++++++++++++++++-------- 6 files changed, 306 insertions(+), 65 deletions(-) diff --git a/builtin/commands/default.lua b/builtin/commands/default.lua index cb71f68..af31814 100644 --- a/builtin/commands/default.lua +++ b/builtin/commands/default.lua @@ -122,15 +122,23 @@ cmd { name = "buffer.self-insert", -- C-s / C-r begin a live in-buffer isearch: the match under the cursor -- highlights as you type, the same key steps to the next/previous -- match, RET accepts (keeping the highlights until the next edit), and --- C-g / Esc restore the pre-search cursor. While a search is running --- every keystroke is intercepted in Rust (dispatch_search_key), so --- these commands only run to *start* a search from an idle keymap. +-- C-g / Esc restore the pre-search cursor. C-M-s / C-M-r start a regex +-- search, and M-r toggles literal <-> regex mid-search (handled in +-- Rust). While a search is running every keystroke is intercepted in +-- Rust (dispatch_search_key), so these commands only run to *start* a +-- search from an idle keymap. ed.search_start(forward, regex). cmd { name = "search.forward", description = "Start an incremental search forward from the cursor.", - fn = function() ed.search_start(true) end } + fn = function() ed.search_start(true, false) end } cmd { name = "search.backward", description = "Start an incremental search backward from the cursor.", - fn = function() ed.search_start(false) end } + fn = function() ed.search_start(false, false) end } +cmd { name = "search.forward-regex", + description = "Start an incremental regex search forward from the cursor.", + fn = function() ed.search_start(true, true) end } +cmd { name = "search.backward-regex", + description = "Start an incremental regex search backward from the cursor.", + fn = function() ed.search_start(false, true) end } -- History -------------------------------------------------------------------- diff --git a/builtin/keymaps/default.lua b/builtin/keymaps/default.lua index 46fe6c4..18b9386 100644 --- a/builtin/keymaps/default.lua +++ b/builtin/keymaps/default.lua @@ -59,8 +59,12 @@ bind("TAB", "buffer.tab") -- adds isearch without colliding with the CUA / Emacs editing keys. -- Once a search is running, C-s / C-r step to the next / previous -- match; that interception happens in Rust, so it needs no binding. +-- C-M-s / C-M-r start a regex search (Emacs isearch-forward-regexp); +-- M-r toggles literal <-> regex mid-search (intercepted in Rust). bind("C-s", "search.forward") bind("C-r", "search.backward") +bind("C-M-s", "search.forward-regex") +bind("C-M-r", "search.backward-regex") -- CUA-style word-level deletion (the same shortcuts users expect from -- IDEs, browsers, terminals on Linux/Windows). C-BS deletes back to diff --git a/src/editor.rs b/src/editor.rs index 42be2cb..174db2e 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -641,6 +641,7 @@ impl EditorState { /// * `RET` --- accept (keep cursor + highlights). /// * `C-g` / `Esc` --- cancel (restore origin cursor). /// * `BS` --- shorten the query by one char. + /// * `M-r` --- toggle literal ↔ regex (Q#RX3). /// * a printable char --- extend the query. /// /// Unrecognized chords are swallowed (an active isearch eats every @@ -654,6 +655,7 @@ impl EditorState { SearchKey::Accept => self.core.borrow_mut().search_finish(true), SearchKey::Cancel => self.core.borrow_mut().search_finish(false), SearchKey::Backspace => self.core.borrow_mut().search_backspace(), + SearchKey::ToggleRegex => self.core.borrow_mut().search_toggle_regex(), SearchKey::Insert(ch) => self.core.borrow_mut().search_input_char(ch), SearchKey::Ignore => {} } @@ -1221,6 +1223,8 @@ enum SearchKey { Cancel, /// Shorten the query by one character (BS). Backspace, + /// Toggle literal ↔ regex matching (M-r; Q#RX3). + ToggleRegex, /// Extend the query with a printable character. Insert(char), /// Unhandled --- swallowed without complaint. @@ -1260,6 +1264,14 @@ impl SearchKey { _ => Self::Ignore, }; } + // M-r toggles regex mode (Q#RX3). Alt-only chord, distinct from + // the C-r (previous-match) above. + if alt + && !ctrl + && let KeyCode::Char('r') = chord.code + { + return Self::ToggleRegex; + } Self::Ignore } } @@ -1726,24 +1738,28 @@ fn paint_minibuffer( /// Paint the incremental-search prompt on the bottom row: /// `I-search: (n/m)`. Backward searches read `I-search -/// backward:`; a non-empty query with no matches reads `[no match]`. -/// Overwrites the status line painted just before it. The terminal -/// cursor is *not* returned here — it stays in the buffer at the -/// active match (see [`paint_frame`]). +/// backward:`; regex searches prefix `Regex `; a non-empty query with +/// no matches reads `[no match]`, and an uncompilable regex reads +/// `[invalid]`. Overwrites the status line painted just before it. The +/// terminal cursor is *not* returned here — it stays in the buffer at +/// the active match (see [`paint_frame`]). fn paint_search_prompt( grid: &mut crate::cell::CellGrid<'_>, core: &EditorCore, term_size: crate::cell::CellSize, ) { - let prompt = if core.search_forward() { - "I-search: " - } else { - "I-search backward: " + let prompt = match (core.search_is_regex(), core.search_forward()) { + (false, true) => "I-search: ", + (false, false) => "I-search backward: ", + (true, true) => "Regex I-search: ", + (true, false) => "Regex I-search backward: ", }; let query = core.search_query(); let (active, total) = core.search_match_summary(); let suffix = if query.is_empty() { String::new() + } else if core.search_is_invalid() { + " [invalid]".to_string() } else if total == 0 { " [no match]".to_string() } else { @@ -2106,6 +2122,40 @@ mod tests { assert_eq!(s.core.borrow().search_query(), "foo"); } + #[test] + fn regex_isearch_via_dispatch_c_m_s() { + let mut s = fresh_with(b"a1 b2 c3"); + s.core.borrow_mut().active_window_mut().cursor = 0; + // C-M-s starts a regex search (search.forward-regex). + s.dispatch_key( + FrontendId::LOCAL, + key( + KeyCode::Char('s'), + KeyModifiers::CONTROL | KeyModifiers::ALT, + ), + ); + assert!(s.core.borrow().search_active()); + assert!(s.core.borrow().search_is_regex()); + type_chars(&mut s, r"\d"); + assert_eq!(s.core.borrow().search_match_summary().1, 3); + } + + #[test] + fn m_r_toggles_regex_mid_search() { + let mut s = fresh_with(b"a.b axb"); + s.core.borrow_mut().active_window_mut().cursor = 0; + s.dispatch_key(FrontendId::LOCAL, ctrl('s')); // literal + type_chars(&mut s, "a.b"); + assert_eq!(s.core.borrow().search_match_summary().1, 1); + // M-r toggles to regex (intercepted in dispatch_search_key). + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Char('r'), KeyModifiers::ALT), + ); + assert!(s.core.borrow().search_is_regex()); + assert_eq!(s.core.borrow().search_match_summary().1, 2); + } + #[test] fn isearch_accumulates_across_renders_like_run_loop() { // Reproduce the real run loop: a render between every keystroke diff --git a/src/editor_core.rs b/src/editor_core.rs index 9c0feb2..c289158 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -78,6 +78,14 @@ pub struct SearchSession { /// Drives the prompt label ("I-search" vs "I-search backward") /// and the wrap direction of an empty-query repeat. forward: bool, + /// Whether the query is a regex (Q#RX3). `false` = smart-case + /// substring (`find_all`); `true` = smart-case regex + /// (`find_all_regex`). Toggled live by `M-r`. + regex: bool, + /// `true` when the last recompute's regex pattern failed to compile + /// — the prompt shows `[invalid]` instead of a match count. Always + /// `false` in literal mode (substring never "fails to compile"). + invalid: bool, } /// The world state mutated by editor commands. @@ -536,6 +544,21 @@ impl EditorCore { self.search.as_ref().is_none_or(|s| s.forward) } + /// `true` iff the active search is in regex mode (Q#RX3). `false` + /// for literal substring, or when no search is running. + #[must_use] + pub fn search_is_regex(&self) -> bool { + self.search.as_ref().is_some_and(|s| s.regex) + } + + /// `true` iff the active regex search's pattern failed to compile — + /// the prompt shows `[invalid]` rather than a match count. Always + /// `false` in literal mode / when no search is running. + #[must_use] + pub fn search_is_invalid(&self) -> bool { + self.search.as_ref().is_some_and(|s| s.invalid) + } + /// `(active_index, total)` for the active buffer's matches, for the /// prompt's "n/m" readout. `active_index` is 0-based and `None` /// when there are no matches. @@ -552,11 +575,12 @@ impl EditorCore { } /// Begin an incremental search anchored at the active buffer + - /// cursor. `forward` sets the initial step direction. A no-op if a - /// search is already running (the entry chord is intercepted while - /// active, so this is only reached from an inactive state — the - /// guard is belt-and-suspenders). - pub fn search_begin(&mut self, forward: bool) { + /// cursor. `forward` sets the initial step direction; `regex` + /// selects regex (`true`) vs literal substring (`false`) matching. + /// A no-op if a search is already running (the entry chord is + /// intercepted while active, so this is only reached from an + /// inactive state — the guard is belt-and-suspenders). + pub fn search_begin(&mut self, forward: bool, regex: bool) { if self.search.is_some() { return; } @@ -571,9 +595,22 @@ impl EditorCore { query: String::new(), origin, forward, + regex, + invalid: false, }); } + /// Toggle the active search between literal and regex matching + /// (Q#RX3, `M-r`), re-running the current query in the new mode. A + /// no-op when no search is running. + pub fn search_toggle_regex(&mut self) { + let Some(session) = self.search.as_mut() else { + return; + }; + session.regex = !session.regex; + self.search_recompute(); + } + /// Ensure the active window carries a [`crate::search::SearchView`] /// overlay, attaching one if absent (deduped by overlay kind). The /// view reads the per-buffer [`Self::search_store`] keyed on the @@ -617,8 +654,23 @@ impl EditorCore { let bid = session.origin.0; let origin_byte = session.origin.1; let query = session.query.clone(); + let regex = session.regex; let bytes = self.buffer_bytes(bid); - let matches = crate::search::find_all(&bytes, &query); + // Regex: `None` ⇒ the pattern won't compile (mark invalid, drop + // matches). Literal substring never fails. An invalid pattern + // clears the store (no stale matches paint) and shows + // `[invalid]` via the prompt. + let (matches, invalid) = if regex { + match crate::search::find_all_regex(&bytes, &query) { + Some(m) => (m, false), + None => (Vec::new(), true), + } + } else { + (crate::search::find_all(&bytes, &query), false) + }; + if let Some(session) = self.search.as_mut() { + session.invalid = invalid; + } let focus = { let mut guard = self .search_store @@ -2422,7 +2474,7 @@ mod tests { let mut s = from_bytes(b"foo bar foo baz foo"); let bid = s.active_buffer_id(); s.active_window_mut().cursor = 0; - s.search_begin(true); + s.search_begin(true, false); assert!(s.search_active()); type_query(&mut s, "foo"); // Three matches: 0..3, 8..11, 16..19; first (at/after origin 0) @@ -2438,7 +2490,7 @@ mod tests { fn search_step_walks_matches_and_wraps() { let mut s = from_bytes(b"foo bar foo baz foo"); s.active_window_mut().cursor = 0; - s.search_begin(true); + s.search_begin(true, false); type_query(&mut s, "foo"); assert_eq!(s.cursor(), 0); s.search_step(true); @@ -2455,7 +2507,7 @@ mod tests { fn search_focuses_first_match_at_or_after_origin() { let mut s = from_bytes(b"foo bar foo"); s.active_window_mut().cursor = 5; // inside "bar" - s.search_begin(true); + s.search_begin(true, false); type_query(&mut s, "foo"); // First match with start >= 5 is the one at byte 8. assert_eq!(s.cursor(), 8); @@ -2467,7 +2519,7 @@ mod tests { let mut s = from_bytes(b"foo bar foo"); let bid = s.active_buffer_id(); s.active_window_mut().cursor = 5; - s.search_begin(true); + s.search_begin(true, false); type_query(&mut s, "foo"); assert_eq!(s.cursor(), 8); s.search_finish(false); // cancel @@ -2488,7 +2540,7 @@ mod tests { let mut s = from_bytes(b"foo bar foo"); let bid = s.active_buffer_id(); s.active_window_mut().cursor = 0; - s.search_begin(true); + s.search_begin(true, false); type_query(&mut s, "foo"); s.search_step(true); // focus the match at byte 8 assert_eq!(s.cursor(), 8); @@ -2509,7 +2561,7 @@ mod tests { fn search_backspace_widens_the_match_set() { let mut s = from_bytes(b"fo foo food"); s.active_window_mut().cursor = 0; - s.search_begin(true); + s.search_begin(true, false); type_query(&mut s, "foo"); // matches "foo" at 3..6, 7..10 assert_eq!(s.search_match_summary().1, 2); s.search_backspace(); // query "fo" @@ -2521,7 +2573,7 @@ mod tests { fn search_smart_case_is_case_sensitive_with_uppercase() { let mut s = from_bytes(b"Foo foo FOO"); s.active_window_mut().cursor = 0; - s.search_begin(true); + s.search_begin(true, false); type_query(&mut s, "Foo"); // uppercase => case-sensitive assert_eq!(s.search_match_summary().1, 1); s.search_backspace(); @@ -2536,7 +2588,7 @@ mod tests { let mut s = from_bytes(b"foo foo"); let bid = s.active_buffer_id(); s.active_window_mut().cursor = 0; - s.search_begin(true); + s.search_begin(true, false); type_query(&mut s, "foo"); s.search_finish(true); // matches persist after accept assert!(!s.search_store.lock().expect("store").is_stale(bid)); @@ -2546,4 +2598,50 @@ mod tests { "an edit marks the buffer's matches stale (linger fix)" ); } + + // ---- regex search (Q#RX3) ------------------------------------------ + + #[test] + fn regex_search_matches_pattern() { + let mut s = from_bytes(b"a1 b2 c3"); + s.active_window_mut().cursor = 0; + s.search_begin(true, true); + assert!(s.search_is_regex()); + type_query(&mut s, r"\d"); + assert_eq!(s.search_match_summary().1, 3, "\\d matches 1, 2, 3"); + assert!(!s.search_is_invalid()); + } + + #[test] + fn regex_invalid_pattern_flags_and_recovers() { + let mut s = from_bytes(b"foo"); + s.active_window_mut().cursor = 0; + s.search_begin(true, true); + type_query(&mut s, "fo("); // unbalanced group mid-typing + assert!(s.search_is_invalid(), "incomplete group is invalid"); + assert_eq!(s.search_match_summary().1, 0, "invalid ⇒ no matches"); + type_query(&mut s, "o)"); // completes the group: regex fo(o) → "foo" + assert!(!s.search_is_invalid(), "valid pattern recovers"); + assert_eq!(s.search_match_summary().1, 1); + } + + #[test] + fn toggle_regex_reinterprets_the_query() { + let mut s = from_bytes(b"a.b axb"); + s.active_window_mut().cursor = 0; + s.search_begin(true, false); // literal + type_query(&mut s, "a.b"); + assert!(!s.search_is_regex()); + assert_eq!( + s.search_match_summary().1, + 1, + "literal '.' matches only a.b" + ); + s.search_toggle_regex(); // → regex + assert!(s.search_is_regex()); + assert_eq!(s.search_match_summary().1, 2, "regex '.' also matches axb"); + s.search_toggle_regex(); // back to literal + assert!(!s.search_is_regex()); + assert_eq!(s.search_match_summary().1, 1); + } } diff --git a/src/lua_bindings.rs b/src/lua_bindings.rs index a834a23..7cee70e 100644 --- a/src/lua_bindings.rs +++ b/src/lua_bindings.rs @@ -11439,13 +11439,14 @@ fn install_history(editor: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result /// post-accept navigation commands can begin / step a search from Lua. fn install_search(editor: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result<()> { { - // search_start(forward): begin an isearch in the given - // direction, anchored at the active buffer + cursor. + // search_start(forward, regex): begin an isearch in the given + // direction, anchored at the active buffer + cursor. `regex` + // selects regex vs literal substring matching (Q#RX3). let cc = core.clone(); editor.set( "search_start", - lua.create_function(move |_, forward: bool| { - cc.borrow_mut().search_begin(forward); + lua.create_function(move |_, (forward, regex): (bool, bool)| { + cc.borrow_mut().search_begin(forward, regex); Ok(()) })?, )?; diff --git a/src/search.rs b/src/search.rs index 5e974bc..2fbffa7 100644 --- a/src/search.rs +++ b/src/search.rs @@ -363,45 +363,65 @@ impl View for SearchView { let cell_origin = viewport.cell_origin; for m in &matches { - let line = crate::diag::line_at_offset(&line_offsets, m.start as u32); - if line < start_line_buf { - continue; - } - let row_offset = line - start_line_buf; - if row_offset >= max_rows { - break; - } - let line_start = line_offsets[line as usize]; - let line_end = line_offsets - .get(line as usize + 1) - .copied() - .unwrap_or(source.len() as u32); - let line_end_no_nl = if line_end > line_start - && source.get(line_end as usize - 1).copied() == Some(b'\n') - { - line_end - 1 - } else { - line_end - }; - let line_bytes = &source[line_start as usize..line_end_no_nl as usize]; - let within_start = (m.start as u32).saturating_sub(line_start) as usize; - let within_end = (m.end as u32).saturating_sub(line_start) as usize; - let (start_col, end_col) = - crate::diag::byte_range_to_display_cols(line_bytes, within_start, within_end); - if end_col <= start_col { - continue; - } let style = if Some(*m) == active { active_match_style() } else { match_style() }; - let cell_row = cell_origin.row + row_offset; - let clamped_start = start_col.min(max_cols); - let clamped_end = end_col.min(max_cols); - for col in clamped_start..clamped_end { - let cell = cells.at(CellCoord::new(cell_row, cell_origin.col + col)); - cell.style = merge_styles(cell.style, style); + // A regex match may span multiple lines (Q#RX4); wash each + // row's clipped slice, mirroring the selection renderer. + // Single-line matches (every literal match) touch one row. + let first_line = crate::diag::line_at_offset(&line_offsets, m.start as u32); + // Matches are sorted ascending, so once one starts below the + // viewport every later one does too — stop. + if first_line >= start_line_buf.saturating_add(max_rows) { + break; + } + let last_byte = m.end.saturating_sub(1).max(m.start) as u32; + let last_line = crate::diag::line_at_offset(&line_offsets, last_byte); + for line in first_line..=last_line { + if line < start_line_buf { + continue; + } + let row_offset = line - start_line_buf; + if row_offset >= max_rows { + break; + } + let line_start = line_offsets[line as usize]; + let line_end = line_offsets + .get(line as usize + 1) + .copied() + .unwrap_or(source.len() as u32); + let line_end_no_nl = if line_end > line_start + && source.get(line_end as usize - 1).copied() == Some(b'\n') + { + line_end - 1 + } else { + line_end + }; + // Clip the match to this line's content (newline excluded + // so a multi-line match doesn't wash a phantom trailing + // cell). + let paint_start = (m.start as u32).max(line_start); + let paint_end = (m.end as u32).min(line_end_no_nl); + if paint_start >= paint_end { + continue; + } + let line_bytes = &source[line_start as usize..line_end_no_nl as usize]; + let within_start = (paint_start - line_start) as usize; + let within_end = (paint_end - line_start) as usize; + let (start_col, end_col) = + crate::diag::byte_range_to_display_cols(line_bytes, within_start, within_end); + if end_col <= start_col { + continue; + } + let cell_row = cell_origin.row + row_offset; + let clamped_start = start_col.min(max_cols); + let clamped_end = end_col.min(max_cols); + for col in clamped_start..clamped_end { + let cell = cells.at(CellCoord::new(cell_row, cell_origin.col + col)); + cell.style = merge_styles(cell.style, style); + } } } } @@ -607,6 +627,66 @@ mod tests { ); } + #[test] + fn search_view_washes_a_multiline_match_per_row() { + use crate::cell::{Cell, CellGrid, CellSize}; + use crate::view::Viewport; + + let store = make_shared_store(); + let bid = BufferId::next(); + let mut buf = Buffer::new(bid, "t.txt"); + // "foo\nbar\nbaz": match [0,7) = "foo\nbar" spans lines 0–1. + buf.apply_edit(crate::buffer::EditOp::Insert { + pos: 0, + bytes: b"foo\nbar\nbaz", + }) + .expect("seed"); + store.lock().unwrap().set(bid, r"foo\nbar", vec![r(0, 7)]); + + let (rows, cols) = (3u32, 10u32); + let mut backing = vec![Cell::default(); (rows * cols) as usize]; + let mut grid = CellGrid { + cells: &mut backing, + stride: cols, + size: CellSize::new(rows, cols), + }; + SearchView::new(store.clone()).render( + &buf, + Viewport { + buffer_start: 0, + buffer_end: buf.len(), + cell_origin: CellCoord::new(0, 0), + cell_size: CellSize::new(rows, cols), + }, + &mut grid, + ); + + // Row 0 "foo" and row 1 "bar" both wash (the active match's + // bright color); the newline cells and row 2 "baz" do not. + for col in 0..3 { + assert_eq!( + grid.get(CellCoord::new(0, col)).style.bg, + Color::Indexed(11), + "row 0 col {col} should wash" + ); + assert_eq!( + grid.get(CellCoord::new(1, col)).style.bg, + Color::Indexed(11), + "row 1 col {col} should wash" + ); + } + assert_eq!( + grid.get(CellCoord::new(0, 3)).style.bg, + Color::Default, + "the newline cell past 'foo' is not washed" + ); + assert_eq!( + grid.get(CellCoord::new(2, 0)).style.bg, + Color::Default, + "row 2 'baz' is outside the match" + ); + } + #[test] fn store_staleness_tracks_per_buffer() { let mut s = SearchStore::new(); From 6e47fb47257921c87d616c2bdf31a1fb16ca6377 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 27 Jun 2026 14:46:43 -0400 Subject: [PATCH 4/5] regex-search: GUI regex prompt + protocol v10 (Q#RX5/RX6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carries regex mode to the GUI status band and lets the GUI start a regex search. SearchPrompt gains `regex` + `invalid` (protocol v10; SUPPORTED grows to [6,7,8,9,10]). The fields changed that variant's encoding, so the daemon's per-session gate moves from >= 9 to >= 10 — a v9 peer negotiates v9 and is simply sent no SearchPrompt (the decorations still highlight) rather than mis-decoding the wider shape. The producer fills both from the active SearchSession. GUI: `is_search_entry_chord` also forwards C-M-s / C-M-r (Ctrl+Alt) so a regex search can start; M-r (the toggle) already round-trips via the intercept path once a search runs. The status band reads `Regex I-search:` in regex mode and `[invalid]` when the pattern won't compile. Multi-line regex matches needed no GUI change — push_glyph_extent_rects already fans a byte range across lines. Tests: SearchPrompt postcard round-trip extended to regex/invalid shapes; protocol version pin 9→10 + ladder grows to v10; GUI entry chord accepts C-s/C-r and C-M-s/C-M-r. (last_search_prompt's 5-tuple factored into a SearchPromptFacts alias to satisfy type_complexity.) Co-Authored-By: Claude Opus 4.8 (1M context) --- pmacs-gpu/src/main.rs | 55 +++++++++++++++++++++++------------ pmacs-protocol/src/message.rs | 30 +++++++++++++++---- src/daemon.rs | 8 +++-- src/protocol.rs | 41 ++++++++++++++++---------- src/semantic_render.rs | 31 +++++++++++--------- 5 files changed, 111 insertions(+), 54 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 35abe0a..41edc39 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -551,14 +551,16 @@ struct StatusFactsLocal { diag_warnings: u32, } -/// The live incremental-search prompt (Q#SR5, protocol v9), mirrored -/// from a `SearchPrompt` message whose `query` was `Some`. +/// The live incremental-search prompt (Q#SR5/Q#RX6, protocol v10), +/// mirrored from a `SearchPrompt` message whose `query` was `Some`. #[derive(Clone, Debug, PartialEq, Eq)] struct SearchPromptLocal { buffer_id: BufferId, query: String, active: Option, total: u32, + regex: bool, + invalid: bool, } /// pmacs-gpu's own cursor position, mirrored from `CursorByte`. @@ -2097,23 +2099,27 @@ impl State { self.window.request_redraw(); None } - // Q#SR5 — the live isearch prompt (protocol v9). `query: - // None` clears the band (search ended); `Some` shows - // `I-search: (n/m)` on the band's left side. The - // matches themselves arrive as SearchMatch decorations and - // the keys round-trip via the DispatchIdle gate, so this + // Q#SR5 / Q#RX6 — the live isearch prompt (protocol v10). + // `query: None` clears the band (search ended); `Some` shows + // `[Regex] I-search: (n/m)` on the band's left side. + // The matches themselves arrive as SearchMatch decorations + // and the keys round-trip via the intercept gate, so this // handler only drives the prompt text. InstanceMessage::SearchPrompt { buffer_id, query, active, total, + regex, + invalid, } => { self.search_prompt = query.map(|q| SearchPromptLocal { buffer_id, query: q, active, total, + regex, + invalid, }); self.window.request_redraw(); None @@ -2628,14 +2634,21 @@ impl State { .as_ref() .filter(|s| Some(s.buffer_id) == self.current_buffer_id) { + let label = if sp.regex { + "Regex I-search: " + } else { + "I-search: " + }; let count = if sp.query.is_empty() { String::new() + } else if sp.invalid { + " [invalid]".to_string() } else if sp.total == 0 { " [no match]".to_string() } else { format!(" ({}/{})", sp.active.map_or(0, |a| a + 1), sp.total) }; - return format!("I-search: {}{}", sp.query, count); + return format!("{}{}{}", label, sp.query, count); } match self .status_facts @@ -4126,12 +4139,14 @@ fn should_forward_key(key: ProtocolKey, mods: Modifiers) -> bool { ) } -/// `C-s` / `C-r` — the chords that begin an incremental search -/// (Q#SR5). Forwarded even when idle (they are otherwise withheld as -/// command chords by [`should_forward_key`]) so a search can start; -/// once it is running every key round-trips via the intercept path. +/// The chords that begin an incremental search: `C-s` / `C-r` (literal) +/// and `C-M-s` / `C-M-r` (regex, Q#RX5). Forwarded even when idle (they +/// are otherwise withheld as command chords by [`should_forward_key`]) +/// so a search can start; once it is running every key round-trips via +/// the intercept path (including `M-r`, the regex toggle). fn is_search_entry_chord(key: ProtocolKey, mods: Modifiers) -> bool { - mods == Modifiers::CTRL && matches!(key, ProtocolKey::Char('s' | 'r')) + matches!(key, ProtocolKey::Char('s' | 'r')) + && (mods == Modifiers::CTRL || mods == Modifiers::CTRL | Modifiers::ALT) } fn is_plain_text_modifiers(mods: Modifiers) -> bool { @@ -5175,9 +5190,11 @@ mod tests { } #[test] - fn search_entry_chord_is_ctrl_s_or_ctrl_r_only() { - // C-s / C-r start a search (Q#SR5) — forwarded even though - // `should_forward_key` withholds them as command chords. + fn search_entry_chord_is_ctrl_or_ctrl_alt_s_r() { + // C-s / C-r (literal) and C-M-s / C-M-r (regex, Q#RX5) start a + // search — forwarded even though `should_forward_key` withholds + // them as command chords. + let ctrl_alt = Modifiers::CTRL | Modifiers::ALT; assert!(is_search_entry_chord( ProtocolKey::Char('s'), Modifiers::CTRL @@ -5186,11 +5203,13 @@ mod tests { ProtocolKey::Char('r'), Modifiers::CTRL )); + assert!(is_search_entry_chord(ProtocolKey::Char('s'), ctrl_alt)); + assert!(is_search_entry_chord(ProtocolKey::Char('r'), ctrl_alt)); assert!( !should_forward_key(ProtocolKey::Char('s'), Modifiers::CTRL), "C-s is otherwise a withheld chord; the search-entry path is what forwards it" ); - // Other Ctrl chords, and C-s without Ctrl, are not entry chords. + // Other Ctrl chords, and s/r without Ctrl, are not entry chords. assert!(!is_search_entry_chord( ProtocolKey::Char('x'), Modifiers::CTRL @@ -5201,7 +5220,7 @@ mod tests { )); assert!(!is_search_entry_chord( ProtocolKey::Char('s'), - Modifiers::CTRL | Modifiers::ALT + Modifiers::ALT )); } diff --git a/pmacs-protocol/src/message.rs b/pmacs-protocol/src/message.rs index 1504f54..e153eec 100644 --- a/pmacs-protocol/src/message.rs +++ b/pmacs-protocol/src/message.rs @@ -823,9 +823,11 @@ pub enum InstanceMessage { /// `None` means no search is running — the frontend hides the band. /// /// Emitted by the semantic producer when the search state changes - /// (cached-compare suppressed, like [`Self::StatusFacts`]). Kept - /// off wires negotiated `< 9` by the daemon's per-session filter - /// (additive variant — an older peer would hard-error decoding it). + /// (cached-compare suppressed, like [`Self::StatusFacts`]). The + /// `regex` / `invalid` fields (Q#RX6) changed this variant's + /// encoding, so the daemon's per-session filter now keeps it off + /// wires negotiated `< 10` (was `< 9` for the original four-field + /// shape — see `PROTOCOL_VERSION`). SearchPrompt { /// Buffer the search is anchored in (the active buffer). buffer_id: crate::BufferId, @@ -837,6 +839,13 @@ pub enum InstanceMessage { active: Option, /// Total number of matches for the current query. total: u32, + /// `true` when the search is in regex mode (Q#RX3) — the + /// frontend prefixes the prompt with `Regex `. + regex: bool, + /// `true` when a regex pattern failed to compile — the frontend + /// shows `[invalid]` instead of a match count. Always `false` + /// in literal mode. + invalid: bool, }, } @@ -1098,7 +1107,13 @@ pub enum ResourceBody { /// Q#SR5 (incremental search): bumped from 8 to 9 for /// [`InstanceMessage::SearchPrompt`]. Additive and daemon-gated per /// session, identical shape to the `StatusFacts` (v8) bump. -pub const PROTOCOL_VERSION: u32 = 9; +/// +/// Q#RX6 (regex search): bumped from 9 to 10 — `SearchPrompt` gained +/// `regex` / `invalid` fields, changing that variant's postcard +/// encoding. Still daemon-gated per session (now at `< 10`); a v9 peer +/// negotiates v9 and simply receives no `SearchPrompt` (the decorations +/// still highlight), rather than mis-decoding the wider shape. +pub const PROTOCOL_VERSION: u32 = 10; /// T M10.5: the set of protocol versions a v1.0 binary accepts on /// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept @@ -1141,7 +1156,12 @@ pub const PROTOCOL_VERSION: u32 = 9; /// /// Q#SR5: extended to `[6, 7, 8, 9]`. `InstanceMessage::SearchPrompt` /// is additive and daemon-gated per session. -pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8, 9]; +/// +/// Q#RX6: extended to `[6, 7, 8, 9, 10]`. `SearchPrompt` gained +/// `regex` / `invalid` (encoding change to that variant); v9 and v10 +/// interoperate because the variant is daemon-gated per session, so a +/// v9 peer is simply never sent the wider shape. +pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8, 9, 10]; /// T M10.5: predicate for the handshake check. Returns `true` if /// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`]. diff --git a/src/daemon.rs b/src/daemon.rs index 84b2c2a..1e6513f 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1021,11 +1021,13 @@ fn dispatcher_loop( let peer_knows_status_facts = session_registry .session_state(*fid) .is_some_and(|s| s.negotiated_protocol_version >= 8); - // Q#SR5 — `SearchPrompt` is a v9 variant; gate it the - // same way so an < 9 peer never sees the new shape. + // Q#SR5 / Q#RX6 — `SearchPrompt` gained regex/invalid + // fields in v10 (encoding change); gate at >= 10 so a v9 + // peer is sent no SearchPrompt rather than the wider + // shape it would mis-decode. let peer_knows_search_prompt = session_registry .session_state(*fid) - .is_some_and(|s| s.negotiated_protocol_version >= 9); + .is_some_and(|s| s.negotiated_protocol_version >= 10); for msg in &messages { if !peer_knows_status_facts && matches!(msg, InstanceMessage::StatusFacts { .. }) diff --git a/src/protocol.rs b/src/protocol.rs index 7d1a653..52a579d 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -1683,7 +1683,7 @@ mod tests { // --- M5.5a handshake & postcard round-trips --- #[test] - fn protocol_version_is_nine_for_search_prompt() { + fn protocol_version_is_ten_for_regex_search_prompt() { // Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp / // PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the // SemanticFrame family + FrontendEvent::Viewport). T M11.6 @@ -1696,7 +1696,9 @@ mod tests { // Q#S1 bumped 7→8 (`InstanceMessage::StatusFacts`, additive // + daemon-gated per session). Q#SR5 bumped 8→9 // (`InstanceMessage::SearchPrompt`, additive + daemon-gated). - assert_eq!(PROTOCOL_VERSION, 9); + // Q#RX6 bumped 9→10 (`SearchPrompt` gained regex/invalid; + // encoding change to that variant, still daemon-gated). + assert_eq!(PROTOCOL_VERSION, 10); } #[test] @@ -1705,18 +1707,19 @@ mod tests { // every cell-carrying message, ending the v1–v5 ladder — // pre-v6 peers are refused at the handshake (a clean // VersionMismatch) rather than garbling postcard mid-session. - // Q#M4 / Q#S1 / Q#SR5: the ladder resumes above that floor — v7 - // (`TripleDown`, frontend-gated), v8 (`StatusFacts`) and v9 - // (`SearchPrompt`, both daemon-gated) are additive, so v6 - // through v9 interoperate. + // Q#M4 / Q#S1 / Q#SR5 / Q#RX6: the ladder resumes above that + // floor — v7 (`TripleDown`, frontend-gated), v8 (`StatusFacts`), + // v9 + v10 (`SearchPrompt` and its regex/invalid extension, both + // daemon-gated) interoperate, so v6 through v10 talk. assert!(is_supported_protocol_version(6)); assert!(is_supported_protocol_version(7)); assert!(is_supported_protocol_version(8)); assert!(is_supported_protocol_version(9)); - for rejected in [0, 1, 2, 3, 4, 5, 10, u32::MAX] { + assert!(is_supported_protocol_version(10)); + for rejected in [0, 1, 2, 3, 4, 5, 11, u32::MAX] { assert!( !is_supported_protocol_version(rejected), - "v{rejected} must be rejected by a v9 binary" + "v{rejected} must be rejected by a v10 binary" ); } } @@ -1882,20 +1885,24 @@ mod tests { #[test] fn search_prompt_round_trips_through_postcard() { - // Q#SR5 — the v9 wire variant. Cover the three shapes the - // producer emits: an active search with matches, a failing - // search (active=None), and a cleared band (query=None). + // Q#SR5 / Q#RX6 — the v10 wire variant. Cover the shapes the + // producer emits: an active literal search with matches, an + // invalid regex (regex=true, invalid=true, no matches), a regex + // with matches, and a cleared band (query=None). let cases = [ - (Some("foo".to_owned()), Some(2u32), 5u32), - (Some("zzz".to_owned()), None, 0u32), - (None, None, 0u32), + (Some("foo".to_owned()), Some(2u32), 5u32, false, false), + (Some("foo(".to_owned()), None, 0u32, true, true), + (Some(r"\d".to_owned()), Some(0u32), 3u32, true, false), + (None, None, 0u32, false, false), ]; - for (query, active, total) in cases { + for (query, active, total, regex, invalid) in cases { let msg = InstanceMessage::SearchPrompt { buffer_id: crate::buffer::BufferId::next(), query: query.clone(), active, total, + regex, + invalid, }; let bytes = postcard::to_allocvec(&msg).expect("encode"); let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode"); @@ -1904,11 +1911,15 @@ mod tests { query: q, active: a, total: t, + regex: rx, + invalid: inv, .. } => { assert_eq!(q, query); assert_eq!(a, active); assert_eq!(t, total); + assert_eq!(rx, regex); + assert_eq!(inv, invalid); } other => panic!("expected SearchPrompt, got {other:?}"), } diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 990baa5..d0116f1 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -76,6 +76,11 @@ struct LastFrame { generation: u64, } +/// Cached `SearchPrompt` payload for cached-compare suppression +/// (Q#SR5 / Q#RX6): `(query, active, total, regex, invalid)`. A `None` +/// query means the last emission cleared the band. +type SearchPromptFacts = (Option, Option, u32, bool, bool); + /// Owns one `semantic_render` session's projection state: the last /// viewport the frontend declared, and the diff baseline per buffer /// for the `StyleSpans` and `Decorations` families. @@ -121,10 +126,9 @@ pub struct SemanticRenderState { /// `(name, modified, diag_errors, diag_warnings)` last emitted as /// `StatusFacts` (Q#S1) — cached-compare suppression. last_status: HashMap, - /// `(query, active, total)` last emitted as `SearchPrompt` - /// (Q#SR5) — cached-compare suppression. A `None` query means the - /// last emission cleared the band (no active search). - last_search_prompt: HashMap, Option, u32)>, + /// Last emitted `SearchPrompt` payload per buffer, for + /// cached-compare suppression (see [`SearchPromptFacts`]). + last_search_prompt: HashMap, /// `StyleSpans` recompute gate (perf). `scoped_style_spans` runs /// the tree-sitter highlights query over the *whole declared /// viewport* (which the GPU frontend sets to the entire buffer) @@ -426,18 +430,17 @@ impl SemanticRenderState { Some(core.search_query().to_owned()), active_idx.and_then(|i| u32::try_from(i).ok()), u32::try_from(total).unwrap_or(u32::MAX), + core.search_is_regex(), + core.search_is_invalid(), ) } else { - // No search → a cleared band. active/total are zeroed so - // the inactive state is one canonical tuple (the GPU only - // reads them when `query` is `Some`). The accepted matches - // keep highlighting via Decorations regardless. - (None, None, 0) + // No search → a cleared band. active/total/regex/invalid + // are zeroed so the inactive state is one canonical tuple + // (the GPU only reads them when `query` is `Some`). The + // accepted matches keep highlighting via Decorations. + (None, None, 0, false, false) } }; - if self.last_search_prompt.get(&buffer_id) == Some(&facts) { - return None; - } let cached = self.last_search_prompt.get(&buffer_id); if cached == Some(&facts) { return None; @@ -456,6 +459,8 @@ impl SemanticRenderState { query: facts.0.clone(), active: facts.1, total: facts.2, + regex: facts.3, + invalid: facts.4, }; self.last_search_prompt.insert(buffer_id, facts); Some(msg) @@ -3096,7 +3101,7 @@ mod tests { // Begin + type "foo": the live query + active/total ship. { let mut core = state.core.borrow_mut(); - core.search_begin(true); + core.search_begin(true, false); for ch in "foo".chars() { core.search_input_char(ch); } From 6b7d3fb95d25d39171f4b8f3f810675298a82d20 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 27 Jun 2026 15:03:31 -0400 Subject: [PATCH 5/5] docs: consolidate in-buffer search framing (substring + regex) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folds incremental-search-framing.md (#70) and regex-search-framing.md into a single in-buffer-search-framing.md that records the design as shipped, and removes the two superseded docs. The incremental doc in particular described the C-f + minibuffer-hosted plan that never shipped (C-s/C-r + a dedicated core SearchSession did), which was actively misleading. The consolidated doc adds an "As-built divergences" section capturing the four places implementation departed from the framing passes: C-f → C-s/C-r (veto resolved), minibuffer-hosted → frontend-agnostic core mode (the GUI has no minibuffer), regex deferred → shipped, and single-line substring → multi-line regex wash. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/in-buffer-search-framing.md | 133 +++++++++++++++++++++++++++ docs/incremental-search-framing.md | 123 ------------------------- docs/regex-search-framing.md | 138 ----------------------------- 3 files changed, 133 insertions(+), 261 deletions(-) create mode 100644 docs/in-buffer-search-framing.md delete mode 100644 docs/incremental-search-framing.md delete mode 100644 docs/regex-search-framing.md diff --git a/docs/in-buffer-search-framing.md b/docs/in-buffer-search-framing.md new file mode 100644 index 0000000..a6127d0 --- /dev/null +++ b/docs/in-buffer-search-framing.md @@ -0,0 +1,133 @@ +# 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` (query + sorted `Vec` matches + active + index), shared `Arc`, 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_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 unless `query` has an uppercase + char. +- **`find_all_regex(haystack, pattern) -> Option>`** — + `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-regex` → `ed.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: + (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, active: Option, 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-f` → `C-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. diff --git a/docs/incremental-search-framing.md b/docs/incremental-search-framing.md deleted file mode 100644 index 58b0561..0000000 --- a/docs/incremental-search-framing.md +++ /dev/null @@ -1,123 +0,0 @@ -# Incremental in-buffer search — framing pass - -Date: 2026-06-15. The last deferred GUI item: `SearchMatch` / -`SearchMatchActive` decorations exist on the wire (message.rs) and are -gated to `None` in both frontends "waiting on a search feature." This -builds that feature. Decided up front (user): **incremental isearch** -(highlight live as you type, same key steps to next, Enter accepts, -Esc/C-g restores origin) with **smart-case substring** matching -(case-insensitive unless the query has an uppercase letter). - -## Survey facts (anchors) - -- Greenfield in-buffer search; only `project.search` (cross-file grep) - exists. No `Buffer::find` / rope search. -- `DiagnosticStore` (diag.rs) is a near-exact template: keyed - `Arc` store, sorted entries, `next_after`/`previous_before`, - stale tracking, Lua nav bindings, overlay `_attach_view`. -- Producer `scoped_decorations` (semantic_render.rs) + TUI - `DiagnosticView::render` are the emit/paint templates (viewport clip, - line-start cache, stale-skip). -- GPU `decoration_kind_to_bg_color` already draws bg decorations - through the quad pipeline; SearchMatch/Active just need their arms. -- The minibuffer (minibuffer.rs) is keystroke-driven and pseudo-modal - (dispatch_minibuffer_key intercepts all keys while active) but has - **no live-preview/on_changed hook** — the one missing piece for - incremental highlight. - -## Q#SR1 — store shape & ownership - -**Stance: a per-buffer `SearchStore` mirroring `DiagnosticStore`.** -`by_buffer: HashMap` where `SearchState` holds -the resolved query, the sorted `Vec` matches, and the -active index. Shared `Arc`. The active index lives on the store -(navigation state), not per-window — v1 accepts that two windows on -the same buffer share the active highlight (note it; selection is the -per-window concept, search mirrors diagnostics). Edits mark the -buffer's entry stale (M11.8 model) so matches at pre-edit byte -positions aren't painted until re-search. - -## Q#SR2 — search primitive - -**Stance: smart-case substring over a rope snapshot, regex deferred.** -`find_all(haystack, query) -> Vec`: case-insensitive unless -`query` contains an uppercase char (then exact). Built on -`snapshot_rope().slice` bytes (the diagnostics path's cheap snapshot). -Recomputed on query change; invalidated on edit. No `regex` crate in -v1 (literal-text is the 95% case; regex is a later toggle). Overlapping -matches: advance past each match's start+1 (standard non-overlapping). - -## Q#SR3 — decoration emission - -**Stance: mirror the diagnostics producer.** In `scoped_decorations`, -read the search store for the viewport buffer, emit `SearchMatch` for -every visible match and `SearchMatchActive` for the active one -(emitted last / higher z so it wins the overlap). Reuse the line cache -+ `clip_to_viewport`. Stale-skip exactly like diagnostics. TUI gets a -`SearchView` overlay (mirrors `DiagnosticView`) painting bg, attached -via `pmacs.search._attach_view`. - -## Q#SR4 — colors - -**Stance: a single search palette.** SearchMatch = translucent yellow -wash; SearchMatchActive = stronger amber/orange. GPU: the two -`decoration_kind_to_bg_color` arms. TUI: reverse-ish colored bg in the -`SearchView`. Distinct from selection (blue) and diagnostics -(severity). - -## Q#SR5 — input & modality (incremental) - -**Stance: host the query in the minibuffer + a small `on_changed` -hook + targeted next/prev interception.** Entry opens a minibuffer -search session (prompt `I-search: `); `on_changed` (new optional -session callback, fired after each content mutation in -dispatch_minibuffer_key) recomputes matches → updates the store → -re-decorate. While that session is active, the entry chord again = -`search.next`, its shift/`C-r` variant = `search.prev` (control keys, -not self-insert, so safe to intercept in the search branch of -dispatch_minibuffer_key). `Enter` accepts (close, leave cursor at the -active match); `Esc`/`C-g` cancels (close, restore the origin cursor -saved at entry, clear the store). Reusing the minibuffer's input -editing + prompt avoids reimplementing a modal query line. - -## Q#SR6 — navigation - -**Stance: `search.next`/`search.prev` mirror `diag.next/prev`.** -Advance the active index with wrap, move the active window's cursor to -the active match start, scroll it into view. The active index drives -which match is `SearchMatchActive`. Usable both during the live -session and afterward (matches persist until cleared / next search). - -## Binding (proposal, flagged for veto) - -`C-f` → search (CUA "Find"), rebound from `cursor.right`. Consistent -with the editor's CUA direction (arrows move; Ctrl+F finds); the -Emacs-holdover `C-f = forward-char` is the inconsistent one. Easy to -change — call out in validation. - -## Predicted findings (categorical bets) - -1. **Stale-after-edit linger** (the squiggle lesson again): matches at - pre-edit byte positions paint over shifted text until re-search — - the store's stale gate + re-search-on-change must be right, or - highlights drift during typing. -2. **Minibuffer `on_changed` × completion**: the hook interacts with - the existing per-keystroke candidate recompute; the search session - must opt out of completion cleanly (a session "kind" seam). -3. **Per-buffer active match across windows** surfaces as navigating - in one window moving the active highlight in another — accepted for - v1, but worth eyeballing. -4. **Empty / all-match queries**: empty query → no matches (not all); - a 1-char common letter → many matches → viewport-clipped emission - must stay cheap (line cache + only-visible). - -## Session plan - -Three green commits: -1. Core `SearchStore` + `find_all` smart-case primitive + unit tests. -2. Producer emission + GPU bg colors + TUI `SearchView` + attach. -3. Incremental UX: minibuffer `on_changed`, search session, - next/prev interception + commands, cancel-restores-origin, binding. - -Manual validation gate as usual (type to highlight live, step matches, -edit mid-search, Esc restores). diff --git a/docs/regex-search-framing.md b/docs/regex-search-framing.md deleted file mode 100644 index e4c00cb..0000000 --- a/docs/regex-search-framing.md +++ /dev/null @@ -1,138 +0,0 @@ -# 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).