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 { query: String::new(), matches: Vec::new(), matcher, match_sort: None };
34 fuzzy.matches = fuzzy.search(false);
35 fuzzy
36 }
37
38 pub fn from_matches(matches: Vec<T>) -> Self {
40 let nucleo = Nucleo::new(Config::DEFAULT, Arc::new(|| {}), Some(1), 1);
41 Self { query: String::new(), matches, matcher: nucleo, match_sort: None }
42 }
43
44 pub fn query(&self) -> &str {
45 &self.query
46 }
47
48 pub fn matches(&self) -> &[T] {
49 &self.matches
50 }
51
52 pub fn is_empty(&self) -> bool {
53 self.matches.is_empty()
54 }
55
56 pub fn set_match_sort(&mut self, sort: fn(&T, &T) -> Ordering) {
57 self.match_sort = Some(sort);
58 self.matches = self.search(false);
59 }
60
61 pub fn push_query_char(&mut self, c: char) {
62 self.query.push(c);
63 self.refresh_matches(true);
64 }
65
66 pub fn pop_query_char(&mut self) -> bool {
67 if self.query.pop().is_none() {
68 return false;
69 }
70 self.refresh_matches(false);
71 true
72 }
73
74 pub fn refresh_matches(&mut self, append: bool) {
77 self.matches = self.search(append);
78 }
79
80 fn search(&mut self, append: bool) -> Vec<T> {
81 self.matcher.pattern.reparse(0, &self.query, CaseMatching::Smart, Normalization::Smart, append);
82 let mut status = self.matcher.tick(MATCH_TIMEOUT_MS);
83 let mut ticks = 0;
84 while status.running && ticks < MAX_TICKS_PER_QUERY {
85 status = self.matcher.tick(MATCH_TIMEOUT_MS);
86 ticks += 1;
87 }
88
89 let snapshot = self.matcher.snapshot();
90 let limit = snapshot.matched_item_count().min(MAX_MATCHES);
91 let mut matches: Vec<T> = snapshot.matched_items(0..limit).map(|item| item.data.clone()).collect();
92 if let Some(sort) = self.match_sort {
93 matches.sort_by(sort);
94 }
95 matches
96 }
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102
103 #[derive(Debug, Clone, PartialEq)]
104 struct FakeItem {
105 text: String,
106 }
107
108 impl FakeItem {
109 fn new(text: &str) -> Self {
110 Self { text: text.to_string() }
111 }
112 }
113
114 impl Searchable for FakeItem {
115 fn search_text(&self) -> String {
116 self.text.clone()
117 }
118 }
119
120 #[test]
121 fn new_returns_all_items_with_empty_query() {
122 let items = vec![FakeItem::new("alpha"), FakeItem::new("beta"), FakeItem::new("gamma")];
123 let matcher = FuzzyMatcher::new(items);
124 assert_eq!(matcher.matches().len(), 3);
125 assert_eq!(matcher.query(), "");
126 }
127
128 #[test]
129 fn push_query_char_filters_matches() {
130 let items = vec![FakeItem::new("apple"), FakeItem::new("banana"), FakeItem::new("avocado")];
131 let mut matcher = FuzzyMatcher::new(items);
132 for c in "ban".chars() {
133 matcher.push_query_char(c);
134 }
135 assert_eq!(matcher.matches().len(), 1);
136 assert_eq!(matcher.matches()[0].text, "banana");
137 }
138
139 #[test]
140 fn push_and_pop_query_char() {
141 let items = vec![FakeItem::new("cat"), FakeItem::new("car"), FakeItem::new("dog")];
142 let mut matcher = FuzzyMatcher::new(items);
143 matcher.push_query_char('c');
144 assert_eq!(matcher.query(), "c");
145 matcher.push_query_char('a');
146 assert_eq!(matcher.query(), "ca");
147
148 matcher.pop_query_char();
149 assert_eq!(matcher.query(), "c");
150 matcher.pop_query_char();
151 assert_eq!(matcher.query(), "");
152
153 matcher.pop_query_char();
155 assert_eq!(matcher.query(), "");
156 }
157
158 #[test]
159 fn from_matches_populates_directly() {
160 let items = vec![FakeItem::new("pre-populated")];
161 let fuzzy = FuzzyMatcher::from_matches(items);
162 assert_eq!(fuzzy.matches().len(), 1);
163 }
164
165 #[test]
166 fn is_empty_reflects_match_state() {
167 let empty: FuzzyMatcher<FakeItem> = FuzzyMatcher::from_matches(vec![]);
168 assert!(empty.is_empty());
169
170 let non_empty = FuzzyMatcher::from_matches(vec![FakeItem::new("a")]);
171 assert!(!non_empty.is_empty());
172 }
173
174 #[test]
175 fn set_match_sort_reorders_matches() {
176 let items = vec![FakeItem::new("banana"), FakeItem::new("apple"), FakeItem::new("cherry")];
177 let mut matcher = FuzzyMatcher::new(items);
178 matcher.set_match_sort(|a, b| a.text.cmp(&b.text));
179 assert_eq!(matcher.matches()[0].text, "apple");
180 assert_eq!(matcher.matches()[1].text, "banana");
181 assert_eq!(matcher.matches()[2].text, "cherry");
182 }
183}