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();