Skip to main content

ochrepad_core/
search.rs

1//! Find and replace over the document without copying it into a `String`.
2//!
3//! Regexes run on `regex-cursor` (a prototype-stage fork of
4//! `regex-automata` that searches discontiguous text) through a cursor over
5//! the document's chunks. It is wrapped behind [`Matcher`] so another engine
6//! — for example a `fancy-regex` fallback that materializes the text, for
7//! lookaround and backreferences — can be added later.
8//!
9//! Dialect: Rust `regex` syntax, not Notepad++'s Boost regex. There is no
10//! lookaround and no backreferences; `^`/`$` match at line boundaries.
11
12use std::fmt;
13use std::ops::Range;
14
15use regex_cursor::engines::meta::Regex;
16use regex_cursor::regex_automata::util::captures::Captures;
17use regex_cursor::regex_automata::util::interpolate;
18use regex_cursor::regex_automata::util::syntax;
19use regex_cursor::regex_automata::{Anchored, PatternID};
20use regex_cursor::{Cursor, Input};
21use serde::{Deserialize, Serialize};
22
23use crate::position::{ByteOffset, next_grapheme_boundary};
24use crate::selection::SelectionSet;
25use crate::text::Document;
26
27#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
28pub struct SearchOptions {
29    pub regex: bool,
30    pub match_case: bool,
31    pub whole_word: bool,
32    pub wrap: bool,
33    /// Count, select all and replace all only look inside the selections.
34    pub in_selection: bool,
35}
36
37/// What the find panel searches for; part of the recorded find actions.
38#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
39pub struct SearchQuery {
40    pub pattern: String,
41    pub options: SearchOptions,
42}
43
44impl SearchQuery {
45    pub fn new(pattern: impl Into<String>, options: SearchOptions) -> Self {
46        Self {
47            pattern: pattern.into(),
48            options,
49        }
50    }
51}
52
53#[derive(Clone, Debug, PartialEq, Eq)]
54pub struct SearchError(pub String);
55
56impl fmt::Display for SearchError {
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        f.write_str(&self.0)
59    }
60}
61
62/// A compiled pattern that can search a document.
63pub trait Matcher: Send + Sync {
64    /// The leftmost match inside `span`, using the text around `span` as
65    /// context for anchors and word boundaries.
66    fn find(&self, doc: &Document, span: Range<usize>) -> Option<Range<usize>>;
67
68    /// All non-overlapping matches inside `span`, in order.
69    fn find_iter<'a>(
70        &'a self,
71        doc: &'a Document,
72        span: Range<usize>,
73    ) -> Box<dyn Iterator<Item = Range<usize>> + 'a>;
74
75    /// Expands `$1`/`${name}` in `replacement` for the match at `m`.
76    fn expand(&self, doc: &Document, m: Range<usize>, replacement: &str) -> String;
77}
78
79/// A [`regex_cursor::Cursor`] over the document's chunks.
80struct DocCursor<'a> {
81    doc: &'a Document,
82    chunk: &'a [u8],
83    start: usize,
84}
85
86impl<'a> DocCursor<'a> {
87    fn at(doc: &'a Document, offset: usize) -> Self {
88        let (chunk, start) = doc.chunk_at(ByteOffset(offset.min(doc.len().0)));
89        Self {
90            doc,
91            chunk: chunk.as_bytes(),
92            start: start.0,
93        }
94    }
95}
96
97impl Cursor for DocCursor<'_> {
98    fn chunk(&self) -> &[u8] {
99        self.chunk
100    }
101
102    fn advance(&mut self) -> bool {
103        let next = self.start + self.chunk.len();
104        if next >= self.doc.len().0 {
105            return false;
106        }
107        let (chunk, start) = self.doc.chunk_at(ByteOffset(next));
108        self.chunk = chunk.as_bytes();
109        self.start = start.0;
110        true
111    }
112
113    fn backtrack(&mut self) -> bool {
114        if self.start == 0 {
115            return false;
116        }
117        let (chunk, start) = self.doc.chunk_at(ByteOffset(self.start - 1));
118        self.chunk = chunk.as_bytes();
119        self.start = start.0;
120        true
121    }
122
123    fn total_bytes(&self) -> Option<usize> {
124        Some(self.doc.len().0)
125    }
126
127    fn offset(&self) -> usize {
128        self.start
129    }
130}
131
132struct CursorRegex {
133    re: Regex,
134    /// Literal mode: `$` in the replacement is not special.
135    expand_captures: bool,
136}
137
138fn input(doc: &Document, span: Range<usize>) -> Input<DocCursor<'_>> {
139    Input::new(DocCursor::at(doc, span.start)).range(span)
140}
141
142impl Matcher for CursorRegex {
143    fn find(&self, doc: &Document, span: Range<usize>) -> Option<Range<usize>> {
144        self.re.find(input(doc, span)).map(|m| m.range())
145    }
146
147    fn find_iter<'a>(
148        &'a self,
149        doc: &'a Document,
150        span: Range<usize>,
151    ) -> Box<dyn Iterator<Item = Range<usize>> + 'a> {
152        Box::new(self.re.find_iter(input(doc, span)).map(|m| m.range()))
153    }
154
155    fn expand(&self, doc: &Document, m: Range<usize>, replacement: &str) -> String {
156        if !self.expand_captures {
157            return replacement.to_owned();
158        }
159        let mut caps: Captures = self.re.create_captures();
160        let mut inp = input(doc, m.start..doc.len().0);
161        inp.anchored(Anchored::Yes);
162        self.re.captures(inp, &mut caps);
163        let replacement = unescape(replacement);
164        let mut out = String::new();
165        interpolate::string(
166            &replacement,
167            |i, dst| {
168                if let Some(span) = caps.get_group(i) {
169                    dst.push_str(&doc.slice(ByteOffset(span.start)..ByteOffset(span.end)));
170                }
171            },
172            |name| caps.group_info().to_index(PatternID::ZERO, name),
173            &mut out,
174        );
175        out
176    }
177}
178
179/// `\n`, `\t` and `\\` in a regex-mode replacement, as in Notepad++.
180fn unescape(s: &str) -> String {
181    let mut out = String::with_capacity(s.len());
182    let mut chars = s.chars();
183    while let Some(c) = chars.next() {
184        if c != '\\' {
185            out.push(c);
186            continue;
187        }
188        match chars.next() {
189            Some('n') => out.push('\n'),
190            Some('t') => out.push('\t'),
191            Some('\\') => out.push('\\'),
192            Some(other) => {
193                out.push('\\');
194                out.push(other);
195            }
196            None => out.push('\\'),
197        }
198    }
199    out
200}
201
202/// Escapes regex metacharacters so `s` matches literally.
203pub fn escape(s: &str) -> String {
204    let mut out = String::with_capacity(s.len() * 2);
205    for c in s.chars() {
206        if r"\.+*?()|[]{}^$#&-~".contains(c) {
207            out.push('\\');
208        }
209        out.push(c);
210    }
211    out
212}
213
214fn is_word_char(c: char) -> bool {
215    c.is_alphanumeric() || c == '_'
216}
217
218/// A query compiled for searching.
219pub struct Searcher {
220    matcher: Box<dyn Matcher>,
221    options: SearchOptions,
222}
223
224impl Searcher {
225    pub fn new(query: &SearchQuery) -> Result<Self, SearchError> {
226        let o = &query.options;
227        if query.pattern.is_empty() {
228            return Err(SearchError("nothing to find".to_owned()));
229        }
230        let pattern = if o.regex {
231            if o.whole_word {
232                format!(r"\b(?:{})\b", query.pattern)
233            } else {
234                query.pattern.clone()
235            }
236        } else {
237            let mut p = escape(&query.pattern);
238            // Word boundaries only where the text starts or ends with a word
239            // character, so "foo(" can still match as a whole word.
240            if o.whole_word {
241                if query.pattern.chars().next().is_some_and(is_word_char) {
242                    p.insert_str(0, r"\b");
243                }
244                if query.pattern.chars().last().is_some_and(is_word_char) {
245                    p.push_str(r"\b");
246                }
247            }
248            p
249        };
250        let re = Regex::builder()
251            .syntax(
252                syntax::Config::new()
253                    .case_insensitive(!o.match_case)
254                    .multi_line(true),
255            )
256            .build(&pattern)
257            .map_err(|e| SearchError(e.to_string()))?;
258        Ok(Self {
259            matcher: Box::new(CursorRegex {
260                re,
261                expand_captures: o.regex,
262            }),
263            options: o.clone(),
264        })
265    }
266
267    pub fn options(&self) -> &SearchOptions {
268        &self.options
269    }
270
271    /// The first match starting at or after `from`.
272    pub fn find_next(&self, doc: &Document, from: ByteOffset) -> Option<Range<ByteOffset>> {
273        let len = doc.len().0;
274        (from.0 <= len)
275            .then(|| self.matcher.find(doc, from.0..len))
276            .flatten()
277            .map(to_offsets)
278    }
279
280    /// The last match starting before `before`. Searches backwards in
281    /// growing windows, so a match near the caret is found quickly.
282    pub fn find_prev(&self, doc: &Document, before: ByteOffset) -> Option<Range<ByteOffset>> {
283        let len = doc.len().0;
284        let mut window = 64 * 1024;
285        loop {
286            let start = before.0.saturating_sub(window);
287            let last = self
288                .matcher
289                .find_iter(doc, start..len)
290                .take_while(|m| m.start < before.0)
291                .last();
292            if last.is_some() || start == 0 {
293                return last.map(to_offsets);
294            }
295            window *= 4;
296        }
297    }
298
299    /// The matches inside `range`, found as the iterator is advanced (so a
300    /// caller can stop after enough of them).
301    pub fn matches<'a>(
302        &'a self,
303        doc: &'a Document,
304        range: Range<ByteOffset>,
305    ) -> impl Iterator<Item = Range<ByteOffset>> + 'a {
306        self.matcher
307            .find_iter(doc, range.start.0..range.end.0)
308            .map(to_offsets)
309    }
310
311    /// All matches inside `range`.
312    pub fn find_all(&self, doc: &Document, range: Range<ByteOffset>) -> Vec<Range<ByteOffset>> {
313        self.matcher
314            .find_iter(doc, range.start.0..range.end.0)
315            .map(to_offsets)
316            .collect()
317    }
318
319    /// The replacement text for the match at `m`.
320    pub fn expand(&self, doc: &Document, m: Range<ByteOffset>, replacement: &str) -> String {
321        self.matcher.expand(doc, m.start.0..m.end.0, replacement)
322    }
323
324    /// The ranges to search: the non-empty selections with "in selection"
325    /// on, otherwise the whole document.
326    pub fn ranges(&self, doc: &Document, sels: &SelectionSet) -> Vec<Range<ByteOffset>> {
327        let selected: Vec<_> = sels
328            .ranges()
329            .iter()
330            .filter(|s| !s.is_empty())
331            .map(|s| s.range())
332            .collect();
333        if self.options.in_selection && !selected.is_empty() {
334            selected
335        } else {
336            vec![ByteOffset(0)..doc.len()]
337        }
338    }
339
340    /// Matches in the ranges `ranges` selects.
341    pub fn find_in(&self, doc: &Document, sels: &SelectionSet) -> Vec<Range<ByteOffset>> {
342        self.ranges(doc, sels)
343            .into_iter()
344            .flat_map(|r| self.find_all(doc, r))
345            .collect()
346    }
347
348    /// The match to select for Find Next/Previous from the current primary
349    /// selection, wrapping if enabled.
350    pub fn next_from(
351        &self,
352        doc: &Document,
353        sels: &SelectionSet,
354        backward: bool,
355    ) -> Option<Range<ByteOffset>> {
356        let cur = sels.primary().range();
357        if backward {
358            self.find_prev(doc, cur.start)
359                .filter(|m| *m != cur)
360                .or_else(|| {
361                    self.options
362                        .wrap
363                        .then(|| self.find_prev(doc, ByteOffset(doc.len().0 + 1)))
364                        .flatten()
365                })
366        } else {
367            let mut found = self.find_next(doc, cur.end);
368            // An empty match at the caret is where we already are: step on.
369            if found.as_ref() == Some(&cur) && cur.end < doc.len() {
370                found = self.find_next(doc, next_grapheme_boundary(doc, cur.end));
371            } else if found.as_ref() == Some(&cur) {
372                found = None;
373            }
374            found.or_else(|| {
375                self.options
376                    .wrap
377                    .then(|| self.find_next(doc, ByteOffset(0)))
378                    .flatten()
379            })
380        }
381    }
382
383    /// If the primary selection is exactly a match, the range and its
384    /// replacement text (for Replace).
385    pub fn replacement_for_selection(
386        &self,
387        doc: &Document,
388        sels: &SelectionSet,
389        replacement: &str,
390    ) -> Option<(Range<ByteOffset>, String)> {
391        let cur = sels.primary().range();
392        let m = self.find_next(doc, cur.start)?;
393        (m == cur).then(|| {
394            let text = self.expand(doc, m.clone(), replacement);
395            (m, text)
396        })
397    }
398}
399
400fn to_offsets(r: Range<usize>) -> Range<ByteOffset> {
401    ByteOffset(r.start)..ByteOffset(r.end)
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    fn q(pattern: &str, f: impl FnOnce(&mut SearchOptions)) -> Searcher {
409        let mut o = SearchOptions::default();
410        f(&mut o);
411        Searcher::new(&SearchQuery::new(pattern, o)).expect("valid pattern")
412    }
413
414    fn all(s: &Searcher, text: &str) -> Vec<(usize, usize)> {
415        let doc = Document::new(text);
416        s.find_all(&doc, ByteOffset(0)..doc.len())
417            .into_iter()
418            .map(|r| (r.start.0, r.end.0))
419            .collect()
420    }
421
422    #[test]
423    fn literal_case_and_whole_word() {
424        let text = "Foo foo food (foo) foo(";
425        assert_eq!(all(&q("foo", |_| {}), text).len(), 5);
426        assert_eq!(all(&q("foo", |o| o.match_case = true), text).len(), 4);
427        assert_eq!(
428            all(&q("foo", |o| o.whole_word = true), text),
429            vec![(0, 3), (4, 7), (14, 17), (19, 22)]
430        );
431        // A literal ending in a non-word char still matches as a word.
432        assert_eq!(
433            all(&q("foo(", |o| o.whole_word = true), text),
434            vec![(19, 23)]
435        );
436        // Metacharacters are literal unless regex is on.
437        assert_eq!(all(&q("(foo)", |_| {}), text), vec![(13, 18)]);
438    }
439
440    #[test]
441    fn regex_lines_and_invalid_patterns() {
442        let s = q(r"^\w+", |o| o.regex = true);
443        assert_eq!(all(&s, "ab cd\nef"), vec![(0, 2), (6, 8)]);
444        let err = Searcher::new(&SearchQuery::new(
445            "(unclosed",
446            SearchOptions {
447                regex: true,
448                ..Default::default()
449            },
450        ));
451        assert!(err.is_err());
452        // No lookaround in this dialect.
453        assert!(
454            Searcher::new(&SearchQuery::new(
455                "a(?=b)",
456                SearchOptions {
457                    regex: true,
458                    ..Default::default()
459                }
460            ))
461            .is_err()
462        );
463    }
464
465    #[test]
466    fn matches_across_rope_chunks() {
467        let text = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000)).repeat(20);
468        let s = q("needle", |_| {});
469        let found = all(&s, &text);
470        assert_eq!(found.len(), 20);
471        let doc = Document::new(&text);
472        for (a, b) in found {
473            assert_eq!(doc.slice(ByteOffset(a)..ByteOffset(b)), "needle");
474        }
475    }
476
477    #[test]
478    fn previous_and_next() {
479        let doc = Document::new(&format!("ab{}ab", "-".repeat(200_000)));
480        let s = q("ab", |_| {});
481        let end = doc.len();
482        assert_eq!(s.find_prev(&doc, end).map(|r| r.start.0), Some(200_002));
483        assert_eq!(
484            s.find_prev(&doc, ByteOffset(200_002)).map(|r| r.start.0),
485            Some(0)
486        );
487        assert_eq!(s.find_prev(&doc, ByteOffset(0)), None);
488        assert_eq!(
489            s.find_next(&doc, ByteOffset(1)).map(|r| r.start.0),
490            Some(200_002)
491        );
492    }
493
494    #[test]
495    fn expansion() {
496        let doc = Document::new("John Smith");
497        let s = q(r"(?<first>\w+) (\w+)", |o| o.regex = true);
498        let m = s.find_next(&doc, ByteOffset(0)).unwrap();
499        assert_eq!(s.expand(&doc, m.clone(), "$2, ${first}"), "Smith, John");
500        assert_eq!(s.expand(&doc, m.clone(), r"$0\n\t$$"), "John Smith\n\t$");
501        // Literal mode leaves the replacement alone.
502        let lit = q("John", |_| {});
503        let m = lit.find_next(&doc, ByteOffset(0)).unwrap();
504        assert_eq!(lit.expand(&doc, m, "$1 \\n"), "$1 \\n");
505    }
506}