Skip to main content

whycodes_index/
fuzzy.rs

1//! Fuzzy matching over the index: a `nucleo` engine fed by streaming
2//! injection, so keystroke queries never touch the filesystem and matches
3//! appear while the initial walk is still running.
4//!
5//! Query handling mirrors the proven helix patterns:
6//! path-aware scoring (`Config::match_paths`), smart case, incremental
7//! reparse when the query grows by one char, and a length-scaled minimum
8//! score to keep junk out of short queries.
9
10use std::sync::Arc;
11use std::sync::atomic::{AtomicBool, Ordering};
12
13use nucleo::pattern::{CaseMatching, MultiPattern, Normalization, Pattern};
14use nucleo::{Config, Injector, Matcher, Nucleo};
15
16/// Matcher threads scale with the host: matching cost grows with item count,
17/// and a TUI frame budget is 16 ms. Two threads starve at 35k+ items; four
18/// keeps full rematches in the low single-digit ms there.
19fn matcher_threads() -> usize {
20    matcher_thread_count(
21        std::thread::available_parallelism()
22            .map(|n| n.get())
23            .unwrap_or(2),
24    )
25}
26
27pub(crate) fn matcher_thread_count(n: usize) -> usize {
28    match n {
29        0..=4 => 2,
30        5..=8 => 3,
31        _ => 4,
32    }
33}
34
35/// Per-item payload stored alongside the matched column.
36#[derive(Debug, Clone, Copy)]
37pub struct EntryMeta {
38    pub is_dir: bool,
39}
40
41/// One fuzzy match returned to the UI.
42#[derive(Debug, Clone, Default)]
43pub struct FileMatch {
44    /// Root-relative path (no trailing slash for dirs).
45    pub rel: String,
46    pub is_dir: bool,
47    /// Matcher score; higher is better. 0 in browse mode.
48    pub score: u32,
49    /// Matched character positions in `rel` (for highlighting).
50    pub indices: Vec<u32>,
51    /// Which root the match came from (index into `WorkspaceIndex::roots`).
52    pub root: u16,
53}
54
55/// Length-scaled floor that keeps weak tail matches out of the picker
56/// (helix completion uses `7 * len + 14`; this uses `7 + len * 14`).
57fn min_score(query: &str) -> u32 {
58    7 + query.chars().count() as u32 * 14
59}
60
61/// Bound for `nucleo::tick` when we must wait for a snapshot. `5` was enough
62/// isolated; llvm-cov / parallel CI needs the same wait as a blocking query.
63const SETTLE_MS: u64 = 50;
64
65pub(crate) fn below_floor(score: u32, floor: u32) -> bool {
66    score < floor
67}
68
69/// Streaming fuzzy engine over path strings.
70pub struct FuzzyEngine {
71    nucleo: Nucleo<EntryMeta>,
72    /// Reused for highlight-index computation (holds internal scratch space).
73    matcher: Matcher,
74    /// Last parsed query (drives the incremental-reparse fast path).
75    query: String,
76    /// True while the matcher workers are busy (from the last `tick(0)`).
77    running: bool,
78}
79
80impl FuzzyEngine {
81    /// `results_dirty` is shared with the index so one flag wakes the UI;
82    /// nucleo's notify callback flips it whenever workers publish results.
83    pub fn new(results_dirty: Arc<AtomicBool>) -> Self {
84        let config = Config::DEFAULT.match_paths();
85        let notify = move || results_dirty.store(true, Ordering::Release);
86        let mut nucleo = Nucleo::new(config.clone(), Arc::new(notify), Some(matcher_threads()), 1);
87        nucleo.pattern = MultiPattern::new(1);
88        Self {
89            nucleo,
90            matcher: Matcher::new(config),
91            query: String::new(),
92            running: false,
93        }
94    }
95
96    /// A clonable handle for walk threads to stream entries into the engine.
97    pub fn injector(&self) -> Injector<EntryMeta> {
98        self.nucleo.injector()
99    }
100
101    /// Push one entry through an injector (thread-safe, callable anywhere).
102    pub fn push(injector: &Injector<EntryMeta>, rel: &str, is_dir: bool) {
103        // Dirs carry a trailing `/` in the matched column: it reads naturally
104        // in the picker and makes `src/` style queries work. Stripped on the
105        // way back out via `EntryMeta::is_dir`.
106        let text = if is_dir {
107            format!("{rel}/")
108        } else {
109            rel.to_owned()
110        };
111        injector.push(EntryMeta { is_dir }, move |_, cols| {
112            cols[0] = text.into();
113        });
114    }
115
116    /// Drop all items, keeping allocations. Used before bulk re-injection
117    /// after watcher removals (nucleo has no per-item removal).
118    pub fn restart(&mut self) {
119        self.nucleo.restart(true);
120        self.query.clear();
121    }
122
123    /// Nudge the matcher without waiting; returns true while work (matching
124    /// or walk ingestion) is still in flight.
125    pub fn nudge(&mut self) -> bool {
126        let status = self.nucleo.tick(0);
127        self.running = status.running || self.nucleo.active_injectors() > 0;
128        self.running
129    }
130
131    /// Re-parse the current query and wait for a snapshot.
132    ///
133    /// `set_query` does not reparse when the pattern is unchanged. Under load
134    /// a `tick(0)` can miss nucleo's notify (workers finish with
135    /// `should_notify == false`), so the picker sits on an empty snapshot
136    /// until the next keystroke. The idle-empty poll path calls this.
137    ///
138    /// Wait matches [`query_blocking`]: `tick(5)` times out under llvm-cov /
139    /// parallel CI and leaves `picker_flow_over_real_index` on
140    /// `Ready { total: 4 }` with `matches=[]`.
141    pub fn rearm(&mut self) {
142        if self.query.is_empty() {
143            return;
144        }
145        let pattern = self.query.clone();
146        self.nucleo.pattern.reparse(
147            0,
148            &pattern,
149            CaseMatching::Smart,
150            Normalization::Smart,
151            false,
152        );
153        let status = self.nucleo.tick(SETTLE_MS);
154        self.running = status.running || self.nucleo.active_injectors() > 0;
155    }
156
157    /// Non-blocking query update: reparse and `tick(0)`. Never wait here —
158    /// a keystroke must not block on a full rematch (3–16 ms at 35k items).
159    pub fn set_query(&mut self, pattern: &str) {
160        if pattern == self.query {
161            // Same pattern is not a full no-op: a missed notify still needs
162            // a tick so the next poll can adopt an already-finished snapshot.
163            let status = self.nucleo.tick(0);
164            self.running = status.running || self.nucleo.active_injectors() > 0;
165            return;
166        }
167        let append = pattern.as_bytes().starts_with(self.query.as_bytes())
168            && !pattern.ends_with('\\')
169            && !pattern
170                .as_bytes()
171                .last()
172                .is_some_and(|ch| ch.is_ascii_whitespace());
173        self.nucleo.pattern.reparse(
174            0,
175            pattern,
176            CaseMatching::Smart,
177            Normalization::Smart,
178            append,
179        );
180        self.query.clear();
181        self.query.push_str(pattern);
182        self.nucleo.tick(0);
183    }
184
185    /// Read the matches currently in the snapshot (best-first, capped at
186    /// `limit`). Never blocks longer than a `tick(0)`; the second return
187    /// value is true while the matcher workers are still refining results.
188    pub fn read(&mut self, limit: usize) -> (Vec<FileMatch>, bool) {
189        let pattern = self.query.clone();
190        if pattern.is_empty() {
191            let status = self.nucleo.tick(0);
192            self.running = status.running;
193            return (Vec::new(), self.running); // browse mode is store-backed
194        }
195        let status = self.nucleo.tick(0);
196        self.running = status.running;
197
198        // Split borrows: snapshot borrows `nucleo`, indices need `matcher`.
199        let Self {
200            nucleo, matcher, ..
201        } = self;
202        let snapshot = nucleo.snapshot();
203        let col_pattern = Pattern::parse(&pattern, CaseMatching::Smart, Normalization::Smart);
204        let floor = min_score(&pattern);
205
206        // `matched_items` yields best-first; scores are recomputed per item
207        // (cheap at picker sizes) so the length-scaled floor can drop junk.
208        let total = snapshot.matched_item_count();
209        let mut out = Vec::with_capacity(limit.min(256));
210        for item in snapshot.matched_items(0..total.min(limit as u32)) {
211            let text = &item.matcher_columns[0];
212            let mut indices = Vec::new();
213            let score = col_pattern
214                .indices(text.slice(..), matcher, &mut indices)
215                .unwrap_or(0);
216            if below_floor(score, floor) {
217                continue;
218            }
219            let mut rel = text.to_string();
220            let is_dir = item.data.is_dir;
221            if is_dir && rel.ends_with('/') {
222                rel.pop();
223            }
224            out.push(FileMatch {
225                rel,
226                is_dir,
227                score,
228                indices,
229                root: 0, // filled in by the caller (per-root engine)
230            });
231        }
232        (out, self.running)
233    }
234
235    /// Blocking convenience for tests and one-shot tools: set the query and
236    /// wait (bounded) for the matcher to settle, then read.
237    pub fn query_blocking(&mut self, pattern: &str, limit: usize) -> Vec<FileMatch> {
238        self.set_query(pattern);
239        self.nucleo.tick(SETTLE_MS);
240        self.read(limit).0
241    }
242}
243
244impl Default for FuzzyEngine {
245    fn default() -> Self {
246        Self::new(Arc::new(AtomicBool::new(false)))
247    }
248}
249
250#[cfg(test)]
251#[path = "fuzzy_tests.rs"]
252mod tests;