Skip to main content

typ_buffer/
search.rs

1//! Literal, line-scoped search.
2//!
3//! Line-scoped on purpose: a match never spans a line break, so every result
4//! is expressible as `(line, grapheme)` without a second coordinate system,
5//! and that is what a user typing into a search box means anyway. Regex
6//! belongs behind this same `SearchQuery` type later, not beside it.
7
8use unicode_segmentation::UnicodeSegmentation;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct SearchQuery {
12    pub needle: String,
13    pub case_sensitive: bool,
14}
15
16impl SearchQuery {
17    pub fn new(needle: impl Into<String>, case_sensitive: bool) -> Self {
18        Self {
19            needle: needle.into(),
20            case_sensitive,
21        }
22    }
23}
24
25/// Compare two graphemes, optionally folding case, without allocating.
26///
27/// `to_lowercase` on a `char` yields an iterator precisely so this can be done
28/// lazily — folding into `String`s first would allocate twice per comparison,
29/// which on a long line is thousands of allocations for one keystroke.
30fn grapheme_eq(a: &str, b: &str, case_sensitive: bool) -> bool {
31    if case_sensitive {
32        return a == b;
33    }
34    a.chars()
35        .flat_map(char::to_lowercase)
36        .eq(b.chars().flat_map(char::to_lowercase))
37}
38
39/// Grapheme index pairs of every non-overlapping match in one line.
40///
41/// Indices come out in graphemes directly, so nothing has to map byte offsets
42/// back afterwards — and case folding, which can change a string's byte length,
43/// never gets the chance to shift them.
44pub fn find_in_line(line: &str, query: &SearchQuery) -> Vec<(usize, usize)> {
45    if query.needle.is_empty() {
46        return Vec::new();
47    }
48    let needle: Vec<&str> = query.needle.graphemes(true).collect();
49    find_in_line_with(line, &needle, query)
50}
51
52/// `find_in_line` with the needle already split.
53///
54/// Splitting it is per-search work, not per-line work: a whole-buffer scan calls
55/// this once per line, and rebuilding the needle each time was one allocation
56/// per line for a value that never changes. That, plus collecting the haystack
57/// into a `Vec<&str>`, was what put a 50k-line search at 141 ms against a 16 ms
58/// keystroke budget. Neither allocation survives here.
59pub(crate) fn find_in_line_with(
60    line: &str,
61    needle: &[&str],
62    query: &SearchQuery,
63) -> Vec<(usize, usize)> {
64    if needle.is_empty() {
65        return Vec::new();
66    }
67
68    // A byte-level containment check, memchr-backed and allocation-free. Most
69    // lines in a real search hold no match at all, and this retires them before
70    // any grapheme segmentation happens. Case-sensitive only: folding can change
71    // a string's byte length, so the bytes of a case-insensitive needle are not
72    // a sound precondition for its matches.
73    if query.case_sensitive && !line.contains(query.needle.as_str()) {
74        return Vec::new();
75    }
76
77    // Segmenting the line once and indexing the result beats re-segmenting from
78    // each candidate position: building a `Graphemes` iterator per position cost
79    // more than the single `Vec` of borrowed slices it was meant to avoid,
80    // measured at 190 ms against 141 ms on a 50k-line scan.
81    let haystack: Vec<&str> = line.graphemes(true).collect();
82    if needle.len() > haystack.len() {
83        return Vec::new();
84    }
85
86    let mut hits = Vec::new();
87    let mut i = 0usize;
88    while i + needle.len() <= haystack.len() {
89        let matched = haystack[i..i + needle.len()]
90            .iter()
91            .zip(needle)
92            .all(|(h, n)| grapheme_eq(h, n, query.case_sensitive));
93        if matched {
94            hits.push((i, i + needle.len()));
95            // Advance past the match. Overlapping hits would let a replace-all
96            // rewrite text it had already rewritten.
97            i += needle.len();
98        } else {
99            i += 1;
100        }
101    }
102    hits
103}