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}