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 { query: String::new(), matches: Vec::new(), matcher, match_sort: None };
34        fuzzy.matches = fuzzy.search(false);
35        fuzzy
36    }
37
38    /// Creates a `FuzzyMatcher` with pre-populated matches (no Nucleo indexing).
39    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    /// Re-runs the search and updates the stored matches. Returns true if the
75    /// match count changed (callers may need to clamp selection indices).
76    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        // pop on empty is no-op
154        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}