reovim_plugin_microscope/microscope/
matcher.rs

1//! Streaming fuzzy matcher using Nucleo<T>
2//!
3//! This module provides non-blocking fuzzy matching using nucleo's streaming API.
4//! Items can be injected from background tasks while the matcher continues processing.
5//!
6//! # Architecture
7//!
8//! ```text
9//! ┌─────────────┐     ┌──────────────┐     ┌─────────────┐
10//! │ Background  │────>│  Injector<T> │────>│  Nucleo<T>  │
11//! │   Tasks     │     │  (lock-free) │     │ (workers)   │
12//! └─────────────┘     └──────────────┘     └─────────────┘
13//!                                                 │
14//!                                          ┌──────┴──────┐
15//!                                          │  tick(10ms) │
16//!                                          │ (non-block) │
17//!                                          └──────┬──────┘
18//!                                                 │
19//!                                          ┌──────┴──────┐
20//!                                          │  snapshot() │
21//!                                          │  (instant)  │
22//!                                          └─────────────┘
23//! ```
24
25use std::sync::Arc;
26
27use nucleo::{
28    Config, Injector, Nucleo, Utf32String,
29    pattern::{CaseMatching, Normalization},
30};
31
32use super::item::MicroscopeItem;
33
34/// Data stored in nucleo for each item
35#[derive(Clone)]
36pub struct MatcherItem {
37    /// The original microscope item
38    pub item: MicroscopeItem,
39    /// Pre-converted match text for nucleo
40    match_text: Utf32String,
41}
42
43impl MatcherItem {
44    /// Create a new matcher item from a microscope item
45    #[must_use]
46    pub fn new(item: MicroscopeItem) -> Self {
47        let match_text = Utf32String::from(item.match_text());
48        Self { item, match_text }
49    }
50}
51
52/// Status of the matcher after a tick
53#[derive(Debug, Clone, Copy)]
54pub struct MatcherStatus {
55    /// Whether matching is still in progress
56    pub running: bool,
57    /// Whether new items were added since last tick
58    pub changed: bool,
59}
60
61/// Streaming fuzzy matcher using Nucleo<T>
62///
63/// Provides non-blocking fuzzy matching with progressive results.
64/// Items are injected via `Injector` and results are retrieved via `snapshot()`.
65///
66/// # Example
67///
68/// ```ignore
69/// let matcher = MicroscopeMatcher::new();
70///
71/// // Get injector for background task
72/// let injector = matcher.injector();
73/// tokio::spawn(async move {
74///     for item in items {
75///         injector.push(MatcherItem::new(item), |mi, cols| {
76///             cols[0] = mi.match_text.clone();
77///         });
78///     }
79/// });
80///
81/// // In render loop
82/// matcher.tick(10); // Process for max 10ms
83/// let results = matcher.get_matched_items(100); // Get top 100 results
84/// ```
85pub struct MicroscopeMatcher {
86    nucleo: Nucleo<MatcherItem>,
87}
88
89impl MicroscopeMatcher {
90    /// Create a new streaming matcher
91    ///
92    /// Uses smart case matching and Unicode normalization.
93    /// Spawns worker threads for parallel matching.
94    #[must_use]
95    pub fn new() -> Self {
96        let config = Config::DEFAULT;
97        // notify callback (called when results change) - we use polling via tick()
98        let notify = Arc::new(|| {});
99        // Number of columns to match against (we use 1: the display text)
100        let columns = 1;
101
102        Self {
103            nucleo: Nucleo::new(config, notify, None, columns),
104        }
105    }
106
107    /// Get an injector for pushing items from background tasks
108    ///
109    /// The injector is thread-safe and can be cloned and sent to other threads.
110    #[must_use]
111    pub fn injector(&self) -> Injector<MatcherItem> {
112        self.nucleo.injector()
113    }
114
115    /// Update the search pattern
116    ///
117    /// This triggers re-matching of all items against the new pattern.
118    pub fn set_pattern(&mut self, query: &str) {
119        self.nucleo.pattern.reparse(
120            0,
121            query,
122            CaseMatching::Smart,
123            Normalization::Smart,
124            query.starts_with(char::is_lowercase),
125        );
126    }
127
128    /// Process pending matches (non-blocking)
129    ///
130    /// Call this regularly (e.g., in render loop) to process matches.
131    /// Returns status indicating if more processing is needed.
132    ///
133    /// # Arguments
134    ///
135    /// * `timeout_ms` - Maximum time to spend processing (in milliseconds)
136    #[must_use]
137    pub fn tick(&mut self, timeout_ms: u64) -> MatcherStatus {
138        let status = self.nucleo.tick(timeout_ms);
139        MatcherStatus {
140            running: status.running,
141            changed: status.changed,
142        }
143    }
144
145    /// Get matched items from the current snapshot
146    ///
147    /// Returns items sorted by match score (best matches first).
148    /// This is instant and does not block.
149    ///
150    /// Note: The items are pre-sorted by nucleo, so the order reflects
151    /// the ranking. Individual scores are not exposed by nucleo's streaming API,
152    /// so we use the item index as a rough score proxy (lower = better match).
153    ///
154    /// # Arguments
155    ///
156    /// * `max_items` - Maximum number of items to return
157    #[must_use]
158    #[allow(clippy::cast_possible_truncation)]
159    pub fn get_matched_items(&self, max_items: usize) -> Vec<MicroscopeItem> {
160        let snapshot = self.nucleo.snapshot();
161        let count = snapshot.matched_item_count();
162        let limit = (max_items as u32).min(count);
163
164        snapshot
165            .matched_items(..limit)
166            .enumerate()
167            .map(|(idx, item)| {
168                let mut microscope_item = item.data.item.clone();
169                // Use reverse index as score proxy (higher = better match at top)
170                microscope_item.score = Some((limit - idx as u32) * 100);
171                microscope_item
172            })
173            .collect()
174    }
175
176    /// Get total number of items in the matcher
177    #[must_use]
178    pub fn total_count(&self) -> u32 {
179        self.nucleo.snapshot().item_count()
180    }
181
182    /// Get number of items matching the current pattern
183    #[must_use]
184    pub fn matched_count(&self) -> u32 {
185        self.nucleo.snapshot().matched_item_count()
186    }
187
188    /// Check if pattern is empty (all items match)
189    #[must_use]
190    pub fn is_pattern_empty(&self) -> bool {
191        self.nucleo.pattern.column_pattern(0).atoms.is_empty()
192    }
193
194    /// Restart the matcher (clear all items)
195    ///
196    /// Call this when switching pickers or starting a new search.
197    pub fn restart(&mut self) {
198        self.nucleo.restart(false);
199    }
200
201    /// Restart and also clear the pattern
202    pub fn restart_with_clear(&mut self) {
203        self.nucleo.restart(true);
204    }
205}
206
207impl Default for MicroscopeMatcher {
208    fn default() -> Self {
209        Self::new()
210    }
211}
212
213/// Push a microscope item into the injector
214///
215/// Helper function to properly format items for nucleo matching.
216pub fn push_item(injector: &Injector<MatcherItem>, item: MicroscopeItem) {
217    let matcher_item = MatcherItem::new(item);
218    injector.push(matcher_item, |mi, cols| {
219        cols[0] = mi.match_text.clone();
220    });
221}
222
223/// Push multiple items into the injector
224pub fn push_items(
225    injector: &Injector<MatcherItem>,
226    items: impl IntoIterator<Item = MicroscopeItem>,
227) {
228    for item in items {
229        push_item(injector, item);
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use {super::*, crate::microscope::item::MicroscopeData, std::path::PathBuf};
236
237    fn make_item(display: &str) -> MicroscopeItem {
238        MicroscopeItem::new(
239            display,
240            display,
241            MicroscopeData::FilePath(PathBuf::from(display)),
242            "test",
243        )
244    }
245
246    #[test]
247    fn test_new_matcher() {
248        let matcher = MicroscopeMatcher::new();
249        assert_eq!(matcher.total_count(), 0);
250        assert_eq!(matcher.matched_count(), 0);
251        assert!(matcher.is_pattern_empty());
252    }
253
254    #[test]
255    fn test_inject_and_match() {
256        let mut matcher = MicroscopeMatcher::new();
257        let injector = matcher.injector();
258
259        // Push items
260        push_item(&injector, make_item("foo.rs"));
261        push_item(&injector, make_item("bar.rs"));
262        push_item(&injector, make_item("foobar.rs"));
263
264        // Process matches
265        let _ = matcher.tick(100);
266
267        // All items should match (empty pattern)
268        assert_eq!(matcher.total_count(), 3);
269        assert_eq!(matcher.matched_count(), 3);
270
271        let results = matcher.get_matched_items(10);
272        assert_eq!(results.len(), 3);
273    }
274
275    #[test]
276    fn test_pattern_matching() {
277        let mut matcher = MicroscopeMatcher::new();
278        let injector = matcher.injector();
279
280        push_item(&injector, make_item("foo.rs"));
281        push_item(&injector, make_item("bar.rs"));
282        push_item(&injector, make_item("foobar.rs"));
283
284        let _ = matcher.tick(100);
285
286        // Set pattern
287        matcher.set_pattern("foo");
288        let _ = matcher.tick(100);
289
290        // Only "foo.rs" and "foobar.rs" should match
291        let results = matcher.get_matched_items(10);
292        assert!(results.iter().any(|i| i.display == "foo.rs"));
293        assert!(results.iter().any(|i| i.display == "foobar.rs"));
294        assert!(!results.iter().any(|i| i.display == "bar.rs"));
295    }
296
297    #[test]
298    fn test_restart() {
299        let mut matcher = MicroscopeMatcher::new();
300        let injector = matcher.injector();
301
302        push_item(&injector, make_item("test.rs"));
303        let _ = matcher.tick(100);
304        assert_eq!(matcher.total_count(), 1);
305
306        // Restart clears items
307        matcher.restart();
308        let _ = matcher.tick(100);
309        assert_eq!(matcher.total_count(), 0);
310    }
311
312    #[test]
313    fn test_score_ordering() {
314        let mut matcher = MicroscopeMatcher::new();
315        let injector = matcher.injector();
316
317        push_item(&injector, make_item("src/lib/main.rs"));
318        push_item(&injector, make_item("main.rs"));
319        push_item(&injector, make_item("src/main_helper.rs"));
320
321        let _ = matcher.tick(100);
322        matcher.set_pattern("main");
323        let _ = matcher.tick(100);
324
325        let results = matcher.get_matched_items(10);
326
327        // "main.rs" should be ranked higher (better match)
328        assert!(!results.is_empty());
329        // First result should have highest score
330        if results.len() > 1 {
331            assert!(results[0].score >= results[1].score);
332        }
333    }
334}