Skip to main content

termesh_editor/
search.rs

1//! Finding text in a buffer. Pure functions over a rope, in char offsets.
2//!
3//! Literal substring matching, not regex: ARCHITECTURE.md §14 puts find/replace in the
4//! MVP and regex nowhere, and a literal search is what people reach for when they are
5//! looking at a symbol name. Regex belongs with the ripgrep-backed workspace search in
6//! Phase 05, where the engine already exists.
7
8use ropey::Rope;
9
10/// A match, as a char range.
11pub type Match = (usize, usize);
12
13/// Whether a search distinguishes case.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum CaseMode {
16    /// Case-insensitive until the query contains an uppercase letter — the "smart case"
17    /// behaviour people expect without having to reach for a toggle.
18    #[default]
19    Smart,
20    Sensitive,
21    Insensitive,
22}
23
24impl CaseMode {
25    fn sensitive_for(self, needle: &str) -> bool {
26        match self {
27            CaseMode::Sensitive => true,
28            CaseMode::Insensitive => false,
29            CaseMode::Smart => needle.chars().any(char::is_uppercase),
30        }
31    }
32}
33
34/// Every occurrence of `needle`, left to right, non-overlapping.
35///
36/// An empty needle matches nothing: reporting a match at every position would be
37/// technically defensible and useless.
38pub fn find_all(text: &Rope, needle: &str, mode: CaseMode) -> Vec<Match> {
39    if needle.is_empty() {
40        return Vec::new();
41    }
42    let sensitive = mode.sensitive_for(needle);
43
44    // Searching a `String` rather than walking the rope: find/replace runs on a keystroke,
45    // not per frame, and a whole-buffer scan is simpler to get right than a chunk-aware
46    // one. If large files ever make this hurt, ropey's chunk API is the escape hatch.
47    let haystack = text.to_string();
48    let (haystack, needle) = if sensitive {
49        (haystack, needle.to_string())
50    } else {
51        (haystack.to_lowercase(), needle.to_lowercase())
52    };
53
54    // Byte offsets from `match_indices`, converted to chars — the unit everything else
55    // in this crate speaks (ADR-0006 §1).
56    let mut matches = Vec::new();
57    let needle_chars = needle.chars().count();
58    for (byte, _) in haystack.match_indices(&needle) {
59        let start = haystack[..byte].chars().count();
60        matches.push((start, start + needle_chars));
61    }
62    matches
63}
64
65/// The first match at or after `from`, wrapping to the start.
66pub fn next_from(matches: &[Match], from: usize) -> Option<usize> {
67    if matches.is_empty() {
68        return None;
69    }
70    Some(matches.iter().position(|(start, _)| *start >= from).unwrap_or(0))
71}
72
73/// The last match starting strictly before `from`, wrapping to the end.
74///
75/// Callers navigating backwards pass the *current match's start*, not the raw cursor: a
76/// cursor sitting inside a match would otherwise find that same match again and appear
77/// stuck.
78pub fn prev_from(matches: &[Match], from: usize) -> Option<usize> {
79    if matches.is_empty() {
80        return None;
81    }
82    Some(matches.iter().rposition(|(start, _)| *start < from).unwrap_or(matches.len() - 1))
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    fn rope(s: &str) -> Rope {
90        Rope::from_str(s)
91    }
92
93    #[test]
94    fn every_occurrence_is_found_in_order() {
95        let text = rope("one two one three one");
96        let found = find_all(&text, "one", CaseMode::Sensitive);
97        assert_eq!(found, [(0, 3), (8, 11), (18, 21)]);
98    }
99
100    #[test]
101    fn nothing_matches_an_empty_query() {
102        // A match at every position is technically defensible and useless.
103        assert!(find_all(&rope("anything"), "", CaseMode::Smart).is_empty());
104    }
105
106    #[test]
107    fn a_missing_needle_finds_nothing() {
108        assert!(find_all(&rope("abc"), "zzz", CaseMode::Smart).is_empty());
109    }
110
111    #[test]
112    fn smart_case_is_insensitive_until_you_type_a_capital() {
113        let text = rope("Error error ERROR");
114        assert_eq!(find_all(&text, "error", CaseMode::Smart).len(), 3, "all three");
115        assert_eq!(find_all(&text, "Error", CaseMode::Smart), [(0, 5)], "the capital narrows it");
116    }
117
118    #[test]
119    fn explicit_modes_override_the_smart_default() {
120        let text = rope("Error error");
121        assert_eq!(find_all(&text, "Error", CaseMode::Insensitive).len(), 2);
122        assert_eq!(find_all(&text, "error", CaseMode::Sensitive).len(), 1);
123    }
124
125    #[test]
126    fn matches_are_char_offsets_not_byte_offsets() {
127        // "héllo" is 5 chars, 6 bytes: a byte offset would land mid-character.
128        let text = rope("héllo world héllo");
129        assert_eq!(find_all(&text, "world", CaseMode::Sensitive), [(6, 11)]);
130    }
131
132    #[test]
133    fn matches_span_lines_correctly() {
134        let text = rope("first\nsecond\nfirst\n");
135        assert_eq!(find_all(&text, "first", CaseMode::Sensitive), [(0, 5), (13, 18)]);
136    }
137
138    #[test]
139    fn overlapping_candidates_are_reported_without_overlap() {
140        // "aa" in "aaaa" is found at 0 and 2, not 0/1/2.
141        assert_eq!(find_all(&rope("aaaa"), "aa", CaseMode::Sensitive), [(0, 2), (2, 4)]);
142    }
143
144    // --- navigation -----------------------------------------------------------------
145
146    #[test]
147    fn next_finds_the_match_at_or_after_the_cursor() {
148        let matches = [(0, 3), (8, 11), (18, 21)];
149        assert_eq!(next_from(&matches, 0), Some(0));
150        assert_eq!(next_from(&matches, 1), Some(1));
151        assert_eq!(next_from(&matches, 8), Some(1), "a cursor sitting on one stays on it");
152        assert_eq!(next_from(&matches, 12), Some(2));
153    }
154
155    #[test]
156    fn next_wraps_past_the_last_match() {
157        let matches = [(0, 3), (8, 11)];
158        assert_eq!(next_from(&matches, 99), Some(0), "back to the top");
159    }
160
161    #[test]
162    fn prev_finds_the_last_match_starting_before_the_offset_and_wraps() {
163        let matches = [(0, 3), (8, 11), (18, 21)];
164        assert_eq!(prev_from(&matches, 18), Some(1));
165        assert_eq!(prev_from(&matches, 8), Some(0));
166        assert_eq!(prev_from(&matches, 0), Some(2), "wraps to the end");
167    }
168
169    /// Stepping backwards from inside a match must not land on that same match, which is
170    /// why callers pass the current match's start rather than the cursor.
171    #[test]
172    fn stepping_backwards_repeatedly_walks_the_matches() {
173        let matches = [(0, 3), (8, 11), (18, 21)];
174        let mut at = 2; // sitting on the last match
175
176        at = prev_from(&matches, matches[at].0).unwrap();
177        assert_eq!(at, 1);
178        at = prev_from(&matches, matches[at].0).unwrap();
179        assert_eq!(at, 0);
180        at = prev_from(&matches, matches[at].0).unwrap();
181        assert_eq!(at, 2, "and wraps");
182    }
183
184    #[test]
185    fn navigating_an_empty_result_set_goes_nowhere() {
186        assert_eq!(next_from(&[], 0), None);
187        assert_eq!(prev_from(&[], 0), None);
188    }
189}