datui_lib/search.rs
1//! Recursive search for datasets below a directory.
2//!
3//! The home screen's filter is a fuzzy match over rows that are already listed. This
4//! module is what puts more rows in front of it: one bounded walk of the working
5//! directory, run once in the background, whose result is then filtered in memory
6//! like everything else. Nothing here is repeated per keystroke — a walk per
7//! character is how a file finder becomes slow on exactly the trees where it matters.
8//!
9//! Every limit exists because some real directory violates it. See [`Limits`].
10//!
11//! The walk keeps every data file it finds, and the filter is scored against that
12//! index off the UI thread ([`score`]). The cap on what is listed counts matches, not
13//! files: a file that matches is never lost behind thousands that do not.
14
15use crate::config::SearchConfig;
16use crate::discover::{Entry, EntryKind, is_data_file};
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19use std::time::{Duration, Instant};
20
21/// The most files one walk keeps. The time budget bounds a walk first in practice; this
22/// bounds its memory on a tree fast enough to list a million names inside it.
23pub const MAX_INDEXED: usize = 100_000;
24
25/// How far a walk got, and why it stopped.
26///
27/// A search that quietly returned less than the truth would be worse than no search:
28/// "not found here" is a thing people act on.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30pub struct Outcome {
31 /// Directory entries examined, whether or not they were data.
32 pub scanned: usize,
33 /// Stopped at [`MAX_INDEXED`] files.
34 pub hit_result_limit: bool,
35 /// Stopped at `time_budget_ms`.
36 pub hit_time_limit: bool,
37 /// Stopped at `max_depth` somewhere; deeper datasets may exist.
38 pub hit_depth_limit: bool,
39}
40
41impl Outcome {
42 pub fn complete(&self) -> bool {
43 !self.hit_result_limit && !self.hit_time_limit && !self.hit_depth_limit
44 }
45
46 /// A short phrase for the section heading, or `None` when the walk saw everything.
47 pub fn note(&self) -> Option<&'static str> {
48 if self.hit_time_limit {
49 Some("partial · out of time")
50 } else if self.hit_result_limit {
51 Some("partial · too many files")
52 } else if self.hit_depth_limit {
53 Some("partial · too deep")
54 } else {
55 None
56 }
57 }
58}
59
60/// How often the walker hands back what it has found so far.
61///
62/// Small enough that a cold tree fills the screen while it is still working, large
63/// enough that a warm one does not spend its time sending messages.
64const BATCH: usize = 64;
65const BATCH_INTERVAL: Duration = Duration::from_millis(120);
66
67/// Walk `root` for datasets, handing batches to `emit` as they are found.
68///
69/// `emit` returns `false` to abandon the walk — the caller has moved on, and there is
70/// no reason to keep reading a filesystem for an answer nobody is waiting for.
71///
72/// This blocks and touches the filesystem, so it must never be called from the thread
73/// drawing the screen.
74pub fn walk<F>(root: &Path, config: &SearchConfig, emit: F) -> Outcome
75where
76 F: FnMut(Vec<Entry>, Outcome) -> bool,
77{
78 walk_up_to(root, config, MAX_INDEXED, emit)
79}
80
81/// [`walk`], keeping the files `formats` reads as well, as the listing names them: by a
82/// spec's glob, or by its magic in the first bytes of a file whose name says nothing,
83/// at most [`crate::discover::MAX_SNIFFS_PER_DIR`] of them a directory.
84pub fn walk_with_specs<F>(
85 root: &Path,
86 config: &SearchConfig,
87 formats: &crate::formats::Registry,
88 emit: F,
89) -> Outcome
90where
91 F: FnMut(Vec<Entry>, Outcome) -> bool,
92{
93 walk_inner(root, config, MAX_INDEXED, formats, emit)
94}
95
96/// [`walk`], keeping at most `cap` files.
97pub fn walk_up_to<F>(root: &Path, config: &SearchConfig, cap: usize, emit: F) -> Outcome
98where
99 F: FnMut(Vec<Entry>, Outcome) -> bool,
100{
101 walk_inner(
102 root,
103 config,
104 cap,
105 &crate::formats::Registry::default(),
106 emit,
107 )
108}
109
110fn walk_inner<F>(
111 root: &Path,
112 config: &SearchConfig,
113 cap: usize,
114 formats: &crate::formats::Registry,
115 mut emit: F,
116) -> Outcome
117where
118 F: FnMut(Vec<Entry>, Outcome) -> bool,
119{
120 let mut outcome = Outcome::default();
121 if !config.enabled {
122 return outcome;
123 }
124
125 let deadline = Instant::now() + config.time_budget.duration();
126 let skip = config.skipped_dirs();
127 let extensions: Vec<String> = config
128 .extensions
129 .iter()
130 .map(|e| e.trim_start_matches('.').to_ascii_lowercase())
131 .collect();
132
133 let mut builder = ignore::WalkBuilder::new(root);
134 builder
135 // Hidden directories are skipped for the same reason `scan_dir` skips them,
136 // and it does most of this module's work: `.git`, `.venv`, `.tox`, the caches.
137 .hidden(true)
138 // Off on purpose. See `SearchConfig::follow_gitignore`.
139 .git_ignore(config.follow_gitignore)
140 .git_global(config.follow_gitignore)
141 .git_exclude(config.follow_gitignore)
142 .ignore(config.follow_gitignore)
143 .parents(config.follow_gitignore)
144 // Without this, `.gitignore` is consulted only inside a git repository.
145 // Someone who turned the option on meant the file, not the repository.
146 .require_git(false)
147 // A symlink can point at its own parent, or at a mount that is not answering.
148 // Neither is worth the risk for a convenience feature.
149 .follow_links(false)
150 // The limit that matters most: it is what keeps a walk from wandering onto a
151 // network share, and on autofs, from mounting one merely by looking.
152 .same_file_system(!config.cross_filesystems)
153 .max_depth(Some(config.max_depth))
154 // One thread. The walk is bounded and usually finishes in milliseconds warm;
155 // a thread pool competing with the load that opens a dataset is a worse trade
156 // than the milliseconds it would save.
157 .threads(1);
158
159 if !skip.is_empty() {
160 let mut over = ignore::overrides::OverrideBuilder::new(root);
161 for name in &skip {
162 // A leading `!` makes this an exclusion; matching both the bare name and
163 // any depth catches `node_modules` wherever it appears.
164 let _ = over.add(&format!("!**/{name}"));
165 let _ = over.add(&format!("!{name}"));
166 }
167 if let Ok(over) = over.build() {
168 builder.overrides(over);
169 }
170 }
171
172 let mut batch: Vec<Entry> = Vec::with_capacity(BATCH);
173 let mut found = 0usize;
174 let mut last_emit = Instant::now();
175 // Where the files live, for the row's storage glyph (#547 D10). One filesystem unless the walk
176 // may cross into others, and then asked per file.
177 let mounts = crate::locality::Mounts::cached();
178 let root_source = mounts.describe(root).fstype;
179 // Specs name files only when no extension filter narrows the search.
180 let specs = extensions.is_empty() && !formats.is_empty();
181 // The directory being walked and how many of its files have been looked inside.
182 let mut sniffed_in: (PathBuf, usize) = (PathBuf::new(), 0);
183
184 for result in builder.build() {
185 outcome.scanned += 1;
186
187 // Checked per entry rather than per batch: one enormous directory can burn
188 // the whole budget without ever completing a batch.
189 if Instant::now() >= deadline {
190 outcome.hit_time_limit = true;
191 break;
192 }
193
194 let Ok(dir_entry) = result else {
195 // A directory that cannot be read is not an error worth reporting here —
196 // permissions on someone else's tree are normal.
197 continue;
198 };
199
200 if dir_entry.depth() >= config.max_depth {
201 // Reaching the limit is only worth reporting if there was more below it.
202 if dir_entry.file_type().is_some_and(|t| t.is_dir()) {
203 outcome.hit_depth_limit = true;
204 }
205 continue;
206 }
207
208 let Some(file_type) = dir_entry.file_type() else {
209 continue;
210 };
211 // Directories are traversed, not offered: a search result is something you
212 // can open. Anything that is not a regular file — a FIFO, a socket, a device
213 // — is never opened, which is the rule the rest of datui already follows.
214 if !file_type.is_file() {
215 continue;
216 }
217
218 let path = dir_entry.path();
219 let mut spec = None;
220 if !matches_extension(path, &extensions) {
221 if !specs {
222 continue;
223 }
224 spec = spec_of(path, formats, &mut sniffed_in);
225 if spec.is_none() {
226 continue;
227 }
228 }
229
230 let mut entry = Entry::new(path.to_path_buf(), EntryKind::File);
231 if let Some(spec) = spec {
232 crate::discover::name_spec_file(&mut entry, &spec);
233 }
234 if let Ok(meta) = dir_entry.metadata() {
235 entry = entry.with_fs_metadata(&meta);
236 }
237 entry.cost.source = Some(if config.cross_filesystems {
238 mounts.describe(path).fstype
239 } else {
240 root_source.clone()
241 });
242 // The name carries the path relative to where the search started, because
243 // "sales.parquet" three times over says nothing about which one you want.
244 entry.name = relative_label(root, path);
245 batch.push(entry);
246 found += 1;
247
248 if found >= cap {
249 outcome.hit_result_limit = true;
250 break;
251 }
252
253 // Either condition, not both. A full batch bounds the work done between
254 // checkpoints; the interval covers the opposite case, a walk crossing
255 // thousands of entries that match nothing, where the caller still wants a
256 // progress count and still needs somewhere to say "stop".
257 if batch.len() >= BATCH || last_emit.elapsed() >= BATCH_INTERVAL {
258 last_emit = Instant::now();
259 if !emit(std::mem::take(&mut batch), outcome) {
260 return outcome;
261 }
262 batch.reserve(BATCH);
263 }
264 }
265
266 emit(batch, outcome);
267 outcome
268}
269
270/// The spec that reads `path`, a file no extension names, as the listing finds it: by
271/// glob, else by its first bytes when its name says nothing, within the per-directory
272/// cap `sniffed_in` counts.
273fn spec_of(
274 path: &Path,
275 formats: &crate::formats::Registry,
276 sniffed_in: &mut (PathBuf, usize),
277) -> Option<Arc<crate::formats::Spec>> {
278 if let Some(spec) = formats.by_glob(path, false).into_iter().next() {
279 return Some(spec);
280 }
281 if !crate::discover::worth_sniffing(path) {
282 return None;
283 }
284 let dir = path.parent().unwrap_or(path);
285 if sniffed_in.0 != dir {
286 *sniffed_in = (dir.to_path_buf(), 0);
287 }
288 if sniffed_in.1 >= crate::discover::MAX_SNIFFS_PER_DIR {
289 return None;
290 }
291 sniffed_in.1 += 1;
292 match crate::discover::sniff_listed(path, formats)? {
293 crate::discover::Sniffed::Spec(spec) => Some(spec),
294 crate::discover::Sniffed::Format(_) => None,
295 }
296}
297
298/// Whether `path` is a format the search is looking for.
299fn matches_extension(path: &Path, extensions: &[String]) -> bool {
300 if extensions.is_empty() {
301 return is_data_file(path);
302 }
303 path.extension()
304 .and_then(|e| e.to_str())
305 .map(|e| e.to_ascii_lowercase())
306 .is_some_and(|e| extensions.contains(&e))
307}
308
309/// A label naming the dataset by where it sits under the search root.
310///
311/// Always with forward slashes. The separator here is a display choice, not a path:
312/// the row carries its real `path` for opening, and a list mixing `a/b/c.parquet`
313/// with `a\b\c.parquet` depending on the platform is worse to read and worse to
314/// write tests against.
315fn relative_label(root: &Path, path: &Path) -> String {
316 let relative = path.strip_prefix(root).unwrap_or(path);
317 relative
318 .components()
319 .map(|c| c.as_os_str().to_string_lossy())
320 .collect::<Vec<_>>()
321 .join("/")
322}
323
324/// Where a search should start, given where the user is.
325///
326/// `None` when there is nothing sensible to search: no working directory, or one on a
327/// filesystem that must not be walked.
328pub fn search_root(
329 browsing: Option<&PathBuf>,
330 network_check: fn(&Path) -> bool,
331) -> Option<PathBuf> {
332 let root = match browsing {
333 Some(dir) => dir.clone(),
334 None => std::env::current_dir().ok()?,
335 };
336 // A remote root is listed by its probe, one directory at a time, precisely so that
337 // a share which stops answering cannot take the interface with it. Recursively
338 // walking one would undo that.
339 if network_check(&root) {
340 return None;
341 }
342 Some(root)
343}
344
345/// What the filter matched among the files a walk kept.
346#[derive(Debug, Clone, Default)]
347pub struct Matches {
348 /// The filter these are matches for.
349 pub query: String,
350 /// How many files of the index were looked at: the first `upto`.
351 pub upto: usize,
352 /// Every match, by its place in the index, in index order. Kept whole so the next,
353 /// longer query only has to look at these.
354 pub ids: Vec<u32>,
355 /// The best matches, best first, at most `max_results` of them: what is listed.
356 pub top: Vec<Entry>,
357 /// The score of each of `top`, so listing them does not score them again.
358 pub scores: Vec<i32>,
359}
360
361impl Matches {
362 /// Whether these can be narrowed to `query` rather than looked for again: every
363 /// match of a longer query is a match of its prefix, for a subsequence of the name
364 /// and for a substring of a column alike.
365 pub fn narrows_to(&self, query: &str) -> bool {
366 !self.query.is_empty() && query.to_lowercase().starts_with(&self.query.to_lowercase())
367 }
368}
369
370impl Matches {
371 /// Score files the walk found since, `start` being the first one's place in the
372 /// index, and fold them in. Whether any of them is now among the best listed.
373 pub fn extend(&mut self, files: &[Entry], start: usize, limit: usize) -> bool {
374 let mut changed = false;
375 for (i, entry) in files.iter().enumerate() {
376 let Some(score) = crate::home::match_score(&self.query, entry) else {
377 continue;
378 };
379 self.ids.push((start + i) as u32);
380 // After every listed match that ranks above or level with it: the ones found
381 // first win a tie, as in a whole scoring.
382 let at = self
383 .scores
384 .iter()
385 .zip(&self.top)
386 .position(|(&s, e)| s < score || (s == score && e.name.len() > entry.name.len()))
387 .unwrap_or(self.top.len());
388 if at < limit {
389 self.top.insert(at, entry.clone());
390 self.scores.insert(at, score);
391 self.top.truncate(limit);
392 self.scores.truncate(limit);
393 changed = true;
394 }
395 }
396 self.upto = start + files.len();
397 changed
398 }
399}
400
401/// Score `query` against the files in `index`, keeping the best `limit` to list.
402///
403/// With `base` from a prefix of `query`, only its matches and the files indexed since
404/// are looked at. Called off the UI thread: over a large tree this is the work that made
405/// every keystroke wait.
406pub fn score(index: &[Arc<[Entry]>], query: &str, base: Option<&Matches>, limit: usize) -> Matches {
407 let all: Vec<&Entry> = index.iter().flat_map(|batch| batch.iter()).collect();
408 let base = base.filter(|b| b.narrows_to(query) && b.upto <= all.len());
409 let candidates: Box<dyn Iterator<Item = usize>> = match base {
410 Some(b) => Box::new(b.ids.iter().map(|&id| id as usize).chain(b.upto..all.len())),
411 None => Box::new(0..all.len()),
412 };
413 let mut hits: Vec<(i32, usize)> = candidates
414 .filter_map(|id| crate::home::match_score(query, all[id]).map(|s| (s, id)))
415 .collect();
416 let ids: Vec<u32> = hits.iter().map(|&(_, id)| id as u32).collect();
417 // Best first; ties to the shorter name, as the listing ranks them.
418 let order = |a: &(i32, usize), b: &(i32, usize)| {
419 b.0.cmp(&a.0)
420 .then_with(|| all[a.1].name.len().cmp(&all[b.1].name.len()))
421 .then_with(|| a.1.cmp(&b.1))
422 };
423 if hits.len() > limit && limit > 0 {
424 hits.select_nth_unstable_by(limit - 1, order);
425 hits.truncate(limit);
426 } else if limit == 0 {
427 hits.clear();
428 }
429 hits.sort_unstable_by(order);
430 Matches {
431 query: query.to_string(),
432 upto: all.len(),
433 ids,
434 scores: hits.iter().map(|&(score, _)| score).collect(),
435 top: hits.into_iter().map(|(_, id)| all[id].clone()).collect(),
436 }
437}