1use nucleo::pattern::{CaseMatching, Normalization};
2use nucleo::{Config, Nucleo};
3use std::cmp::Ordering;
4use std::sync::Arc;
5
6pub trait Searchable: Clone {
7 fn search_text(&self) -> String;
8}
9
10const MAX_MATCHES: u32 = 200;
11const MATCH_TIMEOUT_MS: u64 = 10;
12const MAX_TICKS_PER_QUERY: usize = 4;
13
14pub struct FuzzyMatcher<T: Searchable + Send + Sync + 'static> {
15 query: String,
16 matches: Vec<T>,
17 matcher: Nucleo<T>,
18 match_sort: Option<fn(&T, &T) -> Ordering>,
19}
20
21impl<T: Searchable + Send + Sync + 'static> FuzzyMatcher<T> {
22 pub fn new(items: Vec<T>) -> Self {
23 let mut matcher = Nucleo::new(Config::DEFAULT, Arc::new(|| {}), Some(1), 1);
24 let injector = matcher.injector();
25 for item in items {
26 injector.push(item, |item, columns| {
27 let text = item.search_text();
28 columns[0] = text.as_str().into();
29 });
30 }
31 let _ = matcher.tick(0);
32
33 let mut fuzzy = Self {
34 query: String::new(),
35 matches: Vec::new(),
36 matcher,
37 match_sort: None,
38 };
39 fuzzy.matches = fuzzy.search(false);
40 fuzzy
41 }
42
43 pub fn from_matches(matches: Vec<T>) -> Self {
45 let nucleo = Nucleo::new(Config::DEFAULT, Arc::new(|| {}), Some(1), 1);
46 Self {
47 query: String::new(),
48 matches,
49 matcher: nucleo,
50 match_sort: None,
51 }
52 }
53
54 pub fn query(&self) -> &str {
55 &self.query
56 }
57
58 pub fn matches(&self) -> &[T] {
59 &self.matches
60 }
61
62 pub fn is_empty(&self) -> bool {
63 self.matches.is_empty()
64 }
65
66 pub fn set_match_sort(&mut self, sort: fn(&T, &T) -> Ordering) {
67 self.match_sort = Some(sort);
68 self.matches = self.search(false);
69 }
70
71 pub fn push_query_char(&mut self, c: char) {
72 self.query.push(c);
73 self.refresh_matches(true);
74 }
75
76 pub fn pop_query_char(&mut self) -> bool {
77 if self.query.pop().is_none() {
78 return false;
79 }
80 self.refresh_matches(false);
81 true
82 }
83
84 pub fn refresh_matches(&mut self, append: bool) {
87 self.matches = self.search(append);
88 }
89
90 fn search(&mut self, append: bool) -> Vec<T> {
91 self.matcher.pattern.reparse(
92 0,
93 &self.query,
94 CaseMatching::Smart,
95 Normalization::Smart,
96 append,
97 );
98 let mut status = self.matcher.tick(MATCH_TIMEOUT_MS);
99 let mut ticks = 0;
100 while status.running && ticks < MAX_TICKS_PER_QUERY {
101 status = self.matcher.tick(MATCH_TIMEOUT_MS);
102 ticks += 1;
103 }
104
105 let snapshot = self.matcher.snapshot();
106 let limit = snapshot.matched_item_count().min(MAX_MATCHES);
107 let mut matches: Vec<T> = snapshot
108 .matched_items(0..limit)
109 .map(|item| item.data.clone())
110 .collect();
111 if let Some(sort) = self.match_sort {
112 matches.sort_by(sort);
113 }
114 matches
115 }
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121
122 #[derive(Debug, Clone, PartialEq)]
123 struct FakeItem {
124 text: String,
125 }
126
127 impl FakeItem {
128 fn new(text: &str) -> Self {
129 Self {
130 text: text.to_string(),
131 }
132 }
133 }
134
135 impl Searchable for FakeItem {
136 fn search_text(&self) -> String {
137 self.text.clone()
138 }
139 }
140
141 #[test]
142 fn new_returns_all_items_with_empty_query() {
143 let items = vec![
144 FakeItem::new("alpha"),
145 FakeItem::new("beta"),
146 FakeItem::new("gamma"),
147 ];
148 let matcher = FuzzyMatcher::new(items);
149 assert_eq!(matcher.matches().len(), 3);
150 assert_eq!(matcher.query(), "");
151 }
152
153 #[test]
154 fn push_query_char_filters_matches() {
155 let items = vec![
156 FakeItem::new("apple"),
157 FakeItem::new("banana"),
158 FakeItem::new("avocado"),
159 ];
160 let mut matcher = FuzzyMatcher::new(items);
161 for c in "ban".chars() {
162 matcher.push_query_char(c);
163 }
164 assert_eq!(matcher.matches().len(), 1);
165 assert_eq!(matcher.matches()[0].text, "banana");
166 }
167
168 #[test]
169 fn push_and_pop_query_char() {
170 let items = vec![
171 FakeItem::new("cat"),
172 FakeItem::new("car"),
173 FakeItem::new("dog"),
174 ];
175 let mut matcher = FuzzyMatcher::new(items);
176 matcher.push_query_char('c');
177 assert_eq!(matcher.query(), "c");
178 matcher.push_query_char('a');
179 assert_eq!(matcher.query(), "ca");
180
181 matcher.pop_query_char();
182 assert_eq!(matcher.query(), "c");
183 matcher.pop_query_char();
184 assert_eq!(matcher.query(), "");
185
186 matcher.pop_query_char();
188 assert_eq!(matcher.query(), "");
189 }
190
191 #[test]
192 fn from_matches_populates_directly() {
193 let matches = vec![FakeItem::new("pre-populated")];
194 let matcher = FuzzyMatcher::from_matches(matches);
195 assert_eq!(matcher.matches().len(), 1);
196 }
197
198 #[test]
199 fn is_empty_reflects_match_state() {
200 let empty: FuzzyMatcher<FakeItem> = FuzzyMatcher::from_matches(vec![]);
201 assert!(empty.is_empty());
202
203 let non_empty = FuzzyMatcher::from_matches(vec![FakeItem::new("a")]);
204 assert!(!non_empty.is_empty());
205 }
206
207 #[test]
208 fn set_match_sort_reorders_matches() {
209 let items = vec![
210 FakeItem::new("banana"),
211 FakeItem::new("apple"),
212 FakeItem::new("cherry"),
213 ];
214 let mut matcher = FuzzyMatcher::new(items);
215 matcher.set_match_sort(|a, b| a.text.cmp(&b.text));
216 assert_eq!(matcher.matches()[0].text, "apple");
217 assert_eq!(matcher.matches()[1].text, "banana");
218 assert_eq!(matcher.matches()[2].text, "cherry");
219 }
220}