regex-search: TUI regex mode + multi-line wash (Q#RX3/RX4/RX5)

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) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-06-27 14:46:33 -04:00
parent 5c34b3c37a
commit 7723f51f12
6 changed files with 306 additions and 65 deletions

View File

@ -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 --------------------------------------------------------------------

View File

@ -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

View File

@ -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: <query> (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

View File

@ -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);
}
}

View File

@ -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(())
})?,
)?;

View File

@ -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 01.
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();