Skip to main content

tui/
fuzzy_matcher.rs

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    /// Creates a `FuzzyMatcher` with pre-populated matches (no Nucleo indexing).
44    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    /// Re-runs the search and updates the stored matches. Returns true if the
85    /// match count changed (callers may need to clamp selection indices).
86    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        // pop on empty is no-op
187        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}