1#[derive(Debug, Clone, PartialEq, Eq, Default)]
20pub struct Search {
21 pattern: String,
24 case_sensitive: bool,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub struct Hit {
30 pub row: usize,
31 pub wrapped: bool,
35}
36
37impl Search {
38 pub fn new(pattern: impl Into<String>) -> Self {
41 let pattern = pattern.into();
42 let case_sensitive = smartcase(&pattern);
43 Self {
44 pattern,
45 case_sensitive,
46 }
47 }
48
49 pub fn pattern(&self) -> &str {
50 &self.pattern
51 }
52
53 pub fn is_empty(&self) -> bool {
54 self.pattern.is_empty()
55 }
56
57 pub fn matches(&self, haystack: &str) -> bool {
59 if self.pattern.is_empty() {
60 return false;
61 }
62 haystack
63 .char_indices()
64 .any(|(at, _)| self.match_at(haystack, at).is_some())
65 }
66
67 pub fn spans(&self, haystack: &str) -> Vec<(usize, usize)> {
75 let mut out = Vec::new();
76 if self.pattern.is_empty() {
77 return out;
78 }
79 let starts: Vec<usize> = haystack.char_indices().map(|(i, _)| i).collect();
80 let mut i = 0;
81 while i < starts.len() {
82 match self.match_at(haystack, starts[i]) {
83 Some(end) => {
84 out.push((starts[i], end));
85 while i < starts.len() && starts[i] < end {
88 i += 1;
89 }
90 }
91 None => i += 1,
92 }
93 }
94 out
95 }
96
97 fn match_at(&self, haystack: &str, at: usize) -> Option<usize> {
99 let mut rest = haystack[at..].chars();
100 for want in self.pattern.chars() {
101 let got = rest.next()?;
102 let same = if self.case_sensitive {
103 got == want
104 } else {
105 got.to_lowercase().eq(want.to_lowercase())
106 };
107 if !same {
108 return None;
109 }
110 }
111 Some(haystack.len() - rest.as_str().len())
112 }
113}
114
115fn smartcase(pattern: &str) -> bool {
122 pattern.chars().any(char::is_uppercase)
123}
124
125pub fn find(
142 search: &Search,
143 labels: &[String],
144 from: usize,
145 forward: bool,
146 inclusive: bool,
147) -> Option<Hit> {
148 if search.is_empty() || labels.is_empty() {
149 return None;
150 }
151 let n = labels.len();
152 let offset = if inclusive { 0 } else { 1 };
153
154 (0..n).find_map(|i| {
158 let step = i + offset;
159 let (row, wrapped) = if forward {
160 ((from + step) % n, from + step >= n)
161 } else {
162 ((from + n - (step % n)) % n, step > from)
163 };
164 search.matches(&labels[row]).then_some(Hit { row, wrapped })
165 })
166}
167
168pub fn count(search: &Search, labels: &[String]) -> usize {
170 if search.is_empty() {
171 return 0;
172 }
173 labels.iter().filter(|l| search.matches(l)).count()
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 fn labels(v: &[&str]) -> Vec<String> {
181 v.iter().map(|s| s.to_string()).collect()
182 }
183
184 #[test]
185 fn a_lowercase_pattern_ignores_case() {
186 let s = Search::new("charge");
187 assert!(s.matches("ChargeCard"));
188 assert!(s.matches("CHARGE"));
189 }
190
191 #[test]
192 fn a_pattern_with_uppercase_is_case_sensitive() {
193 let s = Search::new("Charge");
194 assert!(s.matches("ChargeCard"));
195 assert!(!s.matches("chargecard"));
196 }
197
198 #[test]
199 fn an_empty_pattern_matches_nothing() {
200 let s = Search::new("");
203 assert!(!s.matches("anything"));
204 assert_eq!(find(&s, &labels(&["a", "b"]), 0, true, false), None);
205 }
206
207 #[test]
208 fn spans_point_into_the_original_string() {
209 let s = Search::new("ab");
210 let hay = "xxabyyab";
211 assert_eq!(s.spans(hay), vec![(2, 4), (6, 8)]);
212 for (a, b) in s.spans(hay) {
213 assert_eq!(&hay[a..b], "ab");
215 }
216 }
217
218 #[test]
219 fn spans_survive_a_multibyte_haystack() {
220 let s = Search::new("é");
221 let hay = "aéb";
222 let spans = s.spans(hay);
223 assert_eq!(spans.len(), 1);
224 let (a, b) = spans[0];
225 assert_eq!(&hay[a..b], "é");
226 }
227
228 #[test]
229 fn overlapping_matches_are_reported_once() {
230 let s = Search::new("aa");
232 assert_eq!(s.spans("aaa"), vec![(0, 2)]);
233 }
234
235 #[test]
236 fn find_skips_the_row_the_cursor_is_on() {
237 let rows = labels(&["charge", "ship", "charge"]);
238 let s = Search::new("charge");
239 assert_eq!(find(&s, &rows, 0, true, false).unwrap().row, 2);
240 }
241
242 #[test]
243 fn find_wraps_and_says_so() {
244 let rows = labels(&["charge", "ship", "refund"]);
245 let s = Search::new("charge");
246 let hit = find(&s, &rows, 1, true, false).unwrap();
247 assert_eq!(hit.row, 0);
248 assert!(hit.wrapped, "going forward past the last match wraps");
249 }
250
251 #[test]
252 fn find_backwards_wraps_too() {
253 let rows = labels(&["charge", "ship", "refund"]);
254 let s = Search::new("refund");
255 let hit = find(&s, &rows, 0, false, false).unwrap();
256 assert_eq!(hit.row, 2);
257 assert!(hit.wrapped);
258 }
259
260 #[test]
261 fn a_sole_match_is_found_from_itself_by_wrapping() {
262 let rows = labels(&["charge", "ship"]);
265 let s = Search::new("charge");
266 let hit = find(&s, &rows, 0, true, false).unwrap();
267 assert_eq!(hit.row, 0);
268 assert!(hit.wrapped);
269 }
270
271 #[test]
272 fn no_match_is_none_rather_than_a_jump_to_zero() {
273 let rows = labels(&["charge", "ship"]);
274 assert_eq!(find(&Search::new("nope"), &rows, 0, true, false), None);
275 }
276
277 #[test]
278 fn an_inclusive_search_from_row_zero_does_not_claim_to_have_wrapped() {
279 let rows = labels(&["charge", "ship", "refund"]);
282 let hit = find(&Search::new("charge"), &rows, 0, true, true).unwrap();
283 assert_eq!(hit.row, 0);
284 assert!(!hit.wrapped, "row 0 is where we started, not a wrap");
285 }
286
287 #[test]
288 fn an_inclusive_search_matches_the_row_it_starts_on() {
289 let rows = labels(&["charge", "ship", "charge"]);
290 let hit = find(&Search::new("charge"), &rows, 2, true, true).unwrap();
291 assert_eq!(hit.row, 2, "the cursor's own row is a candidate");
292 assert!(!hit.wrapped);
293 }
294
295 #[test]
296 fn an_inclusive_search_still_reports_a_real_wrap() {
297 let rows = labels(&["charge", "ship", "refund"]);
298 let hit = find(&Search::new("charge"), &rows, 1, true, true).unwrap();
299 assert_eq!(hit.row, 0);
300 assert!(hit.wrapped, "going past the end to reach it is a wrap");
301 }
302
303 #[test]
304 fn an_exclusive_search_from_row_zero_reports_a_backward_wrap() {
305 let rows = labels(&["charge", "ship", "refund"]);
306 let hit = find(&Search::new("refund"), &rows, 0, false, false).unwrap();
307 assert_eq!(hit.row, 2);
308 assert!(hit.wrapped);
309 }
310
311 #[test]
312 fn count_reports_every_matching_row() {
313 let rows = labels(&["charge", "ship", "Charge"]);
314 assert_eq!(count(&Search::new("charge"), &rows), 2);
315 assert_eq!(count(&Search::new("Charge"), &rows), 1);
316 }
317}