1use std::path::Path;
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7
8use nucleo_matcher::pattern::{AtomKind, CaseMatching, Normalization, Pattern};
9use nucleo_matcher::{Config, Matcher, Utf32Str};
10
11const TTL: Duration = Duration::from_secs(30);
12const MAX_RESULTS: usize = 10;
13const MAX_INDEXED: usize = 50_000;
16
17pub struct FileIndex {
18 paths: Arc<Vec<String>>,
19 built_at: Instant,
20}
21
22impl FileIndex {
23 #[must_use]
33 pub fn build(root: &Path) -> Self {
34 let mut paths = Vec::new();
35 let walker = ignore::WalkBuilder::new(root)
36 .hidden(true) .ignore(true)
38 .git_ignore(true)
39 .build();
40
41 for entry in walker.flatten() {
42 if entry.file_type().is_some_and(|ft| ft.is_file()) {
43 let path = entry.path();
44 let rel = path.strip_prefix(root).unwrap_or(path);
45 if let Some(s) = rel.to_str() {
46 paths.push(s.replace('\\', "/"));
48 }
49 if paths.len() >= MAX_INDEXED {
50 tracing::warn!(
51 max = MAX_INDEXED,
52 root = %root.display(),
53 "file index cap reached; some files will not be searchable"
54 );
55 break;
56 }
57 }
58 }
59 paths.sort_unstable();
60 Self {
61 paths: Arc::new(paths),
62 built_at: Instant::now(),
63 }
64 }
65
66 #[must_use]
67 pub fn is_stale(&self) -> bool {
68 self.built_at.elapsed() > TTL
69 }
70
71 #[must_use]
72 pub fn paths(&self) -> &[String] {
73 &self.paths
74 }
75
76 #[must_use]
77 pub fn paths_arc(&self) -> Arc<Vec<String>> {
78 Arc::clone(&self.paths)
79 }
80}
81
82#[derive(Clone)]
83pub struct PickerMatch {
84 pub path: String,
85 pub score: u32,
86}
87
88pub struct FilePickerState {
89 pub query: String,
90 pub selected: usize,
91 matches: Vec<PickerMatch>,
92 index: Arc<Vec<String>>,
94 matcher: Matcher,
96}
97
98impl FilePickerState {
99 #[must_use]
100 pub fn new(index: &FileIndex) -> Self {
101 let mut state = Self {
102 query: String::new(),
103 selected: 0,
104 matches: Vec::new(),
105 index: index.paths_arc(),
106 matcher: Matcher::new(Config::DEFAULT),
107 };
108 state.refilter();
109 state
110 }
111
112 pub fn update_query(&mut self, query: &str) {
113 query.clone_into(&mut self.query);
114 self.refilter();
115 }
116
117 pub fn push_char(&mut self, c: char) {
119 self.query.push(c);
120 self.refilter();
121 }
122
123 pub fn pop_char(&mut self) -> bool {
126 if self.query.pop().is_some() {
127 self.refilter();
128 true
129 } else {
130 false
131 }
132 }
133
134 #[must_use]
135 pub fn matches(&self) -> &[PickerMatch] {
136 &self.matches
137 }
138
139 #[must_use]
140 pub fn selected_path(&self) -> Option<&str> {
141 self.matches.get(self.selected).map(|m| m.path.as_str())
142 }
143
144 pub fn move_selection(&mut self, delta: i32) {
145 let len = self.matches.len();
146 if len == 0 {
147 return;
148 }
149 let len_i = i32::try_from(len).unwrap_or(i32::MAX);
150 let cur_i = i32::try_from(self.selected).unwrap_or(0);
151 let new_i = (cur_i + delta).rem_euclid(len_i);
152 self.selected = usize::try_from(new_i).unwrap_or(0);
153 }
154
155 fn refilter(&mut self) {
156 self.selected = 0;
157 if self.query.is_empty() {
158 self.matches = self
159 .index
160 .iter()
161 .take(MAX_RESULTS)
162 .map(|p| PickerMatch {
163 path: p.clone(),
164 score: 0,
165 })
166 .collect();
167 return;
168 }
169
170 let pattern = Pattern::new(
171 &self.query,
172 CaseMatching::Smart,
173 Normalization::Smart,
174 AtomKind::Fuzzy,
175 );
176
177 let mut scored: Vec<PickerMatch> = self
178 .index
179 .iter()
180 .filter_map(|p| {
181 let mut buf = Vec::new();
182 let haystack = Utf32Str::new(p, &mut buf);
183 pattern
184 .score(haystack, &mut self.matcher)
185 .map(|score| PickerMatch {
186 path: p.clone(),
187 score,
188 })
189 })
190 .collect();
191
192 scored.sort_unstable_by_key(|entry| std::cmp::Reverse(entry.score));
193 scored.truncate(MAX_RESULTS);
194 self.matches = scored;
195 }
196}
197
198#[cfg(test)]
199mod tests {
200 use std::fs;
201
202 use super::*;
203
204 fn make_index(files: &[&str]) -> FileIndex {
205 let dir = tempfile::tempdir().unwrap();
206 for &f in files {
207 let path = dir.path().join(f);
208 if let Some(parent) = path.parent() {
209 fs::create_dir_all(parent).unwrap();
210 }
211 fs::write(&path, "").unwrap();
212 }
213 FileIndex::build(dir.path())
214 }
215
216 #[test]
217 fn build_collects_files() {
218 let idx = make_index(&["src/main.rs", "src/lib.rs", "README.md"]);
219 assert_eq!(idx.paths().len(), 3);
220 assert!(idx.paths().iter().any(|p| p.ends_with("main.rs")));
221 }
222
223 #[test]
224 fn is_stale_false_when_fresh() {
225 let idx = make_index(&["a.rs"]);
226 assert!(!idx.is_stale());
227 }
228
229 #[test]
230 fn empty_query_returns_up_to_10_files() {
231 let files: Vec<String> = (0..15).map(|i| format!("file{i}.rs")).collect();
232 let refs: Vec<&str> = files.iter().map(String::as_str).collect();
233 let idx = make_index(&refs);
234 let state = FilePickerState::new(&idx);
235 assert_eq!(state.matches().len(), 10);
236 }
237
238 #[test]
239 fn fuzzy_query_filters_results() {
240 let idx = make_index(&["src/main.rs", "src/lib.rs", "tests/foo.rs"]);
241 let mut state = FilePickerState::new(&idx);
242 state.update_query("main");
243 assert!(!state.matches().is_empty());
244 assert!(state.matches().iter().any(|m| m.path.contains("main")));
245 }
246
247 #[test]
248 fn selected_path_returns_first_match() {
249 let idx = make_index(&["alpha.rs", "beta.rs"]);
250 let state = FilePickerState::new(&idx);
251 assert!(state.selected_path().is_some());
252 }
253
254 #[test]
255 fn move_selection_wraps_around() {
256 let idx = make_index(&["a.rs", "b.rs", "c.rs"]);
257 let mut state = FilePickerState::new(&idx);
258 assert_eq!(state.selected, 0);
259 state.move_selection(-1);
260 assert_eq!(state.selected, state.matches().len() - 1);
261 }
262
263 #[test]
264 fn move_selection_noop_when_empty() {
265 let idx = make_index(&["a.rs"]);
266 let mut state = FilePickerState::new(&idx);
267 state.matches = vec![];
268 state.move_selection(1);
269 assert_eq!(state.selected, 0);
270 }
271
272 #[test]
273 fn no_match_query_returns_empty_and_selected_path_none() {
274 let idx = make_index(&["src/main.rs", "src/lib.rs"]);
275 let mut state = FilePickerState::new(&idx);
276 state.update_query("xyznotfound");
277 assert!(state.matches().is_empty());
278 assert!(state.selected_path().is_none());
279 }
280
281 #[test]
282 fn unicode_paths_are_indexed_and_searchable() {
283 let idx = make_index(&["src/данные.rs", "データ/main.rs", "normal.rs"]);
284 assert!(idx.paths().iter().any(|p| p.contains("данные")));
285 assert!(idx.paths().iter().any(|p| p.contains("main")));
286
287 let mut state = FilePickerState::new(&idx);
288 state.update_query("данные");
289 assert!(
290 !state.matches().is_empty(),
291 "expected match for unicode query"
292 );
293 }
294
295 #[test]
296 fn push_char_appends_and_refilters() {
297 let idx = make_index(&["src/main.rs", "src/lib.rs"]);
298 let mut state = FilePickerState::new(&idx);
299 state.push_char('m');
300 state.push_char('a');
301 assert!(state.matches().iter().any(|m| m.path.contains("main")));
302 }
303
304 #[test]
305 fn pop_char_removes_last_and_refilters() {
306 let idx = make_index(&["src/main.rs", "src/lib.rs"]);
307 let mut state = FilePickerState::new(&idx);
308 state.push_char('m');
309 let removed = state.pop_char();
310 assert!(removed);
311 assert!(state.query.is_empty());
312 }
313
314 #[test]
315 fn pop_char_on_empty_returns_false() {
316 let idx = make_index(&["a.rs"]);
317 let mut state = FilePickerState::new(&idx);
318 assert!(!state.pop_char());
319 }
320
321 #[test]
322 fn arc_index_shared_not_cloned() {
323 let idx = make_index(&["a.rs", "b.rs"]);
324 let arc1 = idx.paths_arc();
325 let state = FilePickerState::new(&idx);
326 assert!(Arc::ptr_eq(&arc1, &state.index));
328 }
329
330 use proptest::prelude::*;
331
332 proptest! {
333 #![proptest_config(proptest::test_runner::Config::with_cases(200))]
334
335 #[test]
336 fn move_selection_never_panics(
337 n in 1usize..20,
338 delta in -10i32..10,
339 ) {
340 let files: Vec<String> = (0..n).map(|i| format!("f{i}.rs")).collect();
341 let refs: Vec<&str> = files.iter().map(String::as_str).collect();
342 let idx = make_index(&refs);
343 let mut state = FilePickerState::new(&idx);
344 state.move_selection(delta);
345 prop_assert!(state.selected < state.matches().len().max(1));
346 }
347 }
348}