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    /// All matches inside `range`.
300    pub fn find_all(&self, doc: &Document, range: Range<ByteOffset>) -> Vec<Range<ByteOffset>> {
301        self.matcher
302            .find_iter(doc, range.start.0..range.end.0)
303            .map(to_offsets)
304            .collect()
305    }
306
307    /// The replacement text for the match at `m`.
308    pub fn expand(&self, doc: &Document, m: Range<ByteOffset>, replacement: &str) -> String {
309        self.matcher.expand(doc, m.start.0..m.end.0, replacement)
310    }
311
312    /// The ranges to search: the non-empty selections with "in selection"
313    /// on, otherwise the whole document.
314    pub fn ranges(&self, doc: &Document, sels: &SelectionSet) -> Vec<Range<ByteOffset>> {
315        let selected: Vec<_> = sels
316            .ranges()
317            .iter()
318            .filter(|s| !s.is_empty())
319            .map(|s| s.range())
320            .collect();
321        if self.options.in_selection && !selected.is_empty() {
322            selected
323        } else {
324            vec![ByteOffset(0)..doc.len()]
325        }
326    }
327
328    /// Matches in the ranges `ranges` selects.
329    pub fn find_in(&self, doc: &Document, sels: &SelectionSet) -> Vec<Range<ByteOffset>> {
330        self.ranges(doc, sels)
331            .into_iter()
332            .flat_map(|r| self.find_all(doc, r))
333            .collect()
334    }
335
336    /// The match to select for Find Next/Previous from the current primary
337    /// selection, wrapping if enabled.
338    pub fn next_from(
339        &self,
340        doc: &Document,
341        sels: &SelectionSet,
342        backward: bool,
343    ) -> Option<Range<ByteOffset>> {
344        let cur = sels.primary().range();
345        if backward {
346            self.find_prev(doc, cur.start)
347                .filter(|m| *m != cur)
348                .or_else(|| {
349                    self.options
350                        .wrap
351                        .then(|| self.find_prev(doc, ByteOffset(doc.len().0 + 1)))
352                        .flatten()
353                })
354        } else {
355            let mut found = self.find_next(doc, cur.end);
356            // An empty match at the caret is where we already are: step on.
357            if found.as_ref() == Some(&cur) && cur.end < doc.len() {
358                found = self.find_next(doc, next_grapheme_boundary(doc, cur.end));
359            } else if found.as_ref() == Some(&cur) {
360                found = None;
361            }
362            found.or_else(|| {
363                self.options
364                    .wrap
365                    .then(|| self.find_next(doc, ByteOffset(0)))
366                    .flatten()
367            })
368        }
369    }
370
371    /// If the primary selection is exactly a match, the range and its
372    /// replacement text (for Replace).
373    pub fn replacement_for_selection(
374        &self,
375        doc: &Document,
376        sels: &SelectionSet,
377        replacement: &str,
378    ) -> Option<(Range<ByteOffset>, String)> {
379        let cur = sels.primary().range();
380        let m = self.find_next(doc, cur.start)?;
381        (m == cur).then(|| {
382            let text = self.expand(doc, m.clone(), replacement);
383            (m, text)
384        })
385    }
386}
387
388fn to_offsets(r: Range<usize>) -> Range<ByteOffset> {
389    ByteOffset(r.start)..ByteOffset(r.end)
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    fn q(pattern: &str, f: impl FnOnce(&mut SearchOptions)) -> Searcher {
397        let mut o = SearchOptions::default();
398        f(&mut o);
399        Searcher::new(&SearchQuery::new(pattern, o)).expect("valid pattern")
400    }
401
402    fn all(s: &Searcher, text: &str) -> Vec<(usize, usize)> {
403        let doc = Document::new(text);
404        s.find_all(&doc, ByteOffset(0)..doc.len())
405            .into_iter()
406            .map(|r| (r.start.0, r.end.0))
407            .collect()
408    }
409
410    #[test]
411    fn literal_case_and_whole_word() {
412        let text = "Foo foo food (foo) foo(";
413        assert_eq!(all(&q("foo", |_| {}), text).len(), 5);
414        assert_eq!(all(&q("foo", |o| o.match_case = true), text).len(), 4);
415        assert_eq!(
416            all(&q("foo", |o| o.whole_word = true), text),
417            vec![(0, 3), (4, 7), (14, 17), (19, 22)]
418        );
419        // A literal ending in a non-word char still matches as a word.
420        assert_eq!(
421            all(&q("foo(", |o| o.whole_word = true), text),
422            vec![(19, 23)]
423        );
424        // Metacharacters are literal unless regex is on.
425        assert_eq!(all(&q("(foo)", |_| {}), text), vec![(13, 18)]);
426    }
427
428    #[test]
429    fn regex_lines_and_invalid_patterns() {
430        let s = q(r"^\w+", |o| o.regex = true);
431        assert_eq!(all(&s, "ab cd\nef"), vec![(0, 2), (6, 8)]);
432        let err = Searcher::new(&SearchQuery::new(
433            "(unclosed",
434            SearchOptions {
435                regex: true,
436                ..Default::default()
437            },
438        ));
439        assert!(err.is_err());
440        // No lookaround in this dialect.
441        assert!(
442            Searcher::new(&SearchQuery::new(
443                "a(?=b)",
444                SearchOptions {
445                    regex: true,
446                    ..Default::default()
447                }
448            ))
449            .is_err()
450        );
451    }
452
453    #[test]
454    fn matches_across_rope_chunks() {
455        let text = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000)).repeat(20);
456        let s = q("needle", |_| {});
457        let found = all(&s, &text);
458        assert_eq!(found.len(), 20);
459        let doc = Document::new(&text);
460        for (a, b) in found {
461            assert_eq!(doc.slice(ByteOffset(a)..ByteOffset(b)), "needle");
462        }
463    }
464
465    #[test]
466    fn previous_and_next() {
467        let doc = Document::new(&format!("ab{}ab", "-".repeat(200_000)));
468        let s = q("ab", |_| {});
469        let end = doc.len();
470        assert_eq!(s.find_prev(&doc, end).map(|r| r.start.0), Some(200_002));
471        assert_eq!(
472            s.find_prev(&doc, ByteOffset(200_002)).map(|r| r.start.0),
473            Some(0)
474        );
475        assert_eq!(s.find_prev(&doc, ByteOffset(0)), None);
476        assert_eq!(
477            s.find_next(&doc, ByteOffset(1)).map(|r| r.start.0),
478            Some(200_002)
479        );
480    }
481
482    #[test]
483    fn expansion() {
484        let doc = Document::new("John Smith");
485        let s = q(r"(?<first>\w+) (\w+)", |o| o.regex = true);
486        let m = s.find_next(&doc, ByteOffset(0)).unwrap();
487        assert_eq!(s.expand(&doc, m.clone(), "$2, ${first}"), "Smith, John");
488        assert_eq!(s.expand(&doc, m.clone(), r"$0\n\t$$"), "John Smith\n\t$");
489        // Literal mode leaves the replacement alone.
490        let lit = q("John", |_| {});
491        let m = lit.find_next(&doc, ByteOffset(0)).unwrap();
492        assert_eq!(lit.expand(&doc, m, "$1 \\n"), "$1 \\n");
493    }
494}