Skip to main content

twrite_core/
search.rs

1use std::ops::Range;
2
3use crate::{EditorBuffer, EditorError};
4
5/// A headless find query: literal substring or regex, with case and
6/// whole-word toggles. Compiled to a [`regex::Regex`] on use.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct SearchQuery {
9    /// The raw pattern text (literal or regex source).
10    pub pattern: String,
11    /// Whether matching is case-sensitive (default `true`).
12    pub case_sensitive: bool,
13    /// Whether matches must span whole words (default `false`).
14    pub whole_word: bool,
15    /// Whether `pattern` is a regex (default `false` = literal substring).
16    pub is_regex: bool,
17}
18
19impl SearchQuery {
20    /// Creates a case-sensitive literal substring query.
21    pub fn literal(pattern: &str) -> Self {
22        Self {
23            pattern: pattern.to_string(),
24            case_sensitive: true,
25            whole_word: false,
26            is_regex: false,
27        }
28    }
29
30    /// Creates a query with explicit options.
31    pub fn new(pattern: &str, case_sensitive: bool, whole_word: bool, is_regex: bool) -> Self {
32        Self {
33            pattern: pattern.to_string(),
34            case_sensitive,
35            whole_word,
36            is_regex,
37        }
38    }
39}
40
41impl Default for SearchQuery {
42    fn default() -> Self {
43        Self::literal("")
44    }
45}
46
47/// Compiles a [`SearchQuery`] into a [`regex::Regex`].
48///
49/// Literal patterns are escaped; `whole_word` wraps the core in
50/// `\b(?:...)\b`; case-insensitive queries gain a `(?i)` prefix.
51pub fn compile_query(query: &SearchQuery) -> Result<regex::Regex, EditorError> {
52    if query.pattern.is_empty() {
53        return Err(EditorError::EmptySearchPattern);
54    }
55    let mut source = if query.is_regex {
56        query.pattern.clone()
57    } else {
58        regex::escape(&query.pattern)
59    };
60    if query.whole_word {
61        source = format!(r"\b(?:{source})\b");
62    }
63    if !query.case_sensitive {
64        source = format!(r"(?i){source}");
65    }
66    regex::Regex::new(&source).map_err(|e| EditorError::InvalidRegex {
67        pattern: query.pattern.clone(),
68        message: e.to_string(),
69    })
70}
71
72/// Returns all match byte ranges for `query` in `buffer`, in ascending order.
73pub fn find_matches(
74    buffer: &EditorBuffer,
75    query: &SearchQuery,
76) -> Result<Vec<Range<usize>>, EditorError> {
77    let re = compile_query(query)?;
78    let text = buffer.text().to_string();
79    Ok(re
80        .find_iter(&text)
81        .map(|m| m.start()..m.end())
82        .filter(|r| buffer.is_char_boundary(r.start) && buffer.is_char_boundary(r.end))
83        .collect())
84}
85
86/// Returns the first match at or after `from_offset`, wrapping to the start
87/// when `wrap` is set. Returns `Ok(None)` when there are no matches.
88pub fn find_next(
89    buffer: &EditorBuffer,
90    query: &SearchQuery,
91    from_offset: usize,
92    wrap: bool,
93) -> Result<Option<Range<usize>>, EditorError> {
94    let matches = find_matches(buffer, query)?;
95    if matches.is_empty() {
96        return Ok(None);
97    }
98    let from = from_offset.min(buffer.len_bytes());
99    if let Some(m) = matches.iter().find(|m| m.start >= from) {
100        return Ok(Some(m.clone()));
101    }
102    if wrap {
103        Ok(matches.into_iter().next())
104    } else {
105        Ok(None)
106    }
107}
108
109/// Returns the last match at or before `from_offset`, wrapping to the end
110/// when `wrap` is set. Returns `Ok(None)` when there are no matches.
111pub fn find_prev(
112    buffer: &EditorBuffer,
113    query: &SearchQuery,
114    from_offset: usize,
115    wrap: bool,
116) -> Result<Option<Range<usize>>, EditorError> {
117    let matches = find_matches(buffer, query)?;
118    if matches.is_empty() {
119        return Ok(None);
120    }
121    let from = from_offset.min(buffer.len_bytes());
122    // Strictly-before so repeated `prev` calls walk backwards instead of
123    // re-reporting the match that starts exactly at the cursor.
124    if let Some(m) = matches.iter().rev().find(|m| m.start < from) {
125        return Ok(Some(m.clone()));
126    }
127    if wrap {
128        Ok(matches.into_iter().next_back())
129    } else {
130        Ok(None)
131    }
132}
133
134/// Version-cached search state for interactive find (prompt, vim `/`).
135///
136/// Call [`Self::refresh`] after edits or query changes; navigation is served
137/// from the cache without rescanning.
138#[derive(Debug, Clone, Default)]
139pub struct SearchState {
140    query: Option<SearchQuery>,
141    matches: Vec<Range<usize>>,
142    current: Option<usize>,
143    version: usize,
144}
145
146impl SearchState {
147    /// Creates an empty state with no query.
148    pub fn new() -> Self {
149        Self::default()
150    }
151
152    /// Sets the active query (clears matches until [`Self::refresh`]).
153    pub fn set_query(&mut self, query: SearchQuery) {
154        if self.query.as_ref() != Some(&query) {
155            self.query = Some(query);
156            self.matches.clear();
157            self.current = None;
158            // Sentinel: unreachable by real edits, forces the next
159            // `refresh` to rescan even when the document is unchanged.
160            self.version = usize::MAX;
161        }
162    }
163
164    /// Returns the active query, if any.
165    pub fn query(&self) -> Option<&SearchQuery> {
166        self.query.as_ref()
167    }
168
169    /// Re-scans `buffer` when the document version or query changed.
170    pub fn refresh(&mut self, buffer: &EditorBuffer) -> Result<(), EditorError> {
171        if self.version == buffer.version() {
172            return Ok(());
173        }
174        let Some(query) = self.query.clone() else {
175            self.matches.clear();
176            self.current = None;
177            return Ok(());
178        };
179        let matches = find_matches(buffer, &query)?;
180        self.version = buffer.version();
181        if self.current.is_some_and(|i| i >= matches.len()) {
182            self.current = None;
183        }
184        self.matches = matches;
185        Ok(())
186    }
187
188    /// All cached match ranges in ascending order.
189    pub fn matches(&self) -> &[Range<usize>] {
190        &self.matches
191    }
192
193    /// Number of cached matches.
194    pub fn match_count(&self) -> usize {
195        self.matches.len()
196    }
197
198    /// Index of the current match, if navigation has occurred.
199    pub fn current_index(&self) -> Option<usize> {
200        self.current
201    }
202
203    /// The current match range, if navigation has occurred.
204    pub fn current_match(&self) -> Option<Range<usize>> {
205        self.current.and_then(|i| self.matches.get(i).cloned())
206    }
207
208    /// Advances to the next match at or after `from_offset`.
209    pub fn next(
210        &mut self,
211        buffer: &EditorBuffer,
212        from_offset: usize,
213        wrap: bool,
214    ) -> Result<Option<Range<usize>>, EditorError> {
215        self.refresh(buffer)?;
216        if self.matches.is_empty() {
217            self.current = None;
218            return Ok(None);
219        }
220        let from = from_offset.min(buffer.len_bytes());
221        let idx = self
222            .matches
223            .iter()
224            .position(|m| m.start >= from)
225            .or_else(|| wrap.then_some(0));
226        match idx {
227            Some(i) => {
228                self.current = Some(i);
229                Ok(Some(self.matches[i].clone()))
230            }
231            None => Ok(None),
232        }
233    }
234
235    /// Moves to the previous match at or before `from_offset`.
236    pub fn prev(
237        &mut self,
238        buffer: &EditorBuffer,
239        from_offset: usize,
240        wrap: bool,
241    ) -> Result<Option<Range<usize>>, EditorError> {
242        self.refresh(buffer)?;
243        if self.matches.is_empty() {
244            self.current = None;
245            return Ok(None);
246        }
247        let from = from_offset.min(buffer.len_bytes());
248        let idx = self
249            .matches
250            .iter()
251            .rposition(|m| m.start < from)
252            .or_else(|| wrap.then(|| self.matches.len() - 1));
253        match idx {
254            Some(i) => {
255                self.current = Some(i);
256                Ok(Some(self.matches[i].clone()))
257            }
258            None => Ok(None),
259        }
260    }
261}
262
263/// Collects `(range, expanded_text)` pairs for `query` in `text`, with
264/// 0-based ascending ranges.
265///
266/// Ranges are non-empty and on char boundaries. Supports `$1` / `$name`
267/// capture expansion in regex mode; literal mode uses `replacement` verbatim.
268/// [`replace_all_query`] applies these to a whole buffer; line-scoped
269/// substitutes (`:s` without `%`) collect on a line slice and offset the
270/// ranges by the line start.
271pub fn collect_replacements(
272    text: &str,
273    query: &SearchQuery,
274    replacement: &str,
275) -> Result<Vec<(Range<usize>, String)>, EditorError> {
276    let re = compile_query(query)?;
277    let mut replacements = Vec::new();
278    if query.is_regex {
279        // Per-match `$1` / `$name` capture expansion.
280        for caps in re.captures_iter(text) {
281            let m = caps.get(0).expect("captures_iter always yields group 0");
282            let range = m.start()..m.end();
283            if range.is_empty() {
284                continue;
285            }
286            if !(text.is_char_boundary(range.start) && text.is_char_boundary(range.end)) {
287                continue;
288            }
289            let mut expanded = String::new();
290            caps.expand(replacement, &mut expanded);
291            replacements.push((range, expanded));
292        }
293    } else {
294        for m in re.find_iter(text) {
295            let range = m.start()..m.end();
296            if range.is_empty() {
297                continue;
298            }
299            if !(text.is_char_boundary(range.start) && text.is_char_boundary(range.end)) {
300                continue;
301            }
302            replacements.push((range, replacement.to_string()));
303        }
304    }
305    Ok(replacements)
306}
307
308/// Replaces all matches of `query` with `replacement` as a **single**
309/// undoable transaction. Supports `$1` / `$name` capture expansion in regex
310/// mode; literal mode uses `replacement` verbatim. Returns the number of
311/// replacements applied.
312pub fn replace_all_query(
313    buffer: &mut EditorBuffer,
314    query: &SearchQuery,
315    replacement: &str,
316) -> Result<usize, EditorError> {
317    let text = buffer.text().to_string();
318    let replacements = collect_replacements(&text, query, replacement)?;
319    Ok(buffer.replace_many(replacements))
320}
321
322/// Replaces the single match `range` with `replacement` (undoable).
323///
324/// Returns `Ok(false)` without touching the buffer when `range` is empty,
325/// out of bounds, or not a current match of `query`. Regex replacements
326/// expand `$1` / `$name` captures.
327pub fn replace_one_query(
328    buffer: &mut EditorBuffer,
329    query: &SearchQuery,
330    range: Range<usize>,
331    replacement: &str,
332) -> Result<bool, EditorError> {
333    if range.is_empty() || range.end > buffer.len_bytes() {
334        return Ok(false);
335    }
336    if !buffer.is_char_boundary(range.start) || !buffer.is_char_boundary(range.end) {
337        return Ok(false);
338    }
339    let text = buffer.text().to_string();
340    let expanded = collect_replacements(&text, query, replacement)?
341        .into_iter()
342        .find(|(r, _)| *r == range)
343        .map(|(_, expanded)| expanded);
344    match expanded {
345        Some(expanded) => {
346            buffer.replace_range(range, &expanded);
347            Ok(true)
348        }
349        None => Ok(false),
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[test]
358    fn literal_find_returns_ascending_byte_ranges() {
359        let buffer = EditorBuffer::new("hello world hello");
360        let matches = find_matches(&buffer, &SearchQuery::literal("hello")).unwrap();
361        assert_eq!(matches, vec![0..5, 12..17]);
362    }
363
364    #[test]
365    fn literal_is_case_sensitive_by_default() {
366        let buffer = EditorBuffer::new("Hello hello");
367        let matches = find_matches(&buffer, &SearchQuery::literal("hello")).unwrap();
368        assert_eq!(matches, vec![6..11]);
369    }
370
371    #[test]
372    fn case_insensitive_matches_all_cases() {
373        let buffer = EditorBuffer::new("Hello HELLO hello");
374        let query = SearchQuery::new("hello", false, false, false);
375        let matches = find_matches(&buffer, &query).unwrap();
376        assert_eq!(matches, vec![0..5, 6..11, 12..17]);
377    }
378
379    #[test]
380    fn regex_mode_matches_pattern_class() {
381        let buffer = EditorBuffer::new("hello hallo hxllo");
382        let query = SearchQuery::new("h.llo", true, false, true);
383        let matches = find_matches(&buffer, &query).unwrap();
384        assert_eq!(matches, vec![0..5, 6..11, 12..17]);
385    }
386
387    #[test]
388    fn literal_mode_does_not_treat_dot_as_wildcard() {
389        let buffer = EditorBuffer::new("h.llo hello");
390        let matches = find_matches(&buffer, &SearchQuery::literal("h.llo")).unwrap();
391        assert_eq!(matches, vec![0..5]);
392    }
393
394    #[test]
395    fn whole_word_skips_substring_matches() {
396        let buffer = EditorBuffer::new("foo foobar foo");
397        let query = SearchQuery::new("foo", true, true, false);
398        let matches = find_matches(&buffer, &query).unwrap();
399        assert_eq!(matches, vec![0..3, 11..14]);
400    }
401
402    #[test]
403    fn empty_pattern_is_an_error() {
404        let buffer = EditorBuffer::new("hello");
405        let err = find_matches(&buffer, &SearchQuery::literal("")).unwrap_err();
406        assert!(matches!(err, EditorError::EmptySearchPattern));
407    }
408
409    #[test]
410    fn invalid_regex_is_an_error() {
411        let buffer = EditorBuffer::new("hello");
412        let query = SearchQuery::new("(", true, false, true);
413        let err = find_matches(&buffer, &query).unwrap_err();
414        assert!(matches!(err, EditorError::InvalidRegex { .. }));
415    }
416
417    #[test]
418    fn no_match_returns_empty_vec() {
419        let buffer = EditorBuffer::new("hello world");
420        let matches = find_matches(&buffer, &SearchQuery::literal("xyz")).unwrap();
421        assert!(matches.is_empty());
422    }
423
424    #[test]
425    fn unicode_matches_land_on_char_boundaries() {
426        let buffer = EditorBuffer::new("héllo héllo");
427        // "é" is 2 bytes: "héllo" spans 6 bytes.
428        let matches = find_matches(&buffer, &SearchQuery::literal("héllo")).unwrap();
429        assert_eq!(matches, vec![0..6, 7..13]);
430        for m in &matches {
431            assert!(buffer.is_char_boundary(m.start));
432            assert!(buffer.is_char_boundary(m.end));
433        }
434    }
435
436    #[test]
437    fn find_next_advances_and_wraps() {
438        let buffer = EditorBuffer::new("aa aa aa");
439        let query = SearchQuery::literal("aa");
440        assert_eq!(find_next(&buffer, &query, 0, true).unwrap(), Some(0..2));
441        assert_eq!(find_next(&buffer, &query, 1, true).unwrap(), Some(3..5));
442        assert_eq!(find_next(&buffer, &query, 7, true).unwrap(), Some(0..2));
443        assert_eq!(find_next(&buffer, &query, 7, false).unwrap(), None);
444    }
445
446    #[test]
447    fn find_prev_retreats_and_wraps() {
448        let buffer = EditorBuffer::new("aa aa aa");
449        let query = SearchQuery::literal("aa");
450        assert_eq!(find_prev(&buffer, &query, 8, true).unwrap(), Some(6..8));
451        assert_eq!(find_prev(&buffer, &query, 6, true).unwrap(), Some(3..5));
452        assert_eq!(find_prev(&buffer, &query, 0, true).unwrap(), Some(6..8));
453        assert_eq!(find_prev(&buffer, &query, 0, false).unwrap(), None);
454    }
455
456    #[test]
457    fn search_state_caches_and_rescans_on_edit() {
458        let mut buffer = EditorBuffer::new("foo foo");
459        let mut state = SearchState::new();
460        state.set_query(SearchQuery::literal("foo"));
461        state.refresh(&buffer).unwrap();
462        assert_eq!(state.match_count(), 2);
463
464        buffer.set_cursor_offset(buffer.len_bytes());
465        buffer.insert(" foo");
466        state.refresh(&buffer).unwrap();
467        assert_eq!(state.match_count(), 3);
468        assert_eq!(state.matches(), &[0..3, 4..7, 8..11]);
469    }
470
471    #[test]
472    fn search_state_navigation_tracks_current() {
473        let buffer = EditorBuffer::new("aa aa aa");
474        let mut state = SearchState::new();
475        state.set_query(SearchQuery::literal("aa"));
476        assert_eq!(state.next(&buffer, 0, true).unwrap(), Some(0..2));
477        assert_eq!(state.current_index(), Some(0));
478        assert_eq!(state.next(&buffer, 1, true).unwrap(), Some(3..5));
479        assert_eq!(state.current_index(), Some(1));
480        assert_eq!(state.prev(&buffer, 4, true).unwrap(), Some(3..5));
481        assert_eq!(state.current_match(), Some(3..5));
482    }
483
484    #[test]
485    fn replace_many_is_a_single_undo_step() {
486        let mut buffer = EditorBuffer::new("a a a");
487        let n = buffer.replace_many(vec![
488            (0..1, "b".to_string()),
489            (2..3, "b".to_string()),
490            (4..5, "b".to_string()),
491        ]);
492        assert_eq!(n, 3);
493        assert_eq!(buffer.text().to_string(), "b b b");
494
495        buffer.undo();
496        assert_eq!(buffer.text().to_string(), "a a a");
497
498        buffer.redo();
499        assert_eq!(buffer.text().to_string(), "b b b");
500    }
501
502    #[test]
503    fn replace_many_with_no_matches_changes_nothing() {
504        let mut buffer = EditorBuffer::new("hello");
505        let version = buffer.version();
506        let n = buffer.replace_many(vec![]);
507        assert_eq!(n, 0);
508        assert_eq!(buffer.version(), version);
509        assert_eq!(buffer.text().to_string(), "hello");
510    }
511
512    #[test]
513    fn replace_many_handles_growing_replacements() {
514        let mut buffer = EditorBuffer::new("ab ab");
515        let n = buffer.replace_many(vec![(0..2, "abcd".to_string()), (3..5, "abcd".to_string())]);
516        assert_eq!(n, 2);
517        assert_eq!(buffer.text().to_string(), "abcd abcd");
518        buffer.undo();
519        assert_eq!(buffer.text().to_string(), "ab ab");
520        buffer.redo();
521        assert_eq!(buffer.text().to_string(), "abcd abcd");
522    }
523
524    #[test]
525    fn replace_all_query_literal_replaces_everything_at_once() {
526        let mut buffer = EditorBuffer::new("foo bar foo");
527        let n = replace_all_query(&mut buffer, &SearchQuery::literal("foo"), "baz").unwrap();
528        assert_eq!(n, 2);
529        assert_eq!(buffer.text().to_string(), "baz bar baz");
530        buffer.undo();
531        assert_eq!(buffer.text().to_string(), "foo bar foo");
532    }
533
534    #[test]
535    fn replace_all_query_expands_regex_captures() {
536        let mut buffer = EditorBuffer::new("2024-01-02");
537        let query = SearchQuery::new(r"(\d+)-(\d+)-(\d+)", true, false, true);
538        let n = replace_all_query(&mut buffer, &query, "$3/$2/$1").unwrap();
539        assert_eq!(n, 1);
540        assert_eq!(buffer.text().to_string(), "02/01/2024");
541        buffer.undo();
542        assert_eq!(buffer.text().to_string(), "2024-01-02");
543    }
544
545    #[test]
546    fn replace_all_query_with_no_matches_is_a_noop() {
547        let mut buffer = EditorBuffer::new("hello");
548        let version = buffer.version();
549        let n = replace_all_query(&mut buffer, &SearchQuery::literal("xyz"), "baz").unwrap();
550        assert_eq!(n, 0);
551        assert_eq!(buffer.version(), version);
552    }
553
554    #[test]
555    fn replace_all_query_rejects_empty_and_invalid_patterns() {
556        let mut buffer = EditorBuffer::new("hello");
557        let err = replace_all_query(&mut buffer, &SearchQuery::literal(""), "x").unwrap_err();
558        assert!(matches!(err, EditorError::EmptySearchPattern));
559
560        let bad = SearchQuery::new("(", true, false, true);
561        let err = replace_all_query(&mut buffer, &bad, "x").unwrap_err();
562        assert!(matches!(err, EditorError::InvalidRegex { .. }));
563    }
564
565    #[test]
566    fn replace_one_query_replaces_exact_match_only() {
567        let mut buffer = EditorBuffer::new("foo bar foo");
568        assert!(replace_one_query(&mut buffer, &SearchQuery::literal("foo"), 0..3, "baz").unwrap());
569        assert_eq!(buffer.text().to_string(), "baz bar foo");
570        buffer.undo();
571        assert_eq!(buffer.text().to_string(), "foo bar foo");
572
573        // Not a match boundary: untouched.
574        assert!(
575            !replace_one_query(&mut buffer, &SearchQuery::literal("foo"), 1..4, "baz").unwrap()
576        );
577        assert_eq!(buffer.text().to_string(), "foo bar foo");
578
579        // Empty / out-of-bounds ranges: untouched.
580        assert!(
581            !replace_one_query(&mut buffer, &SearchQuery::literal("foo"), 0..0, "baz").unwrap()
582        );
583        assert!(
584            !replace_one_query(&mut buffer, &SearchQuery::literal("foo"), 0..99, "baz").unwrap()
585        );
586    }
587
588    #[test]
589    fn replace_one_query_expands_regex_captures() {
590        let mut buffer = EditorBuffer::new("ab cd");
591        let query = SearchQuery::new(r"(\w)(\w)", true, false, true);
592        assert!(replace_one_query(&mut buffer, &query, 0..2, "$2$1").unwrap());
593        assert_eq!(buffer.text().to_string(), "ba cd");
594    }
595
596    #[test]
597    fn collect_replacements_supports_line_scoped_offsets() {
598        let mut buffer = EditorBuffer::new("foo one\nfoo two\n");
599        let query = SearchQuery::literal("foo");
600        // Simulate `:s` on row 1: collect on the line slice, offset by line start.
601        let line_start = buffer.point_to_offset(crate::Point::new(1, 0));
602        let line = buffer.line_to_string(1);
603        let local = collect_replacements(&line, &query, "bar").unwrap();
604        assert_eq!(local.len(), 1);
605        let scoped: Vec<(Range<usize>, String)> = local
606            .into_iter()
607            .map(|(r, s)| (r.start + line_start..r.end + line_start, s))
608            .collect();
609        assert_eq!(buffer.replace_many(scoped), 1);
610        assert_eq!(buffer.text().to_string(), "foo one\nbar two\n");
611        buffer.undo();
612        assert_eq!(buffer.text().to_string(), "foo one\nfoo two\n");
613    }
614}