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;